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.
- package/README.md +3 -2
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +95 -47
- package/dist/extensions/dashboard-server/api-contracts/index.js +1 -1
- package/dist/extensions/dashboard-server/api-contracts/turns.js +10 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
- package/dist/extensions/dashboard-server/routes-topics.js +87 -0
- package/dist/extensions/dashboard-server/routes-turns.js +264 -0
- package/dist/extensions/dashboard-server/routes.js +3 -1
- package/dist/extensions/dashboard-server/server.js +6 -2
- package/dist/extensions/mega-compact.js +10 -6
- package/dist/extensions/mega-config.js +4 -0
- package/dist/extensions/mega-events/agent-handlers.js +62 -49
- package/dist/extensions/mega-events/context-handler.js +48 -2
- package/dist/extensions/mega-events/session-handlers.js +25 -1
- package/dist/extensions/mega-metrics-cmds.js +90 -0
- package/dist/extensions/mega-pipeline/recall.js +47 -17
- package/dist/extensions/mega-topics-cmds.js +111 -0
- package/dist/extensions/mega-turn-store.js +110 -0
- package/dist/extensions/mega-turn-store.test.js +68 -0
- package/dist/src/config/turns.js +48 -0
- package/dist/src/fork.js +33 -0
- package/dist/src/fork.test.js +81 -0
- package/dist/src/intent.js +51 -0
- package/dist/src/intent.test.js +78 -0
- package/dist/src/metrics/index.js +4 -0
- package/dist/src/metrics/turns.js +118 -0
- package/dist/src/metrics/turns.test.js +152 -0
- package/dist/src/store/sqlite/raw-transcript.js +5 -3
- package/dist/src/store/sqlite.dbmirror.test.js +9 -0
- package/dist/src/store/turns/connection.js +123 -0
- package/dist/src/store/turns/contract-compliance.test.js +428 -0
- package/dist/src/store/turns/index.js +31 -0
- package/dist/src/store/turns/memory-store.js +357 -0
- package/dist/src/store/turns/memory-store.test.js +9 -0
- package/dist/src/store/turns/migrations.js +178 -0
- package/dist/src/store/turns/migrations.test.js +138 -0
- package/dist/src/store/turns/schema.js +115 -0
- package/dist/src/store/turns/sqlite-store.js +455 -0
- package/dist/src/store/turns/sqlite-store.test.js +144 -0
- package/dist/src/store/turns/types.js +16 -0
- package/dist/src/topics/cluster.js +141 -0
- package/dist/src/topics/cluster.test.js +150 -0
- package/dist/src/topics/index.js +6 -0
- package/dist/src/topics/kselection.js +142 -0
- package/dist/src/topics/labels.js +56 -0
- package/dist/src/topics/store.js +119 -0
- package/dist/src/topics/store.test.js +117 -0
- package/dist/src/topics/types.js +8 -0
- package/dist/src/wiki.js +133 -0
- package/dist/src/wiki.test.js +171 -0
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js → AchievementTiles-DZp_xQOW.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DZp_xQOW.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-BzFHpAHM.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-BzFHpAHM.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DEQbTq2T.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DEQbTq2T.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-DQ4FG33J.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-DQ4FG33J.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-D5HoEVYo.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-D5HoEVYo.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Jzsc4yeY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Jzsc4yeY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-D_XnsEYX.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-D_XnsEYX.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-BMREvAth.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-BMREvAth.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DuyhOktG.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DuyhOktG.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-mz_vz8i2.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-mz_vz8i2.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-B8AIjNTY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-B8AIjNTY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/TopicsTab-DKuct4oD.js +2 -0
- package/extensions/dashboard-client/dist/assets/TopicsTab-DKuct4oD.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-11MGLxY8.js +2 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-11MGLxY8.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-CqLN7STl.js} +10 -10
- package/extensions/dashboard-client/dist/assets/index-CqLN7STl.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-og3ghxyY.css → index-gn66lYD2.css} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js → useSSE-CXrQSbVY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-CXrQSbVY.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/src/App.tsx +10 -2
- package/extensions/dashboard-client/src/api/client.ts +90 -1
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/turns.css +142 -0
- package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +186 -0
- package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +336 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +394 -291
- package/extensions/dashboard-server/api-contracts/game-types.ts +141 -88
- package/extensions/dashboard-server/api-contracts/index.ts +153 -133
- package/extensions/dashboard-server/api-contracts/turns.ts +114 -0
- package/extensions/dashboard-server/api-contracts.test.ts +920 -919
- package/extensions/dashboard-server/routes-topics.ts +119 -0
- package/extensions/dashboard-server/routes-turns.ts +305 -0
- package/extensions/dashboard-server/routes.ts +8 -1
- package/extensions/dashboard-server/server.ts +6 -2
- package/extensions/mega-compact.ts +75 -59
- package/extensions/mega-config.ts +232 -220
- package/extensions/mega-events/agent-handlers.ts +194 -151
- package/extensions/mega-events/context-handler.ts +50 -1
- package/extensions/mega-events/session-handlers.ts +26 -5
- package/extensions/mega-metrics-cmds.ts +114 -0
- package/extensions/mega-pipeline/recall.ts +215 -152
- package/extensions/mega-topics-cmds.ts +151 -0
- package/extensions/mega-turn-store.test.ts +108 -0
- package/extensions/mega-turn-store.ts +169 -0
- package/package.json +3 -1
- package/src/config/turns.ts +69 -0
- package/src/fork.test.ts +94 -0
- package/src/fork.ts +78 -0
- package/src/intent.test.ts +87 -0
- package/src/intent.ts +115 -0
- package/src/metrics/index.ts +9 -0
- package/src/metrics/turns.test.ts +165 -0
- package/src/metrics/turns.ts +201 -0
- package/src/store/sqlite/raw-transcript.ts +9 -3
- package/src/store/sqlite.dbmirror.test.ts +10 -0
- package/src/store/turns/connection.ts +137 -0
- package/src/store/turns/contract-compliance.test.ts +562 -0
- package/src/store/turns/index.ts +65 -0
- package/src/store/turns/memory-store.test.ts +15 -0
- package/src/store/turns/memory-store.ts +469 -0
- package/src/store/turns/migrations.test.ts +161 -0
- package/src/store/turns/migrations.ts +203 -0
- package/src/store/turns/schema.ts +158 -0
- package/src/store/turns/sqlite-store.test.ts +161 -0
- package/src/store/turns/sqlite-store.ts +656 -0
- package/src/store/turns/types.ts +181 -0
- package/src/topics/cluster.test.ts +176 -0
- package/src/topics/cluster.ts +191 -0
- package/src/topics/index.ts +18 -0
- package/src/topics/kselection.ts +182 -0
- package/src/topics/labels.ts +67 -0
- package/src/topics/store.test.ts +132 -0
- package/src/topics/store.ts +208 -0
- package/src/topics/types.ts +64 -0
- package/src/wiki.test.ts +217 -0
- package/src/wiki.ts +198 -0
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
- 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
|
+
});
|