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.
@@ -237,6 +237,49 @@ def _general_search(matched: list, nodes: list, edges: list) -> dict:
237
237
  }
238
238
 
239
239
 
240
+ def module_map(graph_dict: dict, limit: int = 100) -> dict:
241
+ """
242
+ Return a module map: for each file, its exported functions/classes and what it imports.
243
+ Output grouped by file, sorted by export count descending.
244
+ """
245
+ nodes = graph_dict.get("nodes", [])
246
+ edges = graph_dict.get("edges", [])
247
+
248
+ files: dict[str, dict] = {}
249
+ for n in nodes:
250
+ f = n.get("file") or "unknown"
251
+ if f not in files:
252
+ files[f] = {"exports": [], "imports": set()}
253
+ node_type = n.get("type", "?")
254
+ if node_type in ("function", "class", "struct"):
255
+ files[f]["exports"].append({"name": n["name"], "type": node_type})
256
+
257
+ node_map = {n["id"]: n for n in nodes}
258
+ for e in edges:
259
+ from_node = node_map.get(e.get("from", ""))
260
+ to_node = node_map.get(e.get("to", ""))
261
+ if not from_node or not to_node:
262
+ continue
263
+ from_file = from_node.get("file") or "unknown"
264
+ to_file = to_node.get("file") or "unknown"
265
+ if from_file != to_file and from_file in files:
266
+ files[from_file]["imports"].add(to_file)
267
+
268
+ result = []
269
+ for fpath, data in sorted(files.items(), key=lambda x: -len(x[1]["exports"])):
270
+ result.append({
271
+ "file": fpath,
272
+ "exports": data["exports"][:30],
273
+ "imports": sorted(data["imports"])[:20],
274
+ })
275
+
276
+ return {
277
+ "files": result[:limit],
278
+ "total_files": len(files),
279
+ "truncated": len(files) > limit,
280
+ }
281
+
282
+
240
283
  def _render_subgraph(result_nodes: list, all_edges: list, token_budget: int) -> str:
241
284
  """
242
285
  Render a subgraph as structured plain text (graphify-style).
@@ -4,10 +4,10 @@ codegraph/server.py — MCP server exposing codebase knowledge graph tools.
4
4
 
5
5
  Tools:
6
6
  codegraph_build — scan project, extract AST nodes, build graph (local only, no API)
7
- codegraph_query — structural question OR single-node lookup (or both); replaces codegraph_explain
7
+ codegraph_query — structural question OR single-node lookup (or both)
8
+ codegraph_arch — module map: every file with its exports and imports
8
9
  codegraph_report — return full CODEGRAPH_REPORT.md
9
10
  codegraph_nodes — list nodes of a given type
10
- codegraph_path — shortest path between two concepts
11
11
  """
12
12
 
13
13
  import asyncio
@@ -25,7 +25,7 @@ from .config import classify_file
25
25
  from .cache import file_hash, set_cached_nodes, save_cache
26
26
  from .extractors.ast_extractor import extract as ast_extract
27
27
  from .graph.builder import build, to_json_dict, save_graph, load_graph
28
- from .graph.query import answer as graph_answer, find_path
28
+ from .graph.query import answer as graph_answer, module_map
29
29
  from .graph.clustering import detect_communities
30
30
  from .report import generate as generate_report
31
31
 
@@ -95,16 +95,19 @@ TOOLS = [
95
95
  },
96
96
  ),
97
97
  Tool(
98
- name="codegraph_path",
99
- description="Find the shortest relationship path between two concepts in the graph.",
98
+ name="codegraph_arch",
99
+ description=(
100
+ "Return a module map: every file with its exported functions/classes and what it imports. "
101
+ "Use this to understand project structure without reading any files. "
102
+ "Call after codegraph_build. Much faster than reading each file."
103
+ ),
100
104
  inputSchema={
101
105
  "type": "object",
102
106
  "properties": {
103
- "path": {"type": "string"},
104
- "from": {"type": "string"},
105
- "to": {"type": "string"},
107
+ "path": {"type": "string", "description": "Project root"},
108
+ "limit": {"type": "integer", "description": "Max files in output (default 100)"},
106
109
  },
107
- "required": ["path", "from", "to"],
110
+ "required": ["path"],
108
111
  },
109
112
  ),
110
113
  ]
@@ -125,12 +128,11 @@ async def call_tool(name: str, arguments: dict):
125
128
 
126
129
 
127
130
  async def _dispatch(name: str, args: dict):
128
- if name == "codegraph_build": return await _build(args)
129
- if name == "codegraph_query": return await _query(args)
130
- if name == "codegraph_explain": return await _query(args)
131
- if name == "codegraph_report": return await _report(args)
132
- if name == "codegraph_nodes": return await _nodes(args)
133
- if name == "codegraph_path": return await _path(args)
131
+ if name == "codegraph_build": return await _build(args)
132
+ if name == "codegraph_query": return await _query(args)
133
+ if name == "codegraph_report": return await _report(args)
134
+ if name == "codegraph_nodes": return await _nodes(args)
135
+ if name == "codegraph_arch": return await _arch(args)
134
136
  raise ValueError(f"Unknown tool: {name}")
135
137
 
136
138
 
@@ -285,11 +287,12 @@ async def _nodes(args: dict) -> dict:
285
287
  return {"type": node_type, "count": len(matched), "nodes": matched[:limit]}
286
288
 
287
289
 
288
- async def _path(args: dict) -> dict:
290
+ async def _arch(args: dict) -> dict:
289
291
  graph_dict = load_graph(args["path"])
290
292
  if not graph_dict:
291
293
  raise ValueError("No graph found. Run codegraph_build first.")
292
- return find_path(args["from"], args["to"], graph_dict)
294
+ limit = args.get("limit", 100)
295
+ return module_map(graph_dict, limit=limit)
293
296
 
294
297
 
295
298
  # ── Entry point ───────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mcp-server",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Persistent AI memory + codebase knowledge graph MCP server. Works across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Claude.ai, and ChatGPT.",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codegraph-mcp"
7
- version = "1.0.8"
7
+ version = "1.1.0"
8
8
  description = "Codebase knowledge graph MCP server — AST extraction, graph queries, community detection"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
package/src/cli.js CHANGED
@@ -107,45 +107,29 @@ function printSection(title, meta = '') {
107
107
  function printUsage() {
108
108
  printBanner();
109
109
 
110
- // Terminal commands (ctx / context ...)
111
- printSection('Terminal commands', 'run from your shell (ctx … or context …)');
110
+ printSection('Commands');
112
111
  const cmd = (c, desc) => console.log(` ${accent(c.padEnd(40))} ${faint(desc)}`);
113
112
  cmd('ctx', 'open interactive mode');
114
113
  cmd('ctx list [project]', 'list entries + discussions + graphs');
115
114
  cmd('ctx search <query>', 'keyword → semantic fallback search');
116
115
  cmd('ctx add', 'add entry interactively');
116
+ cmd('ctx save --title "…" --content "…" --project <p> --type <t>', 'non-interactive save (scripts/hooks)');
117
117
  cmd('ctx delete <id-prefix>', 'delete one entry');
118
118
  cmd('ctx delete project <name|id>', 'delete all entries for a project');
119
119
  cmd('ctx summary [project]', 'summarize recent entries');
120
120
  cmd('ctx projects', 'show all projects + graphs');
121
121
  cmd('ctx discuss [project]', 'show discussions');
122
- cmd('ctx benchmark', 'token savings report (memory + graph)');
122
+ cmd('ctx stats', 'storage report: entries, types, graph status');
123
123
  console.log('');
124
- cmd('ctx install --initial', 'install / update Node.js + Python (codegraph) deps');
125
- cmd('ctx install --<platform>', 'write MCP config + skill/rules for an AI platform');
126
- cmd('ctx install --all', 'install for all platforms at once');
124
+ cmd('ctx install --initial', 'install / update Node.js + Python (codegraph) deps only');
125
+ cmd('ctx install --<platform>', 'write MCP config + skill/rules file only (no uv/npm)');
126
+ cmd('ctx install --all', 'write config + skill files for all platforms');
127
127
  cmd('ctx online [--port N]', 'start HTTP server for Claude.ai / ChatGPT');
128
128
  cmd('ctx online --close', 'stop the running HTTP server');
129
129
  cmd('ctx settings', 'view and edit config (port, host, client id/secret)');
130
130
  cmd('ctx update', 'check for and apply latest version');
131
131
  cmd('ctx help', 'show this screen');
132
132
  console.log('');
133
-
134
- // Interactive mode commands (no prefix needed)
135
- printSection('Interactive mode', 'type these inside the UI — no "ctx" prefix needed');
136
- const icmd = (c, desc) => console.log(` ${accent(c.padEnd(40))} ${faint(desc)}`);
137
- icmd('list [project]', 'list entries');
138
- icmd('search <query>', 'search context');
139
- icmd('add', 'add entry');
140
- icmd('projects', 'show all projects');
141
- icmd('discuss [project]', 'show discussions');
142
- icmd('summary [project]', 'summarize recent entries');
143
- icmd('benchmark', 'token savings report');
144
- icmd('install --<platform>', 'install for a platform');
145
- icmd('settings', 'edit config');
146
- icmd('clear', 'clear screen');
147
- icmd('exit / quit / q', 'exit interactive mode');
148
- console.log('');
149
133
  }
150
134
  function clearScreen() {
151
135
  // \x1b[2J = clear screen, \x1b[3J = clear scrollback, \x1b[H = cursor home
@@ -155,7 +139,8 @@ function clearScreen() {
155
139
  // ── List (grouped by project) ─────────────────────────────────────────────────
156
140
 
157
141
  function cmdList(args) {
158
- const filterProject = args[0];
142
+ const projectFlagIdx = args.indexOf('--project');
143
+ const filterProject = projectFlagIdx !== -1 ? args[projectFlagIdx + 1] : args[0];
159
144
  const entries = getContext({ project: filterProject, limit: 100 });
160
145
  const allDiscussions = listDiscussions({ project: filterProject });
161
146
  const allGraphs = listGraphs();
@@ -163,7 +148,6 @@ function cmdList(args) {
163
148
 
164
149
  printSection('Context', filterProject ? `project: ${filterProject}` : 'all projects');
165
150
 
166
- // Build per-project map, split entries into their three trees
167
151
  const projects = {};
168
152
  const ensureProj = p => {
169
153
  if (!projects[p]) projects[p] = { context: [], summary: [], plans: [] };
@@ -213,13 +197,12 @@ function cmdList(args) {
213
197
  const id = item.id.slice(0, 8);
214
198
  const tags = safeTags(item.tags);
215
199
  const pipe = secIsLast ? ' ' : '│';
216
- console.log(` ${color(C.darkgray, pipe)} ${color(C.darkgray, br)} ${pill(item.type || 'note')} ${bold(item.title || '(no title)')} ${faint('id:' + id)} ${faint(date)}`);
200
+ console.log(` ${color(C.darkgray, pipe)} ${color(C.darkgray, br)} ${bold(item.title || '(no title)')} ${faint('id:' + id)} ${faint(date)}`);
217
201
  if (tags.length) console.log(` ${color(C.darkgray, pipe)} ${faint(tags.map(t => '#' + t).join(' '))}`);
218
202
  });
219
203
  if (!secIsLast) console.log(` ${color(C.darkgray, '│')}`);
220
204
  };
221
205
 
222
- // ── Graph (build stats only) ──────────────────────────────────────────────
223
206
  if (graphBuild) {
224
207
  secIdx++;
225
208
  const isLast = secIdx === sections.length;
@@ -228,13 +211,11 @@ function cmdList(args) {
228
211
  if (!isLast) console.log(` ${color(C.darkgray, '│')}`);
229
212
  }
230
213
 
231
- // ── Context ───────────────────────────────────────────────────────────────
232
214
  if (pData.context.length) {
233
215
  secIdx++;
234
216
  renderEntries(pData.context, 'context', secIdx === sections.length);
235
217
  }
236
218
 
237
- // ── Summary (compaction digests only — no type labels) ───────────────────
238
219
  if (pData.summary.length) {
239
220
  secIdx++;
240
221
  const isLast = secIdx === sections.length;
@@ -248,7 +229,6 @@ function cmdList(args) {
248
229
  if (!isLast) console.log(` ${color(C.darkgray, '│')}`);
249
230
  }
250
231
 
251
- // ── Plans ─────────────────────────────────────────────────────────────────
252
232
  if (pData.plans.length) {
253
233
  secIdx++;
254
234
  const isLast = secIdx === sections.length;
@@ -285,7 +265,7 @@ function cmdSearch(args) {
285
265
  const id = entry.id.slice(0, 8);
286
266
  const type = entry.type || 'note';
287
267
  const isLast = index === results.length - 1;
288
- console.log(` ${color(C.darkgray, isLast ? '└─' : '├─')} ${bold(entry.title || '(no title)')}${score} ${pill(type)} ${faint('id:' + id)} ${faint(date)}`);
268
+ console.log(` ${color(C.darkgray, isLast ? '└─' : '├─')} ${bold(entry.title || '(no title)')}${score} ${faint('id:' + id)} ${faint(date)}`);
289
269
  });
290
270
  console.log('');
291
271
  console.log(line());
@@ -313,7 +293,6 @@ function cmdProjects() {
313
293
  console.log(`\n ${color(C.dblue, '◆')} ${bold(lblue(project.name))}${idTag} ${bar} ${faint(project.count + ' entries')}`);
314
294
  console.log(` ${color(C.darkgray, '│')}`);
315
295
 
316
- // Graph status
317
296
  if (graph) {
318
297
  const builtAt = (graph.builtAt || '').slice(0, 10);
319
298
  console.log(` ${color(C.darkgray, '├─')} ${accent('⬡')} ${muted('graph')} ${faint(`${graph.nodes}n · ${graph.edges}e · ${graph.communities} clusters · ${builtAt}`)}`);
@@ -321,18 +300,15 @@ function cmdProjects() {
321
300
  console.log(` ${color(C.darkgray, '├─')} ${faint('⬡ no graph')}`);
322
301
  }
323
302
 
324
- // Recent context
325
303
  if (entries.length) {
326
304
  console.log(` ${color(C.darkgray, '├─')} ${muted('recent')}`);
327
305
  entries.forEach((e, i) => {
328
306
  const br = i === entries.length - 1 && !activeD.length ? '└─' : '├─';
329
- const type = e.type || 'note';
330
307
  const date = (e.createdAt || '').slice(0, 10);
331
- console.log(` ${color(C.darkgray, '│')} ${color(C.darkgray, br)} ${pill(type)} ${bold(e.title || '(no title)')} ${faint(date)}`);
308
+ console.log(` ${color(C.darkgray, '│')} ${color(C.darkgray, br)} ${bold(e.title || '(no title)')} ${faint(date)}`);
332
309
  });
333
310
  }
334
311
 
335
- // Active discussions
336
312
  if (activeD.length) {
337
313
  console.log(` ${color(C.darkgray, '├─')} ${muted('discussions')}`);
338
314
  activeD.forEach((d, i) => {
@@ -354,7 +330,8 @@ function cmdProjects() {
354
330
  // ── Discussions ───────────────────────────────────────────────────────────────
355
331
 
356
332
  function cmdDiscussions(args) {
357
- const filterProject = args[0];
333
+ const projectFlagIdx = args.indexOf('--project');
334
+ const filterProject = projectFlagIdx !== -1 ? args[projectFlagIdx + 1] : args[0];
358
335
  const discussions = listDiscussions({ project: filterProject });
359
336
  printSection('Discussions', filterProject || 'all projects');
360
337
 
@@ -392,7 +369,8 @@ function cmdDiscussions(args) {
392
369
  // ── Summary ───────────────────────────────────────────────────────────────────
393
370
 
394
371
  function cmdSummary(args) {
395
- const project = args[0];
372
+ const projectFlagIdx = args.indexOf('--project');
373
+ const project = projectFlagIdx !== -1 ? args[projectFlagIdx + 1] : args[0];
396
374
  const entries = getContext({ project, limit: 50 });
397
375
  printSection('Summary', project || 'global');
398
376
  if (!entries.length) { console.log(` ${faint('no entries to summarize')}`); return; }
@@ -411,137 +389,54 @@ function cmdSummary(args) {
411
389
  // ── Benchmark ────────────────────────────────────────────────────────────────
412
390
 
413
391
 
414
- function _walkBytes(dirPath) {
415
- const walkScript =
416
- `const fs=require('fs'),path=require('path');` +
417
- `function walk(d,t=0){try{for(const f of fs.readdirSync(d)){` +
418
- `const p=path.join(d,f);try{const s=fs.statSync(p);` +
419
- `if(s.isDirectory()&&!['node_modules','.git','codegraph-cache','.venv','venv','__pycache__','dist','build','.next'].includes(f))t+=walk(p);` +
420
- `else if(s.isFile()&&/\\.(js|jsx|ts|tsx|py|json|md|yaml|yml|toml|sh|bash|env|txt|css|html|sql|go|rs|java|rb|php|c|cpp|h)$/.test(f)&&!/^(package-lock|uv\.lock|yarn\.lock|Pipfile\.lock|poetry\.lock)$/.test(path.basename(f,path.extname(f))+path.extname(f)))t+=s.size;}catch{}}}catch{}return t;}` +
421
- `console.log(walk(${JSON.stringify(dirPath)}));`;
422
- const res = spawnSync('node', ['-e', walkScript], { encoding: 'utf8', timeout: 8000 });
423
- return res.stdout ? parseInt(res.stdout.trim()) : null;
424
- }
425
-
426
- function _sampleQueryTokens(graphPath) {
427
- const questions = ['what does the server do', 'how is the graph built', 'what calls save'];
428
- const sizes = [];
429
- for (const q of questions) {
430
- const req = JSON.stringify({ tool: 'codegraph_query', args: { path: graphPath, question: q, token_budget: 2000 } });
431
- const res = spawnSync('uv', ['run', 'python', '-m', 'codegraph'],
432
- { input: req, encoding: 'utf8', cwd: process.cwd(), timeout: 12000 });
433
- if (res.stdout) { try { const r = JSON.parse(res.stdout); if (!r.error) sizes.push(res.stdout.length); } catch {} }
434
- }
435
- return { avgTok: sizes.length ? Math.round(sizes.reduce((a, b) => a + b, 0) / sizes.length / 4) : 472, measured: sizes.length > 0 };
436
- }
437
-
438
- function cmdBenchmark() {
439
- const graphs = listGraphs();
440
- const projects = listProjects();
441
- printSection('Benchmark', 'real token savings');
442
-
443
- const RESUME_LIMIT = 15;
444
- const COMPACT_AT = 20;
445
-
446
- // ── Measure entry sizes from actual stored data ──────────────────────────────
447
- const allEntries = getContext({ limit: 500, compact: false });
448
- const totalEntries = allEntries.length;
449
- let avgFullTok = 155, avgCompactTok = 50;
450
- if (allEntries.length) {
451
- const fullSizes = allEntries.map(e => JSON.stringify(e).length);
452
- const compactSizes = allEntries.map(e =>
453
- ([e.title || '', (e.content || '').slice(0, 200), (e.tags || []).join(' ')].join(' ')).length);
454
- avgFullTok = Math.round(fullSizes.reduce((a, b) => a + b, 0) / fullSizes.length / 4);
455
- avgCompactTok = Math.round(compactSizes.reduce((a, b) => a + b, 0) / compactSizes.length / 4);
456
- }
392
+ function cmdStats() {
393
+ const projects = listProjects();
394
+ const graphs = listGraphs();
395
+ const allEntries = getContext({ limit: 2000, compact: false });
457
396
 
458
- // ── Run graph queries once, reuse in both sections ────────────────────────────
459
- let avgQueryTok = 472, queryMeasured = false;
460
- if (graphs.length) {
461
- const r = _sampleQueryTokens(graphs[0].path);
462
- avgQueryTok = r.avgTok;
463
- queryMeasured = r.measured;
464
- }
397
+ printSection('Stats', 'context-mcp storage report');
465
398
 
466
- // ── Corpus size (run once) ────────────────────────────────────────────────────
467
- let corpusToks = null;
468
- if (graphs.length) {
469
- const bytes = _walkBytes(graphs[0].path);
470
- if (bytes) corpusToks = Math.round(bytes / 4);
471
- }
472
-
473
- // ── Memory ───────────────────────────────────────────────────────────────────
474
- // Without context-mcp: AI reads all entries at full size every conversation
475
- // With context-mcp: AI loads min(15, N) entries as compact previews via resume
476
- console.log(`\n ${bold(lblue('Memory'))} ${faint('measured from actual stored entries')}`);
477
- if (totalEntries) {
478
- const resumeCount = Math.min(RESUME_LIMIT, totalEntries);
479
- const withoutMemTok = totalEntries * avgFullTok; // load all entries full
480
- const withMemTok = resumeCount * avgCompactTok; // resume: compact previews only
481
- const memSaved = withoutMemTok - withMemTok;
482
- const memReduction = (withoutMemTok / withMemTok).toFixed(1);
483
- const memPct = ((1 - withMemTok / withoutMemTok) * 100).toFixed(1);
484
-
485
- console.log(` ${faint('avg entry size (full): ')} ${muted(avgFullTok)} tok ${faint('(measured)')}`);
486
- console.log(` ${faint('avg entry size (compact):')} ${muted(avgCompactTok)} tok ${faint('(measured)')}`);
487
- console.log(` ${faint('stored: ')} ${muted(totalEntries)} entries ${faint('across')} ${muted(projects.length)} project(s)`);
488
- console.log(` ${faint('without — load all full: ')} ${warn('~' + withoutMemTok.toLocaleString('en-US'))} tokens`);
489
- console.log(` ${faint('with — resume compact:')} ${ok('~' + withMemTok.toLocaleString('en-US'))} tokens ${faint(`(${resumeCount} of ${totalEntries} entries)`)}`);
490
- console.log(` ${faint('saved per chat: ')} ${ok('~' + memSaved.toLocaleString('en-US'))} tokens ${highlight(memReduction + '×')} ${ok(memPct + '%')} reduction`);
491
- console.log(` ${faint('auto-compact at: ')} ${faint(COMPACT_AT + ' entries → oldest summarized to 1')}`);
492
- console.log('');
399
+ // ── Projects ──────────────────────────────────────────────────────────────────
400
+ console.log(`\n ${bold(lblue('Projects'))}`);
401
+ if (!projects.length) {
402
+ console.log(` ${faint('no projects yet')}`);
403
+ } else {
493
404
  for (const p of projects) {
494
- const pToks = p.count * avgFullTok;
495
- const barLen = Math.min(Math.ceil(p.count / 2), 24);
496
- const bar = color(C.dblue, '█'.repeat(barLen)) + color(C.darkgray, '░'.repeat(24 - barLen));
497
- console.log(` ${color(C.darkgray, '·')} ${muted(p.name.padEnd(22))} ${bar} ${faint(p.count + ' entries · ~' + pToks.toLocaleString('en-US') + ' tok full')}`);
405
+ const g = graphs.find(gr => {
406
+ const gp = (gr.path || '').replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
407
+ return gp === p.name;
408
+ });
409
+ const graphStatus = g ? ok(`graph: ${g.nodes} nodes, ${g.edges} edges`) : faint('no graph');
410
+ const builtAt = g?.builtAt ? faint(' built ' + g.builtAt.slice(0, 10)) : '';
411
+ console.log(` ${bold(p.name.padEnd(24))} ${muted(String(p.count).padStart(3) + ' entries')} ${graphStatus}${builtAt}`);
498
412
  }
499
- } else {
500
- console.log(` ${faint('no entries yet')}`);
501
413
  }
502
414
 
503
- // ── CodeGraph ─────────────────────────────────────────────────────────────────
504
- // Without context-mcp: AI reads all source files to answer structural questions
505
- // With context-mcp: AI calls codegraph_query → focused NODE/EDGE subgraph
506
- console.log(`\n ${bold(lblue('CodeGraph'))} ${faint('measured from live graph queries')}`);
507
- if (!graphs.length) {
508
- console.log(` ${faint('no graphs — run codegraph_build first')}`);
415
+ // ── Entry type breakdown ──────────────────────────────────────────────────────
416
+ console.log(`\n ${bold(lblue('Entries by type'))}`);
417
+ if (!allEntries.length) {
418
+ console.log(` ${faint('no entries yet')}`);
509
419
  } else {
510
- for (const g of graphs) {
511
- const pathShort = g.path.replace(/\\/g, '/').split('/').slice(-2).join('/');
512
- const builtAt = (g.builtAt || '').slice(0, 10);
513
- const graphReduce = corpusToks ? (corpusToks / avgQueryTok).toFixed(0) + '×' : null;
514
- const graphPct = corpusToks ? ((1 - avgQueryTok / corpusToks) * 100).toFixed(2) : null;
515
-
516
- console.log(`\n ${accent('⬡')} ${bold(pathShort)} ${faint(builtAt)}`);
517
- console.log(` ${faint('nodes:')} ${muted(g.nodes)} ${faint('edges:')} ${muted(g.edges)} ${faint('clusters:')} ${muted(g.communities)}`);
518
- if (corpusToks) console.log(` ${faint('without — read all files:')} ${warn('~' + corpusToks.toLocaleString('en-US'))} tokens ${faint('(all source + config + doc files)')}`);
519
- console.log(` ${faint('with — graph query: ')} ${ok('~' + avgQueryTok.toLocaleString('en-US'))} tokens ${faint(queryMeasured ? '(avg of 3 live queries)' : '(calibrated fallback)')}`);
520
- if (graphReduce) console.log(` ${faint('saved per query: ')} ${highlight(graphReduce)} ${ok(graphPct + '%')} fewer tokens`);
420
+ const byType = {};
421
+ for (const e of allEntries) { byType[e.type] = (byType[e.type] || 0) + 1; }
422
+ for (const [type, count] of Object.entries(byType).sort((a, b) => b[1] - a[1])) {
423
+ const bar = color(C.dblue, '█'.repeat(Math.min(count, 20))) + color(C.darkgray, ''.repeat(Math.max(0, 20 - count)));
424
+ console.log(` ${type.padEnd(14)} ${bar} ${muted(count)}`);
521
425
  }
522
426
  }
523
427
 
524
- // ── Combined ──────────────────────────────────────────────────────────────────
525
- // Without: read all files (no graph) + load all entries full (no memory system)
526
- // With: compact resume (memory) + one graph query (codegraph)
527
- if (graphs.length) {
528
- const resumeCount = Math.min(RESUME_LIMIT, totalEntries);
529
- const withMemTok = resumeCount * avgCompactTok;
530
- const withMcp = withMemTok + avgQueryTok;
531
- const withoutMemTok = totalEntries * avgFullTok;
532
- const withoutMcp = withoutMemTok + (corpusToks || graphs[0].nodes * 80);
533
- const totalRed = withoutMcp > 0 ? (withoutMcp / withMcp).toFixed(0) : '—';
534
- const totalPct = withoutMcp > 0 ? ((1 - withMcp / withoutMcp) * 100).toFixed(2) : '—';
535
-
536
- console.log(`\n ${bold(lblue('Combined'))} ${faint('per conversation')}`);
537
- console.log(` ${faint('without context-mcp: ')} ${warn('~' + withoutMcp.toLocaleString('en-US'))} tokens ${faint('(all entries full + all files read directly)')}`);
538
- console.log(` ${faint('with context-mcp: ')} ${ok('~' + withMcp.toLocaleString('en-US'))} tokens ${faint('(compact resume + 1 graph query)')}`);
539
- console.log(` ${faint('total reduction: ')} ${highlight(totalRed + '×')} ${ok(totalPct + '%')} fewer tokens`);
540
- }
541
-
428
+ // ── Storage summary ───────────────────────────────────────────────────────────
429
+ const compactions = allEntries.filter(e => e.type === 'compaction').length;
430
+ const avgSize = allEntries.length
431
+ ? Math.round(allEntries.reduce((s, e) => s + (e.content || '').length, 0) / allEntries.length)
432
+ : 0;
433
+
434
+ console.log(`\n ${bold(lblue('Storage'))}`);
435
+ console.log(` ${faint('total entries: ')} ${muted(allEntries.length)}`);
436
+ console.log(` ${faint('compactions: ')} ${muted(compactions)}`);
437
+ console.log(` ${faint('avg entry size: ')} ${muted(avgSize + ' chars')}`);
438
+ console.log(` ${faint('graphs built: ')} ${muted(graphs.length)}`);
542
439
  console.log('');
543
- console.log(line());
544
- console.log(faint(' token estimate: chars ÷ 4 · corpus = all source/config/doc files (excl. lock files, .venv, node_modules)'));
545
440
  }
546
441
 
547
442
 
@@ -796,6 +691,15 @@ async function cmdInstall(args) {
796
691
  console.log(` ${ok('✓')} Python environment ready — codegraph enabled`);
797
692
  }
798
693
  }
694
+ // Bootstrap store structure — creates ~/.context-mcp/, projects/, contextconfig.json
695
+ console.log(` ${bold(lblue('Store'))}`);
696
+ try {
697
+ getConfig(); // triggers DATA_DIR creation + contextconfig.json generation
698
+ console.log(` ${ok('✓')} store ready ${faint(getStorePath())}`);
699
+ console.log(` ${ok('✓')} config ready ${faint(getConfigPath())}`);
700
+ } catch (e) {
701
+ console.log(` ${bad('✗')} store init failed: ${faint(e.message)}`);
702
+ }
799
703
  console.log('');
800
704
  return;
801
705
  }
@@ -871,27 +775,6 @@ async function cmdInstall(args) {
871
775
  // ── Global gitignore — add context-mcp runtime files if global gitignore exists ──
872
776
  _updateGlobalGitignore();
873
777
  console.log('');
874
-
875
- // ── Python / uv setup (codegraph) ─────────────────────────────────────────
876
- console.log(` ${bold(lblue('Python Codegraph'))}`);
877
- const uvCheck = spawnSync('uv', ['--version'], { encoding: 'utf8' });
878
- if (uvCheck.error || uvCheck.status !== 0) {
879
- console.log(` ${bad('✗')} uv not found — install it from ${accent('https://docs.astral.sh/uv/')} to enable codegraph`);
880
- console.log('');
881
- return;
882
- }
883
- console.log(` ${ok('✓')} uv found: ${faint(uvCheck.stdout.trim())}`);
884
-
885
- // Package root is one level up from src/
886
- const __dirname_cli = dirname(fileURLToPath(import.meta.url));
887
- const pkgRoot = join(__dirname_cli, '..');
888
- const sync = spawnSync('uv', ['--directory', pkgRoot, 'sync', '--no-dev'], { encoding: 'utf8' });
889
- if (sync.status !== 0) {
890
- console.log(` ${bad('✗')} uv sync failed:\n${faint((sync.stderr || sync.stdout || '').trim())}`);
891
- } else {
892
- console.log(` ${ok('✓')} Python environment ready — codegraph enabled`);
893
- }
894
- console.log('');
895
778
  }
896
779
 
897
780
  // ── Online ────────────────────────────────────────────────────────────────────
@@ -1077,7 +960,7 @@ async function cmdAdd(existingRl) {
1077
960
  const content = await ask('Content:');
1078
961
  const project = await ask('Project (blank = global):');
1079
962
  const tagsRaw = await ask('Tags (comma-separated):');
1080
- const type = await ask('Type (note/decision/code/bug/architecture/config/error):');
963
+ const type = await ask('Type (note/compaction):');
1081
964
 
1082
965
  if (!existingRl) rl.close();
1083
966
  if (!content.trim()) { console.log(` ${bad('✗')} content required`); return; }
@@ -1094,6 +977,30 @@ async function cmdAdd(existingRl) {
1094
977
  console.log(` ${ok('✓')} ${bold(entry.title || '(no title)')} ${faint('id:' + entry.id.slice(0, 8))}`);
1095
978
  }
1096
979
 
980
+ // ── Save (non-interactive, flag-based — used by hooks and scripts) ───────────
981
+
982
+ function cmdSave(args) {
983
+ // Usage: ctx save --title "..." --content "..." --project <p> --type <t> --tags <t1,t2>
984
+ const flags = {};
985
+ for (let i = 0; i < args.length; i++) {
986
+ if (args[i].startsWith('--')) {
987
+ flags[args[i].slice(2)] = args[i + 1] || '';
988
+ i++;
989
+ }
990
+ }
991
+ const content = flags.content || flags.c;
992
+ if (!content) { console.log(` ${bad('✗')} --content required`); process.exit(1); }
993
+ const entry = saveContext({
994
+ title: (flags.title || flags.t || '').trim(),
995
+ content: content.trim(),
996
+ project: (flags.project || flags.p || '').trim() || 'global',
997
+ tags: (flags.tags || '').split(',').map(s => s.trim()).filter(Boolean),
998
+ type: (flags.type || 'note').trim(),
999
+ source: 'cli',
1000
+ });
1001
+ console.log(` ${ok('✓')} saved "${entry.title || entry.id.slice(0, 8)}" → ${entry.project}`);
1002
+ }
1003
+
1097
1004
  // ── Delete ────────────────────────────────────────────────────────────────────
1098
1005
 
1099
1006
  function cmdDelete(args) {
@@ -1193,8 +1100,8 @@ async function interactive() {
1193
1100
  clearScreen(); printCompactHeader('discussions'); cmdDiscussions(rest); break;
1194
1101
  case 'summary':
1195
1102
  clearScreen(); printCompactHeader('summary'); cmdSummary(rest); break;
1196
- case 'benchmark': case 'bench':
1197
- clearScreen(); printCompactHeader('benchmark'); cmdBenchmark(); break;
1103
+ case 'stats':
1104
+ clearScreen(); printCompactHeader('stats'); cmdStats(); break;
1198
1105
  case 'install':
1199
1106
  clearScreen(); printCompactHeader('install'); await cmdInstall(rest); break;
1200
1107
  case 'online':
@@ -1203,6 +1110,8 @@ async function interactive() {
1203
1110
  clearScreen(); printCompactHeader('settings'); await cmdSettings(rl); break;
1204
1111
  case 'add':
1205
1112
  clearScreen(); printCompactHeader('add'); await cmdAdd(rl); break;
1113
+ case 'save':
1114
+ cmdSave(rest); break;
1206
1115
  case 'delete': case 'del': case 'rm':
1207
1116
  clearScreen(); printCompactHeader('delete'); cmdDelete(rest); break;
1208
1117
  case 'help': case '?':
@@ -1257,8 +1166,8 @@ async function checkForUpdate() {
1257
1166
  cmdDiscussions(rest); break;
1258
1167
  case 'summary':
1259
1168
  cmdSummary(rest); break;
1260
- case 'benchmark': case 'bench':
1261
- cmdBenchmark(); break;
1169
+ case 'stats':
1170
+ cmdStats(); break;
1262
1171
  case 'install':
1263
1172
  await cmdInstall(rest);
1264
1173
  process.exit(0);
@@ -1284,6 +1193,8 @@ async function checkForUpdate() {
1284
1193
  await cmdSettings(); break;
1285
1194
  case 'add':
1286
1195
  await cmdAdd(); break;
1196
+ case 'save':
1197
+ cmdSave(rest); break;
1287
1198
  case 'delete': case 'del': case 'rm':
1288
1199
  cmdDelete(rest); break;
1289
1200
  case 'help': case '--help': case '-h':