pi-mega-compact 0.9.2 → 0.11.0

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 +264 -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 +25 -1
  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-DZp_xQOW.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DZp_xQOW.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-BzFHpAHM.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-BzFHpAHM.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DEQbTq2T.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DEQbTq2T.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-DQ4FG33J.js} +2 -2
  58. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-DQ4FG33J.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-D5HoEVYo.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-D5HoEVYo.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Jzsc4yeY.js} +2 -2
  62. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Jzsc4yeY.js.map} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-D_XnsEYX.js} +2 -2
  64. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-D_XnsEYX.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-BMREvAth.js} +2 -2
  66. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-BMREvAth.js.map} +1 -1
  67. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DuyhOktG.js} +2 -2
  68. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DuyhOktG.js.map} +1 -1
  69. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-mz_vz8i2.js} +2 -2
  70. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-mz_vz8i2.js.map} +1 -1
  71. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-B8AIjNTY.js} +2 -2
  72. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-B8AIjNTY.js.map} +1 -1
  73. package/extensions/dashboard-client/dist/assets/TopicsTab-DKuct4oD.js +2 -0
  74. package/extensions/dashboard-client/dist/assets/TopicsTab-DKuct4oD.js.map +1 -0
  75. package/extensions/dashboard-client/dist/assets/TurnsTab-11MGLxY8.js +2 -0
  76. package/extensions/dashboard-client/dist/assets/TurnsTab-11MGLxY8.js.map +1 -0
  77. package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-CqLN7STl.js} +10 -10
  78. package/extensions/dashboard-client/dist/assets/index-CqLN7STl.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-CXrQSbVY.js} +2 -2
  81. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-CXrQSbVY.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 +90 -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 +186 -0
  88. package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +336 -0
  89. package/extensions/dashboard-server/api-contracts/endpoints.ts +394 -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 +305 -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 +26 -5
  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,65 @@
1
+ /**
2
+ * index.ts — Barrel for src/store/turns/ (S49 reconciled).
3
+ *
4
+ * Re-exports the contract-first factory + all contract types (master's design)
5
+ * and the two backend constructors (SqliteTurnStore / InMemoryTurnStore), plus
6
+ * this branch's reconciled connection layer + migration helper. Hosts import
7
+ * only this file + types.ts.
8
+ *
9
+ * PREVENT-PI-004: no network. Reuse-clean: no pi imports.
10
+ */
11
+
12
+ export type {
13
+ TurnId,
14
+ ConversationId,
15
+ SessionId,
16
+ TurnEntry,
17
+ TurnRecallEntry,
18
+ ConversationFork,
19
+ TurnFilter,
20
+ PruneReport,
21
+ RetentionPolicy,
22
+ StoreSnapshot,
23
+ ConversationStats,
24
+ TurnReader,
25
+ TurnWriter,
26
+ TurnAdmin,
27
+ TurnStore,
28
+ TurnStoreOptions,
29
+ TurnStoreFactory,
30
+ } from "./types.js";
31
+
32
+ export { SqliteTurnStore } from "./sqlite-store.js";
33
+ export { InMemoryTurnStore } from "./memory-store.js";
34
+
35
+ // Reconciled connection layer (master shape + this branch's migration +
36
+ // closed-handle eviction + env override + openTurnStore back-compat alias).
37
+ export {
38
+ openTurnDb,
39
+ openTurnStore,
40
+ closeTurnDb,
41
+ closeTurnStore,
42
+ closeAllTurnDbs,
43
+ turnDbPath,
44
+ TURNS_DB_FILE,
45
+ withTx,
46
+ } from "./connection.js";
47
+ export { initTurnSchema } from "./schema.js";
48
+ export { migrateTurnTablesIfNeeded } from "./migrations.js";
49
+
50
+ import { SqliteTurnStore } from "./sqlite-store.js";
51
+ import { InMemoryTurnStore } from "./memory-store.js";
52
+ import type { TurnStore, TurnStoreOptions } from "./types.js";
53
+
54
+ /**
55
+ * Factory: create a TurnStore from options.
56
+ *
57
+ * - Default: SqliteTurnStore backed by turns.db in stateDir
58
+ * - inMemory: InMemoryTurnStore (no file I/O)
59
+ */
60
+ export function createTurnStore(options: TurnStoreOptions): TurnStore {
61
+ if (options.inMemory) {
62
+ return new InMemoryTurnStore(options);
63
+ }
64
+ return new SqliteTurnStore(options);
65
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * memory-store.test.ts — InMemoryTurnStore compliance.
3
+ */
4
+
5
+ import { InMemoryTurnStore } from "./memory-store.js";
6
+ import { runComplianceSuite } from "./contract-compliance.test.js";
7
+
8
+ // Run the shared compliance suite against the in-memory backend
9
+ runComplianceSuite(
10
+ "InMemoryTurnStore",
11
+ (options) => {
12
+ return new InMemoryTurnStore(options);
13
+ },
14
+ { stateDir: "/tmp/turns-compliance-memory", inMemory: true },
15
+ );
@@ -0,0 +1,469 @@
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
+
10
+ import { randomBytes } from "node:crypto";
11
+ import type {
12
+ TurnStore,
13
+ TurnReader,
14
+ TurnWriter,
15
+ TurnAdmin,
16
+ TurnId,
17
+ ConversationId,
18
+ SessionId,
19
+ TurnEntry,
20
+ TurnRecallEntry,
21
+ ConversationFork,
22
+ TurnFilter,
23
+ PruneReport,
24
+ RetentionPolicy,
25
+ StoreSnapshot,
26
+ ConversationStats,
27
+ TurnStoreOptions,
28
+ } from "./types.js";
29
+
30
+ function newConversationId(): ConversationId {
31
+ return `conv_${randomBytes(8).toString("hex")}`;
32
+ }
33
+
34
+ function normalizeSessionId(sid: SessionId): SessionId {
35
+ return sid.replace(/-.*$/, "");
36
+ }
37
+
38
+ // Auto-increment counter
39
+ let nextId = 1;
40
+ function allocId(): number {
41
+ return nextId++;
42
+ }
43
+ function resetIdCounter(): void {
44
+ nextId = 1;
45
+ }
46
+
47
+ // Internal row with numeric id
48
+ interface TurnRow {
49
+ id: number;
50
+ entry: TurnEntry;
51
+ epochId?: string;
52
+ }
53
+
54
+ interface RecallRow {
55
+ turnId: number;
56
+ entry: TurnRecallEntry;
57
+ }
58
+
59
+ export class InMemoryTurnStore implements TurnStore {
60
+ private turns: Map<number, TurnRow> = new Map();
61
+ private recall: RecallRow[] = [];
62
+ private forks: ConversationFork[] = [];
63
+ // conversation_id → turn ids (for fast lookup)
64
+ private convIndex: Map<ConversationId, number[]> = new Map();
65
+ // session_id → conversation_id
66
+ private sessionConv: Map<SessionId, ConversationId> = new Map();
67
+
68
+ constructor(_options?: TurnStoreOptions) {
69
+ resetIdCounter();
70
+ }
71
+
72
+ // ── TurnReader ──────────────────────────────────────────────
73
+
74
+ query(filter: TurnFilter): TurnEntry[] {
75
+ let results = [...this.turns.values()].map((r) => r.entry);
76
+
77
+ if (filter.conversationId !== undefined) {
78
+ results = results.filter(
79
+ (t) => t.conversationId === filter.conversationId,
80
+ );
81
+ }
82
+ if (filter.sessionId !== undefined) {
83
+ results = results.filter(
84
+ (t) =>
85
+ normalizeSessionId(t.sessionId) ===
86
+ normalizeSessionId(filter.sessionId!),
87
+ );
88
+ }
89
+ if (filter.sinceMs !== undefined) {
90
+ results = results.filter((t) => t.endedAt >= filter.sinceMs!);
91
+ }
92
+ if (filter.untilMs !== undefined) {
93
+ results = results.filter((t) => t.endedAt <= filter.untilMs!);
94
+ }
95
+ if (filter.pressureBand !== undefined) {
96
+ results = results.filter((t) => t.pressureBand === filter.pressureBand);
97
+ }
98
+
99
+ results.sort((a, b) => a.endedAt - b.endedAt);
100
+
101
+ const offset = filter.offset ?? 0;
102
+ const limit = filter.limit ?? 1000;
103
+ return results.slice(offset, offset + limit);
104
+ }
105
+
106
+ getTurn(turnId: TurnId): TurnEntry | undefined {
107
+ const row = this.turns.get(Number(turnId));
108
+ return row ? { ...row.entry, epochId: row.epochId } : undefined;
109
+ }
110
+
111
+ getTurnByIndex(
112
+ conversationId: ConversationId,
113
+ turnIndex: number,
114
+ ): TurnEntry | undefined {
115
+ const ids = this.convIndex.get(conversationId) ?? [];
116
+ for (const id of ids) {
117
+ const row = this.turns.get(id);
118
+ if (row && row.entry.turnIndex === turnIndex) {
119
+ return { ...row.entry, epochId: row.epochId };
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ listRecall(turnId: TurnId): TurnRecallEntry[] {
126
+ return this.recall
127
+ .filter((r) => r.turnId === Number(turnId))
128
+ .map((r) => r.entry)
129
+ .sort((a, b) => b.score - a.score);
130
+ }
131
+
132
+ listRecallByIndex(
133
+ conversationId: ConversationId,
134
+ turnIndex: number,
135
+ ): TurnRecallEntry[] {
136
+ const turn = this.getTurnByIndex(conversationId, turnIndex);
137
+ if (!turn) return [];
138
+ // Resolve the numeric row id for this turn via the convIndex.
139
+ const ids = this.convIndex.get(conversationId) ?? [];
140
+ for (const id of ids) {
141
+ const row = this.turns.get(id);
142
+ if (row && row.entry.turnIndex === turnIndex) {
143
+ return this.listRecall(String(id));
144
+ }
145
+ }
146
+ return [];
147
+ }
148
+
149
+ listForks(conversationId: ConversationId): ConversationFork[] {
150
+ return this.forks
151
+ .filter((f) => f.parentConversationId === conversationId)
152
+ .sort((a, b) => a.createdAt - b.createdAt);
153
+ }
154
+
155
+ countTurns(conversationId: ConversationId): number {
156
+ const ids = this.convIndex.get(conversationId);
157
+ return ids?.length ?? 0;
158
+ }
159
+
160
+ conversationStats(conversationId: ConversationId): ConversationStats {
161
+ const ids = this.convIndex.get(conversationId) ?? [];
162
+ const entries = ids
163
+ .map((id) => this.turns.get(id)?.entry)
164
+ .filter((e): e is TurnEntry => e !== undefined);
165
+
166
+ if (entries.length === 0) {
167
+ return {
168
+ turnCount: 0,
169
+ firstTurnAt: 0,
170
+ lastTurnAt: 0,
171
+ avgCtxPercent: 0,
172
+ pressureBands: {},
173
+ };
174
+ }
175
+
176
+ let ctxSum = 0;
177
+ let ctxCount = 0;
178
+ const bands: Record<string, number> = {};
179
+
180
+ for (const e of entries) {
181
+ if (e.ctxPercent !== undefined) {
182
+ ctxSum += e.ctxPercent;
183
+ ctxCount++;
184
+ }
185
+ if (e.pressureBand)
186
+ bands[e.pressureBand] = (bands[e.pressureBand] ?? 0) + 1;
187
+ }
188
+
189
+ return {
190
+ turnCount: entries.length,
191
+ firstTurnAt: entries[0].endedAt,
192
+ lastTurnAt: entries[entries.length - 1].endedAt,
193
+ avgCtxPercent: ctxCount > 0 ? ctxSum / ctxCount : 0,
194
+ pressureBands: bands,
195
+ };
196
+ }
197
+
198
+ // ── TurnWriter ──────────────────────────────────────────────
199
+
200
+ appendTurn(entry: TurnEntry): TurnId {
201
+ const id = allocId();
202
+ const sid = normalizeSessionId(entry.sessionId);
203
+ const normalized = { ...entry, sessionId: sid };
204
+ this.turns.set(id, { id, entry: normalized });
205
+
206
+ // Update indices
207
+ const convIds = this.convIndex.get(entry.conversationId) ?? [];
208
+ convIds.push(id);
209
+ this.convIndex.set(entry.conversationId, convIds);
210
+
211
+ this.sessionConv.set(sid, entry.conversationId);
212
+
213
+ return String(id);
214
+ }
215
+
216
+ appendRecall(entry: TurnRecallEntry): void {
217
+ // De-dup by (turn_id, checkpoint_id)
218
+ const exists = this.recall.some(
219
+ (r) =>
220
+ r.turnId === Number(entry.turnId) &&
221
+ r.entry.checkpointId === entry.checkpointId,
222
+ );
223
+ if (!exists) {
224
+ this.recall.push({ turnId: Number(entry.turnId), entry });
225
+ }
226
+ }
227
+
228
+ ensureConversationId(sessionId: SessionId): ConversationId {
229
+ const sid = normalizeSessionId(sessionId);
230
+ const existing = this.sessionConv.get(sid);
231
+ if (existing) return existing;
232
+ // Generate and store a new conversation ID for this session
233
+ const convId = newConversationId();
234
+ this.sessionConv.set(sid, convId);
235
+ return convId;
236
+ }
237
+
238
+ forkConversation(
239
+ parentId: ConversationId,
240
+ forkTurnIndex: number,
241
+ ): ConversationId {
242
+ const childId = newConversationId();
243
+ const now = Date.now();
244
+
245
+ // 1. Record the fork lineage
246
+ this.forks.push({
247
+ parentConversationId: parentId,
248
+ childConversationId: childId,
249
+ forkTurnIndex,
250
+ createdAt: now,
251
+ });
252
+
253
+ // 2. Find the parent's turn at forkTurnIndex
254
+ const parentTurnIds = this.convIndex.get(parentId) ?? [];
255
+ const parentTurnRow = parentTurnIds
256
+ .map((id) => this.turns.get(id))
257
+ .find((r) => r?.entry.turnIndex === forkTurnIndex);
258
+
259
+ if (!parentTurnRow) return childId; // no parent turn — nothing to seed
260
+
261
+ // 3. Create a seed turn in the child conversation
262
+ const seedId = allocId();
263
+ const seedEntry: TurnEntry = {
264
+ conversationId: childId,
265
+ sessionId: `fork_${childId}`,
266
+ turnIndex: 0,
267
+ role: "system",
268
+ endedAt: now,
269
+ };
270
+ this.turns.set(seedId, { id: seedId, entry: seedEntry });
271
+ this.convIndex.set(childId, [seedId]);
272
+
273
+ // 4. Copy the parent's recall entries into the child's seed turn
274
+ const parentRecall = this.recall.filter(
275
+ (r) => r.turnId === parentTurnRow.id,
276
+ );
277
+ for (const r of parentRecall) {
278
+ this.recall.push({
279
+ turnId: seedId,
280
+ entry: {
281
+ turnId: String(seedId),
282
+ checkpointId: r.entry.checkpointId,
283
+ score: r.entry.score,
284
+ source: r.entry.source,
285
+ raptorLevel: r.entry.raptorLevel,
286
+ },
287
+ });
288
+ }
289
+
290
+ return childId;
291
+ }
292
+
293
+ // ── TurnAdmin ───────────────────────────────────────────────
294
+
295
+ prune(policy: RetentionPolicy): PruneReport {
296
+ const cutoff = Date.now() - policy.maxTurnAgeMs;
297
+ let turnsRemoved = 0;
298
+ let recallRemoved = 0;
299
+
300
+ for (const [convId, turnIds] of this.convIndex.entries()) {
301
+ const total = turnIds.length;
302
+ const canDelete = Math.max(0, total - policy.keepMinPerConversation);
303
+ if (canDelete === 0) continue;
304
+
305
+ // Find old turns to delete
306
+ const oldIds = turnIds
307
+ .filter((id) => {
308
+ const row = this.turns.get(id);
309
+ return row && row.entry.endedAt < cutoff;
310
+ })
311
+ .slice(0, canDelete);
312
+
313
+ for (const id of oldIds) {
314
+ // Remove recall
315
+ const before = this.recall.length;
316
+ this.recall = this.recall.filter((r) => r.turnId !== id);
317
+ recallRemoved += before - this.recall.length;
318
+
319
+ this.turns.delete(id);
320
+ turnsRemoved++;
321
+ }
322
+
323
+ // Update convIndex
324
+ this.convIndex.set(
325
+ convId,
326
+ turnIds.filter((id) => this.turns.has(id)),
327
+ );
328
+ }
329
+
330
+ const branchesPreserved = this.forks.length;
331
+
332
+ return {
333
+ turnsRemoved,
334
+ recallRemoved,
335
+ branchesPreserved,
336
+ freedBytes: 0, // in-memory — no file size to measure
337
+ };
338
+ }
339
+
340
+ vacuum(): void {
341
+ // No-op for in-memory store
342
+ }
343
+
344
+ checkpoint(): StoreSnapshot {
345
+ // Emit turns in a stable order (by internal ID / insertion order)
346
+ const sorted = [...this.turns.values()].sort((a, b) => a.id - b.id);
347
+ const turns = sorted.map((r) => r.entry);
348
+
349
+ // Build mapping: internal ID → 1-based position
350
+ const idToPos = new Map<number, number>();
351
+ for (let i = 0; i < sorted.length; i++) {
352
+ idToPos.set(sorted[i].id, i + 1);
353
+ }
354
+
355
+ // Replace recall's turnId with the stable 1-based position
356
+ const recall = this.recall.map((r) => ({
357
+ turnId: String(idToPos.get(r.turnId) ?? r.turnId),
358
+ checkpointId: r.entry.checkpointId,
359
+ score: r.entry.score,
360
+ source: r.entry.source,
361
+ raptorLevel: r.entry.raptorLevel,
362
+ }));
363
+
364
+ return {
365
+ version: 1,
366
+ exportedAt: Date.now(),
367
+ turns,
368
+ recall,
369
+ forks: [...this.forks],
370
+ };
371
+ }
372
+
373
+ restore(from: StoreSnapshot): void {
374
+ this.clear();
375
+
376
+ // Map: 1-based position → new internal ID
377
+ const posToNewId = new Map<number, number>();
378
+
379
+ for (let i = 0; i < from.turns.length; i++) {
380
+ const t = from.turns[i];
381
+ const newId = allocId();
382
+ const sid = normalizeSessionId(t.sessionId);
383
+ const normalized = { ...t, sessionId: sid };
384
+ this.turns.set(newId, { id: newId, entry: normalized });
385
+
386
+ // Update indices
387
+ const convIds = this.convIndex.get(t.conversationId) ?? [];
388
+ convIds.push(newId);
389
+ this.convIndex.set(t.conversationId, convIds);
390
+ this.sessionConv.set(sid, t.conversationId);
391
+
392
+ posToNewId.set(i + 1, newId);
393
+ }
394
+
395
+ // Re-attach recall with position-based TurnId → new internal ID mapping
396
+ for (const r of from.recall) {
397
+ const targetInternalId = posToNewId.get(Number(r.turnId));
398
+ if (targetInternalId !== undefined) {
399
+ this.recall.push({
400
+ turnId: targetInternalId,
401
+ entry: r,
402
+ });
403
+ }
404
+ }
405
+
406
+ this.forks = [...from.forks];
407
+ }
408
+
409
+ clear(): void {
410
+ this.turns.clear();
411
+ this.recall = [];
412
+ this.forks = [];
413
+ this.convIndex.clear();
414
+ this.sessionConv.clear();
415
+ resetIdCounter();
416
+ }
417
+
418
+ stampTurnsEpoch(sessionId: SessionId, epochId: string): number {
419
+ const sid = normalizeSessionId(sessionId);
420
+ let stamped = 0;
421
+ for (const row of this.turns.values()) {
422
+ if (row.epochId === undefined && row.entry.sessionId === sid) {
423
+ row.epochId = epochId;
424
+ stamped++;
425
+ }
426
+ }
427
+ return stamped;
428
+ }
429
+
430
+ // ── Capability gating ───────────────────────────────────────
431
+
432
+ asReader(): TurnReader {
433
+ return {
434
+ query: (f) => this.query(f),
435
+ getTurn: (id) => this.getTurn(id),
436
+ getTurnByIndex: (c, t) => this.getTurnByIndex(c, t),
437
+ listRecall: (id) => this.listRecall(id),
438
+ listRecallByIndex: (c, t) => this.listRecallByIndex(c, t),
439
+ listForks: (id) => this.listForks(id),
440
+ countTurns: (id) => this.countTurns(id),
441
+ conversationStats: (id) => this.conversationStats(id),
442
+ };
443
+ }
444
+
445
+ asWriter(): TurnWriter {
446
+ return {
447
+ appendTurn: (e) => this.appendTurn(e),
448
+ appendRecall: (e) => this.appendRecall(e),
449
+ ensureConversationId: (s) => this.ensureConversationId(s),
450
+ forkConversation: (p, t) => this.forkConversation(p, t),
451
+ };
452
+ }
453
+
454
+ asAdmin(): TurnAdmin {
455
+ return {
456
+ prune: (p) => this.prune(p),
457
+ vacuum: () => this.vacuum(),
458
+ checkpoint: () => this.checkpoint(),
459
+ restore: (s) => this.restore(s),
460
+ clear: () => this.clear(),
461
+ stampTurnsEpoch: (s, e) => this.stampTurnsEpoch(s, e),
462
+ };
463
+ }
464
+
465
+ close(): void {
466
+ // No-op for in-memory store
467
+ this.clear();
468
+ }
469
+ }
@@ -0,0 +1,161 @@
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
+
9
+ import { test, beforeEach, afterEach } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { DatabaseSync } from "node:sqlite";
15
+ import { openTurnStore, closeTurnStore } from "./connection.js";
16
+ import { TurnsConfig } from "../../config/turns.js";
17
+
18
+ let tmpDir: string;
19
+ let counter = 0;
20
+
21
+ beforeEach(() => {
22
+ tmpDir = mkdtempSync(join(tmpdir(), "mc-migrate-"));
23
+ });
24
+
25
+ afterEach(() => {
26
+ rmSync(tmpDir, { recursive: true, force: true });
27
+ });
28
+
29
+ function stateDir(): string {
30
+ return join(tmpDir, `run-${counter++}`);
31
+ }
32
+
33
+ /** Seed a legacy main db with S48-era turn tables + a conversation pointer. */
34
+ function seedLegacyMainDb(dir: string): void {
35
+ mkdirSync(dir, { recursive: true });
36
+ const db = new DatabaseSync(join(dir, "sqlite.db"));
37
+ db.exec(`
38
+ CREATE TABLE turns (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL,
40
+ session_id TEXT NOT NULL, turn_index INTEGER NOT NULL, role TEXT,
41
+ started_at INTEGER NOT NULL, ended_at INTEGER, ctx_tokens INTEGER,
42
+ ctx_percent REAL, pressure_band TEXT, model_id TEXT, epoch_id TEXT,
43
+ UNIQUE(session_id, turn_index)
44
+ );
45
+ CREATE TABLE turn_recall (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT, turn_id INTEGER NOT NULL,
47
+ checkpoint_id TEXT NOT NULL, score REAL NOT NULL, source TEXT NOT NULL,
48
+ raptor_level INTEGER, UNIQUE(turn_id, checkpoint_id)
49
+ );
50
+ CREATE TABLE conversation_branches (
51
+ conversation_id TEXT PRIMARY KEY, parent_conversation_id TEXT NOT NULL,
52
+ fork_turn_id INTEGER NOT NULL, created_at INTEGER NOT NULL
53
+ );
54
+ CREATE TABLE session_state (
55
+ session_id TEXT PRIMARY KEY, injected_checkpoint_ids TEXT,
56
+ stored_region_hashes TEXT, conversation_id TEXT, last_turn_id INTEGER
57
+ );
58
+ INSERT INTO turns (conversation_id, session_id, turn_index, started_at, ended_at, ctx_tokens)
59
+ VALUES ('conv_leg', 'sess_leg', 0, 100, 200, 500);
60
+ INSERT INTO turn_recall (turn_id, checkpoint_id, score, source)
61
+ VALUES (1, 'cp_leg', 0.6, 'flat');
62
+ INSERT INTO conversation_branches (conversation_id, parent_conversation_id, fork_turn_id, created_at)
63
+ VALUES ('conv_child', 'conv_leg', 1, 300);
64
+ INSERT INTO session_state (session_id, conversation_id) VALUES ('sess_leg', 'conv_leg');
65
+ `);
66
+ db.close();
67
+ }
68
+
69
+ function tableExists(db: DatabaseSync, name: string): boolean {
70
+ return (
71
+ db
72
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
73
+ .get(name) !== undefined
74
+ );
75
+ }
76
+
77
+ test("fresh dir (no main db) → no-op, marks migrated", () => {
78
+ const dir = stateDir();
79
+ const db = openTurnStore(dir);
80
+ // openTurnStore ran the migration hook; with no sqlite.db it should just mark.
81
+ const row = db
82
+ .prepare("SELECT value FROM turns_meta WHERE key='migrated_from_main'")
83
+ .get() as { value: string } | undefined;
84
+ assert.equal(row?.value, "1");
85
+ closeTurnStore(dir);
86
+ });
87
+
88
+ test("legacy rows moved, legacy tables dropped, conversation pointer migrated", () => {
89
+ const dir = stateDir();
90
+ seedLegacyMainDb(dir);
91
+ const db = openTurnStore(dir); // triggers migration
92
+
93
+ // Rows present in turns.db.
94
+ const turns = db.prepare("SELECT * FROM turns").all() as Array<
95
+ Record<string, unknown>
96
+ >;
97
+ assert.equal(turns.length, 1);
98
+ assert.equal(turns[0].conversation_id, "conv_leg");
99
+ const recall = db.prepare("SELECT * FROM turn_recall").all() as Array<
100
+ Record<string, unknown>
101
+ >;
102
+ assert.equal(recall.length, 1);
103
+ assert.equal(recall[0].checkpoint_id, "cp_leg");
104
+ const branches = db
105
+ .prepare("SELECT * FROM conversation_forks")
106
+ .all() as Array<Record<string, unknown>>;
107
+ assert.equal(branches.length, 1);
108
+ assert.equal(branches[0].parent_conversation_id, "conv_leg");
109
+ assert.equal(branches[0].child_conversation_id, "conv_child");
110
+ assert.equal(branches[0].fork_turn_index, 1);
111
+ // Conversation pointer migrated to session_conversations (session_id → conversation_id).
112
+ const ptr = db
113
+ .prepare(
114
+ "SELECT conversation_id FROM session_conversations WHERE session_id='sess_leg'",
115
+ )
116
+ .get() as { conversation_id: string } | undefined;
117
+ assert.equal(ptr?.conversation_id, "conv_leg");
118
+
119
+ closeTurnStore(dir);
120
+
121
+ // Legacy tables dropped from the main db.
122
+ const main = new DatabaseSync(join(dir, "sqlite.db"));
123
+ assert.ok(!tableExists(main, "turns"));
124
+ assert.ok(!tableExists(main, "turn_recall"));
125
+ assert.ok(!tableExists(main, "conversation_branches"));
126
+ assert.ok(tableExists(main, "session_state")); // left intact
127
+ main.close();
128
+ });
129
+
130
+ test("idempotent: re-open does not re-copy or re-drop", () => {
131
+ const dir = stateDir();
132
+ seedLegacyMainDb(dir);
133
+ openTurnStore(dir);
134
+ closeTurnStore(dir);
135
+ // Second open — marker set, should be a no-op even though main db now lacks turn tables.
136
+ const db = openTurnStore(dir);
137
+ const turns = db.prepare("SELECT * FROM turns").all() as Array<
138
+ Record<string, unknown>
139
+ >;
140
+ assert.equal(turns.length, 1); // still exactly the migrated row, not duplicated
141
+ closeTurnStore(dir);
142
+ });
143
+
144
+ test("flag OFF → legacy tables untouched, no marker write from hook", () => {
145
+ const dir = stateDir();
146
+ seedLegacyMainDb(dir);
147
+ const original = TurnsConfig.TURNS_DB_ENABLED;
148
+ TurnsConfig.TURNS_DB_ENABLED = false;
149
+ try {
150
+ const db = openTurnStore(dir);
151
+ closeTurnStore(dir);
152
+ void db;
153
+ } finally {
154
+ TurnsConfig.TURNS_DB_ENABLED = original;
155
+ }
156
+ // Main db turn tables must be intact (migration hook was gated off).
157
+ const main = new DatabaseSync(join(dir, "sqlite.db"));
158
+ assert.ok(tableExists(main, "turns"));
159
+ assert.ok(tableExists(main, "conversation_branches"));
160
+ main.close();
161
+ });