diff --git a/internal/agent/retrieval/activity_tracker.go b/internal/agent/retrieval/activity_tracker.go index 39f3f75b..8b683a19 100644 --- a/internal/agent/retrieval/activity_tracker.go +++ b/internal/agent/retrieval/activity_tracker.go @@ -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) { diff --git a/internal/agent/retrieval/agent.go b/internal/agent/retrieval/agent.go index 4944c159..c51642c7 100644 --- a/internal/agent/retrieval/agent.go +++ b/internal/agent/retrieval/agent.go @@ -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) { diff --git a/internal/api/routes/activity.go b/internal/api/routes/activity.go index 9d396700..a5a268e5 100644 --- a/internal/api/routes/activity.go +++ b/internal/api/routes/activity.go @@ -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. @@ -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) } } diff --git a/internal/api/routes/patterns.go b/internal/api/routes/patterns.go index 142eefdb..454b1c13 100644 --- a/internal/api/routes/patterns.go +++ b/internal/api/routes/patterns.go @@ -2,6 +2,8 @@ package routes import ( "context" + "encoding/json" + "errors" "log/slog" "net/http" "time" @@ -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 { diff --git a/internal/api/server.go b/internal/api/server.go index 2e99abac..3a118ab9 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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)) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index cb491f56..5674dd3e 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -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); @@ -1679,6 +1701,8 @@

Activity

+
Active Concepts
+
Insights
Live Events
@@ -2087,7 +2111,8 @@

Activity

var concepts = (p.concepts || []).slice(0, 5); var evidenceCount = (p.evidence_ids || []).length; var age = p.created_at ? relativeTime(p.created_at) : ''; - var html = '
' + escapeHtml(p.title || 'Untitled'); + var html = '
' + escapeHtml(p.title || 'Untitled'); + html += ''; if (p.project) html += ' [' + escapeHtml(p.project) + ']'; html += '
' + escapeHtml(p.pattern_type || 'pattern') + ''; if (p.state && p.state !== 'active') html += ' ' + escapeHtml(p.state) + ''; @@ -2103,6 +2128,20 @@

Activity

}).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 || []; @@ -2673,11 +2712,52 @@

Activity

} // ── 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 = '
No active concepts (watcher idle)
'; + 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 '
' + + '' + escapeHtml(c.name) + '' + + '
' + + '' + mins + 'm
'; + }).join(''); + } catch(e) { /* ignore */ } } function updateBadge() {