graph.mjs
130 lines 4.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Knowledge graph resource (Issue #1 Phase A5).
3 */
4
5 import { listMarkdownFiles, readNote, normalizeSlug } from '../../lib/vault.mjs';
6 import { MCP_RESOURCE_PAGE_SIZE } from './pagination.mjs';
7
8 const WIKILINK = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
9
10 /**
11 * @param {import('../../lib/config.mjs').loadConfig extends () => infer R ? R : never} config
12 */
13 export function buildKnowledgeGraph(config) {
14 const paths = listMarkdownFiles(config.vault_path, { ignore: config.ignore });
15 const pathSet = new Set(paths.map((p) => p.replace(/\\/g, '/')));
16 const allNotes = [];
17
18 for (const p of paths) {
19 try {
20 allNotes.push(readNote(config.vault_path, p));
21 } catch (_) {}
22 }
23
24 const nodes = allNotes.map((note) => ({
25 path: note.path.replace(/\\/g, '/'),
26 title: note.frontmatter?.title ?? null,
27 tags: note.tags || [],
28 project: note.project ?? null,
29 }));
30
31 const edges = [];
32 const byBasename = new Map();
33 for (const n of allNotes) {
34 const rel = n.path.replace(/\\/g, '/');
35 const base = rel.replace(/\.md$/i, '').split('/').pop();
36 if (base) byBasename.set(base.toLowerCase(), rel);
37 }
38
39 for (const note of allNotes) {
40 const rel = note.path.replace(/\\/g, '/');
41
42 const follows = note.frontmatter?.follows;
43 if (follows) {
44 const target = String(follows).replace(/\\/g, '/');
45 const to = pathSet.has(target) ? target : pathSet.has(`${target}.md`) ? `${target}.md` : null;
46 if (to) edges.push({ from: rel, to, type: 'follows' });
47 }
48
49 const summarizes = note.frontmatter?.summarizes;
50 if (summarizes) {
51 const target = String(summarizes).replace(/\\/g, '/');
52 const to = pathSet.has(target) ? target : pathSet.has(`${target}.md`) ? `${target}.md` : null;
53 if (to) edges.push({ from: rel, to, type: 'summarizes' });
54 }
55
56 let m;
57 const body = note.body || '';
58 WIKILINK.lastIndex = 0;
59 while ((m = WIKILINK.exec(body)) !== null) {
60 const raw = m[1].trim();
61 const targetBase = raw.replace(/\.md$/i, '').split('/').pop().toLowerCase();
62 const resolved = byBasename.get(targetBase);
63 if (resolved && resolved !== rel) {
64 edges.push({ from: rel, to: resolved, type: 'wikilink' });
65 }
66 }
67 }
68
69 const byChain = new Map();
70 for (const note of allNotes) {
71 const c = note.frontmatter?.causal_chain_id;
72 if (c == null) continue;
73 const k = normalizeSlug(String(c));
74 if (!k) continue;
75 const rel = note.path.replace(/\\/g, '/');
76 if (!byChain.has(k)) byChain.set(k, []);
77 byChain.get(k).push(rel);
78 }
79 for (const group of byChain.values()) {
80 if (group.length < 2) continue;
81 group.sort();
82 for (let i = 1; i < group.length; i++) {
83 edges.push({ from: group[i - 1], to: group[i], type: 'causal_chain' });
84 }
85 }
86
87 if (nodes.length > MCP_RESOURCE_PAGE_SIZE) {
88 const keep = new Set(nodes.slice(0, MCP_RESOURCE_PAGE_SIZE).map((n) => n.path));
89 return {
90 truncated: true,
91 node_limit: MCP_RESOURCE_PAGE_SIZE,
92 nodes: nodes.slice(0, MCP_RESOURCE_PAGE_SIZE),
93 edges: edges.filter((e) => keep.has(e.from) && keep.has(e.to)),
94 note: `Graph truncated to ${MCP_RESOURCE_PAGE_SIZE} nodes; refine with list/search tools.`,
95 };
96 }
97
98 return { nodes, edges, truncated: false };
99 }
100
101 /**
102 * Notes whose frontmatter `causal_chain_id` matches (for MCP prompts / tooling).
103 * Sorted by `date` then path for a stable narrative order.
104 * @param {import('../../lib/config.mjs').loadConfig extends () => infer R ? R : never} config
105 * @param {string} chainId
106 * @returns {Array<{ path: string, body: string, frontmatter: object, date?: string }>}
107 */
108 export function listNotesForCausalChainId(config, chainId) {
109 const k = normalizeSlug(String(chainId || ''));
110 if (!k) return [];
111 const paths = listMarkdownFiles(config.vault_path, { ignore: config.ignore });
112 const notes = [];
113 for (const p of paths) {
114 try {
115 const n = readNote(config.vault_path, p);
116 const cid = n.frontmatter?.causal_chain_id;
117 if (cid == null) continue;
118 if (normalizeSlug(String(cid)) !== k) continue;
119 notes.push(n);
120 } catch (_) {}
121 }
122 notes.sort((a, b) => {
123 const da = String(a.date || a.frontmatter?.date || '');
124 const db = String(b.date || b.frontmatter?.date || '');
125 const c = da.localeCompare(db);
126 if (c !== 0) return c;
127 return a.path.localeCompare(b.path);
128 });
129 return notes;
130 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago