@lemoncat7/dsh-knowledge 2.1.1 → 2.2.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.
@@ -8,7 +8,7 @@ import { 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';
11
- import { mergeKnowledgeBodies } from './knowledge-merge.js';
11
+ import { applyKnowledgeTextEdits, mergeKnowledgeBodies } from './knowledge-merge.js';
12
12
  import { NoteStore } from './notes/store.js';
13
13
  const ENTRY_COLUMNS = `
14
14
  id, knowledge_base_id, title, body, type, tags_json, scope_kind, scope_id, confidence,
@@ -44,7 +44,7 @@ export class LocalKnowledgeProvider {
44
44
  }
45
45
  migrate() {
46
46
  let version = Number(this.db.prepare('PRAGMA user_version').get().user_version ?? 0);
47
- if (version > 10)
47
+ if (version > 11)
48
48
  throw new Error(`knowledge database schema ${version} is newer than this plugin supports`);
49
49
  if (version === 0)
50
50
  this.db.exec(`
@@ -277,6 +277,25 @@ export class LocalKnowledgeProvider {
277
277
  throw error;
278
278
  }
279
279
  }
280
+ if (version <= 9)
281
+ version = 10;
282
+ if (version === 10) {
283
+ this.db.exec('BEGIN IMMEDIATE');
284
+ try {
285
+ const candidateTable = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='knowledge_candidates'").get();
286
+ const candidateColumns = candidateTable === undefined
287
+ ? []
288
+ : this.db.prepare('PRAGMA table_info(knowledge_candidates)').all();
289
+ if (candidateTable !== undefined && !candidateColumns.some(column => String(column.name) === 'change_json')) {
290
+ this.db.exec('ALTER TABLE knowledge_candidates ADD COLUMN change_json TEXT');
291
+ }
292
+ this.db.exec('PRAGMA user_version = 11; COMMIT');
293
+ }
294
+ catch (error) {
295
+ this.db.exec('ROLLBACK');
296
+ throw error;
297
+ }
298
+ }
280
299
  // Alpha v2 used a migration note as the default base's routing description.
281
300
  // Clear only that exact placeholder so existing user-authored descriptions stay untouched.
282
301
  this.db.prepare("UPDATE knowledge_bases SET description='' WHERE id=? AND description=?")
@@ -1177,7 +1196,7 @@ export class LocalKnowledgeProvider {
1177
1196
  return result;
1178
1197
  }
1179
1198
  insertCandidate(proposal, sourceKey) {
1180
- const hash = contentHash(proposal.draft) + `:${proposal.action}:${proposal.targetId ?? ''}`;
1199
+ const hash = contentHash(proposal.draft) + `:${proposal.action}:${proposal.targetId ?? ''}:${JSON.stringify(proposal.change ?? null)}`;
1181
1200
  if (sourceKey !== undefined) {
1182
1201
  const existing = this.db.prepare('SELECT * FROM knowledge_candidates WHERE source_key = ? AND proposal_hash = ?').get(sourceKey, hash);
1183
1202
  if (existing !== undefined)
@@ -1191,9 +1210,9 @@ export class LocalKnowledgeProvider {
1191
1210
  createdAt: nowIso(),
1192
1211
  };
1193
1212
  this.db.prepare(`
1194
- INSERT INTO knowledge_candidates(id,action,target_id,draft_json,reason,status,source_key,proposal_hash,created_at)
1195
- VALUES(?,?,?,?,?,'pending',?,?,?)
1196
- `).run(candidate.id, candidate.action, candidate.targetId ?? null, JSON.stringify(candidate.draft), candidate.reason, sourceKey ?? null, hash, candidate.createdAt);
1213
+ INSERT INTO knowledge_candidates(id,action,target_id,change_json,draft_json,reason,status,source_key,proposal_hash,created_at)
1214
+ VALUES(?,?,?,?,?,?,'pending',?,?,?)
1215
+ `).run(candidate.id, candidate.action, candidate.targetId ?? null, candidate.change === undefined ? null : JSON.stringify(candidate.change), JSON.stringify(candidate.draft), candidate.reason, sourceKey ?? null, hash, candidate.createdAt);
1197
1216
  return candidate;
1198
1217
  }
1199
1218
  resolveDirectProposal(proposal) {
@@ -1209,10 +1228,11 @@ export class LocalKnowledgeProvider {
1209
1228
  if (!sameScope(target.scope, proposal.draft.scope)) {
1210
1229
  return { outcome: 'conflict', proposal: { ...proposal, action: 'conflict' } };
1211
1230
  }
1212
- if (potentiallyConflicts(target, proposal.draft)) {
1231
+ const applied = applyCandidateToTarget(target, proposal, true);
1232
+ if (!applied.ok) {
1213
1233
  return { outcome: 'conflict', proposal: { ...proposal, action: 'conflict' } };
1214
1234
  }
1215
- const draft = mergeKnowledgeDraft(target, proposal.draft, true);
1235
+ const draft = applied.draft;
1216
1236
  if (contentHash(draft) === contentHash(target))
1217
1237
  return { outcome: 'duplicate', entry: target };
1218
1238
  return { outcome: 'merged', proposal: { ...proposal, action: 'update', draft } };
@@ -1268,7 +1288,17 @@ export class LocalKnowledgeProvider {
1268
1288
  async listCandidates(status, limit) {
1269
1289
  this.assertOpen();
1270
1290
  return this.db.prepare('SELECT * FROM knowledge_candidates WHERE status = ? ORDER BY created_at DESC LIMIT ?')
1271
- .all(status, Math.max(1, Math.min(limit, 100))).map(rowToCandidate);
1291
+ .all(status, Math.max(1, Math.min(limit, 100))).map(rowToCandidate).map(candidate => {
1292
+ if (candidate.status !== 'pending' || candidate.targetId === undefined || candidate.change?.kind !== 'revise')
1293
+ return candidate;
1294
+ try {
1295
+ const applied = applyCandidateToTarget(this.activeEntry(candidate.targetId), candidate, true);
1296
+ return applied.ok ? { ...candidate, draft: applied.draft } : candidate;
1297
+ }
1298
+ catch {
1299
+ return candidate;
1300
+ }
1301
+ });
1272
1302
  }
1273
1303
  async review(id, decision) {
1274
1304
  this.assertOpen();
@@ -1282,7 +1312,20 @@ export class LocalKnowledgeProvider {
1282
1312
  throw conflict(`candidate ${id} was already ${candidate.status}`);
1283
1313
  let draft = decision.draft === undefined ? candidate.draft : normalizeDraft(decision.draft);
1284
1314
  if (decision.decision === 'approve') {
1285
- if (candidate.action === 'conflict') {
1315
+ if (candidate.action !== 'create' && decision.draft !== undefined) {
1316
+ if (candidate.action === 'conflict' && decision.resolution !== 'merge') {
1317
+ throw conflict('conflict candidate requires an explicit merge resolution');
1318
+ }
1319
+ if (candidate.targetId === undefined)
1320
+ throw new Error('candidate target is missing');
1321
+ const target = this.activeEntry(candidate.targetId);
1322
+ if (draft.knowledgeBaseId !== target.knowledgeBaseId) {
1323
+ throw conflict('candidate approval cannot move a document between knowledge bases');
1324
+ }
1325
+ assertExpectedReviewVersion(target, decision.expectedVersion);
1326
+ this.updateEntry(candidate.targetId, editedKnowledgeDraft(target, draft), 'update');
1327
+ }
1328
+ else if (candidate.action === 'conflict') {
1286
1329
  if (decision.resolution !== 'merge') {
1287
1330
  throw conflict('conflict candidate requires an explicit merge resolution');
1288
1331
  }
@@ -1292,12 +1335,16 @@ export class LocalKnowledgeProvider {
1292
1335
  if (draft.knowledgeBaseId !== target.knowledgeBaseId) {
1293
1336
  throw conflict('candidate approval cannot move a document between knowledge bases');
1294
1337
  }
1295
- this.updateEntry(candidate.targetId, mergeKnowledgeDraft(target, draft, true), 'update');
1338
+ const applied = applyCandidateToTarget(target, candidate, true);
1339
+ if (!applied.ok)
1340
+ throw conflict(`${applied.reason}; edit the current document to resolve this conflict`);
1341
+ this.updateEntry(candidate.targetId, applied.draft, 'update');
1296
1342
  }
1297
1343
  else {
1298
1344
  const resolution = this.resolveDirectProposal({
1299
1345
  action: candidate.action,
1300
1346
  ...candidate.targetId === undefined ? {} : { targetId: candidate.targetId },
1347
+ ...candidate.change === undefined ? {} : { change: candidate.change },
1301
1348
  draft,
1302
1349
  reason: candidate.reason,
1303
1350
  });
@@ -1305,9 +1352,9 @@ export class LocalKnowledgeProvider {
1305
1352
  const proposal = resolution.proposal;
1306
1353
  this.db.prepare(`
1307
1354
  UPDATE knowledge_candidates
1308
- SET action='conflict', target_id=?, draft_json=?, reason=?
1355
+ SET action='conflict', target_id=?, change_json=?, draft_json=?, reason=?
1309
1356
  WHERE id=? AND status='pending'
1310
- `).run(proposal.targetId ?? null, JSON.stringify(proposal.draft), proposal.reason, id);
1357
+ `).run(proposal.targetId ?? null, proposal.change === undefined ? null : JSON.stringify(proposal.change), JSON.stringify(proposal.draft), proposal.reason, id);
1311
1358
  return {
1312
1359
  ...candidate,
1313
1360
  action: 'conflict',
@@ -1646,12 +1693,14 @@ function rowToCandidate(row) {
1646
1693
  const reviewedAt = row.reviewed_at == null ? undefined : String(row.reviewed_at);
1647
1694
  const reviewNote = row.review_note == null ? undefined : String(row.review_note);
1648
1695
  const draft = JSON.parse(String(row.draft_json));
1696
+ const change = row.change_json == null ? undefined : normalizeCandidateChange(JSON.parse(String(row.change_json)));
1649
1697
  if (draft.knowledgeBaseId === undefined)
1650
1698
  draft.knowledgeBaseId = DEFAULT_KNOWLEDGE_BASE_ID;
1651
1699
  return {
1652
1700
  id: String(row.id),
1653
1701
  action: String(row.action),
1654
1702
  ...targetId === undefined ? {} : { targetId },
1703
+ ...change === undefined ? {} : { change },
1655
1704
  draft,
1656
1705
  reason: String(row.reason),
1657
1706
  status: String(row.status),
@@ -1662,17 +1711,52 @@ function rowToCandidate(row) {
1662
1711
  };
1663
1712
  }
1664
1713
  function normalizeProposal(input) {
1714
+ const change = normalizeCandidateChange(input.change);
1665
1715
  const proposal = {
1666
1716
  action: input.action,
1667
1717
  ...input.targetId === undefined ? {} : { targetId: input.targetId },
1718
+ ...change === undefined ? {} : { change },
1668
1719
  draft: normalizeDraft(input.draft),
1669
1720
  reason: input.reason.trim().slice(0, 2000),
1670
1721
  };
1671
1722
  if (proposal.action !== 'create' && proposal.targetId === undefined) {
1672
1723
  throw new Error(`${proposal.action} candidate requires targetId`);
1673
1724
  }
1725
+ if (proposal.action === 'create' && proposal.change !== undefined) {
1726
+ throw new Error('create candidate cannot contain a document change');
1727
+ }
1674
1728
  return proposal;
1675
1729
  }
1730
+ function normalizeCandidateChange(input) {
1731
+ if (input === undefined)
1732
+ return undefined;
1733
+ if (input.kind === 'append')
1734
+ return { kind: 'append' };
1735
+ if (input.kind !== 'revise')
1736
+ throw new Error('unsupported candidate change kind');
1737
+ if (!Number.isSafeInteger(input.baseVersion) || input.baseVersion < 1) {
1738
+ throw new Error('revision baseVersion must be a positive integer');
1739
+ }
1740
+ const baseHash = input.baseHash.trim().toLocaleLowerCase();
1741
+ if (!/^[a-f0-9]{64}$/u.test(baseHash))
1742
+ throw new Error('revision baseHash must be a SHA-256 hash');
1743
+ if (!Array.isArray(input.edits) || input.edits.length < 1 || input.edits.length > 20) {
1744
+ throw new Error('revision must contain 1-20 text edits');
1745
+ }
1746
+ const edits = input.edits.map((edit, index) => {
1747
+ const oldText = edit.oldText.replace(/\r\n?/gu, '\n');
1748
+ const newText = edit.newText.replace(/\r\n?/gu, '\n');
1749
+ if (oldText.length === 0 || oldText.length > 12_000)
1750
+ throw new Error(`revision edit ${index + 1} anchor must contain 1-12000 characters`);
1751
+ if (newText.length > 12_000)
1752
+ throw new Error(`revision edit ${index + 1} replacement must contain at most 12000 characters`);
1753
+ return { oldText, newText };
1754
+ });
1755
+ const append = input.append?.trim();
1756
+ if (append !== undefined && append.length > 12_000)
1757
+ throw new Error('revision append must contain at most 12000 characters');
1758
+ return { kind: 'revise', baseVersion: input.baseVersion, baseHash, edits, ...append ? { append } : {} };
1759
+ }
1676
1760
  function mergeKnowledgeDraft(current, incoming, preferIncomingTitle) {
1677
1761
  return normalizeDraft({
1678
1762
  knowledgeBaseId: current.knowledgeBaseId,
@@ -1687,6 +1771,44 @@ function mergeKnowledgeDraft(current, incoming, preferIncomingTitle) {
1687
1771
  : { source: incoming.source },
1688
1772
  });
1689
1773
  }
1774
+ function applyCandidateToTarget(current, proposal, preferIncomingTitle) {
1775
+ if (proposal.change?.kind !== 'revise') {
1776
+ if (potentiallyConflicts(current, proposal.draft))
1777
+ return { ok: false, reason: 'candidate contradicts the current document' };
1778
+ return { ok: true, draft: mergeKnowledgeDraft(current, proposal.draft, preferIncomingTitle) };
1779
+ }
1780
+ const revised = applyKnowledgeTextEdits(current.body, proposal.change.edits, proposal.change.append);
1781
+ if (!revised.ok)
1782
+ return revised;
1783
+ return {
1784
+ ok: true,
1785
+ draft: normalizeDraft({
1786
+ knowledgeBaseId: current.knowledgeBaseId,
1787
+ title: preferIncomingTitle ? proposal.draft.title : current.title,
1788
+ body: revised.body,
1789
+ type: current.type,
1790
+ tags: [...current.tags, ...proposal.draft.tags],
1791
+ scope: current.scope,
1792
+ confidence: Math.max(current.confidence, proposal.draft.confidence),
1793
+ ...proposal.draft.source === undefined
1794
+ ? current.source === undefined ? {} : { source: current.source }
1795
+ : { source: proposal.draft.source },
1796
+ }),
1797
+ };
1798
+ }
1799
+ function editedKnowledgeDraft(current, incoming) {
1800
+ return normalizeDraft({
1801
+ ...incoming,
1802
+ knowledgeBaseId: current.knowledgeBaseId,
1803
+ type: current.type,
1804
+ scope: current.scope,
1805
+ });
1806
+ }
1807
+ function assertExpectedReviewVersion(current, expectedVersion) {
1808
+ if (expectedVersion !== undefined && current.version !== expectedVersion) {
1809
+ throw conflict(`knowledge changed during review (expected version ${expectedVersion}, current version ${current.version}); reopen the editor and resolve the current document`);
1810
+ }
1811
+ }
1690
1812
  function potentiallyConflicts(current, incoming) {
1691
1813
  const currentBody = normalizedBody(current.body);
1692
1814
  const incomingBody = normalizedBody(incoming.body);