@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.
- package/README.md +3 -3
- package/docs/architecture.md +2 -2
- package/docs/requirements.md +3 -3
- package/lib/api.js +42 -1
- package/lib/api.js.map +1 -1
- package/lib/domain.d.ts +19 -0
- package/lib/domain.d.ts.map +1 -1
- package/lib/domain.js.map +1 -1
- package/lib/extraction.d.ts.map +1 -1
- package/lib/extraction.js +143 -11
- package/lib/extraction.js.map +1 -1
- package/lib/knowledge-merge.d.ts +15 -4
- package/lib/knowledge-merge.d.ts.map +1 -1
- package/lib/knowledge-merge.js +33 -6
- package/lib/knowledge-merge.js.map +1 -1
- package/lib/local-provider.d.ts.map +1 -1
- package/lib/local-provider.js +135 -13
- package/lib/local-provider.js.map +1 -1
- package/lib/web-change-review.d.ts +1 -1
- package/lib/web-change-review.d.ts.map +1 -1
- package/lib/web-change-review.js +4 -2
- package/lib/web-change-review.js.map +1 -1
- package/package.json +1 -1
- package/web/app.js +33 -15
- package/web/change-review.js +3 -3
package/lib/local-provider.js
CHANGED
|
@@ -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 >
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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);
|