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
+
+
+
+
+
+
+ mnemonic
+
+ Mind
+ Graph
+ Timeline
+ Recall
+
+ 77 mem · 3.6 MB
+
+
+
+
+
+
+
+
+
+
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%
+
+
+
+
+
+
+
+
+
+
← back
+
+ cleverness debt
+ /
+ Centralized 40+ hardcoded values...
+
+
+
+
+
constellation
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience 0.44
+
source mcp
+
connections 15
+
created Mar 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
Analysis of the 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.
+
+ agent
+ config
+ performance
+
+
+ 10:05 AM
+ ·
+ 85% salience
+
+
+
+
+
+
+
+
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
+
+
+
+
+
learning
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
+
+
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
+
+
+
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+
+
+
+
+
+
+
+
+
+
+
+
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%
+
+
+
+
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%
+
+
+
+
+
+
+
+
+
+
+ Recall
+
+
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
+
+
+
+
+
+
+
+
+
+
← Back
+
cleverness debt › Centralized 40+ hardcoded values...
+
+
+
ego graph renders here
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience 0.44
+
source mcp
+
connections 15
+
created Mar 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
+
+
+
+
+
+
+
+
+
+ mnemonic
+
+
+ Mind
+ Graph
+ Timeline
+ Recall
+
+
+ memories1,247
+ encoded98.2%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
+ Mar 21
+ 85%
+
+
agent config performance
+
+
+
+
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
+ Mar 21
+ 79%
+
+
python api planning
+
+
+
+
+
+
Back button fails to appear due to missing history push in neighbor click handler
+
+ Mar 21
+ 76%
+
+
javascript ui debugging
+
+
+
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
+ Mar 21
+ 66%
+
+
go retrieval performance
+
+
+
+
+
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
+ Mar 20
+ 52%
+
+
deployment documentation cli
+
+
+
+
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
+ Mar 20
+ 51%
+
+
ci deployment github
+
+
+
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+ Mar 21
+ 44%
+
+
refactoring configuration go
+
+
+
+
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+ Mar 21
+ 39%
+
+
llm benchmarking performance
+
+
+
+
+
+
+
+
+
+ ← Back
+ /
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
10:05 AM
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
mcp 85% agent, config, performance
+
+
+
+
+
9:42 AM
+
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
mcp 79% python, api, planning
+
+
+
+
+
9:18 AM
+
+
+
Back button fails to appear due to missing history push in neighbor click handler
+
mcp 76% javascript, ui, debugging
+
+
+
+
+
8:55 AM
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
mcp 66% go, retrieval, performance
+
+
+
+
+
8:31 AM
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
mcp 44% refactoring, configuration, go
+
+
+
+
+
+
+
+
+
4:22 PM
+
+
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
mcp 52% deployment, documentation, cli
+
+
+
+
+
3:10 PM
+
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
mcp 51% 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
+
mcp 39% llm, benchmarking, performance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
4 results · 12ms
+
+
+
+
0.92
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
insight agent, config, performance Mar 21
+
+
+
+
+
0.78
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
decision refactoring, configuration, go Mar 21
+
+
+
+
+
0.61
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
learning go, retrieval, performance Mar 21
+
+
+
+
+
0.43
+
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
insight llm, benchmarking, performance Mar 21
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+ 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%
+
+
+
+
Related memories
+
+
+
+
+
+
+
+
+
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 21 6 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 20 2 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
+
+
+
+
+
+
+
+
+
+
+
← back
+
+ cleverness debt › Centralized 40+ hardcoded values...
+
+
+
+
ego graph renders here
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience 0.44
+
source mcp
+
connections 15
+
created Mar 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
+
+
+
+
+
+
+
+
+
+
+ m n e m o n i c
+
+
+ Mind
+ Graph
+ Timeline
+ Recall
+
+
+
+
+
+
+
+
+
+
+
+
semantic search with spread activation
+
+
+
+
+
+
+
+
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
+
+ agent config performance architecture
+
+
+
+
+
+
+
+
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI over building a custom adapter
+
+ python api planning
+
+
+
+
+
+
Back button fails to appear due to missing history push in neighbor click handler — state management in the graph view needs a proper stack
+
+ javascript ui debugging
+
+
+
+
+
+
+
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
+ go retrieval performance
+
+
+
+
+
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
+ deployment documentation cli
+
+
+
+
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
+ ci deployment github
+
+
+
+
+
+
+
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+ refactoring configuration go
+
+
+
+
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+ llm benchmarking performance
+
+
+
+
+
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
+ python llm benchmarking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
+ mcp
+ 85%
+ agent · config · performance
+
+
+
+
+
+
+
+
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
+ mcp
+ 79%
+ python · api · planning
+
+
+
+
+
+
+
+
+
+
Back button fails to appear due to missing history push in neighbor click handler
+
+ mcp
+ 76%
+ javascript · ui · debugging
+
+
+
+
+
+
+
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
+ mcp
+ 66%
+ go · retrieval · performance
+
+
+
+
+
+
+
+
+
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
+ mcp
+ 52%
+ deployment · documentation · cli
+
+
+
+
+
+
+
+
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
+ mcp
+ 51%
+ ci · deployment · github
+
+
+
+
+
+
+
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+ mcp
+ 44%
+ refactoring · configuration · go
+
+
+
+
+
+
+
+
+
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
+ mcp
+ 38%
+ python · llm · benchmarking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+ agent config performance architecture
+
+
+
+
+
+
+
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
+
+
+ refactoring configuration go
+
+
+
+
+
+
+
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
+
+
+ go retrieval performance
+
+
+
+
+
+
+
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
+
+
+ llm benchmarking performance
+
+
+
+
+
+
+
+
+
+
\ 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 ↓
+
+
+
+
+
+
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
+
+ Forum
+ Thread
+ Timeline
+ Search
+
+
+ ● 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
+
+
+
+
+
+ Filter
+ New Memory
+
+
+
+
+
+
+
+
+
EP Mnemonic v0.31.0 Release and the Linux Watcher Race Condition Shipped PRs #296-#309. Race condition in watcher_other.go. race condition inotify pattern decay
+
6
+
3
+
+
+
+
+
EP System Audit and Recursive Self-Correction 50% encoding failure rate identified. Reinforced Principle 2 for diagnostic tools. system audit encoding failure meta-cognition
+
6
+
3
+
+
+
+
+
EP Mnemonic Version Management and Upgrade Downgraded to v0.29.1. Resolved training script conflicts. version control deployment
+
3
+
2
+
+
+
+
+
+
+
+
+
+
+
IN Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems agent · config · performance
+
85%
+
15
+
+
+
+
+
DE Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI python · api · planning
+
79%
+
8
+
+
+
+
+
IN Back button fails to appear due to missing history push in neighbor click handler javascript · ui · debugging
+
76%
+
4
+
+
+
+
+
LE Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval go · retrieval · performance
+
66%
+
6
+
+
+
+
+
LE Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml ci · deployment · github
+
51%
+
3
+
+
+
+
+
DE Centralized 40+ hardcoded values into config.yaml to improve system configurability refactoring · configuration · go
+
44%
+
12
+
+
+
+
+
IN Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax llm · benchmarking · performance
+
39%
+
5
+
+
+
+
+
DE Committed JSON repair and benchmark tools; parse rate rose from 78.3% to 98.1% python · llm · benchmarking
+
38%
+
7
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
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
+
+
+
+
Mnemonic's retrieval feedback is written to SQLite but never read by the retrieval agent's ranking
+
+
+
+
+
+
+
+
+
+
+ Today
+ Week
+ Month
+
+
+
Friday, March 21 6 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 20 2 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%
+
+
+
+
+
+
+ Search
+
+
4 results 12ms spread activation: 3 hops
+
+
+
.92
IN Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems agent · config · performance
85%
15
+
+
.78
DE Centralized 40+ hardcoded values into config.yaml to improve system configurability refactoring · configuration · go
44%
12
+
+
.61
LE Verified context_boost 30-minute decay window and distinguished it from activity_bonus go · retrieval · performance
66%
6
+
+
.43
IN Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans llm · benchmarking · performance
39%
5
+
+
+
+
+
+
+
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
+
+
+
+
+
+ Forum
+ Thread View
+ Timeline
+ Search
+
+
+ ● daemon online
+ 77 memories
+
+
+
+
+
+
+
+
+
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
+
+
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 condition inotify
+
6
+
3
+
+
+
+
+
+
+
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 audit encoding
+
6
+
3
+
+
+
+
+
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
+
+
+
+
+
+
+
+
Recent Memories
+
sorted by salience
+
+
Memory Sal Links Created
+
+
LE
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml ci deployment github
51%
3
+
+
DE
Centralized 40+ hardcoded values into config.yaml to improve configurability refactoring configuration go
44%
12
+
+
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans llm benchmarking
39%
5
+
+
DE
Committed JSON repair and benchmark tools; parse rate 78.3% → 98.1% python llm
38%
7
+
+
+
+
+
+
+
+
+
+
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 6 View 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.
+
agent config performance
+
+
↳ 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 6 View 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.
+
python api planning decision
+
+
+
+
+
+
Learning
+
Salience: 66%
+
Links: 6
+
Source: mcp
+
8:55 AM
+
+
+
Memory #3 of 6 View 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.
+
go retrieval performance
+
+
↳ Reinforces (temporal)
+
Mnemonic's retrieval feedback is written to SQLite but never read by the retrieval agent's ranking algorithm
+
+
+
+
+
+
+
+
+
+
Friday, March 21 6 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 20 2 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%
+
+
+
+
+
+
+
+ Search
+
+
4 results · 12ms · spread activation: 3 hops
+
Score Memory Sal Links Created
+
.92
IN
Critical audit found cleverness debt and hardcoded agent weights across 6+ subsystems
85%
15
+
.78
DE
Centralized 40+ hardcoded values into config.yaml to improve configurability
44%
12
+
.61
LE
Verified context_boost 30-minute decay window and distinguished from activity_bonus
66%
6
+
.43
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
39%
5
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+ All types
+ MCP only
+ Active
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent config performance
+
+
85%
+
15
+
10:05 AM
+
+
+
+
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
python api planning
+
+
79%
+
8
+
9:42 AM
+
+
+
+
+
+
Back button fails to appear due to missing history push in neighbor click handler
+
javascript ui debugging
+
+
76%
+
4
+
9:18 AM
+
+
+
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
go retrieval performance
+
+
66%
+
6
+
8:55 AM
+
+
+
+
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
ci deployment github
+
+
51%
+
3
+
3:10 PM
+
+
+
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
refactoring configuration go
+
+
44%
+
12
+
8:31 AM
+
+
+
+
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
llm benchmarking performance
+
+
39%
+
5
+
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%
+
7
+
1:12 PM
+
+
+
+
+
+
+
+
+ Today
+ Week
+ Month
+
+
+
Friday, March 21 6 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 20 2 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%
+
+
+
+
+
+
+ Recall
+
+
+ 4 results
+ 12ms
+ 3 hops
+
+
+
+
+
+
.92
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems agent config performance
+
85%
+
15
+
10:05 AM
+
+
+
+
.78
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability refactoring configuration go
+
44%
+
12
+
8:31 AM
+
+
+
+
.61
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval go retrieval performance
+
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 llm benchmarking performance
+
39%
+
5
+
1:45 PM
+
+
+
+
+
+
+
← Back
+
cleverness debt › Centralized 40+ hardcoded values...
+
+
+
ego graph
+
+
DECISION
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience 0.44
+
source mcp
+
connections 15
+
created Mar 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
+
+ mind
+ graph
+ timeline
+ recall
+
+
+
+ 77 memories · 3.6 mb
+
+
+
+
+
+
+
+
+
+
+ ← back
+ 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
+
salience 0.44
+
source mcp
+
connections 15
+
created Mar 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
-
+
+
-
-
-
-
-
- Recall
-
-
-
- Explore
-
-
-
- Timeline
-
-
-
- Mind
-
-
-
- SDK
-
-
-
- LLM
-
-
-
- Tools
-
+
+
mnemonic
+
+
+ Midnight
+ Ember
+ Nord
+ Slate
+ Parchment
+
+
+
+
+
+
-
-
-
- memories
-
- tokens
-
+
+
+ Search
+ Forum
+ Timeline
+ SDK
+ LLM
+ Tools
+
+
+
+ memories
+
-
- Midnight
- Ember
- Nord
- Slate
- Parchment
-
-
-
- 0
-
-
-
-
-
+
+
@@ -1509,23 +1162,60 @@
What do you remember?
-
+