diff --git a/internal/agent/consolidation/agent.go b/internal/agent/consolidation/agent.go index 1db1c6ba..24643536 100644 --- a/internal/agent/consolidation/agent.go +++ b/internal/agent/consolidation/agent.go @@ -375,12 +375,17 @@ func (ca *ConsolidationAgent) runCycle(ctx context.Context) (*CycleReport, error // Publish consolidation completed event if ca.bus != nil { _ = ca.bus.Publish(ctx, events.ConsolidationCompleted{ - DurationMs: report.Duration.Milliseconds(), - MemoriesProcessed: report.MemoriesProcessed, - MemoriesDecayed: report.MemoriesDecayed, - MergedClusters: report.MergesPerformed, - AssociationsPruned: report.AssociationsPruned, - Ts: time.Now(), + DurationMs: report.Duration.Milliseconds(), + MemoriesProcessed: report.MemoriesProcessed, + MemoriesDecayed: report.MemoriesDecayed, + MergedClusters: report.MergesPerformed, + AssociationsPruned: report.AssociationsPruned, + TransitionedFading: report.TransitionedFading, + TransitionedArchived: report.TransitionedArchived, + PatternsExtracted: report.PatternsExtracted, + PatternsDecayed: report.PatternsDecayed, + NeverRecalledArchived: report.NeverRecalledArchived, + Ts: time.Now(), }) } diff --git a/internal/api/routes/backfill.go b/internal/api/routes/backfill.go new file mode 100644 index 00000000..2679949f --- /dev/null +++ b/internal/api/routes/backfill.go @@ -0,0 +1,102 @@ +package routes + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/appsprout-dev/mnemonic/internal/llm" + "github.com/appsprout-dev/mnemonic/internal/store" +) + +// BackfillResponse reports what the backfill operation did. +type BackfillResponse struct { + Total int `json:"total"` + Embedded int `json:"embedded"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Errors []string `json:"errors,omitempty"` +} + +// HandleBackfillEmbeddings finds memories with empty embeddings and generates them. +func HandleBackfillEmbeddings(s store.Store, provider llm.Provider, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + + // Find all active memories missing embeddings + memories, err := s.ListMemories(ctx, "", 500, 0) + if err != nil { + log.Error("backfill: failed to list memories", "error", err) + writeError(w, http.StatusInternalServerError, "failed to list memories", "STORE_ERROR") + return + } + + var missing []store.Memory + for _, m := range memories { + if len(m.Embedding) == 0 { + missing = append(missing, m) + } + } + + if len(missing) == 0 { + writeJSON(w, http.StatusOK, BackfillResponse{Total: 0}) + return + } + + log.Info("backfill: starting embedding backfill", "missing", len(missing)) + + // Quick sanity check: can we embed at all? + testEmb, testErr := provider.Embed(ctx, "test embedding sanity check") + if testErr != nil { + log.Error("backfill: embedding sanity check failed", "error", testErr) + writeJSON(w, http.StatusOK, BackfillResponse{Total: len(missing), Errors: []string{"sanity check failed: " + testErr.Error()}}) + return + } + log.Info("backfill: sanity check passed", "dims", len(testEmb)) + + resp := BackfillResponse{Total: len(missing)} + + for _, mem := range missing { + select { + case <-ctx.Done(): + log.Warn("backfill: context cancelled", "embedded", resp.Embedded, "remaining", resp.Total-resp.Embedded-resp.Failed) + writeJSON(w, http.StatusOK, resp) + return + default: + } + + // Build embedding text from summary + content (same as encoding agent) + text := mem.Summary + " " + mem.Content + if len(text) > 4000 { + text = text[:4000] + } + + embedding, err := provider.Embed(ctx, text) + if err != nil { + resp.Errors = append(resp.Errors, "embed:"+mem.ID[:8]+":"+err.Error()) + resp.Failed++ + continue + } + + if len(embedding) == 0 { + resp.Skipped++ + continue + } + + // Use targeted update to avoid FK issues with raw_id + if err := s.UpdateEmbedding(ctx, mem.ID, embedding); err != nil { + resp.Errors = append(resp.Errors, "update:"+mem.ID[:8]+":"+err.Error()) + resp.Failed++ + continue + } + + resp.Embedded++ + log.Debug("backfill: embedded memory", "id", mem.ID, "dims", len(embedding)) + } + + log.Info("backfill: completed", "total", resp.Total, "embedded", resp.Embedded, "failed", resp.Failed) + writeJSON(w, http.StatusOK, resp) + } +} diff --git a/internal/api/routes/retrieval.go b/internal/api/routes/retrieval.go new file mode 100644 index 00000000..454beabf --- /dev/null +++ b/internal/api/routes/retrieval.go @@ -0,0 +1,24 @@ +package routes + +import ( + "log/slog" + "net/http" + + "github.com/appsprout-dev/mnemonic/internal/agent/retrieval" +) + +// HandleRetrievalStats returns the retrieval agent's in-memory performance stats. +func HandleRetrievalStats(retriever *retrieval.RetrievalAgent, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if retriever == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "total_queries": 0, + "total_memories_retrieved": 0, + "avg_memories_per_query": 0, + "avg_synthesis_ms": 0, + }) + return + } + writeJSON(w, http.StatusOK, retriever.GetStats()) + } +} diff --git a/internal/api/routes/ws.go b/internal/api/routes/ws.go index 4c4a409c..fd70f44b 100644 --- a/internal/api/routes/ws.go +++ b/internal/api/routes/ws.go @@ -100,6 +100,10 @@ func HandleWebSocket(bus events.Bus, log *slog.Logger) http.HandlerFunc { events.TypeSystemHealth, events.TypeWatcherEvent, events.TypeEpisodeClosed, + events.TypePatternDiscovered, + events.TypeAbstractionCreated, + events.TypeMemoryAmended, + events.TypeSessionEnded, } for _, eventType := range eventTypes { @@ -208,6 +212,16 @@ func wsConnEventToMessage(evt events.Event) WebSocketMessage { payload = e case events.WatcherEvent: payload = e + case events.EpisodeClosed: + payload = e + case events.PatternDiscovered: + payload = e + case events.AbstractionCreated: + payload = e + case events.MemoryAmended: + payload = e + case events.SessionEnded: + payload = e default: // Fallback for unknown event types payload = map[string]interface{}{} diff --git a/internal/api/server.go b/internal/api/server.go index 6ed70e15..7799f9fe 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -105,6 +105,12 @@ func (s *Server) registerRoutes() { // Activity (watcher-derived concept tracker for MCP sync) s.mux.HandleFunc("GET /api/v1/activity", routes.HandleActivity(s.deps.Retriever, s.deps.Log)) + // Retrieval stats + s.mux.HandleFunc("GET /api/v1/retrieval/stats", routes.HandleRetrievalStats(s.deps.Retriever, s.deps.Log)) + + // Embedding backfill + s.mux.HandleFunc("POST /api/v1/embeddings/backfill", routes.HandleBackfillEmbeddings(s.deps.Store, s.deps.LLM, s.deps.Log)) + // Feedback s.mux.HandleFunc("POST /api/v1/feedback", routes.HandleFeedback(s.deps.Store, s.deps.Log)) diff --git a/internal/events/types.go b/internal/events/types.go index 0be38971..15a3da75 100644 --- a/internal/events/types.go +++ b/internal/events/types.go @@ -62,12 +62,17 @@ func (e ConsolidationStarted) EventTimestamp() time.Time { return e.Ts } // ConsolidationCompleted is emitted when a consolidation cycle finishes. type ConsolidationCompleted struct { - DurationMs int64 `json:"duration_ms"` - MemoriesProcessed int `json:"memories_processed"` - MemoriesDecayed int `json:"memories_decayed"` - MergedClusters int `json:"merged_clusters"` - AssociationsPruned int `json:"associations_pruned"` - Ts time.Time `json:"timestamp"` + DurationMs int64 `json:"duration_ms"` + MemoriesProcessed int `json:"memories_processed"` + MemoriesDecayed int `json:"memories_decayed"` + MergedClusters int `json:"merged_clusters"` + AssociationsPruned int `json:"associations_pruned"` + TransitionedFading int `json:"transitioned_fading"` + TransitionedArchived int `json:"transitioned_archived"` + PatternsExtracted int `json:"patterns_extracted"` + PatternsDecayed int `json:"patterns_decayed"` + NeverRecalledArchived int `json:"never_recalled_archived"` + Ts time.Time `json:"timestamp"` } func (e ConsolidationCompleted) EventType() string { return TypeConsolidationCompleted } diff --git a/internal/store/sqlite/sqlite.go b/internal/store/sqlite/sqlite.go index 2b9ecc80..45c77e15 100644 --- a/internal/store/sqlite/sqlite.go +++ b/internal/store/sqlite/sqlite.go @@ -927,6 +927,28 @@ func (s *SQLiteStore) UpdateMemory(ctx context.Context, mem store.Memory) error } // UpdateSalience updates the salience of a memory. +func (s *SQLiteStore) UpdateEmbedding(ctx context.Context, id string, embedding []float32) error { + var embeddingBlob []byte + if len(embedding) > 0 { + embeddingBlob = encodeEmbedding(embedding) + } + + query := `UPDATE memories SET embedding = ?, updated_at = ? WHERE id = ?` + result, err := s.db.ExecContext(ctx, query, embeddingBlob, time.Now().Format(time.RFC3339), id) + if err != nil { + return fmt.Errorf("failed to update embedding: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("memory with id %s: %w", id, store.ErrNotFound) + } + return nil +} + func (s *SQLiteStore) UpdateSalience(ctx context.Context, id string, salience float32) error { query := `UPDATE memories SET salience = ?, updated_at = ? WHERE id = ?` diff --git a/internal/store/store.go b/internal/store/store.go index 51caad97..4ef6c5fb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -378,6 +378,7 @@ type Store interface { GetMemoryByRawID(ctx context.Context, rawID string) (Memory, error) UpdateMemory(ctx context.Context, mem Memory) error UpdateSalience(ctx context.Context, id string, salience float32) error + UpdateEmbedding(ctx context.Context, id string, embedding []float32) error UpdateState(ctx context.Context, id string, state string) error IncrementAccess(ctx context.Context, id string) error ListMemories(ctx context.Context, state string, limit, offset int) ([]Memory, error) diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index 18db1b72..e78f94f2 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -44,7 +44,8 @@ func (MockStore) GetMemoryByRawID(context.Context, string) (store.Memory, error) return store.Memory{}, nil } func (MockStore) UpdateMemory(context.Context, store.Memory) error { return nil } -func (MockStore) UpdateSalience(context.Context, string, float32) error { return nil } +func (MockStore) UpdateSalience(context.Context, string, float32) error { return nil } +func (MockStore) UpdateEmbedding(context.Context, string, []float32) error { return nil } func (MockStore) UpdateState(context.Context, string, string) error { return nil } func (MockStore) IncrementAccess(context.Context, string) error { return nil } func (MockStore) ListMemories(context.Context, string, int, int) ([]store.Memory, error) { diff --git a/internal/web/static/index.html b/internal/web/static/index.html index a0106b6d..80fb5058 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -959,22 +959,51 @@ 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; } - .session-lane { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border-subtle); } - .session-lane:last-child { border-bottom: none; } - .session-label { flex: 0 0 100px; font-size: 0.72rem; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .session-bar-track { flex: 1; height: 20px; position: relative; background: var(--bg-tertiary); border-radius: 3px; } - .session-bar { position: absolute; height: 100%; border-radius: 3px; min-width: 4px; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #fff; font-weight: 600; } - .session-meta { flex: 0 0 60px; font-size: 0.68rem; color: var(--text-muted); text-align: right; } - .pipeline-bar { display: flex; align-items: center; gap: 8px; margin: 6px 0; } - .pipeline-bar-label { flex: 0 0 80px; font-size: 0.75rem; color: var(--text-dim); } - .pipeline-bar-track { flex: 1; height: 16px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; } - .pipeline-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } - .pipeline-bar-value { flex: 0 0 50px; font-size: 0.72rem; color: var(--text-muted); text-align: right; } - .salience-row { display: flex; align-items: center; gap: 4px; margin: 4px 0; } - .salience-label { flex: 0 0 60px; font-size: 0.72rem; color: var(--text-dim); text-transform: capitalize; } - .salience-stacked { flex: 1; height: 18px; display: flex; border-radius: 3px; overflow: hidden; } - .salience-seg { height: 100%; min-width: 2px; transition: width 0.3s; } - .salience-count { flex: 0 0 40px; font-size: 0.68rem; color: var(--text-muted); text-align: right; } + /* ── Research Analytics Brief ── */ + .ra-brief { + font-size: 0.82rem; line-height: 1.6; color: var(--text-muted); margin-bottom: 14px; + padding: 12px 16px; background: var(--bg-card); border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); border-left: 3px solid var(--accent-cyan); + } + .ra-brief strong { color: var(--text-primary); font-weight: 600; } + .ra-brief .ra-warn { color: var(--accent-orange); } + .ra-brief .ra-good { color: var(--accent-green); } + .ra-brief .ra-bad { color: var(--accent-red); } + /* ── Research Analytics KPI cards ── */ + .ra-kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; } + .ra-kpi { background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 14px 16px; } + .ra-kpi-label { font-size: 0.68rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; } + .ra-kpi-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; } + .ra-kpi-value { font-size: 1.5rem; font-weight: 700; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; line-height: 1; } + .ra-kpi-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; } + .ra-kpi-delta { font-size: 0.66rem; font-weight: 600; } + .ra-kpi-delta.positive { color: var(--accent-green); } + .ra-kpi-delta.negative { color: var(--accent-red); } + .ra-kpi-delta.neutral { color: var(--text-dim); } + .ra-kpi-target { font-size: 0.62rem; color: var(--text-dim); } + /* ── Session Activity rows ── */ + .session-row { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-bottom: 1px solid var(--border-subtle); transition: background 0.15s; } + .session-row:last-child { border-bottom: none; } + .session-row-expandable { cursor: pointer; } + .session-row-expandable:hover { background: var(--bg-tertiary); } + .session-quality-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } + .session-when { flex: 0 0 90px; font-size: 0.72rem; color: var(--text-secondary); white-space: nowrap; } + .session-when .session-date { display: block; font-weight: 600; color: var(--text-primary); font-size: 0.73rem; } + .session-when .session-time { display: block; color: var(--text-dim); font-size: 0.66rem; margin-top: 1px; } + .session-bar-col { flex: 0 0 100px; display: flex; align-items: center; gap: 6px; } + .session-bar-inline { height: 14px; border-radius: 3px; min-width: 6px; transition: width 0.3s; } + .session-count { font-size: 0.72rem; font-weight: 600; color: var(--text-secondary); min-width: 16px; } + .session-dur { flex: 0 0 45px; font-size: 0.68rem; color: var(--text-dim); text-align: right; } + .session-feedback-badge { font-size: 0.6rem; padding: 1px 6px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-muted); flex-shrink: 0; } + .session-concepts { flex: 1; display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; } + .session-concept-pill { font-size: 0.62rem; padding: 2px 7px; border-radius: 10px; background: var(--bg-tertiary); color: var(--text-muted); border: 1px solid var(--border-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; } + .session-chevron { font-size: 0.65rem; color: var(--text-dim); transition: transform 0.2s; flex-shrink: 0; width: 12px; text-align: center; } + .session-chevron.open { transform: rotate(90deg); } + .session-detail { display: none; padding: 6px 8px 10px 30px; border-bottom: 1px solid var(--border-subtle); font-size: 0.72rem; color: var(--text-muted); background: var(--bg-secondary); } + .session-detail.open { display: block; } + .session-detail-row { margin-bottom: 4px; } + .session-detail-label { font-weight: 600; color: var(--text-dim); font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.04em; } + .session-detail-pills { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; } .pattern-archive-btn { float: right; background: none; border: 1px solid var(--border-color); color: var(--text-muted); cursor: pointer; font-size: 0.7rem; @@ -1000,6 +1029,18 @@ .event-type { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); } .event-desc { font-size: 0.8rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .event-time { font-size: 0.7rem; color: var(--text-dim); margin-top: 2px; } + .event-item.event-clickable { cursor: pointer; } + .event-item.event-clickable:hover { background: var(--bg-tertiary); } + .event-metrics { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; line-height: 1.5; } + .event-subtitle { font-size: 0.72rem; color: var(--text-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; } + .event-duration { font-size: 0.65rem; color: var(--text-dim); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } + /* ── Cognitive Agents Panel ── */ + .cognitive-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; } + .cognitive-card { background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px 14px; text-align: center; } + .cognitive-card-label { font-size: 0.68rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; } + .cognitive-card-value { font-size: 1.3rem; font-weight: 700; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; color: var(--accent-cyan); line-height: 1; } + .cognitive-card-sub { font-size: 0.65rem; color: var(--text-dim); margin-top: 4px; } + @media (max-width: 640px) { .cognitive-grid { grid-template-columns: repeat(2, 1fr); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } .drawer-footer { padding: 12px 16px; border-top: 1px solid var(--border-color); @@ -1075,6 +1116,111 @@ } @keyframes spin { to { transform: rotate(360deg); } } + /* ── Mind View ── */ + .mind-view { padding: 0; flex-direction: column; height: 100%; overflow: hidden !important; } + .mind-view.active { display: flex; } + .mind-controls { + flex-shrink: 0; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; + padding: 10px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-subtle); + } + .mind-toggle-group { display: flex; gap: 2px; background: var(--bg-tertiary); border-radius: var(--radius-sm); padding: 2px; } + .mind-toggle { + padding: 4px 12px; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: 500; + color: var(--text-muted); cursor: pointer; border: none; background: none; transition: all 0.15s; + } + .mind-toggle:hover { color: var(--text-primary); } + .mind-toggle.active { color: var(--accent-cyan); background: var(--bg-card); box-shadow: var(--shadow-sm); } + .mind-label { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--text-muted); white-space: nowrap; } + .mind-val { color: var(--accent-cyan); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.7rem; min-width: 28px; } + .mind-select { + padding: 3px 8px; border-radius: var(--radius-sm); background: var(--bg-primary); + border: 1px solid var(--border-color); color: var(--text-primary); font-size: 0.75rem; outline: none; + } + .mind-input { + width: 56px; padding: 3px 6px; border-radius: var(--radius-sm); background: var(--bg-primary); + border: 1px solid var(--border-color); color: var(--text-primary); font-size: 0.75rem; outline: none; text-align: center; + } + .mind-slider { width: 90px; accent-color: var(--accent-cyan); } + .mind-stats { + flex-shrink: 0; display: flex; align-items: center; gap: 20px; padding: 6px 24px; + background: var(--bg-secondary); border-bottom: 1px solid var(--border-subtle); min-height: 28px; + } + .mind-stat { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; } + .mind-stat-value { color: var(--accent-cyan); font-weight: 600; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } + .mind-stat-label { color: var(--text-dim); } + .mind-canvas { flex: 1; position: relative; overflow: hidden; background: var(--bg-primary); } + .mind-canvas svg { display: block; width: 100%; height: 100%; } + .mind-search { + position: absolute; top: 12px; left: 16px; z-index: 10; display: flex; align-items: center; + } + .mind-search-icon { width: 16px; height: 16px; position: absolute; left: 10px; color: var(--text-dim); pointer-events: none; } + .mind-search-input { + width: 220px; padding: 7px 30px 7px 32px; border-radius: var(--radius-md); + background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-primary); + font-size: 0.8rem; outline: none; transition: border-color 0.15s, box-shadow 0.15s; + } + .mind-search-input:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 2px rgba(6,182,212,0.12); } + .mind-search-input::placeholder { color: var(--text-dim); } + .mind-search-clear { + position: absolute; right: 6px; background: none; border: none; color: var(--text-dim); + cursor: pointer; font-size: 1.1rem; line-height: 1; padding: 2px 4px; transition: color 0.15s; + } + .mind-search-clear:hover { color: var(--text-primary); } + .mind-tooltip { + position: absolute; pointer-events: none; z-index: 20; display: none; + background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); + padding: 8px 12px; font-size: 0.8rem; color: var(--text-primary); max-width: 300px; + box-shadow: var(--shadow-md); + } + .mind-tooltip-summary { margin-bottom: 4px; font-weight: 500; line-height: 1.3; } + .mind-tooltip-meta { font-size: 0.7rem; color: var(--text-dim); display: flex; gap: 8px; } + .mind-detail { + position: absolute; bottom: 16px; right: 16px; width: 320px; max-height: 50vh; + overflow-y: auto; background: var(--bg-card); border: 1px solid var(--border-color); + border-radius: var(--radius-md); padding: 16px; box-shadow: var(--shadow-lg); z-index: 10; + } + .mind-detail-close { + position: absolute; top: 8px; right: 10px; background: none; border: none; + color: var(--text-dim); cursor: pointer; font-size: 1.1rem; line-height: 1; transition: color 0.15s; + } + .mind-detail-close:hover { color: var(--text-primary); } + .mind-detail-summary { font-size: 0.85rem; font-weight: 500; color: var(--text-primary); margin-bottom: 10px; padding-right: 20px; line-height: 1.4; } + .mind-detail-row { display: flex; justify-content: space-between; font-size: 0.78rem; padding: 3px 0; border-bottom: 1px solid var(--border-subtle); } + .mind-detail-label { color: var(--text-dim); } + .mind-detail-value { color: var(--text-secondary); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.75rem; } + .mind-detail-section { font-size: 0.72rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 10px; margin-bottom: 4px; } + .mind-detail-connections { font-size: 0.78rem; } + .mind-detail-conn { display: flex; align-items: center; gap: 6px; padding: 2px 0; color: var(--text-secondary); } + .mind-detail-conn-line { width: 16px; height: 2px; display: inline-block; flex-shrink: 0; } + .mind-detail-concepts { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } + .mind-detail-concept { + font-size: 0.7rem; padding: 1px 7px; border-radius: 3px; + background: var(--bg-tertiary); color: var(--text-muted); cursor: pointer; transition: all 0.15s; + } + .mind-detail-concept:hover { color: var(--accent-cyan); background: rgba(6,182,212,0.1); } + .mind-legend { + position: absolute; bottom: 16px; left: 16px; z-index: 5; + background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); + padding: 8px 12px; font-size: 0.7rem; opacity: 0.9; display: grid; grid-template-columns: 1fr 1fr; gap: 3px 14px; + } + .mind-legend-item { display: flex; align-items: center; gap: 5px; color: var(--text-dim); white-space: nowrap; } + .mind-legend-line { width: 18px; height: 0; display: inline-block; flex-shrink: 0; } + .mind-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); gap: 8px; } + .mind-empty-icon { font-size: 2.5rem; opacity: 0.4; } + .mind-empty-text { font-size: 0.95rem; } + .mind-empty-sub { font-size: 0.8rem; color: var(--text-dim); } + @keyframes mindPulse { + 0%, 100% { filter: drop-shadow(0 0 3px var(--accent-cyan)); } + 50% { filter: drop-shadow(0 0 10px var(--accent-cyan)); } + } + @media (max-width: 640px) { + .mind-controls { padding: 8px 12px; gap: 8px; } + .mind-legend { display: none; } + .mind-detail { width: calc(100% - 32px); left: 16px; right: 16px; } + .mind-stats { padding: 4px 12px; gap: 10px; flex-wrap: wrap; } + .mind-search-input { width: 160px; } + } + /* ── Agent View ── */ .agent-view { padding: 24px; max-width: 1200px; margin: 0 auto; } .agent-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } @@ -1233,6 +1379,7 @@ /* ── Mobile ── */ @media (max-width: 640px) { + .ra-kpis { grid-template-columns: repeat(2, 1fr); } .nav-stats { display: none; } .nav-tab span { display: none; } .recall-hero { padding: 30px 16px 16px; } @@ -1279,6 +1426,10 @@ Timeline + + + + + + + + +
+
+ +
+ +
+
+ +
@@ -1674,50 +1863,42 @@

What do you remember?

-
Research Analytics
-
Is the cognitive pipeline actually working?
-
-
-
Raw Observations
-
-
Unique Encoded
-
-
Dedup Rate
-
-
MCP Survival
-
-
Noise Survival
-
-
Recall Learning
-
-
-
-
-
Signal vs Noise — Source Survival Rates
-
-
-
-
Recall Effectiveness — Is the System Learning?
-
+
+
+
Research Analytics
+
Is the memory system getting smarter?
+
+
+ + + +
-
-
-
Memory Survival by Day
-
-
-
-
Feedback Quality Trend
-
+
+
+
+
+
+
Memory Lifecycle (14d)
+
+
+
-
Encoding Pipeline
-
+
Signal Quality by Source
+
-
Salience Distribution
-
+
Recall Learning Curve
+
-
Session Timeline
-
+
Session Activity
+
@@ -1760,6 +1941,7 @@

Activity

localStorage.setItem('mnemonic-theme', name); var sel = document.getElementById('themeSelect'); if (sel) sel.value = name; + if (state.mindLoaded) updateMindColors(); } // Sync dropdown to current theme on load @@ -1788,6 +1970,14 @@

Activity

agentData: null, llmLoaded: false, toolsLoaded: false, + dreamSessionTotals: { cycles: 0, replayed: 0, strengthened: 0, newLinks: 0, insights: 0, crossProject: 0, demoted: 0 }, + mindLoaded: false, + mindSimulation: null, + mindData: null, + mindSelectedNode: null, + mindSearchTerm: '', + mindView: 'memories', + mindAdjacency: null, }; const CONFIG = { @@ -1847,6 +2037,7 @@

Activity

if (name === 'agent' && !state.agentLoaded) loadAgentData(); if (name === 'llm' && !state.llmLoaded) loadLLMUsage(); if (name === 'tools' && !state.toolsLoaded) loadToolUsage(); + if (name === 'mind' && !state.mindLoaded) loadMindGraph(); } function switchExploreTab(tab) { @@ -1860,7 +2051,7 @@

Activity

function handleHash() { var hash = window.location.hash.replace('#', ''); - if (['recall', 'explore', 'timeline', 'agent', 'llm'].includes(hash)) switchView(hash); + if (['recall', 'explore', 'timeline', 'mind', 'agent', 'llm', 'tools'].includes(hash)) switchView(hash); } window.addEventListener('hashchange', handleHash); @@ -2796,12 +2987,84 @@

Activity

else { badge.classList.remove('visible'); } } - function addEvent(type, description, timestamp) { - var dotColors = { raw_memory_created: 'green', memory_encoded: 'blue', memory_accessed: 'violet', query_executed: 'cyan', consolidation_started: 'orange', consolidation_completed: 'orange', dream_cycle_completed: 'violet', meta_cycle_completed: 'violet', watcher_event: 'yellow', system_health: 'cyan', episode_closed: 'green' }; + var _eventDotColors = { raw_memory_created: 'green', memory_encoded: 'blue', memory_accessed: 'violet', query_executed: 'cyan', consolidation_started: 'orange', consolidation_completed: 'orange', dream_cycle_completed: 'violet', meta_cycle_completed: 'violet', watcher_event: 'yellow', system_health: 'cyan', episode_closed: 'green', pattern_discovered: 'cyan', abstraction_created: 'violet', memory_amended: 'blue', session_ended: 'green' }; + var _eventNavTargets = { pattern_discovered: ['explore', 'patterns'], abstraction_created: ['explore', 'abstractions'], episode_closed: ['explore', 'episodes'], memory_encoded: ['explore', 'memories'] }; + + function _nonZero(items) { + return items.filter(function(i) { return i[0] > 0; }).map(function(i) { return i[0] + ' ' + i[1]; }).join(' \u00b7 '); + } + + function buildEventDetail(type, payload) { + var p = payload || {}; + switch (type) { + case 'consolidation_completed': { + var line1 = _nonZero([[p.memories_processed, 'processed'], [p.memories_decayed, 'decayed'], [p.merged_clusters, 'merged']]); + var line2 = _nonZero([[p.patterns_extracted, 'patterns found'], [p.never_recalled_archived, 'noise archived'], [p.transitioned_fading, 'fading'], [p.transitioned_archived, 'archived']]); + var dur = p.duration_ms ? (p.duration_ms / 1000).toFixed(1) + 's' : ''; + var html = ''; + if (line1) html += '
' + line1 + '
'; + if (line2) html += '
' + line2 + '
'; + if (dur) html += '
' + dur + '
'; + return html; + } + case 'dream_cycle_completed': { + var l1 = _nonZero([[p.memories_replayed, 'replayed'], [p.associations_strengthened, 'strengthened'], [p.new_associations_created, 'new links']]); + var l2 = _nonZero([[p.insights_generated, 'insights'], [p.cross_project_links, 'cross-project'], [p.noisy_memories_demoted, 'demoted']]); + var d = p.duration_ms ? (p.duration_ms / 1000).toFixed(1) + 's' : ''; + var h = ''; + if (l1) h += '
' + l1 + '
'; + if (l2) h += '
' + l2 + '
'; + if (d) h += '
' + d + '
'; + return h; + } + case 'memory_encoded': { + var concepts = (p.concepts || []).length; + var assocs = p.associations_created || 0; + var parts = _nonZero([[concepts, 'concepts'], [assocs, 'associations']]); + return parts ? '
' + parts + '
' : ''; + } + case 'pattern_discovered': { + var html = ''; + if (p.title) html += '
' + escapeHtml(p.title.slice(0, 60)) + '
'; + var meta = _nonZero([[1, p.pattern_type || 'pattern'], [p.evidence_count, 'evidence']]); + if (meta) html += '
' + meta + '
'; + return html; + } + case 'abstraction_created': { + var html = ''; + if (p.title) html += '
' + escapeHtml(p.title.slice(0, 60)) + '
'; + if (p.source_count) html += '
from ' + p.source_count + ' sources
'; + return html; + } + case 'episode_closed': { + var html = ''; + if (p.title) html += '
' + escapeHtml(p.title.slice(0, 60)) + '
'; + var meta = _nonZero([[p.event_count, 'events']]); + if (p.duration_sec) meta += (meta ? ' \u00b7 ' : '') + Math.round(p.duration_sec / 60) + 'min'; + if (meta) html += '
' + meta + '
'; + return html; + } + case 'query_executed': { + return '
' + (p.results_returned || 0) + ' results \u00b7 ' + (p.took_ms || 0) + 'ms
'; + } + default: return ''; + } + } + + function addEvent(type, description, timestamp, payload) { var container = document.getElementById('eventsContainer'); var el = document.createElement('div'); - el.className = 'event-item'; - el.innerHTML = '
' + escapeHtml(type.replace(/_/g, ' ')) + '
' + escapeHtml(description) + '
' + relativeTime(new Date(timestamp)) + '
'; + var navTarget = _eventNavTargets[type]; + el.className = 'event-item' + (navTarget ? ' event-clickable' : ''); + if (navTarget) { + el.onclick = function() { + toggleDrawer(); + switchView(navTarget[0]); + if (navTarget[1]) switchExploreTab(navTarget[1]); + }; + } + var detail = buildEventDetail(type, payload); + el.innerHTML = '
' + escapeHtml(type.replace(/_/g, ' ')) + '
' + escapeHtml(description) + '
' + detail + '
' + relativeTime(new Date(timestamp)) + '
'; container.prepend(el); while (container.children.length > CONFIG.MAX_EVENTS) container.removeChild(container.lastChild); if (!state.drawerOpen) { state.unreadEvents++; updateBadge(); } @@ -2934,13 +3197,13 @@

Activity

case 'query_executed': desc = 'Query: "' + (payload.query_text || '').slice(0, 40) + '" (' + (payload.results_returned || 0) + ' results)'; break; case 'consolidation_started': desc = 'Consolidation cycle started'; break; case 'consolidation_completed': desc = 'Consolidated: ' + (payload.memories_processed || 0) + ' processed'; state.exploreLoaded.episodes = false; state.exploreLoaded.patterns = false; state.exploreLoaded.abstractions = false; break; - case 'dream_cycle_completed': desc = 'Dream cycle completed'; state.exploreLoaded.abstractions = false; break; + case 'dream_cycle_completed': desc = 'Dream cycle'; state.exploreLoaded.abstractions = false; state.dreamSessionTotals.cycles++; state.dreamSessionTotals.replayed += (payload.memories_replayed || 0); state.dreamSessionTotals.strengthened += (payload.associations_strengthened || 0); state.dreamSessionTotals.newLinks += (payload.new_associations_created || 0); state.dreamSessionTotals.insights += (payload.insights_generated || 0); state.dreamSessionTotals.crossProject += (payload.cross_project_links || 0); state.dreamSessionTotals.demoted += (payload.noisy_memories_demoted || 0); break; case 'pattern_discovered': desc = 'Pattern: ' + (payload.title || '').slice(0, 40); state.exploreLoaded.patterns = false; break; case 'abstraction_created': desc = (payload.level === 3 ? 'Axiom' : 'Principle') + ': ' + (payload.title || '').slice(0, 40); state.exploreLoaded.abstractions = false; break; case 'episode_closed': desc = 'Episode closed: ' + (payload.title || '').slice(0, 40); state.exploreLoaded.episodes = false; break; default: desc = type.replace(/_/g, ' '); } - addEvent(type, desc, msg.timestamp || new Date().toISOString()); + addEvent(type, desc, msg.timestamp || new Date().toISOString(), payload); if (['memory_encoded', 'consolidation_completed', 'dream_cycle_completed'].includes(type) && state.currentView === 'timeline') { clearTimeout(state._timelineLiveReload); state._timelineLiveReload = setTimeout(function() { loadTimelineData(false); }, 5000); @@ -3208,6 +3471,18 @@

Activity

// ── Tool Usage Analytics ── var _toolRange = '24h'; + var _toolLog = []; + var _analyticsRange = 14; + + function setAnalyticsRange(days) { + _analyticsRange = days; + document.querySelectorAll('#analyticsRangeTabs .llm-range-tab').forEach(function(t) { + t.classList.toggle('active', t.getAttribute('data-range') === String(days)); + }); + document.getElementById('lifecycleTitle').textContent = 'Memory Lifecycle (' + days + 'd)'; + loadAnalytics(); + loadSessionTimeline(); + } function setToolRange(range) { _toolRange = range; @@ -3250,6 +3525,7 @@

Activity

document.getElementById('toolUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString(); var log = data.log || []; + _toolLog = log; // expose for session enrichment // Per-tool extras from log (avg latency, avg size) var toolExtras = {}; @@ -3405,6 +3681,43 @@

Activity

} } + // ── Research Analytics ── + + var _raSourceColors = { mcp: 'var(--accent-green)', filesystem: 'var(--accent-cyan)', terminal: 'var(--accent-yellow)', git: 'var(--accent-violet)', clipboard: 'var(--text-muted)', ingest: 'var(--accent-green)', benchmark: 'var(--accent-red)', system: 'var(--text-muted)' }; + + function _thresholdColor(value, greenAbove, yellowAbove) { + if (value >= greenAbove) return 'var(--accent-green)'; + if (value >= yellowAbove) return 'var(--accent-yellow)'; + return 'var(--accent-red)'; + } + + function _renderSparkline(container, data, color) { + if (!data || data.length < 2) return; + var w = 64, h = 24; + var svg = d3.select(container).append('svg').attr('width', w).attr('height', h); + var ext = d3.extent(data); + if (ext[0] === ext[1]) { ext[0] -= 1; ext[1] += 1; } + var x = d3.scaleLinear().domain([0, data.length - 1]).range([2, w - 2]); + var y = d3.scaleLinear().domain(ext).range([h - 2, 2]); + var line = d3.line().x(function(d, i) { return x(i); }).y(function(d) { return y(d); }).curve(d3.curveMonotoneX); + var area = d3.area().x(function(d, i) { return x(i); }).y0(h).y1(function(d) { return y(d); }).curve(d3.curveMonotoneX); + svg.append('path').datum(data).attr('d', area).attr('fill', color).attr('opacity', 0.1); + svg.append('path').datum(data).attr('d', line).attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1.5); + svg.append('circle').attr('cx', x(data.length - 1)).attr('cy', y(data[data.length - 1])).attr('r', 2.5).attr('fill', color); + } + + function _computeDelta(data) { + if (!data || data.length < 2) return { text: '', cls: 'neutral' }; + var recent = data.slice(-Math.min(3, data.length)); + var older = data.slice(0, Math.max(1, data.length - 3)); + var avgRecent = recent.reduce(function(a, b) { return a + b; }, 0) / recent.length; + var avgOlder = older.reduce(function(a, b) { return a + b; }, 0) / older.length; + var delta = avgRecent - avgOlder; + if (Math.abs(delta) < 0.5) return { text: 'stable', cls: 'neutral' }; + var sign = delta > 0 ? '+' : ''; + return { text: sign + delta.toFixed(1), cls: delta > 0 ? 'positive' : 'negative' }; + } + async function loadAnalytics() { try { var data = await fetchJSON('/analytics'); @@ -3413,193 +3726,488 @@

Activity

var re = data.recall_effectiveness || []; var fb = data.feedback_trend || []; var sv = data.memory_survival || []; + var ch = data.consolidation_history || []; + var days = _analyticsRange; + + // Slice data to selected range + var svData = sv.slice().reverse().slice(-days); + var fbData = fb.slice().reverse().slice(-days); + var chData = ch.slice().reverse().slice(-days); + + // ── KPI Cards ── + var kpiContainer = document.getElementById('raKpis'); + + // Pipeline Health: encoding rate + var encodingRate = (p.encoding_rate || 0) * 100; + var pipelineSpark = svData.map(function(d) { + return d.created > 0 ? (d.active / d.created) * 100 : 0; + }); + var pipelineDelta = _computeDelta(pipelineSpark); - // Pipeline summary cards - document.getElementById('raRaw').textContent = (p.total_raw || 0).toLocaleString(); - document.getElementById('raEncoded').textContent = (p.total_encoded || 0).toLocaleString(); - var dedupEl = document.getElementById('raDedupRate'); - dedupEl.textContent = ((p.dedup_rate || 0) * 100).toFixed(1) + '%'; - dedupEl.style.color = 'var(--accent-cyan)'; - - // MCP survival rate - var mcpData = sn.mcp || {}; - var mcpSurv = document.getElementById('raMCPSurvival'); - mcpSurv.textContent = ((mcpData.survival_rate || 0) * 100).toFixed(0) + '%'; - mcpSurv.style.color = 'var(--accent-green)'; - - // Filesystem (noise) survival rate - var fsData = sn.filesystem || {}; - var noiseSurv = document.getElementById('raNoiseSurvival'); - noiseSurv.textContent = ((fsData.survival_rate || 0) * 100).toFixed(0) + '%'; - noiseSurv.style.color = (fsData.survival_rate || 0) > 0.5 ? 'var(--accent-yellow)' : 'var(--accent-green)'; - - // Recall learning indicator: ratio of 6+ salience to never-recalled salience + // MCP Survival + var mcpSurv = ((sn.mcp || {}).survival_rate || 0) * 100; + + // Recall Learning var neverBucket = re.find(function(b) { return b.bucket.indexOf('never') >= 0; }); var highBucket = re.find(function(b) { return b.bucket.indexOf('6+') >= 0; }); - var learningEl = document.getElementById('raLearning'); - if (neverBucket && highBucket && neverBucket.avg_salience > 0) { - var ratio = (highBucket.avg_salience / neverBucket.avg_salience).toFixed(1) + 'x'; - learningEl.textContent = ratio; - learningEl.style.color = 'var(--accent-green)'; - } else { - learningEl.textContent = '-'; - } + var learningRatio = (neverBucket && highBucket && neverBucket.avg_salience > 0) + ? highBucket.avg_salience / neverBucket.avg_salience : 0; - // Signal vs Noise — horizontal bars showing survival rate per source - var snEl = document.getElementById('signalNoiseChart'); - var sources = Object.keys(sn).sort(function(a, b) { return (sn[b].total || 0) - (sn[a].total || 0); }); - var sourceColors = { mcp: 'var(--accent-green)', filesystem: 'var(--accent-cyan)', terminal: 'var(--accent-yellow)', git: 'var(--accent-purple)', clipboard: 'var(--text-muted)', ingest: 'var(--accent-green)', benchmark: 'var(--accent-red)', system: 'var(--text-muted)' }; - snEl.innerHTML = sources.map(function(src) { - var s = sn[src]; - var survPct = ((s.survival_rate || 0) * 100).toFixed(1); - var barWidth = Math.max(2, (s.survival_rate || 0) * 100); - var color = sourceColors[src] || 'var(--text-muted)'; - return '
' + - '
' + - '' + src + ' (' + s.total + ' memories)' + - '' + s.active + ' active (' + survPct + '%)
' + - '
' + - '
' + - '
'; - }).join(''); + // Recall Quality + var qualitySpark = fbData.map(function(d) { + var total = d.helpful + d.partial + d.irrelevant; + return total > 0 ? (d.helpful / total) * 100 : 0; + }); + var totalFb = fbData.reduce(function(a, d) { return a + d.helpful + d.partial + d.irrelevant; }, 0); + var totalHelpful = fbData.reduce(function(a, d) { return a + d.helpful; }, 0); + var qualityPct = totalFb > 0 ? (totalHelpful / totalFb) * 100 : 0; + var qualityDelta = _computeDelta(qualitySpark); + + kpiContainer.innerHTML = '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + // Pipeline Health card + var el1 = document.getElementById('kpiPipeline'); + el1.innerHTML = '
Pipeline Health
' + + '
' + encodingRate.toFixed(0) + '%
' + + ''; + _renderSparkline(document.getElementById('sparkPipeline'), pipelineSpark, _thresholdColor(encodingRate, 50, 30)); + + // MCP Survival card + var el2 = document.getElementById('kpiMcp'); + el2.innerHTML = '
MCP Survival
' + + '
' + mcpSurv.toFixed(0) + '%
' + + ''; + + // Recall Learning card + var el3 = document.getElementById('kpiLearning'); + var lColor = _thresholdColor(learningRatio, 1.5, 1.0); + el3.innerHTML = '
Recall Learning
' + + '
' + (learningRatio > 0 ? learningRatio.toFixed(1) + 'x' : '-') + '
' + + ''; + + // Recall Quality card + var el4 = document.getElementById('kpiQuality'); + el4.innerHTML = '
Recall Quality
' + + '
' + (totalFb > 0 ? qualityPct.toFixed(0) + '%' : '-') + '
' + + ''; + _renderSparkline(document.getElementById('sparkQuality'), qualitySpark, _thresholdColor(qualityPct, 70, 50)); + + // Network Density KPI (from /api/v1/stats, already loaded at init) + var el5 = document.getElementById('kpiDensity'); + try { + var statsResp = await fetchJSON('/stats'); + var avgAssoc = (statsResp.store || {}).avg_associations_per_memory || 0; + el5.innerHTML = '
Network Density
' + + '
' + avgAssoc.toFixed(1) + '
' + + ''; + } catch(e) { el5.innerHTML = '
Network Density
-
'; } + + // Retrieval Performance KPI (from new endpoint) + var el6 = document.getElementById('kpiRetrieval'); + try { + var retStats = await fetchJSON('/retrieval/stats'); + var avgPerQuery = retStats.avg_memories_per_query || 0; + var totalQ = retStats.total_queries || 0; + var avgMs = retStats.avg_synthesis_ms || 0; + el6.innerHTML = '
Retrieval Perf
' + + '
' + (totalQ > 0 ? avgPerQuery.toFixed(1) : '-') + '
' + + ''; + } catch(e) { el6.innerHTML = '
Retrieval Perf
-
'; } + + // ── Cognitive Agents Panel ── + try { + var absResp = await fetchJSON('/abstractions?limit=500'); + var abstractions = absResp.abstractions || absResp || []; + if (!Array.isArray(abstractions)) abstractions = []; + var principles = abstractions.filter(function(a) { return a.level === 2; }).length; + var axioms = abstractions.filter(function(a) { return a.level === 3; }).length; + + var chTotals = (ch || []).reduce(function(acc, c) { + acc.cycles++; acc.processed += (c.processed || 0); acc.merged += (c.merged || 0); acc.decayed += (c.decayed || 0); + return acc; + }, { cycles: 0, processed: 0, merged: 0, decayed: 0 }); + + var ds = state.dreamSessionTotals; + var cogGrid = document.getElementById('cognitiveGrid'); + cogGrid.innerHTML = + '
Encoding
' + (p.total_encoded || 0) + '
' + (encodingRate > 0 ? encodingRate.toFixed(0) + '% rate' : 'no data') + '
' + + '
Consolidation
' + chTotals.cycles + '
' + chTotals.merged + ' merged \u00b7 ' + chTotals.decayed + ' decayed
' + + '
Dreaming
' + ds.cycles + '
' + ds.insights + ' insights \u00b7 ' + ds.newLinks + ' new links
' + + '
Abstraction
' + principles + '
' + axioms + ' axioms \u00b7 ' + (principles + axioms) + ' total
'; + } catch(e) { console.error('Cognitive panel load failed:', e); } + + // ── System Analysis ── + try { + var briefEl = document.getElementById('raBrief'); + var statsResp2 = await fetchJSON('/stats'); + var st = statsResp2.store || {}; + var sessResp = await fetchJSON('/sessions?days=' + days + '&limit=100'); + var sessCount = sessResp.count || 0; + var avgAssoc = st.avg_associations_per_memory || 0; + + var lines = []; + + // Overall health sentence + var healthIssues = 0; + if (encodingRate < 50) healthIssues++; + if (mcpSurv < 60) healthIssues++; + if (qualityPct < 50 && totalFb > 5) healthIssues++; + if (learningRatio < 1.0 && learningRatio > 0) healthIssues++; + + if (healthIssues === 0) { + lines.push('Mnemonic is performing well. ' + (st.total_memories || 0) + ' memories across ' + sessCount + ' sessions, with a well-connected network (' + avgAssoc.toFixed(1) + ' associations per memory).'); + } else if (healthIssues <= 2) { + lines.push('Mnemonic is running but has areas that need attention. ' + (st.total_memories || 0) + ' memories across ' + sessCount + ' sessions.'); + } else { + lines.push('Mnemonic has several metrics below target. ' + (st.total_memories || 0) + ' memories across ' + sessCount + ' sessions. This is expected early on \u2014 the system improves with use.'); + } - // Recall Effectiveness — bar chart showing access buckets vs avg salience - var recallEl = document.getElementById('recallChart'); - if (re.length === 0) { - recallEl.innerHTML = 'No recall data'; - } else { - var maxSal = Math.max.apply(null, re.map(function(b) { return b.avg_salience; })); - recallEl.innerHTML = '
Memories recalled more often have higher salience = system is learning
' + - re.map(function(b) { - var barWidth = maxSal > 0 ? (b.avg_salience / maxSal * 100) : 0; - var color = b.bucket.indexOf('never') >= 0 ? 'var(--accent-red)' : b.bucket.indexOf('6+') >= 0 ? 'var(--accent-green)' : 'var(--accent-cyan)'; - return '
' + - '
' + - '' + b.bucket + ' (' + b.count + ')' + - '' + b.avg_salience.toFixed(3) + '
' + - '
' + - '
' + - '
'; - }).join(''); - } + // Encoding pipeline insight + if (encodingRate >= 80) { + lines.push('The encoding pipeline is converting ' + encodingRate.toFixed(0) + '% of observations into memories \u2014 minimal signal loss.'); + } else if (encodingRate >= 50) { + lines.push('The encoding pipeline is at ' + encodingRate.toFixed(0) + '%. Some observations are being filtered or failing to encode.'); + } else if (encodingRate > 0) { + lines.push('Encoding is struggling at ' + encodingRate.toFixed(0) + '%. Check LLM availability \u2014 most observations are failing to encode.'); + } - // Memory Survival by day — stacked text table (D3 chart would be ideal but text works) - var survEl = document.getElementById('survivalChart'); - var survData = sv.slice().reverse().slice(-14); // last 14 days, chronological - if (survData.length === 0) { - survEl.innerHTML = 'No survival data'; - } else { - survEl.innerHTML = '' + - survData.map(function(d) { - var total = d.created || 1; - var activePct = ((d.active / total) * 100).toFixed(0); - return '' + - '' + - '' + - '' + - ''; - }).join('') + '
DateCreatedActiveFadingArchivedMerged
' + d.date.substring(5) + '' + d.created + '' + d.active + ' (' + activePct + '%)' + d.fading + '' + d.archived + '' + d.merged + '
'; - } + // MCP survival insight + if (mcpSurv > 0 && mcpSurv < 60) { + var mcpActive = (sn.mcp || {}).active || 0; + var mcpTotal = (sn.mcp || {}).total || 0; + lines.push('Only ' + mcpSurv.toFixed(0) + '% of MCP memories survive (' + mcpActive + '/' + mcpTotal + ' active). Older memories naturally decay over time \u2014 this ratio improves as you build fresh, high-quality memories through active use.'); + } else if (mcpSurv >= 60 && mcpSurv < 80) { + lines.push('MCP survival is ' + mcpSurv.toFixed(0) + '% \u2014 some older memories have been pruned, which is healthy.'); + } - // Feedback trend - var fbEl = document.getElementById('feedbackTrendChart'); - var fbData = fb.slice().reverse().slice(-14); - if (fbData.length === 0) { - fbEl.innerHTML = 'No feedback data'; - } else { - fbEl.innerHTML = '' + - fbData.map(function(d) { - var total = d.helpful + d.partial + d.irrelevant; - var quality = total > 0 ? ((d.helpful / total) * 100).toFixed(0) + '%' : '-'; - var qColor = total > 0 && (d.helpful / total) >= 0.5 ? 'var(--accent-green)' : 'var(--accent-yellow)'; - return '' + - '' + - '' + - '' + - ''; - }).join('') + '
DateHelpfulPartialIrrelevantQuality
' + d.date.substring(5) + '' + d.helpful + '' + d.partial + '' + d.irrelevant + '' + quality + '
'; - } + // Recall quality insight + if (totalFb > 5) { + if (qualityPct >= 70) { + lines.push('Recall quality is strong at ' + qualityPct.toFixed(0) + '% \u2014 the system is returning useful memories most of the time.'); + } else if (qualityDelta.cls === 'positive') { + lines.push('Recall quality is at ' + qualityPct.toFixed(0) + '% but trending up (' + qualityDelta.text + '). The feedback loop is working \u2014 keep rating recalls.'); + } else { + lines.push('Recall quality is below target at ' + qualityPct.toFixed(0) + '%. More feedback will help the system learn which memories matter.'); + } + } else { + lines.push('Not enough recall feedback yet to assess quality. Rate your recalls (helpful/partial/irrelevant) to train the system.'); + } - // Encoding Pipeline — funnel visualization - var epEl = document.getElementById('encodingPipelineChart'); - var totalRaw = p.total_raw || 0; - var totalEncoded = p.total_encoded || 0; - var totalMerged = p.total_merged || 0; - var maxVal = Math.max(totalRaw, 1); - var stages = [ - { label: 'Raw', value: totalRaw, color: 'var(--accent-blue)' }, - { label: 'Encoded', value: totalEncoded, color: 'var(--accent-cyan)' }, - { label: 'Merged', value: totalMerged, color: 'var(--accent-violet)' }, - { label: 'Active', value: totalEncoded - totalMerged, color: 'var(--accent-green)' } - ]; - epEl.innerHTML = stages.map(function(s) { - var pct = Math.max(((s.value / maxVal) * 100), 2); - return '
' + s.label + '' + - '
' + - '' + (s.value >= 0 ? s.value.toLocaleString() : 0) + '
'; - }).join(''); + // Learning signal + if (learningRatio > 1.5) { + lines.push('Frequently recalled memories have ' + learningRatio.toFixed(1) + 'x higher salience than unused ones \u2014 the system is learning what matters.'); + } else if (learningRatio > 0 && learningRatio < 1.0) { + lines.push('Frequently recalled memories don\u2019t yet have higher salience than unused ones. This is normal early on \u2014 the recall learning signal strengthens over time with more feedback.'); + } - // Salience Distribution — stacked bar chart by tier - var sdEl = document.getElementById('salienceHistogram'); - var sd = data.salience_distribution || {}; - var sourceColors = { - mcp: 'var(--accent-cyan)', filesystem: 'var(--accent-blue)', - git: 'var(--accent-violet)', terminal: 'var(--accent-green)', - clipboard: 'var(--accent-yellow)', benchmark: 'var(--accent-pink)', - ingest: 'var(--accent-red)', system: 'var(--text-muted)' - }; - var tiers = ['high', 'medium', 'low', 'noise']; - var allSources = new Set(); - tiers.forEach(function(t) { Object.keys(sd[t] || {}).forEach(function(s) { allSources.add(s); }); }); - var sources = Array.from(allSources); - var maxTierTotal = 0; - tiers.forEach(function(t) { - var total = 0; - sources.forEach(function(s) { total += (sd[t] || {})[s] || 0; }); - if (total > maxTierTotal) maxTierTotal = total; - }); - if (maxTierTotal === 0) { - sdEl.innerHTML = 'No salience data'; - } else { - sdEl.innerHTML = tiers.map(function(tier) { - var tierData = sd[tier] || {}; - var tierTotal = 0; - sources.forEach(function(s) { tierTotal += tierData[s] || 0; }); - var segs = sources.map(function(s) { - var val = tierData[s] || 0; - if (val === 0) return ''; - var pct = (val / maxTierTotal) * 100; - return '
'; - }).join(''); - return '
' + tier + '' + - '
' + segs + '
' + - '' + tierTotal + '
'; - }).join('') + - '
' + - sources.map(function(s) { - return '' + s + ''; - }).join('') + '
'; - } + // Abstraction formation + if (principles > 0) { + var axiomText = axioms > 0 ? ' and ' + axioms + ' axiom' + (axioms !== 1 ? 's' : '') + '' : ''; + lines.push('The abstraction agent has synthesized ' + principles + ' principle' + (principles !== 1 ? 's' : '') + '' + axiomText + ' from your memory network \u2014 higher-order patterns that inform future recall.'); + } + + briefEl.innerHTML = lines.join(' '); + } catch(e) { /* analysis is optional, fail silently */ } + + // ── Memory Lifecycle Chart (D3 stacked area) ── + renderLifecycleChart(svData, fbData, chData); + + // ── Signal Quality by Source (D3 horizontal bars) ── + renderSignalChart(sn); + + // ── Recall Learning Curve (D3 connected dots) ── + renderRecallChart(re); } catch(e) { console.error('Analytics load failed:', e); } } - // Session Timeline — swimlane visualization - function _fmtTimelineAxis(d) { - var now = new Date(); - if (d.toDateString() === now.toDateString()) { - return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); + function renderLifecycleChart(svData, fbData, chData) { + var container = document.getElementById('lifecycleChart'); + var legendEl = document.getElementById('lifecycleLegend'); + container.innerHTML = ''; + if (!svData || svData.length === 0) { + container.innerHTML = '
No lifecycle data yet
'; + legendEl.innerHTML = ''; + return; + } + + var w = container.clientWidth || 600; + var h = 220; + var margin = { top: 10, right: 50, bottom: 28, left: 45 }; + var iw = w - margin.left - margin.right; + var ih = h - margin.top - margin.bottom; + + var svg = d3.select(container).append('svg').attr('width', w).attr('height', h); + var g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + // Prepare stack data + var stackKeys = ['active', 'merged', 'fading', 'archived']; + var stackColors = { active: 'var(--accent-green)', merged: 'var(--accent-cyan)', fading: 'var(--accent-yellow)', archived: 'var(--text-dim)' }; + + var x = d3.scaleBand().domain(svData.map(function(d) { return d.date; })).range([0, iw]).padding(0.1); + var maxY = d3.max(svData, function(d) { return (d.active || 0) + (d.merged || 0) + (d.fading || 0) + (d.archived || 0); }) || 1; + var y = d3.scaleLinear().domain([0, maxY]).nice().range([ih, 0]); + + var stack = d3.stack().keys(stackKeys).value(function(d, key) { return d[key] || 0; }); + var series = stack(svData); + + var area = d3.area() + .x(function(d) { return x(d.data.date) + x.bandwidth() / 2; }) + .y0(function(d) { return y(d[0]); }) + .y1(function(d) { return y(d[1]); }) + .curve(d3.curveMonotoneX); + + series.forEach(function(s) { + g.append('path').datum(s).attr('d', area) + .attr('fill', stackColors[s.key]) + .attr('opacity', s.key === 'archived' ? 0.3 : 0.6); + }); + + // Feedback quality overlay line (secondary Y axis) + if (fbData && fbData.length > 0) { + var yRight = d3.scaleLinear().domain([0, 100]).range([ih, 0]); + // Map feedback dates to survival dates + var fbMap = {}; + fbData.forEach(function(d) { var t = d.helpful + d.partial + d.irrelevant; fbMap[d.date] = t > 0 ? (d.helpful / t) * 100 : null; }); + var fbPoints = svData.map(function(d) { return { date: d.date, quality: fbMap[d.date] }; }).filter(function(d) { return d.quality !== null && d.quality !== undefined; }); + + if (fbPoints.length > 1) { + var fbLine = d3.line() + .x(function(d) { return x(d.date) + x.bandwidth() / 2; }) + .y(function(d) { return yRight(d.quality); }) + .curve(d3.curveMonotoneX); + g.append('path').datum(fbPoints).attr('d', fbLine) + .attr('fill', 'none').attr('stroke', 'var(--accent-violet)').attr('stroke-width', 2).attr('stroke-dasharray', '4,2'); + + // Right axis label + g.append('text').attr('x', iw + 8).attr('y', yRight(50)).attr('fill', 'var(--text-dim)') + .attr('font-size', '0.6rem').attr('dominant-baseline', 'middle').text('quality %'); + } + } + + // Consolidation diamond markers + if (chData && chData.length > 0) { + var chMap = {}; + chData.forEach(function(d) { if (d.processed > 0) chMap[d.date] = d.processed; }); + svData.forEach(function(d) { + if (chMap[d.date]) { + var cx = x(d.date) + x.bandwidth() / 2; + g.append('path') + .attr('d', 'M' + cx + ',' + (y(maxY) - 2) + ' l4,6 l-4,6 l-4,-6 z') + .attr('fill', 'var(--accent-orange)').attr('opacity', 0.7); + } + }); } + + // X axis — show every Nth date label to avoid crowding + var labelEvery = Math.max(1, Math.ceil(svData.length / 7)); var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - return months[d.getMonth()] + ' ' + d.getDate() + ' ' + - String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); + g.append('g').attr('transform', 'translate(0,' + ih + ')').call( + d3.axisBottom(x).tickValues(svData.filter(function(d, i) { return i % labelEvery === 0; }).map(function(d) { return d.date; })) + .tickFormat(function(d) { var parts = d.split('-'); return months[parseInt(parts[1]) - 1] + ' ' + parseInt(parts[2]); }) + ).selectAll('text').attr('fill', 'var(--text-dim)').attr('font-size', '0.6rem'); + g.selectAll('.domain,.tick line').attr('stroke', 'var(--border-subtle)'); + + // Y axis + g.append('g').call(d3.axisLeft(y).ticks(4).tickFormat(d3.format('d'))) + .selectAll('text').attr('fill', 'var(--text-dim)').attr('font-size', '0.6rem'); + g.selectAll('.domain,.tick line').attr('stroke', 'var(--border-subtle)'); + + // Tooltip on hover + var tooltipRect = g.append('rect').attr('width', iw).attr('height', ih).attr('fill', 'transparent'); + var tooltipLine = g.append('line').attr('stroke', 'var(--text-dim)').attr('stroke-dasharray', '2,2').attr('y1', 0).attr('y2', ih).style('display', 'none'); + var tooltipDiv = document.getElementById('lifecycleTooltip'); + + tooltipRect.on('mousemove', function(event) { + var mx = d3.pointer(event, this)[0]; + var dates = svData.map(function(d) { return d.date; }); + var idx = Math.round(mx / iw * (dates.length - 1)); + idx = Math.max(0, Math.min(idx, dates.length - 1)); + var d = svData[idx]; + tooltipLine.attr('x1', x(d.date) + x.bandwidth() / 2).attr('x2', x(d.date) + x.bandwidth() / 2).style('display', null); + var fb = fbData.find(function(f) { return f.date === d.date; }); + var qStr = ''; + if (fb) { var t = fb.helpful + fb.partial + fb.irrelevant; qStr = t > 0 ? ' · quality: ' + ((fb.helpful / t) * 100).toFixed(0) + '%' : ''; } + tooltipDiv.style.display = 'block'; + tooltipDiv.style.left = (event.offsetX + 12) + 'px'; + tooltipDiv.style.top = (event.offsetY - 10) + 'px'; + tooltipDiv.innerHTML = '' + d.date + '
Active: ' + (d.active || 0) + ' · Merged: ' + (d.merged || 0) + ' · Fading: ' + (d.fading || 0) + ' · Archived: ' + (d.archived || 0) + qStr; + }).on('mouseleave', function() { + tooltipLine.style('display', 'none'); + tooltipDiv.style.display = 'none'; + }); + + // Legend + legendEl.innerHTML = stackKeys.map(function(k) { + return '' + k + ''; + }).join('') + 'quality' + + 'consolidation'; + } + + function renderSignalChart(sn) { + var container = document.getElementById('signalNoiseChart'); + container.innerHTML = ''; + var sources = Object.keys(sn).sort(function(a, b) { return (sn[b].total || 0) - (sn[a].total || 0); }); + if (sources.length === 0) { container.innerHTML = '
No source data
'; return; } + + var avgSurv = 0; + var totalAll = 0; + sources.forEach(function(s) { avgSurv += (sn[s].survival_rate || 0) * (sn[s].total || 0); totalAll += (sn[s].total || 0); }); + avgSurv = totalAll > 0 ? avgSurv / totalAll : 0; + + var w = container.clientWidth || 400; + var h = sources.length * 36 + 10; + var margin = { top: 4, right: 12, bottom: 4, left: 80 }; + var iw = w - margin.left - margin.right; + + var svg = d3.select(container).append('svg').attr('width', w).attr('height', h); + var g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + var y = d3.scaleBand().domain(sources).range([0, h - margin.top - margin.bottom]).padding(0.25); + var x = d3.scaleLinear().domain([0, 100]).range([0, iw]); + + // Background tracks + g.selectAll('.track').data(sources).enter().append('rect') + .attr('x', 0).attr('y', function(d) { return y(d); }) + .attr('width', iw).attr('height', y.bandwidth()) + .attr('fill', 'var(--bg-tertiary)').attr('rx', 3); + + // Bars + g.selectAll('.bar').data(sources).enter().append('rect') + .attr('x', 0).attr('y', function(d) { return y(d); }) + .attr('width', function(d) { return Math.max(2, x((sn[d].survival_rate || 0) * 100)); }) + .attr('height', y.bandwidth()) + .attr('fill', function(d) { return _raSourceColors[d] || 'var(--text-muted)'; }) + .attr('opacity', 0.7).attr('rx', 3); + + // Average line + g.append('line').attr('x1', x(avgSurv * 100)).attr('x2', x(avgSurv * 100)) + .attr('y1', 0).attr('y2', h - margin.top - margin.bottom) + .attr('stroke', 'var(--text-muted)').attr('stroke-dasharray', '3,3').attr('stroke-width', 1); + + // Labels (left) + g.selectAll('.label').data(sources).enter().append('text') + .attr('x', -6).attr('y', function(d) { return y(d) + y.bandwidth() / 2; }) + .attr('text-anchor', 'end').attr('dominant-baseline', 'middle') + .attr('fill', function(d) { return _raSourceColors[d] || 'var(--text-muted)'; }) + .attr('font-size', '0.72rem').attr('font-weight', 600) + .text(function(d) { return d; }); + + // Value labels (on bar) + g.selectAll('.val').data(sources).enter().append('text') + .attr('x', function(d) { return Math.max(2, x((sn[d].survival_rate || 0) * 100)) + 4; }) + .attr('y', function(d) { return y(d) + y.bandwidth() / 2; }) + .attr('dominant-baseline', 'middle') + .attr('fill', 'var(--text-muted)').attr('font-size', '0.65rem') + .text(function(d) { + var s = sn[d]; + return s.active + '/' + s.total + ' (' + ((s.survival_rate || 0) * 100).toFixed(0) + '%)' + + (s.avg_salience ? ' · sal ' + s.avg_salience.toFixed(2) : ''); + }); + } + + function renderRecallChart(re) { + var container = document.getElementById('recallChart'); + container.innerHTML = ''; + if (!re || re.length === 0) { container.innerHTML = '
No recall data yet
'; return; } + + var w = container.clientWidth || 400; + var h = 180; + var margin = { top: 12, right: 16, bottom: 36, left: 45 }; + var iw = w - margin.left - margin.right; + var ih = h - margin.top - margin.bottom; + + var svg = d3.select(container).append('svg').attr('width', w).attr('height', h); + var g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + var buckets = re.map(function(b) { return b.bucket; }); + var x = d3.scalePoint().domain(buckets).range([20, iw - 20]).padding(0.5); + var maxSal = d3.max(re, function(b) { return b.avg_salience; }) || 1; + var y = d3.scaleLinear().domain([0, Math.max(maxSal * 1.1, 0.5)]).range([ih, 0]); + var maxCount = d3.max(re, function(b) { return b.count; }) || 1; + + // Reference line at first bucket's salience (to show slope) + if (re.length > 1) { + g.append('line').attr('x1', 0).attr('x2', iw).attr('y1', y(re[0].avg_salience)).attr('y2', y(re[0].avg_salience)) + .attr('stroke', 'var(--border-subtle)').attr('stroke-dasharray', '3,3'); + } + + // Connecting line + var line = d3.line().x(function(d) { return x(d.bucket); }).y(function(d) { return y(d.avg_salience); }).curve(d3.curveMonotoneX); + var isLearning = re.length >= 2 && re[re.length - 1].avg_salience > re[0].avg_salience; + g.append('path').datum(re).attr('d', line) + .attr('fill', 'none').attr('stroke', isLearning ? 'var(--accent-green)' : 'var(--accent-red)') + .attr('stroke-width', 2); + + // Dots (sized by count) + var rScale = d3.scaleSqrt().domain([0, maxCount]).range([4, 12]); + re.forEach(function(b) { + var color = b.bucket.indexOf('never') >= 0 ? 'var(--accent-red)' : b.bucket.indexOf('6+') >= 0 ? 'var(--accent-green)' : 'var(--accent-cyan)'; + g.append('circle').attr('cx', x(b.bucket)).attr('cy', y(b.avg_salience)) + .attr('r', rScale(b.count)).attr('fill', color).attr('opacity', 0.7); + g.append('text').attr('x', x(b.bucket)).attr('y', y(b.avg_salience) - rScale(b.count) - 5) + .attr('text-anchor', 'middle').attr('fill', 'var(--text-secondary)').attr('font-size', '0.68rem').attr('font-weight', 600) + .text(b.avg_salience.toFixed(2)); + }); + + // X axis + g.append('g').attr('transform', 'translate(0,' + ih + ')').call(d3.axisBottom(x)) + .selectAll('text').attr('fill', 'var(--text-dim)').attr('font-size', '0.65rem'); + g.selectAll('.domain,.tick line').attr('stroke', 'var(--border-subtle)'); + + // Y axis + g.append('g').call(d3.axisLeft(y).ticks(4)).selectAll('text').attr('fill', 'var(--text-dim)').attr('font-size', '0.6rem'); + g.selectAll('.domain,.tick line').attr('stroke', 'var(--border-subtle)'); + + // Verdict text + var ratio = (re.length >= 2 && re[0].avg_salience > 0) ? (re[re.length - 1].avg_salience / re[0].avg_salience) : 0; + var verdictColor = isLearning ? 'var(--accent-green)' : 'var(--accent-red)'; + var verdictText = isLearning + ? 'Learning: ' + ratio.toFixed(1) + 'x salience increase from "' + re[0].bucket + '" to "' + re[re.length - 1].bucket + '"' + : 'No clear learning signal — salience flat or declining'; + svg.append('text').attr('x', margin.left).attr('y', h - 2) + .attr('fill', verdictColor).attr('font-size', '0.65rem').text(verdictText); + } + + // ── Session Activity (expandable rows with quality enrichment) ── + + function _buildSessionEnrichment() { + var enrichment = {}; + _toolLog.forEach(function(r) { + var sid = r.session_id || ''; + if (!sid) return; + if (!enrichment[sid]) enrichment[sid] = { tools: {}, types: {}, fbH: 0, fbP: 0, fbI: 0 }; + var e = enrichment[sid]; + var tool = r.tool_name || r.tool || ''; + if (tool) e.tools[tool] = (e.tools[tool] || 0) + 1; + if ((tool === 'remember') && r.memory_type) e.types[r.memory_type] = (e.types[r.memory_type] || 0) + 1; + if (tool === 'feedback') { + var rating = r.rating || r.quality || ''; + if (rating === 'helpful') e.fbH++; + else if (rating === 'partial') e.fbP++; + else if (rating === 'irrelevant') e.fbI++; + } + }); + return enrichment; + } + + function toggleSession(sessionId) { + var detail = document.getElementById('detail-' + sessionId); + var chevron = document.getElementById('chevron-' + sessionId); + if (detail) detail.classList.toggle('open'); + if (chevron) chevron.classList.toggle('open'); } async function loadSessionTimeline() { try { - var resp = await fetch(CONFIG.API_BASE + '/sessions?days=7&limit=15'); + var resp = await fetch(CONFIG.API_BASE + '/sessions?days=' + _analyticsRange + '&limit=15'); if (!resp.ok) return; var data = await resp.json(); var sessions = data.sessions || []; @@ -3609,54 +4217,77 @@

Activity

return; } - // Filter out sessions with no memories var activeSessions = sessions.filter(function(s) { return s.memory_count > 0; }); if (activeSessions.length === 0) { el.innerHTML = 'No sessions with memories'; return; } - // Find time range across all sessions - var minT = Infinity, maxT = -Infinity; - activeSessions.forEach(function(s) { - var st = new Date(s.start_time).getTime(); - var et = new Date(s.end_time).getTime(); - if (st < minT) minT = st; - if (et > maxT) maxT = et; - }); - var rangeMs = maxT - minT || 1; - + var enrichment = _buildSessionEnrichment(); + var maxCount = Math.max.apply(null, activeSessions.map(function(s) { return s.memory_count; })); var colors = ['var(--accent-cyan)', 'var(--accent-green)', 'var(--accent-violet)', 'var(--accent-blue)', 'var(--accent-pink)', 'var(--accent-yellow)']; - - var html = activeSessions.map(function(s, i) { - var st = new Date(s.start_time).getTime(); - var et = new Date(s.end_time).getTime(); - var left = ((st - minT) / rangeMs) * 100; - var width = Math.max(((et - st) / rangeMs) * 100, 2); - var label = s.session_id.replace('mcp-', '').substring(0, 8); - var dur = Math.round((et - st) / 60000); + var now = new Date(); + var today = now.toDateString(); + var yesterday = new Date(now.getTime() - 86400000).toDateString(); + var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + + var rows = activeSessions.map(function(s, i) { + var st = new Date(s.start_time); + var et = new Date(s.end_time); + var durMin = Math.round((et - st) / 60000); + var durStr = durMin >= 60 ? Math.floor(durMin / 60) + 'h ' + (durMin % 60) + 'm' : durMin + 'm'; + if (durMin === 0) durStr = '<1m'; + var dateStr = st.toDateString() === today ? 'Today' : st.toDateString() === yesterday ? 'Yesterday' : months[st.getMonth()] + ' ' + st.getDate(); + var timeStr = String(st.getHours()).padStart(2, '0') + ':' + String(st.getMinutes()).padStart(2, '0'); + + var barPct = Math.max((s.memory_count / maxCount) * 100, 8); var color = colors[i % colors.length]; - var concepts = (s.top_concepts && s.top_concepts.length > 0) ? ' \u00b7 ' + s.top_concepts.slice(0, 3).join(', ') : ''; - return '
' + - '' + escapeHtml(label) + '' + - '
' + - '
' + - s.memory_count + '
' + - '' + dur + 'm / ' + s.memory_count + concepts + '
'; - }).join(''); - // Time axis labels - var tickCount = Math.min(5, activeSessions.length); - var axisHtml = '
'; - for (var ti = 0; ti < tickCount; ti++) { - var pct = tickCount > 1 ? (ti / (tickCount - 1)) * 100 : 50; - var tickMs = minT + (pct / 100) * rangeMs; - var tickLabel = _fmtTimelineAxis(new Date(tickMs)); - axisHtml += '' + tickLabel + ''; - } - axisHtml += '
'; + // Enrichment from tool log + var e = enrichment[s.session_id] || { tools: {}, types: {}, fbH: 0, fbP: 0, fbI: 0 }; + var totalFb = e.fbH + e.fbP + e.fbI; + var qualityDotColor = 'var(--text-dim)'; + var fbBadge = ''; + if (totalFb > 0) { + var helpfulPct = (e.fbH / totalFb) * 100; + qualityDotColor = helpfulPct >= 70 ? 'var(--accent-green)' : helpfulPct >= 50 ? 'var(--accent-yellow)' : 'var(--accent-red)'; + fbBadge = '' + e.fbH + '/' + totalFb + ' helpful'; + } - el.innerHTML = html + axisHtml; + // Bar color reflects quality + if (totalFb > 0) { + var hp = (e.fbH / totalFb) * 100; + color = hp >= 70 ? 'var(--accent-green)' : hp >= 50 ? 'var(--accent-yellow)' : 'var(--accent-cyan)'; + } + + var concepts = (s.top_concepts || []).slice(0, 2); + var pillsHtml = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + + var sid = s.session_id.replace(/[^a-zA-Z0-9]/g, ''); + + // Expanded detail + var toolStr = Object.keys(e.tools).map(function(t) { return t + '(' + e.tools[t] + ')'; }).join(' '); + var typeStr = Object.keys(e.types).map(function(t) { return t + '(' + e.types[t] + ')'; }).join(' '); + var allConcepts = (s.top_concepts || []).map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + + var detailHtml = '
'; + if (toolStr) detailHtml += '
Tools: ' + toolStr + '
'; + if (typeStr) detailHtml += '
Types: ' + typeStr + '
'; + if (allConcepts) detailHtml += '
Topics:
' + allConcepts + '
'; + detailHtml += '
'; + + return '
' + + '' + + '' + dateStr + '' + timeStr + '' + + '' + s.memory_count + '' + + '' + durStr + '' + + fbBadge + + '' + pillsHtml + '' + + '' + + '
' + detailHtml; + }).join(''); + + el.innerHTML = rows; } catch(e) { /* ignore */ } } @@ -3765,8 +4396,10 @@

Activity

case '1': switchView('recall'); break; case '2': switchView('explore'); break; case '3': switchView('timeline'); break; - case '4': switchView('agent'); break; - case '5': switchView('llm'); break; + case '4': switchView('mind'); break; + case '5': switchView('agent'); break; + case '6': switchView('llm'); break; + case '7': switchView('tools'); break; } }); @@ -4614,6 +5247,564 @@

Activity

} } + // ── Mind View ── + var _mindFilterTimer = null; + var _mindSearchTimer = null; + var _mindResizeObserver = null; + var _mindColorCache = {}; + + function getGraphColor(varName) { + if (!_mindColorCache[varName]) { + _mindColorCache[varName] = getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; + } + return _mindColorCache[varName]; + } + function invalidateColorCache() { _mindColorCache = {}; } + + function mindEdgeStyle(type) { + var styles = { + similar: { color: function() { return getGraphColor('--text-dim'); }, dash: '', opacity: 0.5 }, + caused_by: { color: function() { return getGraphColor('--accent-orange'); }, dash: '', opacity: 0.8 }, + part_of: { color: function() { return getGraphColor('--accent-blue'); }, dash: '', opacity: 0.7 }, + reinforces: { color: function() { return getGraphColor('--accent-green'); }, dash: '', opacity: 0.7 }, + temporal: { color: function() { return getGraphColor('--text-dim'); }, dash: '4,3', opacity: 0.3 }, + contradicts: { color: function() { return getGraphColor('--accent-red'); }, dash: '', opacity: 0.8 }, + }; + return styles[type] || styles.similar; + } + + function mindColorScale() { + var dim = document.getElementById('mindColorBy').value; + var maps = { + source: { mcp: '--accent-cyan', filesystem: '--accent-violet', terminal: '--accent-orange', clipboard: '--accent-green', consolidation: '--accent-blue', git: '--accent-pink' }, + emotional_tone: { neutral: '--text-dim', frustrating: '--accent-red', satisfying: '--accent-green', surprising: '--accent-yellow' }, + significance: { routine: '--text-dim', notable: '--accent-blue', important: '--accent-orange', critical: '--accent-red', success: '--accent-green', failure: '--accent-red', blocked: '--accent-yellow' }, + state: { active: '--accent-green', fading: '--accent-yellow', archived: '--text-dim', merged: '--accent-blue' }, + }; + var m = maps[dim] || maps.source; + return function(node) { + var val = node[dim] || ''; + var varName = m[val] || '--text-muted'; + return getGraphColor(varName); + }; + } + + function nodeRadius(salience) { return 4 + (salience || 0) * 16; } + + function computeMindStats(data) { + var nCount = data.nodes.length; + var eCount = data.edges.length; + if (nCount === 0) return { nodes: 0, edges: 0, clusters: 0, orphans: 0, avgDegree: 0 }; + + // Build adjacency for cluster detection + var adj = {}; + data.nodes.forEach(function(n) { adj[n.id] = []; }); + data.edges.forEach(function(e) { + if (adj[e.source] || adj[e.source.id]) { + var sid = typeof e.source === 'object' ? e.source.id : e.source; + var tid = typeof e.target === 'object' ? e.target.id : e.target; + if (adj[sid]) adj[sid].push(tid); + if (adj[tid]) adj[tid].push(sid); + } + }); + + // BFS connected components + var visited = {}; + var clusters = 0; + var orphans = 0; + data.nodes.forEach(function(n) { + if (visited[n.id]) return; + clusters++; + var queue = [n.id]; + visited[n.id] = true; + var size = 0; + while (queue.length > 0) { + var cur = queue.shift(); + size++; + (adj[cur] || []).forEach(function(neighbor) { + if (!visited[neighbor]) { visited[neighbor] = true; queue.push(neighbor); } + }); + } + if (size === 1 && (!adj[n.id] || adj[n.id].length === 0)) orphans++; + }); + + return { + nodes: nCount, + edges: eCount, + clusters: clusters, + orphans: orphans, + avgDegree: nCount > 0 ? ((eCount * 2) / nCount).toFixed(1) : '0.0', + }; + } + + function renderMindStats(stats) { + var el = document.getElementById('mindStats'); + var items = [ + { v: stats.nodes, l: 'nodes' }, + { v: stats.edges, l: 'edges' }, + { v: stats.clusters, l: stats.clusters === 1 ? 'cluster' : 'clusters' }, + { v: stats.orphans, l: 'orphans' }, + { v: stats.avgDegree, l: 'avg degree' }, + ]; + el.innerHTML = items.map(function(s) { + return '
' + s.v + '' + s.l + '
'; + }).join(''); + } + + function buildMindLegend() { + var el = document.getElementById('mindLegend'); + var types = [ + { type: 'similar', label: 'similar' }, + { type: 'caused_by', label: 'caused by' }, + { type: 'part_of', label: 'part of' }, + { type: 'reinforces', label: 'reinforces' }, + { type: 'temporal', label: 'temporal' }, + { type: 'contradicts', label: 'contradicts' }, + ]; + el.innerHTML = types.map(function(t) { + var s = mindEdgeStyle(t.type); + var dashAttr = s.dash ? ' stroke-dasharray="' + s.dash + '"' : ''; + return '
' + + '' + + '' + t.label + '
'; + }).join(''); + } + + function buildMindAdjacency(data) { + var adj = {}; + data.nodes.forEach(function(n) { adj[n.id] = new Set(); }); + data.edges.forEach(function(e) { + var sid = typeof e.source === 'object' ? e.source.id : e.source; + var tid = typeof e.target === 'object' ? e.target.id : e.target; + if (adj[sid]) adj[sid].add(tid); + if (adj[tid]) adj[tid].add(sid); + }); + return adj; + } + + function getNeighborhood(nodeId, hops) { + var adj = state.mindAdjacency; + if (!adj) return new Set([nodeId]); + var visited = new Set([nodeId]); + var frontier = [nodeId]; + for (var h = 0; h < hops; h++) { + var next = []; + frontier.forEach(function(id) { + (adj[id] || new Set()).forEach(function(neighbor) { + if (!visited.has(neighbor)) { visited.add(neighbor); next.push(neighbor); } + }); + }); + frontier = next; + } + return visited; + } + + function showMindDetail(node) { + var el = document.getElementById('mindDetail'); + state.mindSelectedNode = node; + + // Count connections by type + var connCounts = {}; + if (state.mindData) { + state.mindData.edges.forEach(function(e) { + var sid = typeof e.source === 'object' ? e.source.id : e.source; + var tid = typeof e.target === 'object' ? e.target.id : e.target; + if (sid === node.id || tid === node.id) { + connCounts[e.relation_type] = (connCounts[e.relation_type] || 0) + 1; + } + }); + } + var totalConns = Object.values(connCounts).reduce(function(a, b) { return a + b; }, 0); + + var date = node.timestamp ? new Date(node.timestamp) : null; + var dateStr = date ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : '-'; + + var rows = [ + { l: 'Salience', v: (node.salience || 0).toFixed(2) }, + { l: 'Source', v: node.source || '-' }, + { l: 'State', v: node.state || '-' }, + ]; + if (node.significance) rows.push({ l: 'Significance', v: node.significance }); + if (node.emotional_tone) rows.push({ l: 'Tone', v: node.emotional_tone }); + rows.push({ l: 'Created', v: dateStr }); + rows.push({ l: 'Connections', v: totalConns }); + if (node.event_count) rows.push({ l: 'Events', v: node.event_count }); + + var html = ''; + html += '
' + escapeHtml(node.summary || '') + '
'; + html += rows.map(function(r) { + return '
' + r.l + '' + escapeHtml(String(r.v)) + '
'; + }).join(''); + + if (totalConns > 0) { + html += '
Connections
'; + Object.keys(connCounts).sort().forEach(function(type) { + var s = mindEdgeStyle(type); + var dashStyle = s.dash ? ' stroke-dasharray="' + s.dash + '"' : ''; + html += '
' + + '' + + '' + type.replace(/_/g, ' ') + ' (' + connCounts[type] + ')
'; + }); + html += '
'; + } + + if (node.concepts && node.concepts.length > 0) { + html += '
Concepts
'; + node.concepts.forEach(function(c) { + html += '' + escapeHtml(c) + ''; + }); + html += '
'; + } + + if (node.files_modified && node.files_modified.length > 0) { + html += '
Files
'; + node.files_modified.slice(0, 8).forEach(function(f) { html += '
' + escapeHtml(f) + '
'; }); + if (node.files_modified.length > 8) html += '
+' + (node.files_modified.length - 8) + ' more
'; + html += '
'; + } + + el.innerHTML = html; + el.style.display = 'block'; + } + + function closeMindDetail() { + document.getElementById('mindDetail').style.display = 'none'; + state.mindSelectedNode = null; + clearMindHighlight(); + } + + function mindSearchConcept(concept) { + var input = document.getElementById('mindSearch'); + input.value = concept; + document.getElementById('mindSearchClear').style.display = ''; + state.mindSearchTerm = concept.toLowerCase(); + applyMindSearch(); + } + + function onMindSearch() { + clearTimeout(_mindSearchTimer); + _mindSearchTimer = setTimeout(function() { + var val = document.getElementById('mindSearch').value.trim(); + document.getElementById('mindSearchClear').style.display = val ? '' : 'none'; + state.mindSearchTerm = val.toLowerCase(); + applyMindSearch(); + }, 200); + } + + function clearMindSearch() { + document.getElementById('mindSearch').value = ''; + document.getElementById('mindSearchClear').style.display = 'none'; + state.mindSearchTerm = ''; + clearMindHighlight(); + } + + function applyMindSearch() { + var canvas = document.getElementById('mindCanvas'); + var term = state.mindSearchTerm; + if (!term || !state.mindData) { clearMindHighlight(); return; } + + var matchIds = new Set(); + state.mindData.nodes.forEach(function(n) { + var haystack = ((n.summary || '') + ' ' + (n.concepts || []).join(' ')).toLowerCase(); + if (haystack.indexOf(term) !== -1) matchIds.add(n.id); + }); + + d3.select(canvas).selectAll('circle.mind-node') + .attr('opacity', function(d) { return matchIds.has(d.id) ? 1 : 0.08; }) + .style('animation', function(d) { return matchIds.has(d.id) ? 'mindPulse 1.5s ease-in-out infinite' : 'none'; }); + d3.select(canvas).selectAll('line.mind-edge') + .attr('opacity', function(d) { + var sid = typeof d.source === 'object' ? d.source.id : d.source; + var tid = typeof d.target === 'object' ? d.target.id : d.target; + return (matchIds.has(sid) || matchIds.has(tid)) ? mindEdgeStyle(d.relation_type).opacity : 0.03; + }); + d3.select(canvas).selectAll('text.mind-label') + .attr('opacity', function(d) { return matchIds.has(d.id) ? 1 : 0.08; }); + } + + function applyMindNeighborhood(nodeId) { + var canvas = document.getElementById('mindCanvas'); + var neighborhood = getNeighborhood(nodeId, 2); + + d3.select(canvas).selectAll('circle.mind-node') + .attr('opacity', function(d) { return neighborhood.has(d.id) ? 1 : 0.08; }) + .attr('stroke', function(d) { return d.id === nodeId ? getGraphColor('--accent-cyan') : getGraphColor('--border-color'); }) + .attr('stroke-width', function(d) { return d.id === nodeId ? 2.5 : 1; }) + .style('animation', 'none'); + d3.select(canvas).selectAll('line.mind-edge') + .attr('opacity', function(d) { + var sid = typeof d.source === 'object' ? d.source.id : d.source; + var tid = typeof d.target === 'object' ? d.target.id : d.target; + return (neighborhood.has(sid) && neighborhood.has(tid)) ? mindEdgeStyle(d.relation_type).opacity : 0.03; + }); + d3.select(canvas).selectAll('text.mind-label') + .attr('opacity', function(d) { return neighborhood.has(d.id) ? 1 : 0.08; }); + } + + function clearMindHighlight() { + var canvas = document.getElementById('mindCanvas'); + d3.select(canvas).selectAll('circle.mind-node') + .attr('opacity', 1) + .attr('stroke', getGraphColor('--border-color')) + .attr('stroke-width', 1) + .style('animation', 'none'); + d3.select(canvas).selectAll('line.mind-edge') + .attr('opacity', function(d) { return mindEdgeStyle(d.relation_type).opacity; }); + d3.select(canvas).selectAll('text.mind-label').attr('opacity', 1); + } + + function updateMindColors() { + invalidateColorCache(); + var canvas = document.getElementById('mindCanvas'); + var color = mindColorScale(); + d3.select(canvas).selectAll('circle.mind-node') + .attr('fill', function(d) { return color(d); }); + // Rebuild legend with new colors + buildMindLegend(); + } + + function setMindView(view) { + state.mindView = view; + document.querySelectorAll('.mind-toggle').forEach(function(b) { + b.classList.toggle('active', b.getAttribute('data-mind-view') === view); + }); + state.mindLoaded = false; + loadMindGraph(); + } + + function onMindFilterChange() { + document.getElementById('mindSalienceVal').textContent = parseFloat(document.getElementById('mindSalience').value).toFixed(2); + document.getElementById('mindStrengthVal').textContent = parseFloat(document.getElementById('mindStrength').value).toFixed(2); + clearTimeout(_mindFilterTimer); + _mindFilterTimer = setTimeout(function() { + state.mindLoaded = false; + loadMindGraph(); + }, 300); + } + + function buildMindGraph(data) { + var canvas = document.getElementById('mindCanvas'); + // Remove existing SVG but keep overlays + var existingSvg = canvas.querySelector('svg'); + if (existingSvg) existingSvg.remove(); + + var rect = canvas.getBoundingClientRect(); + var w = rect.width || 800; + var h = rect.height || 500; + + var svg = d3.select(canvas).append('svg') + .attr('width', w).attr('height', h) + .style('position', 'absolute').style('top', '0').style('left', '0'); + + var g = svg.append('g'); + + // Zoom + var zoom = d3.zoom() + .scaleExtent([0.15, 5]) + .on('zoom', function(event) { g.attr('transform', event.transform); }); + svg.call(zoom); + // Click on canvas background to deselect + svg.on('click', function(event) { + if (event.target === svg.node()) { + closeMindDetail(); + clearMindSearch(); + } + }); + + var color = mindColorScale(); + var nodes = data.nodes; + var edges = data.edges; + + // Build adjacency + state.mindAdjacency = buildMindAdjacency(data); + + // Edges + var edgeG = g.append('g').attr('class', 'mind-edges'); + var link = edgeG.selectAll('line') + .data(edges).enter().append('line') + .attr('class', 'mind-edge') + .attr('stroke', function(d) { return mindEdgeStyle(d.relation_type).color(); }) + .attr('stroke-width', function(d) { return 1 + (d.strength || 0) * 3; }) + .attr('stroke-dasharray', function(d) { return mindEdgeStyle(d.relation_type).dash || null; }) + .attr('opacity', function(d) { return mindEdgeStyle(d.relation_type).opacity; }); + + // Nodes + var nodeG = g.append('g').attr('class', 'mind-nodes'); + var node = nodeG.selectAll('circle') + .data(nodes).enter().append('circle') + .attr('class', 'mind-node') + .attr('r', function(d) { return nodeRadius(d.salience); }) + .attr('fill', function(d) { return color(d); }) + .attr('stroke', getGraphColor('--border-color')) + .attr('stroke-width', 1) + .attr('cursor', 'pointer') + .style('transition', 'opacity 0.2s'); + + // Labels for high-salience nodes + var labelG = g.append('g').attr('class', 'mind-labels'); + var labels = labelG.selectAll('text') + .data(nodes.filter(function(d) { return d.salience > 0.6; })) + .enter().append('text') + .attr('class', 'mind-label') + .text(function(d) { var s = d.summary || ''; return s.length > 22 ? s.slice(0, 20) + '...' : s; }) + .attr('font-size', '9px') + .attr('fill', getGraphColor('--text-muted')) + .attr('text-anchor', 'middle') + .attr('pointer-events', 'none') + .attr('dy', function(d) { return nodeRadius(d.salience) + 12; }); + + // Tooltip + var tooltip = document.getElementById('mindTooltip'); + + node.on('mouseenter', function(event, d) { + var concepts = (d.concepts || []).slice(0, 5).join(', '); + tooltip.innerHTML = '
' + escapeHtml((d.summary || '').slice(0, 80)) + '
' + + '
' + + '' + (d.source || '') + '' + + 'salience: ' + (d.salience || 0).toFixed(2) + '' + + (concepts ? '' + escapeHtml(concepts) + '' : '') + + '
'; + tooltip.style.display = 'block'; + var cr = canvas.getBoundingClientRect(); + tooltip.style.left = (event.clientX - cr.left + 14) + 'px'; + tooltip.style.top = (event.clientY - cr.top + 14) + 'px'; + }) + .on('mousemove', function(event) { + var cr = canvas.getBoundingClientRect(); + var x = event.clientX - cr.left + 14; + var y = event.clientY - cr.top + 14; + // Keep tooltip in view + if (x + 300 > cr.width) x = event.clientX - cr.left - 310; + if (y + 100 > cr.height) y = event.clientY - cr.top - 80; + tooltip.style.left = x + 'px'; + tooltip.style.top = y + 'px'; + }) + .on('mouseleave', function() { tooltip.style.display = 'none'; }); + + // Click: neighborhood focus + detail + node.on('click', function(event, d) { + event.stopPropagation(); + applyMindNeighborhood(d.id); + showMindDetail(d); + }); + + // Drag + var drag = d3.drag() + .on('start', function(event, d) { + if (event.sourceEvent) event.sourceEvent.stopPropagation(); + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; d.fy = d.y; + }) + .on('drag', function(event, d) { d.fx = event.x; d.fy = event.y; }) + .on('end', function(event, d) { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; d.fy = null; + }); + node.call(drag); + + // Force simulation + var simulation = d3.forceSimulation(nodes) + .force('link', d3.forceLink(edges).id(function(d) { return d.id; }) + .distance(function(d) { return 60 + (1 - (d.strength || 0)) * 140; }) + .strength(function(d) { return 0.2 + (d.strength || 0) * 0.6; })) + .force('charge', d3.forceManyBody().strength(-120).distanceMax(350)) + .force('center', d3.forceCenter(w / 2, h / 2).strength(0.04)) + .force('collide', d3.forceCollide().radius(function(d) { return nodeRadius(d.salience) + 3; }).strength(0.7)) + .force('x', d3.forceX(w / 2).strength(0.02)) + .force('y', d3.forceY(h / 2).strength(0.02)) + .alphaDecay(0.015) + .velocityDecay(0.4) + .on('tick', function() { + link.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; }) + .attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; }); + node.attr('cx', function(d) { return d.x; }).attr('cy', function(d) { return d.y; }); + labels.attr('x', function(d) { return d.x; }).attr('y', function(d) { return d.y; }); + }); + + state.mindSimulation = simulation; + + // Resize observer + if (_mindResizeObserver) _mindResizeObserver.disconnect(); + _mindResizeObserver = new ResizeObserver(function() { + var r = canvas.getBoundingClientRect(); + if (r.width < 10 || r.height < 10) return; + svg.attr('width', r.width).attr('height', r.height); + simulation.force('center', d3.forceCenter(r.width / 2, r.height / 2).strength(0.04)); + simulation.force('x', d3.forceX(r.width / 2).strength(0.02)); + simulation.force('y', d3.forceY(r.height / 2).strength(0.02)); + simulation.alpha(0.1).restart(); + }); + _mindResizeObserver.observe(canvas); + + // Escape key to clear selection + function onMindEscape(e) { + if (e.key === 'Escape' && state.currentView === 'mind') { + closeMindDetail(); + clearMindSearch(); + } + } + // Remove previous listener before adding (avoid stacking) + document.removeEventListener('keydown', onMindEscape); + document.addEventListener('keydown', onMindEscape); + } + + async function loadMindGraph() { + var canvas = document.getElementById('mindCanvas'); + + // Show loading state + var existingSvg = canvas.querySelector('svg'); + if (existingSvg) existingSvg.remove(); + + // Stop existing simulation + if (state.mindSimulation) { state.mindSimulation.stop(); state.mindSimulation = null; } + + var view = state.mindView; + var limit = parseInt(document.getElementById('mindLimit').value) || 150; + var minSalience = parseFloat(document.getElementById('mindSalience').value) || 0; + var minStrength = parseFloat(document.getElementById('mindStrength').value) || 0; + + var url = '/graph?view=' + view + '&limit=' + limit; + if (minSalience > 0) url += '&min_salience=' + minSalience; + if (minStrength > 0) url += '&min_strength=' + minStrength; + + try { + var data = await fetchJSON(url); + state.mindData = data; + + // Remove any previous empty state + var prevEmpty = canvas.querySelector('.mind-empty'); + if (prevEmpty) prevEmpty.remove(); + + if (!data.nodes || data.nodes.length === 0) { + var emptyDiv = document.createElement('div'); + emptyDiv.className = 'mind-empty'; + emptyDiv.innerHTML = '
' + + '
No memories to visualize
' + + '
Associations form as your memory network grows
'; + canvas.appendChild(emptyDiv); + renderMindStats({ nodes: 0, edges: 0, clusters: 0, orphans: 0, avgDegree: '0.0' }); + state.mindLoaded = true; + return; + } + + var stats = computeMindStats(data); + renderMindStats(stats); + buildMindGraph(data); + buildMindLegend(); + state.mindLoaded = true; + } catch (err) { + var prevEmpty = canvas.querySelector('.mind-empty'); + if (prevEmpty) prevEmpty.remove(); + var errDiv = document.createElement('div'); + errDiv.className = 'mind-empty'; + errDiv.innerHTML = '
' + + '
Failed to load graph
' + + '
' + escapeHtml(err.message) + '
'; + canvas.appendChild(errDiv); + renderMindStats({ nodes: 0, edges: 0, clusters: 0, orphans: 0, avgDegree: '0.0' }); + } + } + // ── Init ── async function initializeApp() { loadStats();