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.
- package/README.md +135 -1
- package/bin/clawvault.js +51 -1252
- package/bin/command-registration.test.js +148 -0
- package/bin/command-runtime.js +42 -0
- package/bin/command-runtime.test.js +102 -0
- package/bin/help-contract.test.js +23 -0
- package/bin/register-core-commands.js +139 -0
- package/bin/register-maintenance-commands.js +137 -0
- package/bin/register-query-commands.js +225 -0
- package/bin/register-resilience-commands.js +147 -0
- package/bin/register-session-lifecycle-commands.js +204 -0
- package/bin/register-template-commands.js +72 -0
- package/bin/register-vault-operations-commands.js +295 -0
- package/bin/test-helpers/cli-command-fixtures.js +94 -0
- package/dashboard/lib/graph-diff.js +3 -1
- package/dashboard/lib/graph-diff.test.js +19 -0
- package/dashboard/lib/vault-parser.js +330 -26
- package/dashboard/lib/vault-parser.test.js +191 -11
- package/dashboard/public/app.js +22 -9
- package/dist/chunk-MXSSG3QU.js +42 -0
- package/dist/chunk-O5V7SD5C.js +398 -0
- package/dist/chunk-PAYUH64O.js +284 -0
- package/dist/{chunk-3HFB7EMU.js → chunk-QFBKWDYR.js} +12 -0
- package/dist/{chunk-UBRYOIII.js → chunk-TBVI4N53.js} +210 -21
- package/dist/chunk-TXO34J3O.js +56 -0
- package/dist/commands/compat.d.ts +28 -0
- package/dist/commands/compat.js +10 -0
- package/dist/commands/context.d.ts +2 -33
- package/dist/commands/context.js +3 -2
- package/dist/commands/doctor.js +61 -3
- package/dist/commands/entities.d.ts +1 -0
- package/dist/commands/entities.js +4 -4
- package/dist/commands/graph.d.ts +21 -0
- package/dist/commands/graph.js +10 -0
- package/dist/commands/link.d.ts +1 -0
- package/dist/commands/link.js +14 -5
- package/dist/commands/sleep.js +7 -6
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +63 -3
- package/dist/commands/wake.js +5 -4
- package/dist/context-COo8oq1k.d.ts +45 -0
- package/dist/index.d.ts +63 -2
- package/dist/index.js +53 -15
- package/dist/lib/config.d.ts +6 -1
- package/dist/lib/config.js +7 -3
- package/hooks/clawvault/HOOK.md +6 -1
- package/hooks/clawvault/handler.js +44 -3
- package/hooks/clawvault/handler.test.js +161 -0
- package/package.json +34 -2
- package/dashboard/public/graph.js +0 -376
- package/dashboard/public/style.css +0 -154
- 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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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.
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
67
|
-
|
|
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
|
}
|