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.
- 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 +251 -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 +24 -0
- 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-DUulvXUY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DUulvXUY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-Dx0YtCNf.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-Dx0YtCNf.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DtkaUo1v.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DtkaUo1v.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-CLa4KfiZ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-CLa4KfiZ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-Q6tEIBBi.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-Q6tEIBBi.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Hq7GNWSY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Hq7GNWSY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-BtmBV6lU.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-BtmBV6lU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-CL10PwVU.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-CL10PwVU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DAwANwbI.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DAwANwbI.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-Ec08TZRQ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-Ec08TZRQ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-COb8dpTy.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-COb8dpTy.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js +2 -0
- package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js +2 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-UMrussFX.js} +10 -10
- package/extensions/dashboard-client/dist/assets/index-UMrussFX.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-BDHZ7CeS.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-BDHZ7CeS.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 +84 -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 +185 -0
- package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +326 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +389 -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 +282 -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 +23 -0
- 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,203 @@
|
|
|
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 type { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { withTx } from "./connection.js";
|
|
23
|
+
|
|
24
|
+
/** Main memory DB filename (must match src/store/sqlite/utils.ts). */
|
|
25
|
+
const MAIN_DB_FILE = "sqlite.db";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Legacy tables dropped from the main db after a successful copy. The
|
|
29
|
+
* DROPs below use these as literal identifiers (no interpolation) so there
|
|
30
|
+
* is no injection surface.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const MIGRATED_KEY = "migrated_from_main";
|
|
34
|
+
|
|
35
|
+
function isMigrated(db: DatabaseSync): boolean {
|
|
36
|
+
const row = db
|
|
37
|
+
.prepare("SELECT value FROM turns_meta WHERE key = ?")
|
|
38
|
+
.get(MIGRATED_KEY) as { value: string } | undefined;
|
|
39
|
+
return row?.value === "1";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function markMigrated(db: DatabaseSync): void {
|
|
43
|
+
db.prepare(
|
|
44
|
+
"INSERT OR REPLACE INTO turns_meta (key, value) VALUES (?, '1')",
|
|
45
|
+
).run(MIGRATED_KEY);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Map a legacy recall `source` value onto the contract enum
|
|
50
|
+
* (`checkpoint` | `cluster_summary` | `memory`). Legacy free-text values:
|
|
51
|
+
* - `flat` / `checkpoint` → `checkpoint`
|
|
52
|
+
* - `raptor` / `cluster_summary` → `cluster_summary`
|
|
53
|
+
* - `cross-repo` / `memory` / * → `memory`
|
|
54
|
+
*/
|
|
55
|
+
function mapSource(legacy: string): string {
|
|
56
|
+
switch (legacy) {
|
|
57
|
+
case "flat":
|
|
58
|
+
case "checkpoint":
|
|
59
|
+
return "checkpoint";
|
|
60
|
+
case "raptor":
|
|
61
|
+
case "cluster_summary":
|
|
62
|
+
return "cluster_summary";
|
|
63
|
+
default:
|
|
64
|
+
return "memory";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Move legacy turn tables out of the main db into turns.db, once. Transforms
|
|
70
|
+
* rows to the unified contract schema (column renames + enum coercion + NULL
|
|
71
|
+
* backfill). Idempotent + non-fatal.
|
|
72
|
+
*
|
|
73
|
+
* @param turnDb the open isolated turns.db handle
|
|
74
|
+
* @param stateDir the state dir containing sqlite.db
|
|
75
|
+
* @param log optional logger (defaults to console.warn on failure)
|
|
76
|
+
*/
|
|
77
|
+
export function migrateTurnTablesIfNeeded(
|
|
78
|
+
turnDb: DatabaseSync,
|
|
79
|
+
stateDir: string,
|
|
80
|
+
log: (msg: string) => void = (m) => console.warn(`[turns-migrate] ${m}`),
|
|
81
|
+
): void {
|
|
82
|
+
if (isMigrated(turnDb)) return; // already done — idempotent no-op.
|
|
83
|
+
|
|
84
|
+
const mainPath = join(stateDir, MAIN_DB_FILE);
|
|
85
|
+
if (!existsSync(mainPath)) {
|
|
86
|
+
// No legacy main db (fresh install / test dir) — nothing to move.
|
|
87
|
+
markMigrated(turnDb);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
turnDb.prepare("ATTACH DATABASE ? AS legacy").run(mainPath);
|
|
93
|
+
try {
|
|
94
|
+
const hasTurns = turnDb
|
|
95
|
+
.prepare(
|
|
96
|
+
"SELECT name FROM legacy.sqlite_master WHERE type = 'table' AND name = 'turns'",
|
|
97
|
+
)
|
|
98
|
+
.get() as { name: string } | undefined;
|
|
99
|
+
if (!hasTurns) {
|
|
100
|
+
// Main db predates S48 turn tables — nothing to move.
|
|
101
|
+
markMigrated(turnDb);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
withTx(turnDb, () => {
|
|
106
|
+
// turns: rename model_id→model, coerce role/ended_at non-null, clamp
|
|
107
|
+
// pressure_band to the enum. Idempotent on (conversation_id, turn_index).
|
|
108
|
+
turnDb.exec(`
|
|
109
|
+
INSERT OR IGNORE INTO main.turns
|
|
110
|
+
(id, conversation_id, session_id, turn_index, role, started_at, ended_at,
|
|
111
|
+
ctx_tokens, ctx_percent, pressure_band, model, epoch_id)
|
|
112
|
+
SELECT
|
|
113
|
+
id, conversation_id, session_id, turn_index,
|
|
114
|
+
COALESCE(NULLIF(role, ''), 'user') AS role,
|
|
115
|
+
started_at,
|
|
116
|
+
COALESCE(ended_at, started_at, 0) AS ended_at,
|
|
117
|
+
ctx_tokens, ctx_percent,
|
|
118
|
+
CASE pressure_band
|
|
119
|
+
WHEN 'green' THEN 'green'
|
|
120
|
+
WHEN 'yellow' THEN 'yellow'
|
|
121
|
+
WHEN 'red' THEN 'red'
|
|
122
|
+
ELSE NULL
|
|
123
|
+
END AS pressure_band,
|
|
124
|
+
model_id AS model,
|
|
125
|
+
epoch_id
|
|
126
|
+
FROM legacy.turns
|
|
127
|
+
`);
|
|
128
|
+
|
|
129
|
+
// turn_recall: coerce source onto the contract enum. Idempotent on
|
|
130
|
+
// (turn_id, checkpoint_id).
|
|
131
|
+
turnDb.exec(`
|
|
132
|
+
INSERT OR IGNORE INTO main.turn_recall
|
|
133
|
+
(id, turn_id, checkpoint_id, score, source, raptor_level)
|
|
134
|
+
SELECT
|
|
135
|
+
id, turn_id, checkpoint_id, score,
|
|
136
|
+
CASE source
|
|
137
|
+
WHEN 'flat' THEN 'checkpoint'
|
|
138
|
+
WHEN 'checkpoint' THEN 'checkpoint'
|
|
139
|
+
WHEN 'raptor' THEN 'cluster_summary'
|
|
140
|
+
WHEN 'cluster_summary' THEN 'cluster_summary'
|
|
141
|
+
ELSE 'memory'
|
|
142
|
+
END AS source,
|
|
143
|
+
raptor_level
|
|
144
|
+
FROM legacy.turn_recall
|
|
145
|
+
`);
|
|
146
|
+
|
|
147
|
+
// conversation_branches → conversation_forks (rename + column map).
|
|
148
|
+
// Legacy: (conversation_id PK, parent_conversation_id, fork_turn_id, created_at).
|
|
149
|
+
// Contract: (id, parent_conversation_id, child_conversation_id, fork_turn_index, created_at).
|
|
150
|
+
turnDb.exec(`
|
|
151
|
+
INSERT OR IGNORE INTO main.conversation_forks
|
|
152
|
+
(parent_conversation_id, child_conversation_id, fork_turn_index, created_at)
|
|
153
|
+
SELECT
|
|
154
|
+
parent_conversation_id,
|
|
155
|
+
conversation_id AS child_conversation_id,
|
|
156
|
+
fork_turn_id AS fork_turn_index,
|
|
157
|
+
created_at
|
|
158
|
+
FROM legacy.conversation_branches
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
// session_conversations: hydrate from legacy session_state if it has a
|
|
162
|
+
// conversation_id column (S48 stored the active conversation there).
|
|
163
|
+
const hasSessionState = turnDb
|
|
164
|
+
.prepare(
|
|
165
|
+
"SELECT name FROM legacy.sqlite_master WHERE type = 'table' AND name = 'session_state'",
|
|
166
|
+
)
|
|
167
|
+
.get() as { name: string } | undefined;
|
|
168
|
+
if (hasSessionState) {
|
|
169
|
+
const cols = turnDb
|
|
170
|
+
.prepare("PRAGMA legacy.table_info(session_state)")
|
|
171
|
+
.all() as Array<{ name: string }>;
|
|
172
|
+
if (cols.some((c) => c.name === "conversation_id")) {
|
|
173
|
+
turnDb.exec(`
|
|
174
|
+
INSERT OR IGNORE INTO main.session_conversations (session_id, conversation_id)
|
|
175
|
+
SELECT session_id, conversation_id
|
|
176
|
+
FROM legacy.session_state
|
|
177
|
+
WHERE conversation_id IS NOT NULL
|
|
178
|
+
`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Drop legacy tables from the main db only after a successful copy.
|
|
183
|
+
// These are literal identifiers from the fixed LEGACY_TABLES allowlist
|
|
184
|
+
// (not user input) — SQLite cannot parameterize DDL identifiers, so a
|
|
185
|
+
// literal per table is the provably-injection-free form.
|
|
186
|
+
turnDb.exec("DROP TABLE IF EXISTS legacy.turns");
|
|
187
|
+
turnDb.exec("DROP TABLE IF EXISTS legacy.turn_recall");
|
|
188
|
+
turnDb.exec("DROP TABLE IF EXISTS legacy.conversation_branches");
|
|
189
|
+
markMigrated(turnDb);
|
|
190
|
+
});
|
|
191
|
+
} finally {
|
|
192
|
+
turnDb.exec("DETACH DATABASE legacy");
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
// Non-fatal: leave legacy tables intact so nothing is lost; retry next open.
|
|
196
|
+
log(
|
|
197
|
+
`migration failed (will retry on next open): ${e instanceof Error ? e.message : String(e)}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Exported for tests that need to inspect the source mapping.
|
|
203
|
+
export { mapSource };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.ts — S49 unified turn-store schema (isolated turns.db).
|
|
3
|
+
*
|
|
4
|
+
* RECONCILIATION (s49-turn-db ∪ master): the contract-first shapes from
|
|
5
|
+
* master's SqliteTurnStore (`turns` / `turn_recall` / `conversation_forks` /
|
|
6
|
+
* `session_conversations` / `turns_meta`, with the strict enums the contract
|
|
7
|
+
* `TurnEntry`/`TurnRecallEntry` requires) PLUS the additive shells from this
|
|
8
|
+
* branch's program: `turns.epoch_id` (S50B epoch linking), `pending_fork`
|
|
9
|
+
* (S52 rewind handshake), and `topics` / `memory_topics` (S51 wiki). No other
|
|
10
|
+
* sprint needs a new migration — everything is pre-created here.
|
|
11
|
+
*
|
|
12
|
+
* Idempotent (all `IF NOT EXISTS`). Single statement per `exec` so a failure
|
|
13
|
+
* names the exact table. Pi-agnostic (PREVENT-PI-004: pure node:sqlite).
|
|
14
|
+
* PREVENT-002: DDL only; all runtime queries are parameterized elsewhere.
|
|
15
|
+
*/
|
|
16
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
17
|
+
|
|
18
|
+
/** Schema version stamp (written once into turns_meta). */
|
|
19
|
+
const SCHEMA_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
/** Create the unified turn-store schema if absent. Idempotent. */
|
|
22
|
+
export function initTurnSchema(db: DatabaseSync): void {
|
|
23
|
+
// ── Contract tables (master's SqliteTurnStore shape) ──────────────────
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS turns_meta (
|
|
26
|
+
key TEXT PRIMARY KEY,
|
|
27
|
+
value TEXT NOT NULL
|
|
28
|
+
)
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
// One row per turn_end. role/source/pressure_band use the contract enums.
|
|
32
|
+
// epoch_id is the S50B additive column (links a turn to the compact epoch).
|
|
33
|
+
db.exec(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS turns (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
conversation_id TEXT NOT NULL,
|
|
37
|
+
session_id TEXT NOT NULL,
|
|
38
|
+
turn_index INTEGER NOT NULL,
|
|
39
|
+
role TEXT NOT NULL CHECK(role IN ('user','assistant','system','tool')),
|
|
40
|
+
started_at INTEGER,
|
|
41
|
+
ended_at INTEGER NOT NULL,
|
|
42
|
+
ctx_tokens INTEGER,
|
|
43
|
+
ctx_percent REAL,
|
|
44
|
+
pressure_band TEXT CHECK(pressure_band IS NULL OR pressure_band IN ('green','yellow','red')),
|
|
45
|
+
model TEXT,
|
|
46
|
+
epoch_id TEXT,
|
|
47
|
+
UNIQUE(conversation_id, turn_index)
|
|
48
|
+
)
|
|
49
|
+
`);
|
|
50
|
+
db.exec(
|
|
51
|
+
`CREATE INDEX IF NOT EXISTS idx_turns_conversation ON turns(conversation_id, turn_index)`,
|
|
52
|
+
);
|
|
53
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id)`);
|
|
54
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_turns_ended_at ON turns(ended_at)`);
|
|
55
|
+
db.exec(
|
|
56
|
+
`CREATE INDEX IF NOT EXISTS idx_turns_pressure ON turns(pressure_band) WHERE pressure_band IS NOT NULL`,
|
|
57
|
+
);
|
|
58
|
+
db.exec(
|
|
59
|
+
`CREATE INDEX IF NOT EXISTS idx_turns_epoch ON turns(epoch_id) WHERE epoch_id IS NOT NULL`,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Recall provenance — which checkpoints / cluster summaries were injected
|
|
63
|
+
// at a turn, with score + source path. source uses the contract enum.
|
|
64
|
+
db.exec(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS turn_recall (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
turn_id INTEGER NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
|
|
68
|
+
checkpoint_id TEXT NOT NULL,
|
|
69
|
+
score REAL NOT NULL,
|
|
70
|
+
source TEXT NOT NULL CHECK(source IN ('checkpoint','cluster_summary','memory')),
|
|
71
|
+
raptor_level INTEGER,
|
|
72
|
+
UNIQUE(turn_id, checkpoint_id)
|
|
73
|
+
)
|
|
74
|
+
`);
|
|
75
|
+
db.exec(
|
|
76
|
+
`CREATE INDEX IF NOT EXISTS idx_turn_recall_turn ON turn_recall(turn_id)`,
|
|
77
|
+
);
|
|
78
|
+
db.exec(
|
|
79
|
+
`CREATE INDEX IF NOT EXISTS idx_turn_recall_cp ON turn_recall(checkpoint_id)`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Conversation fork registry (contract: ConversationFork).
|
|
83
|
+
db.exec(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS conversation_forks (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
parent_conversation_id TEXT NOT NULL,
|
|
87
|
+
child_conversation_id TEXT NOT NULL,
|
|
88
|
+
fork_turn_index INTEGER NOT NULL,
|
|
89
|
+
created_at INTEGER NOT NULL,
|
|
90
|
+
UNIQUE(child_conversation_id)
|
|
91
|
+
)
|
|
92
|
+
`);
|
|
93
|
+
db.exec(
|
|
94
|
+
`CREATE INDEX IF NOT EXISTS idx_forks_parent ON conversation_forks(parent_conversation_id)`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Session → active conversation map (contract: one current conversation per session).
|
|
98
|
+
db.exec(`
|
|
99
|
+
CREATE TABLE IF NOT EXISTS session_conversations (
|
|
100
|
+
session_id TEXT PRIMARY KEY,
|
|
101
|
+
conversation_id TEXT NOT NULL
|
|
102
|
+
)
|
|
103
|
+
`);
|
|
104
|
+
|
|
105
|
+
// ── Program additive shells (this branch) ───────────────────────────
|
|
106
|
+
// S52: rewind-and-fork intents written by an external surface (dashboard)
|
|
107
|
+
// and consumed by the host at a safe lifecycle point. Pre-created → S52
|
|
108
|
+
// adds no migration.
|
|
109
|
+
db.exec(`
|
|
110
|
+
CREATE TABLE IF NOT EXISTS pending_fork (
|
|
111
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
112
|
+
target_conversation_id TEXT NOT NULL,
|
|
113
|
+
target_turn_id INTEGER NOT NULL,
|
|
114
|
+
requested_at INTEGER NOT NULL,
|
|
115
|
+
consumed_at INTEGER
|
|
116
|
+
)
|
|
117
|
+
`);
|
|
118
|
+
db.exec(
|
|
119
|
+
`CREATE INDEX IF NOT EXISTS idx_pending_fork_unconsumed ON pending_fork(consumed_at) WHERE consumed_at IS NULL`,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// S51: auto-categorizing wiki topics (k-means over real embeddings + TF-IDF
|
|
123
|
+
// labels — see docs/specs/s47-auto-categorizing-wiki.md). Pre-created → S51
|
|
124
|
+
// adds no migration. Derived at rebuild time; no seed data.
|
|
125
|
+
db.exec(`
|
|
126
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
127
|
+
id TEXT PRIMARY KEY,
|
|
128
|
+
label TEXT NOT NULL,
|
|
129
|
+
term_scores TEXT,
|
|
130
|
+
memory_count INTEGER DEFAULT 0,
|
|
131
|
+
last_updated INTEGER,
|
|
132
|
+
cluster_model_built_at INTEGER
|
|
133
|
+
)
|
|
134
|
+
`);
|
|
135
|
+
db.exec(`
|
|
136
|
+
CREATE TABLE IF NOT EXISTS memory_topics (
|
|
137
|
+
memory_id TEXT NOT NULL,
|
|
138
|
+
topic_id TEXT NOT NULL REFERENCES topics(id),
|
|
139
|
+
confidence REAL,
|
|
140
|
+
assigned_at INTEGER,
|
|
141
|
+
method TEXT CHECK(method IN ('kmeans+tfidf')),
|
|
142
|
+
PRIMARY KEY (memory_id, topic_id)
|
|
143
|
+
)
|
|
144
|
+
`);
|
|
145
|
+
db.exec(
|
|
146
|
+
`CREATE INDEX IF NOT EXISTS idx_memory_topics_topic ON memory_topics(topic_id)`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Stamp schema version once.
|
|
150
|
+
const existing = db
|
|
151
|
+
.prepare("SELECT value FROM turns_meta WHERE key = 'schema_version'")
|
|
152
|
+
.get() as { value: string } | undefined;
|
|
153
|
+
if (!existing) {
|
|
154
|
+
db.prepare(
|
|
155
|
+
"INSERT INTO turns_meta (key, value) VALUES ('schema_version', ?)",
|
|
156
|
+
).run(String(SCHEMA_VERSION));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sqlite-store.test.ts — SqliteTurnStore compliance + SQLite-specific tests.
|
|
3
|
+
*
|
|
4
|
+
* Compliance: shared suite run in-memory (fast).
|
|
5
|
+
* SQLite-specific: file-backed tests verifying:
|
|
6
|
+
* - Separate turns.db (not sqlite.db)
|
|
7
|
+
* - WAL mode active
|
|
8
|
+
* - Connection caching (same path → same connection)
|
|
9
|
+
* - dbSizeBytes reports file size
|
|
10
|
+
* - File persistence across store instances
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { existsSync, statSync, rmSync, mkdtempSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { SqliteTurnStore } from "./sqlite-store.js";
|
|
19
|
+
import { runComplianceSuite } from "./contract-compliance.test.js";
|
|
20
|
+
import { closeAllTurnDbs } from "./connection.js";
|
|
21
|
+
import type { TurnEntry } from "./types.js";
|
|
22
|
+
|
|
23
|
+
// ── Compliance suite (in-memory, fast) ──────────────────────────
|
|
24
|
+
|
|
25
|
+
runComplianceSuite(
|
|
26
|
+
"SqliteTurnStore",
|
|
27
|
+
(options) => new SqliteTurnStore(options),
|
|
28
|
+
{ stateDir: join(tmpdir(), "turns-compliance-sqlite"), inMemory: true },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// ── SQLite-specific file-backed tests ───────────────────────────
|
|
32
|
+
|
|
33
|
+
describe("SqliteTurnStore — file-backed", () => {
|
|
34
|
+
let stateDir: string;
|
|
35
|
+
let store: SqliteTurnStore;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
closeAllTurnDbs(); // clear any cached connections
|
|
39
|
+
stateDir = mkdtempSync(join(tmpdir(), "turns-file-"));
|
|
40
|
+
store = new SqliteTurnStore({ stateDir });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
try {
|
|
45
|
+
store.close();
|
|
46
|
+
} catch {
|
|
47
|
+
// best-effort
|
|
48
|
+
}
|
|
49
|
+
closeAllTurnDbs();
|
|
50
|
+
try {
|
|
51
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
52
|
+
} catch {
|
|
53
|
+
// best-effort
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("creates turns.db separate from sqlite.db", () => {
|
|
58
|
+
const turnsDbPath = join(stateDir, "turns.db");
|
|
59
|
+
assert.ok(existsSync(turnsDbPath), "turns.db should exist");
|
|
60
|
+
|
|
61
|
+
// sqlite.db should NOT be created by the turns store
|
|
62
|
+
const sqliteDbPath = join(stateDir, "sqlite.db");
|
|
63
|
+
assert.ok(!existsSync(sqliteDbPath), "sqlite.db should NOT be created");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("uses WAL journal mode", () => {
|
|
67
|
+
const row = (store as unknown as { db: { prepare: (s: string) => { get: () => unknown } } })
|
|
68
|
+
.db.prepare("PRAGMA journal_mode")
|
|
69
|
+
.get() as { journal_mode: string };
|
|
70
|
+
assert.equal(row.journal_mode, "wal");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("caches connections for the same path", () => {
|
|
74
|
+
// Creating a second store with the same stateDir should reuse
|
|
75
|
+
// the cached connection (not create a new file lock conflict)
|
|
76
|
+
const store2 = new SqliteTurnStore({ stateDir });
|
|
77
|
+
try {
|
|
78
|
+
// Both should be able to write without locking errors
|
|
79
|
+
const entry: TurnEntry = {
|
|
80
|
+
conversationId: "conv_cache",
|
|
81
|
+
sessionId: "sess_cache",
|
|
82
|
+
turnIndex: 0,
|
|
83
|
+
role: "assistant",
|
|
84
|
+
endedAt: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
store.asWriter().appendTurn(entry);
|
|
87
|
+
store2.asWriter().appendTurn({
|
|
88
|
+
...entry,
|
|
89
|
+
turnIndex: 1,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.equal(store.asReader().countTurns("conv_cache"), 2);
|
|
93
|
+
} finally {
|
|
94
|
+
store2.close();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reports dbSizeBytes for file-backed store", () => {
|
|
99
|
+
const entry: TurnEntry = {
|
|
100
|
+
conversationId: "conv_size",
|
|
101
|
+
sessionId: "sess_size",
|
|
102
|
+
turnIndex: 0,
|
|
103
|
+
role: "assistant",
|
|
104
|
+
endedAt: Date.now(),
|
|
105
|
+
};
|
|
106
|
+
store.asWriter().appendTurn(entry);
|
|
107
|
+
|
|
108
|
+
// prune returns a PruneReport with freedBytes; we can also
|
|
109
|
+
// verify the file exists and has content
|
|
110
|
+
const turnsDbPath = join(stateDir, "turns.db");
|
|
111
|
+
const size = statSync(turnsDbPath).size;
|
|
112
|
+
assert.ok(size > 0, "turns.db should have non-zero size after writes");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("persists data across store instances", () => {
|
|
116
|
+
const entry: TurnEntry = {
|
|
117
|
+
conversationId: "conv_persist",
|
|
118
|
+
sessionId: "sess_persist",
|
|
119
|
+
turnIndex: 0,
|
|
120
|
+
role: "assistant",
|
|
121
|
+
endedAt: Date.now(),
|
|
122
|
+
model: "test-model",
|
|
123
|
+
};
|
|
124
|
+
store.asWriter().appendTurn(entry);
|
|
125
|
+
store.close();
|
|
126
|
+
closeAllTurnDbs();
|
|
127
|
+
|
|
128
|
+
// Re-open the same stateDir
|
|
129
|
+
const store2 = new SqliteTurnStore({ stateDir });
|
|
130
|
+
try {
|
|
131
|
+
const turns = store2.asReader().query({ conversationId: "conv_persist" });
|
|
132
|
+
assert.equal(turns.length, 1);
|
|
133
|
+
assert.equal(turns[0].model, "test-model");
|
|
134
|
+
} finally {
|
|
135
|
+
store2.close();
|
|
136
|
+
closeAllTurnDbs();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("supports custom dbPath", () => {
|
|
141
|
+
const customPath = join(stateDir, "custom-turns.db");
|
|
142
|
+
const customStore = new SqliteTurnStore({
|
|
143
|
+
stateDir,
|
|
144
|
+
dbPath: customPath,
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
assert.ok(existsSync(customPath), "custom db path should be created");
|
|
148
|
+
customStore.asWriter().appendTurn({
|
|
149
|
+
conversationId: "conv_custom",
|
|
150
|
+
sessionId: "sess_custom",
|
|
151
|
+
turnIndex: 0,
|
|
152
|
+
role: "user",
|
|
153
|
+
endedAt: Date.now(),
|
|
154
|
+
});
|
|
155
|
+
assert.equal(customStore.asReader().countTurns("conv_custom"), 1);
|
|
156
|
+
} finally {
|
|
157
|
+
customStore.close();
|
|
158
|
+
closeAllTurnDbs();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|