context-mcp-server 1.0.8 → 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
@@ -15,11 +15,11 @@
15
15
  import {
16
16
  readFileSync, writeFileSync, mkdirSync, existsSync,
17
17
  openSync, closeSync, unlinkSync, renameSync, chmodSync, rmdirSync,
18
+ readdirSync,
18
19
  } from 'node:fs';
19
20
  import { homedir, platform } from 'node:os';
20
21
  import { join } from 'node:path';
21
22
  import { randomUUID } from 'node:crypto';
22
- import { runMigration } from './migrator.js';
23
23
 
24
24
  const DATA_DIR = process.env.CONTEXT_MCP_DIR || join(homedir(), '.context-mcp');
25
25
  const PROJECTS_DIR = join(DATA_DIR, 'projects');
@@ -74,7 +74,6 @@ let _dirtyProjects = new Set();
74
74
  let _dirty = false;
75
75
  let _writeTimer = null;
76
76
  let _generation = 0;
77
- let _migrated = false;
78
77
 
79
78
  // ── File I/O helpers ─────────────────────────────────────────────────────────
80
79
 
@@ -122,7 +121,18 @@ function _readObj(filePath, defaults) {
122
121
 
123
122
  function loadProjectsIndex() {
124
123
  if (_projectsIndex) return _projectsIndex;
125
- if (!existsSync(PROJECTS_PATH)) { _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
+ }
126
136
  try {
127
137
  const d = JSON.parse(readFileSync(PROJECTS_PATH, 'utf8'));
128
138
  _projectsIndex = Array.isArray(d.projects) ? d.projects : [];
@@ -130,21 +140,6 @@ function loadProjectsIndex() {
130
140
  return _projectsIndex;
131
141
  }
132
142
 
133
- // ── Migration ─────────────────────────────────────────────────────────────────
134
-
135
- function migrate() {
136
- if (_migrated) return;
137
- _migrated = true;
138
- runMigration({
139
- dataDir: DATA_DIR,
140
- projectsDir: PROJECTS_DIR,
141
- projectsPath: PROJECTS_PATH,
142
- slugify,
143
- flushFile: _flushFile,
144
- projectsIndex: loadProjectsIndex(),
145
- });
146
- }
147
-
148
143
  // ── Per-project data loading ─────────────────────────────────────────────────
149
144
 
150
145
  function loadProjectData(name) {
@@ -166,7 +161,6 @@ function getAllEntries(projectName) {
166
161
  return [...data.context, ...data.summary];
167
162
  }
168
163
 
169
- // Find an entry by ID, optionally scoped to a project.
170
164
  function findEntryById(id, projectHint) {
171
165
  const search = (data) => {
172
166
  for (const arr of [data.context, data.summary]) {
@@ -184,7 +178,6 @@ function findEntryById(id, projectHint) {
184
178
  const e = search(data);
185
179
  if (e) return { entry: e, projectName: name };
186
180
  }
187
- // Load all remaining projects
188
181
  const idx = loadProjectsIndex();
189
182
  for (const proj of idx) {
190
183
  if (_projectData.has(proj.name) || proj.name === projectHint) continue;
@@ -194,7 +187,6 @@ function findEntryById(id, projectHint) {
194
187
  return null;
195
188
  }
196
189
 
197
- // Remove an entry from its array in the project data.
198
190
  function removeEntryFromData(data, entry) {
199
191
  if (treeFor(entry) === 'summary') {
200
192
  data.summary = data.summary.filter(e => e.id !== entry.id);
@@ -223,7 +215,7 @@ function flushProjectToDisk(name) {
223
215
  _flushFile(discussFilePath(name), { discussions: data.discussions });
224
216
  }
225
217
 
226
- function flushToDisk() {
218
+ export function flushToDisk() {
227
219
  if (!_dirty) return;
228
220
  _writeTimer = null;
229
221
 
@@ -244,11 +236,8 @@ process.on('exit', flushToDisk);
244
236
  process.on('SIGINT', () => { flushToDisk(); process.exit(); });
245
237
  process.on('SIGTERM', () => { flushToDisk(); process.exit(); });
246
238
 
247
- // ── Initialise: run migration lazily on first access ─────────────────────────
248
-
249
239
  function init() {
250
240
  loadProjectsIndex();
251
- migrate();
252
241
  }
253
242
 
254
243
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -283,6 +272,8 @@ function compactEntry(e) {
283
272
  updatedAt: e.updatedAt || null,
284
273
  preview: truncate(e.content, PREVIEW_LENGTH),
285
274
  };
275
+ if (e.why) compact.why = e.why;
276
+ if (e.outcome) compact.outcome = e.outcome;
286
277
  if (e.files && e.files.length) compact.files = e.files;
287
278
  if (e.codeRefs && e.codeRefs.length) compact.codeRefs = e.codeRefs;
288
279
  if (e.expiresAt) compact.expiresAt = e.expiresAt;
@@ -291,7 +282,26 @@ function compactEntry(e) {
291
282
 
292
283
  // ── Context entries ──────────────────────────────────────────────────────────
293
284
 
294
- export function saveContext({ project, content, tags = [], source = 'user', title = '',
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 = '',
295
305
  type = 'note', status = 'active', files = [], codeRefs = [],
296
306
  sessionId = null, parentId = null, expiresAt = null, rootPath = null }) {
297
307
  init();
@@ -299,6 +309,9 @@ export function saveContext({ project, content, tags = [], source = 'user', titl
299
309
  ensureProject(projectName, rootPath || undefined);
300
310
  const data = loadProjectData(projectName);
301
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);
302
315
  const entry = {
303
316
  id: randomUUID(),
304
317
  project: projectName,
@@ -306,29 +319,31 @@ export function saveContext({ project, content, tags = [], source = 'user', titl
306
319
  parentId: parentId || sessionId || `project:${projectName}`,
307
320
  nodeType: 'entry',
308
321
  version: 1,
309
- title: truncate(title, 60),
310
- content: truncate(content, MAX_CONTENT_LENGTH),
311
- type,
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,
312
327
  status,
313
- tags: normalizeTags(tags),
328
+ tags: normalizedTags,
314
329
  source: normalizeSource(source),
315
- files: Array.isArray(files) ? files : [],
330
+ files: normalizedFiles,
316
331
  codeRefs: Array.isArray(codeRefs) ? codeRefs : [],
332
+ importance: computeImportance({ files: normalizedFiles, why, outcome, tags: normalizedTags, type: validatedType }),
317
333
  discussionId: null,
318
334
  createdAt: now,
319
335
  updatedAt: null,
320
336
  expiresAt: expiresAt || null,
321
337
  };
322
338
  const tree = treeFor(entry);
323
- if (tree === 'graph') data.graph.entries.push(entry);
324
- else if (tree === 'summary') data.summary.push(entry);
339
+ if (tree === 'summary') data.summary.push(entry);
325
340
  else data.context.push(entry);
326
341
  _dirtyProjects.add(projectName);
327
342
  markDirty();
328
343
  return entry;
329
344
  }
330
345
 
331
- export function updateContext({ id, content, title, tags, type, status, files, codeRefs, sessionId, parentId, expiresAt }) {
346
+ export function updateContext({ id, content, why, outcome, title, tags, type, status, files, codeRefs, sessionId, parentId, expiresAt }) {
332
347
  init();
333
348
  const found = findEntryById(id);
334
349
  if (!found) return null;
@@ -337,9 +352,11 @@ export function updateContext({ id, content, title, tags, type, status, files, c
337
352
 
338
353
  const oldTree = treeFor(entry);
339
354
  if (content !== undefined) entry.content = truncate(content, MAX_CONTENT_LENGTH);
340
- if (title !== undefined) entry.title = truncate(title, 60);
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);
341
358
  if (tags !== undefined) entry.tags = normalizeTags(tags);
342
- if (type !== undefined) entry.type = type;
359
+ if (type !== undefined) entry.type = VALID_TYPES.has(type) ? type : entry.type;
343
360
  if (status !== undefined) entry.status = status;
344
361
  if (files !== undefined) entry.files = Array.isArray(files) ? files : [];
345
362
  if (codeRefs !== undefined) entry.codeRefs = Array.isArray(codeRefs) ? codeRefs : [];
@@ -353,11 +370,8 @@ export function updateContext({ id, content, title, tags, type, status, files, c
353
370
  const newTree = treeFor(entry);
354
371
  if (newTree !== oldTree) {
355
372
  removeEntryFromData(data, entry);
356
- // Re-add with updated tree
357
- const tempEntry = { ...entry };
358
- if (newTree === 'graph') data.graph.entries.push(tempEntry);
359
- else if (newTree === 'summary') data.summary.push(tempEntry);
360
- else data.context.push(tempEntry);
373
+ if (newTree === 'summary') data.summary.push(entry);
374
+ else data.context.push(entry);
361
375
  }
362
376
 
363
377
  _dirtyProjects.add(projectName);
@@ -478,7 +492,7 @@ export function deleteProject(nameOrId) {
478
492
 
479
493
  // Count before removing
480
494
  const data = _projectData.get(projectName) || loadProjectData(projectName);
481
- const ctxCount = data.context.length + data.graph.entries.length + data.summary.length;
495
+ const ctxCount = data.context.length + data.summary.length;
482
496
  const discCount = data.discussions.length;
483
497
 
484
498
  // Remove project directory from disk
@@ -605,7 +619,7 @@ export function saveDiscussion({ name, title, description, content, project, tag
605
619
  project: project !== undefined ? (project || 'global') : (prev?.project ?? 'global'),
606
620
  sessionId: sessionId !== undefined ? (sessionId || null) : (prev?.sessionId ?? null),
607
621
  parentId: parentId !== undefined ? (parentId || null) : (prev?.parentId ?? null),
608
- title: title !== undefined ? truncate(title || name, 80) : (prev?.title ?? name),
622
+ title: title !== undefined ? truncate(title || name, 120) : (prev?.title ?? name),
609
623
  description: description !== undefined ? (description || '') : (prev?.description ?? ''),
610
624
  content: content !== undefined ? truncate(content || '', MAX_CONTENT_LENGTH) : (prev?.content ?? ''),
611
625
  type: type !== undefined ? (VALID_DISCUSSION_TYPES.has(type) ? type : 'plan') : (prev?.type ?? 'plan'),
@@ -651,7 +665,7 @@ export function updateDiscussion({ id, name, title, description, content, status
651
665
  if (found) { disc = found; projName = pName; break; }
652
666
  }
653
667
  if (!disc) return null;
654
- if (title !== undefined) disc.title = truncate(title || disc.name, 80);
668
+ if (title !== undefined) disc.title = truncate(title || disc.name, 120);
655
669
  if (description !== undefined) disc.description = description || '';
656
670
  if (content !== undefined) disc.content = truncate(content || '', MAX_CONTENT_LENGTH);
657
671
  if (type !== undefined) disc.type = VALID_DISCUSSION_TYPES.has(type) ? type : disc.type;
@@ -747,28 +761,6 @@ export function linkContextToDiscussion({ discussionId, discussionName, contextI
747
761
  return { discussionId: disc.id, contextId };
748
762
  }
749
763
 
750
- export function getContextByDiscussion(discussionId) {
751
- init();
752
- const idx = loadProjectsIndex();
753
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
754
- const results = [];
755
- for (const name of seen) {
756
- results.push(...getAllEntries(name).filter(c => c.discussionId === discussionId));
757
- }
758
- return results;
759
- }
760
-
761
- export function clearDiscussionLink(contextId) {
762
- init();
763
- const found = findEntryById(contextId);
764
- if (!found) return null;
765
- found.entry.discussionId = null;
766
- found.entry.updatedAt = new Date().toISOString();
767
- _dirtyProjects.add(found.projectName);
768
- markDirty();
769
- return found.entry;
770
- }
771
-
772
764
  export function deleteDiscussion({ name, id }) {
773
765
  init();
774
766
  const idx = loadProjectsIndex();
@@ -790,38 +782,6 @@ export function deleteDiscussion({ name, id }) {
790
782
  return { deleted: 0 };
791
783
  }
792
784
 
793
- export function updateDiscussionStep({ discussionName, discussionId, stepId, status, linkedContextId }) {
794
- init();
795
- let disc = null;
796
- let projName = null;
797
- const idx = loadProjectsIndex();
798
- const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
799
- for (const pName of seen) {
800
- const d = loadProjectData(pName);
801
- const found = discussionId
802
- ? d.discussions.find(x => x.id === discussionId)
803
- : d.discussions.find(x => x.name === discussionName);
804
- if (found) { disc = found; projName = pName; break; }
805
- }
806
- if (!disc) return null;
807
- const step = (disc.steps || []).find(s => s.id === stepId);
808
- if (!step) return null;
809
- if (status) step.status = status;
810
- if (status === 'done') step.completedAt = new Date().toISOString();
811
- if (linkedContextId) {
812
- if (!Array.isArray(step.linkedContextIds)) step.linkedContextIds = [];
813
- if (!step.linkedContextIds.includes(linkedContextId)) step.linkedContextIds.push(linkedContextId);
814
- if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
815
- if (!disc.linkedContextIds.includes(linkedContextId)) disc.linkedContextIds.push(linkedContextId);
816
- }
817
- const allDone = disc.steps.every(s => s.status === 'done' || s.status === 'skipped');
818
- if (allDone && disc.status !== 'done') disc.status = 'done';
819
- disc.updatedAt = new Date().toISOString();
820
- _dirtyProjects.add(projName);
821
- markDirty();
822
- return { discussion: disc, step };
823
- }
824
-
825
785
  // ── Auto-operations ───────────────────────────────────────────────────────────
826
786
 
827
787
  export function archiveExpired(project) {
@@ -862,21 +822,39 @@ const COMPACTION_THRESHOLD = 20;
862
822
  const COMPACTION_TARGET = 30;
863
823
 
864
824
  export function shouldCompact(project) {
865
- return countContext(project) > COMPACTION_THRESHOLD;
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;
866
831
  }
867
832
 
868
- export function compactProject(project, summaryContent) {
833
+ export function compactProject(project, summaryContent, { skipSummaryEntry = false } = {}) {
869
834
  init();
870
835
  const proj = project || 'global';
871
836
  const data = loadProjectData(proj);
837
+ const now = Date.now();
872
838
  const entries = data.context
873
- .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
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
+ });
874
848
  if (entries.length < COMPACTION_TARGET) return null;
875
849
  const toRemove = new Set(entries.slice(0, COMPACTION_TARGET).map(e => e.id));
876
850
  const removed = entries.filter(e => toRemove.has(e.id));
877
851
  for (const entry of removed) removeEntryFromData(data, entry);
878
852
  _dirtyProjects.add(proj);
879
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
+ }
880
858
  const summary = saveContext({
881
859
  project: proj,
882
860
  title: `Compacted ${removed.length} entries — ${new Date().toISOString().slice(0, 10)}`,
@@ -892,7 +870,6 @@ export function compactProject(project, summaryContent) {
892
870
 
893
871
  export function saveGraph({ path, nodes, edges, communities, cached, changed, time_ms, summary }) {
894
872
  init();
895
- // Find project by rootPath matching graph path
896
873
  const idx = loadProjectsIndex();
897
874
  const proj = idx.find(p => normPath(p.rootPath) === normPath(path));
898
875
  const projName = proj ? proj.name : 'global';
package/src/search.js CHANGED
@@ -1,13 +1,71 @@
1
- /**
2
- * search.js — unified search entry point
3
- *
4
- * Single function replaces direct calls to searchContext / vectorSearch / findRelated
5
- * across index.js, cli.js, and error_check. db.js + vector.js stay as low-level impls.
6
- */
7
-
8
1
  import { getContext, searchContext } from './db.js';
9
2
  import { vectorSearch, findRelated } from './vector.js';
10
3
 
4
+ // ── Vocabulary cache (lazy singleton per process) ─────────────────────────────
5
+
6
+ let _vocabCache = null;
7
+
8
+ function buildVocab(entries) {
9
+ const vocab = new Set();
10
+ for (const e of entries) {
11
+ const text = `${e.title || ''} ${e.content || ''}`.toLowerCase();
12
+ for (const w of text.split(/\W+/)) {
13
+ if (w.length > 2) vocab.add(w);
14
+ }
15
+ }
16
+ return vocab;
17
+ }
18
+
19
+ // ── Levenshtein fuzzy correction ──────────────────────────────────────────────
20
+
21
+ function levenshtein(a, b) {
22
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
23
+ for (let i = 0; i < a.length; i++) {
24
+ const curr = [i + 1];
25
+ for (let j = 0; j < b.length; j++) {
26
+ curr.push(Math.min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (a[i] === b[j] ? 0 : 1)));
27
+ }
28
+ prev.splice(0, prev.length, ...curr);
29
+ }
30
+ return prev[b.length];
31
+ }
32
+
33
+ function maxEditDist(len) {
34
+ if (len <= 4) return 1;
35
+ if (len <= 12) return 2;
36
+ return 3;
37
+ }
38
+
39
+ function fuzzyCorrect(word, vocab) {
40
+ const max = maxEditDist(word.length);
41
+ let bestWord = word;
42
+ let bestDist = max + 1;
43
+ for (const candidate of vocab) {
44
+ if (Math.abs(candidate.length - word.length) > max) continue;
45
+ const dist = levenshtein(word, candidate);
46
+ if (dist < bestDist) { bestDist = dist; bestWord = candidate; }
47
+ }
48
+ return bestDist <= max ? bestWord : word;
49
+ }
50
+
51
+ // ── Smart snippet (window centered on first match position) ───────────────────
52
+
53
+ function snippet(text, terms, windowSize = 200) {
54
+ if (!text) return text;
55
+ const lower = text.toLowerCase();
56
+ let bestPos = -1;
57
+ for (const term of terms) {
58
+ const idx = lower.indexOf(term.toLowerCase());
59
+ if (idx !== -1 && (bestPos === -1 || idx < bestPos)) bestPos = idx;
60
+ }
61
+ if (bestPos === -1) return text.slice(0, windowSize);
62
+ const start = Math.max(0, bestPos - Math.floor(windowSize / 3));
63
+ const end = Math.min(text.length, start + windowSize);
64
+ return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
65
+ }
66
+
67
+ // ── Unified search ────────────────────────────────────────────────────────────
68
+
11
69
  /**
12
70
  * @param {Object} opts
13
71
  * @param {string} opts.query - search query (keyword/semantic)
@@ -21,7 +79,14 @@ export function search({ query, mode = 'semantic', project, limit = 10, id, comp
21
79
  switch (mode) {
22
80
  case 'keyword': {
23
81
  if (!query) throw new Error('query required for keyword search');
24
- return searchContext({ query, project, limit, compact });
82
+ if (!_vocabCache) {
83
+ _vocabCache = buildVocab(getContext({ project, limit: 500 }));
84
+ }
85
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
86
+ const corrected = terms.map(t => fuzzyCorrect(t, _vocabCache));
87
+ const correctedQuery = corrected.join(' ');
88
+ const results = searchContext({ query: correctedQuery, project, limit, compact: false });
89
+ return results.map(e => ({ ...e, content: snippet(e.content, corrected) }));
25
90
  }
26
91
  case 'semantic': {
27
92
  if (!query) throw new Error('query required for semantic search');
@@ -33,7 +98,6 @@ export function search({ query, mode = 'semantic', project, limit = 10, id, comp
33
98
  const all = getContext({ limit: 1000 });
34
99
  const target = all.find(e => e.id === id || e.id.startsWith(id));
35
100
  if (!target) throw new Error(`No entry found with id starting "${id}"`);
36
- // explicit relations first, semantic enrichment for remainder
37
101
  const explicitIds = new Set([
38
102
  ...(target.relations || []).map(r => r.id),
39
103
  ...(target.relatedBy || []).map(r => r.id),
@@ -1,80 +1,90 @@
1
1
  # Context-MCP — Codex CLI Usage Guide
2
2
 
3
3
  Persistent memory + codebase knowledge graph.
4
- Every conversation starts with `context.resume`. Every codebase question uses `codegraph_query`. Files only read for bugs/logic.
4
+ `context.resume` starts every session. `codegraph_arch` shows module structure. `codegraph_query` finds specific symbols. Files only for bugs/logic.
5
5
 
6
6
  ---
7
7
 
8
8
  ## 1. Start of Every Conversation (MANDATORY)
9
9
 
10
- Call the `context` MCP tool with `action: "resume"`, `project: "<project-name>"` **before anything else**.
10
+ Call `context` MCP tool: `action:"resume"`, `project:"<project>"` before anything else.
11
11
 
12
- Returns:
13
- - `recentEntries` — decisions, bugs, notes from previous conversations
14
- - `activePlans` — active AI-created plans for this project
15
- - `codegraph` — `{ built: true/false, nodes, edges, communities }`
12
+ Returns: `recentEntries`, `activePlans`, `codegraph`, `stats.totalEntries`.
16
13
 
17
- Then:
18
- - `codegraph.built: true` → use `codegraph_query` before reading any files
19
- - `codegraph.built: false` → call `codegraph_build(path)` first, then proceed
14
+ - `codegraph.built: true` → use graph tools before reading files
15
+ - `codegraph.built: false` → call `codegraph_build(path)` first
16
+ - `stats.totalEntries ≥ 20` → write compaction summary FIRST (see Rule 4)
17
+ - `activePlans` non-empty → read them before starting new work
20
18
 
21
19
  ---
22
20
 
23
- ## 2. When to Auto-Save Context
21
+ ## 2. Save Triggers (MANDATORY)
24
22
 
25
- **After graph build or rebuild** every time `codegraph_build` completes:
26
- ```
27
- context.save type: "note" title: "ContextGraph built — <project>"
28
- content: "nodes: X | edges: Y | communities: Z"
29
- ```
23
+ Call `context.save` with `type: "note"` after finishing anything worth keeping:
30
24
 
31
- **User explicitly asks** "save this", "remember this", "note that" → save immediately.
25
+ | Trigger | Required fields |
26
+ |---|---|
27
+ | Task / fix / feature complete | title, why, outcome, files[] |
28
+ | Decision made | title, why, outcome |
29
+ | Discovery / constraint / gotcha | title, content |
30
+ | Config / env / deploy info | title, content |
31
+ | Graph build complete | title, content (nodes/edges count) |
32
+ | User says "save this" | title, content |
33
+ | "compact now" / "compress memory" | `type:"compaction"`, full session summary |
32
34
 
33
- **During plan / implementation / discussion / research** save only when genuinely valuable:
35
+ **Do NOT save:** routine reads, search results, explanations of existing code.
36
+
37
+ ---
34
38
 
35
- | What happened | Type |
36
- |--------------|------|
37
- | Approach / library / pattern decided | `decision` |
38
- | Bug found, root cause known, or fixed | `bug` |
39
- | Gotcha, constraint, discovery, structure understood | `note` |
40
- | Config / env var / secret / deploy step | `config` |
39
+ ## 3. Plans (MANDATORY for multi-file work)
41
40
 
42
- Do NOT save: routine reads, search results, temporary debugging dead-ends.
43
- **Making any kind of plan** → call `plan.save` immediately with the plan summary and `planDir` pointing to your platform's plans folder.
44
- Need past info → `search` before asking. Always pass `project`.
41
+ **Create a plan when:** editing 2+ files, multi-step implementation, refactor, multi-file bug fix.
42
+
43
+ 1. Call `plan.save` with name, content, project before starting
44
+ 2. Call `plan.update status:"done"` when complete — deletes the plan
45
+
46
+ Check `activePlans` on resume — don't create duplicates.
45
47
 
46
48
  ---
47
49
 
48
- ## 3. ContextGraph Pipeline
50
+ ## 4. Auto-Summary at ≥ 20 Entries (MANDATORY)
49
51
 
50
- > The knowledge graph is also called **ContextGraph**. The MCP tools use the `codegraph_*` prefix — both names refer to the same thing.
52
+ When `totalEntries 20`, call `context.save` BEFORE the user's task:
51
53
 
52
- ### Step 1 — Build (once, fast, local)
53
54
  ```
54
- codegraph_build(path) → AST graph: functions, classes, imports, edges
55
+ type: "compaction" title: "Session summary <YYYY-MM-DD>"
56
+ content: "<what was built, decided, broke, current state>"
57
+ project: "<project>"
55
58
  ```
56
59
 
57
- ### Step 2 — Query (free, instant)
58
- ```
59
- codegraph_query(path, question) → fetch any details about the codebase
60
- codegraph_path(path, from, to) → shortest path
61
- codegraph_nodes(path, type) → list nodes by type
62
- codegraph_report(path) → full graph analysis
63
- ```
60
+ ---
61
+
62
+ ## 5. Search Before Asking
63
+
64
+ Call `search` before asking user to re-explain past work.
64
65
 
65
66
  ---
66
67
 
67
- ## 4. Graph vs File
68
+ ## 6. ContextGraph Tools
69
+
70
+ ```
71
+ codegraph_build(path) → build AST graph (run once)
72
+ codegraph_arch(path, limit?) → module map: files, exports, imports
73
+ codegraph_query(path, question?, node?) → find symbol or answer structural question
74
+ codegraph_nodes(path, type) → list all nodes of a type
75
+ codegraph_report(path) → structural analysis
76
+ ```
68
77
 
69
- **Graph** use for any question about what exists: finding functions, classes, files, dependencies, callers, imports, paths between concepts.
70
- **File** — bugs, logic, tracing unexpected behavior.
78
+ Use `codegraph_arch` first. Never read files for structure questions.
71
79
 
72
80
  ---
73
81
 
74
- ## 5. Rules
82
+ ## 7. Rules
75
83
 
76
- 1. **`context.resume` first** — before any tool or response
77
- 2. **Always pass `project`**
78
- 3. **`search` before asking**if user references past work
79
- 4. **`codegraph_query` before reading files**graph is faster and cheaper
80
- 5. **Read files for bugs/logic only**
84
+ 1. `context.resume` first — before any tool or response
85
+ 2. Always pass `project`
86
+ 3. Save on task complete `why` + `outcome` + `files`
87
+ 4. Compaction at 20 entries before starting task
88
+ 5. Plan before multi-file work — `status:"done"` deletes it
89
+ 6. Search before asking about past work
90
+ 7. Graph tools before files