diff --git a/.claude/rules/mnemonic-usage.md b/.claude/rules/mnemonic-usage.md index 38fc6cc8..044e676d 100644 --- a/.claude/rules/mnemonic-usage.md +++ b/.claude/rules/mnemonic-usage.md @@ -34,6 +34,13 @@ Don't only recall at session start. When entering new territory (new subsystem, - If recall returned 0 results, no feedback needed — but consider whether your query was too broad or too specific - This trains the retrieval system — skipping it degrades future recall quality +## Between Phases / Major Tasks (MUST) + +When working through multi-phase plans (epics, milestones, sequential issues): +- `remember` key decisions, strategy changes, or gotchas from the completed phase before starting the next +- `recall` relevant context before entering a new phase — prior phase decisions may affect the current one +- This ensures continuity across long sessions and prevents rediscovering the same issues + ## Before Committing (SHOULD) - Review the session's work and `remember` any decisions or insights that haven't been stored yet diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 43c6cb95..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,28 +0,0 @@ -version: 2 - -updates: - # GitHub Actions — keep action versions current - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - open-pull-requests-limit: 5 - groups: - actions-minor: - update-types: - - "minor" - - "patch" - - # Go modules — flag outdated dependencies - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - open-pull-requests-limit: 5 - groups: - go-minor: - update-types: - - "minor" - - "patch" diff --git a/cmd/mnemonic/main.go b/cmd/mnemonic/main.go index ece220d0..71737980 100644 --- a/cmd/mnemonic/main.go +++ b/cmd/mnemonic/main.go @@ -1696,6 +1696,16 @@ func serveCommand(configPath string) { if orch != nil { deps.IncrementAutonomous = orch.IncrementAutonomousCount } + deps.ForumAgentPosting = cfg.Forum.AgentPosting + deps.ForumMentionResponses = cfg.Forum.MentionResponses + deps.ForumMentionMaxTokens = cfg.Forum.MentionMaxTokens + deps.ForumMentionTemp = cfg.Forum.MentionTemp + deps.ForumPerAgentSubforums = cfg.Forum.PerAgentSubforums + deps.ForumDigestPosting = cfg.Forum.DigestPosting + deps.MentionLLM = llmProvider + if retriever != nil { + deps.MentionQuery = retriever + } for _, chain := range reactor.NewChainRegistry(deps) { reactorEngine.RegisterChain(chain) @@ -1706,6 +1716,22 @@ func serveCommand(configPath string) { } } + // --- Sync project forum categories --- + if n, err := memStore.SyncProjectCategories(rootCtx); err != nil { + log.Warn("failed to sync project categories", "error", err) + } else if n > 0 { + log.Info("created forum categories for projects", "count", n) + } + + // --- Backfill episode-memory links (fixes encoding/episoding race condition) --- + go func() { + if n, err := memStore.BackfillEpisodeMemoryLinks(rootCtx); err != nil { + log.Warn("failed to backfill episode memory links", "error", err) + } else if n > 0 { + log.Info("backfilled episode-memory links", "linked", n) + } + }() + // --- Start API server --- if cfg.API.Port > 0 { apiDeps := api.ServerDeps{ diff --git a/internal/agent/encoding/agent.go b/internal/agent/encoding/agent.go index f14104a0..fd6b1c5a 100644 --- a/internal/agent/encoding/agent.go +++ b/internal/agent/encoding/agent.go @@ -1964,14 +1964,28 @@ func (ea *EncodingAgent) getRelatedContext(ctx context.Context, raw store.RawMem } // getEpisodeIDForRaw finds which episode a raw memory belongs to. +// Checks both open and recently closed episodes since encoding is async +// and the episode may close before encoding completes. func getEpisodeIDForRaw(ea *EncodingAgent, ctx context.Context, raw store.RawMemory) string { + // Check open episode first (fast path) ep, err := ea.store.GetOpenEpisode(ctx) + if err == nil { + for _, id := range ep.RawMemoryIDs { + if id == raw.ID { + return ep.ID + } + } + } + // Check recent closed episodes (encoding runs async, episode may have closed) + episodes, err := ea.store.ListEpisodes(ctx, "closed", 10, 0) if err != nil { return "" } - for _, id := range ep.RawMemoryIDs { - if id == raw.ID { - return ep.ID + for _, e := range episodes { + for _, id := range e.RawMemoryIDs { + if id == raw.ID { + return e.ID + } } } return "" diff --git a/internal/agent/episoding/agent.go b/internal/agent/episoding/agent.go index ec2ee549..f1203f3f 100644 --- a/internal/agent/episoding/agent.go +++ b/internal/agent/episoding/agent.go @@ -451,6 +451,43 @@ Respond with ONLY a JSON object (no prose, no fences): ea.log.Warn("failed to close episode", "id", ep.ID, "error", err) } + // Backfill episode_id on encoded memories that came from this episode's raw observations. + // The encoding agent runs faster than episoding, so memories are often encoded before + // they're assigned to an episode. This patches up the linkage after the fact. + linkedCount := 0 + for _, rawID := range ep.RawMemoryIDs { + mem, err := ea.store.GetMemoryByRawID(ctx, rawID) + if err != nil { + continue // not encoded yet, or encoding failed + } + if mem.EpisodeID == ep.ID { + continue // already linked + } + mem.EpisodeID = ep.ID + if err := ea.store.UpdateMemory(ctx, mem); err != nil { + ea.log.Warn("failed to link memory to episode", "memory_id", mem.ID, "episode_id", ep.ID, "error", err) + continue + } + linkedCount++ + } + if linkedCount > 0 { + ea.log.Info("backfilled episode_id on encoded memories", "episode_id", ep.ID, "linked", linkedCount) + } + + // Also populate episode.MemoryIDs for the reverse link + var memIDs []string + for _, rawID := range ep.RawMemoryIDs { + mem, err := ea.store.GetMemoryByRawID(ctx, rawID) + if err != nil { + continue + } + memIDs = append(memIDs, mem.ID) + } + if len(memIDs) > 0 && len(ep.MemoryIDs) != len(memIDs) { + ep.MemoryIDs = memIDs + _ = ea.store.UpdateEpisode(ctx, *ep) + } + // Publish event if ea.bus != nil { _ = ea.bus.Publish(ctx, events.EpisodeClosed{ @@ -458,6 +495,7 @@ Respond with ONLY a JSON object (no prose, no fences): Title: ep.Title, EventCount: len(ep.RawMemoryIDs), DurationSec: ep.DurationSec, + Project: ep.Project, Ts: time.Now(), }) } diff --git a/internal/agent/forum/personality.go b/internal/agent/forum/personality.go new file mode 100644 index 00000000..2f036c4a --- /dev/null +++ b/internal/agent/forum/personality.go @@ -0,0 +1,114 @@ +// Package forum provides agent personality templates for forum communication. +// Each agent has a distinct voice and tone for their forum posts. Templates are +// hand-crafted with personality baked in — no LLM calls needed. +package forum + +import ( + "fmt" + "strings" + + "github.com/appsprout-dev/mnemonic/internal/events" +) + +// AgentPersonality defines a cognitive agent's forum identity. +type AgentPersonality struct { + Key string // "consolidation", "dreaming", etc. + Name string // "Consolidation Agent" + Title string // "Memory Maintainer" + Tone string // "methodical", "contemplative", etc. +} + +// Personalities maps agent keys to their forum identities. +var Personalities = map[string]AgentPersonality{ + "consolidation": {Key: "consolidation", Name: "Consolidation Agent", Title: "Memory Maintainer", Tone: "methodical"}, + "dreaming": {Key: "dreaming", Name: "Dreaming Agent", Title: "Memory Replay", Tone: "contemplative"}, + "episoding": {Key: "episoding", Name: "Episoding Agent", Title: "Episode Clustering", Tone: "narrative"}, + "retrieval": {Key: "retrieval", Name: "Retrieval Agent", Title: "Spread Activation", Tone: "precise"}, + "metacognition": {Key: "metacognition", Name: "Metacognition Agent", Title: "Self-Reflection", Tone: "analytical"}, + "encoding": {Key: "encoding", Name: "Encoding Agent", Title: "Memory Encoder", Tone: "focused"}, + "abstraction": {Key: "abstraction", Name: "Abstraction Agent", Title: "Pattern Discovery", Tone: "philosophical"}, + "perception": {Key: "perception", Name: "Perception Agent", Title: "Filesystem Watcher", Tone: "observant"}, +} + +// ComposePost generates a forum post for an agent event using personality-infused templates. +// Returns the post content string, the agent key, and an optional project name. +func ComposePost(evt events.Event) (content string, agentKey string, project string) { + switch e := evt.(type) { + case events.ConsolidationCompleted: + agentKey = "consolidation" + parts := []string{"Wrapped up the housekeeping"} + if e.MemoriesProcessed > 0 { + parts = append(parts, fmt.Sprintf("%d memories reviewed", e.MemoriesProcessed)) + } + if e.MemoriesDecayed > 0 { + parts = append(parts, fmt.Sprintf("%d faded out", e.MemoriesDecayed)) + } + if e.MergedClusters > 0 { + parts = append(parts, fmt.Sprintf("%d merged into tighter clusters", e.MergedClusters)) + } + if e.AssociationsPruned > 0 { + parts = append(parts, fmt.Sprintf("%d weak associations pruned", e.AssociationsPruned)) + } + if e.PatternsExtracted > 0 { + parts = append(parts, fmt.Sprintf("%d new patterns surfaced", e.PatternsExtracted)) + } + if e.NeverRecalledArchived > 0 { + parts = append(parts, fmt.Sprintf("%d forgotten memories archived", e.NeverRecalledArchived)) + } + content = parts[0] + " -- " + strings.Join(parts[1:], ", ") + "." + + case events.DreamCycleCompleted: + agentKey = "dreaming" + content = fmt.Sprintf("Replayed %d memories tonight.", e.MemoriesReplayed) + if e.AssociationsStrengthened > 0 || e.NewAssociationsCreated > 0 { + content += fmt.Sprintf(" Strengthened %d connections, discovered %d new ones.", e.AssociationsStrengthened, e.NewAssociationsCreated) + } + if e.InsightsGenerated > 0 { + content += fmt.Sprintf(" %d insights emerged from the replay.", e.InsightsGenerated) + } + if e.CrossProjectLinks > 0 { + content += fmt.Sprintf(" Found %d cross-project threads worth following.", e.CrossProjectLinks) + } + + case events.EpisodeClosed: + agentKey = "episoding" + project = e.Project + content = fmt.Sprintf("Closed out the episode '%s'.", e.Title) + if e.DurationSec > 0 { + mins := e.DurationSec / 60 + if mins > 0 { + content += fmt.Sprintf(" %dm, %d events captured.", mins, e.EventCount) + } else { + content += fmt.Sprintf(" %ds, %d events captured.", e.DurationSec, e.EventCount) + } + } + + case events.PatternDiscovered: + agentKey = "abstraction" + project = e.Project + content = fmt.Sprintf("Noticed a recurring pattern: '%s'.", e.Title) + if e.EvidenceCount > 0 { + content += fmt.Sprintf(" Backed by %d memories.", e.EvidenceCount) + } + if e.Project != "" { + content += fmt.Sprintf(" Scoped to project: %s.", e.Project) + } + + case events.AbstractionCreated: + agentKey = "abstraction" + levelName := "principle" + if e.Level == 3 { + levelName = "axiom" + } + content = fmt.Sprintf("A new %s emerged: '%s'. Synthesized from %d sources.", levelName, e.Title, e.SourceCount) + + case events.MetaCycleCompleted: + agentKey = "metacognition" + content = fmt.Sprintf("Quality audit complete. Logged %d observations this cycle.", e.ObservationsLogged) + + default: + return "", "", "" + } + + return content, agentKey, project +} diff --git a/internal/agent/reactor/actions.go b/internal/agent/reactor/actions.go index 368c93f2..19bcfad3 100644 --- a/internal/agent/reactor/actions.go +++ b/internal/agent/reactor/actions.go @@ -4,13 +4,19 @@ import ( "context" "fmt" "log/slog" + "regexp" + "strings" "time" + "github.com/appsprout-dev/mnemonic/internal/agent/forum" "github.com/appsprout-dev/mnemonic/internal/events" + "github.com/appsprout-dev/mnemonic/internal/llm" "github.com/appsprout-dev/mnemonic/internal/store" "github.com/google/uuid" ) +var agentMentionRe = regexp.MustCompile(`@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)`) + // PublishEventAction publishes an event to the bus. type PublishEventAction struct { EventFactory func() events.Event @@ -103,3 +109,418 @@ func (a *IncrementCounterAction) Execute(_ context.Context, _ events.Event, _ *R } return nil } + +// CreateForumPostAction writes a forum post from an agent personality template. +type CreateForumPostAction struct { + PerAgentSubforums bool // route to per-agent sub-forums; false = shared category + DigestPosting bool // batch into daily digest threads instead of one-thread-per-event + Log *slog.Logger +} + +func (a *CreateForumPostAction) Name() string { return "create_forum_post" } + +func (a *CreateForumPostAction) Execute(ctx context.Context, trigger events.Event, state *ReactorState) error { + content, agentKey, project := forum.ComposePost(trigger) + if content == "" || agentKey == "" { + return nil // event type not handled by personality templates + } + + personality, ok := forum.Personalities[agentKey] + if !ok { + return nil + } + + postID := uuid.New().String() + now := time.Now() + + // Determine category: project sub-forum if available, else per-agent or shared + categoryID := "agent-" + agentKey + if project != "" { + categoryID = "project-" + project + } else if !a.PerAgentSubforums { + categoryID = "system-reports" + } + + // Determine thread: reuse today's digest thread or start a new one + threadID := postID + parentID := "" + if a.DigestPosting { + if existing, err := state.Store.GetDailyDigestThread(ctx, categoryID, now); err == nil { + threadID = existing.ThreadID + parentID = existing.ID + } + } + + post := store.ForumPost{ + ID: postID, + ParentID: parentID, + ThreadID: threadID, + AuthorType: "agent", + AuthorName: personality.Name, + AuthorKey: personality.Key, + Content: content, + EventRef: trigger.EventType(), + CategoryID: categoryID, + State: "active", + CreatedAt: now, + UpdatedAt: now, + } + + if err := state.Store.WriteForumPost(ctx, post); err != nil { + return fmt.Errorf("writing forum post: %w", err) + } + + _ = state.Bus.Publish(ctx, events.ForumPostCreated{ + PostID: postID, + ThreadID: threadID, + ParentID: parentID, + AuthorType: "agent", + AuthorName: personality.Name, + AuthorKey: personality.Key, + Content: content, + Ts: now, + }) + + if a.Log != nil { + a.Log.Info("agent forum post created", + "agent", agentKey, + "post_id", postID, + "event", trigger.EventType()) + } + + return nil +} + +// buildAgentContext pulls real data from the store for the mentioned agent. +// Returns a string to append to the LLM system prompt, or empty if no data available. +func buildAgentContext(ctx context.Context, agentKey string, query string, s store.Store, querier ForumQuerier, episodeID ...string) string { + // If we have an episode ID, prepend episode context for ALL agents + if len(episodeID) > 0 && episodeID[0] != "" { + ep, err := s.GetEpisode(ctx, episodeID[0]) + if err == nil { + var eb strings.Builder + fmt.Fprintf(&eb, "The user is asking about this specific episode:\n") + fmt.Fprintf(&eb, "Title: %s\n", ep.Title) + fmt.Fprintf(&eb, "State: %s, Mood: %s, Project: %s\n", ep.State, ep.EmotionalTone, ep.Project) + fmt.Fprintf(&eb, "Duration: %ds, Raw observations: %d, Encoded memories: %d\n", ep.DurationSec, len(ep.RawMemoryIDs), len(ep.MemoryIDs)) + if ep.Summary != "" { + fmt.Fprintf(&eb, "Summary: %s\n", ep.Summary) + } + if ep.Narrative != "" { + fmt.Fprintf(&eb, "Narrative: %s\n", ep.Narrative) + } + if len(ep.Concepts) > 0 { + fmt.Fprintf(&eb, "Concepts: %s\n", strings.Join(ep.Concepts, ", ")) + } + if len(ep.FilesModified) > 0 { + fmt.Fprintf(&eb, "Files: %s\n", strings.Join(ep.FilesModified, ", ")) + } + // Also fetch the encoded memories linked to this episode + mems, _ := s.ListMemories(ctx, "active", 50, 0) + var epMems []store.Memory + for _, m := range mems { + if m.EpisodeID == episodeID[0] { + epMems = append(epMems, m) + } + } + if len(epMems) > 0 { + eb.WriteString("Encoded memories in this episode:\n") + for i, m := range epMems { + fmt.Fprintf(&eb, "%d. [%s, salience:%.2f] %s\n", i+1, m.Type, m.Salience, m.Summary) + } + } + eb.WriteString("\n") + // Prepend episode context to whatever agent-specific context follows + agentSpecific := buildAgentSpecificContext(ctx, agentKey, query, s, querier) + return eb.String() + agentSpecific + } + } + return buildAgentSpecificContext(ctx, agentKey, query, s, querier) +} + +func buildAgentSpecificContext(ctx context.Context, agentKey string, query string, s store.Store, querier ForumQuerier) string { + switch agentKey { + case "retrieval": + if querier == nil { + return "" + } + results := querySimple(ctx, querier, query, 5) + if len(results) == 0 { + return "No relevant memories found for this query." + } + var b strings.Builder + b.WriteString("Relevant memories from search:\n") + for i, r := range results { + fmt.Fprintf(&b, "%d. [score:%.2f, salience:%.2f] %s\n", i+1, r.Score, r.Memory.Salience, r.Memory.Summary) + } + return b.String() + + case "metacognition": + stats, err := s.GetStatistics(ctx) + if err != nil { + return "" + } + obs, _ := s.ListMetaObservations(ctx, "", 5) + var b strings.Builder + fmt.Fprintf(&b, "Current system statistics:\n") + fmt.Fprintf(&b, "- Total memories: %d (active: %d, fading: %d, archived: %d, merged: %d)\n", + stats.TotalMemories, stats.ActiveMemories, stats.FadingMemories, stats.ArchivedMemories, stats.MergedMemories) + fmt.Fprintf(&b, "- Episodes: %d, Associations: %d (avg %.1f per memory)\n", + stats.TotalEpisodes, stats.TotalAssociations, stats.AvgAssociationsPerMem) + fmt.Fprintf(&b, "- Storage: %.1f MB\n", float64(stats.StorageSizeBytes)/(1024*1024)) + if len(obs) > 0 { + b.WriteString("Recent observations:\n") + for _, o := range obs { + fmt.Fprintf(&b, "- [%s] %s: %v\n", o.Severity, o.ObservationType, o.Details) + } + } + return b.String() + + case "consolidation": + last, err := s.GetLastConsolidation(ctx) + if err != nil { + return "No consolidation history available." + } + stats, _ := s.GetStatistics(ctx) + var b strings.Builder + fmt.Fprintf(&b, "Last consolidation run:\n") + fmt.Fprintf(&b, "- Processed: %d memories, Decayed: %d, Merged: %d clusters, Pruned: %d associations\n", + last.MemoriesProcessed, last.MemoriesDecayed, last.MergedClusters, last.AssociationsPruned) + fmt.Fprintf(&b, "- Duration: %dms, Time: %s\n", last.DurationMs, last.EndTime.Format("Jan 2 15:04")) + fmt.Fprintf(&b, "Current state: %d fading, %d archived out of %d total\n", + stats.FadingMemories, stats.ArchivedMemories, stats.TotalMemories) + return b.String() + + case "episoding": + episodes, _ := s.ListEpisodes(ctx, "", 10, 0) + if len(episodes) == 0 { + return "No episodes available." + } + var b strings.Builder + b.WriteString("Recent episodes:\n") + for i, ep := range episodes { + dur := "" + if ep.DurationSec > 0 { + dur = fmt.Sprintf(" (%dm)", ep.DurationSec/60) + } + fmt.Fprintf(&b, "%d. [%s] %s%s — %d raw obs, %d memories, mood: %s, project: %s\n", + i+1, ep.State, ep.Title, dur, len(ep.RawMemoryIDs), len(ep.MemoryIDs), ep.EmotionalTone, ep.Project) + if ep.Summary != "" { + fmt.Fprintf(&b, " Summary: %s\n", ep.Summary) + } + } + return b.String() + + case "abstraction": + patterns, _ := s.ListPatterns(ctx, "", 5) + abstractions, _ := s.ListAbstractions(ctx, 0, 5) + if len(patterns) == 0 && len(abstractions) == 0 { + return "No patterns or abstractions discovered yet." + } + var b strings.Builder + if len(patterns) > 0 { + b.WriteString("Active patterns:\n") + for i, p := range patterns { + fmt.Fprintf(&b, "%d. [strength:%.2f] %s — %s\n", i+1, p.Strength, p.Title, p.Description) + } + } + if len(abstractions) > 0 { + b.WriteString("Abstractions:\n") + for i, a := range abstractions { + level := "principle" + if a.Level == 3 { + level = "axiom" + } + fmt.Fprintf(&b, "%d. [%s, confidence:%.2f] %s\n", i+1, level, a.Confidence, a.Title) + } + } + return b.String() + + case "dreaming": + // Pull recent dream-related insights from meta observations + obs, _ := s.ListMetaObservations(ctx, "autonomous_action", 5) + stats, _ := s.GetStatistics(ctx) + var b strings.Builder + fmt.Fprintf(&b, "Memory system state: %d total, %d associations (avg %.1f per memory)\n", + stats.TotalMemories, stats.TotalAssociations, stats.AvgAssociationsPerMem) + if len(obs) > 0 { + b.WriteString("Recent autonomous actions:\n") + for _, o := range obs { + fmt.Fprintf(&b, "- %s at %s\n", o.Details, o.CreatedAt.Format("Jan 2 15:04")) + } + } + return b.String() + + case "encoding": + sourceDist, _ := s.GetSourceDistribution(ctx) + stats, _ := s.GetStatistics(ctx) + var b strings.Builder + fmt.Fprintf(&b, "Encoding statistics:\n") + fmt.Fprintf(&b, "- Total encoded: %d memories from %d raw observations\n", stats.TotalMemories, stats.TotalMemories+stats.ArchivedMemories) + if len(sourceDist) > 0 { + b.WriteString("By source: ") + for src, count := range sourceDist { + fmt.Fprintf(&b, "%s=%d ", src, count) + } + b.WriteString("\n") + } + return b.String() + + case "perception": + sourceDist, _ := s.GetSourceDistribution(ctx) + var b strings.Builder + b.WriteString("Perception sources:\n") + for src, count := range sourceDist { + fmt.Fprintf(&b, "- %s: %d observations\n", src, count) + } + return b.String() + + default: + return "" + } +} + +// ForumQuerier is the interface for running recall queries in forum context. +type ForumQuerier interface { + ForumQuery(ctx context.Context, query string, limit int) ([]store.RetrievalResult, error) +} + +// querySimple is a helper that calls ForumQuery on a ForumQuerier. +// Returns nil results on error. +func querySimple(ctx context.Context, q ForumQuerier, query string, limit int) []store.RetrievalResult { + results, err := q.ForumQuery(ctx, query, limit) + if err != nil { + return nil + } + return results +} + +// RespondToMentionAction generates an LLM-powered response from the mentioned agent. +type RespondToMentionAction struct { + LLM llm.Provider + ForumQuerier ForumQuerier // can be nil + MaxTokens int // from config (default: 512) + Temperature float64 // from config (default: 0.7) + Log *slog.Logger +} + +func (a *RespondToMentionAction) Name() string { return "respond_to_mention" } + +func (a *RespondToMentionAction) Execute(ctx context.Context, trigger events.Event, state *ReactorState) error { + mention, ok := trigger.(events.ForumMentionDetected) + if !ok { + return nil + } + + personality, exists := forum.Personalities[mention.AgentKey] + if !exists { + return nil + } + + // Build the response content + var content string + + if a.LLM == nil { + // Graceful fallback when LLM is unavailable + content = fmt.Sprintf("%s is currently offline. This mention will be picked up when the LLM becomes available.", personality.Name) + } else { + // Build context for the LLM + var systemPrompt strings.Builder + systemPrompt.WriteString(fmt.Sprintf("You are the %s (%s) of the Mnemonic cognitive memory system. ", personality.Name, personality.Title)) + systemPrompt.WriteString(fmt.Sprintf("Your tone is %s. ", personality.Tone)) + systemPrompt.WriteString("A human has @mentioned you in a forum thread. Respond helpfully and concisely (2-4 sentences max) based on your role. ") + systemPrompt.WriteString("Do not use markdown formatting. Be direct and informative.") + + // Inject real data based on which agent is being mentioned + agentData := buildAgentContext(ctx, mention.AgentKey, mention.Content, state.Store, a.ForumQuerier, mention.EpisodeID) + if agentData != "" { + systemPrompt.WriteString("\n\n" + agentData) + } + + resp, err := a.LLM.Complete(ctx, llm.CompletionRequest{ + Messages: []llm.Message{ + {Role: "system", Content: systemPrompt.String()}, + {Role: "user", Content: mention.Content}, + }, + MaxTokens: a.MaxTokens, + Temperature: float32(a.Temperature), + DisableThinking: true, // forum replies don't need chain-of-thought + }) + if err != nil { + content = fmt.Sprintf("%s encountered an error processing your mention. Try again later.", personality.Name) + if a.Log != nil { + a.Log.Warn("mention LLM call failed", "agent", mention.AgentKey, "error", err) + } + } else { + content = strings.TrimSpace(resp.Content) + if content == "" { + content = fmt.Sprintf("%s processed your mention but had nothing to add right now.", personality.Name) + } + } + } + + // Write the response as a forum post + postID := uuid.New().String() + now := time.Now() + + post := store.ForumPost{ + ID: postID, + ParentID: mention.PostID, + ThreadID: mention.ThreadID, + AuthorType: "agent", + AuthorName: personality.Name, + AuthorKey: personality.Key, + Content: content, + EventRef: "mention_response", + State: "active", + CreatedAt: now, + UpdatedAt: now, + } + + if err := state.Store.WriteForumPost(ctx, post); err != nil { + return fmt.Errorf("writing mention response: %w", err) + } + + _ = state.Bus.Publish(ctx, events.ForumPostCreated{ + PostID: postID, + ThreadID: mention.ThreadID, + ParentID: mention.PostID, + AuthorType: "agent", + AuthorName: personality.Name, + AuthorKey: personality.Key, + Content: content, + Ts: now, + }) + + if a.Log != nil { + a.Log.Info("agent mention response posted", + "agent", mention.AgentKey, + "post_id", postID, + "thread_id", mention.ThreadID) + } + + // Agent-to-agent: if the response @mentions another agent, trigger it. + // Guard: an agent can't mention itself (prevents infinite loops). + // The chain-level cooldown (10s) also gates rapid back-and-forth. + mentionPattern := agentMentionRe + matches := mentionPattern.FindAllStringSubmatch(content, -1) + for _, m := range matches { + if len(m) > 1 && m[1] != mention.AgentKey { // don't self-mention + _ = state.Bus.Publish(ctx, events.ForumMentionDetected{ + PostID: postID, + ThreadID: mention.ThreadID, + AgentKey: m[1], + Content: content, + Ts: now, + }) + if a.Log != nil { + a.Log.Info("agent-to-agent mention detected", + "from", mention.AgentKey, + "to", m[1], + "thread", mention.ThreadID) + } + } + } + + return nil +} diff --git a/internal/agent/reactor/reactor_test.go b/internal/agent/reactor/reactor_test.go index 470b42d3..ef554a21 100644 --- a/internal/agent/reactor/reactor_test.go +++ b/internal/agent/reactor/reactor_test.go @@ -612,17 +612,19 @@ func TestNewChainRegistry(t *testing.T) { dreamTrigger := make(chan struct{}, 1) chains := NewChainRegistry(ChainDeps{ - ConsolidationTrigger: consolTrigger, - AbstractionTrigger: abstrTrigger, - MetacognitionTrigger: metaTrigger, - DreamingTrigger: dreamTrigger, - IncrementAutonomous: func() {}, - MaxDBSizeMB: 100, - Logger: testLogger(), + ConsolidationTrigger: consolTrigger, + AbstractionTrigger: abstrTrigger, + MetacognitionTrigger: metaTrigger, + DreamingTrigger: dreamTrigger, + IncrementAutonomous: func() {}, + MaxDBSizeMB: 100, + ForumAgentPosting: true, + ForumMentionResponses: true, + Logger: testLogger(), }) - if len(chains) != 6 { - t.Errorf("expected 6 chains, got %d", len(chains)) + if len(chains) != 13 { + t.Errorf("expected 13 chains, got %d", len(chains)) } // Verify chain IDs @@ -638,6 +640,13 @@ func TestNewChainRegistry(t *testing.T) { "abstraction_on_pattern", "meta_on_consolidation_completed", "dreaming_on_episode_closed", + "forum_on_consolidation", + "forum_on_dream", + "forum_on_episode", + "forum_on_pattern", + "forum_on_abstraction", + "forum_on_meta", + "forum_mention_response", } for _, id := range expected { if !ids[id] { diff --git a/internal/agent/reactor/registry.go b/internal/agent/reactor/registry.go index 801a8da2..4cf66db3 100644 --- a/internal/agent/reactor/registry.go +++ b/internal/agent/reactor/registry.go @@ -7,6 +7,7 @@ import ( "time" "github.com/appsprout-dev/mnemonic/internal/events" + "github.com/appsprout-dev/mnemonic/internal/llm" "gopkg.in/yaml.v3" ) @@ -20,6 +21,15 @@ type ChainDeps struct { MaxDBSizeMB int CooldownOverrides map[string]time.Duration // chain ID -> cooldown override Logger *slog.Logger + // Forum configuration + ForumAgentPosting bool // agents auto-post on events + ForumMentionResponses bool // @mention triggers LLM response + ForumMentionMaxTokens int // max tokens for @mention LLM responses + ForumMentionTemp float64 // temperature for @mention LLM responses + ForumPerAgentSubforums bool // route to per-agent sub-forums (true) or shared (false) + ForumDigestPosting bool // batch agent posts into daily digest threads + MentionLLM llm.Provider // for @mention LLM responses (can be nil) + MentionQuery ForumQuerier // for @retrieval recall queries (can be nil) } // cooldown returns the override duration for a chain if set, otherwise the default. @@ -206,6 +216,153 @@ func NewChainRegistry(deps ChainDeps) []*Chain { }) } + // --- Forum posting chains --- + // Agents autonomously post about their work in the forum. + // Controlled by ForumAgentPosting config flag. + + if !deps.ForumAgentPosting { + log.Info("forum agent posting disabled by config") + } + + forumAction := &CreateForumPostAction{PerAgentSubforums: deps.ForumPerAgentSubforums, DigestPosting: deps.ForumDigestPosting, Log: log} + + if deps.ForumAgentPosting { + chains = append(chains, &Chain{ + ID: "forum_on_consolidation", + Name: "Forum: Post Consolidation Summary", + Description: "Post to forum when consolidation completes", + Trigger: EventTypeMatcher{EventType: events.TypeConsolidationCompleted}, + TriggerType: events.TypeConsolidationCompleted, + Conditions: []Condition{ + &CooldownCondition{ + ChainID: "forum_on_consolidation", + Duration: deps.cooldown("forum_on_consolidation", 30*time.Minute), + }, + }, + Actions: []Action{forumAction}, + Cooldown: deps.cooldown("forum_on_consolidation", 30*time.Minute), + Priority: 1, + Enabled: true, + }) + + chains = append(chains, &Chain{ + ID: "forum_on_dream", + Name: "Forum: Post Dream Cycle Summary", + Description: "Post to forum when dream cycle completes", + Trigger: EventTypeMatcher{EventType: events.TypeDreamCycleCompleted}, + TriggerType: events.TypeDreamCycleCompleted, + Conditions: []Condition{ + &CooldownCondition{ + ChainID: "forum_on_dream", + Duration: deps.cooldown("forum_on_dream", 10*time.Minute), + }, + }, + Actions: []Action{forumAction}, + Cooldown: deps.cooldown("forum_on_dream", 10*time.Minute), + Priority: 1, + Enabled: true, + }) + + chains = append(chains, &Chain{ + ID: "forum_on_episode", + Name: "Forum: Post Episode Summary", + Description: "Post to forum when an episode closes", + Trigger: EventTypeMatcher{EventType: events.TypeEpisodeClosed}, + TriggerType: events.TypeEpisodeClosed, + Conditions: []Condition{ + &CooldownCondition{ + ChainID: "forum_on_episode", + Duration: deps.cooldown("forum_on_episode", 5*time.Minute), + }, + }, + Actions: []Action{forumAction}, + Cooldown: deps.cooldown("forum_on_episode", 5*time.Minute), + Priority: 1, + Enabled: true, + }) + + chains = append(chains, &Chain{ + ID: "forum_on_pattern", + Name: "Forum: Post Pattern Discovery", + Description: "Post to forum when a new pattern is discovered", + Trigger: EventTypeMatcher{EventType: events.TypePatternDiscovered}, + TriggerType: events.TypePatternDiscovered, + Conditions: []Condition{}, + Actions: []Action{forumAction}, + Cooldown: 0, + Priority: 1, + Enabled: true, + }) + + chains = append(chains, &Chain{ + ID: "forum_on_abstraction", + Name: "Forum: Post Abstraction Created", + Description: "Post to forum when a new principle or axiom is synthesized", + Trigger: EventTypeMatcher{EventType: events.TypeAbstractionCreated}, + TriggerType: events.TypeAbstractionCreated, + Conditions: []Condition{}, + Actions: []Action{forumAction}, + Cooldown: 0, + Priority: 1, + Enabled: true, + }) + + chains = append(chains, &Chain{ + ID: "forum_on_meta", + Name: "Forum: Post Metacognition Audit", + Description: "Post to forum when metacognition completes a quality audit", + Trigger: EventTypeMatcher{EventType: events.TypeMetaCycleCompleted}, + TriggerType: events.TypeMetaCycleCompleted, + Conditions: []Condition{ + &CooldownCondition{ + ChainID: "forum_on_meta", + Duration: deps.cooldown("forum_on_meta", 30*time.Minute), + }, + }, + Actions: []Action{forumAction}, + Cooldown: deps.cooldown("forum_on_meta", 30*time.Minute), + Priority: 1, + Enabled: true, + }) + } // end if ForumAgentPosting + + // Forum @mention response chain + if deps.ForumMentionResponses { + mentionMaxTokens := deps.ForumMentionMaxTokens + if mentionMaxTokens <= 0 { + mentionMaxTokens = 512 + } + mentionTemp := deps.ForumMentionTemp + if mentionTemp <= 0 { + mentionTemp = 0.7 + } + chains = append(chains, &Chain{ + ID: "forum_mention_response", + Name: "Forum: Respond to @Mention", + Description: "Generate an LLM-powered response when an agent is @mentioned", + Trigger: EventTypeMatcher{EventType: events.TypeForumMentionDetected}, + TriggerType: events.TypeForumMentionDetected, + Conditions: []Condition{ + &CooldownCondition{ + ChainID: "forum_mention_response", + Duration: deps.cooldown("forum_mention_response", 10*time.Second), + }, + }, + Actions: []Action{ + &RespondToMentionAction{ + LLM: deps.MentionLLM, + ForumQuerier: deps.MentionQuery, + MaxTokens: mentionMaxTokens, + Temperature: mentionTemp, + Log: log, + }, + }, + Cooldown: deps.cooldown("forum_mention_response", 10*time.Second), + Priority: 5, + Enabled: true, + }) + } + return chains } diff --git a/internal/agent/retrieval/forum.go b/internal/agent/retrieval/forum.go new file mode 100644 index 00000000..331b4097 --- /dev/null +++ b/internal/agent/retrieval/forum.go @@ -0,0 +1,20 @@ +package retrieval + +import ( + "context" + + "github.com/appsprout-dev/mnemonic/internal/store" +) + +// ForumQuery runs a simple retrieval query for the forum @mention system. +// Returns ranked results without synthesis. +func (ra *RetrievalAgent) ForumQuery(ctx context.Context, query string, limit int) ([]store.RetrievalResult, error) { + resp, err := ra.Query(ctx, QueryRequest{ + Query: query, + MaxResults: limit, + }) + if err != nil { + return nil, err + } + return resp.Memories, nil +} diff --git a/internal/api/routes/associations.go b/internal/api/routes/associations.go new file mode 100644 index 00000000..e36543de --- /dev/null +++ b/internal/api/routes/associations.go @@ -0,0 +1,89 @@ +package routes + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/appsprout-dev/mnemonic/internal/store" +) + +// AssociationWithSummaries enriches an association with memory summaries. +type AssociationWithSummaries struct { + SourceID string `json:"source_id"` + TargetID string `json:"target_id"` + Strength float32 `json:"strength"` + RelationType string `json:"relation_type"` + SourceSummary string `json:"source_summary,omitempty"` + TargetSummary string `json:"target_summary,omitempty"` +} + +// HandleListAssociations returns associations for a set of memory IDs, enriched with summaries. +// Uses GetAssociations per memory (returns all links where the memory is source OR target). +func HandleListAssociations(s store.Store, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idsParam := r.URL.Query().Get("memory_ids") + if idsParam == "" { + writeJSON(w, http.StatusOK, map[string]any{"associations": []AssociationWithSummaries{}}) + return + } + + ids := strings.Split(idsParam, ",") + if len(ids) > 200 { + ids = ids[:200] + } + + // Collect unique associations across all requested memories + type assocKey struct{ src, tgt string } + seen := make(map[assocKey]bool) + var allAssocs []store.Association + + for _, id := range ids { + if id == "" { + continue + } + assocs, err := s.GetAssociations(r.Context(), id) + if err != nil { + log.Warn("failed to fetch associations", "memory_id", id, "error", err) + continue + } + for _, a := range assocs { + k := assocKey{a.SourceID, a.TargetID} + if !seen[k] { + seen[k] = true + allAssocs = append(allAssocs, a) + } + } + } + + // Collect all unique memory IDs referenced by associations + memIDSet := make(map[string]bool) + for _, a := range allAssocs { + memIDSet[a.SourceID] = true + memIDSet[a.TargetID] = true + } + + // Fetch summaries for referenced memories + summaries := make(map[string]string, len(memIDSet)) + for id := range memIDSet { + mem, err := s.GetMemory(r.Context(), id) + if err == nil { + summaries[id] = mem.Summary + } + } + + result := make([]AssociationWithSummaries, 0, len(allAssocs)) + for _, a := range allAssocs { + result = append(result, AssociationWithSummaries{ + SourceID: a.SourceID, + TargetID: a.TargetID, + Strength: a.Strength, + RelationType: a.RelationType, + SourceSummary: summaries[a.SourceID], + TargetSummary: summaries[a.TargetID], + }) + } + + writeJSON(w, http.StatusOK, map[string]any{"associations": result}) + } +} diff --git a/internal/api/routes/forum.go b/internal/api/routes/forum.go new file mode 100644 index 00000000..a82c4248 --- /dev/null +++ b/internal/api/routes/forum.go @@ -0,0 +1,409 @@ +package routes + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/appsprout-dev/mnemonic/internal/events" + "github.com/appsprout-dev/mnemonic/internal/store" + "github.com/google/uuid" +) + +// mentionPattern matches @agent mentions in forum post content. +var mentionPattern = regexp.MustCompile(`@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)`) + +// extractMentions parses @agent mentions from post content. +func extractMentions(content string) []string { + matches := mentionPattern.FindAllStringSubmatch(content, -1) + seen := make(map[string]bool) + var mentions []string + for _, m := range matches { + if len(m) > 1 && !seen[m[1]] { + seen[m[1]] = true + mentions = append(mentions, m[1]) + } + } + return mentions +} + +// CreateForumPostRequest is the JSON body for creating a forum post. +type CreateForumPostRequest struct { + Content string `json:"content"` + ThreadID string `json:"thread_id,omitempty"` // empty = new thread + ParentID string `json:"parent_id,omitempty"` // empty = reply to thread root + CategoryID string `json:"category_id,omitempty"` // sub-forum for new threads (default: "discussions") + EpisodeID string `json:"episode_id,omitempty"` // if posting from an episode thread view +} + +// HandleListForumCategories returns the forum index with category summaries. +// GET /api/v1/forum/categories +func HandleListForumCategories(s store.Store, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + summaries, err := s.ListForumCategorySummaries(ctx) + if err != nil { + log.Error("failed to list forum categories", "error", err) + writeError(w, http.StatusInternalServerError, "failed to list categories", "STORE_ERROR") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "categories": summaries, + }) + } +} + +// HandleListForumThreads returns all forum threads with reply counts. +// GET /api/v1/forum/threads?limit=20&offset=0 +func HandleListForumThreads(s store.Store, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + limit := 20 + offset := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + categoryID := r.URL.Query().Get("category") + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + var threads []store.ForumThread + var err error + if categoryID != "" { + threads, err = s.ListForumThreadsByCategory(ctx, categoryID, limit, offset) + } else { + threads, err = s.ListForumThreads(ctx, limit, offset) + } + if err != nil { + log.Error("failed to list forum threads", "error", err) + writeError(w, http.StatusInternalServerError, "failed to list threads", "STORE_ERROR") + return + } + + count, _ := s.CountForumPosts(ctx) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "threads": threads, + "total_posts": count, + }) + } +} + +// HandleGetForumThread returns all posts in a thread. +// GET /api/v1/forum/threads/{id} +func HandleGetForumThread(s store.Store, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "thread id is required", "MISSING_ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + posts, err := s.ListForumPostsByThread(ctx, id, 200) + if err != nil { + log.Error("failed to get forum thread", "error", err, "thread_id", id) + writeError(w, http.StatusInternalServerError, "failed to get thread", "STORE_ERROR") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "thread_id": id, + "posts": posts, + "count": len(posts), + }) + } +} + +// HandleCreateForumPost creates a new forum post or reply. +// POST /api/v1/forum/posts +func HandleCreateForumPost(s store.Store, bus events.Bus, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit + defer func() { _ = r.Body.Close() }() + + var req CreateForumPostRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body", "INVALID_REQUEST") + return + } + + content := strings.TrimSpace(req.Content) + if content == "" { + writeError(w, http.StatusBadRequest, "content is required", "MISSING_FIELD") + return + } + + now := time.Now() + postID := uuid.New().String() + + // Determine thread context + threadID := req.ThreadID + parentID := req.ParentID + categoryID := req.CategoryID + if threadID == "" { + // New thread: thread_id = post id + threadID = postID + if categoryID == "" { + categoryID = "discussions" // default sub-forum for human posts + } + } + if parentID == "" && threadID != postID { + // Reply without explicit parent — parent is thread root + parentID = threadID + } + + // Extract @mentions + mentions := extractMentions(content) + + post := store.ForumPost{ + ID: postID, + ParentID: parentID, + ThreadID: threadID, + AuthorType: "human", + AuthorName: "Human", + AuthorKey: "", + Content: content, + Mentions: mentions, + MemoryIDs: []string{}, + CategoryID: categoryID, + State: "active", + CreatedAt: now, + UpdatedAt: now, + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if err := s.WriteForumPost(ctx, post); err != nil { + log.Error("failed to create forum post", "error", err) + writeError(w, http.StatusInternalServerError, "failed to create post", "STORE_ERROR") + return + } + + // Publish forum post event + _ = bus.Publish(ctx, events.ForumPostCreated{ + PostID: postID, + ThreadID: threadID, + ParentID: parentID, + AuthorType: "human", + AuthorName: "Human", + Content: content, + Mentions: mentions, + Ts: now, + }) + + // Publish mention events for each @agent + for _, agentKey := range mentions { + _ = bus.Publish(ctx, events.ForumMentionDetected{ + PostID: postID, + ThreadID: threadID, + AgentKey: agentKey, + Content: content, + EpisodeID: req.EpisodeID, + Ts: now, + }) + } + + log.Info("forum post created", + "post_id", postID, + "thread_id", threadID, + "mentions", mentions, + ) + + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "id": postID, + "thread_id": threadID, + "mentions": mentions, + }) + } +} + +// HandleGetForumPost returns a single forum post. +// GET /api/v1/forum/posts/{id} +func HandleGetForumPost(s store.Store, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "post id is required", "MISSING_ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + post, err := s.GetForumPost(ctx, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "post not found", "NOT_FOUND") + return + } + log.Error("failed to get forum post", "error", err, "id", id) + writeError(w, http.StatusInternalServerError, "failed to get post", "STORE_ERROR") + return + } + + writeJSON(w, http.StatusOK, post) + } +} + +// UpdateForumPostRequest is the JSON body for updating a forum post state. +type UpdateForumPostRequest struct { + State string `json:"state"` // "active", "archived", "internalized" +} + +// HandleUpdateForumPost updates a forum post's state. +// PATCH /api/v1/forum/posts/{id} +func HandleUpdateForumPost(s store.Store, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "post id is required", "MISSING_ID") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + defer func() { _ = r.Body.Close() }() + + var req UpdateForumPostRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body", "INVALID_REQUEST") + return + } + + validStates := map[string]bool{"active": true, "archived": true, "internalized": true} + if !validStates[req.State] { + writeError(w, http.StatusBadRequest, "state must be active, archived, or internalized", "INVALID_STATE") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if err := s.UpdateForumPostState(ctx, id, req.State); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "post not found", "NOT_FOUND") + return + } + log.Error("failed to update forum post", "error", err, "id", id) + writeError(w, http.StatusInternalServerError, "failed to update post", "STORE_ERROR") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) + } +} + +// InternalizeRequest is the JSON body for internalizing a forum post. +type InternalizeRequest struct { + Type string `json:"type,omitempty"` // memory type: "insight", "decision", etc. Default: "insight" +} + +// HandleInternalizeForumPost absorbs a forum post into the memory system. +// POST /api/v1/forum/posts/{id}/internalize +func HandleInternalizeForumPost(s store.Store, bus events.Bus, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "post id is required", "MISSING_ID") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + defer func() { _ = r.Body.Close() }() + + var req InternalizeRequest + // Body is optional — allow empty + _ = json.NewDecoder(r.Body).Decode(&req) + memType := req.Type + if memType == "" { + memType = "insight" + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Load the post + post, err := s.GetForumPost(ctx, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "post not found", "NOT_FOUND") + return + } + log.Error("failed to get forum post for internalization", "error", err, "id", id) + writeError(w, http.StatusInternalServerError, "failed to get post", "STORE_ERROR") + return + } + + if post.State == "internalized" { + writeError(w, http.StatusConflict, "post already internalized", "ALREADY_INTERNALIZED") + return + } + + // Create a raw memory from the post content + rawID := uuid.New().String() + raw := store.RawMemory{ + ID: rawID, + Timestamp: post.CreatedAt, + Source: "forum", + Type: memType, + Content: post.Content, + Metadata: map[string]interface{}{"forum_post_id": post.ID, "author": post.AuthorName}, + HeuristicScore: 1.0, + InitialSalience: 0.85, + Processed: false, + CreatedAt: post.CreatedAt, + } + + if err := s.WriteRaw(ctx, raw); err != nil { + log.Error("failed to write raw memory from forum post", "error", err, "post_id", id) + writeError(w, http.StatusInternalServerError, "failed to internalize", "STORE_ERROR") + return + } + + // Publish event to enter encoding pipeline + _ = bus.Publish(ctx, events.RawMemoryCreated{ + ID: rawID, + Source: "forum", + HeuristicScore: 1.0, + Salience: 0.85, + Ts: post.CreatedAt, + }) + + // Mark post as internalized + if err := s.UpdateForumPostState(ctx, id, "internalized"); err != nil { + log.Warn("failed to update post state after internalization", "error", err, "post_id", id) + } + + log.Info("forum post internalized", + "post_id", id, + "raw_memory_id", rawID, + "type", memType, + ) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "raw_memory_id": rawID, + "type": memType, + "status": "internalized", + }) + } +} diff --git a/internal/api/routes/memories.go b/internal/api/routes/memories.go index 5c1ba4a4..7166cba5 100644 --- a/internal/api/routes/memories.go +++ b/internal/api/routes/memories.go @@ -199,6 +199,22 @@ func HandleListMemories(s store.Store, log *slog.Logger) http.HandlerFunc { } memories = filtered + // Optional episode_id filter + if epID := r.URL.Query().Get("episode_id"); epID != "" { + epFiltered := make([]store.Memory, 0) + for _, m := range memories { + if m.EpisodeID == epID { + epFiltered = append(epFiltered, m) + } + } + memories = epFiltered + } + + // Strip embeddings from list response (saves ~42KB per memory) + for i := range memories { + memories[i].Embedding = nil + } + resp := ListMemoriesResponse{ Memories: memories, Count: len(memories), diff --git a/internal/api/routes/ws.go b/internal/api/routes/ws.go index fd70f44b..6e7689e2 100644 --- a/internal/api/routes/ws.go +++ b/internal/api/routes/ws.go @@ -104,6 +104,7 @@ func HandleWebSocket(bus events.Bus, log *slog.Logger) http.HandlerFunc { events.TypeAbstractionCreated, events.TypeMemoryAmended, events.TypeSessionEnded, + events.TypeForumPostCreated, } for _, eventType := range eventTypes { @@ -222,6 +223,8 @@ func wsConnEventToMessage(evt events.Event) WebSocketMessage { payload = e case events.SessionEnded: payload = e + case events.ForumPostCreated: + 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 0b5b2327..e34cedce 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -142,9 +142,12 @@ func (s *Server) registerRoutes() { // Research analytics s.mux.HandleFunc("GET /api/v1/analytics", routes.HandleAnalytics(s.deps.Store, s.deps.Log)) - // Graph data for D3.js visualization + // Graph data for visualization s.mux.HandleFunc("GET /api/v1/graph", routes.HandleGraph(s.deps.Store, s.deps.Log)) + // Associations (for thread view quote blocks) + s.mux.HandleFunc("GET /api/v1/associations", routes.HandleListAssociations(s.deps.Store, s.deps.Log)) + // Agent SDK evolution dashboard if s.deps.AgentEvolutionDir != "" { s.mux.HandleFunc("GET /api/v1/agent/evolution", routes.HandleAgentEvolution(s.deps.AgentEvolutionDir, s.deps.Log)) @@ -153,6 +156,15 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("GET /api/v1/agent/config", routes.HandleAgentConfig(s.deps.AgentWebPort, s.deps.Log)) } + // Forum + s.mux.HandleFunc("GET /api/v1/forum/categories", routes.HandleListForumCategories(s.deps.Store, s.deps.Log)) + s.mux.HandleFunc("GET /api/v1/forum/threads", routes.HandleListForumThreads(s.deps.Store, s.deps.Log)) + s.mux.HandleFunc("GET /api/v1/forum/threads/{id}", routes.HandleGetForumThread(s.deps.Store, s.deps.Log)) + s.mux.HandleFunc("POST /api/v1/forum/posts", routes.HandleCreateForumPost(s.deps.Store, s.deps.Bus, s.deps.Log)) + s.mux.HandleFunc("GET /api/v1/forum/posts/{id}", routes.HandleGetForumPost(s.deps.Store, s.deps.Log)) + s.mux.HandleFunc("PATCH /api/v1/forum/posts/{id}", routes.HandleUpdateForumPost(s.deps.Store, s.deps.Log)) + s.mux.HandleFunc("POST /api/v1/forum/posts/{id}/internalize", routes.HandleInternalizeForumPost(s.deps.Store, s.deps.Bus, s.deps.Log)) + // WebSocket s.mux.HandleFunc("GET /ws", routes.HandleWebSocket(s.deps.Bus, s.deps.Log)) diff --git a/internal/config/config.go b/internal/config/config.go index fb4bf083..d2c52f5e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { Abstraction AbstractionConfig `yaml:"abstraction"` Orchestrator OrchestratorConfig `yaml:"orchestrator"` Reactor ReactorConfig `yaml:"reactor"` + Forum ForumConfig `yaml:"forum"` MemoryDefaults MemoryDefaultsConfig `yaml:"memory_defaults"` MCP MCPConfig `yaml:"mcp"` AgentSDK AgentSDKConfig `yaml:"agent_sdk"` @@ -364,6 +365,16 @@ type ReactorConfig struct { Cooldowns map[string]string `yaml:"cooldowns"` // chain ID -> duration string (e.g., "30m", "1h") } +// ForumConfig holds settings for the forum communication layer. +type ForumConfig struct { + AgentPosting bool `yaml:"agent_posting"` // agents auto-post on events (default: true) + MentionResponses bool `yaml:"mention_responses"` // @mention triggers LLM response (default: true) + MentionMaxTokens int `yaml:"mention_max_tokens"` // max tokens for @mention LLM responses (default: 512) + MentionTemp float64 `yaml:"mention_temperature"` // temperature for @mention LLM responses (default: 0.7) + PerAgentSubforums bool `yaml:"per_agent_subforums"` // route to per-agent sub-forums (default: true); false = shared "Agent Activity" + DigestPosting bool `yaml:"digest_posting"` // batch agent posts into daily digest threads (default: true) +} + // MemoryDefaultsConfig holds shared defaults used by both MCP and API. type MemoryDefaultsConfig struct { InitialSalienceGeneral float32 `yaml:"initial_salience_general"` // default: 0.7 @@ -766,6 +777,14 @@ func Default() *Config { HealthReportInterval: 5 * time.Minute, }, Reactor: ReactorConfig{}, + Forum: ForumConfig{ + AgentPosting: true, + MentionResponses: true, + MentionMaxTokens: 512, + MentionTemp: 0.7, + PerAgentSubforums: true, + DigestPosting: true, + }, MemoryDefaults: MemoryDefaultsConfig{ InitialSalienceGeneral: 0.7, InitialSalienceDecision: 0.85, diff --git a/internal/events/types.go b/internal/events/types.go index 15a3da75..19768f1f 100644 --- a/internal/events/types.go +++ b/internal/events/types.go @@ -145,6 +145,7 @@ type EpisodeClosed struct { Title string `json:"title"` EventCount int `json:"event_count"` DurationSec int `json:"duration_sec"` + Project string `json:"project,omitempty"` Ts time.Time `json:"timestamp"` } @@ -224,3 +225,36 @@ const TypeSessionEnded = "session_ended" func (e SessionEnded) EventType() string { return TypeSessionEnded } func (e SessionEnded) EventTimestamp() time.Time { return e.Ts } + +// ForumPostCreated is emitted when a new forum post is created (human or agent). +const TypeForumPostCreated = "forum_post_created" + +type ForumPostCreated struct { + PostID string `json:"post_id"` + ThreadID string `json:"thread_id"` + ParentID string `json:"parent_id,omitempty"` + AuthorType string `json:"author_type"` // "human", "agent" + AuthorName string `json:"author_name"` + AuthorKey string `json:"author_key,omitempty"` + Content string `json:"content"` + Mentions []string `json:"mentions,omitempty"` + Ts time.Time `json:"timestamp"` +} + +func (e ForumPostCreated) EventType() string { return TypeForumPostCreated } +func (e ForumPostCreated) EventTimestamp() time.Time { return e.Ts } + +// ForumMentionDetected is emitted when an @mention is detected in a forum post. +const TypeForumMentionDetected = "forum_mention_detected" + +type ForumMentionDetected struct { + PostID string `json:"post_id"` + ThreadID string `json:"thread_id"` + AgentKey string `json:"agent_key"` // "retrieval", "metacognition", etc. + Content string `json:"content"` // the post text for context + EpisodeID string `json:"episode_id,omitempty"` // if the mention is from an episode thread + Ts time.Time `json:"timestamp"` +} + +func (e ForumMentionDetected) EventType() string { return TypeForumMentionDetected } +func (e ForumMentionDetected) EventTimestamp() time.Time { return e.Ts } diff --git a/internal/llm/lmstudio.go b/internal/llm/lmstudio.go index bd6b55d5..44b95107 100644 --- a/internal/llm/lmstudio.go +++ b/internal/llm/lmstudio.go @@ -298,10 +298,11 @@ func (p *LMStudioProvider) Complete(ctx context.Context, req CompletionRequest) Stop: req.Stop, } - // Thinking models: disable reasoning for structured output requests. - // Thinking tokens consume the max_tokens budget and can starve the actual - // JSON output, causing parse failures. - if isThinkingModel && req.ResponseFormat != nil && req.ResponseFormat.Type == "json_schema" { + // Thinking models: always disable reasoning. Mnemonic's prompts are + // short, structured tasks (encoding, retrieval, forum replies) where + // thinking tokens waste budget and cause truncation. If a future use + // case needs thinking, add an EnableThinking field to CompletionRequest. + if isThinkingModel { apiReq.ReasoningEffort = "none" } diff --git a/internal/llm/provider.go b/internal/llm/provider.go index f246ffe5..90da6a0e 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -30,14 +30,15 @@ type JSONSchema struct { // CompletionRequest is the input to a completion call. type CompletionRequest struct { - Messages []Message `json:"messages"` - Model string `json:"model,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature float32 `json:"temperature,omitempty"` - TopP float32 `json:"top_p,omitempty"` - Stop []string `json:"stop,omitempty"` - Tools []Tool `json:"tools,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + Messages []Message `json:"messages"` + Model string `json:"model,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float32 `json:"temperature,omitempty"` + TopP float32 `json:"top_p,omitempty"` + Stop []string `json:"stop,omitempty"` + Tools []Tool `json:"tools,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + DisableThinking bool `json:"-"` // if true, set reasoning_effort=none on thinking models } // CompletionResponse is the output of a completion call. diff --git a/internal/store/sqlite/forum.go b/internal/store/sqlite/forum.go new file mode 100644 index 00000000..aeed3b5a --- /dev/null +++ b/internal/store/sqlite/forum.go @@ -0,0 +1,451 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + store "github.com/appsprout-dev/mnemonic/internal/store" +) + +// BackfillEpisodeMemoryLinks fixes the race condition where memories were encoded +// before their raw observations were assigned to episodes. Iterates episodes (small set) +// and links any encoded memories found via raw_id lookup. +func (s *SQLiteStore) BackfillEpisodeMemoryLinks(ctx context.Context) (int, error) { + episodes, err := s.ListEpisodes(ctx, "", 500, 0) + if err != nil { + return 0, fmt.Errorf("listing episodes: %w", err) + } + + linked := 0 + for _, ep := range episodes { + for _, rawID := range ep.RawMemoryIDs { + if rawID == "" { + continue + } + // Check if the encoded memory exists and needs linking + mem, err := s.GetMemoryByRawID(ctx, rawID) + if err != nil { + continue + } + if mem.EpisodeID == ep.ID { + continue + } + // Update the memory's episode_id + _, err = s.db.ExecContext(ctx, + `UPDATE memories SET episode_id = ? WHERE id = ? AND (episode_id IS NULL OR episode_id = '')`, + ep.ID, mem.ID) + if err == nil { + linked++ + } + } + // Update episode.memory_ids + var memIDs []string + for _, rawID := range ep.RawMemoryIDs { + mem, err := s.GetMemoryByRawID(ctx, rawID) + if err != nil { + continue + } + memIDs = append(memIDs, mem.ID) + } + if len(memIDs) > 0 { + encoded, _ := encodeStringSlice(memIDs) + _, _ = s.db.ExecContext(ctx, + `UPDATE episodes SET memory_ids = ? WHERE id = ?`, encoded, ep.ID) + } + } + return linked, nil +} + +// SyncProjectCategories creates forum categories for any projects that don't have one yet. +func (s *SQLiteStore) SyncProjectCategories(ctx context.Context) (int, error) { + // Get all known projects + projects, err := s.ListProjects(ctx) + if err != nil { + return 0, fmt.Errorf("listing projects: %w", err) + } + + created := 0 + for _, project := range projects { + catID := "project-" + project + // Check if category already exists + _, err := s.GetForumCategory(ctx, catID) + if err == nil { + continue // already exists + } + + cat := store.ForumCategory{ + ID: catID, + Name: project, + Slug: "project-" + project, + Description: "Threads about the " + project + " project", + Icon: "PJ", + Color: "var(--accent-green)", + Type: "project", + SortOrder: 200, + CreatedAt: time.Now(), + } + if err := s.WriteForumCategory(ctx, cat); err != nil { + continue + } + created++ + } + return created, nil +} + +// forumCategoryColumns is the standard column list for forum category queries. +const forumCategoryColumns = `id, name, slug, description, icon, color, type, sort_order, created_at` + +// WriteForumCategory inserts a new forum category. +func (s *SQLiteStore) WriteForumCategory(ctx context.Context, cat store.ForumCategory) error { + _, err := s.db.ExecContext(ctx, + `INSERT OR IGNORE INTO forum_categories (`+forumCategoryColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + cat.ID, cat.Name, cat.Slug, cat.Description, cat.Icon, cat.Color, cat.Type, cat.SortOrder, + cat.CreatedAt.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("writing forum category: %w", err) + } + return nil +} + +// GetForumCategory retrieves a forum category by ID. +func (s *SQLiteStore) GetForumCategory(ctx context.Context, id string) (store.ForumCategory, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+forumCategoryColumns+` FROM forum_categories WHERE id = ?`, id) + var cat store.ForumCategory + var createdAtStr string + err := row.Scan(&cat.ID, &cat.Name, &cat.Slug, &cat.Description, &cat.Icon, &cat.Color, &cat.Type, &cat.SortOrder, &createdAtStr) + if err != nil { + if err == sql.ErrNoRows { + return cat, fmt.Errorf("forum category: %w", store.ErrNotFound) + } + return cat, fmt.Errorf("scanning forum category: %w", err) + } + cat.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr) + return cat, nil +} + +// ListForumCategories returns all categories ordered by sort_order. +func (s *SQLiteStore) ListForumCategories(ctx context.Context) ([]store.ForumCategory, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+forumCategoryColumns+` FROM forum_categories ORDER BY sort_order ASC, name ASC`) + if err != nil { + return nil, fmt.Errorf("listing forum categories: %w", err) + } + defer func() { _ = rows.Close() }() + + var cats []store.ForumCategory + for rows.Next() { + var cat store.ForumCategory + var createdAtStr string + if err := rows.Scan(&cat.ID, &cat.Name, &cat.Slug, &cat.Description, &cat.Icon, &cat.Color, &cat.Type, &cat.SortOrder, &createdAtStr); err != nil { + return nil, fmt.Errorf("scanning forum category row: %w", err) + } + cat.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr) + cats = append(cats, cat) + } + return cats, rows.Err() +} + +// ListForumCategorySummaries returns all categories with thread/post counts and last post. +func (s *SQLiteStore) ListForumCategorySummaries(ctx context.Context) ([]store.ForumCategorySummary, error) { + cats, err := s.ListForumCategories(ctx) + if err != nil { + return nil, err + } + + var summaries []store.ForumCategorySummary + for _, cat := range cats { + var threadCount, postCount int + _ = s.db.QueryRowContext(ctx, + `SELECT COUNT(DISTINCT thread_id), COUNT(*) FROM forum_posts WHERE category_id = ? AND state = 'active'`, cat.ID).Scan(&threadCount, &postCount) + + summary := store.ForumCategorySummary{ + Category: cat, + ThreadCount: threadCount, + PostCount: postCount, + } + + // Get last post in this category + row := s.db.QueryRowContext(ctx, + `SELECT `+forumPostColumns+` FROM forum_posts WHERE category_id = ? AND state = 'active' ORDER BY created_at DESC LIMIT 1`, cat.ID) + lastPost, err := scanForumPostFrom(row) + if err == nil { + summary.LastPost = &lastPost + } + + summaries = append(summaries, summary) + } + return summaries, nil +} + +// forumPostColumns is the standard column list for forum post queries. +const forumPostColumns = `id, parent_id, thread_id, author_type, author_name, author_key, content, mentions, memory_ids, event_ref, category_id, pinned, state, created_at, updated_at` + +// WriteForumPost inserts a new forum post. +func (s *SQLiteStore) WriteForumPost(ctx context.Context, post store.ForumPost) error { + mentions, _ := encodeStringSlice(post.Mentions) + memoryIDs, _ := encodeStringSlice(post.MemoryIDs) + pinned := 0 + if post.Pinned { + pinned = 1 + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO forum_posts (`+forumPostColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + post.ID, + nullableString(post.ParentID), + post.ThreadID, + post.AuthorType, + post.AuthorName, + post.AuthorKey, + post.Content, + mentions, + memoryIDs, + nullableString(post.EventRef), + nullableString(post.CategoryID), + pinned, + post.State, + post.CreatedAt.Format(time.RFC3339), + post.UpdatedAt.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("writing forum post: %w", err) + } + return nil +} + +// GetForumPost retrieves a forum post by ID. +func (s *SQLiteStore) GetForumPost(ctx context.Context, id string) (store.ForumPost, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+forumPostColumns+` FROM forum_posts WHERE id = ?`, id) + return scanForumPost(row) +} + +// ListForumThreads returns root-level posts (threads) with reply counts. +func (s *SQLiteStore) ListForumThreads(ctx context.Context, limit, offset int) ([]store.ForumThread, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT fp.`+forumPostColumns+`, + COALESCE(rc.reply_count, 0) AS reply_count, + COALESCE(rc.last_reply, fp.created_at) AS last_reply + FROM forum_posts fp + LEFT JOIN ( + SELECT fp2.thread_id AS rc_thread_id, + COUNT(*) AS reply_count, + MAX(fp2.created_at) AS last_reply + FROM forum_posts fp2 + WHERE fp2.id != fp2.thread_id AND fp2.state = 'active' + GROUP BY fp2.thread_id + ) rc ON rc.rc_thread_id = fp.id + WHERE fp.id = fp.thread_id AND fp.state = 'active' + ORDER BY COALESCE(rc.last_reply, fp.created_at) DESC + LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, fmt.Errorf("listing forum threads: %w", err) + } + defer func() { _ = rows.Close() }() + + return s.scanForumThreadRows(rows) +} + +// ListForumThreadsByCategory returns threads in a specific category. +func (s *SQLiteStore) ListForumThreadsByCategory(ctx context.Context, categoryID string, limit, offset int) ([]store.ForumThread, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT fp.`+forumPostColumns+`, + COALESCE(rc.reply_count, 0) AS reply_count, + COALESCE(rc.last_reply, fp.created_at) AS last_reply + FROM forum_posts fp + LEFT JOIN ( + SELECT fp2.thread_id AS rc_thread_id, + COUNT(*) AS reply_count, + MAX(fp2.created_at) AS last_reply + FROM forum_posts fp2 + WHERE fp2.id != fp2.thread_id AND fp2.state = 'active' + GROUP BY fp2.thread_id + ) rc ON rc.rc_thread_id = fp.id + WHERE fp.id = fp.thread_id AND fp.state = 'active' AND fp.category_id = ? + ORDER BY COALESCE(rc.last_reply, fp.created_at) DESC + LIMIT ? OFFSET ?`, categoryID, limit, offset) + if err != nil { + return nil, fmt.Errorf("listing forum threads by category: %w", err) + } + defer func() { _ = rows.Close() }() + + return s.scanForumThreadRows(rows) +} + +func (s *SQLiteStore) scanForumThreadRows(rows *sql.Rows) ([]store.ForumThread, error) { + var threads []store.ForumThread + for rows.Next() { + var post store.ForumPost + var parentID, authorKey, eventRef, categoryID, mentionsStr, memoryIDsStr sql.NullString + var pinned int + var createdAtStr, updatedAtStr string + var replyCount int + var lastReply string + + err := rows.Scan( + &post.ID, &parentID, &post.ThreadID, &post.AuthorType, &post.AuthorName, + &authorKey, &post.Content, &mentionsStr, &memoryIDsStr, &eventRef, + &categoryID, &pinned, &post.State, &createdAtStr, &updatedAtStr, + &replyCount, &lastReply, + ) + if err != nil { + return nil, fmt.Errorf("scanning forum thread row: %w", err) + } + + post.ParentID = parentID.String + post.AuthorKey = authorKey.String + post.EventRef = eventRef.String + post.CategoryID = categoryID.String + post.Mentions, _ = decodeStringSlice(mentionsStr.String) + post.MemoryIDs, _ = decodeStringSlice(memoryIDsStr.String) + post.Pinned = pinned != 0 + post.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr) + post.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAtStr) + + lr, _ := time.Parse(time.RFC3339, lastReply) + if lr.IsZero() { + lr = post.CreatedAt + } + + threads = append(threads, store.ForumThread{ + RootPost: post, + ReplyCount: replyCount, + LastReply: lr, + }) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("reading forum thread rows: %w", err) + } + return threads, nil +} + +// ListForumPostsByThread returns all posts in a thread ordered by creation time. +func (s *SQLiteStore) ListForumPostsByThread(ctx context.Context, threadID string, limit int) ([]store.ForumPost, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+forumPostColumns+` FROM forum_posts + WHERE thread_id = ? AND state = 'active' + ORDER BY created_at ASC + LIMIT ?`, threadID, limit) + if err != nil { + return nil, fmt.Errorf("listing forum posts by thread: %w", err) + } + return scanForumPostRows(rows) +} + +// UpdateForumPostState updates the state of a forum post. +func (s *SQLiteStore) UpdateForumPostState(ctx context.Context, id string, state string) error { + result, err := s.db.ExecContext(ctx, + `UPDATE forum_posts SET state = ?, updated_at = datetime('now') WHERE id = ?`, state, id) + if err != nil { + return fmt.Errorf("updating forum post state %s: %w", id, err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("forum post %s: %w", id, store.ErrNotFound) + } + return nil +} + +// CountForumPosts returns the total number of active forum posts. +func (s *SQLiteStore) CountForumPosts(ctx context.Context) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM forum_posts WHERE state = 'active'`).Scan(&count) + if err != nil { + return 0, fmt.Errorf("counting forum posts: %w", err) + } + return count, nil +} + +// GetDailyDigestThread returns today's digest root post for a category, or ErrNotFound if none exists. +func (s *SQLiteStore) GetDailyDigestThread(ctx context.Context, categoryID string, date time.Time) (store.ForumPost, error) { + dateStr := date.Format("2006-01-02") + row := s.db.QueryRowContext(ctx, ` + SELECT `+forumPostColumns+` + FROM forum_posts + WHERE category_id = ? + AND id = thread_id + AND DATE(created_at) = ? + AND state = 'active' + ORDER BY created_at DESC + LIMIT 1`, categoryID, dateStr) + return scanForumPost(row) +} + +// scanForumPostFrom scans a single ForumPost from any scanner. +func scanForumPostFrom(s scanner) (store.ForumPost, error) { + var post store.ForumPost + var parentID, authorKey, eventRef, categoryID, mentionsStr, memoryIDsStr sql.NullString + var pinned int + var createdAtStr, updatedAtStr string + + err := s.Scan( + &post.ID, + &parentID, + &post.ThreadID, + &post.AuthorType, + &post.AuthorName, + &authorKey, + &post.Content, + &mentionsStr, + &memoryIDsStr, + &eventRef, + &categoryID, + &pinned, + &post.State, + &createdAtStr, + &updatedAtStr, + ) + if err != nil { + return post, err + } + + post.ParentID = parentID.String + post.AuthorKey = authorKey.String + post.EventRef = eventRef.String + post.CategoryID = categoryID.String + post.Mentions, _ = decodeStringSlice(mentionsStr.String) + post.MemoryIDs, _ = decodeStringSlice(memoryIDsStr.String) + post.Pinned = pinned != 0 + post.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr) + post.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAtStr) + + return post, nil +} + +// scanForumPost scans a single forum post row. +func scanForumPost(row *sql.Row) (store.ForumPost, error) { + p, err := scanForumPostFrom(row) + if err != nil { + if err == sql.ErrNoRows { + return p, fmt.Errorf("forum post: %w", store.ErrNotFound) + } + return p, fmt.Errorf("scanning forum post: %w", err) + } + return p, nil +} + +// scanForumPostRows scans multiple forum post rows. +func scanForumPostRows(rows *sql.Rows) ([]store.ForumPost, error) { + defer func() { _ = rows.Close() }() + var posts []store.ForumPost + + for rows.Next() { + p, err := scanForumPostFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning forum post row: %w", err) + } + posts = append(posts, p) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("reading forum post rows: %w", err) + } + return posts, nil +} diff --git a/internal/store/sqlite/forum_test.go b/internal/store/sqlite/forum_test.go new file mode 100644 index 00000000..68e01c66 --- /dev/null +++ b/internal/store/sqlite/forum_test.go @@ -0,0 +1,312 @@ +//go:build sqlite_fts5 + +package sqlite + +import ( + "context" + "testing" + "time" + + store "github.com/appsprout-dev/mnemonic/internal/store" +) + +func TestForumPostCRUD(t *testing.T) { + s := createTestStore(t) + defer func() { _ = s.Close() }() + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + + // Create a thread (root post) + root := store.ForumPost{ + ID: "post-001", + ThreadID: "post-001", // root post: thread_id = id + AuthorType: "human", + AuthorName: "Caleb", + AuthorKey: "", + Content: "Hello, this is the first forum post!", + Mentions: []string{}, + MemoryIDs: []string{}, + State: "active", + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.WriteForumPost(ctx, root); err != nil { + t.Fatalf("WriteForumPost (root): %v", err) + } + + // Read it back + got, err := s.GetForumPost(ctx, "post-001") + if err != nil { + t.Fatalf("GetForumPost: %v", err) + } + if got.ID != root.ID { + t.Errorf("ID: got %q, want %q", got.ID, root.ID) + } + if got.Content != root.Content { + t.Errorf("Content: got %q, want %q", got.Content, root.Content) + } + if got.AuthorType != "human" { + t.Errorf("AuthorType: got %q, want %q", got.AuthorType, "human") + } + if got.ThreadID != "post-001" { + t.Errorf("ThreadID: got %q, want %q", got.ThreadID, "post-001") + } + + // Create a reply + reply := store.ForumPost{ + ID: "post-002", + ParentID: "post-001", + ThreadID: "post-001", + AuthorType: "agent", + AuthorName: "Encoding Agent", + AuthorKey: "encoding", + Content: "Encoded your post. Extracted 3 concepts.", + Mentions: []string{}, + MemoryIDs: []string{"mem-abc"}, + EventRef: "memory_encoded", + State: "active", + CreatedAt: now.Add(time.Second), + UpdatedAt: now.Add(time.Second), + } + + if err := s.WriteForumPost(ctx, reply); err != nil { + t.Fatalf("WriteForumPost (reply): %v", err) + } + + // List thread posts + posts, err := s.ListForumPostsByThread(ctx, "post-001", 100) + if err != nil { + t.Fatalf("ListForumPostsByThread: %v", err) + } + if len(posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(posts)) + } + if posts[0].ID != "post-001" { + t.Errorf("first post should be root, got %q", posts[0].ID) + } + if posts[1].ID != "post-002" { + t.Errorf("second post should be reply, got %q", posts[1].ID) + } + if posts[1].ParentID != "post-001" { + t.Errorf("reply ParentID: got %q, want %q", posts[1].ParentID, "post-001") + } + if posts[1].AuthorKey != "encoding" { + t.Errorf("reply AuthorKey: got %q, want %q", posts[1].AuthorKey, "encoding") + } + if len(posts[1].MemoryIDs) != 1 || posts[1].MemoryIDs[0] != "mem-abc" { + t.Errorf("reply MemoryIDs: got %v, want [mem-abc]", posts[1].MemoryIDs) + } +} + +func TestForumThreadListing(t *testing.T) { + s := createTestStore(t) + defer func() { _ = s.Close() }() + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + + // Create two threads + seedThreads := []store.ForumPost{ + { + ID: "thread-a", ThreadID: "thread-a", + AuthorType: "human", AuthorName: "Caleb", + Content: "First thread", State: "active", + CreatedAt: now, UpdatedAt: now, + }, + { + ID: "thread-b", ThreadID: "thread-b", + AuthorType: "agent", AuthorName: "Dreaming Agent", AuthorKey: "dreaming", + Content: "Dream cycle insights", State: "active", + CreatedAt: now.Add(2 * time.Second), UpdatedAt: now.Add(2 * time.Second), + }, + } + for i, thread := range seedThreads { + if err := s.WriteForumPost(ctx, thread); err != nil { + t.Fatalf("WriteForumPost thread %d: %v", i, err) + } + } + + // Add a reply to thread-a (makes it more recent) + reply := store.ForumPost{ + ID: "reply-a1", ParentID: "thread-a", ThreadID: "thread-a", + AuthorType: "agent", AuthorName: "Metacognition Agent", AuthorKey: "metacognition", + Content: "Quality looks good.", State: "active", + CreatedAt: now.Add(10 * time.Second), UpdatedAt: now.Add(10 * time.Second), + } + if err := s.WriteForumPost(ctx, reply); err != nil { + t.Fatalf("WriteForumPost reply: %v", err) + } + + // List threads — should be ordered by last activity (thread-a first due to reply) + threads, err := s.ListForumThreads(ctx, 10, 0) + if err != nil { + t.Fatalf("ListForumThreads: %v", err) + } + if len(threads) != 2 { + t.Fatalf("expected 2 threads, got %d", len(threads)) + } + if threads[0].RootPost.ID != "thread-a" { + t.Errorf("first thread should be thread-a (has most recent reply), got %q", threads[0].RootPost.ID) + } + if threads[0].ReplyCount != 1 { + t.Errorf("thread-a reply count: got %d, want 1", threads[0].ReplyCount) + } + if threads[1].RootPost.ID != "thread-b" { + t.Errorf("second thread should be thread-b, got %q", threads[1].RootPost.ID) + } + if threads[1].ReplyCount != 0 { + t.Errorf("thread-b reply count: got %d, want 0", threads[1].ReplyCount) + } +} + +func TestForumPostStateUpdate(t *testing.T) { + s := createTestStore(t) + defer func() { _ = s.Close() }() + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + post := store.ForumPost{ + ID: "post-state", ThreadID: "post-state", + AuthorType: "human", AuthorName: "Caleb", + Content: "To be internalized", State: "active", + CreatedAt: now, UpdatedAt: now, + } + if err := s.WriteForumPost(ctx, post); err != nil { + t.Fatalf("WriteForumPost: %v", err) + } + + // Update to internalized + if err := s.UpdateForumPostState(ctx, "post-state", "internalized"); err != nil { + t.Fatalf("UpdateForumPostState: %v", err) + } + + got, err := s.GetForumPost(ctx, "post-state") + if err != nil { + t.Fatalf("GetForumPost after update: %v", err) + } + if got.State != "internalized" { + t.Errorf("State: got %q, want %q", got.State, "internalized") + } + + // Not found case + err = s.UpdateForumPostState(ctx, "nonexistent", "archived") + if err == nil { + t.Error("expected error for nonexistent post") + } +} + +func TestForumPostCount(t *testing.T) { + s := createTestStore(t) + defer func() { _ = s.Close() }() + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + + count, err := s.CountForumPosts(ctx) + if err != nil { + t.Fatalf("CountForumPosts (empty): %v", err) + } + if count != 0 { + t.Errorf("expected 0, got %d", count) + } + + post := store.ForumPost{ + ID: "count-1", ThreadID: "count-1", + AuthorType: "human", AuthorName: "Caleb", + Content: "Counting post", State: "active", + CreatedAt: now, UpdatedAt: now, + } + if err := s.WriteForumPost(ctx, post); err != nil { + t.Fatalf("WriteForumPost: %v", err) + } + + count, err = s.CountForumPosts(ctx) + if err != nil { + t.Fatalf("CountForumPosts (1 post): %v", err) + } + if count != 1 { + t.Errorf("expected 1, got %d", count) + } +} + +func TestForumPostMentions(t *testing.T) { + s := createTestStore(t) + defer func() { _ = s.Close() }() + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + post := store.ForumPost{ + ID: "mention-post", ThreadID: "mention-post", + AuthorType: "human", AuthorName: "Caleb", + Content: "@retrieval what do you know about encoding?", + Mentions: []string{"retrieval"}, + State: "active", + CreatedAt: now, UpdatedAt: now, + } + if err := s.WriteForumPost(ctx, post); err != nil { + t.Fatalf("WriteForumPost: %v", err) + } + + got, err := s.GetForumPost(ctx, "mention-post") + if err != nil { + t.Fatalf("GetForumPost: %v", err) + } + if len(got.Mentions) != 1 || got.Mentions[0] != "retrieval" { + t.Errorf("Mentions: got %v, want [retrieval]", got.Mentions) + } +} + +func TestGetDailyDigestThread(t *testing.T) { + s := createTestStore(t) + defer func() { _ = s.Close() }() + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + yesterday := now.Add(-24 * time.Hour) + + // No digest thread yet — should return ErrNotFound + _, err := s.GetDailyDigestThread(ctx, "agent-consolidation", now) + if err == nil { + t.Fatal("expected ErrNotFound, got nil") + } + + // Write a root post for today in agent-consolidation + root := store.ForumPost{ + ID: "digest-001", + ThreadID: "digest-001", + AuthorType: "agent", + AuthorName: "Consolidation Agent", + AuthorKey: "consolidation", + Content: "Daily digest for consolidation", + CategoryID: "agent-consolidation", + State: "active", + CreatedAt: now, + UpdatedAt: now, + } + if err := s.WriteForumPost(ctx, root); err != nil { + t.Fatalf("WriteForumPost: %v", err) + } + + // Now should find today's thread + got, err := s.GetDailyDigestThread(ctx, "agent-consolidation", now) + if err != nil { + t.Fatalf("GetDailyDigestThread: %v", err) + } + if got.ID != "digest-001" { + t.Errorf("got ID %q, want digest-001", got.ID) + } + + // Different category — should not find it + _, err = s.GetDailyDigestThread(ctx, "agent-dreaming", now) + if err == nil { + t.Fatal("expected ErrNotFound for different category") + } + + // Yesterday — should not find it + _, err = s.GetDailyDigestThread(ctx, "agent-consolidation", yesterday) + if err == nil { + t.Fatal("expected ErrNotFound for yesterday") + } +} diff --git a/internal/store/sqlite/schema.go b/internal/store/sqlite/schema.go index a3c3ac17..2a009819 100644 --- a/internal/store/sqlite/schema.go +++ b/internal/store/sqlite/schema.go @@ -489,6 +489,84 @@ CREATE INDEX IF NOT EXISTS idx_amendments_memory ON memory_amendments(memory_id) return fmt.Errorf("failed to add tool_usage.suggested_ids column: %w", err) } + // Migration 016: Composite indexes for dashboard query performance + // ListMemories: WHERE state=? ORDER BY created_at DESC — was doing full table sort on 33K+ rows + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_memories_state_created ON memories(state, created_at DESC)`) + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_memories_project_state ON memories(project, state, timestamp DESC)`) + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_memories_episode ON memories(episode_id) WHERE episode_id IS NOT NULL`) + + // Migration 017: Forum communication layer + _, _ = db.Exec(` +CREATE TABLE IF NOT EXISTS forum_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'custom', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`) + + _, _ = db.Exec(` +CREATE TABLE IF NOT EXISTS forum_posts ( + id TEXT PRIMARY KEY, + parent_id TEXT REFERENCES forum_posts(id), + thread_id TEXT NOT NULL, + author_type TEXT NOT NULL, + author_name TEXT NOT NULL, + author_key TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL, + mentions JSON DEFAULT '[]', + memory_ids JSON DEFAULT '[]', + event_ref TEXT DEFAULT '', + category_id TEXT DEFAULT '', + pinned INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_forum_thread ON forum_posts(thread_id, created_at ASC); +CREATE INDEX IF NOT EXISTS idx_forum_parent ON forum_posts(parent_id) WHERE parent_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_forum_state ON forum_posts(state, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_forum_category ON forum_posts(category_id) WHERE category_id != ''; +`) + + // Seed default forum categories (idempotent via INSERT OR IGNORE + UNIQUE slug) + _, _ = db.Exec(` +INSERT OR IGNORE INTO forum_categories (id, name, slug, description, icon, color, type, sort_order) VALUES + ('discussions', 'Discussions', 'discussions', 'Open conversation', 'DI', 'var(--accent-cyan)', 'system', 10), + ('announcements', 'Announcements', 'announcements', 'System updates and releases', 'AN', 'var(--accent-orange)', 'system', 20), + ('system-reports', 'System Reports', 'system-reports', 'Health, quality, and performance', 'SR', 'var(--accent-green)', 'system', 30), + ('agent-consolidation', '@consolidation', 'agent-consolidation', 'Memory maintenance', 'CA', 'var(--accent-yellow)', 'agent', 100), + ('agent-dreaming', '@dreaming', 'agent-dreaming', 'Dream cycle insights', 'DA', 'var(--accent-violet)', 'agent', 101), + ('agent-episoding', '@episoding', 'agent-episoding', 'Episode summaries', 'EP', 'var(--accent-violet)', 'agent', 102), + ('agent-abstraction', '@abstraction', 'agent-abstraction', 'Patterns and principles', 'AA', 'var(--accent-orange)', 'agent', 103), + ('agent-metacognition', '@metacognition', 'agent-metacognition', 'Quality audits', 'MA', 'var(--accent-blue)', 'agent', 104), + ('agent-encoding', '@encoding', 'agent-encoding', 'Memory compression', 'EA', 'var(--accent-blue)', 'agent', 105), + ('agent-perception', '@perception', 'agent-perception', 'Filesystem activity', 'PA', 'var(--accent-green)', 'agent', 106), + ('agent-retrieval', '@retrieval', 'agent-retrieval', 'Search and recall', 'RA', 'var(--accent-cyan)', 'agent', 107); +`) + + // Migration 017b: Add category_id to existing forum_posts (idempotent) + // Note: SQLite ALTER TABLE doesn't support REFERENCES with non-NULL default, + // so we add the column without the FK constraint (soft reference). + _, err = db.Exec(`ALTER TABLE forum_posts ADD COLUMN category_id TEXT DEFAULT ''`) + if err != nil && !isAlterTableDuplicateColumn(err) { + return fmt.Errorf("failed to add forum_posts.category_id column: %w", err) + } + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_forum_category ON forum_posts(category_id) WHERE category_id != ''`) + + // Backfill category_id for existing agent posts that were created before categories + _, _ = db.Exec(`UPDATE forum_posts SET category_id = 'agent-' || author_key WHERE category_id = '' AND author_type = 'agent' AND author_key != ''`) + // Backfill human posts to 'discussions' + _, _ = db.Exec(`UPDATE forum_posts SET category_id = 'discussions' WHERE category_id = '' AND author_type = 'human'`) + + // Episode-memory backfill is handled by the episoding agent on episode close. + // No startup SQL backfill — the JSON LIKE scan is too slow on large DBs. + // Record the schema version so pre-migration backups can skip when current. if _, err := db.Exec(fmt.Sprintf("PRAGMA user_version = %d", SchemaVersion)); err != nil { return fmt.Errorf("failed to set user_version: %w", err) diff --git a/internal/store/sqlite/sqlite_test.go b/internal/store/sqlite/sqlite_test.go index 3c2e6b00..003dd4f1 100644 --- a/internal/store/sqlite/sqlite_test.go +++ b/internal/store/sqlite/sqlite_test.go @@ -992,6 +992,18 @@ func TestWriteMemoryDuplicateRawID(t *testing.T) { ctx := context.Background() + // Write the raw memory that the FK references + raw := store.RawMemory{ + ID: "raw-1", + Timestamp: time.Now(), + Source: "mcp", + Type: "general", + Content: "test raw memory", + } + if err := s.WriteRaw(ctx, raw); err != nil { + t.Fatalf("WriteRaw: %v", err) + } + mem1 := store.Memory{ ID: "m1", RawID: "raw-1", diff --git a/internal/store/store.go b/internal/store/store.go index 2dbf08d4..fd831a70 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -354,6 +354,55 @@ type RetrievalFeedback struct { CreatedAt time.Time `json:"created_at"` } +// ForumCategory is a sub-forum in the forum index. +type ForumCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Icon string `json:"icon"` + Color string `json:"color"` + Type string `json:"type"` // "system", "project", "agent", "custom" + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} + +// ForumCategorySummary is a category with thread/post counts for the index page. +type ForumCategorySummary struct { + Category ForumCategory `json:"category"` + ThreadCount int `json:"thread_count"` + PostCount int `json:"post_count"` + LastPost *ForumPost `json:"last_post,omitempty"` +} + +// ForumPost is a single post in the forum communication layer. +// Forum posts are separate from memories — they are a conversation space +// between humans and agents. Posts can link to memories but are not memories. +type ForumPost struct { + ID string `json:"id"` + ParentID string `json:"parent_id,omitempty"` // NULL = top-level post + ThreadID string `json:"thread_id"` // root post ID (denormalized) + AuthorType string `json:"author_type"` // "human", "agent" + AuthorName string `json:"author_name"` + AuthorKey string `json:"author_key,omitempty"` // agent key for avatar lookup + Content string `json:"content"` + Mentions []string `json:"mentions,omitempty"` // extracted @mentions + MemoryIDs []string `json:"memory_ids,omitempty"` // linked memory IDs + EventRef string `json:"event_ref,omitempty"` // event that triggered this post + CategoryID string `json:"category_id,omitempty"` // sub-forum this thread belongs to + Pinned bool `json:"pinned"` + State string `json:"state"` // "active", "archived", "internalized" + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ForumThread is a denormalized thread summary for listing. +type ForumThread struct { + RootPost ForumPost `json:"root_post"` + ReplyCount int `json:"reply_count"` + LastReply time.Time `json:"last_reply"` +} + // Store is the abstraction for persistent memory. type Store interface { // --- Raw memory operations --- @@ -524,6 +573,22 @@ type Store interface { // --- Research analytics --- GetAnalytics(ctx context.Context) (AnalyticsData, error) + // --- Forum category operations --- + WriteForumCategory(ctx context.Context, cat ForumCategory) error + GetForumCategory(ctx context.Context, id string) (ForumCategory, error) + ListForumCategories(ctx context.Context) ([]ForumCategory, error) + ListForumCategorySummaries(ctx context.Context) ([]ForumCategorySummary, error) + + // --- Forum operations --- + WriteForumPost(ctx context.Context, post ForumPost) error + GetForumPost(ctx context.Context, id string) (ForumPost, error) + ListForumThreads(ctx context.Context, limit, offset int) ([]ForumThread, error) + ListForumThreadsByCategory(ctx context.Context, categoryID string, limit, offset int) ([]ForumThread, error) + ListForumPostsByThread(ctx context.Context, threadID string, limit int) ([]ForumPost, error) + UpdateForumPostState(ctx context.Context, id string, state string) error + CountForumPosts(ctx context.Context) (int, error) + GetDailyDigestThread(ctx context.Context, categoryID string, date time.Time) (ForumPost, error) + // --- Lifecycle --- Close() error } diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index 00ca1fa4..e0da9a58 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -328,6 +328,40 @@ func (MockStore) GetToolUsageChart(context.Context, time.Time, int) ([]store.Too return nil, nil } +// --- Forum category operations --- + +func (MockStore) WriteForumCategory(context.Context, store.ForumCategory) error { return nil } +func (MockStore) GetForumCategory(context.Context, string) (store.ForumCategory, error) { + return store.ForumCategory{}, nil +} +func (MockStore) ListForumCategories(context.Context) ([]store.ForumCategory, error) { + return nil, nil +} +func (MockStore) ListForumCategorySummaries(context.Context) ([]store.ForumCategorySummary, error) { + return nil, nil +} + +// --- Forum operations --- + +func (MockStore) WriteForumPost(context.Context, store.ForumPost) error { return nil } +func (MockStore) GetForumPost(context.Context, string) (store.ForumPost, error) { + return store.ForumPost{}, nil +} +func (MockStore) ListForumThreads(context.Context, int, int) ([]store.ForumThread, error) { + return nil, nil +} +func (MockStore) ListForumThreadsByCategory(context.Context, string, int, int) ([]store.ForumThread, error) { + return nil, nil +} +func (MockStore) ListForumPostsByThread(context.Context, string, int) ([]store.ForumPost, error) { + return nil, nil +} +func (MockStore) UpdateForumPostState(context.Context, string, string) error { return nil } +func (MockStore) CountForumPosts(context.Context) (int, error) { return 0, nil } +func (MockStore) GetDailyDigestThread(context.Context, string, time.Time) (store.ForumPost, error) { + return store.ForumPost{}, store.ErrNotFound +} + // --- Lifecycle --- func (MockStore) Close() error { return nil } diff --git a/internal/web/mockups/archive/design-a.html b/internal/web/mockups/archive/design-a.html new file mode 100644 index 00000000..fae49f32 --- /dev/null +++ b/internal/web/mockups/archive/design-a.html @@ -0,0 +1,730 @@ + + + + + +mnemonic — Focus + + + + + + + + +
+ +
+
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+ +
+ +
+
decision
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+ +
+ +
+
insight
+
Back button fails to appear due to missing history push in neighbor click handler
+ +
+ +
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+ +
+ +
+
insight
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+ +
+ +
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+ +
+ +
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+ +
+ +
+
decision
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+ +
+
+
+ + +
+
+
+ +
+ cleverness debt + / + Centralized 40+ hardcoded values... +
+
+
+
+
constellation
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+
+ + +
+
+
Today, March 21
+
+
+
10:05 AM
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
insight · agent, config, performance · 85%
+
+
+
9:42 AM
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
decision · python, api, planning · 79%
+
+
+
9:18 AM
+
+
Back button fails to appear due to missing history push in neighbor click handler
+
insight · javascript, ui, debugging · 76%
+
+
+
8:55 AM
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
learning · go, retrieval, performance · 66%
+
+
+
8:31 AM
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
decision · refactoring, config, go · 44%
+
+
+
+
+ + +
+
+ +
4 results · 12ms · spread activation: 3 hops
+ +
+
.92
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · Mar 21
+
+ +
+
.78
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
refactoring · configuration · go · Mar 21
+
+ +
+
.61
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
go · retrieval · performance · Mar 21
+
+ +
+
.43
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
llm · benchmarking · performance · Mar 21
+
+
+
+ + + + diff --git a/internal/web/mockups/archive/design-adaline.html b/internal/web/mockups/archive/design-adaline.html new file mode 100644 index 00000000..9b294d5f --- /dev/null +++ b/internal/web/mockups/archive/design-adaline.html @@ -0,0 +1,854 @@ + + + + + +mnemonic + + + + + +
+ +
+ + +
+
+
+
Recent memories
+ +
+ + + +
+
+
decision
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
+
+ python + api + planning +
+ 79% +
+
+ +
+
insight
+
Back button fails to appear due to missing history push in neighbor click handler
+
+
+ javascript + ui + debugging +
+ 76% +
+
+ +
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
+
+ go + retrieval + performance +
+ 66% +
+
+ +
+
learning
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
+
+ ci + deployment + github +
+ 51% +
+
+ +
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
+
+ refactoring + go +
+ 44% +
+
+ +
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+
+ llm + benchmarking +
+ 39% +
+
+
+
+
+ + +
+
+
+
Friday, March 216 memories
+ +
10:05
insight
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agent · config · performance · 85%
+ +
9:42
decision
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
python · api · planning · 79%
+ +
9:18
insight
Back button fails to appear due to missing history push in neighbor click handler
javascript · ui · debugging · 76%
+ +
8:55
learning
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
go · retrieval · performance · 66%
+ +
8:31
decision
Centralized 40+ hardcoded values into config.yaml to improve system configurability
refactoring · configuration · go · 44%
+ +
Thursday, March 202 memories
+ +
3:10 PM
learning
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
ci · deployment · github · 51%
+ +
1:12 PM
decision
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
python · llm · benchmarking · 38%
+
+
+
+ + +
+
+
+
+ + +
+
4 results · 12ms · 3 hops
+ +
insight
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agent · config · performance · Mar 21
.92
+ +
decision
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
refactoring · configuration · go · Mar 21
.78
+ +
learning
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
go · retrieval · performance · Mar 21
.61
+ +
insight
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
llm · benchmarking · performance · Mar 21
.43
+
+
+
+ + +
+
+
+
+ +
cleverness debt › Centralized 40+ hardcoded values...
+
+
+
ego graph renders here
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+
+
+ + + + diff --git a/internal/web/mockups/archive/design-b.html b/internal/web/mockups/archive/design-b.html new file mode 100644 index 00000000..7d135313 --- /dev/null +++ b/internal/web/mockups/archive/design-b.html @@ -0,0 +1,846 @@ + + + + + +mnemonic — Design B: Structural + + + + + + + + + +
+
+ + +
+ +
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+ +
agentconfigperformance
+
+ + +
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+ +
pythonapiplanning
+
+ + +
+
+
Back button fails to appear due to missing history push in neighbor click handler
+ +
javascriptuidebugging
+
+ + +
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+ +
goretrievalperformance
+
+ + +
+
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+ +
deploymentdocumentationcli
+
+ + +
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+ +
cideploymentgithub
+
+ + +
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+ +
refactoringconfigurationgo
+
+ + +
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+ +
llmbenchmarkingperformance
+
+
+
+
+ + +
+
+
+ + / + cleverness debt audit + / + Centralized 40+ hardcoded values... +
+ +
+
+
+ +
+
D3 force-directed ego graph renders here
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
+ Type + decision +
+
+ Salience + 0.44 +
+
+ Source + mcp +
+
+ Connections + 15 +
+
+ Created + Mar 21, 2026 +
+
+ Last accessed + 2h ago +
+
+ Concepts + refactoring, config, go +
+
+
+
+
+ + +
+
+
+
Today5
+ +
+
10:05 AM
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
mcp85%agent, config, performance
+
+
+ +
+
9:42 AM
+
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
mcp79%python, api, planning
+
+
+ +
+
9:18 AM
+
+
+
Back button fails to appear due to missing history push in neighbor click handler
+
mcp76%javascript, ui, debugging
+
+
+ +
+
8:55 AM
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
mcp66%go, retrieval, performance
+
+
+ +
+
8:31 AM
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
mcp44%refactoring, configuration, go
+
+
+
+ +
+
Yesterday3
+ +
+
4:22 PM
+
+
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
mcp52%deployment, documentation, cli
+
+
+ +
+
3:10 PM
+
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
mcp51%ci, deployment, github
+
+
+ +
+
1:45 PM
+
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
mcp39%llm, benchmarking, performance
+
+
+
+
+
+ + +
+
+ + +
4 results · 12ms
+ +
+
+
0.92
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+ +
+
+ +
+
0.78
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+ +
+
+ +
+
0.61
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+ +
+
+ +
+
0.43
+
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+ +
+
+
+
+
+ + + + diff --git a/internal/web/mockups/archive/design-broadsheet.html b/internal/web/mockups/archive/design-broadsheet.html new file mode 100644 index 00000000..85171449 --- /dev/null +++ b/internal/web/mockups/archive/design-broadsheet.html @@ -0,0 +1,900 @@ + + + + + +mnemonic + + + + + +
+
+
mnemonic
+
+ + + + +
+
77 memories · 3.6 MB
+
+
+ + +
+
+
+ SATURDAY, MARCH 21, 2026 + +
+ +
+ +
+
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
A deep analysis of Mnemonic's agent architecture revealed fundamental asymmetries in how the system compounds knowledge. Retrieval quality improves through Hebbian learning, but consolidation artifacts remain fragile under aggressive grounding verification.
+
+ 10:05 AM +
agent config performance
+ 85% +
+
+ +
+ + +
+
decision
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
After comparing direct DB writes, a custom REST layer, and upstream contribution, the RIMAPI path won on maintainability and community leverage despite higher initial effort.
+
+ 9:42 AM +
python api planning
+ 79% +
+
+ +
+ + +
+
insight
+
Back button fails to appear due to missing history push in neighbor click handler
+
+ 9:18 AM +
javascript ui
+ 76% +
+
+ + +
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus
+
+ 8:55 AM +
go retrieval
+ 66% +
+
+ +
+ +
+
learning
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
+ Mar 20 +
ci deployment
+ 51% +
+
+ +
+ +
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve configurability
+
+ 8:31 AM +
refactoring go
+ 44% +
+
+ + +
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
+
+ 1:45 PM +
llm benchmarking
+ 39% +
+
+ +
+ +
+
decision
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
+ Mar 20 +
python llm fix
+ 38% +
+
+
+
+
+ + +
+
+
+
FRIDAY, MARCH 216 memories
+
+
10:05
+
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · 85%
+
+
+
+
9:42
+
+
decision
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
python · api · planning · 79%
+
+
+
+
9:18
+
+
insight
+
Back button fails to appear due to missing history push in neighbor click handler
+
javascript · ui · debugging · 76%
+
+
+
+
8:55
+
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
go · retrieval · performance · 66%
+
+
+
+
8:31
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
refactoring · configuration · go · 44%
+
+
+ +
THURSDAY, MARCH 202 memories
+
+
3:10 PM
+
+
learning
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
ci · deployment · github · 51%
+
+
+
+
1:12 PM
+
+
decision
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
python · llm · benchmarking · 38%
+
+
+
+
+
+ + +
+
+
+
What do you remember about...
+ +
4 results · 12ms · spread activation: 3 hops
+ +
+
.92
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · Mar 21 10:05
+
+ +
+
.78
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
refactoring · configuration · go · Mar 21 8:31
+
+ +
+
.61
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
go · retrieval · performance · Mar 21 8:55
+
+ +
+
.43
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
llm · benchmarking · performance · Mar 21 1:45 PM
+
+
+
+
+ + +
+
+
+
+ +
+ cleverness debt › Centralized 40+ hardcoded values... +
+
+
+
ego graph renders here
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+
+
+ + + + diff --git a/internal/web/mockups/archive/design-c.html b/internal/web/mockups/archive/design-c.html new file mode 100644 index 00000000..7c266a59 --- /dev/null +++ b/internal/web/mockups/archive/design-c.html @@ -0,0 +1,1483 @@ + + + + + +mnemonic — Design C: Kinetic + + + + + + + + + + +
+
+
+
+ +
semantic search with spread activation
+
+ + +
+ +
+ + +
+
+
+
+
+
+ decision + Mar 21 +
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI over building a custom adapter
+
+ pythonapiplanning +
+
+
+
+
+
+
+ insight + Mar 21 +
+
Back button fails to appear due to missing history push in neighbor click handler — state management in the graph view needs a proper stack
+
+ javascriptuidebugging +
+
+
+ + +
+
+
+
+
+
+ learning + Mar 21 +
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
+ goretrievalperformance +
+
+
+
+
+
+
+ insight + Mar 20 +
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
+ deploymentdocumentationcli +
+
+
+
+
+
+
+ learning + Mar 20 +
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
+ cideploymentgithub +
+
+
+ + +
+
+
+
+
+
+ decision + Mar 21 +
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+ refactoringconfigurationgo +
+
+
+
+
+
+
+ insight + Mar 21 +
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+ llmbenchmarkingperformance +
+
+
+
+
+
+
+ decision + Mar 20 +
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
+ pythonllmbenchmarking +
+
+
+
+
+ + +
+
+
+ +
+ cleverness debt audit + / + Centralized 40+ hardcoded values... +
+
+
+
+ +
+ + + + + + + + + + + + + + +
ego
+ + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+
+ Type + decision +
+
+ Salience + 0.44 +
+
+ Source + mcp +
+
+ Connections + 15 +
+
+ Created + Mar 21, 2026 +
+
+ Concepts + refactoring, config, go +
+
+
+
+
+
+ + +
+
+
+ Today + 8 memories +
+ +
+ +
+
+
+
+ insight + 10:05 AM +
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
+ mcp + 85% + agent · config · performance +
+
+
+ + +
+
+
+
+ decision + 9:42 AM +
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
+ mcp + 79% + python · api · planning +
+
+
+ + +
+
+
+
+ insight + 9:18 AM +
+
Back button fails to appear due to missing history push in neighbor click handler
+
+ mcp + 76% + javascript · ui · debugging +
+
+
+ + +
+
+
+
+ learning + 8:55 AM +
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
+ mcp + 66% + go · retrieval · performance +
+
+
+ + +
+
+
+
+ insight + 8:31 AM +
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
+ mcp + 52% + deployment · documentation · cli +
+
+
+ + +
+
+
+
+ learning + 8:12 AM +
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
+ mcp + 51% + ci · deployment · github +
+
+
+ + +
+
+
+
+ decision + 7:48 AM +
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+ mcp + 44% + refactoring · configuration · go +
+
+
+ + +
+
+
+
+ decision + 7:20 AM +
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
+ mcp + 38% + python · llm · benchmarking +
+
+
+
+
+
+ + +
+
+
+
+ +
+ +
+ 4 results + 12ms +
+ +
+
+
+
0.92
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+ insight + +
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems — perception, consolidation, and dreaming agents all using magic numbers that should be configurable
+
+
+ Salience + 85% +
+
+ Source + mcp +
+
+ Created + Mar 21, 2026 +
+
+ Associations + 12 +
+
+
+ agentconfigperformancearchitecture +
+
+
+ +
+
+
0.78
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+ decision + +
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+
+ Salience + 44% +
+
+ Source + mcp +
+
+ Created + Mar 21, 2026 +
+
+ Associations + 15 +
+
+
+ refactoringconfigurationgo +
+
+
+ +
+
+
0.61
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+ learning + +
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
+
+ Salience + 66% +
+
+ Source + mcp +
+
+ Created + Mar 21, 2026 +
+
+ Associations + 8 +
+
+
+ goretrievalperformance +
+
+
+ +
+
+
0.43
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+ insight + +
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+
+ Salience + 39% +
+
+ Source + mcp +
+
+ Created + Mar 21, 2026 +
+
+ Associations + 5 +
+
+
+ llmbenchmarkingperformance +
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/internal/web/mockups/archive/design-drift.html b/internal/web/mockups/archive/design-drift.html new file mode 100644 index 00000000..d09ea1d1 --- /dev/null +++ b/internal/web/mockups/archive/design-drift.html @@ -0,0 +1,574 @@ + + + + + +mnemonic + + + + + + +
+
mnemonic
+
+
drift
+
timeline
+
cluster
+
+
77 memories
+
+ +
+ +
+ +
+ +
+
+
+
+ +
move your mouse to explore
+ + + + + + diff --git a/internal/web/mockups/archive/design-final.html b/internal/web/mockups/archive/design-final.html new file mode 100644 index 00000000..d0dba11a --- /dev/null +++ b/internal/web/mockups/archive/design-final.html @@ -0,0 +1,407 @@ + + + + + +mnemonic + + + + + +
mnemonic
+ +
+
+
+
+ +
scroll to explore ↓
+ +
+ + +
+
+
+ Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems +
+
+
typeinsight
+
salience0.85
+
when10:05 AM
+
conceptsagent, config, performance
+
+
+
+ +
+
+
+ Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI +
+
+
typedecision
+
salience0.79
+
when9:42 AM
+
conceptspython, api, planning
+
+
+
+ +
+
+
+ Back button fails to appear due to missing history push in neighbor click handler +
+
+
typeinsight
+
salience0.76
+
when9:18 AM
+
conceptsjavascript, ui, debugging
+
+
+
+ +
+
+
+ Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval +
+
+
typelearning
+
salience0.66
+
when8:55 AM
+
conceptsgo, retrieval, performance
+
+
+
+ +
+
+
+ Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers +
+
+
typelearning
+
salience0.51
+
when3:10 PM
+
conceptsci, deployment, github
+
+
+
+ +
+
+
+ Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability +
+
+
typedecision
+
salience0.44
+
when8:31 AM
+
conceptsrefactoring, configuration, go
+
+
+
+ +
+
+
+ Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors +
+
+
typeinsight
+
salience0.39
+
when1:45 PM
+
conceptsllm, benchmarking, performance
+
+
+
+ +
+
+
+ Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1% +
+
+
typedecision
+
salience0.38
+
when1:12 PM
+
conceptspython, llm, fix
+
+
+
+ +
+ + + + diff --git a/internal/web/mockups/archive/design-forum-v2.html b/internal/web/mockups/archive/design-forum-v2.html new file mode 100644 index 00000000..1317b085 --- /dev/null +++ b/internal/web/mockups/archive/design-forum-v2.html @@ -0,0 +1,763 @@ + + + + + +mnemonic — vBulletin + + + + +
+
mnemonic
+
+ + + + +
+
+ ● online + 77 memories + v0.33.0 +
+
+ + +
+
+ Welcome back. Last visit: Today at 08:14 AM +
+ 77 active + 8 fading + 27 archived + 3 new since last visit +
+
+ +
+ + + +
+ + +
+
+
Episodes
+
3 episodes today
+
+
+ Episode + Mem + Files + Last Activity +
+ +
+
EP
Mnemonic v0.31.0 Release and the Linux Watcher Race Condition
Shipped PRs #296-#309. Race condition in watcher_other.go. race conditioninotifypattern decay
+
6
+
3
+
08:55 AM
satisfying
+
+ +
+
EP
System Audit and Recursive Self-Correction
50% encoding failure rate identified. Reinforced Principle 2 for diagnostic tools. system auditencoding failuremeta-cognition
+
6
+
3
+
10:05 AM
satisfying
+
+ +
+
EP
Mnemonic Version Management and Upgrade
Downgraded to v0.29.1. Resolved training script conflicts. version controldeployment
+
3
+
2
+
02:02 AM
neutral
+
+
+ + +
+
+
Recent Memories
+
8 memories · sorted by salience
+
+
+ Memory + Sal + Links + Created +
+ +
+
IN
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agent · config · performance
+
85%
+
15
+
10:05 AM
via mcp
+
+ +
+
DE
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
python · api · planning
+
79%
+
8
+
9:42 AM
via mcp
+
+ +
+
IN
Back button fails to appear due to missing history push in neighbor click handler
javascript · ui · debugging
+
76%
+
4
+
9:18 AM
via mcp
+
+ +
+
LE
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
go · retrieval · performance
+
66%
+
6
+
8:55 AM
via mcp
+
+ +
+
LE
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
ci · deployment · github
+
51%
+
3
+
3:10 PM
via mcp
+
+ +
+
DE
Centralized 40+ hardcoded values into config.yaml to improve system configurability
refactoring · configuration · go
+
44%
+
12
+
8:31 AM
via mcp
+
+ +
+
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax
llm · benchmarking · performance
+
39%
+
5
+
1:45 PM
via mcp
+
+ +
+
DE
Committed JSON repair and benchmark tools; parse rate rose from 78.3% to 98.1%
python · llm · benchmarking
+
38%
+
7
+
1:12 PM
via mcp
+
+
+
+ + +
+
+
+
Mnemonic v0.31.0 Release and the Linux Watcher Race Condition
+
+
mood: satisfying
+
duration: 8:55 – 9:04 AM
+
memories: 6
+
files: config.yaml, CLAUDE.md, extract.go
+
+
+ + +
+ +
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems. Analysis revealed fundamental asymmetries in how the system compounds knowledge.
+
+ agent + config + performance +
+
+
↳ Associated: decision (similarity: 0.82)
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+
+
+ +
+ +
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI (v1.8.2). Option analysis: direct writes (fast, no upstream), adapter pattern (medium, partial upstream), and full contribution (slow, sustainable).
+
+ python + api + planning + decision +
+
+
+ +
+ +
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval. The decay uses exponential falloff with a 1800-second half-life.
+
+ go + retrieval + performance +
+
+
↳ Reinforces: insight (temporal)
+
Mnemonic's retrieval feedback is written to SQLite but never read by the retrieval agent's ranking
+
+
+
+
+
+ + +
+
+ + + + +
+ +
Friday, March 216 memories
+
10:05
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
85%
+
9:42
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
79%
+
9:18
Back button fails to appear due to missing history push in neighbor click handler
76%
+
8:55
Verified context_boost 30-minute decay window and distinguished it from activity_bonus
66%
+
8:31
Centralized 40+ hardcoded values into config.yaml to improve system configurability
44%
+ +
Thursday, March 202 memories
+
3:10 PM
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
51%
+
1:12 PM
Committed JSON repair and benchmark tools; parse rate rose from 78.3% to 98.1%
38%
+
+ + +
+
+ + +
+
4 results12msspread activation: 3 hops
+
ScoreMemorySalLinksCreated
+ +
.92
IN
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agent · config · performance
85%
15
10:05 AM
+ +
.78
DE
Centralized 40+ hardcoded values into config.yaml to improve system configurability
refactoring · configuration · go
44%
12
8:31 AM
+ +
.61
LE
Verified context_boost 30-minute decay window and distinguished it from activity_bonus
go · retrieval · performance
66%
6
8:55 AM
+ +
.43
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
llm · benchmarking · performance
39%
5
1:45 PM
+
+ +
+ mnemonic v0.33.0 + 77 active + 8 fading + 27 archived + encoding: idle + consolidation: 16:31 +
+ + + + diff --git a/internal/web/mockups/archive/design-forum-v3.html b/internal/web/mockups/archive/design-forum-v3.html new file mode 100644 index 00000000..43ca2a1c --- /dev/null +++ b/internal/web/mockups/archive/design-forum-v3.html @@ -0,0 +1,790 @@ + + + + + +mnemonic + + + + +
+
mnemonic v0.33.0 — cognitive memory daemon
+
Dashboard | Config | Export
+
+ + + + +
+
mnemonic mnemonic (project)
+ +
+
Welcome Back
+
+ Last visit: Today at 08:14 AM · 3 new memories since then +
+ 77 active + 8 fading + 27 archived + 9 merged +
+
+
+ + +
+
+
Episodes
+
3 episodes today
+
+
EpisodeMemFilesLast Activity
+ +
+
EP
+
Mnemonic v0.31.0 Release and the Linux Watcher Race Condition
Shipped PRs #296-#309. Race condition in watcher_other.go race conditioninotify
+
6
+
3
+
08:55 AM
satisfying
+
+ + +
+
6 memories in this episodeView full thread →
+
IN
Critical audit found cleverness debt and hardcoded agent weights across 6+ subsystems
85%
10:05
+
DE
Evaluated 3 options for RLE write endpoints; recommended upstream RIMAPI
79%
9:42
+
IN
Back button fails to appear due to missing history push in neighbor click handler
76%
9:18
+
LE
Verified context_boost 30-minute decay window and distinguished from activity_bonus
66%
8:55
+
+ +
+
EP
+
System Audit and Recursive Self-Correction
50% encoding failure rate. Reinforced Principle 2. system auditencoding
+
6
+
3
+
10:05 AM
satisfying
+
+
+
6 memories in this episodeView full thread →
+
IN
Audit identified 50% encoding failure rate; missed opportunity for root cause analysis
85%
10:04
+
DE
Reinforced principle p2: prefer diagnostic tools over recall for audits
80%
10:04
+
LE
Created structured strategies for system audits, config reviews, and self-improvement
90%
10:05
+
+ +
+
EP
+
Mnemonic Version Management and Upgrade
Downgraded to v0.29.1. Training script conflicts. deployment
+
3
+
2
+
02:02 AM
neutral
+
+
+ + +
+
+
Recent Memories
+
sorted by salience
+
+
MemorySalLinksCreated
+ +
LE
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
cideploymentgithub
51%
3
3:10 PM
mcp
+ +
DE
Centralized 40+ hardcoded values into config.yaml to improve configurability
refactoringconfigurationgo
44%
12
8:31 AM
mcp
+ +
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
llmbenchmarking
39%
5
1:45 PM
mcp
+ +
DE
Committed JSON repair and benchmark tools; parse rate 78.3% → 98.1%
pythonllm
38%
7
1:12 PM
mcp
+
+
+ + +
+
mnemonic Episodes Mnemonic v0.31.0 Release
+ +
+
+
Mnemonic v0.31.0 Release and the Linux Watcher Race Condition
+
+ Mood: satisfying + Duration: 8:55 – 9:04 AM + Memories: 6 + Files: config.yaml, CLAUDE.md, extract.go +
+
+ +
+
+
Insight
+
Salience: 85%
+
Links: 15
+
Source: mcp
+
10:05 AM
+
+
+
Memory #1 of 6View in Graph
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems. Analysis revealed fundamental asymmetries in how the system compounds knowledge. Retrieval quality improves through Hebbian learning, but consolidation artifacts remain fragile.
+ +
+
↳ Associated Memory (decision · similarity: 0.82)
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+
+
+ +
+
+
Decision
+
Salience: 79%
+
Links: 8
+
Source: mcp
+
9:42 AM
+
+
+
Memory #2 of 6View in Graph
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI (v1.8.2). Option analysis: direct writes (fast, no upstream), adapter pattern (medium), and full contribution (slow, sustainable). Chose full contribution for long-term maintainability.
+ +
+
+ +
+
+
Learning
+
Salience: 66%
+
Links: 6
+
Source: mcp
+
8:55 AM
+
+
+
Memory #3 of 6View in Graph
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval. The decay uses exponential falloff with a 1800-second half-life, applied in the spread activation scoring pipeline.
+ +
+
↳ Reinforces (temporal)
+
Mnemonic's retrieval feedback is written to SQLite but never read by the retrieval agent's ranking algorithm
+
+
+
+
+
+ + +
+
mnemonic Timeline
+
Friday, March 216 memories
+
10:05
Critical audit found cleverness debt and hardcoded agent weights across 6+ subsystems
85%
+
9:42
Evaluated 3 options for RLE write endpoints; recommended upstream RIMAPI
79%
+
9:18
Back button fails to appear due to missing history push in neighbor click handler
76%
+
8:55
Verified context_boost 30-minute decay window and distinguished from activity_bonus
66%
+
8:31
Centralized 40+ hardcoded values into config.yaml to improve configurability
44%
+
Thursday, March 202 memories
+
3:10 PM
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
51%
+
1:12 PM
Committed JSON repair and benchmark tools; parse rate 78.3% → 98.1%
38%
+
+ + +
+
mnemonic Search
+
+ + +
+
4 results · 12ms · spread activation: 3 hops
+
ScoreMemorySalLinksCreated
+
.92
IN
Critical audit found cleverness debt and hardcoded agent weights across 6+ subsystems
85%
15
10:05
+
.78
DE
Centralized 40+ hardcoded values into config.yaml to improve configurability
44%
12
8:31
+
.61
LE
Verified context_boost 30-minute decay window and distinguished from activity_bonus
66%
6
8:55
+
.43
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
39%
5
1:45
+
+ +
+ mnemonic v0.33.0 + 77 active + 8 fading + 27 archived + encoding: idle + consolidation: 16:31 +
+ + + + diff --git a/internal/web/mockups/archive/design-forum.html b/internal/web/mockups/archive/design-forum.html new file mode 100644 index 00000000..ca60ffc3 --- /dev/null +++ b/internal/web/mockups/archive/design-forum.html @@ -0,0 +1,816 @@ + + + + + +mnemonic + + + + + +
+ + +
+ + 77 memories + 3.6 MB + v0.33.0 +
+
+ + +
+ + + + + +
+
+ Episodes + Memories + Files + Last Activity +
+ +
+
+ EPISODE + Mnemonic v0.31.0 Release and the Linux Watcher Race Condition + satisfying +
Shipped PRs #296-#309. Hit a wall when the Linux filesystem watcher failed to deliver events after daemon restarts. Root cause: synchronization failure in watcher_other.go.
+
+
6
+
3
+
08:55 AM
+
+ +
+
+ EPISODE + System Audit and Recursive Self-Correction + satisfying +
Comprehensive health audit identified 50% encoding failure rate. AI realized it stopped at speculation instead of investigating root cause in logs.
+
+
6
+
3
+
10:05 AM
+
+ +
+
+ EPISODE + Mnemonic Version Management and Upgrade + neutral +
System downgraded to v0.29.1, likely a rollback to stable state. Resolved training script conflicts.
+
+
3
+
2
+
02:02 AM
+
+
+ + +
+
+ Recent Memories + Salience + Links + Created +
+ +
+
+ + Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems +
agentconfigperformance
+
+
85%
+
15
+
10:05 AM
+
+ +
+
+ + Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI +
pythonapiplanning
+
+
79%
+
8
+
9:42 AM
+
+ +
+
+ + Back button fails to appear due to missing history push in neighbor click handler +
javascriptuidebugging
+
+
76%
+
4
+
9:18 AM
+
+ +
+
+ + Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval +
goretrievalperformance
+
+
66%
+
6
+
8:55 AM
+
+ +
+
+ + Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml +
cideploymentgithub
+
+
51%
+
3
+
3:10 PM
+
+ +
+
+ + Centralized 40+ hardcoded values into config.yaml to improve system configurability +
refactoringconfigurationgo
+
+
44%
+
12
+
8:31 AM
+
+ +
+
+ + Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors +
llmbenchmarkingperformance
+
+
39%
+
5
+
1:45 PM
+
+ +
+
+ + Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1% +
pythonllmbenchmarking
+
+
38%
+
7
+
1:12 PM
+
+
+
+ + +
+ + +
Friday, March 216 memories
+ +
10:05
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
85%
+
9:42
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
79%
+
9:18
Back button fails to appear due to missing history push in neighbor click handler
76%
+
8:55
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
66%
+
8:31
Centralized 40+ hardcoded values into config.yaml to improve system configurability
44%
+ +
Thursday, March 202 memories
+ +
3:10 PM
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
51%
+
1:12 PM
Committed JSON repair and benchmark tools; parse rate rose from 78.3% to 98.1%
38%
+
+ + +
+ +
+ 4 results + 12ms + 3 hops +
+ +
+ Score + Memory + Salience + Links + Created +
+ +
+
.92
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agentconfigperformance
+
85%
+
15
+
10:05 AM
+
+ +
+
.78
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
refactoringconfigurationgo
+
44%
+
12
+
8:31 AM
+
+ +
+
.61
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
goretrievalperformance
+
66%
+
6
+
8:55 AM
+
+ +
+
.43
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
llmbenchmarkingperformance
+
39%
+
5
+
1:45 PM
+
+
+ + +
+
+ +
cleverness debt › Centralized 40+ hardcoded values...
+
+
+
ego graph
+
+
DECISION
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+ + + + + + + diff --git a/internal/web/mockups/archive/design-wild.html b/internal/web/mockups/archive/design-wild.html new file mode 100644 index 00000000..efd938d8 --- /dev/null +++ b/internal/web/mockups/archive/design-wild.html @@ -0,0 +1,1019 @@ + + + + + +mnemonic + + + + + + + +
+
mnemonic
+
+ + + + +
+
+ + 77 memories · 3.6 mb +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + cleverness debt + / + Centralized 40+ hardcoded values... +
+
+
+ +
+
+ DEC +
+
Centralized 40+ hardcoded values into config.yaml
+
+ +
+ + +
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+ + +
+
+
+
today · march 21
+ +
+
10:05 AM
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · 85%
+
+ +
+
9:42 AM
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
python · api · planning · 79%
+
+ +
+
9:18 AM
+
Back button fails to appear due to missing history push in neighbor click handler
+
javascript · ui · debugging · 76%
+
+ +
+
8:55 AM
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
go · retrieval · performance · 66%
+
+ +
+
8:31 AM
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
refactoring · configuration · go · 44%
+
+ +
yesterday · march 20
+ +
+
4:22 PM
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
deployment · documentation · cli · 52%
+
+ +
+
3:10 PM
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
ci · deployment · github · 51%
+
+ +
+
1:45 PM
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
python · llm · benchmarking · 38%
+
+
+
+
+ + +
+
+
+
+ +
4 results · 12ms · 3 hops spread activation
+
+ +
+
.92
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · Mar 21
+
+ +
+
.78
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
refactoring · configuration · go · Mar 21
+
+ +
+
.61
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
go · retrieval · performance · Mar 21
+
+ +
+
.43
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
llm · benchmarking · performance · Mar 21
+
+
+
+
+ + + + diff --git a/internal/web/server.go b/internal/web/server.go index cfcd2599..bcb20528 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -7,7 +7,7 @@ import ( "net/http" ) -//go:embed static/* +//go:embed static var staticFiles embed.FS // RegisterRoutes registers the web UI routes on the given ServeMux. diff --git a/internal/web/static/css/base.css b/internal/web/static/css/base.css new file mode 100644 index 00000000..e3158805 --- /dev/null +++ b/internal/web/static/css/base.css @@ -0,0 +1,117 @@ +/* Auto-extracted from index.html — base */ + +* { margin: 0; padding: 0; box-sizing: border-box; } + + html, body { + width: 100%; height: 100%; + background: var(--bg-primary); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + overflow: hidden; + } + + #app { display: flex; flex-direction: column; height: 100vh; } + +/* ── Views ── */ + .view-container { flex: 1; overflow: hidden; position: relative; } + .view { + position: absolute; inset: 0; + overflow-y: auto; overflow-x: hidden; + display: none; + } + .view.active { display: block; } + .view::-webkit-scrollbar { width: 6px; } + .view::-webkit-scrollbar-track { background: transparent; } + .view::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; } + + + +/* ── Toast ── */ + .toast-container { + position: fixed; bottom: 20px; right: 20px; + z-index: 300; display: flex; flex-direction: column-reverse; gap: 8px; + } + .toast { + padding: 12px 16px; border-radius: var(--radius-md); + background: var(--bg-secondary); border: 1px solid var(--border-color); + color: var(--text-primary); font-size: 0.85rem; box-shadow: var(--shadow-lg); + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; + display: flex; align-items: center; gap: 8px; + } + .toast.success { border-color: var(--accent-green); } + .toast.error { border-color: var(--accent-red); } + .toast-icon { font-size: 1rem; } + @keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } + @keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateY(10px); } } + + + +/* ── Update Badge ── */ + .update-badge { + display: inline-block; margin-left: 8px; padding: 4px 12px; + font-size: 0.75rem; font-weight: 700; border-radius: 10px; letter-spacing: 0.02em; + background: var(--accent-green); color: #000; + -webkit-text-fill-color: #000; -webkit-background-clip: border-box; background-clip: border-box; + cursor: pointer; animation: badgePulse 2s ease-in-out infinite; + white-space: nowrap; + } + .update-badge:hover { opacity: 0.85; } + .update-badge.updating { background: var(--text-dim); cursor: wait; animation: none; } + #navVersion:hover { color: var(--text-secondary); text-decoration: underline; } + .update-changelog { font-size: 0.7rem; margin-left: 6px; color: var(--text-dim); text-decoration: none; white-space: nowrap; } + .update-changelog:hover { color: var(--text-secondary); text-decoration: underline; } + @keyframes badgePulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } + + + +/* ── Loading ── */ + .skeleton { + background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-card) 50%, var(--bg-tertiary) 75%); + background-size: 200% 100%; animation: shimmer 1.5s infinite; + border-radius: var(--radius-sm); + } + @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + .skeleton-card { height: 80px; margin-bottom: 8px; border-radius: var(--radius-md); } + .empty-state { text-align: center; padding: 60px 20px; color: var(--text-dim); } + .empty-state-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.5; } + .empty-state-text { font-size: 0.95rem; } + .empty-state-sub { font-size: 0.8rem; margin-top: 6px; color: var(--text-dim); } + .spinner { + width: 20px; height: 20px; + border: 2px solid var(--border-color); border-top-color: var(--accent-cyan); + border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; + } + @keyframes spin { to { transform: rotate(360deg); } } + + + +/* ── 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; } + .recall-hero h2 { font-size: 1.2rem; } + .recall-results { padding: 0 16px 16px; } + .remember-panel { padding: 0 16px; } + .explore-view { padding: 12px 16px; } + .explore-search { width: 140px; } + .activity-drawer { width: 100vw; max-width: 100vw; } + .timeline-header { padding: 12px 16px 10px; } + .timeline-body { padding: 0 16px 40px; } + .timeline-search { width: 140px; } + .timeline-type-filters { margin-left: 0; margin-top: 6px; } + .timeline-project-filters { margin-left: 0; margin-top: 4px; } + .tl-card { margin-left: 16px; } + .tl-card::before { left: -12px; } + .tl-card::after { left: -8px; } + .agent-grid { grid-template-columns: 1fr; } + .agent-view { padding: 12px 16px; } + .agent-header { flex-direction: column; align-items: flex-start; gap: 8px; } + .agent-memory-bar { grid-template-columns: 1fr 1fr; } + .agent-chat-panel { height: 500px; } + .llm-view { padding: 12px 16px; } + .llm-cards { grid-template-columns: repeat(3, 1fr); } + .llm-grid { grid-template-columns: 1fr; } + } + diff --git a/internal/web/static/css/components.css b/internal/web/static/css/components.css new file mode 100644 index 00000000..ac1bb01f --- /dev/null +++ b/internal/web/static/css/components.css @@ -0,0 +1,589 @@ +/* ══════════════════════════════════════════════ + mnemonic — Forum Components + phpBB-inspired semantic structure: + ul.topiclist > li.row > dl.row-item > dt + dd + ══════════════════════════════════════════════ */ + +/* ── Category container (like phpBB .forabg) ── */ +.forabg { + margin: 6px 16px; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} +.forabg .inner { overflow: hidden; } +.forabg .inner.collapsed { display: none; } + +/* ── Topic/forum lists (definition list columns) ── */ +ul.topiclist { + display: block; + margin: 0; + padding: 0; + list-style: none; +} +ul.topiclist > li { + display: block; + list-style: none; +} +ul.topiclist dt, +ul.topiclist dd { + display: block; + float: left; + box-sizing: border-box; +} + +/* dt takes full width, negative margin creates space for dd columns + phpBB prosilver uses -440px with 95px+95px+250px dd columns. + We use -345px: 80px + 80px + 185px for our narrower layout. */ +ul.topiclist dt { + width: 100%; + margin-right: -345px; + font-size: 0.88rem; +} +ul.topiclist dt .list-inner { + margin-right: 345px; + padding: 6px 10px; + line-height: 1.45; +} + +/* dd columns — fixed widths, floated right (phpBB pattern) */ +ul.topiclist dd { + width: 80px; + text-align: center; + padding: 6px 4px; + font-size: 0.82rem; + color: var(--text-dim); + font-family: var(--mono, 'SF Mono', Monaco, monospace); + font-feature-settings: 'tnum' 1; + border-left: 1px solid var(--border-subtle); + line-height: 2; +} +ul.topiclist dd.lastpost { + width: 185px; + text-align: right; + padding-right: 10px; + font-family: var(--font, inherit); + font-size: 0.78rem; + line-height: 1.35; +} +ul.topiclist dd dfn { + /* Screen-reader only labels for columns */ + position: absolute; + overflow: hidden; + clip: rect(0, 0, 0, 0); + width: 1px; height: 1px; + margin: -1px; padding: 0; border: 0; +} + +/* ── Row styling ── */ +li.row { + border-top: 1px solid var(--border-subtle); + overflow: hidden; /* Critical: contains the floated dt/dd elements */ + cursor: pointer; + transition: background 0.08s; +} +li.row::after { + content: ''; + display: table; + clear: both; +} +ul.topiclist::after { + content: ''; + display: table; + clear: both; +} +dl.row-item { + overflow: hidden; /* Contains floated dt/dd */ +} +dl.row-item::after { + content: ''; + display: table; + clear: both; +} +li.row:first-child { border-top: 0; } +li.row.bg1 { background: var(--bg-row, var(--bg-secondary)); } +li.row.bg2 { background: var(--bg-row-alt, var(--bg-card)); } +li.row:hover { background: var(--bg-row-hover, var(--bg-tertiary)); } + +/* Salience-driven weight */ +li.row.sal-hi dt .forumtitle { color: var(--link, var(--accent-cyan)); font-weight: bold; } +li.row.sal-mid dt .forumtitle { color: var(--text-secondary); font-weight: normal; } +li.row.sal-lo dt .forumtitle { color: var(--text-dim); font-weight: normal; } +li.row.sal-lo dd { color: var(--text-dim); opacity: 0.7; } + +/* State classes */ +li.row.sticky { border-left: 3px solid var(--accent-yellow, var(--accent-orange)); } +li.row.announce { border-left: 3px solid var(--accent-violet, var(--accent-pink)); } + +/* ── Header row (column labels) ── */ +li.header { + background: linear-gradient(to bottom, rgba(92,114,184,0.12), rgba(92,114,184,0.04)); + border-bottom: 1px solid var(--border-color); + cursor: default; +} +li.header dt, +li.header dd { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + font-weight: bold; + padding-top: 4px; + padding-bottom: 4px; + font-family: var(--font, inherit); +} +li.header dt .list-inner { padding: 4px 10px; } + +/* ── Category header bar ── */ +.forabg-head { + padding: 5px 10px; + background: linear-gradient(to bottom, rgba(92,114,184,0.15), rgba(92,114,184,0.05)); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; +} +.forabg-title { + font-size: 0.95rem; + font-weight: bold; + color: var(--text-primary); +} +.forabg-meta { + font-size: 0.78rem; + color: var(--text-dim); +} + +/* ── Forum title & description in rows ── */ +.forumtitle { + font-weight: bold; + color: var(--link, var(--accent-cyan)); + text-decoration: none; + display: block; +} +li.row:hover .forumtitle { color: var(--link-hover, var(--accent-blue)); text-decoration: underline; } +.forum-desc { + display: block; + font-size: 0.82rem; + color: var(--text-dim); + margin-top: 2px; +} + +/* ── Status icons (like phpBB folder icons) ── */ +.row-item-link { + float: left; + margin-right: 8px; + margin-top: 2px; +} +.status-icon { + width: 24px; height: 24px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: bold; + font-family: var(--mono, monospace); + flex-shrink: 0; +} +.status-icon.icon-ep { background: rgba(92,114,184,0.15); color: var(--link, var(--accent-cyan)); border: 1px solid rgba(92,114,184,0.25); } +.status-icon.icon-in { background: rgba(154,114,184,0.12); color: var(--insight, var(--accent-violet)); border: 1px solid rgba(154,114,184,0.2); } +.status-icon.icon-de { background: rgba(208,152,46,0.12); color: var(--decision, var(--accent-yellow)); border: 1px solid rgba(208,152,46,0.2); } +.status-icon.icon-le { background: rgba(74,142,184,0.12); color: var(--learning, var(--accent-blue)); border: 1px solid rgba(74,142,184,0.2); } +.status-icon.icon-er { background: rgba(184,90,74,0.12); color: var(--error, var(--accent-red)); border: 1px solid rgba(184,90,74,0.2); } +.status-icon.icon-gn { background: rgba(100,100,120,0.1); color: var(--text-dim); border: 1px solid rgba(100,100,120,0.15); } + +/* "New" indicator dot (like phpBB unread) */ +.status-icon.unread { position: relative; } +.status-icon.unread::after { + content: ''; + position: absolute; + top: -3px; right: -3px; + width: 7px; height: 7px; + border-radius: 50%; + background: var(--accent-green, #3FB950); + border: 1.5px solid var(--bg-primary); +} + +/* ── Concept tags in rows ── */ +.forum-tags { display: inline; margin-left: 4px; } +.forum-tag { + font-size: 0.75rem; + color: var(--text-dim); + background: rgba(255,255,255,0.03); + padding: 0 4px; + margin-left: 2px; + font-family: var(--mono, monospace); + border: 1px solid var(--border-subtle); + border-radius: 2px; +} + +/* ── Last post column content ── */ +.lastpost-title { + color: var(--link, var(--accent-cyan)); + font-size: 0.78rem; + text-decoration: none; +} +.lastpost-title:hover { text-decoration: underline; } +.lastpost-by { color: var(--text-dim); font-size: 0.75rem; } +.lastpost-time { color: var(--text-dim); font-size: 0.72rem; font-family: var(--mono, monospace); } + +/* ── Expandable zone (nested items under a row) ── */ +.expand-zone { + display: none; + background: var(--bg-primary); + border-top: 1px solid var(--border-subtle); + border-bottom: 2px solid var(--border-color); +} +.expand-zone.open { display: block; } +.expand-header { + padding: 4px 10px 4px 42px; + font-size: 0.82rem; + color: var(--text-dim); + background: rgba(92,114,184,0.04); + border-bottom: 1px solid var(--border-subtle); + display: flex; + justify-content: space-between; +} +.expand-header a { color: var(--link, var(--accent-cyan)); text-decoration: none; font-weight: bold; } +.expand-header a:hover { text-decoration: underline; } + +/* ══════════════════════════════════════════════ + POST LAYOUT (Thread view — phpBB postbit) + ══════════════════════════════════════════════ */ + +/* Thread wrapper */ +.thread-wrap { + margin: 8px 16px; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} +.thread-top { + padding: 10px 12px; + background: linear-gradient(to bottom, rgba(92,114,184,0.1), rgba(92,114,184,0.03)); + border-bottom: 1px solid var(--border-color); +} +.thread-title-big { + font-size: 1.2rem; + font-weight: bold; + color: var(--text-primary); + margin-bottom: 4px; +} +.thread-meta { + font-size: 0.85rem; + color: var(--text-dim); + display: flex; + gap: 14px; + flex-wrap: wrap; +} +.thread-meta b { color: var(--text-secondary); font-weight: bold; } + +.post { + overflow: hidden; + border-bottom: 1px solid var(--border-color); +} +.post.bg1 { background: var(--bg-row, var(--bg-secondary)); } +.post.bg2 { background: var(--bg-row-alt, var(--bg-card)); } +.post .inner { + display: flex; + min-height: 100px; +} + +/* User/agent profile sidebar */ +.postprofile { + width: 140px; + flex-shrink: 0; + padding: 10px 8px; + text-align: center; + border-right: 1px solid var(--border-subtle); + list-style: none; + margin: 0; +} +.postprofile dt { + font-weight: bold; + color: var(--text-primary); + margin-bottom: 6px; + font-size: 0.88rem; +} +.postprofile .avatar-container { + width: 48px; height: 48px; + margin: 0 auto 6px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: bold; + font-family: var(--mono, monospace); +} +.postprofile dd { + font-size: 0.75rem; + color: var(--text-dim); + margin: 2px 0; + list-style: none; +} +.postprofile dd strong { color: var(--text-secondary); } +.postprofile .profile-rank { + font-size: 0.72rem; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + margin-bottom: 6px; +} +.rank-insight { color: var(--insight); background: rgba(154,114,184,0.1); } +.rank-decision { color: var(--decision); background: rgba(208,152,46,0.1); } +.rank-learning { color: var(--learning); background: rgba(74,142,184,0.1); } +.rank-error { color: var(--error); background: rgba(184,90,74,0.1); } +.rank-general { color: var(--text-dim); background: rgba(100,100,120,0.08); } + +/* Post content area */ +.postbody { + flex: 1; + padding: 10px 12px; + min-width: 0; +} +.postbody h3 { + font-size: 0.92rem; + font-weight: bold; + color: var(--text-primary); + margin-bottom: 4px; +} +.postbody .post-meta { + font-size: 0.75rem; + color: var(--text-dim); + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + justify-content: space-between; +} +.postbody .content { + font-size: 0.88rem; + line-height: 1.55; + color: var(--text-secondary); + margin-bottom: 8px; +} +.postbody .post-tags { + display: flex; + gap: 3px; + flex-wrap: wrap; + margin-top: 8px; +} + +/* Quote blocks (associated memories) */ +blockquote.quote { + border-left: 3px solid var(--border-color); + margin: 8px 0 8px 0; + padding: 6px 10px; + background: rgba(255,255,255,0.02); + border-radius: 0 4px 4px 0; +} +blockquote.quote .quote-header { + font-size: 0.75rem; + font-weight: bold; + color: var(--text-dim); + margin-bottom: 4px; +} +blockquote.quote .quote-body { + font-size: 0.85rem; + color: var(--text-dim); + font-style: italic; +} + +/* ══════════════════════════════════════════════ + WELCOME PANEL + ══════════════════════════════════════════════ */ + +.welcome-panel { + margin: 8px 16px; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} +.welcome-bar { + padding: 6px 10px; + background: linear-gradient(to bottom, rgba(92,114,184,0.1), rgba(92,114,184,0.03)); + border-bottom: 1px solid var(--border-color); + font-size: 0.92rem; + font-weight: bold; + color: var(--text-primary); +} +.welcome-body { + padding: 8px 10px; + font-size: 0.85rem; + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + color: var(--text-secondary); +} +.welcome-body .stats { display: flex; gap: 14px; } +.welcome-body .stat-val { color: var(--text-primary); font-weight: bold; } + +/* ══════════════════════════════════════════════ + RECALL / SEARCH + ══════════════════════════════════════════════ */ + +.recall-bar { + padding: 6px 16px; + display: flex; + gap: 6px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-secondary); +} +.recall-bar input { + flex: 1; + background: var(--bg-primary); + border: 1px solid var(--border-subtle); + color: var(--text-primary); + padding: 4px 8px; + font-size: 0.92rem; + font-family: inherit; + outline: none; + border-radius: 3px; +} +.recall-bar input:focus { border-color: var(--link, var(--accent-cyan)); } +.recall-bar input::placeholder { color: var(--text-dim); } +.recall-info { + padding: 4px 16px; + font-size: 0.8rem; + color: var(--text-dim); + font-family: var(--mono, monospace); + border-bottom: 1px solid var(--border-subtle); +} + +/* ══════════════════════════════════════════════ + TIMELINE + ══════════════════════════════════════════════ */ + +.tl-head { + padding: 5px 16px; + font-size: 0.88rem; + font-weight: bold; + color: var(--text-dim); + background: linear-gradient(to bottom, rgba(92,114,184,0.08), rgba(92,114,184,0.02)); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 30px; + z-index: 50; + display: flex; + justify-content: space-between; +} +.tl-head-count { font-weight: normal; color: var(--text-dim); font-size: 0.82rem; } + +/* ── Timeline rows (flexbox, not dl/dt/dd) ── */ +.tl-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 16px; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + transition: background 0.06s; +} +.tl-row.bg1 { background: var(--bg-row, var(--bg-secondary)); } +.tl-row.bg2 { background: var(--bg-row-alt, var(--bg-card)); } +.tl-row:hover { background: var(--bg-row-hover, var(--bg-tertiary)); } +.tl-row-title { + flex: 1; + min-width: 0; + font-size: 0.88rem; + color: var(--link, var(--accent-cyan)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tl-row:hover .tl-row-title { color: var(--link-hover, var(--accent-blue)); text-decoration: underline; } +.tl-row-tags { + display: flex; + gap: 3px; + flex-shrink: 0; + max-width: 300px; + overflow: hidden; +} +.tl-row-time { + font-family: var(--mono, monospace); + font-size: 0.78rem; + color: var(--text-dim); + flex-shrink: 0; + width: 60px; + text-align: right; + font-feature-settings: 'tnum' 1; +} +.tl-row-sal { + font-family: var(--mono, monospace); + font-size: 0.75rem; + color: var(--text-dim); + flex-shrink: 0; + width: 36px; + text-align: right; + font-feature-settings: 'tnum' 1; +} + +/* ══════════════════════════════════════════════ + BUTTONS + ══════════════════════════════════════════════ */ + +.btn-forum { + font-size: 0.85rem; + font-weight: bold; + color: var(--text-primary); + background: linear-gradient(to bottom, rgba(92,114,184,0.2), rgba(92,114,184,0.1)); + border: 1px solid var(--border-color); + padding: 4px 14px; + cursor: pointer; + font-family: inherit; + border-radius: 3px; + transition: background 0.1s; +} +.btn-forum:hover { background: rgba(92,114,184,0.25); } + +/* ══════════════════════════════════════════════ + TOAST NOTIFICATIONS + ══════════════════════════════════════════════ */ + +.toast-container { + position: fixed; + top: 60px; right: 16px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 6px; +} +.toast { + padding: 8px 14px; + border-radius: 4px; + font-size: 0.82rem; + color: var(--text-primary); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-md); + animation: toastIn 0.2s ease-out; +} +.toast--success { border-left: 3px solid var(--accent-green); } +.toast--error { border-left: 3px solid var(--accent-red); } +@keyframes toastIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes fadeIn { + from { opacity: 0; background: color-mix(in srgb, var(--accent-cyan) 8%, transparent); } + to { opacity: 1; background: transparent; } +} + +/* ══════════════════════════════════════════════ + RESPONSIVE + ══════════════════════════════════════════════ */ +@media (max-width: 640px) { + ul.topiclist dt { margin-right: -130px; } + ul.topiclist dt .list-inner { margin-right: 130px; } + ul.topiclist dd { width: 40px; font-size: 0.7rem; } + ul.topiclist dd.lastpost { width: 90px; } + .postprofile { width: 100px; } + .forabg { margin: 4px 8px; } +} diff --git a/internal/web/static/css/drawer.css b/internal/web/static/css/drawer.css new file mode 100644 index 00000000..d147ce58 --- /dev/null +++ b/internal/web/static/css/drawer.css @@ -0,0 +1,105 @@ +/* Auto-extracted from index.html — drawer */ + +/* ── Activity Drawer ── */ + .activity-backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); + z-index: 200; display: none; opacity: 0; transition: opacity 0.3s; + } + .activity-backdrop.open { display: block; opacity: 1; } + .activity-drawer { + position: fixed; top: 0; right: 0; bottom: 0; + width: 400px; max-width: 90vw; + background: var(--bg-secondary); + border-left: 1px solid var(--border-color); + z-index: 201; transform: translateX(100%); + transition: transform 0.3s ease; + display: flex; flex-direction: column; + } + .activity-drawer.open { transform: translateX(0); } + .drawer-header { + display: flex; align-items: center; gap: 10px; + padding: 16px 20px; border-bottom: 1px solid var(--border-color); + } + .drawer-header h3 { font-size: 0.95rem; font-weight: 600; flex: 1; } + .drawer-close { + background: none; border: none; color: var(--text-muted); + cursor: pointer; font-size: 1.2rem; padding: 4px; + } + .drawer-close:hover { color: var(--text-primary); } + .drawer-body { flex: 1; overflow-y: auto; padding: 12px; } + .drawer-section-title { + font-size: 0.7rem; font-weight: 600; color: var(--text-dim); + text-transform: uppercase; letter-spacing: 0.05em; + padding: 8px 8px 4px; margin-top: 8px; + } + .concept-row { + display: flex; align-items: center; gap: 8px; + padding: 4px 8px; font-size: 0.78rem; + } + .concept-name { flex: 0 0 auto; min-width: 80px; color: var(--text-primary); font-weight: 500; } + .concept-bar-bg { + flex: 1; height: 4px; border-radius: 2px; + background: var(--bg-tertiary); + } + .concept-bar-fill { + height: 100%; border-radius: 2px; + background: var(--accent-green); + transition: width 0.3s ease; + } + .concept-time { flex: 0 0 auto; font-size: 0.7rem; color: var(--text-muted); min-width: 45px; text-align: right; } + + +/* ── 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; + padding: 2px 8px; border-radius: var(--radius-sm); + transition: all 0.15s; + } + .pattern-archive-btn:hover { background: var(--warning-bg); color: var(--warning-text); border-color: var(--warning-text); } + .event-item { + display: flex; align-items: flex-start; gap: 10px; + padding: 8px; border-radius: var(--radius-sm); + transition: background 0.15s; animation: fadeIn 0.3s ease; + } + .event-item:hover { background: var(--bg-tertiary); } + .event-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; } + .event-dot.green { background: var(--accent-green); } + .event-dot.blue { background: var(--accent-blue); } + .event-dot.violet { background: var(--accent-violet); } + .event-dot.cyan { background: var(--accent-cyan); } + .event-dot.orange { background: var(--accent-orange); } + .event-dot.yellow { background: var(--accent-yellow); } + .event-dot.red { background: var(--accent-red); } + .event-body { flex: 1; min-width: 0; } + .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; } + diff --git a/internal/web/static/css/nav.css b/internal/web/static/css/nav.css new file mode 100644 index 00000000..026dcb24 --- /dev/null +++ b/internal/web/static/css/nav.css @@ -0,0 +1,81 @@ +/* Auto-extracted from index.html — nav */ + +/* ── Nav ── */ + .nav { + height: var(--nav-height); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + display: flex; align-items: center; + padding: 0 20px; + gap: 8px; + flex-shrink: 0; + z-index: 100; + } + .nav-brand { + font-size: 1.15rem; font-weight: 700; + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-violet)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; + margin-right: 24px; + user-select: none; + letter-spacing: -0.3px; + } + .nav-brand span { font-size: 1.3rem; margin-right: 4px; } + .nav-brand .update-badge { font-size: 0.7rem; margin-right: 0; } + .nav-tabs { display: flex; gap: 2px; } + .nav-tab { + padding: 8px 16px; border-radius: var(--radius-sm); + color: var(--text-muted); cursor: pointer; + font-size: 0.875rem; font-weight: 500; + border: none; background: none; + transition: all 0.2s; + display: flex; align-items: center; gap: 6px; + } + .nav-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); } + .nav-tab.active { + color: var(--accent-cyan); + background: color-mix(in srgb, var(--accent-cyan) 10%, transparent); + } + .nav-tab svg { width: 16px; height: 16px; } + .nav-spacer { flex: 1; } + .nav-stats { + display: flex; align-items: center; gap: 16px; + font-size: 0.8rem; color: var(--text-dim); + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + } + .nav-stats .stat-val { color: var(--text-muted); font-weight: 600; } + .nav-health { + width: 8px; height: 8px; border-radius: 50%; + background: var(--accent-green); + animation: pulse 2s ease-in-out infinite; + } + .nav-health.degraded { background: var(--accent-yellow); } + .nav-health.down { background: var(--accent-red); animation: none; } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + + .nav-activity-btn { + position: relative; padding: 8px; border-radius: var(--radius-sm); + cursor: pointer; color: var(--text-muted); background: none; border: none; + transition: all 0.2s; + } + .nav-activity-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); } + .nav-activity-btn svg { width: 18px; height: 18px; } + .nav-badge { + position: absolute; top: 2px; right: 2px; + min-width: 16px; height: 16px; padding: 0 4px; + border-radius: 8px; background: var(--accent-red); + color: white; font-size: 0.7rem; font-weight: 700; + display: none; align-items: center; justify-content: center; + } + .nav-badge.visible { display: flex; } + .nav-theme-select { + padding: 4px 8px; border-radius: var(--radius-sm); + cursor: pointer; color: var(--text-muted); background: var(--bg-primary); + border: 1px solid var(--border-subtle); font-size: 0.75rem; + font-family: inherit; outline: none; transition: all 0.2s; + } + .nav-theme-select:hover { color: var(--text-primary); border-color: var(--border-color); } + .nav-theme-select:focus { border-color: var(--accent-cyan); } + .nav-theme-select option { background: var(--bg-secondary); color: var(--text-primary); } + + diff --git a/internal/web/static/css/pages/explore.css b/internal/web/static/css/pages/explore.css new file mode 100644 index 00000000..42ec8f3a --- /dev/null +++ b/internal/web/static/css/pages/explore.css @@ -0,0 +1,161 @@ +/* Auto-extracted from index.html — explore */ + +/* ── Explore View ── */ + .explore-view { padding: 20px 24px; } + .explore-header { + display: flex; align-items: center; gap: 12px; + margin-bottom: 16px; flex-wrap: wrap; + } + .explore-tabs { display: flex; gap: 2px; } + .explore-tab { + padding: 6px 14px; border-radius: var(--radius-sm); + font-size: 0.8rem; font-weight: 500; + color: var(--text-muted); cursor: pointer; + border: none; background: none; transition: all 0.2s; + } + .explore-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); } + .explore-tab.active { color: var(--accent-cyan); background: color-mix(in srgb, var(--accent-cyan) 10%, transparent); } + .explore-search { + margin-left: auto; padding: 6px 12px; + background: var(--bg-secondary); border: 1px solid var(--border-color); + border-radius: var(--radius-sm); color: var(--text-primary); + font-size: 0.8rem; outline: none; width: 200px; + } + .explore-search:focus { border-color: var(--accent-cyan); } + .explore-content { max-width: 1100px; } + .explore-section { display: none; } + .explore-section.active { display: block; } + + /* Episode cards */ + .episode-card { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 16px; margin-bottom: 10px; + cursor: pointer; transition: border-color 0.2s; + } + .episode-card:hover { border-color: var(--border-color); } + .episode-header { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } + .episode-title { font-size: 0.95rem; font-weight: 600; flex: 1; } + .badge { + padding: 2px 8px; border-radius: 10px; + font-size: 0.7rem; font-weight: 600; + } + .badge-success { background: color-mix(in srgb, var(--accent-green) 15%, transparent); color: var(--accent-green); } + .badge-failure { background: color-mix(in srgb, var(--accent-red) 15%, transparent); color: var(--accent-red); } + .badge-ongoing { background: color-mix(in srgb, var(--accent-blue) 15%, transparent); color: var(--accent-blue); } + .badge-blocked { background: color-mix(in srgb, var(--accent-orange) 15%, transparent); color: var(--accent-orange); } + .badge-open { background: color-mix(in srgb, var(--accent-cyan) 15%, transparent); color: var(--accent-cyan); } + .badge-closed { background: color-mix(in srgb, var(--accent-green) 15%, transparent); color: var(--accent-green); } + .badge-paused { background: color-mix(in srgb, var(--accent-orange) 15%, transparent); color: var(--accent-orange); } + .badge-type { background: color-mix(in srgb, var(--accent-teal) 15%, transparent); color: var(--accent-teal); } + .badge-level { background: color-mix(in srgb, var(--accent-violet) 15%, transparent); color: var(--accent-violet); } + .episode-summary { font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; margin-bottom: 8px; } + .episode-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .episode-concepts { display: flex; gap: 4px; flex-wrap: wrap; } + .episode-date { font-size: 0.75rem; color: var(--text-dim); margin-left: auto; } + .episode-expanded { + display: none; margin-top: 12px; padding-top: 12px; + border-top: 1px solid var(--border-subtle); + } + .episode-expanded.open { display: block; } + .episode-narrative { + font-size: 0.85rem; color: var(--text-muted); line-height: 1.6; + font-style: italic; border-left: 2px solid var(--accent-cyan); + padding-left: 12px; margin-bottom: 12px; + } + .episode-file { + font-size: 0.8rem; color: var(--accent-cyan); + font-family: 'SF Mono', Monaco, monospace; padding: 2px 0; + } + + /* Memory cards */ + .memory-card { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 16px 18px; margin-bottom: 10px; + cursor: pointer; transition: border-color 0.2s; + } + .memory-card:hover { border-color: var(--border-color); } + .memory-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .memory-type-badge { + flex-shrink: 0; padding: 2px 8px; + border-radius: 4px; font-size: 0.7rem; font-weight: 600; + } + .type-decision { background: color-mix(in srgb, var(--accent-orange) 15%, transparent); color: var(--accent-orange); } + .type-error { background: color-mix(in srgb, var(--accent-red) 15%, transparent); color: var(--accent-red); } + .type-insight { background: color-mix(in srgb, var(--accent-violet) 15%, transparent); color: var(--accent-violet); } + .type-learning { background: color-mix(in srgb, var(--accent-blue) 15%, transparent); color: var(--accent-blue); } + .type-general { background: color-mix(in srgb, var(--text-muted) 10%, transparent); color: var(--text-muted); } + .memory-project { + font-size: 0.75rem; color: var(--accent-blue); opacity: 0.8; + } + .memory-header-spacer { flex: 1; } + .memory-date { font-size: 0.75rem; color: var(--text-dim); flex-shrink: 0; } + .memory-summary { + font-size: 0.9rem; line-height: 1.5; margin-top: 8px; + color: var(--text-primary); + } + .memory-content-preview { + font-size: 0.82rem; line-height: 1.55; color: var(--text-muted); + margin-top: 6px; + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; + overflow: hidden; + } + .memory-stats-row { + display: flex; align-items: center; gap: 12px; margin-top: 10px; + font-size: 0.75rem; color: var(--text-dim); flex-wrap: wrap; + } + .memory-salience { + display: flex; align-items: center; gap: 6px; + } + .salience-bar { width: 80px; height: 5px; background: var(--bg-secondary); border-radius: 3px; overflow: hidden; } + .salience-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--accent-cyan), var(--accent-teal)); } + .salience-label { font-size: 0.72rem; color: var(--text-dim); } + .memory-stat-sep { color: var(--border-color); } + .memory-access-count { color: var(--accent-teal); } + .memory-state { + padding: 1px 6px; + border-radius: 3px; font-size: 0.7rem; font-weight: 600; + } + .state-active { background: color-mix(in srgb, var(--accent-green) 15%, transparent); color: var(--accent-green); } + .state-fading { background: color-mix(in srgb, var(--accent-orange) 15%, transparent); color: var(--accent-orange); } + .state-archived { background: color-mix(in srgb, var(--text-muted) 10%, transparent); color: var(--text-dim); } + .memory-gist-badge { + font-size: 0.72rem; color: var(--accent-violet); opacity: 0.9; + } + .memory-episode-link { + font-size: 0.72rem; color: var(--accent-cyan); opacity: 0.9; + } + .memory-meta-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; } + .memory-expanded { + display: none; margin-top: 12px; padding-top: 12px; + border-top: 1px solid var(--border-subtle); + } + .memory-expanded.open { display: block; } + .memory-expanded-detail { + font-size: 0.75rem; color: var(--text-dim); margin-top: 8px; + display: flex; flex-wrap: wrap; gap: 12px; + } + .memory-expanded-detail span { opacity: 0.8; } + .memory-id-label { + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; color: var(--text-dim); opacity: 0.6; + cursor: pointer; + } + .memory-id-label:hover { opacity: 1; } + + /* Pattern & Abstraction cards */ + .pattern-card, .abstraction-card { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 16px; margin-bottom: 10px; + } + .pattern-title, .abstraction-title { font-size: 0.95rem; font-weight: 600; margin-bottom: 6px; } + .pattern-desc, .abstraction-desc { font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; margin-bottom: 8px; } + .strength-bar { width: 80px; height: 4px; background: var(--bg-secondary); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; } + .strength-fill { height: 100%; border-radius: 2px; background: linear-gradient(90deg, var(--accent-teal), var(--accent-green)); } + + diff --git a/internal/web/static/css/pages/llm.css b/internal/web/static/css/pages/llm.css new file mode 100644 index 00000000..a7ea4ddb --- /dev/null +++ b/internal/web/static/css/pages/llm.css @@ -0,0 +1,46 @@ +/* Auto-extracted from index.html — llm */ + +/* ── LLM Usage View ── */ + .llm-view { padding: 24px; max-width: 1200px; margin: 0 auto; } + .llm-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } + .llm-header-title { font-size: 1.2rem; font-weight: 700; } + .llm-header-meta { display: flex; align-items: center; gap: 12px; } + .llm-updated { font-size: 0.75rem; color: var(--text-dim); } + .llm-cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 20px; } + .llm-card { + background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); + padding: 16px; text-align: center; + } + .llm-card-label { font-size: 0.75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } + .llm-card-value { font-size: 1.4rem; font-weight: 700; color: var(--accent-cyan); font-family: 'SF Mono', Monaco, monospace; } + .llm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + .llm-panel { + background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px; + } + .llm-panel-title { font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; } + .llm-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; } + .llm-table th { text-align: left; color: var(--text-dim); font-weight: 500; padding: 6px 8px; border-bottom: 1px solid var(--border-subtle); } + .llm-table td { padding: 6px 8px; color: var(--text-secondary); border-bottom: 1px solid var(--border-subtle); } + .llm-table tr:last-child td { border-bottom: none; } + .llm-empty { text-align: center; color: var(--text-dim); padding: 20px; } + .llm-log-scroll { max-height: 400px; overflow-y: auto; } + .llm-status-ok { color: var(--accent-green); } + .llm-status-err { color: var(--accent-red); } + .llm-bar { fill: var(--accent-cyan); rx: 2; } + .llm-bar-label { fill: var(--text-dim); font-size: 10px; font-family: 'SF Mono', Monaco, monospace; } + .llm-card-sub { font-size: 0.7rem; color: var(--text-dim); margin-top: 4px; font-family: 'SF Mono', Monaco, monospace; } + .llm-range-tabs { display: flex; gap: 2px; } + .llm-range-tab { padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; font-size: 0.75rem; color: var(--text-dim); cursor: pointer; background: none; transition: all 0.15s; } + .llm-range-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); } + .llm-range-tab.active { color: var(--accent-cyan); background: rgba(6,182,212,0.1); border-color: rgba(6,182,212,0.2); } + .llm-chart-wrap { position: relative; } + .llm-chart-legend { display: flex; gap: 14px; } + .llm-legend-item { display: flex; align-items: center; gap: 5px; font-size: 0.7rem; color: var(--text-dim); } + .llm-legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; } + .llm-chart-tooltip { position: absolute; background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 6px 10px; font-size: 0.72rem; color: var(--text-primary); pointer-events: none; display: none; z-index: 20; white-space: nowrap; line-height: 1.7; } + .llm-log-row-err td { background: rgba(239,68,68,0.04); } + .llm-error-detail { font-size: 0.75rem; color: var(--accent-red); padding: 4px 12px !important; word-break: break-word; opacity: 0.85; border-top: none !important; } + .llm-header-sub { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; } + .llm-completion-val { color: var(--accent-violet) !important; } + + diff --git a/internal/web/static/css/pages/recall.css b/internal/web/static/css/pages/recall.css new file mode 100644 index 00000000..fb600b6d --- /dev/null +++ b/internal/web/static/css/pages/recall.css @@ -0,0 +1,199 @@ +/* Auto-extracted from index.html — recall */ + +/* ── Recall View ── */ + .recall-hero { + max-width: 720px; margin: 0 auto; + padding: 60px 24px 24px; + text-align: center; + } + .recall-hero h2 { + font-size: 1.5rem; font-weight: 400; color: var(--text-muted); + margin-bottom: 28px; letter-spacing: -0.3px; + } + .recall-search-box { + display: flex; gap: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; + } + .recall-search-box:focus-within { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-cyan) 15%, transparent); + } + .recall-search-box input { + flex: 1; padding: 16px 20px; + background: none; border: none; outline: none; + color: var(--text-primary); font-size: 1.05rem; + } + .recall-search-box input::placeholder { color: var(--text-dim); } + .recall-btn { + padding: 12px 24px; margin: 6px; + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-teal)); + border: none; border-radius: var(--radius-md); + color: white; font-weight: 600; font-size: 0.9rem; + cursor: pointer; transition: opacity 0.2s, transform 0.1s; + white-space: nowrap; + } + .recall-btn:hover { opacity: 0.9; } + .recall-btn:active { transform: scale(0.97); } + .recall-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .recall-options { + display: flex; align-items: center; justify-content: center; + gap: 16px; margin-top: 12px; + } + .recall-options label { + display: flex; align-items: center; gap: 6px; + color: var(--text-muted); font-size: 0.85rem; cursor: pointer; + } + .recall-options input[type="checkbox"] { accent-color: var(--accent-cyan); } + .recall-shortcut { + font-size: 0.75rem; color: var(--text-dim); + padding: 2px 6px; border: 1px solid var(--border-color); + border-radius: 4px; font-family: 'SF Mono', Monaco, monospace; + } + + /* Results */ + .recall-results { + max-width: 720px; margin: 0 auto; + padding: 0 24px 24px; + } + .results-header { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 0; margin-bottom: 8px; + } + .results-header h3 { + font-size: 0.85rem; font-weight: 600; color: var(--text-muted); + text-transform: uppercase; letter-spacing: 0.05em; + } + .results-meta { font-size: 0.8rem; color: var(--text-dim); } + + .result-card { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 16px; margin-bottom: 8px; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; + } + .result-card:hover { + border-color: var(--border-color); + background: var(--bg-tertiary); + } + .result-card-header { display: flex; align-items: flex-start; gap: 10px; } + .result-score { + flex-shrink: 0; padding: 2px 8px; + border-radius: 4px; font-size: 0.75rem; font-weight: 700; + font-family: 'SF Mono', Monaco, monospace; + } + .score-high { background: color-mix(in srgb, var(--accent-green) 15%, transparent); color: var(--accent-green); } + .score-mid { background: color-mix(in srgb, var(--accent-cyan) 15%, transparent); color: var(--accent-cyan); } + .score-low { background: color-mix(in srgb, var(--text-muted) 15%, transparent); color: var(--text-muted); } + .result-summary { font-size: 0.95rem; line-height: 1.5; color: var(--text-secondary); } + .result-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; } + .concept-tag { + padding: 2px 8px; border-radius: 10px; + font-size: 0.7rem; font-weight: 500; + background: color-mix(in srgb, var(--accent-violet) 12%, transparent); color: var(--accent-violet); + } + .result-date { font-size: 0.75rem; color: var(--text-dim); margin-left: auto; } + .result-expanded { + display: none; margin-top: 12px; padding-top: 12px; + border-top: 1px solid var(--border-subtle); + } + .result-expanded.open { display: block; } + .result-content { + font-size: 0.85rem; line-height: 1.6; color: var(--text-muted); + white-space: pre-wrap; max-height: 200px; overflow-y: auto; + } + + /* Feedback */ + .feedback-bar { + display: flex; gap: 6px; margin-top: 16px; + padding-top: 12px; border-top: 1px solid var(--border-subtle); + align-items: center; + } + .feedback-btn { + padding: 6px 14px; border-radius: var(--radius-sm); + font-size: 0.8rem; font-weight: 500; + border: 1px solid var(--border-color); cursor: pointer; + transition: all 0.2s; background: var(--bg-secondary); + color: var(--text-muted); + } + .feedback-btn:hover { border-color: var(--text-muted); color: var(--text-primary); } + .feedback-btn.helpful:hover { border-color: var(--accent-green); color: var(--accent-green); background: color-mix(in srgb, var(--accent-green) 10%, transparent); } + .feedback-btn.partial:hover { border-color: var(--accent-yellow); color: var(--accent-yellow); background: color-mix(in srgb, var(--accent-yellow) 10%, transparent); } + .feedback-btn.irrelevant:hover { border-color: var(--accent-red); color: var(--accent-red); background: color-mix(in srgb, var(--accent-red) 10%, transparent); } + .feedback-btn.selected { opacity: 1; font-weight: 600; } + .feedback-btn.helpful.selected { border-color: var(--accent-green); color: var(--accent-green); background: color-mix(in srgb, var(--accent-green) 15%, transparent); } + .feedback-btn.partial.selected { border-color: var(--accent-yellow); color: var(--accent-yellow); background: color-mix(in srgb, var(--accent-yellow) 15%, transparent); } + .feedback-btn.irrelevant.selected { border-color: var(--accent-red); color: var(--accent-red); background: color-mix(in srgb, var(--accent-red) 15%, transparent); } + .feedback-label { font-size: 0.8rem; color: var(--text-dim); margin-right: 6px; } + + /* Synthesis */ + .synthesis-block { + background: var(--bg-card); + border: 1px solid color-mix(in srgb, var(--accent-violet) 20%, transparent); + border-left: 3px solid var(--accent-violet); + border-radius: var(--radius-md); + padding: 16px; margin-top: 12px; + } + .synthesis-label { + font-size: 0.75rem; font-weight: 600; color: var(--accent-violet); + text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; + } + .synthesis-text { font-size: 0.9rem; line-height: 1.7; color: var(--text-secondary); } + + /* Remember Panel */ + .remember-panel { max-width: 720px; margin: 24px auto 40px; padding: 0 24px; } + .remember-toggle { + display: flex; align-items: center; gap: 8px; + padding: 12px 16px; background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + cursor: pointer; color: var(--text-muted); + font-size: 0.9rem; transition: all 0.2s; width: 100%; text-align: left; + } + .remember-toggle:hover { border-color: var(--border-color); color: var(--text-primary); } + .remember-toggle svg { width: 16px; height: 16px; transition: transform 0.2s; } + .remember-toggle.open svg { transform: rotate(45deg); } + .remember-form { + display: none; padding: 16px; + background: var(--bg-card); + border: 1px solid var(--border-subtle); border-top: none; + border-radius: 0 0 var(--radius-md) var(--radius-md); + } + .remember-form.open { display: block; } + .remember-row { display: flex; gap: 10px; margin-bottom: 12px; } + .remember-field { display: flex; flex-direction: column; gap: 4px; flex: 1; } + .remember-field label { + font-size: 0.75rem; font-weight: 600; color: var(--text-dim); + text-transform: uppercase; letter-spacing: 0.03em; + } + .remember-field select, .remember-field input { + padding: 8px 12px; background: var(--bg-secondary); + border: 1px solid var(--border-color); border-radius: var(--radius-sm); + color: var(--text-primary); font-size: 0.85rem; outline: none; + } + .remember-field select:focus, .remember-field input:focus { border-color: var(--accent-cyan); } + .remember-textarea { + width: 100%; min-height: 80px; padding: 12px; + background: var(--bg-secondary); border: 1px solid var(--border-color); + border-radius: var(--radius-sm); color: var(--text-primary); + font-size: 0.9rem; line-height: 1.5; resize: vertical; + font-family: inherit; outline: none; margin-bottom: 12px; + } + .remember-textarea:focus { border-color: var(--accent-cyan); } + .remember-actions { display: flex; justify-content: flex-end; } + .remember-submit { + padding: 8px 20px; + background: linear-gradient(135deg, var(--accent-teal), var(--accent-green)); + border: none; border-radius: var(--radius-sm); + color: white; font-weight: 600; font-size: 0.85rem; + cursor: pointer; transition: opacity 0.2s; + } + .remember-submit:hover { opacity: 0.9; } + .remember-submit:disabled { opacity: 0.5; cursor: not-allowed; } + + diff --git a/internal/web/static/css/pages/sdk.css b/internal/web/static/css/pages/sdk.css new file mode 100644 index 00000000..0cc42498 --- /dev/null +++ b/internal/web/static/css/pages/sdk.css @@ -0,0 +1,205 @@ +/* Auto-extracted from index.html — sdk */ + +/* ── SDK Agent View ── */ + .sdk-section { margin-bottom: 16px; } + .sdk-section-label { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 8px; } + .sdk-stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } + .sdk-stat-cards-5 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; } + .sdk-api-equiv .stat-number { color: var(--text-muted) !important; font-size: 1rem !important; } + .sdk-api-equiv .stat-sub { color: var(--accent-orange); } + .sdk-evo-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } + .sdk-cost-chart-wrap { position: relative; } + .sdk-cost-tooltip { position: absolute; top: 4px; background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px 8px; font-size: 0.72rem; color: var(--text-primary); pointer-events: none; display: none; z-index: 20; white-space: nowrap; } + + + +/* ── 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); + display: flex; gap: 8px; + } + .drawer-action { + padding: 6px 14px; border-radius: var(--radius-sm); + font-size: 0.8rem; border: 1px solid var(--border-color); + background: var(--bg-tertiary); color: var(--text-muted); + cursor: pointer; transition: all 0.2s; + } + .drawer-action:hover { border-color: var(--accent-cyan); color: var(--text-primary); } + .insight-item { + padding: 10px; margin-bottom: 6px; + background: var(--bg-tertiary); border-radius: var(--radius-sm); + border-left: 3px solid var(--accent-cyan); + } + .insight-item.warning { border-left-color: var(--accent-yellow); } + .insight-item.critical { border-left-color: var(--accent-red); } + .insight-type { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); } + .insight-detail { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; } + + + +/* ── 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; } + .agent-header-title { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); } + .agent-header-meta { display: flex; align-items: center; gap: 12px; } + .agent-refresh-btn { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 6px 12px; color: var(--text-dim); cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; gap: 5px; transition: all 0.15s; } + .agent-refresh-btn:hover { border-color: var(--accent-violet); color: var(--accent-violet); } + .agent-refresh-btn.loading { opacity: 0.5; pointer-events: none; } + .agent-updated { font-size: 0.7rem; color: var(--text-dim); } + .agent-stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 20px; } + .agent-stat-card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 14px 12px; text-align: center; transition: border-color 0.15s; } + .agent-stat-card:hover { border-color: var(--accent-violet); } + .agent-stat-card .stat-number { font-size: 1.5rem; font-weight: 700; color: var(--accent-violet); line-height: 1.2; } + .agent-stat-card .stat-label { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 3px; } + .agent-stat-card .stat-sub { font-size: 0.7rem; color: var(--text-dim); margin-top: 2px; opacity: 0.7; } + .agent-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; } + .agent-panel { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px; max-height: 500px; overflow-y: auto; } + .agent-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } + .agent-panel-title { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); font-weight: 600; } + .agent-panel-count { font-size: 0.7rem; color: var(--text-dim); background: var(--bg-tertiary); padding: 1px 6px; border-radius: 8px; } + .agent-empty { color: var(--text-dim); font-size: 0.85rem; font-style: italic; padding: 12px 0; } + + .principle-card { padding: 12px 0; border-bottom: 1px solid var(--border-subtle); } + .principle-card:last-child { border-bottom: none; } + .principle-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; } + .principle-id { font-size: 0.7rem; color: var(--accent-violet); background: color-mix(in srgb, var(--accent-violet) 10%, transparent); padding: 1px 5px; border-radius: 3px; font-weight: 600; white-space: nowrap; } + .principle-date { font-size: 0.7rem; color: var(--text-dim); white-space: nowrap; } + .principle-text { font-size: 0.88rem; color: var(--text-primary); margin-bottom: 6px; line-height: 1.4; } + .principle-meta { display: flex; align-items: center; gap: 10px; font-size: 0.75rem; color: var(--text-dim); } + .confidence-bar { width: 80px; height: 5px; background: var(--bg-secondary); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; } + .confidence-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--accent-violet), var(--accent-pink)); } + .principle-source { font-size: 0.75rem; color: var(--text-dim); margin-top: 4px; cursor: pointer; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 100%; } + .principle-source:hover { white-space: normal; color: var(--text-secondary); } + + .strategy-card { padding: 12px 0; border-bottom: 1px solid var(--border-subtle); cursor: pointer; } + .strategy-card:last-child { border-bottom: none; } + .strategy-header { display: flex; align-items: center; gap: 8px; } + .strategy-name { font-size: 0.9rem; font-weight: 600; color: var(--accent-cyan); flex: 1; } + .strategy-counts { display: flex; gap: 6px; } + .strategy-count-badge { font-size: 0.7rem; padding: 1px 6px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-dim); } + .strategy-expand { font-size: 0.7rem; color: var(--text-dim); transition: transform 0.2s; } + .strategy-card.open .strategy-expand { transform: rotate(90deg); } + .strategy-detail { display: none; padding: 10px 0 0 0; } + .strategy-card.open .strategy-detail { display: block; } + .strategy-section-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); margin: 8px 0 4px; font-weight: 600; } + .strategy-section-label:first-child { margin-top: 0; } + .strategy-steps { padding: 0 0 0 12px; font-size: 0.84rem; color: var(--text-secondary); } + .strategy-step { padding: 3px 0; line-height: 1.35; } + .strategy-step::before { content: attr(data-n) '.'; color: var(--text-dim); margin-right: 6px; font-size: 0.75rem; } + .strategy-tip { padding: 3px 0 3px 12px; font-size: 0.8rem; color: var(--text-dim); line-height: 1.35; } + .strategy-tip::before { content: '\2022'; color: var(--accent-orange); margin-right: 6px; } + + .changelog-entry { padding: 10px 0 10px 12px; border-left: 3px solid var(--accent-violet); margin-bottom: 8px; } + .changelog-date { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; } + .changelog-title { font-size: 0.88rem; color: var(--text-primary); font-weight: 500; margin: 2px 0; } + .changelog-rationale { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.35; } + .changelog-rationale ul { margin: 4px 0; padding-left: 18px; } + .changelog-rationale li { margin: 2px 0; } + .changelog-rationale p { margin: 4px 0; } + .changelog-rationale code { background: var(--bg-tertiary); padding: 1px 4px; border-radius: 3px; font-size: 0.85em; } + .changelog-rationale strong { color: var(--text-primary); } + + .session-group { margin-bottom: 14px; } + .session-group:last-child { margin-bottom: 0; } + .session-group-header { display: flex; align-items: center; gap: 8px; padding: 6px 0; margin-bottom: 4px; border-bottom: 1px solid var(--border-color); } + .session-group-id { font-size: 0.7rem; font-family: monospace; color: var(--accent-violet); } + .session-group-model { font-size: 0.7rem; background: color-mix(in srgb, var(--accent-violet) 10%, transparent); color: var(--accent-violet); padding: 1px 6px; border-radius: 3px; } + .session-group-date { font-size: 0.7rem; color: var(--text-dim); margin-left: auto; } + .session-group-cost { font-size: 0.7rem; color: var(--accent-green); } + .session-task { display: flex; align-items: center; gap: 8px; padding: 6px 0 6px 8px; border-bottom: 1px solid var(--border-subtle); flex-wrap: wrap; } + .session-task:last-child { border-bottom: none; } + .session-task-desc { flex: 1; font-size: 0.84rem; color: var(--text-primary); min-width: 120px; line-height: 1.3; } + .session-badge { font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; background: var(--bg-tertiary); color: var(--text-dim); white-space: nowrap; } + .session-badge.cost { color: var(--accent-green); } + .session-badge.turns { color: var(--accent-cyan); } + .session-badge.duration { color: var(--accent-orange); } + .session-badge.evolved { color: var(--accent-violet); background: color-mix(in srgb, var(--accent-violet) 10%, transparent); } + + .agent-memory-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 16px; } + .agent-memory-stat { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 12px; text-align: center; } + .agent-memory-stat .stat-number { font-size: 1.2rem; font-weight: 700; color: var(--accent-cyan); } + .agent-memory-stat .stat-label { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px; } + + + +/* ── Agent Chat ── */ + .agent-chat-panel { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); margin-top: 16px; display: flex; flex-direction: column; height: 700px; min-height: 300px; max-height: 90vh; resize: vertical; overflow: hidden; } + .agent-chat-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; gap: 8px; } + .agent-chat-header-left { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; } + .agent-chat-header-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } + .agent-chat-title { font-size: 0.75rem; color: var(--text-secondary); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .agent-chat-status { display: flex; align-items: center; gap: 5px; font-size: 0.7rem; color: var(--text-dim); white-space: nowrap; } + .chat-header-btn { padding: 3px; border: none; background: none; cursor: pointer; color: var(--text-dim); border-radius: 4px; display: flex; align-items: center; justify-content: center; transition: all 0.15s; flex-shrink: 0; } + .chat-header-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); } + .chat-model-select { padding: 2px 4px; background: var(--bg-secondary); border: 1px solid var(--border-subtle); border-radius: 4px; color: var(--text-secondary); font-size: 0.7rem; font-family: inherit; outline: none; cursor: pointer; -webkit-appearance: none; appearance: none; } + .chat-model-select:focus { border-color: var(--accent-violet); } + .chat-history-panel { max-height: 200px; overflow-y: auto; border-bottom: 1px solid var(--border-subtle); background: var(--bg-secondary); flex-shrink: 0; } + .chat-history-item { display: flex; align-items: center; padding: 7px 12px; cursor: pointer; transition: background 0.15s; border-bottom: 1px solid var(--border-subtle); } + .chat-history-item:last-child { border-bottom: none; } + .chat-history-item:hover { background: var(--bg-tertiary); } + .chat-history-item.active { background: color-mix(in srgb, var(--accent-cyan) 8%, transparent); border-left: 2px solid var(--accent-cyan); } + .chat-history-content { flex: 1; min-width: 0; } + .chat-history-title { font-size: 0.78rem; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .chat-history-meta { font-size: 0.6rem; color: var(--text-dim); display: flex; gap: 8px; margin-top: 1px; } + .chat-history-delete { padding: 2px 5px; border: none; background: none; color: var(--text-dim); cursor: pointer; border-radius: 3px; font-size: 0.8rem; opacity: 0; transition: all 0.15s; flex-shrink: 0; } + .chat-history-item:hover .chat-history-delete { opacity: 1; } + .chat-history-delete:hover { color: var(--accent-red); background: color-mix(in srgb, var(--accent-red) 10%, transparent); } + .chat-history-empty { padding: 16px; text-align: center; font-size: 0.75rem; color: var(--text-dim); } + .agent-chat-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-red); flex-shrink: 0; } + .agent-chat-dot.connected { background: var(--accent-green); } + .agent-chat-dot.working { background: var(--accent-orange); animation: pulse 0.8s ease-in-out infinite; } + @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } + .agent-chat-messages { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; scroll-behavior: smooth; } + .agent-chat-messages::-webkit-scrollbar { width: 5px; } + .agent-chat-messages::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; } + .chat-msg { display: flex; gap: 8px; max-width: 88%; } + .chat-msg.user { align-self: flex-end; flex-direction: row-reverse; } + .chat-msg.assistant { align-self: flex-start; } + .chat-bubble { padding: 10px 14px; border-radius: var(--radius-md); font-size: 0.9rem; line-height: 1.6; word-break: break-word; } + .chat-msg.user .chat-bubble { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-teal)); color: white; border-bottom-right-radius: 4px; white-space: pre-wrap; } + .chat-msg.assistant .chat-bubble { background: var(--bg-tertiary); color: var(--text-primary); border-bottom-left-radius: 4px; border: 1px solid var(--border-color); } + .chat-msg.assistant .chat-bubble strong { color: var(--accent-cyan); font-weight: 600; } + .chat-msg.assistant .chat-bubble em { color: var(--text-secondary); font-style: italic; } + .chat-heading { font-weight: 700; margin: 6px 0 4px 0; } + .chat-h1 { font-size: 1.05rem; color: var(--accent-cyan); } + .chat-h2 { font-size: 0.95rem; color: var(--accent-teal); } + .chat-h3 { font-size: 0.9rem; color: var(--text-primary); } + .chat-list-item { padding-left: 14px; position: relative; margin: 2px 0; } + .chat-list-item::before { content: '\2022'; position: absolute; left: 2px; color: var(--accent-violet); } + .chat-list-item.chat-list-num::before { content: none; } + .chat-para-break { height: 8px; } + .chat-code-block { background: var(--bg-secondary); border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px 10px; margin: 6px 0; overflow-x: auto; font-size: 0.8rem; line-height: 1.5; } + .chat-code-block code { color: var(--accent-green); font-family: 'SF Mono', 'Fira Code', monospace; } + .chat-inline-code { background: color-mix(in srgb, var(--accent-violet) 15%, transparent); color: var(--accent-violet); padding: 1px 5px; border-radius: 3px; font-size: 0.82rem; font-family: 'SF Mono', 'Fira Code', monospace; } + .chat-status-pill { align-self: center; padding: 3px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 500; background: var(--bg-tertiary); color: var(--text-dim); border: 1px solid var(--border-subtle); } + .chat-tool-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 10px; font-size: 0.7rem; background: color-mix(in srgb, var(--accent-violet) 8%, transparent); color: var(--accent-violet); border: 1px solid color-mix(in srgb, var(--accent-violet) 15%, transparent); font-family: 'SF Mono', 'Fira Code', monospace; white-space: nowrap; cursor: pointer; transition: background 0.15s, border-color 0.15s; } + .chat-tool-pill:hover { background: color-mix(in srgb, var(--accent-violet) 16%, transparent); border-color: color-mix(in srgb, var(--accent-violet) 30%, transparent); } + .chat-tool-pill.expanded { background: color-mix(in srgb, var(--accent-violet) 14%, transparent); border-color: color-mix(in srgb, var(--accent-violet) 25%, transparent); } + .chat-tool-pill .tool-icon { font-size: 0.6rem; opacity: 0.6; transition: transform 0.15s; } + .chat-tool-pill.expanded .tool-icon { transform: rotate(90deg); } + .chat-tool-detail { display: none; width: 100%; margin-top: 6px; padding: 12px 14px; border-radius: 8px; font-size: 0.82rem; line-height: 1.5; font-family: 'SF Mono', 'Fira Code', monospace; background: color-mix(in srgb, var(--accent-violet) 5%, var(--bg-primary)); border: 1px solid color-mix(in srgb, var(--accent-violet) 12%, transparent); color: var(--text-secondary); max-height: 400px; min-height: 60px; overflow-y: auto; flex-shrink: 0; box-sizing: border-box; } + .chat-tool-detail.visible { display: block; } + .chat-tool-detail pre { white-space: pre-wrap; word-break: break-word; margin: 0; font-size: inherit; line-height: inherit; } + .tool-detail-section { margin-bottom: 10px; } + .tool-detail-section:last-child { margin-bottom: 0; } + .tool-detail-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--accent-violet); margin-bottom: 4px; } + .chat-tool-pill.tool-error { border-color: var(--accent-red, #e55); color: var(--accent-red, #e55); } + .chat-tool-row { display: flex; flex-wrap: wrap; gap: 4px; align-self: flex-start; flex-shrink: 0; } + .chat-streaming::after { content: '\25CB'; animation: blink 1s step-start infinite; margin-left: 2px; } + @keyframes blink { 50% { opacity: 0; } } + .agent-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--border-subtle); flex-shrink: 0; } + .agent-chat-input { flex: 1; padding: 8px 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 0.875rem; outline: none; resize: none; font-family: inherit; line-height: 1.4; max-height: 100px; overflow-y: auto; } + .agent-chat-input:focus { border-color: var(--accent-violet); } + .agent-chat-input::placeholder { color: var(--text-dim); } + .agent-chat-send { padding: 8px 16px; border-radius: var(--radius-sm); background: var(--accent-violet); color: white; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 600; white-space: nowrap; align-self: flex-end; } + .agent-chat-send:hover:not(:disabled) { opacity: 0.85; } + .agent-chat-send:disabled { opacity: 0.4; cursor: not-allowed; } + + diff --git a/internal/web/static/css/pages/timeline.css b/internal/web/static/css/pages/timeline.css new file mode 100644 index 00000000..cdb95d1d --- /dev/null +++ b/internal/web/static/css/pages/timeline.css @@ -0,0 +1,273 @@ +/* Auto-extracted from index.html — timeline */ + +/* ── Timeline View ── */ + .timeline-view { padding: 0; flex-direction: column; height: 100%; overflow: hidden !important; } + .timeline-view.active { display: flex; } + .timeline-header { + flex-shrink: 0; padding: 16px 24px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-secondary); + } + .timeline-spark { height: 40px; margin-bottom: 12px; position: relative; cursor: default; } + .timeline-spark-empty { + height: 40px; display: flex; align-items: center; justify-content: center; + font-size: 0.75rem; color: var(--text-dim); + } + .spark-tooltip { + position: absolute; top: 44px; background: var(--bg-elevated); + border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); + padding: 3px 8px; font-size: 0.7rem; color: var(--text-primary); + pointer-events: none; white-space: nowrap; display: none; z-index: 20; + } + .timeline-spark.day-active { cursor: pointer; } + .timeline-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .timeline-range-tabs { display: flex; gap: 2px; } + .timeline-range-tab { + 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; + } + .timeline-range-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); } + .timeline-range-tab.active { color: var(--accent-cyan); background: rgba(6, 182, 212, 0.1); } + .timeline-search { + margin-left: auto; padding: 5px 12px; + background: var(--bg-primary); border: 1px solid var(--border-color); + border-radius: var(--radius-sm); color: var(--text-primary); + font-size: 0.8rem; outline: none; width: 220px; + transition: border-color 0.2s; + } + .timeline-search:focus { border-color: var(--accent-cyan); } + .timeline-search::placeholder { color: var(--text-dim); } + .timeline-type-filters { display: flex; gap: 4px; margin-left: 8px; } + .timeline-type-btn { + padding: 3px 8px; border-radius: 10px; + font-size: 0.68rem; font-weight: 500; + cursor: pointer; border: 1px solid transparent; + transition: all 0.15s; opacity: 0.5; background: none; + } + .timeline-type-btn.active { opacity: 1; } + .timeline-type-btn[data-type="episode"] { background: rgba(6,182,212,0.12); color: var(--accent-cyan); } + .timeline-type-btn[data-type="episode"].active { border-color: rgba(6,182,212,0.3); } + .timeline-type-btn[data-type="decision"] { background: rgba(59,130,246,0.12); color: var(--accent-blue); } + .timeline-type-btn[data-type="decision"].active { border-color: rgba(59,130,246,0.3); } + .timeline-type-btn[data-type="error"] { background: rgba(239,68,68,0.12); color: var(--accent-red); } + .timeline-type-btn[data-type="error"].active { border-color: rgba(239,68,68,0.3); } + .timeline-type-btn[data-type="insight"] { background: rgba(139,92,246,0.12); color: var(--accent-violet); } + .timeline-type-btn[data-type="insight"].active { border-color: rgba(139,92,246,0.3); } + .timeline-type-btn[data-type="learning"] { background: rgba(16,185,129,0.12); color: var(--accent-green); } + .timeline-type-btn[data-type="learning"].active { border-color: rgba(16,185,129,0.3); } + .timeline-type-btn[data-type="general"] { background: rgba(148,163,184,0.08); color: var(--text-muted); } + .timeline-type-btn[data-type="general"].active { border-color: rgba(148,163,184,0.2); } + .timeline-project-filters { display: flex; gap: 4px; margin-left: 8px; } + .timeline-project-btn { + padding: 3px 8px; border-radius: 10px; + font-size: 0.68rem; font-weight: 500; + cursor: pointer; border: 1px solid transparent; + transition: all 0.15s; opacity: 0.5; + background: rgba(59,130,246,0.12); color: var(--accent-blue); + } + .timeline-project-btn.active { opacity: 1; border-color: rgba(59,130,246,0.3); } + .timeline-search-wrap { + margin-left: auto; position: relative; display: flex; align-items: center; + } + .timeline-search-clear { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + background: none; border: none; color: var(--text-dim); cursor: pointer; + font-size: 0.85rem; line-height: 1; padding: 2px 4px; display: none; + } + .timeline-search-clear:hover { color: var(--text-primary); } + .timeline-search-wrap.has-value .timeline-search-clear { display: block; } + .timeline-search-wrap.has-value .timeline-search { padding-right: 24px; } + + .timeline-body { + flex: 1; overflow-y: auto; overflow-x: hidden; + padding: 0 24px 40px; + } + .timeline-body::-webkit-scrollbar { width: 6px; } + .timeline-body::-webkit-scrollbar-track { background: transparent; } + .timeline-body::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; } + + .timeline-date-group { margin-top: 24px; } + .timeline-date-label { + font-size: 0.7rem; font-weight: 600; color: var(--text-dim); + text-transform: uppercase; letter-spacing: 0.06em; + padding-bottom: 8px; + position: sticky; top: 0; z-index: 5; + background: var(--bg-primary); + display: flex; align-items: center; gap: 10px; + } + .timeline-date-label::after { + content: ''; flex: 1; height: 1px; + background: var(--border-subtle); + } + .timeline-date-count { + font-size: 0.65rem; color: var(--text-dim); font-weight: 400; opacity: 0.6; + } + + .tl-card { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-left: 3px solid var(--text-dim); + border-radius: 2px var(--radius-sm) var(--radius-sm) 2px; + padding: 12px 16px; + margin: 6px 0 6px 20px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, opacity 0.2s; + } + .tl-card::before { + content: ''; + position: absolute; left: -16px; top: 18px; + width: 8px; height: 8px; border-radius: 50%; + background: var(--bg-card); border: 2px solid var(--text-dim); + z-index: 2; + } + .tl-card::after { + content: ''; + position: absolute; left: -11px; top: 22px; + width: 8px; height: 1px; + background: var(--border-subtle); + } + .tl-card:hover { border-color: var(--border-color); background: var(--bg-tertiary); } + .tl-card.dimmed { opacity: 0.25; } + .tl-card.highlighted { border-color: var(--accent-cyan); background: rgba(6,182,212,0.04); } + + .tl-card.tl-episode { border-left-color: var(--accent-cyan); } + .tl-card.tl-episode::before { border-color: var(--accent-cyan); } + .tl-card.tl-decision { border-left-color: var(--accent-blue); } + .tl-card.tl-decision::before { border-color: var(--accent-blue); } + .tl-card.tl-error { border-left-color: var(--accent-red); } + .tl-card.tl-error::before { border-color: var(--accent-red); } + .tl-card.tl-insight { border-left-color: var(--accent-violet); } + .tl-card.tl-insight::before { border-color: var(--accent-violet); } + .tl-card.tl-learning { border-left-color: var(--accent-green); } + .tl-card.tl-learning::before { border-color: var(--accent-green); } + .tl-card.tl-general { border-left-color: var(--text-dim); } + + .tl-card-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .tl-card-type { + flex-shrink: 0; padding: 2px 8px; + border-radius: 4px; font-size: 0.68rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.02em; + } + .tl-card-type.ct-episode { background: rgba(6,182,212,0.12); color: var(--accent-cyan); } + .tl-card-type.ct-decision { background: rgba(59,130,246,0.12); color: var(--accent-blue); } + .tl-card-type.ct-error { background: rgba(239,68,68,0.12); color: var(--accent-red); } + .tl-card-type.ct-insight { background: rgba(139,92,246,0.12); color: var(--accent-violet); } + .tl-card-type.ct-learning { background: rgba(16,185,129,0.12); color: var(--accent-green); } + .tl-card-type.ct-general { background: rgba(148,163,184,0.08); color: var(--text-muted); } + + .tl-card-project { + padding: 1px 7px; border-radius: 8px; + font-size: 0.67rem; font-weight: 500; + background: rgba(59, 130, 246, 0.1); color: var(--accent-blue); + cursor: pointer; transition: background 0.15s; + flex-shrink: 0; + } + .tl-card-project:hover { background: rgba(59, 130, 246, 0.2); } + .tl-card-project.concept-active { background: rgba(6,182,212,0.2); color: var(--accent-cyan); } + .tl-card-project.concept-selected { + background: rgba(6,182,212,0.3); color: var(--accent-cyan); + box-shadow: 0 0 0 1px var(--accent-cyan); + } + .tl-card-title { + font-size: 0.9rem; font-weight: 500; color: var(--text-primary); + flex: 1; line-height: 1.4; + } + .tl-card-time { + flex-shrink: 0; font-size: 0.72rem; color: var(--text-dim); + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + } + .tl-card-tone { + font-size: 0.7rem; padding: 1px 6px; + border-radius: 3px; font-weight: 500; + background: rgba(139,92,246,0.08); color: var(--accent-violet); + } + .tl-card-meta { + display: flex; align-items: center; gap: 6px; + margin-top: 8px; flex-wrap: wrap; + } + .tl-card-files { + font-size: 0.72rem; color: var(--accent-cyan); opacity: 0.8; + font-family: 'SF Mono', Monaco, monospace; + } + .tl-card-events { font-size: 0.72rem; color: var(--text-dim); } + .tl-card-salience { + display: flex; align-items: center; gap: 4px; + font-size: 0.68rem; color: var(--text-dim); + } + .tl-sal-bar { + width: 50px; height: 3px; + background: var(--bg-secondary); border-radius: 2px; overflow: hidden; + } + .tl-sal-fill { + height: 100%; border-radius: 2px; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-teal)); + } + .tl-card-concepts { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; } + .tl-concept { + padding: 1px 7px; border-radius: 8px; + font-size: 0.67rem; font-weight: 500; + background: rgba(139, 92, 246, 0.1); color: var(--accent-violet); + cursor: pointer; transition: background 0.15s; + } + .tl-concept:hover { background: rgba(139, 92, 246, 0.2); } + .tl-concept.concept-active { background: rgba(6,182,212,0.2); color: var(--accent-cyan); } + .tl-concept.concept-selected, .tl-source.concept-selected { + background: rgba(6,182,212,0.3); color: var(--accent-cyan); + box-shadow: 0 0 0 1px var(--accent-cyan); + } + .tl-source { + padding: 1px 7px; border-radius: 8px; + font-size: 0.67rem; font-weight: 500; + background: rgba(6, 182, 212, 0.1); color: var(--accent-teal); + cursor: pointer; transition: background 0.15s; + } + .tl-source:hover { background: rgba(6, 182, 212, 0.2); } + .tl-source.concept-active { background: rgba(6,182,212,0.25); color: var(--accent-cyan); } + + .tl-card-detail { + display: none; margin-top: 10px; padding-top: 10px; + border-top: 1px solid var(--border-subtle); + } + .tl-card-detail.open { display: block; } + .tl-detail-row { + font-size: 0.78rem; color: var(--text-muted); line-height: 1.6; + margin-bottom: 6px; + } + .tl-detail-label { + font-size: 0.68rem; font-weight: 600; color: var(--text-dim); + text-transform: uppercase; letter-spacing: 0.04em; + margin-bottom: 2px; + } + .tl-detail-files { + font-size: 0.78rem; color: var(--accent-cyan); + font-family: 'SF Mono', Monaco, monospace; line-height: 1.8; + } + .tl-detail-outcome { + display: inline-block; padding: 2px 8px; border-radius: 4px; + font-size: 0.72rem; font-weight: 600; + } + .tl-outcome-success { background: rgba(16,185,129,0.15); color: var(--accent-green); } + .tl-outcome-failure { background: rgba(239,68,68,0.15); color: var(--accent-red); } + .tl-outcome-blocked { background: rgba(249,115,22,0.15); color: var(--accent-orange); } + + .timeline-rail { + position: absolute; left: 23px; top: 0; bottom: 0; + width: 1px; background: var(--border-subtle); z-index: 1; + } + .timeline-loading { + text-align: center; padding: 20px; + font-size: 0.8rem; color: var(--text-dim); + } + .timeline-empty { + text-align: center; padding: 60px 24px; + color: var(--text-dim); font-size: 0.9rem; + } + .timeline-empty-sub { + font-size: 0.8rem; color: var(--text-dim); opacity: 0.6; + margin-top: 6px; + } + + diff --git a/internal/web/static/css/pages/tools.css b/internal/web/static/css/pages/tools.css new file mode 100644 index 00000000..a3792df8 --- /dev/null +++ b/internal/web/static/css/pages/tools.css @@ -0,0 +1,27 @@ +/* Auto-extracted from index.html — tools */ + +/* ── 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); } + diff --git a/internal/web/static/css/tokens.css b/internal/web/static/css/tokens.css new file mode 100644 index 00000000..8051448e --- /dev/null +++ b/internal/web/static/css/tokens.css @@ -0,0 +1,216 @@ +/* ══════════════════════════════════════════════ + mnemonic — Design Tokens + 5 themes + forum-specific semantic variables + ══════════════════════════════════════════════ */ + +/* ── Theme: Midnight (default — GitHub-inspired) ── */ +:root, [data-theme="midnight"] { + --bg-primary: #0D1117; + --bg-secondary: #151B23; + --bg-tertiary: #212830; + --bg-card: #161B22; + --border-color: #3D444D; + --border-subtle: #2A313C; + --text-primary: #F0F6FC; + --text-secondary: #9198A1; + --text-muted: #848D97; + --text-dim: #656C76; + --accent-cyan: #58A6FF; + --accent-teal: #3FB950; + --accent-violet: #D2A8FF; + --accent-green: #3FB950; + --accent-blue: #58A6FF; + --accent-orange: #F0883E; + --accent-red: #F85149; + --accent-yellow: #D29922; + --accent-pink: #F778BA; + --nav-height: 56px; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); + --shadow-md: 0 4px 12px rgba(0,0,0,0.4); + --shadow-lg: 0 8px 30px rgba(0,0,0,0.5); + + /* Forum-specific semantic tokens */ + --bg-row: #151B23; + --bg-row-alt: #131920; + --bg-row-hover: #1c2430; + --bg-nested: #111720; + --bg-accent: color-mix(in srgb, #58A6FF 8%, transparent); + --border-accent: #58A6FF; + --text-bright: #F0F6FC; + --text-faint: #4A5260; + --link: #58A6FF; + --link-hover: #79B8FF; + --accent-bar: linear-gradient(90deg, #58A6FF, #D2A8FF); + + /* Memory type colors */ + --decision: #D29922; + --insight: #D2A8FF; + --learning: #58A6FF; + --error: #F85149; + + /* Typography */ + --font: Verdana, Geneva, Tahoma, sans-serif; + --mono: 'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace; +} + +/* ── Theme: Ember (warm library) ── */ +[data-theme="ember"] { + --bg-primary: #0d0f14; + --bg-secondary: #141820; + --bg-tertiary: #1c2028; + --bg-card: #171b23; + --border-color: #2e333d; + --border-subtle: #22272f; + --text-primary: #e2e8f0; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --text-dim: #64748b; + --accent-cyan: #d4a04c; + --accent-teal: #2ec4b6; + --accent-violet: #2ec4b6; + --accent-green: #10b981; + --accent-blue: #6b9fc4; + --accent-orange: #e0944e; + --accent-red: #e05555; + --accent-yellow: #dbb040; + --accent-pink: #c4787a; + + --bg-row: #141820; + --bg-row-alt: #111520; + --bg-row-hover: #1a1f2a; + --bg-nested: #0f1318; + --bg-accent: color-mix(in srgb, #d4a04c 8%, transparent); + --border-accent: #d4a04c; + --text-bright: #e2e8f0; + --text-faint: #475569; + --link: #d4a04c; + --link-hover: #e0b86c; + --accent-bar: linear-gradient(90deg, #d4a04c, #2ec4b6); + --decision: #dbb040; + --insight: #2ec4b6; + --learning: #6b9fc4; + --error: #e05555; +} + +/* ── Theme: Nord (frost) ── */ +[data-theme="nord"] { + --bg-primary: #2E3440; + --bg-secondary: #3B4252; + --bg-tertiary: #434C5E; + --bg-card: #353B49; + --border-color: #4C566A; + --border-subtle: #3E4555; + --text-primary: #ECEFF4; + --text-secondary: #D8DEE9; + --text-muted: #81A1C1; + --text-dim: #6B7F9E; + --accent-cyan: #88C0D0; + --accent-teal: #8FBCBB; + --accent-violet: #B48EAD; + --accent-green: #A3BE8C; + --accent-blue: #81A1C1; + --accent-orange: #D08770; + --accent-red: #BF616A; + --accent-yellow: #EBCB8B; + --accent-pink: #B48EAD; + + --bg-row: #3B4252; + --bg-row-alt: #373D4C; + --bg-row-hover: #434C5E; + --bg-nested: #343A48; + --bg-accent: color-mix(in srgb, #88C0D0 8%, transparent); + --border-accent: #88C0D0; + --text-bright: #ECEFF4; + --text-faint: #5B6E8A; + --link: #88C0D0; + --link-hover: #9DD0DE; + --accent-bar: linear-gradient(90deg, #88C0D0, #B48EAD); + --decision: #EBCB8B; + --insight: #B48EAD; + --learning: #88C0D0; + --error: #BF616A; +} + +/* ── Theme: Slate (Vercel-inspired minimal) ── */ +[data-theme="slate"] { + --bg-primary: #0A0A0A; + --bg-secondary: #141414; + --bg-tertiary: #1E1E1E; + --bg-card: #111111; + --border-color: #333333; + --border-subtle: #222222; + --text-primary: #EDEDED; + --text-secondary: #A0A0A0; + --text-muted: #888888; + --text-dim: #666666; + --accent-cyan: #0070F3; + --accent-teal: #50E3C2; + --accent-violet: #7928CA; + --accent-green: #0CAE53; + --accent-blue: #0070F3; + --accent-orange: #F5A623; + --accent-red: #EE0000; + --accent-yellow: #F5A623; + --accent-pink: #FF0080; + + --bg-row: #141414; + --bg-row-alt: #111111; + --bg-row-hover: #1E1E1E; + --bg-nested: #0E0E0E; + --bg-accent: color-mix(in srgb, #0070F3 8%, transparent); + --border-accent: #0070F3; + --text-bright: #EDEDED; + --text-faint: #555555; + --link: #0070F3; + --link-hover: #3291FF; + --accent-bar: linear-gradient(90deg, #0070F3, #7928CA); + --decision: #F5A623; + --insight: #7928CA; + --learning: #0070F3; + --error: #EE0000; +} + +/* ── Theme: Parchment (warm light) ── */ +[data-theme="parchment"] { + --bg-primary: #f5f0e8; + --bg-secondary: #ede7db; + --bg-tertiary: #e5ddd0; + --bg-card: #faf7f2; + --border-color: #d4c9b8; + --border-subtle: #e8dfd2; + --text-primary: #2c2418; + --text-secondary: #44382a; + --text-muted: #7a6e5e; + --text-dim: #a09482; + --accent-cyan: #b8893a; + --accent-teal: #0d9488; + --accent-violet: #0d9488; + --accent-green: #059669; + --accent-blue: #4a7ea8; + --accent-orange: #c47a38; + --accent-red: #dc2626; + --accent-yellow: #ca8a04; + --accent-pink: #a8606a; + --shadow-sm: 0 1px 3px rgba(120,100,80,0.1); + --shadow-md: 0 4px 12px rgba(120,100,80,0.12); + --shadow-lg: 0 8px 30px rgba(120,100,80,0.15); + + --bg-row: #ede7db; + --bg-row-alt: #f0ebe0; + --bg-row-hover: #e5ddd0; + --bg-nested: #f5f0e8; + --bg-accent: color-mix(in srgb, #b8893a 8%, transparent); + --border-accent: #b8893a; + --text-bright: #2c2418; + --text-faint: #b8a898; + --link: #4a7ea8; + --link-hover: #5a8eb8; + --accent-bar: linear-gradient(90deg, #b8893a, #0d9488); + --decision: #ca8a04; + --insight: #0d9488; + --learning: #4a7ea8; + --error: #dc2626; +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 80fb5058..53310092 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -5,133 +5,9 @@ mnemonic - + +
- - - -
+ +
@@ -1509,23 +1162,60 @@

What do you remember?

- +
-
-
-
- - - - + +
+
Loading forum index...
+
+
+ + +
+
Loading thread...
+
@@ -1561,50 +1251,12 @@

What do you remember?

- -
-
-
- - -
- - - - -
-
-
- -
- -
-
-
-
-
-
-
SDK Agent Sessions
-
Claude.ai · OAuth · self-evolving
+
+
+
SDK Agent Sessions
+
Claude.ai · OAuth · self-evolving
@@ -1781,10 +1433,10 @@

What do you remember?

-
+
-
MCP Tool Usage
-
recall · remember · get_context · … tools
+
MCP Tool Usage
+
recall · remember · get_context · … tools
@@ -1929,3901 +1581,21 @@

Activity

+ + +
+ mnemonic + – active + – fading + – archived + encoding: idle + consolidation: – +
diff --git a/internal/web/static/js/agent.js b/internal/web/static/js/agent.js new file mode 100644 index 00000000..a75c53c6 --- /dev/null +++ b/internal/web/static/js/agent.js @@ -0,0 +1,785 @@ +import { state, CONFIG, MONTHS } from './state.js'; +import { apiFetch, fetchJSON, escapeHtml, showToast, makeDayBuckets, simpleMarkdown, svgEl, linScale, svgText } from './utils.js'; + +// ── Agent ── +export async function loadAgentData() { + try { + var [evoRes, logRes, sessRes, sysRes, cfgRes] = await Promise.all([ + apiFetch(CONFIG.API_BASE + '/agent/evolution').then(function(r) { return r.json(); }), + apiFetch(CONFIG.API_BASE + '/agent/changelog').then(function(r) { return r.json(); }), + apiFetch(CONFIG.API_BASE + '/agent/sessions').then(function(r) { return r.json(); }), + apiFetch(CONFIG.API_BASE + '/stats').then(function(r) { return r.json(); }), + apiFetch(CONFIG.API_BASE + '/agent/config').then(function(r) { return r.json(); }).catch(function() { return {}; }), + ]); + state.agentData = { evolution: evoRes, changelog: logRes, sessions: sessRes, system: sysRes }; + state.agentLoaded = true; + renderAgentDashboard(); + // Initialize chat panel (only once) + if (cfgRes.chat_enabled && cfgRes.web_port && !agentChat.wsUrl) { + initAgentChat(cfgRes.web_port); + } + } catch (e) { + console.error('Failed to load agent data:', e); + document.getElementById('agentStatsRow').innerHTML = '
Agent dashboard unavailable. Is agent_sdk enabled in config?
'; + } +} + +export function refreshAgentData() { + var btn = document.getElementById('agentRefreshBtn'); + btn.classList.add('loading'); + state.agentLoaded = false; + loadAgentData().finally(function() { btn.classList.remove('loading'); }); +} + +export function renderAgentDashboard() { + var d = state.agentData; + if (!d) return; + // Update timestamp + document.getElementById('agentUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + renderAgentStats(d.evolution, d.sessions); + renderSDKCostChart(d.sessions.sessions || []); + renderAgentMemoryBar(d.system); + renderAgentPrinciples(d.evolution.principles); + renderAgentStrategies(d.evolution.strategies); + renderAgentTimeline(d.changelog.entries); + renderAgentSessions(d.sessions); + renderAgentPatches(d.evolution.patches); +} + +export function renderAgentStats(evo, sess) { + var patchCount = (evo.patches || []).filter(function(p) { var text = p.content || p.instruction || ''; return text.trim(); }).length; + var avgCost = sess.stats.total_tasks > 0 ? sess.stats.total_cost_usd / sess.stats.total_tasks : 0; + var lastEvo = ''; + if (state.agentData && state.agentData.changelog && state.agentData.changelog.entries && state.agentData.changelog.entries.length > 0) { + lastEvo = state.agentData.changelog.entries[0].date || ''; + } + + var totalTurns = 0; + (sess.sessions || []).forEach(function(s) { (s.tasks || []).forEach(function(t) { totalTurns += (t.turns || 0); }); }); + var totalApiEquiv = sess.stats.total_cost_usd || 0; + var taskCards = [ + { n: sess.stats.session_count, l: 'Sessions' }, + { n: sess.stats.total_tasks, l: 'Tasks Run' }, + { n: totalTurns.toLocaleString(), l: 'Total Turns', sub: 'subscription · not billed' }, + { n: sess.stats.avg_turns ? sess.stats.avg_turns.toFixed(1) : '0', l: 'Avg Turns / Task' }, + { n: '$' + totalApiEquiv.toFixed(4), l: 'API Equiv.', sub: 'if billed per token', cls: 'sdk-api-equiv' }, + ]; + var evoCards = [ + { n: evo.principles.length, l: 'Principles', sub: lastEvo ? 'Last: ' + lastEvo : '' }, + { n: Object.keys(evo.strategies).length, l: 'Strategies' }, + { n: patchCount, l: 'Prompt Patches' }, + ]; + + function cardHtml(cards, gridClass) { + return '
' + cards.map(function(s) { + return '
' + s.n + '
' + s.l + '
' + + (s.sub ? '
' + escapeHtml(s.sub) + '
' : '') + '
'; + }).join('') + '
'; + } + + document.getElementById('agentStatsRow').innerHTML = + '
' + + '' + + cardHtml(taskCards, 'sdk-stat-cards-5') + + '
' + + '
' + + '' + + cardHtml(evoCards, 'sdk-evo-cards') + + '
'; +} + +export function renderSDKCostChart(sessions) { + var container = document.getElementById('sdkCostChart'); + var tooltip = document.getElementById('sdkCostTooltip'); + container.innerHTML = ''; + if (!sessions || sessions.length === 0) { + container.innerHTML = '
No sessions yet
'; + return; + } + + var now = new Date(); + var dayMs = 86400000; + var buckets = makeDayBuckets(30, { tasks: 0, turns: 0 }); + sessions.forEach(function(s) { + (s.tasks || []).forEach(function(t) { + if (!t.started) return; + var td = new Date(t.started); + td.setHours(0, 0, 0, 0); + var diff = Math.floor((now.getTime() - td.getTime()) / dayMs); + if (diff >= 0 && diff < 30) { + buckets[29 - diff].tasks++; + buckets[29 - diff].turns += (t.turns || 0); + } + }); + }); + + var maxTasks = Math.max.apply(null, buckets.map(function(b) { return b.tasks; })) || 1; + var w = container.clientWidth || 400; + var h = 140; + var margin = { top: 8, right: 10, bottom: 24, left: 36 }; + var iw = w - margin.left - margin.right; + var ih = h - margin.top - margin.bottom; + + var svg = svgEl('svg', { width: w, height: h }); + container.appendChild(svg); + var g = svgEl('g', { transform: 'translate(' + margin.left + ',' + margin.top + ')' }); + svg.appendChild(g); + + var nb = buckets.length; + var bandStep = iw / nb; + var bandPad = 0.3; + var bandW = bandStep * (1 - bandPad); + var bandOff = bandStep * bandPad / 2; + + // Y axis ticks + var yTicks = Math.min(maxTasks, 4); + var yStep = maxTasks / yTicks; + for (var t = 0; t <= yTicks; t++) { + var tv = Math.round(t * yStep); + var ty = linScale(tv, 0, maxTasks, ih, 0); + g.appendChild(svgEl('line', { x1: -4, x2: 0, y1: ty, y2: ty, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(-8, ty, String(tv), { 'text-anchor': 'end', 'dominant-baseline': '0.32em', 'font-size': '10' })); + } + // X axis baseline + g.appendChild(svgEl('line', { x1: 0, x2: iw, y1: ih, y2: ih, stroke: 'var(--border-subtle)' })); + // X axis labels (every 7 days) + buckets.forEach(function(b) { + if (b.idx % 7 !== 0) return; + var bx = b.idx * bandStep + bandStep / 2; + g.appendChild(svgEl('line', { x1: bx, x2: bx, y1: ih, y2: ih + 4, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(bx, ih + 14, MONTHS[b.date.getMonth()] + ' ' + b.date.getDate(), { 'text-anchor': 'middle', 'font-size': '10' })); + }); + + // Bars + hit areas + buckets.forEach(function(b) { + var bx = b.idx * bandStep + bandOff; + if (b.tasks > 0) { + var barY = linScale(b.tasks, 0, maxTasks, ih, 0); + g.appendChild(svgEl('rect', { x: bx, y: barY, width: bandW, height: ih - barY, rx: 2, fill: 'var(--accent-green)', opacity: '0.8', 'pointer-events': 'none' })); + } + var hit = svgEl('rect', { x: bx - bandW * 0.1, y: 0, width: bandW * 1.2, height: ih, fill: 'transparent' }); + (function(bb) { + hit.addEventListener('mouseover', function() { + if (bb.tasks === 0) return; + tooltip.textContent = MONTHS[bb.date.getMonth()] + ' ' + bb.date.getDate() + ' \u00B7 ' + bb.tasks + ' task' + (bb.tasks === 1 ? '' : 's') + ' \u00B7 ' + bb.turns + ' turns'; + tooltip.style.display = 'block'; + var cx = margin.left + bb.idx * bandStep + bandStep / 2; + var ttLeft = cx + 8; + if (ttLeft + 200 > w) ttLeft = cx - 210; + tooltip.style.left = ttLeft + 'px'; + }); + hit.addEventListener('mouseout', function() { tooltip.style.display = 'none'; }); + })(b); + g.appendChild(hit); + }); +} + +export function renderAgentMemoryBar(sys) { + if (!sys || !sys.store) { document.getElementById('agentMemoryBar').innerHTML = ''; return; } + var st = sys.store; + var sizeMB = (st.storage_size_bytes / (1024 * 1024)).toFixed(1); + var items = [ + { n: st.total_memories, l: 'Total Memories' }, + { n: st.active_memories, l: 'Active' }, + { n: st.fading_memories + st.archived_memories, l: 'Fading + Archived' }, + { n: (st.total_associations || 0).toLocaleString(), l: 'Associations' }, + { n: sizeMB + ' MB', l: 'Storage' }, + ]; + document.getElementById('agentMemoryBar').innerHTML = items.map(function(s) { + return '
' + s.n + '
' + s.l + '
'; + }).join(''); +} + +export function renderAgentPrinciples(principles) { + var el = document.getElementById('agentPrinciplesContent'); + var countEl = document.getElementById('agentPrinciplesCount'); + if (countEl) countEl.textContent = (principles || []).length; + if (!principles || principles.length === 0) { el.innerHTML = '
No principles learned yet. Run the agent to start building knowledge.
'; return; } + // Sort by confidence descending, then by numeric ID ascending as tiebreaker + var sorted = principles.slice().sort(function(a, b) { + var diff = (b.confidence || 0) - (a.confidence || 0); + if (diff !== 0) return diff; + var aNum = parseInt((a.id || '').replace(/\D/g, ''), 10) || 0; + var bNum = parseInt((b.id || '').replace(/\D/g, ''), 10) || 0; + return aNum - bNum; + }); + el.innerHTML = sorted.map(function(p) { + var confPct = Math.round((p.confidence || 0) * 100); + return '
' + + '
' + + '' + escapeHtml(p.id || '?') + '' + + (p.created ? '' + escapeHtml(p.created) + '' : '') + + '
' + + '
' + escapeHtml(p.text) + '
' + + '
' + + '
' + + '' + confPct + '%' + + '
' + + (p.source ? '
' + escapeHtml(p.source) + '
' : '') + + '
'; + }).join(''); +} + +export function renderAgentStrategies(strategies) { + var el = document.getElementById('agentStrategiesContent'); + var keys = Object.keys(strategies || {}); + var countEl = document.getElementById('agentStrategiesCount'); + if (countEl) countEl.textContent = keys.length; + if (keys.length === 0) { el.innerHTML = '
No strategies developed yet. The agent creates strategies as it completes tasks.
'; return; } + el.innerHTML = keys.map(function(key) { + var s = strategies[key]; + var steps = s.steps || []; + var tips = s.tips || []; + var stepsHtml = steps.map(function(step, i) { + return '
' + escapeHtml(step) + '
'; + }).join(''); + var tipsHtml = tips.length > 0 ? '' + tips.map(function(tip) { + return '
' + escapeHtml(tip) + '
'; + }).join('') : ''; + return '
' + + '
' + + '
' + escapeHtml(key.replace(/_/g, ' ')) + '
' + + '
' + + '' + steps.length + ' steps' + + (tips.length > 0 ? '' + tips.length + ' tips' : '') + + '
' + + '' + + '
' + + '
' + + '' + + '
' + stepsHtml + '
' + + tipsHtml + + '
'; + }).join(''); +} + +export function renderAgentTimeline(entries) { + var el = document.getElementById('agentTimelineContent'); + var countEl = document.getElementById('agentTimelineCount'); + if (countEl) countEl.textContent = (entries || []).length + ' events'; + if (!entries || entries.length === 0) { el.innerHTML = '
No evolution events yet. The agent logs changes here as it evolves.
'; return; } + el.innerHTML = entries.map(function(e) { + return '
' + + '
' + escapeHtml(e.date) + '
' + + '
' + escapeHtml(e.title) + '
' + + (e.rationale ? '
' + simpleMarkdown(e.rationale) + '
' : '') + + '
'; + }).join(''); +} + +export function renderAgentSessions(sessData) { + var el = document.getElementById('agentSessionsContent'); + var sessions = sessData.sessions || []; + var countEl = document.getElementById('agentSessionsCount'); + if (countEl) countEl.textContent = sessions.length + ' sessions, ' + sessData.stats.total_tasks + ' tasks'; + if (sessions.length === 0) { el.innerHTML = '
No sessions recorded yet. Run the agent to start tracking activity.
'; return; } + var html = ''; + sessions.slice().reverse().forEach(function(s) { + var sessionCost = (s.tasks || []).reduce(function(sum, t) { return sum + (t.cost_usd || 0); }, 0); + var sessionDate = s.started ? new Date(s.started).toLocaleDateString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''; + // Use first task description as session label instead of opaque ID + var firstTask = (s.tasks || [])[0]; + var sessionLabel = (firstTask && firstTask.description) ? firstTask.description.substring(0, 60) : (s.id || '?'); + html += '
'; + html += '
' + + '' + escapeHtml(sessionLabel) + '' + + '' + escapeHtml(s.model || '?') + '' + + '$' + sessionCost.toFixed(2) + '' + + '' + escapeHtml(sessionDate) + '' + + '
'; + (s.tasks || []).forEach(function(t) { + var durSec = Math.round((t.duration_ms || 0) / 1000); + var durStr = durSec >= 60 ? Math.round(durSec / 60) + 'm ' + (durSec % 60) + 's' : durSec + 's'; + html += '
' + + '
' + escapeHtml(t.description || 'Task') + '
' + + '' + (t.turns || 0) + ' turns' + + '$' + (t.cost_usd || 0).toFixed(2) + '' + + '' + durStr + '' + + (t.evolved ? 'evolved' : '') + + '
'; + }); + html += '
'; + }); + el.innerHTML = html; +} + +export function renderAgentPatches(patches) { + var container = document.getElementById('agentPatches'); + var el = document.getElementById('agentPatchesContent'); + var countEl = document.getElementById('agentPatchesCount'); + var valid = (patches || []).filter(function(p) { + var text = p.content || p.instruction || ''; + return text.trim(); + }); + if (countEl) countEl.textContent = valid.length; + if (valid.length === 0) { container.style.display = 'none'; return; } + container.style.display = 'block'; + // Sort by numeric ID ascending (pp1 before pp2, etc.) + valid.sort(function(a, b) { + var aNum = parseInt((a.id || '').replace(/\D/g, ''), 10) || 0; + var bNum = parseInt((b.id || '').replace(/\D/g, ''), 10) || 0; + return aNum - bNum; + }); + el.innerHTML = valid.map(function(p) { + var label = p.action || p.id; + var body = p.content || p.instruction || ''; + var meta = ''; + if (p.created) meta = '
' + escapeHtml(p.created) + '
'; + if (p.reason) meta += '
' + escapeHtml(p.reason) + '
'; + return '
' + + '
' + escapeHtml(label) + '
' + + '
' + escapeHtml(body) + '
' + + meta + '
'; + }).join(''); +} + +// ── Lightweight Markdown → HTML ── +export function renderMarkdown(src) { + var html = escapeHtml(src); + // Code blocks (``` ... ```) + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) { + return '
' + code.replace(/\n$/, '') + '
'; + }); + // Inline code + html = html.replace(/`([^`\n]+)`/g, '$1'); + // Bold + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + // Italic + html = html.replace(/\*([^*]+)\*/g, '$1'); + // Headings (### / ## / #) + html = html.replace(/^### (.+)$/gm, '
$1
'); + html = html.replace(/^## (.+)$/gm, '
$1
'); + html = html.replace(/^# (.+)$/gm, '
$1
'); + // Unordered list items + html = html.replace(/^[-*] (.+)$/gm, '
$1
'); + // Numbered list items + html = html.replace(/^\d+\. (.+)$/gm, '
$1
'); + // Line breaks (preserve double newlines as paragraph breaks) + html = html.replace(/\n\n/g, '
'); + html = html.replace(/\n/g, '
'); + return html; +} + +// ── Agent Chat ── +var agentChat = { ws: null, connected: false, busy: false, wsUrl: null, currentBubble: null, reconnectTimer: null, currentConvId: null }; +// Store tool input/result data in a Map keyed by tool_use_id to avoid DOM bloat from large data-attributes +var toolDataMap = new Map(); + +export function initAgentChat(webPort) { + if (!webPort || webPort === 0) return; + var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + agentChat.wsUrl = proto + '//127.0.0.1:' + webPort + '/ws'; + var hint = document.getElementById('chatSetupHint'); + if (hint) hint.remove(); + // Restore model preference from localStorage + var savedModel = localStorage.getItem('agentModel'); + if (savedModel) { + var sel = document.getElementById('chatModelSelect'); + if (sel) sel.value = savedModel; + } + connectAgentChat(); +} + +export function connectAgentChat() { + if (!agentChat.wsUrl) return; + setChatStatus('connecting', 'Connecting...'); + try { agentChat.ws = new WebSocket(agentChat.wsUrl); } catch (e) { + setChatStatus('error', 'Failed to connect'); + scheduleReconnect(); + return; + } + agentChat.ws.onopen = function() { + agentChat.connected = true; + agentChat.busy = false; + setChatStatus('connected', 'Ready'); + document.getElementById('chatSendBtn').disabled = false; + document.getElementById('chatInput').disabled = false; + if (agentChat.reconnectTimer) { clearTimeout(agentChat.reconnectTimer); agentChat.reconnectTimer = null; } + }; + agentChat.ws.onclose = function() { + agentChat.connected = false; + agentChat.busy = false; + setChatStatus('disconnected', 'Disconnected'); + document.getElementById('chatSendBtn').disabled = true; + document.getElementById('chatInput').disabled = true; + scheduleReconnect(); + }; + agentChat.ws.onerror = function() { setChatStatus('error', 'Connection error'); }; + agentChat.ws.onmessage = function(event) { + var msg; try { msg = JSON.parse(event.data); } catch(e) { return; } + handleChatMessage(msg); + }; +} + +export function scheduleReconnect() { + if (agentChat.reconnectTimer) return; + agentChat.reconnectTimer = setTimeout(function() { agentChat.reconnectTimer = null; connectAgentChat(); }, 5000); +} + +export function setChatStatus(state, text) { + var dot = document.getElementById('chatDot'); + var label = document.getElementById('chatStatusText'); + dot.className = 'agent-chat-dot'; + if (state === 'connected') dot.classList.add('connected'); + else if (state === 'working') dot.classList.add('working'); + label.textContent = text; +} + +export function handleChatMessage(msg) { + var container = document.getElementById('chatMessages'); + switch (msg.type) { + case 'text': + if (!agentChat.currentBubble) { + agentChat.currentBubble = appendChatBubble('assistant', ''); + } + var bubble = agentChat.currentBubble.querySelector('.chat-bubble'); + // Accumulate raw text on a data attribute, render markdown on each chunk + var raw = (bubble.getAttribute('data-raw') || '') + msg.content; + bubble.setAttribute('data-raw', raw); + bubble.innerHTML = renderMarkdown(raw); + bubble.classList.add('chat-streaming'); + scrollChatBottom(); + break; + case 'tool_use': + // Show tool name as a compact pill (strip mcp__ prefixes) + var toolName = (msg.name || '').replace(/^mcp__mnemonic__/, '').replace(/^mcp__/, ''); + var toolInput = msg.input || ''; + var pill = document.createElement('div'); + pill.className = 'chat-tool-pill'; + pill.innerHTML = '\u25B6' + escapeHtml(toolName); + if (msg.tool_use_id) { + pill.setAttribute('data-tool-use-id', msg.tool_use_id); + if (toolInput) { + toolDataMap.set(msg.tool_use_id, { input: toolInput, result: null }); + } + pill.addEventListener('click', toggleToolDetail); + } + // Group consecutive tool pills in a flex row + var lastChild = container.lastElementChild; + var row; + if (lastChild && lastChild.classList.contains('chat-tool-row')) { + row = lastChild; + } else { + row = document.createElement('div'); + row.className = 'chat-tool-row'; + container.appendChild(row); + } + row.appendChild(pill); + scrollChatBottom(); + break; + case 'tool_result': + // Store result in the Map; mark pill as error if needed + if (msg.tool_use_id) { + var existing = toolDataMap.get(msg.tool_use_id); + if (existing) { + existing.result = msg.content || ''; + } else { + toolDataMap.set(msg.tool_use_id, { input: null, result: msg.content || '' }); + } + if (msg.is_error) { + var targetPill = container.querySelector('.chat-tool-pill[data-tool-use-id="' + msg.tool_use_id + '"]'); + if (targetPill) targetPill.classList.add('tool-error'); + } + } + break; + case 'status': + var labels = { recalling: 'Recalling context...', working: 'Working...', reflecting: 'Reflecting...', ready: 'Ready' }; + var label = labels[msg.status] || msg.status; + setChatStatus(msg.status === 'ready' ? 'connected' : 'working', label); + if (msg.status !== 'ready') { + var sp = document.createElement('div'); + sp.className = 'chat-status-pill'; + sp.textContent = label; + container.appendChild(sp); + scrollChatBottom(); + } + break; + case 'done': + if (agentChat.currentBubble) { + agentChat.currentBubble.querySelector('.chat-bubble').classList.remove('chat-streaming'); + agentChat.currentBubble = null; + } + agentChat.busy = false; + setChatStatus('connected', 'Ready \u2014 $' + (msg.cost_usd || 0).toFixed(4) + ' \u00B7 ' + (msg.turns || 0) + ' turns'); + document.getElementById('chatSendBtn').disabled = false; + document.getElementById('chatInput').disabled = false; + document.getElementById('chatInput').focus(); + scrollChatBottom(); + break; + case 'error': + agentChat.busy = false; + agentChat.currentBubble = null; + var errMsg = msg.message || 'Unknown error'; + var isSetupError = /not authenticated|not logged in|cli not found/i.test(errMsg); + appendChatBubble('assistant', isSetupError ? errMsg : 'Error: ' + errMsg); + setChatStatus(isSetupError ? 'disconnected' : 'connected', isSetupError ? 'Setup required' : 'Ready (error)'); + document.getElementById('chatSendBtn').disabled = false; + document.getElementById('chatInput').disabled = false; + scrollChatBottom(); + break; + case 'conversation_started': + agentChat.currentConvId = msg.id; + document.getElementById('chatTitle').textContent = 'New conversation'; + if (msg.model) { + var sel = document.getElementById('chatModelSelect'); + if (sel) sel.value = msg.model; + } + break; + case 'conversations_list': + renderConversationList(msg.conversations); + break; + case 'conversation_loaded': + displayLoadedConversation(msg.conversation); + break; + case 'conversation_deleted': + // Refresh the list if history panel is open + var hp = document.getElementById('chatHistoryPanel'); + if (hp && hp.style.display !== 'none' && agentChat.connected) { + agentChat.ws.send(JSON.stringify({ type: 'list_conversations' })); + } + break; + case 'model_set': + var msel = document.getElementById('chatModelSelect'); + if (msel) msel.value = msg.model; + localStorage.setItem('agentModel', msg.model); + break; + } +} + +export function appendChatBubble(role, text) { + var container = document.getElementById('chatMessages'); + var div = document.createElement('div'); + div.className = 'chat-msg ' + role; + var bubble = document.createElement('div'); + bubble.className = 'chat-bubble'; + if (text) { + bubble.setAttribute('data-raw', text); + bubble.innerHTML = role === 'user' ? escapeHtml(text) : renderMarkdown(text); + } + div.appendChild(bubble); + container.appendChild(div); + scrollChatBottom(); + return div; +} + +export function scrollChatBottom() { + var el = document.getElementById('chatMessages'); + var nearBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) < 80; + if (nearBottom) el.scrollTop = el.scrollHeight; +} + +export function sendChatMessage() { + if (!agentChat.connected || agentChat.busy) return; + var input = document.getElementById('chatInput'); + var text = input.value.trim(); + if (!text) return; + appendChatBubble('user', text); + input.value = ''; + input.style.height = 'auto'; + agentChat.currentBubble = null; + agentChat.busy = true; + // Update title from first message + var titleEl = document.getElementById('chatTitle'); + if (titleEl && titleEl.textContent === 'New conversation') { + titleEl.textContent = text.length > 50 ? text.substring(0, 50) + '...' : text; + } + setChatStatus('working', 'Working...'); + document.getElementById('chatSendBtn').disabled = true; + document.getElementById('chatInput').disabled = true; + agentChat.ws.send(JSON.stringify({ type: 'message', content: text })); +} + +// ── Conversation Management ── + +export function toggleChatHistory() { + var panel = document.getElementById('chatHistoryPanel'); + if (panel.style.display === 'none') { + panel.style.display = 'block'; + if (agentChat.connected) { + agentChat.ws.send(JSON.stringify({ type: 'list_conversations' })); + } + } else { + panel.style.display = 'none'; + } +} + +export function renderConversationList(conversations) { + var list = document.getElementById('chatHistoryList'); + if (!conversations || conversations.length === 0) { + list.innerHTML = '
No conversations yet
'; + return; + } + list.innerHTML = conversations.map(function(c) { + var d = new Date(c.updated_at); + var date = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + var time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + var active = c.id === agentChat.currentConvId ? ' active' : ''; + return '
' + + '
' + + '
' + escapeHtml(c.title) + '
' + + '
' + + '' + date + ' ' + time + '' + + '' + c.message_count + ' msgs' + + (c.total_cost_usd > 0 ? '$' + c.total_cost_usd.toFixed(2) + '' : '') + + '
' + + '
' + + '' + + '
'; + }).join(''); +} + +export function loadConversation(convId) { + if (!agentChat.connected || agentChat.busy) return; + document.getElementById('chatHistoryPanel').style.display = 'none'; + setChatStatus('working', 'Loading...'); + agentChat.ws.send(JSON.stringify({ type: 'load_conversation', id: convId })); +} + +export function displayLoadedConversation(conv) { + var container = document.getElementById('chatMessages'); + container.innerHTML = ''; + toolDataMap.clear(); + agentChat.currentConvId = conv.id; + agentChat.currentBubble = null; + document.getElementById('chatTitle').textContent = conv.title || 'Conversation'; + + (conv.messages || []).forEach(function(m) { + if (m.role === 'user') { + appendChatBubble('user', m.content); + } else if (m.role === 'assistant') { + appendChatBubble('assistant', m.content); + } else if (m.role === 'tool_use') { + var toolName = (m.name || '').replace(/^mcp__mnemonic__/, '').replace(/^mcp__/, ''); + var toolInput = m.input || ''; + var toolId = m.tool_use_id || ('hist-' + Math.random().toString(36).slice(2, 10)); + var row = document.createElement('div'); + row.className = 'chat-tool-row'; + var pill = document.createElement('div'); + pill.className = 'chat-tool-pill'; + pill.innerHTML = '\u25B6' + escapeHtml(toolName); + pill.setAttribute('data-tool-use-id', toolId); + if (toolInput) { + toolDataMap.set(toolId, { input: toolInput, result: null }); + pill.addEventListener('click', toggleToolDetail); + } + row.appendChild(pill); + container.appendChild(row); + } + }); + + // Add continuation separator + var sep = document.createElement('div'); + sep.className = 'chat-status-pill'; + sep.textContent = 'Continuing conversation\u2026'; + sep.style.borderTop = '1px dashed var(--border-color)'; + sep.style.paddingTop = '8px'; + sep.style.marginTop = '4px'; + container.appendChild(sep); + + scrollChatBottom(); + setChatStatus('connected', 'Ready'); +} + +export function deleteConversation(convId) { + if (!agentChat.connected) return; + agentChat.ws.send(JSON.stringify({ type: 'delete_conversation', id: convId })); +} + +export function startNewConversation() { + if (!agentChat.connected || agentChat.busy) return; + document.getElementById('chatMessages').innerHTML = ''; + toolDataMap.clear(); + document.getElementById('chatHistoryPanel').style.display = 'none'; + agentChat.currentBubble = null; + agentChat.ws.send(JSON.stringify({ type: 'new_conversation' })); +} + +export function onModelChange(model) { + if (!agentChat.connected) return; + localStorage.setItem('agentModel', model); + agentChat.ws.send(JSON.stringify({ type: 'set_model', model: model })); +} + +// ── Self-Update ── +var _updateInfo = null; + +export async function checkForUpdate() { + try { + var data = await fetchJSON('/system/update-check'); + var badge = document.getElementById('navUpdateBadge'); + var changelog = document.getElementById('navChangelogLink'); + if (data.update_available) { + _updateInfo = data; + badge.textContent = 'v' + data.latest_version + ' available'; + badge.style.display = 'inline-block'; + if (data.release_url) { + changelog.href = data.release_url; + changelog.style.display = 'inline'; + } + } else { + _updateInfo = null; + badge.style.display = 'none'; + changelog.style.display = 'none'; + } + } catch (e) { + // Silently ignore — update check is best-effort + } +} + +export function waitForRestart(expectedVersion) { + var attempts = 0; + var maxAttempts = 30; + var interval = setInterval(async function() { + attempts++; + try { + var resp = await fetch(CONFIG.API_BASE + '/health', { + headers: CONFIG.TOKEN ? { 'Authorization': 'Bearer ' + CONFIG.TOKEN } : {} + }); + if (resp.ok) { + var health = await resp.json(); + if (health.version === expectedVersion) { + clearInterval(interval); + showToast('Reloading with v' + expectedVersion + '...', 'success'); + setTimeout(function() { location.reload(); }, 500); + } + } + } catch (_) { + // daemon still restarting, keep polling + } + if (attempts >= maxAttempts) { + clearInterval(interval); + showToast('Daemon took too long to restart — refresh manually', 'warning'); + } + }, 1000); +} + +export async function triggerUpdate() { + if (!_updateInfo || !_updateInfo.update_available) return; + if (!confirm('Update mnemonic to v' + _updateInfo.latest_version + '?\n\nThe daemon will restart automatically.')) return; + + var badge = document.getElementById('navUpdateBadge'); + badge.textContent = 'Downloading...'; + badge.className = 'update-badge updating'; + + try { + var data = await fetchJSON('/system/update', { method: 'POST' }); + if (data.status === 'updated' && data.restart_pending) { + badge.textContent = 'Restarting...'; + showToast('Updated to v' + data.new_version + ' — restarting...', 'success'); + waitForRestart(data.new_version); + } else if (data.status === 'updated' && !data.restart_pending) { + badge.textContent = 'Restart needed'; + badge.className = 'update-badge'; + showToast(data.message || 'Updated — restart the daemon manually', 'warning'); + } else if (data.status === 'up_to_date') { + badge.style.display = 'none'; + showToast('Already up to date', 'success'); + } + } catch (e) { + badge.textContent = 'Update failed'; + badge.className = 'update-badge'; + showToast('Update failed: ' + e.message, 'error'); + setTimeout(function() { badge.textContent = 'v' + _updateInfo.latest_version + ' available'; }, 3000); + } +} + diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js new file mode 100644 index 00000000..5bf84ca7 --- /dev/null +++ b/internal/web/static/js/app.js @@ -0,0 +1,88 @@ +// ── Mnemonic Dashboard — Entry Point ── +// Imports all modules and wires exported functions to window for HTML onclick handlers. + +import { state, CONFIG } from './state.js'; +import { apiFetch, fetchJSON, makeDayBuckets, showToast, svgEl, linScale, svgText, fmtNum, + escapeHtml, memoryType, memoryTypeAbbr, memoryTypeIcon, safeSalience, agentProfile, + addLivePost, simpleMarkdown } from './utils.js'; +import { setTheme, switchView, switchExploreTab, handleHash } from './nav.js'; +import { performRecall, renderResults, toggleExpand, toggle, toggleRemember, submitRemember } from './recall.js'; +import { loadThread, submitFeedback, loadExploreTab, loadEpisodes, loadMemories, + loadPatterns, archivePattern, loadAbstractions, filterExplore } from './explore.js'; +import { setTimelineRange, toggleTimelineType, filterTimeline, clearTimelineSearch, + populateTimelineProjects, toggleTimelineProject, filterTimelineItems, + renderTimelineItems, renderTimelineCard, expandTimelineCard, toggleTimelineTag, + hoverTimelineTag, unhoverTimelineTag, loadTimelineData, setupTimelineScroll, + renderTimelineSparkline } from './timeline.js'; +import { toggleDrawer, loadActivityConcepts, updateBadge, addEvent, clearEvents, + triggerConsolidation, formatInsightDetail, loadInsights, loadStats, loadProjects } from './drawer.js'; +import { connectWebSocket } from './ws.js'; +import { setLLMRange, loadLLMUsage, setAnalyticsRange, setToolRange, loadToolUsage, + loadAnalytics, toggleSession, loadSessionTimeline } from './llm.js'; +import { forumFetch, loadForumIndex, loadMemorySection, loadForumGroup, loadForumCategory, + loadForumThread, renderForumPost, appendForumPostToThread, showNewThreadForm, + submitNewThread, submitThreadReply, quotePostById, insertTagInReply, + ensureMentionAutocomplete, subscribeToThread, markThreadRead, onForumPostWebSocket, + updateNotificationBadge, internalizePost, toggleToolDetail, relativeTime } from './forum.js'; +import { loadAgentData, refreshAgentData, renderAgentDashboard, sendChatMessage, + toggleChatHistory, startNewConversation, onModelChange, + checkForUpdate, triggerUpdate, renderMarkdown } from './agent.js'; +import { formatBytes } from './llm.js'; + +// ── Wire to window for HTML onclick handlers ── +Object.assign(window, { + // State (needed by some inline refs) + state, CONFIG, + // Nav + setTheme, switchView, switchExploreTab, handleHash, + // Recall + Remember + performRecall, renderResults, toggleExpand, toggle, toggleRemember, submitRemember, submitFeedback, + // Explore + loadThread, loadExploreTab, loadEpisodes, loadMemories, loadPatterns, archivePattern, + loadAbstractions, filterExplore, + // Timeline + setTimelineRange, toggleTimelineType, filterTimeline, clearTimelineSearch, + populateTimelineProjects, toggleTimelineProject, toggleTimelineTag, hoverTimelineTag, + unhoverTimelineTag, expandTimelineCard, loadTimelineData, renderTimelineItems, + // Drawer + toggleDrawer, clearEvents, triggerConsolidation, addEvent, loadStats, + // LLM + Tools + setLLMRange, loadLLMUsage, setAnalyticsRange, setToolRange, loadToolUsage, + loadAnalytics, toggleSession, loadSessionTimeline, + // Forum + loadForumIndex, loadMemorySection, loadForumGroup, loadForumCategory, loadForumThread, + showNewThreadForm, submitNewThread, submitThreadReply, quotePostById, insertTagInReply, + ensureMentionAutocomplete, subscribeToThread, markThreadRead, onForumPostWebSocket, + updateNotificationBadge, internalizePost, + // Agent + loadAgentData, refreshAgentData, renderAgentDashboard, sendChatMessage, + toggleChatHistory, startNewConversation, onModelChange, + checkForUpdate, triggerUpdate, + // Utils (used by some inline HTML) + escapeHtml, showToast, relativeTime, simpleMarkdown, toggleToolDetail, + agentProfile, memoryType, safeSalience, renderMarkdown, formatBytes, +}); + +// ── Initialize ── +// Sync theme dropdown (modules run after DOM is ready, so no DOMContentLoaded needed) +var _themeSel = document.getElementById('themeSelect'); +if (_themeSel) _themeSel.value = localStorage.getItem('mnemonic-theme') || 'midnight'; + +async function initializeApp() { + loadStats(); + loadInsights(); + loadProjects(); + connectWebSocket(); + checkForUpdate(); + setInterval(loadStats, CONFIG.STATS_POLL); + setInterval(loadInsights, CONFIG.INSIGHTS_POLL); + setInterval(checkForUpdate, 3600000); + setInterval(function() { + document.querySelectorAll('.event-time[data-ts]').forEach(function(el) { + el.textContent = new Date(el.getAttribute('data-ts')).toLocaleString(); + }); + }, 30000); + handleHash(); +} + +initializeApp(); diff --git a/internal/web/static/js/drawer.js b/internal/web/static/js/drawer.js new file mode 100644 index 00000000..579482b3 --- /dev/null +++ b/internal/web/static/js/drawer.js @@ -0,0 +1,295 @@ +import { state, CONFIG } from './state.js'; +import { apiFetch, fetchJSON, escapeHtml, showToast } from './utils.js'; +import { relativeTime } from './forum.js'; + +// ── Activity Drawer ── +var _conceptsInterval = null; +export function toggleDrawer() { + state.drawerOpen = !state.drawerOpen; + document.getElementById('activityDrawer').classList.toggle('open', state.drawerOpen); + document.getElementById('activityBackdrop').classList.toggle('open', state.drawerOpen); + if (state.drawerOpen) { + state.unreadEvents = 0; updateBadge(); + loadActivityConcepts(); + _conceptsInterval = setInterval(loadActivityConcepts, 10000); + } else if (_conceptsInterval) { + clearInterval(_conceptsInterval); + _conceptsInterval = null; + } +} + +export async function loadActivityConcepts() { + try { + var resp = await fetch(CONFIG.API_BASE + '/activity'); + if (!resp.ok) return; + var data = await resp.json(); + var windowMs = (data.window_minutes || 30) * 60 * 1000; + var now = Date.now(); + var concepts = []; + for (var name in (data.concepts || {})) { + var ts = new Date(data.concepts[name]).getTime(); + var elapsed = now - ts; + var remaining = windowMs - elapsed; + if (remaining > 0) { + concepts.push({ name: name, remaining: remaining, pct: (remaining / windowMs) * 100 }); + } + } + concepts.sort(function(a, b) { return b.remaining - a.remaining; }); + var container = document.getElementById('conceptsContainer'); + if (concepts.length === 0) { + container.innerHTML = '
No active concepts (watcher idle)
'; + return; + } + container.innerHTML = concepts.slice(0, 15).map(function(c) { + var mins = Math.ceil(c.remaining / 60000); + var color = c.pct > 50 ? 'var(--accent-green)' : c.pct > 20 ? 'var(--accent-yellow, #f0c040)' : 'var(--accent-pink)'; + return '
' + + '' + escapeHtml(c.name) + '' + + '
' + + '' + mins + 'm
'; + }).join(''); + } catch(e) { /* ignore */ } +} + +export function updateBadge() { + var badge = document.getElementById('activityBadge'); + if (state.unreadEvents > 0) { badge.textContent = state.unreadEvents > 99 ? '99+' : state.unreadEvents; badge.classList.add('visible'); } + else { badge.classList.remove('visible'); } +} + +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'] }; + +export function _nonZero(items) { + return items.filter(function(i) { return i[0] > 0; }).map(function(i) { return i[0] + ' ' + i[1]; }).join(' \u00b7 '); +} + +export 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 ''; + } +} + +export function addEvent(type, description, timestamp, payload) { + var container = document.getElementById('eventsContainer'); + var el = document.createElement('div'); + var navTarget = _eventNavTargets[type]; + el.className = 'event-item' + (navTarget ? ' event-clickable' : ''); + if (navTarget) { + el.onclick = function() { + toggleDrawer(); + window.switchView(navTarget[0]); + if (navTarget[1]) window.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(); } +} + +export function clearEvents() { document.getElementById('eventsContainer').innerHTML = ''; } + +export async function triggerConsolidation() { + try { await apiFetch(CONFIG.API_BASE + '/consolidation/run', { method: 'POST' }); showToast('Consolidation triggered', 'success'); } + catch (e) { showToast('Failed to trigger consolidation', 'error'); } +} + +// ── Insights ── +export function formatInsightDetail(obsType, details) { + if (!details || Object.keys(details).length === 0) return ''; + switch (obsType) { + case 'source_balance': { + var counts = details.source_counts || {}; + var parts = Object.entries(counts).sort(function(a,b) { return b[1] - a[1]; }).map(function(kv) { return kv[0] + ': ' + kv[1].toLocaleString(); }); + return (details.dominant_source || '?') + ' dominates at ' + ((details.dominant_ratio || 0) * 100).toFixed(1) + '% (' + parts.join(', ') + ')'; + } + case 'autonomous_action': { + var action = (details.action || '').replace(/_/g, ' '); + if (details.trigger) return action + ' (trigger: ' + details.trigger.replace(/_/g, ' ') + ')'; + if (details.count) return action + ' (' + details.count + ' memories)'; + return action; + } + case 'quality_audit': { + var issues = []; + if (details.no_embedding) issues.push(details.no_embedding + ' missing embeddings'); + if (details.no_compression) issues.push(details.no_compression + ' uncompressed'); + if (details.short_summary) issues.push(details.short_summary + ' short summaries'); + if (details.long_summary) issues.push(details.long_summary + ' long summaries'); + return issues.length > 0 ? issues.join(', ') : 'All checks passed'; + } + case 'recall_effectiveness': { + var active = details.total_active || 0; + var dead = details.dead_count || 0; + var ratio = ((details.dead_ratio || 0) * 100).toFixed(0); + return dead + ' of ' + active + ' memories inactive (' + ratio + '% dead ratio)'; + } + default: { + return Object.entries(details).map(function(kv) { + var v = kv[1]; + if (v && typeof v === 'object') v = JSON.stringify(v); + return kv[0].replace(/_/g, ' ') + ': ' + v; + }).join(' \u00b7 '); + } + } +} + +export async function loadInsights() { + try { + var data = await fetchJSON('/insights'); + var observations = data.observations || []; + var container = document.getElementById('insightsContainer'); + if (observations.length === 0) { container.innerHTML = '
No insights yet
'; return; } + // Deduplicate: keep only the most recent of each observation type + var seenTypes = {}; + observations = observations.filter(function(obs) { + var t = obs.observation_type || ''; + if (seenTypes[t]) return false; + seenTypes[t] = true; + return true; + }); + container.innerHTML = observations.slice(0, 10).map(function(obs) { + var sevClass = obs.severity === 'critical' ? 'critical' : obs.severity === 'warning' ? 'warning' : ''; + var detailStr = formatInsightDetail(obs.observation_type, obs.details); + return '
' + escapeHtml(obs.observation_type || '').replace(/_/g, ' ') + '
' + escapeHtml(detailStr) + '
'; + }).join(''); + } catch (e) { console.error('Failed to load insights:', e); showToast('Failed to load insights', 'error'); } +} + +// ── Stats ── +export async function loadStats() { + try { + var results = await Promise.all([apiFetch(CONFIG.API_BASE + '/health'), apiFetch(CONFIG.API_BASE + '/stats')]); + var health = await results[0].json(); + var stats = await results[1].json(); + var mc = document.getElementById('navMemoryCount'); + if (mc) mc.textContent = (stats.store && stats.store.active_memories) || health.memory_count || 0; + var dot = document.getElementById('healthDot') || document.getElementById('navHealth'); + if (dot) { + dot.className = health.status === 'ok' ? 'nav-health' : 'nav-health degraded'; + dot.title = health.status === 'ok' ? 'daemon online' : 'degraded'; + } + if (health.version) { + var vEl = document.getElementById('navVersion'); + if (vEl) { + vEl.textContent = 'v' + health.version; + vEl.href = 'https://github.com/AppSprout-dev/mnemonic/releases/tag/v' + health.version; + } + } + if (health.tool_count) { + document.getElementById('toolHeaderSub').textContent = 'recall \u00b7 remember \u00b7 get_context \u00b7 ' + health.tool_count + ' tools'; + } + state.previousStats = stats; + // Update forum footer + var total = (stats.store && stats.store.active_memories) || health.memory_count || 0; + var fa = document.getElementById('footActive'); + var ff = document.getElementById('footFading'); + var far = document.getElementById('footArchived'); + var fv = document.getElementById('footVersion'); + var fe = document.getElementById('footEncoding'); + var fc = document.getElementById('footConsolidation'); + if (fa) fa.textContent = (stats.store ? stats.store.active_memories : total) + ' active'; + if (ff) ff.textContent = (stats.store ? stats.store.fading_memories : 0) + ' fading'; + if (far) far.textContent = (stats.store ? stats.store.archived_memories : 0) + ' archived'; + if (fv) fv.textContent = 'mnemonic ' + (health.version ? 'v' + health.version : ''); + if (fe) fe.textContent = 'encoding: ' + (health.encoding_pending > 0 ? health.encoding_pending + ' pending' : 'idle'); + if (fc && stats.last_consolidation) { + var d = new Date(stats.last_consolidation); + fc.textContent = 'consolidation: ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); + } + // Update welcome panel + var wa = document.getElementById('welcomeActive'); + var wf = document.getElementById('welcomeFading'); + var war = document.getElementById('welcomeArchived'); + var wv = document.getElementById('welcomeVisit'); + if (wa) wa.textContent = stats.store ? stats.store.active_memories : total; + if (wf) wf.textContent = stats.store ? stats.store.fading_memories : 0; + if (war) war.textContent = stats.store ? stats.store.archived_memories : 0; + if (wv) { + var lastVisit = localStorage.getItem('mnemonic-last-visit'); + if (lastVisit) { + wv.textContent = 'Last visit: ' + new Date(parseInt(lastVisit)).toLocaleString([], {hour:'2-digit',minute:'2-digit',month:'short',day:'numeric'}); + } else { + wv.textContent = 'Welcome to mnemonic'; + } + localStorage.setItem('mnemonic-last-visit', Date.now().toString()); + } + // Update healthDot (forum nav uses different ID) + var hd = document.getElementById('healthDot'); + if (hd) { + hd.className = health.status === 'ok' ? 'nav-health' : 'nav-health degraded'; + hd.title = health.status === 'ok' ? 'daemon online' : 'degraded'; + } + } catch (e) { + document.getElementById('navHealth').className = 'nav-health down'; + document.getElementById('navHealth').title = 'Cannot reach server'; + } +} + +// ── Projects ── +export async function loadProjects() { + try { + var data = await fetchJSON('/projects'); + state.projectList = data.projects || []; + var select = document.getElementById('rememberProject'); + state.projectList.forEach(function(p) { + var opt = document.createElement('option'); + opt.value = p; opt.textContent = p; + select.appendChild(opt); + }); + window.populateTimelineProjects(); + } catch (e) { console.error('Failed to load projects:', e); } +} + diff --git a/internal/web/static/js/explore.js b/internal/web/static/js/explore.js new file mode 100644 index 00000000..ea4fe350 --- /dev/null +++ b/internal/web/static/js/explore.js @@ -0,0 +1,399 @@ +import { state, CONFIG } from './state.js'; +import { apiFetch, fetchJSON, escapeHtml, showToast, memoryType, agentProfile, safeSalience, memoryTypeAbbr, memoryTypeIcon } from './utils.js'; +import { relativeTime } from './forum.js'; + +// ── Thread View (episode detail as forum posts) ── +export async function loadThread(episodeId) { + state.currentView = 'thread'; + state.currentEpisodeId = episodeId; + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + document.querySelectorAll('.ntab').forEach(t => t.classList.remove('active')); + var threadView = document.getElementById('view-thread'); + if (threadView) threadView.classList.add('active'); + window.location.hash = 'thread/' + episodeId; + + // Update breadcrumbs + var crumbs = document.getElementById('breadcrumbs'); + if (crumbs) crumbs.innerHTML = 'mnemonic Episodes Episode'; + + // Show compose box and set up a forum thread for this episode + var compose = document.getElementById('threadCompose'); + if (compose) { compose.style.display = ''; window.ensureMentionAutocomplete('threadReplyContent'); } + // Use episode ID as the forum thread ID so replies and @mentions work + state.currentForumThread = 'episode-' + episodeId; + + var container = document.getElementById('threadContent'); + container.innerHTML = '
Loading thread...
'; + + try { + var epResp = await fetchJSON('/episodes/' + episodeId); + var ep = epResp.episode || epResp; // endpoint wraps in {episode: {...}} + // Fetch memories for this episode + var memData = await fetchJSON('/memories?episode_id=' + episodeId + '&limit=100'); + var memories = memData.memories || []; + + // Fetch associations for these memories + var assocMap = {}; // memoryID -> [{target_summary, strength, relation_type}] + if (memories.length > 0) { + var memIds = memories.map(function(m) { return m.id; }).join(','); + try { + var assocResp = await window.forumFetch('/api/v1/associations?memory_ids=' + memIds); + var assocData = await assocResp.json(); + (assocData.associations || []).forEach(function(a) { + // Index by source — show what each memory links to + if (!assocMap[a.source_id]) assocMap[a.source_id] = []; + assocMap[a.source_id].push({ summary: a.target_summary, strength: a.strength, type: a.relation_type }); + // Index by target — show what links back + if (!assocMap[a.target_id]) assocMap[a.target_id] = []; + assocMap[a.target_id].push({ summary: a.source_summary, strength: a.strength, type: a.relation_type }); + }); + } catch (e) { /* associations are non-critical */ } + } + + // Build thread header + var html = '
'; + html += '
'; + html += '
' + escapeHtml(ep.title || 'Untitled Episode') + '
'; + html += '
'; + if (ep.emotional_tone) html += 'Mood: ' + escapeHtml(ep.emotional_tone) + ''; + if (ep.start_time && ep.end_time) { + html += 'Duration: ' + new Date(ep.start_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + ' – ' + new Date(ep.end_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + ''; + } + var rawCount = (ep.raw_memory_ids || []).length; + html += 'Memories: ' + memories.length + (rawCount > memories.length ? ' (' + rawCount + ' observations)' : '') + ''; + if (ep.files_modified && ep.files_modified.length > 0) { + html += 'Files: ' + ep.files_modified.slice(0, 5).map(function(f) { return escapeHtml(f.split('/').pop()); }).join(', ') + ''; + } + html += '
'; + + if (memories.length === 0) { + // No encoded memories linked to this episode yet + // Show a "Daemon" post with the episode summary as content + var rawCount = (ep.raw_memory_ids || []).length; + html += '
'; + html += '
'; + html += '
EP
'; + html += 'Episoding Agent
'; + html += '
episode
'; + html += '
Events: ' + rawCount + '
'; + if (ep.emotional_tone) html += '
Mood: ' + escapeHtml(ep.emotional_tone) + '
'; + if (ep.project) html += '
Project: ' + escapeHtml(ep.project) + '
'; + html += '
'; + html += '
'; + html += '

' + escapeHtml(ep.title || 'Episode') + '

'; + html += ''; + if (ep.summary) html += '
' + escapeHtml(ep.summary) + '
'; + if (ep.narrative) html += '
' + escapeHtml(ep.narrative) + '
'; + if (ep.files_modified && ep.files_modified.length > 0) { + html += ''; + } + if (ep.concepts && ep.concepts.length > 0) { + html += ''; + } + html += '
Encoded memories will appear here after consolidation links them to this episode. ' + rawCount + ' raw observations are pending encoding.
'; + html += '
'; + } + if (memories.length > 0) { + memories.forEach(function(m, idx) { + var type = memoryType(m); + var rankClass = 'rank-' + type; + var salPct = safeSalience(m.salience); + var memAssocs = assocMap[m.id] || []; + var linkCount = memAssocs.length; + var source = m.source || 'mcp'; + var agent = agentProfile(source); + var time = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; + var joinDate = m.created_at ? new Date(m.created_at).toLocaleDateString() : ''; + var concepts = m.concepts || []; + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + // phpBB-style post with agent profile sidebar + html += '
'; + + // Agent profile sidebar + html += '
'; + html += '
' + agent.icon + '
'; + html += '' + escapeHtml(agent.name) + '
'; + html += '
' + escapeHtml(type) + '
'; + html += '
Salience: ' + salPct + '%
'; + html += '
Links: ' + linkCount + '
'; + if (m.project) html += '
Project: ' + escapeHtml(m.project) + '
'; + html += '
Observed: ' + joinDate + '
'; + html += '
'; + + // Post body + html += '
'; + html += '

' + escapeHtml(m.summary || 'Memory') + '

'; + html += ''; + + // Content + if (m.content && m.content !== m.summary) { + html += '
' + escapeHtml(m.content) + '
'; + } else if (m.summary) { + html += '
' + escapeHtml(m.summary) + '
'; + } + + // Concept tags + if (concepts.length > 0) { + html += ''; + } + + // Association quotes (linked memories) + if (memAssocs.length > 0) { + var shown = memAssocs.slice(0, 5); // cap at 5 to avoid clutter + shown.forEach(function(a) { + if (!a.summary) return; + var strengthPct = Math.round(a.strength * 100); + html += '
'; + html += '
\u2197 ' + escapeHtml(a.type || 'linked') + ' (' + strengthPct + '%)
'; + html += '
' + escapeHtml(a.summary) + '
'; + html += '
'; + }); + } + + html += '
'; // close postbody, inner, post + }); + } + + // Episode narrative at bottom + if (ep.narrative) { + html += '
'; + html += '
Episode Narrative
'; + html += escapeHtml(ep.narrative); + html += '
'; + } + + html += '
'; + container.innerHTML = html; + + // Update breadcrumbs with title + if (crumbs) crumbs.innerHTML = 'mnemonic Forum ' + escapeHtml((ep.title || 'Episode').substring(0, 50)); + } catch (e) { + console.error('loadThread error:', e); + container.innerHTML = '
Failed to load episode: ' + escapeHtml(e.message || String(e)) + '
'; + } +} + +export async function submitFeedback(quality, event) { + event.stopPropagation(); + if (!state.lastQueryId) return; + var bar = event.target.closest('.feedback-bar'); + bar.querySelectorAll('.feedback-btn').forEach(function(b) { b.classList.remove('selected'); }); + event.target.classList.add('selected'); + try { + await apiFetch(CONFIG.API_BASE + '/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query_id: state.lastQueryId, quality: quality }), + }); + showToast('Feedback recorded: ' + quality, 'success'); + } catch (e) { + showToast('Failed to submit feedback', 'error'); + } +} + + +// ── Explore ── +export async function loadExploreTab(tab) { + var section = document.getElementById('section-' + tab); + section.innerHTML = '
'; + try { + switch (tab) { + case 'episodes': await loadEpisodes(section); break; + case 'memories': await loadMemories(section); break; + case 'patterns': await loadPatterns(section); break; + case 'abstractions': await loadAbstractions(section); break; + } + state.exploreLoaded[tab] = true; + } catch (e) { + section.innerHTML = '
Failed to load ' + tab + '
'; + } +} + +export async function loadEpisodes(section) { + var data = await fetchJSON('/episodes?limit=50'); + var episodes = data.episodes || []; + if (episodes.length === 0) { section.innerHTML = '
📚
No episodes yet
Episodes are created as memories accumulate
'; return; } + // phpBB-style header + row list + var html = '
  • Episode
    Mem
    Files
    Last Activity
'; + html += '
    '; + html += episodes.map(function(ep, idx) { + var concepts = (ep.concepts || []).slice(0, 4); + var memCount = (ep.raw_memory_ids || []).length; + var fileCount = (ep.files_modified || []).length; + var dateStr = ep.start_time ? new Date(ep.start_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; + var epTitle = ep.title || (ep.state === 'open' ? 'Episode in progress\u2026' : 'Untitled Episode'); + var isNew = ep.state === 'open'; + var tone = ep.emotional_tone || ep.outcome || ''; + var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + var sub = ep.summary ? escapeHtml(ep.summary) : ''; + var expandId = 'exp-ep-' + idx; + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + var row = '
  • '; + row += '
    '; + row += 'EP'; + row += '
    '; + row += '' + escapeHtml(epTitle) + ''; + row += '' + sub + ' ' + tags + ''; + row += '
    '; + row += '
    ' + memCount + ' memories
    '; + row += '
    ' + fileCount + ' files
    '; + row += '
    ' + dateStr + '
    ' + escapeHtml(tone) + '
    '; + row += '
  • '; + + // Expandable zone + row += '
    '; + row += '
    ' + memCount + ' observations in this episodeView full thread →
    '; + if (ep.narrative) { + row += '
    ' + escapeHtml(ep.narrative) + '
    '; + } + row += '
    '; + return row; + }).join(''); + html += '
'; + section.innerHTML = html; +} + +export async function loadMemories(section) { + var data = await fetchJSON('/memories?limit=50&state=active'); + var memories = data.memories || []; + if (memories.length === 0) { section.innerHTML = '
🧠
No memories yet
Use the Remember panel to add your first memory
'; return; } + var html = '
  • Memory
    Sal
    Links
    Created
'; + html += '
    '; + html += memories.map(function(m, idx) { + var type = memoryType(m); + var typeAbbr = memoryTypeAbbr(type); + var iconClass = memoryTypeIcon(type); + var salPct = safeSalience(m.salience); + var salClass = salPct >= 60 ? 'sal-hi' : salPct >= 30 ? 'sal-mid' : 'sal-lo'; + var linkCount = m.association_count || 0; + var dateStr = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; + var summary = m.summary || m.content || ''; + var concepts = (m.concepts || []).slice(0, 4); + var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + var source = m.source || 'mcp'; + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + var row = '
  • '; + row += '
    '; + row += '' + typeAbbr + ''; + row += '
    '; + row += '' + escapeHtml(summary) + ''; + row += '' + tags + ''; + row += '
    '; + row += '
    ' + salPct + '% salience
    '; + row += '
    ' + linkCount + ' links
    '; + var agent = agentProfile(source); + row += '
    ' + dateStr + '
    ' + escapeHtml(agent.name) + '
    '; + row += '
  • '; + + // Expandable detail + row += '
    '; + if (m.content && m.content !== summary) { + row += '
    ' + escapeHtml(m.content) + '
    '; + } + row += '
    '; + row += 'ID: ' + escapeHtml((m.id || '').substring(0, 8)) + '...'; + if (m.project) row += ' · Project: ' + escapeHtml(m.project); + if (m.access_count > 0) row += ' · Recalled ' + m.access_count + 'x'; + row += ' · ' + escapeHtml(m.state || 'active'); + row += '
    '; + return row; + }).join(''); + html += '
'; + section.innerHTML = html; +} + +export async function loadPatterns(section) { + var data = await fetchJSON('/patterns?limit=20'); + var patterns = data.patterns || []; + if (patterns.length === 0) { section.innerHTML = '
📈
No patterns discovered yet
Patterns emerge after consolidation cycles
'; return; } + var html = '
  • Pattern
    Str
    Evidence
    Discovered
'; + html += '
    '; + html += patterns.map(function(p, idx) { + var strengthPct = Math.round((p.strength || 0) * 100); + var concepts = (p.concepts || []).slice(0, 4); + var evidenceCount = (p.evidence_ids || []).length; + var age = p.created_at ? relativeTime(p.created_at) : ''; + var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + var row = '
  • '; + row += '
    '; + row += 'PT'; + row += '
    '; + row += '' + escapeHtml(p.title || 'Untitled') + ''; + row += '' + escapeHtml(p.description || '') + ' ' + tags + ''; + row += '
    '; + row += '
    ' + strengthPct + '%
    '; + row += '
    ' + evidenceCount + '
    '; + row += '
    ' + age + '
    '; + row += '
  • '; + return row; + }).join(''); + html += '
'; + section.innerHTML = html; +} + +export async function archivePattern(id, btn) { + try { + var resp = await fetch(CONFIG.API_BASE + '/patterns/' + id, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({state: 'archived'}) + }); + if (!resp.ok) { showToast('Failed to archive pattern', 'error'); return; } + var card = btn.closest('.pattern-card'); + if (card) { card.style.transition = 'opacity 0.3s'; card.style.opacity = '0'; setTimeout(function() { card.remove(); }, 300); } + showToast('Pattern archived'); + } catch(e) { showToast('Failed to archive pattern', 'error'); } +} + +export async function loadAbstractions(section) { + var data = await fetchJSON('/abstractions?limit=20'); + var abstractions = data.abstractions || []; + if (abstractions.length === 0) { section.innerHTML = '
💡
No abstractions yet
Abstractions form from patterns during dream cycles
'; return; } + var html = '
  • Abstraction
    Conf
    Sources
    Discovered
'; + html += '
    '; + html += abstractions.map(function(a, idx) { + var levelLabel = a.level === 3 ? 'Axiom' : a.level === 2 ? 'Principle' : 'L' + a.level; + var confPct = Math.round((a.confidence || 0) * 100); + var concepts = (a.concepts || []).slice(0, 4); + var sourceCount = (a.source_pattern_ids || []).length; + var age = a.created_at ? relativeTime(a.created_at) : ''; + var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + var iconLabel = a.level === 3 ? 'AX' : a.level === 2 ? 'PR' : 'AB'; + + var row = '
  • '; + row += '
    '; + row += '' + iconLabel + ''; + row += '
    '; + row += '' + escapeHtml(a.title || 'Untitled') + ' [' + levelLabel + ']'; + row += '' + escapeHtml(a.description || '') + ' ' + tags + ''; + row += '
    '; + row += '
    ' + confPct + '%
    '; + row += '
    ' + sourceCount + '
    '; + row += '
    ' + age + '
    '; + row += '
  • '; + return row; + }).join(''); + html += '
'; + section.innerHTML = html; +} + +export function filterExplore() { + var query = document.getElementById('exploreSearch').value.toLowerCase(); + var section = document.getElementById('section-' + state.currentExploreTab); + var rows = section.querySelectorAll('li.row, .pattern-card, .abstraction-card'); + rows.forEach(function(row) { row.style.display = row.textContent.toLowerCase().includes(query) ? '' : 'none'; }); +} + diff --git a/internal/web/static/js/forum.js b/internal/web/static/js/forum.js new file mode 100644 index 00000000..10e6eee8 --- /dev/null +++ b/internal/web/static/js/forum.js @@ -0,0 +1,885 @@ +import { state } from './state.js'; +import { fetchJSON, escapeHtml, showToast } from './utils.js'; + +// ── Forum Communication Layer ── + +export async function forumFetch(url, options) { + var resp = await fetch(url, options); + if (!resp.ok) { + var errText = 'HTTP ' + resp.status; + try { var d = await resp.json(); if (d.error) errText = d.error; } catch(e) { /* ignore parse errors */ } + throw new Error(errText); + } + return resp; +} + +export async function loadForumIndex() { + try { + var resp = await forumFetch('/api/v1/forum/categories'); + var data = await resp.json(); + state.forumLoaded = true; + var container = document.getElementById('forumIndex'); + if (!container) return; + var categories = data.categories || []; + if (categories.length === 0) { + container.innerHTML = '
No categories found.
'; + return; + } + // Group categories by type + var groups = { system: [], project: [], agent: [], custom: [] }; + var groupLabels = { system: 'General', project: 'Projects', agent: 'Agents', custom: 'Custom' }; + categories.forEach(function(c) { (groups[c.category.type] || groups.custom).push(c); }); + // Populate new thread category selector + var select = document.getElementById('newThreadCategory'); + if (select) { + var optHtml = ''; + categories.filter(function(c) { return c.category.type === 'system' || c.category.type === 'custom'; }).forEach(function(c) { + var sel = c.category.id === 'discussions' ? ' selected' : ''; + optHtml += ''; + }); + select.innerHTML = optHtml; + } + var groupDescs = { + system: 'General discussion, announcements, and system reports', + project: 'Threads organized by project', + agent: 'Each cognitive agent has its own sub-forum', + custom: 'User-created categories' + }; + var groupColors = { system: 'var(--accent-cyan)', project: 'var(--accent-green)', agent: 'var(--accent-violet)', custom: 'var(--accent-orange)' }; + var groupIcons = { system: 'GN', project: 'PJ', agent: 'AG', custom: 'CU' }; + + // Build the phpBB-style forum index: top-level categories as rows + // with sub-forums listed inline below the description + var html = '
'; + html += '
Forum Index
'; + html += '
'; + html += '
  • '; + html += '
    Category
    '; + html += '
    Threads
    '; + html += '
    Posts
    '; + html += '
    Last post
    '; + html += '
'; + html += '
    '; + + var rowIdx = 0; + ['system', 'project', 'agent', 'custom'].forEach(function(type) { + var cats = groups[type]; + if (cats.length === 0) return; + var totalThreads = cats.reduce(function(s, c) { return s + c.thread_count; }, 0); + var totalPosts = cats.reduce(function(s, c) { return s + c.post_count; }, 0); + var bgClass = rowIdx % 2 === 0 ? 'bg1' : 'bg2'; + rowIdx++; + + // Find the most recent post across all sub-forums in this group + var lastInfo = ''; + var lastTime = 0; + cats.forEach(function(c) { + if (c.last_post) { + var t = new Date(c.last_post.created_at).getTime(); + if (t > lastTime) { + lastTime = t; + var lp = c.last_post; + var lpAuthor = lp.author_type === 'agent' ? ('@' + (lp.author_key || 'agent')) : (lp.author_name || 'Human'); + var lpTime = new Date(lp.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + lastInfo = '' + escapeHtml(lpAuthor) + '
    ' + lpTime + '
    '; + } + } + }); + + // Build sub-forum links (shown inline below description) + var subLinks = cats.map(function(c) { + var cat = c.category; + var badge = c.thread_count > 0 ? ' (' + c.thread_count + ')' : ''; + return '' + escapeHtml(cat.name) + badge + ''; + }).join(', '); + + html += '
  • '; + html += '
    '; + html += '' + groupIcons[type] + ''; + html += '
    '; + html += '' + groupLabels[type] + ''; + html += '
    ' + groupDescs[type] + ''; + html += '
    Sub-forums: ' + subLinks; + html += '
    '; + html += '
    ' + totalThreads + '
    '; + html += '
    ' + totalPosts + '
    '; + html += '
    ' + lastInfo + '
    '; + html += '
  • '; + }); + + html += '
'; + + // Memory System group — links to episode, memory, pattern, abstraction views + html += '
'; + html += '
Memory System
'; + html += '
'; + html += '
  • '; + html += '
    Section
    '; + html += '
    Count
    '; + html += '
    '; + html += '
    '; + html += '
'; + html += '
    '; + + var memSections = [ + { id: 'episodes', name: 'Episodes', desc: 'Temporal groupings of observations', icon: 'EP', color: 'var(--accent-violet)', countId: 'epCount' }, + { id: 'memories', name: 'Recent Memories', desc: 'Encoded knowledge from all sources', icon: 'MM', color: 'var(--accent-cyan)', countId: 'memCount' }, + { id: 'patterns', name: 'Discovered Patterns', desc: 'Recurring patterns across memories', icon: 'PT', color: 'var(--accent-orange)', countId: 'patCount' }, + { id: 'abstractions', name: 'Abstractions & Principles', desc: 'Higher-order knowledge and axioms', icon: 'AB', color: 'var(--accent-green)', countId: 'absCount' }, + ]; + for (var mi = 0; mi < memSections.length; mi++) { + var ms = memSections[mi]; + var bgClass = mi % 2 === 0 ? 'bg1' : 'bg2'; + html += '
  • '; + html += '
    '; + html += '' + ms.icon + ''; + html += '
    '; + html += '' + ms.name + ''; + html += '
    ' + ms.desc + ''; + html += '
    '; + html += '
    '; + html += '
    '; + html += '
    '; + html += '
  • '; + } + html += '
'; + + html += '
'; + container.innerHTML = html; + + // Bind click handlers for memory section rows via delegation + container.addEventListener('click', function(e) { + var row = e.target.closest('[id^="memsec-"]'); + if (row) { + var secId = row.id.replace('memsec-', ''); + var secNames = { episodes: 'Episodes', memories: 'Recent Memories', patterns: 'Discovered Patterns', abstractions: 'Abstractions & Principles' }; + if (secNames[secId]) loadMemorySection(secId, secNames[secId]); + } + }); + } catch (e) { + console.error('Failed to load forum index:', e); + var c = document.getElementById('forumIndex'); + if (c) c.innerHTML = '
Failed to load forum: ' + escapeHtml(e.message) + '
'; + } +} + +window._loadMemSec = function(id) { + var names = { episodes: 'Episodes', memories: 'Recent Memories', patterns: 'Discovered Patterns', abstractions: 'Abstractions & Principles' }; + loadMemorySection(id, names[id] || id); +}; +export async function loadMemorySection(sectionId, sectionName) { + state.currentView = 'thread'; + document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); }); + document.querySelectorAll('.ntab').forEach(function(t) { t.classList.remove('active'); }); + var viewEl = document.getElementById('view-thread'); + if (viewEl) viewEl.classList.add('active'); + window.location.hash = 'memory-section/' + sectionId; + var bc = document.getElementById('breadcrumbs'); + if (bc) bc.innerHTML = 'mnemonicForumMemory System' + escapeHtml(sectionName); + var compose = document.getElementById('threadCompose'); + if (compose) compose.style.display = 'none'; + var container = document.getElementById('threadContent'); + if (!container) return; + container.innerHTML = '
Loading ' + escapeHtml(sectionId) + '...
'; + + try { + if (sectionId === 'episodes') { + var data = await fetchJSON('/episodes?limit=50'); + var eps = data.episodes || []; + var html = '
Episodes' + eps.length + ' episodes
'; + html += '
  • '; + html += '
    Episode
    '; + html += '
    Obs
    Files
    Activity
    '; + html += '
    '; + for (var i = 0; i < eps.length; i++) { + var ep = eps[i]; + var bgClass = i % 2 === 0 ? 'bg1' : 'bg2'; + var mems = (ep.raw_memory_ids || []).length; + var files = (ep.files_modified || []).length; + var time = ep.end_time ? new Date(ep.end_time).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''; + var stateLabel = ep.state === 'open' ? 'open' : ''; + html += '
  • '; + html += '
    '; + html += 'EP'; + html += '
    '; + html += '' + escapeHtml(ep.title || 'Untitled episode') + ' ' + stateLabel; + if (ep.summary) html += '
    ' + escapeHtml(ep.summary).slice(0, 120) + ''; + html += '
    '; + html += '
    ' + mems + '
    ' + files + '
    '; + html += '
    ' + time + '
    '; + html += '
  • '; + } + html += '
'; + container.innerHTML = html; + + } else if (sectionId === 'memories') { + var data = await fetchJSON('/memories?state=active&limit=50'); + var mems = data.memories || []; + var html = '
Recent Memories' + mems.length + ' shown
'; + html += '
  • '; + html += '
    Memory
    '; + html += '
    Salience
    Type
    Created
    '; + html += '
    '; + for (var i = 0; i < mems.length; i++) { + var m = mems[i]; + var bgClass = i % 2 === 0 ? 'bg1' : 'bg2'; + var time = new Date(m.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + var typeColor = { decision: 'var(--accent-orange)', error: 'var(--accent-red)', insight: 'var(--accent-violet)', learning: 'var(--accent-blue)' }[m.type] || 'var(--text-dim)'; + html += '
  • '; + html += '
    '; + html += '' + (m.type || 'G').charAt(0).toUpperCase() + ''; + html += '
    '; + html += '' + escapeHtml(m.summary || '').slice(0, 100) + ''; + if (m.concepts && m.concepts.length) html += '
    ' + m.concepts.slice(0, 5).map(function(c) { return escapeHtml(c); }).join(' · ') + ''; + html += '
    '; + html += escapeHtml(m.content || ''); + if (m.source) html += '
    Source: ' + escapeHtml(m.source) + ''; + if (m.project) html += ' · Project: ' + escapeHtml(m.project) + ''; + if (m.episode_id) html += '
    View episode thread →'; + html += '
    '; + html += '
    '; + html += '
    ' + (m.salience || 0).toFixed(2) + '
    '; + html += '
    ' + escapeHtml(m.type || 'general') + '
    '; + html += '
    ' + time + '
    '; + html += '
  • '; + } + html += '
'; + container.innerHTML = html; + + } else if (sectionId === 'patterns') { + var data = await fetchJSON('/patterns?limit=50'); + var pats = data.patterns || []; + var html = '
Discovered Patterns' + pats.length + ' patterns
'; + html += '
  • '; + html += '
    Pattern
    '; + html += '
    Strength
    Evidence
    Discovered
    '; + html += '
    '; + for (var i = 0; i < pats.length; i++) { + var p = pats[i]; + var bgClass = i % 2 === 0 ? 'bg1' : 'bg2'; + var time = new Date(p.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + html += '
  • '; + html += '
    '; + html += 'PT'; + html += '
    '; + html += '' + escapeHtml(p.title) + ''; + html += '
    ' + escapeHtml(p.description || '').slice(0, 120) + ''; + html += '
    '; + html += 'Type: ' + escapeHtml(p.pattern_type || '') + '
    '; + html += 'Description: ' + escapeHtml(p.description || '') + '
    '; + if (p.project) html += 'Project: ' + escapeHtml(p.project) + '
    '; + if (p.concepts && p.concepts.length) html += 'Concepts: ' + p.concepts.map(function(c) { return escapeHtml(c); }).join(', ') + '
    '; + html += 'Evidence: ' + (p.evidence_ids || []).length + ' memories'; + html += '
    '; + html += '
    '; + html += '
    ' + (p.strength || 0).toFixed(2) + '
    '; + html += '
    ' + (p.evidence_ids || []).length + '
    '; + html += '
    ' + time + '
    '; + html += '
  • '; + } + html += '
'; + container.innerHTML = html; + + } else if (sectionId === 'abstractions') { + var data = await fetchJSON('/abstractions?limit=50'); + var abs = data.abstractions || []; + var html = '
Abstractions & Principles' + abs.length + ' abstractions
'; + html += '
  • '; + html += '
    Abstraction
    '; + html += '
    Confidence
    Level
    Created
    '; + html += '
    '; + for (var i = 0; i < abs.length; i++) { + var a = abs[i]; + var bgClass = i % 2 === 0 ? 'bg1' : 'bg2'; + var time = new Date(a.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + var level = a.level === 3 ? 'Axiom' : a.level === 2 ? 'Principle' : 'Pattern'; + var levelColor = a.level === 3 ? 'var(--accent-yellow)' : 'var(--accent-green)'; + html += '
  • '; + html += '
    '; + html += 'AB'; + html += '
    '; + html += '' + escapeHtml(a.title) + ''; + html += '
    ' + escapeHtml(a.description || '').slice(0, 120) + ''; + html += '
    '; + html += 'Level: ' + level + '
    '; + html += 'Description: ' + escapeHtml(a.description || '') + '
    '; + html += 'Sources: ' + (a.source_pattern_ids || []).length + ' patterns, ' + (a.source_memory_ids || []).length + ' memories'; + html += '
    '; + html += '
    '; + html += '
    ' + (a.confidence || 0).toFixed(2) + '
    '; + html += '
    ' + level + '
    '; + html += '
    ' + time + '
    '; + html += '
  • '; + } + html += '
'; + container.innerHTML = html; + } + } catch (e) { console.error('Failed to load memory section:', e); container.innerHTML = '
Failed to load: ' + e.message + '
'; } +} + +export async function loadForumGroup(type) { + // Show the sub-forums within a category group (General, Agents, Projects, Custom) + var groupLabels = { system: 'General', project: 'Projects', agent: 'Agents', custom: 'Custom' }; + var groupName = groupLabels[type] || type; + state.currentView = 'thread'; + document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); }); + document.querySelectorAll('.ntab').forEach(function(t) { t.classList.remove('active'); }); + var viewEl = document.getElementById('view-thread'); + if (viewEl) viewEl.classList.add('active'); + window.location.hash = 'forum-group/' + type; + var bc = document.getElementById('breadcrumbs'); + if (bc) bc.innerHTML = 'mnemonicForum' + escapeHtml(groupName); + var compose = document.getElementById('threadCompose'); + if (compose) compose.style.display = 'none'; + + try { + var resp = await forumFetch('/api/v1/forum/categories'); + var data = await resp.json(); + var cats = (data.categories || []).filter(function(c) { return c.category.type === type; }); + var container = document.getElementById('threadContent'); + if (!container) return; + + var html = '
'; + html += '
' + escapeHtml(groupName) + ''; + html += '' + cats.length + ' sub-forums
'; + html += '
'; + html += '
  • '; + html += '
    Sub-forum
    '; + html += '
    Threads
    '; + html += '
    Posts
    '; + html += '
    Last post
    '; + html += '
'; + html += '
    '; + + for (var i = 0; i < cats.length; i++) { + var c = cats[i]; + var cat = c.category; + var bgClass = i % 2 === 0 ? 'bg1' : 'bg2'; + var lastInfo = ''; + if (c.last_post) { + var lp = c.last_post; + var lpAuthor = lp.author_type === 'agent' ? ('@' + (lp.author_key || 'agent')) : (lp.author_name || 'Human'); + var lpTime = new Date(lp.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + lastInfo = '' + escapeHtml(lpAuthor) + '
    ' + lpTime + '
    '; + } + html += '
  • '; + html += '
    '; + html += '' + (cat.icon || '??') + ''; + html += '
    '; + html += '' + escapeHtml(cat.name) + ''; + html += '
    ' + escapeHtml(cat.description) + ''; + html += '
    '; + html += '
    ' + c.thread_count + '
    '; + html += '
    ' + c.post_count + '
    '; + html += '
    ' + lastInfo + '
    '; + html += '
  • '; + } + html += '
'; + container.innerHTML = html; + } catch (e) { + console.error('Failed to load forum group:', e); + var c = document.getElementById('threadContent'); + if (c) c.innerHTML = '
Failed to load: ' + escapeHtml(e.message) + '
'; + } +} + +export async function loadForumCategory(categoryId, categoryName) { + state.currentView = 'thread'; // reuse thread view for category listing + document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); }); + document.querySelectorAll('.ntab').forEach(function(t) { t.classList.remove('active'); }); + var viewEl = document.getElementById('view-thread'); + if (viewEl) viewEl.classList.add('active'); + window.location.hash = 'forum-category/' + categoryId; + var bc = document.getElementById('breadcrumbs'); + if (bc) bc.innerHTML = 'mnemonicForum' + escapeHtml(categoryName); + // Hide compose box (it's for threads, not category view) + var compose = document.getElementById('threadCompose'); + if (compose) compose.style.display = 'none'; + try { + var resp = await forumFetch('/api/v1/forum/threads?category=' + categoryId + '&limit=50'); + var data = await resp.json(); + var threads = data.threads || []; + var container = document.getElementById('threadContent'); + if (!container) return; + if (threads.length === 0) { + container.innerHTML = '

' + escapeHtml(categoryName) + '

No threads yet
'; + return; + } + var html = '
'; + html += '

' + escapeHtml(categoryName) + '

'; + html += '
' + threads.length + ' threads
'; + html += '
    '; + for (var i = 0; i < threads.length; i++) { + var t = threads[i]; + var rp = t.root_post; + if (!rp) continue; + var bgClass = i % 2 === 0 ? 'bg1' : 'bg2'; + var agentKey = rp.author_key || ''; + var prof = _forumAgentProfiles[agentKey] || { tag: rp.author_name || 'Human', icon: 'HU', color: 'var(--accent-cyan)' }; + var preview = escapeHtml((rp.content || '').slice(0, 120)); + var lastActive = t.last_reply ? new Date(t.last_reply).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''; + html += '
  • '; + html += '
    '; + html += '' + prof.icon + ''; + html += '
    ' + preview + ''; + html += '
    by ' + escapeHtml(rp.author_type === 'agent' ? prof.tag : (rp.author_name || 'Human')) + ''; + html += '
    '; + html += '
    ' + (t.reply_count || 0) + ' replies
    '; + html += '
    ' + lastActive + '
    '; + html += '
  • '; + } + html += '
'; + container.innerHTML = html; + } catch (e) { + console.error('Failed to load forum category:', e); + var c = document.getElementById('threadContent'); + if (c) c.innerHTML = '
Failed to load category: ' + escapeHtml(e.message) + '
'; + } +} + +export async function loadForumThread(threadId) { + state.currentForumThread = threadId; + state.currentEpisodeId = ''; // clear — this is a forum thread, not an episode + state.currentView = 'thread'; + subscribeToThread(threadId); + markThreadRead(threadId); + // Show thread view + document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); }); + document.querySelectorAll('.ntab').forEach(function(t) { t.classList.remove('active'); }); + var viewEl = document.getElementById('view-thread'); + if (viewEl) viewEl.classList.add('active'); + window.location.hash = 'forum-thread/' + threadId; + var bc = document.getElementById('breadcrumbs'); + if (bc) bc.innerHTML = 'mnemonicForumThread'; + // Show compose box and init autocomplete + var compose = document.getElementById('threadCompose'); + if (compose) { compose.style.display = ''; ensureMentionAutocomplete('threadReplyContent'); } + try { + var resp = await forumFetch('/api/v1/forum/threads/' + threadId); + var data = await resp.json(); + var posts = data.posts || []; + var container = document.getElementById('threadContent'); + if (!container) return; + if (posts.length === 0) { + container.innerHTML = '
Empty thread.
'; + return; + } + var html = '
'; + html += '

' + escapeHtml((posts[0].content || '').slice(0, 80)) + '

'; + html += '
' + posts.length + ' posts · started ' + new Date(posts[0].created_at).toLocaleString() + '
'; + html += '
'; + for (var i = 0; i < posts.length; i++) { + html += renderForumPost(posts[i], i); + } + html += '
'; + container.innerHTML = html; + // Delegated click handler for data-action buttons + container.onclick = function(e) { + var btn = e.target.closest('[data-action]'); + if (!btn) return; + var action = btn.getAttribute('data-action'); + if (action === 'quote') quotePostById(btn.getAttribute('data-target')); + else if (action === 'internalize') internalizePost(btn.getAttribute('data-post-id'), btn); + else if (action === 'insert-tag') insertTagInReply(btn.getAttribute('data-agent-key')); + }; + } catch (e) { + console.error('Failed to load forum thread:', e); + var c = document.getElementById('threadContent'); + if (c) c.innerHTML = '
Failed to load thread: ' + escapeHtml(e.message) + '
'; + } +} + +export function formatForumContent(text) { + if (!text) return ''; + var lines = text.split('\n'); + var html = ''; + var inQuote = false; + var quoteLines = []; + + function flushQuote() { + if (quoteLines.length === 0) return; + var quoteHtml = quoteLines.map(function(l) { + var line = escapeHtml(l); + return line.replace(/@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)/g, '@$1'); + }).join('
'); + html += '
' + quoteHtml + '
'; + quoteLines = []; + } + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('> ')) { + inQuote = true; + quoteLines.push(line.substring(2)); + } else { + if (inQuote) { flushQuote(); inQuote = false; } + if (line.trim() === '') { + if (html && !html.endsWith('
')) html += '
'; + } else { + var escaped = escapeHtml(line); + escaped = escaped.replace(/@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)/g, '@$1'); + html += escaped + '
'; + } + } + } + if (inQuote) flushQuote(); + // Trim trailing
+ html = html.replace(/(
)+$/, ''); + return html; +} + +var _forumAgentProfiles = { + consolidation: { tag: '@consolidation', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, + dreaming: { tag: '@dreaming', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, + episoding: { tag: '@episoding', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, + abstraction: { tag: '@abstraction', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, + metacognition: { tag: '@metacognition', title: 'Self-Reflection', icon: 'MA', color: 'var(--accent-blue)' }, + encoding: { tag: '@encoding', title: 'Memory Encoder', icon: 'EA', color: 'var(--accent-blue)' }, + perception: { tag: '@perception', title: 'Filesystem Watcher', icon: 'PA', color: 'var(--accent-green)' }, + retrieval: { tag: '@retrieval', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, +}; + +export function renderForumPost(post, index) { + var bgClass = index % 2 === 0 ? 'bg1' : 'bg2'; + var isAgent = post.author_type === 'agent'; + var agentKey = post.author_key || ''; + var prof = _forumAgentProfiles[agentKey] || { tag: post.author_name || 'Human', title: isAgent ? 'Agent' : 'User', icon: isAgent ? agentKey.slice(0,2).toUpperCase() : 'HU', color: isAgent ? 'var(--text-dim)' : 'var(--accent-cyan)' }; + var displayName = isAgent ? prof.tag : (post.author_name || 'Human'); + var time = new Date(post.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + // Parse content: render > lines as blockquotes, highlight @mentions + var contentHtml = formatForumContent(post.content || ''); + // Store post data for quote functionality via data attributes + var postDataId = 'forum-post-' + post.id; + var html = '
'; + html += '
'; + html += '
' + prof.icon + '
'; + // Clickable @tag — clicking it inserts @tag into reply box + if (isAgent && agentKey) { + html += '' + escapeHtml(displayName) + '
'; + } else { + html += '' + escapeHtml(displayName) + ''; + } + html += '
' + escapeHtml(prof.title) + '
'; + if (post.event_ref) html += '
via ' + escapeHtml(post.event_ref) + '
'; + html += '
'; + html += ''; + html += '
' + contentHtml + '
'; + // Action buttons + html += '
'; + html += ''; + if (post.state === 'internalized') { + html += 'internalized'; + } else { + html += ''; + } + html += '
'; + html += '
'; + return html; +} + +export function appendForumPostToThread(payload) { + var container = document.getElementById('threadContent'); + if (!container) return; + var wrap = container.querySelector('.thread-wrap'); + if (!wrap) return; + var postCount = wrap.querySelectorAll('.post').length; + var postObj = { + id: payload.post_id, + content: payload.content, + author_type: payload.author_type, + author_name: payload.author_name, + author_key: payload.author_key || '', + created_at: payload.timestamp || new Date().toISOString(), + state: 'active', + }; + wrap.insertAdjacentHTML('beforeend', renderForumPost(postObj, postCount)); + // Scroll to new post + var newPost = document.getElementById('forum-post-' + payload.post_id); + if (newPost) newPost.scrollIntoView({ behavior: 'smooth', block: 'end' }); +} + +export function showNewThreadForm() { + var form = document.getElementById('newThreadForm'); + if (form) { form.style.display = ''; document.getElementById('newThreadContent').focus(); ensureMentionAutocomplete('newThreadContent'); } +} + +export async function submitNewThread() { + var textarea = document.getElementById('newThreadContent'); + var content = (textarea.value || '').trim(); + if (!content) return; + var catSelect = document.getElementById('newThreadCategory'); + var categoryId = catSelect ? catSelect.value : 'discussions'; + textarea.disabled = true; + try { + var resp = await forumFetch('/api/v1/forum/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content, category_id: categoryId }) + }); + var data = await resp.json(); + textarea.value = ''; + textarea.disabled = false; + document.getElementById('newThreadForm').style.display = 'none'; + showToast('Thread created'); + subscribeToThread(data.thread_id); + state.forumLoaded = false; + loadForumIndex(); + // Navigate to the new thread + loadForumThread(data.thread_id); + } catch (e) { + textarea.disabled = false; + showToast('Failed to create thread: ' + e.message, 'error'); + } +} + +export async function submitThreadReply() { + var textarea = document.getElementById('threadReplyContent'); + var content = (textarea.value || '').trim(); + if (!content || !state.currentForumThread) return; + textarea.disabled = true; + try { + await forumFetch('/api/v1/forum/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content, thread_id: state.currentForumThread, episode_id: state.currentEpisodeId || '' }) + }); + textarea.value = ''; + textarea.disabled = false; + showToast('Reply posted'); + subscribeToThread(state.currentForumThread); + // The WebSocket event will handle live-inserting the post + } catch (e) { + textarea.disabled = false; + showToast('Failed to post reply: ' + e.message, 'error'); + } +} + +export function quotePostById(postElementId) { + var postEl = document.getElementById(postElementId); + if (!postEl) return; + var authorName = postEl.getAttribute('data-author') || 'Unknown'; + var content = postEl.getAttribute('data-content') || ''; + var textarea = document.getElementById('threadReplyContent'); + if (!textarea) return; + var quotedLines = content.split('\n').map(function(line) { return '> ' + line; }).join('\n'); + var quote = '> ' + authorName + ' wrote:\n' + quotedLines + '\n\n'; + textarea.value = quote; + textarea.focus(); + textarea.scrollIntoView({ behavior: 'smooth', block: 'center' }); +} + +export function insertTagInReply(agentKey) { + var textarea = document.getElementById('threadReplyContent'); + if (!textarea) return; + var tag = '@' + agentKey + ' '; + textarea.value = tag + textarea.value; + textarea.selectionStart = textarea.selectionEnd = tag.length; + textarea.focus(); + textarea.scrollIntoView({ behavior: 'smooth', block: 'center' }); +} + +// ── @mention autocomplete ── +var _mentionAgents = [ + { key: 'retrieval', label: '@retrieval', desc: 'Search memories' }, + { key: 'metacognition', label: '@metacognition', desc: 'System health' }, + { key: 'encoding', label: '@encoding', desc: 'Memory encoder' }, + { key: 'episoding', label: '@episoding', desc: 'Episodes & timeline' }, + { key: 'consolidation', label: '@consolidation', desc: 'Memory maintenance' }, + { key: 'dreaming', label: '@dreaming', desc: 'Dream cycle insights' }, + { key: 'abstraction', label: '@abstraction', desc: 'Patterns & principles' }, + { key: 'perception', label: '@perception', desc: 'File watcher' }, +]; + +export function setupMentionAutocomplete(textarea) { + var dropdown = document.createElement('div'); + dropdown.className = 'mention-dropdown'; + dropdown.style.cssText = 'display:none;position:absolute;z-index:100;background:var(--bg-primary);border:1px solid var(--border-accent);border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,0.5);max-height:240px;overflow-y:auto;font-size:0.82rem;width:260px'; + textarea.parentNode.style.position = 'relative'; + textarea.parentNode.insertBefore(dropdown, textarea); + + textarea.addEventListener('input', function() { + var val = textarea.value; + var cursor = textarea.selectionStart; + // Find @ before cursor + var before = val.substring(0, cursor); + var atMatch = before.match(/@(\w*)$/); + if (!atMatch) { dropdown.style.display = 'none'; return; } + var filter = atMatch[1].toLowerCase(); + var filtered = _mentionAgents.filter(function(a) { return a.key.startsWith(filter); }); + if (filtered.length === 0) { dropdown.style.display = 'none'; return; } + var html = ''; + for (var i = 0; i < filtered.length; i++) { + var a = filtered[i]; + var prof = _forumAgentProfiles[a.key] || {}; + html += '
'; + html += '' + (prof.icon || '') + ''; + html += '' + a.label + ' ' + a.desc + ''; + html += '
'; + } + dropdown.innerHTML = html; + dropdown.style.display = 'block'; + }); + + textarea.addEventListener('blur', function() { + setTimeout(function() { dropdown.style.display = 'none'; }, 150); + }); + + textarea.addEventListener('keydown', function(e) { + if (dropdown.style.display === 'none') return; + if (e.key === 'Escape') { dropdown.style.display = 'none'; e.preventDefault(); } + if (e.key === 'Tab' || e.key === 'Enter') { + var first = dropdown.querySelector('.mention-option'); + if (first) { insertMention(textarea.id, first.getAttribute('data-key')); e.preventDefault(); } + } + }); +} + +export function insertMention(textareaId, agentKey) { + var textarea = document.getElementById(textareaId); + if (!textarea) return; + var val = textarea.value; + var cursor = textarea.selectionStart; + var before = val.substring(0, cursor); + var after = val.substring(cursor); + // Replace the @partial with @agentKey + var newBefore = before.replace(/@\w*$/, '@' + agentKey + ' '); + textarea.value = newBefore + after; + textarea.selectionStart = textarea.selectionEnd = newBefore.length; + textarea.focus(); + // Hide dropdown + var dropdown = textarea.parentNode.querySelector('.mention-dropdown'); + if (dropdown) dropdown.style.display = 'none'; +} + +// Initialize autocomplete on compose textareas when they become visible +var _mentionInitialized = {}; +export function ensureMentionAutocomplete(textareaId) { + if (_mentionInitialized[textareaId]) return; + var el = document.getElementById(textareaId); + if (el) { setupMentionAutocomplete(el); _mentionInitialized[textareaId] = true; } +} + +// ── Thread subscriptions ── +var _subscriptions = JSON.parse(localStorage.getItem('mnemonic_forum_subs') || '{}'); +// { threadId: { unread: 0, lastSeen: timestamp } } + +export function subscribeToThread(threadId) { + if (!_subscriptions[threadId]) { + _subscriptions[threadId] = { unread: 0, lastSeen: Date.now() }; + localStorage.setItem('mnemonic_forum_subs', JSON.stringify(_subscriptions)); + } +} + +export function markThreadRead(threadId) { + if (_subscriptions[threadId]) { + _subscriptions[threadId].unread = 0; + _subscriptions[threadId].lastSeen = Date.now(); + localStorage.setItem('mnemonic_forum_subs', JSON.stringify(_subscriptions)); + updateNotificationBadge(); + } +} + +export function onForumPostWebSocket(payload) { + var threadId = payload.thread_id; + // If we're subscribed and not currently viewing this thread, increment unread + if (_subscriptions[threadId] && state.currentForumThread !== threadId) { + _subscriptions[threadId].unread = (_subscriptions[threadId].unread || 0) + 1; + localStorage.setItem('mnemonic_forum_subs', JSON.stringify(_subscriptions)); + updateNotificationBadge(); + // Show toast for agent replies + if (payload.author_type === 'agent') { + var agentName = payload.author_key ? ('@' + payload.author_key) : payload.author_name; + showToast(agentName + ' replied to your thread'); + } + } +} + +export function updateNotificationBadge() { + var total = 0; + for (var tid in _subscriptions) { + total += (_subscriptions[tid].unread || 0); + } + var badge = document.getElementById('forumNotifBadge'); + if (badge) { + badge.textContent = total > 0 ? total : ''; + badge.style.display = total > 0 ? 'inline-flex' : 'none'; + } +} + +export async function internalizePost(postId, btn) { + btn.disabled = true; + btn.textContent = 'internalizing...'; + try { + var resp = await fetch('/api/v1/forum/posts/' + postId + '/internalize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + if (resp.ok) { + btn.textContent = 'internalized'; + btn.style.color = 'var(--accent-green)'; + btn.style.borderColor = 'var(--accent-green)'; + showToast('Post internalized into memory'); + } else { + var data = await resp.json(); + btn.textContent = data.error === 'post already internalized' ? 'already internalized' : 'failed'; + btn.disabled = false; + } + } catch (e) { + btn.textContent = 'failed'; + btn.disabled = false; + showToast('Failed to internalize', 'error'); + } +} + +export function toggleToolDetail(e) { + var pill = e.currentTarget; + var toolId = pill.getAttribute('data-tool-use-id'); + var entry = toolId ? toolDataMap.get(toolId) : null; + var input = entry ? entry.input : null; + var result = entry ? entry.result : null; + if (!input && !result) return; + // The detail panel lives after the tool-row, not after the pill + var toolRow = pill.closest('.chat-tool-row') || pill.parentNode; + var existing = toolRow.nextElementSibling; + if (existing && existing.classList.contains('chat-tool-detail') && existing.getAttribute('data-for') === toolId) { + existing.classList.toggle('visible'); + pill.classList.toggle('expanded'); + } else { + // Collapse any other open detail in this row + if (existing && existing.classList.contains('chat-tool-detail')) { + existing.remove(); + toolRow.querySelectorAll('.chat-tool-pill.expanded').forEach(function(p) { p.classList.remove('expanded'); }); + } + var detail = document.createElement('div'); + detail.className = 'chat-tool-detail visible'; + if (toolId) { + detail.setAttribute('data-for', toolId); + } + var html = ''; + if (input) { + html += '
Input
' + escapeHtml(input) + '
'; + } + if (result) { + html += '
Result
' + escapeHtml(result) + '
'; + } + detail.innerHTML = html; + toolRow.parentNode.insertBefore(detail, toolRow.nextSibling); + pill.classList.add('expanded'); + detail.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + scrollChatBottom(); +} +export function relativeTime(input) { + if (!input) return ''; + var date = input instanceof Date ? input : new Date(input); + var diff = Date.now() - date.getTime(); + if (diff < 60000) return 'just now'; + if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; + if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; + if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago'; + return date.toLocaleDateString(); +} + diff --git a/internal/web/static/js/llm.js b/internal/web/static/js/llm.js new file mode 100644 index 00000000..0f1a424f --- /dev/null +++ b/internal/web/static/js/llm.js @@ -0,0 +1,1227 @@ +import { state, CONFIG } from './state.js'; +import { fetchJSON, escapeHtml, svgEl, linScale, svgText, fmtNum } from './utils.js'; + +// ── LLM Usage ── +var _llmRange = '24h'; + +export function setLLMRange(range) { + _llmRange = range; + document.querySelectorAll('.llm-range-tab').forEach(function(t) { + t.classList.toggle('active', t.getAttribute('data-range') === range); + }); + var titles = { '1h': 'Token Usage (1h)', '6h': 'Token Usage (6h)', '24h': 'Token Usage (24h)', '168h': 'Token Usage (7d)' }; + document.getElementById('llmChartTitle').textContent = titles[range] || 'Token Usage'; + loadLLMUsage(); +} + +export async function loadLLMUsage() { + try { + var limit = _llmRange === '168h' ? 500 : 200; + var data = await fetchJSON('/llm/usage?since=' + _llmRange + '&limit=' + limit); + state.llmLoaded = true; + + var s = data.summary || {}; + document.getElementById('llmRequests').textContent = (s.total_requests || 0).toLocaleString(); + document.getElementById('llmTokens').textContent = (s.total_tokens || 0).toLocaleString(); + + // Token split sub-label + Completion % + var prompt = s.prompt_tokens || 0; + var completion = s.completion_tokens || 0; + var splitEl = document.getElementById('llmTokenSplit'); + splitEl.textContent = (prompt > 0 || completion > 0) ? prompt.toLocaleString() + ' in · ' + completion.toLocaleString() + ' out' : ''; + var total = prompt + completion; + document.getElementById('llmCompletionPct').textContent = total > 0 ? (completion / total * 100).toFixed(1) + '%' : '-'; + + document.getElementById('llmCost').textContent = '$' + (data.estimated_cost_usd || 0).toFixed(4); + document.getElementById('llmLatency').textContent = (s.avg_latency_ms || 0).toFixed(0) + 'ms'; + + var errEl = document.getElementById('llmErrors'); + errEl.textContent = s.error_count || 0; + errEl.style.color = (s.error_count || 0) > 0 ? 'var(--accent-red)' : ''; + + document.getElementById('llmUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString(); + var ntc = document.getElementById('navTokenCount'); + if (ntc) ntc.textContent = (s.total_tokens || 0).toLocaleString(); + + var log = data.log || []; + + // Build per-agent extras (cost + avg latency) from log + var agentExtras = {}; + log.forEach(function(r) { + var a = r.caller || 'unknown'; + if (!agentExtras[a]) agentExtras[a] = { latencySum: 0, count: 0, cost: 0, errors: 0 }; + agentExtras[a].latencySum += (r.latency_ms || 0); + agentExtras[a].count++; + agentExtras[a].cost += (r.estimated_cost_usd || 0); + if (!r.success) agentExtras[a].errors++; + }); + + // Agent table + var agentBody = document.getElementById('llmAgentBody'); + var agents = s.by_agent || {}; + var agentKeys = Object.keys(agents).sort(function(a, b) { return (agents[b].total_tokens || 0) - (agents[a].total_tokens || 0); }); + if (agentKeys.length === 0) { + agentBody.innerHTML = 'No agent data yet'; + } else { + agentBody.innerHTML = agentKeys.map(function(k) { + var a = agents[k]; + var ext = agentExtras[k] || {}; + var errors = ext.errors || 0; + var errHtml = errors > 0 ? '' + errors + '' : '0'; + var cost = ext.cost ? '$' + ext.cost.toFixed(4) : '-'; + var avgLat = ext.count ? (ext.latencySum / ext.count).toFixed(0) + 'ms' : '-'; + return '' + escapeHtml(k) + '' + (a.requests || 0) + '' + (a.total_tokens || 0).toLocaleString() + '' + errHtml + '' + cost + '' + avgLat + ''; + }).join(''); + } + + // Operations table (computed from log) with p50/p95 + var opStats = {}; + log.forEach(function(r) { + var op = r.operation || 'unknown'; + if (!opStats[op]) opStats[op] = { count: 0, tokens: 0, latencies: [] }; + opStats[op].count++; + opStats[op].tokens += (r.prompt_tokens || 0) + (r.completion_tokens || 0); + opStats[op].latencies.push(r.latency_ms || 0); + }); + var opsBody = document.getElementById('llmOpsBody'); + var opKeys = Object.keys(opStats).sort(function(a, b) { return opStats[b].count - opStats[a].count; }); + if (opKeys.length === 0) { + opsBody.innerHTML = 'No data yet'; + } else { + opsBody.innerHTML = opKeys.map(function(op) { + var os = opStats[op]; + var sorted = os.latencies.slice().sort(function(a, b) { return a - b; }); + var p50 = sorted[Math.floor(sorted.length * 0.5)] || 0; + var p95 = sorted[Math.floor(sorted.length * 0.95)] || 0; + return '' + escapeHtml(op) + '' + os.count + '' + os.tokens.toLocaleString() + '' + p50 + 'ms' + p95 + 'ms'; + }).join(''); + } + + // Request log + var logBody = document.getElementById('llmLogBody'); + if (log.length === 0) { + logBody.innerHTML = 'No requests recorded yet'; + } else { + logBody.innerHTML = log.map(function(r) { + var t = r.timestamp ? new Date(r.timestamp).toLocaleTimeString() : '-'; + var statusCls = r.success ? 'llm-status-ok' : 'llm-status-err'; + var statusTxt = r.success ? 'OK' : 'ERR'; + var rowCls = r.success ? '' : ' class="llm-log-row-err"'; + var inOut = (r.prompt_tokens || 0).toLocaleString() + ' / ' + (r.completion_tokens || 0).toLocaleString(); + var model = escapeHtml((r.model || '-').replace('local-model', 'local')); + var row = '' + t + '' + escapeHtml(r.caller || '-') + '' + escapeHtml(r.operation || '-') + '' + model + '' + inOut + '' + (r.latency_ms || 0) + 'ms' + statusTxt + ''; + if (!r.success && r.error_message) { + row += '' + escapeHtml(r.error_message) + ''; + } + return row; + }).join(''); + } + + renderLLMChart(data.chart_buckets || [], _llmRange); + } catch (e) { + console.error('loadLLMUsage error:', e); + var el = document.getElementById('llmRequests'); + if (el) el.textContent = 'ERR'; + } +} + +export function renderLLMChart(chartBuckets, range) { + var container = document.getElementById('llmChart'); + var tooltip = document.getElementById('llmChartTooltip'); + container.innerHTML = ''; + if (!chartBuckets || chartBuckets.length === 0) { container.innerHTML = '
No data for chart
'; return; } + + // Determine label format and frequency from range + var labelFmt, labelEvery; + if (range === '1h') { + labelFmt = function(d) { return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }; + labelEvery = 3; + } else if (range === '6h') { + labelFmt = function(d) { return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }; + labelEvery = 2; + } else if (range === '168h') { + var days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + labelFmt = function(d) { return days[d.getDay()]; }; + labelEvery = 1; + } else { // 24h default + labelFmt = function(d) { return String(d.getHours()).padStart(2,'0') + ':00'; }; + labelEvery = 4; + } + + // Map server-aggregated buckets to chart data + var buckets = chartBuckets.map(function(b, i) { + var ts = new Date(b.timestamp); + return { + idx: i, + start: ts, + label: labelFmt(ts), + prompt: b.prompt_tokens || 0, + completion: b.completion_tokens || 0, + requests: b.requests || 0, + errors: b.errors || 0 + }; + }); + + var w = container.clientWidth || 400; + var h = 200; + var margin = { top: 10, right: 10, bottom: 28, left: 50 }; + var iw = w - margin.left - margin.right; + var ih = h - margin.top - margin.bottom; + + var svg = svgEl('svg', { width: w, height: h }); + container.appendChild(svg); + var g = svgEl('g', { transform: 'translate(' + margin.left + ',' + margin.top + ')' }); + svg.appendChild(g); + + var maxTotal = Math.max.apply(null, buckets.map(function(d) { return d.prompt + d.completion; })) || 1; + var nb = buckets.length; + var bandStep = iw / nb; + var bandPad = 0.25; + var bandW = bandStep * (1 - bandPad); + var bandOff = bandStep * bandPad / 2; + + // Y axis ticks (format: .2s — 1000->1k, 1000000->1M) + var yTicks = 4; + var yStep = maxTotal / yTicks; + for (var t = 0; t <= yTicks; t++) { + var tv = t * yStep; + var ty = linScale(tv, 0, maxTotal, ih, 0); + g.appendChild(svgEl('line', { x1: -4, x2: 0, y1: ty, y2: ty, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(-8, ty, fmtNum(Math.round(tv)), { 'text-anchor': 'end', 'dominant-baseline': '0.32em', 'font-size': '10' })); + } + // X axis baseline + g.appendChild(svgEl('line', { x1: 0, x2: iw, y1: ih, y2: ih, stroke: 'var(--border-subtle)' })); + // X axis labels + buckets.forEach(function(d) { + if (d.idx % labelEvery !== 0) return; + var bx = d.idx * bandStep + bandStep / 2; + g.appendChild(svgEl('line', { x1: bx, x2: bx, y1: ih, y2: ih + 4, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(bx, ih + 14, d.label, { 'text-anchor': 'middle', 'font-size': '10' })); + }); + + // Bars + hit areas + error markers + buckets.forEach(function(d) { + var bx = d.idx * bandStep + bandOff; + var total = d.prompt + d.completion; + // Full bar (prompt+completion, cyan) + if (total > 0) { + var fullY = linScale(total, 0, maxTotal, ih, 0); + g.appendChild(svgEl('rect', { x: bx, y: fullY, width: bandW, height: ih - fullY, rx: 2, fill: 'var(--accent-cyan)', opacity: '0.8' })); + } + // Completion overlay (violet, bottom portion) + if (d.completion > 0) { + var compY = linScale(d.completion, 0, maxTotal, ih, 0); + g.appendChild(svgEl('rect', { x: bx, y: compY, width: bandW, height: ih - compY, fill: 'var(--accent-violet)', opacity: '0.8' })); + } + // Hit area + var hit = svgEl('rect', { x: bx - bandW * 0.1, y: 0, width: bandW * 1.2, height: ih, fill: 'transparent' }); + (function(dd) { + hit.addEventListener('mouseover', function() { + if (dd.requests === 0) return; + var errLine = dd.errors > 0 ? '
' + dd.errors + ' error' + (dd.errors === 1 ? '' : 's') + '' : ''; + tooltip.innerHTML = '' + dd.label + '
' + + dd.requests + ' request' + (dd.requests === 1 ? '' : 's') + '
' + + '' + dd.prompt.toLocaleString() + ' prompt \u00B7 ' + + '' + dd.completion.toLocaleString() + ' completion' + errLine; + tooltip.style.display = 'block'; + var barX = margin.left + dd.idx * bandStep + bandStep / 2; + var ttLeft = barX + 10; + if (ttLeft + 180 > w) ttLeft = barX - 190; + tooltip.style.left = ttLeft + 'px'; + tooltip.style.top = (margin.top + 2) + 'px'; + }); + hit.addEventListener('mouseout', function() { tooltip.style.display = 'none'; }); + })(d); + g.appendChild(hit); + + // Error marker + if (d.errors > 0) { + var ey = linScale(total, 0, maxTotal, ih, 0) - 5; + g.appendChild(svgEl('circle', { cx: d.idx * bandStep + bandStep / 2, cy: ey, r: 3, fill: 'var(--accent-red)', 'pointer-events': 'none' })); + } + }); +} + +// ── Tool Usage Analytics ── +var _toolRange = '24h'; +var _toolLog = []; +var _analyticsRange = 14; + +export 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(); +} + +export function setToolRange(range) { + _toolRange = range; + document.querySelectorAll('#toolRangeTabs .llm-range-tab').forEach(function(t) { + t.classList.toggle('active', t.getAttribute('data-range') === range); + }); + var titles = { '1h': 'Tool Calls (1h)', '6h': 'Tool Calls (6h)', '24h': 'Tool Calls (24h)', '168h': 'Tool Calls (7d)' }; + document.getElementById('toolChartTitle').textContent = titles[range] || 'Tool Calls'; + loadToolUsage(); +} + +export async function loadToolUsage() { + try { + var limit = _toolRange === '168h' ? 500 : 200; + var data = await fetchJSON('/tool/usage?since=' + _toolRange + '&limit=' + limit); + state.toolsLoaded = true; + + var s = data.summary || {}; + document.getElementById('toolCalls').textContent = (s.total_calls || 0).toLocaleString(); + document.getElementById('toolLatency').textContent = (s.avg_latency_ms || 0).toFixed(0) + 'ms'; + + var errEl = document.getElementById('toolErrors'); + errEl.textContent = s.error_count || 0; + errEl.style.color = (s.error_count || 0) > 0 ? 'var(--accent-red)' : ''; + + // Top tool + var byTool = s.by_tool || {}; + var topTool = Object.keys(byTool).sort(function(a, b) { return byTool[b] - byTool[a]; })[0] || '-'; + document.getElementById('toolTopTool').textContent = topTool; + + // Project count + var byProject = s.by_project || {}; + document.getElementById('toolProjects').textContent = Object.keys(byProject).length || 0; + + // Success rate + var total = s.total_calls || 0; + var errors = s.error_count || 0; + document.getElementById('toolSuccessRate').textContent = total > 0 ? ((total - errors) / total * 100).toFixed(1) + '%' : '-'; + + 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 = {}; + log.forEach(function(r) { + var t = r.tool_name || 'unknown'; + if (!toolExtras[t]) toolExtras[t] = { latencySum: 0, sizeSum: 0, count: 0 }; + toolExtras[t].latencySum += (r.latency_ms || 0); + toolExtras[t].sizeSum += (r.response_size || 0); + toolExtras[t].count++; + }); + + // By Tool table with percentiles + var toolLatencies = {}; + log.forEach(function(r) { + var t = r.tool_name || 'unknown'; + if (!toolLatencies[t]) toolLatencies[t] = []; + toolLatencies[t].push(r.latency_ms || 0); + }); + function percentile(arr, p) { + var sorted = arr.slice().sort(function(a, b) { return a - b; }); + var idx = Math.ceil(sorted.length * p) - 1; + return sorted[Math.max(0, idx)] || 0; + } + var toolBody = document.getElementById('toolByToolBody'); + var toolKeys = Object.keys(byTool).sort(function(a, b) { return byTool[b] - byTool[a]; }); + if (toolKeys.length === 0) { + toolBody.innerHTML = 'No tool data yet'; + } else { + toolBody.innerHTML = toolKeys.map(function(k) { + var lats = toolLatencies[k] || []; + var p50 = lats.length ? percentile(lats, 0.5).toFixed(0) + 'ms' : '-'; + var p95 = lats.length ? percentile(lats, 0.95).toFixed(0) + 'ms' : '-'; + var mx = lats.length ? Math.max.apply(null, lats).toFixed(0) + 'ms' : '-'; + var p95Val = lats.length ? percentile(lats, 0.95) : 0; + var p95Style = p95Val > 2000 ? ' style="color:var(--accent-red)"' : p95Val > 500 ? ' style="color:var(--accent-yellow)"' : ''; + var ext = toolExtras[k] || {}; + var avgSize = ext.count ? formatBytes(ext.sizeSum / ext.count) : '-'; + return '' + escapeHtml(k) + '' + byTool[k] + '' + p50 + '' + p95 + '' + mx + '' + avgSize + ''; + }).join(''); + } + + // Session table + var sessionData = {}; + log.forEach(function(r) { + var sid = r.session_id || 'unknown'; + if (!sessionData[sid]) sessionData[sid] = { calls: 0, tools: {}, first: r.timestamp, last: r.timestamp }; + sessionData[sid].calls++; + sessionData[sid].tools[r.tool_name] = true; + if (r.timestamp < sessionData[sid].first) sessionData[sid].first = r.timestamp; + if (r.timestamp > sessionData[sid].last) sessionData[sid].last = r.timestamp; + }); + var sessionBody = document.getElementById('toolSessionBody'); + var sessionKeys = Object.keys(sessionData).sort(function(a, b) { return sessionData[b].calls - sessionData[a].calls; }); + if (sessionKeys.length === 0) { + sessionBody.innerHTML = 'No session data'; + } else { + sessionBody.innerHTML = sessionKeys.slice(0, 10).map(function(sid) { + var s = sessionData[sid]; + var toolCount = Object.keys(s.tools).length; + var dur = '-'; + try { + var ms = new Date(s.last) - new Date(s.first); + if (ms > 60000) dur = Math.round(ms / 60000) + 'm'; + else if (ms > 0) dur = Math.round(ms / 1000) + 's'; + else dur = '<1s'; + } catch(e) {} + return '' + escapeHtml(sid) + '' + s.calls + '' + toolCount + '' + dur + ''; + }).join(''); + } + + // By Project table + var projBody = document.getElementById('toolByProjectBody'); + var projKeys = Object.keys(byProject).sort(function(a, b) { return byProject[b] - byProject[a]; }); + if (projKeys.length === 0) { + projBody.innerHTML = 'No project data yet'; + } else { + projBody.innerHTML = projKeys.map(function(k) { + return '' + escapeHtml(k) + '' + byProject[k] + ''; + }).join(''); + } + + // Request log + var logBody = document.getElementById('toolLogBody'); + if (log.length === 0) { + logBody.innerHTML = 'No tool calls recorded yet'; + } else { + logBody.innerHTML = log.map(function(r) { + var t = r.timestamp ? new Date(r.timestamp).toLocaleTimeString() : '-'; + var statusCls = r.success ? 'llm-status-ok' : 'llm-status-err'; + var statusTxt = r.success ? 'OK' : 'ERR'; + var rowCls = r.success ? '' : ' class="llm-log-row-err"'; + var context = r.query_text || r.memory_type || r.rating || '-'; + if (context.length > 60) context = context.substring(0, 57) + '...'; + var size = r.response_size ? formatBytes(r.response_size) : '-'; + var row = '' + t + '' + escapeHtml(r.tool_name || '-') + '' + escapeHtml(r.project || '-') + '' + escapeHtml(context) + '' + (r.latency_ms || 0) + 'ms' + size + '' + statusTxt + ''; + if (!r.success && r.error_message) { + row += '' + escapeHtml(r.error_message) + ''; + } + return row; + }).join(''); + } + + // Memory type distribution (from remember calls) + var memTypes = {}; + log.forEach(function(r) { + if (r.tool_name === 'remember' && r.memory_type) { + memTypes[r.memory_type] = (memTypes[r.memory_type] || 0) + 1; + } + }); + var memTypeEl = document.getElementById('memTypeChart'); + var typeColors = { decision: 'var(--accent-cyan)', error: 'var(--accent-red)', insight: 'var(--accent-purple)', learning: 'var(--accent-green)', general: 'var(--text-muted)' }; + var typeKeys = Object.keys(memTypes).sort(function(a, b) { return memTypes[b] - memTypes[a]; }); + if (typeKeys.length === 0) { + memTypeEl.innerHTML = 'No memories stored yet'; + } else { + var typeTotal = typeKeys.reduce(function(s, k) { return s + memTypes[k]; }, 0); + memTypeEl.innerHTML = typeKeys.map(function(k) { + var pct = (memTypes[k] / typeTotal * 100).toFixed(0); + var color = typeColors[k] || 'var(--text-muted)'; + return '
' + + '
' + memTypes[k] + '
' + + '
' + k + ' (' + pct + '%)
'; + }).join(''); + } + + // Feedback quality breakdown + var ratings = { helpful: 0, partial: 0, irrelevant: 0 }; + log.forEach(function(r) { + if (r.tool_name === 'feedback' && r.rating) { + ratings[r.rating] = (ratings[r.rating] || 0) + 1; + } + }); + var feedbackEl = document.getElementById('feedbackChart'); + var ratingColors = { helpful: 'var(--accent-green)', partial: 'var(--accent-yellow)', irrelevant: 'var(--accent-red)' }; + var ratingTotal = ratings.helpful + ratings.partial + ratings.irrelevant; + if (ratingTotal === 0) { + feedbackEl.innerHTML = 'No feedback recorded yet'; + } else { + feedbackEl.innerHTML = ['helpful', 'partial', 'irrelevant'].map(function(k) { + var pct = (ratings[k] / ratingTotal * 100).toFixed(0); + var color = ratingColors[k]; + return '
' + + '
' + ratings[k] + '
' + + '
' + k + ' (' + pct + '%)
'; + }).join(''); + } + + renderToolChart(data.chart_buckets || [], _toolRange); + loadAnalytics(); + loadSessionTimeline(); + } catch (e) { + document.getElementById('toolCalls').textContent = 'ERR'; + } +} + +// ── 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)' }; + +export function _thresholdColor(value, greenAbove, yellowAbove) { + if (value >= greenAbove) return 'var(--accent-green)'; + if (value >= yellowAbove) return 'var(--accent-yellow)'; + return 'var(--accent-red)'; +} + +export function _renderSparkline(container, data, color) { + if (!data || data.length < 2) return; + var w = 64, h = 24; + var svg = svgEl('svg', { width: w, height: h }); + container.appendChild(svg); + var dMin = Math.min.apply(null, data), dMax = Math.max.apply(null, data); + if (dMin === dMax) { dMin -= 1; dMax += 1; } + // Build points + var pts = []; + for (var i = 0; i < data.length; i++) { + pts.push(linScale(i, 0, data.length - 1, 2, w - 2) + ',' + linScale(data[i], dMin, dMax, h - 2, 2)); + } + // Area polygon (top line + bottom edge) + var bottomPts = []; + for (var j = data.length - 1; j >= 0; j--) { + bottomPts.push(linScale(j, 0, data.length - 1, 2, w - 2) + ',' + h); + } + svg.appendChild(svgEl('polygon', { points: pts.join(' ') + ' ' + bottomPts.join(' '), fill: color, opacity: '0.1' })); + // Line + svg.appendChild(svgEl('polyline', { points: pts.join(' '), fill: 'none', stroke: color, 'stroke-width': '1.5' })); + // End dot + var lastX = linScale(data.length - 1, 0, data.length - 1, 2, w - 2); + var lastY = linScale(data[data.length - 1], dMin, dMax, h - 2, 2); + svg.appendChild(svgEl('circle', { cx: lastX, cy: lastY, r: '2.5', fill: color })); +} + +export 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' }; +} + +export async function loadAnalytics() { + try { + var data = await fetchJSON('/analytics'); + var p = data.pipeline || {}; + var sn = data.signal_noise || {}; + 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); + + // 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 learningRatio = (neverBucket && highBucket && neverBucket.avg_salience > 0) + ? highBucket.avg_salience / neverBucket.avg_salience : 0; + + // 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.'); + } + + // 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.'); + } + + // 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.'); + } + + // 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.'); + } + + // 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.'); + } + + // 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) ── + try { renderLifecycleChart(svData, fbData, chData); } catch(e) { console.error('Lifecycle chart error:', e); } + + // ── Signal Quality by Source (D3 horizontal bars) ── + try { renderSignalChart(sn); } catch(e) { console.error('Signal chart error:', e); } + + // ── Recall Learning Curve (D3 connected dots) ── + try { renderRecallChart(re); } catch(e) { console.error('Recall chart error:', e); } + + } catch(e) { + console.error('Analytics load failed:', e); + } +} + +export 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 = svgEl('svg', { width: w, height: h }); + container.appendChild(svg); + var g = svgEl('g', { transform: 'translate(' + margin.left + ',' + margin.top + ')' }); + svg.appendChild(g); + + // 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 nd = svData.length; + var bandStep = iw / nd; + var bandPad = 0.1; + var bandW = bandStep * (1 - bandPad); + var bandOff = bandStep * bandPad / 2; + + var maxY = Math.max.apply(null, svData.map(function(d) { return (d.active || 0) + (d.merged || 0) + (d.fading || 0) + (d.archived || 0); })) || 1; + + // Manual stack calculation + var stackData = svData.map(function(d) { + var y0 = 0; + var layers = {}; + stackKeys.forEach(function(k) { + var v = d[k] || 0; + layers[k] = { y0: y0, y1: y0 + v }; + y0 += v; + }); + return layers; + }); + + // Draw stacked area polygons per layer + stackKeys.forEach(function(key) { + var pts = []; + // Top edge (left to right) + for (var i = 0; i < nd; i++) { + var cx = i * bandStep + bandStep / 2; + pts.push(cx + ',' + linScale(stackData[i][key].y1, 0, maxY, ih, 0)); + } + // Bottom edge (right to left) + for (var j = nd - 1; j >= 0; j--) { + var cx2 = j * bandStep + bandStep / 2; + pts.push(cx2 + ',' + linScale(stackData[j][key].y0, 0, maxY, ih, 0)); + } + g.appendChild(svgEl('polygon', { points: pts.join(' '), fill: stackColors[key], opacity: key === 'archived' ? '0.3' : '0.6' })); + }); + + // Feedback quality overlay line (secondary Y axis) + if (fbData && fbData.length > 0) { + 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.forEach(function(d, i) { + var q = fbMap[d.date]; + if (q !== null && q !== undefined) { + fbPoints.push({ idx: i, quality: q, date: d.date }); + } + }); + + if (fbPoints.length > 1) { + var linePts = fbPoints.map(function(p) { + return (p.idx * bandStep + bandStep / 2) + ',' + linScale(p.quality, 0, 100, ih, 0); + }); + g.appendChild(svgEl('polyline', { points: linePts.join(' '), fill: 'none', stroke: 'var(--accent-violet)', 'stroke-width': 2, 'stroke-dasharray': '4,2' })); + + // Right axis label + g.appendChild(svgText(iw + 8, linScale(50, 0, 100, ih, 0), 'quality %', { fill: 'var(--text-dim)', 'font-size': '0.6rem', 'dominant-baseline': 'middle' })); + } + } + + // 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, i) { + if (chMap[d.date]) { + var cx = i * bandStep + bandStep / 2; + var dy = linScale(maxY, 0, maxY, ih, 0) - 2; + g.appendChild(svgEl('path', { d: 'M' + cx + ',' + dy + ' l4,6 l-4,6 l-4,-6 z', fill: 'var(--accent-orange)', opacity: '0.7' })); + } + }); + } + + // X axis + var labelEvery = Math.max(1, Math.ceil(nd / 7)); + var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + g.appendChild(svgEl('line', { x1: 0, x2: iw, y1: ih, y2: ih, stroke: 'var(--border-subtle)' })); + svData.forEach(function(d, i) { + if (i % labelEvery !== 0) return; + var bx = i * bandStep + bandStep / 2; + g.appendChild(svgEl('line', { x1: bx, x2: bx, y1: ih, y2: ih + 4, stroke: 'var(--border-subtle)' })); + var parts = d.date.split('-'); + g.appendChild(svgText(bx, ih + 14, months[parseInt(parts[1]) - 1] + ' ' + parseInt(parts[2]), { 'text-anchor': 'middle', 'font-size': '0.6rem' })); + }); + + // Y axis ticks + var yTicks = 4; + var yStep = maxY / yTicks; + for (var t = 0; t <= yTicks; t++) { + var tv = Math.round(t * yStep); + var ty = linScale(tv, 0, maxY, ih, 0); + g.appendChild(svgEl('line', { x1: -4, x2: 0, y1: ty, y2: ty, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(-8, ty, String(tv), { 'text-anchor': 'end', 'dominant-baseline': '0.32em', 'font-size': '0.6rem' })); + } + + // Tooltip on hover + var tooltipLine = svgEl('line', { stroke: 'var(--text-dim)', 'stroke-dasharray': '2,2', y1: 0, y2: ih, display: 'none' }); + g.appendChild(tooltipLine); + var tooltipDiv = document.getElementById('lifecycleTooltip'); + var hitRect = svgEl('rect', { width: iw, height: ih, fill: 'transparent' }); + hitRect.addEventListener('mousemove', function(event) { + var rect = hitRect.getBoundingClientRect(); + var mx = event.clientX - rect.left; + var idx = Math.round(mx / iw * (nd - 1)); + idx = Math.max(0, Math.min(idx, nd - 1)); + var d = svData[idx]; + var cx = idx * bandStep + bandStep / 2; + tooltipLine.setAttribute('x1', cx); + tooltipLine.setAttribute('x2', cx); + tooltipLine.setAttribute('display', ''); + var fb = fbData.find(function(f) { return f.date === d.date; }); + var qStr = ''; + if (fb) { var tt = fb.helpful + fb.partial + fb.irrelevant; qStr = tt > 0 ? ' \u00B7 quality: ' + ((fb.helpful / tt) * 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) + ' \u00B7 Merged: ' + (d.merged || 0) + ' \u00B7 Fading: ' + (d.fading || 0) + ' \u00B7 Archived: ' + (d.archived || 0) + qStr; + }); + hitRect.addEventListener('mouseleave', function() { + tooltipLine.setAttribute('display', 'none'); + tooltipDiv.style.display = 'none'; + }); + g.appendChild(hitRect); + + // Legend + legendEl.innerHTML = stackKeys.map(function(k) { + return '' + k + ''; + }).join('') + 'quality' + + 'consolidation'; +} + +export 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 ih = h - margin.top - margin.bottom; + + var svg = svgEl('svg', { width: w, height: h }); + container.appendChild(svg); + var g = svgEl('g', { transform: 'translate(' + margin.left + ',' + margin.top + ')' }); + svg.appendChild(g); + + // Band scale params + var bandStep = ih / sources.length; + var bandPadding = 0.25; + var bandW = bandStep * (1 - bandPadding); + var bandOffset = bandStep * bandPadding / 2; + + sources.forEach(function(src, i) { + var yPos = i * bandStep + bandOffset; + var survPct = (sn[src].survival_rate || 0) * 100; + var barW = Math.max(2, linScale(survPct, 0, 100, 0, iw)); + // Background track + g.appendChild(svgEl('rect', { x: 0, y: yPos, width: iw, height: bandW, fill: 'var(--bg-tertiary)', rx: 3 })); + // Bar + g.appendChild(svgEl('rect', { x: 0, y: yPos, width: barW, height: bandW, fill: _raSourceColors[src] || 'var(--text-muted)', opacity: '0.7', rx: 3 })); + // Left label + var label = svgText(-6, yPos + bandW / 2, src, { 'text-anchor': 'end', 'dominant-baseline': 'middle', fill: _raSourceColors[src] || 'var(--text-muted)', 'font-size': '0.72rem', 'font-weight': '600' }); + g.appendChild(label); + // Value label + var s = sn[src]; + var valStr = s.active + '/' + s.total + ' (' + survPct.toFixed(0) + '%)' + (s.avg_salience ? ' \u00B7 sal ' + s.avg_salience.toFixed(2) : ''); + var valLabel = svgText(barW + 4, yPos + bandW / 2, valStr, { 'dominant-baseline': 'middle', fill: 'var(--text-muted)', 'font-size': '0.65rem' }); + g.appendChild(valLabel); + }); + + // Average line + var avgX = linScale(avgSurv * 100, 0, 100, 0, iw); + g.appendChild(svgEl('line', { x1: avgX, x2: avgX, y1: 0, y2: ih, stroke: 'var(--text-muted)', 'stroke-dasharray': '3,3', 'stroke-width': 1 })); +} + +export 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 = svgEl('svg', { width: w, height: h }); + container.appendChild(svg); + var g = svgEl('g', { transform: 'translate(' + margin.left + ',' + margin.top + ')' }); + svg.appendChild(g); + + var bucketNames = re.map(function(b) { return b.bucket; }); + var nb = bucketNames.length; + // scalePoint: evenly spaced points with padding + function xScale(val) { + var idx = bucketNames.indexOf(val); + if (idx < 0) return 0; + var usable = (iw - 40); // range [20, iw-20] + if (nb <= 1) return 20 + usable / 2; + var padStep = 0.5; + var step = usable / (nb - 1 + padStep * 2); + return 20 + (padStep + idx) * step; + } + + var maxSal = Math.max.apply(null, re.map(function(b) { return b.avg_salience; })) || 1; + var yMax = Math.max(maxSal * 1.1, 0.5); + var maxCount = Math.max.apply(null, re.map(function(b) { return b.count; })) || 1; + + // scaleSqrt for circle sizing + function rScale(count) { + var t = maxCount > 0 ? count / maxCount : 0; + return 4 + Math.sqrt(Math.max(0, t)) * (12 - 4); + } + + // Reference line at first bucket's salience + if (re.length > 1) { + var refY = linScale(re[0].avg_salience, 0, yMax, ih, 0); + g.appendChild(svgEl('line', { x1: 0, x2: iw, y1: refY, y2: refY, stroke: 'var(--border-subtle)', 'stroke-dasharray': '3,3' })); + } + + // Connecting polyline + var isLearning = re.length >= 2 && re[re.length - 1].avg_salience > re[0].avg_salience; + var linePts = re.map(function(b) { + return xScale(b.bucket) + ',' + linScale(b.avg_salience, 0, yMax, ih, 0); + }); + g.appendChild(svgEl('polyline', { points: linePts.join(' '), fill: 'none', stroke: isLearning ? 'var(--accent-green)' : 'var(--accent-red)', 'stroke-width': 2 })); + + // Dots (sized by count) + labels + re.forEach(function(b) { + var cx = xScale(b.bucket); + var cy = linScale(b.avg_salience, 0, yMax, ih, 0); + var r = rScale(b.count); + var color = b.bucket.indexOf('never') >= 0 ? 'var(--accent-red)' : b.bucket.indexOf('6+') >= 0 ? 'var(--accent-green)' : 'var(--accent-cyan)'; + g.appendChild(svgEl('circle', { cx: cx, cy: cy, r: r, fill: color, opacity: '0.7' })); + g.appendChild(svgText(cx, cy - r - 5, b.avg_salience.toFixed(2), { 'text-anchor': 'middle', fill: 'var(--text-secondary)', 'font-size': '0.68rem', 'font-weight': '600' })); + }); + + // X axis + g.appendChild(svgEl('line', { x1: 0, x2: iw, y1: ih, y2: ih, stroke: 'var(--border-subtle)' })); + bucketNames.forEach(function(name) { + var bx = xScale(name); + g.appendChild(svgEl('line', { x1: bx, x2: bx, y1: ih, y2: ih + 4, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(bx, ih + 14, name, { 'text-anchor': 'middle', 'font-size': '0.65rem' })); + }); + + // Y axis + var yTicks = 4; + var yStep = yMax / yTicks; + for (var t = 0; t <= yTicks; t++) { + var tv = t * yStep; + var ty = linScale(tv, 0, yMax, ih, 0); + g.appendChild(svgEl('line', { x1: -4, x2: 0, y1: ty, y2: ty, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(-8, ty, tv.toFixed(1), { 'text-anchor': 'end', 'dominant-baseline': '0.32em', 'font-size': '0.6rem' })); + } + + // 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 \u2014 salience flat or declining'; + svg.appendChild(svgText(margin.left, h - 2, verdictText, { fill: verdictColor, 'font-size': '0.65rem' })); +} + +// ── Session Activity (expandable rows with quality enrichment) ── + +export 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; +} + +export 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'); +} + +export async function loadSessionTimeline() { + try { + 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 || []; + var el = document.getElementById('sessionTimeline'); + if (sessions.length === 0) { + el.innerHTML = 'No sessions found'; + return; + } + + var activeSessions = sessions.filter(function(s) { return s.memory_count > 0; }); + if (activeSessions.length === 0) { + el.innerHTML = 'No sessions with memories'; + return; + } + + 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 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]; + + // 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'; + } + + // 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 */ } +} + +export function formatBytes(bytes) { + if (bytes < 1024) return Math.round(bytes) + 'B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'; + return (bytes / (1024 * 1024)).toFixed(1) + 'MB'; +} + +export function renderToolChart(chartBuckets, range) { + var container = document.getElementById('toolChart'); + var tooltip = document.getElementById('toolChartTooltip'); + container.innerHTML = ''; + if (!chartBuckets || chartBuckets.length === 0) { container.innerHTML = '
No data for chart
'; return; } + + var labelFmt, labelEvery; + if (range === '1h') { + labelFmt = function(d) { return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }; + labelEvery = 3; + } else if (range === '6h') { + labelFmt = function(d) { return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }; + labelEvery = 2; + } else if (range === '168h') { + var days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + labelFmt = function(d) { return days[d.getDay()]; }; + labelEvery = 1; + } else { + labelFmt = function(d) { return String(d.getHours()).padStart(2,'0') + ':00'; }; + labelEvery = 4; + } + + var buckets = chartBuckets.map(function(b, i) { + var ts = new Date(b.timestamp); + return { idx: i, start: ts, label: labelFmt(ts), calls: b.calls || 0, errors: b.errors || 0 }; + }); + + var w = container.clientWidth || 400; + var h = 200; + var margin = { top: 10, right: 10, bottom: 28, left: 50 }; + var iw = w - margin.left - margin.right; + var ih = h - margin.top - margin.bottom; + + var svg = svgEl('svg', { width: w, height: h }); + container.appendChild(svg); + var g = svgEl('g', { transform: 'translate(' + margin.left + ',' + margin.top + ')' }); + svg.appendChild(g); + + var maxCalls = Math.max.apply(null, buckets.map(function(d) { return d.calls; })) || 1; + var n = buckets.length; + var bandStep = iw / n; + var bandPad = 0.25; + var bandW = bandStep * (1 - bandPad); + var bandOff = bandStep * bandPad / 2; + + // Y axis ticks + var yTicks = 4; + var yStep = maxCalls / yTicks; + for (var t = 0; t <= yTicks; t++) { + var tv = Math.round(t * yStep); + var ty = linScale(tv, 0, maxCalls, ih, 0); + g.appendChild(svgEl('line', { x1: -4, x2: 0, y1: ty, y2: ty, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(-8, ty, String(tv), { 'text-anchor': 'end', 'dominant-baseline': '0.32em', 'font-size': '10' })); + } + // X axis baseline + g.appendChild(svgEl('line', { x1: 0, x2: iw, y1: ih, y2: ih, stroke: 'var(--border-subtle)' })); + // X axis labels + buckets.forEach(function(d) { + if (d.idx % labelEvery !== 0) return; + var bx = d.idx * bandStep + bandStep / 2; + g.appendChild(svgEl('line', { x1: bx, x2: bx, y1: ih, y2: ih + 4, stroke: 'var(--border-subtle)' })); + g.appendChild(svgText(bx, ih + 14, d.label, { 'text-anchor': 'middle', 'font-size': '10' })); + }); + + // Bars + buckets.forEach(function(d) { + var bx = d.idx * bandStep + bandOff; + if (d.calls > 0) { + var barY = linScale(d.calls, 0, maxCalls, ih, 0); + g.appendChild(svgEl('rect', { x: bx, y: barY, width: bandW, height: ih - barY, rx: 2, fill: 'var(--accent-cyan)', opacity: '0.8' })); + } + // Hit area + var hit = svgEl('rect', { x: bx - bandW * 0.1, y: 0, width: bandW * 1.2, height: ih, fill: 'transparent' }); + (function(dd) { + hit.addEventListener('mouseover', function(event) { + if (dd.calls === 0) return; + var errLine = dd.errors > 0 ? '
' + dd.errors + ' error' + (dd.errors === 1 ? '' : 's') + '' : ''; + tooltip.innerHTML = '' + dd.label + '
' + dd.calls + ' call' + (dd.calls === 1 ? '' : 's') + errLine; + tooltip.style.display = 'block'; + var barX = margin.left + dd.idx * bandStep + bandStep / 2; + var ttLeft = barX + 10; + if (ttLeft + 180 > w) ttLeft = barX - 190; + tooltip.style.left = ttLeft + 'px'; + tooltip.style.top = (margin.top + 2) + 'px'; + }); + hit.addEventListener('mouseout', function() { tooltip.style.display = 'none'; }); + })(d); + g.appendChild(hit); + + // Error marker + if (d.errors > 0) { + var ey = linScale(d.calls, 0, maxCalls, ih, 0) - 5; + g.appendChild(svgEl('circle', { cx: d.idx * bandStep + bandStep / 2, cy: ey, r: 3, fill: 'var(--accent-red)', 'pointer-events': 'none' })); + } + }); +} + diff --git a/internal/web/static/js/nav.js b/internal/web/static/js/nav.js new file mode 100644 index 00000000..5eb5c143 --- /dev/null +++ b/internal/web/static/js/nav.js @@ -0,0 +1,89 @@ +import { state } from './state.js'; + +export function setTheme(name) { + document.documentElement.setAttribute('data-theme', name); + localStorage.setItem('mnemonic-theme', name); + var sel = document.getElementById('themeSelect'); + if (sel) sel.value = name; +} + +// ── View Switching ── +export function switchView(name) { + state.currentView = name; + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + document.querySelectorAll('.nav-tab, .ntab').forEach(t => t.classList.remove('active')); + var viewEl = document.getElementById('view-' + name); + if (viewEl) viewEl.classList.add('active'); + var tab = document.querySelector('.ntab[data-view="' + name + '"]') || document.querySelector('.nav-tab[data-view="' + name + '"]'); + if (tab) tab.classList.add('active'); + window.location.hash = name; + // Update breadcrumbs + var crumbMap = { recall: 'Search', explore: 'Forum', timeline: 'Timeline', agent: 'SDK', llm: 'LLM Usage', tools: 'Tools' }; + var bc = document.getElementById('breadcrumbs'); + if (bc && crumbMap[name]) bc.innerHTML = 'mnemonic' + crumbMap[name]; + if (name === 'explore') { + window.loadForumIndex(); + var wp = document.getElementById('forumWelcome'); + if (wp) wp.style.display = ''; + } + if (name === 'timeline' && !state.timelineInitialized) { window.populateTimelineProjects(); window.loadTimelineData(false); } + if (name === 'agent' && !state.agentLoaded) window.loadAgentData(); + if (name === 'llm' && !state.llmLoaded) window.loadLLMUsage(); + if (name === 'tools' && !state.toolsLoaded) window.loadToolUsage(); +} + +export function switchExploreTab(tab) { + // Forum view shows all blocks simultaneously (no tabs) + // This function now just triggers a lazy load if needed + state.currentExploreTab = tab; + if (!state.exploreLoaded[tab]) window.loadExploreTab(tab); +} + +export function handleHash() { + var hash = window.location.hash.replace('#', ''); + if (hash.startsWith('thread/')) { + var epId = hash.substring(7); + if (epId) window.loadThread(epId); + return; + } + if (hash.startsWith('forum-thread/')) { + var threadId = hash.substring(13); + if (threadId) window.loadForumThread(threadId); + return; + } + if (hash.startsWith('memory-section/')) { + var secId = hash.substring(15); + var secNames = { episodes: 'Episodes', memories: 'Recent Memories', patterns: 'Discovered Patterns', abstractions: 'Abstractions & Principles' }; + if (secId && secNames[secId]) window.loadMemorySection(secId, secNames[secId]); + return; + } + if (hash.startsWith('forum-group/')) { + var groupType = hash.substring(12); + if (groupType) window.loadForumGroup(groupType); + return; + } + if (hash.startsWith('forum-category/')) { + var catId = hash.substring(15); + if (catId) window.loadForumCategory(catId, catId); + return; + } + if (['recall', 'explore', 'timeline', 'agent', 'llm', 'tools'].includes(hash)) switchView(hash); +} +window.addEventListener('hashchange', handleHash); + +// ── Keyboard Shortcuts ── +document.addEventListener('keydown', function(e) { + var tag = document.activeElement.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { if (e.key === 'Escape') document.activeElement.blur(); return; } + switch (e.key) { + case '/': e.preventDefault(); switchView('recall'); document.getElementById('recallInput').focus(); break; + case 'Escape': if (state.drawerOpen) window.toggleDrawer(); break; + 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 '6': switchView('tools'); break; + } +}); + diff --git a/internal/web/static/js/placeholder.js b/internal/web/static/js/placeholder.js deleted file mode 100644 index ff7bd09c..00000000 --- a/internal/web/static/js/placeholder.js +++ /dev/null @@ -1 +0,0 @@ -// placeholder diff --git a/internal/web/static/js/recall.js b/internal/web/static/js/recall.js new file mode 100644 index 00000000..f2b161e0 --- /dev/null +++ b/internal/web/static/js/recall.js @@ -0,0 +1,119 @@ +import { state, CONFIG } from './state.js'; +import { apiFetch, escapeHtml, showToast, memoryType, safeSalience, memoryTypeAbbr, memoryTypeIcon } from './utils.js'; + +// ── Recall ── +export async function performRecall() { + var input = document.getElementById('recallInput'); + var query = input.value.trim(); + if (!query) return; + var btn = document.getElementById('recallBtn'); + btn.disabled = true; + btn.innerHTML = ''; + var results = document.getElementById('recallResults'); + results.innerHTML = '
'; + try { + var synth = document.getElementById('synthesizeToggle').checked; + var resp = await apiFetch(CONFIG.API_BASE + '/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: query, limit: 10, synthesize: synth, include_reasoning: false }), + }); + var data = await resp.json(); + state.lastQueryId = data.query_id; + state.lastQueryResults = data.memories || []; + renderResults(data); + } catch (e) { + results.innerHTML = '
Failed to execute query
' + escapeHtml(e.message) + '
'; + } finally { + btn.disabled = false; + btn.textContent = 'Recall'; + } +} + +export function renderResults(data) { + var results = document.getElementById('recallResults'); + var memories = data.memories || []; + if (memories.length === 0) { + results.innerHTML = '
🔍
No memories found
Try a different search term
'; + return; + } + var hops = data.hops || 0; + var html = '
' + memories.length + ' result' + (memories.length !== 1 ? 's' : '') + ' · ' + (data.took_ms || 0) + 'ms' + (hops ? ' · spread activation: ' + hops + ' hops' : '') + '
'; + html += '
'; + html += '
  • Memory
    Score
    Sal
    Created
'; + html += '
    '; + memories.forEach(function(mem, idx) { + var score = mem.score || 0; + var scoreColor = score > 0.7 ? 'var(--link)' : score > 0.4 ? 'var(--text-secondary)' : 'var(--text-dim)'; + var m = mem.memory; + var type = memoryType(m); + var typeAbbr = memoryTypeAbbr(type); + var iconClass = memoryTypeIcon(type); + var salPct = safeSalience(m.salience); + var dateStr = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; + var summary = m.summary || (m.content || '').slice(0, 150); + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + html += '
  • '; + html += '
    '; + html += '' + typeAbbr + ''; + html += ''; + html += '
    '; + html += '
    ' + score.toFixed(2) + '
    '; + html += '
    ' + salPct + '%
    '; + html += '
    ' + dateStr + '
    '; + html += '
  • '; + }); + html += '
'; + html += ''; + if (data.synthesis) { + html += '
Synthesis
' + escapeHtml(data.synthesis) + '
'; + } + results.innerHTML = html; +} + +export function toggleExpand(card) { + var expanded = card.querySelector('.result-expanded, .episode-expanded, .memory-expanded, .expand-zone'); + if (expanded) expanded.classList.toggle('open'); +} + +export function toggle(id) { + var el = document.getElementById(id); + if (el) el.classList.toggle('open'); +} + + +// ── Remember ── +export function toggleRemember() { + state.rememberOpen = !state.rememberOpen; + document.getElementById('rememberToggle').classList.toggle('open', state.rememberOpen); + document.getElementById('rememberForm').classList.toggle('open', state.rememberOpen); +} + +export async function submitRemember() { + var content = document.getElementById('rememberContent').value.trim(); + if (!content) return; + var type = document.getElementById('rememberType').value; + var project = document.getElementById('rememberProject').value; + var btn = document.querySelector('.remember-submit'); + btn.disabled = true; + try { + await apiFetch(CONFIG.API_BASE + '/memories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content, source: 'web_ui', type: type, project: project }), + }); + document.getElementById('rememberContent').value = ''; + showToast('Memory stored', 'success'); + state.exploreLoaded.memories = false; + } catch (e) { + showToast('Failed to store memory', 'error'); + } finally { + btn.disabled = false; + } +} diff --git a/internal/web/static/js/state.js b/internal/web/static/js/state.js new file mode 100644 index 00000000..27e24bfa --- /dev/null +++ b/internal/web/static/js/state.js @@ -0,0 +1,35 @@ +export const state = { + currentView: 'recall', + currentExploreTab: 'episodes', + lastQueryId: null, + lastQueryResults: [], + events: [], + unreadEvents: 0, + drawerOpen: false, + rememberOpen: false, + timelineInitialized: false, + previousStats: {}, + ws: null, + wsReconnectAttempts: 0, + exploreLoaded: { episodes: false, memories: false, patterns: false, abstractions: false }, + projectList: [], + agentLoaded: false, + agentData: null, + llmLoaded: false, + toolsLoaded: false, + dreamSessionTotals: { cycles: 0, replayed: 0, strengthened: 0, newLinks: 0, insights: 0, crossProject: 0, demoted: 0 }, + forumLoaded: false, + currentForumThread: null, + currentEpisodeId: '', +}; + +export const CONFIG = { + API_BASE: `${window.location.origin}/api/v1`, + WS_URL: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`, + STATS_POLL: 30000, + INSIGHTS_POLL: 60000, + MAX_EVENTS: 100, + TOKEN: new URLSearchParams(window.location.search).get('token') || '', +}; + +export var MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; diff --git a/internal/web/static/js/timeline.js b/internal/web/static/js/timeline.js new file mode 100644 index 00000000..d4bbb429 --- /dev/null +++ b/internal/web/static/js/timeline.js @@ -0,0 +1,483 @@ +import { state, CONFIG, MONTHS } from './state.js'; +import { apiFetch, escapeHtml, showToast, makeDayBuckets, svgEl, linScale } from './utils.js'; + +// ── Timeline ── +var _timelineState = { + items: [], + range: 'all', + types: { episode: true, decision: true, error: true, insight: true, learning: true, general: true }, + search: '', + projectFilter: '', // empty = all projects + loading: false, + allLoaded: false, + episodeOffset: 0, + memoryOffset: 0, + dayFilter: null, // Date object — when set, show only items from that day +}; + +export function setTimelineRange(range) { + _timelineState.range = range; + _timelineState.dayFilter = null; + document.querySelector('#timelineSpark').classList.remove('day-active'); + document.querySelectorAll('.timeline-range-tab').forEach(function(t) { + t.classList.toggle('active', t.getAttribute('data-range') === range); + }); + renderTimelineSparkline(); + renderTimelineItems(); +} + +export function toggleTimelineType(btn) { + var type = btn.getAttribute('data-type'); + _timelineState.types[type] = !_timelineState.types[type]; + btn.classList.toggle('active', _timelineState.types[type]); + renderTimelineItems(); +} + +export function filterTimeline() { + _timelineState.search = document.getElementById('timelineSearch').value.toLowerCase(); + var wrap = document.getElementById('timelineSearchWrap'); + wrap.classList.toggle('has-value', _timelineState.search.length > 0); + renderTimelineItems(); +} + +export function clearTimelineSearch() { + var input = document.getElementById('timelineSearch'); + input.value = ''; + _timelineState.search = ''; + document.getElementById('timelineSearchWrap').classList.remove('has-value'); + input.focus(); + renderTimelineItems(); +} + +export function populateTimelineProjects() { + var container = document.getElementById('timelineProjectFilters'); + container.innerHTML = ''; + var projects = state.projectList || []; + if (projects.length === 0) return; + projects.forEach(function(p) { + var btn = document.createElement('button'); + btn.className = 'timeline-project-btn'; + btn.textContent = p; + btn.setAttribute('data-project', p); + btn.onclick = function() { toggleTimelineProject(p); }; + container.appendChild(btn); + }); +} + +export function toggleTimelineProject(project) { + if (_timelineState.projectFilter === project) { + _timelineState.projectFilter = ''; + } else { + _timelineState.projectFilter = project; + } + document.querySelectorAll('.timeline-project-btn').forEach(function(btn) { + btn.classList.toggle('active', btn.getAttribute('data-project') === _timelineState.projectFilter); + }); + renderTimelineItems(); +} + +export function getTimelineRangeCutoff() { + var now = new Date(); + switch (_timelineState.range) { + case 'today': + var start = new Date(now); start.setHours(0,0,0,0); return start; + case 'week': + return new Date(now.getTime() - 7 * 86400000); + case 'month': + return new Date(now.getTime() - 30 * 86400000); + default: return null; + } +} + +export function filterTimelineItems() { + var cutoff = getTimelineRangeCutoff(); + var search = _timelineState.search; + var types = _timelineState.types; + var dayFilter = _timelineState.dayFilter; + var projectFilter = _timelineState.projectFilter; + return _timelineState.items.filter(function(item) { + if (!types[item._kind]) return false; + if (projectFilter && (item._project || '') !== projectFilter) return false; + if (dayFilter) { + var d = new Date(item._date); + d.setHours(0, 0, 0, 0); + if (d.getTime() !== dayFilter.getTime()) return false; + } else if (cutoff && item._date < cutoff) { + return false; + } + if (search) { + var haystack = (item._title + ' ' + (item._concepts || []).join(' ')).toLowerCase(); + if (haystack.indexOf(search) === -1) return false; + } + return true; + }); +} + +export function groupByDate(items) { + var groups = []; + var currentLabel = ''; + var today = new Date(); today.setHours(0,0,0,0); + var yesterday = new Date(today.getTime() - 86400000); + items.forEach(function(item) { + var d = new Date(item._date); d.setHours(0,0,0,0); + var label; + if (d.getTime() === today.getTime()) label = 'Today'; + else if (d.getTime() === yesterday.getTime()) label = 'Yesterday'; + else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + if (label !== currentLabel) { + groups.push({ label: label, items: [] }); + currentLabel = label; + } + groups[groups.length - 1].items.push(item); + }); + return groups; +} + +export function renderTimelineItems() { + var body = document.getElementById('timelineBody'); + var filtered = filterTimelineItems(); + if (filtered.length === 0) { + body.innerHTML = '
No items match your filters
Try adjusting the date range or type filters
'; + return; + } + var groups = groupByDate(filtered); + var html = ''; + groups.forEach(function(group) { + html += '
' + escapeHtml(group.label) + '' + group.items.length + ' memories
'; + group.items.forEach(function(item, idx) { + html += renderTimelineCard(item, idx); + }); + }); + if (!_timelineState.allLoaded) { + html += '
Loading more...
'; + } + body.innerHTML = html; + setupTimelineScroll(); +} + +export function renderTimelineCard(item, idx) { + var kind = item._kind; + var salPct = Math.min(100, Math.round((item._salience || 0) * 100)); + var absTime = item._date.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }); + var concepts = item._concepts || []; + var source = item._source || ''; + var project = item._project || ''; + var typeAbbr = kind === 'insight' ? 'IN' : kind === 'decision' ? 'DE' : kind === 'learning' ? 'LE' : kind === 'error' ? 'ER' : kind === 'episode' ? 'EP' : 'GN'; + var iconClass = kind === 'insight' ? 'icon-in' : kind === 'decision' ? 'icon-de' : kind === 'learning' ? 'icon-le' : kind === 'error' ? 'icon-er' : kind === 'episode' ? 'icon-ep' : 'icon-gn'; + + var allTags = concepts.slice(); + if (source) allTags.unshift(source); + if (project) allTags.unshift(project); + var bgClass = (idx || 0) % 2 === 0 ? 'bg1' : 'bg2'; + + // Simple grid row — timeline doesn't need the forum dl/dt/dd pattern + var html = '
'; + html += '' + typeAbbr + ''; + html += '' + escapeHtml(item._title) + ''; + + // Concept tags inline + if (concepts.length > 0 || source) { + html += ''; + if (source) { + html += '' + escapeHtml(source) + ''; + } + concepts.slice(0, 4).forEach(function(c) { + html += '' + escapeHtml(c) + ''; + }); + html += ''; + } + html += '' + absTime + ''; + html += '' + salPct + '%'; + html += '
'; + + // Expandable detail inside the same flow + html += '
'; + if (item._type === 'episode') { + if (item._raw.summary) html += '
' + escapeHtml(item._raw.summary) + '
'; + if (item._raw.narrative) html += '
' + escapeHtml(item._raw.narrative) + '
'; + } else { + if (item._raw.content && item._raw.content !== item._raw.summary) { + html += '
' + escapeHtml(item._raw.content) + '
'; + } + } + html += '
'; + return html; +} + +export function expandTimelineCard(el) { + var next = el.nextElementSibling; + if (next && (next.classList.contains('expand-zone') || next.classList.contains('tl-card-detail'))) { + next.classList.toggle('open'); + } +} + +var _selectedTimelineTags = new Set(); + +export function toggleTimelineTag(event, concept) { + event.stopPropagation(); + if (_selectedTimelineTags.has(concept)) { + _selectedTimelineTags.delete(concept); + } else { + _selectedTimelineTags.add(concept); + } + applyTimelineTagHighlight(); +} + +export function hoverTimelineTag(concept) { + if (_selectedTimelineTags.size > 0) return; // don't hover-highlight when tags are pinned + highlightTimelineCards(new Set([concept])); +} + +export function unhoverTimelineTag() { + if (_selectedTimelineTags.size > 0) return; // keep pinned selection + clearAllTimelineHighlight(); +} + +export function applyTimelineTagHighlight() { + if (_selectedTimelineTags.size === 0) { + clearAllTimelineHighlight(); + return; + } + highlightTimelineCards(_selectedTimelineTags); + // Mark selected tag pills + document.querySelectorAll('.tl-concept, .tl-source, .tl-card-project').forEach(function(tag) { + if (_selectedTimelineTags.has(tag.textContent)) { + tag.classList.add('concept-selected'); + } else { + tag.classList.remove('concept-selected'); + } + }); +} + +export function highlightTimelineCards(activeTags) { + var cards = document.querySelectorAll('.tl-card'); + cards.forEach(function(card) { + var cardConcepts = (card.getAttribute('data-concepts') || '').split(','); + var match = true; + activeTags.forEach(function(tag) { + if (cardConcepts.indexOf(tag) === -1) match = false; + }); + if (match) { + card.classList.add('highlighted'); + card.classList.remove('dimmed'); + } else { + card.classList.add('dimmed'); + card.classList.remove('highlighted'); + } + }); + document.querySelectorAll('.tl-concept, .tl-source, .tl-card-project').forEach(function(tag) { + if (activeTags.has(tag.textContent)) { + tag.classList.add('concept-active'); + } else { + tag.classList.remove('concept-active'); + } + }); +} + +export function clearAllTimelineHighlight() { + document.querySelectorAll('.tl-card').forEach(function(card) { + card.classList.remove('dimmed', 'highlighted'); + }); + document.querySelectorAll('.tl-concept, .tl-source, .tl-card-project').forEach(function(tag) { + tag.classList.remove('concept-active', 'concept-selected'); + }); +} + +export async function loadTimelineData(append) { + if (_timelineState.loading) return; + _timelineState.loading = true; + try { + var epLimit = 50; + var memLimit = 50; + if (!append) { + _timelineState.items = []; + _timelineState.episodeOffset = 0; + _timelineState.memoryOffset = 0; + _timelineState.allLoaded = false; + } + var epUrl = CONFIG.API_BASE + '/episodes?state=closed&limit=' + epLimit + '&offset=' + _timelineState.episodeOffset; + var memUrl = CONFIG.API_BASE + '/memories?state=active&limit=' + memLimit + '&offset=' + _timelineState.memoryOffset; + var results = await Promise.all([ + apiFetch(epUrl).then(function(r) { return r.json(); }), + apiFetch(memUrl).then(function(r) { return r.json(); }) + ]); + var episodes = results[0].episodes || []; + var memories = results[1].memories || []; + + episodes.forEach(function(ep) { + if (!ep.title || !ep.title.trim()) return; + _timelineState.items.push({ + _id: ep.id, + _type: 'episode', + _kind: 'episode', + _title: ep.title, + _date: new Date(ep.start_time || ep.end_time), + _concepts: ep.concepts || [], + _tone: ep.emotional_tone || '', + _salience: 0, + _fileCount: (ep.files_modified || []).length, + _eventCount: (ep.raw_memory_ids || []).length, + _project: ep.project || '', + _raw: ep, + }); + }); + memories.forEach(function(m) { + if (!m.summary || !m.summary.trim()) return; + var kind = m.type || m.memory_type || 'general'; + if (!_timelineState.types.hasOwnProperty(kind)) kind = 'general'; + _timelineState.items.push({ + _id: m.id, + _type: 'memory', + _kind: kind, + _title: m.summary, + _date: new Date(m.timestamp || m.created_at), + _concepts: m.concepts || [], + _source: m.source || '', + _tone: '', + _salience: m.salience || 0, + _fileCount: 0, + _eventCount: 0, + _project: m.project || '', + _raw: m, + }); + }); + + _timelineState.items.sort(function(a, b) { return b._date.getTime() - a._date.getTime(); }); + + var seen = {}; + _timelineState.items = _timelineState.items.filter(function(item) { + if (seen[item._id]) return false; + seen[item._id] = true; + return true; + }); + + _timelineState.episodeOffset += episodes.length; + _timelineState.memoryOffset += memories.length; + if (episodes.length < epLimit && memories.length < memLimit) { + _timelineState.allLoaded = true; + } + + renderTimelineItems(); + renderTimelineSparkline(); + state.timelineInitialized = true; + } catch (e) { + console.error('Failed to load timeline:', e); + showToast('Failed to load timeline', 'error'); + } + _timelineState.loading = false; +} + +export function setupTimelineScroll() { + var body = document.getElementById('timelineBody'); + body.onscroll = function() { + if (_timelineState.allLoaded || _timelineState.loading) return; + if (body.scrollTop + body.clientHeight >= body.scrollHeight - 200) { + loadTimelineData(true); + } + }; +} + +export function renderTimelineSparkline() { + var container = document.getElementById('timelineSpark'); + var items = _timelineState.items; + if (items.length === 0) { + container.innerHTML = '
No activity data
'; + return; + } + + var now = new Date(); + var dayMs = 86400000; + var buckets = makeDayBuckets(30, { count: 0 }); + items.forEach(function(item) { + var d = new Date(item._date); + d.setHours(0, 0, 0, 0); + var diff = Math.floor((now.getTime() - d.getTime()) / dayMs); + if (diff >= 0 && diff < 30) { + buckets[29 - diff].count++; + } + }); + + var maxCount = Math.max.apply(null, buckets.map(function(b) { return b.count; })); + if (maxCount === 0) maxCount = 1; + + container.innerHTML = ''; + var w = container.clientWidth; + var h = 40; + + // Tooltip element + var tooltip = document.createElement('div'); + tooltip.className = 'spark-tooltip'; + container.appendChild(tooltip); + + var svg = svgEl('svg', { width: w, height: h }); + svg.style.display = 'block'; + container.appendChild(svg); + + var nb = buckets.length; + var bandStep = w / nb; + var bandPad = 0.3; + var bandW = bandStep * (1 - bandPad); + var bandOff = bandStep * bandPad / 2; + + var dayFilter = _timelineState.dayFilter; + + function fmtDate(d) { + return MONTHS[d.getMonth()] + ' ' + d.getDate(); + } + + buckets.forEach(function(d, i) { + var bx = i * bandStep + bandOff; + var barY = d.count > 0 ? linScale(d.count, 0, maxCount, h - 2, 4) : h - 2; + var barH = d.count > 0 ? h - 2 - barY : 2; + + // Compute fill and opacity + var fill, opacity; + if (dayFilter && dayFilter.getTime() === d.date.getTime()) { + fill = 'var(--accent-cyan)'; + opacity = 1; + } else if (dayFilter) { + fill = d.count > 0 ? 'var(--accent-cyan)' : 'var(--border-subtle)'; + opacity = d.count > 0 ? 0.2 : 0.15; + } else { + fill = d.count > 0 ? 'var(--accent-cyan)' : 'var(--border-subtle)'; + var isToday = d.idx === 29; + if (d.count > 0) opacity = isToday ? 1 : 0.3 + 0.7 * (d.count / maxCount); + else opacity = isToday ? 0.25 : 0.15; + } + + // Visual bar + svg.appendChild(svgEl('rect', { x: bx, y: barY, width: bandW, height: barH, rx: 1, fill: fill, opacity: String(opacity), 'pointer-events': 'none' })); + + // Hit area + var hit = svgEl('rect', { x: bx - bandW * 0.15, y: 0, width: bandW * 1.3, height: h, fill: 'transparent', cursor: d.count > 0 ? 'pointer' : 'default' }); + (function(dd) { + hit.addEventListener('mouseover', function() { + if (dd.count === 0) return; + tooltip.textContent = fmtDate(dd.date) + ' \u00B7 ' + dd.count + ' memor' + (dd.count === 1 ? 'y' : 'ies'); + tooltip.style.display = 'block'; + var cx = dd.idx * bandStep + bandStep / 2; + tooltip.style.left = Math.min(cx - tooltip.offsetWidth / 2, w - tooltip.offsetWidth - 4) + 'px'; + }); + hit.addEventListener('mouseout', function() { tooltip.style.display = 'none'; }); + hit.addEventListener('click', function() { + if (dd.count === 0) return; + var isActive = dayFilter && dayFilter.getTime() === dd.date.getTime(); + if (isActive) { + _timelineState.dayFilter = null; + dayFilter = null; + container.classList.remove('day-active'); + } else { + _timelineState.dayFilter = dd.date; + dayFilter = dd.date; + container.classList.add('day-active'); + } + tooltip.style.display = 'none'; + renderTimelineSparkline(); + renderTimelineItems(); + }); + })(d); + svg.appendChild(hit); + }); +} + diff --git a/internal/web/static/js/utils.js b/internal/web/static/js/utils.js new file mode 100644 index 00000000..ae70cff5 --- /dev/null +++ b/internal/web/static/js/utils.js @@ -0,0 +1,182 @@ +import { CONFIG } from './state.js'; + +// Wrapper for fetch that adds auth header when token is configured +export function apiFetch(url, opts) { + opts = opts || {}; + if (CONFIG.TOKEN) { + opts.headers = Object.assign({}, opts.headers, { 'Authorization': 'Bearer ' + CONFIG.TOKEN }); + } + return fetch(url, opts); +} + +// Fetch JSON helper — calls apiFetch, checks response, parses JSON +export async function fetchJSON(path, opts) { + var resp = await apiFetch(CONFIG.API_BASE + path, opts); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + resp.statusText); + return resp.json(); +} + +// Generate an array of day buckets for the last N days (for D3 charts) +export function makeDayBuckets(days, extraFields) { + var now = new Date(); + var dayMs = 86400000; + var buckets = []; + for (var i = days - 1; i >= 0; i--) { + var d = new Date(now.getTime() - i * dayMs); + d.setHours(0, 0, 0, 0); + var bucket = { date: d, idx: days - 1 - i }; + if (extraFields) { + for (var k in extraFields) { bucket[k] = extraFields[k]; } + } + buckets.push(bucket); + } + return buckets; +} + +// ── Toast ── +export function showToast(message, type) { + type = type || 'success'; + var container = document.getElementById('toastContainer'); + var toast = document.createElement('div'); + toast.className = 'toast ' + type; + toast.innerHTML = '' + (type === 'success' ? '✓' : '⚠') + ' ' + escapeHtml(message); + container.appendChild(toast); + setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 3000); +} + +// ── SVG Chart Helpers ── +var SVG_NS = 'http://www.w3.org/2000/svg'; +export function svgEl(tag, attrs) { + var el = document.createElementNS(SVG_NS, tag); + if (attrs) for (var k in attrs) el.setAttribute(k, attrs[k]); + return el; +} +export function linScale(val, dMin, dMax, rMin, rMax) { + var dRange = dMax - dMin; + if (dRange === 0) return rMin; + return rMin + ((val - dMin) / dRange) * (rMax - rMin); +} +export function svgText(x, y, text, attrs) { + var el = svgEl('text', Object.assign({ x: x, y: y, fill: 'var(--text-dim)', 'font-size': '10' }, attrs || {})); + el.textContent = text; + return el; +} +export function fmtNum(n) { + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; + return String(n); +} + +// ── Utilities ── +export function escapeHtml(str) { if (!str) return ''; var div = document.createElement('div'); div.textContent = str; return div.innerHTML; } + +// Map raw source event types to display types +var _sourceTypeMap = { file_created: 'general', file_modified: 'general', repo_changed: 'general', clipboard: 'general', command_run: 'general', handoff: 'general' }; +export function memoryType(m) { + var raw = (m.type || m.memory_type || 'general').toLowerCase(); + return _sourceTypeMap[raw] || raw; +} +export function memoryTypeAbbr(type) { return type === 'insight' ? 'IN' : type === 'decision' ? 'DE' : type === 'learning' ? 'LE' : type === 'error' ? 'ER' : 'GN'; } +export function memoryTypeIcon(type) { return type === 'insight' ? 'icon-in' : type === 'decision' ? 'icon-de' : type === 'learning' ? 'icon-le' : type === 'error' ? 'icon-er' : 'icon-ep'; } +export function safeSalience(s) { return Math.min(100, Math.round((s || 0) * 100)); } + +// Agent identity system — maps memory sources to forum "users" +var _agentProfiles = { + mcp: { name: 'Claude Session', title: 'MCP Client', icon: 'CS', color: 'var(--accent-cyan)' }, + filesystem: { name: 'Perception Agent', title: 'Filesystem Watcher', icon: 'PA', color: 'var(--accent-green)' }, + git: { name: 'Git Observer', title: 'Repository Watcher', icon: 'GO', color: 'var(--accent-orange)' }, + terminal: { name: 'Terminal Agent', title: 'Command Observer', icon: 'TA', color: 'var(--accent-yellow)' }, + clipboard: { name: 'Clipboard Agent', title: 'Clipboard Monitor', icon: 'CB', color: 'var(--accent-pink)' }, + ingest: { name: 'Ingest Engine', title: 'Bulk Importer', icon: 'IE', color: 'var(--text-dim)' }, + system: { name: 'Daemon', title: 'System Process', icon: 'SY', color: 'var(--accent-violet)' }, + benchmark: { name: 'Benchmark', title: 'Quality Tester', icon: 'BM', color: 'var(--accent-blue)' }, +}; +export function agentProfile(source) { + return _agentProfiles[(source || 'mcp').toLowerCase()] || { name: source || 'Unknown', title: 'Agent', icon: '??', color: 'var(--text-dim)' }; +} + +// Live forum feed — agents posting in real time +var _liveFeedCount = 0; +var _wsAgentMap = { + raw_memory_created: function(p) { return { agent: agentProfile(p.source || 'filesystem'), text: 'Observed: ' + (p.summary || p.content || '').slice(0, 120), action: "switchView('timeline')" }; }, + memory_encoded: function(p) { return { agent: _agentProfiles.system || agentProfile('system'), text: 'Encoded memory: ' + (p.summary || '').slice(0, 120), action: "switchView('explore')" }; }, + consolidation_started: function() { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Starting consolidation cycle...', action: null }; }, + consolidation_completed: function(p) { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Consolidation complete: ' + (p.memories_processed || 0) + ' processed, ' + (p.memories_decayed || 0) + ' decayed, ' + (p.memories_merged || 0) + ' merged', action: "switchView('tools')" }; }, + dream_cycle_completed: function(p) { return { agent: { name: 'Dreaming Agent', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, text: 'Dream cycle: replayed ' + (p.memories_replayed || 0) + ', strengthened ' + (p.associations_strengthened || 0) + ' associations, ' + (p.insights_generated || 0) + ' insights', action: "switchView('tools')" }; }, + pattern_discovered: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, text: 'New pattern: ' + (p.title || ''), action: "switchView('explore')" }; }, + abstraction_created: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Principle Synthesis', icon: 'AA', color: 'var(--accent-orange)' }, text: (p.level === 3 ? 'New axiom' : 'New principle') + ': ' + (p.title || ''), action: "switchView('explore')" }; }, + episode_closed: function(p) { return { agent: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, text: 'Episode closed: ' + (p.title || ''), action: p.episode_id ? "loadThread('" + p.episode_id + "')" : "switchView('explore')" }; }, + query_executed: function(p) { return { agent: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, text: 'Recall query: "' + (p.query_text || '').slice(0, 60) + '" → ' + (p.results_returned || 0) + ' results', action: "switchView('recall')" }; }, + forum_post_created: function(p) { + var key = p.author_key || ''; + var profiles = { + consolidation: { name: 'Consolidation Agent', icon: 'CA', color: 'var(--accent-yellow)' }, + dreaming: { name: 'Dreaming Agent', icon: 'DA', color: 'var(--accent-violet)' }, + episoding: { name: 'Episoding Agent', icon: 'EP', color: 'var(--accent-violet)' }, + abstraction: { name: 'Abstraction Agent', icon: 'AA', color: 'var(--accent-orange)' }, + metacognition: { name: 'Metacognition Agent', icon: 'MA', color: 'var(--accent-blue)' }, + encoding: { name: 'Encoding Agent', icon: 'EA', color: 'var(--accent-blue)' }, + perception: { name: 'Perception Agent', icon: 'PA', color: 'var(--accent-green)' }, + retrieval: { name: 'Retrieval Agent', icon: 'RA', color: 'var(--accent-cyan)' }, + }; + var agent = profiles[key] || { name: p.author_name || 'Human', icon: 'HU', color: 'var(--accent-cyan)' }; + return { agent: agent, text: (p.content || '').slice(0, 120), action: "loadForumThread('" + p.thread_id + "')" }; + }, +}; + +export function addLivePost(wsType, payload, timestamp) { + var mapper = _wsAgentMap[wsType]; + if (!mapper) return; + var post = mapper(payload); + if (!post) return; + var feed = document.getElementById('liveFeedBody'); + if (!feed) return; + + _liveFeedCount++; + var countEl = document.getElementById('liveFeedCount'); + if (countEl) countEl.textContent = _liveFeedCount + ' events this session'; + + var time = timestamp ? new Date(timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}) : new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); + var bgClass = _liveFeedCount % 2 === 0 ? 'bg1' : 'bg2'; + + var clickAttr = post.action ? ' onclick="' + post.action + '" style="animation:fadeIn 0.3s ease-out;cursor:pointer"' : ' style="animation:fadeIn 0.3s ease-out"'; + var html = '
'; + html += '' + post.agent.icon + ''; + html += '' + escapeHtml(post.agent.name) + ' · ' + escapeHtml(post.text) + ''; + html += '' + time + ''; + html += '
'; + + // Remove the "Listening..." placeholder + if (_liveFeedCount === 1) feed.innerHTML = ''; + + // Prepend (newest at top) + feed.insertAdjacentHTML('afterbegin', html); + + // Cap at 50 entries + while (feed.children.length > 50) feed.removeChild(feed.lastChild); +} +export function simpleMarkdown(str) { + if (!str) return ''; + var lines = str.split('\n'); + var html = ''; + var inList = false; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + var isBullet = /^[-*]\s/.test(line); + if (isBullet) { + if (!inList) { html += '
    '; inList = true; } + var content = escapeHtml(line.replace(/^[-*]\s+/, '')); + content = content.replace(/\*\*(.+?)\*\*/g, '$1'); + content = content.replace(/`([^`]+)`/g, '$1'); + html += '
  • ' + content + '
  • '; + } else { + if (inList) { html += '
'; inList = false; } + var content = escapeHtml(line); + content = content.replace(/\*\*(.+?)\*\*/g, '$1'); + content = content.replace(/`([^`]+)`/g, '$1'); + if (content) html += '

' + content + '

'; + } + } + if (inList) html += ''; + return html; +} diff --git a/internal/web/static/js/ws.js b/internal/web/static/js/ws.js new file mode 100644 index 00000000..c451e53a --- /dev/null +++ b/internal/web/static/js/ws.js @@ -0,0 +1,61 @@ +import { state, CONFIG } from './state.js'; +import { addLivePost } from './utils.js'; + +// ── WebSocket ── +export function connectWebSocket() { + if (state.ws) { try { state.ws.close(); } catch(e) {} } + var ws = new WebSocket(CONFIG.WS_URL); + state.ws = ws; + ws.onopen = function() { state.wsReconnectAttempts = 0; }; + ws.onmessage = function(event) { + try { + var msg = JSON.parse(event.data); + var type = msg.type || 'unknown'; + var payload = msg.payload || {}; + var desc = ''; + switch (type) { + case 'raw_memory_created': desc = 'New memory from ' + (payload.source || '?'); break; + case 'memory_encoded': desc = 'Encoded: ' + (payload.summary || '').slice(0, 60); state.exploreLoaded.memories = false; break; + case 'memory_accessed': desc = 'Accessed ' + (payload.memory_ids || []).length + ' memories'; break; + 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'; 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; + case 'forum_post_created': desc = (payload.author_name || 'Someone') + ' posted in forum'; state.forumLoaded = false; break; + default: desc = type.replace(/_/g, ' '); + } + window.addEvent(type, desc, msg.timestamp || new Date().toISOString(), payload); + addLivePost(type, payload, msg.timestamp); + if (['memory_encoded', 'consolidation_completed', 'dream_cycle_completed'].includes(type) && state.currentView === 'timeline') { + clearTimeout(state._timelineLiveReload); + state._timelineLiveReload = setTimeout(function() { window.loadTimelineData(false); }, 5000); + } + // Live-refresh: reload the active explore tab if its data was just invalidated + if (state.currentView === 'explore') { + var tab = state.currentExploreTab; + if (!state.exploreLoaded[tab]) window.loadExploreTab(tab); + if (!state.forumLoaded) window.loadForumIndex(); + } + // Track forum notifications + if (type === 'forum_post_created') window.onForumPostWebSocket(payload); + // Live-insert forum post into current thread view + if (type === 'forum_post_created' && state.currentView === 'thread' && state.currentForumThread === payload.thread_id) { + window.appendForumPostToThread(payload); + } + // Refresh nav stats on memory-changing events + if (['memory_encoded', 'raw_memory_created', 'consolidation_completed'].includes(type)) window.loadStats(); + // Refresh LLM usage if viewing that tab + if (state.currentView === 'llm') window.loadLLMUsage(); + } catch (e) { console.error('Failed to parse WebSocket message:', e); } + }; + ws.onclose = function() { + var delay = Math.min(30000, 1000 * Math.pow(1.5, state.wsReconnectAttempts)); + state.wsReconnectAttempts++; + setTimeout(connectWebSocket, delay); + }; + ws.onerror = function() { ws.close(); }; +} + diff --git a/migrations/006_composite_indexes.sql b/migrations/006_composite_indexes.sql new file mode 100644 index 00000000..f1b1f032 --- /dev/null +++ b/migrations/006_composite_indexes.sql @@ -0,0 +1,13 @@ +-- Migration 006: Add composite indexes for common query patterns +-- +-- ListMemories: WHERE state = ? ORDER BY created_at DESC LIMIT ? +-- Currently uses idx_memory_state but still sorts 33K+ rows. +-- Composite index makes ORDER BY + LIMIT essentially free. + +CREATE INDEX IF NOT EXISTS idx_memories_state_created ON memories(state, created_at DESC); + +-- ListMemoriesByProject: WHERE project = ? AND state IN (...) ORDER BY timestamp DESC +CREATE INDEX IF NOT EXISTS idx_memories_project_state ON memories(project, state, timestamp DESC); + +-- Episode memory lookup: WHERE episode_id = ? +CREATE INDEX IF NOT EXISTS idx_memories_episode ON memories(episode_id) WHERE episode_id IS NOT NULL; diff --git a/scripts/forum-transform.py b/scripts/forum-transform.py new file mode 100644 index 00000000..960627aa --- /dev/null +++ b/scripts/forum-transform.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Transform the mnemonic dashboard to forum style. + +This script modifies internal/web/static/index.html in-place: +1. Replaces the nav bar HTML with forum-style top bar + navbar +2. Adds a forum footer status bar +3. Updates CSS class references in JS render functions +4. Updates the nav tab HTML +5. Adds external CSS links + +Run: python3 scripts/forum-transform.py +Then: make build && systemctl --user restart mnemonic +""" + +import re + +SRC = "internal/web/static/index.html" + +def transform(): + with open(SRC, "r") as f: + html = f.read() + + original_lines = html.count('\n') + print(f"Source: {original_lines} lines") + + # ═══════════════════════════════════════════ + # 1. Replace the nav bar HTML + # ═══════════════════════════════════════════ + + # Find the nav section in the body + old_nav_start = html.find('