context-mcp-server 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +464 -0
  2. package/codegraph/__init__.py +0 -0
  3. package/codegraph/__main__.py +24 -0
  4. package/codegraph/__pycache__/__init__.cpython-313.pyc +0 -0
  5. package/codegraph/__pycache__/__main__.cpython-313.pyc +0 -0
  6. package/codegraph/__pycache__/cache.cpython-313.pyc +0 -0
  7. package/codegraph/__pycache__/config.cpython-313.pyc +0 -0
  8. package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
  9. package/codegraph/__pycache__/scanner.cpython-313.pyc +0 -0
  10. package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
  11. package/codegraph/cache.py +137 -0
  12. package/codegraph/config.py +31 -0
  13. package/codegraph/extractors/__init__.py +0 -0
  14. package/codegraph/extractors/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
  16. package/codegraph/extractors/__pycache__/audio_extractor.cpython-313.pyc +0 -0
  17. package/codegraph/extractors/__pycache__/doc_extractor.cpython-313.pyc +0 -0
  18. package/codegraph/extractors/__pycache__/image_extractor.cpython-313.pyc +0 -0
  19. package/codegraph/extractors/ast_extractor.py +222 -0
  20. package/codegraph/extractors/audio_extractor.py +8 -0
  21. package/codegraph/extractors/doc_extractor.py +34 -0
  22. package/codegraph/extractors/image_extractor.py +26 -0
  23. package/codegraph/graph/__init__.py +0 -0
  24. package/codegraph/graph/__pycache__/__init__.cpython-313.pyc +0 -0
  25. package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
  26. package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
  27. package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
  28. package/codegraph/graph/builder.py +145 -0
  29. package/codegraph/graph/clustering.py +40 -0
  30. package/codegraph/graph/query.py +283 -0
  31. package/codegraph/report.py +115 -0
  32. package/codegraph/scanner.py +92 -0
  33. package/codegraph/server.py +514 -0
  34. package/package.json +62 -0
  35. package/src/cli.js +1010 -0
  36. package/src/config.js +89 -0
  37. package/src/db.js +786 -0
  38. package/src/guard.js +20 -0
  39. package/src/hooks/autoContext.js +17 -0
  40. package/src/hooks/autoLink.js +7 -0
  41. package/src/http.js +765 -0
  42. package/src/index.js +47 -0
  43. package/src/search.js +50 -0
  44. package/src/server.js +80 -0
  45. package/src/summarizer.js +124 -0
  46. package/src/templates/AGENTS.md +76 -0
  47. package/src/templates/CLAUDE.md +94 -0
  48. package/src/templates/GEMINI.md +76 -0
  49. package/src/templates/cursor-rules.mdc +41 -0
  50. package/src/templates/windsurf-rules.md +35 -0
  51. package/src/tools/codegraph.js +215 -0
  52. package/src/tools/context.js +188 -0
  53. package/src/tools/discussion.js +123 -0
  54. package/src/tools/errorCheck.js +65 -0
  55. package/src/tools/fileTools.js +185 -0
  56. package/src/tools/gitTools.js +259 -0
  57. package/src/tools/search.js +55 -0
  58. package/src/vector.js +153 -0
package/src/db.js ADDED
@@ -0,0 +1,786 @@
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
+ const WRITE_DEBOUNCE_MS = 500;
27
+ const LOCK_WAIT_TIMEOUT_MS = 2000;
28
+
29
+ const _isWin = platform() === 'win32';
30
+
31
+ function _secureFile(p) {
32
+ if (_isWin) return;
33
+ try { chmodSync(p, 0o600); } catch {}
34
+ }
35
+
36
+ if (!existsSync(DATA_DIR)) {
37
+ mkdirSync(DATA_DIR, { recursive: true });
38
+ if (!_isWin) { try { chmodSync(DATA_DIR, 0o700); } catch {} }
39
+ }
40
+
41
+ // ── In-memory cache ──────────────────────────────────────────────────────────
42
+
43
+ let _cache = null;
44
+ let _dirty = false;
45
+ let _writeTimer = null;
46
+ let _generation = 0;
47
+ const _changedContextIds = new Set();
48
+ const _deletedContextIds = new Set();
49
+ const _changedDiscussionNames = new Set();
50
+ const _changedGraphPaths = new Set();
51
+ const _changedProjectIds = new Set();
52
+
53
+
54
+ function _readCollection(path, key) {
55
+ if (!existsSync(path)) return [];
56
+ try {
57
+ const data = JSON.parse(readFileSync(path, 'utf8'));
58
+ return Array.isArray(data[key]) ? data[key] : (Array.isArray(data) ? data : []);
59
+ } catch { return []; }
60
+ }
61
+
62
+ function load() {
63
+ if (_cache) return _cache;
64
+ _cache = {
65
+ contexts: _readCollection(CONTEXTS_PATH, 'contexts'),
66
+ discussions: _readCollection(DISCUSSIONS_PATH, 'discussions'),
67
+ graphs: _readCollection(GRAPHS_PATH, 'graphs'),
68
+ projects: _readCollection(PROJECTS_PATH, 'projects'),
69
+ };
70
+ return _cache;
71
+ }
72
+
73
+ function readStoreFromDisk() {
74
+ return {
75
+ contexts: _readCollection(CONTEXTS_PATH, 'contexts'),
76
+ discussions: _readCollection(DISCUSSIONS_PATH, 'discussions'),
77
+ graphs: _readCollection(GRAPHS_PATH, 'graphs'),
78
+ projects: _readCollection(PROJECTS_PATH, 'projects'),
79
+ };
80
+ }
81
+
82
+ function refreshFromDisk() {
83
+ const latest = readStoreFromDisk();
84
+ _cache = mergeStore(latest, _cache || { contexts: [], discussions: [], graphs: [], projects: [] });
85
+ }
86
+
87
+ function normalizeTags(tags) {
88
+ if (Array.isArray(tags)) return tags;
89
+ if (typeof tags === 'string') return tags.split(',').map(t => t.trim()).filter(Boolean);
90
+ return [];
91
+ }
92
+
93
+ const VALID_SOURCES = new Set(['user', 'ai-summary', 'file', 'web', 'cli', 'auto']);
94
+ function normalizeSource(s) {
95
+ return VALID_SOURCES.has(s) ? s : 'user';
96
+ }
97
+
98
+ const VALID_PRIORITIES = new Set(['low', 'normal', 'high', 'critical']);
99
+ function normalizePriority(p) {
100
+ return VALID_PRIORITIES.has(p) ? p : 'normal';
101
+ }
102
+
103
+ // backward-compat: old data has relations as string[], new is {id, relType}[]
104
+ function normalizeRelations(relations) {
105
+ if (!Array.isArray(relations)) return [];
106
+ return relations.map(r => {
107
+ if (typeof r === 'string') return { id: r, relType: 'relates-to' };
108
+ if (r && typeof r.id === 'string') return { id: r.id, relType: r.relType || 'relates-to' };
109
+ return null;
110
+ }).filter(Boolean);
111
+ }
112
+
113
+
114
+ function mergeStore(latest, local) {
115
+ const contextsById = new Map(
116
+ latest.contexts
117
+ .filter(c => !_deletedContextIds.has(c.id))
118
+ .map(c => [c.id, c])
119
+ );
120
+ for (const context of local.contexts) {
121
+ if (_changedContextIds.has(context.id)) contextsById.set(context.id, context);
122
+ }
123
+
124
+ const discussionsByName = new Map(latest.discussions.map(d => [d.name, d]));
125
+ for (const disc of local.discussions) {
126
+ if (_changedDiscussionNames.has(disc.name)) discussionsByName.set(disc.name, disc);
127
+ }
128
+
129
+ const graphsByPath = new Map((latest.graphs || []).map(g => [g.path, g]));
130
+ for (const graph of (local.graphs || [])) {
131
+ if (_changedGraphPaths.has(graph.path)) graphsByPath.set(graph.path, graph);
132
+ }
133
+
134
+ const projectsById = new Map((latest.projects || []).map(p => [p.id, p]));
135
+ for (const proj of (local.projects || [])) {
136
+ if (_changedProjectIds.has(proj.id)) projectsById.set(proj.id, proj);
137
+ }
138
+
139
+ return {
140
+ contexts: [...contextsById.values()],
141
+ discussions: [...discussionsByName.values()],
142
+ graphs: [...graphsByPath.values()],
143
+ projects: [...projectsById.values()],
144
+ };
145
+ }
146
+
147
+ function markDirty() {
148
+ _dirty = true;
149
+ _generation++;
150
+ // Debounce: schedule a write after WRITE_DEBOUNCE_MS of no further mutations
151
+ if (_writeTimer) clearTimeout(_writeTimer);
152
+ _writeTimer = setTimeout(flushToDisk, WRITE_DEBOUNCE_MS);
153
+ }
154
+
155
+ function _flushCollection(filePath, key, data) {
156
+ const lockPath = `${filePath}.lock`;
157
+ const tmpPath = `${filePath}.tmp`;
158
+ let lockFd;
159
+ let renamed = false;
160
+ try {
161
+ const started = Date.now();
162
+ for (;;) {
163
+ try { lockFd = openSync(lockPath, 'wx'); break; }
164
+ catch (err) {
165
+ if (err && err.code !== 'EEXIST') throw err;
166
+ if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS)
167
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
168
+ const t = Date.now(); while (Date.now() - t < 10) {}
169
+ }
170
+ }
171
+ writeFileSync(tmpPath, JSON.stringify({ [key]: data }, null, 2), 'utf8');
172
+ _secureFile(tmpPath);
173
+ renameSync(tmpPath, filePath);
174
+ renamed = true;
175
+ } finally {
176
+ if (lockFd !== undefined) { closeSync(lockFd); try { unlinkSync(lockPath); } catch {} }
177
+ try { if (!renamed && existsSync(tmpPath)) unlinkSync(tmpPath); } catch {}
178
+ }
179
+ }
180
+
181
+ function flushToDisk() {
182
+ if (!_dirty || !_cache) return;
183
+ _writeTimer = null;
184
+
185
+ const latest = readStoreFromDisk();
186
+ _cache = mergeStore(latest, _cache);
187
+
188
+ if (_changedContextIds.size > 0 || _deletedContextIds.size > 0) {
189
+ _flushCollection(CONTEXTS_PATH, 'contexts', _cache.contexts);
190
+ _changedContextIds.clear();
191
+ _deletedContextIds.clear();
192
+ }
193
+ if (_changedDiscussionNames.size > 0) {
194
+ _flushCollection(DISCUSSIONS_PATH, 'discussions', _cache.discussions);
195
+ _changedDiscussionNames.clear();
196
+ }
197
+ if (_changedGraphPaths.size > 0) {
198
+ _flushCollection(GRAPHS_PATH, 'graphs', _cache.graphs);
199
+ _changedGraphPaths.clear();
200
+ }
201
+ if (_changedProjectIds.size > 0) {
202
+ _flushCollection(PROJECTS_PATH, 'projects', _cache.projects);
203
+ _changedProjectIds.clear();
204
+ }
205
+
206
+ _dirty = false;
207
+ }
208
+
209
+ // Flush on process exit to guarantee no data loss
210
+ process.on('exit', flushToDisk);
211
+ process.on('SIGINT', () => { flushToDisk(); process.exit(); });
212
+ process.on('SIGTERM', () => { flushToDisk(); process.exit(); });
213
+
214
+ // ── Helpers ──────────────────────────────────────────────────────────────────
215
+
216
+ function truncate(text, max) {
217
+ if (!text || text.length <= max) return text;
218
+ return text.slice(0, max - 3) + '...';
219
+ }
220
+
221
+ function compactEntry(e) {
222
+ const compact = {
223
+ id: e.id,
224
+ project: e.project,
225
+ sessionId: e.sessionId,
226
+ nodeType: e.nodeType || 'entry',
227
+ title: e.title || '',
228
+ type: e.type || 'note',
229
+ status: e.status || 'active',
230
+ version: e.version || 1,
231
+ tags: e.tags,
232
+ source: e.source,
233
+ createdAt: e.createdAt,
234
+ updatedAt: e.updatedAt || null,
235
+ preview: truncate(e.content, PREVIEW_LENGTH),
236
+ };
237
+ if (e.files && e.files.length) compact.files = e.files;
238
+ if (e.codeRefs && e.codeRefs.length) compact.codeRefs = e.codeRefs;
239
+ if (e.expiresAt) compact.expiresAt = e.expiresAt;
240
+ return compact;
241
+ }
242
+
243
+ // ── Context entries ──────────────────────────────────────────────────────────
244
+
245
+ export function saveContext({ project, content, tags = [], source = 'user', title = '',
246
+ type = 'note', status = 'active', files = [], codeRefs = [],
247
+ relations = [], sessionId = null, parentId = null, expiresAt = null }) {
248
+ refreshFromDisk();
249
+ const store = load();
250
+ const projectName = project || 'global';
251
+ ensureProject(projectName);
252
+ const now = new Date().toISOString();
253
+ const entry = {
254
+ id: randomUUID(),
255
+ project: projectName,
256
+ sessionId: sessionId || null,
257
+ parentId: parentId || sessionId || `project:${projectName}`,
258
+ nodeType: 'entry',
259
+ version: 1,
260
+ title: truncate(title, 60),
261
+ content: truncate(content, MAX_CONTENT_LENGTH),
262
+ type,
263
+ status,
264
+ tags: normalizeTags(tags),
265
+ source: normalizeSource(source),
266
+ files: Array.isArray(files) ? files : [],
267
+ codeRefs: Array.isArray(codeRefs) ? codeRefs : [],
268
+ relations: normalizeRelations(relations),
269
+ relatedBy: [], // back-references written by addRelation()
270
+ discussionId: null, // set by linkContextToDiscussion()
271
+ createdAt: now,
272
+ updatedAt: null,
273
+ expiresAt: expiresAt || null,
274
+ };
275
+ store.contexts.push(entry);
276
+ _changedContextIds.add(entry.id);
277
+ markDirty();
278
+ return entry;
279
+ }
280
+
281
+ export function updateContext({ id, content, title, tags, type, status, files, codeRefs, relations, sessionId, parentId, expiresAt }) {
282
+ refreshFromDisk();
283
+ const store = load();
284
+ const entry = store.contexts.find(c => c.id === id);
285
+ if (!entry) return null;
286
+ if (content !== undefined) entry.content = truncate(content, MAX_CONTENT_LENGTH);
287
+ if (title !== undefined) entry.title = truncate(title, 60);
288
+ if (tags !== undefined) entry.tags = normalizeTags(tags);
289
+ if (type !== undefined) entry.type = type;
290
+ if (status !== undefined) entry.status = status;
291
+ if (files !== undefined) entry.files = Array.isArray(files) ? files : [];
292
+ if (codeRefs !== undefined) entry.codeRefs = Array.isArray(codeRefs) ? codeRefs : [];
293
+ if (relations !== undefined) entry.relations = normalizeRelations(relations);
294
+ if (expiresAt !== undefined) entry.expiresAt = expiresAt || null;
295
+ if (sessionId !== undefined) entry.sessionId = sessionId || null;
296
+ if (parentId !== undefined) entry.parentId = parentId || entry.sessionId || `project:${entry.project || 'global'}`;
297
+ entry.version = (entry.version || 1) + 1;
298
+ entry.updatedAt = new Date().toISOString();
299
+ _changedContextIds.add(entry.id);
300
+ _deletedContextIds.delete(entry.id);
301
+ markDirty();
302
+ return entry;
303
+ }
304
+
305
+ /**
306
+ * Get recent context entries.
307
+ * @param {Object} opts
308
+ * @param {boolean} opts.compact - If true, returns previews instead of full content (saves tokens)
309
+ */
310
+ export function getContext({ project, tags, limit = 20, compact = false } = {}) {
311
+ refreshFromDisk();
312
+ const store = load();
313
+ let results = store.contexts;
314
+ if (project) results = results.filter(c => c.project === project || c.project === 'global');
315
+ if (tags && tags.length) {
316
+ const tagList = Array.isArray(tags) ? tags : tags.split(',').map(t => t.trim());
317
+ results = results.filter(c => tagList.some(t => Array.isArray(c.tags) && c.tags.includes(t)));
318
+ }
319
+ const sliced = results.slice(-limit).reverse();
320
+ return compact ? sliced.map(compactEntry) : sliced;
321
+ }
322
+
323
+ export function getContextSince(since, project) {
324
+ refreshFromDisk();
325
+ const store = load();
326
+ let results = store.contexts;
327
+ if (project) results = results.filter(c => c.project === project || c.project === 'global');
328
+ return results.filter(c => c.createdAt >= since);
329
+ }
330
+
331
+ export function searchContext({ query, project, limit = 10, compact = false }) {
332
+ refreshFromDisk();
333
+ const store = load();
334
+ const terms = query.toLowerCase().split(/\s+/);
335
+ let results = store.contexts;
336
+ if (project) results = results.filter(c => c.project === project || c.project === 'global');
337
+ const scored = results.map(c => {
338
+ const haystack = `${c.title || ''} ${c.content || ''} ${(Array.isArray(c.tags) ? c.tags : []).join(' ')}`.toLowerCase();
339
+ const score = terms.reduce((s, t) => s + (haystack.split(t).length - 1), 0);
340
+ return { ...c, score };
341
+ }).filter(c => c.score > 0).sort((a, b) => b.score - a.score);
342
+ const sliced = scored.slice(0, limit).map(({ score, ...c }) => c);
343
+ return compact ? sliced.map(compactEntry) : sliced;
344
+ }
345
+
346
+ export function deleteContext({ id }) {
347
+ refreshFromDisk();
348
+ const store = load();
349
+ const before = store.contexts.length;
350
+ const removed = store.contexts.filter(c => c.id === id);
351
+ store.contexts = store.contexts.filter(c => c.id !== id);
352
+ if (store.contexts.length < before) {
353
+ for (const entry of removed) {
354
+ _deletedContextIds.add(entry.id);
355
+ _changedContextIds.delete(entry.id);
356
+ }
357
+ markDirty();
358
+ }
359
+ return { deleted: before - store.contexts.length };
360
+ }
361
+
362
+ export function deleteProject(nameOrId) {
363
+ refreshFromDisk();
364
+ const store = load();
365
+ // Resolve name from ID if needed
366
+ const byId = store.projects.find(p => p.id === nameOrId);
367
+ const projectName = byId ? byId.name : nameOrId;
368
+
369
+ const beforeCtx = store.contexts.length;
370
+ const beforeDisc = store.discussions.length;
371
+ const removed = store.contexts.filter(c => c.project === projectName);
372
+ store.contexts = store.contexts.filter(c => c.project !== projectName);
373
+ store.discussions = store.discussions.filter(d => d.project !== projectName);
374
+ // Remove from project registry
375
+ const beforeProj = store.projects.length;
376
+ store.projects = store.projects.filter(p => p.name !== projectName);
377
+ for (const entry of removed) {
378
+ _deletedContextIds.add(entry.id);
379
+ _changedContextIds.delete(entry.id);
380
+ }
381
+ if (store.contexts.length < beforeCtx || store.discussions.length < beforeDisc || store.projects.length < beforeProj) {
382
+ if (store.projects.length < beforeProj && byId) _changedProjectIds.add(byId.id);
383
+ markDirty();
384
+ }
385
+ return {
386
+ deletedEntries: beforeCtx - store.contexts.length,
387
+ deletedDiscussions: beforeDisc - store.discussions.length,
388
+ };
389
+ }
390
+
391
+ export function countContext(project) {
392
+ refreshFromDisk();
393
+ const store = load();
394
+ if (!project) return store.contexts.length;
395
+ return store.contexts.filter(c => c.project === project || c.project === 'global').length;
396
+ }
397
+
398
+ // Ensure a project entity exists for this name; returns the project record.
399
+ // If rootPath is provided and the project has no rootPath yet, it is stored.
400
+ export function ensureProject(name, rootPath) {
401
+ if (!name || name === 'global') return null;
402
+ const store = load();
403
+ let proj = store.projects.find(p => p.name === name);
404
+ if (!proj) {
405
+ proj = { id: randomUUID(), name, createdAt: new Date().toISOString() };
406
+ store.projects.push(proj);
407
+ _changedProjectIds.add(proj.id);
408
+ markDirty();
409
+ }
410
+ if (rootPath && !proj.rootPath) {
411
+ proj.rootPath = rootPath;
412
+ _changedProjectIds.add(proj.id);
413
+ markDirty();
414
+ }
415
+ return proj;
416
+ }
417
+
418
+ // Returns the stored rootPath for a project, or null if not set.
419
+ export function getProjectRoot(name) {
420
+ if (!name || name === 'global') return null;
421
+ const store = load();
422
+ return store.projects.find(p => p.name === name)?.rootPath || null;
423
+ }
424
+
425
+ export function listProjects() {
426
+ refreshFromDisk();
427
+ const store = load();
428
+ // Count entries per project name
429
+ const counts = {};
430
+ for (const c of store.contexts) {
431
+ counts[c.project] = (counts[c.project] || 0) + 1;
432
+ }
433
+ // Merge with project registry (provides stable IDs); backfill any missing
434
+ const registered = new Map(store.projects.map(p => [p.name, p]));
435
+ for (const name of Object.keys(counts)) {
436
+ if (!registered.has(name)) ensureProject(name); // auto-register legacy projects
437
+ }
438
+ // Re-read after potential backfill
439
+ const reg = new Map(store.projects.map(p => [p.name, p]));
440
+ return Object.entries(counts).map(([name, count]) => ({
441
+ id: reg.get(name)?.id || null,
442
+ name,
443
+ count,
444
+ createdAt: reg.get(name)?.createdAt || null,
445
+ })); // only show projects that have entries
446
+ }
447
+
448
+ // ── Auto-dedup ───────────────────────────────────────────────────────────────
449
+
450
+ export function findDuplicate(content, project) {
451
+ refreshFromDisk();
452
+ const existing = getContext({ project, limit: 50 });
453
+ if (!existing.length) return null;
454
+
455
+ const newWords = new Set(content.toLowerCase().split(/\s+/).filter(w => w.length > 3));
456
+ if (!newWords.size) return null;
457
+
458
+ for (const entry of existing) {
459
+ const oldWords = new Set((entry.content || '').toLowerCase().split(/\s+/).filter(w => w.length > 3));
460
+ if (!oldWords.size) continue;
461
+ const overlap = [...newWords].filter(w => oldWords.has(w)).length;
462
+ const similarity = overlap / Math.max(newWords.size, oldWords.size);
463
+ if (similarity > 0.85) return entry;
464
+ }
465
+ return null;
466
+ }
467
+
468
+ // ── Discussions ───────────────────────────────────────────────────────────────
469
+
470
+ const VALID_DISCUSSION_TYPES = new Set(['plan','research','idea','design','implementation','review','thread']);
471
+ const VALID_DISCUSSION_STATUSES = new Set(['active','done']);
472
+
473
+ export function saveDiscussion({ name, title, description, content, project, tags,
474
+ type, status, steps,
475
+ linkedContextIds, parentId, sessionId }) {
476
+ refreshFromDisk();
477
+ const store = load();
478
+ const existing = store.discussions.findIndex(d => d.name === name);
479
+ const now = new Date().toISOString();
480
+ const prev = existing >= 0 ? store.discussions[existing] : null;
481
+
482
+ // When updating an existing discussion, only overwrite fields that were
483
+ // explicitly provided by the caller — preserve everything else from prev.
484
+ const disc = {
485
+ id: prev?.id || randomUUID(),
486
+ name,
487
+ project: project !== undefined ? (project || 'global') : (prev?.project ?? 'global'),
488
+ sessionId: sessionId !== undefined ? (sessionId || null) : (prev?.sessionId ?? null),
489
+ parentId: parentId !== undefined ? (parentId || null) : (prev?.parentId ?? null),
490
+ title: title !== undefined ? truncate(title || name, 80) : (prev?.title ?? name),
491
+ description: description !== undefined ? (description || '') : (prev?.description ?? ''),
492
+ content: content !== undefined ? truncate(content || '', MAX_CONTENT_LENGTH) : (prev?.content ?? ''),
493
+ type: type !== undefined ? (VALID_DISCUSSION_TYPES.has(type) ? type : 'plan'): (prev?.type ?? 'plan'),
494
+ status: status !== undefined ? (VALID_DISCUSSION_STATUSES.has(status) ? status : 'active') : (prev?.status ?? 'active'),
495
+ tags: tags !== undefined ? normalizeTags(tags) : (prev?.tags ?? []),
496
+ // For steps: if caller passed steps[], re-normalize them but preserve any
497
+ // per-step fields (linkedContextIds, completedAt) that already exist on prev.
498
+ steps: steps !== undefined ? mergeSteps(prev?.steps ?? [], steps) : (prev?.steps ?? []),
499
+ linkedContextIds: linkedContextIds !== undefined ? (Array.isArray(linkedContextIds) ? linkedContextIds : []) : (prev?.linkedContextIds ?? []),
500
+ createdAt: prev?.createdAt || now,
501
+ updatedAt: now,
502
+ };
503
+ if (existing >= 0) store.discussions[existing] = disc;
504
+ else store.discussions.push(disc);
505
+ _changedDiscussionNames.add(disc.name);
506
+ markDirty();
507
+ return disc;
508
+ }
509
+
510
+ // Merge incoming steps[] with the existing steps[], preserving runtime state
511
+ // (linkedContextIds, completedAt) for steps that already exist by id or order.
512
+ function mergeSteps(prevSteps, incomingSteps) {
513
+ if (!Array.isArray(incomingSteps) || incomingSteps.length === 0) return prevSteps;
514
+ return incomingSteps.map((s, i) => {
515
+ const prev = prevSteps.find(p => p.id && p.id === s.id) || prevSteps[i];
516
+ return {
517
+ id: s.id || prev?.id || randomUUID(),
518
+ title: s.title ?? prev?.title ?? '',
519
+ description: s.description ?? prev?.description ?? '',
520
+ status: s.status ?? prev?.status ?? 'pending',
521
+ order: s.order ?? prev?.order ?? i,
522
+ linkedContextIds: s.linkedContextIds ?? prev?.linkedContextIds ?? [],
523
+ completedAt: s.completedAt ?? prev?.completedAt ?? null,
524
+ };
525
+ });
526
+ }
527
+
528
+ export function updateDiscussion({ id, name, title, description, content, status, type, tags, steps, linkedContextIds, parentId, sessionId }) {
529
+ refreshFromDisk();
530
+ const store = load();
531
+ const disc = id
532
+ ? store.discussions.find(d => d.id === id)
533
+ : store.discussions.find(d => d.name === name);
534
+ if (!disc) return null;
535
+ if (title !== undefined) disc.title = truncate(title || disc.name, 80);
536
+ if (description !== undefined) disc.description = description || '';
537
+ if (content !== undefined) disc.content = truncate(content || '', MAX_CONTENT_LENGTH);
538
+ if (type !== undefined) disc.type = VALID_DISCUSSION_TYPES.has(type) ? type : disc.type;
539
+ if (status !== undefined) disc.status = VALID_DISCUSSION_STATUSES.has(status) ? status : disc.status;
540
+ if (tags !== undefined) disc.tags = normalizeTags(tags);
541
+ if (steps !== undefined) disc.steps = mergeSteps(disc.steps ?? [], steps);
542
+ if (linkedContextIds !== undefined) disc.linkedContextIds = Array.isArray(linkedContextIds) ? linkedContextIds : disc.linkedContextIds;
543
+ if (parentId !== undefined) disc.parentId = parentId || null;
544
+ if (sessionId !== undefined) disc.sessionId = sessionId || null;
545
+ disc.updatedAt = new Date().toISOString();
546
+ _changedDiscussionNames.add(disc.name);
547
+ markDirty();
548
+ return disc;
549
+ }
550
+ export function getDiscussion({ project, name, id } = {}) {
551
+ refreshFromDisk();
552
+ const store = load();
553
+ let list = store.discussions;
554
+ if (project) list = list.filter(d => d.project === project || d.project === 'global');
555
+ if (id) return list.find(d => d.id === id) || null;
556
+ if (name) return list.find(d => d.name === name) || null;
557
+ return null;
558
+ }
559
+
560
+ export function listDiscussions({ project, status, type } = {}) {
561
+ refreshFromDisk();
562
+ const store = load();
563
+ let list = store.discussions;
564
+ if (project) list = list.filter(d => d.project === project || d.project === 'global');
565
+ if (status) list = list.filter(d => d.status === status);
566
+ if (type) list = list.filter(d => d.type === type);
567
+ // Return without full content — just header + stepsSummary
568
+ return list.map(({ content: _, steps, ...rest }) => ({
569
+ ...rest,
570
+ stepsSummary: {
571
+ total: (steps || []).length,
572
+ done: (steps || []).filter(s => s.status === 'done').length,
573
+ inProgress: (steps || []).filter(s => s.status === 'in-progress').length,
574
+ },
575
+ }));
576
+ }
577
+
578
+ export function linkContextToDiscussion({ discussionId, discussionName, contextId }) {
579
+ refreshFromDisk();
580
+ const store = load();
581
+ const disc = discussionId
582
+ ? store.discussions.find(d => d.id === discussionId)
583
+ : store.discussions.find(d => d.name === discussionName);
584
+ if (!disc) return null;
585
+ if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
586
+ let changed = false;
587
+ if (!disc.linkedContextIds.includes(contextId)) {
588
+ disc.linkedContextIds.push(contextId);
589
+ disc.updatedAt = new Date().toISOString();
590
+ _changedDiscussionNames.add(disc.name);
591
+ changed = true;
592
+ }
593
+ // write discussionId back onto the context entry
594
+ const entry = store.contexts.find(c => c.id === contextId);
595
+ if (entry && entry.discussionId !== disc.id) {
596
+ entry.discussionId = disc.id;
597
+ entry.updatedAt = new Date().toISOString();
598
+ _changedContextIds.add(entry.id);
599
+ changed = true;
600
+ }
601
+ if (changed) markDirty();
602
+ return { discussionId: disc.id, contextId };
603
+ }
604
+
605
+ export function addRelation({ fromId, toId, relType = 'relates-to' }) {
606
+ refreshFromDisk();
607
+ const store = load();
608
+ const from = store.contexts.find(c => c.id === fromId);
609
+ const to = store.contexts.find(c => c.id === toId);
610
+ if (!from || !to) return null;
611
+ if (!Array.isArray(from.relations)) from.relations = [];
612
+ if (!Array.isArray(to.relatedBy)) to.relatedBy = [];
613
+ if (!from.relations.find(r => r.id === toId)) {
614
+ from.relations.push({ id: toId, relType });
615
+ from.updatedAt = new Date().toISOString();
616
+ _changedContextIds.add(from.id);
617
+ }
618
+ if (!to.relatedBy.find(r => r.id === fromId)) {
619
+ to.relatedBy.push({ id: fromId, relType });
620
+ to.updatedAt = new Date().toISOString();
621
+ _changedContextIds.add(to.id);
622
+ }
623
+ markDirty();
624
+ return { fromId, toId, relType };
625
+ }
626
+
627
+ export function getContextByDiscussion(discussionId) {
628
+ refreshFromDisk();
629
+ const store = load();
630
+ return store.contexts.filter(c => c.discussionId === discussionId);
631
+ }
632
+
633
+ export function clearDiscussionLink(contextId) {
634
+ refreshFromDisk();
635
+ const store = load();
636
+ const entry = store.contexts.find(c => c.id === contextId);
637
+ if (!entry) return null;
638
+ entry.discussionId = null;
639
+ entry.updatedAt = new Date().toISOString();
640
+ _changedContextIds.add(entry.id);
641
+ markDirty();
642
+ return entry;
643
+ }
644
+
645
+ export function deleteDiscussion({ name, id }) {
646
+ refreshFromDisk();
647
+ const store = load();
648
+ const before = store.discussions.length;
649
+ // Find the discussion first so we can clean up _changedDiscussionNames regardless of
650
+ // whether it was matched by name or id.
651
+ const toDelete = store.discussions.find(d => (id && d.id === id) || (name && d.name === name));
652
+ store.discussions = store.discussions.filter(d => {
653
+ if (id) return d.id !== id;
654
+ if (name) return d.name !== name;
655
+ return true;
656
+ });
657
+ if (store.discussions.length < before) {
658
+ if (toDelete) _changedDiscussionNames.delete(toDelete.name);
659
+ markDirty();
660
+ }
661
+ return { deleted: before - store.discussions.length };
662
+ }
663
+
664
+ export function updateDiscussionStep({ discussionName, discussionId, stepId, status, linkedContextId }) {
665
+ refreshFromDisk();
666
+ const store = load();
667
+ const disc = discussionId
668
+ ? store.discussions.find(d => d.id === discussionId)
669
+ : store.discussions.find(d => d.name === discussionName);
670
+ if (!disc) return null;
671
+ const step = (disc.steps || []).find(s => s.id === stepId);
672
+ if (!step) return null;
673
+ if (status) step.status = status;
674
+ if (status === 'done') step.completedAt = new Date().toISOString();
675
+ if (linkedContextId) {
676
+ if (!Array.isArray(step.linkedContextIds)) step.linkedContextIds = [];
677
+ if (!step.linkedContextIds.includes(linkedContextId)) step.linkedContextIds.push(linkedContextId);
678
+ if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
679
+ if (!disc.linkedContextIds.includes(linkedContextId)) disc.linkedContextIds.push(linkedContextId);
680
+ }
681
+ const allDone = disc.steps.every(s => s.status === 'done' || s.status === 'skipped');
682
+ if (allDone && disc.status !== 'done') disc.status = 'done';
683
+ disc.updatedAt = new Date().toISOString();
684
+ _changedDiscussionNames.add(disc.name);
685
+ markDirty();
686
+ return { discussion: disc, step };
687
+ }
688
+
689
+ // ── Auto-operations ───────────────────────────────────────────────────────────
690
+
691
+ export function archiveExpired(project) {
692
+ refreshFromDisk();
693
+ const store = load();
694
+ const now = new Date().toISOString();
695
+ let count = 0;
696
+ for (const entry of store.contexts) {
697
+ if (entry.expiresAt && entry.expiresAt < now && entry.status !== 'archived') {
698
+ entry.status = 'archived';
699
+ entry.updatedAt = now;
700
+ _changedContextIds.add(entry.id);
701
+ count++;
702
+ }
703
+ }
704
+ if (count > 0) markDirty();
705
+ return { archived: count };
706
+ }
707
+
708
+ // ── Exports ──────────────────────────────────────────────────────────────────
709
+
710
+ export function getStorePath() { return DATA_DIR; }
711
+ export function getGeneration() { return _generation; }
712
+ export function flushStore() { flushToDisk(); }
713
+
714
+ // ── Auto-compaction ───────────────────────────────────────────────────────────
715
+
716
+ const COMPACTION_THRESHOLD = 50;
717
+ const COMPACTION_TARGET = 30;
718
+
719
+ export function shouldCompact(project) {
720
+ return countContext(project) > COMPACTION_THRESHOLD;
721
+ }
722
+
723
+ export function compactProject(project, summaryContent) {
724
+ refreshFromDisk();
725
+ const store = load();
726
+ const proj = project || 'global';
727
+ const entries = store.contexts
728
+ .filter(c => (c.project === proj) && c.type !== 'summary')
729
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
730
+ if (entries.length < COMPACTION_TARGET) return null;
731
+ const toRemove = new Set(entries.slice(0, COMPACTION_TARGET).map(e => e.id));
732
+ const removed = store.contexts.filter(c => toRemove.has(c.id));
733
+ store.contexts = store.contexts.filter(c => !toRemove.has(c.id));
734
+ for (const e of removed) {
735
+ _deletedContextIds.add(e.id);
736
+ _changedContextIds.delete(e.id);
737
+ }
738
+ markDirty();
739
+ // save the compaction summary as a new entry
740
+ const summary = saveContext({
741
+ project: proj,
742
+ title: `Compacted ${removed.length} entries — ${new Date().toISOString().slice(0, 10)}`,
743
+ content: summaryContent,
744
+ type: 'summary',
745
+ source: 'auto',
746
+ tags: ['compaction', 'auto'],
747
+ });
748
+ return { removedCount: removed.length, summaryId: summary.id };
749
+ }
750
+
751
+ // ── Graph registry ────────────────────────────────────────────────────────────
752
+
753
+ export function saveGraph({ path, nodes, edges, communities, cached, changed, time_ms, summary }) {
754
+ refreshFromDisk();
755
+ const store = load();
756
+ const existing = store.graphs.find(g => g.path === path);
757
+ const record = {
758
+ path,
759
+ nodes: nodes ?? existing?.nodes ?? 0,
760
+ edges: edges ?? existing?.edges ?? 0,
761
+ communities: communities ?? existing?.communities ?? 0,
762
+ cached: cached ?? 0,
763
+ changed: changed ?? 0,
764
+ time_ms: time_ms ?? 0,
765
+ summary: summary || existing?.summary || '',
766
+ builtAt: new Date().toISOString(),
767
+ };
768
+ if (existing) {
769
+ Object.assign(existing, record);
770
+ } else {
771
+ store.graphs.push(record);
772
+ }
773
+ _changedGraphPaths.add(path);
774
+ markDirty();
775
+ return record;
776
+ }
777
+
778
+ export function getGraph(path) {
779
+ const store = load();
780
+ if (path) return store.graphs.find(g => g.path === path) || null;
781
+ return store.graphs;
782
+ }
783
+
784
+ export function listGraphs() {
785
+ return load().graphs;
786
+ }