pi-mega-compact 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +3 -2
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +95 -47
  3. package/dist/extensions/dashboard-server/api-contracts/index.js +1 -1
  4. package/dist/extensions/dashboard-server/api-contracts/turns.js +10 -0
  5. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  6. package/dist/extensions/dashboard-server/routes-topics.js +87 -0
  7. package/dist/extensions/dashboard-server/routes-turns.js +264 -0
  8. package/dist/extensions/dashboard-server/routes.js +3 -1
  9. package/dist/extensions/dashboard-server/server.js +6 -2
  10. package/dist/extensions/mega-compact.js +10 -6
  11. package/dist/extensions/mega-config.js +4 -0
  12. package/dist/extensions/mega-events/agent-handlers.js +62 -49
  13. package/dist/extensions/mega-events/context-handler.js +48 -2
  14. package/dist/extensions/mega-events/session-handlers.js +25 -1
  15. package/dist/extensions/mega-metrics-cmds.js +90 -0
  16. package/dist/extensions/mega-pipeline/recall.js +47 -17
  17. package/dist/extensions/mega-topics-cmds.js +111 -0
  18. package/dist/extensions/mega-turn-store.js +110 -0
  19. package/dist/extensions/mega-turn-store.test.js +68 -0
  20. package/dist/src/config/turns.js +48 -0
  21. package/dist/src/fork.js +33 -0
  22. package/dist/src/fork.test.js +81 -0
  23. package/dist/src/intent.js +51 -0
  24. package/dist/src/intent.test.js +78 -0
  25. package/dist/src/metrics/index.js +4 -0
  26. package/dist/src/metrics/turns.js +118 -0
  27. package/dist/src/metrics/turns.test.js +152 -0
  28. package/dist/src/store/sqlite/raw-transcript.js +5 -3
  29. package/dist/src/store/sqlite.dbmirror.test.js +9 -0
  30. package/dist/src/store/turns/connection.js +123 -0
  31. package/dist/src/store/turns/contract-compliance.test.js +428 -0
  32. package/dist/src/store/turns/index.js +31 -0
  33. package/dist/src/store/turns/memory-store.js +357 -0
  34. package/dist/src/store/turns/memory-store.test.js +9 -0
  35. package/dist/src/store/turns/migrations.js +178 -0
  36. package/dist/src/store/turns/migrations.test.js +138 -0
  37. package/dist/src/store/turns/schema.js +115 -0
  38. package/dist/src/store/turns/sqlite-store.js +455 -0
  39. package/dist/src/store/turns/sqlite-store.test.js +144 -0
  40. package/dist/src/store/turns/types.js +16 -0
  41. package/dist/src/topics/cluster.js +141 -0
  42. package/dist/src/topics/cluster.test.js +150 -0
  43. package/dist/src/topics/index.js +6 -0
  44. package/dist/src/topics/kselection.js +142 -0
  45. package/dist/src/topics/labels.js +56 -0
  46. package/dist/src/topics/store.js +119 -0
  47. package/dist/src/topics/store.test.js +117 -0
  48. package/dist/src/topics/types.js +8 -0
  49. package/dist/src/wiki.js +133 -0
  50. package/dist/src/wiki.test.js +171 -0
  51. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js → AchievementTiles-DZp_xQOW.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DZp_xQOW.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-BzFHpAHM.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-BzFHpAHM.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DEQbTq2T.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DEQbTq2T.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-DQ4FG33J.js} +2 -2
  58. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-DQ4FG33J.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-D5HoEVYo.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-D5HoEVYo.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Jzsc4yeY.js} +2 -2
  62. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Jzsc4yeY.js.map} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-D_XnsEYX.js} +2 -2
  64. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-D_XnsEYX.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-BMREvAth.js} +2 -2
  66. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-BMREvAth.js.map} +1 -1
  67. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DuyhOktG.js} +2 -2
  68. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DuyhOktG.js.map} +1 -1
  69. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-mz_vz8i2.js} +2 -2
  70. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-mz_vz8i2.js.map} +1 -1
  71. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-B8AIjNTY.js} +2 -2
  72. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-B8AIjNTY.js.map} +1 -1
  73. package/extensions/dashboard-client/dist/assets/TopicsTab-DKuct4oD.js +2 -0
  74. package/extensions/dashboard-client/dist/assets/TopicsTab-DKuct4oD.js.map +1 -0
  75. package/extensions/dashboard-client/dist/assets/TurnsTab-11MGLxY8.js +2 -0
  76. package/extensions/dashboard-client/dist/assets/TurnsTab-11MGLxY8.js.map +1 -0
  77. package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-CqLN7STl.js} +10 -10
  78. package/extensions/dashboard-client/dist/assets/index-CqLN7STl.js.map +1 -0
  79. package/extensions/dashboard-client/dist/assets/{index-og3ghxyY.css → index-gn66lYD2.css} +1 -1
  80. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js → useSSE-CXrQSbVY.js} +2 -2
  81. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-CXrQSbVY.js.map} +1 -1
  82. package/extensions/dashboard-client/dist/index.html +2 -2
  83. package/extensions/dashboard-client/src/App.tsx +10 -2
  84. package/extensions/dashboard-client/src/api/client.ts +90 -1
  85. package/extensions/dashboard-client/src/main.tsx +1 -0
  86. package/extensions/dashboard-client/src/styles/turns.css +142 -0
  87. package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +186 -0
  88. package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +336 -0
  89. package/extensions/dashboard-server/api-contracts/endpoints.ts +394 -291
  90. package/extensions/dashboard-server/api-contracts/game-types.ts +141 -88
  91. package/extensions/dashboard-server/api-contracts/index.ts +153 -133
  92. package/extensions/dashboard-server/api-contracts/turns.ts +114 -0
  93. package/extensions/dashboard-server/api-contracts.test.ts +920 -919
  94. package/extensions/dashboard-server/routes-topics.ts +119 -0
  95. package/extensions/dashboard-server/routes-turns.ts +305 -0
  96. package/extensions/dashboard-server/routes.ts +8 -1
  97. package/extensions/dashboard-server/server.ts +6 -2
  98. package/extensions/mega-compact.ts +75 -59
  99. package/extensions/mega-config.ts +232 -220
  100. package/extensions/mega-events/agent-handlers.ts +194 -151
  101. package/extensions/mega-events/context-handler.ts +50 -1
  102. package/extensions/mega-events/session-handlers.ts +26 -5
  103. package/extensions/mega-metrics-cmds.ts +114 -0
  104. package/extensions/mega-pipeline/recall.ts +215 -152
  105. package/extensions/mega-topics-cmds.ts +151 -0
  106. package/extensions/mega-turn-store.test.ts +108 -0
  107. package/extensions/mega-turn-store.ts +169 -0
  108. package/package.json +3 -1
  109. package/src/config/turns.ts +69 -0
  110. package/src/fork.test.ts +94 -0
  111. package/src/fork.ts +78 -0
  112. package/src/intent.test.ts +87 -0
  113. package/src/intent.ts +115 -0
  114. package/src/metrics/index.ts +9 -0
  115. package/src/metrics/turns.test.ts +165 -0
  116. package/src/metrics/turns.ts +201 -0
  117. package/src/store/sqlite/raw-transcript.ts +9 -3
  118. package/src/store/sqlite.dbmirror.test.ts +10 -0
  119. package/src/store/turns/connection.ts +137 -0
  120. package/src/store/turns/contract-compliance.test.ts +562 -0
  121. package/src/store/turns/index.ts +65 -0
  122. package/src/store/turns/memory-store.test.ts +15 -0
  123. package/src/store/turns/memory-store.ts +469 -0
  124. package/src/store/turns/migrations.test.ts +161 -0
  125. package/src/store/turns/migrations.ts +203 -0
  126. package/src/store/turns/schema.ts +158 -0
  127. package/src/store/turns/sqlite-store.test.ts +161 -0
  128. package/src/store/turns/sqlite-store.ts +656 -0
  129. package/src/store/turns/types.ts +181 -0
  130. package/src/topics/cluster.test.ts +176 -0
  131. package/src/topics/cluster.ts +191 -0
  132. package/src/topics/index.ts +18 -0
  133. package/src/topics/kselection.ts +182 -0
  134. package/src/topics/labels.ts +67 -0
  135. package/src/topics/store.test.ts +132 -0
  136. package/src/topics/store.ts +208 -0
  137. package/src/topics/types.ts +64 -0
  138. package/src/wiki.test.ts +217 -0
  139. package/src/wiki.ts +198 -0
  140. package/dist/extensions/openclaw-mega-compact.js +0 -291
  141. package/dist/src/minilm.js +0 -92
  142. package/dist/src/wordpiece.js +0 -129
  143. package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +0 -1
@@ -0,0 +1,115 @@
1
+ /** Schema version stamp (written once into turns_meta). */
2
+ const SCHEMA_VERSION = 1;
3
+ /** Create the unified turn-store schema if absent. Idempotent. */
4
+ export function initTurnSchema(db) {
5
+ // ── Contract tables (master's SqliteTurnStore shape) ──────────────────
6
+ db.exec(`
7
+ CREATE TABLE IF NOT EXISTS turns_meta (
8
+ key TEXT PRIMARY KEY,
9
+ value TEXT NOT NULL
10
+ )
11
+ `);
12
+ // One row per turn_end. role/source/pressure_band use the contract enums.
13
+ // epoch_id is the S50B additive column (links a turn to the compact epoch).
14
+ db.exec(`
15
+ CREATE TABLE IF NOT EXISTS turns (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ conversation_id TEXT NOT NULL,
18
+ session_id TEXT NOT NULL,
19
+ turn_index INTEGER NOT NULL,
20
+ role TEXT NOT NULL CHECK(role IN ('user','assistant','system','tool')),
21
+ started_at INTEGER,
22
+ ended_at INTEGER NOT NULL,
23
+ ctx_tokens INTEGER,
24
+ ctx_percent REAL,
25
+ pressure_band TEXT CHECK(pressure_band IS NULL OR pressure_band IN ('green','yellow','red')),
26
+ model TEXT,
27
+ epoch_id TEXT,
28
+ UNIQUE(conversation_id, turn_index)
29
+ )
30
+ `);
31
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turns_conversation ON turns(conversation_id, turn_index)`);
32
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id)`);
33
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turns_ended_at ON turns(ended_at)`);
34
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turns_pressure ON turns(pressure_band) WHERE pressure_band IS NOT NULL`);
35
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turns_epoch ON turns(epoch_id) WHERE epoch_id IS NOT NULL`);
36
+ // Recall provenance — which checkpoints / cluster summaries were injected
37
+ // at a turn, with score + source path. source uses the contract enum.
38
+ db.exec(`
39
+ CREATE TABLE IF NOT EXISTS turn_recall (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ turn_id INTEGER NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
42
+ checkpoint_id TEXT NOT NULL,
43
+ score REAL NOT NULL,
44
+ source TEXT NOT NULL CHECK(source IN ('checkpoint','cluster_summary','memory')),
45
+ raptor_level INTEGER,
46
+ UNIQUE(turn_id, checkpoint_id)
47
+ )
48
+ `);
49
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_recall_turn ON turn_recall(turn_id)`);
50
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_recall_cp ON turn_recall(checkpoint_id)`);
51
+ // Conversation fork registry (contract: ConversationFork).
52
+ db.exec(`
53
+ CREATE TABLE IF NOT EXISTS conversation_forks (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ parent_conversation_id TEXT NOT NULL,
56
+ child_conversation_id TEXT NOT NULL,
57
+ fork_turn_index INTEGER NOT NULL,
58
+ created_at INTEGER NOT NULL,
59
+ UNIQUE(child_conversation_id)
60
+ )
61
+ `);
62
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_forks_parent ON conversation_forks(parent_conversation_id)`);
63
+ // Session → active conversation map (contract: one current conversation per session).
64
+ db.exec(`
65
+ CREATE TABLE IF NOT EXISTS session_conversations (
66
+ session_id TEXT PRIMARY KEY,
67
+ conversation_id TEXT NOT NULL
68
+ )
69
+ `);
70
+ // ── Program additive shells (this branch) ───────────────────────────
71
+ // S52: rewind-and-fork intents written by an external surface (dashboard)
72
+ // and consumed by the host at a safe lifecycle point. Pre-created → S52
73
+ // adds no migration.
74
+ db.exec(`
75
+ CREATE TABLE IF NOT EXISTS pending_fork (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ target_conversation_id TEXT NOT NULL,
78
+ target_turn_id INTEGER NOT NULL,
79
+ requested_at INTEGER NOT NULL,
80
+ consumed_at INTEGER
81
+ )
82
+ `);
83
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_pending_fork_unconsumed ON pending_fork(consumed_at) WHERE consumed_at IS NULL`);
84
+ // S51: auto-categorizing wiki topics (k-means over real embeddings + TF-IDF
85
+ // labels — see docs/specs/s47-auto-categorizing-wiki.md). Pre-created → S51
86
+ // adds no migration. Derived at rebuild time; no seed data.
87
+ db.exec(`
88
+ CREATE TABLE IF NOT EXISTS topics (
89
+ id TEXT PRIMARY KEY,
90
+ label TEXT NOT NULL,
91
+ term_scores TEXT,
92
+ memory_count INTEGER DEFAULT 0,
93
+ last_updated INTEGER,
94
+ cluster_model_built_at INTEGER
95
+ )
96
+ `);
97
+ db.exec(`
98
+ CREATE TABLE IF NOT EXISTS memory_topics (
99
+ memory_id TEXT NOT NULL,
100
+ topic_id TEXT NOT NULL REFERENCES topics(id),
101
+ confidence REAL,
102
+ assigned_at INTEGER,
103
+ method TEXT CHECK(method IN ('kmeans+tfidf')),
104
+ PRIMARY KEY (memory_id, topic_id)
105
+ )
106
+ `);
107
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_memory_topics_topic ON memory_topics(topic_id)`);
108
+ // Stamp schema version once.
109
+ const existing = db
110
+ .prepare("SELECT value FROM turns_meta WHERE key = 'schema_version'")
111
+ .get();
112
+ if (!existing) {
113
+ db.prepare("INSERT INTO turns_meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
114
+ }
115
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * sqlite-store.ts — SqliteTurnStore: the reference implementation.
3
+ *
4
+ * Private schema (SQL lives here + connection.ts, not in types.ts).
5
+ * All methods satisfy the TurnStore contract from types.ts.
6
+ * Append-only: no UPDATE on turns/turn_recall after INSERT (only in migration).
7
+ *
8
+ * PREVENT-PI-004: node:sqlite in-process only.
9
+ * PREVENT-002: all queries use bound parameters.
10
+ * PREVENT-001: safeJson not needed — no JSON columns in turns.db.
11
+ */
12
+ import { randomBytes } from "node:crypto";
13
+ import { statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { openTurnDb, closeTurnDb } from "./connection.js";
16
+ // ─── Helpers ──────────────────────────────────────────────────────
17
+ function newConversationId() {
18
+ return `conv_${randomBytes(8).toString("hex")}`;
19
+ }
20
+ function normalizeSessionId(sid) {
21
+ // Matches the existing normalizeSessionId in src/store.ts
22
+ return sid.replace(/-.*$/, "");
23
+ }
24
+ function rowToEntry(r) {
25
+ return {
26
+ conversationId: r.conversation_id,
27
+ sessionId: r.session_id,
28
+ turnIndex: r.turn_index,
29
+ role: r.role,
30
+ endedAt: r.ended_at,
31
+ ctxTokens: r.ctx_tokens ?? undefined,
32
+ ctxPercent: r.ctx_percent ?? undefined,
33
+ pressureBand: r.pressure_band ?? undefined,
34
+ model: r.model ?? undefined,
35
+ epochId: r.epoch_id ?? undefined,
36
+ };
37
+ }
38
+ function rowToRecall(r) {
39
+ return {
40
+ turnId: String(r.turn_id),
41
+ checkpointId: r.checkpoint_id,
42
+ score: r.score,
43
+ source: r.source,
44
+ raptorLevel: r.raptor_level ?? undefined,
45
+ };
46
+ }
47
+ function rowToFork(r) {
48
+ return {
49
+ parentConversationId: r.parent_conversation_id,
50
+ childConversationId: r.child_conversation_id,
51
+ forkTurnIndex: r.fork_turn_index,
52
+ createdAt: r.created_at,
53
+ };
54
+ }
55
+ // ─── SqliteTurnStore ──────────────────────────────────────────────
56
+ export class SqliteTurnStore {
57
+ db;
58
+ stateDir;
59
+ opts;
60
+ constructor(options) {
61
+ this.opts = options;
62
+ this.stateDir = options.stateDir;
63
+ this.db = openTurnDb(options.stateDir, {
64
+ dbPath: options.dbPath,
65
+ inMemory: options.inMemory,
66
+ });
67
+ }
68
+ // ── TurnReader ──────────────────────────────────────────────
69
+ query(filter) {
70
+ const clauses = [];
71
+ const params = [];
72
+ if (filter.conversationId !== undefined) {
73
+ clauses.push("conversation_id = ?");
74
+ params.push(filter.conversationId);
75
+ }
76
+ if (filter.sessionId !== undefined) {
77
+ clauses.push("session_id = ?");
78
+ params.push(normalizeSessionId(filter.sessionId));
79
+ }
80
+ if (filter.sinceMs !== undefined) {
81
+ clauses.push("ended_at >= ?");
82
+ params.push(filter.sinceMs);
83
+ }
84
+ if (filter.untilMs !== undefined) {
85
+ clauses.push("ended_at <= ?");
86
+ params.push(filter.untilMs);
87
+ }
88
+ if (filter.pressureBand !== undefined) {
89
+ clauses.push("pressure_band = ?");
90
+ params.push(filter.pressureBand);
91
+ }
92
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
93
+ const limit = filter.limit ?? 1000;
94
+ const offset = filter.offset ?? 0;
95
+ const rows = this.db
96
+ .prepare(`SELECT * FROM turns ${where} ORDER BY ended_at ASC LIMIT ? OFFSET ?`)
97
+ .all(...params, limit, offset);
98
+ return rows.map(rowToEntry);
99
+ }
100
+ getTurn(turnId) {
101
+ const row = this.db
102
+ .prepare("SELECT * FROM turns WHERE id = ?")
103
+ .get(Number(turnId));
104
+ return row ? rowToEntry(row) : undefined;
105
+ }
106
+ getTurnByIndex(conversationId, turnIndex) {
107
+ const row = this.db
108
+ .prepare("SELECT * FROM turns WHERE conversation_id = ? AND turn_index = ?")
109
+ .get(conversationId, turnIndex);
110
+ return row ? rowToEntry(row) : undefined;
111
+ }
112
+ listRecall(turnId) {
113
+ const rows = this.db
114
+ .prepare("SELECT * FROM turn_recall WHERE turn_id = ? ORDER BY score DESC")
115
+ .all(Number(turnId));
116
+ return rows.map(rowToRecall);
117
+ }
118
+ listRecallByIndex(conversationId, turnIndex) {
119
+ const row = this.db
120
+ .prepare("SELECT id FROM turns WHERE conversation_id = ? AND turn_index = ?")
121
+ .get(conversationId, turnIndex);
122
+ if (!row)
123
+ return [];
124
+ return this.listRecall(String(row.id));
125
+ }
126
+ listForks(conversationId) {
127
+ const rows = this.db
128
+ .prepare("SELECT * FROM conversation_forks WHERE parent_conversation_id = ? ORDER BY created_at ASC")
129
+ .all(conversationId);
130
+ return rows.map(rowToFork);
131
+ }
132
+ countTurns(conversationId) {
133
+ const row = this.db
134
+ .prepare("SELECT COUNT(*) as cnt FROM turns WHERE conversation_id = ?")
135
+ .get(conversationId);
136
+ return row.cnt;
137
+ }
138
+ conversationStats(conversationId) {
139
+ const rows = this.db
140
+ .prepare("SELECT ended_at, ctx_percent, pressure_band FROM turns WHERE conversation_id = ? ORDER BY ended_at ASC")
141
+ .all(conversationId);
142
+ if (rows.length === 0) {
143
+ return {
144
+ turnCount: 0,
145
+ firstTurnAt: 0,
146
+ lastTurnAt: 0,
147
+ avgCtxPercent: 0,
148
+ pressureBands: {},
149
+ };
150
+ }
151
+ let ctxSum = 0;
152
+ let ctxCount = 0;
153
+ const bands = {};
154
+ for (const r of rows) {
155
+ if (r.ctx_percent !== null) {
156
+ ctxSum += r.ctx_percent;
157
+ ctxCount++;
158
+ }
159
+ const band = r.pressure_band;
160
+ if (band)
161
+ bands[band] = (bands[band] ?? 0) + 1;
162
+ }
163
+ return {
164
+ turnCount: rows.length,
165
+ firstTurnAt: rows[0].ended_at,
166
+ lastTurnAt: rows[rows.length - 1].ended_at,
167
+ avgCtxPercent: ctxCount > 0 ? ctxSum / ctxCount : 0,
168
+ pressureBands: bands,
169
+ };
170
+ }
171
+ // ── TurnWriter ──────────────────────────────────────────────
172
+ appendTurn(entry) {
173
+ const sid = normalizeSessionId(entry.sessionId);
174
+ this.persistSessionConv(sid, entry.conversationId);
175
+ this.db
176
+ .prepare(`INSERT INTO turns (conversation_id, session_id, turn_index, role, ended_at,
177
+ ctx_tokens, ctx_percent, pressure_band, model)
178
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
179
+ .run(entry.conversationId, sid, entry.turnIndex, entry.role, entry.endedAt, entry.ctxTokens ?? null, entry.ctxPercent ?? null, entry.pressureBand ?? null, entry.model ?? null);
180
+ const row = this.db
181
+ .prepare("SELECT id FROM turns WHERE conversation_id = ? AND turn_index = ?")
182
+ .get(entry.conversationId, entry.turnIndex);
183
+ return String(row.id);
184
+ }
185
+ /** Persist the session → conversation mapping when a turn is written. */
186
+ persistSessionConv(sid, conversationId) {
187
+ this.db
188
+ .prepare("INSERT OR IGNORE INTO session_conversations (session_id, conversation_id) VALUES (?, ?)")
189
+ .run(sid, conversationId);
190
+ }
191
+ appendRecall(entry) {
192
+ this.db
193
+ .prepare(`INSERT OR IGNORE INTO turn_recall (turn_id, checkpoint_id, score, source, raptor_level)
194
+ VALUES (?, ?, ?, ?, ?)`)
195
+ .run(Number(entry.turnId), entry.checkpointId, entry.score, entry.source, entry.raptorLevel ?? null);
196
+ }
197
+ ensureConversationId(sessionId) {
198
+ const sid = normalizeSessionId(sessionId);
199
+ // Check session_conversations table first (persistent mapping)
200
+ const row = this.db
201
+ .prepare("SELECT conversation_id FROM session_conversations WHERE session_id = ?")
202
+ .get(sid);
203
+ if (row)
204
+ return row.conversation_id;
205
+ // Check if any turn exists for this session (fallback)
206
+ const turnRow = this.db
207
+ .prepare("SELECT conversation_id FROM turns WHERE session_id = ? ORDER BY ended_at DESC LIMIT 1")
208
+ .get(sid);
209
+ if (turnRow) {
210
+ // Persist the mapping so future calls are consistent
211
+ this.db
212
+ .prepare("INSERT OR IGNORE INTO session_conversations (session_id, conversation_id) VALUES (?, ?)")
213
+ .run(sid, turnRow.conversation_id);
214
+ return turnRow.conversation_id;
215
+ }
216
+ // No turns yet — generate and persist a new conversation id
217
+ const convId = newConversationId();
218
+ this.db
219
+ .prepare("INSERT OR IGNORE INTO session_conversations (session_id, conversation_id) VALUES (?, ?)")
220
+ .run(sid, convId);
221
+ return convId;
222
+ }
223
+ forkConversation(parentId, forkTurnIndex) {
224
+ const childId = newConversationId();
225
+ const now = Date.now();
226
+ // 1. Record the fork lineage
227
+ this.db
228
+ .prepare(`INSERT INTO conversation_forks (parent_conversation_id, child_conversation_id, fork_turn_index, created_at)
229
+ VALUES (?, ?, ?, ?)`)
230
+ .run(parentId, childId, forkTurnIndex, now);
231
+ // 2. Find the parent's turn at forkTurnIndex
232
+ const parentTurn = this.db
233
+ .prepare("SELECT id FROM turns WHERE conversation_id = ? AND turn_index = ?")
234
+ .get(parentId, forkTurnIndex);
235
+ if (!parentTurn)
236
+ return childId; // no parent turn at that index — nothing to seed
237
+ // 3. Create a seed turn in the child conversation (turnIndex 0, role system)
238
+ this.db
239
+ .prepare(`INSERT INTO turns (conversation_id, session_id, turn_index, role, ended_at)
240
+ VALUES (?, ?, 0, 'system', ?)`)
241
+ .run(childId, `fork_${childId}`, now);
242
+ const childTurnRow = this.db
243
+ .prepare("SELECT id FROM turns WHERE conversation_id = ? AND turn_index = 0")
244
+ .get(childId);
245
+ if (!childTurnRow)
246
+ return childId;
247
+ // 4. Copy the parent's recall entries into the child's seed turn
248
+ const parentRecall = this.db
249
+ .prepare("SELECT checkpoint_id, score, source, raptor_level FROM turn_recall WHERE turn_id = ?")
250
+ .all(parentTurn.id);
251
+ const insertRecall = this.db.prepare(`INSERT OR IGNORE INTO turn_recall (turn_id, checkpoint_id, score, source, raptor_level)
252
+ VALUES (?, ?, ?, ?, ?)`);
253
+ for (const r of parentRecall) {
254
+ insertRecall.run(childTurnRow.id, r.checkpoint_id, r.score, r.source, r.raptor_level ?? null);
255
+ }
256
+ return childId;
257
+ }
258
+ // ── TurnAdmin ───────────────────────────────────────────────
259
+ prune(policy) {
260
+ const beforeSize = this.dbSizeBytes();
261
+ const cutoff = Date.now() - policy.maxTurnAgeMs;
262
+ // Find conversations with turns to potentially prune
263
+ const convRows = this.db
264
+ .prepare("SELECT DISTINCT conversation_id FROM turns")
265
+ .all();
266
+ let turnsRemoved = 0;
267
+ let recallRemoved = 0;
268
+ for (const { conversation_id } of convRows) {
269
+ // Count total turns in this conversation
270
+ const total = this.db
271
+ .prepare("SELECT COUNT(*) as cnt FROM turns WHERE conversation_id = ?")
272
+ .get(conversation_id);
273
+ // Keep at least keepMinPerConversation — delete only the oldest
274
+ const canDelete = Math.max(0, total.cnt - policy.keepMinPerConversation);
275
+ if (canDelete === 0)
276
+ continue;
277
+ // Find old turns to delete
278
+ const oldTurns = this.db
279
+ .prepare(`SELECT id FROM turns WHERE conversation_id = ? AND ended_at < ?
280
+ ORDER BY ended_at ASC LIMIT ?`)
281
+ .all(conversation_id, cutoff, canDelete);
282
+ if (oldTurns.length === 0)
283
+ continue;
284
+ const ids = oldTurns.map((t) => t.id);
285
+ const placeholders = ids.map(() => "?").join(",");
286
+ // Delete recall for those turns
287
+ const delRecall = this.db
288
+ .prepare(`DELETE FROM turn_recall WHERE turn_id IN (${placeholders})`)
289
+ .run(...ids);
290
+ recallRemoved += Number(delRecall.changes);
291
+ // Delete the turns
292
+ const delTurns = this.db
293
+ .prepare(`DELETE FROM turns WHERE id IN (${placeholders})`)
294
+ .run(...ids);
295
+ turnsRemoved += Number(delTurns.changes);
296
+ }
297
+ // Count preserved branches
298
+ const branchesPreserved = this.db
299
+ .prepare("SELECT COUNT(*) as cnt FROM conversation_forks")
300
+ .get().cnt;
301
+ if (policy.vacuumAfterPrune && (turnsRemoved > 0 || recallRemoved > 0)) {
302
+ this.vacuum();
303
+ }
304
+ const afterSize = this.dbSizeBytes();
305
+ return {
306
+ turnsRemoved,
307
+ recallRemoved,
308
+ branchesPreserved,
309
+ freedBytes: Math.max(0, beforeSize - afterSize),
310
+ };
311
+ }
312
+ vacuum() {
313
+ this.db.exec("VACUUM");
314
+ }
315
+ checkpoint() {
316
+ // Query turns in row-id order (ended_at ASC with unique constraint)
317
+ const turnRows = this.db
318
+ .prepare("SELECT * FROM turns ORDER BY ended_at ASC")
319
+ .all();
320
+ const turns = turnRows.map(rowToEntry);
321
+ // Build mapping: row_id → 1-based position in turns array
322
+ const rowIdToPos = new Map();
323
+ for (let i = 0; i < turnRows.length; i++) {
324
+ rowIdToPos.set(turnRows[i].id, i + 1);
325
+ }
326
+ const recallRows = this.db
327
+ .prepare("SELECT * FROM turn_recall ORDER BY turn_id ASC")
328
+ .all();
329
+ // Replace recall's turnId with the 1-based position of the referenced turn
330
+ // in the turns array. This makes the TurnId stable across restore.
331
+ const recall = recallRows.map((r) => {
332
+ const originalTurnId = r.turn_id;
333
+ const stableTurnId = rowIdToPos.get(originalTurnId) ?? originalTurnId;
334
+ return {
335
+ turnId: String(stableTurnId),
336
+ checkpointId: r.checkpoint_id,
337
+ score: r.score,
338
+ source: r.source,
339
+ raptorLevel: r.raptor_level ?? undefined,
340
+ };
341
+ });
342
+ const forks = this.db
343
+ .prepare("SELECT * FROM conversation_forks ORDER BY created_at ASC")
344
+ .all().map(rowToFork);
345
+ return {
346
+ version: 1,
347
+ exportedAt: Date.now(),
348
+ turns,
349
+ recall,
350
+ forks,
351
+ };
352
+ }
353
+ restore(from) {
354
+ this.clear();
355
+ const insertTurn = this.db.prepare(`INSERT INTO turns (conversation_id, session_id, turn_index, role, ended_at,
356
+ ctx_tokens, ctx_percent, pressure_band, model)
357
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
358
+ const insertRecall = this.db.prepare(`INSERT INTO turn_recall (turn_id, checkpoint_id, score, source, raptor_level)
359
+ VALUES (?, ?, ?, ?, ?)`);
360
+ const insertFork = this.db.prepare(`INSERT INTO conversation_forks (parent_conversation_id, child_conversation_id, fork_turn_index, created_at)
361
+ VALUES (?, ?, ?, ?)`);
362
+ this.db.exec("BEGIN TRANSACTION");
363
+ try {
364
+ // Map: 1-based position → new auto-increment row ID
365
+ const posToNewId = new Map();
366
+ for (let i = 0; i < from.turns.length; i++) {
367
+ const t = from.turns[i];
368
+ insertTurn.run(t.conversationId, normalizeSessionId(t.sessionId), t.turnIndex, t.role, t.endedAt, t.ctxTokens ?? null, t.ctxPercent ?? null, t.pressureBand ?? null, t.model ?? null);
369
+ const row = this.db
370
+ .prepare("SELECT id FROM turns WHERE conversation_id = ? AND turn_index = ?")
371
+ .get(t.conversationId, t.turnIndex);
372
+ if (row)
373
+ posToNewId.set(i + 1, row.id);
374
+ }
375
+ // Recall's turnId is now the 1-based position from checkpoint().
376
+ // Map it to the new row ID.
377
+ for (const r of from.recall) {
378
+ const targetTurnId = posToNewId.get(Number(r.turnId)) ?? Number(r.turnId);
379
+ insertRecall.run(targetTurnId, r.checkpointId, r.score, r.source, r.raptorLevel ?? null);
380
+ }
381
+ for (const f of from.forks) {
382
+ insertFork.run(f.parentConversationId, f.childConversationId, f.forkTurnIndex, f.createdAt);
383
+ }
384
+ this.db.exec("COMMIT");
385
+ }
386
+ catch (err) {
387
+ this.db.exec("ROLLBACK");
388
+ throw err;
389
+ }
390
+ }
391
+ clear() {
392
+ this.db.exec("DELETE FROM turn_recall");
393
+ this.db.exec("DELETE FROM conversation_forks");
394
+ this.db.exec("DELETE FROM turns");
395
+ this.db.exec("DELETE FROM session_conversations");
396
+ // Reset autoincrement
397
+ this.db.exec("DELETE FROM sqlite_sequence WHERE name IN ('turns','turn_recall','conversation_forks')");
398
+ }
399
+ stampTurnsEpoch(sessionId, epochId) {
400
+ const sid = normalizeSessionId(sessionId);
401
+ const res = this.db
402
+ .prepare("UPDATE turns SET epoch_id = ? WHERE session_id = ? AND epoch_id IS NULL")
403
+ .run(epochId, sid);
404
+ return Number(res.changes ?? 0);
405
+ }
406
+ // ── Capability gating ───────────────────────────────────────
407
+ asReader() {
408
+ return {
409
+ query: (f) => this.query(f),
410
+ getTurn: (id) => this.getTurn(id),
411
+ getTurnByIndex: (c, t) => this.getTurnByIndex(c, t),
412
+ listRecall: (id) => this.listRecall(id),
413
+ listRecallByIndex: (c, t) => this.listRecallByIndex(c, t),
414
+ listForks: (id) => this.listForks(id),
415
+ countTurns: (id) => this.countTurns(id),
416
+ conversationStats: (id) => this.conversationStats(id),
417
+ };
418
+ }
419
+ asWriter() {
420
+ return {
421
+ appendTurn: (e) => this.appendTurn(e),
422
+ appendRecall: (e) => this.appendRecall(e),
423
+ ensureConversationId: (s) => this.ensureConversationId(s),
424
+ forkConversation: (p, t) => this.forkConversation(p, t),
425
+ };
426
+ }
427
+ asAdmin() {
428
+ return {
429
+ prune: (p) => this.prune(p),
430
+ vacuum: () => this.vacuum(),
431
+ checkpoint: () => this.checkpoint(),
432
+ restore: (s) => this.restore(s),
433
+ clear: () => this.clear(),
434
+ stampTurnsEpoch: (s, e) => this.stampTurnsEpoch(s, e),
435
+ };
436
+ }
437
+ close() {
438
+ closeTurnDb(this.stateDir, {
439
+ dbPath: this.opts.dbPath,
440
+ inMemory: this.opts.inMemory,
441
+ });
442
+ }
443
+ // ── Private helpers ─────────────────────────────────────────
444
+ dbSizeBytes() {
445
+ if (this.opts.inMemory)
446
+ return 0;
447
+ try {
448
+ const path = this.opts.dbPath ?? join(this.stateDir, "turns.db");
449
+ return statSync(path).size;
450
+ }
451
+ catch {
452
+ return 0;
453
+ }
454
+ }
455
+ }