@jbarket/loomai 0.5.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 (176) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/LICENSE +661 -0
  3. package/README.md +702 -0
  4. package/assets/demo.cast +396 -0
  5. package/assets/skill/SKILL.md +192 -0
  6. package/dist/backends/embedding-runtime.d.ts +39 -0
  7. package/dist/backends/embedding-runtime.js +265 -0
  8. package/dist/backends/episodes.d.ts +36 -0
  9. package/dist/backends/episodes.js +126 -0
  10. package/dist/backends/fastembed.d.ts +18 -0
  11. package/dist/backends/fastembed.js +95 -0
  12. package/dist/backends/glob.d.ts +10 -0
  13. package/dist/backends/glob.js +16 -0
  14. package/dist/backends/index.d.ts +6 -0
  15. package/dist/backends/index.js +78 -0
  16. package/dist/backends/migrations.d.ts +38 -0
  17. package/dist/backends/migrations.js +157 -0
  18. package/dist/backends/mmr.d.ts +45 -0
  19. package/dist/backends/mmr.js +82 -0
  20. package/dist/backends/proposals.d.ts +65 -0
  21. package/dist/backends/proposals.js +182 -0
  22. package/dist/backends/recall-log.d.ts +42 -0
  23. package/dist/backends/recall-log.js +143 -0
  24. package/dist/backends/retry.d.ts +29 -0
  25. package/dist/backends/retry.js +60 -0
  26. package/dist/backends/salience.d.ts +57 -0
  27. package/dist/backends/salience.js +186 -0
  28. package/dist/backends/sqlite-knowledge.d.ts +37 -0
  29. package/dist/backends/sqlite-knowledge.js +788 -0
  30. package/dist/backends/sqlite-vec.d.ts +64 -0
  31. package/dist/backends/sqlite-vec.js +737 -0
  32. package/dist/backends/ttl.d.ts +24 -0
  33. package/dist/backends/ttl.js +50 -0
  34. package/dist/backends/types.d.ts +562 -0
  35. package/dist/backends/types.js +8 -0
  36. package/dist/blocks/harness.d.ts +45 -0
  37. package/dist/blocks/harness.js +142 -0
  38. package/dist/blocks/model.d.ts +4 -0
  39. package/dist/blocks/model.js +56 -0
  40. package/dist/blocks/types.d.ts +40 -0
  41. package/dist/blocks/types.js +34 -0
  42. package/dist/categories.d.ts +16 -0
  43. package/dist/categories.js +29 -0
  44. package/dist/cli/args.d.ts +21 -0
  45. package/dist/cli/args.js +47 -0
  46. package/dist/cli/bootstrap.d.ts +2 -0
  47. package/dist/cli/bootstrap.js +214 -0
  48. package/dist/cli/doctor.d.ts +2 -0
  49. package/dist/cli/doctor.js +206 -0
  50. package/dist/cli/forget.d.ts +2 -0
  51. package/dist/cli/forget.js +97 -0
  52. package/dist/cli/harness.d.ts +2 -0
  53. package/dist/cli/harness.js +91 -0
  54. package/dist/cli/index.d.ts +2 -0
  55. package/dist/cli/index.js +127 -0
  56. package/dist/cli/inject.d.ts +2 -0
  57. package/dist/cli/inject.js +298 -0
  58. package/dist/cli/install.d.ts +2 -0
  59. package/dist/cli/install.js +137 -0
  60. package/dist/cli/io.d.ts +14 -0
  61. package/dist/cli/io.js +63 -0
  62. package/dist/cli/knowledge.d.ts +2 -0
  63. package/dist/cli/knowledge.js +216 -0
  64. package/dist/cli/memory.d.ts +2 -0
  65. package/dist/cli/memory.js +608 -0
  66. package/dist/cli/migrate.d.ts +2 -0
  67. package/dist/cli/migrate.js +116 -0
  68. package/dist/cli/recall.d.ts +2 -0
  69. package/dist/cli/recall.js +91 -0
  70. package/dist/cli/remember.d.ts +2 -0
  71. package/dist/cli/remember.js +111 -0
  72. package/dist/cli/serve.d.ts +2 -0
  73. package/dist/cli/serve.js +71 -0
  74. package/dist/cli/subcommands.d.ts +7 -0
  75. package/dist/cli/subcommands.js +11 -0
  76. package/dist/cli/tui/multi-select.d.ts +53 -0
  77. package/dist/cli/tui/multi-select.js +116 -0
  78. package/dist/cli/update-identity.d.ts +2 -0
  79. package/dist/cli/update-identity.js +90 -0
  80. package/dist/cli/update.d.ts +2 -0
  81. package/dist/cli/update.js +87 -0
  82. package/dist/cli/wake.d.ts +2 -0
  83. package/dist/cli/wake.js +53 -0
  84. package/dist/clients.d.ts +8 -0
  85. package/dist/clients.js +63 -0
  86. package/dist/config.d.ts +59 -0
  87. package/dist/config.js +160 -0
  88. package/dist/index.d.ts +15 -0
  89. package/dist/index.js +75 -0
  90. package/dist/injection/harnesses.d.ts +17 -0
  91. package/dist/injection/harnesses.js +51 -0
  92. package/dist/injection/render.d.ts +17 -0
  93. package/dist/injection/render.js +50 -0
  94. package/dist/injection/writer.d.ts +25 -0
  95. package/dist/injection/writer.js +118 -0
  96. package/dist/install/harnesses.d.ts +26 -0
  97. package/dist/install/harnesses.js +90 -0
  98. package/dist/install/names.d.ts +16 -0
  99. package/dist/install/names.js +36 -0
  100. package/dist/install/render.d.ts +18 -0
  101. package/dist/install/render.js +42 -0
  102. package/dist/install/skill-source.d.ts +1 -0
  103. package/dist/install/skill-source.js +11 -0
  104. package/dist/path-safety.d.ts +19 -0
  105. package/dist/path-safety.js +62 -0
  106. package/dist/server.d.ts +8 -0
  107. package/dist/server.js +571 -0
  108. package/dist/tools/append-maintenance-log.d.ts +52 -0
  109. package/dist/tools/append-maintenance-log.js +103 -0
  110. package/dist/tools/archive.d.ts +2 -0
  111. package/dist/tools/archive.js +21 -0
  112. package/dist/tools/bootstrap.d.ts +11 -0
  113. package/dist/tools/bootstrap.js +161 -0
  114. package/dist/tools/dossier.d.ts +1 -0
  115. package/dist/tools/dossier.js +102 -0
  116. package/dist/tools/eurorack-import.d.ts +47 -0
  117. package/dist/tools/eurorack-import.js +199 -0
  118. package/dist/tools/find-similar.d.ts +3 -0
  119. package/dist/tools/find-similar.js +26 -0
  120. package/dist/tools/forget.d.ts +6 -0
  121. package/dist/tools/forget.js +70 -0
  122. package/dist/tools/harness.d.ts +18 -0
  123. package/dist/tools/harness.js +54 -0
  124. package/dist/tools/identity-scaffold.d.ts +22 -0
  125. package/dist/tools/identity-scaffold.js +142 -0
  126. package/dist/tools/identity.d.ts +4 -0
  127. package/dist/tools/identity.js +226 -0
  128. package/dist/tools/knowledge-archive.d.ts +5 -0
  129. package/dist/tools/knowledge-archive.js +24 -0
  130. package/dist/tools/knowledge-history.d.ts +6 -0
  131. package/dist/tools/knowledge-history.js +55 -0
  132. package/dist/tools/knowledge-maintain.d.ts +9 -0
  133. package/dist/tools/knowledge-maintain.js +113 -0
  134. package/dist/tools/knowledge-merge.d.ts +8 -0
  135. package/dist/tools/knowledge-merge.js +40 -0
  136. package/dist/tools/knowledge-move.d.ts +10 -0
  137. package/dist/tools/knowledge-move.js +48 -0
  138. package/dist/tools/knowledge-purge.d.ts +5 -0
  139. package/dist/tools/knowledge-purge.js +20 -0
  140. package/dist/tools/knowledge-recall.d.ts +20 -0
  141. package/dist/tools/knowledge-recall.js +142 -0
  142. package/dist/tools/knowledge-restore.d.ts +4 -0
  143. package/dist/tools/knowledge-restore.js +24 -0
  144. package/dist/tools/knowledge-supersede.d.ts +6 -0
  145. package/dist/tools/knowledge-supersede.js +36 -0
  146. package/dist/tools/knowledge-verify.d.ts +8 -0
  147. package/dist/tools/knowledge-verify.js +39 -0
  148. package/dist/tools/knowledge-write.d.ts +25 -0
  149. package/dist/tools/knowledge-write.js +87 -0
  150. package/dist/tools/memory-audit.d.ts +3 -0
  151. package/dist/tools/memory-audit.js +47 -0
  152. package/dist/tools/memory-list.d.ts +4 -0
  153. package/dist/tools/memory-list.js +23 -0
  154. package/dist/tools/prune.d.ts +4 -0
  155. package/dist/tools/prune.js +28 -0
  156. package/dist/tools/recall.d.ts +4 -0
  157. package/dist/tools/recall.js +24 -0
  158. package/dist/tools/remember.d.ts +10 -0
  159. package/dist/tools/remember.js +43 -0
  160. package/dist/tools/restore.d.ts +2 -0
  161. package/dist/tools/restore.js +20 -0
  162. package/dist/tools/update-identity.d.ts +34 -0
  163. package/dist/tools/update-identity.js +194 -0
  164. package/dist/tools/update.d.ts +2 -0
  165. package/dist/tools/update.js +16 -0
  166. package/dist/transport/guards.d.ts +35 -0
  167. package/dist/transport/guards.js +107 -0
  168. package/dist/transport/http-server.d.ts +17 -0
  169. package/dist/transport/http-server.js +198 -0
  170. package/dist/transport/mcp-app-hello.d.ts +3 -0
  171. package/dist/transport/mcp-app-hello.js +66 -0
  172. package/dist/widgets/display-modes.html +131 -0
  173. package/dist/widgets/hello.html +138 -0
  174. package/dist/widgets/proposals.html +131 -0
  175. package/dist/widgets/top-of-mind.html +131 -0
  176. package/package.json +73 -0
@@ -0,0 +1,788 @@
1
+ /**
2
+ * SQLite knowledge backend — isolated from the memory wing.
3
+ *
4
+ * This backend opens a separate knowledge.db file, never loads sqlite-vec,
5
+ * and never touches memories.db. It follows the frozen v1 schema (§B) from
6
+ * the hardened design: pages + citations tables, PRAGMA user_version = 1,
7
+ * WAL mode, close-in-finally pattern.
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { mkdirSync } from 'node:fs';
11
+ import { dirname } from 'node:path';
12
+ import BetterSqlite3 from 'better-sqlite3';
13
+ import { retryWrite } from './retry.js';
14
+ /** Hard size caps — enforced at write boundary (design §A3). */
15
+ const MAX_PAGE_BODY_LENGTH = 64 * 1024; // 64 KB
16
+ const MAX_CITATION_EXCERPT_LENGTH = 4096; // 4 KB
17
+ /** Body snapshots retained per page; oldest pruned beyond this. */
18
+ const MAX_REVISIONS_PER_PAGE = 10;
19
+ export class SqliteKnowledgeBackend {
20
+ config;
21
+ db = null;
22
+ constructor(config) {
23
+ this.config = config;
24
+ mkdirSync(dirname(config.dbPath), { recursive: true });
25
+ // Open in constructor for schema init; caller closes after use.
26
+ this.db = new BetterSqlite3(config.dbPath);
27
+ this.db.pragma('journal_mode = WAL');
28
+ // Single-writer (c-loom-strictness §single-writer): fail fast on a second
29
+ // concurrent writer (SQLITE_BUSY) instead of blocking for the 5s default —
30
+ // see sqlite-vec.ts for the rationale. The knowledge wing shares the policy.
31
+ this.db.pragma('busy_timeout = 0');
32
+ this.db.pragma('user_version = 1');
33
+ this.initSchema();
34
+ }
35
+ // ── KnowledgeBackend interface ──
36
+ writePage(input) {
37
+ try {
38
+ enforcePageBodyCap(input.body);
39
+ const db = this.ensureOpen();
40
+ const timestamp = new Date().toISOString();
41
+ const existing = db.prepare('SELECT id, uuid, title, body FROM pages WHERE slug = ?').get(input.slug);
42
+ let pageId;
43
+ let uuid;
44
+ let title;
45
+ let sourcing;
46
+ let appliedMode;
47
+ if (existing) {
48
+ uuid = existing.uuid;
49
+ // Title and domain follow the write — the page is addressed by slug,
50
+ // so a differing title/domain on upsert is an intentional revision.
51
+ title = input.title;
52
+ sourcing = input.sourcing ?? 'sourced';
53
+ pageId = existing.id;
54
+ appliedMode = input.bodyMode ?? 'replace';
55
+ const newBody = appliedMode === 'append'
56
+ ? `${existing.body}\n\n${input.body}`
57
+ : input.body;
58
+ enforcePageBodyCap(newBody);
59
+ // Replace-writes destroy the stored body — snapshot it first so the
60
+ // page is recoverable (the 2026-06-01 verify run stomped 13 bodies
61
+ // with no recovery path other than transcript archaeology).
62
+ if (appliedMode === 'replace' && newBody !== existing.body) {
63
+ retryWrite(() => this.snapshotRevision(db, pageId, input.slug, existing.body, 'write-replace', timestamp));
64
+ }
65
+ // verified_at is a verification stamp, not a write stamp: an update
66
+ // only moves it when the writer explicitly claims verification.
67
+ // (Creation still stamps write time — the page was just synthesized
68
+ // against its sources.)
69
+ // created_by / version are preserved across upserts when omitted.
70
+ retryWrite(() => db.prepare(`UPDATE pages SET title = ?, domain = ?, body = ?, sourcing = ?, verified_at = COALESCE(?, verified_at), freshness_anchor = COALESCE(?, freshness_anchor), created_by = COALESCE(?, created_by), version = COALESCE(?, version), updated = ? WHERE id = ?`).run(title, input.domain, newBody, sourcing, input.verified_at ?? null, input.freshness_anchor ?? null, input.created_by ?? null, input.version ?? null, timestamp, pageId));
71
+ }
72
+ else {
73
+ uuid = randomUUID();
74
+ title = input.title;
75
+ sourcing = input.sourcing ?? 'sourced';
76
+ appliedMode = 'create';
77
+ const result = retryWrite(() => db.prepare(`INSERT INTO pages (uuid, slug, title, domain, body, sourcing, provenance, verified_at, freshness_anchor, created_by, version, created, updated)
78
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid, input.slug, title, input.domain, input.body, sourcing, input.provenance ?? null, input.verified_at ?? timestamp, input.freshness_anchor ?? null, input.created_by ?? null, input.version ?? null, timestamp, timestamp));
79
+ pageId = Number(result.lastInsertRowid);
80
+ }
81
+ let citationsAdded = 0;
82
+ let citationsDeduped = 0;
83
+ if (input.citations && input.citations.length > 0) {
84
+ for (const cit of input.citations) {
85
+ enforceExcerptCap(cit.excerpt);
86
+ }
87
+ // Exact-duplicate guard: re-sending a page's citations on upsert must
88
+ // not multiply them. A citation is a duplicate when claim, kind,
89
+ // locator, and excerpt all match an existing row on the same page
90
+ // (same identity tuple mergePages dedupes on).
91
+ const dupCheck = db.prepare(`SELECT 1 FROM citations
92
+ WHERE page_id = ? AND claim = ? AND source_kind = ?
93
+ AND COALESCE(source_locator, '') = COALESCE(?, '')
94
+ AND excerpt = ?`);
95
+ const insertCit = db.prepare(`INSERT INTO citations (page_id, claim, source_kind, source_locator, excerpt, retrieved_at, created)
96
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
97
+ const tx = db.transaction(() => {
98
+ for (const cit of input.citations) {
99
+ const dup = dupCheck.get(pageId, cit.claim, cit.source_kind, cit.source_locator ?? null, cit.excerpt);
100
+ if (dup) {
101
+ citationsDeduped++;
102
+ continue;
103
+ }
104
+ insertCit.run(pageId, cit.claim, cit.source_kind, cit.source_locator ?? null, cit.excerpt, timestamp, timestamp);
105
+ citationsAdded++;
106
+ }
107
+ });
108
+ retryWrite(() => tx());
109
+ }
110
+ return Promise.resolve({
111
+ uuid,
112
+ slug: input.slug,
113
+ title,
114
+ citationsAdded,
115
+ citationsDeduped,
116
+ created: appliedMode === 'create',
117
+ bodyMode: appliedMode,
118
+ });
119
+ }
120
+ catch (e) {
121
+ return Promise.reject(e);
122
+ }
123
+ }
124
+ getPage(slug, opts) {
125
+ try {
126
+ const db = this.ensureOpen();
127
+ const page = db.prepare('SELECT * FROM pages WHERE slug = ?').get(slug);
128
+ if (!page)
129
+ return Promise.resolve(null);
130
+ if (opts?.stampAccess) {
131
+ const now = new Date().toISOString();
132
+ retryWrite(() => db.prepare('UPDATE pages SET last_accessed = ?, hit_count = hit_count + 1 WHERE id = ?').run(now, page.id));
133
+ page.last_accessed = now;
134
+ page.hit_count += 1;
135
+ }
136
+ const citations = db.prepare('SELECT * FROM citations WHERE page_id = ? ORDER BY id').all(page.id);
137
+ return Promise.resolve({ ...page, citations });
138
+ }
139
+ catch (e) {
140
+ return Promise.reject(e);
141
+ }
142
+ }
143
+ listPages(input) {
144
+ try {
145
+ const db = this.ensureOpen();
146
+ const limit = input?.limit ?? 50;
147
+ const clauses = [];
148
+ const params = [];
149
+ if (input?.excludeStatus) {
150
+ clauses.push('status != ?');
151
+ params.push(input.excludeStatus);
152
+ }
153
+ if (input?.domain) {
154
+ // Prefix filter INCLUDING the exact domain itself — 'music/gear'
155
+ // must match pages in 'music/gear' as well as 'music/gear/elektron'.
156
+ clauses.push('(domain = ? OR domain LIKE ?)');
157
+ params.push(input.domain, `${input.domain}/%`);
158
+ }
159
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
160
+ params.push(limit);
161
+ const rows = db.prepare(`SELECT * FROM pages ${where} ORDER BY updated DESC, created DESC LIMIT ?`).all(...params);
162
+ return Promise.resolve(rows.map((page) => ({
163
+ ...page,
164
+ citations: this.fetchCitationsForPage(db, page.id),
165
+ })));
166
+ }
167
+ catch (e) {
168
+ return Promise.reject(e);
169
+ }
170
+ }
171
+ queryPages(input) {
172
+ try {
173
+ const db = this.ensureOpen();
174
+ const limit = input.limit ?? 10;
175
+ const clauses = [];
176
+ const params = [];
177
+ if (input.query) {
178
+ // Split into tokens on whitespace. Never filter by token length —
179
+ // single-letter tokens like 'I' and roman numerals like 'II' are
180
+ // semantically meaningful (e.g. "Digitakt I" vs "Digitakt II") and
181
+ // must never be stopworded or dropped.
182
+ const tokens = input.query.trim().split(/\s+/).filter((t) => t.length > 0);
183
+ for (const token of tokens) {
184
+ const likePattern = `%${token}%`;
185
+ clauses.push('(title LIKE ? OR body LIKE ? OR domain LIKE ?)');
186
+ params.push(likePattern, likePattern, likePattern);
187
+ }
188
+ }
189
+ if (input.domain) {
190
+ // Prefix filter INCLUDING the exact domain itself — 'music/gear'
191
+ // must match pages in 'music/gear' as well as 'music/gear/elektron'.
192
+ clauses.push('(domain = ? OR domain LIKE ?)');
193
+ params.push(input.domain, `${input.domain}/%`);
194
+ }
195
+ if (input.excludeStatus) {
196
+ clauses.push('status != ?');
197
+ params.push(input.excludeStatus);
198
+ }
199
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
200
+ params.push(limit);
201
+ // Stale-first ordering for the verification engine: never-verified
202
+ // pages first (NULL sorts lowest in SQLite ASC), then oldest-verified.
203
+ const orderBy = input.sortByVerified
204
+ ? 'ORDER BY verified_at ASC NULLS FIRST, updated ASC'
205
+ : 'ORDER BY hit_count DESC, updated DESC, created DESC';
206
+ const rows = db.prepare(`SELECT * FROM pages ${where} ${orderBy} LIMIT ?`).all(...params);
207
+ // Stamp last_accessed + increment hit_count in a single transaction for all hits.
208
+ // This is the usage signal the Phase-4 expansion engine depends on.
209
+ // Callers doing index-style browsing pass stampAccess: false — a page
210
+ // appearing in a listing is not a read.
211
+ if (rows.length > 0 && input.stampAccess !== false) {
212
+ const now = new Date().toISOString();
213
+ const stamp = db.prepare('UPDATE pages SET last_accessed = ?, hit_count = hit_count + 1 WHERE id = ?');
214
+ const tx = db.transaction((ids) => {
215
+ for (const id of ids)
216
+ stamp.run(now, id);
217
+ });
218
+ retryWrite(() => tx(rows.map((r) => r.id)));
219
+ }
220
+ return Promise.resolve(rows.map((page) => ({
221
+ ...page,
222
+ citations: this.fetchCitationsForPage(db, page.id),
223
+ })));
224
+ }
225
+ catch (e) {
226
+ return Promise.reject(e);
227
+ }
228
+ }
229
+ archivePage(input) {
230
+ try {
231
+ const db = this.ensureOpen();
232
+ const timestamp = new Date().toISOString();
233
+ const page = db.prepare("SELECT id, status FROM pages WHERE slug = ?").get(input.slug);
234
+ if (!page || page.status === 'archived') {
235
+ return Promise.resolve({ slug: input.slug, archived: false });
236
+ }
237
+ retryWrite(() => db.prepare("UPDATE pages SET status = 'archived', tombstone_note = ?, updated = ? WHERE slug = ?").run(input.note ?? null, timestamp, input.slug));
238
+ return Promise.resolve({ slug: input.slug, archived: true });
239
+ }
240
+ catch (e) {
241
+ return Promise.reject(e);
242
+ }
243
+ }
244
+ restorePage(input) {
245
+ try {
246
+ const db = this.ensureOpen();
247
+ const timestamp = new Date().toISOString();
248
+ const page = db.prepare("SELECT id, status FROM pages WHERE slug = ?").get(input.slug);
249
+ if (!page || page.status !== 'archived') {
250
+ return Promise.resolve({ slug: input.slug, restored: false });
251
+ }
252
+ retryWrite(() => db.prepare("UPDATE pages SET status = 'active', tombstone_note = NULL, updated = ? WHERE slug = ?").run(timestamp, input.slug));
253
+ return Promise.resolve({ slug: input.slug, restored: true });
254
+ }
255
+ catch (e) {
256
+ return Promise.reject(e);
257
+ }
258
+ }
259
+ supersedePage(input) {
260
+ try {
261
+ const db = this.ensureOpen();
262
+ const oldPage = db.prepare("SELECT id, status FROM pages WHERE slug = ?").get(input.old_slug);
263
+ if (!oldPage) {
264
+ return Promise.reject(new Error(`supersedePage: old page not found: ${input.old_slug}`));
265
+ }
266
+ const newPage = db.prepare("SELECT id FROM pages WHERE slug = ?").get(input.new_slug);
267
+ if (!newPage) {
268
+ return Promise.reject(new Error(`supersedePage: new (canonical) page not found: ${input.new_slug}`));
269
+ }
270
+ if (oldPage.status === 'archived') {
271
+ return Promise.resolve({ old_slug: input.old_slug, new_slug: input.new_slug, archived: false });
272
+ }
273
+ const timestamp = new Date().toISOString();
274
+ const tombstoneNote = input.note
275
+ ? `Superseded by ${input.new_slug}. ${input.note}`
276
+ : `Superseded by ${input.new_slug}.`;
277
+ const tx = db.transaction(() => {
278
+ // Archive the old page with a tombstone.
279
+ db.prepare("UPDATE pages SET status = 'archived', tombstone_note = ?, updated = ? WHERE slug = ?").run(tombstoneNote, timestamp, input.old_slug);
280
+ // Record the supersession.
281
+ db.prepare("INSERT INTO supersessions (old_slug, new_slug, note, created) VALUES (?, ?, ?, ?)").run(input.old_slug, input.new_slug, input.note ?? null, timestamp);
282
+ });
283
+ retryWrite(() => tx());
284
+ return Promise.resolve({
285
+ old_slug: input.old_slug,
286
+ new_slug: input.new_slug,
287
+ archived: oldPage.status !== 'archived',
288
+ });
289
+ }
290
+ catch (e) {
291
+ return Promise.reject(e);
292
+ }
293
+ }
294
+ movePage(input) {
295
+ try {
296
+ const db = this.ensureOpen();
297
+ const timestamp = new Date().toISOString();
298
+ // ── Batch by domain prefix substitution ──────────────────────────────────
299
+ if (input.from_domain_prefix !== undefined) {
300
+ if (!input.to_domain_prefix) {
301
+ return Promise.reject(new Error('movePage: to_domain_prefix is required when from_domain_prefix is set'));
302
+ }
303
+ if (input.from_domain_prefix === input.to_domain_prefix) {
304
+ return Promise.reject(new Error('movePage: from_domain_prefix and to_domain_prefix must differ'));
305
+ }
306
+ const affected = db.prepare(`SELECT slug, domain FROM pages WHERE domain = ? OR domain LIKE ?`).all(input.from_domain_prefix, `${input.from_domain_prefix}/%`);
307
+ const pages = [];
308
+ const tx = db.transaction(() => {
309
+ for (const p of affected) {
310
+ const newDomain = input.to_domain_prefix + p.domain.slice(input.from_domain_prefix.length);
311
+ db.prepare('UPDATE pages SET domain = ?, updated = ? WHERE slug = ?').run(newDomain, timestamp, p.slug);
312
+ pages.push({ slug: p.slug, old_domain: p.domain, new_domain: newDomain });
313
+ }
314
+ });
315
+ retryWrite(() => tx());
316
+ return Promise.resolve({ moved: pages.length, pages, pointers_written: 0 });
317
+ }
318
+ // ── Batch by explicit slug list (re-domain only) ──────────────────────────
319
+ if (input.slugs && input.slugs.length > 0) {
320
+ if (!input.new_domain) {
321
+ return Promise.reject(new Error('movePage: new_domain is required when slugs list is provided'));
322
+ }
323
+ const pages = [];
324
+ const tx = db.transaction(() => {
325
+ for (const slug of input.slugs) {
326
+ const page = db.prepare('SELECT domain FROM pages WHERE slug = ?').get(slug);
327
+ if (!page) {
328
+ throw new Error(`movePage: page not found: ${slug}`);
329
+ }
330
+ db.prepare('UPDATE pages SET domain = ?, updated = ? WHERE slug = ?').run(input.new_domain, timestamp, slug);
331
+ pages.push({ slug, old_domain: page.domain, new_domain: input.new_domain });
332
+ }
333
+ });
334
+ retryWrite(() => tx());
335
+ return Promise.resolve({ moved: pages.length, pages, pointers_written: 0 });
336
+ }
337
+ // ── Single-page mode ──────────────────────────────────────────────────────
338
+ if (!input.slug) {
339
+ return Promise.reject(new Error('movePage: slug is required for single-page mode'));
340
+ }
341
+ if (!input.new_slug && !input.new_domain) {
342
+ return Promise.reject(new Error('movePage: at least one of new_slug or new_domain is required'));
343
+ }
344
+ if (input.new_slug && input.new_slug === input.slug) {
345
+ return Promise.reject(new Error('movePage: new_slug must differ from the current slug'));
346
+ }
347
+ const page = db.prepare('SELECT domain FROM pages WHERE slug = ?').get(input.slug);
348
+ if (!page) {
349
+ return Promise.reject(new Error(`movePage: page not found: ${input.slug}`));
350
+ }
351
+ // Slug-collision check — must happen before the transaction.
352
+ if (input.new_slug) {
353
+ const collision = db.prepare('SELECT id FROM pages WHERE slug = ?').get(input.new_slug);
354
+ if (collision) {
355
+ return Promise.reject(new Error(`movePage: slug '${input.new_slug}' already exists — use knowledge_merge instead of knowledge_move to consolidate pages.`));
356
+ }
357
+ }
358
+ const oldSlug = input.slug;
359
+ const oldDomain = page.domain;
360
+ const leavePointer = input.leave_pointer !== false; // default true
361
+ let pointersWritten = 0;
362
+ const tx = db.transaction(() => {
363
+ if (input.new_slug) {
364
+ db.prepare('UPDATE pages SET slug = ?, updated = ? WHERE slug = ?').run(input.new_slug, timestamp, oldSlug);
365
+ if (leavePointer) {
366
+ db.prepare('INSERT INTO supersessions (old_slug, new_slug, note, created) VALUES (?, ?, ?, ?)').run(oldSlug, input.new_slug, 'slug rename via knowledge_move', timestamp);
367
+ pointersWritten = 1;
368
+ }
369
+ }
370
+ if (input.new_domain) {
371
+ // Address the row by its current slug after the possible rename.
372
+ const currentSlug = input.new_slug ?? oldSlug;
373
+ db.prepare('UPDATE pages SET domain = ?, updated = ? WHERE slug = ?').run(input.new_domain, timestamp, currentSlug);
374
+ }
375
+ });
376
+ retryWrite(() => tx());
377
+ return Promise.resolve({
378
+ moved: 1,
379
+ pages: [{
380
+ slug: input.new_slug ?? oldSlug,
381
+ old_slug: input.new_slug ? oldSlug : undefined,
382
+ old_domain: oldDomain,
383
+ new_domain: input.new_domain ?? oldDomain,
384
+ }],
385
+ pointers_written: pointersWritten,
386
+ });
387
+ }
388
+ catch (e) {
389
+ return Promise.reject(e);
390
+ }
391
+ }
392
+ mergePages(input) {
393
+ try {
394
+ const db = this.ensureOpen();
395
+ if (!input.source_slugs || input.source_slugs.length === 0) {
396
+ return Promise.reject(new Error('mergePages: source_slugs must not be empty'));
397
+ }
398
+ const targetRow = db.prepare('SELECT id, body, verified_at, freshness_anchor FROM pages WHERE slug = ?').get(input.target_slug);
399
+ if (!targetRow) {
400
+ return Promise.reject(new Error(`mergePages: target page not found: ${input.target_slug}`));
401
+ }
402
+ const sourceRows = [];
403
+ for (const slug of input.source_slugs) {
404
+ if (slug === input.target_slug) {
405
+ return Promise.reject(new Error(`mergePages: source_slug '${slug}' is the same as target_slug — cannot merge a page into itself`));
406
+ }
407
+ const row = db.prepare('SELECT id, slug, body, verified_at, freshness_anchor FROM pages WHERE slug = ?').get(slug);
408
+ if (!row) {
409
+ return Promise.reject(new Error(`mergePages: source page not found: ${slug}`));
410
+ }
411
+ sourceRows.push(row);
412
+ }
413
+ const timestamp = new Date().toISOString();
414
+ // MAX(verified_at) across all pages; null when all are unverified.
415
+ const allVerifiedAts = [targetRow.verified_at, ...sourceRows.map((r) => r.verified_at)]
416
+ .filter((v) => v != null);
417
+ const maxVerifiedAt = allVerifiedAts.length > 0
418
+ ? allVerifiedAts.reduce((max, v) => (v > max ? v : max))
419
+ : null;
420
+ // freshness_anchor: keep target's; fall back to first non-null source's.
421
+ let freshnessAnchor = targetRow.freshness_anchor;
422
+ if (!freshnessAnchor) {
423
+ for (const row of sourceRows) {
424
+ if (row.freshness_anchor) {
425
+ freshnessAnchor = row.freshness_anchor;
426
+ break;
427
+ }
428
+ }
429
+ }
430
+ // Capture loser bodies before the transaction (returned in result).
431
+ const losers = sourceRows.map((r) => ({ slug: r.slug, body: r.body }));
432
+ let citationsMoved = 0;
433
+ let citationsDeduped = 0;
434
+ const tx = db.transaction(() => {
435
+ for (const source of sourceRows) {
436
+ // Count total source citations before dedup. (Within the transaction,
437
+ // reads are consistent with the write lock we hold.)
438
+ const before = db.prepare('SELECT COUNT(*) as count FROM citations WHERE page_id = ?').get(source.id).count;
439
+ // Delete source citations that are identical to existing target citations.
440
+ db.prepare(`
441
+ DELETE FROM citations
442
+ WHERE page_id = ?
443
+ AND EXISTS (
444
+ SELECT 1 FROM citations c2
445
+ WHERE c2.page_id = ?
446
+ AND c2.claim = citations.claim
447
+ AND c2.source_kind = citations.source_kind
448
+ AND COALESCE(c2.source_locator, '') = COALESCE(citations.source_locator, '')
449
+ AND c2.excerpt = citations.excerpt
450
+ )
451
+ `).run(source.id, targetRow.id);
452
+ const after = db.prepare('SELECT COUNT(*) as count FROM citations WHERE page_id = ?').get(source.id).count;
453
+ citationsDeduped += before - after;
454
+ // Re-parent remaining source citations to target.
455
+ db.prepare('UPDATE citations SET page_id = ? WHERE page_id = ?').run(targetRow.id, source.id);
456
+ citationsMoved += after;
457
+ }
458
+ // Update target: verified_at, freshness_anchor, updated.
459
+ db.prepare('UPDATE pages SET verified_at = ?, freshness_anchor = ?, updated = ? WHERE id = ?').run(maxVerifiedAt, freshnessAnchor ?? null, timestamp, targetRow.id);
460
+ // Optionally append loser bodies to target body.
461
+ if (input.append_loser_bodies) {
462
+ let appendedBody = targetRow.body;
463
+ for (const loser of losers) {
464
+ appendedBody += `\n\n--- merged from ${loser.slug} ---\n\n${loser.body}`;
465
+ }
466
+ enforcePageBodyCap(appendedBody);
467
+ db.prepare('UPDATE pages SET body = ? WHERE id = ?').run(appendedBody, targetRow.id);
468
+ }
469
+ // Archive each source and record the supersession.
470
+ for (const source of sourceRows) {
471
+ const tombstoneNote = input.note
472
+ ? `Merged into ${input.target_slug}. ${input.note}`
473
+ : `Merged into ${input.target_slug}.`;
474
+ db.prepare("UPDATE pages SET status = 'archived', tombstone_note = ?, updated = ? WHERE id = ?").run(tombstoneNote, timestamp, source.id);
475
+ db.prepare('INSERT INTO supersessions (old_slug, new_slug, note, created) VALUES (?, ?, ?, ?)').run(source.slug, input.target_slug, input.note ?? null, timestamp);
476
+ }
477
+ // hard_delete_losers: archive first (supersession pointer written above), then DELETE.
478
+ // Citations cascade via FK ON DELETE CASCADE; supersession rows survive (plain TEXT refs, no FK).
479
+ if (input.hard_delete_losers) {
480
+ for (const source of sourceRows) {
481
+ db.prepare('DELETE FROM pages WHERE id = ?').run(source.id);
482
+ }
483
+ }
484
+ });
485
+ retryWrite(() => tx());
486
+ return Promise.resolve({
487
+ target_slug: input.target_slug,
488
+ sources_merged: sourceRows.length,
489
+ citations_moved: citationsMoved,
490
+ citations_deduped: citationsDeduped,
491
+ verified_at: maxVerifiedAt,
492
+ losers,
493
+ });
494
+ }
495
+ catch (e) {
496
+ return Promise.reject(e);
497
+ }
498
+ }
499
+ purgePages(input) {
500
+ try {
501
+ const db = this.ensureOpen();
502
+ if (!input.slugs || input.slugs.length === 0) {
503
+ return Promise.reject(new Error('purgePages: slugs must not be empty'));
504
+ }
505
+ if (input.confirm !== true) {
506
+ return Promise.reject(new Error('purgePages: confirm must be explicitly true'));
507
+ }
508
+ const rows = [];
509
+ for (const slug of input.slugs) {
510
+ const page = db.prepare('SELECT id, status FROM pages WHERE slug = ?').get(slug);
511
+ if (!page) {
512
+ return Promise.reject(new Error(`purgePages: page not found: ${slug}`));
513
+ }
514
+ if (page.status !== 'archived') {
515
+ return Promise.reject(new Error(`purgePages: page '${slug}' is not archived — archive it first before purging`));
516
+ }
517
+ rows.push({ id: page.id, slug });
518
+ }
519
+ // All checks passed — delete in a single transaction.
520
+ // Citations cascade via FK ON DELETE CASCADE.
521
+ // Supersession rows survive (plain TEXT refs, no FK).
522
+ const tx = db.transaction(() => {
523
+ for (const row of rows) {
524
+ db.prepare('DELETE FROM pages WHERE id = ?').run(row.id);
525
+ }
526
+ });
527
+ retryWrite(() => tx());
528
+ return Promise.resolve({ purged: input.slugs.length, slugs: input.slugs });
529
+ }
530
+ catch (e) {
531
+ return Promise.reject(e);
532
+ }
533
+ }
534
+ verifyPages(input) {
535
+ try {
536
+ const db = this.ensureOpen();
537
+ const timestamp = new Date().toISOString();
538
+ const verifiedAt = input.verified_at ?? timestamp;
539
+ const single = input.slug !== undefined;
540
+ const batch = input.slugs !== undefined;
541
+ if (single === batch) {
542
+ return Promise.reject(new Error('verifyPages: provide exactly one of slug (single) or slugs (batch)'));
543
+ }
544
+ if (batch && (input.note !== undefined || input.freshness_anchor !== undefined)) {
545
+ return Promise.reject(new Error('verifyPages: note and freshness_anchor are single-page options — not allowed in batch mode'));
546
+ }
547
+ const slugs = single ? [input.slug] : input.slugs;
548
+ if (slugs.length === 0) {
549
+ return Promise.reject(new Error('verifyPages: slugs must not be empty'));
550
+ }
551
+ const rows = [];
552
+ for (const slug of slugs) {
553
+ const page = db.prepare('SELECT id, status, body FROM pages WHERE slug = ?').get(slug);
554
+ if (!page) {
555
+ return Promise.reject(new Error(`verifyPages: page not found: ${slug}`));
556
+ }
557
+ if (page.status === 'archived') {
558
+ return Promise.reject(new Error(`verifyPages: page '${slug}' is archived — restore it before verifying`));
559
+ }
560
+ rows.push({ id: page.id, slug, body: page.body });
561
+ }
562
+ let noted = false;
563
+ const tx = db.transaction(() => {
564
+ for (const row of rows) {
565
+ db.prepare('UPDATE pages SET verified_at = ?, freshness_anchor = COALESCE(?, freshness_anchor) WHERE id = ?').run(verifiedAt, input.freshness_anchor ?? null, row.id);
566
+ if (input.note) {
567
+ const dateLabel = verifiedAt.slice(0, 10);
568
+ const newBody = `${row.body}\n\n## Verification — ${dateLabel}\n\n${input.note}`;
569
+ enforcePageBodyCap(newBody);
570
+ // A note changes the body, so it bumps `updated` — a pure stamp
571
+ // deliberately does not (updated means "content changed").
572
+ db.prepare('UPDATE pages SET body = ?, updated = ? WHERE id = ?').run(newBody, timestamp, row.id);
573
+ noted = true;
574
+ }
575
+ }
576
+ });
577
+ retryWrite(() => tx());
578
+ return Promise.resolve({
579
+ verified: rows.length,
580
+ slugs: rows.map((r) => r.slug),
581
+ verified_at: verifiedAt,
582
+ noted,
583
+ });
584
+ }
585
+ catch (e) {
586
+ return Promise.reject(e);
587
+ }
588
+ }
589
+ listRevisions(slug) {
590
+ try {
591
+ const db = this.ensureOpen();
592
+ const page = db.prepare('SELECT id FROM pages WHERE slug = ?').get(slug);
593
+ if (!page) {
594
+ return Promise.reject(new Error(`listRevisions: page not found: ${slug}`));
595
+ }
596
+ const rows = db.prepare(`SELECT id, page_id, slug, op, replaced_at, LENGTH(body) AS body_length
597
+ FROM page_revisions WHERE page_id = ? ORDER BY id DESC`).all(page.id);
598
+ return Promise.resolve(rows);
599
+ }
600
+ catch (e) {
601
+ return Promise.reject(e);
602
+ }
603
+ }
604
+ getRevision(revisionId) {
605
+ try {
606
+ const db = this.ensureOpen();
607
+ const row = db.prepare('SELECT id, page_id, slug, op, replaced_at, body FROM page_revisions WHERE id = ?').get(revisionId);
608
+ return Promise.resolve(row ?? null);
609
+ }
610
+ catch (e) {
611
+ return Promise.reject(e);
612
+ }
613
+ }
614
+ restoreRevision(input) {
615
+ try {
616
+ const db = this.ensureOpen();
617
+ const timestamp = new Date().toISOString();
618
+ const page = db.prepare('SELECT id, body FROM pages WHERE slug = ?').get(input.slug);
619
+ if (!page) {
620
+ return Promise.reject(new Error(`restoreRevision: page not found: ${input.slug}`));
621
+ }
622
+ const revision = db.prepare('SELECT id, page_id, body FROM page_revisions WHERE id = ?').get(input.revision_id);
623
+ if (!revision) {
624
+ return Promise.reject(new Error(`restoreRevision: revision not found: ${input.revision_id}`));
625
+ }
626
+ if (revision.page_id !== page.id) {
627
+ return Promise.reject(new Error(`restoreRevision: revision ${input.revision_id} belongs to a different page than '${input.slug}'`));
628
+ }
629
+ let snapshotId = 0;
630
+ const tx = db.transaction(() => {
631
+ // The body being displaced is itself preserved — a restore must
632
+ // never be the second irreversible overwrite in the story.
633
+ snapshotId = this.snapshotRevision(db, page.id, input.slug, page.body, 'history-restore', timestamp);
634
+ db.prepare('UPDATE pages SET body = ?, updated = ? WHERE id = ?').run(revision.body, timestamp, page.id);
635
+ });
636
+ retryWrite(() => tx());
637
+ return Promise.resolve({
638
+ slug: input.slug,
639
+ revision_id: input.revision_id,
640
+ restored: true,
641
+ snapshot_id: snapshotId,
642
+ });
643
+ }
644
+ catch (e) {
645
+ return Promise.reject(e);
646
+ }
647
+ }
648
+ /**
649
+ * Insert a body snapshot and prune beyond the per-page cap. Caller owns the
650
+ * transaction when called from restoreRevision; called bare from writePage.
651
+ * Each statement is wrapped in retryWrite so both call sites are safe.
652
+ */
653
+ snapshotRevision(db, pageId, slug, body, op, timestamp) {
654
+ const result = retryWrite(() => db.prepare('INSERT INTO page_revisions (page_id, slug, body, op, replaced_at) VALUES (?, ?, ?, ?, ?)').run(pageId, slug, body, op, timestamp));
655
+ retryWrite(() => db.prepare(`DELETE FROM page_revisions WHERE page_id = ? AND id NOT IN (
656
+ SELECT id FROM page_revisions WHERE page_id = ? ORDER BY id DESC LIMIT ?
657
+ )`).run(pageId, pageId, MAX_REVISIONS_PER_PAGE));
658
+ return Number(result.lastInsertRowid);
659
+ }
660
+ addCitations(slug, citations) {
661
+ try {
662
+ const db = this.ensureOpen();
663
+ const page = db.prepare('SELECT id FROM pages WHERE slug = ?').get(slug);
664
+ if (!page) {
665
+ throw new Error(`addCitations: page not found: ${slug}`);
666
+ }
667
+ const timestamp = new Date().toISOString();
668
+ let added = 0;
669
+ const insert = db.prepare(`INSERT INTO citations (page_id, claim, source_kind, source_locator, excerpt, retrieved_at, created)
670
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
671
+ const tx = db.transaction(() => {
672
+ for (const cit of citations) {
673
+ enforceExcerptCap(cit.excerpt);
674
+ insert.run(page.id, cit.claim, cit.source_kind, cit.source_locator ?? null, cit.excerpt, timestamp, timestamp);
675
+ added++;
676
+ }
677
+ });
678
+ retryWrite(() => tx());
679
+ return Promise.resolve(added);
680
+ }
681
+ catch (e) {
682
+ return Promise.reject(e);
683
+ }
684
+ }
685
+ close() {
686
+ if (this.db) {
687
+ this.db.close();
688
+ this.db = null;
689
+ }
690
+ }
691
+ // ── Internals ──
692
+ ensureOpen() {
693
+ if (!this.db) {
694
+ throw new Error('KnowledgeBackend is already closed');
695
+ }
696
+ return this.db;
697
+ }
698
+ fetchCitationsForPage(db, pageId) {
699
+ return db.prepare('SELECT * FROM citations WHERE page_id = ? ORDER BY id').all(pageId);
700
+ }
701
+ initSchema() {
702
+ const statements = [
703
+ `CREATE TABLE IF NOT EXISTS pages (
704
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
705
+ uuid TEXT NOT NULL UNIQUE,
706
+ slug TEXT NOT NULL UNIQUE,
707
+ title TEXT NOT NULL,
708
+ domain TEXT NOT NULL,
709
+ body TEXT NOT NULL,
710
+ sourcing TEXT NOT NULL DEFAULT 'sourced',
711
+ provenance TEXT,
712
+ status TEXT NOT NULL DEFAULT 'active',
713
+ created TEXT NOT NULL,
714
+ updated TEXT,
715
+ last_accessed TEXT,
716
+ hit_count INTEGER NOT NULL DEFAULT 0,
717
+ verified_at TEXT,
718
+ freshness_anchor TEXT
719
+ )`,
720
+ `CREATE INDEX IF NOT EXISTS idx_pages_domain ON pages(domain)`,
721
+ `CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages(slug)`,
722
+ `CREATE INDEX IF NOT EXISTS idx_pages_status ON pages(status)`,
723
+ `CREATE TABLE IF NOT EXISTS citations (
724
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
725
+ page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
726
+ claim TEXT NOT NULL,
727
+ source_kind TEXT NOT NULL,
728
+ source_locator TEXT,
729
+ excerpt TEXT NOT NULL,
730
+ retrieved_at TEXT NOT NULL,
731
+ created TEXT NOT NULL
732
+ )`,
733
+ `CREATE INDEX IF NOT EXISTS idx_citations_page ON citations(page_id)`,
734
+ `CREATE TABLE IF NOT EXISTS supersessions (
735
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
736
+ old_slug TEXT NOT NULL,
737
+ new_slug TEXT NOT NULL,
738
+ note TEXT,
739
+ created TEXT NOT NULL
740
+ )`,
741
+ `CREATE INDEX IF NOT EXISTS idx_supersessions_old ON supersessions(old_slug)`,
742
+ `CREATE INDEX IF NOT EXISTS idx_supersessions_new ON supersessions(new_slug)`,
743
+ `CREATE TABLE IF NOT EXISTS page_revisions (
744
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
745
+ page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
746
+ slug TEXT NOT NULL,
747
+ body TEXT NOT NULL,
748
+ op TEXT NOT NULL,
749
+ replaced_at TEXT NOT NULL
750
+ )`,
751
+ `CREATE INDEX IF NOT EXISTS idx_page_revisions_page ON page_revisions(page_id)`,
752
+ ];
753
+ for (const sql of statements) {
754
+ this.db.prepare(sql).run();
755
+ }
756
+ // Idempotent migrations for existing DBs (ALTER ADD COLUMN has no IF NOT EXISTS).
757
+ const pageCols = this.db.prepare('PRAGMA table_info(pages)').all().map((c) => c.name);
758
+ if (!pageCols.includes('verified_at')) {
759
+ this.db.prepare('ALTER TABLE pages ADD COLUMN verified_at TEXT').run();
760
+ }
761
+ if (!pageCols.includes('freshness_anchor')) {
762
+ this.db.prepare('ALTER TABLE pages ADD COLUMN freshness_anchor TEXT').run();
763
+ }
764
+ if (!pageCols.includes('tombstone_note')) {
765
+ this.db.prepare('ALTER TABLE pages ADD COLUMN tombstone_note TEXT').run();
766
+ }
767
+ // ours/ class metadata (t-81, 2026-08-31).
768
+ if (!pageCols.includes('created_by')) {
769
+ this.db.prepare('ALTER TABLE pages ADD COLUMN created_by TEXT').run();
770
+ }
771
+ if (!pageCols.includes('version')) {
772
+ this.db.prepare('ALTER TABLE pages ADD COLUMN version TEXT').run();
773
+ }
774
+ }
775
+ }
776
+ // ── Helpers ──
777
+ function enforcePageBodyCap(body) {
778
+ if (body.length > MAX_PAGE_BODY_LENGTH) {
779
+ throw new Error(`Page body exceeds hard cap of ${MAX_PAGE_BODY_LENGTH} characters ` +
780
+ `(got ${body.length}). The knowledge backend enforces a size limit to bound memory and disk usage.`);
781
+ }
782
+ }
783
+ function enforceExcerptCap(excerpt) {
784
+ if (excerpt.length > MAX_CITATION_EXCERPT_LENGTH) {
785
+ throw new Error(`Citation excerpt exceeds hard cap of ${MAX_CITATION_EXCERPT_LENGTH} characters ` +
786
+ `(got ${excerpt.length}).`);
787
+ }
788
+ }