@lemoncat7/dsh-knowledge 2.2.1 → 2.2.8

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 (59) hide show
  1. package/docs/architecture.md +5 -5
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +55 -24
  4. package/lib/api.js.map +1 -1
  5. package/lib/async-pool.d.ts +7 -0
  6. package/lib/async-pool.d.ts.map +1 -0
  7. package/lib/async-pool.js +21 -0
  8. package/lib/async-pool.js.map +1 -0
  9. package/lib/client.d.ts.map +1 -1
  10. package/lib/client.js +53 -17
  11. package/lib/client.js.map +2 -2
  12. package/lib/domain.d.ts +16 -0
  13. package/lib/domain.d.ts.map +1 -1
  14. package/lib/domain.js.map +1 -1
  15. package/lib/extraction.d.ts +3 -0
  16. package/lib/extraction.d.ts.map +1 -1
  17. package/lib/extraction.js +65 -11
  18. package/lib/extraction.js.map +1 -1
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +101 -29
  21. package/lib/index.js.map +1 -1
  22. package/lib/local-provider.d.ts +13 -2
  23. package/lib/local-provider.d.ts.map +1 -1
  24. package/lib/local-provider.js +307 -73
  25. package/lib/local-provider.js.map +1 -1
  26. package/lib/note-tools.js +4 -4
  27. package/lib/note-tools.js.map +1 -1
  28. package/lib/provider-router.d.ts +6 -2
  29. package/lib/provider-router.d.ts.map +1 -1
  30. package/lib/provider-router.js +10 -6
  31. package/lib/provider-router.js.map +1 -1
  32. package/lib/provider.d.ts +4 -2
  33. package/lib/provider.d.ts.map +1 -1
  34. package/lib/remote-provider.d.ts +4 -2
  35. package/lib/remote-provider.d.ts.map +1 -1
  36. package/lib/remote-provider.js +22 -2
  37. package/lib/remote-provider.js.map +1 -1
  38. package/lib/retrieval.d.ts.map +1 -1
  39. package/lib/retrieval.js +9 -7
  40. package/lib/retrieval.js.map +1 -1
  41. package/lib/tool-authorization.d.ts +3 -1
  42. package/lib/tool-authorization.d.ts.map +1 -1
  43. package/lib/tool-authorization.js +27 -14
  44. package/lib/tool-authorization.js.map +1 -1
  45. package/lib/tools.js +1 -1
  46. package/lib/tools.js.map +1 -1
  47. package/lib/tracking.d.ts.map +1 -1
  48. package/lib/tracking.js +8 -3
  49. package/lib/tracking.js.map +1 -1
  50. package/lib/web-workspace-effects.d.ts.map +1 -1
  51. package/lib/web-workspace-effects.js +10 -1
  52. package/lib/web-workspace-effects.js.map +1 -1
  53. package/lib/web.d.ts.map +1 -1
  54. package/lib/web.js +7 -4
  55. package/lib/web.js.map +1 -1
  56. package/package.json +2 -2
  57. package/web/app.js +215 -21
  58. package/web/styles.css +31 -17
  59. package/web/workspace-effects.js +2 -2
@@ -4,7 +4,7 @@ import { mkdirSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { DatabaseSync } from 'node:sqlite';
6
6
  import { contentHash, DEFAULT_KNOWLEDGE_BASE_ID, newId, normalizeDraft, normalizeKnowledgeBaseDraft, normalizeKnowledgeMountDraft, normalizeKnowledgeSettings, nowIso, } from './domain.js';
7
- import { renderKnowledgeMarkdown } from './documents/markdown.js';
7
+ import { markdownHash, renderKnowledgeMarkdown } from './documents/markdown.js';
8
8
  import { knowledgeDocumentPath } from './documents/path.js';
9
9
  import { KnowledgeDocumentStore } from './documents/store.js';
10
10
  import { enqueueDocumentProjection } from './documents/projection-queue.js';
@@ -33,10 +33,11 @@ export class LocalKnowledgeProvider {
33
33
  if (path !== ':memory:')
34
34
  mkdirSync(dirname(path), { recursive: true });
35
35
  const inMemory = path === ':memory:';
36
- this.notes = new NoteStore(inMemory
37
- ? join(tmpdir(), `dsh-knowledge-notes-${randomUUID()}`)
38
- : join(dirname(path), 'notes'), inMemory);
39
- this.documentStore = new KnowledgeDocumentStore(join(dirname(path), 'documents'));
36
+ const storageRoot = inMemory
37
+ ? join(tmpdir(), `dsh-knowledge-${randomUUID()}`)
38
+ : dirname(path);
39
+ this.notes = new NoteStore(inMemory ? storageRoot : join(storageRoot, 'notes'), inMemory);
40
+ this.documentStore = new KnowledgeDocumentStore(join(storageRoot, 'documents'));
40
41
  this.db = new DatabaseSync(path);
41
42
  this.db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
42
43
  this.migrate();
@@ -44,7 +45,7 @@ export class LocalKnowledgeProvider {
44
45
  }
45
46
  migrate() {
46
47
  let version = Number(this.db.prepare('PRAGMA user_version').get().user_version ?? 0);
47
- if (version > 11)
48
+ if (version > 13)
48
49
  throw new Error(`knowledge database schema ${version} is newer than this plugin supports`);
49
50
  if (version === 0)
50
51
  this.db.exec(`
@@ -296,6 +297,52 @@ export class LocalKnowledgeProvider {
296
297
  throw error;
297
298
  }
298
299
  }
300
+ if (version <= 10)
301
+ version = 11;
302
+ if (version === 11)
303
+ this.db.exec(`
304
+ BEGIN IMMEDIATE;
305
+ CREATE INDEX IF NOT EXISTS knowledge_documents_index_order ON knowledge_documents(
306
+ knowledge_base_id,
307
+ (CASE WHEN rel_path='README.md' THEN 0 ELSE 1 END),
308
+ rel_path,
309
+ id
310
+ );
311
+ PRAGMA user_version = 12;
312
+ COMMIT;
313
+ `);
314
+ if (version <= 11)
315
+ version = 12;
316
+ if (version === 12) {
317
+ this.db.exec('BEGIN IMMEDIATE');
318
+ try {
319
+ const extractionTable = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='extraction_jobs'").get();
320
+ if (extractionTable === undefined) {
321
+ this.db.exec(`
322
+ CREATE TABLE extraction_jobs (
323
+ source_key TEXT PRIMARY KEY,
324
+ status TEXT NOT NULL CHECK(status IN ('running','completed','failed')),
325
+ attempts INTEGER NOT NULL,
326
+ candidate_count INTEGER NOT NULL DEFAULT 0,
327
+ last_error TEXT,
328
+ completion_json TEXT,
329
+ updated_at TEXT NOT NULL
330
+ )
331
+ `);
332
+ }
333
+ else {
334
+ const extractionColumns = this.db.prepare('PRAGMA table_info(extraction_jobs)').all();
335
+ if (!extractionColumns.some(column => String(column.name) === 'completion_json')) {
336
+ this.db.exec('ALTER TABLE extraction_jobs ADD COLUMN completion_json TEXT');
337
+ }
338
+ }
339
+ this.db.exec('PRAGMA user_version = 13; COMMIT');
340
+ }
341
+ catch (error) {
342
+ this.db.exec('ROLLBACK');
343
+ throw error;
344
+ }
345
+ }
299
346
  // Alpha v2 used a migration note as the default base's routing description.
300
347
  // Clear only that exact placeholder so existing user-authored descriptions stay untouched.
301
348
  this.db.prepare("UPDATE knowledge_bases SET description='' WHERE id=? AND description=?")
@@ -371,7 +418,7 @@ export class LocalKnowledgeProvider {
371
418
  id,name,description,default_tags_json,extraction_instructions,writeback_policy,writeback_provider,writeback_model,status,created_at,updated_at
372
419
  ) VALUES(?,?,?,?,?,?,?,?,'active',?,?)
373
420
  `).run(base.id, base.name, base.description, JSON.stringify(base.defaultTags), base.extractionInstructions, base.writebackPolicy, base.writebackProvider ?? null, base.writebackModel ?? null, timestamp, timestamp);
374
- await this.syncKnowledgeDocumentsQueued(base.id);
421
+ await this.syncKnowledgeBaseManifestQueued(base.id);
375
422
  return base;
376
423
  }
377
424
  async updateKnowledgeBase(id, input) {
@@ -393,7 +440,7 @@ export class LocalKnowledgeProvider {
393
440
  name=?,description=?,default_tags_json=?,extraction_instructions=?,writeback_policy=?,writeback_provider=?,writeback_model=?,updated_at=?
394
441
  WHERE id=?
395
442
  `).run(updated.name, updated.description, JSON.stringify(updated.defaultTags), updated.extractionInstructions, updated.writebackPolicy, updated.writebackProvider ?? null, updated.writebackModel ?? null, updated.updatedAt, id);
396
- await this.syncKnowledgeDocumentsQueued(id);
443
+ await this.syncKnowledgeBaseManifestQueued(id);
397
444
  return updated;
398
445
  }
399
446
  async patchKnowledgeBase(id, patch) {
@@ -642,11 +689,12 @@ export class LocalKnowledgeProvider {
642
689
  resolved.set(mount.knowledgeBaseId, { mount, inheritedFrom: 'project' });
643
690
  for (const mount of session)
644
691
  resolved.set(mount.knowledgeBaseId, { mount });
692
+ const bases = new Map((await this.listKnowledgeBases()).map(base => [base.id, base]));
645
693
  const output = [];
646
694
  for (const { mount, inheritedFrom } of resolved.values()) {
647
695
  if (!mount.enabled)
648
696
  continue;
649
- const base = await this.getKnowledgeBase(mount.knowledgeBaseId);
697
+ const base = bases.get(mount.knowledgeBaseId);
650
698
  if (base === undefined || base.status !== 'active')
651
699
  continue;
652
700
  output.push({ ...mount, base, ...inheritedFrom === undefined ? {} : { inheritedFrom } });
@@ -823,6 +871,24 @@ export class LocalKnowledgeProvider {
823
871
  const row = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id = ?`).get(id);
824
872
  return row === undefined ? undefined : rowToEntry(row);
825
873
  }
874
+ /** Management-only bulk lookup used to avoid one HTTP/SQL round trip per review target. */
875
+ entriesByIds(ids) {
876
+ this.assertOpen();
877
+ const uniqueIds = [...new Set(ids.map(id => id.trim()).filter(Boolean))].slice(0, 100);
878
+ if (uniqueIds.length === 0)
879
+ return [];
880
+ const placeholders = uniqueIds.map(() => '?').join(',');
881
+ const rows = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id IN (${placeholders})`)
882
+ .all(...uniqueIds);
883
+ const entries = new Map(rows.map(row => {
884
+ const entry = rowToEntry(row);
885
+ return [entry.id, entry];
886
+ }));
887
+ return uniqueIds.flatMap(id => {
888
+ const entry = entries.get(id);
889
+ return entry === undefined ? [] : [entry];
890
+ });
891
+ }
826
892
  async versions(id) {
827
893
  this.assertOpen();
828
894
  const rows = this.db.prepare('SELECT * FROM knowledge_versions WHERE knowledge_id = ? ORDER BY version DESC').all(id);
@@ -832,7 +898,7 @@ export class LocalKnowledgeProvider {
832
898
  this.assertOpen();
833
899
  await this.documentsReady;
834
900
  const entry = this.transaction(() => this.insertEntry(draft));
835
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
901
+ await this.syncKnowledgeEntryQueued(entry.id);
836
902
  return entry;
837
903
  }
838
904
  insertEntry(input) {
@@ -859,11 +925,8 @@ export class LocalKnowledgeProvider {
859
925
  async update(id, draft) {
860
926
  this.assertOpen();
861
927
  await this.documentsReady;
862
- const current = await this.get(id);
863
928
  const entry = this.transaction(() => this.updateEntry(id, draft, 'update'));
864
- if (current !== undefined && current.knowledgeBaseId !== entry.knowledgeBaseId)
865
- await this.syncKnowledgeDocumentsQueued(current.knowledgeBaseId);
866
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
929
+ await this.syncKnowledgeEntryQueued(entry.id);
867
930
  return entry;
868
931
  }
869
932
  async finalize(id, state, note) {
@@ -899,7 +962,7 @@ export class LocalKnowledgeProvider {
899
962
  this.upsertFts(updated);
900
963
  return updated;
901
964
  });
902
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
965
+ await this.syncKnowledgeEntryQueued(entry.id);
903
966
  return entry;
904
967
  }
905
968
  async reopen(id) {
@@ -931,7 +994,42 @@ export class LocalKnowledgeProvider {
931
994
  this.upsertFts(updated);
932
995
  return updated;
933
996
  });
934
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
997
+ await this.syncKnowledgeEntryQueued(entry.id);
998
+ return entry;
999
+ }
1000
+ async moveDocument(id, knowledgeBaseId) {
1001
+ this.assertOpen();
1002
+ await this.documentsReady;
1003
+ let changed = false;
1004
+ const entry = this.transaction(() => {
1005
+ const row = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id=?`).get(id);
1006
+ if (row === undefined)
1007
+ throw notFound('knowledge entry', id);
1008
+ const current = rowToEntry(row);
1009
+ if (current.status !== 'active')
1010
+ throw conflict('only active knowledge documents can be moved');
1011
+ if (current.knowledgeBaseId === knowledgeBaseId)
1012
+ return current;
1013
+ if (this.db.prepare("SELECT id FROM knowledge_bases WHERE id=? AND status='active'").get(knowledgeBaseId) === undefined) {
1014
+ throw notFound('active knowledge base', knowledgeBaseId);
1015
+ }
1016
+ const updated = {
1017
+ ...current,
1018
+ knowledgeBaseId,
1019
+ version: current.version + 1,
1020
+ updatedAt: nowIso(),
1021
+ };
1022
+ this.db.prepare(`
1023
+ UPDATE knowledge_entries
1024
+ SET knowledge_base_id=?,version=?,updated_at=?
1025
+ WHERE id=?
1026
+ `).run(knowledgeBaseId, updated.version, updated.updatedAt, id);
1027
+ this.writeVersion(updated, 'update');
1028
+ changed = true;
1029
+ return updated;
1030
+ });
1031
+ if (changed)
1032
+ await this.syncKnowledgeEntryQueued(entry.id);
935
1033
  return entry;
936
1034
  }
937
1035
  updateEntry(id, input, changeKind) {
@@ -984,7 +1082,7 @@ export class LocalKnowledgeProvider {
984
1082
  this.db.prepare('DELETE FROM knowledge_fts WHERE knowledge_id = ?').run(id);
985
1083
  return updated;
986
1084
  });
987
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
1085
+ await this.syncKnowledgeEntryQueued(entry.id);
988
1086
  return entry;
989
1087
  }
990
1088
  async delete(id) {
@@ -998,7 +1096,7 @@ export class LocalKnowledgeProvider {
998
1096
  throw notFound('knowledge entry', id);
999
1097
  });
1000
1098
  if (current !== undefined)
1001
- await this.syncKnowledgeDocumentsQueued(current.knowledgeBaseId);
1099
+ await this.syncKnowledgeEntryQueued(current.id);
1002
1100
  }
1003
1101
  async listNotes(request = {}) {
1004
1102
  this.assertOpen();
@@ -1138,6 +1236,30 @@ export class LocalKnowledgeProvider {
1138
1236
  documentTitle: String(row.document_title),
1139
1237
  }));
1140
1238
  }
1239
+ /** Compatibility path for manually embedded legacy note:// markers. */
1240
+ legacyNoteReferencesForNotes(noteIds) {
1241
+ this.assertOpen();
1242
+ const requested = new Set(noteIds.map(id => id.toLocaleLowerCase()));
1243
+ if (requested.size === 0)
1244
+ return [];
1245
+ const rows = this.db.prepare(`
1246
+ SELECT knowledge_base_id,id,title,content
1247
+ FROM knowledge_documents
1248
+ WHERE content LIKE '%note://note_%'
1249
+ ORDER BY updated_at DESC,id
1250
+ `).all();
1251
+ return rows.flatMap(row => {
1252
+ const references = String(row.content).match(/note:\/\/(note_[a-f0-9]{32})/giu) ?? [];
1253
+ return [...new Set(references.map(value => value.slice('note://'.length).toLocaleLowerCase()))]
1254
+ .filter(noteId => requested.has(noteId))
1255
+ .map(noteId => ({
1256
+ noteId,
1257
+ knowledgeBaseId: String(row.knowledge_base_id),
1258
+ documentId: String(row.id),
1259
+ documentTitle: String(row.title),
1260
+ }));
1261
+ });
1262
+ }
1141
1263
  deleteNoteReferences(noteIds) {
1142
1264
  this.assertOpen();
1143
1265
  const ids = [...new Set(noteIds)];
@@ -1157,7 +1279,7 @@ export class LocalKnowledgeProvider {
1157
1279
  async writeDirect(input, sourceKey) {
1158
1280
  this.assertOpen();
1159
1281
  await this.documentsReady;
1160
- let touchedBaseId;
1282
+ let touchedEntryId;
1161
1283
  const result = this.transaction(() => {
1162
1284
  const resolution = this.resolveDirectProposal(normalizeProposal(input));
1163
1285
  if (resolution.outcome === 'duplicate')
@@ -1177,7 +1299,7 @@ export class LocalKnowledgeProvider {
1177
1299
  .run('approved', reviewedAt, resolution.outcome === 'merged'
1178
1300
  ? 'Automatically merged by direct-write reconciliation.'
1179
1301
  : 'Automatically approved by direct-write policy.', candidate.id);
1180
- touchedBaseId = entry.knowledgeBaseId;
1302
+ touchedEntryId = entry.id;
1181
1303
  return {
1182
1304
  outcome: resolution.outcome,
1183
1305
  candidate: {
@@ -1191,8 +1313,8 @@ export class LocalKnowledgeProvider {
1191
1313
  entry,
1192
1314
  };
1193
1315
  });
1194
- if (touchedBaseId !== undefined)
1195
- await this.syncKnowledgeDocumentsQueued(touchedBaseId);
1316
+ if (touchedEntryId !== undefined)
1317
+ await this.syncKnowledgeEntryQueued(touchedEntryId);
1196
1318
  return result;
1197
1319
  }
1198
1320
  insertCandidate(proposal, sourceKey) {
@@ -1303,6 +1425,7 @@ export class LocalKnowledgeProvider {
1303
1425
  async review(id, decision) {
1304
1426
  this.assertOpen();
1305
1427
  await this.documentsReady;
1428
+ let touchedEntryId;
1306
1429
  const reviewed = this.transaction(() => {
1307
1430
  const row = this.db.prepare('SELECT * FROM knowledge_candidates WHERE id = ?').get(id);
1308
1431
  if (row === undefined)
@@ -1323,7 +1446,7 @@ export class LocalKnowledgeProvider {
1323
1446
  throw conflict('candidate approval cannot move a document between knowledge bases');
1324
1447
  }
1325
1448
  assertExpectedReviewVersion(target, decision.expectedVersion);
1326
- this.updateEntry(candidate.targetId, editedKnowledgeDraft(target, draft), 'update');
1449
+ touchedEntryId = this.updateEntry(candidate.targetId, editedKnowledgeDraft(target, draft), 'update').id;
1327
1450
  }
1328
1451
  else if (candidate.action === 'conflict') {
1329
1452
  if (decision.resolution !== 'merge') {
@@ -1338,7 +1461,7 @@ export class LocalKnowledgeProvider {
1338
1461
  const applied = applyCandidateToTarget(target, candidate, true);
1339
1462
  if (!applied.ok)
1340
1463
  throw conflict(`${applied.reason}; edit the current document to resolve this conflict`);
1341
- this.updateEntry(candidate.targetId, applied.draft, 'update');
1464
+ touchedEntryId = this.updateEntry(candidate.targetId, applied.draft, 'update').id;
1342
1465
  }
1343
1466
  else {
1344
1467
  const resolution = this.resolveDirectProposal({
@@ -1366,10 +1489,10 @@ export class LocalKnowledgeProvider {
1366
1489
  if (resolution.outcome === 'finalized')
1367
1490
  throw finalizedConflict(resolution.entry);
1368
1491
  if (resolution.outcome !== 'duplicate') {
1369
- if (resolution.proposal.action === 'create')
1370
- this.insertEntry(resolution.proposal.draft);
1371
- else
1372
- this.updateEntry(resolution.proposal.targetId, resolution.proposal.draft, 'update');
1492
+ const entry = resolution.proposal.action === 'create'
1493
+ ? this.insertEntry(resolution.proposal.draft)
1494
+ : this.updateEntry(resolution.proposal.targetId, resolution.proposal.draft, 'update');
1495
+ touchedEntryId = entry.id;
1373
1496
  }
1374
1497
  }
1375
1498
  }
@@ -1380,8 +1503,8 @@ export class LocalKnowledgeProvider {
1380
1503
  .run(status, reviewedAt, note ?? null, id);
1381
1504
  return { ...candidate, status, reviewedAt, ...note === undefined ? {} : { reviewNote: note } };
1382
1505
  });
1383
- if (decision.decision === 'approve')
1384
- await this.syncKnowledgeDocumentsQueued(reviewed.draft.knowledgeBaseId);
1506
+ if (touchedEntryId !== undefined)
1507
+ await this.syncKnowledgeEntryQueued(touchedEntryId);
1385
1508
  return reviewed;
1386
1509
  }
1387
1510
  async claimExtraction(sourceKey) {
@@ -1393,7 +1516,7 @@ export class LocalKnowledgeProvider {
1393
1516
  VALUES(?,'running',1,0,?)
1394
1517
  ON CONFLICT(source_key) DO UPDATE SET
1395
1518
  status='running', attempts=extraction_jobs.attempts+1, candidate_count=0,
1396
- last_error=NULL, updated_at=excluded.updated_at
1519
+ last_error=NULL, completion_json=NULL, updated_at=excluded.updated_at
1397
1520
  WHERE extraction_jobs.attempts < 3 AND (
1398
1521
  extraction_jobs.status='failed'
1399
1522
  OR (extraction_jobs.status='running' AND extraction_jobs.updated_at < ?)
@@ -1401,12 +1524,13 @@ export class LocalKnowledgeProvider {
1401
1524
  `).run(sourceKey, claimedAt, staleBefore);
1402
1525
  return result.changes === 1;
1403
1526
  }
1404
- async completeExtraction(sourceKey, candidateCount) {
1527
+ async completeExtraction(sourceKey, value) {
1405
1528
  this.assertOpen();
1529
+ const completion = normalizeExtractionCompletion(value);
1406
1530
  const result = this.db.prepare(`
1407
- UPDATE extraction_jobs SET status='completed', candidate_count=?, last_error=NULL, updated_at=?
1531
+ UPDATE extraction_jobs SET status='completed', candidate_count=?, last_error=NULL, completion_json=?, updated_at=?
1408
1532
  WHERE source_key=? AND status='running'
1409
- `).run(candidateCount, nowIso(), sourceKey);
1533
+ `).run(completion.candidateCount, JSON.stringify(completion), nowIso(), sourceKey);
1410
1534
  if (result.changes === 0) {
1411
1535
  const current = await this.extractionJob(sourceKey);
1412
1536
  if (current?.status !== 'completed')
@@ -1427,7 +1551,7 @@ export class LocalKnowledgeProvider {
1427
1551
  }
1428
1552
  async resetExtraction(sourceKey) {
1429
1553
  this.assertOpen();
1430
- this.db.prepare(`UPDATE extraction_jobs SET status='failed', attempts=0, candidate_count=0, last_error=NULL, updated_at=? WHERE source_key=?`)
1554
+ this.db.prepare(`UPDATE extraction_jobs SET status='failed', attempts=0, candidate_count=0, last_error=NULL, completion_json=NULL, updated_at=? WHERE source_key=?`)
1431
1555
  .run(nowIso(), sourceKey);
1432
1556
  }
1433
1557
  async extractionJob(sourceKey) {
@@ -1542,12 +1666,86 @@ export class LocalKnowledgeProvider {
1542
1666
  syncKnowledgeDocumentsQueued(knowledgeBaseId) {
1543
1667
  return this.enqueueDocumentSync(() => this.syncKnowledgeDocuments(knowledgeBaseId));
1544
1668
  }
1669
+ syncKnowledgeBaseManifestQueued(knowledgeBaseId) {
1670
+ return this.enqueueDocumentSync(async () => {
1671
+ const row = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?').get(knowledgeBaseId);
1672
+ if (row !== undefined)
1673
+ await this.documentStore.ensureBase(rowToKnowledgeBase(row));
1674
+ });
1675
+ }
1676
+ syncKnowledgeEntryQueued(entryId) {
1677
+ return this.enqueueDocumentSync(() => this.syncKnowledgeEntry(entryId));
1678
+ }
1679
+ /** Keep the derived Markdown projection proportional to one changed entry. */
1680
+ async syncKnowledgeEntry(entryId) {
1681
+ const projected = this.db.prepare(`
1682
+ SELECT id,knowledge_base_id,rel_path FROM knowledge_documents WHERE id=?
1683
+ `).get(entryId);
1684
+ const entryRow = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id=?`).get(entryId);
1685
+ if (entryRow === undefined || entryRow.status !== 'active') {
1686
+ if (projected !== undefined)
1687
+ await this.removeProjectedDocument(projected);
1688
+ this.db.prepare('DELETE FROM knowledge_documents WHERE id=?').run(entryId);
1689
+ return;
1690
+ }
1691
+ const entry = rowToEntry(entryRow);
1692
+ const baseRow = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?').get(entry.knowledgeBaseId);
1693
+ if (baseRow === undefined)
1694
+ return;
1695
+ const base = rowToKnowledgeBase(baseRow);
1696
+ const directory = await this.documentStore.ensureBase(base);
1697
+ const relPath = knowledgeDocumentPath(entry);
1698
+ const markdown = renderEntryMarkdown(entry);
1699
+ const stored = await this.documentStore.writeDocument(directory, relPath, markdown);
1700
+ this.upsertProjectedDocument(entry, relPath, stored.contentHash);
1701
+ if (projected !== undefined && (String(projected.knowledge_base_id) !== entry.knowledgeBaseId
1702
+ || String(projected.rel_path) !== relPath))
1703
+ await this.removeProjectedDocument(projected);
1704
+ }
1705
+ async removeProjectedDocument(projected) {
1706
+ const baseRow = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?')
1707
+ .get(String(projected.knowledge_base_id));
1708
+ if (baseRow === undefined)
1709
+ return;
1710
+ const directory = this.documentStore.baseDirectory(rowToKnowledgeBase(baseRow));
1711
+ try {
1712
+ await this.documentStore.deleteDocument(directory, String(projected.rel_path));
1713
+ }
1714
+ catch (error) {
1715
+ if (!(error instanceof Error && error.code === 'ENOENT'))
1716
+ throw error;
1717
+ }
1718
+ }
1719
+ upsertProjectedDocument(entry, relPath, projectedHash) {
1720
+ this.db.prepare(`
1721
+ INSERT INTO knowledge_documents(
1722
+ id,knowledge_base_id,rel_path,title,content,entry_count,content_hash,
1723
+ document_state,finalized_at,finalization_note,created_at,updated_at
1724
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
1725
+ ON CONFLICT(id) DO UPDATE SET
1726
+ knowledge_base_id=excluded.knowledge_base_id,rel_path=excluded.rel_path,
1727
+ title=excluded.title,content=excluded.content,entry_count=excluded.entry_count,
1728
+ content_hash=excluded.content_hash,document_state=excluded.document_state,
1729
+ finalized_at=excluded.finalized_at,finalization_note=excluded.finalization_note,
1730
+ updated_at=excluded.updated_at
1731
+ WHERE knowledge_documents.knowledge_base_id<>excluded.knowledge_base_id
1732
+ OR knowledge_documents.content_hash<>excluded.content_hash
1733
+ OR knowledge_documents.rel_path<>excluded.rel_path
1734
+ OR knowledge_documents.title<>excluded.title
1735
+ OR knowledge_documents.entry_count<>excluded.entry_count
1736
+ OR knowledge_documents.document_state<>excluded.document_state
1737
+ OR knowledge_documents.finalized_at IS NOT excluded.finalized_at
1738
+ OR knowledge_documents.finalization_note IS NOT excluded.finalization_note
1739
+ `).run(entry.id, entry.knowledgeBaseId, relPath, entry.title, renderEntryContent(entry), 1, projectedHash, entry.documentState, entry.finalizedAt ?? null, entry.finalizationNote ?? null, entry.createdAt, entry.updatedAt);
1740
+ }
1545
1741
  async syncKnowledgeDocuments(knowledgeBaseId) {
1546
1742
  const baseRow = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?').get(knowledgeBaseId);
1547
1743
  if (baseRow === undefined)
1548
1744
  return;
1549
1745
  const base = rowToKnowledgeBase(baseRow);
1550
1746
  const directory = await this.documentStore.ensureBase(base);
1747
+ const storedDocuments = await this.documentStore.listDocuments(directory);
1748
+ const storedById = new Map(storedDocuments.map(document => [document.metadata.id, document]));
1551
1749
  const entries = this.db.prepare(`
1552
1750
  SELECT ${ENTRY_COLUMNS} FROM knowledge_entries
1553
1751
  WHERE knowledge_base_id=? AND status='active'
@@ -1556,58 +1754,28 @@ export class LocalKnowledgeProvider {
1556
1754
  const desired = new Map();
1557
1755
  for (const entry of entries) {
1558
1756
  const relPath = knowledgeDocumentPath(entry);
1559
- const markdown = renderKnowledgeMarkdown({
1560
- metadata: {
1561
- id: entry.id,
1562
- type: entry.type,
1563
- tags: entry.tags,
1564
- scope: entry.scope,
1565
- confidence: entry.confidence,
1566
- status: entry.status,
1567
- documentState: entry.documentState,
1568
- ...entry.finalizedAt === undefined ? {} : { finalizedAt: entry.finalizedAt },
1569
- ...entry.finalizationNote === undefined ? {} : { finalizationNote: entry.finalizationNote },
1570
- },
1571
- title: entry.title,
1572
- body: entry.body,
1573
- });
1574
- const stored = await this.documentStore.writeDocument(directory, relPath, markdown);
1757
+ const markdown = renderEntryMarkdown(entry);
1758
+ const current = storedById.get(entry.id);
1759
+ const contentHash = markdownHash(markdown);
1760
+ const stored = current?.relPath === relPath && current.contentHash === contentHash
1761
+ ? current
1762
+ : await this.documentStore.writeDocument(directory, relPath, markdown);
1575
1763
  desired.set(entry.id, {
1576
1764
  entry,
1577
1765
  relPath,
1578
- content: `# ${markdownHeading(entry.title)}\n\n${entry.body.trim()}\n`,
1579
1766
  contentHash: stored.contentHash,
1580
1767
  });
1581
1768
  }
1582
- const storedDocuments = await this.documentStore.listDocuments(directory);
1583
1769
  for (const document of storedDocuments) {
1584
1770
  const expected = desired.get(document.metadata.id);
1585
1771
  if (expected === undefined || expected.relPath !== document.relPath) {
1586
1772
  await this.documentStore.deleteDocument(directory, document.relPath, document.contentHash);
1587
1773
  }
1588
1774
  }
1589
- const existing = this.db.prepare('SELECT id,rel_path,created_at FROM knowledge_documents WHERE knowledge_base_id=?')
1775
+ const existing = this.db.prepare('SELECT id FROM knowledge_documents WHERE knowledge_base_id=?')
1590
1776
  .all(knowledgeBaseId);
1591
1777
  for (const document of desired.values()) {
1592
- this.db.prepare(`
1593
- INSERT INTO knowledge_documents(
1594
- id,knowledge_base_id,rel_path,title,content,entry_count,content_hash,
1595
- document_state,finalized_at,finalization_note,created_at,updated_at
1596
- ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
1597
- ON CONFLICT(id) DO UPDATE SET
1598
- knowledge_base_id=excluded.knowledge_base_id,rel_path=excluded.rel_path,
1599
- title=excluded.title,content=excluded.content,entry_count=excluded.entry_count,
1600
- content_hash=excluded.content_hash,document_state=excluded.document_state,
1601
- finalized_at=excluded.finalized_at,finalization_note=excluded.finalization_note,
1602
- updated_at=excluded.updated_at
1603
- WHERE knowledge_documents.content_hash<>excluded.content_hash
1604
- OR knowledge_documents.rel_path<>excluded.rel_path
1605
- OR knowledge_documents.title<>excluded.title
1606
- OR knowledge_documents.entry_count<>excluded.entry_count
1607
- OR knowledge_documents.document_state<>excluded.document_state
1608
- OR knowledge_documents.finalized_at IS NOT excluded.finalized_at
1609
- OR knowledge_documents.finalization_note IS NOT excluded.finalization_note
1610
- `).run(document.entry.id, knowledgeBaseId, document.relPath, document.entry.title, document.content, 1, document.contentHash, document.entry.documentState, document.entry.finalizedAt ?? null, document.entry.finalizationNote ?? null, document.entry.createdAt, document.entry.updatedAt);
1778
+ this.upsertProjectedDocument(document.entry, document.relPath, document.contentHash);
1611
1779
  }
1612
1780
  for (const row of existing) {
1613
1781
  if (!desired.has(String(row.id)))
@@ -1615,6 +1783,26 @@ export class LocalKnowledgeProvider {
1615
1783
  }
1616
1784
  }
1617
1785
  }
1786
+ function renderEntryMarkdown(entry) {
1787
+ return renderKnowledgeMarkdown({
1788
+ metadata: {
1789
+ id: entry.id,
1790
+ type: entry.type,
1791
+ tags: entry.tags,
1792
+ scope: entry.scope,
1793
+ confidence: entry.confidence,
1794
+ status: entry.status,
1795
+ documentState: entry.documentState,
1796
+ ...entry.finalizedAt === undefined ? {} : { finalizedAt: entry.finalizedAt },
1797
+ ...entry.finalizationNote === undefined ? {} : { finalizationNote: entry.finalizationNote },
1798
+ },
1799
+ title: entry.title,
1800
+ body: entry.body,
1801
+ });
1802
+ }
1803
+ function renderEntryContent(entry) {
1804
+ return `# ${markdownHeading(entry.title)}\n\n${entry.body.trim()}\n`;
1805
+ }
1618
1806
  function markdownHeading(value) {
1619
1807
  return value.replace(/[\r\n]+/g, ' ').replace(/^#+\s*/, '').trim();
1620
1808
  }
@@ -1929,15 +2117,61 @@ function rowToMount(row) {
1929
2117
  }
1930
2118
  function rowToExtractionJob(row) {
1931
2119
  const lastError = row.last_error == null ? undefined : String(row.last_error);
2120
+ const completion = row.completion_json == null
2121
+ ? undefined
2122
+ : normalizeExtractionCompletion(JSON.parse(String(row.completion_json)));
1932
2123
  return {
1933
2124
  sourceKey: String(row.source_key),
1934
2125
  status: String(row.status),
1935
2126
  attempts: Number(row.attempts),
1936
2127
  candidateCount: Number(row.candidate_count),
1937
2128
  ...lastError === undefined ? {} : { lastError },
2129
+ ...completion === undefined ? {} : { completion },
1938
2130
  updatedAt: String(row.updated_at),
1939
2131
  };
1940
2132
  }
2133
+ function normalizeExtractionCompletion(value) {
2134
+ if (typeof value === 'number') {
2135
+ const candidateCount = Math.max(0, Math.floor(value));
2136
+ return { outcome: 'completed', candidateCount, directCount: 0, auditCount: candidateCount, destinations: [] };
2137
+ }
2138
+ if (value.outcome !== 'completed' && value.outcome !== 'skipped' && value.outcome !== 'unmounted') {
2139
+ throw new Error('extraction completion outcome is invalid');
2140
+ }
2141
+ const count = (input, field) => {
2142
+ if (!Number.isInteger(input) || input < 0 || input > 10_000)
2143
+ throw new Error(`extraction completion ${field} is invalid`);
2144
+ return input;
2145
+ };
2146
+ if (!Array.isArray(value.destinations) || value.destinations.length > 100) {
2147
+ throw new Error('extraction completion destinations are invalid');
2148
+ }
2149
+ const text = (input, field, limit) => {
2150
+ const normalized = typeof input === 'string' ? input.trim() : '';
2151
+ if (normalized.length === 0 || normalized.length > limit)
2152
+ throw new Error(`extraction completion ${field} is invalid`);
2153
+ return normalized;
2154
+ };
2155
+ return {
2156
+ outcome: value.outcome,
2157
+ candidateCount: count(value.candidateCount, 'candidateCount'),
2158
+ directCount: count(value.directCount, 'directCount'),
2159
+ auditCount: count(value.auditCount, 'auditCount'),
2160
+ destinations: value.destinations.map(destination => {
2161
+ if (destination.disposition !== 'written' && destination.disposition !== 'pending-review') {
2162
+ throw new Error('extraction completion disposition is invalid');
2163
+ }
2164
+ return {
2165
+ knowledgeBaseId: text(destination.knowledgeBaseId, 'knowledgeBaseId', 200),
2166
+ knowledgeBaseName: text(destination.knowledgeBaseName, 'knowledgeBaseName', 200),
2167
+ ...destination.documentId === undefined ? {} : { documentId: text(destination.documentId, 'documentId', 200) },
2168
+ documentTitle: text(destination.documentTitle, 'documentTitle', 500),
2169
+ ...destination.documentPath === undefined ? {} : { documentPath: text(destination.documentPath, 'documentPath', 1000) },
2170
+ disposition: destination.disposition,
2171
+ };
2172
+ }),
2173
+ };
2174
+ }
1941
2175
  function rowToToken(row) {
1942
2176
  const lastUsedAt = row.last_used_at == null ? undefined : String(row.last_used_at);
1943
2177
  const revokedAt = row.revoked_at == null ? undefined : String(row.revoked_at);