pi-mega-compact 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +3 -2
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +95 -47
  3. package/dist/extensions/dashboard-server/api-contracts/index.js +1 -1
  4. package/dist/extensions/dashboard-server/api-contracts/turns.js +10 -0
  5. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  6. package/dist/extensions/dashboard-server/routes-topics.js +87 -0
  7. package/dist/extensions/dashboard-server/routes-turns.js +251 -0
  8. package/dist/extensions/dashboard-server/routes.js +3 -1
  9. package/dist/extensions/dashboard-server/server.js +6 -2
  10. package/dist/extensions/mega-compact.js +10 -6
  11. package/dist/extensions/mega-config.js +4 -0
  12. package/dist/extensions/mega-events/agent-handlers.js +62 -49
  13. package/dist/extensions/mega-events/context-handler.js +48 -2
  14. package/dist/extensions/mega-events/session-handlers.js +24 -0
  15. package/dist/extensions/mega-metrics-cmds.js +90 -0
  16. package/dist/extensions/mega-pipeline/recall.js +47 -17
  17. package/dist/extensions/mega-topics-cmds.js +111 -0
  18. package/dist/extensions/mega-turn-store.js +110 -0
  19. package/dist/extensions/mega-turn-store.test.js +68 -0
  20. package/dist/src/config/turns.js +48 -0
  21. package/dist/src/fork.js +33 -0
  22. package/dist/src/fork.test.js +81 -0
  23. package/dist/src/intent.js +51 -0
  24. package/dist/src/intent.test.js +78 -0
  25. package/dist/src/metrics/index.js +4 -0
  26. package/dist/src/metrics/turns.js +118 -0
  27. package/dist/src/metrics/turns.test.js +152 -0
  28. package/dist/src/store/sqlite/raw-transcript.js +5 -3
  29. package/dist/src/store/sqlite.dbmirror.test.js +9 -0
  30. package/dist/src/store/turns/connection.js +123 -0
  31. package/dist/src/store/turns/contract-compliance.test.js +428 -0
  32. package/dist/src/store/turns/index.js +31 -0
  33. package/dist/src/store/turns/memory-store.js +357 -0
  34. package/dist/src/store/turns/memory-store.test.js +9 -0
  35. package/dist/src/store/turns/migrations.js +178 -0
  36. package/dist/src/store/turns/migrations.test.js +138 -0
  37. package/dist/src/store/turns/schema.js +115 -0
  38. package/dist/src/store/turns/sqlite-store.js +455 -0
  39. package/dist/src/store/turns/sqlite-store.test.js +144 -0
  40. package/dist/src/store/turns/types.js +16 -0
  41. package/dist/src/topics/cluster.js +141 -0
  42. package/dist/src/topics/cluster.test.js +150 -0
  43. package/dist/src/topics/index.js +6 -0
  44. package/dist/src/topics/kselection.js +142 -0
  45. package/dist/src/topics/labels.js +56 -0
  46. package/dist/src/topics/store.js +119 -0
  47. package/dist/src/topics/store.test.js +117 -0
  48. package/dist/src/topics/types.js +8 -0
  49. package/dist/src/wiki.js +133 -0
  50. package/dist/src/wiki.test.js +171 -0
  51. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js → AchievementTiles-DUulvXUY.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DUulvXUY.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-Dx0YtCNf.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-Dx0YtCNf.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DtkaUo1v.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DtkaUo1v.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-CLa4KfiZ.js} +2 -2
  58. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-CLa4KfiZ.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-Q6tEIBBi.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-Q6tEIBBi.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Hq7GNWSY.js} +2 -2
  62. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Hq7GNWSY.js.map} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-BtmBV6lU.js} +2 -2
  64. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-BtmBV6lU.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-CL10PwVU.js} +2 -2
  66. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-CL10PwVU.js.map} +1 -1
  67. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DAwANwbI.js} +2 -2
  68. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DAwANwbI.js.map} +1 -1
  69. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-Ec08TZRQ.js} +2 -2
  70. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-Ec08TZRQ.js.map} +1 -1
  71. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-COb8dpTy.js} +2 -2
  72. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-COb8dpTy.js.map} +1 -1
  73. package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js +2 -0
  74. package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js.map +1 -0
  75. package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js +2 -0
  76. package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js.map +1 -0
  77. package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-UMrussFX.js} +10 -10
  78. package/extensions/dashboard-client/dist/assets/index-UMrussFX.js.map +1 -0
  79. package/extensions/dashboard-client/dist/assets/{index-og3ghxyY.css → index-gn66lYD2.css} +1 -1
  80. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js → useSSE-BDHZ7CeS.js} +2 -2
  81. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-BDHZ7CeS.js.map} +1 -1
  82. package/extensions/dashboard-client/dist/index.html +2 -2
  83. package/extensions/dashboard-client/src/App.tsx +10 -2
  84. package/extensions/dashboard-client/src/api/client.ts +84 -1
  85. package/extensions/dashboard-client/src/main.tsx +1 -0
  86. package/extensions/dashboard-client/src/styles/turns.css +142 -0
  87. package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +185 -0
  88. package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +326 -0
  89. package/extensions/dashboard-server/api-contracts/endpoints.ts +389 -291
  90. package/extensions/dashboard-server/api-contracts/game-types.ts +141 -88
  91. package/extensions/dashboard-server/api-contracts/index.ts +153 -133
  92. package/extensions/dashboard-server/api-contracts/turns.ts +114 -0
  93. package/extensions/dashboard-server/api-contracts.test.ts +920 -919
  94. package/extensions/dashboard-server/routes-topics.ts +119 -0
  95. package/extensions/dashboard-server/routes-turns.ts +282 -0
  96. package/extensions/dashboard-server/routes.ts +8 -1
  97. package/extensions/dashboard-server/server.ts +6 -2
  98. package/extensions/mega-compact.ts +75 -59
  99. package/extensions/mega-config.ts +232 -220
  100. package/extensions/mega-events/agent-handlers.ts +194 -151
  101. package/extensions/mega-events/context-handler.ts +50 -1
  102. package/extensions/mega-events/session-handlers.ts +23 -0
  103. package/extensions/mega-metrics-cmds.ts +114 -0
  104. package/extensions/mega-pipeline/recall.ts +215 -152
  105. package/extensions/mega-topics-cmds.ts +151 -0
  106. package/extensions/mega-turn-store.test.ts +108 -0
  107. package/extensions/mega-turn-store.ts +169 -0
  108. package/package.json +3 -1
  109. package/src/config/turns.ts +69 -0
  110. package/src/fork.test.ts +94 -0
  111. package/src/fork.ts +78 -0
  112. package/src/intent.test.ts +87 -0
  113. package/src/intent.ts +115 -0
  114. package/src/metrics/index.ts +9 -0
  115. package/src/metrics/turns.test.ts +165 -0
  116. package/src/metrics/turns.ts +201 -0
  117. package/src/store/sqlite/raw-transcript.ts +9 -3
  118. package/src/store/sqlite.dbmirror.test.ts +10 -0
  119. package/src/store/turns/connection.ts +137 -0
  120. package/src/store/turns/contract-compliance.test.ts +562 -0
  121. package/src/store/turns/index.ts +65 -0
  122. package/src/store/turns/memory-store.test.ts +15 -0
  123. package/src/store/turns/memory-store.ts +469 -0
  124. package/src/store/turns/migrations.test.ts +161 -0
  125. package/src/store/turns/migrations.ts +203 -0
  126. package/src/store/turns/schema.ts +158 -0
  127. package/src/store/turns/sqlite-store.test.ts +161 -0
  128. package/src/store/turns/sqlite-store.ts +656 -0
  129. package/src/store/turns/types.ts +181 -0
  130. package/src/topics/cluster.test.ts +176 -0
  131. package/src/topics/cluster.ts +191 -0
  132. package/src/topics/index.ts +18 -0
  133. package/src/topics/kselection.ts +182 -0
  134. package/src/topics/labels.ts +67 -0
  135. package/src/topics/store.test.ts +132 -0
  136. package/src/topics/store.ts +208 -0
  137. package/src/topics/types.ts +64 -0
  138. package/src/wiki.test.ts +217 -0
  139. package/src/wiki.ts +198 -0
  140. package/dist/extensions/openclaw-mega-compact.js +0 -291
  141. package/dist/src/minilm.js +0 -92
  142. package/dist/src/wordpiece.js +0 -129
  143. package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +0 -1
package/README.md CHANGED
@@ -8,12 +8,13 @@ A local context compressor for the [pi coding agent](https://github.com/earendil
8
8
  - **Two-layer compaction** — every LLM call sees a live trim of the context window, and every trim is checkpointed to SQLite so a crash or a `/clear` never loses the work.
9
9
  - **Semantic dedup, three layers deep** — exact hash → MinHash/LSH → cosine over trigram embeddings. You rarely notice it; that's the point.
10
10
  - **RAPTOR memory hierarchy** — decisions you made an hour ago don't scroll off; they get packed up as hierarchical checkpoints and re-inlined the moment your next session asks for them. Multi-level retrieval (leaves + summary clusters) is on by default and tunes itself off the build history.
11
- - **Per-turn tracking.** Every turn, checkpoint, and recall hit lands as a row in the local DB — `conversation_branches`, `turns`, `turn_recall`. When things go wrong (or when you want to fork a detour off the main thread), the history is there.
11
+ - **Per-turn tracking + rewind.** Every turn, checkpoint, and recall hit lands as a row in an isolated `turns.db` — `turns`, `turn_recall`, `conversation_forks`. The dashboard **Turns tab** shows turn-by-turn memory: context pressure, the compact epoch that superseded each turn, and the exact checkpoints recalled into it. A **fork** action branches a conversation at any turn (carrying its recall set); a **rewind** action queues an intent the host consumes at the next `before_agent_start`. The `TurnStore` is contract-first (capability-gated reader/writer/admin views) so the same spine backs the dashboard, the TUI, or an API gateway.
12
12
  - **Cross-repo recall** — doors I close in one repo don't reopen when I move to another. A decision stored while hacking repo A is a recall hit the next time I'm in repo B.
13
13
  - **Durable memory** — every ten turns the store auto-reviews and safe-keeps decisions, facts, and preferences as first-class RAG memories, so long-running projects remember what mattered.
14
14
  - **Fully local** — node:sqlite + trigram embeddings by default. Bring your own localhost embedder (ONNX, Ollama, TEI) for better semantic matches. Zero calls off your machine except the optional, localhost-only dashboard.
15
15
  - **Team-run aware** — fine-grained durable trim fires at agent settle during sub-agent runs, so long multi-agent work doesn't just collapse at the end.
16
- - **Multi-pi dashboard** — one dashboard tab per active pi process with the context stack, per-repo stats, and a live SSE feed across all of them.
16
+ - **Multi-pi dashboard** — one dashboard tab per active pi process with the context stack, per-repo stats, and a live SSE feed across all of them. The React SPA now has lazy-loaded tabs: Overview, Repos, Events, Config, Metrics, Cache, Game, Achievements, Sessions, Topics (wiki), and Turns (per-turn memory + recall + rewind).
17
+ - **Auto-categorizing wiki.** Every ten compactions, the store clusters your real memory embeddings (k-means) and labels each cluster with its most discriminative terms (TF-IDF) — no LLM, no Ollama, fully local. The dashboard **Wiki tab** browses topics, searches by label or term, and drills down into the member memories of each cluster.
17
18
 
18
19
  ## Install
19
20
 
@@ -31,94 +31,142 @@
31
31
  export const ENDPOINTS = {
32
32
  /** GET /api/snapshot — Full session, store, compression, and context snapshot. */
33
33
  snapshot: {
34
- method: 'GET',
35
- path: '/api/snapshot',
36
- description: 'Full session, store, compression, and context snapshot.',
34
+ method: "GET",
35
+ path: "/api/snapshot",
36
+ description: "Full session, store, compression, and context snapshot.",
37
37
  },
38
38
  /** GET /api/version — Dashboard server version string. */
39
39
  version: {
40
- method: 'GET',
41
- path: '/api/version',
42
- description: 'Dashboard server version for stale-server detection.',
40
+ method: "GET",
41
+ path: "/api/version",
42
+ description: "Dashboard server version for stale-server detection.",
43
43
  },
44
44
  /** GET /api/index — Multi-repo aggregate index (or fallback when unavailable). */
45
45
  index: {
46
- method: 'GET',
47
- path: '/api/index',
48
- description: 'Machine-wide multi-repo registry with checkpoints, tokens, and model info.',
46
+ method: "GET",
47
+ path: "/api/index",
48
+ description: "Machine-wide multi-repo registry with checkpoints, tokens, and model info.",
49
49
  },
50
50
  /** GET /api/repos — Registry list with optional active-window filter. */
51
51
  repos: {
52
- method: 'GET',
53
- path: '/api/repos',
54
- description: 'Repo registry list, optionally filtered by recent activity.',
52
+ method: "GET",
53
+ path: "/api/repos",
54
+ description: "Repo registry list, optionally filtered by recent activity.",
55
55
  },
56
56
  /** GET /api/summary — Lightweight header tiles without the full repo list. */
57
57
  summary: {
58
- method: 'GET',
59
- path: '/api/summary',
60
- description: 'Aggregate summary with active/total repo counts (no repo list).',
58
+ method: "GET",
59
+ path: "/api/summary",
60
+ description: "Aggregate summary with active/total repo counts (no repo list).",
61
61
  },
62
62
  /** GET /api/drift — Cross-repo drift report. */
63
63
  drift: {
64
- method: 'GET',
65
- path: '/api/drift',
66
- description: 'Cross-repo drift report flagging stale repos, compaction lag, and model churn.',
64
+ method: "GET",
65
+ path: "/api/drift",
66
+ description: "Cross-repo drift report flagging stale repos, compaction lag, and model churn.",
67
67
  },
68
68
  /** GET /api/servers — Recently-active repo servers with live snapshot data. */
69
69
  servers: {
70
- method: 'GET',
71
- path: '/api/servers',
72
- description: 'Recently-active repo servers with live snapshot data.',
70
+ method: "GET",
71
+ path: "/api/servers",
72
+ description: "Recently-active repo servers with live snapshot data.",
73
73
  },
74
74
  /** GET /api/events — SSE stream of dashboard events. */
75
75
  events: {
76
- type: 'sse',
77
- method: 'GET',
78
- path: '/api/events',
79
- description: 'Server-Sent Events stream of all dashboard events.',
80
- event: 'data',
76
+ type: "sse",
77
+ method: "GET",
78
+ path: "/api/events",
79
+ description: "Server-Sent Events stream of all dashboard events.",
80
+ event: "data",
81
81
  },
82
82
  /** GET /api/game-state — Current game-mode settings. */
83
83
  getGameState: {
84
- method: 'GET',
85
- path: '/api/game-state',
86
- description: 'Current game-mode configuration and active ritual state.',
84
+ method: "GET",
85
+ path: "/api/game-state",
86
+ description: "Current game-mode configuration and active ritual state.",
87
87
  },
88
88
  /** PUT /api/game-state — Apply a partial patch to game-mode settings. */
89
89
  putGameState: {
90
- method: 'PUT',
91
- path: '/api/game-state',
92
- description: 'Apply a partial patch to game-mode settings and return the updated state.',
90
+ method: "PUT",
91
+ path: "/api/game-state",
92
+ description: "Apply a partial patch to game-mode settings and return the updated state.",
93
93
  },
94
94
  /** GET /api/game-scores — High-score leaderboard for a metric. */
95
95
  gameScores: {
96
- method: 'GET',
97
- path: '/api/game-scores',
98
- description: 'High-score leaderboard for a game metric.',
96
+ method: "GET",
97
+ path: "/api/game-scores",
98
+ description: "High-score leaderboard for a game metric.",
99
99
  },
100
100
  /** GET /api/perf — Rolling-window performance aggregates. */
101
101
  perf: {
102
- method: 'GET',
103
- path: '/api/perf',
104
- description: 'Rolling-window performance aggregates over perf_samples.',
102
+ method: "GET",
103
+ path: "/api/perf",
104
+ description: "Rolling-window performance aggregates over perf_samples.",
105
105
  },
106
106
  /** GET /api/achievements — Achievement tiles with unlock state. */
107
107
  achievements: {
108
- method: 'GET',
109
- path: '/api/achievements',
110
- description: 'All achievement rows with unlock state.',
108
+ method: "GET",
109
+ path: "/api/achievements",
110
+ description: "All achievement rows with unlock state.",
111
111
  },
112
112
  /** GET /api/sessions — Active sessions with latest token usage (S39). */
113
113
  sessions: {
114
- method: 'GET',
115
- path: '/api/sessions',
116
- description: 'Active pi sessions with latest token usage + heartbeat.',
114
+ method: "GET",
115
+ path: "/api/sessions",
116
+ description: "Active pi sessions with latest token usage + heartbeat.",
117
117
  },
118
118
  /** GET /api/sessions/timeseries — Stacked per-session token timeseries (S39). */
119
119
  sessionTimeseries: {
120
- method: 'GET',
121
- path: '/api/sessions/timeseries',
122
- description: 'Recharts-ready per-session token timeseries for the stacked memory graph.',
120
+ method: "GET",
121
+ path: "/api/sessions/timeseries",
122
+ description: "Recharts-ready per-session token timeseries for the stacked memory graph.",
123
+ },
124
+ /** GET /api/topics — Auto-categorizing wiki topics (S51). */
125
+ topics: {
126
+ method: "GET",
127
+ path: "/api/topics",
128
+ description: "Auto-categorized wiki topics from k-means + TF-IDF over real embeddings.",
129
+ },
130
+ /** GET /api/turns — Turn-by-turn memory tracking + recall (S52). */
131
+ turns: {
132
+ method: "GET",
133
+ path: "/api/turns",
134
+ description: "Per-conversation turn list with recall + epoch provenance (turn-by-turn memory tracking).",
135
+ },
136
+ /** GET /api/turns/conversation/:convId — per-turn detail + recall hits. */
137
+ conversationTurns: {
138
+ method: "GET",
139
+ path: "/api/turns/conversation/:convId",
140
+ description: "Per-turn metrics + injected-checkpoint recall provenance for one conversation.",
141
+ },
142
+ /** GET /api/turns/intents — pending rewind intents (S52A). */
143
+ turnIntents: {
144
+ method: "GET",
145
+ path: "/api/turns/intents",
146
+ description: "Pending rewind intents queued by the dashboard for the host to consume.",
147
+ },
148
+ /** POST /api/turns/intent — post a rewind intent. */
149
+ postTurnIntent: {
150
+ method: "POST",
151
+ path: "/api/turns/intent",
152
+ description: "Queue a rewind-to-turn-N intent for the host to apply at before_agent_start.",
153
+ },
154
+ /** POST /api/fork — fork a conversation at a turn. */
155
+ fork: {
156
+ method: "POST",
157
+ path: "/api/fork",
158
+ description: "Branch a child conversation off a parent turn + return the recall set to rehydrate.",
159
+ },
160
+ /** POST /api/turns/prune — admin prune (capability-gated). */
161
+ pruneTurns: {
162
+ method: "POST",
163
+ path: "/api/turns/prune",
164
+ description: "Prune old turns (admin capability — dashboard uses asAdmin()).",
165
+ },
166
+ /** GET /api/topics/:topicId/memories — wiki topic drill-down (S52). */
167
+ topicMemories: {
168
+ method: "GET",
169
+ path: "/api/topics/:topicId/memories",
170
+ description: "Member memories assigned to a wiki topic (drill-down).",
123
171
  },
124
172
  };
@@ -6,4 +6,4 @@
6
6
  * // or explicitly:
7
7
  * import type { SnapshotResponse } from '../api-contracts/snapshot';
8
8
  */
9
- export { ENDPOINTS } from './endpoints.js';
9
+ export { ENDPOINTS } from "./endpoints.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * api-contracts/turns.ts — S52 turn-by-turn memory tracking + recall contracts.
3
+ *
4
+ * The dashboard Turns tab surfaces the S49/S50/S51 data spine: per-conversation
5
+ * turn lists with per-turn metrics (ctx tokens/percent, recall hits, dedup,
6
+ * compression, epoch) and per-turn recall provenance (which checkpoints were
7
+ * injected at each turn), plus the S52 rewind-intent queue. Read-mostly via
8
+ * `asReader()`; prune/vacuum via `asAdmin()`; fork/intent via the queue.
9
+ */
10
+ export {};
@@ -76,7 +76,7 @@ void _c_achievements;
76
76
  // Verify the ENDPOINTS registry has exactly 15 entries with correct paths.
77
77
  // (Sprint 39 added /api/sessions + /api/sessions/timeseries.)
78
78
  const ENDPOINT_KEYS = Object.keys(ENDPOINTS);
79
- const EXPECTED_ENDPOINT_COUNT = 15;
79
+ const EXPECTED_ENDPOINT_COUNT = 23;
80
80
  /** All `/api/*` paths served by server.ts (extracted from the route handlers). */
81
81
  const SERVER_TS_PATHS = [
82
82
  "/api/snapshot",
@@ -93,10 +93,11 @@ const SERVER_TS_PATHS = [
93
93
  "/api/achievements",
94
94
  "/api/sessions",
95
95
  "/api/sessions/timeseries",
96
+ "/api/topics",
96
97
  ];
97
98
  // ─── Tests ───────────────────────────────────────────────────────────────────
98
99
  describe("ENDPOINTS registry", () => {
99
- test("has exactly 15 endpoint entries", () => {
100
+ test("has exactly 16 endpoint entries", () => {
100
101
  assert.equal(ENDPOINT_KEYS.length, EXPECTED_ENDPOINT_COUNT, `ENDPOINTS must have ${EXPECTED_ENDPOINT_COUNT} entries, got ${ENDPOINT_KEYS.length}`);
101
102
  });
102
103
  test("every server.ts /api/* path has a corresponding ENDPOINTS entry", () => {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * routes-topics.ts — S51 auto-categorizing wiki topics route handler.
3
+ *
4
+ * GET /api/topics — Returns topic list with memory counts + TF-IDF labels.
5
+ * Read-only (PREVENT-PI-001/002); parameterized queries (PREVENT-002);
6
+ * pure local node:sqlite (PREVENT-PI-004).
7
+ */
8
+ import { openTurnStore } from "../../src/store/turns/connection.js";
9
+ import { createTopicStore } from "../../src/topics/store.js";
10
+ export function handleTopics(req, res, ctx) {
11
+ const url = req.url ?? "";
12
+ // ── GET /api/topics/:topicId/memories — wiki topic drill-down (S52) ──
13
+ const drillMatch = req.method === "GET"
14
+ ? url.match(/^\/api\/topics\/([^/?]+)\/memories$/)
15
+ : null;
16
+ if (drillMatch) {
17
+ try {
18
+ const topicId = decodeURIComponent(drillMatch[1]);
19
+ const store = createTopicStore(ctx.stateDir);
20
+ const topics = store.getTopics();
21
+ const topic = topics.find((t) => t.id === topicId);
22
+ const assignments = store.getMemoriesForTopic(topicId, 200);
23
+ const body = {
24
+ topicId,
25
+ label: topic?.label ?? topicId,
26
+ assignments: assignments.map((a) => ({
27
+ memoryId: a.memoryId,
28
+ confidence: a.confidence ?? null,
29
+ assignedAt: a.assignedAt ?? null,
30
+ })),
31
+ };
32
+ res.writeHead(200, { "Content-Type": "application/json" });
33
+ res.end(JSON.stringify(body));
34
+ }
35
+ catch (e) {
36
+ res.writeHead(500, { "Content-Type": "application/json" });
37
+ res.end(JSON.stringify({ error: String(e) }));
38
+ }
39
+ return true;
40
+ }
41
+ if (url !== "/api/topics")
42
+ return false;
43
+ try {
44
+ const tdb = openTurnStore(ctx.stateDir);
45
+ const topics = tdb
46
+ .prepare(`SELECT id, label, term_scores, memory_count, last_updated
47
+ FROM topics ORDER BY memory_count DESC, id ASC`)
48
+ .all().map((r) => ({
49
+ id: r.id,
50
+ label: r.label,
51
+ memoryCount: r.memory_count,
52
+ lastUpdated: r.last_updated ?? 0,
53
+ termScore: safeParse(r.term_scores),
54
+ }));
55
+ const totalAssigned = tdb.prepare("SELECT COUNT(*) AS c FROM memory_topics").get().c;
56
+ const lastRebuildAt = tdb
57
+ .prepare("SELECT MAX(cluster_model_built_at) AS m FROM topics")
58
+ .get().m;
59
+ const body = {
60
+ updatedAt: new Date().toISOString(),
61
+ totalTopics: topics.length,
62
+ totalAssigned,
63
+ lastRebuildAt,
64
+ topics,
65
+ };
66
+ res.writeHead(200, { "Content-Type": "application/json" });
67
+ res.end(JSON.stringify(body));
68
+ }
69
+ catch (e) {
70
+ res.writeHead(500, { "Content-Type": "application/json" });
71
+ res.end(JSON.stringify({ error: String(e) }));
72
+ }
73
+ return true;
74
+ }
75
+ function safeParse(json) {
76
+ if (!json)
77
+ return [];
78
+ try {
79
+ const v = JSON.parse(json);
80
+ return Array.isArray(v)
81
+ ? v
82
+ : [];
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * routes-turns.ts — S52 turn-by-turn memory tracking + recall + rewind routes.
3
+ *
4
+ * GET /api/turns — conversation list (turns tab payload)
5
+ * GET /api/turns/conversation/:convId — per-turn detail + recall provenance
6
+ * GET /api/turns/intents — pending rewind intents (S52A)
7
+ * POST /api/turns/intent — post a rewind intent
8
+ * POST /api/fork — fork a conversation at a turn
9
+ * POST /api/turns/prune — admin prune (capability-gated)
10
+ * POST /api/turns/vacuum — admin vacuum
11
+ *
12
+ * Capability-gated per the S49 contract: display via `asReader()`, prune/vacuum
13
+ * via `asAdmin()`, fork via `asWriter()`. Read-mostly. The store never calls
14
+ * back into the host (ledger protocol). Loopback-only (PREVENT-PI-004).
15
+ * Parameterized (PREVENT-002).
16
+ */
17
+ import { createTurnStore } from "../../src/store/turns/index.js";
18
+ import { openTurnStore } from "../../src/store/turns/connection.js";
19
+ import { openIntentQueue } from "../../src/intent.js";
20
+ import { forkFromConversation } from "../../src/fork.js";
21
+ function sendJson(res, status, body) {
22
+ res.writeHead(status, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
23
+ res.end(JSON.stringify(body));
24
+ }
25
+ /** Read a capped JSON body; returns { ok, value } or { error }. */
26
+ function readJsonBody(req, cb) {
27
+ let body = "";
28
+ let tooBig = false;
29
+ req.on("data", (chunk) => {
30
+ if (body.length > 65536) {
31
+ tooBig = true;
32
+ return;
33
+ }
34
+ body += chunk.toString();
35
+ });
36
+ req.on("end", () => {
37
+ if (tooBig)
38
+ return cb({ ok: false, error: "body_too_large" });
39
+ try {
40
+ const v = body ? JSON.parse(body) : {};
41
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
42
+ return cb({ ok: false, error: "invalid_object" });
43
+ }
44
+ cb({ ok: true, value: v });
45
+ }
46
+ catch {
47
+ cb({ ok: false, error: "invalid_json" });
48
+ }
49
+ });
50
+ }
51
+ export function handleTurns(req, res, ctx) {
52
+ const url = req.url ?? "";
53
+ if (!url.startsWith("/api/turns") && url !== "/api/fork")
54
+ return false;
55
+ // ── GET /api/turns — conversation list ─────────────────────────────
56
+ if (req.method === "GET" && url === "/api/turns") {
57
+ try {
58
+ const store = createTurnStore({ stateDir: ctx.stateDir });
59
+ const reader = store.asReader();
60
+ // Enumerate conversations via session_conversations + a distinct scan.
61
+ const tdb = openTurnStore(ctx.stateDir);
62
+ const convRows = tdb
63
+ .prepare(`SELECT DISTINCT conversation_id FROM turns ORDER BY conversation_id ASC`)
64
+ .all();
65
+ const conversations = convRows.map((r) => {
66
+ const stats = reader.conversationStats(r.conversation_id);
67
+ const epochCount = tdb
68
+ .prepare("SELECT COUNT(DISTINCT epoch_id) AS c FROM turns WHERE conversation_id = ? AND epoch_id IS NOT NULL")
69
+ .get(r.conversation_id).c;
70
+ const totalRecall = tdb
71
+ .prepare(`SELECT COUNT(*) AS c FROM turn_recall tr JOIN turns t ON tr.turn_id = t.id
72
+ WHERE t.conversation_id = ?`)
73
+ .get(r.conversation_id).c;
74
+ return {
75
+ conversationId: r.conversation_id,
76
+ turnCount: stats.turnCount,
77
+ firstTurnAt: stats.firstTurnAt,
78
+ lastTurnAt: stats.lastTurnAt,
79
+ avgCtxPercent: stats.avgCtxPercent,
80
+ epochCount,
81
+ totalRecall,
82
+ };
83
+ });
84
+ // Active conversation = the one with the most recent turn across all.
85
+ let activeConversationId = null;
86
+ let lastSeen = 0;
87
+ for (const c of conversations) {
88
+ if (c.lastTurnAt > lastSeen) {
89
+ lastSeen = c.lastTurnAt;
90
+ activeConversationId = c.conversationId;
91
+ }
92
+ }
93
+ sendJson(res, 200, { conversations, activeConversationId });
94
+ return true;
95
+ }
96
+ catch (e) {
97
+ sendJson(res, 500, { error: String(e) });
98
+ return true;
99
+ }
100
+ }
101
+ // ── GET /api/turns/conversation/:convId — per-turn detail + recall ─
102
+ const convMatch = req.method === "GET"
103
+ ? url.match(/^\/api\/turns\/conversation\/([^/?]+)$/)
104
+ : null;
105
+ if (convMatch) {
106
+ try {
107
+ const convId = decodeURIComponent(convMatch[1]);
108
+ const store = createTurnStore({ stateDir: ctx.stateDir });
109
+ const reader = store.asReader();
110
+ const entries = reader.query({ conversationId: convId, limit: 10000 });
111
+ const turns = entries.map((e) => ({
112
+ turnIndex: e.turnIndex,
113
+ conversationId: e.conversationId,
114
+ sessionId: e.sessionId,
115
+ role: e.role,
116
+ endedAt: e.endedAt,
117
+ ctxTokens: e.ctxTokens ?? null,
118
+ ctxPercent: e.ctxPercent ?? null,
119
+ pressureBand: e.pressureBand ?? null,
120
+ model: e.model ?? null,
121
+ epochId: e.epochId ?? null,
122
+ recall: reader
123
+ .listRecallByIndex(convId, e.turnIndex)
124
+ .map((r) => ({
125
+ checkpointId: r.checkpointId,
126
+ score: r.score,
127
+ source: r.source,
128
+ raptorLevel: r.raptorLevel ?? null,
129
+ })),
130
+ }));
131
+ sendJson(res, 200, { conversationId: convId, turns });
132
+ return true;
133
+ }
134
+ catch (e) {
135
+ sendJson(res, 500, { error: String(e) });
136
+ return true;
137
+ }
138
+ }
139
+ // ── GET /api/turns/intents — pending rewind intents ───────────────
140
+ if (req.method === "GET" && url === "/api/turns/intents") {
141
+ try {
142
+ const tdb = openTurnStore(ctx.stateDir);
143
+ const q = openIntentQueue(tdb);
144
+ const intents = q.pendingIntents().map((i) => ({
145
+ id: i.id,
146
+ conversationId: i.conversationId,
147
+ targetTurnIndex: i.targetTurnIndex,
148
+ createdAt: i.createdAt,
149
+ status: i.status,
150
+ }));
151
+ sendJson(res, 200, { intents });
152
+ return true;
153
+ }
154
+ catch (e) {
155
+ sendJson(res, 500, { error: String(e) });
156
+ return true;
157
+ }
158
+ }
159
+ // ── POST /api/turns/intent — post a rewind intent ─────────────────
160
+ if (req.method === "POST" && url === "/api/turns/intent") {
161
+ readJsonBody(req, (result) => {
162
+ if (!result.ok)
163
+ return sendJson(res, 400, { error: result.error });
164
+ const v = result.value;
165
+ const conversationId = typeof v.conversationId === "string" ? v.conversationId : "";
166
+ const targetTurnIndex = typeof v.targetTurnIndex === "number" ? v.targetTurnIndex : -1;
167
+ if (!conversationId || targetTurnIndex < 0) {
168
+ return sendJson(res, 400, { error: "missing_conversationId_or_targetTurnIndex" });
169
+ }
170
+ try {
171
+ const tdb = openTurnStore(ctx.stateDir);
172
+ const q = openIntentQueue(tdb);
173
+ const intent = q.postIntent({ conversationId, targetTurnIndex });
174
+ sendJson(res, 201, intent);
175
+ }
176
+ catch (e) {
177
+ sendJson(res, 500, { error: String(e) });
178
+ }
179
+ });
180
+ return true;
181
+ }
182
+ // ── POST /api/fork — fork a conversation at a turn ─────────────────
183
+ if (req.method === "POST" && url === "/api/fork") {
184
+ readJsonBody(req, (result) => {
185
+ if (!result.ok)
186
+ return sendJson(res, 400, { error: result.error });
187
+ const v = result.value;
188
+ const conversationId = typeof v.conversationId === "string" ? v.conversationId : "";
189
+ const turnIndex = typeof v.turnIndex === "number" ? v.turnIndex : -1;
190
+ if (!conversationId || turnIndex < 0) {
191
+ return sendJson(res, 400, { error: "missing_conversationId_or_turnIndex" });
192
+ }
193
+ try {
194
+ const store = createTurnStore({ stateDir: ctx.stateDir });
195
+ const out = forkFromConversation(store, conversationId, turnIndex);
196
+ sendJson(res, 201, {
197
+ childConversationId: out.childConversationId,
198
+ recalledCount: out.recalled.length,
199
+ checkpointIds: out.checkpointIds,
200
+ });
201
+ }
202
+ catch (e) {
203
+ sendJson(res, 400, { error: String(e) });
204
+ }
205
+ });
206
+ return true;
207
+ }
208
+ // ── POST /api/turns/prune — admin prune ───────────────────────────
209
+ if (req.method === "POST" && url === "/api/turns/prune") {
210
+ readJsonBody(req, (result) => {
211
+ if (!result.ok)
212
+ return sendJson(res, 400, { error: result.error });
213
+ const v = result.value;
214
+ const maxTurnAgeMs = typeof v.maxTurnAgeMs === "number" ? v.maxTurnAgeMs : 0;
215
+ const keepMinPerConversation = typeof v.keepMinPerConversation === "number" ? v.keepMinPerConversation : 50;
216
+ if (maxTurnAgeMs <= 0) {
217
+ return sendJson(res, 400, { error: "invalid_maxTurnAgeMs" });
218
+ }
219
+ try {
220
+ const store = createTurnStore({ stateDir: ctx.stateDir });
221
+ const report = store.asAdmin().prune({
222
+ maxTurnAgeMs,
223
+ keepMinPerConversation,
224
+ vacuumAfterPrune: true,
225
+ });
226
+ sendJson(res, 200, {
227
+ turnsRemoved: report.turnsRemoved,
228
+ recallRemoved: report.recallRemoved,
229
+ branchesPreserved: report.branchesPreserved,
230
+ freedBytes: report.freedBytes,
231
+ });
232
+ }
233
+ catch (e) {
234
+ sendJson(res, 500, { error: String(e) });
235
+ }
236
+ });
237
+ return true;
238
+ }
239
+ // ── POST /api/turns/vacuum — admin vacuum ────────────────────────
240
+ if (req.method === "POST" && url === "/api/turns/vacuum") {
241
+ try {
242
+ createTurnStore({ stateDir: ctx.stateDir }).asAdmin().vacuum();
243
+ sendJson(res, 200, { ok: true });
244
+ }
245
+ catch (e) {
246
+ sendJson(res, 500, { error: String(e) });
247
+ }
248
+ return true;
249
+ }
250
+ return false;
251
+ }
@@ -6,5 +6,7 @@
6
6
  */
7
7
  export { buildRouteContext } from "./routes-core.js";
8
8
  export { handleIndex, handleRepoIndex, handleStatic } from "./routes-repo.js";
9
- export { handleGameState, handleGameScores, handlePerf, handleAchievements } from "./routes-game.js";
9
+ export { handleGameState, handleGameScores, handlePerf, handleAchievements, } from "./routes-game.js";
10
10
  export { handleEvents, handleSessions } from "./routes-sessions.js";
11
+ export { handleTopics } from "./routes-topics.js";
12
+ export { handleTurns } from "./routes-turns.js";
@@ -12,7 +12,7 @@ import { join, dirname } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { createRequire } from "node:module";
14
14
  import { log, setLogPath, setDashboardServerVersion } from "./state.js";
15
- import { buildRouteContext, handleIndex, handleRepoIndex, handleEvents, handleGameState, handleGameScores, handlePerf, handleAchievements, handleSessions, handleStatic, } from "./routes.js";
15
+ import { buildRouteContext, handleIndex, handleRepoIndex, handleEvents, handleGameState, handleGameScores, handlePerf, handleAchievements, handleSessions, handleTopics, handleTurns, handleStatic, } from "./routes.js";
16
16
  export async function launchDashboardServer(stateDir) {
17
17
  // Our own package version — exposed at /api/version so the launcher can
18
18
  // detect a stale server (started by an older build) and replace it on
@@ -163,7 +163,7 @@ export async function launchDashboardServer(stateDir) {
163
163
  res.setHeader("Access-Control-Allow-Origin", origin);
164
164
  res.setHeader("Vary", "Origin");
165
165
  }
166
- res.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
166
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS");
167
167
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
168
168
  if (req.method === "OPTIONS") {
169
169
  res.writeHead(204);
@@ -187,6 +187,10 @@ export async function launchDashboardServer(stateDir) {
187
187
  return;
188
188
  if (handleSessions(req, res, ctx))
189
189
  return;
190
+ if (handleTopics(req, res, ctx))
191
+ return;
192
+ if (handleTurns(req, res, ctx))
193
+ return;
190
194
  handleStatic(req, res, ctx);
191
195
  });
192
196
  // Bind base + range are env-configurable so tests can use a private,