melchizedek-agents 0.9.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 (111) hide show
  1. package/AGENT_SETUP.md +182 -0
  2. package/DOCUMENTATION.md +614 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +164 -0
  5. package/README.md +151 -0
  6. package/config/agents/README.md +27 -0
  7. package/config/agents/examples/ares.yaml +74 -0
  8. package/config/agents/examples/cartographers.yaml +115 -0
  9. package/config/agents/examples/claude.yaml +17 -0
  10. package/config/agents/examples/council.yaml +65 -0
  11. package/config/agents/examples/critic.yaml +74 -0
  12. package/config/agents/examples/delegation.yaml +29 -0
  13. package/config/agents/examples/hierarchical.yaml +26 -0
  14. package/config/agents/examples/image_production.yaml +112 -0
  15. package/config/agents/examples/librarian.yaml +75 -0
  16. package/config/agents/examples/model_zoo.yaml +81 -0
  17. package/config/agents/examples/patient_advocate.yaml +107 -0
  18. package/config/agents/examples/scriptorium.yaml +96 -0
  19. package/config/agents/examples/style_council.yaml +83 -0
  20. package/config/agents/examples/syndicate.yaml +34 -0
  21. package/config/agents/examples/tutor.yaml +66 -0
  22. package/config/agents/syndicateSchema.yaml +291 -0
  23. package/dist/lib/config.d.ts +108 -0
  24. package/dist/lib/config.js +110 -0
  25. package/dist/lib/dispatch.d.ts +133 -0
  26. package/dist/lib/dispatch.js +182 -0
  27. package/dist/lib/grounding.d.ts +28 -0
  28. package/dist/lib/grounding.js +61 -0
  29. package/dist/lib/index.d.ts +14 -0
  30. package/dist/lib/index.js +13 -0
  31. package/dist/lib/loadEnv.d.ts +21 -0
  32. package/dist/lib/loadEnv.js +58 -0
  33. package/dist/lib/loadSyndicate.d.ts +177 -0
  34. package/dist/lib/loadSyndicate.js +264 -0
  35. package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
  36. package/dist/lib/memory/supabaseMemoryService.js +499 -0
  37. package/dist/lib/models/claudeLlm.d.ts +93 -0
  38. package/dist/lib/models/claudeLlm.js +332 -0
  39. package/dist/lib/models/gptLlm.d.ts +110 -0
  40. package/dist/lib/models/gptLlm.js +497 -0
  41. package/dist/lib/models/grokLlm.d.ts +64 -0
  42. package/dist/lib/models/grokLlm.js +86 -0
  43. package/dist/lib/models/ollamaLlm.d.ts +105 -0
  44. package/dist/lib/models/ollamaLlm.js +134 -0
  45. package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
  46. package/dist/lib/models/openAiCompatibleLlm.js +331 -0
  47. package/dist/lib/models/providerMap.d.ts +28 -0
  48. package/dist/lib/models/providerMap.js +45 -0
  49. package/dist/lib/models/registry.d.ts +71 -0
  50. package/dist/lib/models/registry.js +159 -0
  51. package/dist/lib/models/schemaNormalize.d.ts +22 -0
  52. package/dist/lib/models/schemaNormalize.js +58 -0
  53. package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
  54. package/dist/lib/observability/supabaseSpanExporter.js +115 -0
  55. package/dist/lib/observability/tracer.d.ts +44 -0
  56. package/dist/lib/observability/tracer.js +242 -0
  57. package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
  58. package/dist/lib/persistence/supabaseProvider.js +88 -0
  59. package/dist/lib/session/supabaseSessionService.d.ts +26 -0
  60. package/dist/lib/session/supabaseSessionService.js +193 -0
  61. package/dist/lib/session/transcript.d.ts +193 -0
  62. package/dist/lib/session/transcript.js +309 -0
  63. package/dist/lib/toolRegistry.d.ts +15 -0
  64. package/dist/lib/toolRegistry.js +58 -0
  65. package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
  66. package/dist/lib/tools/collectionsSearchTool.js +86 -0
  67. package/dist/lib/tools/generateImageTool.d.ts +16 -0
  68. package/dist/lib/tools/generateImageTool.js +119 -0
  69. package/dist/lib/tools/inspectImageTool.d.ts +21 -0
  70. package/dist/lib/tools/inspectImageTool.js +97 -0
  71. package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
  72. package/dist/lib/tools/mcpToolFactory.js +116 -0
  73. package/dist/lib/tools/toolContract.d.ts +78 -0
  74. package/dist/lib/tools/toolContract.js +129 -0
  75. package/dist/lib/tools/webExtractTool.d.ts +70 -0
  76. package/dist/lib/tools/webExtractTool.js +461 -0
  77. package/dist/lib/tools/webSearchTool.d.ts +53 -0
  78. package/dist/lib/tools/webSearchTool.js +126 -0
  79. package/dist/lib/tools/wikiTools.d.ts +112 -0
  80. package/dist/lib/tools/wikiTools.js +697 -0
  81. package/dist/lib/tools/xSearchTool.d.ts +61 -0
  82. package/dist/lib/tools/xSearchTool.js +125 -0
  83. package/dist/lib/wiki/agentRun.d.ts +39 -0
  84. package/dist/lib/wiki/agentRun.js +79 -0
  85. package/dist/lib/wiki/builder.d.ts +71 -0
  86. package/dist/lib/wiki/builder.js +165 -0
  87. package/dist/lib/wiki/dive.d.ts +56 -0
  88. package/dist/lib/wiki/dive.js +208 -0
  89. package/dist/lib/wiki/entities.d.ts +220 -0
  90. package/dist/lib/wiki/entities.js +661 -0
  91. package/dist/lib/wiki/extract.d.ts +97 -0
  92. package/dist/lib/wiki/extract.js +305 -0
  93. package/dist/lib/wiki/fill.d.ts +37 -0
  94. package/dist/lib/wiki/fill.js +175 -0
  95. package/dist/lib/wiki/format.d.ts +132 -0
  96. package/dist/lib/wiki/format.js +177 -0
  97. package/dist/lib/wiki/graph.d.ts +80 -0
  98. package/dist/lib/wiki/graph.js +147 -0
  99. package/dist/lib/wiki/lint.d.ts +58 -0
  100. package/dist/lib/wiki/lint.js +267 -0
  101. package/dist/lib/wiki/markdown.d.ts +127 -0
  102. package/dist/lib/wiki/markdown.js +336 -0
  103. package/dist/lib/wiki/pipeline.d.ts +69 -0
  104. package/dist/lib/wiki/pipeline.js +119 -0
  105. package/dist/lib/wiki/vault.d.ts +84 -0
  106. package/dist/lib/wiki/vault.js +194 -0
  107. package/dist/scripts/a2a_server.d.ts +2 -0
  108. package/dist/scripts/a2a_server.js +997 -0
  109. package/dist/scripts/syndicate_chat.d.ts +2 -0
  110. package/dist/scripts/syndicate_chat.js +485 -0
  111. package/package.json +96 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * lib/wiki/entities.ts — the entity layer: a typed graph over repo truth.
3
+ *
4
+ * WHY this file exists:
5
+ * lib/wiki/graph.ts derives ONE graph: documents joined by markdown
6
+ * links. That graph answers "what should I read next", but it cannot
7
+ * answer "which syndicates call web_search", "what breaks without
8
+ * XAI_API_KEY", or "which decision constrains the export pipeline" —
9
+ * because the things those questions name (agents, tools, models,
10
+ * tables, env vars, decisions) are not documents. They are ENTITIES,
11
+ * and the relations between them are typed, not hyperlinks.
12
+ *
13
+ * This module is that second layer, and it keeps the same discipline as
14
+ * the first: nodes and edges are DERIVED from repo truth wherever a
15
+ * parser can see them, and merely ASSERTED where only judgment can.
16
+ * The two tiers are labelled, never mixed:
17
+ *
18
+ * extracted — a parser read it out of a YAML, a contract, DDL, an
19
+ * import statement, a link. Rebuildable, disposable,
20
+ * always regenerated by `wiki:build --graph`.
21
+ * inferred — a human or an agent read prose and asserted it, with
22
+ * evidence and an actor id. Never machine-overwritten;
23
+ * lives in .graph/relations.json beside the snapshot.
24
+ *
25
+ * Everything here is bundle-agnostic. WHAT to extract from (this repo's
26
+ * agent YAMLs, its SQL, its package.json) is the caller's business —
27
+ * scripts/wiki/build.ts does that privately. This module owns the
28
+ * vocabulary, the graph mechanics, the two stores, and the lint rules,
29
+ * including the same private-closure invariant the document lint
30
+ * enforces (wiki/decisions/0003): a public node may never point at a
31
+ * private one, so any public slice of this graph is closed by
32
+ * construction.
33
+ */
34
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
35
+ import { dirname, join } from 'node:path';
36
+ import { z } from 'zod';
37
+ import { isPrivatePath } from "./format.js";
38
+ // ── Node vocabulary ──────────────────────────────────────────────────────────
39
+ /**
40
+ * What kinds of thing this graph knows about. A document keeps its OKF
41
+ * identity (the bundle path); every other node is `kind:name`, so the two
42
+ * namespaces can never collide — a doc id always starts with '/'.
43
+ */
44
+ export const NODE_KINDS = {
45
+ doc: 'a concept document in the bundle — identity is its bundle path',
46
+ syndicate: 'one agent-team definition (a YAML)',
47
+ agent: 'one orchestrator or subagent inside a syndicate',
48
+ model: 'a model id exactly as written in configuration',
49
+ provider: 'a provider adapter the model registry routes to',
50
+ tool: 'a tool an agent may declare by name',
51
+ 'mcp-server': 'a remote MCP endpoint an agent dials at runtime',
52
+ module: 'one source module',
53
+ file: 'a repo file that is not a source module (DDL, config, prose)',
54
+ table: 'a database table',
55
+ env: 'an environment variable the code reads',
56
+ script: 'an npm script entrypoint',
57
+ external: 'a resource outside the repo, named by URL',
58
+ };
59
+ export function entityId(kind, name) {
60
+ return kind === 'doc' ? name : `${kind}:${name}`;
61
+ }
62
+ export function kindOf(id) {
63
+ if (id.startsWith('/'))
64
+ return 'doc';
65
+ const colon = id.indexOf(':');
66
+ return colon === -1 ? 'unknown' : id.slice(0, colon);
67
+ }
68
+ export function nameOf(id) {
69
+ if (id.startsWith('/'))
70
+ return id;
71
+ const colon = id.indexOf(':');
72
+ return colon === -1 ? id : id.slice(colon + 1);
73
+ }
74
+ /**
75
+ * The whole relation vocabulary. Extracted relations carry kind
76
+ * constraints because a parser produces them and a violation is a bug;
77
+ * inferred relations do not, because judgment legitimately connects
78
+ * anything to anything — the evidence field is what disciplines them.
79
+ */
80
+ export const RELATIONS = {
81
+ // ── extracted ──
82
+ // NOTE on constraints: `from`/`to` are declared only where a violation
83
+ // would mean the EXTRACTOR is wrong (a model that is not a model, a table
84
+ // that is not a table). The generic structural relations below are left
85
+ // open, because what "contains" or "runs" what differs per repo — a
86
+ // syndicate contains agents here, a collection contains entries on a
87
+ // site, a dyno runs a script on a worker.
88
+ contains: {
89
+ tier: 'extracted',
90
+ phrase: 'contains',
91
+ gloss: 'the first is composed of the second',
92
+ },
93
+ uses_model: {
94
+ tier: 'extracted',
95
+ phrase: 'runs on',
96
+ gloss: 'the agent is configured with this model id',
97
+ to: ['model'],
98
+ },
99
+ routes_to: {
100
+ tier: 'extracted',
101
+ phrase: 'routes to',
102
+ gloss: 'the model id resolves to this provider adapter',
103
+ from: ['model'],
104
+ to: ['provider'],
105
+ },
106
+ requires_env: {
107
+ tier: 'extracted',
108
+ phrase: 'requires',
109
+ gloss: 'this environment variable must be set for the node to work',
110
+ to: ['env'],
111
+ },
112
+ uses_tool: {
113
+ tier: 'extracted',
114
+ phrase: 'calls',
115
+ gloss: 'the agent declares this tool by name',
116
+ to: ['tool'],
117
+ },
118
+ delegates_to: {
119
+ tier: 'extracted',
120
+ phrase: 'delegates to',
121
+ gloss: 'the agent is a reference to another syndicate, resolved at load time',
122
+ from: ['agent'],
123
+ to: ['syndicate'],
124
+ },
125
+ connects_mcp: {
126
+ tier: 'extracted',
127
+ phrase: 'dials',
128
+ gloss: 'the agent discovers tools from this MCP server at runtime',
129
+ from: ['agent'],
130
+ to: ['mcp-server'],
131
+ },
132
+ defined_in: {
133
+ tier: 'extracted',
134
+ phrase: 'is defined in',
135
+ gloss: 'where the thing is declared in source',
136
+ to: ['module', 'file'],
137
+ },
138
+ imports: {
139
+ tier: 'extracted',
140
+ phrase: 'imports',
141
+ gloss: 'a static import edge between source files',
142
+ },
143
+ reads_table: {
144
+ tier: 'extracted',
145
+ phrase: 'reads or writes',
146
+ gloss: 'the module names this table',
147
+ to: ['table'],
148
+ },
149
+ runs: {
150
+ tier: 'extracted',
151
+ phrase: 'runs',
152
+ gloss: 'an entrypoint — a script, a process, a dyno — executes this',
153
+ },
154
+ documents: {
155
+ tier: 'extracted',
156
+ phrase: 'documents',
157
+ gloss: 'the document derives from, and describes, this entity',
158
+ from: ['doc'],
159
+ },
160
+ derives_from: {
161
+ tier: 'extracted',
162
+ phrase: 'derives from',
163
+ gloss: 'declared in the document’s `sources:` frontmatter',
164
+ from: ['doc'],
165
+ },
166
+ references: {
167
+ tier: 'extracted',
168
+ phrase: 'points readers at',
169
+ gloss: 'the source names this resource for the reader to open',
170
+ },
171
+ links_to: {
172
+ tier: 'extracted',
173
+ phrase: 'links to',
174
+ gloss: 'a resolved markdown link between documents',
175
+ from: ['doc'],
176
+ to: ['doc'],
177
+ },
178
+ // ── inferred ──
179
+ depends_on: {
180
+ tier: 'inferred',
181
+ phrase: 'depends on',
182
+ gloss: 'the first cannot do its job unless the second holds',
183
+ },
184
+ constrains: {
185
+ tier: 'inferred',
186
+ phrase: 'constrains',
187
+ gloss: 'a decision or doctrine limits what the target may do',
188
+ },
189
+ supersedes: {
190
+ tier: 'inferred',
191
+ phrase: 'supersedes',
192
+ gloss: 'replaces an earlier decision or document',
193
+ },
194
+ explains: {
195
+ tier: 'inferred',
196
+ phrase: 'explains',
197
+ gloss: 'the document is where the target’s rationale is written down',
198
+ },
199
+ alternative_to: {
200
+ tier: 'inferred',
201
+ phrase: 'is an alternative to',
202
+ gloss: 'two ways of reaching the same capability',
203
+ },
204
+ mitigates: {
205
+ tier: 'inferred',
206
+ phrase: 'mitigates',
207
+ gloss: 'the mechanism exists to contain the named failure',
208
+ },
209
+ contradicts: {
210
+ tier: 'inferred',
211
+ phrase: 'contradicts',
212
+ gloss: 'two sources state incompatible things — a rot signal',
213
+ },
214
+ };
215
+ export function relationsByTier(tier) {
216
+ return Object.entries(RELATIONS)
217
+ .filter(([, spec]) => spec.tier === tier)
218
+ .map(([id]) => id)
219
+ .sort();
220
+ }
221
+ export function edgeKey(edge) {
222
+ return `${edge.from}|${edge.rel}|${edge.to}`;
223
+ }
224
+ /** Merge two records of the same node; private-ness is sticky (once, always). */
225
+ function mergeNode(a, b) {
226
+ return {
227
+ ...a,
228
+ ...b,
229
+ private: a.private || b.private || undefined,
230
+ attrs: { ...(a.attrs ?? {}), ...(b.attrs ?? {}) },
231
+ };
232
+ }
233
+ export function buildEntityGraph(nodes, edges) {
234
+ const nodeMap = new Map();
235
+ for (const node of nodes) {
236
+ const existing = nodeMap.get(node.id);
237
+ nodeMap.set(node.id, existing ? mergeNode(existing, node) : node);
238
+ }
239
+ const seen = new Set();
240
+ const deduped = [];
241
+ for (const edge of edges) {
242
+ const key = edgeKey(edge);
243
+ if (seen.has(key))
244
+ continue;
245
+ seen.add(key);
246
+ deduped.push(edge);
247
+ }
248
+ const outbound = new Map();
249
+ const inbound = new Map();
250
+ for (const edge of deduped) {
251
+ (outbound.get(edge.from) ?? outbound.set(edge.from, []).get(edge.from)).push(edge);
252
+ (inbound.get(edge.to) ?? inbound.set(edge.to, []).get(edge.to)).push(edge);
253
+ }
254
+ return { nodes: nodeMap, edges: deduped, outbound, inbound };
255
+ }
256
+ /** The document layer, restated as entities so both layers share one graph. */
257
+ export function docLayer(docGraph) {
258
+ const nodes = [];
259
+ for (const node of docGraph.nodes.values()) {
260
+ if (node.kind !== 'concept')
261
+ continue;
262
+ nodes.push({
263
+ id: node.path,
264
+ kind: 'doc',
265
+ label: node.title,
266
+ source: node.path,
267
+ ...(node.isPrivate ? { private: true } : {}),
268
+ attrs: {
269
+ type: node.type,
270
+ status: node.status,
271
+ trust: node.trust,
272
+ words: node.wordCount,
273
+ },
274
+ });
275
+ }
276
+ const docIds = new Set(nodes.map((n) => n.id));
277
+ const edges = docGraph.edges
278
+ .filter((e) => e.resolved && docIds.has(e.source) && docIds.has(e.target))
279
+ .map((e) => ({
280
+ from: e.source,
281
+ to: e.target,
282
+ rel: 'links_to',
283
+ tier: 'extracted',
284
+ evidence: `${e.source}:${e.line}`,
285
+ }));
286
+ return { nodes, edges };
287
+ }
288
+ export function neighbors(graph, originId, options = {}) {
289
+ const depth = options.depth ?? 1;
290
+ const direction = options.direction ?? 'both';
291
+ const allowed = options.relations && options.relations.length > 0
292
+ ? new Set(options.relations)
293
+ : null;
294
+ // Two different bookkeepings, deliberately: EDGES are reported once each
295
+ // (so two relations between the same pair both show — a document that
296
+ // derives_from a module AND constrains it says two things), while NODES
297
+ // are expanded once (so the walk terminates).
298
+ const reported = new Set();
299
+ const expanded = new Set([originId]);
300
+ const result = [];
301
+ let frontier = [originId];
302
+ for (let d = 1; d <= depth; d++) {
303
+ const next = [];
304
+ for (const id of frontier) {
305
+ const candidates = [];
306
+ if (direction !== 'in') {
307
+ for (const e of graph.outbound.get(id) ?? []) {
308
+ candidates.push({ edge: e, other: e.to, dir: 'out' });
309
+ }
310
+ }
311
+ if (direction !== 'out') {
312
+ for (const e of graph.inbound.get(id) ?? []) {
313
+ candidates.push({ edge: e, other: e.from, dir: 'in' });
314
+ }
315
+ }
316
+ for (const c of candidates) {
317
+ if (allowed && !allowed.has(c.edge.rel))
318
+ continue;
319
+ // Keyed by the EDGE, not the direction: walking the same edge back
320
+ // from the other end is not a second fact.
321
+ const key = edgeKey(c.edge);
322
+ if (reported.has(key))
323
+ continue;
324
+ const node = graph.nodes.get(c.other);
325
+ if (!node)
326
+ continue;
327
+ reported.add(key);
328
+ result.push({
329
+ node,
330
+ rel: c.edge.rel,
331
+ direction: c.dir,
332
+ distance: d,
333
+ via: id,
334
+ ...(c.edge.evidence ? { evidence: c.edge.evidence } : {}),
335
+ tier: c.edge.tier,
336
+ });
337
+ if (!expanded.has(c.other)) {
338
+ expanded.add(c.other);
339
+ next.push(c.other);
340
+ }
341
+ }
342
+ }
343
+ frontier = next;
344
+ }
345
+ return result;
346
+ }
347
+ /** Shortest connection between two nodes, walking edges in either direction. */
348
+ export function shortestPath(graph, fromId, toId, maxDepth = 6) {
349
+ if (!graph.nodes.has(fromId) || !graph.nodes.has(toId))
350
+ return null;
351
+ if (fromId === toId) {
352
+ return [{ node: graph.nodes.get(fromId), rel: '', direction: 'origin' }];
353
+ }
354
+ const prev = new Map();
355
+ const seen = new Set([fromId]);
356
+ let frontier = [fromId];
357
+ for (let d = 0; d < maxDepth && frontier.length > 0; d++) {
358
+ const next = [];
359
+ for (const id of frontier) {
360
+ const moves = [
361
+ ...(graph.outbound.get(id) ?? []).map((e) => ({
362
+ other: e.to,
363
+ rel: e.rel,
364
+ dir: 'out',
365
+ })),
366
+ ...(graph.inbound.get(id) ?? []).map((e) => ({
367
+ other: e.from,
368
+ rel: e.rel,
369
+ dir: 'in',
370
+ })),
371
+ ];
372
+ for (const move of moves) {
373
+ if (seen.has(move.other))
374
+ continue;
375
+ seen.add(move.other);
376
+ prev.set(move.other, { id, rel: move.rel, dir: move.dir });
377
+ if (move.other === toId) {
378
+ const chain = [];
379
+ let cursor = toId;
380
+ while (cursor !== fromId) {
381
+ const step = prev.get(cursor);
382
+ chain.unshift({
383
+ node: graph.nodes.get(cursor),
384
+ rel: step.rel,
385
+ direction: step.dir,
386
+ });
387
+ cursor = step.id;
388
+ }
389
+ chain.unshift({
390
+ node: graph.nodes.get(fromId),
391
+ rel: '',
392
+ direction: 'origin',
393
+ });
394
+ return chain;
395
+ }
396
+ next.push(move.other);
397
+ }
398
+ }
399
+ frontier = next;
400
+ }
401
+ return null;
402
+ }
403
+ /** Lexical node lookup: exact id, then id/label substring, then attrs. */
404
+ export function findNodes(graph, query, limit = 10) {
405
+ const q = query.trim().toLowerCase();
406
+ if (q === '')
407
+ return [];
408
+ const matches = [];
409
+ for (const node of graph.nodes.values()) {
410
+ const id = node.id.toLowerCase();
411
+ const label = node.label.toLowerCase();
412
+ let score = 0;
413
+ if (id === q)
414
+ score = 100;
415
+ else if (label === q)
416
+ score = 90;
417
+ else if (nameOf(node.id).toLowerCase() === q)
418
+ score = 80;
419
+ else if (id.includes(q))
420
+ score = 40;
421
+ else if (label.includes(q))
422
+ score = 30;
423
+ else if ((node.source ?? '').toLowerCase().includes(q))
424
+ score = 10;
425
+ if (score > 0)
426
+ matches.push({ node, score });
427
+ }
428
+ return matches
429
+ .sort((a, b) => b.score - a.score || a.node.id.localeCompare(b.node.id))
430
+ .slice(0, limit);
431
+ }
432
+ export function nodesOfKind(graph, kind) {
433
+ return [...graph.nodes.values()]
434
+ .filter((n) => n.kind === kind)
435
+ .sort((a, b) => a.id.localeCompare(b.id));
436
+ }
437
+ export function entityStats(graph) {
438
+ const byKind = {};
439
+ const byRelation = {};
440
+ const byTier = { extracted: 0, inferred: 0 };
441
+ let isolated = 0;
442
+ for (const node of graph.nodes.values()) {
443
+ byKind[node.kind] = (byKind[node.kind] ?? 0) + 1;
444
+ const degree = (graph.outbound.get(node.id)?.length ?? 0) + (graph.inbound.get(node.id)?.length ?? 0);
445
+ if (degree === 0)
446
+ isolated++;
447
+ }
448
+ for (const edge of graph.edges) {
449
+ byRelation[edge.rel] = (byRelation[edge.rel] ?? 0) + 1;
450
+ byTier[edge.tier]++;
451
+ }
452
+ return { nodes: graph.nodes.size, edges: graph.edges.length, byKind, byRelation, byTier, isolated };
453
+ }
454
+ /** True for nodes that must never be referenced from a public one. */
455
+ export function isPrivateNode(node) {
456
+ return node.private === true || (node.kind === 'doc' && isPrivatePath(node.id));
457
+ }
458
+ export function lintEntityGraph(graph) {
459
+ const findings = [];
460
+ for (const edge of graph.edges) {
461
+ const spec = RELATIONS[edge.rel];
462
+ const from = graph.nodes.get(edge.from);
463
+ const to = graph.nodes.get(edge.to);
464
+ if (!from || !to) {
465
+ findings.push({
466
+ severity: 'error',
467
+ rule: 'dangling-endpoint',
468
+ message: `${edge.from} —${edge.rel}→ ${edge.to}: ${!from ? edge.from : edge.to} is not a node in the graph`,
469
+ });
470
+ continue;
471
+ }
472
+ if (!spec) {
473
+ findings.push({
474
+ severity: 'info',
475
+ rule: 'unknown-relation',
476
+ message: `${edge.from} —${edge.rel}→ ${edge.to}: "${edge.rel}" is outside the vocabulary`,
477
+ });
478
+ }
479
+ else {
480
+ if (spec.tier !== edge.tier) {
481
+ findings.push({
482
+ severity: 'warning',
483
+ rule: 'tier-mismatch',
484
+ message: `${edgeKey(edge)} is tagged ${edge.tier} but "${edge.rel}" is an ${spec.tier} relation`,
485
+ });
486
+ }
487
+ if (spec.from && spec.from.length > 0 && !spec.from.includes(from.kind)) {
488
+ findings.push({
489
+ severity: 'warning',
490
+ rule: 'kind-constraint',
491
+ message: `${edgeKey(edge)}: "${edge.rel}" starts at ${spec.from.join('|')}, not ${from.kind}`,
492
+ });
493
+ }
494
+ if (spec.to && spec.to.length > 0 && !spec.to.includes(to.kind)) {
495
+ findings.push({
496
+ severity: 'warning',
497
+ rule: 'kind-constraint',
498
+ message: `${edgeKey(edge)}: "${edge.rel}" points at ${spec.to.join('|')}, not ${to.kind}`,
499
+ });
500
+ }
501
+ }
502
+ if (edge.tier === 'inferred' && !edge.by) {
503
+ findings.push({
504
+ severity: 'warning',
505
+ rule: 'unattributed-assertion',
506
+ message: `${edgeKey(edge)} is inferred but names no actor`,
507
+ });
508
+ }
509
+ // The closure rule mirrors the DOCUMENT invariant exactly: what gets
510
+ // published is the bundle, so a public DOCUMENT may never point into
511
+ // private knowledge. Code-to-code edges are out of scope here — a
512
+ // public module importing a private one is a real fact of this repo,
513
+ // sanitized by the export pipeline's patches, not by this graph.
514
+ if (from.kind === 'doc' && !isPrivateNode(from) && isPrivateNode(to)) {
515
+ findings.push({
516
+ severity: 'error',
517
+ rule: 'private-closure',
518
+ message: `${edgeKey(edge)}: a public document may not point at the private annex`,
519
+ });
520
+ }
521
+ if (edge.from === edge.to) {
522
+ findings.push({
523
+ severity: 'warning',
524
+ rule: 'self-loop',
525
+ message: `${edge.from} —${edge.rel}→ itself`,
526
+ });
527
+ }
528
+ }
529
+ return findings;
530
+ }
531
+ // ── Stores ───────────────────────────────────────────────────────────────────
532
+ /**
533
+ * Both stores live in a dot-directory inside the bundle: the vault walker
534
+ * skips dotfiles, so they are invisible to every document operation, they
535
+ * travel with WIKI_ROOT, and the export pipeline (which copies *.md only)
536
+ * cannot ship them by accident.
537
+ */
538
+ export function graphDir(wikiRoot) {
539
+ return join(wikiRoot, '.graph');
540
+ }
541
+ export function snapshotPath(wikiRoot) {
542
+ return join(graphDir(wikiRoot), 'graph.json');
543
+ }
544
+ export function relationsPath(wikiRoot) {
545
+ return join(graphDir(wikiRoot), 'relations.json');
546
+ }
547
+ export const relationRecordSchema = z.looseObject({
548
+ from: z.string().min(1),
549
+ to: z.string().min(1),
550
+ rel: z.string().min(1),
551
+ /** What was read to justify this — a quotation or a path. */
552
+ evidence: z.string().min(3),
553
+ by: z.string().min(1),
554
+ at: z.string().optional(),
555
+ note: z.string().optional(),
556
+ });
557
+ export const relationStoreSchema = z.looseObject({
558
+ version: z.number().int().default(1),
559
+ relations: z.array(relationRecordSchema).default([]),
560
+ });
561
+ export function loadRelations(wikiRoot) {
562
+ const file = relationsPath(wikiRoot);
563
+ if (!existsSync(file))
564
+ return { records: [], issues: [] };
565
+ let raw;
566
+ try {
567
+ raw = JSON.parse(readFileSync(file, 'utf-8'));
568
+ }
569
+ catch (err) {
570
+ return {
571
+ records: [],
572
+ issues: [`relations.json does not parse: ${err instanceof Error ? err.message : String(err)}`],
573
+ };
574
+ }
575
+ const parsed = relationStoreSchema.safeParse(raw);
576
+ if (!parsed.success) {
577
+ return {
578
+ records: [],
579
+ issues: parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`),
580
+ };
581
+ }
582
+ const records = [];
583
+ const issues = [];
584
+ const seen = new Set();
585
+ for (const [index, record] of parsed.data.relations.entries()) {
586
+ const key = edgeKey(record);
587
+ if (seen.has(key)) {
588
+ issues.push(`relations[${index}]: duplicate assertion ${key}`);
589
+ continue;
590
+ }
591
+ seen.add(key);
592
+ records.push(record);
593
+ }
594
+ return { records, issues };
595
+ }
596
+ export function relationEdges(records) {
597
+ return records.map((r) => ({
598
+ from: r.from,
599
+ to: r.to,
600
+ rel: r.rel,
601
+ tier: 'inferred',
602
+ evidence: r.evidence,
603
+ by: r.by,
604
+ ...(r.at ? { at: r.at } : {}),
605
+ }));
606
+ }
607
+ export function saveRelations(wikiRoot, records) {
608
+ const file = relationsPath(wikiRoot);
609
+ mkdirSync(dirname(file), { recursive: true });
610
+ const sorted = [...records].sort((a, b) => a.from.localeCompare(b.from) || a.rel.localeCompare(b.rel) || a.to.localeCompare(b.to));
611
+ writeFileSync(file, `${JSON.stringify({ version: 1, relations: sorted }, null, 2)}\n`, 'utf-8');
612
+ }
613
+ export function writeSnapshot(wikiRoot, graph, generated) {
614
+ const file = snapshotPath(wikiRoot);
615
+ mkdirSync(dirname(file), { recursive: true });
616
+ const snapshot = {
617
+ version: 1,
618
+ generated,
619
+ stats: entityStats(graph),
620
+ nodes: [...graph.nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
621
+ edges: graph.edges
622
+ .filter((e) => e.tier === 'extracted')
623
+ .sort((a, b) => edgeKey(a).localeCompare(edgeKey(b))),
624
+ };
625
+ writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf-8');
626
+ return file;
627
+ }
628
+ export function readSnapshot(wikiRoot) {
629
+ const file = snapshotPath(wikiRoot);
630
+ if (!existsSync(file))
631
+ return null;
632
+ try {
633
+ return JSON.parse(readFileSync(file, 'utf-8'));
634
+ }
635
+ catch {
636
+ return null;
637
+ }
638
+ }
639
+ /**
640
+ * The read path every consumer uses: derived snapshot (structure, as of the
641
+ * last build) + asserted relations (read live, so a fresh assertion is
642
+ * visible immediately). Missing snapshot yields an empty graph plus the
643
+ * asserted edges, which lint will flag as dangling — honest, not silent.
644
+ */
645
+ export function loadEntityGraph(wikiRoot) {
646
+ const snapshot = readSnapshot(wikiRoot);
647
+ const { records, issues } = loadRelations(wikiRoot);
648
+ const graph = buildEntityGraph(snapshot?.nodes ?? [], [...(snapshot?.edges ?? []), ...relationEdges(records)]);
649
+ return { graph, snapshot, issues, inferredCount: records.length };
650
+ }
651
+ /** Documents the snapshot has not seen yet — the staleness signal. */
652
+ export function snapshotDrift(snapshot, currentDocPaths) {
653
+ if (!snapshot)
654
+ return { missing: [...currentDocPaths].sort(), removed: [] };
655
+ const known = new Set(snapshot.nodes.filter((n) => n.kind === 'doc').map((n) => n.id));
656
+ const current = new Set(currentDocPaths);
657
+ return {
658
+ missing: [...current].filter((p) => !known.has(p)).sort(),
659
+ removed: [...known].filter((p) => !current.has(p)).sort(),
660
+ };
661
+ }