pi-mega-compact 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +3 -2
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +95 -47
  3. package/dist/extensions/dashboard-server/api-contracts/index.js +1 -1
  4. package/dist/extensions/dashboard-server/api-contracts/turns.js +10 -0
  5. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  6. package/dist/extensions/dashboard-server/routes-topics.js +87 -0
  7. package/dist/extensions/dashboard-server/routes-turns.js +251 -0
  8. package/dist/extensions/dashboard-server/routes.js +3 -1
  9. package/dist/extensions/dashboard-server/server.js +6 -2
  10. package/dist/extensions/mega-compact.js +10 -6
  11. package/dist/extensions/mega-config.js +4 -0
  12. package/dist/extensions/mega-events/agent-handlers.js +62 -49
  13. package/dist/extensions/mega-events/context-handler.js +48 -2
  14. package/dist/extensions/mega-events/session-handlers.js +24 -0
  15. package/dist/extensions/mega-metrics-cmds.js +90 -0
  16. package/dist/extensions/mega-pipeline/recall.js +47 -17
  17. package/dist/extensions/mega-topics-cmds.js +111 -0
  18. package/dist/extensions/mega-turn-store.js +110 -0
  19. package/dist/extensions/mega-turn-store.test.js +68 -0
  20. package/dist/src/config/turns.js +48 -0
  21. package/dist/src/fork.js +33 -0
  22. package/dist/src/fork.test.js +81 -0
  23. package/dist/src/intent.js +51 -0
  24. package/dist/src/intent.test.js +78 -0
  25. package/dist/src/metrics/index.js +4 -0
  26. package/dist/src/metrics/turns.js +118 -0
  27. package/dist/src/metrics/turns.test.js +152 -0
  28. package/dist/src/store/sqlite/raw-transcript.js +5 -3
  29. package/dist/src/store/sqlite.dbmirror.test.js +9 -0
  30. package/dist/src/store/turns/connection.js +123 -0
  31. package/dist/src/store/turns/contract-compliance.test.js +428 -0
  32. package/dist/src/store/turns/index.js +31 -0
  33. package/dist/src/store/turns/memory-store.js +357 -0
  34. package/dist/src/store/turns/memory-store.test.js +9 -0
  35. package/dist/src/store/turns/migrations.js +178 -0
  36. package/dist/src/store/turns/migrations.test.js +138 -0
  37. package/dist/src/store/turns/schema.js +115 -0
  38. package/dist/src/store/turns/sqlite-store.js +455 -0
  39. package/dist/src/store/turns/sqlite-store.test.js +144 -0
  40. package/dist/src/store/turns/types.js +16 -0
  41. package/dist/src/topics/cluster.js +141 -0
  42. package/dist/src/topics/cluster.test.js +150 -0
  43. package/dist/src/topics/index.js +6 -0
  44. package/dist/src/topics/kselection.js +142 -0
  45. package/dist/src/topics/labels.js +56 -0
  46. package/dist/src/topics/store.js +119 -0
  47. package/dist/src/topics/store.test.js +117 -0
  48. package/dist/src/topics/types.js +8 -0
  49. package/dist/src/wiki.js +133 -0
  50. package/dist/src/wiki.test.js +171 -0
  51. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js → AchievementTiles-DUulvXUY.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DUulvXUY.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-Dx0YtCNf.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-Dx0YtCNf.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DtkaUo1v.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DtkaUo1v.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-CLa4KfiZ.js} +2 -2
  58. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-CLa4KfiZ.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-Q6tEIBBi.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-Q6tEIBBi.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Hq7GNWSY.js} +2 -2
  62. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Hq7GNWSY.js.map} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-BtmBV6lU.js} +2 -2
  64. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-BtmBV6lU.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-CL10PwVU.js} +2 -2
  66. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-CL10PwVU.js.map} +1 -1
  67. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DAwANwbI.js} +2 -2
  68. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DAwANwbI.js.map} +1 -1
  69. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-Ec08TZRQ.js} +2 -2
  70. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-Ec08TZRQ.js.map} +1 -1
  71. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-COb8dpTy.js} +2 -2
  72. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-COb8dpTy.js.map} +1 -1
  73. package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js +2 -0
  74. package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js.map +1 -0
  75. package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js +2 -0
  76. package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js.map +1 -0
  77. package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-UMrussFX.js} +10 -10
  78. package/extensions/dashboard-client/dist/assets/index-UMrussFX.js.map +1 -0
  79. package/extensions/dashboard-client/dist/assets/{index-og3ghxyY.css → index-gn66lYD2.css} +1 -1
  80. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js → useSSE-BDHZ7CeS.js} +2 -2
  81. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-BDHZ7CeS.js.map} +1 -1
  82. package/extensions/dashboard-client/dist/index.html +2 -2
  83. package/extensions/dashboard-client/src/App.tsx +10 -2
  84. package/extensions/dashboard-client/src/api/client.ts +84 -1
  85. package/extensions/dashboard-client/src/main.tsx +1 -0
  86. package/extensions/dashboard-client/src/styles/turns.css +142 -0
  87. package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +185 -0
  88. package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +326 -0
  89. package/extensions/dashboard-server/api-contracts/endpoints.ts +389 -291
  90. package/extensions/dashboard-server/api-contracts/game-types.ts +141 -88
  91. package/extensions/dashboard-server/api-contracts/index.ts +153 -133
  92. package/extensions/dashboard-server/api-contracts/turns.ts +114 -0
  93. package/extensions/dashboard-server/api-contracts.test.ts +920 -919
  94. package/extensions/dashboard-server/routes-topics.ts +119 -0
  95. package/extensions/dashboard-server/routes-turns.ts +282 -0
  96. package/extensions/dashboard-server/routes.ts +8 -1
  97. package/extensions/dashboard-server/server.ts +6 -2
  98. package/extensions/mega-compact.ts +75 -59
  99. package/extensions/mega-config.ts +232 -220
  100. package/extensions/mega-events/agent-handlers.ts +194 -151
  101. package/extensions/mega-events/context-handler.ts +50 -1
  102. package/extensions/mega-events/session-handlers.ts +23 -0
  103. package/extensions/mega-metrics-cmds.ts +114 -0
  104. package/extensions/mega-pipeline/recall.ts +215 -152
  105. package/extensions/mega-topics-cmds.ts +151 -0
  106. package/extensions/mega-turn-store.test.ts +108 -0
  107. package/extensions/mega-turn-store.ts +169 -0
  108. package/package.json +3 -1
  109. package/src/config/turns.ts +69 -0
  110. package/src/fork.test.ts +94 -0
  111. package/src/fork.ts +78 -0
  112. package/src/intent.test.ts +87 -0
  113. package/src/intent.ts +115 -0
  114. package/src/metrics/index.ts +9 -0
  115. package/src/metrics/turns.test.ts +165 -0
  116. package/src/metrics/turns.ts +201 -0
  117. package/src/store/sqlite/raw-transcript.ts +9 -3
  118. package/src/store/sqlite.dbmirror.test.ts +10 -0
  119. package/src/store/turns/connection.ts +137 -0
  120. package/src/store/turns/contract-compliance.test.ts +562 -0
  121. package/src/store/turns/index.ts +65 -0
  122. package/src/store/turns/memory-store.test.ts +15 -0
  123. package/src/store/turns/memory-store.ts +469 -0
  124. package/src/store/turns/migrations.test.ts +161 -0
  125. package/src/store/turns/migrations.ts +203 -0
  126. package/src/store/turns/schema.ts +158 -0
  127. package/src/store/turns/sqlite-store.test.ts +161 -0
  128. package/src/store/turns/sqlite-store.ts +656 -0
  129. package/src/store/turns/types.ts +181 -0
  130. package/src/topics/cluster.test.ts +176 -0
  131. package/src/topics/cluster.ts +191 -0
  132. package/src/topics/index.ts +18 -0
  133. package/src/topics/kselection.ts +182 -0
  134. package/src/topics/labels.ts +67 -0
  135. package/src/topics/store.test.ts +132 -0
  136. package/src/topics/store.ts +208 -0
  137. package/src/topics/types.ts +64 -0
  138. package/src/wiki.test.ts +217 -0
  139. package/src/wiki.ts +198 -0
  140. package/dist/extensions/openclaw-mega-compact.js +0 -291
  141. package/dist/src/minilm.js +0 -92
  142. package/dist/src/wordpiece.js +0 -129
  143. package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +0 -1
@@ -0,0 +1,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
+ });