From 6145e9ac95a2a2ea1cea9c94f8e33b9d60ec3f5d Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 20:46:02 -0400 Subject: [PATCH 01/74] feat: scaffold modular dashboard structure (Phase 0) Change go:embed directive from `static/*` to `static` to support recursive subdirectory embedding. Create css/, css/pages/, js/, js/pages/ directories for the ES module migration. Part of #342 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/server.go | 2 +- internal/web/static/css/.gitkeep | 0 internal/web/static/css/pages/.gitkeep | 0 internal/web/static/js/.gitkeep | 0 internal/web/static/js/pages/.gitkeep | 0 5 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 internal/web/static/css/.gitkeep create mode 100644 internal/web/static/css/pages/.gitkeep create mode 100644 internal/web/static/js/.gitkeep create mode 100644 internal/web/static/js/pages/.gitkeep 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/.gitkeep b/internal/web/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/web/static/css/pages/.gitkeep b/internal/web/static/css/pages/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/web/static/js/.gitkeep b/internal/web/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/web/static/js/pages/.gitkeep b/internal/web/static/js/pages/.gitkeep new file mode 100644 index 00000000..e69de29b From 354570de76d984facf1ef1ed8b244a4cd543ca29 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 20:53:46 -0400 Subject: [PATCH 02/74] feat: extract CSS tokens, remove D3 CDN, archive mockups (Phase 1) - Extract 5 theme definitions to css/tokens.css with forum-specific semantic variables (--bg-row, --link, --decision, --insight, etc.) - Remove D3.js CDN script tag (breaks air-gap, replaced in Phase 4) - Archive 10 design mockups, keep design-forum-v3.html as reference - Add between-phases recall rule to mnemonic-usage.md - index.html drops from 5829 to 5704 lines Part of #343 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/rules/mnemonic-usage.md | 7 + internal/web/mockups/archive/design-a.html | 730 ++++++++ .../web/mockups/archive/design-adaline.html | 854 ++++++++++ internal/web/mockups/archive/design-b.html | 846 ++++++++++ .../mockups/archive/design-broadsheet.html | 900 ++++++++++ internal/web/mockups/archive/design-c.html | 1483 +++++++++++++++++ .../web/mockups/archive/design-drift.html | 574 +++++++ .../web/mockups/archive/design-final.html | 407 +++++ .../web/mockups/archive/design-forum-v2.html | 763 +++++++++ .../web/mockups/archive/design-forum.html | 816 +++++++++ internal/web/mockups/archive/design-wild.html | 1019 +++++++++++ internal/web/mockups/design-forum-v3.html | 790 +++++++++ internal/web/static/css/tokens.css | 216 +++ internal/web/static/index.html | 127 +- 14 files changed, 9406 insertions(+), 126 deletions(-) create mode 100644 internal/web/mockups/archive/design-a.html create mode 100644 internal/web/mockups/archive/design-adaline.html create mode 100644 internal/web/mockups/archive/design-b.html create mode 100644 internal/web/mockups/archive/design-broadsheet.html create mode 100644 internal/web/mockups/archive/design-c.html create mode 100644 internal/web/mockups/archive/design-drift.html create mode 100644 internal/web/mockups/archive/design-final.html create mode 100644 internal/web/mockups/archive/design-forum-v2.html create mode 100644 internal/web/mockups/archive/design-forum.html create mode 100644 internal/web/mockups/archive/design-wild.html create mode 100644 internal/web/mockups/design-forum-v3.html create mode 100644 internal/web/static/css/tokens.css 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/internal/web/mockups/archive/design-a.html b/internal/web/mockups/archive/design-a.html new file mode 100644 index 00000000..fae49f32 --- /dev/null +++ b/internal/web/mockups/archive/design-a.html @@ -0,0 +1,730 @@ + + + + + +mnemonic — Focus + + + + + + + + +
+ +
+
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+ +
+ +
+
decision
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+ +
+ +
+
insight
+
Back button fails to appear due to missing history push in neighbor click handler
+ +
+ +
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+ +
+ +
+
insight
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+ +
+ +
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+ +
+ +
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+ +
+ +
+
decision
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+ +
+
+
+ + +
+
+
+ +
+ cleverness debt + / + Centralized 40+ hardcoded values... +
+
+
+
+
constellation
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+
+ + +
+
+
Today, March 21
+
+
+
10:05 AM
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
insight · agent, config, performance · 85%
+
+
+
9:42 AM
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
decision · python, api, planning · 79%
+
+
+
9:18 AM
+
+
Back button fails to appear due to missing history push in neighbor click handler
+
insight · javascript, ui, debugging · 76%
+
+
+
8:55 AM
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
learning · go, retrieval, performance · 66%
+
+
+
8:31 AM
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
decision · refactoring, config, go · 44%
+
+
+
+
+ + +
+
+ +
4 results · 12ms · spread activation: 3 hops
+ +
+
.92
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · Mar 21
+
+ +
+
.78
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
refactoring · configuration · go · Mar 21
+
+ +
+
.61
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
go · retrieval · performance · Mar 21
+
+ +
+
.43
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
llm · benchmarking · performance · Mar 21
+
+
+
+ + + + diff --git a/internal/web/mockups/archive/design-adaline.html b/internal/web/mockups/archive/design-adaline.html new file mode 100644 index 00000000..9b294d5f --- /dev/null +++ b/internal/web/mockups/archive/design-adaline.html @@ -0,0 +1,854 @@ + + + + + +mnemonic + + + + + +
+ +
+ + +
+
+
+
Recent memories
+ +
+ + + +
+
+
decision
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
+
+ python + api + planning +
+ 79% +
+
+ +
+
insight
+
Back button fails to appear due to missing history push in neighbor click handler
+
+
+ javascript + ui + debugging +
+ 76% +
+
+ +
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
+
+ go + retrieval + performance +
+ 66% +
+
+ +
+
learning
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
+
+ ci + deployment + github +
+ 51% +
+
+ +
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
+
+ refactoring + go +
+ 44% +
+
+ +
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+
+ llm + benchmarking +
+ 39% +
+
+
+
+
+ + +
+
+
+
Friday, March 216 memories
+ +
10:05
insight
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agent · config · performance · 85%
+ +
9:42
decision
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
python · api · planning · 79%
+ +
9:18
insight
Back button fails to appear due to missing history push in neighbor click handler
javascript · ui · debugging · 76%
+ +
8:55
learning
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
go · retrieval · performance · 66%
+ +
8:31
decision
Centralized 40+ hardcoded values into config.yaml to improve system configurability
refactoring · configuration · go · 44%
+ +
Thursday, March 202 memories
+ +
3:10 PM
learning
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
ci · deployment · github · 51%
+ +
1:12 PM
decision
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
python · llm · benchmarking · 38%
+
+
+
+ + +
+
+
+
+ + +
+
4 results · 12ms · 3 hops
+ +
insight
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agent · config · performance · Mar 21
.92
+ +
decision
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
refactoring · configuration · go · Mar 21
.78
+ +
learning
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
go · retrieval · performance · Mar 21
.61
+ +
insight
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
llm · benchmarking · performance · Mar 21
.43
+
+
+
+ + +
+
+
+
+ +
cleverness debt › Centralized 40+ hardcoded values...
+
+
+
ego graph renders here
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+
+
+ + + + diff --git a/internal/web/mockups/archive/design-b.html b/internal/web/mockups/archive/design-b.html new file mode 100644 index 00000000..7d135313 --- /dev/null +++ b/internal/web/mockups/archive/design-b.html @@ -0,0 +1,846 @@ + + + + + +mnemonic — Design B: Structural + + + + + + + + + +
+
+ + +
+ +
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+ +
agentconfigperformance
+
+ + +
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+ +
pythonapiplanning
+
+ + +
+
+
Back button fails to appear due to missing history push in neighbor click handler
+ +
javascriptuidebugging
+
+ + +
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+ +
goretrievalperformance
+
+ + +
+
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+ +
deploymentdocumentationcli
+
+ + +
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+ +
cideploymentgithub
+
+ + +
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+ +
refactoringconfigurationgo
+
+ + +
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+ +
llmbenchmarkingperformance
+
+
+
+
+ + +
+
+
+ + / + cleverness debt audit + / + Centralized 40+ hardcoded values... +
+ +
+
+
+ +
+
D3 force-directed ego graph renders here
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
+ Type + decision +
+
+ Salience + 0.44 +
+
+ Source + mcp +
+
+ Connections + 15 +
+
+ Created + Mar 21, 2026 +
+
+ Last accessed + 2h ago +
+
+ Concepts + refactoring, config, go +
+
+
+
+
+ + +
+
+
+
Today5
+ +
+
10:05 AM
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
mcp85%agent, config, performance
+
+
+ +
+
9:42 AM
+
+
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
mcp79%python, api, planning
+
+
+ +
+
9:18 AM
+
+
+
Back button fails to appear due to missing history push in neighbor click handler
+
mcp76%javascript, ui, debugging
+
+
+ +
+
8:55 AM
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
mcp66%go, retrieval, performance
+
+
+ +
+
8:31 AM
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
mcp44%refactoring, configuration, go
+
+
+
+ +
+
Yesterday3
+ +
+
4:22 PM
+
+
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
mcp52%deployment, documentation, cli
+
+
+ +
+
3:10 PM
+
+
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
mcp51%ci, deployment, github
+
+
+ +
+
1:45 PM
+
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
mcp39%llm, benchmarking, performance
+
+
+
+
+
+ + +
+
+ + +
4 results · 12ms
+ +
+
+
0.92
+
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+ +
+
+ +
+
0.78
+
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+ +
+
+ +
+
0.61
+
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+ +
+
+ +
+
0.43
+
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+ +
+
+
+
+
+ + + + diff --git a/internal/web/mockups/archive/design-broadsheet.html b/internal/web/mockups/archive/design-broadsheet.html new file mode 100644 index 00000000..85171449 --- /dev/null +++ b/internal/web/mockups/archive/design-broadsheet.html @@ -0,0 +1,900 @@ + + + + + +mnemonic + + + + + +
+
+
mnemonic
+
+ + + + +
+
77 memories · 3.6 MB
+
+
+ + +
+
+
+ SATURDAY, MARCH 21, 2026 + +
+ +
+ +
+
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
A deep analysis of Mnemonic's agent architecture revealed fundamental asymmetries in how the system compounds knowledge. Retrieval quality improves through Hebbian learning, but consolidation artifacts remain fragile under aggressive grounding verification.
+
+ 10:05 AM +
agent config performance
+ 85% +
+
+ +
+ + +
+
decision
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
After comparing direct DB writes, a custom REST layer, and upstream contribution, the RIMAPI path won on maintainability and community leverage despite higher initial effort.
+
+ 9:42 AM +
python api planning
+ 79% +
+
+ +
+ + +
+
insight
+
Back button fails to appear due to missing history push in neighbor click handler
+
+ 9:18 AM +
javascript ui
+ 76% +
+
+ + +
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus
+
+ 8:55 AM +
go retrieval
+ 66% +
+
+ +
+ +
+
learning
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
+ Mar 20 +
ci deployment
+ 51% +
+
+ +
+ +
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve configurability
+
+ 8:31 AM +
refactoring go
+ 44% +
+
+ + +
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
+
+ 1:45 PM +
llm benchmarking
+ 39% +
+
+ +
+ +
+
decision
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
+ Mar 20 +
python llm fix
+ 38% +
+
+
+
+
+ + +
+
+
+
FRIDAY, MARCH 216 memories
+
+
10:05
+
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · 85%
+
+
+
+
9:42
+
+
decision
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
python · api · planning · 79%
+
+
+
+
9:18
+
+
insight
+
Back button fails to appear due to missing history push in neighbor click handler
+
javascript · ui · debugging · 76%
+
+
+
+
8:55
+
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
go · retrieval · performance · 66%
+
+
+
+
8:31
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
refactoring · configuration · go · 44%
+
+
+ +
THURSDAY, MARCH 202 memories
+
+
3:10 PM
+
+
learning
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
ci · deployment · github · 51%
+
+
+
+
1:12 PM
+
+
decision
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
python · llm · benchmarking · 38%
+
+
+
+
+
+ + +
+
+
+
What do you remember about...
+ +
4 results · 12ms · spread activation: 3 hops
+ +
+
.92
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · Mar 21 10:05
+
+ +
+
.78
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
refactoring · configuration · go · Mar 21 8:31
+
+ +
+
.61
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
go · retrieval · performance · Mar 21 8:55
+
+ +
+
.43
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
llm · benchmarking · performance · Mar 21 1:45 PM
+
+
+
+
+ + +
+
+
+
+ +
+ cleverness debt › Centralized 40+ hardcoded values... +
+
+
+
ego graph renders here
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+
+
+ + + + diff --git a/internal/web/mockups/archive/design-c.html b/internal/web/mockups/archive/design-c.html new file mode 100644 index 00000000..7c266a59 --- /dev/null +++ b/internal/web/mockups/archive/design-c.html @@ -0,0 +1,1483 @@ + + + + + +mnemonic — Design C: Kinetic + + + + + + + + + + +
+
+
+
+ +
semantic search with spread activation
+
+ + +
+ +
+ + +
+
+
+
+
+
+ decision + Mar 21 +
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI over building a custom adapter
+
+ pythonapiplanning +
+
+
+
+
+
+
+ insight + Mar 21 +
+
Back button fails to appear due to missing history push in neighbor click handler — state management in the graph view needs a proper stack
+
+ javascriptuidebugging +
+
+
+ + +
+
+
+
+
+
+ learning + Mar 21 +
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
+ goretrievalperformance +
+
+
+
+
+
+
+ insight + Mar 20 +
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
+ deploymentdocumentationcli +
+
+
+
+
+
+
+ learning + Mar 20 +
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
+ cideploymentgithub +
+
+
+ + +
+
+
+
+
+
+ decision + Mar 21 +
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+ refactoringconfigurationgo +
+
+
+
+
+
+
+ insight + Mar 21 +
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+ llmbenchmarkingperformance +
+
+
+
+
+
+
+ decision + Mar 20 +
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
+ pythonllmbenchmarking +
+
+
+
+
+ + +
+
+
+ +
+ cleverness debt audit + / + Centralized 40+ hardcoded values... +
+
+
+
+ +
+ + + + + + + + + + + + + + +
ego
+ + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+
+ Type + decision +
+
+ Salience + 0.44 +
+
+ Source + mcp +
+
+ Connections + 15 +
+
+ Created + Mar 21, 2026 +
+
+ Concepts + refactoring, config, go +
+
+
+
+
+
+ + +
+
+
+ Today + 8 memories +
+ +
+ +
+
+
+
+ insight + 10:05 AM +
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
+ mcp + 85% + agent · config · performance +
+
+
+ + +
+
+
+
+ decision + 9:42 AM +
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
+ mcp + 79% + python · api · planning +
+
+
+ + +
+
+
+
+ insight + 9:18 AM +
+
Back button fails to appear due to missing history push in neighbor click handler
+
+ mcp + 76% + javascript · ui · debugging +
+
+
+ + +
+
+
+
+ learning + 8:55 AM +
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
+ mcp + 66% + go · retrieval · performance +
+
+
+ + +
+
+
+
+ insight + 8:31 AM +
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
+ mcp + 52% + deployment · documentation · cli +
+
+
+ + +
+
+
+
+ learning + 8:12 AM +
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers
+
+ mcp + 51% + ci · deployment · github +
+
+
+ + +
+
+
+
+ decision + 7:48 AM +
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+ mcp + 44% + refactoring · configuration · go +
+
+
+ + +
+
+
+
+ decision + 7:20 AM +
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
+ mcp + 38% + python · llm · benchmarking +
+
+
+
+
+
+ + +
+
+
+
+ +
+ +
+ 4 results + 12ms +
+ +
+
+
+
0.92
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+ insight + +
+
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems — perception, consolidation, and dreaming agents all using magic numbers that should be configurable
+
+
+ Salience + 85% +
+
+ Source + mcp +
+
+ Created + Mar 21, 2026 +
+
+ Associations + 12 +
+
+
+ agentconfigperformancearchitecture +
+
+
+ +
+
+
0.78
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+ decision + +
+
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+
+ Salience + 44% +
+
+ Source + mcp +
+
+ Created + Mar 21, 2026 +
+
+ Associations + 15 +
+
+
+ refactoringconfigurationgo +
+
+
+ +
+
+
0.61
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+ learning + +
+
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
+
+ Salience + 66% +
+
+ Source + mcp +
+
+ Created + Mar 21, 2026 +
+
+ Associations + 8 +
+
+
+ goretrievalperformance +
+
+
+ +
+
+
0.43
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+ insight + +
+
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
+
+ Salience + 39% +
+
+ Source + mcp +
+
+ Created + Mar 21, 2026 +
+
+ Associations + 5 +
+
+
+ llmbenchmarkingperformance +
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/internal/web/mockups/archive/design-drift.html b/internal/web/mockups/archive/design-drift.html new file mode 100644 index 00000000..d09ea1d1 --- /dev/null +++ b/internal/web/mockups/archive/design-drift.html @@ -0,0 +1,574 @@ + + + + + +mnemonic + + + + + + +
+
mnemonic
+
+
drift
+
timeline
+
cluster
+
+
77 memories
+
+ +
+ +
+ +
+ +
+
+
+
+ +
move your mouse to explore
+ + + + + + diff --git a/internal/web/mockups/archive/design-final.html b/internal/web/mockups/archive/design-final.html new file mode 100644 index 00000000..d0dba11a --- /dev/null +++ b/internal/web/mockups/archive/design-final.html @@ -0,0 +1,407 @@ + + + + + +mnemonic + + + + + +
mnemonic
+ +
+
+
+
+ +
scroll to explore ↓
+ +
+ + +
+
+
+ Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems +
+
+
typeinsight
+
salience0.85
+
when10:05 AM
+
conceptsagent, config, performance
+
+
+
+ +
+
+
+ Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI +
+
+
typedecision
+
salience0.79
+
when9:42 AM
+
conceptspython, api, planning
+
+
+
+ +
+
+
+ Back button fails to appear due to missing history push in neighbor click handler +
+
+
typeinsight
+
salience0.76
+
when9:18 AM
+
conceptsjavascript, ui, debugging
+
+
+
+ +
+
+
+ Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval +
+
+
typelearning
+
salience0.66
+
when8:55 AM
+
conceptsgo, retrieval, performance
+
+
+
+ +
+
+
+ Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml for manual triggers +
+
+
typelearning
+
salience0.51
+
when3:10 PM
+
conceptsci, deployment, github
+
+
+
+ +
+
+
+ Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability +
+
+
typedecision
+
salience0.44
+
when8:31 AM
+
conceptsrefactoring, configuration, go
+
+
+
+ +
+
+
+ Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors +
+
+
typeinsight
+
salience0.39
+
when1:45 PM
+
conceptsllm, benchmarking, performance
+
+
+
+ +
+
+
+ Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1% +
+
+
typedecision
+
salience0.38
+
when1:12 PM
+
conceptspython, llm, fix
+
+
+
+ +
+ + + + diff --git a/internal/web/mockups/archive/design-forum-v2.html b/internal/web/mockups/archive/design-forum-v2.html new file mode 100644 index 00000000..1317b085 --- /dev/null +++ b/internal/web/mockups/archive/design-forum-v2.html @@ -0,0 +1,763 @@ + + + + + +mnemonic — vBulletin + + + + +
+
mnemonic
+
+ + + + +
+
+ ● online + 77 memories + v0.33.0 +
+
+ + +
+
+ Welcome back. Last visit: Today at 08:14 AM +
+ 77 active + 8 fading + 27 archived + 3 new since last visit +
+
+ +
+ + + +
+ + +
+
+
Episodes
+
3 episodes today
+
+
+ Episode + Mem + Files + Last Activity +
+ +
+
EP
Mnemonic v0.31.0 Release and the Linux Watcher Race Condition
Shipped PRs #296-#309. Race condition in watcher_other.go. race conditioninotifypattern decay
+
6
+
3
+
08:55 AM
satisfying
+
+ +
+
EP
System Audit and Recursive Self-Correction
50% encoding failure rate identified. Reinforced Principle 2 for diagnostic tools. system auditencoding failuremeta-cognition
+
6
+
3
+
10:05 AM
satisfying
+
+ +
+
EP
Mnemonic Version Management and Upgrade
Downgraded to v0.29.1. Resolved training script conflicts. version controldeployment
+
3
+
2
+
02:02 AM
neutral
+
+
+ + +
+
+
Recent Memories
+
8 memories · sorted by salience
+
+
+ Memory + Sal + Links + Created +
+ +
+
IN
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agent · config · performance
+
85%
+
15
+
10:05 AM
via mcp
+
+ +
+
DE
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
python · api · planning
+
79%
+
8
+
9:42 AM
via mcp
+
+ +
+
IN
Back button fails to appear due to missing history push in neighbor click handler
javascript · ui · debugging
+
76%
+
4
+
9:18 AM
via mcp
+
+ +
+
LE
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
go · retrieval · performance
+
66%
+
6
+
8:55 AM
via mcp
+
+ +
+
LE
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
ci · deployment · github
+
51%
+
3
+
3:10 PM
via mcp
+
+ +
+
DE
Centralized 40+ hardcoded values into config.yaml to improve system configurability
refactoring · configuration · go
+
44%
+
12
+
8:31 AM
via mcp
+
+ +
+
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax
llm · benchmarking · performance
+
39%
+
5
+
1:45 PM
via mcp
+
+ +
+
DE
Committed JSON repair and benchmark tools; parse rate rose from 78.3% to 98.1%
python · llm · benchmarking
+
38%
+
7
+
1:12 PM
via mcp
+
+
+
+ + +
+
+
+
Mnemonic v0.31.0 Release and the Linux Watcher Race Condition
+
+
mood: satisfying
+
duration: 8:55 – 9:04 AM
+
memories: 6
+
files: config.yaml, CLAUDE.md, extract.go
+
+
+ + +
+ +
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems. Analysis revealed fundamental asymmetries in how the system compounds knowledge.
+
+ agent + config + performance +
+
+
↳ Associated: decision (similarity: 0.82)
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+
+
+ +
+ +
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI (v1.8.2). Option analysis: direct writes (fast, no upstream), adapter pattern (medium, partial upstream), and full contribution (slow, sustainable).
+
+ python + api + planning + decision +
+
+
+ +
+ +
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval. The decay uses exponential falloff with a 1800-second half-life.
+
+ go + retrieval + performance +
+
+
↳ Reinforces: insight (temporal)
+
Mnemonic's retrieval feedback is written to SQLite but never read by the retrieval agent's ranking
+
+
+
+
+
+ + +
+
+ + + + +
+ +
Friday, March 216 memories
+
10:05
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
85%
+
9:42
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
79%
+
9:18
Back button fails to appear due to missing history push in neighbor click handler
76%
+
8:55
Verified context_boost 30-minute decay window and distinguished it from activity_bonus
66%
+
8:31
Centralized 40+ hardcoded values into config.yaml to improve system configurability
44%
+ +
Thursday, March 202 memories
+
3:10 PM
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
51%
+
1:12 PM
Committed JSON repair and benchmark tools; parse rate rose from 78.3% to 98.1%
38%
+
+ + +
+
+ + +
+
4 results12msspread activation: 3 hops
+
ScoreMemorySalLinksCreated
+ +
.92
IN
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agent · config · performance
85%
15
10:05 AM
+ +
.78
DE
Centralized 40+ hardcoded values into config.yaml to improve system configurability
refactoring · configuration · go
44%
12
8:31 AM
+ +
.61
LE
Verified context_boost 30-minute decay window and distinguished it from activity_bonus
go · retrieval · performance
66%
6
8:55 AM
+ +
.43
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
llm · benchmarking · performance
39%
5
1:45 PM
+
+ +
+ mnemonic v0.33.0 + 77 active + 8 fading + 27 archived + encoding: idle + consolidation: 16:31 +
+ + + + diff --git a/internal/web/mockups/archive/design-forum.html b/internal/web/mockups/archive/design-forum.html new file mode 100644 index 00000000..ca60ffc3 --- /dev/null +++ b/internal/web/mockups/archive/design-forum.html @@ -0,0 +1,816 @@ + + + + + +mnemonic + + + + + +
+ + +
+ + 77 memories + 3.6 MB + v0.33.0 +
+
+ + +
+ + + + + +
+
+ Episodes + Memories + Files + Last Activity +
+ +
+
+ EPISODE + Mnemonic v0.31.0 Release and the Linux Watcher Race Condition + satisfying +
Shipped PRs #296-#309. Hit a wall when the Linux filesystem watcher failed to deliver events after daemon restarts. Root cause: synchronization failure in watcher_other.go.
+
+
6
+
3
+
08:55 AM
+
+ +
+
+ EPISODE + System Audit and Recursive Self-Correction + satisfying +
Comprehensive health audit identified 50% encoding failure rate. AI realized it stopped at speculation instead of investigating root cause in logs.
+
+
6
+
3
+
10:05 AM
+
+ +
+
+ EPISODE + Mnemonic Version Management and Upgrade + neutral +
System downgraded to v0.29.1, likely a rollback to stable state. Resolved training script conflicts.
+
+
3
+
2
+
02:02 AM
+
+
+ + +
+
+ Recent Memories + Salience + Links + Created +
+ +
+
+ + Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems +
agentconfigperformance
+
+
85%
+
15
+
10:05 AM
+
+ +
+
+ + Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI +
pythonapiplanning
+
+
79%
+
8
+
9:42 AM
+
+ +
+
+ + Back button fails to appear due to missing history push in neighbor click handler +
javascriptuidebugging
+
+
76%
+
4
+
9:18 AM
+
+ +
+
+ + Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval +
goretrievalperformance
+
+
66%
+
6
+
8:55 AM
+
+ +
+
+ + Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml +
cideploymentgithub
+
+
51%
+
3
+
3:10 PM
+
+ +
+
+ + Centralized 40+ hardcoded values into config.yaml to improve system configurability +
refactoringconfigurationgo
+
+
44%
+
12
+
8:31 AM
+
+ +
+
+ + Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors +
llmbenchmarkingperformance
+
+
39%
+
5
+
1:45 PM
+
+ +
+
+ + Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1% +
pythonllmbenchmarking
+
+
38%
+
7
+
1:12 PM
+
+
+
+ + +
+ + +
Friday, March 216 memories
+ +
10:05
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
85%
+
9:42
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
79%
+
9:18
Back button fails to appear due to missing history push in neighbor click handler
76%
+
8:55
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
66%
+
8:31
Centralized 40+ hardcoded values into config.yaml to improve system configurability
44%
+ +
Thursday, March 202 memories
+ +
3:10 PM
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
51%
+
1:12 PM
Committed JSON repair and benchmark tools; parse rate rose from 78.3% to 98.1%
38%
+
+ + +
+ +
+ 4 results + 12ms + 3 hops +
+ +
+ Score + Memory + Salience + Links + Created +
+ +
+
.92
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
agentconfigperformance
+
85%
+
15
+
10:05 AM
+
+ +
+
.78
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
refactoringconfigurationgo
+
44%
+
12
+
8:31 AM
+
+ +
+
.61
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
goretrievalperformance
+
66%
+
6
+
8:55 AM
+
+ +
+
.43
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
llmbenchmarkingperformance
+
39%
+
5
+
1:45 PM
+
+
+ + +
+
+ +
cleverness debt › Centralized 40+ hardcoded values...
+
+
+
ego graph
+
+
DECISION
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+ + + + + + + diff --git a/internal/web/mockups/archive/design-wild.html b/internal/web/mockups/archive/design-wild.html new file mode 100644 index 00000000..efd938d8 --- /dev/null +++ b/internal/web/mockups/archive/design-wild.html @@ -0,0 +1,1019 @@ + + + + + +mnemonic + + + + + + + +
+
mnemonic
+
+ + + + +
+
+ + 77 memories · 3.6 mb +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + cleverness debt + / + Centralized 40+ hardcoded values... +
+
+
+ +
+
+ DEC +
+
Centralized 40+ hardcoded values into config.yaml
+
+ +
+ + +
+
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
salience0.44
+
sourcemcp
+
connections15
+
createdMar 21, 2026
+
+
+
+ + +
+
+
+
today · march 21
+ +
+
10:05 AM
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · 85%
+
+ +
+
9:42 AM
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI
+
python · api · planning · 79%
+
+ +
+
9:18 AM
+
Back button fails to appear due to missing history push in neighbor click handler
+
javascript · ui · debugging · 76%
+
+ +
+
8:55 AM
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval
+
go · retrieval · performance · 66%
+
+ +
+
8:31 AM
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability
+
refactoring · configuration · go · 44%
+
+ +
yesterday · march 20
+ +
+
4:22 PM
+
Mnemonic v0.30.0 release adds pattern scoping, bulk forget, and handoff tools across PRs #310-#314
+
deployment · documentation · cli · 52%
+
+ +
+
3:10 PM
+
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
+
ci · deployment · github · 51%
+
+ +
+
1:45 PM
+
Committed JSON repair and benchmark tools; Qwen3.5-0.8B parse rate rose from 78.3% to 98.1%
+
python · llm · benchmarking · 38%
+
+
+
+
+ + +
+
+
+
+ +
4 results · 12ms · 3 hops spread activation
+
+ +
+
.92
+
insight
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems
+
agent · config · performance · Mar 21
+
+ +
+
.78
+
decision
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
refactoring · configuration · go · Mar 21
+
+ +
+
.61
+
learning
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval scoring
+
go · retrieval · performance · Mar 21
+
+ +
+
.43
+
insight
+
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans; 2B failed on syntax errors
+
llm · benchmarking · performance · Mar 21
+
+
+
+
+ + + + diff --git a/internal/web/mockups/design-forum-v3.html b/internal/web/mockups/design-forum-v3.html new file mode 100644 index 00000000..43ca2a1c --- /dev/null +++ b/internal/web/mockups/design-forum-v3.html @@ -0,0 +1,790 @@ + + + + + +mnemonic + + + + +
+
mnemonic v0.33.0 — cognitive memory daemon
+ +
+ + + + +
+
mnemonic mnemonic (project)
+ +
+
Welcome Back
+
+ Last visit: Today at 08:14 AM · 3 new memories since then +
+ 77 active + 8 fading + 27 archived + 9 merged +
+
+
+ + +
+
+
Episodes
+
3 episodes today
+
+
EpisodeMemFilesLast Activity
+ +
+
EP
+
Mnemonic v0.31.0 Release and the Linux Watcher Race Condition
Shipped PRs #296-#309. Race condition in watcher_other.go race conditioninotify
+
6
+
3
+
08:55 AM
satisfying
+
+ + +
+
6 memories in this episodeView full thread →
+
IN
Critical audit found cleverness debt and hardcoded agent weights across 6+ subsystems
85%
10:05
+
DE
Evaluated 3 options for RLE write endpoints; recommended upstream RIMAPI
79%
9:42
+
IN
Back button fails to appear due to missing history push in neighbor click handler
76%
9:18
+
LE
Verified context_boost 30-minute decay window and distinguished from activity_bonus
66%
8:55
+
+ +
+
EP
+
System Audit and Recursive Self-Correction
50% encoding failure rate. Reinforced Principle 2. system auditencoding
+
6
+
3
+
10:05 AM
satisfying
+
+
+
6 memories in this episodeView full thread →
+
IN
Audit identified 50% encoding failure rate; missed opportunity for root cause analysis
85%
10:04
+
DE
Reinforced principle p2: prefer diagnostic tools over recall for audits
80%
10:04
+
LE
Created structured strategies for system audits, config reviews, and self-improvement
90%
10:05
+
+ +
+
EP
+
Mnemonic Version Management and Upgrade
Downgraded to v0.29.1. Training script conflicts. deployment
+
3
+
2
+
02:02 AM
neutral
+
+
+ + +
+
+
Recent Memories
+
sorted by salience
+
+
MemorySalLinksCreated
+ +
LE
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
cideploymentgithub
51%
3
3:10 PM
mcp
+ +
DE
Centralized 40+ hardcoded values into config.yaml to improve configurability
refactoringconfigurationgo
44%
12
8:31 AM
mcp
+ +
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
llmbenchmarking
39%
5
1:45 PM
mcp
+ +
DE
Committed JSON repair and benchmark tools; parse rate 78.3% → 98.1%
pythonllm
38%
7
1:12 PM
mcp
+
+
+ + +
+
mnemonic Episodes Mnemonic v0.31.0 Release
+ +
+
+
Mnemonic v0.31.0 Release and the Linux Watcher Race Condition
+
+ Mood: satisfying + Duration: 8:55 – 9:04 AM + Memories: 6 + Files: config.yaml, CLAUDE.md, extract.go +
+
+ +
+
+
Insight
+
Salience: 85%
+
Links: 15
+
Source: mcp
+
10:05 AM
+
+
+
Memory #1 of 6View in Graph
+
Critical audit found significant cleverness debt and hardcoded agent weights across 6+ subsystems. Analysis revealed fundamental asymmetries in how the system compounds knowledge. Retrieval quality improves through Hebbian learning, but consolidation artifacts remain fragile.
+ +
+
↳ Associated Memory (decision · similarity: 0.82)
+
Centralized 40+ hardcoded values into config.yaml to improve system configurability and maintainability
+
+
+
+ +
+
+
Decision
+
Salience: 79%
+
Links: 8
+
Source: mcp
+
9:42 AM
+
+
+
Memory #2 of 6View in Graph
+
Evaluated 3 options for RLE write endpoints; recommended contributing to upstream RIMAPI (v1.8.2). Option analysis: direct writes (fast, no upstream), adapter pattern (medium), and full contribution (slow, sustainable). Chose full contribution for long-term maintainability.
+ +
+
+ +
+
+
Learning
+
Salience: 66%
+
Links: 6
+
Source: mcp
+
8:55 AM
+
+
+
Memory #3 of 6View in Graph
+
Verified context_boost 30-minute decay window and distinguished it from activity_bonus in retrieval. The decay uses exponential falloff with a 1800-second half-life, applied in the spread activation scoring pipeline.
+ +
+
↳ Reinforces (temporal)
+
Mnemonic's retrieval feedback is written to SQLite but never read by the retrieval agent's ranking algorithm
+
+
+
+
+
+ + +
+
mnemonic Timeline
+
Friday, March 216 memories
+
10:05
Critical audit found cleverness debt and hardcoded agent weights across 6+ subsystems
85%
+
9:42
Evaluated 3 options for RLE write endpoints; recommended upstream RIMAPI
79%
+
9:18
Back button fails to appear due to missing history push in neighbor click handler
76%
+
8:55
Verified context_boost 30-minute decay window and distinguished from activity_bonus
66%
+
8:31
Centralized 40+ hardcoded values into config.yaml to improve configurability
44%
+
Thursday, March 202 memories
+
3:10 PM
Re-enabled public repo GitHub Actions and added workflow_dispatch to release.yml
51%
+
1:12 PM
Committed JSON repair and benchmark tools; parse rate 78.3% → 98.1%
38%
+
+ + +
+
mnemonic Search
+
+ + +
+
4 results · 12ms · spread activation: 3 hops
+
ScoreMemorySalLinksCreated
+
.92
IN
Critical audit found cleverness debt and hardcoded agent weights across 6+ subsystems
85%
15
10:05
+
.78
DE
Centralized 40+ hardcoded values into config.yaml to improve configurability
44%
12
8:31
+
.61
LE
Verified context_boost 30-minute decay window and distinguished from activity_bonus
66%
6
8:55
+
.43
IN
Qwen3.5-0.8B Q8_0 identified as top small model for JSON ActionPlans
39%
5
1:45
+
+ +
+ mnemonic v0.33.0 + 77 active + 8 fading + 27 archived + encoding: idle + consolidation: 16:31 +
+ + + + diff --git a/internal/web/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..2adcd86a 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -5,133 +5,8 @@ mnemonic - + ) + style_start = source.find('') + css_text = source[style_start:style_end] + + # Extract JS (between ) + # Skip the D3 script tag (already removed) + script_start = source.find('') + js_text = source[script_start:script_end] + + # Extract HTML body + html_body = extract_html_body(source) + + # === CSS SPLITTING === + print(f"\nCSS: {len(css_text)} characters") + css_sections = extract_css_sections(css_text) + + # Also grab the reset/body/view system that comes before any section comments + first_comment = css_text.find('/* ──') + if first_comment > 0: + preamble = css_text[:first_comment].strip() + if 'base' not in css_sections: + css_sections['base'] = [] + css_sections['base'].insert(0, preamble) + + print("\nCSS sections found:") + for name, parts in sorted(css_sections.items()): + total_lines = sum(p.count('\n') for p in parts) + print(f" {name:15s}: {total_lines:4d} lines ({len(parts)} sections)") + + # Write CSS files + for name, parts in css_sections.items(): + content = '\n\n'.join(parts) + if name in ('recall', 'timeline', 'explore', 'sdk', 'llm', 'tools', 'mind'): + path = os.path.join(OUT, 'css', 'pages', f'{name}.css') + else: + path = os.path.join(OUT, 'css', f'{name}.css') + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + f.write(f'/* Auto-extracted from index.html — {name} */\n\n') + f.write(content) + f.write('\n') + print(f" Wrote: {path}") + + # === JS ANALYSIS === + print(f"\nJS: {len(js_text)} characters, {js_text.count(chr(10))} lines") + report = extract_js_functions(js_text) + + report_path = os.path.join(OUT, 'js', 'FUNCTION_MAP.txt') + os.makedirs(os.path.dirname(report_path), exist_ok=True) + with open(report_path, 'w') as f: + f.write('\n'.join(report)) + f.write('\n') + print(f"\nJS function map written to: {report_path}") + print('\n'.join(report[:30])) + if len(report) > 30: + print(f" ... ({len(report) - 30} more)") + + # === HTML === + print(f"\nHTML body: {len(html_body)} characters, {html_body.count(chr(10))} lines") + html_path = os.path.join(OUT, 'BODY_HTML.txt') + with open(html_path, 'w') as f: + f.write(html_body) + print(f" HTML body extracted to: {html_path}") + + # === SUMMARY === + print("\n" + "=" * 60) + print("MIGRATION SUMMARY") + print("=" * 60) + print(f" Source: {SRC}") + print(f" CSS files: {len(css_sections)}") + print(f" JS functions: {len([r for r in report if '|' in r])}") + print(f" HTML body: {html_body.count(chr(10))} lines") + print() + print("Next steps:") + print(" 1. Review CSS files in css/ — merge/rename as needed") + print(" 2. Use FUNCTION_MAP.txt to manually split JS into modules") + print(" 3. Write new index.html shell referencing modular files") + print(" 4. Test: make build && systemctl --user restart mnemonic") + +if __name__ == '__main__': + main() From 94056cac57c2936a0270133823ba7f298cff88f1 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 21:05:21 -0400 Subject: [PATCH 04/74] feat: remove Mind/Graph view entirely (Phase 3 partial) Delete ~828 lines: Mind view CSS (105 lines), HTML (37 lines), and JS (558 lines including D3 force graph, color scales, search, detail panel, neighborhood highlight, all mind-* functions). Remove Mind nav tab. Renumber keyboard shortcuts (1-6). Associations will be shown as quoted posts in thread view instead. Part of #345 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 714 +-------------------------------- 1 file changed, 5 insertions(+), 709 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index eefa957b..4a158760 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -992,111 +992,6 @@ } @keyframes spin { to { transform: rotate(360deg); } } - /* ── Mind View ── */ - .mind-view { padding: 0; flex-direction: column; height: 100%; overflow: hidden !important; } - .mind-view.active { display: flex; } - .mind-controls { - flex-shrink: 0; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; - padding: 10px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-subtle); - } - .mind-toggle-group { display: flex; gap: 2px; background: var(--bg-tertiary); border-radius: var(--radius-sm); padding: 2px; } - .mind-toggle { - padding: 4px 12px; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: 500; - color: var(--text-muted); cursor: pointer; border: none; background: none; transition: all 0.15s; - } - .mind-toggle:hover { color: var(--text-primary); } - .mind-toggle.active { color: var(--accent-cyan); background: var(--bg-card); box-shadow: var(--shadow-sm); } - .mind-label { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--text-muted); white-space: nowrap; } - .mind-val { color: var(--accent-cyan); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.7rem; min-width: 28px; } - .mind-select { - padding: 3px 8px; border-radius: var(--radius-sm); background: var(--bg-primary); - border: 1px solid var(--border-color); color: var(--text-primary); font-size: 0.75rem; outline: none; - } - .mind-input { - width: 56px; padding: 3px 6px; border-radius: var(--radius-sm); background: var(--bg-primary); - border: 1px solid var(--border-color); color: var(--text-primary); font-size: 0.75rem; outline: none; text-align: center; - } - .mind-slider { width: 90px; accent-color: var(--accent-cyan); } - .mind-stats { - flex-shrink: 0; display: flex; align-items: center; gap: 20px; padding: 6px 24px; - background: var(--bg-secondary); border-bottom: 1px solid var(--border-subtle); min-height: 28px; - } - .mind-stat { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; } - .mind-stat-value { color: var(--accent-cyan); font-weight: 600; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } - .mind-stat-label { color: var(--text-dim); } - .mind-canvas { flex: 1; position: relative; overflow: hidden; background: var(--bg-primary); } - .mind-canvas svg { display: block; width: 100%; height: 100%; } - .mind-search { - position: absolute; top: 12px; left: 16px; z-index: 10; display: flex; align-items: center; - } - .mind-search-icon { width: 16px; height: 16px; position: absolute; left: 10px; color: var(--text-dim); pointer-events: none; } - .mind-search-input { - width: 220px; padding: 7px 30px 7px 32px; border-radius: var(--radius-md); - background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-primary); - font-size: 0.8rem; outline: none; transition: border-color 0.15s, box-shadow 0.15s; - } - .mind-search-input:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 2px rgba(6,182,212,0.12); } - .mind-search-input::placeholder { color: var(--text-dim); } - .mind-search-clear { - position: absolute; right: 6px; background: none; border: none; color: var(--text-dim); - cursor: pointer; font-size: 1.1rem; line-height: 1; padding: 2px 4px; transition: color 0.15s; - } - .mind-search-clear:hover { color: var(--text-primary); } - .mind-tooltip { - position: absolute; pointer-events: none; z-index: 20; display: none; - background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); - padding: 8px 12px; font-size: 0.8rem; color: var(--text-primary); max-width: 300px; - box-shadow: var(--shadow-md); - } - .mind-tooltip-summary { margin-bottom: 4px; font-weight: 500; line-height: 1.3; } - .mind-tooltip-meta { font-size: 0.7rem; color: var(--text-dim); display: flex; gap: 8px; } - .mind-detail { - position: absolute; bottom: 16px; right: 16px; width: 320px; max-height: 50vh; - overflow-y: auto; background: var(--bg-card); border: 1px solid var(--border-color); - border-radius: var(--radius-md); padding: 16px; box-shadow: var(--shadow-lg); z-index: 10; - } - .mind-detail-close { - position: absolute; top: 8px; right: 10px; background: none; border: none; - color: var(--text-dim); cursor: pointer; font-size: 1.1rem; line-height: 1; transition: color 0.15s; - } - .mind-detail-close:hover { color: var(--text-primary); } - .mind-detail-summary { font-size: 0.85rem; font-weight: 500; color: var(--text-primary); margin-bottom: 10px; padding-right: 20px; line-height: 1.4; } - .mind-detail-row { display: flex; justify-content: space-between; font-size: 0.78rem; padding: 3px 0; border-bottom: 1px solid var(--border-subtle); } - .mind-detail-label { color: var(--text-dim); } - .mind-detail-value { color: var(--text-secondary); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.75rem; } - .mind-detail-section { font-size: 0.72rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 10px; margin-bottom: 4px; } - .mind-detail-connections { font-size: 0.78rem; } - .mind-detail-conn { display: flex; align-items: center; gap: 6px; padding: 2px 0; color: var(--text-secondary); } - .mind-detail-conn-line { width: 16px; height: 2px; display: inline-block; flex-shrink: 0; } - .mind-detail-concepts { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } - .mind-detail-concept { - font-size: 0.7rem; padding: 1px 7px; border-radius: 3px; - background: var(--bg-tertiary); color: var(--text-muted); cursor: pointer; transition: all 0.15s; - } - .mind-detail-concept:hover { color: var(--accent-cyan); background: rgba(6,182,212,0.1); } - .mind-legend { - position: absolute; bottom: 16px; left: 16px; z-index: 5; - background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); - padding: 8px 12px; font-size: 0.7rem; opacity: 0.9; display: grid; grid-template-columns: 1fr 1fr; gap: 3px 14px; - } - .mind-legend-item { display: flex; align-items: center; gap: 5px; color: var(--text-dim); white-space: nowrap; } - .mind-legend-line { width: 18px; height: 0; display: inline-block; flex-shrink: 0; } - .mind-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); gap: 8px; } - .mind-empty-icon { font-size: 2.5rem; opacity: 0.4; } - .mind-empty-text { font-size: 0.95rem; } - .mind-empty-sub { font-size: 0.8rem; color: var(--text-dim); } - @keyframes mindPulse { - 0%, 100% { filter: drop-shadow(0 0 3px var(--accent-cyan)); } - 50% { filter: drop-shadow(0 0 10px var(--accent-cyan)); } - } - @media (max-width: 640px) { - .mind-controls { padding: 8px 12px; gap: 8px; } - .mind-legend { display: none; } - .mind-detail { width: calc(100% - 32px); left: 16px; right: 16px; } - .mind-stats { padding: 4px 12px; gap: 10px; flex-wrap: wrap; } - .mind-search-input { width: 160px; } - } - /* ── Agent View ── */ .agent-view { padding: 24px; max-width: 1200px; margin: 0 auto; } .agent-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } @@ -1302,10 +1197,6 @@ Timeline - - - - - - - - -
-
- -
- -
-
- -
@@ -1913,7 +1766,7 @@

Activity

if (name === 'agent' && !state.agentLoaded) loadAgentData(); if (name === 'llm' && !state.llmLoaded) loadLLMUsage(); if (name === 'tools' && !state.toolsLoaded) loadToolUsage(); - if (name === 'mind' && !state.mindLoaded) loadMindGraph(); + // mind view removed (Epic #339) } function switchExploreTab(tab) { @@ -4272,10 +4125,9 @@

Activity

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

Activity

} } - // ── Mind View ── - var _mindFilterTimer = null; - var _mindSearchTimer = null; - var _mindResizeObserver = null; - var _mindColorCache = {}; - - function getGraphColor(varName) { - if (!_mindColorCache[varName]) { - _mindColorCache[varName] = getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; - } - return _mindColorCache[varName]; - } - function invalidateColorCache() { _mindColorCache = {}; } - - function mindEdgeStyle(type) { - var styles = { - similar: { color: function() { return getGraphColor('--text-dim'); }, dash: '', opacity: 0.5 }, - caused_by: { color: function() { return getGraphColor('--accent-orange'); }, dash: '', opacity: 0.8 }, - part_of: { color: function() { return getGraphColor('--accent-blue'); }, dash: '', opacity: 0.7 }, - reinforces: { color: function() { return getGraphColor('--accent-green'); }, dash: '', opacity: 0.7 }, - temporal: { color: function() { return getGraphColor('--text-dim'); }, dash: '4,3', opacity: 0.3 }, - contradicts: { color: function() { return getGraphColor('--accent-red'); }, dash: '', opacity: 0.8 }, - }; - return styles[type] || styles.similar; - } - - function mindColorScale() { - var dim = document.getElementById('mindColorBy').value; - var maps = { - source: { mcp: '--accent-cyan', filesystem: '--accent-violet', terminal: '--accent-orange', clipboard: '--accent-green', consolidation: '--accent-blue', git: '--accent-pink' }, - emotional_tone: { neutral: '--text-dim', frustrating: '--accent-red', satisfying: '--accent-green', surprising: '--accent-yellow' }, - significance: { routine: '--text-dim', notable: '--accent-blue', important: '--accent-orange', critical: '--accent-red', success: '--accent-green', failure: '--accent-red', blocked: '--accent-yellow' }, - state: { active: '--accent-green', fading: '--accent-yellow', archived: '--text-dim', merged: '--accent-blue' }, - }; - var m = maps[dim] || maps.source; - return function(node) { - var val = node[dim] || ''; - var varName = m[val] || '--text-muted'; - return getGraphColor(varName); - }; - } - - function nodeRadius(salience) { return 4 + (salience || 0) * 16; } - - function computeMindStats(data) { - var nCount = data.nodes.length; - var eCount = data.edges.length; - if (nCount === 0) return { nodes: 0, edges: 0, clusters: 0, orphans: 0, avgDegree: 0 }; - - // Build adjacency for cluster detection - var adj = {}; - data.nodes.forEach(function(n) { adj[n.id] = []; }); - data.edges.forEach(function(e) { - if (adj[e.source] || adj[e.source.id]) { - var sid = typeof e.source === 'object' ? e.source.id : e.source; - var tid = typeof e.target === 'object' ? e.target.id : e.target; - if (adj[sid]) adj[sid].push(tid); - if (adj[tid]) adj[tid].push(sid); - } - }); - - // BFS connected components - var visited = {}; - var clusters = 0; - var orphans = 0; - data.nodes.forEach(function(n) { - if (visited[n.id]) return; - clusters++; - var queue = [n.id]; - visited[n.id] = true; - var size = 0; - while (queue.length > 0) { - var cur = queue.shift(); - size++; - (adj[cur] || []).forEach(function(neighbor) { - if (!visited[neighbor]) { visited[neighbor] = true; queue.push(neighbor); } - }); - } - if (size === 1 && (!adj[n.id] || adj[n.id].length === 0)) orphans++; - }); - - return { - nodes: nCount, - edges: eCount, - clusters: clusters, - orphans: orphans, - avgDegree: nCount > 0 ? ((eCount * 2) / nCount).toFixed(1) : '0.0', - }; - } - - function renderMindStats(stats) { - var el = document.getElementById('mindStats'); - var items = [ - { v: stats.nodes, l: 'nodes' }, - { v: stats.edges, l: 'edges' }, - { v: stats.clusters, l: stats.clusters === 1 ? 'cluster' : 'clusters' }, - { v: stats.orphans, l: 'orphans' }, - { v: stats.avgDegree, l: 'avg degree' }, - ]; - el.innerHTML = items.map(function(s) { - return '
' + s.v + '' + s.l + '
'; - }).join(''); - } - - function buildMindLegend() { - var el = document.getElementById('mindLegend'); - var types = [ - { type: 'similar', label: 'similar' }, - { type: 'caused_by', label: 'caused by' }, - { type: 'part_of', label: 'part of' }, - { type: 'reinforces', label: 'reinforces' }, - { type: 'temporal', label: 'temporal' }, - { type: 'contradicts', label: 'contradicts' }, - ]; - el.innerHTML = types.map(function(t) { - var s = mindEdgeStyle(t.type); - var dashAttr = s.dash ? ' stroke-dasharray="' + s.dash + '"' : ''; - return '
' - + '' - + '' + t.label + '
'; - }).join(''); - } - - function buildMindAdjacency(data) { - var adj = {}; - data.nodes.forEach(function(n) { adj[n.id] = new Set(); }); - data.edges.forEach(function(e) { - var sid = typeof e.source === 'object' ? e.source.id : e.source; - var tid = typeof e.target === 'object' ? e.target.id : e.target; - if (adj[sid]) adj[sid].add(tid); - if (adj[tid]) adj[tid].add(sid); - }); - return adj; - } - - function getNeighborhood(nodeId, hops) { - var adj = state.mindAdjacency; - if (!adj) return new Set([nodeId]); - var visited = new Set([nodeId]); - var frontier = [nodeId]; - for (var h = 0; h < hops; h++) { - var next = []; - frontier.forEach(function(id) { - (adj[id] || new Set()).forEach(function(neighbor) { - if (!visited.has(neighbor)) { visited.add(neighbor); next.push(neighbor); } - }); - }); - frontier = next; - } - return visited; - } - - function showMindDetail(node) { - var el = document.getElementById('mindDetail'); - state.mindSelectedNode = node; - - // Count connections by type - var connCounts = {}; - if (state.mindData) { - state.mindData.edges.forEach(function(e) { - var sid = typeof e.source === 'object' ? e.source.id : e.source; - var tid = typeof e.target === 'object' ? e.target.id : e.target; - if (sid === node.id || tid === node.id) { - connCounts[e.relation_type] = (connCounts[e.relation_type] || 0) + 1; - } - }); - } - var totalConns = Object.values(connCounts).reduce(function(a, b) { return a + b; }, 0); - - var date = node.timestamp ? new Date(node.timestamp) : null; - var dateStr = date ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : '-'; - - var rows = [ - { l: 'Salience', v: (node.salience || 0).toFixed(2) }, - { l: 'Source', v: node.source || '-' }, - { l: 'State', v: node.state || '-' }, - ]; - if (node.significance) rows.push({ l: 'Significance', v: node.significance }); - if (node.emotional_tone) rows.push({ l: 'Tone', v: node.emotional_tone }); - rows.push({ l: 'Created', v: dateStr }); - rows.push({ l: 'Connections', v: totalConns }); - if (node.event_count) rows.push({ l: 'Events', v: node.event_count }); - - var html = ''; - html += '
' + escapeHtml(node.summary || '') + '
'; - html += rows.map(function(r) { - return '
' + r.l + '' + escapeHtml(String(r.v)) + '
'; - }).join(''); - - if (totalConns > 0) { - html += '
Connections
'; - Object.keys(connCounts).sort().forEach(function(type) { - var s = mindEdgeStyle(type); - var dashStyle = s.dash ? ' stroke-dasharray="' + s.dash + '"' : ''; - html += '
' - + '' - + '' + type.replace(/_/g, ' ') + ' (' + connCounts[type] + ')
'; - }); - html += '
'; - } - - if (node.concepts && node.concepts.length > 0) { - html += '
Concepts
'; - node.concepts.forEach(function(c) { - html += '' + escapeHtml(c) + ''; - }); - html += '
'; - } - - if (node.files_modified && node.files_modified.length > 0) { - html += '
Files
'; - node.files_modified.slice(0, 8).forEach(function(f) { html += '
' + escapeHtml(f) + '
'; }); - if (node.files_modified.length > 8) html += '
+' + (node.files_modified.length - 8) + ' more
'; - html += '
'; - } - - el.innerHTML = html; - el.style.display = 'block'; - } - - function closeMindDetail() { - document.getElementById('mindDetail').style.display = 'none'; - state.mindSelectedNode = null; - clearMindHighlight(); - } - - function mindSearchConcept(concept) { - var input = document.getElementById('mindSearch'); - input.value = concept; - document.getElementById('mindSearchClear').style.display = ''; - state.mindSearchTerm = concept.toLowerCase(); - applyMindSearch(); - } - - function onMindSearch() { - clearTimeout(_mindSearchTimer); - _mindSearchTimer = setTimeout(function() { - var val = document.getElementById('mindSearch').value.trim(); - document.getElementById('mindSearchClear').style.display = val ? '' : 'none'; - state.mindSearchTerm = val.toLowerCase(); - applyMindSearch(); - }, 200); - } - - function clearMindSearch() { - document.getElementById('mindSearch').value = ''; - document.getElementById('mindSearchClear').style.display = 'none'; - state.mindSearchTerm = ''; - clearMindHighlight(); - } - - function applyMindSearch() { - var canvas = document.getElementById('mindCanvas'); - var term = state.mindSearchTerm; - if (!term || !state.mindData) { clearMindHighlight(); return; } - - var matchIds = new Set(); - state.mindData.nodes.forEach(function(n) { - var haystack = ((n.summary || '') + ' ' + (n.concepts || []).join(' ')).toLowerCase(); - if (haystack.indexOf(term) !== -1) matchIds.add(n.id); - }); - - d3.select(canvas).selectAll('circle.mind-node') - .attr('opacity', function(d) { return matchIds.has(d.id) ? 1 : 0.08; }) - .style('animation', function(d) { return matchIds.has(d.id) ? 'mindPulse 1.5s ease-in-out infinite' : 'none'; }); - d3.select(canvas).selectAll('line.mind-edge') - .attr('opacity', function(d) { - var sid = typeof d.source === 'object' ? d.source.id : d.source; - var tid = typeof d.target === 'object' ? d.target.id : d.target; - return (matchIds.has(sid) || matchIds.has(tid)) ? mindEdgeStyle(d.relation_type).opacity : 0.03; - }); - d3.select(canvas).selectAll('text.mind-label') - .attr('opacity', function(d) { return matchIds.has(d.id) ? 1 : 0.08; }); - } - - function applyMindNeighborhood(nodeId) { - var canvas = document.getElementById('mindCanvas'); - var neighborhood = getNeighborhood(nodeId, 2); - - d3.select(canvas).selectAll('circle.mind-node') - .attr('opacity', function(d) { return neighborhood.has(d.id) ? 1 : 0.08; }) - .attr('stroke', function(d) { return d.id === nodeId ? getGraphColor('--accent-cyan') : getGraphColor('--border-color'); }) - .attr('stroke-width', function(d) { return d.id === nodeId ? 2.5 : 1; }) - .style('animation', 'none'); - d3.select(canvas).selectAll('line.mind-edge') - .attr('opacity', function(d) { - var sid = typeof d.source === 'object' ? d.source.id : d.source; - var tid = typeof d.target === 'object' ? d.target.id : d.target; - return (neighborhood.has(sid) && neighborhood.has(tid)) ? mindEdgeStyle(d.relation_type).opacity : 0.03; - }); - d3.select(canvas).selectAll('text.mind-label') - .attr('opacity', function(d) { return neighborhood.has(d.id) ? 1 : 0.08; }); - } - - function clearMindHighlight() { - var canvas = document.getElementById('mindCanvas'); - d3.select(canvas).selectAll('circle.mind-node') - .attr('opacity', 1) - .attr('stroke', getGraphColor('--border-color')) - .attr('stroke-width', 1) - .style('animation', 'none'); - d3.select(canvas).selectAll('line.mind-edge') - .attr('opacity', function(d) { return mindEdgeStyle(d.relation_type).opacity; }); - d3.select(canvas).selectAll('text.mind-label').attr('opacity', 1); - } - - function updateMindColors() { - invalidateColorCache(); - var canvas = document.getElementById('mindCanvas'); - var color = mindColorScale(); - d3.select(canvas).selectAll('circle.mind-node') - .attr('fill', function(d) { return color(d); }); - // Rebuild legend with new colors - buildMindLegend(); - } - - function setMindView(view) { - state.mindView = view; - document.querySelectorAll('.mind-toggle').forEach(function(b) { - b.classList.toggle('active', b.getAttribute('data-mind-view') === view); - }); - state.mindLoaded = false; - loadMindGraph(); - } - - function onMindFilterChange() { - document.getElementById('mindSalienceVal').textContent = parseFloat(document.getElementById('mindSalience').value).toFixed(2); - document.getElementById('mindStrengthVal').textContent = parseFloat(document.getElementById('mindStrength').value).toFixed(2); - clearTimeout(_mindFilterTimer); - _mindFilterTimer = setTimeout(function() { - state.mindLoaded = false; - loadMindGraph(); - }, 300); - } - - function buildMindGraph(data) { - var canvas = document.getElementById('mindCanvas'); - // Remove existing SVG but keep overlays - var existingSvg = canvas.querySelector('svg'); - if (existingSvg) existingSvg.remove(); - - var rect = canvas.getBoundingClientRect(); - var w = rect.width || 800; - var h = rect.height || 500; - - var svg = d3.select(canvas).append('svg') - .attr('width', w).attr('height', h) - .style('position', 'absolute').style('top', '0').style('left', '0'); - - var g = svg.append('g'); - - // Zoom - var zoom = d3.zoom() - .scaleExtent([0.15, 5]) - .on('zoom', function(event) { g.attr('transform', event.transform); }); - svg.call(zoom); - // Click on canvas background to deselect - svg.on('click', function(event) { - if (event.target === svg.node()) { - closeMindDetail(); - clearMindSearch(); - } - }); - - var color = mindColorScale(); - var nodes = data.nodes; - var edges = data.edges; - - // Build adjacency - state.mindAdjacency = buildMindAdjacency(data); - - // Edges - var edgeG = g.append('g').attr('class', 'mind-edges'); - var link = edgeG.selectAll('line') - .data(edges).enter().append('line') - .attr('class', 'mind-edge') - .attr('stroke', function(d) { return mindEdgeStyle(d.relation_type).color(); }) - .attr('stroke-width', function(d) { return 1 + (d.strength || 0) * 3; }) - .attr('stroke-dasharray', function(d) { return mindEdgeStyle(d.relation_type).dash || null; }) - .attr('opacity', function(d) { return mindEdgeStyle(d.relation_type).opacity; }); - - // Nodes - var nodeG = g.append('g').attr('class', 'mind-nodes'); - var node = nodeG.selectAll('circle') - .data(nodes).enter().append('circle') - .attr('class', 'mind-node') - .attr('r', function(d) { return nodeRadius(d.salience); }) - .attr('fill', function(d) { return color(d); }) - .attr('stroke', getGraphColor('--border-color')) - .attr('stroke-width', 1) - .attr('cursor', 'pointer') - .style('transition', 'opacity 0.2s'); - - // Labels for high-salience nodes - var labelG = g.append('g').attr('class', 'mind-labels'); - var labels = labelG.selectAll('text') - .data(nodes.filter(function(d) { return d.salience > 0.6; })) - .enter().append('text') - .attr('class', 'mind-label') - .text(function(d) { var s = d.summary || ''; return s.length > 22 ? s.slice(0, 20) + '...' : s; }) - .attr('font-size', '9px') - .attr('fill', getGraphColor('--text-muted')) - .attr('text-anchor', 'middle') - .attr('pointer-events', 'none') - .attr('dy', function(d) { return nodeRadius(d.salience) + 12; }); - - // Tooltip - var tooltip = document.getElementById('mindTooltip'); - - node.on('mouseenter', function(event, d) { - var concepts = (d.concepts || []).slice(0, 5).join(', '); - tooltip.innerHTML = '
' + escapeHtml((d.summary || '').slice(0, 80)) + '
' - + '
' - + '' + (d.source || '') + '' - + 'salience: ' + (d.salience || 0).toFixed(2) + '' - + (concepts ? '' + escapeHtml(concepts) + '' : '') - + '
'; - tooltip.style.display = 'block'; - var cr = canvas.getBoundingClientRect(); - tooltip.style.left = (event.clientX - cr.left + 14) + 'px'; - tooltip.style.top = (event.clientY - cr.top + 14) + 'px'; - }) - .on('mousemove', function(event) { - var cr = canvas.getBoundingClientRect(); - var x = event.clientX - cr.left + 14; - var y = event.clientY - cr.top + 14; - // Keep tooltip in view - if (x + 300 > cr.width) x = event.clientX - cr.left - 310; - if (y + 100 > cr.height) y = event.clientY - cr.top - 80; - tooltip.style.left = x + 'px'; - tooltip.style.top = y + 'px'; - }) - .on('mouseleave', function() { tooltip.style.display = 'none'; }); - - // Click: neighborhood focus + detail - node.on('click', function(event, d) { - event.stopPropagation(); - applyMindNeighborhood(d.id); - showMindDetail(d); - }); - - // Drag - var drag = d3.drag() - .on('start', function(event, d) { - if (event.sourceEvent) event.sourceEvent.stopPropagation(); - if (!event.active) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; d.fy = d.y; - }) - .on('drag', function(event, d) { d.fx = event.x; d.fy = event.y; }) - .on('end', function(event, d) { - if (!event.active) simulation.alphaTarget(0); - d.fx = null; d.fy = null; - }); - node.call(drag); - - // Force simulation - var simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(edges).id(function(d) { return d.id; }) - .distance(function(d) { return 60 + (1 - (d.strength || 0)) * 140; }) - .strength(function(d) { return 0.2 + (d.strength || 0) * 0.6; })) - .force('charge', d3.forceManyBody().strength(-120).distanceMax(350)) - .force('center', d3.forceCenter(w / 2, h / 2).strength(0.04)) - .force('collide', d3.forceCollide().radius(function(d) { return nodeRadius(d.salience) + 3; }).strength(0.7)) - .force('x', d3.forceX(w / 2).strength(0.02)) - .force('y', d3.forceY(h / 2).strength(0.02)) - .alphaDecay(0.015) - .velocityDecay(0.4) - .on('tick', function() { - link.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; }) - .attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; }); - node.attr('cx', function(d) { return d.x; }).attr('cy', function(d) { return d.y; }); - labels.attr('x', function(d) { return d.x; }).attr('y', function(d) { return d.y; }); - }); - - state.mindSimulation = simulation; - - // Resize observer - if (_mindResizeObserver) _mindResizeObserver.disconnect(); - _mindResizeObserver = new ResizeObserver(function() { - var r = canvas.getBoundingClientRect(); - if (r.width < 10 || r.height < 10) return; - svg.attr('width', r.width).attr('height', r.height); - simulation.force('center', d3.forceCenter(r.width / 2, r.height / 2).strength(0.04)); - simulation.force('x', d3.forceX(r.width / 2).strength(0.02)); - simulation.force('y', d3.forceY(r.height / 2).strength(0.02)); - simulation.alpha(0.1).restart(); - }); - _mindResizeObserver.observe(canvas); - - // Escape key to clear selection - function onMindEscape(e) { - if (e.key === 'Escape' && state.currentView === 'mind') { - closeMindDetail(); - clearMindSearch(); - } - } - // Remove previous listener before adding (avoid stacking) - document.removeEventListener('keydown', onMindEscape); - document.addEventListener('keydown', onMindEscape); - } - - async function loadMindGraph() { - var canvas = document.getElementById('mindCanvas'); - - // Show loading state - var existingSvg = canvas.querySelector('svg'); - if (existingSvg) existingSvg.remove(); - - // Stop existing simulation - if (state.mindSimulation) { state.mindSimulation.stop(); state.mindSimulation = null; } - - var view = state.mindView; - var limit = parseInt(document.getElementById('mindLimit').value) || 150; - var minSalience = parseFloat(document.getElementById('mindSalience').value) || 0; - var minStrength = parseFloat(document.getElementById('mindStrength').value) || 0; - - var url = '/graph?view=' + view + '&limit=' + limit; - if (minSalience > 0) url += '&min_salience=' + minSalience; - if (minStrength > 0) url += '&min_strength=' + minStrength; - - try { - var data = await fetchJSON(url); - state.mindData = data; - - // Remove any previous empty state - var prevEmpty = canvas.querySelector('.mind-empty'); - if (prevEmpty) prevEmpty.remove(); - - if (!data.nodes || data.nodes.length === 0) { - var emptyDiv = document.createElement('div'); - emptyDiv.className = 'mind-empty'; - emptyDiv.innerHTML = '
' - + '
No memories to visualize
' - + '
Associations form as your memory network grows
'; - canvas.appendChild(emptyDiv); - renderMindStats({ nodes: 0, edges: 0, clusters: 0, orphans: 0, avgDegree: '0.0' }); - state.mindLoaded = true; - return; - } - - var stats = computeMindStats(data); - renderMindStats(stats); - buildMindGraph(data); - buildMindLegend(); - state.mindLoaded = true; - } catch (err) { - var prevEmpty = canvas.querySelector('.mind-empty'); - if (prevEmpty) prevEmpty.remove(); - var errDiv = document.createElement('div'); - errDiv.className = 'mind-empty'; - errDiv.innerHTML = '
' - + '
Failed to load graph
' - + '
' + escapeHtml(err.message) + '
'; - canvas.appendChild(errDiv); - renderMindStats({ nodes: 0, edges: 0, clusters: 0, orphans: 0, avgDegree: '0.0' }); - } - } + // ── Mind View — REMOVED (Epic #339) ── // ── Init ── async function initializeApp() { From ef1c48ad2f3cd29fd18593bf87d1f626339f9801 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 21:07:43 -0400 Subject: [PATCH 05/74] feat: transform nav to forum top bar + navbar + footer (Phase 2-3) Replace card-style nav with vBulletin-inspired forum chrome: - Top bar with brand, version, theme select, activity bell - Sticky navbar with Search/Forum/Timeline/SDK/LLM/Tools tabs - Breadcrumb bar - Fixed footer status bar (version, active/fading/archived counts, encoding status, last consolidation time) - loadStats() populates footer on 30s refresh - switchView() handles both old nav-tab and new ntab classes - forum-transform.py script for reproducible transformation Part of #344, #345 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 208 +++++++++++++++++++------ scripts/forum-transform.py | 275 +++++++++++++++++++++++++++++++++ 2 files changed, 435 insertions(+), 48 deletions(-) create mode 100644 scripts/forum-transform.py diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 4a158760..efca0d57 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -18,7 +18,7 @@ overflow: hidden; } - #app { display: flex; flex-direction: column; height: 100vh; } + #app { display: flex; flex-direction: column; height: 100vh; padding-bottom: 22px; } /* ── Nav ── */ .nav { @@ -1177,60 +1177,140 @@ .llm-cards { grid-template-columns: repeat(3, 1fr); } .llm-grid { grid-template-columns: 1fr; } } + + /* ── Forum Footer ── */ + .foot { + position: fixed; + bottom: 0; left: 0; right: 0; + height: 22px; + background: linear-gradient(to bottom, var(--bg-secondary), var(--bg-primary)); + border-top: 1px solid var(--border-color); + display: flex; + align-items: center; + padding: 0 16px; + font-size: 0.75rem; + color: var(--text-dim); + font-family: var(--mono, 'SF Mono', Monaco, monospace); + gap: 10px; + z-index: 100; + } + .foot span + span::before { content: '·'; margin-right: 10px; color: var(--border-color); } + + /* ── Forum Top Bar ── */ + .top { + background: linear-gradient(to bottom, var(--bg-tertiary), var(--bg-secondary)); + border-bottom: 2px solid var(--accent-cyan); + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: space-between; + } + .top-brand { + font-size: 1.15rem; + font-weight: bold; + color: var(--accent-cyan); + letter-spacing: -0.03em; + } + .top-brand small { + font-size: 0.7rem; + font-weight: normal; + color: var(--text-dim); + margin-left: 8px; + } + .top-right { + display: flex; + align-items: center; + gap: 8px; + } + + /* ── Forum Navbar ── */ + .navbar { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 0 16px; + display: flex; + align-items: center; + height: 30px; + position: sticky; + top: 0; + z-index: 100; + } + .navbar-tabs { display: flex; gap: 0; height: 30px; } + .ntab { + padding: 0 14px; + height: 30px; + display: flex; + align-items: center; + font-size: 0.8rem; + font-weight: bold; + color: var(--text-dim); + cursor: pointer; + border: none; + background: none; + font-family: inherit; + transition: color 0.1s, background 0.1s; + } + .ntab:hover { color: var(--text-primary); background: rgba(255,255,255,0.03); } + .ntab.active { color: var(--accent-cyan); background: color-mix(in srgb, var(--accent-cyan) 8%, transparent); } + .navbar-right { + margin-left: auto; + font-size: 0.75rem; + color: var(--text-dim); + display: flex; + gap: 8px; + align-items: center; + font-family: var(--mono, 'SF Mono', Monaco, monospace); + } + + /* ── Breadcrumbs ── */ + .crumbs { + padding: 4px 16px; + font-size: 0.75rem; + color: var(--text-dim); + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-secondary); + } + .crumbs a { color: var(--accent-cyan); text-decoration: none; } + .crumbs a:hover { text-decoration: underline; } + .crumbs .sep { margin: 0 5px; color: var(--text-dim); } +
- - - -
+ +
@@ -2886,6 +2966,29 @@

Activity

document.getElementById('toolHeaderSub').textContent = 'recall \u00b7 remember \u00b7 get_context \u00b7 ' + health.tool_count + ' tools'; } state.previousStats = stats; + // Update forum footer + var total = (stats.store && stats.store.active_memories) || health.memory_count || 0; + var fa = document.getElementById('footActive'); + var ff = document.getElementById('footFading'); + var far = document.getElementById('footArchived'); + var fv = document.getElementById('footVersion'); + var fe = document.getElementById('footEncoding'); + var fc = document.getElementById('footConsolidation'); + if (fa) fa.textContent = (stats.store ? stats.store.active_memories : total) + ' active'; + if (ff) ff.textContent = (stats.store ? stats.store.fading_memories : 0) + ' fading'; + if (far) far.textContent = (stats.store ? stats.store.archived_memories : 0) + ' archived'; + if (fv) fv.textContent = 'mnemonic ' + (health.version ? 'v' + health.version : ''); + if (fe) fe.textContent = 'encoding: ' + (health.encoding_pending > 0 ? health.encoding_pending + ' pending' : 'idle'); + if (fc && stats.last_consolidation) { + var d = new Date(stats.last_consolidation); + fc.textContent = 'consolidation: ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); + } + // Update healthDot (forum nav uses different ID) + var hd = document.getElementById('healthDot'); + if (hd) { + hd.className = health.status === 'ok' ? 'nav-health' : 'nav-health degraded'; + hd.title = health.status === 'ok' ? 'daemon online' : 'degraded'; + } } catch (e) { document.getElementById('navHealth').className = 'nav-health down'; document.getElementById('navHealth').title = 'Cannot reach server'; @@ -4997,5 +5100,14 @@

Activity

initializeApp(); + +
+ mnemonic + – active + – fading + – archived + encoding: idle + consolidation: – +
diff --git a/scripts/forum-transform.py b/scripts/forum-transform.py new file mode 100644 index 00000000..960627aa --- /dev/null +++ b/scripts/forum-transform.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Transform the mnemonic dashboard to forum style. + +This script modifies internal/web/static/index.html in-place: +1. Replaces the nav bar HTML with forum-style top bar + navbar +2. Adds a forum footer status bar +3. Updates CSS class references in JS render functions +4. Updates the nav tab HTML +5. Adds external CSS links + +Run: python3 scripts/forum-transform.py +Then: make build && systemctl --user restart mnemonic +""" + +import re + +SRC = "internal/web/static/index.html" + +def transform(): + with open(SRC, "r") as f: + html = f.read() + + original_lines = html.count('\n') + print(f"Source: {original_lines} lines") + + # ═══════════════════════════════════════════ + # 1. Replace the nav bar HTML + # ═══════════════════════════════════════════ + + # Find the nav section in the body + old_nav_start = html.find('
+ +
+
Loading thread...
+
+
@@ -1410,10 +1415,10 @@

What do you remember?

-
-
-
SDK Agent Sessions
-
Claude.ai · OAuth · self-evolving
+
+
+
SDK Agent Sessions
+
Claude.ai · OAuth · self-evolving
@@ -1590,10 +1595,10 @@

What do you remember?

-
+
-
MCP Tool Usage
-
recall · remember · get_context · … tools
+
MCP Tool Usage
+
recall · remember · get_context · … tools
@@ -1861,7 +1866,12 @@

Activity

function handleHash() { var hash = window.location.hash.replace('#', ''); - if (['recall', 'explore', 'timeline', 'mind', 'agent', 'llm', 'tools'].includes(hash)) switchView(hash); + if (hash.startsWith('thread/')) { + var epId = hash.substring(7); + if (epId) loadThread(epId); + return; + } + if (['recall', 'explore', 'timeline', 'agent', 'llm', 'tools'].includes(hash)) switchView(hash); } window.addEventListener('hashchange', handleHash); @@ -1947,6 +1957,101 @@

Activity

if (el) el.classList.toggle('open'); } + // ── Thread View (episode detail as forum posts) ── + async function loadThread(episodeId) { + state.currentView = 'thread'; + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + document.querySelectorAll('.ntab').forEach(t => t.classList.remove('active')); + document.getElementById('view-thread').classList.add('active'); + window.location.hash = 'thread/' + episodeId; + + // Update breadcrumbs + var crumbs = document.getElementById('breadcrumbs'); + if (crumbs) crumbs.innerHTML = 'mnemonic Forum Episode'; + + var container = document.getElementById('threadContent'); + container.innerHTML = '
Loading thread...
'; + + try { + var ep = await fetchJSON('/episodes/' + episodeId); + var memIds = ep.raw_memory_ids || ep.memory_ids || []; + + // Build thread header + var html = '
'; + html += '
'; + html += '
' + escapeHtml(ep.title || 'Untitled Episode') + '
'; + html += '
'; + if (ep.emotional_tone) html += 'Mood: ' + escapeHtml(ep.emotional_tone) + ''; + if (ep.start_time && ep.end_time) { + html += 'Duration: ' + new Date(ep.start_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + ' – ' + new Date(ep.end_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + ''; + } + html += 'Memories: ' + memIds.length + ''; + if (ep.files_modified && ep.files_modified.length > 0) { + html += 'Files: ' + ep.files_modified.slice(0, 5).map(function(f) { return escapeHtml(f.split('/').pop()); }).join(', ') + ''; + } + html += '
'; + + // Load each memory as a post + if (memIds.length > 0) { + var memPromises = memIds.slice(0, 20).map(function(id) { + return fetchJSON('/memories/' + id).catch(function() { return null; }); + }); + var memories = await Promise.all(memPromises); + memories = memories.filter(function(m) { return m; }); + + memories.forEach(function(m, idx) { + var type = (m.type || m.memory_type || 'general').toLowerCase(); + var typeBadgeClass = 'post-type-badge--' + type; + var salPct = Math.round((m.salience || 0) * 100); + var linkCount = m.association_count || 0; + var source = m.source || 'mcp'; + var time = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; + var concepts = m.concepts || []; + + html += '
'; + // Left sidebar + html += '
'; + html += '
' + escapeHtml(type) + '
'; + html += '
Salience: ' + salPct + '%
'; + html += '
Links: ' + linkCount + '
'; + html += '
Source: ' + escapeHtml(source) + '
'; + html += '
' + time + '
'; + html += '
'; + // Right content + html += '
'; + html += '
Memory #' + (idx + 1) + ' of ' + memories.length + 'Copy ID
'; + html += '
' + escapeHtml(m.summary || m.content || '') + '
'; + if (m.content && m.content !== m.summary) { + html += '
' + escapeHtml(m.content) + '
'; + } + if (concepts.length > 0) { + html += ''; + } + html += '
'; + }); + } + + // Episode narrative at bottom + if (ep.narrative) { + html += '
'; + html += '
Episode Narrative
'; + html += escapeHtml(ep.narrative); + html += '
'; + } + + html += '
'; + container.innerHTML = html; + + // Update breadcrumbs with title + if (crumbs) crumbs.innerHTML = 'mnemonic Forum ' + escapeHtml((ep.title || 'Episode').substring(0, 50)); + } catch (e) { + console.error('loadThread error:', e); + container.innerHTML = '
Failed to load episode: ' + escapeHtml(e.message || String(e)) + '
'; + } + } + async function submitFeedback(quality, event) { event.stopPropagation(); if (!state.lastQueryId) return; @@ -2040,7 +2145,7 @@

Activity

// Expandable zone with nested memories row += '
'; - row += '
' + memCount + ' memories in this episode
'; + row += '
' + memCount + ' memories in this episodeView full thread →
'; if (ep.narrative) { row += '
' + escapeHtml(ep.narrative) + '
'; } From f6bc7a554e59b98eba2a45beed94a5cd79ccc5bf Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:06:37 -0400 Subject: [PATCH 15/74] =?UTF-8?q?chore:=20cleanup=20=E2=80=94=20remove=20m?= =?UTF-8?q?ind=20CSS,=20gitkeeps,=20extraction=20artifacts=20(Phase=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove: - css/pages/mind.css (mind view was deleted) - .gitkeep files (directories now have real files) - BODY_HTML.txt and FUNCTION_MAP.txt (extraction reference files) Part of #350 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/BODY_HTML.txt | 518 ------------------------ internal/web/static/css/.gitkeep | 0 internal/web/static/css/pages/.gitkeep | 0 internal/web/static/css/pages/mind.css | 108 ----- internal/web/static/js/.gitkeep | 0 internal/web/static/js/FUNCTION_MAP.txt | 138 ------- internal/web/static/js/pages/.gitkeep | 0 7 files changed, 764 deletions(-) delete mode 100644 internal/web/static/BODY_HTML.txt delete mode 100644 internal/web/static/css/.gitkeep delete mode 100644 internal/web/static/css/pages/.gitkeep delete mode 100644 internal/web/static/css/pages/mind.css delete mode 100644 internal/web/static/js/.gitkeep delete mode 100644 internal/web/static/js/FUNCTION_MAP.txt delete mode 100644 internal/web/static/js/pages/.gitkeep diff --git a/internal/web/static/BODY_HTML.txt b/internal/web/static/BODY_HTML.txt deleted file mode 100644 index ee6529af..00000000 --- a/internal/web/static/BODY_HTML.txt +++ /dev/null @@ -1,518 +0,0 @@ -
- - - - -
- -
-
-

What do you remember?

- -
- - / -
-
-
-
- -
-
-
- - -
-
- - -
-
- -
- -
-
-
-
- - -
-
-
-
- - - - -
- -
-
-
-
-
-
-
-
-
- - -
-
-
-
-
- - - - -
-
- - - - - - -
-
-
- - -
-
-
-
-
Loading timeline...
-
-
- - -
-
-
- - -
- - - - -
-
-
- -
- -
-
-
- - -
-
-
-
SDK Agent Sessions
-
Claude.ai · OAuth · self-evolving
-
- - -
-
-
-
-
-
Task Activity (30d)
-
-
-
-
-
-
-
- -
-
-
-
-
-
Evolution Timeline
- -
-
Loading...
-
-
-
-
Session Activity
- -
-
Loading...
-
-
-
-
Learned Principles
- -
-
Loading...
-
-
-
-
Task Strategies
- -
-
Loading...
-
-
- - -
-
-
- -
New conversation
-
-
- -
-
- Disconnected -
- -
-
- -
-
-
- Agent chat requires the Python SDK.
- Enable agent_sdk.enabled: true in config.yaml
- and run the agent on port 9998. -
-
-
- - -
-
-
-
- - -
-
-
-
LLM Usage
-
Memory engine · local model
-
-
- - - - -
- - -
-
-
-
Requests
-
-
Tokens
-
-
Est. Cost
-
-
Avg Latency
-
-
Errors
-
-
Completion %
-
out / total tokens
-
-
-
-
Token Usage (24h)
-
-
Prompt
-
Completion
-
-
-
-
-
-
-
-
-
-
Usage by Agent
- - - -
AgentRequestsTokensErrorsCostAvg Latency
No data yet
-
-
-
By Operation
- - - -
OperationCallsTokensp50p95
No data yet
-
-
-
-
Recent Requests
-
- - - -
TimeAgentOperationModelIn / OutLatencyStatus
No data yet
-
-
-
-
- - -
-
-
-
MCP Tool Usage
-
recall · remember · get_context · … tools
-
-
- - - - -
- - -
-
-
-
Tool Calls
-
-
Avg Latency
-
-
Errors
-
-
Top Tool
-
-
Projects
-
-
Success Rate
-
-
-
-
-
Tool Calls (24h)
-
-
Calls
-
Errors
-
-
-
-
-
-
-
-
-
-
Memory Types Stored
-
-
-
-
Feedback Quality
-
-
-
-
-
Tool Performance
- - - -
ToolCallsp50p95MaxAvg Size
No data yet
-
-
-
-
Sessions
- - - -
SessionCallsTools UsedDuration
No session data
-
-
-
Calls by Project
- - - -
ProjectCalls
No data yet
-
-
-
-
Recent Tool Calls
-
- - - -
TimeToolProjectQuery / ContextLatencySizeStatus
No tool calls recorded yet
-
-
-
-
-
-
Research Analytics
-
Is the memory system getting smarter?
-
-
- - - -
-
-
-
-
-
-
-
-
Memory Lifecycle (14d)
-
-
-
-
-
-
-
-
Signal Quality by Source
-
-
-
-
Recall Learning Curve
-
-
-
-
-
Session Activity
-
-
-
-
-
- - -
-
-
-

Activity

- -
-
-
Active Concepts
-
-
Insights
-
-
Live Events
-
-
- -
- -
-
\ No newline at end of file diff --git a/internal/web/static/css/.gitkeep b/internal/web/static/css/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/web/static/css/pages/.gitkeep b/internal/web/static/css/pages/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/web/static/css/pages/mind.css b/internal/web/static/css/pages/mind.css deleted file mode 100644 index f0e6764e..00000000 --- a/internal/web/static/css/pages/mind.css +++ /dev/null @@ -1,108 +0,0 @@ -/* Auto-extracted from index.html — mind */ - -/* ── Mind View ── */ - .mind-view { padding: 0; flex-direction: column; height: 100%; overflow: hidden !important; } - .mind-view.active { display: flex; } - .mind-controls { - flex-shrink: 0; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; - padding: 10px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-subtle); - } - .mind-toggle-group { display: flex; gap: 2px; background: var(--bg-tertiary); border-radius: var(--radius-sm); padding: 2px; } - .mind-toggle { - padding: 4px 12px; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: 500; - color: var(--text-muted); cursor: pointer; border: none; background: none; transition: all 0.15s; - } - .mind-toggle:hover { color: var(--text-primary); } - .mind-toggle.active { color: var(--accent-cyan); background: var(--bg-card); box-shadow: var(--shadow-sm); } - .mind-label { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--text-muted); white-space: nowrap; } - .mind-val { color: var(--accent-cyan); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.7rem; min-width: 28px; } - .mind-select { - padding: 3px 8px; border-radius: var(--radius-sm); background: var(--bg-primary); - border: 1px solid var(--border-color); color: var(--text-primary); font-size: 0.75rem; outline: none; - } - .mind-input { - width: 56px; padding: 3px 6px; border-radius: var(--radius-sm); background: var(--bg-primary); - border: 1px solid var(--border-color); color: var(--text-primary); font-size: 0.75rem; outline: none; text-align: center; - } - .mind-slider { width: 90px; accent-color: var(--accent-cyan); } - .mind-stats { - flex-shrink: 0; display: flex; align-items: center; gap: 20px; padding: 6px 24px; - background: var(--bg-secondary); border-bottom: 1px solid var(--border-subtle); min-height: 28px; - } - .mind-stat { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; } - .mind-stat-value { color: var(--accent-cyan); font-weight: 600; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } - .mind-stat-label { color: var(--text-dim); } - .mind-canvas { flex: 1; position: relative; overflow: hidden; background: var(--bg-primary); } - .mind-canvas svg { display: block; width: 100%; height: 100%; } - .mind-search { - position: absolute; top: 12px; left: 16px; z-index: 10; display: flex; align-items: center; - } - .mind-search-icon { width: 16px; height: 16px; position: absolute; left: 10px; color: var(--text-dim); pointer-events: none; } - .mind-search-input { - width: 220px; padding: 7px 30px 7px 32px; border-radius: var(--radius-md); - background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-primary); - font-size: 0.8rem; outline: none; transition: border-color 0.15s, box-shadow 0.15s; - } - .mind-search-input:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 2px rgba(6,182,212,0.12); } - .mind-search-input::placeholder { color: var(--text-dim); } - .mind-search-clear { - position: absolute; right: 6px; background: none; border: none; color: var(--text-dim); - cursor: pointer; font-size: 1.1rem; line-height: 1; padding: 2px 4px; transition: color 0.15s; - } - .mind-search-clear:hover { color: var(--text-primary); } - .mind-tooltip { - position: absolute; pointer-events: none; z-index: 20; display: none; - background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); - padding: 8px 12px; font-size: 0.8rem; color: var(--text-primary); max-width: 300px; - box-shadow: var(--shadow-md); - } - .mind-tooltip-summary { margin-bottom: 4px; font-weight: 500; line-height: 1.3; } - .mind-tooltip-meta { font-size: 0.7rem; color: var(--text-dim); display: flex; gap: 8px; } - .mind-detail { - position: absolute; bottom: 16px; right: 16px; width: 320px; max-height: 50vh; - overflow-y: auto; background: var(--bg-card); border: 1px solid var(--border-color); - border-radius: var(--radius-md); padding: 16px; box-shadow: var(--shadow-lg); z-index: 10; - } - .mind-detail-close { - position: absolute; top: 8px; right: 10px; background: none; border: none; - color: var(--text-dim); cursor: pointer; font-size: 1.1rem; line-height: 1; transition: color 0.15s; - } - .mind-detail-close:hover { color: var(--text-primary); } - .mind-detail-summary { font-size: 0.85rem; font-weight: 500; color: var(--text-primary); margin-bottom: 10px; padding-right: 20px; line-height: 1.4; } - .mind-detail-row { display: flex; justify-content: space-between; font-size: 0.78rem; padding: 3px 0; border-bottom: 1px solid var(--border-subtle); } - .mind-detail-label { color: var(--text-dim); } - .mind-detail-value { color: var(--text-secondary); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.75rem; } - .mind-detail-section { font-size: 0.72rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 10px; margin-bottom: 4px; } - .mind-detail-connections { font-size: 0.78rem; } - .mind-detail-conn { display: flex; align-items: center; gap: 6px; padding: 2px 0; color: var(--text-secondary); } - .mind-detail-conn-line { width: 16px; height: 2px; display: inline-block; flex-shrink: 0; } - .mind-detail-concepts { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } - .mind-detail-concept { - font-size: 0.7rem; padding: 1px 7px; border-radius: 3px; - background: var(--bg-tertiary); color: var(--text-muted); cursor: pointer; transition: all 0.15s; - } - .mind-detail-concept:hover { color: var(--accent-cyan); background: rgba(6,182,212,0.1); } - .mind-legend { - position: absolute; bottom: 16px; left: 16px; z-index: 5; - background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); - padding: 8px 12px; font-size: 0.7rem; opacity: 0.9; display: grid; grid-template-columns: 1fr 1fr; gap: 3px 14px; - } - .mind-legend-item { display: flex; align-items: center; gap: 5px; color: var(--text-dim); white-space: nowrap; } - .mind-legend-line { width: 18px; height: 0; display: inline-block; flex-shrink: 0; } - .mind-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); gap: 8px; } - .mind-empty-icon { font-size: 2.5rem; opacity: 0.4; } - .mind-empty-text { font-size: 0.95rem; } - .mind-empty-sub { font-size: 0.8rem; color: var(--text-dim); } - @keyframes mindPulse { - 0%, 100% { filter: drop-shadow(0 0 3px var(--accent-cyan)); } - 50% { filter: drop-shadow(0 0 10px var(--accent-cyan)); } - } - @media (max-width: 640px) { - .mind-controls { padding: 8px 12px; gap: 8px; } - .mind-legend { display: none; } - .mind-detail { width: calc(100% - 32px); left: 16px; right: 16px; } - .mind-stats { padding: 4px 12px; gap: 10px; flex-wrap: wrap; } - .mind-search-input { width: 160px; } - } - - diff --git a/internal/web/static/js/.gitkeep b/internal/web/static/js/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/web/static/js/FUNCTION_MAP.txt b/internal/web/static/js/FUNCTION_MAP.txt deleted file mode 100644 index 021f9d73..00000000 --- a/internal/web/static/js/FUNCTION_MAP.txt +++ /dev/null @@ -1,138 +0,0 @@ -# JS Function Inventory -# Total functions found: 135 - - app | line 9 | setTheme - api | line 65 | apiFetch - api | line 74 | fetchJSON - utils | line 81 | makeDayBuckets - app | line 98 | switchView - explore | line 113 | switchExploreTab - app | line 122 | handleHash - recall | line 129 | performRecall - unknown | line 157 | renderResults - unknown | line 194 | toggleExpand - recall | line 199 | submitFeedback - recall | line 218 | toggleRemember - recall | line 224 | submitRemember - explore | line 248 | loadExploreTab - explore | line 264 | loadEpisodes - explore | line 303 | loadMemories - explore | line 396 | loadPatterns - explore | line 422 | archivePattern - explore | line 436 | loadAbstractions - explore | line 462 | filterExplore - timeline | line 483 | setTimelineRange - timeline | line 494 | toggleTimelineType - timeline | line 501 | filterTimeline - recall | line 508 | clearTimelineSearch - timeline | line 517 | populateTimelineProjects - timeline | line 532 | toggleTimelineProject - timeline | line 544 | getTimelineRangeCutoff - timeline | line 557 | filterTimelineItems - unknown | line 581 | groupByDate - timeline | line 601 | renderTimelineItems - timeline | line 626 | renderTimelineCard - timeline | line 717 | expandTimelineCard - timeline | line 724 | toggleTimelineTag - timeline | line 734 | hoverTimelineTag - timeline | line 739 | unhoverTimelineTag - timeline | line 744 | applyTimelineTagHighlight - timeline | line 760 | highlightTimelineCards - timeline | line 785 | clearAllTimelineHighlight - timeline | line 794 | loadTimelineData - timeline | line 878 | setupTimelineScroll - timeline | line 888 | renderTimelineSparkline - unknown | line 935 | fmtDate - unknown | line 1007 | toggleDrawer - unknown | line 1021 | loadActivityConcepts - unknown | line 1054 | updateBadge - unknown | line 1063 | _nonZero - unknown | line 1067 | buildEventDetail - unknown | line 1124 | addEvent - unknown | line 1143 | clearEvents - unknown | line 1145 | triggerConsolidation - utils | line 1151 | formatInsightDetail - unknown | line 1189 | loadInsights - app | line 1212 | loadStats - unknown | line 1237 | loadProjects - api | line 1252 | connectWebSocket - app | line 1301 | showToast - timeline | line 1314 | setLLMRange - llm | line 1324 | loadLLMUsage - llm | line 1432 | renderLLMChart - tools | line 1547 | setAnalyticsRange - tools | line 1557 | setToolRange - tools | line 1567 | loadToolUsage - unknown | line 1617 | percentile - unknown | line 1758 | _thresholdColor - tools | line 1764 | _renderSparkline - unknown | line 1779 | _computeDelta - tools | line 1791 | loadAnalytics - tools | line 2001 | renderLifecycleChart - tools | line 2124 | renderSignalChart - recall | line 2186 | renderRecallChart - unknown | line 2251 | _buildSessionEnrichment - unknown | line 2271 | toggleSession - timeline | line 2278 | loadSessionTimeline - utils | line 2364 | formatBytes - tools | line 2370 | renderToolChart - utils | line 2477 | escapeHtml - utils | line 2478 | simpleMarkdown - tools | line 2503 | toggleToolDetail - utils | line 2541 | relativeTime - sdk | line 2553 | loadAgentData - sdk | line 2575 | refreshAgentData - sdk | line 2582 | renderAgentDashboard - sdk | line 2597 | renderAgentStats - unknown | line 2621 | cardHtml - sdk | line 2639 | renderSDKCostChart - sdk | line 2711 | renderAgentMemoryBar - sdk | line 2727 | renderAgentPrinciples - sdk | line 2757 | renderAgentStrategies - sdk | line 2790 | renderAgentTimeline - sdk | line 2804 | renderAgentSessions - sdk | line 2840 | renderAgentPatches - utils | line 2871 | renderMarkdown - sdk | line 2902 | initAgentChat - sdk | line 2917 | connectAgentChat - unknown | line 2948 | scheduleReconnect - sdk | line 2953 | setChatStatus - sdk | line 2962 | handleChatMessage - sdk | line 3083 | appendChatBubble - sdk | line 3099 | scrollChatBottom - sdk | line 3105 | sendChatMessage - sdk | line 3128 | toggleChatHistory - unknown | line 3140 | renderConversationList - sdk | line 3165 | loadConversation - unknown | line 3172 | displayLoadedConversation - unknown | line 3217 | deleteConversation - unknown | line 3222 | startNewConversation - unknown | line 3231 | onModelChange - unknown | line 3240 | checkForUpdate - unknown | line 3263 | waitForRestart - unknown | line 3290 | triggerUpdate - mind | line 3326 | getGraphColor - unknown | line 3332 | invalidateColorCache - mind | line 3334 | mindEdgeStyle - mind | line 3346 | mindColorScale - unknown | line 3362 | nodeRadius - mind | line 3364 | computeMindStats - mind | line 3410 | renderMindStats - mind | line 3424 | buildMindLegend - mind | line 3443 | buildMindAdjacency - unknown | line 3455 | getNeighborhood - mind | line 3472 | showMindDetail - mind | line 3540 | closeMindDetail - recall | line 3546 | mindSearchConcept - recall | line 3554 | onMindSearch - recall | line 3564 | clearMindSearch - recall | line 3571 | applyMindSearch - mind | line 3595 | applyMindNeighborhood - mind | line 3614 | clearMindHighlight - mind | line 3626 | updateMindColors - mind | line 3636 | setMindView - mind | line 3645 | onMindFilterChange - mind | line 3655 | buildMindGraph - mind | line 3810 | onMindEscape - mind | line 3821 | loadMindGraph - app | line 3879 | initializeApp diff --git a/internal/web/static/js/pages/.gitkeep b/internal/web/static/js/pages/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 8fd5199f1a8ecb8eb05224e1ff3808fa69a757cf Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:15:53 -0400 Subject: [PATCH 16/74] fix: thread view loads memories via episode_id filter Thread view was fetching by raw_memory_ids which don't resolve via /memories/{id}. Now uses /memories?episode_id={id} which correctly returns all encoded memories belonging to the episode. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index fb8c9983..4167deee 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1974,7 +1974,9 @@

Activity

try { var ep = await fetchJSON('/episodes/' + episodeId); - var memIds = ep.raw_memory_ids || ep.memory_ids || []; + // Fetch memories belonging to this episode + var memData = await fetchJSON('/memories?episode_id=' + episodeId + '&limit=50'); + var memories = memData.memories || []; // Build thread header var html = '
'; @@ -1985,19 +1987,13 @@

Activity

if (ep.start_time && ep.end_time) { html += 'Duration: ' + new Date(ep.start_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + ' – ' + new Date(ep.end_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + ''; } - html += 'Memories: ' + memIds.length + ''; + html += 'Memories: ' + memories.length + ''; if (ep.files_modified && ep.files_modified.length > 0) { html += 'Files: ' + ep.files_modified.slice(0, 5).map(function(f) { return escapeHtml(f.split('/').pop()); }).join(', ') + ''; } html += '
'; - // Load each memory as a post - if (memIds.length > 0) { - var memPromises = memIds.slice(0, 20).map(function(id) { - return fetchJSON('/memories/' + id).catch(function() { return null; }); - }); - var memories = await Promise.all(memPromises); - memories = memories.filter(function(m) { return m; }); + if (memories.length > 0) { memories.forEach(function(m, idx) { var type = (m.type || m.memory_type || 'general').toLowerCase(); From 85d8daceff1d7382a9de035c5b7d88f15396527e Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:19:15 -0400 Subject: [PATCH 17/74] fix: thread view unwraps nested episode response GET /episodes/{id} returns {episode: {...}} not the episode directly. loadThread now unwraps correctly, fixing "Untitled Episode" title and missing metadata (mood, duration, files). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 4167deee..576c4886 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1973,7 +1973,8 @@

Activity

container.innerHTML = '
Loading thread...
'; try { - var ep = await fetchJSON('/episodes/' + episodeId); + var epResp = await fetchJSON('/episodes/' + episodeId); + var ep = epResp.episode || epResp; // endpoint wraps in {episode: {...}} // Fetch memories belonging to this episode var memData = await fetchJSON('/memories?episode_id=' + episodeId + '&limit=50'); var memories = memData.memories || []; From 800686433cfb5bc392cbcd00b1423aecd0c54fb0 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:20:27 -0400 Subject: [PATCH 18/74] fix: thread view filters memories client-side by episode_id The /memories?episode_id= param doesn't filter server-side. Now fetches all active memories and filters client-side to show only those belonging to the episode. Shows correct memory count instead of always 50. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 576c4886..1a9b7148 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1975,9 +1975,10 @@

Activity

try { var epResp = await fetchJSON('/episodes/' + episodeId); var ep = epResp.episode || epResp; // endpoint wraps in {episode: {...}} - // Fetch memories belonging to this episode - var memData = await fetchJSON('/memories?episode_id=' + episodeId + '&limit=50'); - var memories = memData.memories || []; + // Fetch memories and filter by episode_id client-side + // (API doesn't filter by episode_id param reliably) + var memData = await fetchJSON('/memories?limit=200&state=active'); + var memories = (memData.memories || []).filter(function(m) { return m.episode_id === episodeId; }); // Build thread header var html = '
'; From 934b6a6b927a4309e0c3edca86603b5c447e8f92 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:23:58 -0400 Subject: [PATCH 19/74] feat: add server-side episode_id filter to /memories endpoint HandleListMemories now reads ?episode_id= query param and filters results server-side. Previously the param was ignored, forcing the thread view to fetch all memories and filter client-side (slow). Reverts client-side workaround in loadThread. Part of #345 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/routes/memories.go | 11 +++++++++++ internal/web/static/index.html | 7 +++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/api/routes/memories.go b/internal/api/routes/memories.go index 5c1ba4a4..fe65f55b 100644 --- a/internal/api/routes/memories.go +++ b/internal/api/routes/memories.go @@ -199,6 +199,17 @@ 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 + } + resp := ListMemoriesResponse{ Memories: memories, Count: len(memories), diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 1a9b7148..10d65b91 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1975,10 +1975,9 @@

Activity

try { var epResp = await fetchJSON('/episodes/' + episodeId); var ep = epResp.episode || epResp; // endpoint wraps in {episode: {...}} - // Fetch memories and filter by episode_id client-side - // (API doesn't filter by episode_id param reliably) - var memData = await fetchJSON('/memories?limit=200&state=active'); - var memories = (memData.memories || []).filter(function(m) { return m.episode_id === episodeId; }); + // Fetch memories for this episode + var memData = await fetchJSON('/memories?episode_id=' + episodeId + '&limit=100'); + var memories = memData.memories || []; // Build thread header var html = '
'; From ad369fab0e53b395c204d680439a20e38e6527f9 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:27:16 -0400 Subject: [PATCH 20/74] fix: map source event types to display types, cap salience at 100% Source event types (FILE_CREATED, REPO_CHANGED) now map to 'general' for display. Cognitive types (decision, insight, learning, error) pass through. Salience capped at 100% (was showing 196% for values >1.0). Added shared helpers: memoryType(), memoryTypeAbbr(), memoryTypeIcon(), safeSalience() to eliminate duplication across forum view, recall results, and thread view renderers. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 10d65b91..2d8364ac 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1918,10 +1918,10 @@

Activity

var score = mem.score || 0; var scoreClass = score > 0.7 ? 'rr-score-hi' : score > 0.4 ? 'rr-score-mid' : 'rr-score-lo'; var m = mem.memory; - var type = (m.type || 'general').toLowerCase(); - var typeAbbr = type === 'insight' ? 'IN' : type === 'decision' ? 'DE' : type === 'learning' ? 'LE' : type === 'error' ? 'ER' : 'GN'; - var iconClass = type === 'insight' ? 'icon-in' : type === 'decision' ? 'icon-de' : type === 'learning' ? 'icon-le' : type === 'error' ? 'icon-er' : 'icon-ep'; - var salPct = Math.round((m.salience || 0) * 100); + var type = memoryType(m); + var typeAbbr = memoryTypeAbbr(type); + var iconClass = memoryTypeIcon(type); + var salPct = safeSalience(m.salience); var linkCount = m.association_count || 0; var dateStr = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; var summary = m.summary || (m.content || '').slice(0, 150); @@ -1997,9 +1997,9 @@

Activity

if (memories.length > 0) { memories.forEach(function(m, idx) { - var type = (m.type || m.memory_type || 'general').toLowerCase(); + var type = memoryType(m); var typeBadgeClass = 'post-type-badge--' + type; - var salPct = Math.round((m.salience || 0) * 100); + var salPct = safeSalience(m.salience); var linkCount = m.association_count || 0; var source = m.source || 'mcp'; var time = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; @@ -2158,10 +2158,10 @@

Activity

if (memories.length === 0) { section.innerHTML = '
🧠
No memories yet
Use the Remember panel to add your first memory
'; return; } var html = '
MemorySalLinksCreated
'; html += memories.map(function(m) { - var type = (m.type || 'general').toLowerCase(); - var typeAbbr = type === 'insight' ? 'IN' : type === 'decision' ? 'DE' : type === 'learning' ? 'LE' : type === 'error' ? 'ER' : 'GN'; - var iconClass = type === 'insight' ? 'icon-in' : type === 'decision' ? 'icon-de' : type === 'learning' ? 'icon-le' : type === 'error' ? 'icon-er' : 'icon-ep'; - var salPct = Math.round((m.salience || 0) * 100); + var type = memoryType(m); + var typeAbbr = memoryTypeAbbr(type); + var iconClass = memoryTypeIcon(type); + var salPct = safeSalience(m.salience); var salClass = salPct >= 60 ? 'sal-hi' : salPct >= 30 ? 'sal-mid' : 'sal-lo'; var linkCount = m.association_count || 0; var dateStr = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; @@ -4585,6 +4585,16 @@

Activity

// ── Utilities ── function escapeHtml(str) { if (!str) return ''; var div = document.createElement('div'); div.textContent = str; return div.innerHTML; } + + // Map raw source event types to display types + var _sourceTypeMap = { file_created: 'general', file_modified: 'general', repo_changed: 'general', clipboard: 'general', command_run: 'general', handoff: 'general' }; + function memoryType(m) { + var raw = (m.type || m.memory_type || 'general').toLowerCase(); + return _sourceTypeMap[raw] || raw; + } + function memoryTypeAbbr(type) { return type === 'insight' ? 'IN' : type === 'decision' ? 'DE' : type === 'learning' ? 'LE' : type === 'error' ? 'ER' : 'GN'; } + function memoryTypeIcon(type) { return type === 'insight' ? 'icon-in' : type === 'decision' ? 'icon-de' : type === 'learning' ? 'icon-le' : type === 'error' ? 'icon-er' : 'icon-ep'; } + function safeSalience(s) { return Math.min(100, Math.round((s || 0) * 100)); } function simpleMarkdown(str) { if (!str) return ''; var lines = str.split('\n'); From cca709a5ea19f127caa64982ce7ed8957ef45583 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:52:44 -0400 Subject: [PATCH 21/74] =?UTF-8?q?feat:=20structural=20rewrite=20=E2=80=94?= =?UTF-8?q?=20phpBB-inspired=20dl/dt/dd=20forum=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite components.css and all render functions to use phpBB prosilver semantic HTML patterns: CSS: - ul.topiclist > li.row > dl.row-item > dt + dd column layout - dt uses negative margin trick for flexible first column - dd columns float with fixed widths and border-left separators - bg1/bg2 alternating row classes (not :nth-child) - .post with .postprofile (dl sidebar) + .postbody (content area) - blockquote.quote for associated memories - .forabg category containers with gradient headers - .status-icon type badges (EP/IN/DE/LE/ER) JS render functions rewritten (4 of 8): - loadEpisodes: ul.topiclist with dl/dt/dd columns - loadMemories: same structure with sal/links/lastpost columns - renderResults: forum table inside .forabg with score column - loadThread: posts with dl.postprofile sidebar (avatar, rank, salience, links, project, join date) + div.postbody content Studied phpBB prosilver templates at /tmp/phpbb/ for structural reference. Bespoke implementation, not a clone. Part of #339 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/css/components.css | 671 +++++++++++++------------ internal/web/static/index.html | 163 +++--- 2 files changed, 454 insertions(+), 380 deletions(-) diff --git a/internal/web/static/css/components.css b/internal/web/static/css/components.css index 514a42b3..79e611c2 100644 --- a/internal/web/static/css/components.css +++ b/internal/web/static/css/components.css @@ -1,326 +1,394 @@ /* ══════════════════════════════════════════════ mnemonic — Forum Components - Shared components: blocks, rows, icons, tags, - buttons, quote blocks, badges, expand zones + phpBB-inspired semantic structure: + ul.topiclist > li.row > dl.row-item > dt + dd ══════════════════════════════════════════════ */ -/* ── Welcome / stats panel ── */ -.welcome-panel { - margin: 8px 16px; - border: 1px solid var(--border-accent, var(--border-color)); - background: var(--bg-row, var(--bg-secondary)); +/* ── Category container (like phpBB .forabg) ── */ +.forabg { + margin: 6px 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-accent, var(--border-color)); - font-size: 1rem; - font-weight: bold; - color: var(--text-bright, var(--text-primary)); +.forabg .inner { overflow: hidden; } + +/* ── Topic/forum lists (definition list columns) ── */ +ul.topiclist { + display: block; + margin: 0; + padding: 0; + list-style: none; } -.welcome-body { - padding: 8px 10px; +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 */ +ul.topiclist dt { + width: 100%; + margin-right: -320px; font-size: 0.92rem; - display: flex; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px; } -.welcome-body .stats { display: flex; gap: 14px; } -.welcome-body .stat-val { color: var(--text-bright, var(--text-primary)); font-weight: bold; } +ul.topiclist dt .list-inner { + margin-right: 320px; + padding: 6px 10px; + line-height: 1.4; +} -/* ── Forum block — the core container ── */ -.fblock { - margin: 6px 16px; - border: 1px solid var(--border-accent, var(--border-color)); +/* dd columns — fixed widths, floated right */ +ul.topiclist dd { + width: 80px; + text-align: center; + padding: 8px 4px; + font-size: 0.85rem; + color: var(--text-dim); + font-family: var(--mono, 'SF Mono', Monaco, monospace); + font-feature-settings: 'tnum' 1; + border-left: 1px solid var(--border-subtle); +} +ul.topiclist dd.lastpost { + width: 160px; + text-align: right; + padding-right: 10px; + font-family: var(--font, inherit); + font-size: 0.8rem; + 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; + cursor: pointer; + transition: background 0.08s; +} +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; } -.fblock-head { +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; - padding: 5px 10px; - background: linear-gradient(to bottom, rgba(92,114,184,0.12), rgba(92,114,184,0.04)); - border-bottom: 1px solid var(--border-accent, var(--border-color)); cursor: pointer; user-select: none; } -.fblock-title { - font-size: 1.05rem; +.forabg-title { + font-size: 0.95rem; font-weight: bold; - color: var(--text-bright, var(--text-primary)); + color: var(--text-primary); } -.fblock-toggle { - font-size: 0.85rem; - color: var(--text-faint, var(--text-dim)); +.forabg-meta { + font-size: 0.78rem; + color: var(--text-dim); } -.fblock-body.collapsed { display: none; } -/* ── Column headers ── */ -.colh { - display: grid; - gap: 0; - padding: 3px 10px; - font-size: 0.85rem; +/* ── Forum title & description in rows ── */ +.forumtitle { font-weight: bold; - color: var(--text-faint, var(--text-dim)); - background: var(--bg-accent, var(--bg-tertiary)); - border-bottom: 1px solid var(--border-subtle); + color: var(--link, var(--accent-cyan)); + text-decoration: none; + display: block; } -.colh-ep { grid-template-columns: 28px 1fr 52px 52px 130px; } -.colh-mem { grid-template-columns: 28px 1fr 48px 48px 110px; } -.colh span:nth-child(3), -.colh span:nth-child(4) { text-align: center; } -.colh span:last-child { text-align: right; } - -/* ── Forum row ── */ -.frow { - display: grid; - gap: 0; - padding: 6px 10px; - border-bottom: 1px solid var(--border-subtle); - cursor: pointer; - transition: background 0.06s; - align-items: center; +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; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.frow-ep { grid-template-columns: 28px 1fr 52px 52px 130px; } -.frow-mem { grid-template-columns: 28px 1fr 48px 48px 110px; } -.frow:nth-child(odd) { background: var(--bg-row, var(--bg-secondary)); } -.frow:nth-child(even) { background: var(--bg-row-alt, var(--bg-card)); } -.frow:hover { background: var(--bg-row-hover, var(--bg-tertiary)); } -/* Status icon — like vBulletin's folder icons */ -.frow-icon { - width: 22px; height: 22px; - border-radius: 3px; +/* ── 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.72rem; + font-size: 0.68rem; font-weight: bold; - font-family: var(--mono); + font-family: var(--mono, monospace); flex-shrink: 0; } -.icon-ep { background: rgba(92,114,184,0.15); color: var(--link); border: 1px solid rgba(92,114,184,0.25); } -.icon-new { position: relative; } -.icon-new::after { +.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: -2px; right: -2px; - width: 6px; height: 6px; + top: -3px; right: -3px; + width: 7px; height: 7px; border-radius: 50%; - background: var(--accent-green); - border: 1px solid var(--bg-primary); -} -.icon-in { background: rgba(154,114,184,0.12); color: var(--insight); border: 1px solid rgba(154,114,184,0.2); } -.icon-de { background: rgba(208,152,46,0.12); color: var(--decision); border: 1px solid rgba(208,152,46,0.2); } -.icon-le { background: rgba(74,142,184,0.12); color: var(--learning); border: 1px solid rgba(74,142,184,0.2); } -.icon-er { background: rgba(184,90,74,0.12); color: var(--error); border: 1px solid rgba(184,90,74,0.2); } -.icon-sm { width: 18px; height: 18px; font-size: 0.6rem; } - -.frow-content { padding-left: 8px; min-width: 0; } -.frow-title { - font-size: 1rem; - font-weight: bold; - color: var(--link); - text-decoration: none; - display: block; + background: var(--accent-green, #3FB950); + border: 1.5px solid var(--bg-primary); } -.frow:hover .frow-title { color: var(--link-hover, var(--accent-blue)); text-decoration: underline; } -.frow-sub { - font-size: 0.92rem; + +/* ── Concept tags in rows ── */ +.forum-tags { display: inline; margin-left: 4px; } +.forum-tag { + font-size: 0.75rem; color: var(--text-dim); - margin-top: 1px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.frow-tags { display: inline; } -.frow-tag { - font-size: 0.82rem; - color: var(--text-faint, var(--text-dim)); background: rgba(255,255,255,0.03); padding: 0 4px; margin-left: 2px; - font-family: var(--mono); -} -.frow-stat { - font-family: var(--mono); - font-size: 0.92rem; - color: var(--text-dim); - text-align: center; - font-feature-settings: 'tnum' 1; -} -.frow-last { - text-align: right; - font-size: 0.85rem; - line-height: 1.35; + font-family: var(--mono, monospace); + border: 1px solid var(--border-subtle); + border-radius: 2px; } -.frow-last-time { color: var(--text-dim); font-family: var(--mono); font-feature-settings: 'tnum' 1; } -.frow-last-via { color: var(--text-faint, var(--text-dim)); font-size: 0.82rem; } -/* Salience-driven visual weight */ -.sal-hi .frow-title { color: var(--link); } -.sal-mid .frow-title { color: var(--text-dim); font-weight: normal; } -.sal-lo .frow-title { color: var(--text-faint, var(--text-dim)); font-weight: normal; } -.sal-lo .frow-stat { color: var(--text-faint, var(--text-dim)); } +/* ── 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 dropdown — nested memories ── */ +/* ── Expandable zone (nested items under a row) ── */ .expand-zone { display: none; - background: var(--bg-nested, var(--bg-primary)); + background: var(--bg-primary); border-top: 1px solid var(--border-subtle); - border-bottom: 2px solid var(--border-accent, var(--border-color)); + border-bottom: 2px solid var(--border-color); } .expand-zone.open { display: block; } .expand-header { - padding: 4px 10px 4px 46px; - font-size: 0.85rem; - color: var(--text-faint, var(--text-dim)); - background: var(--bg-accent, var(--bg-tertiary)); + 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); text-decoration: none; font-weight: bold; } +.expand-header a { color: var(--link, var(--accent-cyan)); text-decoration: none; font-weight: bold; } .expand-header a:hover { text-decoration: underline; } -.nested-row { - display: grid; - grid-template-columns: 46px 22px 1fr 48px 80px; - gap: 0; - padding: 5px 10px; - border-bottom: 1px solid rgba(26,26,40,0.5); - transition: background 0.06s; - align-items: center; - cursor: pointer; + +/* ══════════════════════════════════════════════ + POST LAYOUT (Thread view — phpBB postbit) + ══════════════════════════════════════════════ */ + +.post { + overflow: hidden; + border-bottom: 1px solid var(--border-color); } -.nested-row:nth-child(odd) { background: rgba(13,13,18,0.8); } -.nested-row:nth-child(even) { background: rgba(15,15,21,0.6); } -.nested-row:hover { background: var(--bg-row-hover, var(--bg-tertiary)); } -.nested-indent { border-left: 2px solid var(--border-subtle); height: 100%; margin-left: 20px; } -.nested-text { - font-size: 0.92rem; - color: var(--text-secondary, var(--text-primary)); - padding-left: 6px; +.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; } -.nested-text:hover { color: var(--link-hover, var(--accent-blue)); } -.nested-sal { font-family: var(--mono); font-size: 0.85rem; color: var(--text-faint, var(--text-dim)); text-align: center; font-feature-settings: 'tnum' 1; } -.nested-time { font-family: var(--mono); font-size: 0.82rem; color: var(--text-faint, var(--text-dim)); text-align: right; font-feature-settings: 'tnum' 1; } -/* ── Thread detail: posts ── */ -.thread-wrap { - margin: 8px 16px; - border: 1px solid var(--border-accent, var(--border-color)); -} -.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-accent, var(--border-color)); +/* 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; } -.thread-title-big { - font-size: 1.3rem; +.postprofile dt { font-weight: bold; - color: var(--text-bright, var(--text-primary)); - margin-bottom: 4px; + color: var(--text-primary); + margin-bottom: 6px; + font-size: 0.88rem; } -.thread-meta { - font-size: 0.92rem; - color: var(--text-dim); +.postprofile .avatar-container { + width: 48px; height: 48px; + margin: 0 auto 6px; + border-radius: 6px; display: flex; - gap: 14px; - flex-wrap: wrap; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: bold; + font-family: var(--mono, monospace); } -.thread-meta b { color: var(--text-secondary, var(--text-primary)); font-weight: bold; } - -/* Post — vBulletin classic layout */ -.post { - display: grid; - grid-template-columns: 120px 1fr; - border-bottom: 1px solid var(--border-accent, var(--border-color)); -} -.post:nth-child(odd) { background: var(--bg-row, var(--bg-secondary)); } -.post:nth-child(even) { background: var(--bg-row-alt, var(--bg-card)); } -.post-left { - padding: 8px; - border-right: 1px solid var(--border-subtle); - font-size: 0.85rem; - text-align: center; +.postprofile dd { + font-size: 0.75rem; + color: var(--text-dim); + margin: 2px 0; + list-style: none; } -.post-type-badge { +.postprofile dd strong { color: var(--text-secondary); } +.postprofile .profile-rank { + font-size: 0.72rem; font-weight: bold; - font-size: 0.92rem; text-transform: uppercase; + letter-spacing: 0.03em; + padding: 2px 6px; + border-radius: 3px; + display: inline-block; margin-bottom: 6px; - padding: 2px 0; - border-radius: 2px; } -.post-type-badge--insight { color: var(--insight); background: rgba(154,114,184,0.1); } -.post-type-badge--decision { color: var(--decision); background: rgba(208,152,46,0.1); } -.post-type-badge--learning { color: var(--learning); background: rgba(74,142,184,0.1); } -.post-type-badge--error { color: var(--error); background: rgba(184,90,74,0.1); } -.post-type-badge--general { color: var(--text-dim); background: rgba(255,255,255,0.05); } -.post-stat-row { - font-family: var(--mono); - font-size: 0.82rem; - color: var(--text-faint, var(--text-dim)); - margin: 2px 0; +.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; } -.post-stat-row b { color: var(--text-dim); } -.post-right { padding: 8px 10px; } -.post-head { +.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; - font-size: 0.82rem; - color: var(--text-faint, var(--text-dim)); - border-bottom: 1px solid var(--border-subtle); - padding-bottom: 4px; - margin-bottom: 8px; } -.post-head a { color: var(--link); text-decoration: none; } -.post-head a:hover { text-decoration: underline; } -.post-body-text { - font-size: 1rem; +.postbody .content { + font-size: 0.88rem; line-height: 1.55; - color: var(--text-bright, var(--text-primary)); + color: var(--text-secondary); margin-bottom: 8px; } -.post-tags { display: flex; gap: 3px; flex-wrap: wrap; margin-bottom: 8px; } -.post-tag { - font-size: 0.78rem; - color: var(--text-dim); - background: rgba(255,255,255,0.03); - padding: 1px 5px; - font-family: var(--mono); - border: 1px solid var(--border-subtle); +.postbody .post-tags { + display: flex; + gap: 3px; + flex-wrap: wrap; + margin-top: 8px; } -/* Quoted association — like quoting a forum post */ -.quote-block { - border: 1px solid var(--border-subtle); - margin-top: 6px; - background: rgba(255,255,255,0.015); -} -.quote-head { - padding: 3px 8px; - font-size: 0.82rem; - color: var(--text-faint, var(--text-dim)); +/* 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-bottom: 1px solid var(--border-subtle); + border-radius: 0 4px 4px 0; +} +blockquote.quote .quote-header { + font-size: 0.75rem; font-weight: bold; + color: var(--text-dim); + margin-bottom: 4px; } -.quote-body { - padding: 6px 8px; - font-size: 0.92rem; +blockquote.quote .quote-body { + font-size: 0.85rem; color: var(--text-dim); font-style: italic; } -/* ── Recall results ── */ +/* ══════════════════════════════════════════════ + 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-row, var(--bg-secondary)); + background: var(--bg-secondary); } .recall-bar input { flex: 1; @@ -328,107 +396,70 @@ border: 1px solid var(--border-subtle); color: var(--text-primary); padding: 4px 8px; - font-size: 1rem; - font-family: var(--font, inherit); + font-size: 0.92rem; + font-family: inherit; outline: none; + border-radius: 3px; } -.recall-bar input:focus { border-color: var(--link); } -.recall-bar input::placeholder { color: var(--text-faint, var(--text-dim)); } +.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.85rem; - color: var(--text-faint, var(--text-dim)); - font-family: var(--mono); - border-bottom: 1px solid var(--border-subtle); -} -.rcolh { - display: grid; - grid-template-columns: 40px 28px 1fr 48px 48px 90px; - gap: 0; - padding: 3px 16px; - font-size: 0.82rem; - font-weight: bold; - color: var(--text-faint, var(--text-dim)); - background: var(--bg-accent, var(--bg-tertiary)); - border-bottom: 1px solid var(--border-subtle); -} -.rcolh span:nth-child(1) { text-align: center; } -.rcolh span:nth-child(4), -.rcolh span:nth-child(5) { text-align: center; } -.rcolh span:last-child { text-align: right; } -.rr { - display: grid; - grid-template-columns: 40px 28px 1fr 48px 48px 90px; - gap: 0; - padding: 6px 16px; + font-size: 0.8rem; + color: var(--text-dim); + font-family: var(--mono, monospace); border-bottom: 1px solid var(--border-subtle); - cursor: pointer; - transition: background 0.06s; - align-items: center; } -.rr:nth-child(odd) { background: var(--bg-row, var(--bg-secondary)); } -.rr:nth-child(even) { background: var(--bg-row-alt, var(--bg-card)); } -.rr:hover { background: var(--bg-row-hover, var(--bg-tertiary)); } -.rr-score { font-family: var(--mono); font-size: 1.05rem; font-weight: bold; text-align: center; font-feature-settings: 'tnum' 1, 'zero' 1; } -.rr-score-hi { color: var(--link); } -.rr-score-mid { color: var(--text-dim); } -.rr-score-lo { color: var(--text-faint, var(--text-dim)); } -.rr-title { font-size: 0.92rem; color: var(--link); padding-left: 6px; } -.rr:hover .rr-title { color: var(--link-hover, var(--accent-blue)); text-decoration: underline; } - -/* ── Timeline ── */ + +/* ══════════════════════════════════════════════ + TIMELINE + ══════════════════════════════════════════════ */ + .tl-head { padding: 5px 16px; - font-size: 0.92rem; + font-size: 0.88rem; font-weight: bold; color: var(--text-dim); - background: var(--bg-row, var(--bg-secondary)); - border-bottom: 1px solid var(--border-subtle); + 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-faint, var(--text-dim)); } -.tlr { - display: grid; - grid-template-columns: 50px 22px 1fr 44px; - gap: 6px; - padding: 5px 16px; - border-bottom: 1px solid var(--border-subtle); - cursor: pointer; - transition: background 0.06s; - align-items: center; -} -.tlr:nth-child(odd) { background: var(--bg-row, var(--bg-secondary)); } -.tlr:nth-child(even) { background: var(--bg-row-alt, var(--bg-card)); } -.tlr:hover { background: var(--bg-row-hover, var(--bg-tertiary)); } -.tlr-time { font-family: var(--mono); font-size: 0.85rem; color: var(--text-faint, var(--text-dim)); text-align: right; font-feature-settings: 'tnum' 1; } +.tl-head-count { font-weight: normal; color: var(--text-dim); font-size: 0.82rem; } + +/* Timeline uses same topiclist structure */ .tlr-dot { width: 8px; height: 8px; border-radius: 2px; margin: 0 auto; } -.tlr-text { font-size: 0.92rem; color: var(--text-secondary, var(--text-primary)); } -.tlr-text:hover { color: var(--link-hover, var(--accent-blue)); } -.tlr-sal { font-family: var(--mono); font-size: 0.82rem; color: var(--text-faint, var(--text-dim)); text-align: right; } +.tlr-time { font-family: var(--mono, monospace); font-size: 0.82rem; color: var(--text-dim); text-align: right; font-feature-settings: 'tnum' 1; } +.tlr-sal { font-family: var(--mono, monospace); font-size: 0.78rem; color: var(--text-dim); text-align: right; } -/* ── Buttons ── */ -.btn2 { - font-size: 0.92rem; +/* ══════════════════════════════════════════════ + BUTTONS + ══════════════════════════════════════════════ */ + +.btn-forum { + font-size: 0.85rem; font-weight: bold; - color: var(--text-bright, var(--text-primary)); - background: var(--accent-bar, var(--accent-blue)); - border: 1px solid var(--link); - padding: 3px 12px; + 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: var(--font, inherit); + font-family: inherit; + border-radius: 3px; transition: background 0.1s; } -.btn2:hover { filter: brightness(1.15); } +.btn-forum:hover { background: rgba(92,114,184,0.25); } + +/* ══════════════════════════════════════════════ + TOAST NOTIFICATIONS + ══════════════════════════════════════════════ */ -/* ── Toast ── */ .toast-container { position: fixed; - top: 60px; - right: 16px; + top: 60px; right: 16px; z-index: 1000; display: flex; flex-direction: column; @@ -437,7 +468,7 @@ .toast { padding: 8px 14px; border-radius: 4px; - font-size: 0.85rem; + font-size: 0.82rem; color: var(--text-primary); background: var(--bg-secondary); border: 1px solid var(--border-color); @@ -450,3 +481,15 @@ from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } } + +/* ══════════════════════════════════════════════ + RESPONSIVE + ══════════════════════════════════════════════ */ +@media (max-width: 640px) { + ul.topiclist dt { margin-right: -160px; } + ul.topiclist dt .list-inner { margin-right: 160px; } + ul.topiclist dd { width: 50px; font-size: 0.75rem; } + ul.topiclist dd.lastpost { width: 110px; } + .postprofile { width: 100px; } + .forabg { margin: 4px 8px; } +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 2d8364ac..23b0c568 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1913,28 +1913,32 @@

Activity

} var hops = data.hops || 0; var html = '
' + memories.length + ' result' + (memories.length !== 1 ? 's' : '') + ' · ' + (data.took_ms || 0) + 'ms' + (hops ? ' · spread activation: ' + hops + ' hops' : '') + '
'; - html += '
ScoreMemorySalLinksCreated
'; - memories.forEach(function(mem) { + html += '
'; + html += '
  • Memory
    Score
    Sal
    Created
'; + html += '
    '; + memories.forEach(function(mem, idx) { var score = mem.score || 0; - var scoreClass = score > 0.7 ? 'rr-score-hi' : score > 0.4 ? 'rr-score-mid' : 'rr-score-lo'; + var scoreColor = score > 0.7 ? 'var(--link)' : score > 0.4 ? 'var(--text-secondary)' : 'var(--text-dim)'; var m = mem.memory; var type = memoryType(m); var typeAbbr = memoryTypeAbbr(type); var iconClass = memoryTypeIcon(type); var salPct = safeSalience(m.salience); - var linkCount = m.association_count || 0; var dateStr = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; var summary = m.summary || (m.content || '').slice(0, 150); - - html += '
    '; - html += '
    ' + score.toFixed(2) + '
    '; - html += '
    ' + typeAbbr + '
    '; - html += '
    ' + escapeHtml(summary) + '
    '; - html += '
    ' + salPct + '%
    '; - html += '
    ' + linkCount + '
    '; - html += '
    ' + dateStr + '
    '; - html += '
    '; + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + html += '
  • '; + html += '
    '; + html += '' + typeAbbr + ''; + html += ''; + html += '
    '; + html += '
    ' + score.toFixed(2) + '
    '; + html += '
    ' + salPct + '%
    '; + html += '
    ' + dateStr + '
    '; + html += '
  • '; }); + html += '
'; html += '
'; if (memories.length > 0) { - memories.forEach(function(m, idx) { var type = memoryType(m); - var typeBadgeClass = 'post-type-badge--' + type; + var rankClass = 'rank-' + type; var salPct = safeSalience(m.salience); var linkCount = m.association_count || 0; var source = m.source || 'mcp'; var time = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; + var joinDate = m.created_at ? new Date(m.created_at).toLocaleDateString() : ''; var concepts = m.concepts || []; - - html += '
'; - // Left sidebar - html += '
'; - html += '
' + escapeHtml(type) + '
'; - html += '
Salience: ' + salPct + '%
'; - html += '
Links: ' + linkCount + '
'; - html += '
Source: ' + escapeHtml(source) + '
'; - html += '
' + time + '
'; - html += '
'; - // Right content - html += '
'; - html += '
Memory #' + (idx + 1) + ' of ' + memories.length + 'Copy ID
'; - html += '
' + escapeHtml(m.summary || m.content || '') + '
'; + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + var iconClass = memoryTypeIcon(type); + + // phpBB-style post with profile sidebar + html += '
'; + + // Profile sidebar (like phpBB postprofile) + html += '
'; + html += '
' + memoryTypeAbbr(type) + '
'; + html += '' + escapeHtml(source) + '
'; + html += '
' + escapeHtml(type) + '
'; + html += '
Salience: ' + salPct + '%
'; + html += '
Links: ' + linkCount + '
'; + if (m.project) html += '
Project: ' + escapeHtml(m.project) + '
'; + html += '
Joined: ' + joinDate + '
'; + html += '
'; + + // Post body + html += '
'; + html += '

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

'; + html += ''; + + // Content if (m.content && m.content !== m.summary) { - html += '
' + escapeHtml(m.content) + '
'; + html += '
' + escapeHtml(m.content) + '
'; + } else if (m.summary) { + html += '
' + escapeHtml(m.summary) + '
'; } + + // Concept tags if (concepts.length > 0) { html += ''; } - html += '
'; + + html += '
'; // close postbody, inner, post }); } @@ -2118,7 +2137,9 @@

Activity

var data = await fetchJSON('/episodes?limit=50'); var episodes = data.episodes || []; if (episodes.length === 0) { section.innerHTML = '
📚
No episodes yet
Episodes are created as memories accumulate
'; return; } - var html = '
EpisodeMemFilesLast Activity
'; + // phpBB-style header + row list + var html = '
  • Episode
    Mem
    Files
    Last Activity
'; + html += '
    '; html += episodes.map(function(ep, idx) { var concepts = (ep.concepts || []).slice(0, 4); var memCount = (ep.raw_memory_ids || []).length; @@ -2127,28 +2148,33 @@

    Activity

    var epTitle = ep.title || (ep.state === 'open' ? 'Episode in progress\u2026' : 'Untitled Episode'); var isNew = ep.state === 'open'; var tone = ep.emotional_tone || ep.outcome || ''; - var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); - var sub = ep.summary ? escapeHtml(ep.summary).substring(0, 80) : ''; - if (tags) sub += ' ' + tags; + var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + var sub = ep.summary ? escapeHtml(ep.summary).substring(0, 100) : ''; var expandId = 'exp-ep-' + idx; - - var row = '
    '; - row += '
    EP
    '; - row += '
    ' + escapeHtml(epTitle) + '
    ' + sub + '
    '; - row += '
    ' + memCount + '
    '; - row += '
    ' + fileCount + '
    '; - row += '
    ' + dateStr + '
    ' + escapeHtml(tone) + '
    '; - row += '
    '; - - // Expandable zone with nested memories + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + var row = '
  • '; + row += '
    '; + row += 'EP'; + row += '
    '; + row += '' + escapeHtml(epTitle) + ''; + row += '' + sub + ' ' + tags + ''; + row += '
    '; + row += '
    ' + memCount + ' memories
    '; + row += '
    ' + fileCount + ' files
    '; + row += '
    ' + dateStr + '
    ' + escapeHtml(tone) + '
    '; + row += '
  • '; + + // Expandable zone row += '
    '; - row += '
    ' + memCount + ' memories in this episodeView full thread →
    '; + row += '
    ' + memCount + ' observations in this episodeView full thread →
    '; if (ep.narrative) { - row += '
    ' + escapeHtml(ep.narrative) + '
    '; + row += '
    ' + escapeHtml(ep.narrative) + '
    '; } row += '
    '; return row; }).join(''); + html += '
'; section.innerHTML = html; } @@ -2156,8 +2182,9 @@

Activity

var data = await fetchJSON('/memories?limit=50&state=active'); var memories = data.memories || []; if (memories.length === 0) { section.innerHTML = '
🧠
No memories yet
Use the Remember panel to add your first memory
'; return; } - var html = '
MemorySalLinksCreated
'; - html += memories.map(function(m) { + var html = '
  • Memory
    Sal
    Links
    Created
'; + html += '
    '; + html += memories.map(function(m, idx) { var type = memoryType(m); var typeAbbr = memoryTypeAbbr(type); var iconClass = memoryTypeIcon(type); @@ -2165,34 +2192,38 @@

    Activity

    var salClass = salPct >= 60 ? 'sal-hi' : salPct >= 30 ? 'sal-mid' : 'sal-lo'; var linkCount = m.association_count || 0; var dateStr = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; - var dateDay = m.created_at ? relativeTime(new Date(m.created_at)) : ''; var summary = m.summary || m.content || ''; var concepts = (m.concepts || []).slice(0, 4); - var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); var source = m.source || 'mcp'; - - var row = '
    '; - row += '
    ' + typeAbbr + '
    '; - row += '
    ' + escapeHtml(summary) + ''; - row += '
    ' + tags + '
    '; - row += '
    ' + salPct + '%
    '; - row += '
    ' + linkCount + '
    '; - row += '
    ' + dateStr + '
    ' + escapeHtml(source) + '
    '; - row += '
    '; + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + var row = '
  • '; + row += '
    '; + row += '' + typeAbbr + ''; + row += '
    '; + row += '' + escapeHtml(summary) + ''; + row += '' + tags + ''; + row += '
    '; + row += '
    ' + salPct + '% salience
    '; + row += '
    ' + linkCount + ' links
    '; + row += '
    ' + dateStr + '
    ' + escapeHtml(source) + '
    '; + row += '
  • '; // Expandable detail row += '
    '; if (m.content && m.content !== summary) { - row += '
    ' + escapeHtml(m.content) + '
    '; + row += '
    ' + escapeHtml(m.content) + '
    '; } - row += '
    '; + row += '
    '; row += 'ID: ' + escapeHtml((m.id || '').substring(0, 8)) + '...'; - if (m.project) row += ' · Project: ' + escapeHtml(m.project); - if (m.access_count > 0) row += ' · Recalled ' + m.access_count + 'x'; - row += ' · ' + escapeHtml(m.state || 'active'); + if (m.project) row += ' · Project: ' + escapeHtml(m.project); + if (m.access_count > 0) row += ' · Recalled ' + m.access_count + 'x'; + row += ' · ' + escapeHtml(m.state || 'active'); row += '
    '; return row; }).join(''); + html += '
'; section.innerHTML = html; } From 26cf76ced3b62348cdb9ee7691f29ba7fe28af25 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:58:10 -0400 Subject: [PATCH 22/74] =?UTF-8?q?feat:=20complete=20structural=20rewrite?= =?UTF-8?q?=20=E2=80=94=20all=20views=20use=20phpBB=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite remaining render functions to phpBB dl/dt/dd layout: - renderTimelineCard: li.row with dl/dt/dd + concept tags preserving tl-source/tl-concept classes for tag filtering compatibility - renderTimelineItems: date groups wrapped in .forabg containers - loadPatterns: forum rows with sticky class, archive button - loadAbstractions: forum rows with announce class, level badges Replace Explore tabbed layout with collapsible .forabg blocks: - Episodes, Recent Memories, Discovered Patterns, Abstractions shown simultaneously as forum categories - Click category header to collapse/expand (.collapsed class) - All 4 sections load in parallel on first visit - Welcome panel placeholder with stats Update filterExplore to query li.row elements. Part of #339 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/css/components.css | 1 + internal/web/static/index.html | 211 +++++++++++++++---------- 2 files changed, 129 insertions(+), 83 deletions(-) diff --git a/internal/web/static/css/components.css b/internal/web/static/css/components.css index 79e611c2..f9dc7c5e 100644 --- a/internal/web/static/css/components.css +++ b/internal/web/static/css/components.css @@ -12,6 +12,7 @@ overflow: hidden; } .forabg .inner { overflow: hidden; } +.forabg .inner.collapsed { display: none; } /* ── Topic/forum lists (definition list columns) ── */ ul.topiclist { diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 23b0c568..89725249 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1356,25 +1356,48 @@

What do you remember?

- +
-
-
-
- - - - +
@@ -1847,7 +1870,16 @@

Activity

var tab = document.querySelector('.ntab[data-view="' + name + '"]') || document.querySelector('.nav-tab[data-view="' + name + '"]'); if (tab) tab.classList.add('active'); window.location.hash = name; - if (name === 'explore' && !state.exploreLoaded[state.currentExploreTab]) loadExploreTab(state.currentExploreTab); + if (name === 'explore' && !state.exploreLoaded.episodes) { + // Load all forum blocks in parallel + loadExploreTab('episodes'); + loadExploreTab('memories'); + loadExploreTab('patterns'); + loadExploreTab('abstractions'); + // Populate welcome panel + var wp = document.getElementById('forumWelcome'); + if (wp) wp.style.display = ''; + } if (name === 'timeline' && !state.timelineInitialized) { populateTimelineProjects(); loadTimelineData(false); } if (name === 'agent' && !state.agentLoaded) loadAgentData(); if (name === 'llm' && !state.llmLoaded) loadLLMUsage(); @@ -2231,26 +2263,31 @@

Activity

var data = await fetchJSON('/patterns?limit=20'); var patterns = data.patterns || []; if (patterns.length === 0) { section.innerHTML = '
📈
No patterns discovered yet
Patterns emerge after consolidation cycles
'; return; } - section.innerHTML = patterns.map(function(p) { + var html = '
  • Pattern
    Str
    Evidence
    Discovered
'; + html += '
    '; + html += patterns.map(function(p, idx) { var strengthPct = Math.round((p.strength || 0) * 100); - var concepts = (p.concepts || []).slice(0, 5); + var concepts = (p.concepts || []).slice(0, 4); var evidenceCount = (p.evidence_ids || []).length; var age = p.created_at ? relativeTime(p.created_at) : ''; - var html = '
    ' + escapeHtml(p.title || 'Untitled'); - html += ''; - if (p.project) html += ' [' + escapeHtml(p.project) + ']'; - html += '
    ' + escapeHtml(p.pattern_type || 'pattern') + ''; - if (p.state && p.state !== 'active') html += ' ' + escapeHtml(p.state) + ''; - html += '
    ' + escapeHtml(p.description || '') + '
    '; - html += '
    '; - html += 'Strength ' + strengthPct + '%'; - if (evidenceCount > 0) html += '' + evidenceCount + ' evidence'; - if (p.access_count > 0) html += '' + p.access_count + ' accesses'; - if (age) html += '' + age + ''; - concepts.forEach(function(c) { html += '' + escapeHtml(c) + ''; }); - html += '
    '; - return html; + var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + + var row = '
  • '; + row += '
    '; + row += 'PT'; + row += '
    '; + row += '' + escapeHtml(p.title || 'Untitled') + ''; + row += '' + escapeHtml((p.description || '').substring(0, 100)) + ' ' + tags + ''; + row += '
    '; + row += '
    ' + strengthPct + '%
    '; + row += '
    ' + evidenceCount + '
    '; + row += '
    ' + age + '
    '; + row += '
  • '; + return row; }).join(''); + html += '
'; + section.innerHTML = html; } async function archivePattern(id, btn) { @@ -2271,33 +2308,40 @@

Activity

var data = await fetchJSON('/abstractions?limit=20'); var abstractions = data.abstractions || []; if (abstractions.length === 0) { section.innerHTML = '
💡
No abstractions yet
Abstractions form from patterns during dream cycles
'; return; } - section.innerHTML = abstractions.map(function(a) { + var html = '
  • Abstraction
    Conf
    Sources
    Discovered
'; + html += '
    '; + html += abstractions.map(function(a, idx) { var levelLabel = a.level === 3 ? 'Axiom' : a.level === 2 ? 'Principle' : 'L' + a.level; var confPct = Math.round((a.confidence || 0) * 100); - var concepts = (a.concepts || []).slice(0, 5); + var concepts = (a.concepts || []).slice(0, 4); var sourceCount = (a.source_pattern_ids || []).length; var age = a.created_at ? relativeTime(a.created_at) : ''; - var html = '
    ' + escapeHtml(a.title || 'Untitled'); - html += ' ' + levelLabel + ''; - if (a.state && a.state !== 'active') html += ' ' + escapeHtml(a.state) + ''; - html += '
    '; - html += '
    ' + escapeHtml(a.description || '') + '
    '; - html += '
    '; - html += 'Confidence ' + confPct + '%'; - if (sourceCount > 0) html += '' + sourceCount + ' sources'; - if (a.access_count > 0) html += '' + a.access_count + ' accesses'; - if (age) html += '' + age + ''; - concepts.forEach(function(c) { html += '' + escapeHtml(c) + ''; }); - html += '
    '; - return html; + var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); + var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; + var iconLabel = a.level === 3 ? 'AX' : a.level === 2 ? 'PR' : 'AB'; + + var row = '
  • '; + row += '
    '; + row += '' + iconLabel + ''; + row += '
    '; + row += '' + escapeHtml(a.title || 'Untitled') + ' [' + levelLabel + ']'; + row += '' + escapeHtml((a.description || '').substring(0, 120)) + ' ' + tags + ''; + row += '
    '; + row += '
    ' + confPct + '%
    '; + row += '
    ' + sourceCount + '
    '; + row += '
    ' + age + '
    '; + row += '
  • '; + return row; }).join(''); + html += '
'; + section.innerHTML = html; } function filterExplore() { var query = document.getElementById('exploreSearch').value.toLowerCase(); var section = document.getElementById('section-' + state.currentExploreTab); - var cards = section.querySelectorAll('.episode-card, .memory-card, .pattern-card, .abstraction-card'); - cards.forEach(function(card) { card.style.display = card.textContent.toLowerCase().includes(query) ? '' : 'none'; }); + var rows = section.querySelectorAll('li.row, .pattern-card, .abstraction-card'); + rows.forEach(function(row) { row.style.display = row.textContent.toLowerCase().includes(query) ? '' : 'none'; }); } // ── Timeline ── @@ -2442,10 +2486,13 @@

Activity

var groups = groupByDate(filtered); var html = ''; groups.forEach(function(group) { - html += '
' + escapeHtml(group.label) + '' + group.items.length + ' memories
'; - group.items.forEach(function(item) { - html += renderTimelineCard(item); + html += '
'; + html += '
  • ' + escapeHtml(group.label) + '
    ' + group.items.length + ' memories
'; + html += '
    '; + group.items.forEach(function(item, idx) { + html += renderTimelineCard(item, idx); }); + html += '
'; }); if (!_timelineState.allLoaded) { html += '
Loading more...
'; @@ -2454,69 +2501,67 @@

Activity

setupTimelineScroll(); } - function renderTimelineCard(item) { + function renderTimelineCard(item, idx) { var kind = item._kind; - var salPct = Math.round((item._salience || 0) * 100); + var salPct = Math.min(100, Math.round((item._salience || 0) * 100)); var absTime = item._date.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }); var concepts = item._concepts || []; var source = item._source || ''; var project = item._project || ''; + var typeAbbr = kind === 'insight' ? 'IN' : kind === 'decision' ? 'DE' : kind === 'learning' ? 'LE' : kind === 'error' ? 'ER' : kind === 'episode' ? 'EP' : 'GN'; + var iconClass = kind === 'insight' ? 'icon-in' : kind === 'decision' ? 'icon-de' : kind === 'learning' ? 'icon-le' : kind === 'error' ? 'icon-er' : kind === 'episode' ? 'icon-ep' : 'icon-gn'; - // Type → color mapping for the dot - var dotColor = kind === 'insight' ? 'var(--insight)' : kind === 'decision' ? 'var(--decision)' : kind === 'learning' ? 'var(--learning)' : kind === 'error' ? 'var(--error)' : 'var(--text-dim)'; - - // Include source and project in data-concepts so hover-highlight works across tags + // Include source and project in data-concepts so hover-highlight works var allTags = concepts.slice(); if (source) allTags.unshift(source); if (project) allTags.unshift(project); + var bgClass = (idx || 0) % 2 === 0 ? 'bg1' : 'bg2'; - // Forum-style timeline row - var html = '
'; - html += '
' + absTime + '
'; - html += '
'; - html += '
' + escapeHtml(item._title); + html += '
'; + html += '' + typeAbbr + ''; + html += '
'; + html += '' + escapeHtml(item._title) + ''; - // Inline concept tags for hover/click filtering + // Concept tags (keep tl-source/tl-concept classes for tag filtering) if (concepts.length > 0 || source) { - html += ' '; + html += ''; if (source) { - html += '' + escapeHtml(source) + ''; + html += '' + escapeHtml(source) + ''; } concepts.slice(0, 5).forEach(function(c) { - html += '' + escapeHtml(c) + ''; + html += '' + escapeHtml(c) + ''; }); html += ''; } - html += '
'; - html += '
' + salPct + '%
'; - html += '
'; + html += '
'; + html += '
' + absTime + '
'; + html += '
' + salPct + '%
'; + html += ''; - // Expandable detail (hidden until clicked) - html += '
'; + // Expandable detail + html += '
'; + html += '
'; if (item._type === 'episode') { if (item._raw.summary) html += '
' + escapeHtml(item._raw.summary) + '
'; if (item._raw.narrative) html += '
' + escapeHtml(item._raw.narrative) + '
'; - if (item._raw.files_modified && item._raw.files_modified.length > 0) { - html += '
'; - item._raw.files_modified.slice(0, 5).forEach(function(f) { html += escapeHtml(f) + ' '; }); - html += '
'; - } } else { if (item._raw.content && item._raw.content !== item._raw.summary) { html += '
' + escapeHtml(item._raw.content) + '
'; } } - html += '
'; + html += '
'; return html; } - function expandTimelineCard(card) { - var detail = card.nextElementSibling; - if (detail && detail.classList.contains('tl-card-detail')) detail.classList.toggle('open'); - // Also check for child detail (backward compat) - var childDetail = card.querySelector('.tl-card-detail'); - if (childDetail) childDetail.classList.toggle('open'); + function expandTimelineCard(el) { + // Find the expand-zone that follows this li.row + var next = el.nextElementSibling; + if (next && next.classList.contains('expand-zone')) next.classList.toggle('open'); + // Also try tl-card-detail class + if (next && next.classList.contains('tl-card-detail')) next.classList.toggle('open'); } var _selectedTimelineTags = new Set(); From ef88f514abd33e055668e22386c9d925d1c8539c Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 22:59:43 -0400 Subject: [PATCH 23/74] feat: populate welcome panel with stats + last visit tracking Welcome panel shows active/fading/archived counts from /stats endpoint. Tracks last visit timestamp in localStorage and shows "Last visit: date" or "Welcome to mnemonic" for first-time visitors. Timestamps stored for future "new since last visit" feature. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 89725249..35b927c5 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -3094,6 +3094,23 @@

Activity

var d = new Date(stats.last_consolidation); fc.textContent = 'consolidation: ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); } + // Update welcome panel + var wa = document.getElementById('welcomeActive'); + var wf = document.getElementById('welcomeFading'); + var war = document.getElementById('welcomeArchived'); + var wv = document.getElementById('welcomeVisit'); + if (wa) wa.textContent = stats.store ? stats.store.active_memories : total; + if (wf) wf.textContent = stats.store ? stats.store.fading_memories : 0; + if (war) war.textContent = stats.store ? stats.store.archived_memories : 0; + if (wv) { + var lastVisit = localStorage.getItem('mnemonic-last-visit'); + if (lastVisit) { + wv.textContent = 'Last visit: ' + new Date(parseInt(lastVisit)).toLocaleString([], {hour:'2-digit',minute:'2-digit',month:'short',day:'numeric'}); + } else { + wv.textContent = 'Welcome to mnemonic'; + } + localStorage.setItem('mnemonic-last-visit', Date.now().toString()); + } // Update healthDot (forum nav uses different ID) var hd = document.getElementById('healthDot'); if (hd) { From 6d53442c2a316063d988a00f5bf67f5d510fcedc Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:01:31 -0400 Subject: [PATCH 24/74] =?UTF-8?q?fix:=20D3=20shim=20=E2=80=94=20stack().va?= =?UTF-8?q?lue(),=20datum(),=20attr(fn)=20for=20chart=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix research analytics charts not rendering: - stack() now supports .value(fn) method and attaches .data property to each stack point (D3 convention for accessing original data row) - datum(d) now stores data on element and returns wrapper for chaining - attr(name, fn) now calls fn(datum) when value is a function, matching D3's behavior for things like .attr('d', areaGenerator) - Individual try/catch around each chart render for isolated debugging Part of #346 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 35b927c5..9411aa38 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -3218,7 +3218,7 @@

Activity

el.appendChild(child); return wrap(child); }, - attr: function(name, val) { if (val === undefined) return el.getAttribute(name); el.setAttribute(name, val); return obj; }, + attr: function(name, val) { if (val === undefined) return el.getAttribute(name); el.setAttribute(name, typeof val === 'function' ? val(el.__data__) : val); return obj; }, style: function(name, val) { if (val === undefined) return el.style[name]; el.style[name] = val; return obj; }, text: function(val) { if (val === undefined) return el.textContent; el.textContent = val; return obj; }, html: function(val) { if (val === undefined) return el.innerHTML; el.innerHTML = val; return obj; }, @@ -3228,7 +3228,7 @@

Activity

selectAll: function(sel) { return wrapAll(el.querySelectorAll(sel), el); }, select: function(sel) { return wrap(el.querySelector(sel)); }, classed: function(cls, val) { if (val) el.classList.add(cls); else el.classList.remove(cls); return obj; }, - datum: function() { return obj; }, + datum: function(d) { if (d !== undefined) { el.__data__ = d; return obj; } return el.__data__; }, call: function(fn) { fn(obj); return obj; }, empty: function() { return false; } }; @@ -3405,12 +3405,16 @@

Activity

function stack() { var _keys = []; + var _value = function(d, key) { return d[key] || 0; }; function s(data) { - var result = _keys.map(function(key) { + var result = _keys.map(function(key, keyIdx) { var series = data.map(function(d, i) { var y0 = 0; - _keys.forEach(function(k) { if (k === key) return; if (_keys.indexOf(k) < _keys.indexOf(key)) y0 += (d[k] || 0); }); - return [y0, y0 + (d[key] || 0)]; + for (var k = 0; k < keyIdx; k++) y0 += _value(d, _keys[k]); + var y1 = y0 + _value(d, key); + var point = [y0, y1]; + point.data = d; // D3 attaches original data row + return point; }); series.key = key; return series; @@ -3418,6 +3422,7 @@

Activity

return result; } s.keys = function(k) { _keys = k; return s; }; + s.value = function(fn) { _value = fn; return s; }; return s; } @@ -4189,13 +4194,13 @@

Activity

} catch(e) { /* analysis is optional, fail silently */ } // ── Memory Lifecycle Chart (D3 stacked area) ── - renderLifecycleChart(svData, fbData, chData); + try { renderLifecycleChart(svData, fbData, chData); } catch(e) { console.error('Lifecycle chart error:', e); } // ── Signal Quality by Source (D3 horizontal bars) ── - renderSignalChart(sn); + try { renderSignalChart(sn); } catch(e) { console.error('Signal chart error:', e); } // ── Recall Learning Curve (D3 connected dots) ── - renderRecallChart(re); + try { renderRecallChart(re); } catch(e) { console.error('Recall chart error:', e); } } catch(e) { console.error('Analytics load failed:', e); From afe0d62b42745ec71becc086725c2a9cf5886f71 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:02:45 -0400 Subject: [PATCH 25/74] feat: breadcrumbs update on every view switch switchView() now updates #breadcrumbs with view-specific text: Search, Forum, Timeline, SDK, LLM Usage, Tools. Previously only the thread view updated breadcrumbs. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 9411aa38..19521b94 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1870,6 +1870,10 @@

Activity

var tab = document.querySelector('.ntab[data-view="' + name + '"]') || document.querySelector('.nav-tab[data-view="' + name + '"]'); if (tab) tab.classList.add('active'); window.location.hash = name; + // Update breadcrumbs + var crumbMap = { recall: 'Search', explore: 'Forum', timeline: 'Timeline', agent: 'SDK', llm: 'LLM Usage', tools: 'Tools' }; + var bc = document.getElementById('breadcrumbs'); + if (bc && crumbMap[name]) bc.innerHTML = 'mnemonic' + crumbMap[name]; if (name === 'explore' && !state.exploreLoaded.episodes) { // Load all forum blocks in parallel loadExploreTab('episodes'); From bca032139593cee6ab59cef4e735b473d7fee877 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:05:30 -0400 Subject: [PATCH 26/74] fix: D3 shim area/line accept constants, not just functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d3.area().y0(h) passes a number, not a function. The shim's y0/y1/x setters now wrap constants in functions automatically, matching D3's API. Same fix for d3.line().x() and .y(). Root cause of _renderSparkline crash: "TypeError: _y0 is not a function" — y0 was set to the number 24 (container height) but called as _y0(d, i). Part of #346 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 19521b94..7c898c72 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -3436,7 +3436,6 @@

Activity

var _y1 = function(d) { return d[1]; }; var _curve = null; function a(data) { - // Build SVG path for the area var pts = data.map(function(d, i) { return { x: _x(d, i), y0: _y0(d, i), y1: _y1(d, i) }; }); if (pts.length === 0) return ''; var path = 'M' + pts[0].x + ',' + pts[0].y1; @@ -3445,9 +3444,9 @@

Activity

path += 'Z'; return path; } - a.x = function(fn) { _x = fn; return a; }; - a.y0 = function(fn) { _y0 = fn; return a; }; - a.y1 = function(fn) { _y1 = fn; return a; }; + a.x = function(fn) { _x = typeof fn === 'function' ? fn : function() { return fn; }; return a; }; + a.y0 = function(fn) { _y0 = typeof fn === 'function' ? fn : function() { return fn; }; return a; }; + a.y1 = function(fn) { _y1 = typeof fn === 'function' ? fn : function() { return fn; }; return a; }; a.curve = function(c) { _curve = c; return a; }; return a; } @@ -3459,8 +3458,8 @@

Activity

var pts = data.map(function(d, i) { return _x(d, i) + ',' + _y(d, i); }); return 'M' + pts.join('L'); } - l.x = function(fn) { _x = fn; return l; }; - l.y = function(fn) { _y = fn; return l; }; + l.x = function(fn) { _x = typeof fn === 'function' ? fn : function() { return fn; }; return l; }; + l.y = function(fn) { _y = typeof fn === 'function' ? fn : function() { return fn; }; return l; }; l.curve = function() { return l; }; return l; } From 73269bf426116dad75e0b0d53842b7c895bffad2 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:20:53 -0400 Subject: [PATCH 27/74] fix: timeline layout, thread CSS, tag filtering compatibility Timeline: - Rewrite renderTimelineCard to use flexbox rows (.tl-row) instead of dl/dt/dd which broke layout and caused text overflow - Fix invalid HTML: expand-zone was a div sibling inside ul.topiclist - Proper text-overflow:ellipsis on titles - Time and salience columns fixed-width and right-aligned Thread view: - Add missing CSS: .thread-wrap, .thread-top, .thread-title-big, .thread-meta were used in HTML but had no CSS definitions (lost during migration from inline styles to components.css) - .thread-meta now has display:flex with gap:14px fixing the "Mood: satisfyingDuration:" spacing bug Tag filtering: - Preserved .tl-card, .tl-source, .tl-concept, data-concepts classes for compatibility with highlightTimelineCards() Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/css/components.css | 79 ++++++++++++++++++++++++-- internal/web/static/index.html | 47 +++++++-------- 2 files changed, 94 insertions(+), 32 deletions(-) diff --git a/internal/web/static/css/components.css b/internal/web/static/css/components.css index f9dc7c5e..dc241167 100644 --- a/internal/web/static/css/components.css +++ b/internal/web/static/css/components.css @@ -235,6 +235,33 @@ li.row:hover .forumtitle { color: var(--link-hover, var(--accent-blue)); text-de 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); @@ -431,10 +458,54 @@ blockquote.quote .quote-body { } .tl-head-count { font-weight: normal; color: var(--text-dim); font-size: 0.82rem; } -/* Timeline uses same topiclist structure */ -.tlr-dot { width: 8px; height: 8px; border-radius: 2px; margin: 0 auto; } -.tlr-time { font-family: var(--mono, monospace); font-size: 0.82rem; color: var(--text-dim); text-align: right; font-feature-settings: 'tnum' 1; } -.tlr-sal { font-family: var(--mono, monospace); font-size: 0.78rem; color: var(--text-dim); text-align: right; } +/* ── 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 diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 7c898c72..262fb099 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -2490,13 +2490,10 @@

Activity

var groups = groupByDate(filtered); var html = ''; groups.forEach(function(group) { - html += '
'; - html += '
  • ' + escapeHtml(group.label) + '
    ' + group.items.length + ' memories
'; - html += '
    '; + html += '
    ' + escapeHtml(group.label) + '' + group.items.length + ' memories
    '; group.items.forEach(function(item, idx) { html += renderTimelineCard(item, idx); }); - html += '
'; }); if (!_timelineState.allLoaded) { html += '
Loading more...
'; @@ -2515,57 +2512,51 @@

Activity

var typeAbbr = kind === 'insight' ? 'IN' : kind === 'decision' ? 'DE' : kind === 'learning' ? 'LE' : kind === 'error' ? 'ER' : kind === 'episode' ? 'EP' : 'GN'; var iconClass = kind === 'insight' ? 'icon-in' : kind === 'decision' ? 'icon-de' : kind === 'learning' ? 'icon-le' : kind === 'error' ? 'icon-er' : kind === 'episode' ? 'icon-ep' : 'icon-gn'; - // Include source and project in data-concepts so hover-highlight works var allTags = concepts.slice(); if (source) allTags.unshift(source); if (project) allTags.unshift(project); var bgClass = (idx || 0) % 2 === 0 ? 'bg1' : 'bg2'; - // Forum-style row using dl/dt/dd - var html = '
  • '; - html += '
    '; - html += '' + typeAbbr + ''; - html += '
    '; - html += '' + escapeHtml(item._title) + ''; + html += '' + typeAbbr + ''; + html += '' + escapeHtml(item._title) + ''; - // Concept tags (keep tl-source/tl-concept classes for tag filtering) + // Concept tags inline if (concepts.length > 0 || source) { - html += ''; + html += ''; if (source) { html += '' + escapeHtml(source) + ''; } - concepts.slice(0, 5).forEach(function(c) { + concepts.slice(0, 4).forEach(function(c) { html += '' + escapeHtml(c) + ''; }); html += ''; } - html += '
    '; - html += '
    ' + absTime + '
    '; - html += '
    ' + salPct + '%
    '; - html += '
  • '; + html += '' + absTime + ''; + html += '' + salPct + '%'; + html += '
    '; - // Expandable detail + // Expandable detail inside the same flow html += '
    '; - html += '
    '; if (item._type === 'episode') { - if (item._raw.summary) html += '
    ' + escapeHtml(item._raw.summary) + '
    '; - if (item._raw.narrative) html += '
    ' + escapeHtml(item._raw.narrative) + '
    '; + if (item._raw.summary) html += '
    ' + escapeHtml(item._raw.summary) + '
    '; + if (item._raw.narrative) html += '
    ' + escapeHtml(item._raw.narrative) + '
    '; } else { if (item._raw.content && item._raw.content !== item._raw.summary) { - html += '
    ' + escapeHtml(item._raw.content) + '
    '; + html += '
    ' + escapeHtml(item._raw.content) + '
    '; } } - html += '
    '; + html += '
    '; return html; } function expandTimelineCard(el) { - // Find the expand-zone that follows this li.row var next = el.nextElementSibling; - if (next && next.classList.contains('expand-zone')) next.classList.toggle('open'); - // Also try tl-card-detail class - if (next && next.classList.contains('tl-card-detail')) next.classList.toggle('open'); + if (next && (next.classList.contains('expand-zone') || next.classList.contains('tl-card-detail'))) { + next.classList.toggle('open'); + } } var _selectedTimelineTags = new Set(); From 68feace52478ea04f3969dd58ad9111f19cace1d Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:24:31 -0400 Subject: [PATCH 28/74] chore: remove 69 dead CSS rules from inline styles (-8.2KB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove old card-based CSS rules that are no longer used after the forum structural rewrite: - .result-card, .score-high/mid/low (now uses .forabg + dl/dt/dd) - .episode-card, .episode-header/title/summary/meta (now li.row) - .memory-card, .memory-header/type-badge/stats-row (now li.row) - .tl-card border-left type colors (now .tl-row with status-icon) - .timeline-date-group/label/rail (now .tl-head + .forabg) - .timeline-empty (kept .timeline-empty text, removed structural) index.html: 5574 → 5447 lines (-127 lines) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 257 +++++++++------------------------ 1 file changed, 70 insertions(+), 187 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 262fb099..74b342d1 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -179,44 +179,24 @@ } .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 { @@ -333,16 +313,10 @@ .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; @@ -356,100 +330,49 @@ .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 */ @@ -552,22 +475,10 @@ .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; @@ -597,19 +508,19 @@ .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; @@ -635,32 +546,13 @@ 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; @@ -717,22 +609,13 @@ .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; - } + + /* ── LLM Usage View ── */ .llm-view { padding: 24px; max-width: 1200px; margin: 0 auto; } From 06d185ca40ba0964832c9545c2da7e96a0428a7a Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:25:35 -0400 Subject: [PATCH 29/74] fix: remove old .tl-card CSS that broke timeline layout The old .tl-card rule added border-left, margin-left:20px, padding, position:relative, and ::before/::after pseudo-elements to every timeline row. This caused the huge gap at top and broke the flexbox layout. Kept only .tl-card.dimmed and .tl-card.highlighted which are needed for tag hover/click filtering. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 74b342d1..a9fb493b 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -480,33 +480,9 @@ - .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); } + /* Tag filtering states (used by highlightTimelineCards) */ + .tl-card.dimmed { opacity: 0.25; transition: opacity 0.2s; } + .tl-card.highlighted { outline: 1px solid var(--accent-cyan); background: rgba(6,182,212,0.04); } @@ -1048,7 +1024,7 @@ .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 responsive removed — now uses .tl-row flexbox */ .tl-card::before { left: -12px; } .tl-card::after { left: -8px; } .agent-grid { grid-template-columns: 1fr; } From b27fa9ef92bf2e83fbaaa262398889fb0470aa3c Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:28:37 -0400 Subject: [PATCH 30/74] =?UTF-8?q?perf:=20strip=20embeddings=20from=20/memo?= =?UTF-8?q?ries=20list=20response=20(2MB=20=E2=86=92=2049KB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each memory included a 3072-dimension embedding vector serialized as 42KB of JSON. For 50 memories that's 2.1MB of data the dashboard never uses. Now nil-ing embeddings before JSON serialization. Response size: 2,007,710 → 49,557 bytes (40x reduction) Note: query time still ~2.5s due to SQLite scanning 33K+ rows. DB query optimization tracked separately. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/routes/memories.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/api/routes/memories.go b/internal/api/routes/memories.go index fe65f55b..7166cba5 100644 --- a/internal/api/routes/memories.go +++ b/internal/api/routes/memories.go @@ -210,6 +210,11 @@ func HandleListMemories(s store.Store, log *slog.Logger) http.HandlerFunc { 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), From 5eec78be6e767db2884f86a8e5f6553bf4358220 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:36:05 -0400 Subject: [PATCH 31/74] =?UTF-8?q?perf:=20composite=20indexes=20+=20strip?= =?UTF-8?q?=20embeddings=20(2.5s=20=E2=86=92=209ms)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add composite indexes for common dashboard queries: - idx_memories_state_created: covers ListMemories ORDER BY created_at - idx_memories_project_state: covers project-scoped queries - idx_memories_episode: covers thread view episode_id lookups Strip embedding vectors from /memories list response (42KB per memory, never used by dashboard). Response size: 2MB → 49KB. Combined effect: /memories?state=active&limit=50 goes from 2.5s to 9ms. Closes #352 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/store/sqlite/schema.go | 6 ++++++ migrations/006_composite_indexes.sql | 13 +++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 migrations/006_composite_indexes.sql diff --git a/internal/store/sqlite/schema.go b/internal/store/sqlite/schema.go index 01c56927..2f92b0fb 100644 --- a/internal/store/sqlite/schema.go +++ b/internal/store/sqlite/schema.go @@ -483,6 +483,12 @@ 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`) + return nil } diff --git a/migrations/006_composite_indexes.sql b/migrations/006_composite_indexes.sql new file mode 100644 index 00000000..f1b1f032 --- /dev/null +++ b/migrations/006_composite_indexes.sql @@ -0,0 +1,13 @@ +-- Migration 006: Add composite indexes for common query patterns +-- +-- ListMemories: WHERE state = ? ORDER BY created_at DESC LIMIT ? +-- Currently uses idx_memory_state but still sorts 33K+ rows. +-- Composite index makes ORDER BY + LIMIT essentially free. + +CREATE INDEX IF NOT EXISTS idx_memories_state_created ON memories(state, created_at DESC); + +-- ListMemoriesByProject: WHERE project = ? AND state IN (...) ORDER BY timestamp DESC +CREATE INDEX IF NOT EXISTS idx_memories_project_state ON memories(project, state, timestamp DESC); + +-- Episode memory lookup: WHERE episode_id = ? +CREATE INDEX IF NOT EXISTS idx_memories_episode ON memories(episode_id) WHERE episode_id IS NOT NULL; From abbb48eda91afff41f3f51d2cdb4b519b9eda247 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:41:07 -0400 Subject: [PATCH 32/74] fix: float clearing for dl/dt/dd columns + title overflow Add clearfix (::after with clear:both) to li.row, ul.topiclist, and dl.row-item to properly contain floated dt/dd columns. Without this, the negative margin layout breaks when patterns/abstractions render below episodes/memories. Also add overflow:hidden + text-overflow:ellipsis to .forumtitle to prevent long titles from pushing dd columns off-screen. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/css/components.css | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/web/static/css/components.css b/internal/web/static/css/components.css index dc241167..acecf544 100644 --- a/internal/web/static/css/components.css +++ b/internal/web/static/css/components.css @@ -75,10 +75,28 @@ ul.topiclist dd dfn { /* ── Row styling ── */ li.row { border-top: 1px solid var(--border-subtle); - overflow: hidden; + 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)); } @@ -140,6 +158,9 @@ li.header dt .list-inner { padding: 4px 10px; } color: var(--link, var(--accent-cyan)); text-decoration: none; display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } li.row:hover .forumtitle { color: var(--link-hover, var(--accent-blue)); text-decoration: underline; } .forum-desc { From eec5d631a157492f0bc2dba4975ff0d7e8a75395 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:44:34 -0400 Subject: [PATCH 33/74] fix: allow text wrapping in forum rows, widen title column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove white-space:nowrap + text-overflow:ellipsis from .forumtitle and .forum-desc — titles and descriptions should wrap, not truncate. Reduce dd column total from 320px to 260px (60+60+140) giving more space to the title/description area. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/css/components.css | 32 +++++++++++--------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/internal/web/static/css/components.css b/internal/web/static/css/components.css index acecf544..144afef9 100644 --- a/internal/web/static/css/components.css +++ b/internal/web/static/css/components.css @@ -35,32 +35,32 @@ ul.topiclist dd { /* dt takes full width, negative margin creates space for dd columns */ ul.topiclist dt { width: 100%; - margin-right: -320px; - font-size: 0.92rem; + margin-right: -260px; + font-size: 0.88rem; } ul.topiclist dt .list-inner { - margin-right: 320px; + margin-right: 260px; padding: 6px 10px; - line-height: 1.4; + line-height: 1.45; } /* dd columns — fixed widths, floated right */ ul.topiclist dd { - width: 80px; + width: 60px; text-align: center; - padding: 8px 4px; - font-size: 0.85rem; + 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); } ul.topiclist dd.lastpost { - width: 160px; + width: 140px; text-align: right; padding-right: 10px; font-family: var(--font, inherit); - font-size: 0.8rem; + font-size: 0.78rem; line-height: 1.35; } ul.topiclist dd dfn { @@ -158,9 +158,6 @@ li.header dt .list-inner { padding: 4px 10px; } color: var(--link, var(--accent-cyan)); text-decoration: none; display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } li.row:hover .forumtitle { color: var(--link-hover, var(--accent-blue)); text-decoration: underline; } .forum-desc { @@ -168,9 +165,6 @@ li.row:hover .forumtitle { color: var(--link-hover, var(--accent-blue)); text-de font-size: 0.82rem; color: var(--text-dim); margin-top: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } /* ── Status icons (like phpBB folder icons) ── */ @@ -579,10 +573,10 @@ blockquote.quote .quote-body { RESPONSIVE ══════════════════════════════════════════════ */ @media (max-width: 640px) { - ul.topiclist dt { margin-right: -160px; } - ul.topiclist dt .list-inner { margin-right: 160px; } - ul.topiclist dd { width: 50px; font-size: 0.75rem; } - ul.topiclist dd.lastpost { width: 110px; } + 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; } } From 205ec5580e8629a56a03cf4b052dc746a2520302 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:46:10 -0400 Subject: [PATCH 34/74] =?UTF-8?q?fix:=20remove=20JS=20text=20truncation=20?= =?UTF-8?q?=E2=80=94=20let=20CSS=20handle=20wrapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Episode summaries, pattern descriptions, and abstraction descriptions were being truncated to 100-120 chars in JS before rendering. The CSS now handles wrapping properly so the full text should display. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index a9fb493b..89396a7d 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -2044,7 +2044,7 @@

    Activity

    var isNew = ep.state === 'open'; var tone = ep.emotional_tone || ep.outcome || ''; var tags = concepts.map(function(c) { return '' + escapeHtml(c) + ''; }).join(''); - var sub = ep.summary ? escapeHtml(ep.summary).substring(0, 100) : ''; + var sub = ep.summary ? escapeHtml(ep.summary) : ''; var expandId = 'exp-ep-' + idx; var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; @@ -2141,7 +2141,7 @@

    Activity

    row += 'PT'; row += '
    '; row += '' + escapeHtml(p.title || 'Untitled') + ''; - row += '' + escapeHtml((p.description || '').substring(0, 100)) + ' ' + tags + ''; + row += '' + escapeHtml(p.description || '') + ' ' + tags + ''; row += '
    '; row += '
    ' + strengthPct + '%
    '; row += '
    ' + evidenceCount + '
    '; @@ -2188,7 +2188,7 @@

    Activity

    row += '' + iconLabel + ''; row += '
    '; row += '' + escapeHtml(a.title || 'Untitled') + ' [' + levelLabel + ']'; - row += '' + escapeHtml((a.description || '').substring(0, 120)) + ' ' + tags + ''; + row += '' + escapeHtml(a.description || '') + ' ' + tags + ''; row += '
    '; row += '
    ' + confPct + '%
    '; row += '
    ' + sourceCount + '
    '; From a202266efb44debe18e28a9aaace286bbd77279c Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Mon, 23 Mar 2026 23:49:52 -0400 Subject: [PATCH 35/74] fix: replace old fblock classes with forabg, remove dead CSS, null safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace fblock-title → forabg-title, fblock-head → forabg-head in SDK/LLM/Tools headers (classes exist in components.css) - Remove dead .concept-tag CSS (now .forum-tag) - Add null-check to switchView getElementById to prevent crashes Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 89396a7d..e7fd8017 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -188,11 +188,7 @@ - .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); - } + /* concept-tag removed — now uses .forum-tag in components.css */ @@ -1298,8 +1294,8 @@

    What do you remember?

    -
    -
    SDK Agent Sessions
    +
    +
    SDK Agent Sessions
    Claude.ai · OAuth · self-evolving
    @@ -1410,7 +1406,7 @@

    What do you remember?

    -
    LLM Usage
    +
    LLM Usage
    Memory engine · local model
    @@ -1479,7 +1475,7 @@

    What do you remember?

    -
    MCP Tool Usage
    +
    MCP Tool Usage
    recall · remember · get_context · … tools
    @@ -1725,7 +1721,8 @@

    Activity

    state.currentView = name; document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); document.querySelectorAll('.nav-tab, .ntab').forEach(t => t.classList.remove('active')); - document.getElementById('view-' + name).classList.add('active'); + var viewEl = document.getElementById('view-' + name); + if (viewEl) viewEl.classList.add('active'); var tab = document.querySelector('.ntab[data-view="' + name + '"]') || document.querySelector('.nav-tab[data-view="' + name + '"]'); if (tab) tab.classList.add('active'); window.location.hash = name; From 5f634673f9d1524603ca214c17a5281e53443cd0 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 07:24:58 -0400 Subject: [PATCH 36/74] fix: remove crashy switchExploreTab, add null safety to loadThread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit switchExploreTab() referenced old .explore-tab and .explore-section elements that no longer exist (replaced by collapsible .forabg blocks). Function now just triggers lazy load — no DOM manipulation of removed elements. Also null-checked view-thread getElementById in loadThread. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index e7fd8017..5979fe41 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1748,11 +1748,9 @@

    Activity

    } function switchExploreTab(tab) { + // Forum view shows all blocks simultaneously (no tabs) + // This function now just triggers a lazy load if needed state.currentExploreTab = tab; - document.querySelectorAll('.explore-tab').forEach(t => t.classList.remove('active')); - document.querySelector('.explore-tab[data-tab="' + tab + '"]').classList.add('active'); - document.querySelectorAll('.explore-section').forEach(s => s.classList.remove('active')); - document.getElementById('section-' + tab).classList.add('active'); if (!state.exploreLoaded[tab]) loadExploreTab(tab); } @@ -1858,7 +1856,8 @@

    Activity

    state.currentView = 'thread'; document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); document.querySelectorAll('.ntab').forEach(t => t.classList.remove('active')); - document.getElementById('view-thread').classList.add('active'); + var threadView = document.getElementById('view-thread'); + if (threadView) threadView.classList.add('active'); window.location.hash = 'thread/' + episodeId; // Update breadcrumbs From 20ba19215fd0dab922bd0e9e80767144fc1e265d Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 07:30:19 -0400 Subject: [PATCH 37/74] =?UTF-8?q?feat:=20agent=20identity=20system=20?= =?UTF-8?q?=E2=80=94=20sources=20become=20forum=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map memory sources to named agent profiles with colors and icons: - mcp → "Claude Session" (cyan, CS) - filesystem → "Perception Agent" (green, PA) - git → "Git Observer" (orange, GO) - terminal → "Terminal Agent" (yellow, TA) - clipboard → "Clipboard Agent" (pink, CB) - ingest → "Ingest Engine" (dim, IE) - system → "Daemon" (violet, SY) Thread view posts now show: - Agent avatar with colored icon (CS/PA/GO etc.) - Agent name in profile sidebar with color - "Posted by Claude Session · MCP Client · 10:05 PM" header - Post numbers (#1, #2, etc.) Forum memory rows show agent name in "Last Activity" column. This transforms the thread view from a data dump into something that reads like a forum discussion between cognitive agents. Part of #351 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 5979fe41..493936d0 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1896,31 +1896,31 @@

    Activity

    var salPct = safeSalience(m.salience); var linkCount = m.association_count || 0; var source = m.source || 'mcp'; + var agent = agentProfile(source); var time = m.created_at ? new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; var joinDate = m.created_at ? new Date(m.created_at).toLocaleDateString() : ''; var concepts = m.concepts || []; var bgClass = idx % 2 === 0 ? 'bg1' : 'bg2'; - var iconClass = memoryTypeIcon(type); - // phpBB-style post with profile sidebar + // phpBB-style post with agent profile sidebar html += '
    '; - // Profile sidebar (like phpBB postprofile) + // Agent profile sidebar html += '
    '; - html += '
    ' + memoryTypeAbbr(type) + '
    '; - html += '' + escapeHtml(source) + '
    '; + html += '
    ' + agent.icon + '
    '; + html += '' + escapeHtml(agent.name) + '
    '; html += '
    ' + escapeHtml(type) + '
    '; html += '
    Salience: ' + salPct + '%
    '; html += '
    Links: ' + linkCount + '
    '; if (m.project) html += '
    Project: ' + escapeHtml(m.project) + '
    '; - html += '
    Joined: ' + joinDate + '
    '; + html += '
    Observed: ' + joinDate + '
    '; html += '
    '; // Post body html += '
    '; html += '

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

    '; - html += ''; + html += ''; // Content if (m.content && m.content !== m.summary) { @@ -2098,7 +2098,8 @@

    Activity

    row += '
    '; row += '
    ' + salPct + '% salience
    '; row += '
    ' + linkCount + ' links
    '; - row += '
    ' + dateStr + '
    ' + escapeHtml(source) + '
    '; + var agent = agentProfile(source); + row += '
    ' + dateStr + '
    ' + escapeHtml(agent.name) + '
    '; row += ''; // Expandable detail @@ -4542,6 +4543,21 @@

    Activity

    function memoryTypeAbbr(type) { return type === 'insight' ? 'IN' : type === 'decision' ? 'DE' : type === 'learning' ? 'LE' : type === 'error' ? 'ER' : 'GN'; } function memoryTypeIcon(type) { return type === 'insight' ? 'icon-in' : type === 'decision' ? 'icon-de' : type === 'learning' ? 'icon-le' : type === 'error' ? 'icon-er' : 'icon-ep'; } function safeSalience(s) { return Math.min(100, Math.round((s || 0) * 100)); } + + // Agent identity system — maps memory sources to forum "users" + var _agentProfiles = { + mcp: { name: 'Claude Session', title: 'MCP Client', icon: 'CS', color: 'var(--accent-cyan)' }, + filesystem: { name: 'Perception Agent', title: 'Filesystem Watcher', icon: 'PA', color: 'var(--accent-green)' }, + git: { name: 'Git Observer', title: 'Repository Watcher', icon: 'GO', color: 'var(--accent-orange)' }, + terminal: { name: 'Terminal Agent', title: 'Command Observer', icon: 'TA', color: 'var(--accent-yellow)' }, + clipboard: { name: 'Clipboard Agent', title: 'Clipboard Monitor', icon: 'CB', color: 'var(--accent-pink)' }, + ingest: { name: 'Ingest Engine', title: 'Bulk Importer', icon: 'IE', color: 'var(--text-dim)' }, + system: { name: 'Daemon', title: 'System Process', icon: 'SY', color: 'var(--accent-violet)' }, + benchmark: { name: 'Benchmark', title: 'Quality Tester', icon: 'BM', color: 'var(--accent-blue)' }, + }; + function agentProfile(source) { + return _agentProfiles[(source || 'mcp').toLowerCase()] || { name: source || 'Unknown', title: 'Agent', icon: '??', color: 'var(--text-dim)' }; + } function simpleMarkdown(str) { if (!str) return ''; var lines = str.split('\n'); From 49f6b1465716db7df7588592ed61c6e1aacf6f88 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 07:33:38 -0400 Subject: [PATCH 38/74] feat: thread view shows Episoding Agent post when no encoded memories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an episode has raw observations but no encoded memories linked (which is common for recent episodes — consolidation links them later), the thread view now shows a single post from "Episoding Agent" containing the episode summary, narrative, files, and concepts instead of an empty page. Explains to the user that encoded memories will appear after consolidation. Shows raw observation count. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 493936d0..99b58a0c 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1889,6 +1889,37 @@

    Activity

    } html += '
    '; + if (memories.length === 0) { + // No encoded memories linked to this episode yet + // Show a "Daemon" post with the episode summary as content + var rawCount = (ep.raw_memory_ids || []).length; + html += '
    '; + html += '
    '; + html += '
    EP
    '; + html += 'Episoding Agent
    '; + html += '
    episode
    '; + html += '
    Events: ' + rawCount + '
    '; + if (ep.emotional_tone) html += '
    Mood: ' + escapeHtml(ep.emotional_tone) + '
    '; + if (ep.project) html += '
    Project: ' + escapeHtml(ep.project) + '
    '; + html += '
    '; + html += '
    '; + html += '

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

    '; + html += ''; + if (ep.summary) html += '
    ' + escapeHtml(ep.summary) + '
    '; + if (ep.narrative) html += '
    ' + escapeHtml(ep.narrative) + '
    '; + if (ep.files_modified && ep.files_modified.length > 0) { + html += ''; + } + if (ep.concepts && ep.concepts.length > 0) { + html += ''; + } + html += '
    Encoded memories will appear here after consolidation links them to this episode. ' + rawCount + ' raw observations are pending encoding.
    '; + html += '
    '; + } if (memories.length > 0) { memories.forEach(function(m, idx) { var type = memoryType(m); From 542d7bf904800bdd02fe3195ae1530e45053364a Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 07:37:48 -0400 Subject: [PATCH 39/74] fix: encoding agent checks closed episodes for episode_id linkage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getEpisodeIDForRaw only checked the open episode, but encoding is async — by the time it processes a raw memory, the episoding agent may have already closed the episode. Now checks the 10 most recent closed episodes as fallback. Also ran a SQL backfill to link existing memories to their episodes via raw_memory_ids JSON matching (10,357 memories linked). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/agent/encoding/agent.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/agent/encoding/agent.go b/internal/agent/encoding/agent.go index f6a77e2b..97b3065d 100644 --- a/internal/agent/encoding/agent.go +++ b/internal/agent/encoding/agent.go @@ -1953,14 +1953,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 "" From fb96d78d42829de17a0b4ddc11e44b57ce4b2c23 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 09:14:35 -0400 Subject: [PATCH 40/74] =?UTF-8?q?feat:=20live=20activity=20feed=20?= =?UTF-8?q?=E2=80=94=20agents=20post=20to=20forum=20in=20real-time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Live Activity" forum block at top of Forum view with green pulse indicator. WebSocket events now create real-time forum posts: - Perception Agent posts when filesystem/git changes observed - Daemon posts when memories are encoded - Consolidation Agent posts cycle results (processed/decayed/merged) - Dreaming Agent posts dream cycle stats (replays, insights) - Abstraction Agent posts new patterns and principles - Episoding Agent posts when episodes close - Retrieval Agent posts recall queries and result counts Each post shows agent avatar, colored name, description, and timestamp. Feed auto-scrolls, caps at 50 entries. Posts fade in with animation. This is the first step toward agents actually USING the forum as a communication channel (issue #351). Part of #339, #351 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/css/components.css | 4 ++ internal/web/static/index.html | 55 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/web/static/css/components.css b/internal/web/static/css/components.css index 144afef9..14ea1342 100644 --- a/internal/web/static/css/components.css +++ b/internal/web/static/css/components.css @@ -568,6 +568,10 @@ blockquote.quote .quote-body { 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 diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 99b58a0c..4ccf047c 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1224,6 +1224,15 @@

    What do you remember?

    +
    +
    + Live Activity + waiting for events... +
    +
    +
    Listening for agent activity...
    +
    +
    Episodes @@ -3046,6 +3055,7 @@

    Activity

    default: desc = type.replace(/_/g, ' '); } addEvent(type, desc, msg.timestamp || new Date().toISOString(), payload); + addLivePost(type, payload, msg.timestamp); if (['memory_encoded', 'consolidation_completed', 'dream_cycle_completed'].includes(type) && state.currentView === 'timeline') { clearTimeout(state._timelineLiveReload); state._timelineLiveReload = setTimeout(function() { loadTimelineData(false); }, 5000); @@ -4589,6 +4599,51 @@

    Activity

    function agentProfile(source) { return _agentProfiles[(source || 'mcp').toLowerCase()] || { name: source || 'Unknown', title: 'Agent', icon: '??', color: 'var(--text-dim)' }; } + + // Live forum feed — agents posting in real time + var _liveFeedCount = 0; + var _wsAgentMap = { + raw_memory_created: function(p) { return { agent: agentProfile(p.source || 'filesystem'), text: 'Observed: ' + (p.summary || p.content || '').slice(0, 120), type: 'observation' }; }, + memory_encoded: function(p) { return { agent: _agentProfiles.system || agentProfile('system'), text: 'Encoded memory: ' + (p.summary || '').slice(0, 120), type: 'encoding' }; }, + consolidation_started: function() { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Starting consolidation cycle...', type: 'system' }; }, + consolidation_completed: function(p) { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Consolidation complete: ' + (p.memories_processed || 0) + ' processed, ' + (p.memories_decayed || 0) + ' decayed, ' + (p.memories_merged || 0) + ' merged', type: 'system' }; }, + dream_cycle_completed: function(p) { return { agent: { name: 'Dreaming Agent', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, text: 'Dream cycle: replayed ' + (p.memories_replayed || 0) + ', strengthened ' + (p.associations_strengthened || 0) + ' associations, ' + (p.insights_generated || 0) + ' insights', type: 'insight' }; }, + pattern_discovered: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, text: 'New pattern: ' + (p.title || ''), type: 'discovery' }; }, + abstraction_created: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Principle Synthesis', icon: 'AA', color: 'var(--accent-orange)' }, text: (p.level === 3 ? 'New axiom' : 'New principle') + ': ' + (p.title || ''), type: 'discovery' }; }, + episode_closed: function(p) { return { agent: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, text: 'Episode closed: ' + (p.title || ''), type: 'system' }; }, + query_executed: function(p) { return { agent: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, text: 'Recall query: "' + (p.query_text || '').slice(0, 60) + '" → ' + (p.results_returned || 0) + ' results', type: 'query' }; }, + }; + + function addLivePost(wsType, payload, timestamp) { + var mapper = _wsAgentMap[wsType]; + if (!mapper) return; + var post = mapper(payload); + if (!post) return; + var feed = document.getElementById('liveFeedBody'); + if (!feed) return; + + _liveFeedCount++; + var countEl = document.getElementById('liveFeedCount'); + if (countEl) countEl.textContent = _liveFeedCount + ' events this session'; + + var time = timestamp ? new Date(timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}) : new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); + var bgClass = _liveFeedCount % 2 === 0 ? 'bg1' : 'bg2'; + + var html = '
    '; + html += '' + post.agent.icon + ''; + html += '' + escapeHtml(post.agent.name) + ' · ' + escapeHtml(post.text) + ''; + html += '' + time + ''; + html += '
    '; + + // Remove the "Listening..." placeholder + if (_liveFeedCount === 1) feed.innerHTML = ''; + + // Prepend (newest at top) + feed.insertAdjacentHTML('afterbegin', html); + + // Cap at 50 entries + while (feed.children.length > 50) feed.removeChild(feed.lastChild); + } function simpleMarkdown(str) { if (!str) return ''; var lines = str.split('\n'); From eb47ac2484398fd2496c820b01384555d83a9ee8 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 09:18:14 -0400 Subject: [PATCH 41/74] =?UTF-8?q?feat:=20clickable=20live=20feed=20posts?= =?UTF-8?q?=20=E2=80=94=20navigate=20to=20relevant=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Activity posts now have click actions: - Encoding posts → Forum view (to see the new memory) - Consolidation posts → Tools view (to see analytics) - Dream cycle posts → Tools view (research analytics) - Pattern/abstraction posts → Forum view (to see discoveries) - Episode closed → Thread view for that episode - Recall query → Search view - Raw observation → Timeline view Posts show pointer cursor when clickable. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 4ccf047c..99de46ca 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -4603,15 +4603,15 @@

    Activity

    // Live forum feed — agents posting in real time var _liveFeedCount = 0; var _wsAgentMap = { - raw_memory_created: function(p) { return { agent: agentProfile(p.source || 'filesystem'), text: 'Observed: ' + (p.summary || p.content || '').slice(0, 120), type: 'observation' }; }, - memory_encoded: function(p) { return { agent: _agentProfiles.system || agentProfile('system'), text: 'Encoded memory: ' + (p.summary || '').slice(0, 120), type: 'encoding' }; }, - consolidation_started: function() { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Starting consolidation cycle...', type: 'system' }; }, - consolidation_completed: function(p) { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Consolidation complete: ' + (p.memories_processed || 0) + ' processed, ' + (p.memories_decayed || 0) + ' decayed, ' + (p.memories_merged || 0) + ' merged', type: 'system' }; }, - dream_cycle_completed: function(p) { return { agent: { name: 'Dreaming Agent', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, text: 'Dream cycle: replayed ' + (p.memories_replayed || 0) + ', strengthened ' + (p.associations_strengthened || 0) + ' associations, ' + (p.insights_generated || 0) + ' insights', type: 'insight' }; }, - pattern_discovered: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, text: 'New pattern: ' + (p.title || ''), type: 'discovery' }; }, - abstraction_created: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Principle Synthesis', icon: 'AA', color: 'var(--accent-orange)' }, text: (p.level === 3 ? 'New axiom' : 'New principle') + ': ' + (p.title || ''), type: 'discovery' }; }, - episode_closed: function(p) { return { agent: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, text: 'Episode closed: ' + (p.title || ''), type: 'system' }; }, - query_executed: function(p) { return { agent: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, text: 'Recall query: "' + (p.query_text || '').slice(0, 60) + '" → ' + (p.results_returned || 0) + ' results', type: 'query' }; }, + raw_memory_created: function(p) { return { agent: agentProfile(p.source || 'filesystem'), text: 'Observed: ' + (p.summary || p.content || '').slice(0, 120), action: 'switchView("timeline")' }; }, + memory_encoded: function(p) { return { agent: _agentProfiles.system || agentProfile('system'), text: 'Encoded memory: ' + (p.summary || '').slice(0, 120), action: 'switchView("explore")' }; }, + consolidation_started: function() { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Starting consolidation cycle...', action: null }; }, + consolidation_completed: function(p) { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Consolidation complete: ' + (p.memories_processed || 0) + ' processed, ' + (p.memories_decayed || 0) + ' decayed, ' + (p.memories_merged || 0) + ' merged', action: 'switchView("tools")' }; }, + dream_cycle_completed: function(p) { return { agent: { name: 'Dreaming Agent', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, text: 'Dream cycle: replayed ' + (p.memories_replayed || 0) + ', strengthened ' + (p.associations_strengthened || 0) + ' associations, ' + (p.insights_generated || 0) + ' insights', action: 'switchView("tools")' }; }, + pattern_discovered: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, text: 'New pattern: ' + (p.title || ''), action: 'switchView("explore")' }; }, + abstraction_created: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Principle Synthesis', icon: 'AA', color: 'var(--accent-orange)' }, text: (p.level === 3 ? 'New axiom' : 'New principle') + ': ' + (p.title || ''), action: 'switchView("explore")' }; }, + episode_closed: function(p) { return { agent: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, text: 'Episode closed: ' + (p.title || ''), action: p.episode_id ? 'loadThread("' + p.episode_id + '")' : 'switchView("explore")' }; }, + query_executed: function(p) { return { agent: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, text: 'Recall query: "' + (p.query_text || '').slice(0, 60) + '" → ' + (p.results_returned || 0) + ' results', action: 'switchView("recall")' }; }, }; function addLivePost(wsType, payload, timestamp) { @@ -4629,7 +4629,8 @@

    Activity

    var time = timestamp ? new Date(timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}) : new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); var bgClass = _liveFeedCount % 2 === 0 ? 'bg1' : 'bg2'; - var html = '
    '; + var clickAttr = post.action ? ' onclick="' + post.action + '" style="animation:fadeIn 0.3s ease-out;cursor:pointer"' : ' style="animation:fadeIn 0.3s ease-out"'; + var html = '
    '; html += '' + post.agent.icon + ''; html += '' + escapeHtml(post.agent.name) + ' · ' + escapeHtml(post.text) + ''; html += '' + time + ''; From ca16c3311f83228a0653ce86c8cd8363c4565358 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 09:21:52 -0400 Subject: [PATCH 42/74] =?UTF-8?q?fix:=20live=20feed=20onclick=20=E2=80=94?= =?UTF-8?q?=20fix=20nested=20quote=20escaping=20in=20HTML=20attributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onclick="switchView("tools")" broke because nested double quotes. Changed to onclick="switchView('tools')" with single quotes inside double-quoted attribute. Same fix for all 9 event type actions. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 99de46ca..8c523e05 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -4603,15 +4603,15 @@

    Activity

    // Live forum feed — agents posting in real time var _liveFeedCount = 0; var _wsAgentMap = { - raw_memory_created: function(p) { return { agent: agentProfile(p.source || 'filesystem'), text: 'Observed: ' + (p.summary || p.content || '').slice(0, 120), action: 'switchView("timeline")' }; }, - memory_encoded: function(p) { return { agent: _agentProfiles.system || agentProfile('system'), text: 'Encoded memory: ' + (p.summary || '').slice(0, 120), action: 'switchView("explore")' }; }, + raw_memory_created: function(p) { return { agent: agentProfile(p.source || 'filesystem'), text: 'Observed: ' + (p.summary || p.content || '').slice(0, 120), action: "switchView('timeline')" }; }, + memory_encoded: function(p) { return { agent: _agentProfiles.system || agentProfile('system'), text: 'Encoded memory: ' + (p.summary || '').slice(0, 120), action: "switchView('explore')" }; }, consolidation_started: function() { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Starting consolidation cycle...', action: null }; }, - consolidation_completed: function(p) { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Consolidation complete: ' + (p.memories_processed || 0) + ' processed, ' + (p.memories_decayed || 0) + ' decayed, ' + (p.memories_merged || 0) + ' merged', action: 'switchView("tools")' }; }, - dream_cycle_completed: function(p) { return { agent: { name: 'Dreaming Agent', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, text: 'Dream cycle: replayed ' + (p.memories_replayed || 0) + ', strengthened ' + (p.associations_strengthened || 0) + ' associations, ' + (p.insights_generated || 0) + ' insights', action: 'switchView("tools")' }; }, - pattern_discovered: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, text: 'New pattern: ' + (p.title || ''), action: 'switchView("explore")' }; }, - abstraction_created: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Principle Synthesis', icon: 'AA', color: 'var(--accent-orange)' }, text: (p.level === 3 ? 'New axiom' : 'New principle') + ': ' + (p.title || ''), action: 'switchView("explore")' }; }, - episode_closed: function(p) { return { agent: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, text: 'Episode closed: ' + (p.title || ''), action: p.episode_id ? 'loadThread("' + p.episode_id + '")' : 'switchView("explore")' }; }, - query_executed: function(p) { return { agent: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, text: 'Recall query: "' + (p.query_text || '').slice(0, 60) + '" → ' + (p.results_returned || 0) + ' results', action: 'switchView("recall")' }; }, + consolidation_completed: function(p) { return { agent: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, text: 'Consolidation complete: ' + (p.memories_processed || 0) + ' processed, ' + (p.memories_decayed || 0) + ' decayed, ' + (p.memories_merged || 0) + ' merged', action: "switchView('tools')" }; }, + dream_cycle_completed: function(p) { return { agent: { name: 'Dreaming Agent', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, text: 'Dream cycle: replayed ' + (p.memories_replayed || 0) + ', strengthened ' + (p.associations_strengthened || 0) + ' associations, ' + (p.insights_generated || 0) + ' insights', action: "switchView('tools')" }; }, + pattern_discovered: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, text: 'New pattern: ' + (p.title || ''), action: "switchView('explore')" }; }, + abstraction_created: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Principle Synthesis', icon: 'AA', color: 'var(--accent-orange)' }, text: (p.level === 3 ? 'New axiom' : 'New principle') + ': ' + (p.title || ''), action: "switchView('explore')" }; }, + episode_closed: function(p) { return { agent: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, text: 'Episode closed: ' + (p.title || ''), action: p.episode_id ? "loadThread('" + p.episode_id + "')" : "switchView('explore')" }; }, + query_executed: function(p) { return { agent: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, text: 'Recall query: "' + (p.query_text || '').slice(0, 60) + '" → ' + (p.results_returned || 0) + ' results', action: "switchView('recall')" }; }, }; function addLivePost(wsType, payload, timestamp) { From fe7445dc25315baf7c2a12331c35d771fcaf4bd9 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 10:41:12 -0400 Subject: [PATCH 43/74] =?UTF-8?q?feat:=20forum=20communication=20layer=20?= =?UTF-8?q?=E2=80=94=20posts,=20threads,=20agent=20personality,=20@mention?= =?UTF-8?q?s,=20internalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a living forum communication layer between humans and the cognitive system. Forum posts are a separate entity from memories — they are a conversation space where agents and humans interact. New features: - forum_posts table with threading (parent_id) and state management - CRUD API at /api/v1/forum/{threads,posts} with @mention extraction - 8 agent personality templates with distinct voice/tone - 6 reactor chains for autonomous agent posting (consolidation, dreaming, episoding, pattern, abstraction, metacognition) - @mention system: @retrieval, @metacognition, @encoding, @episoding trigger LLM-generated agent responses in forum threads - Internalization: absorb forum posts into memory pipeline via POST /api/v1/forum/posts/{id}/internalize - WebSocket broadcasting of forum_post_created events - Frontend: forum threads list, compose box, reply box, live-insert via WebSocket, @mention highlighting, internalize button Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mnemonic/main.go | 4 + internal/agent/forum/personality.go | 112 ++++++++ internal/agent/reactor/actions.go | 182 ++++++++++++ internal/agent/reactor/reactor_test.go | 11 +- internal/agent/reactor/registry.go | 132 +++++++++ internal/agent/retrieval/forum.go | 20 ++ internal/api/routes/forum.go | 373 +++++++++++++++++++++++++ internal/api/routes/ws.go | 3 + internal/api/server.go | 8 + internal/events/types.go | 32 +++ internal/store/sqlite/forum.go | 241 ++++++++++++++++ internal/store/sqlite/forum_test.go | 259 +++++++++++++++++ internal/store/sqlite/schema.go | 23 ++ internal/store/store.go | 35 +++ internal/store/storetest/mock.go | 23 +- internal/web/static/index.html | 290 +++++++++++++++++++ 16 files changed, 1742 insertions(+), 6 deletions(-) create mode 100644 internal/agent/forum/personality.go create mode 100644 internal/agent/retrieval/forum.go create mode 100644 internal/api/routes/forum.go create mode 100644 internal/store/sqlite/forum.go create mode 100644 internal/store/sqlite/forum_test.go diff --git a/cmd/mnemonic/main.go b/cmd/mnemonic/main.go index d4d52770..d4ce8c07 100644 --- a/cmd/mnemonic/main.go +++ b/cmd/mnemonic/main.go @@ -1679,6 +1679,10 @@ func serveCommand(configPath string) { if orch != nil { deps.IncrementAutonomous = orch.IncrementAutonomousCount } + deps.MentionLLM = llmProvider + if retriever != nil { + deps.MentionQuery = retriever + } for _, chain := range reactor.NewChainRegistry(deps) { reactorEngine.RegisterChain(chain) diff --git a/internal/agent/forum/personality.go b/internal/agent/forum/personality.go new file mode 100644 index 00000000..0f54721b --- /dev/null +++ b/internal/agent/forum/personality.go @@ -0,0 +1,112 @@ +// 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 and the agent key. +func ComposePost(evt events.Event) (content string, agentKey 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" + 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" + 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 +} diff --git a/internal/agent/reactor/actions.go b/internal/agent/reactor/actions.go index 368c93f2..e13c1073 100644 --- a/internal/agent/reactor/actions.go +++ b/internal/agent/reactor/actions.go @@ -4,9 +4,12 @@ import ( "context" "fmt" "log/slog" + "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" ) @@ -103,3 +106,182 @@ func (a *IncrementCounterAction) Execute(_ context.Context, _ events.Event, _ *R } return nil } + +// CreateForumPostAction writes a forum post from an agent personality template. +type CreateForumPostAction struct { + 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 := 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() + + post := store.ForumPost{ + ID: postID, + ThreadID: postID, // each agent post is a new thread + AuthorType: "agent", + AuthorName: personality.Name, + AuthorKey: personality.Key, + Content: content, + EventRef: trigger.EventType(), + 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: postID, + 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 +} + +// 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 + 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.") + + // If this is the retrieval agent, run a search first + if mention.AgentKey == "retrieval" && a.ForumQuerier != nil { + results := querySimple(ctx, a.ForumQuerier, mention.Content, 5) + if len(results) > 0 { + systemPrompt.WriteString("\n\nRelevant memories from search:\n") + for i, r := range results { + systemPrompt.WriteString(fmt.Sprintf("%d. [%.2f] %s\n", i+1, r.Score, r.Memory.Summary)) + } + } + } + + resp, err := a.LLM.Complete(ctx, llm.CompletionRequest{ + Messages: []llm.Message{ + {Role: "system", Content: systemPrompt.String()}, + {Role: "user", Content: mention.Content}, + }, + MaxTokens: 200, + Temperature: 0.7, + }) + 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 = resp.Content + } + } + + // 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) + } + + return nil +} diff --git a/internal/agent/reactor/reactor_test.go b/internal/agent/reactor/reactor_test.go index 470b42d3..223a3383 100644 --- a/internal/agent/reactor/reactor_test.go +++ b/internal/agent/reactor/reactor_test.go @@ -621,8 +621,8 @@ func TestNewChainRegistry(t *testing.T) { 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 +638,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..23c2b147 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,9 @@ type ChainDeps struct { MaxDBSizeMB int CooldownOverrides map[string]time.Duration // chain ID -> cooldown override Logger *slog.Logger + // Forum @mention dependencies (can be nil to disable) + MentionLLM llm.Provider // for @mention LLM responses + MentionQuery ForumQuerier // for @retrieval recall queries } // cooldown returns the override duration for a chain if set, otherwise the default. @@ -206,6 +210,134 @@ func NewChainRegistry(deps ChainDeps) []*Chain { }) } + // --- Forum posting chains --- + // Agents autonomously post about their work in the forum. + + forumAction := &CreateForumPostAction{Log: log} + + 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, + }) + + // Forum @mention response chain + 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, + Log: log, + }, + }, + Cooldown: deps.cooldown("forum_mention_response", 10*time.Second), + Priority: 5, + 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, + }) + 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/forum.go b/internal/api/routes/forum.go new file mode 100644 index 00000000..dbfa04c9 --- /dev/null +++ b/internal/api/routes/forum.go @@ -0,0 +1,373 @@ +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 +} + +// 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 + } + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + 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 + if threadID == "" { + // New thread: thread_id = post id + threadID = postID + } + 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{}, + 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, + 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/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..b8ec6a99 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -153,6 +153,14 @@ 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/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/events/types.go b/internal/events/types.go index 15a3da75..1f80e819 100644 --- a/internal/events/types.go +++ b/internal/events/types.go @@ -224,3 +224,35 @@ 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 + 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/store/sqlite/forum.go b/internal/store/sqlite/forum.go new file mode 100644 index 00000000..7b4ebadc --- /dev/null +++ b/internal/store/sqlite/forum.go @@ -0,0 +1,241 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + store "github.com/appsprout-dev/mnemonic/internal/store" +) + +// 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, 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), + 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() }() + + var threads []store.ForumThread + for rows.Next() { + var post store.ForumPost + var parentID, authorKey, eventRef, 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, + &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.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 +} + +// scanForumPostFrom scans a single ForumPost from any scanner. +func scanForumPostFrom(s scanner) (store.ForumPost, error) { + var post store.ForumPost + var parentID, authorKey, eventRef, 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, + &pinned, + &post.State, + &createdAtStr, + &updatedAtStr, + ) + if err != nil { + return post, err + } + + post.ParentID = parentID.String + post.AuthorKey = authorKey.String + post.EventRef = eventRef.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..fe508c53 --- /dev/null +++ b/internal/store/sqlite/forum_test.go @@ -0,0 +1,259 @@ +//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) + } +} diff --git a/internal/store/sqlite/schema.go b/internal/store/sqlite/schema.go index 2f92b0fb..631d3f5b 100644 --- a/internal/store/sqlite/schema.go +++ b/internal/store/sqlite/schema.go @@ -489,6 +489,29 @@ CREATE INDEX IF NOT EXISTS idx_amendments_memory ON memory_amendments(memory_id) _, _ = 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_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 '', + 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); +`) + return nil } diff --git a/internal/store/store.go b/internal/store/store.go index 2dbf08d4..830dce0d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -354,6 +354,33 @@ type RetrievalFeedback struct { CreatedAt time.Time `json:"created_at"` } +// 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 + 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 +551,14 @@ type Store interface { // --- Research analytics --- GetAnalytics(ctx context.Context) (AnalyticsData, 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) + 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) + // --- Lifecycle --- Close() error } diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index 7eaff108..58f9fd4c 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -43,11 +43,11 @@ func (MockStore) GetMemory(context.Context, string) (store.Memory, error) { func (MockStore) GetMemoryByRawID(context.Context, string) (store.Memory, error) { return store.Memory{}, nil } -func (MockStore) UpdateMemory(context.Context, store.Memory) error { return nil } -func (MockStore) UpdateSalience(context.Context, string, float32) error { return nil } +func (MockStore) UpdateMemory(context.Context, store.Memory) error { return nil } +func (MockStore) UpdateSalience(context.Context, string, float32) error { return nil } func (MockStore) UpdateEmbedding(context.Context, string, []float32) error { return nil } -func (MockStore) UpdateState(context.Context, string, string) error { return nil } -func (MockStore) IncrementAccess(context.Context, string) error { return nil } +func (MockStore) UpdateState(context.Context, string, string) error { return nil } +func (MockStore) IncrementAccess(context.Context, string) error { return nil } func (MockStore) ListMemories(context.Context, string, int, int) ([]store.Memory, error) { return nil, nil } @@ -328,6 +328,21 @@ func (MockStore) GetToolUsageChart(context.Context, time.Time, int) ([]store.Too 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) 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 } + // --- Lifecycle --- func (MockStore) Close() error { return nil } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 8c523e05..baab98d0 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1224,6 +1224,30 @@

    What do you remember?

    +
    +
    + Forum Threads + +
    +
    +
    Loading threads...
    +
    +
    + +
    +
    +
    Live Activity @@ -1267,6 +1291,17 @@

    What do you remember?

    Loading thread...
    +
    @@ -1672,6 +1707,8 @@

    Activity

    llmLoaded: false, toolsLoaded: false, dreamSessionTotals: { cycles: 0, replayed: 0, strengthened: 0, newLinks: 0, insights: 0, crossProject: 0, demoted: 0 }, + forumLoaded: false, + currentForumThread: null, mindLoaded: false, mindSimulation: null, mindData: null, @@ -1745,10 +1782,12 @@

    Activity

    loadExploreTab('memories'); loadExploreTab('patterns'); loadExploreTab('abstractions'); + loadForumThreads(); // Populate welcome panel var wp = document.getElementById('forumWelcome'); if (wp) wp.style.display = ''; } + if (name === 'explore' && !state.forumLoaded) loadForumThreads(); if (name === 'timeline' && !state.timelineInitialized) { populateTimelineProjects(); loadTimelineData(false); } if (name === 'agent' && !state.agentLoaded) loadAgentData(); if (name === 'llm' && !state.llmLoaded) loadLLMUsage(); @@ -1770,6 +1809,11 @@

    Activity

    if (epId) loadThread(epId); return; } + if (hash.startsWith('forum-thread/')) { + var threadId = hash.substring(13); + if (threadId) loadForumThread(threadId); + return; + } if (['recall', 'explore', 'timeline', 'agent', 'llm', 'tools'].includes(hash)) switchView(hash); } window.addEventListener('hashchange', handleHash); @@ -3052,6 +3096,7 @@

    Activity

    case 'pattern_discovered': desc = 'Pattern: ' + (payload.title || '').slice(0, 40); state.exploreLoaded.patterns = false; break; case 'abstraction_created': desc = (payload.level === 3 ? 'Axiom' : 'Principle') + ': ' + (payload.title || '').slice(0, 40); state.exploreLoaded.abstractions = false; break; case 'episode_closed': desc = 'Episode closed: ' + (payload.title || '').slice(0, 40); state.exploreLoaded.episodes = false; break; + case 'forum_post_created': desc = (payload.author_name || 'Someone') + ' posted in forum'; state.forumLoaded = false; break; default: desc = type.replace(/_/g, ' '); } addEvent(type, desc, msg.timestamp || new Date().toISOString(), payload); @@ -3064,6 +3109,11 @@

    Activity

    if (state.currentView === 'explore') { var tab = state.currentExploreTab; if (!state.exploreLoaded[tab]) loadExploreTab(tab); + if (!state.forumLoaded) loadForumThreads(); + } + // Live-insert forum post into current thread view + if (type === 'forum_post_created' && state.currentView === 'thread' && state.currentForumThread === payload.thread_id) { + appendForumPostToThread(payload); } // Refresh nav stats on memory-changing events if (['memory_encoded', 'raw_memory_created', 'consolidation_completed'].includes(type)) loadStats(); @@ -4612,6 +4662,21 @@

    Activity

    abstraction_created: function(p) { return { agent: { name: 'Abstraction Agent', title: 'Principle Synthesis', icon: 'AA', color: 'var(--accent-orange)' }, text: (p.level === 3 ? 'New axiom' : 'New principle') + ': ' + (p.title || ''), action: "switchView('explore')" }; }, episode_closed: function(p) { return { agent: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, text: 'Episode closed: ' + (p.title || ''), action: p.episode_id ? "loadThread('" + p.episode_id + "')" : "switchView('explore')" }; }, query_executed: function(p) { return { agent: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, text: 'Recall query: "' + (p.query_text || '').slice(0, 60) + '" → ' + (p.results_returned || 0) + ' results', action: "switchView('recall')" }; }, + forum_post_created: function(p) { + var key = p.author_key || ''; + var profiles = { + consolidation: { name: 'Consolidation Agent', icon: 'CA', color: 'var(--accent-yellow)' }, + dreaming: { name: 'Dreaming Agent', icon: 'DA', color: 'var(--accent-violet)' }, + episoding: { name: 'Episoding Agent', icon: 'EP', color: 'var(--accent-violet)' }, + abstraction: { name: 'Abstraction Agent', icon: 'AA', color: 'var(--accent-orange)' }, + metacognition: { name: 'Metacognition Agent', icon: 'MA', color: 'var(--accent-blue)' }, + encoding: { name: 'Encoding Agent', icon: 'EA', color: 'var(--accent-blue)' }, + perception: { name: 'Perception Agent', icon: 'PA', color: 'var(--accent-green)' }, + retrieval: { name: 'Retrieval Agent', icon: 'RA', color: 'var(--accent-cyan)' }, + }; + var agent = profiles[key] || { name: p.author_name || 'Human', icon: 'HU', color: 'var(--accent-cyan)' }; + return { agent: agent, text: (p.content || '').slice(0, 120), action: "loadForumThread('" + p.thread_id + "')" }; + }, }; function addLivePost(wsType, payload, timestamp) { @@ -4670,6 +4735,231 @@

    Activity

    if (inList) html += ''; return html; } + // ── Forum Communication Layer ── + + async function loadForumThreads() { + try { + var resp = await fetch('/api/v1/forum/threads?limit=20'); + var data = await resp.json(); + state.forumLoaded = true; + var body = document.getElementById('forumThreadsBody'); + var countEl = document.getElementById('forumThreadCount'); + if (!body) return; + var threads = data.threads || []; + if (countEl) countEl.textContent = threads.length + ' threads · ' + (data.total_posts || 0) + ' posts'; + if (threads.length === 0) { + body.innerHTML = '
    No threads yet. Start a conversation!
    '; + return; + } + var html = '
      '; + for (var i = 0; i < threads.length; i++) { + var t = threads[i]; + var rp = t.root_post; + var bgClass = i % 2 === 0 ? 'bg1' : 'bg2'; + var isAgent = rp.author_type === 'agent'; + var agentKey = rp.author_key || ''; + var profiles = { + consolidation: { icon: 'CA', color: 'var(--accent-yellow)' }, + dreaming: { icon: 'DA', color: 'var(--accent-violet)' }, + episoding: { icon: 'EP', color: 'var(--accent-violet)' }, + abstraction: { icon: 'AA', color: 'var(--accent-orange)' }, + metacognition: { icon: 'MA', color: 'var(--accent-blue)' }, + encoding: { icon: 'EA', color: 'var(--accent-blue)' }, + perception: { icon: 'PA', color: 'var(--accent-green)' }, + retrieval: { icon: 'RA', color: 'var(--accent-cyan)' }, + }; + var prof = profiles[agentKey] || { icon: 'HU', color: 'var(--accent-cyan)' }; + var authorName = rp.author_name || 'Human'; + var preview = escapeHtml((rp.content || '').slice(0, 100)); + var lastActive = t.last_reply ? new Date(t.last_reply).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''; + html += '
    • '; + html += '
      '; + html += '
      '; + html += '' + prof.icon + ''; + html += '
      '; + html += '' + preview + ''; + html += '
      by ' + escapeHtml(authorName) + ''; + html += '
      '; + html += '
      ' + (t.reply_count || 0) + ' replies
      '; + html += '
      ' + lastActive + '
      '; + html += '
    • '; + } + html += '
    '; + body.innerHTML = html; + } catch (e) { console.error('Failed to load forum threads:', e); } + } + + async function loadForumThread(threadId) { + state.currentForumThread = threadId; + state.currentView = 'thread'; + // Show thread view + document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); }); + document.querySelectorAll('.ntab').forEach(function(t) { t.classList.remove('active'); }); + var viewEl = document.getElementById('view-thread'); + if (viewEl) viewEl.classList.add('active'); + window.location.hash = 'forum-thread/' + threadId; + var bc = document.getElementById('breadcrumbs'); + if (bc) bc.innerHTML = 'mnemonicForumThread'; + // Show compose box + var compose = document.getElementById('threadCompose'); + if (compose) compose.style.display = ''; + try { + var resp = await fetch('/api/v1/forum/threads/' + threadId); + var data = await resp.json(); + var posts = data.posts || []; + var container = document.getElementById('threadContent'); + if (!container) return; + if (posts.length === 0) { + container.innerHTML = '
    Empty thread.
    '; + return; + } + var html = '
    '; + html += '

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

    '; + html += '
    ' + posts.length + ' posts · started ' + new Date(posts[0].created_at).toLocaleString() + '
    '; + html += '
    '; + for (var i = 0; i < posts.length; i++) { + html += renderForumPost(posts[i], i); + } + html += '
    '; + container.innerHTML = html; + } catch (e) { console.error('Failed to load forum thread:', e); } + } + + function renderForumPost(post, index) { + var bgClass = index % 2 === 0 ? 'bg1' : 'bg2'; + var isAgent = post.author_type === 'agent'; + var agentKey = post.author_key || ''; + var profiles = { + consolidation: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, + dreaming: { name: 'Dreaming Agent', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, + episoding: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, + abstraction: { name: 'Abstraction Agent', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, + metacognition: { name: 'Metacognition Agent', title: 'Self-Reflection', icon: 'MA', color: 'var(--accent-blue)' }, + encoding: { name: 'Encoding Agent', title: 'Memory Encoder', icon: 'EA', color: 'var(--accent-blue)' }, + perception: { name: 'Perception Agent', title: 'Filesystem Watcher', icon: 'PA', color: 'var(--accent-green)' }, + retrieval: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, + }; + var prof = profiles[agentKey] || { name: post.author_name || 'Human', title: isAgent ? 'Agent' : 'User', icon: isAgent ? agentKey.slice(0,2).toUpperCase() : 'HU', color: isAgent ? 'var(--text-dim)' : 'var(--accent-cyan)' }; + var time = new Date(post.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + // Highlight @mentions in content + var contentHtml = escapeHtml(post.content || '').replace(/@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)/g, '@$1'); + var html = '
    '; + html += '
    '; + html += '
    ' + prof.icon + '
    '; + html += '' + escapeHtml(prof.name) + '
    '; + html += '
    ' + escapeHtml(prof.title) + '
    '; + if (post.event_ref) html += '
    via ' + escapeHtml(post.event_ref) + '
    '; + html += '
    '; + html += ''; + html += '
    ' + contentHtml + '
    '; + if (post.state === 'internalized') { + html += '
    internalized
    '; + } else { + html += '
    '; + } + html += '
    '; + return html; + } + + function appendForumPostToThread(payload) { + var container = document.getElementById('threadContent'); + if (!container) return; + var wrap = container.querySelector('.thread-wrap'); + if (!wrap) return; + var postCount = wrap.querySelectorAll('.post').length; + var postObj = { + id: payload.post_id, + content: payload.content, + author_type: payload.author_type, + author_name: payload.author_name, + author_key: payload.author_key || '', + created_at: payload.timestamp || new Date().toISOString(), + state: 'active', + }; + wrap.insertAdjacentHTML('beforeend', renderForumPost(postObj, postCount)); + // Scroll to new post + var newPost = document.getElementById('forum-post-' + payload.post_id); + if (newPost) newPost.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + + function showNewThreadForm() { + var form = document.getElementById('newThreadForm'); + if (form) { form.style.display = ''; document.getElementById('newThreadContent').focus(); } + } + + async function submitNewThread() { + var textarea = document.getElementById('newThreadContent'); + var content = (textarea.value || '').trim(); + if (!content) return; + textarea.disabled = true; + try { + var resp = await fetch('/api/v1/forum/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content }) + }); + var data = await resp.json(); + textarea.value = ''; + textarea.disabled = false; + document.getElementById('newThreadForm').style.display = 'none'; + showToast('Thread created'); + state.forumLoaded = false; + loadForumThreads(); + // Navigate to the new thread + loadForumThread(data.thread_id); + } catch (e) { + textarea.disabled = false; + showToast('Failed to create thread', 'error'); + } + } + + async function submitThreadReply() { + var textarea = document.getElementById('threadReplyContent'); + var content = (textarea.value || '').trim(); + if (!content || !state.currentForumThread) return; + textarea.disabled = true; + try { + await fetch('/api/v1/forum/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content, thread_id: state.currentForumThread }) + }); + textarea.value = ''; + textarea.disabled = false; + showToast('Reply posted'); + // The WebSocket event will handle live-inserting the post + } catch (e) { + textarea.disabled = false; + showToast('Failed to post reply', 'error'); + } + } + + async function internalizePost(postId, btn) { + btn.disabled = true; + btn.textContent = 'internalizing...'; + try { + var resp = await fetch('/api/v1/forum/posts/' + postId + '/internalize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + if (resp.ok) { + btn.textContent = 'internalized'; + btn.style.color = 'var(--accent-green)'; + btn.style.borderColor = 'var(--accent-green)'; + showToast('Post internalized into memory'); + } else { + var data = await resp.json(); + btn.textContent = data.error === 'post already internalized' ? 'already internalized' : 'failed'; + btn.disabled = false; + } + } catch (e) { + btn.textContent = 'failed'; + btn.disabled = false; + showToast('Failed to internalize', 'error'); + } + } + function toggleToolDetail(e) { var pill = e.currentTarget; var toolId = pill.getAttribute('data-tool-use-id'); From 1d06d922c6db2a062c2510f282cf5e41982e0069 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 10:41:41 -0400 Subject: [PATCH 44/74] chore: go fmt formatting fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/agent/perception/heuristic.go | 54 ++++++--- internal/config/config.go | 156 ++++++++++++------------- internal/mcp/server.go | 12 +- 3 files changed, 123 insertions(+), 99 deletions(-) diff --git a/internal/agent/perception/heuristic.go b/internal/agent/perception/heuristic.go index 9dfb67af..e72b0e7d 100644 --- a/internal/agent/perception/heuristic.go +++ b/internal/agent/perception/heuristic.go @@ -42,18 +42,42 @@ type HeuristicConfig struct { // scoringOrDefault returns the scoring config with defaults for any zero values. func (s ScoringConfig) withDefaults() ScoringConfig { d := s - if d.BaseFilesystem <= 0 { d.BaseFilesystem = 0.3 } - if d.BaseTerminal <= 0 { d.BaseTerminal = 0.3 } - if d.BaseClipboard <= 0 { d.BaseClipboard = 0.3 } - if d.BaseMCP <= 0 { d.BaseMCP = 0.6 } - if d.BoostErrorLog <= 0 { d.BoostErrorLog = 0.2 } - if d.BoostConfig <= 0 { d.BoostConfig = 0.15 } - if d.BoostSourceCode <= 0 { d.BoostSourceCode = 0.1 } - if d.BoostCommand <= 0 { d.BoostCommand = 0.25 } - if d.BoostCodeSnippet <= 0 { d.BoostCodeSnippet = 0.2 } - if d.KeywordHigh <= 0 { d.KeywordHigh = 0.15 } - if d.KeywordMedium <= 0 { d.KeywordMedium = 0.10 } - if d.KeywordLow <= 0 { d.KeywordLow = 0.05 } + if d.BaseFilesystem <= 0 { + d.BaseFilesystem = 0.3 + } + if d.BaseTerminal <= 0 { + d.BaseTerminal = 0.3 + } + if d.BaseClipboard <= 0 { + d.BaseClipboard = 0.3 + } + if d.BaseMCP <= 0 { + d.BaseMCP = 0.6 + } + if d.BoostErrorLog <= 0 { + d.BoostErrorLog = 0.2 + } + if d.BoostConfig <= 0 { + d.BoostConfig = 0.15 + } + if d.BoostSourceCode <= 0 { + d.BoostSourceCode = 0.1 + } + if d.BoostCommand <= 0 { + d.BoostCommand = 0.25 + } + if d.BoostCodeSnippet <= 0 { + d.BoostCodeSnippet = 0.2 + } + if d.KeywordHigh <= 0 { + d.KeywordHigh = 0.15 + } + if d.KeywordMedium <= 0 { + d.KeywordMedium = 0.10 + } + if d.KeywordLow <= 0 { + d.KeywordLow = 0.05 + } return d } @@ -72,9 +96,9 @@ type frequencyEntry struct { // HeuristicFilter implements the pre-filter logic for watcher events. type HeuristicFilter struct { - cfg HeuristicConfig - scoring ScoringConfig // resolved scoring with defaults applied - log *slog.Logger + cfg HeuristicConfig + scoring ScoringConfig // resolved scoring with defaults applied + log *slog.Logger mu sync.RWMutex frequency map[string][]frequencyEntry // hash -> list of timestamps diff --git a/internal/config/config.go b/internal/config/config.go index 60677dcd..fb4bf083 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,28 +16,28 @@ import ( // Config is the root configuration structure. type Config struct { - LLM LLMConfig `yaml:"llm"` - Store StoreConfig `yaml:"store"` - Memory MemoryConfig `yaml:"memory"` - Perception PerceptionConfig `yaml:"perception"` - Encoding EncodingConfig `yaml:"encoding"` - Consolidation ConsolidationConfig `yaml:"consolidation"` - Retrieval RetrievalConfig `yaml:"retrieval"` - Metacognition MetacognitionConfig `yaml:"metacognition"` - Dreaming DreamingConfig `yaml:"dreaming"` - Episoding EpisodingConfig `yaml:"episoding"` - Abstraction AbstractionConfig `yaml:"abstraction"` - Orchestrator OrchestratorConfig `yaml:"orchestrator"` + LLM LLMConfig `yaml:"llm"` + Store StoreConfig `yaml:"store"` + Memory MemoryConfig `yaml:"memory"` + Perception PerceptionConfig `yaml:"perception"` + Encoding EncodingConfig `yaml:"encoding"` + Consolidation ConsolidationConfig `yaml:"consolidation"` + Retrieval RetrievalConfig `yaml:"retrieval"` + Metacognition MetacognitionConfig `yaml:"metacognition"` + Dreaming DreamingConfig `yaml:"dreaming"` + Episoding EpisodingConfig `yaml:"episoding"` + Abstraction AbstractionConfig `yaml:"abstraction"` + Orchestrator OrchestratorConfig `yaml:"orchestrator"` Reactor ReactorConfig `yaml:"reactor"` MemoryDefaults MemoryDefaultsConfig `yaml:"memory_defaults"` MCP MCPConfig `yaml:"mcp"` - AgentSDK AgentSDKConfig `yaml:"agent_sdk"` - Training TrainingConfig `yaml:"training"` - Coaching CoachingConfig `yaml:"coaching"` - API APIConfig `yaml:"api"` - Web WebConfig `yaml:"web"` - Logging LoggingConfig `yaml:"logging"` - Projects []ProjectConfig `yaml:"projects"` + AgentSDK AgentSDKConfig `yaml:"agent_sdk"` + Training TrainingConfig `yaml:"training"` + Coaching CoachingConfig `yaml:"coaching"` + API APIConfig `yaml:"api"` + Web WebConfig `yaml:"web"` + Logging LoggingConfig `yaml:"logging"` + Projects []ProjectConfig `yaml:"projects"` } // LLMConfig holds LLM provider settings. @@ -292,14 +292,14 @@ type RetrievalConfig struct { // MetacognitionConfig holds metacognition settings. type MetacognitionConfig struct { - Enabled bool `yaml:"enabled"` - IntervalRaw string `yaml:"interval"` - Interval time.Duration `yaml:"-"` - StartupDelaySec int `yaml:"startup_delay_sec"` // seconds before first cycle (default: 60) - ReflectionLookbackRaw string `yaml:"reflection_lookback"` // how far back to analyze (default: "7d") - ReflectionLookback time.Duration `yaml:"-"` - DeadMemoryWindowRaw string `yaml:"dead_memory_window"` // age threshold for dead memory analysis (default: "30d") - DeadMemoryWindow time.Duration `yaml:"-"` + Enabled bool `yaml:"enabled"` + IntervalRaw string `yaml:"interval"` + Interval time.Duration `yaml:"-"` + StartupDelaySec int `yaml:"startup_delay_sec"` // seconds before first cycle (default: 60) + ReflectionLookbackRaw string `yaml:"reflection_lookback"` // how far back to analyze (default: "7d") + ReflectionLookback time.Duration `yaml:"-"` + DeadMemoryWindowRaw string `yaml:"dead_memory_window"` // age threshold for dead memory analysis (default: "30d") + DeadMemoryWindow time.Duration `yaml:"-"` } // DreamingConfig holds dreaming (memory replay) agent settings. @@ -311,52 +311,52 @@ type DreamingConfig struct { SalienceThreshold float32 `yaml:"salience_threshold"` AssociationBoostFactor float32 `yaml:"association_boost_factor"` NoisePruneThreshold float32 `yaml:"noise_prune_threshold"` - StartupDelaySec int `yaml:"startup_delay_sec"` // seconds before first cycle (default: 90) - DeadMemoryWindowRaw string `yaml:"dead_memory_window"` // age threshold for noise pruning (default: "30d") + StartupDelaySec int `yaml:"startup_delay_sec"` // seconds before first cycle (default: 90) + DeadMemoryWindowRaw string `yaml:"dead_memory_window"` // age threshold for noise pruning (default: "30d") DeadMemoryWindow time.Duration `yaml:"-"` - InsightsBudget int `yaml:"insights_budget"` // max insights per dream cycle (default: 2) - DefaultConfidence float32 `yaml:"default_confidence"` // fallback confidence for generated insights (default: 0.6) + InsightsBudget int `yaml:"insights_budget"` // max insights per dream cycle (default: 2) + DefaultConfidence float32 `yaml:"default_confidence"` // fallback confidence for generated insights (default: 0.6) } // EpisodingConfig configures the episoding agent. type EpisodingConfig struct { - Enabled bool `yaml:"enabled"` - EpisodeWindowSizeMin int `yaml:"episode_window_size_min"` - MinEventsPerEpisode int `yaml:"min_events_per_episode"` - StartupLookbackRaw string `yaml:"startup_lookback"` // how far back to look on startup (default: "1h") + Enabled bool `yaml:"enabled"` + EpisodeWindowSizeMin int `yaml:"episode_window_size_min"` + MinEventsPerEpisode int `yaml:"min_events_per_episode"` + StartupLookbackRaw string `yaml:"startup_lookback"` // how far back to look on startup (default: "1h") StartupLookback time.Duration `yaml:"-"` - DefaultSalience float32 `yaml:"default_salience"` // fallback salience for synthesized episodes (default: 0.5) - PollingIntervalSec int `yaml:"polling_interval_sec"` // seconds between episode checks (default: 10) + DefaultSalience float32 `yaml:"default_salience"` // fallback salience for synthesized episodes (default: 0.5) + PollingIntervalSec int `yaml:"polling_interval_sec"` // seconds between episode checks (default: 10) } // AbstractionConfig configures the abstraction agent (hierarchical knowledge). type AbstractionConfig struct { - Enabled bool `yaml:"enabled"` - IntervalRaw string `yaml:"interval"` - Interval time.Duration `yaml:"-"` - MinStrength float32 `yaml:"min_strength"` // minimum pattern strength to consider - MaxLLMCalls int `yaml:"max_llm_calls"` // budget per cycle - StartupDelaySec int `yaml:"startup_delay_sec"` // seconds before first cycle (default: 300) - DefaultConfidence float32 `yaml:"default_confidence"` // fallback confidence for principles (default: 0.6) - PatternAxiomConfidence float32 `yaml:"pattern_axiom_confidence"` // fallback confidence for axioms (default: 0.5) - ConfidenceModerateDecay float32 `yaml:"confidence_moderate_decay"` // grounding multiplier for moderate decay (default: 0.9) - ConfidenceSignificantDecay float32 `yaml:"confidence_significant_decay"` // grounding multiplier for significant decay (default: 0.7) - ConfidenceSevereDecay float32 `yaml:"confidence_severe_decay"` // grounding multiplier for severe decay (default: 0.5) - GroundingFloor float32 `yaml:"grounding_floor"` // confidence floor for young abstractions (default: 0.5) + Enabled bool `yaml:"enabled"` + IntervalRaw string `yaml:"interval"` + Interval time.Duration `yaml:"-"` + MinStrength float32 `yaml:"min_strength"` // minimum pattern strength to consider + MaxLLMCalls int `yaml:"max_llm_calls"` // budget per cycle + StartupDelaySec int `yaml:"startup_delay_sec"` // seconds before first cycle (default: 300) + DefaultConfidence float32 `yaml:"default_confidence"` // fallback confidence for principles (default: 0.6) + PatternAxiomConfidence float32 `yaml:"pattern_axiom_confidence"` // fallback confidence for axioms (default: 0.5) + ConfidenceModerateDecay float32 `yaml:"confidence_moderate_decay"` // grounding multiplier for moderate decay (default: 0.9) + ConfidenceSignificantDecay float32 `yaml:"confidence_significant_decay"` // grounding multiplier for significant decay (default: 0.7) + ConfidenceSevereDecay float32 `yaml:"confidence_severe_decay"` // grounding multiplier for severe decay (default: 0.5) + GroundingFloor float32 `yaml:"grounding_floor"` // confidence floor for young abstractions (default: 0.5) } // OrchestratorConfig configures the autonomous orchestrator. type OrchestratorConfig struct { - Enabled bool `yaml:"enabled"` - AdaptiveIntervals bool `yaml:"adaptive_intervals"` - MaxDBSizeMB int `yaml:"max_db_size_mb"` - SelfTestIntervalRaw string `yaml:"self_test_interval"` - SelfTestInterval time.Duration `yaml:"-"` - AutoRecovery bool `yaml:"auto_recovery"` - MonitorIntervalRaw string `yaml:"monitor_interval"` - MonitorInterval time.Duration `yaml:"-"` - HealthReportIntervalRaw string `yaml:"health_report_interval"` // how often to write health reports (default: "5m") - HealthReportInterval time.Duration `yaml:"-"` + Enabled bool `yaml:"enabled"` + AdaptiveIntervals bool `yaml:"adaptive_intervals"` + MaxDBSizeMB int `yaml:"max_db_size_mb"` + SelfTestIntervalRaw string `yaml:"self_test_interval"` + SelfTestInterval time.Duration `yaml:"-"` + AutoRecovery bool `yaml:"auto_recovery"` + MonitorIntervalRaw string `yaml:"monitor_interval"` + MonitorInterval time.Duration `yaml:"-"` + HealthReportIntervalRaw string `yaml:"health_report_interval"` // how often to write health reports (default: "5m") + HealthReportInterval time.Duration `yaml:"-"` } // ReactorConfig configures the event-driven reactor engine. @@ -520,19 +520,19 @@ func Default() *Config { KeywordMedium: 0.10, KeywordLow: 0.05, }, - ContentDedupTTLSec: 5, - GitOpCooldownSec: 10, - MaxRawContentLen: 10000, - LLMGateSnippetLen: 500, - LLMGateTimeoutSec: 10, - HeuristicPassScore: 0.2, - BatchEditWindowSec: 5, - BatchEditThreshold: 3, - RecallBoostWindowMin: 30, - RecallBoostMax: 0.2, - RejectionThreshold: 50, - RejectionWindowMin: 60, - RejectionMaxPromoted: 20, + ContentDedupTTLSec: 5, + GitOpCooldownSec: 10, + MaxRawContentLen: 10000, + LLMGateSnippetLen: 500, + LLMGateTimeoutSec: 10, + HeuristicPassScore: 0.2, + BatchEditWindowSec: 5, + BatchEditThreshold: 3, + RecallBoostWindowMin: 30, + RecallBoostMax: 0.2, + RejectionThreshold: 50, + RejectionWindowMin: 60, + RejectionMaxPromoted: 20, Filesystem: FilesystemPerceptionConfig{ Enabled: true, WatchDirs: []string{ @@ -754,12 +754,12 @@ func Default() *Config { GroundingFloor: 0.5, }, Orchestrator: OrchestratorConfig{ - Enabled: true, - AdaptiveIntervals: true, - MaxDBSizeMB: 500, - SelfTestIntervalRaw: "12h", - SelfTestInterval: 12 * time.Hour, - AutoRecovery: true, + Enabled: true, + AdaptiveIntervals: true, + MaxDBSizeMB: 500, + SelfTestIntervalRaw: "12h", + SelfTestInterval: 12 * time.Hour, + AutoRecovery: true, MonitorIntervalRaw: "5m", MonitorInterval: 5 * time.Minute, HealthReportIntervalRaw: "5m", diff --git a/internal/mcp/server.go b/internal/mcp/server.go index bc1849a7..fe9a5a2c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -50,12 +50,12 @@ type ProjectResolver interface { // MemoryDefaults holds shared salience and feedback tuning values. type MemoryDefaults struct { - SalienceGeneral float32 - SalienceDecision float32 - SalienceError float32 - SalienceInsight float32 - SalienceLearning float32 - SalienceHandoff float32 + SalienceGeneral float32 + SalienceDecision float32 + SalienceError float32 + SalienceInsight float32 + SalienceLearning float32 + SalienceHandoff float32 FeedbackStrengthDelta float32 FeedbackSalienceBoost float32 } From ec8ce1a660bc1bf01402898dace8b0e58d1d774c Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 10:48:13 -0400 Subject: [PATCH 45/74] feat: restyle SDK, LLM, Tools views to forum aesthetic (#349) Align all three data views with the forum design language: - Reduce border-radius from var(--radius-md) to 4px across all cards/panels - Swap --bg-card for --bg-row background tokens - Add alternating row colors (--bg-row-alt) and hover states to tables - Style .llm-header as forum-style bar (--bg-head background, border) - Add border-bottom separators to panel titles and section labels - Tighten spacing (gaps from 12-16px to 8px) for denser layout - Align .agent-panel, .agent-stat-card, .ra-kpi, .cognitive-card Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 54 ++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index baab98d0..9100a81d 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -589,27 +589,29 @@ - /* ── 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 Usage View (forum-aligned) ── */ + .llm-view { padding: 8px 16px; max-width: 1200px; margin: 0 auto; } + .llm-header { display: flex; align-items: center; justify-content: space-between; margin: 6px 0; padding: 6px 16px; background: var(--bg-head); border: 1px solid var(--border-accent); border-radius: 4px; } .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-cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 8px; padding: 8px 16px; } .llm-card { - background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); - padding: 16px; text-align: center; + background: var(--bg-row); border: 1px solid var(--border-subtle); border-radius: 4px; + padding: 12px 8px; 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-card-label { font-size: 0.72rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } + .llm-card-value { font-size: 1.3rem; font-weight: 700; color: var(--accent-cyan); font-family: 'SF Mono', Monaco, monospace; } + .llm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .llm-panel { - background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px; + background: var(--bg-row); border: 1px solid var(--border-subtle); border-radius: 4px; padding: 12px 16px; margin-bottom: 8px; } - .llm-panel-title { font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; } + .llm-panel-title { font-size: 0.82rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border-subtle); } .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 th { text-align: left; color: var(--text-dim); font-weight: 500; padding: 6px 8px; border-bottom: 1px solid var(--border-subtle); background: var(--bg-head); } .llm-table td { padding: 6px 8px; color: var(--text-secondary); border-bottom: 1px solid var(--border-subtle); } + .llm-table tr:nth-child(even) td { background: var(--bg-row-alt); } + .llm-table tr:hover td { background: var(--bg-row-hover); } .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; } @@ -632,14 +634,14 @@ .llm-header-sub { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; } .llm-completion-val { color: var(--accent-violet) !important; } - /* ── 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 Agent View (forum-aligned) ── */ + .sdk-section { margin-bottom: 8px; } + .sdk-section-label { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; padding: 4px 0; border-bottom: 1px solid var(--border-subtle); } + .sdk-stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } + .sdk-stat-cards-5 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; } .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-evo-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .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; } @@ -701,8 +703,8 @@ .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-kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 8px; } + .ra-kpi { background: var(--bg-row); border: 1px solid var(--border-subtle); border-radius: 4px; padding: 12px 14px; } .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; } @@ -766,8 +768,8 @@ .event-subtitle { font-size: 0.72rem; color: var(--text-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; } .event-duration { font-size: 0.65rem; color: var(--text-dim); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } /* ── Cognitive Agents Panel ── */ - .cognitive-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; } - .cognitive-card { background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px 14px; text-align: center; } + .cognitive-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 8px; } + .cognitive-card { background: var(--bg-row); border: 1px solid var(--border-subtle); border-radius: 4px; padding: 10px 12px; 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; } @@ -857,14 +859,14 @@ .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 { background: var(--bg-row); border: 1px solid var(--border-subtle); border-radius: 4px; padding: 12px 10px; 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-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; } + .agent-panel { background: var(--bg-row); border: 1px solid var(--border-subtle); border-radius: 4px; padding: 12px 16px; max-height: 500px; overflow-y: auto; } + .agent-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border-subtle); } .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; } From 20b09664f356f8fc73997171603201f5eaab9a25 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 10:51:09 -0400 Subject: [PATCH 46/74] chore: remove dead CSS from dashboard (#350) Remove unused card-based CSS rules that were superseded by the forum layout: .type-*, .state-*, .salience-*, .strength-*, .memory-expanded*, .pattern-card, .abstraction-card, .memory-id-label. These classes had CSS definitions but no HTML/JS references. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 51 ---------------------------------- 1 file changed, 51 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 9100a81d..7f703ab7 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -332,57 +332,6 @@ .episode-date { font-size: 0.75rem; color: var(--text-dim); margin-left: auto; } .episode-expanded.open { display: block; } - - - - /* Memory cards */ - - - - - .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); } - - - - - - - - .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); } - - - - .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-expanded.open { display: block; } - - .memory-expanded-detail span { opacity: 0.8; } - - .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)); } - /* ── Timeline View ── */ .timeline-view { padding: 0; flex-direction: column; height: 100%; overflow: hidden !important; } .timeline-view.active { display: flex; } From a37baf9be61c01bc8dc60fd22939363f7e88d7b1 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 10:53:04 -0400 Subject: [PATCH 47/74] fix: increase @mention response token limit from 200 to 512 Agent replies were getting truncated mid-sentence. 200 tokens is too short for a meaningful conversational response. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/agent/reactor/actions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/agent/reactor/actions.go b/internal/agent/reactor/actions.go index e13c1073..967b1d06 100644 --- a/internal/agent/reactor/actions.go +++ b/internal/agent/reactor/actions.go @@ -230,7 +230,7 @@ func (a *RespondToMentionAction) Execute(ctx context.Context, trigger events.Eve {Role: "system", Content: systemPrompt.String()}, {Role: "user", Content: mention.Content}, }, - MaxTokens: 200, + MaxTokens: 512, Temperature: 0.7, }) if err != nil { From 6dabd0ede3a58f19981e7cd27f573116dc517986 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 10:59:03 -0400 Subject: [PATCH 48/74] =?UTF-8?q?refactor:=20externalize=20forum=20config?= =?UTF-8?q?=20=E2=80=94=20no=20more=20magic=20numbers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all forum-related constants to ForumConfig in config.go: - forum.agent_posting: enable/disable autonomous agent forum posts - forum.mention_responses: enable/disable @mention LLM responses - forum.mention_max_tokens: configurable token limit (default 512) - forum.mention_temperature: configurable LLM temperature (default 0.7) Forum reactor chains are now conditionally registered based on config. RespondToMentionAction reads MaxTokens and Temperature from config instead of hardcoded values. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mnemonic/main.go | 4 + internal/agent/reactor/actions.go | 6 +- internal/agent/reactor/reactor_test.go | 16 +- internal/agent/reactor/registry.go | 251 ++++++++++++++----------- internal/config/config.go | 15 ++ 5 files changed, 169 insertions(+), 123 deletions(-) diff --git a/cmd/mnemonic/main.go b/cmd/mnemonic/main.go index d4ce8c07..680de3ae 100644 --- a/cmd/mnemonic/main.go +++ b/cmd/mnemonic/main.go @@ -1679,6 +1679,10 @@ 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.MentionLLM = llmProvider if retriever != nil { deps.MentionQuery = retriever diff --git a/internal/agent/reactor/actions.go b/internal/agent/reactor/actions.go index 967b1d06..9ad2daa8 100644 --- a/internal/agent/reactor/actions.go +++ b/internal/agent/reactor/actions.go @@ -184,6 +184,8 @@ func querySimple(ctx context.Context, q ForumQuerier, query string, limit int) [ 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 } @@ -230,8 +232,8 @@ func (a *RespondToMentionAction) Execute(ctx context.Context, trigger events.Eve {Role: "system", Content: systemPrompt.String()}, {Role: "user", Content: mention.Content}, }, - MaxTokens: 512, - Temperature: 0.7, + MaxTokens: a.MaxTokens, + Temperature: float32(a.Temperature), }) if err != nil { content = fmt.Sprintf("%s encountered an error processing your mention. Try again later.", personality.Name) diff --git a/internal/agent/reactor/reactor_test.go b/internal/agent/reactor/reactor_test.go index 223a3383..ef554a21 100644 --- a/internal/agent/reactor/reactor_test.go +++ b/internal/agent/reactor/reactor_test.go @@ -612,13 +612,15 @@ 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) != 13 { diff --git a/internal/agent/reactor/registry.go b/internal/agent/reactor/registry.go index 23c2b147..fcdca94b 100644 --- a/internal/agent/reactor/registry.go +++ b/internal/agent/reactor/registry.go @@ -21,9 +21,13 @@ type ChainDeps struct { MaxDBSizeMB int CooldownOverrides map[string]time.Duration // chain ID -> cooldown override Logger *slog.Logger - // Forum @mention dependencies (can be nil to disable) - MentionLLM llm.Provider // for @mention LLM responses - MentionQuery ForumQuerier // for @retrieval recall queries + // 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 + 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. @@ -212,131 +216,150 @@ 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{Log: log} - 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), + 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, - }) + 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), + 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, - }) + 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), + 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, - }) + 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_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_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, + }) - // Forum @mention response chain - 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, - Log: log, + 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), + }, }, - }, - Cooldown: deps.cooldown("forum_mention_response", 10*time.Second), - Priority: 5, - Enabled: true, - }) + Actions: []Action{forumAction}, + Cooldown: deps.cooldown("forum_on_meta", 30*time.Minute), + Priority: 1, + Enabled: true, + }) + } // end if ForumAgentPosting - 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), + // 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{forumAction}, - Cooldown: deps.cooldown("forum_on_meta", 30*time.Minute), - Priority: 1, - Enabled: true, - }) + 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/config/config.go b/internal/config/config.go index fb4bf083..159d726c 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,14 @@ 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) +} + // MemoryDefaultsConfig holds shared defaults used by both MCP and API. type MemoryDefaultsConfig struct { InitialSalienceGeneral float32 `yaml:"initial_salience_general"` // default: 0.7 @@ -766,6 +775,12 @@ func Default() *Config { HealthReportInterval: 5 * time.Minute, }, Reactor: ReactorConfig{}, + Forum: ForumConfig{ + AgentPosting: true, + MentionResponses: true, + MentionMaxTokens: 512, + MentionTemp: 0.7, + }, MemoryDefaults: MemoryDefaultsConfig{ InitialSalienceGeneral: 0.7, InitialSalienceDecision: 0.85, From d1b662b4f5ebde3561213321dfb4bbd6b0d5306c Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 11:05:54 -0400 Subject: [PATCH 49/74] =?UTF-8?q?feat:=20forum=20UX=20=E2=80=94=20@mention?= =?UTF-8?q?=20autocomplete,=20quote=20button,=20@tag=20names,=20blank=20re?= =?UTF-8?q?ply=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four improvements based on user testing feedback: 1. @mention autocomplete: typing @ in any compose box shows a dropdown of available agents with icons, tags, and descriptions. Tab/Enter to select, Escape to dismiss. 2. Quote button: each post has a "quote" button that pre-fills the compose box with "> @agent wrote:\n> content" format, preserving context like classic forums. 3. Agent display names: posts now show @tag (e.g., "@dreaming") instead of full names ("Dreaming Agent") so users know exactly what to type to mention an agent. 4. Blank reply fix: guard against empty LLM responses — if the model returns an empty string, a fallback message is shown instead of a blank post. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/agent/reactor/actions.go | 5 +- internal/web/static/index.html | 137 ++++++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 19 deletions(-) diff --git a/internal/agent/reactor/actions.go b/internal/agent/reactor/actions.go index 9ad2daa8..d94cd33f 100644 --- a/internal/agent/reactor/actions.go +++ b/internal/agent/reactor/actions.go @@ -241,7 +241,10 @@ func (a *RespondToMentionAction) Execute(ctx context.Context, trigger events.Eve a.Log.Warn("mention LLM call failed", "agent", mention.AgentKey, "error", err) } } else { - content = resp.Content + content = strings.TrimSpace(resp.Content) + if content == "" { + content = fmt.Sprintf("%s processed your mention but had nothing to add right now.", personality.Name) + } } } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 7f703ab7..648b0543 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -4751,9 +4751,9 @@

    Activity

    window.location.hash = 'forum-thread/' + threadId; var bc = document.getElementById('breadcrumbs'); if (bc) bc.innerHTML = 'mnemonicForumThread'; - // Show compose box + // Show compose box and init autocomplete var compose = document.getElementById('threadCompose'); - if (compose) compose.style.display = ''; + if (compose) { compose.style.display = ''; ensureMentionAutocomplete('threadReplyContent'); } try { var resp = await fetch('/api/v1/forum/threads/' + threadId); var data = await resp.json(); @@ -4776,38 +4776,45 @@

    Activity

    } catch (e) { console.error('Failed to load forum thread:', e); } } + var _forumAgentProfiles = { + consolidation: { tag: '@consolidation', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, + dreaming: { tag: '@dreaming', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, + episoding: { tag: '@episoding', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, + abstraction: { tag: '@abstraction', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, + metacognition: { tag: '@metacognition', title: 'Self-Reflection', icon: 'MA', color: 'var(--accent-blue)' }, + encoding: { tag: '@encoding', title: 'Memory Encoder', icon: 'EA', color: 'var(--accent-blue)' }, + perception: { tag: '@perception', title: 'Filesystem Watcher', icon: 'PA', color: 'var(--accent-green)' }, + retrieval: { tag: '@retrieval', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, + }; + function renderForumPost(post, index) { var bgClass = index % 2 === 0 ? 'bg1' : 'bg2'; var isAgent = post.author_type === 'agent'; var agentKey = post.author_key || ''; - var profiles = { - consolidation: { name: 'Consolidation Agent', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, - dreaming: { name: 'Dreaming Agent', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, - episoding: { name: 'Episoding Agent', title: 'Episode Clustering', icon: 'EP', color: 'var(--accent-violet)' }, - abstraction: { name: 'Abstraction Agent', title: 'Pattern Discovery', icon: 'AA', color: 'var(--accent-orange)' }, - metacognition: { name: 'Metacognition Agent', title: 'Self-Reflection', icon: 'MA', color: 'var(--accent-blue)' }, - encoding: { name: 'Encoding Agent', title: 'Memory Encoder', icon: 'EA', color: 'var(--accent-blue)' }, - perception: { name: 'Perception Agent', title: 'Filesystem Watcher', icon: 'PA', color: 'var(--accent-green)' }, - retrieval: { name: 'Retrieval Agent', title: 'Spread Activation', icon: 'RA', color: 'var(--accent-cyan)' }, - }; - var prof = profiles[agentKey] || { name: post.author_name || 'Human', title: isAgent ? 'Agent' : 'User', icon: isAgent ? agentKey.slice(0,2).toUpperCase() : 'HU', color: isAgent ? 'var(--text-dim)' : 'var(--accent-cyan)' }; + var prof = _forumAgentProfiles[agentKey] || { tag: post.author_name || 'Human', title: isAgent ? 'Agent' : 'User', icon: isAgent ? agentKey.slice(0,2).toUpperCase() : 'HU', color: isAgent ? 'var(--text-dim)' : 'var(--accent-cyan)' }; + var displayName = isAgent ? prof.tag : (post.author_name || 'Human'); var time = new Date(post.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); // Highlight @mentions in content var contentHtml = escapeHtml(post.content || '').replace(/@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)/g, '@$1'); + var quotedContent = (post.content || '').replace(/\n/g, '\n> '); var html = '
    '; html += '
    '; html += '
    ' + prof.icon + '
    '; - html += '' + escapeHtml(prof.name) + '
    '; + html += '' + escapeHtml(displayName) + ''; html += '
    ' + escapeHtml(prof.title) + '
    '; if (post.event_ref) html += '
    via ' + escapeHtml(post.event_ref) + '
    '; html += '
    '; - html += ''; + html += ''; html += '
    ' + contentHtml + '
    '; + // Action buttons + html += '
    '; + html += ''; if (post.state === 'internalized') { - html += '
    internalized
    '; + html += 'internalized'; } else { - html += '
    '; + html += ''; } + html += '
    '; html += '
    '; return html; } @@ -4835,7 +4842,7 @@

    Activity

    function showNewThreadForm() { var form = document.getElementById('newThreadForm'); - if (form) { form.style.display = ''; document.getElementById('newThreadContent').focus(); } + if (form) { form.style.display = ''; document.getElementById('newThreadContent').focus(); ensureMentionAutocomplete('newThreadContent'); } } async function submitNewThread() { @@ -4885,6 +4892,100 @@

    Activity

    } } + function quotePost(authorName, content) { + var textarea = document.getElementById('threadReplyContent'); + if (!textarea) return; + var quote = '> ' + authorName + ' wrote:\n> ' + content + '\n\n'; + textarea.value = quote + textarea.value; + textarea.focus(); + textarea.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + // ── @mention autocomplete ── + var _mentionAgents = [ + { key: 'retrieval', label: '@retrieval', desc: 'Search memories' }, + { key: 'metacognition', label: '@metacognition', desc: 'System health' }, + { key: 'encoding', label: '@encoding', desc: 'Memory encoder' }, + { key: 'episoding', label: '@episoding', desc: 'Episodes & timeline' }, + { key: 'consolidation', label: '@consolidation', desc: 'Memory maintenance' }, + { key: 'dreaming', label: '@dreaming', desc: 'Dream cycle insights' }, + { key: 'abstraction', label: '@abstraction', desc: 'Patterns & principles' }, + { key: 'perception', label: '@perception', desc: 'File watcher' }, + ]; + + function setupMentionAutocomplete(textarea) { + var dropdown = document.createElement('div'); + dropdown.className = 'mention-dropdown'; + dropdown.style.cssText = 'display:none;position:absolute;z-index:100;background:var(--bg-elevated);border:1px solid var(--border-accent);border-radius:4px;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-height:200px;overflow-y:auto;font-size:0.82rem;min-width:200px'; + textarea.parentNode.style.position = 'relative'; + textarea.parentNode.appendChild(dropdown); + + textarea.addEventListener('input', function() { + var val = textarea.value; + var cursor = textarea.selectionStart; + // Find @ before cursor + var before = val.substring(0, cursor); + var atMatch = before.match(/@(\w*)$/); + if (!atMatch) { dropdown.style.display = 'none'; return; } + var filter = atMatch[1].toLowerCase(); + var filtered = _mentionAgents.filter(function(a) { return a.key.startsWith(filter); }); + if (filtered.length === 0) { dropdown.style.display = 'none'; return; } + var html = ''; + for (var i = 0; i < filtered.length; i++) { + var a = filtered[i]; + var prof = _forumAgentProfiles[a.key] || {}; + html += '
    '; + html += '' + (prof.icon || '') + ''; + html += '' + a.label + ' ' + a.desc + ''; + html += '
    '; + } + dropdown.innerHTML = html; + dropdown.style.display = 'block'; + dropdown.style.bottom = (textarea.offsetHeight + 4) + 'px'; + dropdown.style.left = '0'; + }); + + textarea.addEventListener('blur', function() { + setTimeout(function() { dropdown.style.display = 'none'; }, 150); + }); + + textarea.addEventListener('keydown', function(e) { + if (dropdown.style.display === 'none') return; + if (e.key === 'Escape') { dropdown.style.display = 'none'; e.preventDefault(); } + if (e.key === 'Tab' || e.key === 'Enter') { + var first = dropdown.querySelector('.mention-option'); + if (first) { insertMention(textarea.id, first.getAttribute('data-key')); e.preventDefault(); } + } + }); + } + + function insertMention(textareaId, agentKey) { + var textarea = document.getElementById(textareaId); + if (!textarea) return; + var val = textarea.value; + var cursor = textarea.selectionStart; + var before = val.substring(0, cursor); + var after = val.substring(cursor); + // Replace the @partial with @agentKey + var newBefore = before.replace(/@\w*$/, '@' + agentKey + ' '); + textarea.value = newBefore + after; + textarea.selectionStart = textarea.selectionEnd = newBefore.length; + textarea.focus(); + // Hide dropdown + var dropdown = textarea.parentNode.querySelector('.mention-dropdown'); + if (dropdown) dropdown.style.display = 'none'; + } + + // Initialize autocomplete on compose textareas when they become visible + var _mentionInitialized = {}; + function ensureMentionAutocomplete(textareaId) { + if (_mentionInitialized[textareaId]) return; + var el = document.getElementById(textareaId); + if (el) { setupMentionAutocomplete(el); _mentionInitialized[textareaId] = true; } + } + async function internalizePost(postId, btn) { btn.disabled = true; btn.textContent = 'internalizing...'; From fa12f7b79b758f84271c916a98da3102867decd5 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 11:10:10 -0400 Subject: [PATCH 50/74] fix: quote button, clickable @tags, dropdown styling - Quote button: use data-attributes instead of inline JSON to avoid escaping issues. Reads author/content from data-author/data-content on the post element. - Clickable @tags: clicking an agent's @tag in the sidebar inserts @agentkey into the reply box and focuses it. - Dropdown: insert before textarea (not after), use --bg-primary background, wider width, no absolute bottom positioning. - Blank replies: guard against empty LLM responses with fallback text. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 41 +++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 648b0543..da8ee93f 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -4796,11 +4796,17 @@

    Activity

    var time = new Date(post.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); // Highlight @mentions in content var contentHtml = escapeHtml(post.content || '').replace(/@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)/g, '@$1'); - var quotedContent = (post.content || '').replace(/\n/g, '\n> '); - var html = '
    '; + // Store post data for quote functionality via data attributes + var postDataId = 'forum-post-' + post.id; + var html = '
    '; html += '
    '; html += '
    ' + prof.icon + '
    '; - html += '' + escapeHtml(displayName) + '
    '; + // Clickable @tag — clicking it inserts @tag into reply box + if (isAgent && agentKey) { + html += '' + escapeHtml(displayName) + ''; + } else { + html += '' + escapeHtml(displayName) + ''; + } html += '
    ' + escapeHtml(prof.title) + '
    '; if (post.event_ref) html += '
    via ' + escapeHtml(post.event_ref) + '
    '; html += '
    '; @@ -4808,7 +4814,7 @@

    Activity

    html += '
    ' + contentHtml + '
    '; // Action buttons html += '
    '; - html += ''; + html += ''; if (post.state === 'internalized') { html += 'internalized'; } else { @@ -4892,11 +4898,26 @@

    Activity

    } } - function quotePost(authorName, content) { + function quotePostById(postElementId) { + var postEl = document.getElementById(postElementId); + if (!postEl) return; + var authorName = postEl.getAttribute('data-author') || 'Unknown'; + var content = postEl.getAttribute('data-content') || ''; + var textarea = document.getElementById('threadReplyContent'); + if (!textarea) return; + var quotedLines = content.split('\n').map(function(line) { return '> ' + line; }).join('\n'); + var quote = '> ' + authorName + ' wrote:\n' + quotedLines + '\n\n'; + textarea.value = quote; + textarea.focus(); + textarea.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + function insertTagInReply(agentKey) { var textarea = document.getElementById('threadReplyContent'); if (!textarea) return; - var quote = '> ' + authorName + ' wrote:\n> ' + content + '\n\n'; - textarea.value = quote + textarea.value; + var tag = '@' + agentKey + ' '; + textarea.value = tag + textarea.value; + textarea.selectionStart = textarea.selectionEnd = tag.length; textarea.focus(); textarea.scrollIntoView({ behavior: 'smooth', block: 'center' }); } @@ -4916,9 +4937,9 @@

    Activity

    function setupMentionAutocomplete(textarea) { var dropdown = document.createElement('div'); dropdown.className = 'mention-dropdown'; - dropdown.style.cssText = 'display:none;position:absolute;z-index:100;background:var(--bg-elevated);border:1px solid var(--border-accent);border-radius:4px;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-height:200px;overflow-y:auto;font-size:0.82rem;min-width:200px'; + dropdown.style.cssText = 'display:none;position:absolute;z-index:100;background:var(--bg-primary);border:1px solid var(--border-accent);border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,0.5);max-height:240px;overflow-y:auto;font-size:0.82rem;width:260px'; textarea.parentNode.style.position = 'relative'; - textarea.parentNode.appendChild(dropdown); + textarea.parentNode.insertBefore(dropdown, textarea); textarea.addEventListener('input', function() { var val = textarea.value; @@ -4943,8 +4964,6 @@

    Activity

    } dropdown.innerHTML = html; dropdown.style.display = 'block'; - dropdown.style.bottom = (textarea.offsetHeight + 4) + 'px'; - dropdown.style.left = '0'; }); textarea.addEventListener('blur', function() { From cbafcd86fec6e5ee600030f448ee19efb8c7063a Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 12:08:44 -0400 Subject: [PATCH 51/74] =?UTF-8?q?fix:=20disable=20thinking=20for=20forum?= =?UTF-8?q?=20@mention=20replies=20=E2=80=94=20root=20cause=20of=20truncat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini 3 Flash (thinking model) was consuming most of the max_tokens budget on reasoning tokens, leaving only ~100-150 tokens for the actual response. A 512 token limit with reasoning enabled produced truncated replies every time. Fix: add DisableThinking field to CompletionRequest. When set, the LM Studio provider sends reasoning_effort=none to thinking models. Forum mention responses now use DisableThinking:true since conversational replies don't need chain-of-thought. This is a general-purpose fix — any LLM call that expects short output on a thinking model should consider DisableThinking. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/agent/reactor/actions.go | 5 +++-- internal/llm/lmstudio.go | 7 +++---- internal/llm/provider.go | 17 +++++++++-------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/internal/agent/reactor/actions.go b/internal/agent/reactor/actions.go index d94cd33f..88677e13 100644 --- a/internal/agent/reactor/actions.go +++ b/internal/agent/reactor/actions.go @@ -232,8 +232,9 @@ func (a *RespondToMentionAction) Execute(ctx context.Context, trigger events.Eve {Role: "system", Content: systemPrompt.String()}, {Role: "user", Content: mention.Content}, }, - MaxTokens: a.MaxTokens, - Temperature: float32(a.Temperature), + 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) diff --git a/internal/llm/lmstudio.go b/internal/llm/lmstudio.go index bd6b55d5..fd8a5839 100644 --- a/internal/llm/lmstudio.go +++ b/internal/llm/lmstudio.go @@ -298,10 +298,9 @@ 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: disable reasoning when explicitly requested or for + // structured output (thinking tokens consume the max_tokens budget). + if isThinkingModel && (req.DisableThinking || (req.ResponseFormat != nil && req.ResponseFormat.Type == "json_schema")) { apiReq.ReasoningEffort = "none" } diff --git a/internal/llm/provider.go b/internal/llm/provider.go index 699bda5a..7282a32b 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. From 7539c136aad46a3fe771f9ca3af509de902e0989 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 12:10:35 -0400 Subject: [PATCH 52/74] fix: disable thinking project-wide on thinking models Mnemonic's LLM calls are short, structured tasks (encoding, retrieval synthesis, forum replies) where chain-of-thought wastes token budget and causes truncation. Always set reasoning_effort=none when a thinking model (Gemini 2.5+/3+) is detected. Previously this was only applied to JSON schema requests and explicit DisableThinking=true calls. Now it's the default for all requests. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/llm/lmstudio.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/llm/lmstudio.go b/internal/llm/lmstudio.go index fd8a5839..44b95107 100644 --- a/internal/llm/lmstudio.go +++ b/internal/llm/lmstudio.go @@ -298,9 +298,11 @@ func (p *LMStudioProvider) Complete(ctx context.Context, req CompletionRequest) Stop: req.Stop, } - // Thinking models: disable reasoning when explicitly requested or for - // structured output (thinking tokens consume the max_tokens budget). - if isThinkingModel && (req.DisableThinking || (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" } From a6147a9954ab98e348412c4bf0ae5cc4a4f6d09a Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 12:13:12 -0400 Subject: [PATCH 53/74] feat: render quoted text as styled blockquote boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lines starting with > are now parsed into styled blockquote elements with a left accent border, dimmed background, and smaller font — like phpBB quote blocks. @mentions within quotes are still highlighted. The compose box still uses > prefix format (plain text input), but the rendered output shows proper visual quote boxes. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/index.html | 43 ++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index da8ee93f..cf9ce236 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -4776,6 +4776,45 @@

    Activity

    } catch (e) { console.error('Failed to load forum thread:', e); } } + function formatForumContent(text) { + if (!text) return ''; + var lines = text.split('\n'); + var html = ''; + var inQuote = false; + var quoteLines = []; + + function flushQuote() { + if (quoteLines.length === 0) return; + var quoteHtml = quoteLines.map(function(l) { + var line = escapeHtml(l); + return line.replace(/@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)/g, '@$1'); + }).join('
    '); + html += '
    ' + quoteHtml + '
    '; + quoteLines = []; + } + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('> ')) { + inQuote = true; + quoteLines.push(line.substring(2)); + } else { + if (inQuote) { flushQuote(); inQuote = false; } + if (line.trim() === '') { + if (html && !html.endsWith('
    ')) html += '
    '; + } else { + var escaped = escapeHtml(line); + escaped = escaped.replace(/@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)/g, '@$1'); + html += escaped + '
    '; + } + } + } + if (inQuote) flushQuote(); + // Trim trailing
    + html = html.replace(/(
    )+$/, ''); + return html; + } + var _forumAgentProfiles = { consolidation: { tag: '@consolidation', title: 'Memory Maintainer', icon: 'CA', color: 'var(--accent-yellow)' }, dreaming: { tag: '@dreaming', title: 'Memory Replay', icon: 'DA', color: 'var(--accent-violet)' }, @@ -4794,8 +4833,8 @@

    Activity

    var prof = _forumAgentProfiles[agentKey] || { tag: post.author_name || 'Human', title: isAgent ? 'Agent' : 'User', icon: isAgent ? agentKey.slice(0,2).toUpperCase() : 'HU', color: isAgent ? 'var(--text-dim)' : 'var(--accent-cyan)' }; var displayName = isAgent ? prof.tag : (post.author_name || 'Human'); var time = new Date(post.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); - // Highlight @mentions in content - var contentHtml = escapeHtml(post.content || '').replace(/@(retrieval|metacognition|encoding|episoding|consolidation|dreaming|abstraction|perception)/g, '@$1'); + // Parse content: render > lines as blockquotes, highlight @mentions + var contentHtml = formatForumContent(post.content || ''); // Store post data for quote functionality via data attributes var postDataId = 'forum-post-' + post.id; var html = '
    '; From c2b319784a7bd5392fa82b2a9528552cc6c23362 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Tue, 24 Mar 2026 12:31:23 -0400 Subject: [PATCH 54/74] =?UTF-8?q?feat:=20forum=20categories=20=E2=80=94=20?= =?UTF-8?q?sub-forum=20index=20page=20with=20phpBB=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a category system to the forum with a classic phpBB-style index: Data layer: - forum_categories table with id, name, slug, description, icon, color, type (system/project/agent/custom), sort_order - category_id column on forum_posts for thread-to-category mapping - 11 default categories seeded on startup: 3 system (Discussions, Announcements, System Reports) + 8 agent (@consolidation, @dreaming, @episoding, @abstraction, @metacognition, @encoding, @perception, @retrieval) API: - GET /api/v1/forum/categories — returns all categories with thread counts, post counts, and last post per category - GET /api/v1/forum/threads?category=ID — filter threads by category - POST /api/v1/forum/posts now accepts category_id Agent routing: - Agent autonomous posts route to per-agent sub-forums by default (e.g., consolidation → @consolidation) - Configurable via forum.per_agent_subforums (false = shared System Reports category) - Human posts default to Discussions category Frontend: - Forum index page grouped by type (General, Projects, Agents, Custom) - Each category shows icon, name, description, thread count, post count, last post info - Click category to see its threads - New thread form includes category selector - Hash routing for forum-category/ID URLs Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mnemonic/main.go | 1 + internal/agent/reactor/actions.go | 10 +- internal/agent/reactor/registry.go | 15 +-- internal/api/routes/forum.go | 42 +++++++- internal/api/server.go | 1 + internal/config/config.go | 18 ++-- internal/store/sqlite/forum.go | 150 ++++++++++++++++++++++++---- internal/store/sqlite/schema.go | 41 ++++++++ internal/store/store.go | 35 ++++++- internal/store/storetest/mock.go | 16 +++ internal/web/static/index.html | 155 ++++++++++++++++++++--------- 11 files changed, 395 insertions(+), 89 deletions(-) diff --git a/cmd/mnemonic/main.go b/cmd/mnemonic/main.go index 680de3ae..579cd683 100644 --- a/cmd/mnemonic/main.go +++ b/cmd/mnemonic/main.go @@ -1683,6 +1683,7 @@ func serveCommand(configPath string) { deps.ForumMentionResponses = cfg.Forum.MentionResponses deps.ForumMentionMaxTokens = cfg.Forum.MentionMaxTokens deps.ForumMentionTemp = cfg.Forum.MentionTemp + deps.ForumPerAgentSubforums = cfg.Forum.PerAgentSubforums deps.MentionLLM = llmProvider if retriever != nil { deps.MentionQuery = retriever diff --git a/internal/agent/reactor/actions.go b/internal/agent/reactor/actions.go index 88677e13..abd8bc1c 100644 --- a/internal/agent/reactor/actions.go +++ b/internal/agent/reactor/actions.go @@ -109,7 +109,8 @@ func (a *IncrementCounterAction) Execute(_ context.Context, _ events.Event, _ *R // CreateForumPostAction writes a forum post from an agent personality template. type CreateForumPostAction struct { - Log *slog.Logger + PerAgentSubforums bool // route to per-agent sub-forums; false = shared category + Log *slog.Logger } func (a *CreateForumPostAction) Name() string { return "create_forum_post" } @@ -128,6 +129,12 @@ func (a *CreateForumPostAction) Execute(ctx context.Context, trigger events.Even postID := uuid.New().String() now := time.Now() + // Determine category: per-agent sub-forum or shared + categoryID := "agent-" + agentKey + if !a.PerAgentSubforums { + categoryID = "system-reports" + } + post := store.ForumPost{ ID: postID, ThreadID: postID, // each agent post is a new thread @@ -136,6 +143,7 @@ func (a *CreateForumPostAction) Execute(ctx context.Context, trigger events.Even AuthorKey: personality.Key, Content: content, EventRef: trigger.EventType(), + CategoryID: categoryID, State: "active", CreatedAt: now, UpdatedAt: now, diff --git a/internal/agent/reactor/registry.go b/internal/agent/reactor/registry.go index fcdca94b..4746ee56 100644 --- a/internal/agent/reactor/registry.go +++ b/internal/agent/reactor/registry.go @@ -22,12 +22,13 @@ type ChainDeps struct { 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 - MentionLLM llm.Provider // for @mention LLM responses (can be nil) - MentionQuery ForumQuerier // for @retrieval recall queries (can be nil) + 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) + 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. @@ -222,7 +223,7 @@ func NewChainRegistry(deps ChainDeps) []*Chain { log.Info("forum agent posting disabled by config") } - forumAction := &CreateForumPostAction{Log: log} + forumAction := &CreateForumPostAction{PerAgentSubforums: deps.ForumPerAgentSubforums, Log: log} if deps.ForumAgentPosting { chains = append(chains, &Chain{ diff --git a/internal/api/routes/forum.go b/internal/api/routes/forum.go index dbfa04c9..f4b7978b 100644 --- a/internal/api/routes/forum.go +++ b/internal/api/routes/forum.go @@ -35,9 +35,30 @@ func extractMentions(content string) []string { // 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 + 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") +} + +// 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. @@ -57,10 +78,18 @@ func HandleListForumThreads(s store.Store, log *slog.Logger) http.HandlerFunc { } } + categoryID := r.URL.Query().Get("category") + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - threads, err := s.ListForumThreads(ctx, limit, offset) + 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") @@ -129,9 +158,13 @@ func HandleCreateForumPost(s store.Store, bus events.Bus, log *slog.Logger) http // 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 @@ -151,6 +184,7 @@ func HandleCreateForumPost(s store.Store, bus events.Bus, log *slog.Logger) http Content: content, Mentions: mentions, MemoryIDs: []string{}, + CategoryID: categoryID, State: "active", CreatedAt: now, UpdatedAt: now, diff --git a/internal/api/server.go b/internal/api/server.go index b8ec6a99..16bb9fb9 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -154,6 +154,7 @@ func (s *Server) registerRoutes() { } // 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)) diff --git a/internal/config/config.go b/internal/config/config.go index 159d726c..93b22843 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -367,10 +367,11 @@ type ReactorConfig struct { // 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) + 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" } // MemoryDefaultsConfig holds shared defaults used by both MCP and API. @@ -776,10 +777,11 @@ func Default() *Config { }, Reactor: ReactorConfig{}, Forum: ForumConfig{ - AgentPosting: true, - MentionResponses: true, - MentionMaxTokens: 512, - MentionTemp: 0.7, + AgentPosting: true, + MentionResponses: true, + MentionMaxTokens: 512, + MentionTemp: 0.7, + PerAgentSubforums: true, }, MemoryDefaults: MemoryDefaultsConfig{ InitialSalienceGeneral: 0.7, diff --git a/internal/store/sqlite/forum.go b/internal/store/sqlite/forum.go index 7b4ebadc..23c0025c 100644 --- a/internal/store/sqlite/forum.go +++ b/internal/store/sqlite/forum.go @@ -9,8 +9,96 @@ import ( store "github.com/appsprout-dev/mnemonic/internal/store" ) +// 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, pinned, state, created_at, updated_at` +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 { @@ -23,7 +111,7 @@ func (s *SQLiteStore) WriteForumPost(ctx context.Context, post store.ForumPost) _, err := s.db.ExecContext(ctx, `INSERT INTO forum_posts (`+forumPostColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, post.ID, nullableString(post.ParentID), post.ThreadID, @@ -34,6 +122,7 @@ func (s *SQLiteStore) WriteForumPost(ctx context.Context, post store.ForumPost) mentions, memoryIDs, nullableString(post.EventRef), + nullableString(post.CategoryID), pinned, post.State, post.CreatedAt.Format(time.RFC3339), @@ -75,32 +164,50 @@ func (s *SQLiteStore) ListForumThreads(ctx context.Context, limit, offset int) ( } 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, mentionsStr, memoryIDsStr sql.NullString + 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, - &pinned, - &post.State, - &createdAtStr, - &updatedAtStr, - &replyCount, - &lastReply, + &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) @@ -109,6 +216,7 @@ func (s *SQLiteStore) ListForumThreads(ctx context.Context, limit, offset int) ( 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 @@ -173,7 +281,7 @@ func (s *SQLiteStore) CountForumPosts(ctx context.Context) (int, error) { // scanForumPostFrom scans a single ForumPost from any scanner. func scanForumPostFrom(s scanner) (store.ForumPost, error) { var post store.ForumPost - var parentID, authorKey, eventRef, mentionsStr, memoryIDsStr sql.NullString + var parentID, authorKey, eventRef, categoryID, mentionsStr, memoryIDsStr sql.NullString var pinned int var createdAtStr, updatedAtStr string @@ -188,6 +296,7 @@ func scanForumPostFrom(s scanner) (store.ForumPost, error) { &mentionsStr, &memoryIDsStr, &eventRef, + &categoryID, &pinned, &post.State, &createdAtStr, @@ -200,6 +309,7 @@ func scanForumPostFrom(s scanner) (store.ForumPost, error) { 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 diff --git a/internal/store/sqlite/schema.go b/internal/store/sqlite/schema.go index 631d3f5b..e385575a 100644 --- a/internal/store/sqlite/schema.go +++ b/internal/store/sqlite/schema.go @@ -490,6 +490,20 @@ CREATE INDEX IF NOT EXISTS idx_amendments_memory ON memory_amendments(memory_id) _, _ = 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, @@ -502,6 +516,7 @@ CREATE TABLE IF NOT EXISTS forum_posts ( 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')), @@ -510,8 +525,34 @@ CREATE TABLE IF NOT EXISTS forum_posts ( 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 != ''`) + return nil } diff --git a/internal/store/store.go b/internal/store/store.go index 830dce0d..319f9c26 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -354,6 +354,27 @@ 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. @@ -365,9 +386,10 @@ type ForumPost struct { 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 + 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"` @@ -551,10 +573,17 @@ 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) diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index 58f9fd4c..7841ae85 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -328,6 +328,19 @@ 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 } @@ -337,6 +350,9 @@ func (MockStore) GetForumPost(context.Context, string) (store.ForumPost, error) 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 } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index cf9ce236..67e13893 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1175,17 +1175,8 @@

    What do you remember?

    -
    -
    - Forum Threads - -
    -
    -
    Loading threads...
    -
    -
    - -
    +
    +
    Loading forum index...