clawvault 1.7.0 → 1.8.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/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,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
+ });
@@ -0,0 +1,376 @@
1
+ const CATEGORY_COLORS = {
2
+ decisions: '#f26430',
3
+ lessons: '#4ecdc4',
4
+ people: '#ff6b6b',
5
+ projects: '#95e1d3',
6
+ commitments: '#f9d56e',
7
+ research: '#a8e6cf',
8
+ inbox: '#888888',
9
+ root: '#666666',
10
+ default: '#aaaaaa'
11
+ };
12
+
13
+ const HIGHLIGHT_NODE_COLOR = '#ffffff';
14
+ const HIGHLIGHT_LINK_COLOR = '#f3f4f6';
15
+ const DIMMED_NODE_COLOR = '#324055';
16
+ const DIMMED_LINK_COLOR = 'rgba(130, 145, 170, 0.2)';
17
+
18
+ const state = {
19
+ searchTerm: '',
20
+ category: 'all',
21
+ nodes: [],
22
+ links: [],
23
+ stats: null,
24
+ nodeById: new Map(),
25
+ neighborsByNodeId: new Map(),
26
+ linksByNodeId: new Map(),
27
+ hoveredNode: null,
28
+ selectedNode: null,
29
+ highlightedNodeIds: new Set(),
30
+ highlightedLinks: new Set()
31
+ };
32
+
33
+ const graphElement = document.querySelector('#graph');
34
+ const detailsElement = document.querySelector('#node-details');
35
+ const statsElement = document.querySelector('#stats');
36
+ const searchElement = document.querySelector('#search');
37
+ const categoryFilterElement = document.querySelector('#category-filter');
38
+ const refreshButtonElement = document.querySelector('#refresh');
39
+
40
+ if (typeof window.ForceGraph !== 'function') {
41
+ statsElement.textContent = 'ForceGraph failed to load.';
42
+ throw new Error('force-graph library unavailable');
43
+ }
44
+
45
+ const graph = window
46
+ .ForceGraph()(graphElement)
47
+ .backgroundColor('#0c1117')
48
+ .nodeId('id')
49
+ .linkSource('source')
50
+ .linkTarget('target')
51
+ .nodeRelSize(5)
52
+ .nodeVal((node) => 1 + Math.sqrt((node.degree ?? 0) + 1))
53
+ .nodeLabel((node) => `${node.title}\n${node.id}`)
54
+ .nodeColor((node) => getNodeColor(node))
55
+ .linkColor((link) => getLinkColor(link))
56
+ .linkWidth((link) => (state.highlightedLinks.has(link) ? 2.6 : 1))
57
+ .linkDirectionalParticles((link) => (state.highlightedLinks.has(link) ? 2 : 0))
58
+ .linkDirectionalParticleWidth(2)
59
+ .cooldownTicks(120)
60
+ .onNodeHover((node) => {
61
+ state.hoveredNode = node ?? null;
62
+ syncHighlights();
63
+ })
64
+ .onNodeClick((node) => {
65
+ state.selectedNode = node;
66
+ syncHighlights();
67
+ renderDetails(node);
68
+ })
69
+ .onBackgroundClick(() => {
70
+ state.selectedNode = null;
71
+ syncHighlights();
72
+ renderEmptyDetails();
73
+ });
74
+
75
+ resizeGraphToContainer();
76
+
77
+ window.addEventListener('resize', () => {
78
+ resizeGraphToContainer();
79
+ });
80
+
81
+ function resizeGraphToContainer() {
82
+ graph.width(graphElement.clientWidth);
83
+ graph.height(graphElement.clientHeight);
84
+ }
85
+
86
+ searchElement.addEventListener('input', (event) => {
87
+ state.searchTerm = String(event.target.value ?? '').trim().toLowerCase();
88
+ applyFilters();
89
+ });
90
+
91
+ categoryFilterElement.addEventListener('change', (event) => {
92
+ state.category = String(event.target.value ?? 'all');
93
+ applyFilters();
94
+ });
95
+
96
+ refreshButtonElement.addEventListener('click', async () => {
97
+ await loadGraphData({ refresh: true });
98
+ });
99
+
100
+ detailsElement.addEventListener('click', (event) => {
101
+ const target = event.target;
102
+ if (!(target instanceof HTMLElement)) {
103
+ return;
104
+ }
105
+
106
+ const linkedNodeId = target.dataset.nodeId;
107
+ if (!linkedNodeId) {
108
+ return;
109
+ }
110
+
111
+ event.preventDefault();
112
+ const linkedNode = state.nodeById.get(linkedNodeId);
113
+ if (!linkedNode) {
114
+ return;
115
+ }
116
+
117
+ state.selectedNode = linkedNode;
118
+ syncHighlights();
119
+ renderDetails(linkedNode);
120
+ graph.centerAt(linkedNode.x ?? 0, linkedNode.y ?? 0, 450);
121
+ graph.zoom(4, 350);
122
+ });
123
+
124
+ await loadGraphData();
125
+
126
+ async function loadGraphData({ refresh = false } = {}) {
127
+ statsElement.textContent = 'Loading graph...';
128
+ refreshButtonElement.disabled = true;
129
+ try {
130
+ const response = await fetch(refresh ? '/api/graph?refresh=1' : '/api/graph');
131
+ if (!response.ok) {
132
+ throw new Error(`HTTP ${response.status}`);
133
+ }
134
+
135
+ const payload = await response.json();
136
+ const nodes = payload.nodes ?? [];
137
+ const links = (payload.edges ?? []).map((edge) => ({
138
+ source: edge.source,
139
+ target: edge.target
140
+ }));
141
+
142
+ hydrateState(nodes, links, payload.stats ?? null);
143
+ populateCategoryFilter();
144
+ applyFilters();
145
+ renderEmptyDetails();
146
+ updateStats();
147
+ graph.zoomToFit(600, 80);
148
+ } catch (error) {
149
+ statsElement.textContent = `Failed to load graph: ${error instanceof Error ? error.message : String(error)}`;
150
+ } finally {
151
+ refreshButtonElement.disabled = false;
152
+ }
153
+ }
154
+
155
+ function hydrateState(nodes, links, stats) {
156
+ state.nodes = nodes;
157
+ state.links = links;
158
+ state.stats = stats;
159
+ state.nodeById = new Map(nodes.map((node) => [node.id, node]));
160
+ state.neighborsByNodeId = new Map();
161
+ state.linksByNodeId = new Map();
162
+ state.hoveredNode = null;
163
+ state.selectedNode = null;
164
+
165
+ for (const node of nodes) {
166
+ state.neighborsByNodeId.set(node.id, new Set());
167
+ state.linksByNodeId.set(node.id, new Set());
168
+ }
169
+
170
+ for (const link of links) {
171
+ const sourceId = getNodeId(link.source);
172
+ const targetId = getNodeId(link.target);
173
+ if (!sourceId || !targetId) {
174
+ continue;
175
+ }
176
+ state.neighborsByNodeId.get(sourceId)?.add(targetId);
177
+ state.neighborsByNodeId.get(targetId)?.add(sourceId);
178
+ state.linksByNodeId.get(sourceId)?.add(link);
179
+ state.linksByNodeId.get(targetId)?.add(link);
180
+ }
181
+
182
+ graph.graphData({ nodes: state.nodes, links: state.links });
183
+ }
184
+
185
+ function applyFilters() {
186
+ graph.nodeVisibility((node) => isNodeVisible(node));
187
+ graph.linkVisibility((link) => {
188
+ const sourceNode = getNodeFromLinkEnd(link.source);
189
+ const targetNode = getNodeFromLinkEnd(link.target);
190
+ return Boolean(sourceNode && targetNode && isNodeVisible(sourceNode) && isNodeVisible(targetNode));
191
+ });
192
+ syncHighlights();
193
+ graph.refresh();
194
+ }
195
+
196
+ function syncHighlights() {
197
+ state.highlightedNodeIds.clear();
198
+ state.highlightedLinks.clear();
199
+
200
+ const focusNode = state.selectedNode ?? state.hoveredNode;
201
+ if (!focusNode) {
202
+ graph.refresh();
203
+ return;
204
+ }
205
+
206
+ const focusNodeId = focusNode.id;
207
+ state.highlightedNodeIds.add(focusNodeId);
208
+
209
+ for (const neighborId of state.neighborsByNodeId.get(focusNodeId) ?? []) {
210
+ state.highlightedNodeIds.add(neighborId);
211
+ }
212
+ for (const link of state.linksByNodeId.get(focusNodeId) ?? []) {
213
+ state.highlightedLinks.add(link);
214
+ }
215
+
216
+ graph.refresh();
217
+ }
218
+
219
+ function renderEmptyDetails() {
220
+ detailsElement.innerHTML = '<p>Select a node to inspect details and connections.</p>';
221
+ }
222
+
223
+ function renderDetails(node) {
224
+ const neighbors = Array.from(state.neighborsByNodeId.get(node.id) ?? [])
225
+ .map((id) => state.nodeById.get(id))
226
+ .filter(Boolean)
227
+ .sort((a, b) => a.title.localeCompare(b.title));
228
+
229
+ const tags = Array.isArray(node.tags) && node.tags.length > 0 ? node.tags.join(', ') : 'none';
230
+ const category = node.category || 'default';
231
+ const degree = Number(node.degree ?? neighbors.length);
232
+ const pathValue = node.path ?? '(unresolved link target)';
233
+
234
+ const connectionItems = neighbors.length
235
+ ? neighbors
236
+ .map((neighbor) => {
237
+ const color = colorForCategory(neighbor.category);
238
+ return `<li><a href="#" class="connection-link" data-node-id="${escapeHtml(neighbor.id)}" style="color:${color}">${escapeHtml(neighbor.title)}</a></li>`;
239
+ })
240
+ .join('')
241
+ : '<li>No direct connections</li>';
242
+
243
+ detailsElement.innerHTML = `
244
+ <div class="meta-label">Title</div>
245
+ <p class="meta-value">${escapeHtml(node.title)}</p>
246
+ <div class="meta-label">ID</div>
247
+ <p class="meta-value">${escapeHtml(node.id)}</p>
248
+ <div class="meta-label">Category</div>
249
+ <p class="meta-value">${escapeHtml(category)}</p>
250
+ <div class="meta-label">Tags</div>
251
+ <p class="meta-value">${escapeHtml(tags)}</p>
252
+ <div class="meta-label">Degree</div>
253
+ <p class="meta-value">${degree}</p>
254
+ <div class="meta-label">Path</div>
255
+ <p class="meta-value">${escapeHtml(pathValue)}</p>
256
+ <div class="meta-label">Connections (${neighbors.length})</div>
257
+ <ul class="connection-list">${connectionItems}</ul>
258
+ `;
259
+ }
260
+
261
+ function updateStats() {
262
+ const nodeCount = state.stats?.nodeCount ?? state.nodes.length;
263
+ const edgeCount = state.stats?.edgeCount ?? state.links.length;
264
+ const fileCount = state.stats?.fileCount ?? state.nodes.length;
265
+ statsElement.textContent = `${nodeCount} nodes • ${edgeCount} links • ${fileCount} files`;
266
+ }
267
+
268
+ function populateCategoryFilter() {
269
+ const currentValue = state.category;
270
+ const categories = new Set(['all']);
271
+ for (const node of state.nodes) {
272
+ categories.add(node.category || 'default');
273
+ }
274
+
275
+ const options = Array.from(categories).sort((a, b) => {
276
+ if (a === 'all') return -1;
277
+ if (b === 'all') return 1;
278
+ return a.localeCompare(b);
279
+ });
280
+
281
+ categoryFilterElement.innerHTML = options
282
+ .map((value) => `<option value="${escapeHtml(value)}">${escapeHtml(value === 'all' ? 'All categories' : value)}</option>`)
283
+ .join('');
284
+
285
+ if (options.includes(currentValue)) {
286
+ categoryFilterElement.value = currentValue;
287
+ state.category = currentValue;
288
+ } else {
289
+ categoryFilterElement.value = 'all';
290
+ state.category = 'all';
291
+ }
292
+ }
293
+
294
+ function getNodeColor(node) {
295
+ const nodeId = node.id;
296
+ if (!isNodeVisible(node)) {
297
+ return DIMMED_NODE_COLOR;
298
+ }
299
+ if (state.highlightedNodeIds.has(nodeId)) {
300
+ return HIGHLIGHT_NODE_COLOR;
301
+ }
302
+ if ((state.selectedNode || state.hoveredNode) && !state.highlightedNodeIds.has(nodeId)) {
303
+ return DIMMED_NODE_COLOR;
304
+ }
305
+ return colorForCategory(node.category);
306
+ }
307
+
308
+ function getLinkColor(link) {
309
+ if (state.highlightedLinks.has(link)) {
310
+ return HIGHLIGHT_LINK_COLOR;
311
+ }
312
+ const sourceNode = getNodeFromLinkEnd(link.source);
313
+ const targetNode = getNodeFromLinkEnd(link.target);
314
+ if (!sourceNode || !targetNode || !isNodeVisible(sourceNode) || !isNodeVisible(targetNode)) {
315
+ return 'rgba(0, 0, 0, 0)';
316
+ }
317
+ if (state.selectedNode || state.hoveredNode) {
318
+ return DIMMED_LINK_COLOR;
319
+ }
320
+ return 'rgba(157, 176, 198, 0.36)';
321
+ }
322
+
323
+ function isNodeVisible(node) {
324
+ const matchesCategory = state.category === 'all' || (node.category || 'default') === state.category;
325
+ if (!matchesCategory) {
326
+ return false;
327
+ }
328
+
329
+ if (!state.searchTerm) {
330
+ return true;
331
+ }
332
+
333
+ const haystack = [
334
+ node.id,
335
+ node.title,
336
+ Array.isArray(node.tags) ? node.tags.join(' ') : '',
337
+ node.category
338
+ ]
339
+ .join(' ')
340
+ .toLowerCase();
341
+
342
+ return haystack.includes(state.searchTerm);
343
+ }
344
+
345
+ function getNodeFromLinkEnd(linkEnd) {
346
+ if (!linkEnd) {
347
+ return null;
348
+ }
349
+ if (typeof linkEnd === 'object') {
350
+ return linkEnd;
351
+ }
352
+ return state.nodeById.get(linkEnd) ?? null;
353
+ }
354
+
355
+ function getNodeId(linkEnd) {
356
+ if (!linkEnd) {
357
+ return '';
358
+ }
359
+ if (typeof linkEnd === 'object') {
360
+ return linkEnd.id || '';
361
+ }
362
+ return String(linkEnd);
363
+ }
364
+
365
+ function colorForCategory(category) {
366
+ return CATEGORY_COLORS[category] ?? CATEGORY_COLORS.default;
367
+ }
368
+
369
+ function escapeHtml(value) {
370
+ return String(value)
371
+ .replaceAll('&', '&amp;')
372
+ .replaceAll('<', '&lt;')
373
+ .replaceAll('>', '&gt;')
374
+ .replaceAll('"', '&quot;')
375
+ .replaceAll("'", '&#039;');
376
+ }
@@ -0,0 +1,42 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ClawVault Graph Dashboard</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ </head>
9
+ <body>
10
+ <header class="toolbar">
11
+ <div class="toolbar-title">
12
+ <h1>ClawVault Graph</h1>
13
+ <p id="stats">Loading graph...</p>
14
+ </div>
15
+ <div class="toolbar-controls">
16
+ <input id="search" type="search" placeholder="Search notes, ids, tags...">
17
+ <select id="category-filter">
18
+ <option value="all">All categories</option>
19
+ </select>
20
+ <button id="refresh" type="button">Refresh</button>
21
+ </div>
22
+ </header>
23
+
24
+ <main class="layout">
25
+ <section id="graph" aria-label="Vault graph visualization"></section>
26
+ <aside class="details-panel">
27
+ <h2>Node Details</h2>
28
+ <div id="node-details">
29
+ <p>Select a node to inspect details and connections.</p>
30
+ </div>
31
+ </aside>
32
+ </main>
33
+
34
+ <script src="/vendor/force-graph.min.js"></script>
35
+ <script>
36
+ if (!window.ForceGraph) {
37
+ document.write('<script src="https://unpkg.com/force-graph"><\/script>');
38
+ }
39
+ </script>
40
+ <script type="module" src="/graph.js"></script>
41
+ </body>
42
+ </html>
@@ -0,0 +1,154 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
4
+ --bg: #0c1117;
5
+ --panel: #121a24;
6
+ --panel-border: #1e2936;
7
+ --text: #d8e3f0;
8
+ --muted: #8fa1b7;
9
+ --accent: #4ecdc4;
10
+ }
11
+
12
+ * {
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ html,
17
+ body {
18
+ margin: 0;
19
+ width: 100%;
20
+ height: 100%;
21
+ background: var(--bg);
22
+ color: var(--text);
23
+ }
24
+
25
+ body {
26
+ display: flex;
27
+ flex-direction: column;
28
+ }
29
+
30
+ .toolbar {
31
+ border-bottom: 1px solid var(--panel-border);
32
+ background: #0f1520;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ gap: 1rem;
37
+ padding: 0.75rem 1rem;
38
+ }
39
+
40
+ .toolbar h1 {
41
+ margin: 0;
42
+ font-size: 1.1rem;
43
+ }
44
+
45
+ .toolbar p {
46
+ margin: 0.2rem 0 0;
47
+ color: var(--muted);
48
+ font-size: 0.85rem;
49
+ }
50
+
51
+ .toolbar-controls {
52
+ display: flex;
53
+ gap: 0.5rem;
54
+ flex-wrap: wrap;
55
+ }
56
+
57
+ input,
58
+ select,
59
+ button {
60
+ border: 1px solid var(--panel-border);
61
+ background: var(--panel);
62
+ color: var(--text);
63
+ border-radius: 8px;
64
+ padding: 0.5rem 0.65rem;
65
+ font-size: 0.9rem;
66
+ }
67
+
68
+ input {
69
+ min-width: 220px;
70
+ }
71
+
72
+ button {
73
+ cursor: pointer;
74
+ }
75
+
76
+ button:hover {
77
+ border-color: var(--accent);
78
+ }
79
+
80
+ .layout {
81
+ display: grid;
82
+ grid-template-columns: 1fr 300px;
83
+ min-height: 0;
84
+ flex: 1;
85
+ }
86
+
87
+ #graph {
88
+ min-height: 0;
89
+ }
90
+
91
+ .details-panel {
92
+ border-left: 1px solid var(--panel-border);
93
+ padding: 1rem;
94
+ background: #0f1520;
95
+ overflow-y: auto;
96
+ }
97
+
98
+ .details-panel h2 {
99
+ margin-top: 0;
100
+ font-size: 1rem;
101
+ }
102
+
103
+ .meta-label {
104
+ color: var(--muted);
105
+ font-size: 0.82rem;
106
+ margin-bottom: 0.15rem;
107
+ }
108
+
109
+ .meta-value {
110
+ margin: 0 0 0.8rem;
111
+ font-size: 0.92rem;
112
+ word-break: break-word;
113
+ }
114
+
115
+ .connection-list {
116
+ list-style: none;
117
+ margin: 0;
118
+ padding: 0;
119
+ }
120
+
121
+ .connection-list li {
122
+ border-bottom: 1px solid var(--panel-border);
123
+ padding: 0.35rem 0;
124
+ font-size: 0.9rem;
125
+ }
126
+
127
+ .connection-list li:last-child {
128
+ border-bottom: none;
129
+ }
130
+
131
+ .connection-link {
132
+ color: #9bc3ff;
133
+ text-decoration: none;
134
+ }
135
+
136
+ .connection-link:hover {
137
+ text-decoration: underline;
138
+ }
139
+
140
+ @media (max-width: 980px) {
141
+ .layout {
142
+ grid-template-columns: 1fr;
143
+ grid-template-rows: 1fr auto;
144
+ }
145
+
146
+ #graph {
147
+ height: 65vh;
148
+ }
149
+
150
+ .details-panel {
151
+ border-left: none;
152
+ border-top: 1px solid var(--panel-border);
153
+ }
154
+ }
@@ -0,0 +1,184 @@
1
+ import express from 'express';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { buildVaultGraph } from './lib/vault-parser.js';
7
+
8
+ const DEFAULT_PORT = 3377;
9
+ const HOST = '0.0.0.0';
10
+
11
+ export async function startDashboard(options = {}) {
12
+ const port = normalizePort(options.port ?? DEFAULT_PORT);
13
+ const vaultPath = resolveVaultPath(options.vaultPath);
14
+ await assertVaultPath(vaultPath);
15
+
16
+ const app = express();
17
+ const serverDir = path.dirname(fileURLToPath(import.meta.url));
18
+ const projectDir = path.resolve(serverDir, '..');
19
+ const publicDir = path.join(serverDir, 'public');
20
+ const forceGraphDistDir = path.join(projectDir, 'node_modules', 'force-graph', 'dist');
21
+
22
+ const graphCache = createGraphCache(vaultPath);
23
+
24
+ app.get('/api/graph', async (req, res) => {
25
+ try {
26
+ const shouldRefresh = req.query.refresh === '1';
27
+ const graph = await graphCache.get({ forceRefresh: shouldRefresh });
28
+ res.json(graph);
29
+ } catch (error) {
30
+ res.status(500).json({
31
+ error: 'Failed to build graph',
32
+ detail: error instanceof Error ? error.message : String(error)
33
+ });
34
+ }
35
+ });
36
+
37
+ app.get('/api/health', (_req, res) => {
38
+ res.json({
39
+ ok: true,
40
+ vaultPath
41
+ });
42
+ });
43
+
44
+ app.use('/vendor', express.static(forceGraphDistDir));
45
+ app.use(express.static(publicDir, { extensions: ['html'] }));
46
+
47
+ const server = await new Promise((resolve, reject) => {
48
+ const runningServer = app
49
+ .listen(port, HOST, () => resolve(runningServer))
50
+ .on('error', reject);
51
+ });
52
+
53
+ logStartup({
54
+ port,
55
+ vaultPath
56
+ });
57
+
58
+ const shutdown = () => {
59
+ server.close(() => {
60
+ process.exit(0);
61
+ });
62
+ };
63
+
64
+ process.on('SIGINT', shutdown);
65
+ process.on('SIGTERM', shutdown);
66
+
67
+ return server;
68
+ }
69
+
70
+ function createGraphCache(vaultPath) {
71
+ const ttlMs = 2_000;
72
+ let cache = null;
73
+ let fetchedAt = 0;
74
+ let inFlight = null;
75
+
76
+ return {
77
+ async get({ forceRefresh = false } = {}) {
78
+ const ageMs = Date.now() - fetchedAt;
79
+ if (!forceRefresh && cache && ageMs < ttlMs) {
80
+ return cache;
81
+ }
82
+
83
+ if (inFlight) {
84
+ return inFlight;
85
+ }
86
+
87
+ inFlight = buildVaultGraph(vaultPath)
88
+ .then((graph) => {
89
+ cache = graph;
90
+ fetchedAt = Date.now();
91
+ return graph;
92
+ })
93
+ .finally(() => {
94
+ inFlight = null;
95
+ });
96
+
97
+ return inFlight;
98
+ }
99
+ };
100
+ }
101
+
102
+ function parseArgs(argv) {
103
+ const options = {
104
+ port: DEFAULT_PORT,
105
+ vaultPath: undefined
106
+ };
107
+
108
+ for (let i = 0; i < argv.length; i += 1) {
109
+ const arg = argv[i];
110
+ if (arg === '--port' || arg === '-p') {
111
+ options.port = argv[i + 1];
112
+ i += 1;
113
+ continue;
114
+ }
115
+ if (arg === '--vault' || arg === '-v') {
116
+ options.vaultPath = argv[i + 1];
117
+ i += 1;
118
+ }
119
+ }
120
+
121
+ return options;
122
+ }
123
+
124
+ function normalizePort(value) {
125
+ const parsed = Number.parseInt(String(value), 10);
126
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
127
+ throw new Error(`Invalid port: ${value}`);
128
+ }
129
+ return parsed;
130
+ }
131
+
132
+ function resolveVaultPath(input) {
133
+ const candidate = input || process.env.CLAWVAULT_PATH || process.cwd();
134
+ return path.resolve(candidate);
135
+ }
136
+
137
+ async function assertVaultPath(vaultPath) {
138
+ let stat;
139
+ try {
140
+ stat = await fs.stat(vaultPath);
141
+ } catch (error) {
142
+ throw new Error(`Vault path not found: ${vaultPath}`);
143
+ }
144
+
145
+ if (!stat.isDirectory()) {
146
+ throw new Error(`Vault path is not a directory: ${vaultPath}`);
147
+ }
148
+ }
149
+
150
+ function logStartup({ port, vaultPath }) {
151
+ const interfaces = os.networkInterfaces();
152
+ const networkUrls = [];
153
+
154
+ for (const addresses of Object.values(interfaces)) {
155
+ for (const address of addresses ?? []) {
156
+ if (address.family !== 'IPv4' || address.internal) {
157
+ continue;
158
+ }
159
+ networkUrls.push(`http://${address.address}:${port}`);
160
+ }
161
+ }
162
+
163
+ console.log('\nClawVault Dashboard');
164
+ console.log(`Vault: ${vaultPath}`);
165
+ console.log(`Local: http://localhost:${port}`);
166
+ for (const url of networkUrls) {
167
+ console.log(`Network: ${url}`);
168
+ }
169
+ console.log('\nPress Ctrl+C to stop.\n');
170
+ }
171
+
172
+ const currentFile = fileURLToPath(import.meta.url);
173
+ const executedFile = process.argv[1] ? path.resolve(process.argv[1]) : '';
174
+
175
+ if (currentFile === executedFile) {
176
+ startDashboard(parseArgs(process.argv.slice(2))).catch((error) => {
177
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') {
178
+ console.error('Port already in use.');
179
+ } else {
180
+ console.error(error instanceof Error ? error.message : String(error));
181
+ }
182
+ process.exit(1);
183
+ });
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawvault",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "🐘 An elephant never forgets. Structured memory for OpenClaw agents. Context death resilience, Obsidian-compatible markdown, local semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "dist",
21
21
  "bin",
22
+ "dashboard",
22
23
  "templates",
23
24
  "hooks"
24
25
  ],
@@ -61,13 +62,14 @@
61
62
  "node": ">=18"
62
63
  },
63
64
  "dependencies": {
64
- "commander": "^12.0.0",
65
65
  "chalk": "^5.3.0",
66
+ "commander": "^12.0.0",
67
+ "express": "^5.2.1",
68
+ "force-graph": "^1.51.1",
66
69
  "glob": "^10.3.10",
67
70
  "gray-matter": "^4.0.3",
68
71
  "natural": "^6.10.4"
69
72
  },
70
- "optionalDependencies": {},
71
73
  "peerDependencies": {
72
74
  "qmd": "*"
73
75
  },