Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/agent/retrieval/activity_tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ func newActivityTracker(windowMinutes int, maxBoost float32) *activityTracker {
}
}

// windowMinutes returns the decay window in minutes.
func (at *activityTracker) windowMinutes() int {
if at == nil {
return 30
}
return int(at.window.Minutes())
}

// observe records that the given concepts were just seen in watcher activity.
// Upserts timestamps and lazily evicts expired entries when the map grows large.
func (at *activityTracker) observe(concepts []string) {
Expand Down
5 changes: 5 additions & 0 deletions internal/agent/retrieval/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ func (ra *RetrievalAgent) ActivitySnapshot() map[string]time.Time {
return ra.activity.snapshot()
}

// ActivityWindowMinutes returns the activity tracker's decay window in minutes.
func (ra *RetrievalAgent) ActivityWindowMinutes() int {
return ra.activity.windowMinutes()
}

// SyncActivity replaces the activity tracker state with the given snapshot.
// Used by MCP processes to sync activity from the daemon's REST API.
func (ra *RetrievalAgent) SyncActivity(snap map[string]time.Time) {
Expand Down
9 changes: 7 additions & 2 deletions internal/api/routes/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import (

// ActivityResponse is the JSON response for the activity endpoint.
type ActivityResponse struct {
Concepts map[string]time.Time `json:"concepts"`
Concepts map[string]time.Time `json:"concepts"`
WindowMinutes int `json:"window_minutes"`
}

// HandleActivity returns the retrieval agent's current activity tracker state.
Expand All @@ -23,7 +24,11 @@ func HandleActivity(retriever *retrieval.RetrievalAgent, log *slog.Logger) http.
snap = make(map[string]time.Time)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(ActivityResponse{Concepts: snap}); err != nil {
resp := ActivityResponse{
Concepts: snap,
WindowMinutes: retriever.ActivityWindowMinutes(),
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Warn("failed to encode activity response", "error", err)
}
}
Expand Down
54 changes: 54 additions & 0 deletions internal/api/routes/patterns.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package routes

import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
Expand Down Expand Up @@ -79,6 +81,58 @@ func HandleListAbstractions(s store.Store, log *slog.Logger) http.HandlerFunc {
}
}

// HandleArchivePattern archives a single pattern by ID.
// PATCH /api/v1/patterns/{id} body: {"state": "archived"}
func HandleArchivePattern(s store.Store, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "pattern ID is required", "INVALID_REQUEST")
return
}

var body struct {
State string `json:"state"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.State == "" {
writeError(w, http.StatusBadRequest, "state field is required (e.g. \"archived\")", "INVALID_REQUEST")
return
}
if body.State != "active" && body.State != "archived" {
writeError(w, http.StatusBadRequest, "state must be \"active\" or \"archived\"", "INVALID_REQUEST")
return
}

ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()

p, err := s.GetPattern(ctx, id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "pattern not found", "NOT_FOUND")
return
}
log.Error("failed to get pattern", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to get pattern", "STORE_ERROR")
return
}

p.State = body.State
p.UpdatedAt = time.Now()
if err := s.UpdatePattern(ctx, p); err != nil {
log.Error("failed to update pattern", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to update pattern", "STORE_ERROR")
return
}

log.Info("pattern state updated", "id", id, "state", body.State)
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": id,
"state": body.State,
})
}
}

// HandleListProjects returns all known projects.
// GET /api/v1/projects
func HandleListProjects(s store.Store, log *slog.Logger) http.HandlerFunc {
Expand Down
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ func (s *Server) registerRoutes() {

// Patterns and abstractions
s.mux.HandleFunc("GET /api/v1/patterns", routes.HandleListPatterns(s.deps.Store, s.deps.Log))
s.mux.HandleFunc("PATCH /api/v1/patterns/{id}", routes.HandleArchivePattern(s.deps.Store, s.deps.Log))
s.mux.HandleFunc("GET /api/v1/abstractions", routes.HandleListAbstractions(s.deps.Store, s.deps.Log))
s.mux.HandleFunc("GET /api/v1/projects", routes.HandleListProjects(s.deps.Store, s.deps.Log))

Expand Down
84 changes: 82 additions & 2 deletions internal/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,28 @@
text-transform: uppercase; letter-spacing: 0.05em;
padding: 8px 8px 4px; margin-top: 8px;
}
.concept-row {
display: flex; align-items: center; gap: 8px;
padding: 4px 8px; font-size: 0.78rem;
}
.concept-name { flex: 0 0 auto; min-width: 80px; color: var(--text-primary); font-weight: 500; }
.concept-bar-bg {
flex: 1; height: 4px; border-radius: 2px;
background: var(--bg-tertiary);
}
.concept-bar-fill {
height: 100%; border-radius: 2px;
background: var(--accent-green);
transition: width 0.3s ease;
}
.concept-time { flex: 0 0 auto; font-size: 0.7rem; color: var(--text-muted); min-width: 45px; text-align: right; }
.pattern-archive-btn {
float: right; background: none; border: 1px solid var(--border-color);
color: var(--text-muted); cursor: pointer; font-size: 0.7rem;
padding: 2px 8px; border-radius: var(--radius-sm);
transition: all 0.15s;
}
.pattern-archive-btn:hover { background: var(--warning-bg); color: var(--warning-text); border-color: var(--warning-text); }
.event-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 8px; border-radius: var(--radius-sm);
Expand Down Expand Up @@ -1679,6 +1701,8 @@ <h3>Activity</h3>
<button class="drawer-close" onclick="toggleDrawer()">&times;</button>
</div>
<div class="drawer-body">
<div class="drawer-section-title">Active Concepts</div>
<div id="conceptsContainer" style="margin-bottom:12px"></div>
<div class="drawer-section-title">Insights</div>
<div id="insightsContainer"></div>
<div class="drawer-section-title" style="margin-top:16px">Live Events</div>
Expand Down Expand Up @@ -2087,7 +2111,8 @@ <h3>Activity</h3>
var concepts = (p.concepts || []).slice(0, 5);
var evidenceCount = (p.evidence_ids || []).length;
var age = p.created_at ? relativeTime(p.created_at) : '';
var html = '<div class="pattern-card"><div class="pattern-title">' + escapeHtml(p.title || 'Untitled');
var html = '<div class="pattern-card" data-pattern-id="' + escapeHtml(p.id) + '"><div class="pattern-title">' + escapeHtml(p.title || 'Untitled');
html += '<button class="pattern-archive-btn" onclick="archivePattern(\'' + escapeHtml(p.id) + '\', this)">Archive</button>';
if (p.project) html += ' <span style="color:var(--accent-blue);font-size:0.8rem">[' + escapeHtml(p.project) + ']</span>';
html += '</div><span class="badge badge-type">' + escapeHtml(p.pattern_type || 'pattern') + '</span>';
if (p.state && p.state !== 'active') html += ' <span class="badge" style="background:var(--warning-bg);color:var(--warning-text)">' + escapeHtml(p.state) + '</span>';
Expand All @@ -2103,6 +2128,20 @@ <h3>Activity</h3>
}).join('');
}

async function archivePattern(id, btn) {
try {
var resp = await fetch(CONFIG.API_BASE + '/patterns/' + id, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({state: 'archived'})
});
if (!resp.ok) { showToast('Failed to archive pattern', 'error'); return; }
var card = btn.closest('.pattern-card');
if (card) { card.style.transition = 'opacity 0.3s'; card.style.opacity = '0'; setTimeout(function() { card.remove(); }, 300); }
showToast('Pattern archived');
} catch(e) { showToast('Failed to archive pattern', 'error'); }
}

async function loadAbstractions(section) {
var data = await fetchJSON('/abstractions?limit=20');
var abstractions = data.abstractions || [];
Expand Down Expand Up @@ -2673,11 +2712,52 @@ <h3>Activity</h3>
}

// ── Activity Drawer ──
var _conceptsInterval = null;
function toggleDrawer() {
state.drawerOpen = !state.drawerOpen;
document.getElementById('activityDrawer').classList.toggle('open', state.drawerOpen);
document.getElementById('activityBackdrop').classList.toggle('open', state.drawerOpen);
if (state.drawerOpen) { state.unreadEvents = 0; updateBadge(); }
if (state.drawerOpen) {
state.unreadEvents = 0; updateBadge();
loadActivityConcepts();
_conceptsInterval = setInterval(loadActivityConcepts, 10000);
} else if (_conceptsInterval) {
clearInterval(_conceptsInterval);
_conceptsInterval = null;
}
}

async function loadActivityConcepts() {
try {
var resp = await fetch(CONFIG.API_BASE + '/activity');
if (!resp.ok) return;
var data = await resp.json();
var windowMs = (data.window_minutes || 30) * 60 * 1000;
var now = Date.now();
var concepts = [];
for (var name in (data.concepts || {})) {
var ts = new Date(data.concepts[name]).getTime();
var elapsed = now - ts;
var remaining = windowMs - elapsed;
if (remaining > 0) {
concepts.push({ name: name, remaining: remaining, pct: (remaining / windowMs) * 100 });
}
}
concepts.sort(function(a, b) { return b.remaining - a.remaining; });
var container = document.getElementById('conceptsContainer');
if (concepts.length === 0) {
container.innerHTML = '<div style="font-size:0.75rem;color:var(--text-muted);padding:4px 8px">No active concepts (watcher idle)</div>';
return;
}
container.innerHTML = concepts.slice(0, 15).map(function(c) {
var mins = Math.ceil(c.remaining / 60000);
var color = c.pct > 50 ? 'var(--accent-green)' : c.pct > 20 ? 'var(--accent-yellow, #f0c040)' : 'var(--accent-pink)';
return '<div class="concept-row">' +
'<span class="concept-name">' + escapeHtml(c.name) + '</span>' +
'<div class="concept-bar-bg"><div class="concept-bar-fill" style="width:' + Math.round(c.pct) + '%;background:' + color + '"></div></div>' +
'<span class="concept-time">' + mins + 'm</span></div>';
}).join('');
} catch(e) { /* ignore */ }
}

function updateBadge() {
Expand Down