clawvault 1.7.0 → 1.8.1

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/bin/clawvault.js CHANGED
@@ -1186,5 +1186,34 @@ program
1186
1186
  }
1187
1187
  });
1188
1188
 
1189
+ // === DASHBOARD ===
1190
+ program
1191
+ .command('dashboard')
1192
+ .description('Run local vault graph dashboard')
1193
+ .option('-p, --port <port>', 'Dashboard port', '3377')
1194
+ .option('-v, --vault <path>', 'Vault path')
1195
+ .action(async (options) => {
1196
+ try {
1197
+ const parsedPort = Number.parseInt(options.port, 10);
1198
+ if (Number.isNaN(parsedPort)) {
1199
+ console.error(chalk.red(`Error: Invalid port: ${options.port}`));
1200
+ process.exit(1);
1201
+ }
1202
+
1203
+ const vaultPath = options.vault
1204
+ ? path.resolve(options.vault)
1205
+ : resolveVaultPath(undefined);
1206
+
1207
+ const { startDashboard } = await import('../dashboard/server.js');
1208
+ await startDashboard({
1209
+ port: parsedPort,
1210
+ vaultPath
1211
+ });
1212
+ } catch (err) {
1213
+ console.error(chalk.red(`Error: ${err.message}`));
1214
+ process.exit(1);
1215
+ }
1216
+ });
1217
+
1189
1218
  // Parse and run
1190
1219
  program.parse();
@@ -0,0 +1,102 @@
1
+ function toNodeSignature(node) {
2
+ return JSON.stringify({
3
+ title: node.title,
4
+ category: node.category,
5
+ tags: Array.isArray(node.tags) ? [...node.tags].sort() : [],
6
+ path: node.path,
7
+ missing: Boolean(node.missing),
8
+ degree: Number(node.degree ?? 0)
9
+ });
10
+ }
11
+
12
+ function toEdgeKey(edge) {
13
+ return `${edge.source}=>${edge.target}`;
14
+ }
15
+
16
+ /**
17
+ * Compute an efficient patch between graph snapshots.
18
+ * @param {{nodes: Array<object>, edges: Array<object>, stats?: object}} previousGraph
19
+ * @param {{nodes: Array<object>, edges: Array<object>, stats?: object}} nextGraph
20
+ */
21
+ export function diffGraphs(previousGraph, nextGraph) {
22
+ const previousNodes = previousGraph?.nodes ?? [];
23
+ const nextNodes = nextGraph?.nodes ?? [];
24
+ const previousEdges = previousGraph?.edges ?? [];
25
+ const nextEdges = nextGraph?.edges ?? [];
26
+
27
+ const previousNodeById = new Map(previousNodes.map((node) => [node.id, node]));
28
+ const nextNodeById = new Map(nextNodes.map((node) => [node.id, node]));
29
+
30
+ const addedNodes = [];
31
+ const updatedNodes = [];
32
+ const removedNodeIds = [];
33
+
34
+ for (const [nodeId, nextNode] of nextNodeById.entries()) {
35
+ const previousNode = previousNodeById.get(nodeId);
36
+ if (!previousNode) {
37
+ addedNodes.push(nextNode);
38
+ continue;
39
+ }
40
+ if (toNodeSignature(previousNode) !== toNodeSignature(nextNode)) {
41
+ updatedNodes.push(nextNode);
42
+ }
43
+ }
44
+
45
+ for (const nodeId of previousNodeById.keys()) {
46
+ if (!nextNodeById.has(nodeId)) {
47
+ removedNodeIds.push(nodeId);
48
+ }
49
+ }
50
+
51
+ const previousEdgeByKey = new Map(previousEdges.map((edge) => [toEdgeKey(edge), edge]));
52
+ const nextEdgeByKey = new Map(nextEdges.map((edge) => [toEdgeKey(edge), edge]));
53
+ const addedEdges = [];
54
+ const removedEdges = [];
55
+
56
+ for (const [edgeKey, edge] of nextEdgeByKey.entries()) {
57
+ if (!previousEdgeByKey.has(edgeKey)) {
58
+ addedEdges.push(edge);
59
+ }
60
+ }
61
+
62
+ for (const [edgeKey, edge] of previousEdgeByKey.entries()) {
63
+ if (!nextEdgeByKey.has(edgeKey)) {
64
+ removedEdges.push(edge);
65
+ }
66
+ }
67
+
68
+ const touchedNodeIds = new Set();
69
+ for (const node of addedNodes) {
70
+ touchedNodeIds.add(node.id);
71
+ }
72
+ for (const node of updatedNodes) {
73
+ touchedNodeIds.add(node.id);
74
+ }
75
+ for (const nodeId of removedNodeIds) {
76
+ touchedNodeIds.add(nodeId);
77
+ }
78
+ for (const edge of addedEdges) {
79
+ touchedNodeIds.add(edge.source);
80
+ touchedNodeIds.add(edge.target);
81
+ }
82
+ for (const edge of removedEdges) {
83
+ touchedNodeIds.add(edge.source);
84
+ touchedNodeIds.add(edge.target);
85
+ }
86
+
87
+ return {
88
+ addedNodes,
89
+ updatedNodes,
90
+ removedNodeIds,
91
+ addedEdges,
92
+ removedEdges,
93
+ changedNodeIds: Array.from(touchedNodeIds).sort((a, b) => a.localeCompare(b)),
94
+ stats: nextGraph?.stats ?? null,
95
+ hasChanges:
96
+ addedNodes.length > 0 ||
97
+ updatedNodes.length > 0 ||
98
+ removedNodeIds.length > 0 ||
99
+ addedEdges.length > 0 ||
100
+ removedEdges.length > 0
101
+ };
102
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { diffGraphs } from './graph-diff.js';
3
+
4
+ describe('diffGraphs', () => {
5
+ it('detects node and edge additions, updates, and removals', () => {
6
+ const previous = {
7
+ nodes: [
8
+ { id: 'a', title: 'A', category: 'root', tags: [], path: 'a.md', missing: false, degree: 1 },
9
+ { id: 'b', title: 'B', category: 'root', tags: ['x'], path: 'b.md', missing: false, degree: 1 },
10
+ { id: 'c', title: 'C', category: 'root', tags: [], path: null, missing: true, degree: 0 }
11
+ ],
12
+ edges: [{ source: 'a', target: 'b' }],
13
+ stats: { nodeCount: 3, edgeCount: 1 }
14
+ };
15
+
16
+ const next = {
17
+ nodes: [
18
+ { id: 'a', title: 'A Updated', category: 'root', tags: [], path: 'a.md', missing: false, degree: 1 },
19
+ { id: 'b', title: 'B', category: 'root', tags: ['x'], path: 'b.md', missing: false, degree: 2 },
20
+ { id: 'd', title: 'D', category: 'projects', tags: [], path: 'd.md', missing: false, degree: 1 }
21
+ ],
22
+ edges: [
23
+ { source: 'a', target: 'b' },
24
+ { source: 'b', target: 'd' }
25
+ ],
26
+ stats: { nodeCount: 3, edgeCount: 2 }
27
+ };
28
+
29
+ const patch = diffGraphs(previous, next);
30
+
31
+ expect(patch.addedNodes).toEqual([next.nodes[2]]);
32
+ expect(patch.updatedNodes).toEqual(expect.arrayContaining([next.nodes[0], next.nodes[1]]));
33
+ expect(patch.removedNodeIds).toEqual(['c']);
34
+ expect(patch.addedEdges).toEqual([{ source: 'b', target: 'd' }]);
35
+ expect(patch.removedEdges).toEqual([]);
36
+ expect(patch.changedNodeIds).toEqual(['a', 'b', 'c', 'd']);
37
+ expect(patch.hasChanges).toBe(true);
38
+ });
39
+
40
+ it('returns hasChanges=false for equivalent graphs', () => {
41
+ const graph = {
42
+ nodes: [{ id: 'a', title: 'A', category: 'root', tags: ['t'], path: 'a.md', missing: false, degree: 0 }],
43
+ edges: [],
44
+ stats: { nodeCount: 1, edgeCount: 0 }
45
+ };
46
+
47
+ const patch = diffGraphs(graph, structuredClone(graph));
48
+
49
+ expect(patch.hasChanges).toBe(false);
50
+ expect(patch.addedNodes).toEqual([]);
51
+ expect(patch.updatedNodes).toEqual([]);
52
+ expect(patch.removedNodeIds).toEqual([]);
53
+ expect(patch.addedEdges).toEqual([]);
54
+ expect(patch.removedEdges).toEqual([]);
55
+ });
56
+ });
@@ -0,0 +1,252 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import matter from 'gray-matter';
4
+
5
+ export const WIKI_LINK_REGEX = /\[\[([^\]|]+)(\|[^\]]+)?\]\]/g;
6
+
7
+ const MARKDOWN_EXT = '.md';
8
+ const IGNORED_DIRECTORIES = new Set([
9
+ '.git',
10
+ '.obsidian',
11
+ '.trash',
12
+ 'node_modules'
13
+ ]);
14
+
15
+ /**
16
+ * Build a graph from markdown notes in a vault.
17
+ * @param {string} vaultPath
18
+ * @param {{ includeDangling?: boolean }} [options]
19
+ */
20
+ export async function buildVaultGraph(vaultPath, options = {}) {
21
+ const includeDangling = options.includeDangling !== false;
22
+ const root = path.resolve(vaultPath);
23
+ const markdownFiles = await collectMarkdownFiles(root);
24
+
25
+ const nodesById = new Map();
26
+ const edges = [];
27
+ const edgeSet = new Set();
28
+
29
+ for (const absoluteFilePath of markdownFiles) {
30
+ const raw = await fs.readFile(absoluteFilePath, 'utf8');
31
+ const parsed = matter(raw);
32
+ const relativePath = path.relative(root, absoluteFilePath);
33
+ const id = toNodeId(relativePath);
34
+ const frontmatter = parsed.data ?? {};
35
+
36
+ nodesById.set(id, {
37
+ id,
38
+ title: normalizeString(frontmatter.title) || toDisplayTitle(id),
39
+ category: normalizeString(frontmatter.category) || inferCategory(id),
40
+ tags: normalizeTags(frontmatter.tags),
41
+ path: toPosixPath(relativePath),
42
+ missing: false,
43
+ _outboundTargets: extractWikiLinks(parsed.content)
44
+ });
45
+ }
46
+
47
+ const idsByLowercase = new Map();
48
+ const idsByBaseName = new Map();
49
+ for (const id of nodesById.keys()) {
50
+ idsByLowercase.set(id.toLowerCase(), id);
51
+ const baseName = path.posix.basename(id).toLowerCase();
52
+ const existing = idsByBaseName.get(baseName) ?? new Set();
53
+ existing.add(id);
54
+ idsByBaseName.set(baseName, existing);
55
+ }
56
+
57
+ for (const node of nodesById.values()) {
58
+ for (const rawTarget of node._outboundTargets) {
59
+ const targetId = resolveTargetId(rawTarget, {
60
+ idsByLowercase,
61
+ idsByBaseName,
62
+ includeDangling
63
+ });
64
+
65
+ if (!targetId) {
66
+ continue;
67
+ }
68
+
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)) {
83
+ continue;
84
+ }
85
+ edgeSet.add(edgeKey);
86
+ edges.push({ source: node.id, target: targetId });
87
+ }
88
+ }
89
+
90
+ const degreeByNodeId = new Map();
91
+ for (const edge of edges) {
92
+ degreeByNodeId.set(edge.source, (degreeByNodeId.get(edge.source) ?? 0) + 1);
93
+ degreeByNodeId.set(edge.target, (degreeByNodeId.get(edge.target) ?? 0) + 1);
94
+ }
95
+
96
+ const nodes = Array.from(nodesById.values())
97
+ .map(({ _outboundTargets, ...node }) => ({
98
+ ...node,
99
+ degree: degreeByNodeId.get(node.id) ?? 0
100
+ }))
101
+ .sort((a, b) => a.id.localeCompare(b.id));
102
+
103
+ edges.sort((a, b) => {
104
+ const sourceSort = a.source.localeCompare(b.source);
105
+ return sourceSort !== 0 ? sourceSort : a.target.localeCompare(b.target);
106
+ });
107
+
108
+ return {
109
+ nodes,
110
+ edges,
111
+ stats: {
112
+ generatedAt: new Date().toISOString(),
113
+ nodeCount: nodes.length,
114
+ edgeCount: edges.length,
115
+ fileCount: markdownFiles.length
116
+ }
117
+ };
118
+ }
119
+
120
+ async function collectMarkdownFiles(root) {
121
+ const pending = [root];
122
+ const files = [];
123
+
124
+ while (pending.length > 0) {
125
+ const currentDir = pending.pop();
126
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
127
+
128
+ for (const entry of entries) {
129
+ const absolutePath = path.join(currentDir, entry.name);
130
+ if (entry.isDirectory()) {
131
+ if (!IGNORED_DIRECTORIES.has(entry.name)) {
132
+ pending.push(absolutePath);
133
+ }
134
+ continue;
135
+ }
136
+
137
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(MARKDOWN_EXT)) {
138
+ files.push(absolutePath);
139
+ }
140
+ }
141
+ }
142
+
143
+ return files;
144
+ }
145
+
146
+ function extractWikiLinks(markdown) {
147
+ const links = [];
148
+ const regex = new RegExp(WIKI_LINK_REGEX.source, 'g');
149
+ let match = regex.exec(markdown);
150
+
151
+ while (match) {
152
+ const rawTarget = match[1]?.trim();
153
+ if (rawTarget) {
154
+ links.push(rawTarget);
155
+ }
156
+ match = regex.exec(markdown);
157
+ }
158
+
159
+ return links;
160
+ }
161
+
162
+ function resolveTargetId(target, context) {
163
+ const normalized = normalizeWikiTarget(target);
164
+ if (!normalized) {
165
+ return null;
166
+ }
167
+
168
+ const lower = normalized.toLowerCase();
169
+ const exact = context.idsByLowercase.get(lower);
170
+ if (exact) {
171
+ return exact;
172
+ }
173
+
174
+ if (!normalized.includes('/')) {
175
+ const maybeMatches = context.idsByBaseName.get(lower);
176
+ if (maybeMatches?.size === 1) {
177
+ return Array.from(maybeMatches)[0];
178
+ }
179
+ }
180
+
181
+ return context.includeDangling ? normalized : null;
182
+ }
183
+
184
+ function normalizeWikiTarget(target) {
185
+ let value = normalizeString(target);
186
+ if (!value) {
187
+ return null;
188
+ }
189
+
190
+ const hashIndex = value.indexOf('#');
191
+ if (hashIndex >= 0) {
192
+ value = value.slice(0, hashIndex);
193
+ }
194
+
195
+ const caretIndex = value.indexOf('^');
196
+ if (caretIndex >= 0) {
197
+ value = value.slice(0, caretIndex);
198
+ }
199
+
200
+ value = value.replace(/\\/g, '/');
201
+ value = value.replace(/^\.\//, '');
202
+ value = value.replace(/^\/+/, '');
203
+ value = value.replace(/\/+/g, '/');
204
+
205
+ if (value.toLowerCase().endsWith(MARKDOWN_EXT)) {
206
+ value = value.slice(0, -MARKDOWN_EXT.length);
207
+ }
208
+
209
+ return normalizeString(value);
210
+ }
211
+
212
+ function toNodeId(relativePath) {
213
+ const normalized = toPosixPath(relativePath);
214
+ return normalized.toLowerCase().endsWith(MARKDOWN_EXT)
215
+ ? normalized.slice(0, -MARKDOWN_EXT.length)
216
+ : normalized;
217
+ }
218
+
219
+ function inferCategory(id) {
220
+ const category = id.split('/')[0];
221
+ return normalizeString(category) || 'root';
222
+ }
223
+
224
+ function normalizeTags(tags) {
225
+ if (Array.isArray(tags)) {
226
+ return tags.map(normalizeString).filter(Boolean);
227
+ }
228
+ if (typeof tags === 'string') {
229
+ return tags
230
+ .split(',')
231
+ .map((tag) => normalizeString(tag))
232
+ .filter(Boolean);
233
+ }
234
+ return [];
235
+ }
236
+
237
+ function normalizeString(value) {
238
+ return typeof value === 'string' ? value.trim() : '';
239
+ }
240
+
241
+ function toDisplayTitle(id) {
242
+ const base = path.posix.basename(id);
243
+ return base
244
+ .replace(/[-_]+/g, ' ')
245
+ .replace(/\s+/g, ' ')
246
+ .trim()
247
+ .replace(/\b\w/g, (char) => char.toUpperCase());
248
+ }
249
+
250
+ function toPosixPath(filePath) {
251
+ return filePath.split(path.sep).join('/');
252
+ }
@@ -0,0 +1,74 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { buildVaultGraph } from './vault-parser.js';
6
+
7
+ function makeTempVault() {
8
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'clawvault-dashboard-'));
9
+ }
10
+
11
+ function writeVaultFile(root, relativePath, content) {
12
+ const fullPath = path.join(root, relativePath);
13
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
14
+ fs.writeFileSync(fullPath, content, 'utf8');
15
+ }
16
+
17
+ describe('buildVaultGraph', () => {
18
+ it('builds nodes and edges from markdown wiki-links', async () => {
19
+ const vaultPath = makeTempVault();
20
+ try {
21
+ writeVaultFile(
22
+ vaultPath,
23
+ 'decisions/use-clawvault.md',
24
+ `---
25
+ title: Use ClawVault
26
+ tags: [architecture, memory]
27
+ ---
28
+ Linked to [[projects/clawvault|ClawVault Project]] and [[missing-note]].
29
+ `
30
+ );
31
+ writeVaultFile(vaultPath, 'projects/clawvault.md', '# ClawVault');
32
+
33
+ const graph = await buildVaultGraph(vaultPath);
34
+ const decisionNode = graph.nodes.find((node) => node.id === 'decisions/use-clawvault');
35
+ const unresolvedNode = graph.nodes.find((node) => node.id === 'missing-note');
36
+
37
+ expect(decisionNode).toMatchObject({
38
+ title: 'Use ClawVault',
39
+ category: 'decisions',
40
+ tags: ['architecture', 'memory']
41
+ });
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
+ );
48
+ expect(unresolvedNode).toMatchObject({
49
+ missing: true,
50
+ category: 'unresolved'
51
+ });
52
+ } finally {
53
+ fs.rmSync(vaultPath, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ it('resolves basename links when there is a unique match', async () => {
58
+ const vaultPath = makeTempVault();
59
+ try {
60
+ writeVaultFile(vaultPath, 'research/notes.md', 'See [[clawvault]].');
61
+ writeVaultFile(vaultPath, 'projects/clawvault.md', '# ClawVault');
62
+
63
+ const graph = await buildVaultGraph(vaultPath);
64
+
65
+ expect(graph.edges).toEqual(
66
+ expect.arrayContaining([
67
+ { source: 'research/notes', target: 'projects/clawvault' }
68
+ ])
69
+ );
70
+ } finally {
71
+ fs.rmSync(vaultPath, { recursive: true, force: true });
72
+ }
73
+ });
74
+ });