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
@@ -0,0 +1,357 @@
1
+ /**
2
+ * memory-store.ts — InMemoryTurnStore: test harness + embeddable backend.
3
+ *
4
+ * Same contract as SqliteTurnStore, backed by Maps. No file I/O.
5
+ * Used for tests and for hosts that don't want SQLite.
6
+ *
7
+ * PREVENT-PI-004: no network. PREVENT-002: no SQL here.
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+ function newConversationId() {
11
+ return `conv_${randomBytes(8).toString("hex")}`;
12
+ }
13
+ function normalizeSessionId(sid) {
14
+ return sid.replace(/-.*$/, "");
15
+ }
16
+ // Auto-increment counter
17
+ let nextId = 1;
18
+ function allocId() {
19
+ return nextId++;
20
+ }
21
+ function resetIdCounter() {
22
+ nextId = 1;
23
+ }
24
+ export class InMemoryTurnStore {
25
+ turns = new Map();
26
+ recall = [];
27
+ forks = [];
28
+ // conversation_id → turn ids (for fast lookup)
29
+ convIndex = new Map();
30
+ // session_id → conversation_id
31
+ sessionConv = new Map();
32
+ constructor(_options) {
33
+ resetIdCounter();
34
+ }
35
+ // ── TurnReader ──────────────────────────────────────────────
36
+ query(filter) {
37
+ let results = [...this.turns.values()].map((r) => r.entry);
38
+ if (filter.conversationId !== undefined) {
39
+ results = results.filter((t) => t.conversationId === filter.conversationId);
40
+ }
41
+ if (filter.sessionId !== undefined) {
42
+ results = results.filter((t) => normalizeSessionId(t.sessionId) ===
43
+ normalizeSessionId(filter.sessionId));
44
+ }
45
+ if (filter.sinceMs !== undefined) {
46
+ results = results.filter((t) => t.endedAt >= filter.sinceMs);
47
+ }
48
+ if (filter.untilMs !== undefined) {
49
+ results = results.filter((t) => t.endedAt <= filter.untilMs);
50
+ }
51
+ if (filter.pressureBand !== undefined) {
52
+ results = results.filter((t) => t.pressureBand === filter.pressureBand);
53
+ }
54
+ results.sort((a, b) => a.endedAt - b.endedAt);
55
+ const offset = filter.offset ?? 0;
56
+ const limit = filter.limit ?? 1000;
57
+ return results.slice(offset, offset + limit);
58
+ }
59
+ getTurn(turnId) {
60
+ const row = this.turns.get(Number(turnId));
61
+ return row ? { ...row.entry, epochId: row.epochId } : undefined;
62
+ }
63
+ getTurnByIndex(conversationId, turnIndex) {
64
+ const ids = this.convIndex.get(conversationId) ?? [];
65
+ for (const id of ids) {
66
+ const row = this.turns.get(id);
67
+ if (row && row.entry.turnIndex === turnIndex) {
68
+ return { ...row.entry, epochId: row.epochId };
69
+ }
70
+ }
71
+ return undefined;
72
+ }
73
+ listRecall(turnId) {
74
+ return this.recall
75
+ .filter((r) => r.turnId === Number(turnId))
76
+ .map((r) => r.entry)
77
+ .sort((a, b) => b.score - a.score);
78
+ }
79
+ listRecallByIndex(conversationId, turnIndex) {
80
+ const turn = this.getTurnByIndex(conversationId, turnIndex);
81
+ if (!turn)
82
+ return [];
83
+ // Resolve the numeric row id for this turn via the convIndex.
84
+ const ids = this.convIndex.get(conversationId) ?? [];
85
+ for (const id of ids) {
86
+ const row = this.turns.get(id);
87
+ if (row && row.entry.turnIndex === turnIndex) {
88
+ return this.listRecall(String(id));
89
+ }
90
+ }
91
+ return [];
92
+ }
93
+ listForks(conversationId) {
94
+ return this.forks
95
+ .filter((f) => f.parentConversationId === conversationId)
96
+ .sort((a, b) => a.createdAt - b.createdAt);
97
+ }
98
+ countTurns(conversationId) {
99
+ const ids = this.convIndex.get(conversationId);
100
+ return ids?.length ?? 0;
101
+ }
102
+ conversationStats(conversationId) {
103
+ const ids = this.convIndex.get(conversationId) ?? [];
104
+ const entries = ids
105
+ .map((id) => this.turns.get(id)?.entry)
106
+ .filter((e) => e !== undefined);
107
+ if (entries.length === 0) {
108
+ return {
109
+ turnCount: 0,
110
+ firstTurnAt: 0,
111
+ lastTurnAt: 0,
112
+ avgCtxPercent: 0,
113
+ pressureBands: {},
114
+ };
115
+ }
116
+ let ctxSum = 0;
117
+ let ctxCount = 0;
118
+ const bands = {};
119
+ for (const e of entries) {
120
+ if (e.ctxPercent !== undefined) {
121
+ ctxSum += e.ctxPercent;
122
+ ctxCount++;
123
+ }
124
+ if (e.pressureBand)
125
+ bands[e.pressureBand] = (bands[e.pressureBand] ?? 0) + 1;
126
+ }
127
+ return {
128
+ turnCount: entries.length,
129
+ firstTurnAt: entries[0].endedAt,
130
+ lastTurnAt: entries[entries.length - 1].endedAt,
131
+ avgCtxPercent: ctxCount > 0 ? ctxSum / ctxCount : 0,
132
+ pressureBands: bands,
133
+ };
134
+ }
135
+ // ── TurnWriter ──────────────────────────────────────────────
136
+ appendTurn(entry) {
137
+ const id = allocId();
138
+ const sid = normalizeSessionId(entry.sessionId);
139
+ const normalized = { ...entry, sessionId: sid };
140
+ this.turns.set(id, { id, entry: normalized });
141
+ // Update indices
142
+ const convIds = this.convIndex.get(entry.conversationId) ?? [];
143
+ convIds.push(id);
144
+ this.convIndex.set(entry.conversationId, convIds);
145
+ this.sessionConv.set(sid, entry.conversationId);
146
+ return String(id);
147
+ }
148
+ appendRecall(entry) {
149
+ // De-dup by (turn_id, checkpoint_id)
150
+ const exists = this.recall.some((r) => r.turnId === Number(entry.turnId) &&
151
+ r.entry.checkpointId === entry.checkpointId);
152
+ if (!exists) {
153
+ this.recall.push({ turnId: Number(entry.turnId), entry });
154
+ }
155
+ }
156
+ ensureConversationId(sessionId) {
157
+ const sid = normalizeSessionId(sessionId);
158
+ const existing = this.sessionConv.get(sid);
159
+ if (existing)
160
+ return existing;
161
+ // Generate and store a new conversation ID for this session
162
+ const convId = newConversationId();
163
+ this.sessionConv.set(sid, convId);
164
+ return convId;
165
+ }
166
+ forkConversation(parentId, forkTurnIndex) {
167
+ const childId = newConversationId();
168
+ const now = Date.now();
169
+ // 1. Record the fork lineage
170
+ this.forks.push({
171
+ parentConversationId: parentId,
172
+ childConversationId: childId,
173
+ forkTurnIndex,
174
+ createdAt: now,
175
+ });
176
+ // 2. Find the parent's turn at forkTurnIndex
177
+ const parentTurnIds = this.convIndex.get(parentId) ?? [];
178
+ const parentTurnRow = parentTurnIds
179
+ .map((id) => this.turns.get(id))
180
+ .find((r) => r?.entry.turnIndex === forkTurnIndex);
181
+ if (!parentTurnRow)
182
+ return childId; // no parent turn — nothing to seed
183
+ // 3. Create a seed turn in the child conversation
184
+ const seedId = allocId();
185
+ const seedEntry = {
186
+ conversationId: childId,
187
+ sessionId: `fork_${childId}`,
188
+ turnIndex: 0,
189
+ role: "system",
190
+ endedAt: now,
191
+ };
192
+ this.turns.set(seedId, { id: seedId, entry: seedEntry });
193
+ this.convIndex.set(childId, [seedId]);
194
+ // 4. Copy the parent's recall entries into the child's seed turn
195
+ const parentRecall = this.recall.filter((r) => r.turnId === parentTurnRow.id);
196
+ for (const r of parentRecall) {
197
+ this.recall.push({
198
+ turnId: seedId,
199
+ entry: {
200
+ turnId: String(seedId),
201
+ checkpointId: r.entry.checkpointId,
202
+ score: r.entry.score,
203
+ source: r.entry.source,
204
+ raptorLevel: r.entry.raptorLevel,
205
+ },
206
+ });
207
+ }
208
+ return childId;
209
+ }
210
+ // ── TurnAdmin ───────────────────────────────────────────────
211
+ prune(policy) {
212
+ const cutoff = Date.now() - policy.maxTurnAgeMs;
213
+ let turnsRemoved = 0;
214
+ let recallRemoved = 0;
215
+ for (const [convId, turnIds] of this.convIndex.entries()) {
216
+ const total = turnIds.length;
217
+ const canDelete = Math.max(0, total - policy.keepMinPerConversation);
218
+ if (canDelete === 0)
219
+ continue;
220
+ // Find old turns to delete
221
+ const oldIds = turnIds
222
+ .filter((id) => {
223
+ const row = this.turns.get(id);
224
+ return row && row.entry.endedAt < cutoff;
225
+ })
226
+ .slice(0, canDelete);
227
+ for (const id of oldIds) {
228
+ // Remove recall
229
+ const before = this.recall.length;
230
+ this.recall = this.recall.filter((r) => r.turnId !== id);
231
+ recallRemoved += before - this.recall.length;
232
+ this.turns.delete(id);
233
+ turnsRemoved++;
234
+ }
235
+ // Update convIndex
236
+ this.convIndex.set(convId, turnIds.filter((id) => this.turns.has(id)));
237
+ }
238
+ const branchesPreserved = this.forks.length;
239
+ return {
240
+ turnsRemoved,
241
+ recallRemoved,
242
+ branchesPreserved,
243
+ freedBytes: 0, // in-memory — no file size to measure
244
+ };
245
+ }
246
+ vacuum() {
247
+ // No-op for in-memory store
248
+ }
249
+ checkpoint() {
250
+ // Emit turns in a stable order (by internal ID / insertion order)
251
+ const sorted = [...this.turns.values()].sort((a, b) => a.id - b.id);
252
+ const turns = sorted.map((r) => r.entry);
253
+ // Build mapping: internal ID → 1-based position
254
+ const idToPos = new Map();
255
+ for (let i = 0; i < sorted.length; i++) {
256
+ idToPos.set(sorted[i].id, i + 1);
257
+ }
258
+ // Replace recall's turnId with the stable 1-based position
259
+ const recall = this.recall.map((r) => ({
260
+ turnId: String(idToPos.get(r.turnId) ?? r.turnId),
261
+ checkpointId: r.entry.checkpointId,
262
+ score: r.entry.score,
263
+ source: r.entry.source,
264
+ raptorLevel: r.entry.raptorLevel,
265
+ }));
266
+ return {
267
+ version: 1,
268
+ exportedAt: Date.now(),
269
+ turns,
270
+ recall,
271
+ forks: [...this.forks],
272
+ };
273
+ }
274
+ restore(from) {
275
+ this.clear();
276
+ // Map: 1-based position → new internal ID
277
+ const posToNewId = new Map();
278
+ for (let i = 0; i < from.turns.length; i++) {
279
+ const t = from.turns[i];
280
+ const newId = allocId();
281
+ const sid = normalizeSessionId(t.sessionId);
282
+ const normalized = { ...t, sessionId: sid };
283
+ this.turns.set(newId, { id: newId, entry: normalized });
284
+ // Update indices
285
+ const convIds = this.convIndex.get(t.conversationId) ?? [];
286
+ convIds.push(newId);
287
+ this.convIndex.set(t.conversationId, convIds);
288
+ this.sessionConv.set(sid, t.conversationId);
289
+ posToNewId.set(i + 1, newId);
290
+ }
291
+ // Re-attach recall with position-based TurnId → new internal ID mapping
292
+ for (const r of from.recall) {
293
+ const targetInternalId = posToNewId.get(Number(r.turnId));
294
+ if (targetInternalId !== undefined) {
295
+ this.recall.push({
296
+ turnId: targetInternalId,
297
+ entry: r,
298
+ });
299
+ }
300
+ }
301
+ this.forks = [...from.forks];
302
+ }
303
+ clear() {
304
+ this.turns.clear();
305
+ this.recall = [];
306
+ this.forks = [];
307
+ this.convIndex.clear();
308
+ this.sessionConv.clear();
309
+ resetIdCounter();
310
+ }
311
+ stampTurnsEpoch(sessionId, epochId) {
312
+ const sid = normalizeSessionId(sessionId);
313
+ let stamped = 0;
314
+ for (const row of this.turns.values()) {
315
+ if (row.epochId === undefined && row.entry.sessionId === sid) {
316
+ row.epochId = epochId;
317
+ stamped++;
318
+ }
319
+ }
320
+ return stamped;
321
+ }
322
+ // ── Capability gating ───────────────────────────────────────
323
+ asReader() {
324
+ return {
325
+ query: (f) => this.query(f),
326
+ getTurn: (id) => this.getTurn(id),
327
+ getTurnByIndex: (c, t) => this.getTurnByIndex(c, t),
328
+ listRecall: (id) => this.listRecall(id),
329
+ listRecallByIndex: (c, t) => this.listRecallByIndex(c, t),
330
+ listForks: (id) => this.listForks(id),
331
+ countTurns: (id) => this.countTurns(id),
332
+ conversationStats: (id) => this.conversationStats(id),
333
+ };
334
+ }
335
+ asWriter() {
336
+ return {
337
+ appendTurn: (e) => this.appendTurn(e),
338
+ appendRecall: (e) => this.appendRecall(e),
339
+ ensureConversationId: (s) => this.ensureConversationId(s),
340
+ forkConversation: (p, t) => this.forkConversation(p, t),
341
+ };
342
+ }
343
+ asAdmin() {
344
+ return {
345
+ prune: (p) => this.prune(p),
346
+ vacuum: () => this.vacuum(),
347
+ checkpoint: () => this.checkpoint(),
348
+ restore: (s) => this.restore(s),
349
+ clear: () => this.clear(),
350
+ stampTurnsEpoch: (s, e) => this.stampTurnsEpoch(s, e),
351
+ };
352
+ }
353
+ close() {
354
+ // No-op for in-memory store
355
+ this.clear();
356
+ }
357
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * memory-store.test.ts — InMemoryTurnStore compliance.
3
+ */
4
+ import { InMemoryTurnStore } from "./memory-store.js";
5
+ import { runComplianceSuite } from "./contract-compliance.test.js";
6
+ // Run the shared compliance suite against the in-memory backend
7
+ runComplianceSuite("InMemoryTurnStore", (options) => {
8
+ return new InMemoryTurnStore(options);
9
+ }, { stateDir: "/tmp/turns-compliance-memory", inMemory: true });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * migrations.ts — S49B one-time move of turn tables main-db → turns.db.
3
+ *
4
+ * S48 stored turns/turn_recall/conversation_branches inside the main sqlite.db.
5
+ * S49 isolates them into turns.db with a contract-first schema (master
6
+ * reconciliation): `conversation_branches` → `conversation_forks`, `model_id` →
7
+ * `model`, strict `role`/`source`/`pressure_band` enums, and `ended_at` NOT NULL.
8
+ *
9
+ * On first open with the flag ON, existing rows are COPIED across (ATTACH +
10
+ * explicit transformed INSERT) then the legacy tables are DROPPED. Idempotent
11
+ * (a turns_meta marker) and reversible (legacy tables remain in the main
12
+ * schema for one release so flag-OFF never loses history). Copy+drop is
13
+ * wrapped in a single transaction: a crash mid-copy rolls back and retries on
14
+ * next open. Non-fatal: any failure logs and leaves legacy intact.
15
+ *
16
+ * PREVENT-002: parameterized queries; ATTACH path is bound, not concatenated.
17
+ * PREVENT-PI-004: pure node:sqlite, no network.
18
+ */
19
+ import { existsSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { withTx } from "./connection.js";
22
+ /** Main memory DB filename (must match src/store/sqlite/utils.ts). */
23
+ const MAIN_DB_FILE = "sqlite.db";
24
+ /**
25
+ * Legacy tables dropped from the main db after a successful copy. The
26
+ * DROPs below use these as literal identifiers (no interpolation) so there
27
+ * is no injection surface.
28
+ */
29
+ const MIGRATED_KEY = "migrated_from_main";
30
+ function isMigrated(db) {
31
+ const row = db
32
+ .prepare("SELECT value FROM turns_meta WHERE key = ?")
33
+ .get(MIGRATED_KEY);
34
+ return row?.value === "1";
35
+ }
36
+ function markMigrated(db) {
37
+ db.prepare("INSERT OR REPLACE INTO turns_meta (key, value) VALUES (?, '1')").run(MIGRATED_KEY);
38
+ }
39
+ /**
40
+ * Map a legacy recall `source` value onto the contract enum
41
+ * (`checkpoint` | `cluster_summary` | `memory`). Legacy free-text values:
42
+ * - `flat` / `checkpoint` → `checkpoint`
43
+ * - `raptor` / `cluster_summary` → `cluster_summary`
44
+ * - `cross-repo` / `memory` / * → `memory`
45
+ */
46
+ function mapSource(legacy) {
47
+ switch (legacy) {
48
+ case "flat":
49
+ case "checkpoint":
50
+ return "checkpoint";
51
+ case "raptor":
52
+ case "cluster_summary":
53
+ return "cluster_summary";
54
+ default:
55
+ return "memory";
56
+ }
57
+ }
58
+ /**
59
+ * Move legacy turn tables out of the main db into turns.db, once. Transforms
60
+ * rows to the unified contract schema (column renames + enum coercion + NULL
61
+ * backfill). Idempotent + non-fatal.
62
+ *
63
+ * @param turnDb the open isolated turns.db handle
64
+ * @param stateDir the state dir containing sqlite.db
65
+ * @param log optional logger (defaults to console.warn on failure)
66
+ */
67
+ export function migrateTurnTablesIfNeeded(turnDb, stateDir, log = (m) => console.warn(`[turns-migrate] ${m}`)) {
68
+ if (isMigrated(turnDb))
69
+ return; // already done — idempotent no-op.
70
+ const mainPath = join(stateDir, MAIN_DB_FILE);
71
+ if (!existsSync(mainPath)) {
72
+ // No legacy main db (fresh install / test dir) — nothing to move.
73
+ markMigrated(turnDb);
74
+ return;
75
+ }
76
+ try {
77
+ turnDb.prepare("ATTACH DATABASE ? AS legacy").run(mainPath);
78
+ try {
79
+ const hasTurns = turnDb
80
+ .prepare("SELECT name FROM legacy.sqlite_master WHERE type = 'table' AND name = 'turns'")
81
+ .get();
82
+ if (!hasTurns) {
83
+ // Main db predates S48 turn tables — nothing to move.
84
+ markMigrated(turnDb);
85
+ return;
86
+ }
87
+ withTx(turnDb, () => {
88
+ // turns: rename model_id→model, coerce role/ended_at non-null, clamp
89
+ // pressure_band to the enum. Idempotent on (conversation_id, turn_index).
90
+ turnDb.exec(`
91
+ INSERT OR IGNORE INTO main.turns
92
+ (id, conversation_id, session_id, turn_index, role, started_at, ended_at,
93
+ ctx_tokens, ctx_percent, pressure_band, model, epoch_id)
94
+ SELECT
95
+ id, conversation_id, session_id, turn_index,
96
+ COALESCE(NULLIF(role, ''), 'user') AS role,
97
+ started_at,
98
+ COALESCE(ended_at, started_at, 0) AS ended_at,
99
+ ctx_tokens, ctx_percent,
100
+ CASE pressure_band
101
+ WHEN 'green' THEN 'green'
102
+ WHEN 'yellow' THEN 'yellow'
103
+ WHEN 'red' THEN 'red'
104
+ ELSE NULL
105
+ END AS pressure_band,
106
+ model_id AS model,
107
+ epoch_id
108
+ FROM legacy.turns
109
+ `);
110
+ // turn_recall: coerce source onto the contract enum. Idempotent on
111
+ // (turn_id, checkpoint_id).
112
+ turnDb.exec(`
113
+ INSERT OR IGNORE INTO main.turn_recall
114
+ (id, turn_id, checkpoint_id, score, source, raptor_level)
115
+ SELECT
116
+ id, turn_id, checkpoint_id, score,
117
+ CASE source
118
+ WHEN 'flat' THEN 'checkpoint'
119
+ WHEN 'checkpoint' THEN 'checkpoint'
120
+ WHEN 'raptor' THEN 'cluster_summary'
121
+ WHEN 'cluster_summary' THEN 'cluster_summary'
122
+ ELSE 'memory'
123
+ END AS source,
124
+ raptor_level
125
+ FROM legacy.turn_recall
126
+ `);
127
+ // conversation_branches → conversation_forks (rename + column map).
128
+ // Legacy: (conversation_id PK, parent_conversation_id, fork_turn_id, created_at).
129
+ // Contract: (id, parent_conversation_id, child_conversation_id, fork_turn_index, created_at).
130
+ turnDb.exec(`
131
+ INSERT OR IGNORE INTO main.conversation_forks
132
+ (parent_conversation_id, child_conversation_id, fork_turn_index, created_at)
133
+ SELECT
134
+ parent_conversation_id,
135
+ conversation_id AS child_conversation_id,
136
+ fork_turn_id AS fork_turn_index,
137
+ created_at
138
+ FROM legacy.conversation_branches
139
+ `);
140
+ // session_conversations: hydrate from legacy session_state if it has a
141
+ // conversation_id column (S48 stored the active conversation there).
142
+ const hasSessionState = turnDb
143
+ .prepare("SELECT name FROM legacy.sqlite_master WHERE type = 'table' AND name = 'session_state'")
144
+ .get();
145
+ if (hasSessionState) {
146
+ const cols = turnDb
147
+ .prepare("PRAGMA legacy.table_info(session_state)")
148
+ .all();
149
+ if (cols.some((c) => c.name === "conversation_id")) {
150
+ turnDb.exec(`
151
+ INSERT OR IGNORE INTO main.session_conversations (session_id, conversation_id)
152
+ SELECT session_id, conversation_id
153
+ FROM legacy.session_state
154
+ WHERE conversation_id IS NOT NULL
155
+ `);
156
+ }
157
+ }
158
+ // Drop legacy tables from the main db only after a successful copy.
159
+ // These are literal identifiers from the fixed LEGACY_TABLES allowlist
160
+ // (not user input) — SQLite cannot parameterize DDL identifiers, so a
161
+ // literal per table is the provably-injection-free form.
162
+ turnDb.exec("DROP TABLE IF EXISTS legacy.turns");
163
+ turnDb.exec("DROP TABLE IF EXISTS legacy.turn_recall");
164
+ turnDb.exec("DROP TABLE IF EXISTS legacy.conversation_branches");
165
+ markMigrated(turnDb);
166
+ });
167
+ }
168
+ finally {
169
+ turnDb.exec("DETACH DATABASE legacy");
170
+ }
171
+ }
172
+ catch (e) {
173
+ // Non-fatal: leave legacy tables intact so nothing is lost; retry next open.
174
+ log(`migration failed (will retry on next open): ${e instanceof Error ? e.message : String(e)}`);
175
+ }
176
+ }
177
+ // Exported for tests that need to inspect the source mapping.
178
+ export { mapSource };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * migrations.test.ts — S49B main-db → turns.db migration tests.
3
+ *
4
+ * Seeds a legacy main sqlite.db (with S48-era turn tables + session_state
5
+ * conversation pointers), then opens the turns store and asserts rows moved,
6
+ * legacy dropped, and the move is idempotent + flag-gated. No network.
7
+ */
8
+ import { test, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { openTurnStore, closeTurnStore } from "./connection.js";
15
+ import { TurnsConfig } from "../../config/turns.js";
16
+ let tmpDir;
17
+ let counter = 0;
18
+ beforeEach(() => {
19
+ tmpDir = mkdtempSync(join(tmpdir(), "mc-migrate-"));
20
+ });
21
+ afterEach(() => {
22
+ rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+ function stateDir() {
25
+ return join(tmpDir, `run-${counter++}`);
26
+ }
27
+ /** Seed a legacy main db with S48-era turn tables + a conversation pointer. */
28
+ function seedLegacyMainDb(dir) {
29
+ mkdirSync(dir, { recursive: true });
30
+ const db = new DatabaseSync(join(dir, "sqlite.db"));
31
+ db.exec(`
32
+ CREATE TABLE turns (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL,
34
+ session_id TEXT NOT NULL, turn_index INTEGER NOT NULL, role TEXT,
35
+ started_at INTEGER NOT NULL, ended_at INTEGER, ctx_tokens INTEGER,
36
+ ctx_percent REAL, pressure_band TEXT, model_id TEXT, epoch_id TEXT,
37
+ UNIQUE(session_id, turn_index)
38
+ );
39
+ CREATE TABLE turn_recall (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT, turn_id INTEGER NOT NULL,
41
+ checkpoint_id TEXT NOT NULL, score REAL NOT NULL, source TEXT NOT NULL,
42
+ raptor_level INTEGER, UNIQUE(turn_id, checkpoint_id)
43
+ );
44
+ CREATE TABLE conversation_branches (
45
+ conversation_id TEXT PRIMARY KEY, parent_conversation_id TEXT NOT NULL,
46
+ fork_turn_id INTEGER NOT NULL, created_at INTEGER NOT NULL
47
+ );
48
+ CREATE TABLE session_state (
49
+ session_id TEXT PRIMARY KEY, injected_checkpoint_ids TEXT,
50
+ stored_region_hashes TEXT, conversation_id TEXT, last_turn_id INTEGER
51
+ );
52
+ INSERT INTO turns (conversation_id, session_id, turn_index, started_at, ended_at, ctx_tokens)
53
+ VALUES ('conv_leg', 'sess_leg', 0, 100, 200, 500);
54
+ INSERT INTO turn_recall (turn_id, checkpoint_id, score, source)
55
+ VALUES (1, 'cp_leg', 0.6, 'flat');
56
+ INSERT INTO conversation_branches (conversation_id, parent_conversation_id, fork_turn_id, created_at)
57
+ VALUES ('conv_child', 'conv_leg', 1, 300);
58
+ INSERT INTO session_state (session_id, conversation_id) VALUES ('sess_leg', 'conv_leg');
59
+ `);
60
+ db.close();
61
+ }
62
+ function tableExists(db, name) {
63
+ return (db
64
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
65
+ .get(name) !== undefined);
66
+ }
67
+ test("fresh dir (no main db) → no-op, marks migrated", () => {
68
+ const dir = stateDir();
69
+ const db = openTurnStore(dir);
70
+ // openTurnStore ran the migration hook; with no sqlite.db it should just mark.
71
+ const row = db
72
+ .prepare("SELECT value FROM turns_meta WHERE key='migrated_from_main'")
73
+ .get();
74
+ assert.equal(row?.value, "1");
75
+ closeTurnStore(dir);
76
+ });
77
+ test("legacy rows moved, legacy tables dropped, conversation pointer migrated", () => {
78
+ const dir = stateDir();
79
+ seedLegacyMainDb(dir);
80
+ const db = openTurnStore(dir); // triggers migration
81
+ // Rows present in turns.db.
82
+ const turns = db.prepare("SELECT * FROM turns").all();
83
+ assert.equal(turns.length, 1);
84
+ assert.equal(turns[0].conversation_id, "conv_leg");
85
+ const recall = db.prepare("SELECT * FROM turn_recall").all();
86
+ assert.equal(recall.length, 1);
87
+ assert.equal(recall[0].checkpoint_id, "cp_leg");
88
+ const branches = db
89
+ .prepare("SELECT * FROM conversation_forks")
90
+ .all();
91
+ assert.equal(branches.length, 1);
92
+ assert.equal(branches[0].parent_conversation_id, "conv_leg");
93
+ assert.equal(branches[0].child_conversation_id, "conv_child");
94
+ assert.equal(branches[0].fork_turn_index, 1);
95
+ // Conversation pointer migrated to session_conversations (session_id → conversation_id).
96
+ const ptr = db
97
+ .prepare("SELECT conversation_id FROM session_conversations WHERE session_id='sess_leg'")
98
+ .get();
99
+ assert.equal(ptr?.conversation_id, "conv_leg");
100
+ closeTurnStore(dir);
101
+ // Legacy tables dropped from the main db.
102
+ const main = new DatabaseSync(join(dir, "sqlite.db"));
103
+ assert.ok(!tableExists(main, "turns"));
104
+ assert.ok(!tableExists(main, "turn_recall"));
105
+ assert.ok(!tableExists(main, "conversation_branches"));
106
+ assert.ok(tableExists(main, "session_state")); // left intact
107
+ main.close();
108
+ });
109
+ test("idempotent: re-open does not re-copy or re-drop", () => {
110
+ const dir = stateDir();
111
+ seedLegacyMainDb(dir);
112
+ openTurnStore(dir);
113
+ closeTurnStore(dir);
114
+ // Second open — marker set, should be a no-op even though main db now lacks turn tables.
115
+ const db = openTurnStore(dir);
116
+ const turns = db.prepare("SELECT * FROM turns").all();
117
+ assert.equal(turns.length, 1); // still exactly the migrated row, not duplicated
118
+ closeTurnStore(dir);
119
+ });
120
+ test("flag OFF → legacy tables untouched, no marker write from hook", () => {
121
+ const dir = stateDir();
122
+ seedLegacyMainDb(dir);
123
+ const original = TurnsConfig.TURNS_DB_ENABLED;
124
+ TurnsConfig.TURNS_DB_ENABLED = false;
125
+ try {
126
+ const db = openTurnStore(dir);
127
+ closeTurnStore(dir);
128
+ void db;
129
+ }
130
+ finally {
131
+ TurnsConfig.TURNS_DB_ENABLED = original;
132
+ }
133
+ // Main db turn tables must be intact (migration hook was gated off).
134
+ const main = new DatabaseSync(join(dir, "sqlite.db"));
135
+ assert.ok(tableExists(main, "turns"));
136
+ assert.ok(tableExists(main, "conversation_branches"));
137
+ main.close();
138
+ });