@lemoncat7/dsh-knowledge 2.2.0 → 2.2.7

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 (54) hide show
  1. package/docs/architecture.md +5 -5
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +13 -23
  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.js +1 -1
  10. package/lib/client.js.map +1 -1
  11. package/lib/extraction.d.ts.map +1 -1
  12. package/lib/extraction.js +3 -2
  13. package/lib/extraction.js.map +1 -1
  14. package/lib/index.d.ts.map +1 -1
  15. package/lib/index.js +13 -6
  16. package/lib/index.js.map +1 -1
  17. package/lib/local-provider.d.ts +11 -0
  18. package/lib/local-provider.d.ts.map +1 -1
  19. package/lib/local-provider.js +223 -68
  20. package/lib/local-provider.js.map +1 -1
  21. package/lib/note-tools.js +4 -4
  22. package/lib/note-tools.js.map +1 -1
  23. package/lib/provider-router.d.ts +6 -2
  24. package/lib/provider-router.d.ts.map +1 -1
  25. package/lib/provider-router.js +10 -6
  26. package/lib/provider-router.js.map +1 -1
  27. package/lib/provider.d.ts +3 -1
  28. package/lib/provider.d.ts.map +1 -1
  29. package/lib/remote-provider.d.ts +3 -1
  30. package/lib/remote-provider.d.ts.map +1 -1
  31. package/lib/remote-provider.js +17 -0
  32. package/lib/remote-provider.js.map +1 -1
  33. package/lib/retrieval.d.ts.map +1 -1
  34. package/lib/retrieval.js +9 -7
  35. package/lib/retrieval.js.map +1 -1
  36. package/lib/tool-authorization.d.ts +3 -1
  37. package/lib/tool-authorization.d.ts.map +1 -1
  38. package/lib/tool-authorization.js +27 -14
  39. package/lib/tool-authorization.js.map +1 -1
  40. package/lib/tools.js +1 -1
  41. package/lib/tools.js.map +1 -1
  42. package/lib/tracking.d.ts.map +1 -1
  43. package/lib/tracking.js +8 -3
  44. package/lib/tracking.js.map +1 -1
  45. package/lib/web-workspace-effects.d.ts.map +1 -1
  46. package/lib/web-workspace-effects.js +10 -1
  47. package/lib/web-workspace-effects.js.map +1 -1
  48. package/lib/web.d.ts.map +1 -1
  49. package/lib/web.js +7 -4
  50. package/lib/web.js.map +1 -1
  51. package/package.json +2 -2
  52. package/web/app.js +215 -21
  53. package/web/styles.css +31 -17
  54. 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 > 12)
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,20 @@ 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
+ `);
299
314
  // Alpha v2 used a migration note as the default base's routing description.
300
315
  // Clear only that exact placeholder so existing user-authored descriptions stay untouched.
301
316
  this.db.prepare("UPDATE knowledge_bases SET description='' WHERE id=? AND description=?")
@@ -371,7 +386,7 @@ export class LocalKnowledgeProvider {
371
386
  id,name,description,default_tags_json,extraction_instructions,writeback_policy,writeback_provider,writeback_model,status,created_at,updated_at
372
387
  ) VALUES(?,?,?,?,?,?,?,?,'active',?,?)
373
388
  `).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);
389
+ await this.syncKnowledgeBaseManifestQueued(base.id);
375
390
  return base;
376
391
  }
377
392
  async updateKnowledgeBase(id, input) {
@@ -393,7 +408,7 @@ export class LocalKnowledgeProvider {
393
408
  name=?,description=?,default_tags_json=?,extraction_instructions=?,writeback_policy=?,writeback_provider=?,writeback_model=?,updated_at=?
394
409
  WHERE id=?
395
410
  `).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);
411
+ await this.syncKnowledgeBaseManifestQueued(id);
397
412
  return updated;
398
413
  }
399
414
  async patchKnowledgeBase(id, patch) {
@@ -642,11 +657,12 @@ export class LocalKnowledgeProvider {
642
657
  resolved.set(mount.knowledgeBaseId, { mount, inheritedFrom: 'project' });
643
658
  for (const mount of session)
644
659
  resolved.set(mount.knowledgeBaseId, { mount });
660
+ const bases = new Map((await this.listKnowledgeBases()).map(base => [base.id, base]));
645
661
  const output = [];
646
662
  for (const { mount, inheritedFrom } of resolved.values()) {
647
663
  if (!mount.enabled)
648
664
  continue;
649
- const base = await this.getKnowledgeBase(mount.knowledgeBaseId);
665
+ const base = bases.get(mount.knowledgeBaseId);
650
666
  if (base === undefined || base.status !== 'active')
651
667
  continue;
652
668
  output.push({ ...mount, base, ...inheritedFrom === undefined ? {} : { inheritedFrom } });
@@ -823,6 +839,24 @@ export class LocalKnowledgeProvider {
823
839
  const row = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id = ?`).get(id);
824
840
  return row === undefined ? undefined : rowToEntry(row);
825
841
  }
842
+ /** Management-only bulk lookup used to avoid one HTTP/SQL round trip per review target. */
843
+ entriesByIds(ids) {
844
+ this.assertOpen();
845
+ const uniqueIds = [...new Set(ids.map(id => id.trim()).filter(Boolean))].slice(0, 100);
846
+ if (uniqueIds.length === 0)
847
+ return [];
848
+ const placeholders = uniqueIds.map(() => '?').join(',');
849
+ const rows = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id IN (${placeholders})`)
850
+ .all(...uniqueIds);
851
+ const entries = new Map(rows.map(row => {
852
+ const entry = rowToEntry(row);
853
+ return [entry.id, entry];
854
+ }));
855
+ return uniqueIds.flatMap(id => {
856
+ const entry = entries.get(id);
857
+ return entry === undefined ? [] : [entry];
858
+ });
859
+ }
826
860
  async versions(id) {
827
861
  this.assertOpen();
828
862
  const rows = this.db.prepare('SELECT * FROM knowledge_versions WHERE knowledge_id = ? ORDER BY version DESC').all(id);
@@ -832,7 +866,7 @@ export class LocalKnowledgeProvider {
832
866
  this.assertOpen();
833
867
  await this.documentsReady;
834
868
  const entry = this.transaction(() => this.insertEntry(draft));
835
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
869
+ await this.syncKnowledgeEntryQueued(entry.id);
836
870
  return entry;
837
871
  }
838
872
  insertEntry(input) {
@@ -859,11 +893,8 @@ export class LocalKnowledgeProvider {
859
893
  async update(id, draft) {
860
894
  this.assertOpen();
861
895
  await this.documentsReady;
862
- const current = await this.get(id);
863
896
  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);
897
+ await this.syncKnowledgeEntryQueued(entry.id);
867
898
  return entry;
868
899
  }
869
900
  async finalize(id, state, note) {
@@ -899,7 +930,7 @@ export class LocalKnowledgeProvider {
899
930
  this.upsertFts(updated);
900
931
  return updated;
901
932
  });
902
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
933
+ await this.syncKnowledgeEntryQueued(entry.id);
903
934
  return entry;
904
935
  }
905
936
  async reopen(id) {
@@ -931,7 +962,42 @@ export class LocalKnowledgeProvider {
931
962
  this.upsertFts(updated);
932
963
  return updated;
933
964
  });
934
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
965
+ await this.syncKnowledgeEntryQueued(entry.id);
966
+ return entry;
967
+ }
968
+ async moveDocument(id, knowledgeBaseId) {
969
+ this.assertOpen();
970
+ await this.documentsReady;
971
+ let changed = false;
972
+ const entry = this.transaction(() => {
973
+ const row = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id=?`).get(id);
974
+ if (row === undefined)
975
+ throw notFound('knowledge entry', id);
976
+ const current = rowToEntry(row);
977
+ if (current.status !== 'active')
978
+ throw conflict('only active knowledge documents can be moved');
979
+ if (current.knowledgeBaseId === knowledgeBaseId)
980
+ return current;
981
+ if (this.db.prepare("SELECT id FROM knowledge_bases WHERE id=? AND status='active'").get(knowledgeBaseId) === undefined) {
982
+ throw notFound('active knowledge base', knowledgeBaseId);
983
+ }
984
+ const updated = {
985
+ ...current,
986
+ knowledgeBaseId,
987
+ version: current.version + 1,
988
+ updatedAt: nowIso(),
989
+ };
990
+ this.db.prepare(`
991
+ UPDATE knowledge_entries
992
+ SET knowledge_base_id=?,version=?,updated_at=?
993
+ WHERE id=?
994
+ `).run(knowledgeBaseId, updated.version, updated.updatedAt, id);
995
+ this.writeVersion(updated, 'update');
996
+ changed = true;
997
+ return updated;
998
+ });
999
+ if (changed)
1000
+ await this.syncKnowledgeEntryQueued(entry.id);
935
1001
  return entry;
936
1002
  }
937
1003
  updateEntry(id, input, changeKind) {
@@ -984,7 +1050,7 @@ export class LocalKnowledgeProvider {
984
1050
  this.db.prepare('DELETE FROM knowledge_fts WHERE knowledge_id = ?').run(id);
985
1051
  return updated;
986
1052
  });
987
- await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
1053
+ await this.syncKnowledgeEntryQueued(entry.id);
988
1054
  return entry;
989
1055
  }
990
1056
  async delete(id) {
@@ -998,7 +1064,7 @@ export class LocalKnowledgeProvider {
998
1064
  throw notFound('knowledge entry', id);
999
1065
  });
1000
1066
  if (current !== undefined)
1001
- await this.syncKnowledgeDocumentsQueued(current.knowledgeBaseId);
1067
+ await this.syncKnowledgeEntryQueued(current.id);
1002
1068
  }
1003
1069
  async listNotes(request = {}) {
1004
1070
  this.assertOpen();
@@ -1138,6 +1204,30 @@ export class LocalKnowledgeProvider {
1138
1204
  documentTitle: String(row.document_title),
1139
1205
  }));
1140
1206
  }
1207
+ /** Compatibility path for manually embedded legacy note:// markers. */
1208
+ legacyNoteReferencesForNotes(noteIds) {
1209
+ this.assertOpen();
1210
+ const requested = new Set(noteIds.map(id => id.toLocaleLowerCase()));
1211
+ if (requested.size === 0)
1212
+ return [];
1213
+ const rows = this.db.prepare(`
1214
+ SELECT knowledge_base_id,id,title,content
1215
+ FROM knowledge_documents
1216
+ WHERE content LIKE '%note://note_%'
1217
+ ORDER BY updated_at DESC,id
1218
+ `).all();
1219
+ return rows.flatMap(row => {
1220
+ const references = String(row.content).match(/note:\/\/(note_[a-f0-9]{32})/giu) ?? [];
1221
+ return [...new Set(references.map(value => value.slice('note://'.length).toLocaleLowerCase()))]
1222
+ .filter(noteId => requested.has(noteId))
1223
+ .map(noteId => ({
1224
+ noteId,
1225
+ knowledgeBaseId: String(row.knowledge_base_id),
1226
+ documentId: String(row.id),
1227
+ documentTitle: String(row.title),
1228
+ }));
1229
+ });
1230
+ }
1141
1231
  deleteNoteReferences(noteIds) {
1142
1232
  this.assertOpen();
1143
1233
  const ids = [...new Set(noteIds)];
@@ -1157,7 +1247,7 @@ export class LocalKnowledgeProvider {
1157
1247
  async writeDirect(input, sourceKey) {
1158
1248
  this.assertOpen();
1159
1249
  await this.documentsReady;
1160
- let touchedBaseId;
1250
+ let touchedEntryId;
1161
1251
  const result = this.transaction(() => {
1162
1252
  const resolution = this.resolveDirectProposal(normalizeProposal(input));
1163
1253
  if (resolution.outcome === 'duplicate')
@@ -1177,7 +1267,7 @@ export class LocalKnowledgeProvider {
1177
1267
  .run('approved', reviewedAt, resolution.outcome === 'merged'
1178
1268
  ? 'Automatically merged by direct-write reconciliation.'
1179
1269
  : 'Automatically approved by direct-write policy.', candidate.id);
1180
- touchedBaseId = entry.knowledgeBaseId;
1270
+ touchedEntryId = entry.id;
1181
1271
  return {
1182
1272
  outcome: resolution.outcome,
1183
1273
  candidate: {
@@ -1191,8 +1281,8 @@ export class LocalKnowledgeProvider {
1191
1281
  entry,
1192
1282
  };
1193
1283
  });
1194
- if (touchedBaseId !== undefined)
1195
- await this.syncKnowledgeDocumentsQueued(touchedBaseId);
1284
+ if (touchedEntryId !== undefined)
1285
+ await this.syncKnowledgeEntryQueued(touchedEntryId);
1196
1286
  return result;
1197
1287
  }
1198
1288
  insertCandidate(proposal, sourceKey) {
@@ -1303,6 +1393,7 @@ export class LocalKnowledgeProvider {
1303
1393
  async review(id, decision) {
1304
1394
  this.assertOpen();
1305
1395
  await this.documentsReady;
1396
+ let touchedEntryId;
1306
1397
  const reviewed = this.transaction(() => {
1307
1398
  const row = this.db.prepare('SELECT * FROM knowledge_candidates WHERE id = ?').get(id);
1308
1399
  if (row === undefined)
@@ -1323,7 +1414,7 @@ export class LocalKnowledgeProvider {
1323
1414
  throw conflict('candidate approval cannot move a document between knowledge bases');
1324
1415
  }
1325
1416
  assertExpectedReviewVersion(target, decision.expectedVersion);
1326
- this.updateEntry(candidate.targetId, editedKnowledgeDraft(target, draft), 'update');
1417
+ touchedEntryId = this.updateEntry(candidate.targetId, editedKnowledgeDraft(target, draft), 'update').id;
1327
1418
  }
1328
1419
  else if (candidate.action === 'conflict') {
1329
1420
  if (decision.resolution !== 'merge') {
@@ -1338,7 +1429,7 @@ export class LocalKnowledgeProvider {
1338
1429
  const applied = applyCandidateToTarget(target, candidate, true);
1339
1430
  if (!applied.ok)
1340
1431
  throw conflict(`${applied.reason}; edit the current document to resolve this conflict`);
1341
- this.updateEntry(candidate.targetId, applied.draft, 'update');
1432
+ touchedEntryId = this.updateEntry(candidate.targetId, applied.draft, 'update').id;
1342
1433
  }
1343
1434
  else {
1344
1435
  const resolution = this.resolveDirectProposal({
@@ -1366,10 +1457,10 @@ export class LocalKnowledgeProvider {
1366
1457
  if (resolution.outcome === 'finalized')
1367
1458
  throw finalizedConflict(resolution.entry);
1368
1459
  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');
1460
+ const entry = resolution.proposal.action === 'create'
1461
+ ? this.insertEntry(resolution.proposal.draft)
1462
+ : this.updateEntry(resolution.proposal.targetId, resolution.proposal.draft, 'update');
1463
+ touchedEntryId = entry.id;
1373
1464
  }
1374
1465
  }
1375
1466
  }
@@ -1380,8 +1471,8 @@ export class LocalKnowledgeProvider {
1380
1471
  .run(status, reviewedAt, note ?? null, id);
1381
1472
  return { ...candidate, status, reviewedAt, ...note === undefined ? {} : { reviewNote: note } };
1382
1473
  });
1383
- if (decision.decision === 'approve')
1384
- await this.syncKnowledgeDocumentsQueued(reviewed.draft.knowledgeBaseId);
1474
+ if (touchedEntryId !== undefined)
1475
+ await this.syncKnowledgeEntryQueued(touchedEntryId);
1385
1476
  return reviewed;
1386
1477
  }
1387
1478
  async claimExtraction(sourceKey) {
@@ -1542,12 +1633,86 @@ export class LocalKnowledgeProvider {
1542
1633
  syncKnowledgeDocumentsQueued(knowledgeBaseId) {
1543
1634
  return this.enqueueDocumentSync(() => this.syncKnowledgeDocuments(knowledgeBaseId));
1544
1635
  }
1636
+ syncKnowledgeBaseManifestQueued(knowledgeBaseId) {
1637
+ return this.enqueueDocumentSync(async () => {
1638
+ const row = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?').get(knowledgeBaseId);
1639
+ if (row !== undefined)
1640
+ await this.documentStore.ensureBase(rowToKnowledgeBase(row));
1641
+ });
1642
+ }
1643
+ syncKnowledgeEntryQueued(entryId) {
1644
+ return this.enqueueDocumentSync(() => this.syncKnowledgeEntry(entryId));
1645
+ }
1646
+ /** Keep the derived Markdown projection proportional to one changed entry. */
1647
+ async syncKnowledgeEntry(entryId) {
1648
+ const projected = this.db.prepare(`
1649
+ SELECT id,knowledge_base_id,rel_path FROM knowledge_documents WHERE id=?
1650
+ `).get(entryId);
1651
+ const entryRow = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id=?`).get(entryId);
1652
+ if (entryRow === undefined || entryRow.status !== 'active') {
1653
+ if (projected !== undefined)
1654
+ await this.removeProjectedDocument(projected);
1655
+ this.db.prepare('DELETE FROM knowledge_documents WHERE id=?').run(entryId);
1656
+ return;
1657
+ }
1658
+ const entry = rowToEntry(entryRow);
1659
+ const baseRow = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?').get(entry.knowledgeBaseId);
1660
+ if (baseRow === undefined)
1661
+ return;
1662
+ const base = rowToKnowledgeBase(baseRow);
1663
+ const directory = await this.documentStore.ensureBase(base);
1664
+ const relPath = knowledgeDocumentPath(entry);
1665
+ const markdown = renderEntryMarkdown(entry);
1666
+ const stored = await this.documentStore.writeDocument(directory, relPath, markdown);
1667
+ this.upsertProjectedDocument(entry, relPath, stored.contentHash);
1668
+ if (projected !== undefined && (String(projected.knowledge_base_id) !== entry.knowledgeBaseId
1669
+ || String(projected.rel_path) !== relPath))
1670
+ await this.removeProjectedDocument(projected);
1671
+ }
1672
+ async removeProjectedDocument(projected) {
1673
+ const baseRow = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?')
1674
+ .get(String(projected.knowledge_base_id));
1675
+ if (baseRow === undefined)
1676
+ return;
1677
+ const directory = this.documentStore.baseDirectory(rowToKnowledgeBase(baseRow));
1678
+ try {
1679
+ await this.documentStore.deleteDocument(directory, String(projected.rel_path));
1680
+ }
1681
+ catch (error) {
1682
+ if (!(error instanceof Error && error.code === 'ENOENT'))
1683
+ throw error;
1684
+ }
1685
+ }
1686
+ upsertProjectedDocument(entry, relPath, projectedHash) {
1687
+ this.db.prepare(`
1688
+ INSERT INTO knowledge_documents(
1689
+ id,knowledge_base_id,rel_path,title,content,entry_count,content_hash,
1690
+ document_state,finalized_at,finalization_note,created_at,updated_at
1691
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
1692
+ ON CONFLICT(id) DO UPDATE SET
1693
+ knowledge_base_id=excluded.knowledge_base_id,rel_path=excluded.rel_path,
1694
+ title=excluded.title,content=excluded.content,entry_count=excluded.entry_count,
1695
+ content_hash=excluded.content_hash,document_state=excluded.document_state,
1696
+ finalized_at=excluded.finalized_at,finalization_note=excluded.finalization_note,
1697
+ updated_at=excluded.updated_at
1698
+ WHERE knowledge_documents.knowledge_base_id<>excluded.knowledge_base_id
1699
+ OR knowledge_documents.content_hash<>excluded.content_hash
1700
+ OR knowledge_documents.rel_path<>excluded.rel_path
1701
+ OR knowledge_documents.title<>excluded.title
1702
+ OR knowledge_documents.entry_count<>excluded.entry_count
1703
+ OR knowledge_documents.document_state<>excluded.document_state
1704
+ OR knowledge_documents.finalized_at IS NOT excluded.finalized_at
1705
+ OR knowledge_documents.finalization_note IS NOT excluded.finalization_note
1706
+ `).run(entry.id, entry.knowledgeBaseId, relPath, entry.title, renderEntryContent(entry), 1, projectedHash, entry.documentState, entry.finalizedAt ?? null, entry.finalizationNote ?? null, entry.createdAt, entry.updatedAt);
1707
+ }
1545
1708
  async syncKnowledgeDocuments(knowledgeBaseId) {
1546
1709
  const baseRow = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?').get(knowledgeBaseId);
1547
1710
  if (baseRow === undefined)
1548
1711
  return;
1549
1712
  const base = rowToKnowledgeBase(baseRow);
1550
1713
  const directory = await this.documentStore.ensureBase(base);
1714
+ const storedDocuments = await this.documentStore.listDocuments(directory);
1715
+ const storedById = new Map(storedDocuments.map(document => [document.metadata.id, document]));
1551
1716
  const entries = this.db.prepare(`
1552
1717
  SELECT ${ENTRY_COLUMNS} FROM knowledge_entries
1553
1718
  WHERE knowledge_base_id=? AND status='active'
@@ -1556,58 +1721,28 @@ export class LocalKnowledgeProvider {
1556
1721
  const desired = new Map();
1557
1722
  for (const entry of entries) {
1558
1723
  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);
1724
+ const markdown = renderEntryMarkdown(entry);
1725
+ const current = storedById.get(entry.id);
1726
+ const contentHash = markdownHash(markdown);
1727
+ const stored = current?.relPath === relPath && current.contentHash === contentHash
1728
+ ? current
1729
+ : await this.documentStore.writeDocument(directory, relPath, markdown);
1575
1730
  desired.set(entry.id, {
1576
1731
  entry,
1577
1732
  relPath,
1578
- content: `# ${markdownHeading(entry.title)}\n\n${entry.body.trim()}\n`,
1579
1733
  contentHash: stored.contentHash,
1580
1734
  });
1581
1735
  }
1582
- const storedDocuments = await this.documentStore.listDocuments(directory);
1583
1736
  for (const document of storedDocuments) {
1584
1737
  const expected = desired.get(document.metadata.id);
1585
1738
  if (expected === undefined || expected.relPath !== document.relPath) {
1586
1739
  await this.documentStore.deleteDocument(directory, document.relPath, document.contentHash);
1587
1740
  }
1588
1741
  }
1589
- const existing = this.db.prepare('SELECT id,rel_path,created_at FROM knowledge_documents WHERE knowledge_base_id=?')
1742
+ const existing = this.db.prepare('SELECT id FROM knowledge_documents WHERE knowledge_base_id=?')
1590
1743
  .all(knowledgeBaseId);
1591
1744
  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);
1745
+ this.upsertProjectedDocument(document.entry, document.relPath, document.contentHash);
1611
1746
  }
1612
1747
  for (const row of existing) {
1613
1748
  if (!desired.has(String(row.id)))
@@ -1615,6 +1750,26 @@ export class LocalKnowledgeProvider {
1615
1750
  }
1616
1751
  }
1617
1752
  }
1753
+ function renderEntryMarkdown(entry) {
1754
+ return renderKnowledgeMarkdown({
1755
+ metadata: {
1756
+ id: entry.id,
1757
+ type: entry.type,
1758
+ tags: entry.tags,
1759
+ scope: entry.scope,
1760
+ confidence: entry.confidence,
1761
+ status: entry.status,
1762
+ documentState: entry.documentState,
1763
+ ...entry.finalizedAt === undefined ? {} : { finalizedAt: entry.finalizedAt },
1764
+ ...entry.finalizationNote === undefined ? {} : { finalizationNote: entry.finalizationNote },
1765
+ },
1766
+ title: entry.title,
1767
+ body: entry.body,
1768
+ });
1769
+ }
1770
+ function renderEntryContent(entry) {
1771
+ return `# ${markdownHeading(entry.title)}\n\n${entry.body.trim()}\n`;
1772
+ }
1618
1773
  function markdownHeading(value) {
1619
1774
  return value.replace(/[\r\n]+/g, ' ').replace(/^#+\s*/, '').trim();
1620
1775
  }