@lemoncat7/dsh-knowledge 1.1.5 → 2.0.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 (128) hide show
  1. package/README.md +24 -4
  2. package/docs/architecture.md +32 -6
  3. package/docs/requirements.md +10 -0
  4. package/lib/api.d.ts +2 -0
  5. package/lib/api.d.ts.map +1 -1
  6. package/lib/api.js +235 -13
  7. package/lib/api.js.map +1 -1
  8. package/lib/atomic-file.d.ts +16 -0
  9. package/lib/atomic-file.d.ts.map +1 -0
  10. package/lib/atomic-file.js +92 -0
  11. package/lib/atomic-file.js.map +1 -0
  12. package/lib/client.d.ts.map +1 -1
  13. package/lib/client.js +38 -35
  14. package/lib/client.js.map +2 -2
  15. package/lib/config.d.ts.map +1 -1
  16. package/lib/config.js +2 -5
  17. package/lib/config.js.map +1 -1
  18. package/lib/connection.d.ts.map +1 -1
  19. package/lib/connection.js +5 -15
  20. package/lib/connection.js.map +1 -1
  21. package/lib/content-safety.d.ts +12 -0
  22. package/lib/content-safety.d.ts.map +1 -0
  23. package/lib/content-safety.js +38 -0
  24. package/lib/content-safety.js.map +1 -0
  25. package/lib/documents/projection-queue.d.ts +10 -0
  26. package/lib/documents/projection-queue.d.ts.map +1 -0
  27. package/lib/documents/projection-queue.js +21 -0
  28. package/lib/documents/projection-queue.js.map +1 -0
  29. package/lib/documents/store.d.ts +4 -2
  30. package/lib/documents/store.d.ts.map +1 -1
  31. package/lib/documents/store.js +54 -73
  32. package/lib/documents/store.js.map +1 -1
  33. package/lib/domain.d.ts +13 -0
  34. package/lib/domain.d.ts.map +1 -1
  35. package/lib/domain.js.map +1 -1
  36. package/lib/extraction.d.ts.map +1 -1
  37. package/lib/extraction.js +18 -4
  38. package/lib/extraction.js.map +1 -1
  39. package/lib/index.d.ts +1 -0
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +91 -32
  42. package/lib/index.js.map +1 -1
  43. package/lib/knowledge-merge.d.ts +8 -0
  44. package/lib/knowledge-merge.d.ts.map +1 -0
  45. package/lib/knowledge-merge.js +19 -0
  46. package/lib/knowledge-merge.js.map +1 -0
  47. package/lib/local-provider.d.ts +26 -1
  48. package/lib/local-provider.d.ts.map +1 -1
  49. package/lib/local-provider.js +402 -63
  50. package/lib/local-provider.js.map +1 -1
  51. package/lib/management-proxy.d.ts.map +1 -1
  52. package/lib/management-proxy.js +22 -13
  53. package/lib/management-proxy.js.map +1 -1
  54. package/lib/note-reference-handle.d.ts +15 -0
  55. package/lib/note-reference-handle.d.ts.map +1 -0
  56. package/lib/note-reference-handle.js +54 -0
  57. package/lib/note-reference-handle.js.map +1 -0
  58. package/lib/note-reference-tools.d.ts +6 -0
  59. package/lib/note-reference-tools.d.ts.map +1 -0
  60. package/lib/note-reference-tools.js +119 -0
  61. package/lib/note-reference-tools.js.map +1 -0
  62. package/lib/note-tools.d.ts +6 -0
  63. package/lib/note-tools.d.ts.map +1 -0
  64. package/lib/note-tools.js +283 -0
  65. package/lib/note-tools.js.map +1 -0
  66. package/lib/notes/domain.d.ts +44 -0
  67. package/lib/notes/domain.d.ts.map +1 -0
  68. package/lib/notes/domain.js +38 -0
  69. package/lib/notes/domain.js.map +1 -0
  70. package/lib/notes/store.d.ts +39 -0
  71. package/lib/notes/store.d.ts.map +1 -0
  72. package/lib/notes/store.js +407 -0
  73. package/lib/notes/store.js.map +1 -0
  74. package/lib/provider.d.ts +17 -0
  75. package/lib/provider.d.ts.map +1 -1
  76. package/lib/remote-provider.d.ts +19 -0
  77. package/lib/remote-provider.d.ts.map +1 -1
  78. package/lib/remote-provider.js +172 -14
  79. package/lib/remote-provider.js.map +1 -1
  80. package/lib/remote-url.d.ts +2 -0
  81. package/lib/remote-url.d.ts.map +1 -0
  82. package/lib/remote-url.js +14 -0
  83. package/lib/remote-url.js.map +1 -0
  84. package/lib/runtime.d.ts +1 -0
  85. package/lib/runtime.d.ts.map +1 -1
  86. package/lib/runtime.js.map +1 -1
  87. package/lib/service-settings.d.ts.map +1 -1
  88. package/lib/service-settings.js +3 -10
  89. package/lib/service-settings.js.map +1 -1
  90. package/lib/theme-bridge.js +12 -12
  91. package/lib/theme-bridge.js.map +1 -1
  92. package/lib/tool-authorization.d.ts +14 -0
  93. package/lib/tool-authorization.d.ts.map +1 -0
  94. package/lib/tool-authorization.js +124 -0
  95. package/lib/tool-authorization.js.map +1 -0
  96. package/lib/tool-input.d.ts +6 -0
  97. package/lib/tool-input.d.ts.map +1 -0
  98. package/lib/tool-input.js +27 -0
  99. package/lib/tool-input.js.map +1 -0
  100. package/lib/tools.d.ts +2 -1
  101. package/lib/tools.d.ts.map +1 -1
  102. package/lib/tools.js +70 -177
  103. package/lib/tools.js.map +1 -1
  104. package/lib/tracking.d.ts +29 -0
  105. package/lib/tracking.d.ts.map +1 -0
  106. package/lib/tracking.js +141 -0
  107. package/lib/tracking.js.map +1 -0
  108. package/lib/web-change-review.d.ts +34 -0
  109. package/lib/web-change-review.d.ts.map +1 -0
  110. package/lib/web-change-review.js +147 -0
  111. package/lib/web-change-review.js.map +1 -0
  112. package/lib/web-markdown-preview.d.ts.map +1 -1
  113. package/lib/web-markdown-preview.js +10 -0
  114. package/lib/web-markdown-preview.js.map +1 -1
  115. package/lib/web-note-editor.d.ts +44 -0
  116. package/lib/web-note-editor.d.ts.map +1 -0
  117. package/lib/web-note-editor.js +118 -0
  118. package/lib/web-note-editor.js.map +1 -0
  119. package/lib/web.d.ts.map +1 -1
  120. package/lib/web.js +3 -1
  121. package/lib/web.js.map +1 -1
  122. package/package.json +12 -1
  123. package/web/app.js +1888 -154
  124. package/web/change-review.js +5 -0
  125. package/web/index.html +2 -0
  126. package/web/markdown-preview.js +8 -8
  127. package/web/note-editor.js +220 -0
  128. package/web/styles.css +599 -170
@@ -1,11 +1,15 @@
1
1
  import { dirname, join } from 'node:path';
2
- import { createHash, randomBytes } from 'node:crypto';
2
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
3
3
  import { mkdirSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
4
5
  import { DatabaseSync } from 'node:sqlite';
5
6
  import { contentHash, DEFAULT_KNOWLEDGE_BASE_ID, newId, normalizeDraft, normalizeKnowledgeBaseDraft, normalizeKnowledgeMountDraft, normalizeKnowledgeSettings, nowIso, } from './domain.js';
6
7
  import { renderKnowledgeMarkdown } from './documents/markdown.js';
7
8
  import { knowledgeDocumentPath } from './documents/path.js';
8
9
  import { KnowledgeDocumentStore } from './documents/store.js';
10
+ import { enqueueDocumentProjection } from './documents/projection-queue.js';
11
+ import { mergeKnowledgeBodies } from './knowledge-merge.js';
12
+ import { NoteStore } from './notes/store.js';
9
13
  const ENTRY_COLUMNS = `
10
14
  id, knowledge_base_id, title, body, type, tags_json, scope_kind, scope_id, confidence,
11
15
  status, document_state, finalized_at, finalization_note, version, source_json, created_at, updated_at
@@ -17,8 +21,10 @@ const JOINED_ENTRY_COLUMNS = `
17
21
  e.finalized_at AS finalized_at, e.finalization_note AS finalization_note, e.version AS version,
18
22
  e.source_json AS source_json, e.created_at AS created_at, e.updated_at AS updated_at
19
23
  `;
24
+ const EXTRACTION_LEASE_MS = 15 * 60 * 1000;
20
25
  export class LocalKnowledgeProvider {
21
26
  mode = 'local';
27
+ notes;
22
28
  db;
23
29
  documentStore;
24
30
  documentsReady;
@@ -26,15 +32,19 @@ export class LocalKnowledgeProvider {
26
32
  constructor(path) {
27
33
  if (path !== ':memory:')
28
34
  mkdirSync(dirname(path), { recursive: true });
35
+ const inMemory = path === ':memory:';
36
+ this.notes = new NoteStore(inMemory
37
+ ? join(tmpdir(), `dsh-knowledge-notes-${randomUUID()}`)
38
+ : join(dirname(path), 'notes'), inMemory);
29
39
  this.documentStore = new KnowledgeDocumentStore(join(dirname(path), 'documents'));
30
40
  this.db = new DatabaseSync(path);
31
41
  this.db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
32
42
  this.migrate();
33
- this.documentsReady = this.syncAllDocuments();
43
+ this.documentsReady = this.enqueueDocumentSync(() => this.syncAllDocuments());
34
44
  }
35
45
  migrate() {
36
46
  let version = Number(this.db.prepare('PRAGMA user_version').get().user_version ?? 0);
37
- if (version > 9)
47
+ if (version > 10)
38
48
  throw new Error(`knowledge database schema ${version} is newer than this plugin supports`);
39
49
  if (version === 0)
40
50
  this.db.exec(`
@@ -243,11 +253,52 @@ export class LocalKnowledgeProvider {
243
253
  PRAGMA user_version = 9;
244
254
  COMMIT;
245
255
  `);
256
+ if (version <= 8)
257
+ version = 9;
258
+ if (version === 9) {
259
+ this.db.exec('BEGIN IMMEDIATE');
260
+ try {
261
+ this.db.exec(`
262
+ CREATE TABLE knowledge_note_references (
263
+ knowledge_id TEXT NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
264
+ note_id TEXT NOT NULL,
265
+ source TEXT NOT NULL CHECK(source IN ('user','agent','legacy')),
266
+ source_session_id TEXT,
267
+ created_at TEXT NOT NULL,
268
+ PRIMARY KEY(knowledge_id, note_id)
269
+ );
270
+ CREATE INDEX knowledge_note_references_note ON knowledge_note_references(note_id, created_at DESC);
271
+ `);
272
+ this.backfillLegacyNoteReferences();
273
+ this.db.exec('PRAGMA user_version = 10; COMMIT');
274
+ }
275
+ catch (error) {
276
+ this.db.exec('ROLLBACK');
277
+ throw error;
278
+ }
279
+ }
246
280
  // Alpha v2 used a migration note as the default base's routing description.
247
281
  // Clear only that exact placeholder so existing user-authored descriptions stay untouched.
248
282
  this.db.prepare("UPDATE knowledge_bases SET description='' WHERE id=? AND description=?")
249
283
  .run(DEFAULT_KNOWLEDGE_BASE_ID, '由 0.2 版本迁移的知识。');
250
284
  }
285
+ backfillLegacyNoteReferences() {
286
+ const insert = this.db.prepare(`
287
+ INSERT OR IGNORE INTO knowledge_note_references(knowledge_id,note_id,source,source_session_id,created_at)
288
+ VALUES(?,?,'legacy',NULL,?)
289
+ `);
290
+ const timestamp = nowIso();
291
+ const rows = this.db.prepare("SELECT id,body FROM knowledge_entries WHERE body LIKE '%note://note_%'").all();
292
+ for (const row of rows) {
293
+ const noteIds = String(row.body).match(/note:\/\/(note_[a-f0-9]{32})/giu) ?? [];
294
+ for (const reference of new Set(noteIds)) {
295
+ const noteId = reference.slice('note://'.length).toLocaleLowerCase();
296
+ const note = this.notes.get(noteId);
297
+ if (note !== undefined && note.kind !== 'folder')
298
+ insert.run(String(row.id), note.id, timestamp);
299
+ }
300
+ }
301
+ }
251
302
  assertOpen() {
252
303
  if (this.closed)
253
304
  throw new Error('knowledge provider is closed');
@@ -301,7 +352,7 @@ export class LocalKnowledgeProvider {
301
352
  id,name,description,default_tags_json,extraction_instructions,writeback_policy,writeback_provider,writeback_model,status,created_at,updated_at
302
353
  ) VALUES(?,?,?,?,?,?,?,?,'active',?,?)
303
354
  `).run(base.id, base.name, base.description, JSON.stringify(base.defaultTags), base.extractionInstructions, base.writebackPolicy, base.writebackProvider ?? null, base.writebackModel ?? null, timestamp, timestamp);
304
- await this.syncKnowledgeDocuments(base.id);
355
+ await this.syncKnowledgeDocumentsQueued(base.id);
305
356
  return base;
306
357
  }
307
358
  async updateKnowledgeBase(id, input) {
@@ -311,13 +362,19 @@ export class LocalKnowledgeProvider {
311
362
  if (current === undefined)
312
363
  throw notFound('knowledge base', id);
313
364
  const draft = normalizeKnowledgeBaseDraft(input);
314
- const updated = { ...current, ...draft, updatedAt: nowIso() };
365
+ const updated = {
366
+ id: current.id,
367
+ status: current.status,
368
+ createdAt: current.createdAt,
369
+ ...draft,
370
+ updatedAt: nowIso(),
371
+ };
315
372
  this.db.prepare(`
316
373
  UPDATE knowledge_bases SET
317
374
  name=?,description=?,default_tags_json=?,extraction_instructions=?,writeback_policy=?,writeback_provider=?,writeback_model=?,updated_at=?
318
375
  WHERE id=?
319
376
  `).run(updated.name, updated.description, JSON.stringify(updated.defaultTags), updated.extractionInstructions, updated.writebackPolicy, updated.writebackProvider ?? null, updated.writebackModel ?? null, updated.updatedAt, id);
320
- await this.syncKnowledgeDocuments(id);
377
+ await this.syncKnowledgeDocumentsQueued(id);
321
378
  return updated;
322
379
  }
323
380
  async patchKnowledgeBase(id, patch) {
@@ -347,8 +404,10 @@ export class LocalKnowledgeProvider {
347
404
  if (current.status === 'archived')
348
405
  return current;
349
406
  const updated = { ...current, status: 'archived', updatedAt: nowIso() };
350
- this.db.prepare("UPDATE knowledge_bases SET status='archived',updated_at=? WHERE id=?").run(updated.updatedAt, id);
351
- this.db.prepare('UPDATE knowledge_mounts SET enabled=0,updated_at=? WHERE knowledge_base_id=?').run(updated.updatedAt, id);
407
+ this.transaction(() => {
408
+ this.db.prepare("UPDATE knowledge_bases SET status='archived',updated_at=? WHERE id=?").run(updated.updatedAt, id);
409
+ this.db.prepare('UPDATE knowledge_mounts SET enabled=0,updated_at=? WHERE knowledge_base_id=?').run(updated.updatedAt, id);
410
+ });
352
411
  return updated;
353
412
  }
354
413
  async restoreKnowledgeBase(id) {
@@ -368,28 +427,37 @@ export class LocalKnowledgeProvider {
368
427
  const base = await this.getKnowledgeBase(id);
369
428
  if (id === DEFAULT_KNOWLEDGE_BASE_ID)
370
429
  throw conflict('the default knowledge base cannot be deleted');
371
- this.transaction(() => {
372
- const row = this.db.prepare('SELECT status FROM knowledge_bases WHERE id=?').get(id);
373
- if (row === undefined)
374
- throw notFound('knowledge base', id);
375
- if (row.status !== 'archived')
376
- throw conflict('knowledge base must be archived before deletion');
377
- this.db.prepare(`
378
- DELETE FROM knowledge_candidates
379
- WHERE json_extract(draft_json, '$.knowledgeBaseId')=?
380
- OR target_id IN (SELECT id FROM knowledge_entries WHERE knowledge_base_id=?)
381
- `).run(id, id);
382
- this.db.prepare(`
383
- DELETE FROM knowledge_fts
384
- WHERE knowledge_id IN (SELECT id FROM knowledge_entries WHERE knowledge_base_id=?)
385
- `).run(id);
386
- this.db.prepare('DELETE FROM knowledge_entries WHERE knowledge_base_id=?').run(id);
387
- this.db.prepare('DELETE FROM knowledge_mounts WHERE knowledge_base_id=?').run(id);
388
- this.db.prepare('DELETE FROM knowledge_documents WHERE knowledge_base_id=?').run(id);
389
- this.db.prepare('DELETE FROM knowledge_bases WHERE id=?').run(id);
390
- });
391
- if (base !== undefined)
392
- await this.documentStore.deleteBase(this.documentStore.baseDirectory(base));
430
+ if (base === undefined)
431
+ throw notFound('knowledge base', id);
432
+ if (base.status !== 'archived')
433
+ throw conflict('knowledge base must be archived before deletion');
434
+ await this.enqueueDocumentSync(() => this.documentStore.deleteBase(this.documentStore.baseDirectory(base)));
435
+ try {
436
+ this.transaction(() => {
437
+ const row = this.db.prepare('SELECT status FROM knowledge_bases WHERE id=?').get(id);
438
+ if (row === undefined)
439
+ throw notFound('knowledge base', id);
440
+ if (row.status !== 'archived')
441
+ throw conflict('knowledge base must be archived before deletion');
442
+ this.db.prepare(`
443
+ DELETE FROM knowledge_candidates
444
+ WHERE json_extract(draft_json, '$.knowledgeBaseId')=?
445
+ OR target_id IN (SELECT id FROM knowledge_entries WHERE knowledge_base_id=?)
446
+ `).run(id, id);
447
+ this.db.prepare(`
448
+ DELETE FROM knowledge_fts
449
+ WHERE knowledge_id IN (SELECT id FROM knowledge_entries WHERE knowledge_base_id=?)
450
+ `).run(id);
451
+ this.db.prepare('DELETE FROM knowledge_entries WHERE knowledge_base_id=?').run(id);
452
+ this.db.prepare('DELETE FROM knowledge_mounts WHERE knowledge_base_id=?').run(id);
453
+ this.db.prepare('DELETE FROM knowledge_documents WHERE knowledge_base_id=?').run(id);
454
+ this.db.prepare('DELETE FROM knowledge_bases WHERE id=?').run(id);
455
+ });
456
+ }
457
+ catch (error) {
458
+ await this.syncKnowledgeDocumentsQueued(id).catch(() => { });
459
+ throw error;
460
+ }
393
461
  }
394
462
  async listDocuments(knowledgeBaseId, query) {
395
463
  this.assertOpen();
@@ -409,6 +477,68 @@ export class LocalKnowledgeProvider {
409
477
  const sql = `SELECT * FROM knowledge_documents${where.length === 0 ? '' : ` WHERE ${where.join(' AND ')}`} ORDER BY knowledge_base_id, CASE WHEN rel_path='README.md' THEN 0 ELSE 1 END, rel_path`;
410
478
  return this.db.prepare(sql).all(...args).map(rowToDocument);
411
479
  }
480
+ async listDocumentIndex(request) {
481
+ this.assertOpen();
482
+ await this.documentsReady;
483
+ const limit = Math.max(1, Math.min(request.limit, 100));
484
+ const where = [];
485
+ const args = [];
486
+ const knowledgeBaseIds = request.knowledgeBaseIds === undefined
487
+ ? undefined
488
+ : [...new Set(request.knowledgeBaseIds.map(id => id.trim()).filter(Boolean))];
489
+ if (knowledgeBaseIds !== undefined && knowledgeBaseIds.length === 0)
490
+ return { items: [], total: 0 };
491
+ if (knowledgeBaseIds !== undefined) {
492
+ where.push(`knowledge_base_id IN (${knowledgeBaseIds.map(() => '?').join(',')})`);
493
+ args.push(...knowledgeBaseIds);
494
+ }
495
+ if (request.activeKnowledgeBasesOnly) {
496
+ where.push("knowledge_base_id IN (SELECT id FROM knowledge_bases WHERE status='active')");
497
+ }
498
+ const query = request.query?.trim();
499
+ if (query) {
500
+ const like = `%${escapeSqlLike(query)}%`;
501
+ where.push(`(title LIKE ? ESCAPE '\\' OR rel_path LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')`);
502
+ args.push(like, like, like);
503
+ }
504
+ const sourceWhere = where.length === 0 ? '' : ` WHERE ${where.join(' AND ')}`;
505
+ const cursorWhere = [];
506
+ const cursorArgs = [];
507
+ if (request.cursor !== undefined) {
508
+ const cursor = decodeDocumentCursor(request.cursor);
509
+ cursorWhere.push(`(
510
+ knowledge_base_id > ? OR
511
+ (knowledge_base_id = ? AND sort_rank > ?) OR
512
+ (knowledge_base_id = ? AND sort_rank = ? AND rel_path > ?) OR
513
+ (knowledge_base_id = ? AND sort_rank = ? AND rel_path = ? AND id > ?)
514
+ )`);
515
+ cursorArgs.push(cursor.knowledgeBaseId, cursor.knowledgeBaseId, cursor.sortRank, cursor.knowledgeBaseId, cursor.sortRank, cursor.relPath, cursor.knowledgeBaseId, cursor.sortRank, cursor.relPath, cursor.id);
516
+ }
517
+ const rows = this.db.prepare(`
518
+ WITH document_index AS (
519
+ SELECT
520
+ id, knowledge_base_id, rel_path, title, entry_count, content_hash,
521
+ document_state, finalized_at, finalization_note, created_at, updated_at,
522
+ CASE WHEN rel_path='README.md' THEN 0 ELSE 1 END AS sort_rank
523
+ FROM knowledge_documents${sourceWhere}
524
+ )
525
+ SELECT * FROM document_index
526
+ ${cursorWhere.length === 0 ? '' : `WHERE ${cursorWhere.join(' AND ')}`}
527
+ ORDER BY knowledge_base_id, sort_rank, rel_path, id
528
+ LIMIT ?
529
+ `).all(...args, ...cursorArgs, limit + 1);
530
+ const pageRows = rows.slice(0, limit);
531
+ const items = pageRows.map(rowToDocumentSummary);
532
+ const last = pageRows.at(-1);
533
+ const total = Number(this.db.prepare(`SELECT COUNT(*) AS total FROM knowledge_documents${sourceWhere}`).get(...args).total ?? 0);
534
+ return {
535
+ items,
536
+ total,
537
+ ...rows.length <= limit || last === undefined ? {} : {
538
+ nextCursor: encodeDocumentCursor(String(last.knowledge_base_id), Number(last.sort_rank), String(last.rel_path), String(last.id)),
539
+ },
540
+ };
541
+ }
412
542
  async getDocument(id) {
413
543
  this.assertOpen();
414
544
  await this.documentsReady;
@@ -615,7 +745,7 @@ export class LocalKnowledgeProvider {
615
745
  return rows.map(row => {
616
746
  const entry = rowToEntry(row);
617
747
  return { entry, score: relevanceScore(entry, text) };
618
- });
748
+ }).sort((left, right) => right.score - left.score || right.entry.updatedAt.localeCompare(left.entry.updatedAt));
619
749
  }
620
750
  searchByTerms(text, scopeSql, scopeArgs, filterSql, filterArgs, limit) {
621
751
  const terms = fallbackTerms(text);
@@ -683,7 +813,7 @@ export class LocalKnowledgeProvider {
683
813
  this.assertOpen();
684
814
  await this.documentsReady;
685
815
  const entry = this.transaction(() => this.insertEntry(draft));
686
- await this.syncKnowledgeDocuments(entry.knowledgeBaseId);
816
+ await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
687
817
  return entry;
688
818
  }
689
819
  insertEntry(input) {
@@ -713,8 +843,8 @@ export class LocalKnowledgeProvider {
713
843
  const current = await this.get(id);
714
844
  const entry = this.transaction(() => this.updateEntry(id, draft, 'update'));
715
845
  if (current !== undefined && current.knowledgeBaseId !== entry.knowledgeBaseId)
716
- await this.syncKnowledgeDocuments(current.knowledgeBaseId);
717
- await this.syncKnowledgeDocuments(entry.knowledgeBaseId);
846
+ await this.syncKnowledgeDocumentsQueued(current.knowledgeBaseId);
847
+ await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
718
848
  return entry;
719
849
  }
720
850
  async finalize(id, state, note) {
@@ -750,7 +880,7 @@ export class LocalKnowledgeProvider {
750
880
  this.upsertFts(updated);
751
881
  return updated;
752
882
  });
753
- await this.syncKnowledgeDocuments(entry.knowledgeBaseId);
883
+ await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
754
884
  return entry;
755
885
  }
756
886
  async reopen(id) {
@@ -782,7 +912,7 @@ export class LocalKnowledgeProvider {
782
912
  this.upsertFts(updated);
783
913
  return updated;
784
914
  });
785
- await this.syncKnowledgeDocuments(entry.knowledgeBaseId);
915
+ await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
786
916
  return entry;
787
917
  }
788
918
  updateEntry(id, input, changeKind) {
@@ -790,6 +920,9 @@ export class LocalKnowledgeProvider {
790
920
  if (currentRow === undefined)
791
921
  throw notFound('knowledge entry', id);
792
922
  const current = rowToEntry(currentRow);
923
+ if (current.status !== 'active' && changeKind !== 'restore') {
924
+ throw conflict(`knowledge document "${current.title}" is archived and cannot be edited`);
925
+ }
793
926
  if (current.documentState !== 'open')
794
927
  throw finalizedConflict(current);
795
928
  const draft = normalizeDraft(input);
@@ -832,7 +965,7 @@ export class LocalKnowledgeProvider {
832
965
  this.db.prepare('DELETE FROM knowledge_fts WHERE knowledge_id = ?').run(id);
833
966
  return updated;
834
967
  });
835
- await this.syncKnowledgeDocuments(entry.knowledgeBaseId);
968
+ await this.syncKnowledgeDocumentsQueued(entry.knowledgeBaseId);
836
969
  return entry;
837
970
  }
838
971
  async delete(id) {
@@ -846,7 +979,153 @@ export class LocalKnowledgeProvider {
846
979
  throw notFound('knowledge entry', id);
847
980
  });
848
981
  if (current !== undefined)
849
- await this.syncKnowledgeDocuments(current.knowledgeBaseId);
982
+ await this.syncKnowledgeDocumentsQueued(current.knowledgeBaseId);
983
+ }
984
+ async listNotes(request = {}) {
985
+ this.assertOpen();
986
+ return this.notes.list(request);
987
+ }
988
+ async getNote(id) {
989
+ this.assertOpen();
990
+ return this.notes.get(id);
991
+ }
992
+ async readNote(id) {
993
+ this.assertOpen();
994
+ return this.notes.read(id);
995
+ }
996
+ async createNoteFolder(name, parentId = null) {
997
+ this.assertOpen();
998
+ return this.notes.createFolder(name, parentId);
999
+ }
1000
+ async createNoteDocument(name, parentId = null, content = '') {
1001
+ this.assertOpen();
1002
+ return this.notes.createDocument(name, parentId, content);
1003
+ }
1004
+ async updateNoteContent(id, content) {
1005
+ this.assertOpen();
1006
+ return this.notes.updateContent(id, content);
1007
+ }
1008
+ async renameNote(id, name) {
1009
+ this.assertOpen();
1010
+ return this.notes.rename(id, name);
1011
+ }
1012
+ async moveNote(id, parentId) {
1013
+ this.assertOpen();
1014
+ return this.notes.move(id, parentId);
1015
+ }
1016
+ async deleteNote(id) {
1017
+ this.assertOpen();
1018
+ const noteIds = this.notes.subtree(id).filter(node => node.kind !== 'folder').map(node => node.id);
1019
+ const references = this.noteReferencesForNotes(noteIds);
1020
+ if (references.length > 0)
1021
+ throw conflict(`note content is referenced by ${references.length} knowledge document(s)`);
1022
+ await this.notes.delete(id);
1023
+ this.deleteNoteReferences(noteIds);
1024
+ }
1025
+ async searchNotes(query, limit) {
1026
+ this.assertOpen();
1027
+ const value = query.trim();
1028
+ if (value.length === 0)
1029
+ return [];
1030
+ const boundedLimit = Math.max(1, Math.min(Math.trunc(limit), 100));
1031
+ return this.notes.list({ query: value, limit: Math.min(500, boundedLimit * 3) })
1032
+ .filter(node => node.kind !== 'folder')
1033
+ .slice(0, boundedLimit);
1034
+ }
1035
+ async listKnowledgeNoteReferences(knowledgeId) {
1036
+ this.assertOpen();
1037
+ const entry = await this.get(knowledgeId);
1038
+ if (entry === undefined)
1039
+ throw notFound('knowledge entry', knowledgeId);
1040
+ const rows = this.db.prepare(`
1041
+ SELECT knowledge_id,note_id,source,source_session_id,created_at
1042
+ FROM knowledge_note_references
1043
+ WHERE knowledge_id=?
1044
+ ORDER BY created_at,note_id
1045
+ `).all(knowledgeId);
1046
+ return rows.flatMap(row => {
1047
+ const note = this.notes.get(String(row.note_id));
1048
+ if (note === undefined || note.kind === 'folder')
1049
+ return [];
1050
+ return [{
1051
+ knowledgeId: entry.id,
1052
+ knowledgeBaseId: entry.knowledgeBaseId,
1053
+ documentTitle: entry.title,
1054
+ note,
1055
+ source: String(row.source),
1056
+ ...row.source_session_id == null ? {} : { sourceSessionId: String(row.source_session_id) },
1057
+ createdAt: String(row.created_at),
1058
+ }];
1059
+ });
1060
+ }
1061
+ async addKnowledgeNoteReference(knowledgeId, noteId, source, sourceSessionId) {
1062
+ this.assertOpen();
1063
+ const entry = await this.get(knowledgeId);
1064
+ if (entry === undefined)
1065
+ throw notFound('knowledge entry', knowledgeId);
1066
+ if (entry.status !== 'active')
1067
+ throw conflict('archived knowledge documents cannot change note references');
1068
+ if (entry.documentState !== 'open')
1069
+ throw finalizedConflict(entry);
1070
+ const note = this.notes.get(noteId);
1071
+ if (note === undefined)
1072
+ throw notFound('note node', noteId);
1073
+ if (note.kind === 'folder')
1074
+ throw inputError('knowledge documents can reference note documents or files, not folders');
1075
+ if (source !== 'user' && source !== 'agent' && source !== 'legacy')
1076
+ throw inputError('invalid knowledge note reference source');
1077
+ const sessionId = sourceSessionId?.trim() || undefined;
1078
+ if (sessionId !== undefined && sessionId.length > 200)
1079
+ throw inputError('reference source session id is too long');
1080
+ const createdAt = nowIso();
1081
+ this.db.prepare(`
1082
+ INSERT OR IGNORE INTO knowledge_note_references(knowledge_id,note_id,source,source_session_id,created_at)
1083
+ VALUES(?,?,?,?,?)
1084
+ `).run(entry.id, note.id, source, sessionId ?? null, createdAt);
1085
+ const reference = (await this.listKnowledgeNoteReferences(entry.id)).find(item => item.note.id === note.id);
1086
+ if (reference === undefined)
1087
+ throw new Error('knowledge note reference was not persisted');
1088
+ return reference;
1089
+ }
1090
+ async deleteKnowledgeNoteReference(knowledgeId, noteId) {
1091
+ this.assertOpen();
1092
+ const entry = await this.get(knowledgeId);
1093
+ if (entry === undefined)
1094
+ throw notFound('knowledge entry', knowledgeId);
1095
+ if (entry.status !== 'active')
1096
+ throw conflict('archived knowledge documents cannot change note references');
1097
+ if (entry.documentState !== 'open')
1098
+ throw finalizedConflict(entry);
1099
+ const result = this.db.prepare('DELETE FROM knowledge_note_references WHERE knowledge_id=? AND note_id=?').run(knowledgeId, noteId);
1100
+ if (result.changes === 0)
1101
+ throw notFound('knowledge note reference', `${knowledgeId}/${noteId}`);
1102
+ }
1103
+ noteReferencesForNotes(noteIds) {
1104
+ this.assertOpen();
1105
+ const ids = [...new Set(noteIds)];
1106
+ if (ids.length === 0)
1107
+ return [];
1108
+ const placeholders = ids.map(() => '?').join(',');
1109
+ return this.db.prepare(`
1110
+ SELECT r.note_id,e.knowledge_base_id,e.id AS document_id,e.title AS document_title
1111
+ FROM knowledge_note_references r
1112
+ JOIN knowledge_entries e ON e.id=r.knowledge_id
1113
+ WHERE r.note_id IN (${placeholders})
1114
+ ORDER BY e.updated_at DESC,e.id
1115
+ `).all(...ids).map(row => ({
1116
+ noteId: String(row.note_id),
1117
+ knowledgeBaseId: String(row.knowledge_base_id),
1118
+ documentId: String(row.document_id),
1119
+ documentTitle: String(row.document_title),
1120
+ }));
1121
+ }
1122
+ deleteNoteReferences(noteIds) {
1123
+ this.assertOpen();
1124
+ const ids = [...new Set(noteIds)];
1125
+ if (ids.length === 0)
1126
+ return;
1127
+ const placeholders = ids.map(() => '?').join(',');
1128
+ this.db.prepare(`DELETE FROM knowledge_note_references WHERE note_id IN (${placeholders})`).run(...ids);
850
1129
  }
851
1130
  async propose(input, sourceKey) {
852
1131
  this.assertOpen();
@@ -894,7 +1173,7 @@ export class LocalKnowledgeProvider {
894
1173
  };
895
1174
  });
896
1175
  if (touchedBaseId !== undefined)
897
- await this.syncKnowledgeDocuments(touchedBaseId);
1176
+ await this.syncKnowledgeDocumentsQueued(touchedBaseId);
898
1177
  return result;
899
1178
  }
900
1179
  insertCandidate(proposal, sourceKey) {
@@ -1009,10 +1288,7 @@ export class LocalKnowledgeProvider {
1009
1288
  }
1010
1289
  if (candidate.targetId === undefined)
1011
1290
  throw new Error('candidate target is missing');
1012
- const targetRow = this.db.prepare(`SELECT ${ENTRY_COLUMNS} FROM knowledge_entries WHERE id = ?`).get(candidate.targetId);
1013
- if (targetRow === undefined)
1014
- throw notFound('candidate target', candidate.targetId);
1015
- const target = rowToEntry(targetRow);
1291
+ const target = this.activeEntry(candidate.targetId);
1016
1292
  if (draft.knowledgeBaseId !== target.knowledgeBaseId) {
1017
1293
  throw conflict('candidate approval cannot move a document between knowledge bases');
1018
1294
  }
@@ -1058,30 +1334,49 @@ export class LocalKnowledgeProvider {
1058
1334
  return { ...candidate, status, reviewedAt, ...note === undefined ? {} : { reviewNote: note } };
1059
1335
  });
1060
1336
  if (decision.decision === 'approve')
1061
- await this.syncKnowledgeDocuments(reviewed.draft.knowledgeBaseId);
1337
+ await this.syncKnowledgeDocumentsQueued(reviewed.draft.knowledgeBaseId);
1062
1338
  return reviewed;
1063
1339
  }
1064
1340
  async claimExtraction(sourceKey) {
1065
1341
  this.assertOpen();
1342
+ const claimedAt = nowIso();
1343
+ const staleBefore = new Date(Date.now() - EXTRACTION_LEASE_MS).toISOString();
1066
1344
  const result = this.db.prepare(`
1067
1345
  INSERT INTO extraction_jobs(source_key,status,attempts,candidate_count,updated_at)
1068
1346
  VALUES(?,'running',1,0,?)
1069
1347
  ON CONFLICT(source_key) DO UPDATE SET
1070
1348
  status='running', attempts=extraction_jobs.attempts+1, candidate_count=0,
1071
1349
  last_error=NULL, updated_at=excluded.updated_at
1072
- WHERE extraction_jobs.status='failed' AND extraction_jobs.attempts < 3
1073
- `).run(sourceKey, nowIso());
1350
+ WHERE extraction_jobs.attempts < 3 AND (
1351
+ extraction_jobs.status='failed'
1352
+ OR (extraction_jobs.status='running' AND extraction_jobs.updated_at < ?)
1353
+ )
1354
+ `).run(sourceKey, claimedAt, staleBefore);
1074
1355
  return result.changes === 1;
1075
1356
  }
1076
1357
  async completeExtraction(sourceKey, candidateCount) {
1077
1358
  this.assertOpen();
1078
- this.db.prepare(`UPDATE extraction_jobs SET status='completed', candidate_count=?, last_error=NULL, updated_at=? WHERE source_key=?`)
1079
- .run(candidateCount, nowIso(), sourceKey);
1359
+ const result = this.db.prepare(`
1360
+ UPDATE extraction_jobs SET status='completed', candidate_count=?, last_error=NULL, updated_at=?
1361
+ WHERE source_key=? AND status='running'
1362
+ `).run(candidateCount, nowIso(), sourceKey);
1363
+ if (result.changes === 0) {
1364
+ const current = await this.extractionJob(sourceKey);
1365
+ if (current?.status !== 'completed')
1366
+ throw conflict(`extraction job "${sourceKey}" is not running`);
1367
+ }
1080
1368
  }
1081
1369
  async failExtraction(sourceKey, error) {
1082
1370
  this.assertOpen();
1083
- this.db.prepare(`UPDATE extraction_jobs SET status='failed', last_error=?, updated_at=? WHERE source_key=?`)
1084
- .run(error.slice(0, 4000), nowIso(), sourceKey);
1371
+ const result = this.db.prepare(`
1372
+ UPDATE extraction_jobs SET status='failed', last_error=?, updated_at=?
1373
+ WHERE source_key=? AND status='running'
1374
+ `).run(error.slice(0, 4000), nowIso(), sourceKey);
1375
+ if (result.changes === 0) {
1376
+ const current = await this.extractionJob(sourceKey);
1377
+ if (current?.status !== 'failed')
1378
+ throw conflict(`extraction job "${sourceKey}" is not running`);
1379
+ }
1085
1380
  }
1086
1381
  async resetExtraction(sourceKey) {
1087
1382
  this.assertOpen();
@@ -1111,7 +1406,12 @@ export class LocalKnowledgeProvider {
1111
1406
  .get(tokenHash(token));
1112
1407
  if (row === undefined)
1113
1408
  return undefined;
1114
- const usedAt = nowIso();
1409
+ const previous = row.last_used_at == null ? undefined : String(row.last_used_at);
1410
+ const now = Date.now();
1411
+ const shouldRefresh = previous === undefined || !Number.isFinite(Date.parse(previous)) || now - Date.parse(previous) >= 60_000;
1412
+ if (!shouldRefresh)
1413
+ return rowToToken(row);
1414
+ const usedAt = new Date(now).toISOString();
1115
1415
  this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(usedAt, String(row.id));
1116
1416
  return rowToToken({ ...row, last_used_at: usedAt });
1117
1417
  }
@@ -1156,6 +1456,7 @@ export class LocalKnowledgeProvider {
1156
1456
  await this.documentsReady;
1157
1457
  this.closed = true;
1158
1458
  this.db.close();
1459
+ await this.notes.close();
1159
1460
  }
1160
1461
  writeVersion(entry, changeKind) {
1161
1462
  const snapshot = {
@@ -1188,6 +1489,12 @@ export class LocalKnowledgeProvider {
1188
1489
  for (const base of bases)
1189
1490
  await this.syncKnowledgeDocuments(String(base.id));
1190
1491
  }
1492
+ enqueueDocumentSync(operation) {
1493
+ return enqueueDocumentProjection(this.documentStore.root, operation);
1494
+ }
1495
+ syncKnowledgeDocumentsQueued(knowledgeBaseId) {
1496
+ return this.enqueueDocumentSync(() => this.syncKnowledgeDocuments(knowledgeBaseId));
1497
+ }
1191
1498
  async syncKnowledgeDocuments(knowledgeBaseId) {
1192
1499
  const baseRow = this.db.prepare('SELECT * FROM knowledge_bases WHERE id=?').get(knowledgeBaseId);
1193
1500
  if (baseRow === undefined)
@@ -1303,6 +1610,21 @@ function rowToDocument(row) {
1303
1610
  updatedAt: String(row.updated_at),
1304
1611
  };
1305
1612
  }
1613
+ function rowToDocumentSummary(row) {
1614
+ return {
1615
+ id: String(row.id),
1616
+ knowledgeBaseId: String(row.knowledge_base_id),
1617
+ relPath: String(row.rel_path),
1618
+ title: String(row.title),
1619
+ entryCount: Number(row.entry_count),
1620
+ contentHash: String(row.content_hash),
1621
+ documentState: row.document_state == null ? 'open' : String(row.document_state),
1622
+ ...row.finalized_at == null ? {} : { finalizedAt: String(row.finalized_at) },
1623
+ ...row.finalization_note == null ? {} : { finalizationNote: String(row.finalization_note) },
1624
+ createdAt: String(row.created_at),
1625
+ updatedAt: String(row.updated_at),
1626
+ };
1627
+ }
1306
1628
  function rowToVersion(row) {
1307
1629
  const snapshot = JSON.parse(String(row.snapshot_json));
1308
1630
  if (snapshot.knowledgeBaseId === undefined)
@@ -1355,7 +1677,7 @@ function mergeKnowledgeDraft(current, incoming, preferIncomingTitle) {
1355
1677
  return normalizeDraft({
1356
1678
  knowledgeBaseId: current.knowledgeBaseId,
1357
1679
  title: preferIncomingTitle ? incoming.title : current.title,
1358
- body: mergeBodies(current.body, incoming.body),
1680
+ body: mergeKnowledgeBodies(current.body, incoming.body),
1359
1681
  type: current.type,
1360
1682
  tags: [...current.tags, ...incoming.tags],
1361
1683
  scope: current.scope,
@@ -1365,18 +1687,7 @@ function mergeKnowledgeDraft(current, incoming, preferIncomingTitle) {
1365
1687
  : { source: incoming.source },
1366
1688
  });
1367
1689
  }
1368
- function mergeBodies(current, incoming) {
1369
- const currentKey = normalizedBody(current);
1370
- const incomingKey = normalizedBody(incoming);
1371
- if (currentKey === incomingKey || currentKey.includes(incomingKey))
1372
- return current.trim();
1373
- if (incomingKey.includes(currentKey))
1374
- return incoming.trim();
1375
- return `${current.trim()}\n\n${incoming.trim()}`;
1376
- }
1377
1690
  function potentiallyConflicts(current, incoming) {
1378
- if (current.type !== incoming.type)
1379
- return true;
1380
1691
  const currentBody = normalizedBody(current.body);
1381
1692
  const incomingBody = normalizedBody(incoming.body);
1382
1693
  if (currentBody === incomingBody || currentBody.includes(incomingBody) || incomingBody.includes(currentBody))
@@ -1574,12 +1885,40 @@ function decodeCursor(cursor) {
1574
1885
  throw Object.assign(new Error('invalid pagination cursor'), { code: 'BAD_REQUEST' });
1575
1886
  }
1576
1887
  }
1888
+ function encodeDocumentCursor(knowledgeBaseId, sortRank, relPath, id) {
1889
+ return Buffer.from(JSON.stringify({ knowledgeBaseId, sortRank, relPath, id })).toString('base64url');
1890
+ }
1891
+ function decodeDocumentCursor(cursor) {
1892
+ try {
1893
+ const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
1894
+ if (typeof value.knowledgeBaseId !== 'string'
1895
+ || (value.sortRank !== 0 && value.sortRank !== 1)
1896
+ || typeof value.relPath !== 'string'
1897
+ || typeof value.id !== 'string')
1898
+ throw new Error();
1899
+ return {
1900
+ knowledgeBaseId: value.knowledgeBaseId,
1901
+ sortRank: value.sortRank,
1902
+ relPath: value.relPath,
1903
+ id: value.id,
1904
+ };
1905
+ }
1906
+ catch {
1907
+ throw Object.assign(new Error('invalid document pagination cursor'), { code: 'BAD_REQUEST', status: 400 });
1908
+ }
1909
+ }
1910
+ function escapeSqlLike(value) {
1911
+ return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
1912
+ }
1577
1913
  function notFound(kind, id) {
1578
1914
  return Object.assign(new Error(`${kind} "${id}" was not found`), { code: 'NOT_FOUND' });
1579
1915
  }
1580
1916
  function conflict(message) {
1581
1917
  return Object.assign(new Error(message), { code: 'CONFLICT' });
1582
1918
  }
1919
+ function inputError(message) {
1920
+ return Object.assign(new Error(message), { code: 'BAD_REQUEST', status: 400 });
1921
+ }
1583
1922
  function finalizedConflict(entry) {
1584
1923
  const label = entry.documentState === 'resolved' ? 'resolved' : 'collection complete';
1585
1924
  return conflict(`knowledge document "${entry.title}" (${entry.id}) is finalized as ${label}; reopen it before making changes`);