@kontourai/flow-agents 0.4.0 → 1.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.
- package/.github/workflows/kit-gates-demo.yml +171 -0
- package/CHANGELOG.md +35 -0
- package/CONTEXT.md +1 -1
- package/README.md +13 -2
- package/build/src/cli/flow-kit.js +41 -2
- package/build/src/flow-kit/validate.js +98 -0
- package/build/src/tools/validate-source-tree.js +2 -1
- package/context/scripts/hooks/config-protection.js +217 -15
- package/docs/fixture-ownership.md +1 -0
- package/docs/index.md +9 -1
- package/docs/kit-authoring-guide.md +126 -0
- package/docs/knowledge-kit.md +69 -0
- package/docs/vision.md +22 -0
- package/evals/fixtures/kit-conformance-levels/k0-flows-only/flows/review.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k0-flows-only/kit.json +13 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/docs/README.md +3 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/flows/build.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/kit.json +20 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/docs/README.md +3 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/eval-suites/contract-suite/suite.test.js +1 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/flows/synthesize.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/kit.json +27 -0
- package/evals/fixtures/kit-conformance-levels/third-party-extension/flows/review.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/third-party-extension/kit.json +19 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_hook_category_behaviors.sh +51 -0
- package/evals/integration/test_kit_conformance_levels.sh +209 -0
- package/evals/run.sh +2 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +2 -2
- package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
- package/kits/knowledge/adapters/flow-runner/index.js +349 -0
- package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
- package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
- package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
- package/kits/knowledge/adapters/shared/codec.js +325 -0
- package/kits/knowledge/docs/store-contract.md +72 -0
- package/kits/knowledge/evals/entities/demo-acme.js +125 -0
- package/kits/knowledge/evals/entities/suite.test.js +722 -0
- package/kits/knowledge/kit.json +10 -0
- package/kits/release-evidence/fixtures/claims/README.md +14 -0
- package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
- package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
- package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
- package/kits/release-evidence/kit.json +13 -0
- package/package.json +1 -1
- package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
- package/scripts/hooks/config-protection.js +217 -15
- package/src/cli/flow-kit.ts +40 -2
- package/src/flow-kit/validate.ts +127 -0
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit — Shared Codec
|
|
3
|
+
*
|
|
4
|
+
* Utility functions shared between Knowledge Kit store adapters:
|
|
5
|
+
* - Error helpers (MISSING_EVIDENCE, NOT_FOUND)
|
|
6
|
+
* - YAML frontmatter codec (zero-dep subset)
|
|
7
|
+
* - Wikilink parser / indexer
|
|
8
|
+
* - Graph index helpers
|
|
9
|
+
* - Validation constants and helpers
|
|
10
|
+
*
|
|
11
|
+
* @module adapters/shared/codec
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Error helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export function missingEvidenceError(message) {
|
|
22
|
+
const err = new Error(message);
|
|
23
|
+
err.code = "MISSING_EVIDENCE";
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function notFoundError(id) {
|
|
28
|
+
const err = new Error(`Record not found: ${id}`);
|
|
29
|
+
err.code = "NOT_FOUND";
|
|
30
|
+
return err;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// YAML frontmatter codec (no external deps — handles the subset we need)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse a markdown file that begins with a YAML frontmatter block.
|
|
39
|
+
* Returns { meta, body }.
|
|
40
|
+
*/
|
|
41
|
+
export function parseMarkdown(text) {
|
|
42
|
+
if (!text.startsWith("---\n")) {
|
|
43
|
+
return { meta: {}, body: text };
|
|
44
|
+
}
|
|
45
|
+
const end = text.indexOf("\n---\n", 4);
|
|
46
|
+
if (end === -1) {
|
|
47
|
+
return { meta: {}, body: text };
|
|
48
|
+
}
|
|
49
|
+
const yaml = text.slice(4, end);
|
|
50
|
+
const body = text.slice(end + 5).replace(/^\n+/, "");
|
|
51
|
+
return { meta: parseYaml(yaml), body };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Minimal YAML parser: handles the scalar/list/nested-object subset
|
|
56
|
+
* emitted by serializeYaml below. Not a general YAML parser.
|
|
57
|
+
*/
|
|
58
|
+
export function parseYaml(yaml) {
|
|
59
|
+
const lines = yaml.split("\n");
|
|
60
|
+
return parseYamlLines(lines, 0, 0).value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseYamlLines(lines, start, baseIndent) {
|
|
64
|
+
const obj = {};
|
|
65
|
+
let i = start;
|
|
66
|
+
while (i < lines.length) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
if (line.trim() === "" || line.trim().startsWith("#")) { i++; continue; }
|
|
69
|
+
const indent = line.search(/\S/);
|
|
70
|
+
if (indent < baseIndent) break;
|
|
71
|
+
if (indent > baseIndent) { i++; continue; }
|
|
72
|
+
|
|
73
|
+
// key: value OR key:
|
|
74
|
+
const colonIdx = line.indexOf(":");
|
|
75
|
+
if (colonIdx === -1) { i++; continue; }
|
|
76
|
+
const key = line.slice(indent, colonIdx).trim();
|
|
77
|
+
const rest = line.slice(colonIdx + 1).trim();
|
|
78
|
+
|
|
79
|
+
if (rest.startsWith("[")) {
|
|
80
|
+
// Inline array: [a, b, c]
|
|
81
|
+
const inner = rest.slice(1, rest.lastIndexOf("]"));
|
|
82
|
+
obj[key] = inner ? inner.split(",").map((s) => unquote(s.trim())).filter(Boolean) : [];
|
|
83
|
+
i++;
|
|
84
|
+
} else if (rest === "") {
|
|
85
|
+
// Block: peek ahead
|
|
86
|
+
i++;
|
|
87
|
+
if (i < lines.length) {
|
|
88
|
+
const nextLine = lines[i];
|
|
89
|
+
const nextIndent = nextLine.search(/\S/);
|
|
90
|
+
if (nextIndent > baseIndent && nextLine.trimStart().startsWith("- ")) {
|
|
91
|
+
// Block sequence
|
|
92
|
+
const arr = [];
|
|
93
|
+
while (i < lines.length) {
|
|
94
|
+
const l = lines[i];
|
|
95
|
+
if (l.trim() === "") { i++; continue; }
|
|
96
|
+
const ind = l.search(/\S/);
|
|
97
|
+
if (ind < nextIndent) break;
|
|
98
|
+
if (l.trimStart().startsWith("- ")) {
|
|
99
|
+
const itemText = l.trimStart().slice(2).trim();
|
|
100
|
+
if (itemText.includes(": ") || (i + 1 < lines.length && lines[i + 1].search(/\S/) > ind + 1)) {
|
|
101
|
+
// Object item
|
|
102
|
+
const childLines = [" ".repeat(ind + 2) + itemText];
|
|
103
|
+
i++;
|
|
104
|
+
while (i < lines.length) {
|
|
105
|
+
const cl = lines[i];
|
|
106
|
+
const ci = cl.search(/\S/);
|
|
107
|
+
if (cl.trim() === "" || ci <= ind) break;
|
|
108
|
+
childLines.push(cl);
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
arr.push(parseYamlLines(childLines, 0, ind + 2).value);
|
|
112
|
+
} else {
|
|
113
|
+
arr.push(unquote(itemText));
|
|
114
|
+
i++;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
obj[key] = arr;
|
|
121
|
+
} else if (nextIndent > baseIndent) {
|
|
122
|
+
// Nested mapping
|
|
123
|
+
const result = parseYamlLines(lines, i, nextIndent);
|
|
124
|
+
obj[key] = result.value;
|
|
125
|
+
i = result.next;
|
|
126
|
+
} else {
|
|
127
|
+
obj[key] = null;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
obj[key] = null;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
obj[key] = unquote(rest);
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { value: obj, next: i };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function unquote(s) {
|
|
141
|
+
if (s.startsWith('"') && s.endsWith('"')) {
|
|
142
|
+
return s.slice(1, -1).replace(/\\(\\|n|r|")/g, (_, c) => {
|
|
143
|
+
if (c === "\\") return "\\";
|
|
144
|
+
if (c === "n") return "\n";
|
|
145
|
+
if (c === "r") return "\r";
|
|
146
|
+
if (c === '"') return '"';
|
|
147
|
+
return c;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (s.startsWith("'") && s.endsWith("'")) {
|
|
151
|
+
return s.slice(1, -1);
|
|
152
|
+
}
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Serialize an object to YAML-ish text suitable for frontmatter.
|
|
158
|
+
* Only handles strings, numbers, arrays of primitives, and shallow objects.
|
|
159
|
+
*/
|
|
160
|
+
export function serializeYaml(obj, indent = 0) {
|
|
161
|
+
const pad = " ".repeat(indent);
|
|
162
|
+
const lines = [];
|
|
163
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
164
|
+
if (value === undefined || value === null) continue;
|
|
165
|
+
if (Array.isArray(value)) {
|
|
166
|
+
if (value.length === 0) {
|
|
167
|
+
lines.push(`${pad}${key}: []`);
|
|
168
|
+
} else if (value.every((v) => typeof v !== "object")) {
|
|
169
|
+
lines.push(`${pad}${key}: [${value.map(yamlScalar).join(", ")}]`);
|
|
170
|
+
} else {
|
|
171
|
+
lines.push(`${pad}${key}:`);
|
|
172
|
+
for (const item of value) {
|
|
173
|
+
if (typeof item === "object" && item !== null) {
|
|
174
|
+
const entries = Object.entries(item).filter(([, v]) => v !== undefined && v !== null);
|
|
175
|
+
if (entries.length === 0) { lines.push(`${pad} - {}`); continue; }
|
|
176
|
+
const [firstKey, firstVal] = entries[0];
|
|
177
|
+
if (typeof firstVal === "object" && firstVal !== null && !Array.isArray(firstVal)) {
|
|
178
|
+
lines.push(`${pad} - ${firstKey}:`);
|
|
179
|
+
lines.push(serializeYaml(firstVal, indent + 6));
|
|
180
|
+
} else {
|
|
181
|
+
lines.push(`${pad} - ${firstKey}: ${yamlScalar(firstVal)}`);
|
|
182
|
+
}
|
|
183
|
+
for (const [k, v] of entries.slice(1)) {
|
|
184
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
185
|
+
lines.push(`${pad} ${k}:`);
|
|
186
|
+
lines.push(serializeYaml(v, indent + 6));
|
|
187
|
+
} else {
|
|
188
|
+
lines.push(`${pad} ${k}: ${yamlScalar(v)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
lines.push(`${pad} - ${yamlScalar(item)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} else if (typeof value === "object") {
|
|
197
|
+
lines.push(`${pad}${key}:`);
|
|
198
|
+
lines.push(serializeYaml(value, indent + 2));
|
|
199
|
+
} else {
|
|
200
|
+
lines.push(`${pad}${key}: ${yamlScalar(value)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return lines.join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function yamlScalar(v) {
|
|
207
|
+
if (typeof v === "string") {
|
|
208
|
+
// Quote if it contains special chars or actual newlines/carriage returns
|
|
209
|
+
if (/[:#\[\]{},&*?|<>=!%@`"'\n\r]/.test(v) || v.trim() !== v || v === "") {
|
|
210
|
+
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`;
|
|
211
|
+
}
|
|
212
|
+
return v;
|
|
213
|
+
}
|
|
214
|
+
return String(v);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function serializeMarkdown(meta, body) {
|
|
218
|
+
return `---\n${serializeYaml(meta)}\n---\n\n${body}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Wikilink parser / indexer
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Extract all [[target_id]] and [[target_id|label]] links from body text.
|
|
229
|
+
* Returns Link objects.
|
|
230
|
+
*/
|
|
231
|
+
export function extractWikilinks(body) {
|
|
232
|
+
const links = [];
|
|
233
|
+
for (const match of body.matchAll(WIKILINK_RE)) {
|
|
234
|
+
links.push({ target_id: match[1].trim(), kind: "related", label: match[2]?.trim() });
|
|
235
|
+
}
|
|
236
|
+
return links;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Merge explicit links array with wikilink-derived links.
|
|
241
|
+
* De-duplicates by (target_id, kind); explicit links win on conflict.
|
|
242
|
+
*/
|
|
243
|
+
export function mergeLinks(explicit, wikilinks) {
|
|
244
|
+
const key = (l) => `${l.target_id}::${l.kind}`;
|
|
245
|
+
const seen = new Set(explicit.map(key));
|
|
246
|
+
const merged = [...explicit];
|
|
247
|
+
for (const wl of wikilinks) {
|
|
248
|
+
if (!seen.has(key(wl))) {
|
|
249
|
+
merged.push(wl);
|
|
250
|
+
seen.add(key(wl));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return merged;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Graph index
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export const GRAPH_SCHEMA_VERSION = "1.0";
|
|
261
|
+
|
|
262
|
+
export function emptyGraph() {
|
|
263
|
+
return { schema_version: GRAPH_SCHEMA_VERSION, forward: {}, reverse: {} };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function loadGraph(graphPath) {
|
|
267
|
+
if (!fs.existsSync(graphPath)) return emptyGraph();
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(fs.readFileSync(graphPath, "utf8"));
|
|
270
|
+
} catch {
|
|
271
|
+
return emptyGraph();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function saveGraph(graphPath, graph) {
|
|
276
|
+
fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf8");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function addLinksToGraph(graph, sourceId, links) {
|
|
280
|
+
if (!graph.forward[sourceId]) graph.forward[sourceId] = [];
|
|
281
|
+
for (const link of links) {
|
|
282
|
+
const { target_id, kind, label } = link;
|
|
283
|
+
// Idempotent: skip if already present
|
|
284
|
+
const exists = graph.forward[sourceId].some(
|
|
285
|
+
(l) => l.target_id === target_id && l.kind === kind
|
|
286
|
+
);
|
|
287
|
+
if (!exists) {
|
|
288
|
+
const entry = { target_id, kind };
|
|
289
|
+
if (label) entry.label = label;
|
|
290
|
+
graph.forward[sourceId].push(entry);
|
|
291
|
+
if (!graph.reverse[target_id]) graph.reverse[target_id] = [];
|
|
292
|
+
graph.reverse[target_id].push({ source_id: sourceId, kind });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function removeLinksFromGraph(graph, sourceId) {
|
|
298
|
+
const oldForward = graph.forward[sourceId] || [];
|
|
299
|
+
for (const link of oldForward) {
|
|
300
|
+
const rev = graph.reverse[link.target_id] || [];
|
|
301
|
+
graph.reverse[link.target_id] = rev.filter((r) => r.source_id !== sourceId);
|
|
302
|
+
if (graph.reverse[link.target_id].length === 0) delete graph.reverse[link.target_id];
|
|
303
|
+
}
|
|
304
|
+
delete graph.forward[sourceId];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Validation helpers
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
export const VALID_TYPES = new Set(["raw", "compiled", "concept", "snapshot", "person"]);
|
|
312
|
+
export const VALID_STATUSES = new Set(["active", "implemented", "retired"]);
|
|
313
|
+
const CATEGORY_SEGMENT_RE = /^[a-z0-9_-]+$/;
|
|
314
|
+
|
|
315
|
+
// Status transition table: from → allowed targets
|
|
316
|
+
export const VALID_STATUS_TRANSITIONS = {
|
|
317
|
+
active: new Set(["implemented", "retired"]),
|
|
318
|
+
implemented: new Set(["retired"]),
|
|
319
|
+
retired: new Set(), // terminal — no further transitions
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export function validateCategory(cat) {
|
|
323
|
+
if (!cat || typeof cat !== "string") return false;
|
|
324
|
+
return cat.split(".").every((seg) => CATEGORY_SEGMENT_RE.test(seg));
|
|
325
|
+
}
|
|
@@ -648,3 +648,75 @@ Retired records MUST remain reachable from:
|
|
|
648
648
|
|
|
649
649
|
There is no deletion of records. Physical purge (if ever needed) is a separate, future policy
|
|
650
650
|
hook not defined in this version.
|
|
651
|
+
|
|
652
|
+
---
|
|
653
|
+
|
|
654
|
+
## Addendum C — Person Record Type (Entity Cards)
|
|
655
|
+
|
|
656
|
+
### C.1 `person` Record Type
|
|
657
|
+
|
|
658
|
+
A `person` record is a first-class entity card for a named individual mentioned in the knowledge base.
|
|
659
|
+
It participates fully in links, the graph index, and status lifecycle like any other record type.
|
|
660
|
+
|
|
661
|
+
| Field | Notes |
|
|
662
|
+
|---|---|
|
|
663
|
+
| `type` | `"person"` |
|
|
664
|
+
| `title` | The person's full name. Used for exact-match resolution. |
|
|
665
|
+
| `body` | Structured prose with role and/or org: `**Role/Org:** <text>`. Merged on apply during card union. |
|
|
666
|
+
| `tags` | May include `alias:<name>` entries for alternative names (nicknames, initials). |
|
|
667
|
+
| `category` | Dot-separated category. The Obsidian adapter ignores this for routing — person records always land in `people/`. |
|
|
668
|
+
|
|
669
|
+
### C.2 Link Kinds Extended
|
|
670
|
+
|
|
671
|
+
| Kind | Direction | Meaning |
|
|
672
|
+
|---|---|---|
|
|
673
|
+
| `"appears-in"` | person → raw\|compiled | Person was mentioned in that record. |
|
|
674
|
+
| `"person"` | compiled → person | Compiled record references a person card. |
|
|
675
|
+
|
|
676
|
+
### C.3 Obsidian Adapter Layout
|
|
677
|
+
|
|
678
|
+
The Obsidian store adapter (`adapters/obsidian-store`) places person records under a
|
|
679
|
+
top-level `people/` folder regardless of category, so person cards are vault-global entities.
|
|
680
|
+
|
|
681
|
+
```
|
|
682
|
+
<storeRoot>/
|
|
683
|
+
people/
|
|
684
|
+
dana-smith.md
|
|
685
|
+
lee-wong.md
|
|
686
|
+
<category-as-path>/
|
|
687
|
+
<title-slug>.md (concept, snapshot)
|
|
688
|
+
<sourcesDir>/
|
|
689
|
+
<title-slug>.md (raw, compiled)
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Person cards render an **Appears In** section listing all `appears-in` links as Obsidian wikilinks.
|
|
693
|
+
Notes that reference people render a **People** section listing `person` links.
|
|
694
|
+
|
|
695
|
+
### C.4 Alias Storage
|
|
696
|
+
|
|
697
|
+
Aliases are stored in the `tags` array using the prefix `alias:`:
|
|
698
|
+
|
|
699
|
+
```yaml
|
|
700
|
+
tags: [alias:Dana S., alias:D. Smith]
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
Resolution checks both `title` and all `alias:*` tags for exact normalised-name match.
|
|
704
|
+
|
|
705
|
+
### C.5 Entity Extraction (Flow Runner)
|
|
706
|
+
|
|
707
|
+
The `KnowledgeFlowRunner.extractEntities(compiledId, options)` method:
|
|
708
|
+
|
|
709
|
+
1. Runs the extractor (default: `defaultEntityExtractor`) against the compiled record and its source raws.
|
|
710
|
+
2. For each mention, resolves via exact-name match (incl. aliases) or creates a new person card.
|
|
711
|
+
3. Near-matches (same surname + initial) create a **separate** card with a `related` link labelled
|
|
712
|
+
`possible-duplicate` — no auto-merge.
|
|
713
|
+
4. Writes bidirectional links: `person → raw+compiled` (kind `appears-in`) and `compiled → person` (kind `person`).
|
|
714
|
+
|
|
715
|
+
### C.6 Card Merge
|
|
716
|
+
|
|
717
|
+
Card merge uses the existing `propose → apply/reject` gate:
|
|
718
|
+
|
|
719
|
+
- `KnowledgeFlowRunner.mergePerson(primaryId, duplicateId, options)`
|
|
720
|
+
- **apply**: unions body, adds `alias:<duplicate title>` tag to primary, unions `appears-in` links, calls
|
|
721
|
+
`store.supersede(primaryId, [duplicateId])` to archive the duplicate (supersede-not-delete invariant).
|
|
722
|
+
- **reject**: both cards remain byte-identical.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Cards Demo — Acme Example
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates the full entity extraction flow:
|
|
5
|
+
* 1. Capture a raw note with Attendees line (Dana Smith + Lee Wong)
|
|
6
|
+
* 2. Compile the raw note
|
|
7
|
+
* 3. Extract person entities → create person cards with bidirectional links
|
|
8
|
+
* 4. Print Dana's person card verbatim (as Obsidian markdown)
|
|
9
|
+
*
|
|
10
|
+
* Run:
|
|
11
|
+
* node kits/knowledge/evals/entities/demo-acme.js
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const KIT_ROOT = path.resolve(__dirname, "../../../..");
|
|
21
|
+
|
|
22
|
+
// Use Obsidian adapter for richest output
|
|
23
|
+
const obsidianPath = path.join(KIT_ROOT, "kits/knowledge/adapters/obsidian-store/index.js");
|
|
24
|
+
const runnerPath = path.join(KIT_ROOT, "kits/knowledge/adapters/flow-runner/index.js");
|
|
25
|
+
|
|
26
|
+
const { ObsidianKnowledgeStore } = await import(obsidianPath);
|
|
27
|
+
const { KnowledgeFlowRunner } = await import(runnerPath);
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Setup temp store
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "acme-entity-demo-"));
|
|
34
|
+
|
|
35
|
+
const store = new ObsidianKnowledgeStore({ storeRoot: storeDir, sourcesDir: "meetings" });
|
|
36
|
+
const runner = new KnowledgeFlowRunner({ store, agent: "acme-demo" });
|
|
37
|
+
|
|
38
|
+
console.log("Store root:", storeDir);
|
|
39
|
+
console.log("");
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// 1. Capture a raw Acme meeting note
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const meetingNote = `Acme Q3 Kickoff — 2026-06-12
|
|
46
|
+
|
|
47
|
+
Attendees: Dana Smith (Acme VP Eng), Lee Wong
|
|
48
|
+
|
|
49
|
+
Agenda:
|
|
50
|
+
- Q3 roadmap review
|
|
51
|
+
- Engineering capacity planning
|
|
52
|
+
- Open items from Q2 retro
|
|
53
|
+
|
|
54
|
+
Action Items:
|
|
55
|
+
- Dana Smith to share updated roadmap by Friday
|
|
56
|
+
- Lee Wong to provide capacity estimates
|
|
57
|
+
|
|
58
|
+
Next meeting: 2026-06-19`;
|
|
59
|
+
|
|
60
|
+
const { id: rawId } = await runner.capture(meetingNote, {
|
|
61
|
+
title: "Acme Q3 Kickoff",
|
|
62
|
+
category: "sales.acme.meetings",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
console.log("Captured raw note:", rawId);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// 2. Compile the raw note
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const { id: compiledId } = await runner.compile([rawId], {
|
|
72
|
+
title: "Compiled: Acme Q3 Kickoff",
|
|
73
|
+
category: "sales.acme.meetings",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
console.log("Compiled note: ", compiledId);
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// 3. Extract entities → create person cards
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const result = await runner.extractEntities(compiledId);
|
|
83
|
+
|
|
84
|
+
console.log("");
|
|
85
|
+
console.log("Person cards created:");
|
|
86
|
+
for (const pc of result.personCards) {
|
|
87
|
+
const card = await store.get(pc.cardId);
|
|
88
|
+
console.log(` - ${card.title} (id: ${pc.cardId}, created: ${pc.created})`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// 4. Print Dana's person card verbatim (as Obsidian markdown)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const danaResult = result.personCards.find((pc) => pc.name === "Dana Smith");
|
|
96
|
+
if (!danaResult) throw new Error("Dana Smith card not found");
|
|
97
|
+
|
|
98
|
+
// Read the file directly from the vault
|
|
99
|
+
const { default: DefaultKnowledgeStore } = await import(
|
|
100
|
+
path.join(KIT_ROOT, "kits/knowledge/adapters/default-store/index.js")
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Find the obsidian file for Dana's card
|
|
104
|
+
const pathIndexFile = path.join(storeDir, ".graph-index.json");
|
|
105
|
+
const pathIndex = JSON.parse(fs.readFileSync(pathIndexFile, "utf8"));
|
|
106
|
+
const danaEntry = pathIndex.by_id[danaResult.cardId];
|
|
107
|
+
if (!danaEntry) throw new Error("Dana card not found in path index");
|
|
108
|
+
|
|
109
|
+
const danaFilePath = path.join(storeDir, danaEntry.path);
|
|
110
|
+
const danaMarkdown = fs.readFileSync(danaFilePath, "utf8");
|
|
111
|
+
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
114
|
+
console.log("Dana Smith's person card (verbatim Obsidian markdown):");
|
|
115
|
+
console.log("File:", danaFilePath.replace(storeDir, "<vault>"));
|
|
116
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
117
|
+
console.log(danaMarkdown);
|
|
118
|
+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Cleanup
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
fs.rmSync(storeDir, { recursive: true, force: true });
|
|
125
|
+
console.log("\nDemo complete.");
|