context-mcp-server 1.0.6 → 1.0.8

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/src/db.js CHANGED
@@ -1,798 +1,946 @@
1
- /**
2
- * db.js — optimized JSON store for context-mcp
3
- *
4
- * Performance optimizations:
5
- * 1. In-memory cache — disk is read once, all ops hit RAM
6
- * 2. Debounced writes — batches rapid saves into one disk write
7
- * 3. Compact mode — returns previews instead of full content (saves tokens)
8
- * 4. Content size cap — prevents bloated entries
9
- * 5. Flush-on-exit guarantees data is written before process dies
10
- */
11
-
12
- import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, closeSync, unlinkSync, renameSync, chmodSync } from 'node:fs';
13
- import { homedir, platform } from 'node:os';
14
- import { join } from 'node:path';
15
- import { randomUUID } from 'node:crypto';
16
-
17
- const DATA_DIR = process.env.CONTEXT_MCP_DIR || join(homedir(), '.context-mcp');
18
- const CONTEXTS_PATH = join(DATA_DIR, 'contexts.json');
19
- const DISCUSSIONS_PATH = join(DATA_DIR, 'discussions.json');
20
- const GRAPHS_PATH = join(DATA_DIR, 'graphs.json');
21
- const PROJECTS_PATH = join(DATA_DIR, 'projects.json');
22
-
23
-
24
- const MAX_CONTENT_LENGTH = 5000;
25
- const PREVIEW_LENGTH = 200;
26
-
27
- // Normalize file paths for cross-platform comparison (Windows case + slash variants)
28
- function normPath(p) {
29
- return p ? p.toLowerCase().replace(/\\/g, '/').replace(/\/$/, '') : '';
30
- }
31
- const WRITE_DEBOUNCE_MS = 500;
32
- const LOCK_WAIT_TIMEOUT_MS = 2000;
33
-
34
- const _isWin = platform() === 'win32';
35
-
36
- function _secureFile(p) {
37
- if (_isWin) return;
38
- try { chmodSync(p, 0o600); } catch {}
39
- }
40
-
41
- if (!existsSync(DATA_DIR)) {
42
- mkdirSync(DATA_DIR, { recursive: true });
43
- if (!_isWin) { try { chmodSync(DATA_DIR, 0o700); } catch {} }
44
- }
45
-
46
- // ── In-memory cache ──────────────────────────────────────────────────────────
47
-
48
- let _cache = null;
49
- let _dirty = false;
50
- let _writeTimer = null;
51
- let _generation = 0;
52
- const _changedContextIds = new Set();
53
- const _deletedContextIds = new Set();
54
- const _changedDiscussionNames = new Set();
55
- const _changedGraphPaths = new Set();
56
- const _changedProjectIds = new Set();
57
-
58
-
59
- function _readCollection(path, key) {
60
- if (!existsSync(path)) return [];
61
- try {
62
- const data = JSON.parse(readFileSync(path, 'utf8'));
63
- return Array.isArray(data[key]) ? data[key] : (Array.isArray(data) ? data : []);
64
- } catch { return []; }
65
- }
66
-
67
- function load() {
68
- if (_cache) return _cache;
69
- _cache = {
70
- contexts: _readCollection(CONTEXTS_PATH, 'contexts'),
71
- discussions: _readCollection(DISCUSSIONS_PATH, 'discussions'),
72
- graphs: _readCollection(GRAPHS_PATH, 'graphs'),
73
- projects: _readCollection(PROJECTS_PATH, 'projects'),
74
- };
75
- return _cache;
76
- }
77
-
78
- function readStoreFromDisk() {
79
- return {
80
- contexts: _readCollection(CONTEXTS_PATH, 'contexts'),
81
- discussions: _readCollection(DISCUSSIONS_PATH, 'discussions'),
82
- graphs: _readCollection(GRAPHS_PATH, 'graphs'),
83
- projects: _readCollection(PROJECTS_PATH, 'projects'),
84
- };
85
- }
86
-
87
- function refreshFromDisk() {
88
- const latest = readStoreFromDisk();
89
- _cache = mergeStore(latest, _cache || { contexts: [], discussions: [], graphs: [], projects: [] });
90
- }
91
-
92
- function normalizeTags(tags) {
93
- if (Array.isArray(tags)) return tags;
94
- if (typeof tags === 'string') return tags.split(',').map(t => t.trim()).filter(Boolean);
95
- return [];
96
- }
97
-
98
- const VALID_SOURCES = new Set(['user', 'ai-summary', 'file', 'web', 'cli', 'auto']);
99
- function normalizeSource(s) {
100
- return VALID_SOURCES.has(s) ? s : 'user';
101
- }
102
-
103
- const VALID_PRIORITIES = new Set(['low', 'normal', 'high', 'critical']);
104
- function normalizePriority(p) {
105
- return VALID_PRIORITIES.has(p) ? p : 'normal';
106
- }
107
-
108
- // backward-compat: old data has relations as string[], new is {id, relType}[]
109
- function normalizeRelations(relations) {
110
- if (!Array.isArray(relations)) return [];
111
- return relations.map(r => {
112
- if (typeof r === 'string') return { id: r, relType: 'relates-to' };
113
- if (r && typeof r.id === 'string') return { id: r.id, relType: r.relType || 'relates-to' };
114
- return null;
115
- }).filter(Boolean);
116
- }
117
-
118
-
119
- function mergeStore(latest, local) {
120
- const contextsById = new Map(
121
- latest.contexts
122
- .filter(c => !_deletedContextIds.has(c.id))
123
- .map(c => [c.id, c])
124
- );
125
- for (const context of local.contexts) {
126
- if (_changedContextIds.has(context.id)) contextsById.set(context.id, context);
127
- }
128
-
129
- const discussionsByName = new Map(latest.discussions.map(d => [d.name, d]));
130
- for (const disc of local.discussions) {
131
- if (_changedDiscussionNames.has(disc.name)) discussionsByName.set(disc.name, disc);
132
- }
133
-
134
- const graphsByPath = new Map((latest.graphs || []).map(g => [normPath(g.path), g]));
135
- for (const graph of (local.graphs || [])) {
136
- if (_changedGraphPaths.has(graph.path)) graphsByPath.set(normPath(graph.path), graph);
137
- }
138
-
139
- const projectsById = new Map((latest.projects || []).map(p => [p.id, p]));
140
- for (const proj of (local.projects || [])) {
141
- if (_changedProjectIds.has(proj.id)) projectsById.set(proj.id, proj);
142
- }
143
-
144
- return {
145
- contexts: [...contextsById.values()],
146
- discussions: [...discussionsByName.values()],
147
- graphs: [...graphsByPath.values()],
148
- projects: [...projectsById.values()],
149
- };
150
- }
151
-
152
- function markDirty() {
153
- _dirty = true;
154
- _generation++;
155
- // Debounce: schedule a write after WRITE_DEBOUNCE_MS of no further mutations
156
- if (_writeTimer) clearTimeout(_writeTimer);
157
- _writeTimer = setTimeout(flushToDisk, WRITE_DEBOUNCE_MS);
158
- }
159
-
160
- function _flushCollection(filePath, key, data) {
161
- const lockPath = `${filePath}.lock`;
162
- const tmpPath = `${filePath}.tmp`;
163
- let lockFd;
164
- let renamed = false;
165
- try {
166
- const started = Date.now();
167
- for (;;) {
168
- try { lockFd = openSync(lockPath, 'wx'); break; }
169
- catch (err) {
170
- if (err && err.code !== 'EEXIST') throw err;
171
- if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS)
172
- throw new Error(`Timed out waiting for lock: ${lockPath}`);
173
- const t = Date.now(); while (Date.now() - t < 10) {}
174
- }
175
- }
176
- writeFileSync(tmpPath, JSON.stringify({ [key]: data }, null, 2), 'utf8');
177
- _secureFile(tmpPath);
178
- renameSync(tmpPath, filePath);
179
- renamed = true;
180
- } finally {
181
- if (lockFd !== undefined) { closeSync(lockFd); try { unlinkSync(lockPath); } catch {} }
182
- try { if (!renamed && existsSync(tmpPath)) unlinkSync(tmpPath); } catch {}
183
- }
184
- }
185
-
186
- function flushToDisk() {
187
- if (!_dirty || !_cache) return;
188
- _writeTimer = null;
189
-
190
- const latest = readStoreFromDisk();
191
- _cache = mergeStore(latest, _cache);
192
-
193
- if (_changedContextIds.size > 0 || _deletedContextIds.size > 0) {
194
- _flushCollection(CONTEXTS_PATH, 'contexts', _cache.contexts);
195
- _changedContextIds.clear();
196
- _deletedContextIds.clear();
197
- }
198
- if (_changedDiscussionNames.size > 0) {
199
- _flushCollection(DISCUSSIONS_PATH, 'discussions', _cache.discussions);
200
- _changedDiscussionNames.clear();
201
- }
202
- if (_changedGraphPaths.size > 0) {
203
- _flushCollection(GRAPHS_PATH, 'graphs', _cache.graphs);
204
- _changedGraphPaths.clear();
205
- }
206
- if (_changedProjectIds.size > 0) {
207
- _flushCollection(PROJECTS_PATH, 'projects', _cache.projects);
208
- _changedProjectIds.clear();
209
- }
210
-
211
- _dirty = false;
212
- }
213
-
214
- // Flush on process exit to guarantee no data loss
215
- process.on('exit', flushToDisk);
216
- process.on('SIGINT', () => { flushToDisk(); process.exit(); });
217
- process.on('SIGTERM', () => { flushToDisk(); process.exit(); });
218
-
219
- // ── Helpers ──────────────────────────────────────────────────────────────────
220
-
221
- function truncate(text, max) {
222
- if (!text || text.length <= max) return text;
223
- return text.slice(0, max - 3) + '...';
224
- }
225
-
226
- function compactEntry(e) {
227
- const compact = {
228
- id: e.id,
229
- project: e.project,
230
- sessionId: e.sessionId,
231
- nodeType: e.nodeType || 'entry',
232
- title: e.title || '',
233
- type: e.type || 'note',
234
- status: e.status || 'active',
235
- version: e.version || 1,
236
- tags: e.tags,
237
- source: e.source,
238
- createdAt: e.createdAt,
239
- updatedAt: e.updatedAt || null,
240
- preview: truncate(e.content, PREVIEW_LENGTH),
241
- };
242
- if (e.files && e.files.length) compact.files = e.files;
243
- if (e.codeRefs && e.codeRefs.length) compact.codeRefs = e.codeRefs;
244
- if (e.expiresAt) compact.expiresAt = e.expiresAt;
245
- return compact;
246
- }
247
-
248
- // ── Context entries ──────────────────────────────────────────────────────────
249
-
250
- export function saveContext({ project, content, tags = [], source = 'user', title = '',
251
- type = 'note', status = 'active', files = [], codeRefs = [],
252
- relations = [], sessionId = null, parentId = null, expiresAt = null }) {
253
- refreshFromDisk();
254
- const store = load();
255
- const projectName = project || 'global';
256
- ensureProject(projectName);
257
- const now = new Date().toISOString();
258
- const entry = {
259
- id: randomUUID(),
260
- project: projectName,
261
- sessionId: sessionId || null,
262
- parentId: parentId || sessionId || `project:${projectName}`,
263
- nodeType: 'entry',
264
- version: 1,
265
- title: truncate(title, 60),
266
- content: truncate(content, MAX_CONTENT_LENGTH),
267
- type,
268
- status,
269
- tags: normalizeTags(tags),
270
- source: normalizeSource(source),
271
- files: Array.isArray(files) ? files : [],
272
- codeRefs: Array.isArray(codeRefs) ? codeRefs : [],
273
- relations: normalizeRelations(relations),
274
- relatedBy: [], // back-references written by addRelation()
275
- discussionId: null, // set by linkContextToDiscussion()
276
- createdAt: now,
277
- updatedAt: null,
278
- expiresAt: expiresAt || null,
279
- };
280
- store.contexts.push(entry);
281
- _changedContextIds.add(entry.id);
282
- markDirty();
283
- return entry;
284
- }
285
-
286
- export function updateContext({ id, content, title, tags, type, status, files, codeRefs, relations, sessionId, parentId, expiresAt }) {
287
- refreshFromDisk();
288
- const store = load();
289
- const entry = store.contexts.find(c => c.id === id);
290
- if (!entry) return null;
291
- if (content !== undefined) entry.content = truncate(content, MAX_CONTENT_LENGTH);
292
- if (title !== undefined) entry.title = truncate(title, 60);
293
- if (tags !== undefined) entry.tags = normalizeTags(tags);
294
- if (type !== undefined) entry.type = type;
295
- if (status !== undefined) entry.status = status;
296
- if (files !== undefined) entry.files = Array.isArray(files) ? files : [];
297
- if (codeRefs !== undefined) entry.codeRefs = Array.isArray(codeRefs) ? codeRefs : [];
298
- if (relations !== undefined) entry.relations = normalizeRelations(relations);
299
- if (expiresAt !== undefined) entry.expiresAt = expiresAt || null;
300
- if (sessionId !== undefined) entry.sessionId = sessionId || null;
301
- if (parentId !== undefined) entry.parentId = parentId || entry.sessionId || `project:${entry.project || 'global'}`;
302
- entry.version = (entry.version || 1) + 1;
303
- entry.updatedAt = new Date().toISOString();
304
- _changedContextIds.add(entry.id);
305
- _deletedContextIds.delete(entry.id);
306
- markDirty();
307
- return entry;
308
- }
309
-
310
- /**
311
- * Get recent context entries.
312
- * @param {Object} opts
313
- * @param {boolean} opts.compact - If true, returns previews instead of full content (saves tokens)
314
- */
315
- export function getContext({ project, tags, limit = 20, compact = false } = {}) {
316
- refreshFromDisk();
317
- const store = load();
318
- let results = store.contexts;
319
- if (project) results = results.filter(c => c.project === project || c.project === 'global');
320
- if (tags && tags.length) {
321
- const tagList = Array.isArray(tags) ? tags : tags.split(',').map(t => t.trim());
322
- results = results.filter(c => tagList.some(t => Array.isArray(c.tags) && c.tags.includes(t)));
323
- }
324
- const sliced = results.slice(-limit).reverse();
325
- return compact ? sliced.map(compactEntry) : sliced;
326
- }
327
-
328
- export function getContextSince(since, project) {
329
- refreshFromDisk();
330
- const store = load();
331
- let results = store.contexts;
332
- if (project) results = results.filter(c => c.project === project || c.project === 'global');
333
- return results.filter(c => c.createdAt >= since);
334
- }
335
-
336
- export function searchContext({ query, project, limit = 10, compact = false }) {
337
- refreshFromDisk();
338
- const store = load();
339
- const terms = query.toLowerCase().split(/\s+/);
340
- let results = store.contexts;
341
- if (project) results = results.filter(c => c.project === project || c.project === 'global');
342
- const scored = results.map(c => {
343
- const haystack = `${c.title || ''} ${c.content || ''} ${(Array.isArray(c.tags) ? c.tags : []).join(' ')}`.toLowerCase();
344
- const score = terms.reduce((s, t) => s + (haystack.split(t).length - 1), 0);
345
- return { ...c, score };
346
- }).filter(c => c.score > 0).sort((a, b) => b.score - a.score);
347
- const sliced = scored.slice(0, limit).map(({ score, ...c }) => c);
348
- return compact ? sliced.map(compactEntry) : sliced;
349
- }
350
-
351
- export function deleteContext({ id }) {
352
- refreshFromDisk();
353
- const store = load();
354
- const before = store.contexts.length;
355
- const removed = store.contexts.filter(c => c.id === id);
356
- store.contexts = store.contexts.filter(c => c.id !== id);
357
- if (store.contexts.length < before) {
358
- for (const entry of removed) {
359
- _deletedContextIds.add(entry.id);
360
- _changedContextIds.delete(entry.id);
361
- }
362
- markDirty();
363
- }
364
- return { deleted: before - store.contexts.length };
365
- }
366
-
367
- export function deleteProject(nameOrId) {
368
- refreshFromDisk();
369
- const store = load();
370
- // Resolve name from ID if needed
371
- const byId = store.projects.find(p => p.id === nameOrId);
372
- const projectName = byId ? byId.name : nameOrId;
373
-
374
- const beforeCtx = store.contexts.length;
375
- const beforeDisc = store.discussions.length;
376
- const removed = store.contexts.filter(c => c.project === projectName);
377
- store.contexts = store.contexts.filter(c => c.project !== projectName);
378
- store.discussions = store.discussions.filter(d => d.project !== projectName);
379
- // Remove from project registry
380
- const beforeProj = store.projects.length;
381
- store.projects = store.projects.filter(p => p.name !== projectName);
382
- for (const entry of removed) {
383
- _deletedContextIds.add(entry.id);
384
- _changedContextIds.delete(entry.id);
385
- }
386
- if (store.contexts.length < beforeCtx || store.discussions.length < beforeDisc || store.projects.length < beforeProj) {
387
- if (store.projects.length < beforeProj && byId) _changedProjectIds.add(byId.id);
388
- markDirty();
389
- }
390
- return {
391
- deletedEntries: beforeCtx - store.contexts.length,
392
- deletedDiscussions: beforeDisc - store.discussions.length,
393
- };
394
- }
395
-
396
- export function countContext(project) {
397
- refreshFromDisk();
398
- const store = load();
399
- if (!project) return store.contexts.length;
400
- return store.contexts.filter(c => c.project === project || c.project === 'global').length;
401
- }
402
-
403
- // Ensure a project entity exists for this name; returns the project record.
404
- // If rootPath is provided and the project has no rootPath yet, it is stored.
405
- export function ensureProject(name, rootPath) {
406
- if (!name || name === 'global') return null;
407
- const store = load();
408
- let proj = store.projects.find(p => p.name === name);
409
- if (!proj) {
410
- proj = { id: randomUUID(), name, createdAt: new Date().toISOString() };
411
- store.projects.push(proj);
412
- _changedProjectIds.add(proj.id);
413
- markDirty();
414
- }
415
- if (rootPath && !proj.rootPath) {
416
- proj.rootPath = rootPath;
417
- _changedProjectIds.add(proj.id);
418
- markDirty();
419
- }
420
- return proj;
421
- }
422
-
423
- // Returns the stored rootPath for a project, or null if not set.
424
- export function getProjectRoot(name) {
425
- if (!name || name === 'global') return null;
426
- const store = load();
427
- return store.projects.find(p => p.name === name)?.rootPath || null;
428
- }
429
-
430
- export function listProjects() {
431
- refreshFromDisk();
432
- const store = load();
433
- // Count entries per project name
434
- const counts = {};
435
- for (const c of store.contexts) {
436
- counts[c.project] = (counts[c.project] || 0) + 1;
437
- }
438
- // Merge with project registry (provides stable IDs); backfill any missing
439
- const registered = new Map(store.projects.map(p => [p.name, p]));
440
- for (const name of Object.keys(counts)) {
441
- if (!registered.has(name)) ensureProject(name); // auto-register legacy projects
442
- }
443
- // Re-read after potential backfill
444
- const reg = new Map(store.projects.map(p => [p.name, p]));
445
- return Object.entries(counts).map(([name, count]) => ({
446
- id: reg.get(name)?.id || null,
447
- name,
448
- count,
449
- createdAt: reg.get(name)?.createdAt || null,
450
- })); // only show projects that have entries
451
- }
452
-
453
- // ── Auto-dedup ───────────────────────────────────────────────────────────────
454
-
455
- export function findDuplicate(content, project) {
456
- refreshFromDisk();
457
- const existing = getContext({ project, limit: 50 });
458
- if (!existing.length) return null;
459
-
460
- const newWords = new Set(content.toLowerCase().split(/\s+/).filter(w => w.length > 3));
461
- if (!newWords.size) return null;
462
-
463
- for (const entry of existing) {
464
- const oldWords = new Set((entry.content || '').toLowerCase().split(/\s+/).filter(w => w.length > 3));
465
- if (!oldWords.size) continue;
466
- const overlap = [...newWords].filter(w => oldWords.has(w)).length;
467
- const similarity = overlap / Math.max(newWords.size, oldWords.size);
468
- if (similarity > 0.85) return entry;
469
- }
470
- return null;
471
- }
472
-
473
- // ── Discussions ───────────────────────────────────────────────────────────────
474
-
475
- const VALID_DISCUSSION_TYPES = new Set(['plan','research','idea','design','implementation','review','thread']);
476
- const VALID_DISCUSSION_STATUSES = new Set(['active','done']);
477
-
478
- export function saveDiscussion({ name, title, description, content, project, tags,
479
- type, status, steps,
480
- linkedContextIds, parentId, sessionId }) {
481
- refreshFromDisk();
482
- const store = load();
483
- const existing = store.discussions.findIndex(d => d.name === name);
484
- const now = new Date().toISOString();
485
- const prev = existing >= 0 ? store.discussions[existing] : null;
486
-
487
- // When updating an existing discussion, only overwrite fields that were
488
- // explicitly provided by the caller — preserve everything else from prev.
489
- const disc = {
490
- id: prev?.id || randomUUID(),
491
- name,
492
- project: project !== undefined ? (project || 'global') : (prev?.project ?? 'global'),
493
- sessionId: sessionId !== undefined ? (sessionId || null) : (prev?.sessionId ?? null),
494
- parentId: parentId !== undefined ? (parentId || null) : (prev?.parentId ?? null),
495
- title: title !== undefined ? truncate(title || name, 80) : (prev?.title ?? name),
496
- description: description !== undefined ? (description || '') : (prev?.description ?? ''),
497
- content: content !== undefined ? truncate(content || '', MAX_CONTENT_LENGTH) : (prev?.content ?? ''),
498
- type: type !== undefined ? (VALID_DISCUSSION_TYPES.has(type) ? type : 'plan'): (prev?.type ?? 'plan'),
499
- status: status !== undefined ? (VALID_DISCUSSION_STATUSES.has(status) ? status : 'active') : (prev?.status ?? 'active'),
500
- tags: tags !== undefined ? normalizeTags(tags) : (prev?.tags ?? []),
501
- // For steps: if caller passed steps[], re-normalize them but preserve any
502
- // per-step fields (linkedContextIds, completedAt) that already exist on prev.
503
- steps: steps !== undefined ? mergeSteps(prev?.steps ?? [], steps) : (prev?.steps ?? []),
504
- linkedContextIds: linkedContextIds !== undefined ? (Array.isArray(linkedContextIds) ? linkedContextIds : []) : (prev?.linkedContextIds ?? []),
505
- createdAt: prev?.createdAt || now,
506
- updatedAt: now,
507
- };
508
- if (existing >= 0) store.discussions[existing] = disc;
509
- else store.discussions.push(disc);
510
- _changedDiscussionNames.add(disc.name);
511
- markDirty();
512
- return disc;
513
- }
514
-
515
- // Merge incoming steps[] with the existing steps[], preserving runtime state
516
- // (linkedContextIds, completedAt) for steps that already exist by id or order.
517
- function mergeSteps(prevSteps, incomingSteps) {
518
- if (!Array.isArray(incomingSteps) || incomingSteps.length === 0) return prevSteps;
519
- return incomingSteps.map((s, i) => {
520
- const prev = prevSteps.find(p => p.id && p.id === s.id) || prevSteps[i];
521
- return {
522
- id: s.id || prev?.id || randomUUID(),
523
- title: s.title ?? prev?.title ?? '',
524
- description: s.description ?? prev?.description ?? '',
525
- status: s.status ?? prev?.status ?? 'pending',
526
- order: s.order ?? prev?.order ?? i,
527
- linkedContextIds: s.linkedContextIds ?? prev?.linkedContextIds ?? [],
528
- completedAt: s.completedAt ?? prev?.completedAt ?? null,
529
- };
530
- });
531
- }
532
-
533
- export function updateDiscussion({ id, name, title, description, content, status, type, tags, steps, linkedContextIds, parentId, sessionId }) {
534
- refreshFromDisk();
535
- const store = load();
536
- const disc = id
537
- ? store.discussions.find(d => d.id === id)
538
- : store.discussions.find(d => d.name === name);
539
- if (!disc) return null;
540
- if (title !== undefined) disc.title = truncate(title || disc.name, 80);
541
- if (description !== undefined) disc.description = description || '';
542
- if (content !== undefined) disc.content = truncate(content || '', MAX_CONTENT_LENGTH);
543
- if (type !== undefined) disc.type = VALID_DISCUSSION_TYPES.has(type) ? type : disc.type;
544
- if (status !== undefined) disc.status = VALID_DISCUSSION_STATUSES.has(status) ? status : disc.status;
545
- if (tags !== undefined) disc.tags = normalizeTags(tags);
546
- if (steps !== undefined) disc.steps = mergeSteps(disc.steps ?? [], steps);
547
- if (linkedContextIds !== undefined) disc.linkedContextIds = Array.isArray(linkedContextIds) ? linkedContextIds : disc.linkedContextIds;
548
- if (parentId !== undefined) disc.parentId = parentId || null;
549
- if (sessionId !== undefined) disc.sessionId = sessionId || null;
550
- disc.updatedAt = new Date().toISOString();
551
- _changedDiscussionNames.add(disc.name);
552
- markDirty();
553
- return disc;
554
- }
555
- export function getDiscussion({ project, name, id } = {}) {
556
- refreshFromDisk();
557
- const store = load();
558
- let list = store.discussions;
559
- if (project) list = list.filter(d => d.project === project || d.project === 'global');
560
- if (id) return list.find(d => d.id === id) || null;
561
- if (name) return list.find(d => d.name === name) || null;
562
- return null;
563
- }
564
-
565
- export function listDiscussions({ project, status, type } = {}) {
566
- refreshFromDisk();
567
- const store = load();
568
- let list = store.discussions;
569
- if (project) list = list.filter(d => d.project === project || d.project === 'global');
570
- if (status) list = list.filter(d => d.status === status);
571
- if (type) list = list.filter(d => d.type === type);
572
- // Return without full content — just header + stepsSummary
573
- return list.map(({ content: _, steps, ...rest }) => ({
574
- ...rest,
575
- stepsSummary: {
576
- total: (steps || []).length,
577
- done: (steps || []).filter(s => s.status === 'done').length,
578
- inProgress: (steps || []).filter(s => s.status === 'in-progress').length,
579
- },
580
- }));
581
- }
582
-
583
- export function linkContextToDiscussion({ discussionId, discussionName, contextId }) {
584
- refreshFromDisk();
585
- const store = load();
586
- const disc = discussionId
587
- ? store.discussions.find(d => d.id === discussionId)
588
- : store.discussions.find(d => d.name === discussionName);
589
- if (!disc) return null;
590
- if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
591
- let changed = false;
592
- if (!disc.linkedContextIds.includes(contextId)) {
593
- disc.linkedContextIds.push(contextId);
594
- disc.updatedAt = new Date().toISOString();
595
- _changedDiscussionNames.add(disc.name);
596
- changed = true;
597
- }
598
- // write discussionId back onto the context entry
599
- const entry = store.contexts.find(c => c.id === contextId);
600
- if (entry && entry.discussionId !== disc.id) {
601
- entry.discussionId = disc.id;
602
- entry.updatedAt = new Date().toISOString();
603
- _changedContextIds.add(entry.id);
604
- changed = true;
605
- }
606
- if (changed) markDirty();
607
- return { discussionId: disc.id, contextId };
608
- }
609
-
610
- export function addRelation({ fromId, toId, relType = 'relates-to' }) {
611
- refreshFromDisk();
612
- const store = load();
613
- const from = store.contexts.find(c => c.id === fromId);
614
- const to = store.contexts.find(c => c.id === toId);
615
- if (!from || !to) return null;
616
- if (!Array.isArray(from.relations)) from.relations = [];
617
- if (!Array.isArray(to.relatedBy)) to.relatedBy = [];
618
- if (!from.relations.find(r => r.id === toId)) {
619
- from.relations.push({ id: toId, relType });
620
- from.updatedAt = new Date().toISOString();
621
- _changedContextIds.add(from.id);
622
- }
623
- if (!to.relatedBy.find(r => r.id === fromId)) {
624
- to.relatedBy.push({ id: fromId, relType });
625
- to.updatedAt = new Date().toISOString();
626
- _changedContextIds.add(to.id);
627
- }
628
- markDirty();
629
- return { fromId, toId, relType };
630
- }
631
-
632
- export function getContextByDiscussion(discussionId) {
633
- refreshFromDisk();
634
- const store = load();
635
- return store.contexts.filter(c => c.discussionId === discussionId);
636
- }
637
-
638
- export function clearDiscussionLink(contextId) {
639
- refreshFromDisk();
640
- const store = load();
641
- const entry = store.contexts.find(c => c.id === contextId);
642
- if (!entry) return null;
643
- entry.discussionId = null;
644
- entry.updatedAt = new Date().toISOString();
645
- _changedContextIds.add(entry.id);
646
- markDirty();
647
- return entry;
648
- }
649
-
650
- export function deleteDiscussion({ name, id }) {
651
- refreshFromDisk();
652
- const store = load();
653
- const before = store.discussions.length;
654
- // Find the discussion first so we can clean up _changedDiscussionNames regardless of
655
- // whether it was matched by name or id.
656
- const toDelete = store.discussions.find(d => (id && d.id === id) || (name && d.name === name));
657
- store.discussions = store.discussions.filter(d => {
658
- if (id) return d.id !== id;
659
- if (name) return d.name !== name;
660
- return true;
661
- });
662
- if (store.discussions.length < before) {
663
- if (toDelete) _changedDiscussionNames.delete(toDelete.name);
664
- markDirty();
665
- }
666
- return { deleted: before - store.discussions.length };
667
- }
668
-
669
- export function updateDiscussionStep({ discussionName, discussionId, stepId, status, linkedContextId }) {
670
- refreshFromDisk();
671
- const store = load();
672
- const disc = discussionId
673
- ? store.discussions.find(d => d.id === discussionId)
674
- : store.discussions.find(d => d.name === discussionName);
675
- if (!disc) return null;
676
- const step = (disc.steps || []).find(s => s.id === stepId);
677
- if (!step) return null;
678
- if (status) step.status = status;
679
- if (status === 'done') step.completedAt = new Date().toISOString();
680
- if (linkedContextId) {
681
- if (!Array.isArray(step.linkedContextIds)) step.linkedContextIds = [];
682
- if (!step.linkedContextIds.includes(linkedContextId)) step.linkedContextIds.push(linkedContextId);
683
- if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
684
- if (!disc.linkedContextIds.includes(linkedContextId)) disc.linkedContextIds.push(linkedContextId);
685
- }
686
- const allDone = disc.steps.every(s => s.status === 'done' || s.status === 'skipped');
687
- if (allDone && disc.status !== 'done') disc.status = 'done';
688
- disc.updatedAt = new Date().toISOString();
689
- _changedDiscussionNames.add(disc.name);
690
- markDirty();
691
- return { discussion: disc, step };
692
- }
693
-
694
- // ── Auto-operations ───────────────────────────────────────────────────────────
695
-
696
- export function archiveExpired(project) {
697
- refreshFromDisk();
698
- const store = load();
699
- const now = new Date().toISOString();
700
- let count = 0;
701
- for (const entry of store.contexts) {
702
- if (entry.expiresAt && entry.expiresAt < now && entry.status !== 'archived') {
703
- entry.status = 'archived';
704
- entry.updatedAt = now;
705
- _changedContextIds.add(entry.id);
706
- count++;
707
- }
708
- }
709
- if (count > 0) markDirty();
710
- return { archived: count };
711
- }
712
-
713
- // ── Exports ──────────────────────────────────────────────────────────────────
714
-
715
- export function getStorePath() { return DATA_DIR; }
716
- export function getGeneration() { return _generation; }
717
- export function flushStore() { flushToDisk(); }
718
-
719
- // ── Auto-compaction ───────────────────────────────────────────────────────────
720
-
721
- const COMPACTION_THRESHOLD = 20;
722
- const COMPACTION_TARGET = 30;
723
-
724
- export function shouldCompact(project) {
725
- return countContext(project) > COMPACTION_THRESHOLD;
726
- }
727
-
728
- export function compactProject(project, summaryContent) {
729
- refreshFromDisk();
730
- const store = load();
731
- const proj = project || 'global';
732
- const entries = store.contexts
733
- .filter(c => (c.project === proj) && c.type !== 'summary')
734
- .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
735
- if (entries.length < COMPACTION_TARGET) return null;
736
- const toRemove = new Set(entries.slice(0, COMPACTION_TARGET).map(e => e.id));
737
- const removed = store.contexts.filter(c => toRemove.has(c.id));
738
- store.contexts = store.contexts.filter(c => !toRemove.has(c.id));
739
- for (const e of removed) {
740
- _deletedContextIds.add(e.id);
741
- _changedContextIds.delete(e.id);
742
- }
743
- markDirty();
744
- // save the compaction summary as a new entry
745
- const summary = saveContext({
746
- project: proj,
747
- title: `Compacted ${removed.length} entries — ${new Date().toISOString().slice(0, 10)}`,
748
- content: summaryContent,
749
- type: 'summary',
750
- source: 'auto',
751
- tags: ['compaction', 'auto'],
752
- });
753
- return { removedCount: removed.length, summaryId: summary.id };
754
- }
755
-
756
- // ── Graph registry ────────────────────────────────────────────────────────────
757
-
758
- export function saveGraph({ path, nodes, edges, communities, cached, changed, time_ms, summary }) {
759
- refreshFromDisk();
760
- const store = load();
761
- // Deduplicate: collapse any case/slash variants of same path, keep newest
762
- const dupes = store.graphs.filter(g => normPath(g.path) === normPath(path));
763
- if (dupes.length > 1) {
764
- const keep = dupes.reduce((a, b) => (a.builtAt >= b.builtAt ? a : b));
765
- store.graphs = store.graphs.filter(g => normPath(g.path) !== normPath(path));
766
- store.graphs.push(keep);
767
- }
768
- const existing = store.graphs.find(g => normPath(g.path) === normPath(path));
769
- const record = {
770
- path,
771
- nodes: nodes ?? existing?.nodes ?? 0,
772
- edges: edges ?? existing?.edges ?? 0,
773
- communities: communities ?? existing?.communities ?? 0,
774
- cached: cached ?? 0,
775
- changed: changed ?? 0,
776
- time_ms: time_ms ?? 0,
777
- summary: summary || existing?.summary || '',
778
- builtAt: new Date().toISOString(),
779
- };
780
- if (existing) {
781
- Object.assign(existing, record);
782
- } else {
783
- store.graphs.push(record);
784
- }
785
- _changedGraphPaths.add(path);
786
- markDirty();
787
- return record;
788
- }
789
-
790
- export function getGraph(path) {
791
- const store = load();
792
- if (path) return store.graphs.find(g => normPath(g.path) === normPath(path)) || null;
793
- return store.graphs;
794
- }
795
-
796
- export function listGraphs() {
797
- return load().graphs;
798
- }
1
+ /**
2
+ * db.js — per-project directory store for context-mcp
3
+ *
4
+ * Layout:
5
+ * ~/.context-mcp/
6
+ * ├── projects.json ← master index
7
+ * └── projects/
8
+ * └── <slug>/
9
+ * ├── context.json decision, bug, note, code, config, error
10
+ * ├── graph.json ← { build: {...}, entries: [...architecture...] }
11
+ * ├── summary.json ← summary type + archived entries
12
+ * └── discussions.json
13
+ */
14
+
15
+ import {
16
+ readFileSync, writeFileSync, mkdirSync, existsSync,
17
+ openSync, closeSync, unlinkSync, renameSync, chmodSync, rmdirSync,
18
+ } from 'node:fs';
19
+ import { homedir, platform } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { runMigration } from './migrator.js';
23
+
24
+ const DATA_DIR = process.env.CONTEXT_MCP_DIR || join(homedir(), '.context-mcp');
25
+ const PROJECTS_DIR = join(DATA_DIR, 'projects');
26
+ const PROJECTS_PATH = join(DATA_DIR, 'projects.json');
27
+
28
+
29
+ const MAX_CONTENT_LENGTH = 5000;
30
+ const PREVIEW_LENGTH = 200;
31
+ const WRITE_DEBOUNCE_MS = 500;
32
+ const LOCK_WAIT_TIMEOUT_MS = 2000;
33
+
34
+ const _isWin = platform() === 'win32';
35
+
36
+ function normPath(p) {
37
+ return p ? p.toLowerCase().replace(/\\/g, '/').replace(/\/$/, '') : '';
38
+ }
39
+
40
+ function slugify(name) {
41
+ return name.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
42
+ }
43
+
44
+ function projectDataDir(name) { return join(PROJECTS_DIR, slugify(name)); }
45
+ function contextFilePath(name) { return join(projectDataDir(name), 'context.json'); }
46
+ function graphFilePath(name) { return join(projectDataDir(name), 'graph.json'); }
47
+ function summaryFilePath(name) { return join(projectDataDir(name), 'summary.json'); }
48
+ function discussFilePath(name) { return join(projectDataDir(name), 'discussions.json'); }
49
+
50
+ function treeFor(entry) {
51
+ if (entry.type === 'compaction') return 'summary';
52
+ return 'context';
53
+ }
54
+
55
+ function _secureFile(p) {
56
+ if (_isWin) return;
57
+ try { chmodSync(p, 0o600); } catch {}
58
+ }
59
+
60
+ if (!existsSync(DATA_DIR)) {
61
+ mkdirSync(DATA_DIR, { recursive: true });
62
+ if (!_isWin) { try { chmodSync(DATA_DIR, 0o700); } catch {} }
63
+ }
64
+ if (!existsSync(PROJECTS_DIR)) {
65
+ mkdirSync(PROJECTS_DIR, { recursive: true });
66
+ }
67
+
68
+ // ── In-memory cache ──────────────────────────────────────────────────────────
69
+
70
+ let _projectsIndex = null; // array of { id, name, rootPath, createdAt, dataDir }
71
+ let _projectsIndexDirty = false;
72
+ let _projectData = new Map(); // name -> { context: [], graph: { build, entries: [] }, summary: [], discussions: [] }
73
+ let _dirtyProjects = new Set();
74
+ let _dirty = false;
75
+ let _writeTimer = null;
76
+ let _generation = 0;
77
+ let _migrated = false;
78
+
79
+ // ── File I/O helpers ─────────────────────────────────────────────────────────
80
+
81
+ function _flushFile(filePath, content) {
82
+ const lockPath = `${filePath}.lock`;
83
+ const tmpPath = `${filePath}.tmp`;
84
+ let lockFd;
85
+ let renamed = false;
86
+ try {
87
+ const started = Date.now();
88
+ for (;;) {
89
+ try { lockFd = openSync(lockPath, 'wx'); break; }
90
+ catch (err) {
91
+ if (err && err.code !== 'EEXIST') throw err;
92
+ if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS)
93
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
94
+ const t = Date.now(); while (Date.now() - t < 10) {}
95
+ }
96
+ }
97
+ writeFileSync(tmpPath, JSON.stringify(content, null, 2), 'utf8');
98
+ _secureFile(tmpPath);
99
+ renameSync(tmpPath, filePath);
100
+ renamed = true;
101
+ } finally {
102
+ if (lockFd !== undefined) { closeSync(lockFd); try { unlinkSync(lockPath); } catch {} }
103
+ try { if (!renamed && existsSync(tmpPath)) unlinkSync(tmpPath); } catch {}
104
+ }
105
+ }
106
+
107
+ function _readArr(filePath, key) {
108
+ if (!existsSync(filePath)) return [];
109
+ try {
110
+ const d = JSON.parse(readFileSync(filePath, 'utf8'));
111
+ return Array.isArray(d[key]) ? d[key] : (Array.isArray(d) ? d : []);
112
+ } catch { return []; }
113
+ }
114
+
115
+ function _readObj(filePath, defaults) {
116
+ if (!existsSync(filePath)) return { ...defaults };
117
+ try { return { ...defaults, ...JSON.parse(readFileSync(filePath, 'utf8')) }; }
118
+ catch { return { ...defaults }; }
119
+ }
120
+
121
+ // ── Projects index ───────────────────────────────────────────────────────────
122
+
123
+ function loadProjectsIndex() {
124
+ if (_projectsIndex) return _projectsIndex;
125
+ if (!existsSync(PROJECTS_PATH)) { _projectsIndex = []; return _projectsIndex; }
126
+ try {
127
+ const d = JSON.parse(readFileSync(PROJECTS_PATH, 'utf8'));
128
+ _projectsIndex = Array.isArray(d.projects) ? d.projects : [];
129
+ } catch { _projectsIndex = []; }
130
+ return _projectsIndex;
131
+ }
132
+
133
+ // ── Migration ─────────────────────────────────────────────────────────────────
134
+
135
+ function migrate() {
136
+ if (_migrated) return;
137
+ _migrated = true;
138
+ runMigration({
139
+ dataDir: DATA_DIR,
140
+ projectsDir: PROJECTS_DIR,
141
+ projectsPath: PROJECTS_PATH,
142
+ slugify,
143
+ flushFile: _flushFile,
144
+ projectsIndex: loadProjectsIndex(),
145
+ });
146
+ }
147
+
148
+ // ── Per-project data loading ─────────────────────────────────────────────────
149
+
150
+ function loadProjectData(name) {
151
+ if (_projectData.has(name)) return _projectData.get(name);
152
+ const dir = projectDataDir(name);
153
+ mkdirSync(dir, { recursive: true });
154
+ const data = {
155
+ context: _readArr(contextFilePath(name), 'entries'),
156
+ graph: _readObj(graphFilePath(name), { build: null }),
157
+ summary: _readArr(summaryFilePath(name), 'entries'),
158
+ discussions: _readArr(discussFilePath(name), 'discussions'),
159
+ };
160
+ _projectData.set(name, data);
161
+ return data;
162
+ }
163
+
164
+ function getAllEntries(projectName) {
165
+ const data = loadProjectData(projectName);
166
+ return [...data.context, ...data.summary];
167
+ }
168
+
169
+ // Find an entry by ID, optionally scoped to a project.
170
+ function findEntryById(id, projectHint) {
171
+ const search = (data) => {
172
+ for (const arr of [data.context, data.summary]) {
173
+ const e = arr.find(c => c.id === id);
174
+ if (e) return e;
175
+ }
176
+ return null;
177
+ };
178
+ if (projectHint) {
179
+ const e = search(loadProjectData(projectHint));
180
+ if (e) return { entry: e, projectName: projectHint };
181
+ }
182
+ for (const [name, data] of _projectData.entries()) {
183
+ if (name === projectHint) continue;
184
+ const e = search(data);
185
+ if (e) return { entry: e, projectName: name };
186
+ }
187
+ // Load all remaining projects
188
+ const idx = loadProjectsIndex();
189
+ for (const proj of idx) {
190
+ if (_projectData.has(proj.name) || proj.name === projectHint) continue;
191
+ const e = search(loadProjectData(proj.name));
192
+ if (e) return { entry: e, projectName: proj.name };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ // Remove an entry from its array in the project data.
198
+ function removeEntryFromData(data, entry) {
199
+ if (treeFor(entry) === 'summary') {
200
+ data.summary = data.summary.filter(e => e.id !== entry.id);
201
+ } else {
202
+ data.context = data.context.filter(e => e.id !== entry.id);
203
+ }
204
+ }
205
+
206
+ // ── Dirty tracking & flush ───────────────────────────────────────────────────
207
+
208
+ function markDirty() {
209
+ _dirty = true;
210
+ _generation++;
211
+ if (_writeTimer) clearTimeout(_writeTimer);
212
+ _writeTimer = setTimeout(flushToDisk, WRITE_DEBOUNCE_MS);
213
+ }
214
+
215
+ function flushProjectToDisk(name) {
216
+ const data = _projectData.get(name);
217
+ if (!data) return;
218
+ const dir = projectDataDir(name);
219
+ mkdirSync(dir, { recursive: true });
220
+ _flushFile(contextFilePath(name), { entries: data.context });
221
+ _flushFile(graphFilePath(name), data.graph);
222
+ _flushFile(summaryFilePath(name), { entries: data.summary });
223
+ _flushFile(discussFilePath(name), { discussions: data.discussions });
224
+ }
225
+
226
+ function flushToDisk() {
227
+ if (!_dirty) return;
228
+ _writeTimer = null;
229
+
230
+ for (const name of _dirtyProjects) {
231
+ flushProjectToDisk(name);
232
+ }
233
+ _dirtyProjects.clear();
234
+
235
+ if (_projectsIndexDirty && _projectsIndex) {
236
+ _flushFile(PROJECTS_PATH, { projects: _projectsIndex });
237
+ _projectsIndexDirty = false;
238
+ }
239
+
240
+ _dirty = false;
241
+ }
242
+
243
+ process.on('exit', flushToDisk);
244
+ process.on('SIGINT', () => { flushToDisk(); process.exit(); });
245
+ process.on('SIGTERM', () => { flushToDisk(); process.exit(); });
246
+
247
+ // ── Initialise: run migration lazily on first access ─────────────────────────
248
+
249
+ function init() {
250
+ loadProjectsIndex();
251
+ migrate();
252
+ }
253
+
254
+ // ── Helpers ──────────────────────────────────────────────────────────────────
255
+
256
+ function truncate(text, max) {
257
+ if (!text || text.length <= max) return text;
258
+ return text.slice(0, max - 3) + '...';
259
+ }
260
+
261
+ function normalizeTags(tags) {
262
+ if (Array.isArray(tags)) return tags;
263
+ if (typeof tags === 'string') return tags.split(',').map(t => t.trim()).filter(Boolean);
264
+ return [];
265
+ }
266
+
267
+ const VALID_SOURCES = new Set(['user', 'ai-summary', 'file', 'web', 'cli', 'auto']);
268
+ function normalizeSource(s) { return VALID_SOURCES.has(s) ? s : 'user'; }
269
+
270
+ function compactEntry(e) {
271
+ const compact = {
272
+ id: e.id,
273
+ project: e.project,
274
+ sessionId: e.sessionId,
275
+ nodeType: e.nodeType || 'entry',
276
+ title: e.title || '',
277
+ type: e.type || 'note',
278
+ status: e.status || 'active',
279
+ version: e.version || 1,
280
+ tags: e.tags,
281
+ source: e.source,
282
+ createdAt: e.createdAt,
283
+ updatedAt: e.updatedAt || null,
284
+ preview: truncate(e.content, PREVIEW_LENGTH),
285
+ };
286
+ if (e.files && e.files.length) compact.files = e.files;
287
+ if (e.codeRefs && e.codeRefs.length) compact.codeRefs = e.codeRefs;
288
+ if (e.expiresAt) compact.expiresAt = e.expiresAt;
289
+ return compact;
290
+ }
291
+
292
+ // ── Context entries ──────────────────────────────────────────────────────────
293
+
294
+ export function saveContext({ project, content, tags = [], source = 'user', title = '',
295
+ type = 'note', status = 'active', files = [], codeRefs = [],
296
+ sessionId = null, parentId = null, expiresAt = null, rootPath = null }) {
297
+ init();
298
+ const projectName = project || 'global';
299
+ ensureProject(projectName, rootPath || undefined);
300
+ const data = loadProjectData(projectName);
301
+ const now = new Date().toISOString();
302
+ const entry = {
303
+ id: randomUUID(),
304
+ project: projectName,
305
+ sessionId: sessionId || null,
306
+ parentId: parentId || sessionId || `project:${projectName}`,
307
+ nodeType: 'entry',
308
+ version: 1,
309
+ title: truncate(title, 60),
310
+ content: truncate(content, MAX_CONTENT_LENGTH),
311
+ type,
312
+ status,
313
+ tags: normalizeTags(tags),
314
+ source: normalizeSource(source),
315
+ files: Array.isArray(files) ? files : [],
316
+ codeRefs: Array.isArray(codeRefs) ? codeRefs : [],
317
+ discussionId: null,
318
+ createdAt: now,
319
+ updatedAt: null,
320
+ expiresAt: expiresAt || null,
321
+ };
322
+ const tree = treeFor(entry);
323
+ if (tree === 'graph') data.graph.entries.push(entry);
324
+ else if (tree === 'summary') data.summary.push(entry);
325
+ else data.context.push(entry);
326
+ _dirtyProjects.add(projectName);
327
+ markDirty();
328
+ return entry;
329
+ }
330
+
331
+ export function updateContext({ id, content, title, tags, type, status, files, codeRefs, sessionId, parentId, expiresAt }) {
332
+ init();
333
+ const found = findEntryById(id);
334
+ if (!found) return null;
335
+ const { entry, projectName } = found;
336
+ const data = loadProjectData(projectName);
337
+
338
+ const oldTree = treeFor(entry);
339
+ if (content !== undefined) entry.content = truncate(content, MAX_CONTENT_LENGTH);
340
+ if (title !== undefined) entry.title = truncate(title, 60);
341
+ if (tags !== undefined) entry.tags = normalizeTags(tags);
342
+ if (type !== undefined) entry.type = type;
343
+ if (status !== undefined) entry.status = status;
344
+ if (files !== undefined) entry.files = Array.isArray(files) ? files : [];
345
+ if (codeRefs !== undefined) entry.codeRefs = Array.isArray(codeRefs) ? codeRefs : [];
346
+ if (expiresAt !== undefined) entry.expiresAt = expiresAt || null;
347
+ if (sessionId !== undefined) entry.sessionId = sessionId || null;
348
+ if (parentId !== undefined) entry.parentId = parentId || entry.sessionId || `project:${entry.project || 'global'}`;
349
+ entry.version = (entry.version || 1) + 1;
350
+ entry.updatedAt = new Date().toISOString();
351
+
352
+ // Re-route if type/status changed tree membership
353
+ const newTree = treeFor(entry);
354
+ if (newTree !== oldTree) {
355
+ removeEntryFromData(data, entry);
356
+ // Re-add with updated tree
357
+ const tempEntry = { ...entry };
358
+ if (newTree === 'graph') data.graph.entries.push(tempEntry);
359
+ else if (newTree === 'summary') data.summary.push(tempEntry);
360
+ else data.context.push(tempEntry);
361
+ }
362
+
363
+ _dirtyProjects.add(projectName);
364
+ markDirty();
365
+ return entry;
366
+ }
367
+
368
+ export function getContext({ project, tags, limit = 20, compact = false, ids } = {}) {
369
+ init();
370
+
371
+ if (ids && ids.length) {
372
+ const idSet = new Set(ids);
373
+ // Load all projects to find entries
374
+ const idx = loadProjectsIndex();
375
+ const all = [];
376
+ const loaded = new Set(_projectData.keys());
377
+ for (const proj of idx) loaded.add(proj.name);
378
+ for (const name of loaded) {
379
+ for (const e of getAllEntries(name)) {
380
+ if (idSet.has(e.id)) all.push(e);
381
+ }
382
+ }
383
+ return compact ? all.map(compactEntry) : all;
384
+ }
385
+
386
+ let results;
387
+ if (project) {
388
+ const entries = getAllEntries(project);
389
+ const globalEntries = project !== 'global' ? getAllEntries('global') : [];
390
+ results = [...entries, ...globalEntries];
391
+ } else {
392
+ // No project filter: load all
393
+ const idx = loadProjectsIndex();
394
+ const all = [];
395
+ const seen = new Set(_projectData.keys());
396
+ for (const proj of idx) seen.add(proj.name);
397
+ for (const name of seen) {
398
+ all.push(...getAllEntries(name));
399
+ }
400
+ results = all;
401
+ }
402
+
403
+ if (tags && tags.length) {
404
+ const tagList = Array.isArray(tags) ? tags : tags.split(',').map(t => t.trim());
405
+ results = results.filter(c => tagList.some(t => Array.isArray(c.tags) && c.tags.includes(t)));
406
+ }
407
+
408
+ // Sort by createdAt ascending, then take last `limit`
409
+ results.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
410
+ const sliced = results.slice(-limit).reverse();
411
+ return compact ? sliced.map(compactEntry) : sliced;
412
+ }
413
+
414
+ export function getContextSince(since, project) {
415
+ init();
416
+ let results;
417
+ if (project) {
418
+ results = [...getAllEntries(project)];
419
+ if (project !== 'global') results.push(...getAllEntries('global'));
420
+ } else {
421
+ const idx = loadProjectsIndex();
422
+ results = [];
423
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
424
+ for (const name of seen) results.push(...getAllEntries(name));
425
+ }
426
+ return results.filter(c => c.createdAt >= since);
427
+ }
428
+
429
+ export function searchContext({ query, project, limit = 10, compact = false }) {
430
+ init();
431
+ const terms = query.toLowerCase().split(/\s+/);
432
+ let results;
433
+ if (project) {
434
+ results = [...getAllEntries(project)];
435
+ if (project !== 'global') results.push(...getAllEntries('global'));
436
+ } else {
437
+ const idx = loadProjectsIndex();
438
+ results = [];
439
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
440
+ for (const name of seen) results.push(...getAllEntries(name));
441
+ }
442
+ const scored = results.map(c => {
443
+ const haystack = `${c.title || ''} ${c.content || ''} ${(Array.isArray(c.tags) ? c.tags : []).join(' ')}`.toLowerCase();
444
+ const score = terms.reduce((s, t) => s + (haystack.split(t).length - 1), 0);
445
+ return { ...c, score };
446
+ }).filter(c => c.score > 0).sort((a, b) => b.score - a.score);
447
+ const sliced = scored.slice(0, limit).map(({ score, ...c }) => c);
448
+ return compact ? sliced.map(compactEntry) : sliced;
449
+ }
450
+
451
+ export function deleteContext({ id, ids }) {
452
+ init();
453
+ const idSet = new Set(ids && ids.length ? ids : (id ? [id] : []));
454
+ if (!idSet.size) return { deleted: 0 };
455
+ let deleted = 0;
456
+ // Scan all loaded projects
457
+ const seen = new Set(_projectData.keys());
458
+ loadProjectsIndex().forEach(p => seen.add(p.name));
459
+ for (const name of seen) {
460
+ const data = loadProjectData(name);
461
+ const allEntries = getAllEntries(name);
462
+ const toRemove = allEntries.filter(e => idSet.has(e.id));
463
+ if (!toRemove.length) continue;
464
+ for (const entry of toRemove) removeEntryFromData(data, entry);
465
+ _dirtyProjects.add(name);
466
+ deleted += toRemove.length;
467
+ if (deleted >= idSet.size) break;
468
+ }
469
+ if (deleted > 0) markDirty();
470
+ return { deleted };
471
+ }
472
+
473
+ export function deleteProject(nameOrId) {
474
+ init();
475
+ const idx = loadProjectsIndex();
476
+ const byId = idx.find(p => p.id === nameOrId);
477
+ const projectName = byId ? byId.name : nameOrId;
478
+
479
+ // Count before removing
480
+ const data = _projectData.get(projectName) || loadProjectData(projectName);
481
+ const ctxCount = data.context.length + data.graph.entries.length + data.summary.length;
482
+ const discCount = data.discussions.length;
483
+
484
+ // Remove project directory from disk
485
+ const dir = projectDataDir(projectName);
486
+ if (existsSync(dir)) {
487
+ for (const file of ['context.json', 'graph.json', 'summary.json', 'discussions.json']) {
488
+ try { unlinkSync(join(dir, file)); } catch {}
489
+ }
490
+ try { rmdirSync(dir); } catch {}
491
+ }
492
+
493
+ // Drop from cache
494
+ _projectData.delete(projectName);
495
+ _dirtyProjects.delete(projectName);
496
+
497
+ // Remove from index
498
+ const beforeProj = idx.length;
499
+ _projectsIndex = idx.filter(p => p.name !== projectName);
500
+ if (_projectsIndex.length !== beforeProj) {
501
+ _projectsIndexDirty = true;
502
+ markDirty();
503
+ }
504
+
505
+ return { deletedEntries: ctxCount, deletedDiscussions: discCount };
506
+ }
507
+
508
+ export function countContext(project) {
509
+ init();
510
+ if (!project) {
511
+ const idx = loadProjectsIndex();
512
+ let total = 0;
513
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
514
+ for (const name of seen) total += getAllEntries(name).length;
515
+ return total;
516
+ }
517
+ return getAllEntries(project).length;
518
+ }
519
+
520
+ export function ensureProject(name, rootPath) {
521
+ if (!name || name === 'global') return null;
522
+ const idx = loadProjectsIndex();
523
+ let proj = idx.find(p => p.name === name);
524
+ if (!proj) {
525
+ proj = {
526
+ id: randomUUID(),
527
+ name,
528
+ createdAt: new Date().toISOString(),
529
+ dataDir: `projects/${slugify(name)}`,
530
+ };
531
+ idx.push(proj);
532
+ _projectsIndexDirty = true;
533
+ markDirty();
534
+ }
535
+ if (rootPath && !proj.rootPath) {
536
+ proj.rootPath = rootPath;
537
+ if (!proj.dataDir) proj.dataDir = `projects/${slugify(name)}`;
538
+ _projectsIndexDirty = true;
539
+ markDirty();
540
+ }
541
+ return proj;
542
+ }
543
+
544
+ export function getProjectRoot(name) {
545
+ if (!name || name === 'global') return null;
546
+ init();
547
+ return loadProjectsIndex().find(p => p.name === name)?.rootPath || null;
548
+ }
549
+
550
+ export function listProjects() {
551
+ init();
552
+ const idx = loadProjectsIndex();
553
+ // Load all known project dirs to get entry counts
554
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
555
+ return [...seen]
556
+ .map(name => {
557
+ const count = getAllEntries(name).length;
558
+ const reg = idx.find(p => p.name === name);
559
+ if (!reg && count === 0) return null;
560
+ return {
561
+ id: reg?.id || null,
562
+ name,
563
+ count,
564
+ createdAt: reg?.createdAt || null,
565
+ rootPath: reg?.rootPath || null,
566
+ };
567
+ })
568
+ .filter(p => p && p.count > 0);
569
+ }
570
+
571
+ // ── Auto-dedup ───────────────────────────────────────────────────────────────
572
+
573
+ export function findDuplicate(content, project) {
574
+ init();
575
+ const existing = getContext({ project, limit: 50 });
576
+ if (!existing.length) return null;
577
+ const newWords = new Set(content.toLowerCase().split(/\s+/).filter(w => w.length > 3));
578
+ if (!newWords.size) return null;
579
+ for (const entry of existing) {
580
+ const oldWords = new Set((entry.content || '').toLowerCase().split(/\s+/).filter(w => w.length > 3));
581
+ if (!oldWords.size) continue;
582
+ const overlap = [...newWords].filter(w => oldWords.has(w)).length;
583
+ const similarity = overlap / Math.max(newWords.size, oldWords.size);
584
+ if (similarity > 0.85) return entry;
585
+ }
586
+ return null;
587
+ }
588
+
589
+ // ── Discussions ───────────────────────────────────────────────────────────────
590
+
591
+ const VALID_DISCUSSION_TYPES = new Set(['plan','research','idea','design','implementation','review','thread']);
592
+ const VALID_DISCUSSION_STATUSES = new Set(['active','done']);
593
+
594
+ export function saveDiscussion({ name, title, description, content, project, tags,
595
+ type, status, steps, linkedContextIds, parentId, sessionId }) {
596
+ init();
597
+ const proj = project || 'global';
598
+ const data = loadProjectData(proj);
599
+ const existing = data.discussions.findIndex(d => d.name === name);
600
+ const now = new Date().toISOString();
601
+ const prev = existing >= 0 ? data.discussions[existing] : null;
602
+ const disc = {
603
+ id: prev?.id || randomUUID(),
604
+ name,
605
+ project: project !== undefined ? (project || 'global') : (prev?.project ?? 'global'),
606
+ sessionId: sessionId !== undefined ? (sessionId || null) : (prev?.sessionId ?? null),
607
+ parentId: parentId !== undefined ? (parentId || null) : (prev?.parentId ?? null),
608
+ title: title !== undefined ? truncate(title || name, 80) : (prev?.title ?? name),
609
+ description: description !== undefined ? (description || '') : (prev?.description ?? ''),
610
+ content: content !== undefined ? truncate(content || '', MAX_CONTENT_LENGTH) : (prev?.content ?? ''),
611
+ type: type !== undefined ? (VALID_DISCUSSION_TYPES.has(type) ? type : 'plan') : (prev?.type ?? 'plan'),
612
+ status: status !== undefined ? (VALID_DISCUSSION_STATUSES.has(status) ? status : 'active') : (prev?.status ?? 'active'),
613
+ tags: tags !== undefined ? normalizeTags(tags) : (prev?.tags ?? []),
614
+ steps: steps !== undefined ? mergeSteps(prev?.steps ?? [], steps) : (prev?.steps ?? []),
615
+ linkedContextIds: linkedContextIds !== undefined ? (Array.isArray(linkedContextIds) ? linkedContextIds : []) : (prev?.linkedContextIds ?? []),
616
+ createdAt: prev?.createdAt || now,
617
+ updatedAt: now,
618
+ };
619
+ if (existing >= 0) data.discussions[existing] = disc;
620
+ else data.discussions.push(disc);
621
+ _dirtyProjects.add(proj);
622
+ markDirty();
623
+ return disc;
624
+ }
625
+
626
+ function mergeSteps(prevSteps, incomingSteps) {
627
+ if (!Array.isArray(incomingSteps) || incomingSteps.length === 0) return prevSteps;
628
+ return incomingSteps.map((s, i) => {
629
+ const prev = prevSteps.find(p => p.id && p.id === s.id) || prevSteps[i];
630
+ return {
631
+ id: s.id || prev?.id || randomUUID(),
632
+ title: s.title ?? prev?.title ?? '',
633
+ description: s.description ?? prev?.description ?? '',
634
+ status: s.status ?? prev?.status ?? 'pending',
635
+ order: s.order ?? prev?.order ?? i,
636
+ linkedContextIds: s.linkedContextIds ?? prev?.linkedContextIds ?? [],
637
+ completedAt: s.completedAt ?? prev?.completedAt ?? null,
638
+ };
639
+ });
640
+ }
641
+
642
+ export function updateDiscussion({ id, name, title, description, content, status, type, tags, steps, linkedContextIds, parentId, sessionId }) {
643
+ init();
644
+ let disc = null;
645
+ let projName = null;
646
+ const idx = loadProjectsIndex();
647
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
648
+ for (const pName of seen) {
649
+ const d = loadProjectData(pName);
650
+ const found = id ? d.discussions.find(x => x.id === id) : d.discussions.find(x => x.name === name);
651
+ if (found) { disc = found; projName = pName; break; }
652
+ }
653
+ if (!disc) return null;
654
+ if (title !== undefined) disc.title = truncate(title || disc.name, 80);
655
+ if (description !== undefined) disc.description = description || '';
656
+ if (content !== undefined) disc.content = truncate(content || '', MAX_CONTENT_LENGTH);
657
+ if (type !== undefined) disc.type = VALID_DISCUSSION_TYPES.has(type) ? type : disc.type;
658
+ if (status !== undefined) disc.status = VALID_DISCUSSION_STATUSES.has(status) ? status : disc.status;
659
+ if (tags !== undefined) disc.tags = normalizeTags(tags);
660
+ if (steps !== undefined) disc.steps = mergeSteps(disc.steps ?? [], steps);
661
+ if (linkedContextIds !== undefined) disc.linkedContextIds = Array.isArray(linkedContextIds) ? linkedContextIds : disc.linkedContextIds;
662
+ if (parentId !== undefined) disc.parentId = parentId || null;
663
+ if (sessionId !== undefined) disc.sessionId = sessionId || null;
664
+ disc.updatedAt = new Date().toISOString();
665
+ _dirtyProjects.add(projName);
666
+ markDirty();
667
+ return disc;
668
+ }
669
+
670
+ export function getDiscussion({ project, name, id } = {}) {
671
+ init();
672
+ if (project) {
673
+ const data = loadProjectData(project);
674
+ let list = data.discussions;
675
+ if (id) return list.find(d => d.id === id) || null;
676
+ if (name) return list.find(d => d.name === name) || null;
677
+ return null;
678
+ }
679
+ // Search all
680
+ const idx = loadProjectsIndex();
681
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
682
+ for (const pName of seen) {
683
+ const d = loadProjectData(pName);
684
+ const found = id ? d.discussions.find(x => x.id === id) : d.discussions.find(x => x.name === name);
685
+ if (found) return found;
686
+ }
687
+ return null;
688
+ }
689
+
690
+ export function listDiscussions({ project, status, type } = {}) {
691
+ init();
692
+ let list = [];
693
+ if (project) {
694
+ list = loadProjectData(project).discussions;
695
+ if (project !== 'global') list = [...list, ...loadProjectData('global').discussions];
696
+ } else {
697
+ const idx = loadProjectsIndex();
698
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
699
+ for (const pName of seen) list.push(...loadProjectData(pName).discussions);
700
+ }
701
+ if (status) list = list.filter(d => d.status === status);
702
+ if (type) list = list.filter(d => d.type === type);
703
+ return list.map(({ content: _, steps, ...rest }) => ({
704
+ ...rest,
705
+ stepsSummary: {
706
+ total: (steps || []).length,
707
+ done: (steps || []).filter(s => s.status === 'done').length,
708
+ inProgress: (steps || []).filter(s => s.status === 'in-progress').length,
709
+ },
710
+ }));
711
+ }
712
+
713
+ export function linkContextToDiscussion({ discussionId, discussionName, contextId }) {
714
+ init();
715
+ // Find discussion across projects
716
+ let disc = null;
717
+ let discProject = null;
718
+ const idx = loadProjectsIndex();
719
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
720
+ for (const pName of seen) {
721
+ const d = loadProjectData(pName);
722
+ const found = discussionId
723
+ ? d.discussions.find(x => x.id === discussionId)
724
+ : d.discussions.find(x => x.name === discussionName);
725
+ if (found) { disc = found; discProject = pName; break; }
726
+ }
727
+ if (!disc) return null;
728
+
729
+ if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
730
+ let changed = false;
731
+ if (!disc.linkedContextIds.includes(contextId)) {
732
+ disc.linkedContextIds.push(contextId);
733
+ disc.updatedAt = new Date().toISOString();
734
+ _dirtyProjects.add(discProject);
735
+ changed = true;
736
+ }
737
+
738
+ // Write discussionId back onto the context entry
739
+ const found = findEntryById(contextId);
740
+ if (found && found.entry.discussionId !== disc.id) {
741
+ found.entry.discussionId = disc.id;
742
+ found.entry.updatedAt = new Date().toISOString();
743
+ _dirtyProjects.add(found.projectName);
744
+ changed = true;
745
+ }
746
+ if (changed) markDirty();
747
+ return { discussionId: disc.id, contextId };
748
+ }
749
+
750
+ export function getContextByDiscussion(discussionId) {
751
+ init();
752
+ const idx = loadProjectsIndex();
753
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
754
+ const results = [];
755
+ for (const name of seen) {
756
+ results.push(...getAllEntries(name).filter(c => c.discussionId === discussionId));
757
+ }
758
+ return results;
759
+ }
760
+
761
+ export function clearDiscussionLink(contextId) {
762
+ init();
763
+ const found = findEntryById(contextId);
764
+ if (!found) return null;
765
+ found.entry.discussionId = null;
766
+ found.entry.updatedAt = new Date().toISOString();
767
+ _dirtyProjects.add(found.projectName);
768
+ markDirty();
769
+ return found.entry;
770
+ }
771
+
772
+ export function deleteDiscussion({ name, id }) {
773
+ init();
774
+ const idx = loadProjectsIndex();
775
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
776
+ for (const pName of seen) {
777
+ const data = loadProjectData(pName);
778
+ const before = data.discussions.length;
779
+ data.discussions = data.discussions.filter(d => {
780
+ if (id) return d.id !== id;
781
+ if (name) return d.name !== name;
782
+ return true;
783
+ });
784
+ if (data.discussions.length < before) {
785
+ _dirtyProjects.add(pName);
786
+ markDirty();
787
+ return { deleted: before - data.discussions.length };
788
+ }
789
+ }
790
+ return { deleted: 0 };
791
+ }
792
+
793
+ export function updateDiscussionStep({ discussionName, discussionId, stepId, status, linkedContextId }) {
794
+ init();
795
+ let disc = null;
796
+ let projName = null;
797
+ const idx = loadProjectsIndex();
798
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
799
+ for (const pName of seen) {
800
+ const d = loadProjectData(pName);
801
+ const found = discussionId
802
+ ? d.discussions.find(x => x.id === discussionId)
803
+ : d.discussions.find(x => x.name === discussionName);
804
+ if (found) { disc = found; projName = pName; break; }
805
+ }
806
+ if (!disc) return null;
807
+ const step = (disc.steps || []).find(s => s.id === stepId);
808
+ if (!step) return null;
809
+ if (status) step.status = status;
810
+ if (status === 'done') step.completedAt = new Date().toISOString();
811
+ if (linkedContextId) {
812
+ if (!Array.isArray(step.linkedContextIds)) step.linkedContextIds = [];
813
+ if (!step.linkedContextIds.includes(linkedContextId)) step.linkedContextIds.push(linkedContextId);
814
+ if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
815
+ if (!disc.linkedContextIds.includes(linkedContextId)) disc.linkedContextIds.push(linkedContextId);
816
+ }
817
+ const allDone = disc.steps.every(s => s.status === 'done' || s.status === 'skipped');
818
+ if (allDone && disc.status !== 'done') disc.status = 'done';
819
+ disc.updatedAt = new Date().toISOString();
820
+ _dirtyProjects.add(projName);
821
+ markDirty();
822
+ return { discussion: disc, step };
823
+ }
824
+
825
+ // ── Auto-operations ───────────────────────────────────────────────────────────
826
+
827
+ export function archiveExpired(project) {
828
+ init();
829
+ const now = new Date().toISOString();
830
+ let count = 0;
831
+ const processEntries = (entries, projName) => {
832
+ for (const entry of entries) {
833
+ if (entry.expiresAt && entry.expiresAt < now && entry.status !== 'archived') {
834
+ entry.status = 'archived';
835
+ entry.updatedAt = now;
836
+ _dirtyProjects.add(projName);
837
+ count++;
838
+ }
839
+ }
840
+ };
841
+
842
+ if (project) {
843
+ processEntries(getAllEntries(project), project);
844
+ } else {
845
+ const idx = loadProjectsIndex();
846
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
847
+ for (const name of seen) processEntries(getAllEntries(name).slice(), name);
848
+ }
849
+ if (count > 0) markDirty();
850
+ return { archived: count };
851
+ }
852
+
853
+ // ── Exports ──────────────────────────────────────────────────────────────────
854
+
855
+ export function getStorePath() { return DATA_DIR; }
856
+ export function getGeneration() { return _generation; }
857
+ export function flushStore() { flushToDisk(); }
858
+
859
+ // ── Auto-compaction ───────────────────────────────────────────────────────────
860
+
861
+ const COMPACTION_THRESHOLD = 20;
862
+ const COMPACTION_TARGET = 30;
863
+
864
+ export function shouldCompact(project) {
865
+ return countContext(project) > COMPACTION_THRESHOLD;
866
+ }
867
+
868
+ export function compactProject(project, summaryContent) {
869
+ init();
870
+ const proj = project || 'global';
871
+ const data = loadProjectData(proj);
872
+ const entries = data.context
873
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
874
+ if (entries.length < COMPACTION_TARGET) return null;
875
+ const toRemove = new Set(entries.slice(0, COMPACTION_TARGET).map(e => e.id));
876
+ const removed = entries.filter(e => toRemove.has(e.id));
877
+ for (const entry of removed) removeEntryFromData(data, entry);
878
+ _dirtyProjects.add(proj);
879
+ markDirty();
880
+ const summary = saveContext({
881
+ project: proj,
882
+ title: `Compacted ${removed.length} entries — ${new Date().toISOString().slice(0, 10)}`,
883
+ content: summaryContent,
884
+ type: 'compaction',
885
+ source: 'auto',
886
+ tags: ['compaction', 'auto'],
887
+ });
888
+ return { removedCount: removed.length, summaryId: summary.id };
889
+ }
890
+
891
+ // ── Graph registry ────────────────────────────────────────────────────────────
892
+
893
+ export function saveGraph({ path, nodes, edges, communities, cached, changed, time_ms, summary }) {
894
+ init();
895
+ // Find project by rootPath matching graph path
896
+ const idx = loadProjectsIndex();
897
+ const proj = idx.find(p => normPath(p.rootPath) === normPath(path));
898
+ const projName = proj ? proj.name : 'global';
899
+
900
+ const data = loadProjectData(projName);
901
+ const existing = data.graph.build;
902
+ const record = {
903
+ path,
904
+ nodes: nodes ?? existing?.nodes ?? 0,
905
+ edges: edges ?? existing?.edges ?? 0,
906
+ communities: communities ?? existing?.communities ?? 0,
907
+ cached: cached ?? 0,
908
+ changed: changed ?? 0,
909
+ time_ms: time_ms ?? 0,
910
+ summary: summary || existing?.summary || '',
911
+ builtAt: new Date().toISOString(),
912
+ };
913
+ data.graph.build = record;
914
+ _dirtyProjects.add(projName);
915
+ markDirty();
916
+ return record;
917
+ }
918
+
919
+ export function getGraph(path) {
920
+ init();
921
+ if (!path) return listGraphs();
922
+ const idx = loadProjectsIndex();
923
+ for (const proj of idx) {
924
+ if (normPath(proj.rootPath) === normPath(path)) {
925
+ return loadProjectData(proj.name).graph.build || null;
926
+ }
927
+ }
928
+ // fallback: scan all loaded data
929
+ for (const [, data] of _projectData.entries()) {
930
+ if (data.graph.build && normPath(data.graph.build.path) === normPath(path))
931
+ return data.graph.build;
932
+ }
933
+ return null;
934
+ }
935
+
936
+ export function listGraphs() {
937
+ init();
938
+ const idx = loadProjectsIndex();
939
+ const results = [];
940
+ const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
941
+ for (const name of seen) {
942
+ const build = loadProjectData(name).graph.build;
943
+ if (build) results.push(build);
944
+ }
945
+ return results;
946
+ }