@kontourai/flow-agents 0.2.0 → 0.3.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/.github/workflows/runtime-compat.yml +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +38 -19
- package/build/src/cli/flow-kit.js +9 -4
- package/build/src/cli/runtime-adapter.js +9 -5
- package/build/src/cli/telemetry-doctor.js +4 -1
- package/build/src/runtime-adapters.js +34 -0
- package/build/src/tools/build-universal-bundles.js +18 -1
- package/console.telemetry.json +115 -20
- package/docs/_layouts/default.html +2 -0
- package/docs/index.md +8 -0
- package/docs/integrations/index.md +4 -0
- package/docs/integrations/knowledge-kit-live.md +211 -0
- package/docs/kit-authoring-guide.md +169 -0
- package/docs/spec/runtime-hook-surface.md +56 -3
- package/evals/acceptance/run.sh +10 -1
- package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
- package/evals/acceptance/test_pi_harness.sh +15 -0
- package/evals/integration/test_runtime_adapter_activation.sh +113 -1
- package/integrations/strands/examples/knowledge_kit_live.py +461 -0
- package/integrations/strands/flow_agents_strands/steering.py +54 -1
- package/integrations/strands/tests/test_hooks.py +88 -0
- package/integrations/strands-ts/src/hooks.ts +104 -0
- package/integrations/strands-ts/test/test-steering.ts +159 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +821 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
- package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
- package/kits/knowledge/docs/README.md +135 -0
- package/kits/knowledge/docs/store-contract.md +526 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
- package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
- package/kits/knowledge/flows/compile.flow.json +60 -0
- package/kits/knowledge/flows/consolidate.flow.json +77 -0
- package/kits/knowledge/flows/ingest.flow.json +60 -0
- package/kits/knowledge/flows/store-contract.flow.json +48 -0
- package/kits/knowledge/flows/synthesize.flow.json +77 -0
- package/kits/knowledge/kit.json +78 -0
- package/package.json +1 -1
- package/src/cli/flow-kit.ts +10 -4
- package/src/cli/runtime-adapter.ts +10 -5
- package/src/cli/telemetry-doctor.ts +4 -1
- package/src/runtime-adapters.ts +35 -0
- package/src/tools/build-universal-bundles.ts +18 -1
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit — Default Store Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the Knowledge Kit store contract over:
|
|
5
|
+
* - Markdown files with YAML frontmatter (records/<id>.md)
|
|
6
|
+
* - [[wikilink]] style inline links
|
|
7
|
+
* - JSON graph index (graph-index.json)
|
|
8
|
+
*
|
|
9
|
+
* Zero runtime dependencies beyond Node.js built-ins.
|
|
10
|
+
* Store root is passed as a constructor argument: new DefaultKnowledgeStore({ storeRoot }).
|
|
11
|
+
*
|
|
12
|
+
* @module adapters/default-store
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Error helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function missingEvidenceError(message) {
|
|
24
|
+
const err = new Error(message);
|
|
25
|
+
err.code = "MISSING_EVIDENCE";
|
|
26
|
+
return err;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function notFoundError(id) {
|
|
30
|
+
const err = new Error(`Record not found: ${id}`);
|
|
31
|
+
err.code = "NOT_FOUND";
|
|
32
|
+
return err;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// YAML frontmatter codec (no external deps — handles the subset we need)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse a markdown file that begins with a YAML frontmatter block.
|
|
41
|
+
* Returns { meta, body }.
|
|
42
|
+
*/
|
|
43
|
+
function parseMarkdown(text) {
|
|
44
|
+
if (!text.startsWith("---\n")) {
|
|
45
|
+
return { meta: {}, body: text };
|
|
46
|
+
}
|
|
47
|
+
const end = text.indexOf("\n---\n", 4);
|
|
48
|
+
if (end === -1) {
|
|
49
|
+
return { meta: {}, body: text };
|
|
50
|
+
}
|
|
51
|
+
const yaml = text.slice(4, end);
|
|
52
|
+
const body = text.slice(end + 5).replace(/^\n+/, "");
|
|
53
|
+
return { meta: parseYaml(yaml), body };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Minimal YAML parser: handles the scalar/list/nested-object subset
|
|
58
|
+
* emitted by serializeYaml below. Not a general YAML parser.
|
|
59
|
+
*/
|
|
60
|
+
function parseYaml(yaml) {
|
|
61
|
+
const lines = yaml.split("\n");
|
|
62
|
+
return parseYamlLines(lines, 0, 0).value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseYamlLines(lines, start, baseIndent) {
|
|
66
|
+
const obj = {};
|
|
67
|
+
let i = start;
|
|
68
|
+
while (i < lines.length) {
|
|
69
|
+
const line = lines[i];
|
|
70
|
+
if (line.trim() === "" || line.trim().startsWith("#")) { i++; continue; }
|
|
71
|
+
const indent = line.search(/\S/);
|
|
72
|
+
if (indent < baseIndent) break;
|
|
73
|
+
if (indent > baseIndent) { i++; continue; }
|
|
74
|
+
|
|
75
|
+
// key: value OR key:
|
|
76
|
+
const colonIdx = line.indexOf(":");
|
|
77
|
+
if (colonIdx === -1) { i++; continue; }
|
|
78
|
+
const key = line.slice(indent, colonIdx).trim();
|
|
79
|
+
const rest = line.slice(colonIdx + 1).trim();
|
|
80
|
+
|
|
81
|
+
if (rest.startsWith("[")) {
|
|
82
|
+
// Inline array: [a, b, c]
|
|
83
|
+
const inner = rest.slice(1, rest.lastIndexOf("]"));
|
|
84
|
+
obj[key] = inner ? inner.split(",").map((s) => unquote(s.trim())).filter(Boolean) : [];
|
|
85
|
+
i++;
|
|
86
|
+
} else if (rest === "") {
|
|
87
|
+
// Block: peek ahead
|
|
88
|
+
i++;
|
|
89
|
+
if (i < lines.length) {
|
|
90
|
+
const nextLine = lines[i];
|
|
91
|
+
const nextIndent = nextLine.search(/\S/);
|
|
92
|
+
if (nextIndent > baseIndent && nextLine.trimStart().startsWith("- ")) {
|
|
93
|
+
// Block sequence
|
|
94
|
+
const arr = [];
|
|
95
|
+
while (i < lines.length) {
|
|
96
|
+
const l = lines[i];
|
|
97
|
+
if (l.trim() === "") { i++; continue; }
|
|
98
|
+
const ind = l.search(/\S/);
|
|
99
|
+
if (ind < nextIndent) break;
|
|
100
|
+
if (l.trimStart().startsWith("- ")) {
|
|
101
|
+
const itemText = l.trimStart().slice(2).trim();
|
|
102
|
+
if (itemText.includes(": ") || (i + 1 < lines.length && lines[i + 1].search(/\S/) > ind + 1)) {
|
|
103
|
+
// Object item
|
|
104
|
+
const childLines = [" ".repeat(ind + 2) + itemText];
|
|
105
|
+
i++;
|
|
106
|
+
while (i < lines.length) {
|
|
107
|
+
const cl = lines[i];
|
|
108
|
+
const ci = cl.search(/\S/);
|
|
109
|
+
if (cl.trim() === "" || ci <= ind) break;
|
|
110
|
+
childLines.push(cl);
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
arr.push(parseYamlLines(childLines, 0, ind + 2).value);
|
|
114
|
+
} else {
|
|
115
|
+
arr.push(unquote(itemText));
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
obj[key] = arr;
|
|
123
|
+
} else if (nextIndent > baseIndent) {
|
|
124
|
+
// Nested mapping
|
|
125
|
+
const result = parseYamlLines(lines, i, nextIndent);
|
|
126
|
+
obj[key] = result.value;
|
|
127
|
+
i = result.next;
|
|
128
|
+
} else {
|
|
129
|
+
obj[key] = null;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
obj[key] = null;
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
obj[key] = unquote(rest);
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { value: obj, next: i };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function unquote(s) {
|
|
143
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
144
|
+
return s.slice(1, -1);
|
|
145
|
+
}
|
|
146
|
+
return s;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Serialize an object to YAML-ish text suitable for frontmatter.
|
|
151
|
+
* Only handles strings, numbers, arrays of primitives, and shallow objects.
|
|
152
|
+
*/
|
|
153
|
+
function serializeYaml(obj, indent = 0) {
|
|
154
|
+
const pad = " ".repeat(indent);
|
|
155
|
+
const lines = [];
|
|
156
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
157
|
+
if (value === undefined || value === null) continue;
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
if (value.length === 0) {
|
|
160
|
+
lines.push(`${pad}${key}: []`);
|
|
161
|
+
} else if (value.every((v) => typeof v !== "object")) {
|
|
162
|
+
lines.push(`${pad}${key}: [${value.map(yamlScalar).join(", ")}]`);
|
|
163
|
+
} else {
|
|
164
|
+
lines.push(`${pad}${key}:`);
|
|
165
|
+
for (const item of value) {
|
|
166
|
+
if (typeof item === "object" && item !== null) {
|
|
167
|
+
const entries = Object.entries(item).filter(([, v]) => v !== undefined && v !== null);
|
|
168
|
+
if (entries.length === 0) { lines.push(`${pad} - {}`); continue; }
|
|
169
|
+
const [firstKey, firstVal] = entries[0];
|
|
170
|
+
lines.push(`${pad} - ${firstKey}: ${yamlScalar(firstVal)}`);
|
|
171
|
+
for (const [k, v] of entries.slice(1)) {
|
|
172
|
+
lines.push(`${pad} ${k}: ${yamlScalar(v)}`);
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
lines.push(`${pad} - ${yamlScalar(item)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else if (typeof value === "object") {
|
|
180
|
+
lines.push(`${pad}${key}:`);
|
|
181
|
+
lines.push(serializeYaml(value, indent + 2));
|
|
182
|
+
} else {
|
|
183
|
+
lines.push(`${pad}${key}: ${yamlScalar(value)}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function yamlScalar(v) {
|
|
190
|
+
if (typeof v === "string") {
|
|
191
|
+
// Quote if it contains special chars
|
|
192
|
+
if (/[:#\[\]{},&*?|<>=!%@`"'\n]/.test(v) || v.trim() !== v || v === "") {
|
|
193
|
+
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
194
|
+
}
|
|
195
|
+
return v;
|
|
196
|
+
}
|
|
197
|
+
return String(v);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function serializeMarkdown(meta, body) {
|
|
201
|
+
return `---\n${serializeYaml(meta)}\n---\n\n${body}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Wikilink parser / indexer
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Extract all [[target_id]] and [[target_id|label]] links from body text.
|
|
212
|
+
* Returns Link objects.
|
|
213
|
+
*/
|
|
214
|
+
function extractWikilinks(body) {
|
|
215
|
+
const links = [];
|
|
216
|
+
for (const match of body.matchAll(WIKILINK_RE)) {
|
|
217
|
+
links.push({ target_id: match[1].trim(), kind: "related", label: match[2]?.trim() });
|
|
218
|
+
}
|
|
219
|
+
return links;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Merge explicit links array with wikilink-derived links.
|
|
224
|
+
* De-duplicates by (target_id, kind); explicit links win on conflict.
|
|
225
|
+
*/
|
|
226
|
+
function mergeLinks(explicit, wikilinks) {
|
|
227
|
+
const key = (l) => `${l.target_id}::${l.kind}`;
|
|
228
|
+
const seen = new Set(explicit.map(key));
|
|
229
|
+
const merged = [...explicit];
|
|
230
|
+
for (const wl of wikilinks) {
|
|
231
|
+
if (!seen.has(key(wl))) {
|
|
232
|
+
merged.push(wl);
|
|
233
|
+
seen.add(key(wl));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return merged;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Graph index
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
const GRAPH_SCHEMA_VERSION = "1.0";
|
|
244
|
+
|
|
245
|
+
function emptyGraph() {
|
|
246
|
+
return { schema_version: GRAPH_SCHEMA_VERSION, forward: {}, reverse: {} };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function loadGraph(graphPath) {
|
|
250
|
+
if (!fs.existsSync(graphPath)) return emptyGraph();
|
|
251
|
+
try {
|
|
252
|
+
return JSON.parse(fs.readFileSync(graphPath, "utf8"));
|
|
253
|
+
} catch {
|
|
254
|
+
return emptyGraph();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function saveGraph(graphPath, graph) {
|
|
259
|
+
fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf8");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function addLinksToGraph(graph, sourceId, links) {
|
|
263
|
+
if (!graph.forward[sourceId]) graph.forward[sourceId] = [];
|
|
264
|
+
for (const link of links) {
|
|
265
|
+
const { target_id, kind, label } = link;
|
|
266
|
+
// Idempotent: skip if already present
|
|
267
|
+
const exists = graph.forward[sourceId].some(
|
|
268
|
+
(l) => l.target_id === target_id && l.kind === kind
|
|
269
|
+
);
|
|
270
|
+
if (!exists) {
|
|
271
|
+
const entry = { target_id, kind };
|
|
272
|
+
if (label) entry.label = label;
|
|
273
|
+
graph.forward[sourceId].push(entry);
|
|
274
|
+
if (!graph.reverse[target_id]) graph.reverse[target_id] = [];
|
|
275
|
+
graph.reverse[target_id].push({ source_id: sourceId, kind });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function removeLinksFromGraph(graph, sourceId) {
|
|
281
|
+
const oldForward = graph.forward[sourceId] || [];
|
|
282
|
+
for (const link of oldForward) {
|
|
283
|
+
const rev = graph.reverse[link.target_id] || [];
|
|
284
|
+
graph.reverse[link.target_id] = rev.filter((r) => r.source_id !== sourceId);
|
|
285
|
+
if (graph.reverse[link.target_id].length === 0) delete graph.reverse[link.target_id];
|
|
286
|
+
}
|
|
287
|
+
delete graph.forward[sourceId];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Validation helpers
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
const VALID_TYPES = new Set(["raw", "compiled", "concept", "snapshot"]);
|
|
295
|
+
const CATEGORY_SEGMENT_RE = /^[a-z0-9_-]+$/;
|
|
296
|
+
|
|
297
|
+
function validateCategory(cat) {
|
|
298
|
+
if (!cat || typeof cat !== "string") return false;
|
|
299
|
+
return cat.split(".").every((seg) => CATEGORY_SEGMENT_RE.test(seg));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// DefaultKnowledgeStore
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
export class DefaultKnowledgeStore {
|
|
307
|
+
/**
|
|
308
|
+
* @param {{ storeRoot: string }} options
|
|
309
|
+
*/
|
|
310
|
+
constructor({ storeRoot }) {
|
|
311
|
+
if (!storeRoot) throw new Error("storeRoot is required");
|
|
312
|
+
this._root = path.resolve(storeRoot);
|
|
313
|
+
this._recordsDir = path.join(this._root, "records");
|
|
314
|
+
this._graphPath = path.join(this._root, "graph-index.json");
|
|
315
|
+
fs.mkdirSync(this._recordsDir, { recursive: true });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// -------------------------------------------------------------------------
|
|
319
|
+
// Internal helpers
|
|
320
|
+
// -------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
_recordPath(id) {
|
|
323
|
+
return path.join(this._recordsDir, `${id}.md`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_readRecord(id) {
|
|
327
|
+
const p = this._recordPath(id);
|
|
328
|
+
if (!fs.existsSync(p)) return null;
|
|
329
|
+
const text = fs.readFileSync(p, "utf8");
|
|
330
|
+
const { meta, body } = parseMarkdown(text);
|
|
331
|
+
return { ...meta, body };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_writeRecord(record) {
|
|
335
|
+
const { body, ...meta } = record;
|
|
336
|
+
const text = serializeMarkdown(meta, body);
|
|
337
|
+
fs.writeFileSync(this._recordPath(record.id), text, "utf8");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_now() {
|
|
341
|
+
return new Date().toISOString();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// -------------------------------------------------------------------------
|
|
345
|
+
// create
|
|
346
|
+
// -------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
async create(input) {
|
|
349
|
+
// Required field enforcement
|
|
350
|
+
if (!input.type) throw missingEvidenceError("create: missing required field: type");
|
|
351
|
+
if (!VALID_TYPES.has(input.type))
|
|
352
|
+
throw missingEvidenceError(`create: type must be raw, compiled, concept, or snapshot; got: ${input.type}`);
|
|
353
|
+
if (!input.title || !input.title.trim())
|
|
354
|
+
throw missingEvidenceError("create: missing required field: title");
|
|
355
|
+
if (!input.body && input.body !== "")
|
|
356
|
+
throw missingEvidenceError("create: missing required field: body");
|
|
357
|
+
if (input.body !== undefined && !input.body.trim && typeof input.body !== "string")
|
|
358
|
+
throw missingEvidenceError("create: body must be a string");
|
|
359
|
+
if (!input.category) throw missingEvidenceError("create: missing required field: category");
|
|
360
|
+
if (!validateCategory(input.category))
|
|
361
|
+
throw missingEvidenceError(`create: invalid category: ${input.category}`);
|
|
362
|
+
if (!input.provenance?.agent)
|
|
363
|
+
throw missingEvidenceError("create: missing required provenance field: provenance.agent");
|
|
364
|
+
|
|
365
|
+
const id = input.id || randomUUID();
|
|
366
|
+
const now = this._now();
|
|
367
|
+
|
|
368
|
+
// Merge explicit links + wikilinks from body
|
|
369
|
+
const explicitLinks = input.links || [];
|
|
370
|
+
const wikilinks = extractWikilinks(input.body || "");
|
|
371
|
+
const links = mergeLinks(explicitLinks, wikilinks);
|
|
372
|
+
|
|
373
|
+
const record = {
|
|
374
|
+
id,
|
|
375
|
+
type: input.type,
|
|
376
|
+
title: input.title,
|
|
377
|
+
category: input.category,
|
|
378
|
+
tags: input.tags || [],
|
|
379
|
+
created_at: now,
|
|
380
|
+
updated_at: now,
|
|
381
|
+
provenance: {
|
|
382
|
+
agent: input.provenance.agent,
|
|
383
|
+
...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
|
|
384
|
+
...(input.provenance.source_ids?.length ? { source_ids: input.provenance.source_ids } : {}),
|
|
385
|
+
...(input.provenance.note ? { note: input.provenance.note } : {}),
|
|
386
|
+
},
|
|
387
|
+
links,
|
|
388
|
+
mutation_log: [],
|
|
389
|
+
body: input.body || "",
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
this._writeRecord(record);
|
|
393
|
+
|
|
394
|
+
// Update graph index
|
|
395
|
+
const graph = loadGraph(this._graphPath);
|
|
396
|
+
addLinksToGraph(graph, id, links);
|
|
397
|
+
saveGraph(this._graphPath, graph);
|
|
398
|
+
|
|
399
|
+
return id;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// -------------------------------------------------------------------------
|
|
403
|
+
// update
|
|
404
|
+
// -------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
async update(id, fields, evidence) {
|
|
407
|
+
if (!evidence?.agent)
|
|
408
|
+
throw missingEvidenceError("update: missing required evidence field: agent");
|
|
409
|
+
|
|
410
|
+
const record = this._readRecord(id);
|
|
411
|
+
if (!record) throw notFoundError(id);
|
|
412
|
+
|
|
413
|
+
const mutableKeys = ["title", "body", "category", "tags", "links"];
|
|
414
|
+
const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
|
|
415
|
+
if (supplied.length === 0)
|
|
416
|
+
throw missingEvidenceError("update: at least one mutable field must be supplied");
|
|
417
|
+
|
|
418
|
+
if (fields.category !== undefined && !validateCategory(fields.category))
|
|
419
|
+
throw missingEvidenceError(`update: invalid category: ${fields.category}`);
|
|
420
|
+
|
|
421
|
+
const now = this._now();
|
|
422
|
+
|
|
423
|
+
// Merge links if updated
|
|
424
|
+
let newLinks = record.links || [];
|
|
425
|
+
if (fields.links !== undefined) {
|
|
426
|
+
const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
|
|
427
|
+
newLinks = mergeLinks(fields.links, wikilinks);
|
|
428
|
+
} else if (fields.body !== undefined) {
|
|
429
|
+
const wikilinks = extractWikilinks(fields.body);
|
|
430
|
+
newLinks = mergeLinks(record.links || [], wikilinks);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const updated = {
|
|
434
|
+
...record,
|
|
435
|
+
...(fields.title !== undefined ? { title: fields.title } : {}),
|
|
436
|
+
...(fields.body !== undefined ? { body: fields.body } : {}),
|
|
437
|
+
...(fields.category !== undefined ? { category: fields.category } : {}),
|
|
438
|
+
...(fields.tags !== undefined ? { tags: fields.tags } : {}),
|
|
439
|
+
links: newLinks,
|
|
440
|
+
updated_at: now,
|
|
441
|
+
mutation_log: [
|
|
442
|
+
...(record.mutation_log || []),
|
|
443
|
+
{
|
|
444
|
+
op: "update",
|
|
445
|
+
at: now,
|
|
446
|
+
agent: evidence.agent,
|
|
447
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
448
|
+
evidence: { fields: supplied },
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// Update graph index
|
|
454
|
+
const graph = loadGraph(this._graphPath);
|
|
455
|
+
removeLinksFromGraph(graph, id);
|
|
456
|
+
addLinksToGraph(graph, id, newLinks);
|
|
457
|
+
saveGraph(this._graphPath, graph);
|
|
458
|
+
|
|
459
|
+
this._writeRecord(updated);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// -------------------------------------------------------------------------
|
|
463
|
+
// link
|
|
464
|
+
// -------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
async link(sourceId, links, evidence) {
|
|
467
|
+
if (!evidence?.agent)
|
|
468
|
+
throw missingEvidenceError("link: missing required evidence field: agent");
|
|
469
|
+
if (!links || links.length === 0)
|
|
470
|
+
throw missingEvidenceError("link: links array must be non-empty");
|
|
471
|
+
|
|
472
|
+
const source = this._readRecord(sourceId);
|
|
473
|
+
if (!source) throw notFoundError(sourceId);
|
|
474
|
+
|
|
475
|
+
for (const l of links) {
|
|
476
|
+
if (!this._readRecord(l.target_id)) throw notFoundError(l.target_id);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const now = this._now();
|
|
480
|
+
const existingLinks = source.links || [];
|
|
481
|
+
|
|
482
|
+
// Idempotent merge
|
|
483
|
+
const key = (l) => `${l.target_id}::${l.kind}`;
|
|
484
|
+
const seen = new Set(existingLinks.map(key));
|
|
485
|
+
const newLinks = [...existingLinks];
|
|
486
|
+
for (const l of links) {
|
|
487
|
+
if (!seen.has(key(l))) {
|
|
488
|
+
newLinks.push(l);
|
|
489
|
+
seen.add(key(l));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const updated = {
|
|
494
|
+
...source,
|
|
495
|
+
links: newLinks,
|
|
496
|
+
updated_at: now,
|
|
497
|
+
mutation_log: [
|
|
498
|
+
...(source.mutation_log || []),
|
|
499
|
+
{
|
|
500
|
+
op: "link",
|
|
501
|
+
at: now,
|
|
502
|
+
agent: evidence.agent,
|
|
503
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
504
|
+
evidence: { added: links },
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const graph = loadGraph(this._graphPath);
|
|
510
|
+
removeLinksFromGraph(graph, sourceId);
|
|
511
|
+
addLinksToGraph(graph, sourceId, newLinks);
|
|
512
|
+
saveGraph(this._graphPath, graph);
|
|
513
|
+
|
|
514
|
+
this._writeRecord(updated);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// -------------------------------------------------------------------------
|
|
518
|
+
// propose
|
|
519
|
+
// -------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
async propose(conceptId, proposerId, evidence) {
|
|
522
|
+
if (!evidence?.agent)
|
|
523
|
+
throw missingEvidenceError("propose: missing required evidence field: agent");
|
|
524
|
+
if (!evidence?.proposal || !evidence.proposal.trim())
|
|
525
|
+
throw missingEvidenceError("propose: missing required evidence field: proposal");
|
|
526
|
+
|
|
527
|
+
const concept = this._readRecord(conceptId);
|
|
528
|
+
if (!concept) throw notFoundError(conceptId);
|
|
529
|
+
if (concept.type !== "concept" && concept.type !== "snapshot")
|
|
530
|
+
throw missingEvidenceError(`propose: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
|
|
531
|
+
|
|
532
|
+
const proposer = this._readRecord(proposerId);
|
|
533
|
+
if (!proposer) throw notFoundError(proposerId);
|
|
534
|
+
|
|
535
|
+
const now = this._now();
|
|
536
|
+
|
|
537
|
+
// Add proposes link from proposer to concept
|
|
538
|
+
const proposerLinks = proposer.links || [];
|
|
539
|
+
const alreadyLinked = proposerLinks.some(
|
|
540
|
+
(l) => l.target_id === conceptId && l.kind === "proposes"
|
|
541
|
+
);
|
|
542
|
+
if (!alreadyLinked) {
|
|
543
|
+
const updatedProposer = {
|
|
544
|
+
...proposer,
|
|
545
|
+
links: [...proposerLinks, { target_id: conceptId, kind: "proposes" }],
|
|
546
|
+
updated_at: now,
|
|
547
|
+
mutation_log: [
|
|
548
|
+
...(proposer.mutation_log || []),
|
|
549
|
+
{
|
|
550
|
+
op: "propose",
|
|
551
|
+
at: now,
|
|
552
|
+
agent: evidence.agent,
|
|
553
|
+
evidence: { concept_id: conceptId, proposal: evidence.proposal },
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
};
|
|
557
|
+
this._writeRecord(updatedProposer);
|
|
558
|
+
|
|
559
|
+
const graph = loadGraph(this._graphPath);
|
|
560
|
+
removeLinksFromGraph(graph, proposerId);
|
|
561
|
+
addLinksToGraph(graph, proposerId, updatedProposer.links);
|
|
562
|
+
saveGraph(this._graphPath, graph);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Append mutation log to concept
|
|
566
|
+
const updatedConcept = {
|
|
567
|
+
...concept,
|
|
568
|
+
mutation_log: [
|
|
569
|
+
...(concept.mutation_log || []),
|
|
570
|
+
{
|
|
571
|
+
op: "propose",
|
|
572
|
+
at: now,
|
|
573
|
+
agent: evidence.agent,
|
|
574
|
+
evidence: { proposer_id: proposerId, proposal: evidence.proposal },
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
};
|
|
578
|
+
this._writeRecord(updatedConcept);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// -------------------------------------------------------------------------
|
|
582
|
+
// apply
|
|
583
|
+
// -------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
async apply(conceptId, proposerId, evidence) {
|
|
586
|
+
if (!evidence?.agent)
|
|
587
|
+
throw missingEvidenceError("apply: missing required evidence field: agent");
|
|
588
|
+
if (!evidence?.new_body && evidence?.new_body !== "")
|
|
589
|
+
throw missingEvidenceError("apply: missing required evidence field: new_body");
|
|
590
|
+
if (!evidence?.new_body?.trim?.())
|
|
591
|
+
throw missingEvidenceError("apply: new_body must be non-empty");
|
|
592
|
+
if (!evidence?.rationale || !evidence.rationale.trim())
|
|
593
|
+
throw missingEvidenceError("apply: missing required evidence field: rationale");
|
|
594
|
+
|
|
595
|
+
const concept = this._readRecord(conceptId);
|
|
596
|
+
if (!concept) throw notFoundError(conceptId);
|
|
597
|
+
if (concept.type !== "concept" && concept.type !== "snapshot")
|
|
598
|
+
throw missingEvidenceError(`apply: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
|
|
599
|
+
|
|
600
|
+
const proposer = this._readRecord(proposerId);
|
|
601
|
+
if (!proposer) throw notFoundError(proposerId);
|
|
602
|
+
|
|
603
|
+
const proposerLinks = proposer.links || [];
|
|
604
|
+
const hasProposesLink = proposerLinks.some(
|
|
605
|
+
(l) => l.target_id === conceptId && l.kind === "proposes"
|
|
606
|
+
);
|
|
607
|
+
if (!hasProposesLink)
|
|
608
|
+
throw missingEvidenceError(`apply: no "proposes" link from ${proposerId} to ${conceptId}`);
|
|
609
|
+
|
|
610
|
+
const now = this._now();
|
|
611
|
+
const updatedConcept = {
|
|
612
|
+
...concept,
|
|
613
|
+
body: evidence.new_body,
|
|
614
|
+
updated_at: now,
|
|
615
|
+
mutation_log: [
|
|
616
|
+
...(concept.mutation_log || []),
|
|
617
|
+
{
|
|
618
|
+
op: "apply",
|
|
619
|
+
at: now,
|
|
620
|
+
agent: evidence.agent,
|
|
621
|
+
evidence: { proposer_id: proposerId, rationale: evidence.rationale },
|
|
622
|
+
},
|
|
623
|
+
],
|
|
624
|
+
};
|
|
625
|
+
this._writeRecord(updatedConcept);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// -------------------------------------------------------------------------
|
|
629
|
+
// reject
|
|
630
|
+
// -------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
async reject(conceptId, proposerId, evidence) {
|
|
633
|
+
if (!evidence?.agent)
|
|
634
|
+
throw missingEvidenceError("reject: missing required evidence field: agent");
|
|
635
|
+
if (!evidence?.reason || !evidence.reason.trim())
|
|
636
|
+
throw missingEvidenceError("reject: missing required evidence field: reason");
|
|
637
|
+
|
|
638
|
+
const concept = this._readRecord(conceptId);
|
|
639
|
+
if (!concept) throw notFoundError(conceptId);
|
|
640
|
+
if (concept.type !== "concept" && concept.type !== "snapshot")
|
|
641
|
+
throw missingEvidenceError(`reject: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
|
|
642
|
+
|
|
643
|
+
const proposer = this._readRecord(proposerId);
|
|
644
|
+
if (!proposer) throw notFoundError(proposerId);
|
|
645
|
+
|
|
646
|
+
const proposerLinks = proposer.links || [];
|
|
647
|
+
const hasProposesLink = proposerLinks.some(
|
|
648
|
+
(l) => l.target_id === conceptId && l.kind === "proposes"
|
|
649
|
+
);
|
|
650
|
+
if (!hasProposesLink)
|
|
651
|
+
throw missingEvidenceError(`reject: no "proposes" link from ${proposerId} to ${conceptId}`);
|
|
652
|
+
|
|
653
|
+
const now = this._now();
|
|
654
|
+
const updatedConcept = {
|
|
655
|
+
...concept,
|
|
656
|
+
// updated_at NOT changed — concept body was not mutated
|
|
657
|
+
mutation_log: [
|
|
658
|
+
...(concept.mutation_log || []),
|
|
659
|
+
{
|
|
660
|
+
op: "reject",
|
|
661
|
+
at: now,
|
|
662
|
+
agent: evidence.agent,
|
|
663
|
+
evidence: { proposer_id: proposerId, reason: evidence.reason },
|
|
664
|
+
},
|
|
665
|
+
],
|
|
666
|
+
};
|
|
667
|
+
this._writeRecord(updatedConcept);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
// -------------------------------------------------------------------------
|
|
672
|
+
// supersede
|
|
673
|
+
// -------------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
async supersede(newId, supersededIds, evidence) {
|
|
676
|
+
if (!evidence?.agent)
|
|
677
|
+
throw missingEvidenceError("supersede: missing required evidence field: agent");
|
|
678
|
+
if (!evidence?.rationale || !evidence.rationale.trim())
|
|
679
|
+
throw missingEvidenceError("supersede: missing required evidence field: rationale");
|
|
680
|
+
if (!supersededIds || supersededIds.length === 0)
|
|
681
|
+
throw missingEvidenceError("supersede: supersededIds must be a non-empty array");
|
|
682
|
+
|
|
683
|
+
const newRecord = this._readRecord(newId);
|
|
684
|
+
if (!newRecord) throw notFoundError(newId);
|
|
685
|
+
|
|
686
|
+
// Verify all superseded records exist
|
|
687
|
+
for (const sid of supersededIds) {
|
|
688
|
+
const rec = this._readRecord(sid);
|
|
689
|
+
if (!rec) throw notFoundError(sid);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const now = this._now();
|
|
693
|
+
|
|
694
|
+
// Add supersedes links from newId to each superseded record
|
|
695
|
+
const supersededLinks = supersededIds.map((sid) => ({
|
|
696
|
+
target_id: sid,
|
|
697
|
+
kind: "supersedes",
|
|
698
|
+
}));
|
|
699
|
+
|
|
700
|
+
// Update newId record: add supersedes links + mutation log entry
|
|
701
|
+
const existingLinks = newRecord.links || [];
|
|
702
|
+
const key = (l) => `${l.target_id}::${l.kind}`;
|
|
703
|
+
const seen = new Set(existingLinks.map(key));
|
|
704
|
+
const newLinks = [...existingLinks];
|
|
705
|
+
for (const l of supersededLinks) {
|
|
706
|
+
if (!seen.has(key(l))) {
|
|
707
|
+
newLinks.push(l);
|
|
708
|
+
seen.add(key(l));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const updatedNew = {
|
|
713
|
+
...newRecord,
|
|
714
|
+
links: newLinks,
|
|
715
|
+
updated_at: now,
|
|
716
|
+
mutation_log: [
|
|
717
|
+
...(newRecord.mutation_log || []),
|
|
718
|
+
{
|
|
719
|
+
op: "supersede",
|
|
720
|
+
at: now,
|
|
721
|
+
agent: evidence.agent,
|
|
722
|
+
rationale: evidence.rationale,
|
|
723
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
724
|
+
evidence: { superseded_count: supersededIds.length },
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const graph = loadGraph(this._graphPath);
|
|
730
|
+
removeLinksFromGraph(graph, newId);
|
|
731
|
+
addLinksToGraph(graph, newId, newLinks);
|
|
732
|
+
saveGraph(this._graphPath, graph);
|
|
733
|
+
|
|
734
|
+
this._writeRecord(updatedNew);
|
|
735
|
+
|
|
736
|
+
// Append superseded-by mutation log entry to each superseded record
|
|
737
|
+
// Records are NOT deleted — supersede-not-delete invariant
|
|
738
|
+
for (const sid of supersededIds) {
|
|
739
|
+
const supersededRec = this._readRecord(sid);
|
|
740
|
+
if (!supersededRec) continue; // already verified above; defensive
|
|
741
|
+
const updatedSuperseded = {
|
|
742
|
+
...supersededRec,
|
|
743
|
+
// updated_at NOT changed — the record content is not mutated
|
|
744
|
+
mutation_log: [
|
|
745
|
+
...(supersededRec.mutation_log || []),
|
|
746
|
+
{
|
|
747
|
+
op: "superseded-by",
|
|
748
|
+
at: now,
|
|
749
|
+
agent: evidence.agent,
|
|
750
|
+
new_id: newId,
|
|
751
|
+
rationale: evidence.rationale,
|
|
752
|
+
...(evidence.note ? { note: evidence.note } : {}),
|
|
753
|
+
evidence: { superseded_by_id: newId },
|
|
754
|
+
},
|
|
755
|
+
],
|
|
756
|
+
};
|
|
757
|
+
this._writeRecord(updatedSuperseded);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
// -------------------------------------------------------------------------
|
|
763
|
+
// get
|
|
764
|
+
// -------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
async get(id) {
|
|
767
|
+
return this._readRecord(id);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// -------------------------------------------------------------------------
|
|
771
|
+
// getLinks
|
|
772
|
+
// -------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
async getLinks(id) {
|
|
775
|
+
const graph = loadGraph(this._graphPath);
|
|
776
|
+
return {
|
|
777
|
+
forward: (graph.forward[id] || []).map((l) => ({ ...l })),
|
|
778
|
+
reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// -------------------------------------------------------------------------
|
|
783
|
+
// listByCategory
|
|
784
|
+
// -------------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
async listByCategory(category, options = {}) {
|
|
787
|
+
const records = this._allRecords();
|
|
788
|
+
if (options.prefix) {
|
|
789
|
+
return records.filter(
|
|
790
|
+
(r) => r.category === category || r.category.startsWith(`${category}.`)
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
return records.filter((r) => r.category === category);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// -------------------------------------------------------------------------
|
|
797
|
+
// listByType
|
|
798
|
+
// -------------------------------------------------------------------------
|
|
799
|
+
|
|
800
|
+
async listByType(type) {
|
|
801
|
+
return this._allRecords().filter((r) => r.type === type);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// -------------------------------------------------------------------------
|
|
805
|
+
// Internal: read all records
|
|
806
|
+
// -------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
_allRecords() {
|
|
809
|
+
if (!fs.existsSync(this._recordsDir)) return [];
|
|
810
|
+
const files = fs.readdirSync(this._recordsDir).filter((f) => f.endsWith(".md"));
|
|
811
|
+
const records = [];
|
|
812
|
+
for (const file of files) {
|
|
813
|
+
const id = file.slice(0, -3);
|
|
814
|
+
const record = this._readRecord(id);
|
|
815
|
+
if (record) records.push(record);
|
|
816
|
+
}
|
|
817
|
+
return records;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export default DefaultKnowledgeStore;
|