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.
@@ -6,6 +6,7 @@
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { saveGraph, saveContext, updateContext, getContext, flushToDisk } from '../db.js';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const REPO_ROOT = join(__dirname, '..', '..');
@@ -82,16 +83,18 @@ export const definitions = [
82
83
  },
83
84
  },
84
85
  {
85
- name: 'codegraph_path',
86
- description: 'Find the shortest relationship path between two concepts in the graph.',
86
+ name: 'codegraph_arch',
87
+ description:
88
+ 'Return a module map of the project — every file with its exported functions/classes and its imports. ' +
89
+ 'Use this to understand project structure without reading any files. ' +
90
+ 'Call after codegraph_build. Much faster than reading each file individually.',
87
91
  inputSchema: {
88
92
  type: 'object',
89
93
  properties: {
90
- path: { type: 'string' },
91
- from: { type: 'string' },
92
- to: { type: 'string' },
94
+ path: { type: 'string', description: 'Project root' },
95
+ limit: { type: 'integer', description: 'Max files in output (default 100)' },
93
96
  },
94
- required: ['path', 'from', 'to'],
97
+ required: ['path'],
95
98
  },
96
99
  },
97
100
  ];
@@ -103,46 +106,46 @@ export function handle(name, args, state) {
103
106
 
104
107
  // Persist graph metadata + save/update a context entry as a visible build record
105
108
  if (name === 'codegraph_build' && result.success) {
106
- import('../db.js').then(({ saveGraph, saveContext, updateContext, getContext }) => {
107
- saveGraph({
108
- path: args.path,
109
- nodes: result.nodes,
110
- edges: result.edges,
111
- communities: result.communities,
112
- cached: result.cached,
113
- changed: result.changed,
114
- time_ms: result.time_ms,
115
- summary: result.summary || '',
116
- });
109
+ saveGraph({
110
+ path: args.path,
111
+ nodes: result.nodes,
112
+ edges: result.edges,
113
+ communities: result.communities,
114
+ cached: result.cached,
115
+ changed: result.changed,
116
+ time_ms: result.time_ms,
117
+ summary: result.summary || '',
118
+ });
119
+ flushToDisk(); // write graph.json to disk immediately so ctx list sees it
117
120
 
118
- const inferredProject = args.path
119
- ? args.path.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()
120
- : null;
121
- const project = state?.sessionProject || inferredProject || null;
122
- const title = `CodeGraph — ${args.path}`;
123
- const content = [
124
- `nodes: ${result.nodes} | edges: ${result.edges} | communities: ${result.communities}`,
125
- `cached: ${result.cached} | changed: ${result.changed} | time: ${result.time_ms}ms`,
126
- result.summary || '',
127
- ].filter(Boolean).join('\n');
121
+ const inferredProject = args.path
122
+ ? args.path.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()
123
+ : null;
124
+ const project = state?.sessionProject || inferredProject || null;
125
+ const title = `ContextGraph built — ${args.path}`;
126
+ const content = [
127
+ `nodes: ${result.nodes} | edges: ${result.edges} | communities: ${result.communities}`,
128
+ `cached: ${result.cached} | changed: ${result.changed} | time: ${result.time_ms}ms`,
129
+ result.summary || '',
130
+ ].filter(Boolean).join('\n');
128
131
 
129
- const existing = getContext({ project, tags: ['codegraph'], limit: 100 })
130
- .find(e => e.title === title);
132
+ // Search all projects same path always produces same title regardless of session
133
+ const existing = getContext({ tags: ['codegraph'], limit: 100 })
134
+ .find(e => e.title === title);
131
135
 
132
- if (existing) {
133
- updateContext({ id: existing.id, content, status: 'active' });
134
- } else {
135
- saveContext({
136
- project,
137
- sessionId: state?.sessionId || null,
138
- title,
139
- content,
140
- type: 'architecture',
141
- source: 'auto',
142
- tags: ['codegraph', 'graph-build'],
143
- });
144
- }
145
- }).catch(() => {});
136
+ if (existing) {
137
+ updateContext({ id: existing.id, content, status: 'active' });
138
+ } else {
139
+ saveContext({
140
+ project,
141
+ sessionId: state?.sessionId || null,
142
+ title,
143
+ content,
144
+ type: 'note',
145
+ source: 'auto',
146
+ tags: ['codegraph', 'graph-build'],
147
+ });
148
+ }
146
149
  }
147
150
 
148
151
  return result;
@@ -25,22 +25,24 @@ function autoDigest(entries, project) {
25
25
  export const definition = {
26
26
  name: 'context',
27
27
  description:
28
- `Factual memory — record what happened, what was decided, what broke, what was built.\n` +
29
- `• "resume" — START HERE every conversation. Loads recent context, active discussions, and graph status for a project.\n` +
30
- `• "save" — Store a note, decision, bug, or code snippet. Auto-deduplicates.\n` +
31
- `• "get" — Load entries. Pass id/ids to fetch specific ones, or project/tags/limit for recent.\n` +
32
- `• "update" — Edit an existing entry by id (any field).\n` +
33
- `• "delete" — Remove one entry (id) or multiple at once (ids: [...]).\n` +
34
- `• "list_projects"— Show all projects and entry counts.`,
28
+ `Factual memory — decisions, bugs, notes, discoveries.\n` +
29
+ `• "resume" — call first every session. Returns recent entries, active plans, graph status.\n` +
30
+ `• "save" — store an entry. Auto-deduplicates by content similarity.\n` +
31
+ `• "get" — fetch by id/ids, or filter by project/tags/limit.\n` +
32
+ `• "update" — edit an entry by id.\n` +
33
+ `• "delete" — remove one entry (id) or several (ids: [...]).\n` +
34
+ `• "list_projects"— list all projects and entry counts.`,
35
35
  inputSchema: {
36
36
  type: 'object',
37
37
  properties: {
38
38
  action: { type: 'string', enum: ['resume', 'save', 'get', 'update', 'delete', 'list_projects'] },
39
39
  content: { type: 'string' },
40
- title: { type: 'string' },
40
+ title: { type: 'string', description: 'Up to 120 chars' },
41
+ why: { type: 'string', description: 'Why it mattered' },
42
+ outcome: { type: 'string', description: 'What the result was' },
41
43
  project: { type: 'string' },
42
44
  rootPath: { type: 'string', description: 'Absolute path to the project root directory. Stored on first call and used to sandbox file/git tool access.' },
43
- type: { type: 'string', enum: ['decision', 'bug', 'note', 'config'] },
45
+ type: { type: 'string', enum: ['note', 'compaction'] },
44
46
  status: { type: 'string', enum: ['active', 'archived'] },
45
47
  tags: { type: 'array', items: { type: 'string' } },
46
48
  source: { type: 'string', enum: ['user', 'ai-summary', 'file', 'web', 'cli', 'auto'] },
@@ -49,8 +51,8 @@ export const definition = {
49
51
  expiresAt: { type: 'string' },
50
52
  limit: { type: 'number' },
51
53
  includeArchived: { type: 'boolean' },
52
- id: { type: 'string', description: 'Single entry ID (get/update/delete)' },
53
- ids: { type: 'array', items: { type: 'string' }, description: 'Multiple entry IDs — fetch or delete several at once' },
54
+ id: { type: 'string', description: 'Single entry ID' },
55
+ ids: { type: 'array', items: { type: 'string' }, description: 'Multiple entry IDs' },
54
56
  },
55
57
  required: ['action'],
56
58
  },
@@ -78,18 +80,27 @@ export async function handle(args, state) {
78
80
  const proj = args.project || null;
79
81
  archiveExpired(proj);
80
82
 
81
- // Set project on state so autoLink works for subsequent saves
82
83
  if (proj) state.sessionProject = proj;
83
84
 
84
- // Store rootPath with project (first time only) and load it onto session state.
85
- // Auto-detect from git if neither provided nor previously stored.
86
85
  const storedRoot = proj ? getProjectRoot(proj) : null;
87
86
  const resolvedRoot = args.rootPath || storedRoot || detectGitRoot() || null;
88
87
  if (proj) ensureProject(proj, resolvedRoot || undefined);
89
88
  state.projectRootPath = resolvedRoot;
90
89
 
91
- const entries = getContext({ project: proj, limit: 15, compact: true })
90
+ const rawEntries = getContext({ project: proj, limit: 15, compact: false })
92
91
  .filter(e => e.status !== 'archived');
92
+ // Newest 5 get full content; older entries get a lightweight preview
93
+ const entries = rawEntries.map((e, i) => {
94
+ if (i < 5) return e;
95
+ return {
96
+ id: e.id, project: e.project, title: e.title, type: e.type,
97
+ status: e.status, tags: e.tags, source: e.source,
98
+ createdAt: e.createdAt, updatedAt: e.updatedAt,
99
+ ...(e.why ? { why: e.why } : {}),
100
+ ...(e.outcome ? { outcome: e.outcome } : {}),
101
+ preview: (e.content || '').slice(0, 200),
102
+ };
103
+ });
93
104
  const discussions = listDiscussions({ project: proj, status: 'active' });
94
105
  const allGraphs = listGraphs();
95
106
  const np = p => (p || '').toLowerCase().replace(/\\/g, '/');
@@ -103,7 +114,6 @@ export async function handle(args, state) {
103
114
  : null;
104
115
  const totalEntries = countContext(proj);
105
116
 
106
- // Auto-restore single active discussion
107
117
  if (discussions.length === 1) state.discussionId = discussions[0].id;
108
118
 
109
119
  const digest = totalEntries > 10
@@ -127,7 +137,7 @@ export async function handle(args, state) {
127
137
  ? `All file and git operations are sandboxed to: ${state.projectRootPath} — do not use paths outside this root.`
128
138
  : 'No project root configured — pass rootPath to restrict file/git access to a directory.',
129
139
  hint: graphStatus.built
130
- ? `Graph ready (${graphStatus.nodes} nodes). Use codegraph_query for structural questions.`
140
+ ? `Graph ready (${graphStatus.nodes} nodes). Use codegraph_arch for module map, codegraph_query for specific symbol lookups.`
131
141
  : 'No graph built yet. Call codegraph_build on the project root to enable graph queries.',
132
142
  };
133
143
  }
@@ -135,7 +145,6 @@ export async function handle(args, state) {
135
145
  case 'save': {
136
146
  if (!args.content) throw new Error('content is required for save');
137
147
  if (!args.project && state.sessionProject) args = { ...args, project: state.sessionProject };
138
- // Auto-detect and store project root if not yet configured
139
148
  if (args.project) {
140
149
  const existing = getProjectRoot(args.project);
141
150
  if (!existing) {
@@ -146,7 +155,6 @@ export async function handle(args, state) {
146
155
  }
147
156
  }
148
157
  }
149
- // Validate file paths in files[] and codeRefs[] stay within project root
150
158
  if (state.projectRootPath) {
151
159
  if (Array.isArray(args.files)) {
152
160
  args.files.forEach(f => { if (f.path) guardPath(f.path, state.projectRootPath); });
@@ -162,6 +170,8 @@ export async function handle(args, state) {
162
170
  id: dupe.id, content: args.content,
163
171
  title: args.title || dupe.title, tags: args.tags || dupe.tags,
164
172
  type: args.type || dupe.type, status: args.status || dupe.status,
173
+ why: args.why !== undefined ? args.why : dupe.why,
174
+ outcome: args.outcome !== undefined ? args.outcome : dupe.outcome,
165
175
  expiresAt: args.expiresAt !== undefined ? args.expiresAt : dupe.expiresAt,
166
176
  files: args.files || dupe.files, codeRefs: args.codeRefs || dupe.codeRefs,
167
177
  });
@@ -172,13 +182,21 @@ export async function handle(args, state) {
172
182
  const entry = saveContext({ ...args, rootPath: state.projectRootPath || undefined });
173
183
  fireAutoLink(entry.id, state);
174
184
 
175
- // Auto-compact when too many entries accumulate
185
+ // Auto-compact when too many entries accumulate.
186
+ // If the AI just saved a compaction entry, use that content as the summary
187
+ // instead of running TF-IDF on top of it.
176
188
  let compaction = null;
177
189
  if (shouldCompact(entry.project)) {
178
- const old = getContext({ project: entry.project, limit: 500 });
179
- const { summarizeEntries: summarize } = await import('../summarizer.js');
180
- const cs = summarize(old.slice(old.length - 30), { project: entry.project || 'global', sessionLabel: 'auto-compaction', topN: 5 });
181
- compaction = compactProject(entry.project, cs);
190
+ if (entry.type === 'compaction') {
191
+ // AI wrote a proper summary compact old entries without creating a duplicate summary
192
+ compaction = compactProject(entry.project, entry.content, { skipSummaryEntry: true });
193
+ } else {
194
+ // AI didn't write a summary — fall back to TF-IDF extractive summarization
195
+ const old = getContext({ project: entry.project, limit: 500 });
196
+ const { summarizeEntries: summarize } = await import('../summarizer.js');
197
+ const summaryContent = summarize(old.slice(old.length - 30), { project: entry.project || 'global', sessionLabel: 'auto-compaction', topN: 5 });
198
+ compaction = compactProject(entry.project, summaryContent);
199
+ }
182
200
  }
183
201
 
184
202
  return { success: true, id: entry.id, deduplicated: false,
package/src/tools/plan.js CHANGED
@@ -19,13 +19,12 @@ function writePlanFile(planDir, name, content, title) {
19
19
  export const definition = {
20
20
  name: 'plan',
21
21
  description:
22
- `AI plan storage — auto-call this tool whenever you create any kind of plan.\n` +
23
- `Saves a summary of the plan to the project store and writes a .md file to planDir.\n` +
24
- `• "save" Store a new plan or overwrite an existing one by name. Pass planDir to write a file.\n` +
25
- `• "update" Patch title/content/status on an existing plan.\n` +
26
- `• "get" Retrieve a plan by name or id (full content).\n` +
27
- `• "list" List all plans for the project.\n` +
28
- `• "delete" — Remove a plan by name or id.`,
22
+ `Plan storage — saves to project store, optionally writes a .md file to planDir.\n` +
23
+ `• "save" — store a new plan or overwrite by name.\n` +
24
+ `• "update" patch title/content/status.\n` +
25
+ `• "get" retrieve a plan by name or id.\n` +
26
+ `• "list" list all plans for the project.\n` +
27
+ `• "delete" remove a plan by name or id.`,
29
28
  inputSchema: {
30
29
  type: 'object',
31
30
  properties: {
@@ -33,7 +32,7 @@ export const definition = {
33
32
  name: { type: 'string', description: 'Short slug-style identifier for the plan, e.g. "auth-refactor"' },
34
33
  id: { type: 'string' },
35
34
  project: { type: 'string' },
36
- title: { type: 'string', description: 'Human-readable plan title' },
35
+ title: { type: 'string', description: 'Plan title' },
37
36
  content: { type: 'string', description: 'Full plan summary in markdown' },
38
37
  status: { type: 'string', enum: ['active', 'done'] },
39
38
  tags: { type: 'array', items: { type: 'string' } },
@@ -83,6 +82,11 @@ export async function handle(args, state) {
83
82
 
84
83
  case 'update': {
85
84
  if (!args.name && !args.id) throw new Error('name or id is required for update');
85
+ if (args.status === 'done') {
86
+ const result = deleteDiscussion({ name: args.name, id: args.id });
87
+ state.discussionId = null;
88
+ return { success: true, message: `Plan "${args.name || args.id}" completed and removed.` };
89
+ }
86
90
  const updated = updateDiscussion({
87
91
  name: args.name,
88
92
  id: args.id,
@@ -92,13 +96,12 @@ export async function handle(args, state) {
92
96
  tags: args.tags,
93
97
  });
94
98
  if (!updated) throw new Error(`No plan found for "${args.name || args.id}".`);
95
- if (updated.status !== 'active' && state.discussionId === updated.id) state.discussionId = null;
96
99
  if (updated.status === 'active') state.discussionId = updated.id;
97
100
  const filePath = writePlanFile(args.planDir, updated.name, updated.content, updated.title);
98
101
  return {
99
- success: true, id: updated.id, name: updated.name, status: updated.status,
102
+ success: true, id: updated.id, name: updated.name,
100
103
  filePath: filePath || undefined,
101
- message: `Plan "${updated.name}" updated (${updated.status}).`,
104
+ message: `Plan "${updated.name}" updated.`,
102
105
  };
103
106
  }
104
107
 
package/uv.lock CHANGED
@@ -168,7 +168,7 @@ wheels = [
168
168
 
169
169
  [[package]]
170
170
  name = "codegraph-mcp"
171
- version = "1.0.8"
171
+ version = "1.1.0"
172
172
  source = { editable = "." }
173
173
  dependencies = [
174
174
  { name = "mcp" },
package/src/migrator.js DELETED
@@ -1,124 +0,0 @@
1
- /**
2
- * migrator.js — one-time migration from flat JSON files to per-project directory structure.
3
- *
4
- * Old layout:
5
- * ~/.context-mcp/contexts.json all entries flat
6
- * ~/.context-mcp/discussions.json all discussions flat
7
- * ~/.context-mcp/graphs.json all graph build records flat
8
- *
9
- * New layout:
10
- * ~/.context-mcp/projects/<slug>/context.json
11
- * ~/.context-mcp/projects/<slug>/graph.json { build, entries[] }
12
- * ~/.context-mcp/projects/<slug>/summary.json
13
- * ~/.context-mcp/projects/<slug>/discussions.json
14
- */
15
-
16
- import { readFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
17
- import { join } from 'node:path';
18
-
19
- function normPath(p) {
20
- return p ? p.toLowerCase().replace(/\\/g, '/').replace(/\/$/, '') : '';
21
- }
22
-
23
- function treeFor(entry) {
24
- if (entry.type === 'compaction') return 'summary';
25
- return 'context';
26
- }
27
-
28
- function readArr(filePath, key) {
29
- if (!existsSync(filePath)) return [];
30
- try {
31
- const d = JSON.parse(readFileSync(filePath, 'utf8'));
32
- return Array.isArray(d[key]) ? d[key] : (Array.isArray(d) ? d : []);
33
- } catch { return []; }
34
- }
35
-
36
- /**
37
- * Run migration if legacy flat files are present.
38
- * @param {object} opts
39
- * @param {string} opts.dataDir - base data dir (~/.context-mcp)
40
- * @param {string} opts.projectsDir - projects sub-dir
41
- * @param {string} opts.projectsPath - path to projects.json
42
- * @param {Function} opts.slugify - name → filesystem slug
43
- * @param {Function} opts.flushFile - (filePath, content) atomic write
44
- * @param {Array} opts.projectsIndex - current projects.json array (mutated in place)
45
- */
46
- export function runMigration({ dataDir, projectsDir, projectsPath, slugify, flushFile, projectsIndex }) {
47
- const legacyContexts = join(dataDir, 'contexts.json');
48
- const legacyDiscussions = join(dataDir, 'discussions.json');
49
- const legacyGraphs = join(dataDir, 'graphs.json');
50
-
51
- const hasLegacy = existsSync(legacyContexts)
52
- || existsSync(legacyDiscussions)
53
- || existsSync(legacyGraphs);
54
-
55
- if (!hasLegacy) return false;
56
-
57
- const oldContexts = readArr(legacyContexts, 'contexts');
58
- const oldDiscussions = readArr(legacyDiscussions, 'discussions');
59
- const oldGraphs = readArr(legacyGraphs, 'graphs');
60
-
61
- // Remap legacy type names to current 4-type schema
62
- const TYPE_MAP = { architecture: 'note', code: 'note', error: 'bug', summary: 'compaction' };
63
- const remapType = entry => {
64
- if (TYPE_MAP[entry.type]) entry.type = TYPE_MAP[entry.type];
65
- return entry;
66
- };
67
-
68
- // Group everything by project name
69
- const byProject = {};
70
- const ensure = name => {
71
- if (!byProject[name]) byProject[name] = {
72
- context: [], graph: { build: null }, summary: [], discussions: [],
73
- };
74
- return byProject[name];
75
- };
76
-
77
- for (const entry of oldContexts) {
78
- remapType(entry);
79
- const p = entry.project || 'global';
80
- const d = ensure(p);
81
- if (treeFor(entry) === 'summary') d.summary.push(entry);
82
- else d.context.push(entry);
83
- }
84
-
85
- for (const disc of oldDiscussions) {
86
- ensure(disc.project || 'global').discussions.push(disc);
87
- }
88
-
89
- // Match graph build records to projects via rootPath
90
- for (const graph of oldGraphs) {
91
- const proj = projectsIndex.find(p => normPath(p.rootPath) === normPath(graph.path));
92
- const name = proj ? proj.name : 'global';
93
- const d = ensure(name);
94
- d.graph.build = {
95
- path: graph.path, nodes: graph.nodes, edges: graph.edges,
96
- communities: graph.communities, cached: graph.cached || 0,
97
- changed: graph.changed || 0, time_ms: graph.time_ms || 0,
98
- summary: graph.summary || '', builtAt: graph.builtAt,
99
- };
100
- }
101
-
102
- // Write per-project files
103
- for (const [name, data] of Object.entries(byProject)) {
104
- const dir = join(projectsDir, slugify(name));
105
- mkdirSync(dir, { recursive: true });
106
- flushFile(join(dir, 'context.json'), { entries: data.context });
107
- flushFile(join(dir, 'graph.json'), data.graph);
108
- flushFile(join(dir, 'summary.json'), { entries: data.summary });
109
- flushFile(join(dir, 'discussions.json'), { discussions: data.discussions });
110
- }
111
-
112
- // Stamp dataDir onto each project record
113
- for (const proj of projectsIndex) {
114
- if (!proj.dataDir) proj.dataDir = `projects/${slugify(proj.name)}`;
115
- }
116
- flushFile(projectsPath, { projects: projectsIndex });
117
-
118
- // Remove legacy flat files
119
- try { unlinkSync(legacyContexts); } catch {}
120
- try { unlinkSync(legacyDiscussions); } catch {}
121
- try { unlinkSync(legacyGraphs); } catch {}
122
-
123
- return true;
124
- }