@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.
Files changed (52) hide show
  1. package/.github/workflows/kit-gates-demo.yml +171 -0
  2. package/CHANGELOG.md +35 -0
  3. package/CONTEXT.md +1 -1
  4. package/README.md +13 -2
  5. package/build/src/cli/flow-kit.js +41 -2
  6. package/build/src/flow-kit/validate.js +98 -0
  7. package/build/src/tools/validate-source-tree.js +2 -1
  8. package/context/scripts/hooks/config-protection.js +217 -15
  9. package/docs/fixture-ownership.md +1 -0
  10. package/docs/index.md +9 -1
  11. package/docs/kit-authoring-guide.md +126 -0
  12. package/docs/knowledge-kit.md +69 -0
  13. package/docs/vision.md +22 -0
  14. package/evals/fixtures/kit-conformance-levels/k0-flows-only/flows/review.flow.json +26 -0
  15. package/evals/fixtures/kit-conformance-levels/k0-flows-only/kit.json +13 -0
  16. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/docs/README.md +3 -0
  17. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/flows/build.flow.json +26 -0
  18. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/kit.json +20 -0
  19. package/evals/fixtures/kit-conformance-levels/k2-with-evals/docs/README.md +3 -0
  20. package/evals/fixtures/kit-conformance-levels/k2-with-evals/eval-suites/contract-suite/suite.test.js +1 -0
  21. package/evals/fixtures/kit-conformance-levels/k2-with-evals/flows/synthesize.flow.json +26 -0
  22. package/evals/fixtures/kit-conformance-levels/k2-with-evals/kit.json +27 -0
  23. package/evals/fixtures/kit-conformance-levels/third-party-extension/flows/review.flow.json +26 -0
  24. package/evals/fixtures/kit-conformance-levels/third-party-extension/kit.json +19 -0
  25. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  26. package/evals/integration/test_hook_category_behaviors.sh +51 -0
  27. package/evals/integration/test_kit_conformance_levels.sh +209 -0
  28. package/evals/run.sh +2 -0
  29. package/kits/catalog.json +6 -0
  30. package/kits/knowledge/adapters/default-store/index.js +2 -2
  31. package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
  32. package/kits/knowledge/adapters/flow-runner/index.js +349 -0
  33. package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
  34. package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
  35. package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
  36. package/kits/knowledge/adapters/shared/codec.js +325 -0
  37. package/kits/knowledge/docs/store-contract.md +72 -0
  38. package/kits/knowledge/evals/entities/demo-acme.js +125 -0
  39. package/kits/knowledge/evals/entities/suite.test.js +722 -0
  40. package/kits/knowledge/kit.json +10 -0
  41. package/kits/release-evidence/fixtures/claims/README.md +14 -0
  42. package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
  43. package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
  44. package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
  45. package/kits/release-evidence/kit.json +13 -0
  46. package/package.json +1 -1
  47. package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
  48. package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
  49. package/scripts/hooks/config-protection.js +217 -15
  50. package/src/cli/flow-kit.ts +40 -2
  51. package/src/flow-kit/validate.ts +127 -0
  52. 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.");