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