clawvault 1.11.2 → 2.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/README.md +135 -1
  2. package/bin/clawvault.js +51 -1252
  3. package/bin/command-registration.test.js +148 -0
  4. package/bin/command-runtime.js +42 -0
  5. package/bin/command-runtime.test.js +102 -0
  6. package/bin/help-contract.test.js +23 -0
  7. package/bin/register-core-commands.js +139 -0
  8. package/bin/register-maintenance-commands.js +137 -0
  9. package/bin/register-query-commands.js +225 -0
  10. package/bin/register-resilience-commands.js +147 -0
  11. package/bin/register-session-lifecycle-commands.js +204 -0
  12. package/bin/register-template-commands.js +72 -0
  13. package/bin/register-vault-operations-commands.js +295 -0
  14. package/bin/test-helpers/cli-command-fixtures.js +94 -0
  15. package/dashboard/lib/graph-diff.js +3 -1
  16. package/dashboard/lib/graph-diff.test.js +19 -0
  17. package/dashboard/lib/vault-parser.js +330 -26
  18. package/dashboard/lib/vault-parser.test.js +191 -11
  19. package/dashboard/public/app.js +22 -9
  20. package/dist/chunk-MXSSG3QU.js +42 -0
  21. package/dist/chunk-O5V7SD5C.js +398 -0
  22. package/dist/chunk-PAYUH64O.js +284 -0
  23. package/dist/{chunk-3HFB7EMU.js → chunk-QFBKWDYR.js} +12 -0
  24. package/dist/{chunk-UBRYOIII.js → chunk-TBVI4N53.js} +210 -21
  25. package/dist/chunk-TXO34J3O.js +56 -0
  26. package/dist/commands/compat.d.ts +28 -0
  27. package/dist/commands/compat.js +10 -0
  28. package/dist/commands/context.d.ts +2 -33
  29. package/dist/commands/context.js +3 -2
  30. package/dist/commands/doctor.js +61 -3
  31. package/dist/commands/entities.d.ts +1 -0
  32. package/dist/commands/entities.js +4 -4
  33. package/dist/commands/graph.d.ts +21 -0
  34. package/dist/commands/graph.js +10 -0
  35. package/dist/commands/link.d.ts +1 -0
  36. package/dist/commands/link.js +14 -5
  37. package/dist/commands/sleep.js +7 -6
  38. package/dist/commands/status.d.ts +6 -0
  39. package/dist/commands/status.js +63 -3
  40. package/dist/commands/wake.js +5 -4
  41. package/dist/context-COo8oq1k.d.ts +45 -0
  42. package/dist/index.d.ts +63 -2
  43. package/dist/index.js +53 -15
  44. package/dist/lib/config.d.ts +6 -1
  45. package/dist/lib/config.js +7 -3
  46. package/hooks/clawvault/HOOK.md +6 -1
  47. package/hooks/clawvault/handler.js +44 -3
  48. package/hooks/clawvault/handler.test.js +161 -0
  49. package/package.json +34 -2
  50. package/dashboard/public/graph.js +0 -376
  51. package/dashboard/public/style.css +0 -154
  52. package/dist/chunk-4KDZZW4X.js +0 -13
@@ -3,6 +3,8 @@ import * as path from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
 
5
5
  export const WIKI_LINK_REGEX = /\[\[([^\]|]+)(\|[^\]]+)?\]\]/g;
6
+ const HASH_TAG_REGEX = /(^|\s)#([\w-]+)/g;
7
+ const MEMORY_GRAPH_INDEX_PATH = ['.clawvault', 'graph-index.json'];
6
8
 
7
9
  const MARKDOWN_EXT = '.md';
8
10
  const IGNORED_DIRECTORIES = new Set([
@@ -11,6 +13,17 @@ const IGNORED_DIRECTORIES = new Set([
11
13
  '.trash',
12
14
  'node_modules'
13
15
  ]);
16
+ const FRONTMATTER_RELATION_FIELDS = [
17
+ 'related',
18
+ 'depends_on',
19
+ 'dependsOn',
20
+ 'blocked_by',
21
+ 'blocks',
22
+ 'owner',
23
+ 'project',
24
+ 'people',
25
+ 'links'
26
+ ];
14
27
 
15
28
  /**
16
29
  * Build a graph from markdown notes in a vault.
@@ -20,10 +33,23 @@ const IGNORED_DIRECTORIES = new Set([
20
33
  export async function buildVaultGraph(vaultPath, options = {}) {
21
34
  const includeDangling = options.includeDangling !== false;
22
35
  const root = path.resolve(vaultPath);
36
+ const preferIndex = options.preferIndex !== false;
37
+ const validateIndexFreshness = options.validateIndexFreshness !== false;
38
+
39
+ if (preferIndex) {
40
+ const indexedGraph = await loadGraphFromMemoryIndex(root, {
41
+ includeDangling,
42
+ validateFreshness: validateIndexFreshness
43
+ });
44
+ if (indexedGraph) {
45
+ return indexedGraph;
46
+ }
47
+ }
48
+
23
49
  const markdownFiles = await collectMarkdownFiles(root);
24
50
 
25
51
  const nodesById = new Map();
26
- const edges = [];
52
+ const edgesByKey = new Map();
27
53
  const edgeSet = new Set();
28
54
 
29
55
  for (const absoluteFilePath of markdownFiles) {
@@ -37,10 +63,13 @@ export async function buildVaultGraph(vaultPath, options = {}) {
37
63
  id,
38
64
  title: normalizeString(frontmatter.title) || toDisplayTitle(id),
39
65
  category: normalizeString(frontmatter.category) || inferCategory(id),
66
+ type: inferNodeType(id, frontmatter),
40
67
  tags: normalizeTags(frontmatter.tags),
41
68
  path: toPosixPath(relativePath),
42
69
  missing: false,
43
- _outboundTargets: extractWikiLinks(parsed.content)
70
+ _outboundTargets: extractWikiLinks(parsed.content),
71
+ _frontmatterRelations: extractFrontmatterRelations(frontmatter),
72
+ _inlineTags: extractInlineTags(parsed.content)
44
73
  });
45
74
  }
46
75
 
@@ -54,55 +83,117 @@ export async function buildVaultGraph(vaultPath, options = {}) {
54
83
  idsByBaseName.set(baseName, existing);
55
84
  }
56
85
 
86
+ function ensureUnresolvedNode(targetId) {
87
+ if (nodesById.has(targetId) || !includeDangling) {
88
+ return;
89
+ }
90
+ nodesById.set(targetId, {
91
+ id: targetId,
92
+ title: toDisplayTitle(targetId),
93
+ category: 'unresolved',
94
+ type: 'unresolved',
95
+ tags: [],
96
+ path: null,
97
+ missing: true,
98
+ _outboundTargets: [],
99
+ _frontmatterRelations: [],
100
+ _inlineTags: []
101
+ });
102
+ }
103
+
104
+ function addEdge(sourceId, targetId, type, label) {
105
+ const edgeKey = `${type}:${sourceId}=>${targetId}${label ? `:${label}` : ''}`;
106
+ if (edgeSet.has(edgeKey)) {
107
+ return;
108
+ }
109
+ edgeSet.add(edgeKey);
110
+ edgesByKey.set(edgeKey, {
111
+ source: sourceId,
112
+ target: targetId,
113
+ type,
114
+ label
115
+ });
116
+ }
117
+
57
118
  for (const node of nodesById.values()) {
119
+ const tagSet = new Set([...(node.tags ?? []), ...(node._inlineTags ?? [])]);
120
+ for (const inlineTag of tagSet) {
121
+ const tagNodeId = `tag:${inlineTag.toLowerCase()}`;
122
+ if (!nodesById.has(tagNodeId)) {
123
+ nodesById.set(tagNodeId, {
124
+ id: tagNodeId,
125
+ title: `#${inlineTag}`,
126
+ category: 'tag',
127
+ type: 'tag',
128
+ tags: [],
129
+ path: null,
130
+ missing: false,
131
+ _outboundTargets: [],
132
+ _frontmatterRelations: [],
133
+ _inlineTags: []
134
+ });
135
+ }
136
+ addEdge(node.id, tagNodeId, 'tag');
137
+ }
138
+
58
139
  for (const rawTarget of node._outboundTargets) {
59
140
  const targetId = resolveTargetId(rawTarget, {
60
141
  idsByLowercase,
61
142
  idsByBaseName,
62
143
  includeDangling
63
144
  });
64
-
65
145
  if (!targetId) {
66
146
  continue;
67
147
  }
148
+ ensureUnresolvedNode(targetId);
149
+ addEdge(node.id, targetId, 'wiki_link');
150
+ }
68
151
 
69
- if (!nodesById.has(targetId) && includeDangling) {
70
- nodesById.set(targetId, {
71
- id: targetId,
72
- title: toDisplayTitle(targetId),
73
- category: 'unresolved',
74
- tags: [],
75
- path: null,
76
- missing: true,
77
- _outboundTargets: []
78
- });
79
- }
80
-
81
- const edgeKey = `${node.id}=>${targetId}`;
82
- if (edgeSet.has(edgeKey)) {
152
+ for (const relation of node._frontmatterRelations) {
153
+ const targetId = resolveTargetId(relation.target, {
154
+ idsByLowercase,
155
+ idsByBaseName,
156
+ includeDangling
157
+ });
158
+ if (!targetId) {
83
159
  continue;
84
160
  }
85
- edgeSet.add(edgeKey);
86
- edges.push({ source: node.id, target: targetId });
161
+ ensureUnresolvedNode(targetId);
162
+ addEdge(node.id, targetId, 'frontmatter_relation', relation.field);
87
163
  }
88
164
  }
89
165
 
166
+ const edges = Array.from(edgesByKey.values());
90
167
  const degreeByNodeId = new Map();
91
168
  for (const edge of edges) {
92
169
  degreeByNodeId.set(edge.source, (degreeByNodeId.get(edge.source) ?? 0) + 1);
93
170
  degreeByNodeId.set(edge.target, (degreeByNodeId.get(edge.target) ?? 0) + 1);
94
171
  }
95
172
 
173
+ const nodeTypeCounts = {};
96
174
  const nodes = Array.from(nodesById.values())
97
- .map(({ _outboundTargets, ...node }) => ({
98
- ...node,
99
- degree: degreeByNodeId.get(node.id) ?? 0
100
- }))
175
+ .map(({ _outboundTargets, _frontmatterRelations, _inlineTags, ...node }) => {
176
+ nodeTypeCounts[node.type] = (nodeTypeCounts[node.type] ?? 0) + 1;
177
+ return {
178
+ ...node,
179
+ degree: degreeByNodeId.get(node.id) ?? 0
180
+ };
181
+ })
101
182
  .sort((a, b) => a.id.localeCompare(b.id));
102
183
 
184
+ const edgeTypeCounts = {};
185
+ for (const edge of edges) {
186
+ edgeTypeCounts[edge.type] = (edgeTypeCounts[edge.type] ?? 0) + 1;
187
+ }
188
+
103
189
  edges.sort((a, b) => {
104
- const sourceSort = a.source.localeCompare(b.source);
105
- return sourceSort !== 0 ? sourceSort : a.target.localeCompare(b.target);
190
+ const sourceSort = String(a.source).localeCompare(String(b.source));
191
+ if (sourceSort !== 0) return sourceSort;
192
+ const targetSort = String(a.target).localeCompare(String(b.target));
193
+ if (targetSort !== 0) return targetSort;
194
+ const typeSort = String(a.type || '').localeCompare(String(b.type || ''));
195
+ if (typeSort !== 0) return typeSort;
196
+ return String(a.label || '').localeCompare(String(b.label || ''));
106
197
  });
107
198
 
108
199
  return {
@@ -112,11 +203,159 @@ export async function buildVaultGraph(vaultPath, options = {}) {
112
203
  generatedAt: new Date().toISOString(),
113
204
  nodeCount: nodes.length,
114
205
  edgeCount: edges.length,
115
- fileCount: markdownFiles.length
206
+ fileCount: markdownFiles.length,
207
+ nodeTypeCounts,
208
+ edgeTypeCounts
209
+ }
210
+ };
211
+ }
212
+
213
+ async function loadGraphFromMemoryIndex(root, options = {}) {
214
+ const includeDangling = options.includeDangling !== false;
215
+ const validateFreshness = options.validateFreshness !== false;
216
+ const indexPath = path.join(root, ...MEMORY_GRAPH_INDEX_PATH);
217
+
218
+ let parsed;
219
+ try {
220
+ const raw = await fs.readFile(indexPath, 'utf8');
221
+ parsed = JSON.parse(raw);
222
+ } catch {
223
+ return null;
224
+ }
225
+
226
+ const graph = parsed?.graph;
227
+ if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
228
+ return null;
229
+ }
230
+
231
+ if (validateFreshness) {
232
+ const fresh = await isIndexFresh(root, parsed);
233
+ if (!fresh) {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ const nodeById = new Map();
239
+ for (const node of graph.nodes) {
240
+ const mappedId = fromIndexedNodeId(node?.id, node?.type);
241
+ if (!mappedId) continue;
242
+ if (node?.missing && !includeDangling) continue;
243
+
244
+ nodeById.set(mappedId, {
245
+ id: mappedId,
246
+ title: normalizeString(node?.title) || toDisplayTitle(mappedId),
247
+ category: normalizeString(node?.category) || 'root',
248
+ type: normalizeString(node?.type) || 'note',
249
+ tags: normalizeTags(node?.tags),
250
+ path: typeof node?.path === 'string' ? toPosixPath(node.path) : null,
251
+ missing: Boolean(node?.missing),
252
+ degree: Number(node?.degree ?? 0)
253
+ });
254
+ }
255
+
256
+ const edges = [];
257
+ for (const edge of graph.edges) {
258
+ const source = fromIndexedNodeId(edge?.source);
259
+ const target = fromIndexedNodeId(edge?.target);
260
+ if (!source || !target) continue;
261
+ if (!nodeById.has(source) || !nodeById.has(target)) continue;
262
+ edges.push({
263
+ source,
264
+ target,
265
+ type: normalizeString(edge?.type) || 'wiki_link',
266
+ label: normalizeString(edge?.label) || undefined
267
+ });
268
+ }
269
+
270
+ const degreeByNodeId = new Map();
271
+ for (const edge of edges) {
272
+ degreeByNodeId.set(edge.source, (degreeByNodeId.get(edge.source) ?? 0) + 1);
273
+ degreeByNodeId.set(edge.target, (degreeByNodeId.get(edge.target) ?? 0) + 1);
274
+ }
275
+
276
+ const nodeTypeCounts = {};
277
+ const nodes = Array.from(nodeById.values())
278
+ .map((node) => {
279
+ node.degree = degreeByNodeId.get(node.id) ?? 0;
280
+ nodeTypeCounts[node.type] = (nodeTypeCounts[node.type] ?? 0) + 1;
281
+ return node;
282
+ })
283
+ .sort((a, b) => a.id.localeCompare(b.id));
284
+
285
+ const edgeTypeCounts = {};
286
+ for (const edge of edges) {
287
+ edgeTypeCounts[edge.type] = (edgeTypeCounts[edge.type] ?? 0) + 1;
288
+ }
289
+
290
+ edges.sort((a, b) => {
291
+ const sourceSort = String(a.source).localeCompare(String(b.source));
292
+ if (sourceSort !== 0) return sourceSort;
293
+ const targetSort = String(a.target).localeCompare(String(b.target));
294
+ if (targetSort !== 0) return targetSort;
295
+ const typeSort = String(a.type || '').localeCompare(String(b.type || ''));
296
+ if (typeSort !== 0) return typeSort;
297
+ return String(a.label || '').localeCompare(String(b.label || ''));
298
+ });
299
+
300
+ return {
301
+ nodes,
302
+ edges,
303
+ stats: {
304
+ generatedAt: normalizeString(graph?.stats?.generatedAt) || new Date().toISOString(),
305
+ nodeCount: nodes.length,
306
+ edgeCount: edges.length,
307
+ fileCount: Number.isFinite(Number(parsed?.files ? Object.keys(parsed.files).length : graph?.stats?.fileCount))
308
+ ? Number(parsed?.files ? Object.keys(parsed.files).length : graph?.stats?.fileCount)
309
+ : 0,
310
+ nodeTypeCounts,
311
+ edgeTypeCounts
116
312
  }
117
313
  };
118
314
  }
119
315
 
316
+ async function isIndexFresh(root, parsedIndex) {
317
+ const indexedFiles = parsedIndex?.files;
318
+ if (!indexedFiles || typeof indexedFiles !== 'object') {
319
+ return false;
320
+ }
321
+
322
+ const markdownFiles = await collectMarkdownFiles(root);
323
+ const normalizedCurrent = markdownFiles
324
+ .map((absolutePath) => toPosixPath(path.relative(root, absolutePath)))
325
+ .sort((a, b) => a.localeCompare(b));
326
+ const indexedPaths = Object.keys(indexedFiles).sort((a, b) => a.localeCompare(b));
327
+
328
+ if (normalizedCurrent.length !== indexedPaths.length) {
329
+ return false;
330
+ }
331
+
332
+ for (let index = 0; index < normalizedCurrent.length; index += 1) {
333
+ if (normalizedCurrent[index] !== indexedPaths[index]) {
334
+ return false;
335
+ }
336
+ }
337
+
338
+ for (const relativePath of indexedPaths) {
339
+ const fragment = indexedFiles[relativePath];
340
+ const expectedMtime = Number(fragment?.mtimeMs);
341
+ if (!Number.isFinite(expectedMtime)) {
342
+ return false;
343
+ }
344
+ const absolutePath = path.join(root, relativePath);
345
+ let stat;
346
+ try {
347
+ stat = await fs.stat(absolutePath);
348
+ } catch {
349
+ return false;
350
+ }
351
+ if (Math.abs(stat.mtimeMs - expectedMtime) > 1) {
352
+ return false;
353
+ }
354
+ }
355
+
356
+ return true;
357
+ }
358
+
120
359
  async function collectMarkdownFiles(root) {
121
360
  const pending = [root];
122
361
  const files = [];
@@ -159,6 +398,39 @@ function extractWikiLinks(markdown) {
159
398
  return links;
160
399
  }
161
400
 
401
+ function extractInlineTags(markdown) {
402
+ const tags = new Set();
403
+ for (const match of markdown.matchAll(HASH_TAG_REGEX)) {
404
+ const tag = normalizeString(match[2])?.toLowerCase();
405
+ if (tag) {
406
+ tags.add(tag);
407
+ }
408
+ }
409
+ return [...tags];
410
+ }
411
+
412
+ function extractFrontmatterRelations(frontmatter) {
413
+ const relations = [];
414
+ for (const field of FRONTMATTER_RELATION_FIELDS) {
415
+ const raw = frontmatter?.[field];
416
+ if (typeof raw === 'string') {
417
+ for (const target of raw.split(',').map((value) => normalizeWikiTarget(value)).filter(Boolean)) {
418
+ relations.push({ field, target });
419
+ }
420
+ continue;
421
+ }
422
+ if (Array.isArray(raw)) {
423
+ for (const entry of raw) {
424
+ if (typeof entry !== 'string') continue;
425
+ for (const target of entry.split(',').map((value) => normalizeWikiTarget(value)).filter(Boolean)) {
426
+ relations.push({ field, target });
427
+ }
428
+ }
429
+ }
430
+ }
431
+ return relations;
432
+ }
433
+
162
434
  function resolveTargetId(target, context) {
163
435
  const normalized = normalizeWikiTarget(target);
164
436
  if (!normalized) {
@@ -209,6 +481,24 @@ function normalizeWikiTarget(target) {
209
481
  return normalizeString(value);
210
482
  }
211
483
 
484
+ function fromIndexedNodeId(value, nodeType = '') {
485
+ const raw = normalizeString(value);
486
+ if (!raw) return '';
487
+ if (raw.startsWith('note:')) {
488
+ return raw.slice(5);
489
+ }
490
+ if (raw.startsWith('tag:')) {
491
+ return raw;
492
+ }
493
+ if (raw.startsWith('unresolved:')) {
494
+ return raw.slice('unresolved:'.length) || raw;
495
+ }
496
+ if (normalizeString(nodeType).toLowerCase() === 'tag') {
497
+ return raw.startsWith('tag:') ? raw : `tag:${raw}`;
498
+ }
499
+ return raw;
500
+ }
501
+
212
502
  function toNodeId(relativePath) {
213
503
  const normalized = toPosixPath(relativePath);
214
504
  return normalized.toLowerCase().endsWith(MARKDOWN_EXT)
@@ -221,6 +511,20 @@ function inferCategory(id) {
221
511
  return normalizeString(category) || 'root';
222
512
  }
223
513
 
514
+ function inferNodeType(id, frontmatter) {
515
+ const category = inferCategory(id).toLowerCase();
516
+ const explicitType = normalizeString(frontmatter?.type).toLowerCase();
517
+ if (category.includes('daily') || explicitType === 'daily') return 'daily';
518
+ if (category === 'observations' || explicitType === 'observation') return 'observation';
519
+ if (category === 'handoffs' || explicitType === 'handoff') return 'handoff';
520
+ if (category === 'decisions' || explicitType === 'decision') return 'decision';
521
+ if (category === 'lessons' || explicitType === 'lesson') return 'lesson';
522
+ if (category === 'projects' || explicitType === 'project') return 'project';
523
+ if (category === 'people' || explicitType === 'person') return 'person';
524
+ if (category === 'commitments' || explicitType === 'commitment') return 'commitment';
525
+ return 'note';
526
+ }
527
+
224
528
  function normalizeTags(tags) {
225
529
  if (Array.isArray(tags)) {
226
530
  return tags.map(normalizeString).filter(Boolean);
@@ -37,18 +37,32 @@ Linked to [[projects/clawvault|ClawVault Project]] and [[missing-note]].
37
37
  expect(decisionNode).toMatchObject({
38
38
  title: 'Use ClawVault',
39
39
  category: 'decisions',
40
- tags: ['architecture', 'memory']
40
+ tags: ['architecture', 'memory'],
41
+ type: 'decision'
41
42
  });
42
- expect(graph.edges).toEqual(
43
- expect.arrayContaining([
44
- { source: 'decisions/use-clawvault', target: 'projects/clawvault' },
45
- { source: 'decisions/use-clawvault', target: 'missing-note' }
46
- ])
47
- );
43
+ expect(graph.edges).toEqual(expect.arrayContaining([
44
+ expect.objectContaining({
45
+ source: 'decisions/use-clawvault',
46
+ target: 'projects/clawvault',
47
+ type: 'wiki_link'
48
+ }),
49
+ expect.objectContaining({
50
+ source: 'decisions/use-clawvault',
51
+ target: 'missing-note',
52
+ type: 'wiki_link'
53
+ }),
54
+ expect.objectContaining({
55
+ source: 'decisions/use-clawvault',
56
+ target: 'tag:architecture',
57
+ type: 'tag'
58
+ })
59
+ ]));
48
60
  expect(unresolvedNode).toMatchObject({
49
61
  missing: true,
50
62
  category: 'unresolved'
51
63
  });
64
+ expect(graph.stats.edgeTypeCounts.wiki_link).toBeGreaterThanOrEqual(2);
65
+ expect(graph.stats.edgeTypeCounts.tag).toBeGreaterThanOrEqual(1);
52
66
  } finally {
53
67
  fs.rmSync(vaultPath, { recursive: true, force: true });
54
68
  }
@@ -62,11 +76,177 @@ Linked to [[projects/clawvault|ClawVault Project]] and [[missing-note]].
62
76
 
63
77
  const graph = await buildVaultGraph(vaultPath);
64
78
 
65
- expect(graph.edges).toEqual(
66
- expect.arrayContaining([
67
- { source: 'research/notes', target: 'projects/clawvault' }
68
- ])
79
+ expect(graph.edges).toEqual(expect.arrayContaining([
80
+ expect.objectContaining({
81
+ source: 'research/notes',
82
+ target: 'projects/clawvault',
83
+ type: 'wiki_link'
84
+ })
85
+ ]));
86
+ } finally {
87
+ fs.rmSync(vaultPath, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ it('emits frontmatter relation edges with labels', async () => {
92
+ const vaultPath = makeTempVault();
93
+ try {
94
+ writeVaultFile(
95
+ vaultPath,
96
+ 'decisions/db.md',
97
+ `---
98
+ related:
99
+ - projects/clawvault
100
+ owner: people/alice
101
+ ---
102
+ Decision details`
69
103
  );
104
+ writeVaultFile(vaultPath, 'projects/clawvault.md', '# ClawVault');
105
+ writeVaultFile(vaultPath, 'people/alice.md', '# Alice');
106
+
107
+ const graph = await buildVaultGraph(vaultPath);
108
+ expect(graph.edges).toEqual(expect.arrayContaining([
109
+ expect.objectContaining({
110
+ source: 'decisions/db',
111
+ target: 'projects/clawvault',
112
+ type: 'frontmatter_relation',
113
+ label: 'related'
114
+ }),
115
+ expect.objectContaining({
116
+ source: 'decisions/db',
117
+ target: 'people/alice',
118
+ type: 'frontmatter_relation',
119
+ label: 'owner'
120
+ })
121
+ ]));
122
+ } finally {
123
+ fs.rmSync(vaultPath, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ it('loads graph data from memory graph index when present', async () => {
128
+ const vaultPath = makeTempVault();
129
+ try {
130
+ writeVaultFile(vaultPath, 'decisions/use-clawvault.md', '# Placeholder');
131
+ writeVaultFile(vaultPath, 'projects/clawvault.md', '# Placeholder project');
132
+ const decisionMtime = fs.statSync(path.join(vaultPath, 'decisions/use-clawvault.md')).mtimeMs;
133
+ const projectMtime = fs.statSync(path.join(vaultPath, 'projects/clawvault.md')).mtimeMs;
134
+ const indexPath = path.join(vaultPath, '.clawvault', 'graph-index.json');
135
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
136
+ fs.writeFileSync(
137
+ indexPath,
138
+ JSON.stringify({
139
+ schemaVersion: 1,
140
+ files: {
141
+ 'decisions/use-clawvault.md': {
142
+ relativePath: 'decisions/use-clawvault.md',
143
+ mtimeMs: decisionMtime
144
+ },
145
+ 'projects/clawvault.md': {
146
+ relativePath: 'projects/clawvault.md',
147
+ mtimeMs: projectMtime
148
+ }
149
+ },
150
+ graph: {
151
+ nodes: [
152
+ {
153
+ id: 'note:decisions/use-clawvault',
154
+ title: 'Use ClawVault',
155
+ type: 'decision',
156
+ category: 'decisions',
157
+ tags: ['architecture'],
158
+ path: 'decisions/use-clawvault.md',
159
+ missing: false,
160
+ degree: 1
161
+ },
162
+ {
163
+ id: 'note:projects/clawvault',
164
+ title: 'ClawVault Project',
165
+ type: 'project',
166
+ category: 'projects',
167
+ tags: [],
168
+ path: 'projects/clawvault.md',
169
+ missing: false,
170
+ degree: 1
171
+ }
172
+ ],
173
+ edges: [
174
+ {
175
+ source: 'note:decisions/use-clawvault',
176
+ target: 'note:projects/clawvault',
177
+ type: 'frontmatter_relation',
178
+ label: 'related'
179
+ }
180
+ ],
181
+ stats: { generatedAt: '2026-02-13T00:00:00.000Z' }
182
+ }
183
+ }),
184
+ 'utf8'
185
+ );
186
+
187
+ const graph = await buildVaultGraph(vaultPath);
188
+ expect(graph.nodes.find((node) => node.id === 'decisions/use-clawvault')).toBeTruthy();
189
+ expect(graph.edges).toEqual([
190
+ {
191
+ source: 'decisions/use-clawvault',
192
+ target: 'projects/clawvault',
193
+ type: 'frontmatter_relation',
194
+ label: 'related'
195
+ }
196
+ ]);
197
+ expect(graph.stats.fileCount).toBe(2);
198
+ } finally {
199
+ fs.rmSync(vaultPath, { recursive: true, force: true });
200
+ }
201
+ });
202
+
203
+ it('falls back to markdown parsing when memory graph index is stale', async () => {
204
+ const vaultPath = makeTempVault();
205
+ try {
206
+ writeVaultFile(vaultPath, 'projects/clawvault.md', '# ClawVault');
207
+ writeVaultFile(vaultPath, 'decisions/use-clawvault.md', 'See [[projects/clawvault]].');
208
+
209
+ const indexPath = path.join(vaultPath, '.clawvault', 'graph-index.json');
210
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
211
+ fs.writeFileSync(
212
+ indexPath,
213
+ JSON.stringify({
214
+ schemaVersion: 1,
215
+ generatedAt: '2026-02-13T00:00:00.000Z',
216
+ files: {
217
+ 'decisions/use-clawvault.md': { relativePath: 'decisions/use-clawvault.md', mtimeMs: 1 },
218
+ 'projects/clawvault.md': { relativePath: 'projects/clawvault.md', mtimeMs: 1 }
219
+ },
220
+ graph: {
221
+ nodes: [
222
+ {
223
+ id: 'note:decisions/use-clawvault',
224
+ title: 'Old node',
225
+ type: 'decision',
226
+ category: 'decisions',
227
+ tags: [],
228
+ path: 'decisions/use-clawvault.md',
229
+ missing: false,
230
+ degree: 0
231
+ }
232
+ ],
233
+ edges: [],
234
+ stats: { generatedAt: '2026-02-13T00:00:00.000Z' }
235
+ }
236
+ }),
237
+ 'utf8'
238
+ );
239
+
240
+ const graph = await buildVaultGraph(vaultPath);
241
+ const node = graph.nodes.find((candidate) => candidate.id === 'decisions/use-clawvault');
242
+ expect(node?.title).not.toBe('Old node');
243
+ expect(graph.edges).toEqual(expect.arrayContaining([
244
+ expect.objectContaining({
245
+ source: 'decisions/use-clawvault',
246
+ target: 'projects/clawvault',
247
+ type: 'wiki_link'
248
+ })
249
+ ]));
70
250
  } finally {
71
251
  fs.rmSync(vaultPath, { recursive: true, force: true });
72
252
  }