code-context-engine 0.4.0__py3-none-any.whl

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 (63) hide show
  1. code_context_engine-0.4.0.dist-info/METADATA +389 -0
  2. code_context_engine-0.4.0.dist-info/RECORD +63 -0
  3. code_context_engine-0.4.0.dist-info/WHEEL +5 -0
  4. code_context_engine-0.4.0.dist-info/entry_points.txt +4 -0
  5. code_context_engine-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. code_context_engine-0.4.0.dist-info/top_level.txt +1 -0
  7. context_engine/__init__.py +3 -0
  8. context_engine/cli.py +2848 -0
  9. context_engine/cli_style.py +66 -0
  10. context_engine/compression/__init__.py +0 -0
  11. context_engine/compression/compressor.py +144 -0
  12. context_engine/compression/ollama_client.py +33 -0
  13. context_engine/compression/output_rules.py +77 -0
  14. context_engine/compression/prompts.py +9 -0
  15. context_engine/compression/quality.py +37 -0
  16. context_engine/config.py +198 -0
  17. context_engine/dashboard/__init__.py +0 -0
  18. context_engine/dashboard/_page.py +1548 -0
  19. context_engine/dashboard/server.py +429 -0
  20. context_engine/editors.py +265 -0
  21. context_engine/event_bus.py +24 -0
  22. context_engine/indexer/__init__.py +0 -0
  23. context_engine/indexer/chunker.py +147 -0
  24. context_engine/indexer/embedder.py +154 -0
  25. context_engine/indexer/embedding_cache.py +168 -0
  26. context_engine/indexer/git_hooks.py +73 -0
  27. context_engine/indexer/git_indexer.py +136 -0
  28. context_engine/indexer/ignorefile.py +96 -0
  29. context_engine/indexer/manifest.py +78 -0
  30. context_engine/indexer/pipeline.py +624 -0
  31. context_engine/indexer/secrets.py +332 -0
  32. context_engine/indexer/watcher.py +109 -0
  33. context_engine/integration/__init__.py +0 -0
  34. context_engine/integration/bootstrap.py +76 -0
  35. context_engine/integration/git_context.py +132 -0
  36. context_engine/integration/mcp_server.py +1825 -0
  37. context_engine/integration/session_capture.py +306 -0
  38. context_engine/memory/__init__.py +6 -0
  39. context_engine/memory/compressor.py +344 -0
  40. context_engine/memory/db.py +922 -0
  41. context_engine/memory/extractive.py +106 -0
  42. context_engine/memory/grammar.py +419 -0
  43. context_engine/memory/hook_installer.py +258 -0
  44. context_engine/memory/hook_server.py +83 -0
  45. context_engine/memory/hooks.py +327 -0
  46. context_engine/memory/migrate.py +268 -0
  47. context_engine/models.py +96 -0
  48. context_engine/pricing.py +104 -0
  49. context_engine/project_commands.py +296 -0
  50. context_engine/retrieval/__init__.py +0 -0
  51. context_engine/retrieval/confidence.py +47 -0
  52. context_engine/retrieval/query_parser.py +105 -0
  53. context_engine/retrieval/retriever.py +199 -0
  54. context_engine/serve_http.py +208 -0
  55. context_engine/services.py +252 -0
  56. context_engine/storage/__init__.py +0 -0
  57. context_engine/storage/backend.py +39 -0
  58. context_engine/storage/fts_store.py +112 -0
  59. context_engine/storage/graph_store.py +219 -0
  60. context_engine/storage/local_backend.py +109 -0
  61. context_engine/storage/remote_backend.py +117 -0
  62. context_engine/storage/vector_store.py +357 -0
  63. context_engine/utils.py +72 -0
context_engine/cli.py ADDED
@@ -0,0 +1,2848 @@
1
+ # src/context_engine/cli.py
2
+ """CLI entry point for code-context-engine."""
3
+ import asyncio
4
+ import json
5
+ import socket
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from context_engine.config import load_config, PROJECT_CONFIG_NAME
12
+
13
+
14
+ def _configure_mcp(project_dir: Path) -> bool:
15
+ """Write MCP server config to .mcp.json in the project directory.
16
+
17
+ Returns True if the entry was added. Uses an atomic write so a crash or
18
+ partial write can't destroy pre-existing MCP server entries in the file.
19
+ """
20
+ from context_engine.utils import atomic_write_text, resolve_cce_binary
21
+
22
+ mcp_path = project_dir / ".mcp.json"
23
+ # `sys.executable` is wrong for non-venv installs (pipx, Homebrew, pip
24
+ # --user) — its parent has the python interpreter, not necessarily `cce`.
25
+ # `resolve_cce_binary` matches the same fallbacks the SessionStart hook
26
+ # uses so MCP and the hook agree on which `cce` to call.
27
+ command = resolve_cce_binary()
28
+
29
+ entry = {"command": command, "args": ["serve", "--project-dir", str(project_dir)]}
30
+
31
+ if mcp_path.exists():
32
+ try:
33
+ data = json.loads(mcp_path.read_text())
34
+ except (json.JSONDecodeError, OSError):
35
+ data = {}
36
+ else:
37
+ data = {}
38
+
39
+ servers = data.setdefault("mcpServers", {})
40
+ if "context-engine" in servers:
41
+ existing = servers["context-engine"]
42
+ if existing.get("command") == command and existing.get("args") == entry["args"]:
43
+ return False # already configured and up to date
44
+ # Update stale command path or args (e.g. after package rename).
45
+ servers["context-engine"] = entry
46
+ atomic_write_text(mcp_path, json.dumps(data, indent=2) + "\n")
47
+ return True
48
+
49
+ servers["context-engine"] = entry
50
+ atomic_write_text(mcp_path, json.dumps(data, indent=2) + "\n")
51
+ return True
52
+
53
+
54
+ _CCE_CLAUDE_MD_MARKER = "## Context Engine (CCE)"
55
+ # Version stamp embedded as an HTML comment so it doesn't render in the final
56
+ # Markdown but lets `_ensure_claude_md` detect when the installed block is
57
+ # stale and needs replacing. Bump whenever _CCE_CLAUDE_MD_BLOCK changes.
58
+ _CCE_CLAUDE_MD_VERSION = "3"
59
+ _CCE_CLAUDE_MD_VERSION_TAG = f"<!-- cce-block-version: {_CCE_CLAUDE_MD_VERSION} -->"
60
+ _CCE_CLAUDE_MD_VERSION_PREFIX = "<!-- cce-block-version: "
61
+ _CCE_CLAUDE_MD_END_MARKER = "<!-- /cce-block -->"
62
+
63
+ _CCE_CLAUDE_MD_BLOCK = f"""\
64
+ {_CCE_CLAUDE_MD_VERSION_TAG}
65
+ ## Context Engine (CCE)
66
+
67
+ This project uses Code Context Engine for intelligent code retrieval and
68
+ cross-session memory.
69
+
70
+ ### Searching the codebase
71
+
72
+ **You MUST use `context_search` instead of reading files directly** when
73
+ exploring the codebase, answering questions about code, or understanding how
74
+ things work. This is a hard requirement, not a suggestion. `context_search`
75
+ returns the most relevant code chunks with confidence scores instead of whole
76
+ files, and tracks token savings automatically.
77
+
78
+ When to use `context_search`:
79
+ - Answering questions about the codebase ("how does X work?", "where is Y?")
80
+ - Exploring structure or architecture
81
+ - Finding related code, functions, or patterns
82
+ - Any time you would otherwise read a file just to understand it
83
+
84
+ When to use `Read` instead:
85
+ - You need to edit a specific file (read before editing)
86
+ - You need the exact, complete content of a known file path
87
+
88
+ Other search tools:
89
+ - `expand_chunk` — get full source for a compressed result
90
+ - `related_context` — find what calls/imports a function
91
+
92
+ ### Cross-session memory — use it actively
93
+
94
+ This project has persistent memory across Claude Code sessions. **You must
95
+ use it both ways: recall before answering, record after deciding.** Memory
96
+ that is not recorded is lost; memory that is not recalled does nothing.
97
+
98
+ **Before answering a non-trivial question, call `session_recall`.**
99
+ Especially when:
100
+ - The question touches architecture, design, or naming choices
101
+ - The user asks "what / why / how did we ..."
102
+ - You are about to recommend an approach the team may have already chosen
103
+ or already rejected
104
+
105
+ Pass a topic phrase, not a single word — e.g. `session_recall("auth flow")`,
106
+ not `session_recall("auth")`. Recall is vector-similarity-based, so paraphrases
107
+ match. If recall returns relevant entries, lead with them ("Per a prior
108
+ decision: ...") instead of re-deriving the answer.
109
+
110
+ **After making a non-obvious decision, call `record_decision`.** Especially:
111
+ - Choosing one library / pattern / approach over another
112
+ - Resolving an ambiguity in the spec or requirements
113
+ - Establishing a convention the project should follow going forward
114
+ - Anything you would not want to re-litigate next session
115
+
116
+ Format: `record_decision(decision="...", reason="...")`. Keep both fields
117
+ short and specific — they are surfaced verbatim at the start of future
118
+ sessions.
119
+
120
+ **After meaningful work in a file, call `record_code_area`.** Especially when:
121
+ - You added or substantially modified a function/class
122
+ - You traced through a non-obvious flow and want future-you to find it fast
123
+
124
+ Format: `record_code_area(file_path="...", description="...")`.
125
+
126
+ Skip recording for trivial reads, formatting changes, or one-off lookups —
127
+ the goal is durable signal, not an event log.
128
+
129
+ ### Drilling deeper from a recall hit
130
+
131
+ `session_recall` results are tagged with the source session id, e.g.
132
+ `[turn sid:abc123|n:5]`. To drill in:
133
+
134
+ - `session_timeline(session_id="abc123")` — walk the per-turn summaries of
135
+ that session in order. Use this when the user asks "what was the
136
+ reasoning?" or "how did we get there?".
137
+ - `session_event(event_id=N)` — fetch a specific tool event's raw input
138
+ and output (capped at 4 KB at read time). Use this when a turn summary
139
+ references a tool result you actually need to inspect.
140
+
141
+ Both are read-only and cheap. Prefer them over re-running tool calls or
142
+ asking the user to re-paste context.
143
+
144
+ ## Output Style
145
+
146
+ Be concise. Lead with the answer or action, not reasoning. Skip filler words,
147
+ preamble, and phrases like "I'll help you with that" or "Certainly!". Prefer
148
+ fragments over full sentences in explanations. No trailing summaries of what
149
+ you just did. One sentence if it fits.
150
+
151
+ Code blocks, file paths, commands, and error messages are always written in full.
152
+ {_CCE_CLAUDE_MD_END_MARKER}
153
+ """
154
+
155
+
156
+ def _resolve_cce_cmd() -> str:
157
+ """Find the globally installed cce binary path."""
158
+ from context_engine.utils import resolve_cce_binary
159
+ return resolve_cce_binary()
160
+
161
+
162
+ def _has_cce_hook(hook_list: list, marker: str) -> bool:
163
+ """Check if a CCE hook already exists in a hooks list."""
164
+ for entry in hook_list:
165
+ for h in entry.get("hooks", []):
166
+ if marker in h.get("command", ""):
167
+ return True
168
+ return False
169
+
170
+
171
+ def _install_memory_hooks(project_dir: Path) -> None:
172
+ """Install the 5 lifecycle hooks for memory capture (PR 2).
173
+
174
+ Writes ~/.cce/hooks/cce_hook.sh and wires <project>/.claude/settings.json
175
+ entries for SessionStart, UserPromptSubmit, PostToolUse, Stop, SessionEnd.
176
+ Idempotent.
177
+ """
178
+ from context_engine.memory.hook_installer import (
179
+ install_hook_script, install_settings,
180
+ )
181
+ install_hook_script()
182
+ summary = install_settings(project_dir)
183
+ if summary["added"]:
184
+ _ok(
185
+ f"Memory hooks installed "
186
+ + _dim(f"({len(summary['added'])} hooks: {', '.join(summary['added'])})")
187
+ )
188
+ elif summary["skipped"]:
189
+ _ok("Memory hooks already configured")
190
+
191
+
192
+ def _check_memory_capture_reachable(config, project_dir: Path) -> None:
193
+ """Probe the loopback hook server so the user knows whether `cce serve` is
194
+ actually running before they restart Claude Code expecting capture to work.
195
+
196
+ Hooks fail closed (`curl ... || true`), so a missing daemon means capture
197
+ is *silently* disabled — exactly the onboarding footgun this guards
198
+ against. We never block init; we just print clear next steps.
199
+ """
200
+ import socket
201
+ project_name = project_dir.name
202
+ storage_base = Path(config.storage_path) / project_name
203
+ # Try the storage-local file first (authoritative), then fall back to
204
+ # the default-path rendezvous file `cce serve` writes for the hook
205
+ # shell script. Either is sufficient for the probe.
206
+ candidates = [
207
+ storage_base / "serve.port",
208
+ Path.home() / ".cce" / "projects" / project_name / "serve.port",
209
+ ]
210
+ port_file = next((p for p in candidates if p.exists()), None)
211
+ if port_file is None:
212
+ _warn(
213
+ "Memory capture not yet active — `cce serve` hasn't been started "
214
+ "for this project."
215
+ )
216
+ click.echo(
217
+ _dim(
218
+ " Run `cce serve` in a separate terminal so the loopback "
219
+ "hook server starts;\n"
220
+ " until it's running, hooks fire successfully but capture "
221
+ "is silently dropped.\n"
222
+ " Verify any time with `cce sessions status`."
223
+ )
224
+ )
225
+ return
226
+ try:
227
+ port = int(port_file.read_text().strip())
228
+ except (OSError, ValueError):
229
+ _warn(f"Memory capture port file unreadable at {port_file}")
230
+ return
231
+ try:
232
+ with socket.create_connection(("127.0.0.1", port), timeout=1.0):
233
+ pass
234
+ except OSError:
235
+ _warn(
236
+ f"Memory capture stale — found serve.port at :{port} but "
237
+ "nothing is listening."
238
+ )
239
+ click.echo(
240
+ _dim(
241
+ " Either `cce serve` exited or is bound to a different "
242
+ "port now.\n"
243
+ " Restart it; the new port replaces the stale file on "
244
+ "first hook fire."
245
+ )
246
+ )
247
+ return
248
+ _ok(f"Memory capture active " + _dim(f"(127.0.0.1:{port} reachable)"))
249
+
250
+
251
+ def _ensure_session_hook(project_dir: Path) -> None:
252
+ """Add Claude Code hooks so CCE status shows on startup."""
253
+ settings_dir = project_dir / ".claude"
254
+ settings_dir.mkdir(exist_ok=True)
255
+ settings_path = settings_dir / "settings.local.json"
256
+
257
+ if settings_path.exists():
258
+ try:
259
+ data = json.loads(settings_path.read_text())
260
+ except (json.JSONDecodeError, OSError):
261
+ data = {}
262
+ else:
263
+ data = {}
264
+
265
+ hooks = data.setdefault("hooks", {})
266
+ cce_cmd = _resolve_cce_cmd()
267
+ changed = False
268
+
269
+ # SessionStart hook — show CCE status
270
+ session_hooks = hooks.setdefault("SessionStart", [])
271
+ if not _has_cce_hook(session_hooks, "cce status"):
272
+ session_hooks.append({
273
+ "matcher": "",
274
+ "hooks": [{"type": "command", "command": f"{cce_cmd} status --oneline"}],
275
+ })
276
+ changed = True
277
+
278
+ if changed:
279
+ settings_path.write_text(json.dumps(data, indent=2) + "\n")
280
+ _ok("SessionStart hook installed for CCE status")
281
+
282
+
283
+ from context_engine.cli_style import success, warn as _warn_style, dim as _dim_style, value, header, label, CHECK, CROSS, DOT, ARROW
284
+
285
+
286
+ def _ok(msg: str) -> None:
287
+ """Print a green ✓ success line."""
288
+ click.echo(f" {CHECK} {msg}")
289
+
290
+
291
+ def _warn(msg: str) -> None:
292
+ """Print a yellow ! warning line."""
293
+ click.echo(f" {DOT} {_warn_style(msg)}")
294
+
295
+
296
+ def _dim(msg: str) -> str:
297
+ return _dim_style(msg)
298
+
299
+
300
+ def _show_welcome_banner(config) -> None:
301
+ """Show an animated welcome banner when cce is run with no subcommand."""
302
+ import json as _json
303
+ import random
304
+ import re
305
+ import time
306
+ from importlib.metadata import version as pkg_version
307
+
308
+ try:
309
+ ver = pkg_version("code-context-engine")
310
+ except Exception:
311
+ ver = "?"
312
+
313
+ project_dir = Path.cwd()
314
+ project_name = project_dir.name
315
+ storage_dir = Path(config.storage_path) / project_name
316
+
317
+ # Gather stats
318
+ chunks = 0
319
+ queries = 0
320
+ full_file = 0
321
+ served = 0
322
+ saved_pct = 0
323
+ try:
324
+ from context_engine.storage.vector_store import VectorStore
325
+ vs = VectorStore(db_path=str(storage_dir / "vectors"))
326
+ chunks = vs.count()
327
+ except Exception:
328
+ pass
329
+ stats_path = storage_dir / "stats.json"
330
+ if stats_path.exists():
331
+ try:
332
+ stats = _json.loads(stats_path.read_text())
333
+ queries = stats.get("queries", 0)
334
+ full_file = stats.get("full_file_tokens", 0)
335
+ served = stats.get("served_tokens", 0)
336
+ if full_file > 0:
337
+ saved_pct = int((full_file - served) / full_file * 100)
338
+ except Exception:
339
+ pass
340
+
341
+ # Ollama check
342
+ ollama_running = False
343
+ ollama_model = getattr(config, "compression_model", "phi3:mini")
344
+ try:
345
+ import httpx
346
+ resp = httpx.get("http://localhost:11434/api/tags", timeout=1.0)
347
+ if resp.status_code == 200:
348
+ ollama_running = True
349
+ except Exception:
350
+ pass
351
+
352
+ embedding_model = getattr(config, "embedding_model", "BAAI/bge-small-en-v1.5")
353
+ compression_mode = f"LLM ({ollama_model})" if ollama_running else "truncation"
354
+ profile = config.detect_resource_profile()
355
+ indexed = chunks > 0
356
+
357
+ icons = ["⛁", "◈", "⬡", "◉", "⏣", "⎔", "▣", "◇", "⬢", "❖"]
358
+ icon = random.choice(icons)
359
+
360
+ # ── ANSI helpers ──
361
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
362
+
363
+ def _vis_len(s: str) -> int:
364
+ """Visible length of a string (strips ANSI codes)."""
365
+ return len(_ANSI_RE.sub("", s))
366
+
367
+ # Color shortcuts that return styled text
368
+ C = "\033[36m" # cyan
369
+ CB = "\033[1;36m" # cyan bold
370
+ G = "\033[32m" # green
371
+ GB = "\033[1;32m" # green bold
372
+ Y = "\033[33m" # yellow
373
+ WB = "\033[1;37m" # white bold
374
+ D = "\033[2m" # dim
375
+ M = "\033[35m" # magenta
376
+ R = "\033[0m" # reset
377
+
378
+ # ── Layout constants ──
379
+ # Box: │ <LW> │ <RW> │ with 1 space padding each side
380
+ # Total = 1 + 1 + LW + 1 + 1 + 1 + RW + 1 + 1 = LW + RW + 7
381
+ LW = 42
382
+ RW = 38
383
+ W = LW + RW + 7
384
+ FW = W - 2
385
+
386
+ def _rpad(text: str, width: int) -> str:
387
+ """Right-pad styled text to exact visible width."""
388
+ vl = _vis_len(text)
389
+ return text + " " * max(0, width - vl)
390
+
391
+ def _center(text: str, width: int) -> str:
392
+ """Center styled text in exact visible width."""
393
+ vl = _vis_len(text)
394
+ lp = max(0, (width - vl) // 2)
395
+ rp = max(0, width - vl - lp)
396
+ return " " * lp + text + " " * rp
397
+
398
+ def full_line(text: str) -> str:
399
+ return f"{D}│{R} {_center(text, FW - 2)} {D}│{R}"
400
+
401
+ def empty_line() -> str:
402
+ return f"{D}│{R}{' ' * FW}{D}│{R}"
403
+
404
+ def two_col(left: str, right: str) -> str:
405
+ l = _rpad(left, LW)
406
+ r = _rpad(right, RW)
407
+ return f"{D}│{R} {l} {D}│{R} {r} {D}│{R}"
408
+
409
+ # ── Borders ──
410
+ title = f" Code Context Engine v{ver} "
411
+ dashes = W - 2 - len(title)
412
+ ld = dashes // 2
413
+ rd = dashes - ld
414
+
415
+ top_border = f"{D}╭{'─' * ld}{R}{CB}{title}{R}{D}{'─' * rd}╮{R}"
416
+ mid_border = f"{D}├{'─' * (LW + 2)}┬{'─' * (RW + 2)}┤{R}"
417
+ bot_border = f"{D}╰{'─' * (LW + 2)}┴{'─' * (RW + 2)}╯{R}"
418
+
419
+ # ── Build output ──
420
+ out: list[str] = []
421
+
422
+ # Header (full width)
423
+ out.append(top_border)
424
+ out.append(empty_line())
425
+ out.append(full_line(f"{CB}{icon} C C E {icon}{R}"))
426
+ out.append(empty_line())
427
+ out.append(full_line(f"{WB}{project_name}{R}"))
428
+ out.append(full_line(f"{D}{profile} profile · {project_dir}{R}"))
429
+ out.append(empty_line())
430
+
431
+ # Two-column section
432
+ out.append(mid_border)
433
+
434
+ # Build left lines
435
+ left_lines: list[str] = []
436
+ left_lines.append(f"{WB}Status{R}")
437
+ if indexed:
438
+ left_lines.append(f" {G}●{R} Indexed {C}{chunks:,} chunks{R}")
439
+ left_lines.append(f" {G}●{R} Embedding {C}{embedding_model}{R}")
440
+ if ollama_running:
441
+ left_lines.append(f" {G}●{R} Ollama {G}running{R}")
442
+ else:
443
+ left_lines.append(f" {Y}○{R} Ollama {Y}not running{R}")
444
+ left_lines.append(f" {G}●{R} Compress {C}{compression_mode}{R}")
445
+ if queries > 0:
446
+ left_lines.append(f" {G}●{R} Savings {GB}{saved_pct}%{R} over {C}{queries}{R} queries")
447
+ elif full_file > 0:
448
+ left_lines.append(f" {D}○ Savings no queries yet{R}")
449
+ else:
450
+ left_lines.append(f" {Y}○ Not indexed{R}")
451
+ left_lines.append(f" {D}run: cce init{R}")
452
+
453
+ # Build right lines
454
+ right_lines: list[str] = []
455
+ right_lines.append(f"{WB}Getting started{R}")
456
+ if not indexed:
457
+ right_lines.append(f" {C}cce init{R} {D}setup project{R}")
458
+ right_lines.append(f" {C}cce status{R} {D}full diagnostics{R}")
459
+ right_lines.append(f" {C}cce savings{R} {D}token savings{R}")
460
+ right_lines.append(f" {C}cce list{R} {D}all commands{R}")
461
+ right_lines.append("")
462
+ right_lines.append(f"{D}{'─' * RW}{R}")
463
+ right_lines.append(f" {D}Embed:{R} {M}{embedding_model}{R}")
464
+ if ollama_running:
465
+ right_lines.append(f" {D}Ollama:{R} {G}running ({ollama_model}){R}")
466
+ else:
467
+ right_lines.append(f" {D}Ollama:{R} {Y}not running{R}")
468
+
469
+ # Pad to same height
470
+ max_h = max(len(left_lines), len(right_lines))
471
+ while len(left_lines) < max_h:
472
+ left_lines.append("")
473
+ while len(right_lines) < max_h:
474
+ right_lines.append("")
475
+
476
+ for lt, rt in zip(left_lines, right_lines):
477
+ out.append(two_col(lt, rt))
478
+
479
+ out.append(bot_border)
480
+
481
+ # ── Animate ──
482
+ click.echo()
483
+ is_tty = sys.stdout.isatty()
484
+ for i, line in enumerate(out):
485
+ click.echo(line)
486
+ if is_tty and i < 8:
487
+ time.sleep(0.03)
488
+ click.echo()
489
+
490
+
491
+ def _preflight_check(config) -> None:
492
+ """Verify all required components are ready before indexing starts.
493
+
494
+ Downloads the embedding model on first use with a clear progress message,
495
+ and reports Ollama status so users know what compression level they will get.
496
+ """
497
+ # --- Embedding model ---
498
+ click.echo(_dim(" Checking embedding model") + "...", nl=False)
499
+ try:
500
+ from fastembed import TextEmbedding
501
+ model_name = getattr(config, "embedding_model", "BAAI/bge-small-en-v1.5")
502
+ if "/" not in model_name:
503
+ model_name = f"sentence-transformers/{model_name}"
504
+ click.echo(_dim(" downloading if needed (60 MB, first time only)") + "...", nl=False)
505
+ TextEmbedding(model_name)
506
+ click.echo(" " + click.style("ready", fg="green"))
507
+ except Exception as exc:
508
+ click.echo("")
509
+ _warn(f"Could not load embedding model: {exc}")
510
+ _warn("Indexing will attempt to continue but may fail.")
511
+
512
+ # --- Ollama (optional) ---
513
+ try:
514
+ import httpx
515
+ resp = httpx.get("http://localhost:11434/api/tags", timeout=2.0)
516
+ if resp.status_code == 200:
517
+ click.echo(
518
+ " Ollama " + click.style("detected", fg="green") +
519
+ " — LLM summarization enabled."
520
+ )
521
+ else:
522
+ click.echo(
523
+ " Ollama " + click.style("not running", fg="yellow") +
524
+ " — using truncation compression."
525
+ )
526
+ click.echo(_dim(" Tip: ollama pull phi3:mini for LLM summarization"))
527
+ except Exception:
528
+ click.echo(
529
+ " Ollama " + click.style("not running", fg="yellow") +
530
+ " — using truncation compression."
531
+ )
532
+ click.echo(_dim(" Tip: ollama pull phi3:mini for LLM summarization"))
533
+
534
+
535
+ def _ensure_claude_md(project_dir: Path) -> None:
536
+ """Add or upgrade the CCE instructions block in CLAUDE.md.
537
+
538
+ Three states the file can be in:
539
+ - Missing: write the block.
540
+ - Has the current version (matching version tag): no-op.
541
+ - Has an older version OR a pre-versioned block: replace just the CCE
542
+ block, preserving everything else the user wrote in CLAUDE.md.
543
+
544
+ Without the upgrade path, projects installed with v0.2.x kept the old
545
+ instructions forever — Claude never learned to call record_decision /
546
+ session_recall and the cross-session memory loop stayed broken.
547
+ """
548
+ from context_engine.utils import atomic_write_text
549
+
550
+ claude_md = project_dir / "CLAUDE.md"
551
+ if not claude_md.exists():
552
+ atomic_write_text(claude_md, _CCE_CLAUDE_MD_BLOCK)
553
+ _ok("CLAUDE.md created with CCE instructions")
554
+ return
555
+
556
+ existing = claude_md.read_text()
557
+
558
+ # Already on the current version — nothing to do.
559
+ if _CCE_CLAUDE_MD_VERSION_TAG in existing:
560
+ return
561
+
562
+ # An older versioned block OR a pre-versioned block (just the marker).
563
+ # Replace it in place so any custom content the user added around it
564
+ # survives the upgrade.
565
+ old_block = _extract_existing_cce_block(existing)
566
+ if old_block is not None:
567
+ new_content = existing.replace(old_block, _CCE_CLAUDE_MD_BLOCK.rstrip(), 1)
568
+ atomic_write_text(claude_md, new_content)
569
+ _ok("CLAUDE.md upgraded to current CCE instructions")
570
+ return
571
+
572
+ # No CCE block detected — append.
573
+ new_content = existing.rstrip() + "\n\n" + _CCE_CLAUDE_MD_BLOCK
574
+ atomic_write_text(claude_md, new_content)
575
+ _ok("CLAUDE.md updated with CCE instructions")
576
+
577
+
578
+ def _extract_existing_cce_block(content: str) -> str | None:
579
+ """Return the existing CCE block text from a CLAUDE.md, or None.
580
+
581
+ Recognises both the new versioned form (version tag → end marker) and
582
+ the legacy unmarked form (the `## Context Engine (CCE)` heading through
583
+ end-of-file). Returns the slice without trailing whitespace so the
584
+ caller can do an exact string-replace.
585
+ """
586
+ # New format: bounded by version tag and end marker.
587
+ if _CCE_CLAUDE_MD_VERSION_PREFIX in content and _CCE_CLAUDE_MD_END_MARKER in content:
588
+ start = content.find(_CCE_CLAUDE_MD_VERSION_PREFIX)
589
+ end_pos = content.find(_CCE_CLAUDE_MD_END_MARKER, start)
590
+ if start != -1 and end_pos != -1:
591
+ end_pos += len(_CCE_CLAUDE_MD_END_MARKER)
592
+ return content[start:end_pos].rstrip()
593
+
594
+ # Legacy format: the marker heading through the next top-level heading
595
+ # or end of file. Conservative — if the user put their own H2 right
596
+ # after the CCE block, we stop there and don't eat user content.
597
+ if _CCE_CLAUDE_MD_MARKER not in content:
598
+ return None
599
+ start = content.find(_CCE_CLAUDE_MD_MARKER)
600
+ after_start = content.find("\n## ", start + len(_CCE_CLAUDE_MD_MARKER))
601
+ end = after_start if after_start != -1 else len(content)
602
+ return content[start:end].rstrip()
603
+
604
+
605
+ @click.group(invoke_without_command=True)
606
+ @click.version_option(package_name="code-context-engine")
607
+ @click.option("--verbose", "-v", is_flag=True, help="Enable detailed logging output")
608
+ @click.pass_context
609
+ def main(ctx: click.Context, verbose: bool) -> None:
610
+ """code-context-engine — Local context engine for AI coding assistants."""
611
+ ctx.ensure_object(dict)
612
+ project_path = Path.cwd() / PROJECT_CONFIG_NAME
613
+ ctx.obj["config"] = load_config(project_path=project_path if project_path.exists() else None)
614
+ ctx.obj["verbose"] = verbose
615
+
616
+ if ctx.invoked_subcommand is None:
617
+ _show_welcome_banner(ctx.obj["config"])
618
+
619
+
620
+ @main.command()
621
+ @click.pass_context
622
+ def init(ctx: click.Context) -> None:
623
+ """Initialize context engine and connect it to Claude Code."""
624
+ from context_engine.indexer.git_hooks import install_hooks
625
+ from context_engine.project_commands import ensure_gitignore
626
+ config = ctx.obj["config"]
627
+ project_dir = Path.cwd()
628
+
629
+ click.echo("")
630
+ click.echo(
631
+ click.style(" Code Context Engine", fg="cyan", bold=True) +
632
+ click.style(f" · {project_dir.name}", fg="white", bold=True)
633
+ )
634
+ click.echo(_dim(" " + "─" * 44))
635
+ click.echo("")
636
+
637
+ # 1. Pre-flight: verify embedding model + report Ollama status
638
+ _preflight_check(config)
639
+ click.echo("")
640
+
641
+ # 2. Storage
642
+ project_name = project_dir.name
643
+ storage_dir = Path(config.storage_path) / project_name
644
+ storage_dir.mkdir(parents=True, exist_ok=True)
645
+ meta_path = storage_dir / "meta.json"
646
+ meta_path.write_text(json.dumps({"project_dir": str(project_dir.resolve())}))
647
+
648
+ # 3. Git hooks
649
+ is_git_repo = (project_dir / ".git").exists()
650
+ if is_git_repo:
651
+ installed = install_hooks(str(project_dir))
652
+ if installed:
653
+ _ok(f"Git hooks installed " + _dim(f"({len(installed)} hooks, auto-updates on commit)"))
654
+ else:
655
+ _warn("Not a git repository — git hook skipped")
656
+ click.echo(_dim(" Run `cce index` manually after making changes."))
657
+
658
+ # 4. MCP config — Claude Code + any detected editors
659
+ from context_engine.editors import (
660
+ EDITORS, INSTRUCTION_FILES,
661
+ detect_editors, configure_mcp, write_instruction_file,
662
+ )
663
+ configured = _configure_mcp(project_dir)
664
+ if configured:
665
+ _ok("MCP server registered in " + click.style(".mcp.json", fg="cyan"))
666
+ else:
667
+ _ok("MCP server already configured in " + click.style(".mcp.json", fg="cyan"))
668
+
669
+ # Configure MCP for other detected editors (Cursor, VS Code, Gemini)
670
+ detected = detect_editors(project_dir)
671
+ for editor_key in detected:
672
+ if editor_key == "claude":
673
+ continue # already handled above
674
+ editor = EDITORS[editor_key]
675
+ if configure_mcp(project_dir, editor_key):
676
+ _ok(f"MCP server registered for {editor['name']}")
677
+ else:
678
+ _ok(f"MCP server already configured for {editor['name']}")
679
+
680
+ # Write instruction files for detected editors
681
+ for file_key, info in INSTRUCTION_FILES.items():
682
+ for marker in info["detect"]:
683
+ if (project_dir / marker).exists():
684
+ if write_instruction_file(project_dir, file_key):
685
+ _ok(f"CCE instructions added to {info['name']}")
686
+ break
687
+
688
+ # 5. CLAUDE.md + session hook + memory lifecycle hooks
689
+ _ensure_claude_md(project_dir)
690
+ _ensure_session_hook(project_dir)
691
+ _install_memory_hooks(project_dir)
692
+ _check_memory_capture_reachable(config, project_dir)
693
+
694
+ # 6. .gitignore — add CCE per-machine entries
695
+ ensure_gitignore(str(project_dir))
696
+ _ok(".gitignore updated with CCE entries")
697
+
698
+ click.echo("")
699
+ click.echo(
700
+ " " + click.style("Indexing project", fg="cyan", bold=True) + "..."
701
+ )
702
+ asyncio.run(_run_index(config, str(project_dir), full=True))
703
+ click.echo("")
704
+ click.echo(
705
+ click.style(" Done!", fg="green", bold=True) +
706
+ click.style(" Restart Claude Code to activate CCE.", fg="white")
707
+ )
708
+ click.echo("")
709
+
710
+
711
+ @main.command()
712
+ @click.option("--full", is_flag=True, help="Force full re-index of every file")
713
+ @click.option("--path", type=str, default=None, help="Index only this file or directory")
714
+ @click.pass_context
715
+ def index(ctx: click.Context, full: bool, path: str | None) -> None:
716
+ """Index or re-index project files."""
717
+ config = ctx.obj["config"]
718
+ verbose = ctx.obj["verbose"]
719
+ project_dir = str(Path.cwd())
720
+ from context_engine.cli_style import section, animate
721
+ lines = ["", section("Indexing " + Path.cwd().name)]
722
+ animate(lines)
723
+ asyncio.run(_run_index(config, project_dir, full=full, target_path=path, verbose=verbose))
724
+
725
+
726
+ @main.command()
727
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
728
+ @click.option("--oneline", is_flag=True, help="Single-line status for hooks")
729
+ @click.pass_context
730
+ def status(ctx: click.Context, output_json: bool, oneline: bool) -> None:
731
+ """Show index status and config."""
732
+ import json as _json
733
+ from importlib.metadata import version as pkg_version
734
+ config = ctx.obj["config"]
735
+ verbose = ctx.obj["verbose"]
736
+
737
+ if oneline:
738
+ try:
739
+ ver = pkg_version("code-context-engine")
740
+ except Exception:
741
+ ver = "?"
742
+ project_name = Path.cwd().name
743
+ storage = Path(config.storage_path) / project_name
744
+ stats_path = storage / "stats.json"
745
+ chunks = 0
746
+ savings = ""
747
+ try:
748
+ from context_engine.storage.vector_store import VectorStore
749
+ vs = VectorStore(db_path=str(storage / "vectors"))
750
+ chunks = vs.count()
751
+ except Exception:
752
+ pass
753
+ if stats_path.exists():
754
+ try:
755
+ stats = _json.loads(stats_path.read_text())
756
+ q = stats.get("queries", 0)
757
+ full = stats.get("full_file_tokens", 0)
758
+ served = stats.get("served_tokens", 0)
759
+ if q > 0 and full > 0:
760
+ pct = int((full - served) / full * 100)
761
+ savings = f" · {pct}% saved over {q} queries"
762
+ except Exception:
763
+ pass
764
+ click.echo(
765
+ f"CCE v{ver} · {project_name} · {chunks} chunks indexed{savings}\n"
766
+ f"USE context_search MCP tool for all code questions. Do NOT use Read/Grep to explore code."
767
+ )
768
+ return
769
+
770
+ if output_json:
771
+ out = {
772
+ "storage_path": config.storage_path,
773
+ "compression_level": config.compression_level,
774
+ "resource_profile": config.detect_resource_profile(),
775
+ }
776
+ click.echo(_json.dumps(out, indent=2))
777
+ return
778
+
779
+ from context_engine.cli_style import (
780
+ header, label, value, dim, success, warn, magenta, section, animate,
781
+ CHECK, DOT, CROSS, BULLET, BULLET_OFF,
782
+ )
783
+
784
+ lines: list[str] = []
785
+ lines.append("")
786
+ lines.append(section("Status · " + Path.cwd().name))
787
+ lines.append("")
788
+ lines.append(f" {BULLET} {label('Storage')} {value(config.storage_path)}")
789
+ lines.append(f" {BULLET} {label('Compression')} {value(config.compression_level)}")
790
+ lines.append(f" {BULLET} {label('Profile')} {value(config.detect_resource_profile())}")
791
+
792
+ # Embedding model
793
+ model_name = getattr(config, "embedding_model", "BAAI/bge-small-en-v1.5")
794
+ lines.append(f" {BULLET} {label('Embedding')} {magenta(model_name)}")
795
+
796
+ # Ollama status
797
+ ollama_status = warn("not running")
798
+ compression_mode = "truncation (signatures + docstrings)"
799
+ ollama_bullet = BULLET_OFF
800
+ try:
801
+ import httpx
802
+ resp = httpx.get("http://localhost:11434/api/tags", timeout=2.0)
803
+ if resp.status_code == 200:
804
+ ollama_model = getattr(config, "compression_model", "phi3:mini")
805
+ models = [m.get("name", "") for m in resp.json().get("models", [])]
806
+ if any(ollama_model in m for m in models):
807
+ ollama_status = success("running") + dim(f" ({ollama_model})")
808
+ compression_mode = f"LLM summarization via {ollama_model}"
809
+ ollama_bullet = BULLET
810
+ else:
811
+ ollama_status = success("running") + dim(f" (model {ollama_model} not found)")
812
+ except Exception:
813
+ pass
814
+ lines.append(f" {ollama_bullet} {label('Ollama')} {ollama_status}")
815
+ lines.append(f" {BULLET} {label('Compress')} {value(compression_mode)}")
816
+
817
+ # Token savings
818
+ project_name = Path.cwd().name
819
+ stats_path = Path(config.storage_path) / project_name / "stats.json"
820
+ lines.append("")
821
+ lines.append(section("Token Savings"))
822
+ lines.append("")
823
+ if stats_path.exists():
824
+ try:
825
+ stats = _json.loads(stats_path.read_text())
826
+ raw = stats.get("raw_tokens", 0)
827
+ full = stats.get("full_file_tokens", 0)
828
+ served = stats.get("served_tokens", 0)
829
+ queries = stats.get("queries", 0)
830
+ baseline = max(full, raw) if full > 0 else raw
831
+ saved = max(0, baseline - served)
832
+ pct = int(saved / baseline * 100) if baseline > 0 else 0
833
+ lines.append(f" {dim('Queries:')} {value(f'{queries:,}')}")
834
+ lines.append(f" {dim('Full codebase:')} {value(f'{baseline:,}')} {dim('tokens')}")
835
+ lines.append(f" {dim('Served:')} {value(f'{served:,}')} {dim('tokens')}")
836
+ lines.append(f" {CHECK} {success(f'Saved: {saved:,} tokens ({pct}%)')}")
837
+ except (KeyError, _json.JSONDecodeError):
838
+ lines.append(f" {DOT} {dim('Error reading stats')}")
839
+ else:
840
+ storage_dir = Path(config.storage_path) / Path.cwd().name
841
+ vectors_dir = storage_dir / "vectors"
842
+ if not vectors_dir.exists():
843
+ lines.append(f" {DOT} {dim('Project not indexed yet')} {label('cce init')}")
844
+ else:
845
+ lines.append(f" {DOT} {dim('No usage recorded yet')} {dim('run context_search via MCP')}")
846
+
847
+ # Embedding cache stats — surfaces how much the cache is actually saving.
848
+ cache_db = Path(config.storage_path) / Path.cwd().name / "embedding_cache.db"
849
+ if cache_db.exists():
850
+ try:
851
+ from context_engine.indexer.embedding_cache import EmbeddingCache
852
+ _cache = EmbeddingCache(cache_db)
853
+ try:
854
+ cache_size = _cache.size()
855
+ finally:
856
+ _cache.close()
857
+ db_size_mb = cache_db.stat().st_size / (1024 * 1024)
858
+ lines.append("")
859
+ lines.append(section("Embedding Cache"))
860
+ lines.append("")
861
+ lines.append(f" {BULLET} {label('Cached embeddings')} {value(f'{cache_size:,}')}")
862
+ lines.append(f" {BULLET} {label('Cache size')} {value(f'{db_size_mb:.1f} MB')}")
863
+ except Exception:
864
+ lines.append("")
865
+ lines.append(f" {DOT} {dim('Error reading embedding cache')}")
866
+
867
+ if verbose:
868
+ storage_path = Path(config.storage_path)
869
+ if storage_path.exists():
870
+ projects = [d for d in storage_path.iterdir() if d.is_dir()]
871
+ lines.append("")
872
+ lines.append(section("Projects Indexed"))
873
+ lines.append("")
874
+ for project in projects:
875
+ chunks_count = len(list(project.glob("**/*.json")))
876
+ lines.append(f" {dim('·')} {value(project.name)} {dim(f'{chunks_count} files')}")
877
+ else:
878
+ lines.append(f" {DOT} {dim('Storage directory does not exist yet.')}")
879
+
880
+ lines.append("")
881
+ animate(lines)
882
+
883
+
884
+ @main.command("list")
885
+ def list_commands() -> None:
886
+ """Show all available CCE commands with usage examples."""
887
+ from context_engine.cli_style import header, dim, value, label, section, animate, ARROW
888
+
889
+ groups = [
890
+ ("Setup", [
891
+ ("cce init", "Index project, install git hooks, write .mcp.json"),
892
+ ("cce index", "Re-index changed files"),
893
+ ("cce index --full", "Force full re-index of every file"),
894
+ ("cce index --path <file>", "Index one file or directory"),
895
+ ]),
896
+ ("Status & Savings", [
897
+ ("cce status", "Index health, config, embedding model, Ollama status"),
898
+ ("cce status --json", "Machine-readable output"),
899
+ ("cce savings", "Token savings report with visual grid"),
900
+ ("cce savings --all", "Savings across every indexed project"),
901
+ ("cce savings --json", "Machine-readable savings output"),
902
+ ]),
903
+ ("Index Management", [
904
+ ("cce clear", "Clear all index data (asks for confirmation)"),
905
+ ("cce clear --yes", "Skip confirmation"),
906
+ ("cce prune", "Remove data for deleted projects"),
907
+ ("cce prune --dry-run", "Preview without deleting"),
908
+ ]),
909
+ ("Services", [
910
+ ("cce services", "Show status of Ollama, dashboard, MCP"),
911
+ ("cce services start", "Start Ollama + dashboard"),
912
+ ("cce services start ollama", "Start only Ollama"),
913
+ ("cce services start dashboard", "Start dashboard on default port"),
914
+ ("cce services stop", "Stop everything CCE started"),
915
+ ]),
916
+ ("Dashboard", [
917
+ ("cce dashboard", "Open web dashboard in browser"),
918
+ ("cce dashboard --port 8080", "Custom port"),
919
+ ("cce dashboard --no-browser", "Server only, no browser open"),
920
+ ]),
921
+ ("Project Commands", [
922
+ ("cce commands list", "Show all rules, preferences, and hooks"),
923
+ ("cce commands add-rule '<rule>'", "Add a project rule"),
924
+ ("cce commands remove-rule '<rule>'", "Remove a rule"),
925
+ ("cce commands set-pref <key> <val>", "Set a preference"),
926
+ ("cce commands remove-pref <key>", "Remove a preference"),
927
+ ("cce commands add <hook> '<cmd>'", "Add to before_push / before_commit / on_start"),
928
+ ("cce commands remove <hook> '<cmd>'", "Remove from a hook"),
929
+ ("cce commands add-custom <n> '<c>'", "Add a named custom command"),
930
+ ]),
931
+ ("Search", [
932
+ ("cce search '<query>'", "Run a test query and update savings stats"),
933
+ ("cce search '<query>' --top-k 10", "Return more results"),
934
+ ]),
935
+ ("Shortcuts", [
936
+ ("cce start", "Start all services (Ollama + dashboard)"),
937
+ ("cce stop", "Stop all services"),
938
+ ("cce start ollama", "Start only Ollama"),
939
+ ("cce stop dashboard", "Stop only dashboard"),
940
+ ]),
941
+ ("Lifecycle", [
942
+ ("cce init", "Install CCE in project"),
943
+ ("cce upgrade", "Upgrade CCE and refresh project config"),
944
+ ("cce upgrade --check", "Check install method without upgrading"),
945
+ ("cce uninstall", "Remove CCE from project (hooks, MCP, CLAUDE.md)"),
946
+ ("cce serve", "Start MCP server (used by Claude Code)"),
947
+ ]),
948
+ ("Other", [
949
+ ("cce list", "This command"),
950
+ ("cce --version", "Show version"),
951
+ ("cce --help", "Show help"),
952
+ ]),
953
+ ]
954
+
955
+ lines: list[str] = [""]
956
+ for group_name, cmds in groups:
957
+ lines.append(section(group_name))
958
+ for cmd, desc in cmds:
959
+ # Align descriptions at column 36
960
+ pad = max(1, 36 - len(cmd))
961
+ lines.append(f" {label(cmd)}{' ' * pad}{dim(desc)}")
962
+ lines.append("")
963
+
964
+ animate(lines)
965
+
966
+
967
+ @main.group()
968
+ def commands():
969
+ """Manage project-specific commands (before_push, before_commit, etc.)."""
970
+
971
+
972
+ @commands.command("add")
973
+ @click.argument("hook", type=click.Choice(["before_push", "before_commit", "on_start"]))
974
+ @click.argument("command")
975
+ def commands_add(hook: str, command: str) -> None:
976
+ """Add a command to a hook. Example: cce commands add before_push 'composer test'"""
977
+ from context_engine.project_commands import load_project_only, add_command
978
+ from context_engine.cli_style import success, warn, CHECK, DOT
979
+ existing = load_project_only(str(Path.cwd())).get(hook, [])
980
+ if command in existing:
981
+ click.echo(f" {DOT} {warn('Already exists')} in {hook}: {command}")
982
+ return
983
+ add_command(str(Path.cwd()), hook, command)
984
+ click.echo(f" {CHECK} {success('Added')} to {hook}: {command}")
985
+
986
+
987
+ @commands.command("add-custom")
988
+ @click.argument("name")
989
+ @click.argument("command")
990
+ def commands_add_custom(name: str, command: str) -> None:
991
+ """Add a named custom command. Example: cce commands add-custom deploy 'kubectl apply -f k8s/'"""
992
+ from context_engine.project_commands import add_custom_command
993
+ from context_engine.cli_style import success, CHECK
994
+ add_custom_command(str(Path.cwd()), name, command)
995
+ click.echo(f" {CHECK} {success('Added')} custom command '{name}': {command}")
996
+
997
+
998
+ @commands.command("remove")
999
+ @click.argument("hook")
1000
+ @click.argument("command")
1001
+ def commands_remove(hook: str, command: str) -> None:
1002
+ """Remove a command from a hook."""
1003
+ from context_engine.project_commands import remove_command
1004
+ from context_engine.cli_style import success, warn, CHECK, DOT
1005
+ if remove_command(str(Path.cwd()), hook, command):
1006
+ click.echo(f" {CHECK} {success('Removed')} from {hook}: {command}")
1007
+ else:
1008
+ click.echo(f" {DOT} {warn('Not found')} in {hook}: {command}")
1009
+
1010
+
1011
+ @commands.command("add-rule")
1012
+ @click.argument("rule")
1013
+ def commands_add_rule(rule: str) -> None:
1014
+ """Add a project rule. Example: cce commands add-rule 'Never use down() in migrations'"""
1015
+ from context_engine.project_commands import load_project_only, add_rule
1016
+ existing = load_project_only(str(Path.cwd())).get("rules", [])
1017
+ from context_engine.cli_style import success, warn, CHECK, DOT
1018
+ if rule in existing:
1019
+ click.echo(f" {DOT} {warn('Already exists')}: {rule}")
1020
+ return
1021
+ add_rule(str(Path.cwd()), rule)
1022
+ click.echo(f" {CHECK} {success('Rule added')}: {rule}")
1023
+
1024
+
1025
+ @commands.command("remove-rule")
1026
+ @click.argument("rule")
1027
+ def commands_remove_rule(rule: str) -> None:
1028
+ """Remove a project rule."""
1029
+ from context_engine.project_commands import remove_rule
1030
+ from context_engine.cli_style import success, warn, CHECK, DOT
1031
+ if remove_rule(str(Path.cwd()), rule):
1032
+ click.echo(f" {CHECK} {success('Rule removed')}: {rule}")
1033
+ else:
1034
+ click.echo(f" {DOT} {warn('Not found')}: {rule}")
1035
+
1036
+
1037
+ @commands.command("set-pref")
1038
+ @click.argument("key")
1039
+ @click.argument("value")
1040
+ def commands_set_pref(key: str, value: str) -> None:
1041
+ """Set a preference. Example: cce commands set-pref database PostgreSQL"""
1042
+ from context_engine.project_commands import set_preference
1043
+ from context_engine.cli_style import success, CHECK
1044
+ set_preference(str(Path.cwd()), key, value)
1045
+ click.echo(f" {CHECK} {success('Preference set')}: {key} = {value}")
1046
+
1047
+
1048
+ @commands.command("remove-pref")
1049
+ @click.argument("key")
1050
+ def commands_remove_pref(key: str) -> None:
1051
+ """Remove a preference."""
1052
+ from context_engine.project_commands import remove_preference
1053
+ from context_engine.cli_style import success, warn, CHECK, DOT
1054
+ if remove_preference(str(Path.cwd()), key):
1055
+ click.echo(f" {CHECK} {success('Preference removed')}: {key}")
1056
+ else:
1057
+ click.echo(f" {DOT} {warn('Not found')}: {key}")
1058
+
1059
+
1060
+ @commands.command("list")
1061
+ def commands_list() -> None:
1062
+ """Show all project commands, rules, and preferences (merged with workspace)."""
1063
+ from context_engine.project_commands import load_commands
1064
+ from context_engine.cli_style import header, label, dim, value, section, animate, DOT, ARROW, BULLET
1065
+
1066
+ cmds = load_commands(str(Path.cwd()))
1067
+ lines: list[str] = [""]
1068
+
1069
+ if not cmds:
1070
+ lines.append(section("Project Commands"))
1071
+ lines.append("")
1072
+ lines.append(f" {DOT} {dim('No project configuration found.')}")
1073
+ lines.append("")
1074
+ lines.append(f" {dim('Try:')} {label('cce commands add-rule')} {dim(chr(39))}Never use down(){dim(chr(39))}")
1075
+ lines.append(f" {label('cce commands set-pref')} {dim('database PostgreSQL')}")
1076
+ lines.append(f" {label('cce commands add')} {dim('before_push')} {dim(chr(39))}composer test{dim(chr(39))}")
1077
+ lines.append("")
1078
+ animate(lines)
1079
+ return
1080
+
1081
+ rules = cmds.get("rules", [])
1082
+ prefs = cmds.get("preferences", {})
1083
+ hooks = {k: v for k, v in cmds.items() if k not in ("rules", "preferences", "custom") and isinstance(v, list)}
1084
+ custom = cmds.get("custom", {})
1085
+
1086
+ if rules:
1087
+ lines.append(section("Rules"))
1088
+ for r in rules:
1089
+ lines.append(f" {ARROW} {r}")
1090
+ lines.append("")
1091
+ if prefs:
1092
+ lines.append(section("Preferences"))
1093
+ for k, v in prefs.items():
1094
+ pad = max(1, 18 - len(k))
1095
+ lines.append(f" {label(k)}{' ' * pad}{value(str(v))}")
1096
+ lines.append("")
1097
+ hook_labels = {"before_push": "Before push", "before_commit": "Before commit", "on_start": "On start"}
1098
+ for hook_key, hook_cmds in hooks.items():
1099
+ hook_name = hook_labels.get(hook_key, hook_key)
1100
+ lines.append(section(hook_name))
1101
+ for c in hook_cmds:
1102
+ lines.append(f" {BULLET} {dim('$')} {value(c)}")
1103
+ lines.append("")
1104
+ if custom:
1105
+ lines.append(section("Custom Commands"))
1106
+ for name, cmd in custom.items():
1107
+ pad = max(1, 14 - len(name))
1108
+ lines.append(f" {label(name)}{' ' * pad}{ARROW} {dim('$')} {value(cmd)}")
1109
+ lines.append("")
1110
+
1111
+ animate(lines)
1112
+
1113
+
1114
+ @main.command()
1115
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
1116
+ @click.option("--all", "all_projects", is_flag=True, help="Show savings for all indexed projects")
1117
+ @click.pass_context
1118
+ def savings(ctx: click.Context, as_json: bool, all_projects: bool) -> None:
1119
+ """Show token savings report — how much CCE is saving you."""
1120
+ config = ctx.obj["config"]
1121
+ _run_savings_report(config, as_json=as_json, all_projects=all_projects)
1122
+
1123
+
1124
+ def _run_savings_report(config, *, as_json: bool = False, all_projects: bool = False) -> None:
1125
+ """Shared implementation for savings report (used by subcommand and shortcut)."""
1126
+ import json as _json
1127
+
1128
+ storage_root = Path(config.storage_path)
1129
+
1130
+ def _load_stats(project_dir: Path) -> dict | None:
1131
+ stats_path = project_dir / "stats.json"
1132
+ if not stats_path.exists():
1133
+ return None
1134
+ try:
1135
+ return _json.loads(stats_path.read_text())
1136
+ except (KeyError, _json.JSONDecodeError):
1137
+ return None
1138
+
1139
+ def _load_buckets(project_dir: Path) -> tuple[dict, dict]:
1140
+ """Open memory.db and pull per-bucket savings + the
1141
+ output_compression level histogram. Falls back to bucket data
1142
+ embedded in stats.json if memory.db is missing or empty.
1143
+ Returns ({bucket: {baseline, served, calls}}, {level: count}).
1144
+ """
1145
+ from context_engine.memory import db as _memory_db
1146
+ db_path = project_dir / "memory.db"
1147
+ empty = {b: {"baseline": 0, "served": 0, "calls": 0} for b in _memory_db.BUCKETS}
1148
+
1149
+ # Try memory.db first
1150
+ if db_path.exists():
1151
+ try:
1152
+ conn = _memory_db.connect(db_path)
1153
+ try:
1154
+ buckets = _memory_db.aggregate_savings(conn)
1155
+ levels = _memory_db.aggregate_output_compression_levels(conn)
1156
+ # Only use if there's actual data
1157
+ total = sum(int(v.get("baseline", 0)) for v in buckets.values())
1158
+ if total > 0:
1159
+ return buckets, levels
1160
+ finally:
1161
+ conn.close()
1162
+ except Exception:
1163
+ pass
1164
+
1165
+ # Fall back to bucket data embedded in stats.json
1166
+ stats = _load_stats(project_dir)
1167
+ if stats and "buckets" in stats:
1168
+ buckets = {}
1169
+ for key, val in stats["buckets"].items():
1170
+ buckets[key] = {
1171
+ "baseline": int(val.get("baseline", 0)),
1172
+ "served": int(val.get("served", 0)),
1173
+ "calls": int(val.get("calls", 0)),
1174
+ }
1175
+ total = sum(v["baseline"] for v in buckets.values())
1176
+ if total > 0:
1177
+ return buckets, {}
1178
+
1179
+ return empty, {}
1180
+
1181
+ from context_engine.cli_style import header, label, value, dim, success, bold
1182
+ from context_engine.pricing import get_model_pricing
1183
+
1184
+ _all_pricing = get_model_pricing()
1185
+ _pricing_model = config.pricing_model.lower()
1186
+ _price_per_m = _all_pricing.get(_pricing_model, _all_pricing.get("opus", 5.0))
1187
+ _COST_PER_TOKEN = _price_per_m / 1_000_000
1188
+ _model_label = _pricing_model.capitalize()
1189
+ _GRID_COLS = 10
1190
+ _FILLED = "⛁"
1191
+ _EMPTY = "⛶"
1192
+
1193
+ def _fmt_tokens(n: int) -> str:
1194
+ if n >= 1_000_000:
1195
+ return f"{n / 1_000_000:.1f}M"
1196
+ if n >= 1000:
1197
+ return f"{n / 1000:.1f}k"
1198
+ return str(n)
1199
+
1200
+ def _fmt_cost(n: int) -> str:
1201
+ cost = n * _COST_PER_TOKEN
1202
+ if cost < 0.01:
1203
+ return "<$0.01"
1204
+ return f"${cost:.2f}"
1205
+
1206
+ def _bar(saved_pct: int) -> str:
1207
+ """Render ⛁ ⛁ ⛁ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ grid where filled = tokens used."""
1208
+ used_pct = 100 - saved_pct
1209
+ filled = max(1, min(_GRID_COLS, round(used_pct / 100 * _GRID_COLS)))
1210
+ cells = []
1211
+ for i in range(_GRID_COLS):
1212
+ if i < filled:
1213
+ cells.append(click.style(_FILLED, fg="cyan"))
1214
+ else:
1215
+ cells.append(click.style(_EMPTY, dim=True))
1216
+ return " ".join(cells)
1217
+
1218
+ # Bucket display metadata. Order = render order. `estimate=True` adds a
1219
+ # trailing asterisk and a footnote so users know it's a counterfactual.
1220
+ _BUCKET_DISPLAY = [
1221
+ ("retrieval", "retrieval", False),
1222
+ ("chunk_compression", "chunk compression", False),
1223
+ ("output_compression", "output compression", True),
1224
+ ("memory_recall", "memory recall", False),
1225
+ ("grammar", "grammar", False),
1226
+ ("turn_summarization", "turn summarization", False),
1227
+ ("progressive_disclosure", "progressive disclosure", True),
1228
+ ]
1229
+
1230
+ def _bucket_totals(buckets: dict) -> tuple[int, int]:
1231
+ """Sum baseline/served across all buckets."""
1232
+ b = sum(int(v.get("baseline", 0)) for v in buckets.values())
1233
+ s = sum(int(v.get("served", 0)) for v in buckets.values())
1234
+ return b, s
1235
+
1236
+ def _print_project(name: str, stats: dict, buckets: dict, levels: dict) -> None:
1237
+ queries = stats.get("queries", 0)
1238
+
1239
+ # Prefer canonical bucket totals; fall back to legacy stats.json
1240
+ # fields if the project hasn't accumulated any bucket events yet.
1241
+ bucket_baseline, bucket_served = _bucket_totals(buckets)
1242
+ if bucket_baseline > 0:
1243
+ baseline = bucket_baseline
1244
+ served = bucket_served
1245
+ else:
1246
+ full_file = stats.get("full_file_tokens", 0)
1247
+ raw = stats.get("raw_tokens", 0)
1248
+ served_legacy = stats.get("served_tokens", 0)
1249
+ baseline = max(full_file, raw) if full_file > 0 else raw
1250
+ served = served_legacy
1251
+
1252
+ tokens_saved = max(0, baseline - served)
1253
+ saved_pct = int(tokens_saved / baseline * 100) if baseline > 0 else 0
1254
+
1255
+ q_label = "query" if queries == 1 else "queries"
1256
+
1257
+ click.echo()
1258
+ click.echo(f" {bold(name)} {dim('·')} {value(str(queries))} {dim(q_label)}")
1259
+ click.echo()
1260
+
1261
+ # Headline bar + percentage
1262
+ click.echo(
1263
+ f" {_bar(saved_pct)} "
1264
+ f"{click.style(f'{saved_pct}%', fg='green', bold=True)} "
1265
+ f"{dim('tokens saved')}"
1266
+ )
1267
+ click.echo()
1268
+
1269
+ # Before / after / saved
1270
+ click.echo(
1271
+ f" {dim('Without CCE')} "
1272
+ f"{value(_fmt_tokens(baseline)):>10} {dim('tokens')} "
1273
+ f"{dim(_fmt_cost(baseline))}"
1274
+ )
1275
+ click.echo(
1276
+ f" {success('With CCE')} "
1277
+ f"{value(_fmt_tokens(served)):>10} {dim('tokens')} "
1278
+ f"{dim(_fmt_cost(served))}"
1279
+ )
1280
+ click.echo(f" {dim('─' * 42)}")
1281
+ click.echo(
1282
+ f" {success('Saved')} "
1283
+ f"{click.style(_fmt_tokens(tokens_saved), fg='green', bold=True):>10} {dim('tokens')} "
1284
+ f"{click.style(_fmt_cost(tokens_saved), fg='green', bold=True)}"
1285
+ )
1286
+ # Per-query average — the number a user actually grounds "is this
1287
+ # worth my time?" on. Skipped when there are no queries or no
1288
+ # savings (avoids dividing by zero and showing $0.00/query noise).
1289
+ if queries > 0 and tokens_saved > 0:
1290
+ avg_tokens = tokens_saved // max(1, queries)
1291
+ avg_cost = _fmt_cost(avg_tokens)
1292
+ click.echo(
1293
+ f" {dim(f'~{_fmt_tokens(avg_tokens)} tokens / query')} "
1294
+ f"{dim(f'~{avg_cost} / query')}"
1295
+ )
1296
+ click.echo()
1297
+
1298
+ # Per-bucket breakdown — only rows with non-zero savings render.
1299
+ # Definition order is preserved for ties (Python's sort is stable).
1300
+ rows = []
1301
+ for idx, (key, display, is_est) in enumerate(_BUCKET_DISPLAY):
1302
+ b = buckets.get(key, {"baseline": 0, "served": 0, "calls": 0})
1303
+ base = int(b.get("baseline", 0))
1304
+ srv = int(b.get("served", 0))
1305
+ saved = max(0, base - srv)
1306
+ if saved <= 0:
1307
+ continue
1308
+ pct = int(saved / baseline * 100) if baseline > 0 else 0
1309
+ rows.append((display, pct, saved, int(b.get("calls", 0)), is_est, idx))
1310
+ # Polish 2: sort by saved tokens descending. Biggest wins first.
1311
+ rows.sort(key=lambda r: (-r[2], r[5]))
1312
+
1313
+ if rows:
1314
+ click.echo(f" {dim('Breakdown:')}")
1315
+ # Polish 5: glue the asterisk to the label so the percentage column
1316
+ # stays straight. Compute label_width over the asterisk-suffixed
1317
+ # form so estimate buckets don't blow out the alignment.
1318
+ displayed_labels = [
1319
+ f"{display}*" if is_est else display
1320
+ for display, _, _, _, is_est, _ in rows
1321
+ ]
1322
+ label_width = max(len(s) for s in displayed_labels) + 1
1323
+ # Polish 3: normalize bar fill against the largest bucket's saved
1324
+ # tokens, not the total. Otherwise a dominant bucket squashes all
1325
+ # others to 0–1 cells and the visualisation goes blind.
1326
+ max_saved = max(r[2] for r in rows)
1327
+ any_estimate = False
1328
+ for display, pct, saved, calls, is_est in [
1329
+ (d, p, s, c, e) for d, p, s, c, e, _ in rows
1330
+ ]:
1331
+ if is_est:
1332
+ any_estimate = True
1333
+ # Polish 1: never round non-zero savings down to "0%".
1334
+ if saved > 0 and pct < 1:
1335
+ pct_text = "<1%".rjust(4)
1336
+ else:
1337
+ pct_text = f"{pct}%".rjust(4)
1338
+ # Polish 3: bar fill ∝ saved / max_saved, not pct / 100.
1339
+ ratio = saved / max_saved if max_saved > 0 else 0
1340
+ fill = max(1, min(_GRID_COLS, round(ratio * _GRID_COLS))) if saved > 0 else 0
1341
+ mini_bar = (
1342
+ click.style("▰" * fill, fg="cyan")
1343
+ + click.style("▱" * (_GRID_COLS - fill), dim=True)
1344
+ )
1345
+ # Polish 4: singular "1 call" / plural "N calls".
1346
+ call_text = "1 call" if calls == 1 else f"{calls} calls"
1347
+ # Polish 5: asterisk glued to label, no separate marker column.
1348
+ label_text = f"{display}*" if is_est else display
1349
+ click.echo(
1350
+ f" {label(label_text.ljust(label_width))} "
1351
+ f"{value(pct_text)} {mini_bar} "
1352
+ f"{dim(_fmt_tokens(saved).rjust(6))} "
1353
+ f"{dim(_fmt_cost(saved).rjust(8))} "
1354
+ f"{dim(f'· {call_text}')}"
1355
+ )
1356
+ click.echo()
1357
+ if any_estimate:
1358
+ from context_engine.compression.output_rules import (
1359
+ ESTIMATED_AVG_REPLY_TOKENS as _EST_REPLY,
1360
+ )
1361
+ click.echo(
1362
+ " " + dim(
1363
+ f"* estimated. output compression assumes a "
1364
+ f"{_EST_REPLY}-token avg reply; "
1365
+ "progressive disclosure compares against full payload dump."
1366
+ )
1367
+ )
1368
+ if levels:
1369
+ lv = ", ".join(f"{k}={v}" for k, v in sorted(levels.items()))
1370
+ click.echo(f" {dim(f'Output compression levels seen: {lv}')}")
1371
+ else:
1372
+ # Legacy fallback: project has stats.json but no per-bucket data
1373
+ # yet (older deployment, or memory.db not present). Compute the
1374
+ # retrieval / chunk-compression split from legacy fields so
1375
+ # users still see *some* breakdown.
1376
+ full_file_legacy = stats.get("full_file_tokens", 0)
1377
+ raw_legacy = stats.get("raw_tokens", 0)
1378
+ served_legacy = stats.get("served_tokens", 0)
1379
+ retrieval_pct = (
1380
+ int(round((1 - raw_legacy / full_file_legacy) * 100))
1381
+ if full_file_legacy > 0 and raw_legacy <= full_file_legacy
1382
+ else 0
1383
+ )
1384
+ compression_pct = (
1385
+ int(round((1 - served_legacy / raw_legacy) * 100))
1386
+ if raw_legacy > 0 and served_legacy <= raw_legacy
1387
+ else 0
1388
+ )
1389
+ click.echo(
1390
+ f" {dim('How:')} "
1391
+ f"{label('retrieval')} {value(f'{max(0, retrieval_pct)}%')}"
1392
+ f" {dim('+')} "
1393
+ f"{label('compression')} {value(f'{max(0, compression_pct)}%')}"
1394
+ )
1395
+
1396
+ click.echo(
1397
+ f" {dim(f'Cost estimate based on {_model_label} input pricing (${_price_per_m:.0f}/1M tokens)')}"
1398
+ )
1399
+
1400
+ def _json_entry(name: str, stats: dict, buckets: dict, levels: dict) -> dict:
1401
+ full_file = stats.get("full_file_tokens", 0)
1402
+ raw = stats.get("raw_tokens", 0)
1403
+ served = stats.get("served_tokens", 0)
1404
+ bucket_baseline, bucket_served = _bucket_totals(buckets)
1405
+ if bucket_baseline > 0:
1406
+ baseline = bucket_baseline
1407
+ served_total = bucket_served
1408
+ else:
1409
+ baseline = max(full_file, raw) if full_file > 0 else raw
1410
+ served_total = served
1411
+ saved = max(0, baseline - served_total)
1412
+ retrieval_pct = (
1413
+ int(round((1 - raw / full_file) * 100))
1414
+ if full_file > 0 and raw <= full_file
1415
+ else 0
1416
+ )
1417
+ compression_pct = (
1418
+ int(round((1 - served / raw) * 100))
1419
+ if raw > 0 and served <= raw
1420
+ else 0
1421
+ )
1422
+ return {
1423
+ "project": name,
1424
+ "queries": stats.get("queries", 0),
1425
+ "full_file_tokens": full_file,
1426
+ "raw_tokens": raw,
1427
+ "served_tokens": served,
1428
+ "tokens_saved": saved,
1429
+ # Kept for backward compat with anything scraping this JSON:
1430
+ "savings_pct": int(saved / baseline * 100) if baseline > 0 else 0,
1431
+ "retrieval_savings_pct": max(0, retrieval_pct),
1432
+ "compression_savings_pct": max(0, compression_pct),
1433
+ # New per-bucket breakdown.
1434
+ "buckets": buckets,
1435
+ "output_compression_levels": levels,
1436
+ }
1437
+
1438
+ # Collect projects
1439
+ if all_projects:
1440
+ if not storage_root.exists():
1441
+ if as_json:
1442
+ click.echo(_json.dumps({"projects": []}))
1443
+ else:
1444
+ click.echo("No indexed projects found.")
1445
+ return
1446
+ project_dirs = sorted(
1447
+ (d for d in storage_root.iterdir() if d.is_dir()),
1448
+ key=lambda d: d.name,
1449
+ )
1450
+ else:
1451
+ project_name = Path.cwd().name
1452
+ project_dirs = [storage_root / project_name]
1453
+
1454
+ # Each report carries its bucket totals and level histogram alongside
1455
+ # the legacy stats.json so downstream renderers/JSON emitters can
1456
+ # pick the canonical source.
1457
+ reports: list[tuple[str, dict, dict, dict]] = []
1458
+ for pd in project_dirs:
1459
+ stats = _load_stats(pd)
1460
+ buckets, levels = _load_buckets(pd)
1461
+ bucket_baseline = sum(int(v.get("baseline", 0)) for v in buckets.values())
1462
+ if stats is not None or bucket_baseline > 0:
1463
+ reports.append((pd.name, stats or {
1464
+ "queries": 0, "raw_tokens": 0, "served_tokens": 0,
1465
+ "full_file_tokens": 0,
1466
+ }, buckets, levels))
1467
+
1468
+ if not reports:
1469
+ if as_json:
1470
+ if all_projects:
1471
+ click.echo(_json.dumps({"projects": []}))
1472
+ else:
1473
+ empty_buckets = {b: {"baseline": 0, "served": 0, "calls": 0}
1474
+ for b in __import__(
1475
+ "context_engine.memory.db", fromlist=["BUCKETS"],
1476
+ ).BUCKETS}
1477
+ click.echo(_json.dumps(_json_entry(Path.cwd().name, {
1478
+ "raw_tokens": 0, "served_tokens": 0, "queries": 0,
1479
+ }, empty_buckets, {})))
1480
+ else:
1481
+ click.echo(f" {dim('No usage recorded yet.')}")
1482
+ click.echo(f" {dim('Run context_search queries via MCP to start tracking savings.')}")
1483
+ return
1484
+
1485
+ if as_json:
1486
+ if all_projects:
1487
+ click.echo(_json.dumps(
1488
+ {"projects": [_json_entry(n, s, b, lv) for n, s, b, lv in reports]},
1489
+ indent=2,
1490
+ ))
1491
+ else:
1492
+ click.echo(_json.dumps(_json_entry(*reports[0]), indent=2))
1493
+ return
1494
+
1495
+ # Text output
1496
+ for name, stats, buckets, levels in reports:
1497
+ _print_project(name, stats, buckets, levels)
1498
+ if len(reports) > 1:
1499
+ click.echo()
1500
+ click.echo(" " + "─" * 52)
1501
+
1502
+ if len(reports) > 1:
1503
+ # Prefer canonical bucket totals; fall back to legacy fields.
1504
+ def _proj_baseline(s, b):
1505
+ bt = sum(int(v.get("baseline", 0)) for v in b.values())
1506
+ if bt > 0:
1507
+ return bt
1508
+ ff = s.get("full_file_tokens", 0)
1509
+ r = s.get("raw_tokens", 0)
1510
+ return max(ff, r) if ff > 0 else r
1511
+ def _proj_served(s, b):
1512
+ bt = sum(int(v.get("served", 0)) for v in b.values())
1513
+ if bt > 0:
1514
+ return bt
1515
+ return s.get("served_tokens", 0)
1516
+ total_baseline = sum(_proj_baseline(s, b) for _, s, b, _ in reports)
1517
+ total_served = sum(_proj_served(s, b) for _, s, b, _ in reports)
1518
+ total_queries = sum(s.get("queries", 0) for _, s, _, _ in reports)
1519
+ total_saved = max(0, total_baseline - total_served)
1520
+ total_pct = int(total_saved / total_baseline * 100) if total_baseline > 0 else 0
1521
+ click.echo()
1522
+ click.echo(
1523
+ f" {bold('Total')} {dim('across')} {value(str(len(reports)))} "
1524
+ f"{dim('projects ·')} {value(f'{total_queries:,}')} {dim('queries')}"
1525
+ )
1526
+ click.echo(
1527
+ f" {_bar(total_pct)} "
1528
+ f"{click.style(f'{total_pct}%', fg='green', bold=True)} "
1529
+ f"{dim('saved ·')} "
1530
+ f"{click.style(_fmt_tokens(total_saved), fg='green', bold=True)} "
1531
+ f"{dim('tokens ·')} "
1532
+ f"{click.style(_fmt_cost(total_saved), fg='green', bold=True)}"
1533
+ )
1534
+
1535
+ click.echo()
1536
+
1537
+
1538
+ @main.command()
1539
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
1540
+ @click.pass_context
1541
+ def clear(ctx: click.Context, yes: bool) -> None:
1542
+ """Clear all index data for the current project (vectors, FTS, graph, manifest)."""
1543
+ from context_engine.storage.local_backend import LocalBackend
1544
+ from context_engine.cli_style import warn, success, dim, value, section, animate, CHECK, DOT
1545
+
1546
+ config = ctx.obj["config"]
1547
+ project_name = Path.cwd().name
1548
+ storage_dir = Path(config.storage_path) / project_name
1549
+
1550
+ if not storage_dir.exists():
1551
+ animate(["", f" {DOT} {dim('No index data found for')} {value(project_name)}", ""])
1552
+ return
1553
+
1554
+ if not yes:
1555
+ click.echo("")
1556
+ click.echo(section("Clear Index"))
1557
+ click.echo("")
1558
+ click.confirm(f" {warn('Delete all index data for')} {value(project_name)}?", abort=True)
1559
+
1560
+ backend = LocalBackend(base_path=str(storage_dir))
1561
+ asyncio.run(backend.clear())
1562
+
1563
+ manifest_path = storage_dir / "manifest.json"
1564
+ if manifest_path.exists():
1565
+ manifest_path.write_text(json.dumps({"__schema_version": 2, "files": {}}))
1566
+
1567
+ stats_path = storage_dir / "stats.json"
1568
+ stats_path.write_text(json.dumps({"queries": 0, "raw_tokens": 0, "served_tokens": 0, "full_file_tokens": 0}))
1569
+
1570
+ animate([
1571
+ "",
1572
+ f" {CHECK} {success('Cleared')} index data for {value(project_name)}",
1573
+ f" {dim('Run')} {click.style('cce index', fg='cyan')} {dim('to rebuild')}",
1574
+ "",
1575
+ ])
1576
+
1577
+
1578
+ @main.command()
1579
+ @click.option("--dry-run", is_flag=True, help="Show what would be removed without deleting")
1580
+ @click.pass_context
1581
+ def prune(ctx: click.Context, dry_run: bool) -> None:
1582
+ """Remove index data for projects whose directories no longer exist."""
1583
+ import shutil
1584
+ from context_engine.cli_style import success, warn, dim, value, section, animate, CHECK, CROSS, DOT
1585
+
1586
+ config = ctx.obj["config"]
1587
+ storage_root = Path(config.storage_path)
1588
+ if not storage_root.exists():
1589
+ animate(["", f" {DOT} {dim('No indexed projects found.')}", ""])
1590
+ return
1591
+
1592
+ removed = []
1593
+ kept = []
1594
+ for project_dir in sorted(storage_root.iterdir()):
1595
+ if not project_dir.is_dir():
1596
+ continue
1597
+ meta_path = project_dir / "meta.json"
1598
+ if not meta_path.exists():
1599
+ kept.append((project_dir.name, "(no meta.json)"))
1600
+ continue
1601
+ try:
1602
+ meta = json.loads(meta_path.read_text())
1603
+ source_path = Path(meta.get("project_dir", ""))
1604
+ except (json.JSONDecodeError, OSError):
1605
+ kept.append((project_dir.name, "(unreadable meta.json)"))
1606
+ continue
1607
+
1608
+ if source_path and source_path.exists():
1609
+ kept.append((project_dir.name, str(source_path)))
1610
+ else:
1611
+ removed.append((project_dir.name, str(source_path), project_dir))
1612
+
1613
+ lines: list[str] = []
1614
+ lines.append("")
1615
+ lines.append(section("Prune" + (" (dry run)" if dry_run else "")))
1616
+ lines.append("")
1617
+
1618
+ if not removed:
1619
+ lines.append(f" {CHECK} {success('Nothing to prune')} all indexed projects still exist")
1620
+ lines.append("")
1621
+ for name, path in kept:
1622
+ lines.append(f" {CHECK} {value(name)} {dim(path)}")
1623
+ lines.append("")
1624
+ animate(lines)
1625
+ return
1626
+
1627
+ for name, path, storage_dir in removed:
1628
+ if dry_run:
1629
+ lines.append(f" {DOT} {warn('would remove')} {value(name)} {dim(path)}")
1630
+ else:
1631
+ shutil.rmtree(storage_dir)
1632
+ lines.append(f" {CROSS} {warn('removed')} {value(name)} {dim(path)}")
1633
+
1634
+ for name, path in kept:
1635
+ lines.append(f" {CHECK} {dim('kept')} {value(name)} {dim(path)}")
1636
+
1637
+ lines.append("")
1638
+ animate(lines)
1639
+
1640
+
1641
+ @main.command()
1642
+ @click.argument("query")
1643
+ @click.option("--top-k", default=5, show_default=True, help="Number of results")
1644
+ @click.pass_context
1645
+ def search(ctx: click.Context, query: str, top_k: int) -> None:
1646
+ """Run a test search query and show results (also updates savings stats)."""
1647
+ from context_engine.cli_style import section, animate, value, dim, label, success, CHECK, DOT
1648
+
1649
+ config = ctx.obj["config"]
1650
+ project_dir = str(Path.cwd())
1651
+ project_name = Path.cwd().name
1652
+
1653
+ async def _search():
1654
+ from context_engine.storage.local_backend import LocalBackend
1655
+ from context_engine.indexer.embedder import Embedder
1656
+ from context_engine.retrieval.retriever import HybridRetriever
1657
+
1658
+ storage_dir = Path(config.storage_path) / project_name
1659
+ if not (storage_dir / "vectors").exists():
1660
+ animate(["", f" {DOT} {dim('Not indexed yet. Run:')} {label('cce init')}", ""])
1661
+ return
1662
+
1663
+ backend = LocalBackend(base_path=str(storage_dir))
1664
+ embedder = Embedder(model_name=config.embedding_model)
1665
+ retriever = HybridRetriever(backend=backend, embedder=embedder)
1666
+ results = await retriever.retrieve(query, top_k=top_k)
1667
+
1668
+ lines: list[str] = []
1669
+ lines.append("")
1670
+ lines.append(section(f"Search · {query}"))
1671
+ lines.append("")
1672
+
1673
+ if not results:
1674
+ lines.append(f" {DOT} {dim('No results found')}")
1675
+ else:
1676
+ # Compute tokens
1677
+ raw_tokens = 0
1678
+ served_tokens = 0
1679
+ seen_files: set[str] = set()
1680
+ for r in results:
1681
+ chunk_tokens = max(1, len(r.content) // 4)
1682
+ raw_tokens += chunk_tokens
1683
+ served_tokens += chunk_tokens
1684
+ seen_files.add(r.file_path)
1685
+
1686
+ # Estimate full file tokens
1687
+ full_file_tokens = 0
1688
+ for fp in seen_files:
1689
+ full_path = Path(project_dir) / fp
1690
+ if full_path.exists():
1691
+ try:
1692
+ full_file_tokens += max(1, len(full_path.read_text(errors="ignore")) // 4)
1693
+ except OSError:
1694
+ pass
1695
+
1696
+ for i, r in enumerate(results, 1):
1697
+ conf = r.metadata.get("confidence", "")
1698
+ conf_str = f" {dim(f'({conf:.2f})')}" if isinstance(conf, (int, float)) else ""
1699
+ lines.append(f" {label(str(i))}. {value(r.file_path)}:{r.start_line}-{r.end_line}{conf_str}")
1700
+ # Show first line of content
1701
+ first_line = r.content.strip().split("\n")[0][:80]
1702
+ lines.append(f" {dim(first_line)}")
1703
+
1704
+ lines.append("")
1705
+ lines.append(f" {CHECK} {success(f'{len(results)} results')} {dim(f'{served_tokens} tokens served vs {full_file_tokens} full file tokens')}")
1706
+
1707
+ # Update stats
1708
+ stats_path = storage_dir / "stats.json"
1709
+ try:
1710
+ stats = json.loads(stats_path.read_text()) if stats_path.exists() else {}
1711
+ except (json.JSONDecodeError, OSError):
1712
+ stats = {}
1713
+ stats["queries"] = stats.get("queries", 0) + 1
1714
+ stats["raw_tokens"] = stats.get("raw_tokens", 0) + raw_tokens
1715
+ stats["served_tokens"] = stats.get("served_tokens", 0) + served_tokens
1716
+ stats.setdefault("full_file_tokens", 0)
1717
+ stats["full_file_tokens"] = max(stats["full_file_tokens"], full_file_tokens)
1718
+ stats_path.write_text(json.dumps(stats))
1719
+
1720
+ lines.append("")
1721
+ animate(lines)
1722
+
1723
+ asyncio.run(_search())
1724
+
1725
+
1726
+ @main.command()
1727
+ def uninstall() -> None:
1728
+ """Remove CCE from the current project (hooks, .mcp.json entry, CLAUDE.md block)."""
1729
+ from context_engine.cli_style import section, animate, value, dim, success, warn, CHECK, CROSS, DOT
1730
+
1731
+ project_dir = Path.cwd()
1732
+ project_name = project_dir.name
1733
+ lines: list[str] = []
1734
+ lines.append("")
1735
+ lines.append(section(f"Uninstall · {project_name}"))
1736
+ lines.append("")
1737
+
1738
+ # Remove git hooks
1739
+ hooks_dir = project_dir / ".git" / "hooks"
1740
+ removed_hooks = 0
1741
+ if hooks_dir.exists():
1742
+ for hook_name in ["post-commit", "post-checkout", "post-merge"]:
1743
+ hook_file = hooks_dir / hook_name
1744
+ if hook_file.exists():
1745
+ content = hook_file.read_text()
1746
+ if "cce" in content.lower() or "context-engine" in content.lower():
1747
+ hook_file.unlink()
1748
+ removed_hooks += 1
1749
+ if removed_hooks:
1750
+ lines.append(f" {CROSS} {warn('Removed')} {removed_hooks} git hooks")
1751
+ else:
1752
+ lines.append(f" {DOT} {dim('No CCE git hooks found')}")
1753
+
1754
+ # Remove MCP config from all editors
1755
+ from context_engine.editors import EDITORS, INSTRUCTION_FILES, remove_mcp, remove_instruction_file
1756
+
1757
+ for editor_key, editor in EDITORS.items():
1758
+ msg = remove_mcp(project_dir, editor_key)
1759
+ if msg:
1760
+ lines.append(f" {CROSS} {warn(msg)}")
1761
+
1762
+ # Remove instruction files from non-Claude editors
1763
+ for file_key in INSTRUCTION_FILES:
1764
+ msg = remove_instruction_file(project_dir, file_key)
1765
+ if msg:
1766
+ lines.append(f" {CROSS} {warn(msg)}")
1767
+
1768
+ # Remove CCE block from CLAUDE.md. _extract_existing_cce_block() recognises
1769
+ # the current versioned form (<!-- cce-block-version: N --> ... <!-- /cce-block -->),
1770
+ # the legacy "## Context Engine (CCE)" heading-only form, AND the older
1771
+ # CCE:BEGIN/CCE:END marker pair — keeping uninstall in lockstep with init
1772
+ # so the routing instructions don't get left behind.
1773
+ claude_md = project_dir / "CLAUDE.md"
1774
+ if claude_md.exists():
1775
+ content = claude_md.read_text()
1776
+ block = _extract_existing_cce_block(content)
1777
+ legacy_begin = "<!-- CCE:BEGIN -->"
1778
+ legacy_end = "<!-- CCE:END -->"
1779
+ if block is not None:
1780
+ new_content = content.replace(block, "", 1).strip()
1781
+ if new_content:
1782
+ claude_md.write_text(new_content + "\n")
1783
+ else:
1784
+ claude_md.unlink()
1785
+ lines.append(f" {CROSS} {warn('Removed')} CCE block from CLAUDE.md")
1786
+ elif legacy_begin in content:
1787
+ start = content.index(legacy_begin)
1788
+ end = (
1789
+ content.index(legacy_end) + len(legacy_end)
1790
+ if legacy_end in content
1791
+ else len(content)
1792
+ )
1793
+ new_content = (content[:start] + content[end:]).strip()
1794
+ if new_content:
1795
+ claude_md.write_text(new_content + "\n")
1796
+ else:
1797
+ claude_md.unlink()
1798
+ lines.append(f" {CROSS} {warn('Removed')} CCE block from CLAUDE.md")
1799
+ elif "context_search" in content or "context-engine" in content.lower():
1800
+ lines.append(f" {DOT} {warn('CLAUDE.md has CCE references but no markers. Edit manually.')}")
1801
+ else:
1802
+ lines.append(f" {DOT} {dim('No CCE block in CLAUDE.md')}")
1803
+ else:
1804
+ lines.append(f" {DOT} {dim('No CLAUDE.md found')}")
1805
+
1806
+ # Remove .cce directory
1807
+ cce_dir = project_dir / ".cce"
1808
+ if cce_dir.exists():
1809
+ import shutil
1810
+ shutil.rmtree(cce_dir)
1811
+ lines.append(f" {CROSS} {warn('Removed')} .cce/ directory")
1812
+ else:
1813
+ lines.append(f" {DOT} {dim('No .cce/ directory')}")
1814
+
1815
+ # Remove .context-engine.yaml (per-project config)
1816
+ project_config = project_dir / ".context-engine.yaml"
1817
+ if project_config.exists():
1818
+ project_config.unlink()
1819
+ lines.append(f" {CROSS} {warn('Removed')} .context-engine.yaml")
1820
+
1821
+ # Remove CCE hooks from .claude/settings.local.json
1822
+ settings_path = project_dir / ".claude" / "settings.local.json"
1823
+ if settings_path.exists():
1824
+ try:
1825
+ data = json.loads(settings_path.read_text())
1826
+ hooks = data.get("hooks", {})
1827
+ changed = False
1828
+ for event in list(hooks.keys()):
1829
+ original = hooks[event]
1830
+ filtered = [
1831
+ h for h in original
1832
+ if not any(
1833
+ "cce" in cmd.get("command", "")
1834
+ for cmd in (h.get("hooks", []) if isinstance(h, dict) else [])
1835
+ )
1836
+ ]
1837
+ if len(filtered) != len(original):
1838
+ hooks[event] = filtered
1839
+ changed = True
1840
+ # Remove empty hook lists
1841
+ if not hooks[event]:
1842
+ del hooks[event]
1843
+ changed = True
1844
+ if changed:
1845
+ if not hooks:
1846
+ del data["hooks"]
1847
+ if data:
1848
+ settings_path.write_text(json.dumps(data, indent=2) + "\n")
1849
+ else:
1850
+ settings_path.unlink()
1851
+ lines.append(f" {CROSS} {warn('Removed')} CCE hooks from .claude/settings.local.json")
1852
+ except (json.JSONDecodeError, OSError):
1853
+ pass
1854
+
1855
+ # Remove CCE entries from .gitignore
1856
+ gitignore = project_dir / ".gitignore"
1857
+ if gitignore.exists():
1858
+ content = gitignore.read_text()
1859
+ if ".cce/" in content or "context-engine" in content.lower():
1860
+ # Remove lines containing CCE entries
1861
+ new_lines = [
1862
+ line for line in content.splitlines()
1863
+ if ".cce/" not in line
1864
+ and ".cce" not in line
1865
+ and "context-engine" not in line.lower()
1866
+ ]
1867
+ new_content = "\n".join(new_lines).strip()
1868
+ if new_content:
1869
+ gitignore.write_text(new_content + "\n")
1870
+ else:
1871
+ gitignore.unlink()
1872
+ lines.append(f" {CROSS} {warn('Removed')} CCE entries from .gitignore")
1873
+
1874
+ # Remove index data from ~/.cce/projects/<project>
1875
+ config = load_config()
1876
+ index_dir = Path(config.storage_path) / project_name
1877
+ if index_dir.exists():
1878
+ import shutil
1879
+ shutil.rmtree(index_dir)
1880
+ lines.append(f" {CROSS} {warn('Removed')} index data from {dim(str(index_dir))}")
1881
+ else:
1882
+ lines.append(f" {DOT} {dim('No index data found')}")
1883
+
1884
+ lines.append("")
1885
+ animate(lines)
1886
+
1887
+
1888
+ @main.command()
1889
+ @click.argument("service", required=False, type=click.Choice(["ollama", "dashboard", "all"]), default="all")
1890
+ @click.option("--port", default=8080, show_default=True, help="Dashboard port")
1891
+ def start(service: str, port: int) -> None:
1892
+ """Start CCE services (shortcut for cce services start)."""
1893
+ from context_engine.services import start_ollama, start_dashboard
1894
+ from context_engine.cli_style import section, animate, CHECK, DOT
1895
+
1896
+ lines = ["", section("Starting Services")]
1897
+ targets = ["ollama", "dashboard"] if service == "all" else [service]
1898
+ for target in targets:
1899
+ if target == "ollama":
1900
+ ok, msg = start_ollama()
1901
+ else:
1902
+ ok, msg = start_dashboard(port=port)
1903
+ prefix = CHECK if ok else DOT
1904
+ lines.append(f" {prefix} {msg}")
1905
+ lines.append("")
1906
+ animate(lines)
1907
+
1908
+
1909
+ @main.command()
1910
+ @click.argument("service", required=False, type=click.Choice(["ollama", "dashboard", "all"]), default="all")
1911
+ def stop(service: str) -> None:
1912
+ """Stop CCE services (shortcut for cce services stop)."""
1913
+ from context_engine.services import stop_ollama, stop_dashboard
1914
+ from context_engine.cli_style import section, animate, CHECK, DOT
1915
+
1916
+ lines = ["", section("Stopping Services")]
1917
+ targets = ["ollama", "dashboard"] if service == "all" else [service]
1918
+ for target in targets:
1919
+ if target == "ollama":
1920
+ ok, msg = stop_ollama()
1921
+ else:
1922
+ ok, msg = stop_dashboard()
1923
+ prefix = CHECK if ok else DOT
1924
+ lines.append(f" {prefix} {msg}")
1925
+ lines.append("")
1926
+ animate(lines)
1927
+
1928
+
1929
+ @main.command()
1930
+ @click.option("--check", is_flag=True, help="Check for updates without installing")
1931
+ @click.pass_context
1932
+ def upgrade(ctx: click.Context, check: bool) -> None:
1933
+ """Upgrade code-context-engine to the latest version and refresh project config."""
1934
+ import importlib.metadata
1935
+ import subprocess
1936
+ from context_engine.cli_style import section, animate
1937
+
1938
+ current = importlib.metadata.version("code-context-engine")
1939
+ lines = ["", section("Upgrade")]
1940
+ lines.append(f" Current version: {click.style(current, fg='cyan', bold=True)}")
1941
+
1942
+ # Detect install method from the cce binary path
1943
+ cce_bin = Path(sys.argv[0]).resolve()
1944
+ cce_str = str(cce_bin)
1945
+
1946
+ installer = None
1947
+ upgrade_cmd: list[str] = []
1948
+
1949
+ if "/uv/" in cce_str or ".local/share/uv" in cce_str:
1950
+ installer = "uv"
1951
+ upgrade_cmd = ["uv", "tool", "upgrade", "code-context-engine"]
1952
+ elif "/pipx/" in cce_str:
1953
+ installer = "pipx"
1954
+ upgrade_cmd = ["pipx", "upgrade", "code-context-engine"]
1955
+ else:
1956
+ # Check if inside a uv tool environment by looking at the venv path
1957
+ venv_path = str(Path(sys.prefix).resolve())
1958
+ if "uv/tools" in venv_path:
1959
+ installer = "uv"
1960
+ upgrade_cmd = ["uv", "tool", "upgrade", "code-context-engine"]
1961
+ elif "pipx/venvs" in venv_path:
1962
+ installer = "pipx"
1963
+ upgrade_cmd = ["pipx", "upgrade", "code-context-engine"]
1964
+ else:
1965
+ installer = "pip"
1966
+ upgrade_cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "code-context-engine"]
1967
+
1968
+ lines.append(f" Install method: {click.style(installer, fg='cyan')}")
1969
+
1970
+ if check:
1971
+ lines.append("")
1972
+ lines.append(f" To upgrade: {click.style(' '.join(upgrade_cmd), fg='cyan')}")
1973
+ lines.append("")
1974
+ animate(lines)
1975
+ return
1976
+
1977
+ lines.append(f" Running: {_dim(' '.join(upgrade_cmd))}")
1978
+ animate(lines)
1979
+ click.echo("")
1980
+
1981
+ result = subprocess.run(upgrade_cmd, capture_output=True, text=True)
1982
+ if result.returncode != 0:
1983
+ click.echo(f" {CROSS} {click.style('Upgrade failed', fg='red')}")
1984
+ if result.stderr:
1985
+ for err_line in result.stderr.strip().splitlines()[-5:]:
1986
+ click.echo(f" {_dim(err_line)}")
1987
+ click.echo("")
1988
+ sys.exit(1)
1989
+
1990
+ # Show what pip/uv printed (version info)
1991
+ output = (result.stdout or result.stderr or "").strip()
1992
+ for out_line in output.splitlines()[-3:]:
1993
+ click.echo(f" {_dim(out_line)}")
1994
+
1995
+ new_version = current # fallback
1996
+ try:
1997
+ # Re-read version from the just-upgraded package
1998
+ importlib.metadata.invalidate_caches()
1999
+ dist = importlib.metadata.distribution("code-context-engine")
2000
+ new_version = dist.metadata["Version"]
2001
+ except Exception:
2002
+ pass
2003
+
2004
+ click.echo("")
2005
+ if new_version != current:
2006
+ _ok(f"Upgraded {click.style(current, fg='white')} → {click.style(new_version, fg='green', bold=True)}")
2007
+ else:
2008
+ _ok(f"Already on latest version ({click.style(current, fg='cyan')})")
2009
+
2010
+ # Refresh project config if in an initialized project
2011
+ project_dir = Path.cwd()
2012
+ mcp_path = project_dir / ".mcp.json"
2013
+ if mcp_path.exists():
2014
+ click.echo("")
2015
+ click.echo(f" {click.style('Refreshing project config', fg='cyan')}...")
2016
+ configured = _configure_mcp(project_dir)
2017
+ if configured:
2018
+ _ok("MCP server paths updated in " + click.style(".mcp.json", fg="cyan"))
2019
+ else:
2020
+ _ok("MCP server config is current")
2021
+ _ensure_claude_md(project_dir)
2022
+ _ensure_session_hook(project_dir)
2023
+ from context_engine.indexer.git_hooks import install_hooks
2024
+ if (project_dir / ".git").exists():
2025
+ install_hooks(str(project_dir))
2026
+ _ok("Git hooks refreshed")
2027
+
2028
+ click.echo("")
2029
+ click.echo(
2030
+ click.style(" Done!", fg="green", bold=True) +
2031
+ click.style(" Restart Claude Code to pick up changes.", fg="white")
2032
+ )
2033
+ click.echo("")
2034
+
2035
+
2036
+ def savings_shortcut() -> None:
2037
+ """Entry point for the `cce-savings` shortcut command."""
2038
+ @click.command()
2039
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
2040
+ @click.option("--all", "all_projects", is_flag=True, help="Show all projects")
2041
+ def _cmd(as_json: bool, all_projects: bool) -> None:
2042
+ """Show CCE token savings — how much context compression is saving you."""
2043
+ project_path = Path.cwd() / PROJECT_CONFIG_NAME
2044
+ config = load_config(project_path=project_path if project_path.exists() else None)
2045
+ _run_savings_report(config, as_json=as_json, all_projects=all_projects)
2046
+
2047
+ _cmd()
2048
+
2049
+
2050
+ def _find_free_port() -> int:
2051
+ """Return an available TCP port on localhost."""
2052
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2053
+ s.bind(("127.0.0.1", 0))
2054
+ return s.getsockname()[1]
2055
+
2056
+
2057
+ @main.command()
2058
+ @click.option("--http", "as_http", is_flag=True, help="Start HTTP REST server instead of stdio MCP")
2059
+ @click.option("--host", default="127.0.0.1", show_default=True, help="HTTP bind host (requires CCE_API_TOKEN for non-loopback)")
2060
+ @click.option("--port", default=8765, show_default=True, help="HTTP port")
2061
+ @click.option("--project-dir", default=None, help="Project directory (defaults to cwd)")
2062
+ @click.pass_context
2063
+ def serve(ctx: click.Context, as_http: bool, host: str, port: int, project_dir: str | None) -> None:
2064
+ """Start the MCP server (used by Claude Code).
2065
+
2066
+ With --http, starts a REST server exposing the storage backend for remote
2067
+ backend clients. Binds loopback by default; exposing on other interfaces
2068
+ requires CCE_API_TOKEN to be set.
2069
+ """
2070
+ if project_dir:
2071
+ import os
2072
+ os.chdir(project_dir)
2073
+ # Click's main() loaded config from the launch cwd; if the user pointed
2074
+ # us at a different project, re-load so its .context-engine.yaml wins.
2075
+ # Without this, launchers running `cce serve --project-dir /repo` from
2076
+ # a different cwd would silently ignore /repo/.context-engine.yaml.
2077
+ target_config = Path(project_dir) / PROJECT_CONFIG_NAME
2078
+ ctx.obj["config"] = load_config(
2079
+ project_path=target_config if target_config.exists() else None
2080
+ )
2081
+ if as_http:
2082
+ from context_engine.serve_http import run_http_server
2083
+ run_http_server(ctx.obj["config"], host=host, port=port)
2084
+ return
2085
+ from importlib.metadata import version as pkg_version
2086
+ try:
2087
+ ver = pkg_version("code-context-engine")
2088
+ except Exception:
2089
+ ver = "unknown"
2090
+ click.echo(f"CCE v{ver} · Starting context engine MCP server...", err=True)
2091
+ asyncio.run(_run_serve(ctx.obj["config"]))
2092
+
2093
+
2094
+ @main.command()
2095
+ @click.option("--port", default=0, type=int, help="Port to listen on (0 = random free port)")
2096
+ @click.option("--no-browser", is_flag=True, help="Don't open browser automatically")
2097
+ @click.pass_context
2098
+ def dashboard(ctx: click.Context, port: int, no_browser: bool) -> None:
2099
+ """Start the web dashboard for index inspection."""
2100
+ import os as _os
2101
+ import webbrowser
2102
+ import uvicorn
2103
+ from context_engine.dashboard.server import create_app
2104
+
2105
+ config = ctx.obj["config"]
2106
+ project_dir = Path.cwd()
2107
+
2108
+ if port == 0:
2109
+ port = _find_free_port()
2110
+
2111
+ # When CCE_DASHBOARD_TOKEN is set, append it to the URL so the dashboard
2112
+ # JS picks it up and includes it on mutating requests. Without the token
2113
+ # in the URL the page itself loads fine but Reindex / Clear / etc. would
2114
+ # 401 — most users would assume the dashboard was broken, so we surface
2115
+ # the URL with the token already attached.
2116
+ token = (_os.environ.get("CCE_DASHBOARD_TOKEN") or "").strip()
2117
+ from context_engine.cli_style import header, value, dim
2118
+ base_url = f"http://localhost:{port}"
2119
+ url = f"{base_url}?token={token}" if token else base_url
2120
+ click.echo(f" {header('CCE Dashboard')} at {value(url)}")
2121
+ if token:
2122
+ click.echo(f" {dim('Auth: bearer token required for write actions.')}")
2123
+ click.echo(f" {dim('Press Ctrl+C to stop.')}")
2124
+
2125
+ if not no_browser:
2126
+ webbrowser.open(url)
2127
+
2128
+ app = create_app(config, project_dir)
2129
+ uvicorn.run(app, host="127.0.0.1", port=port, log_level="error")
2130
+
2131
+
2132
+ # ── services command group ────────────────────────────────────────────────────
2133
+
2134
+ @main.group(invoke_without_command=True)
2135
+ @click.pass_context
2136
+ def services(ctx: click.Context) -> None:
2137
+ """Show status of CCE services (Ollama, Dashboard)."""
2138
+ if ctx.invoked_subcommand is None:
2139
+ ctx.invoke(services_status)
2140
+
2141
+
2142
+ @services.command(name="status")
2143
+ def services_status() -> None:
2144
+ """Show status of all CCE services."""
2145
+ from context_engine.services import get_ollama_status, get_dashboard_status, get_mcp_status
2146
+ from context_engine.cli_style import header, dim, section, animate, value, success, warn, BULLET, BULLET_OFF
2147
+
2148
+ rows = [
2149
+ get_ollama_status(),
2150
+ get_dashboard_status(),
2151
+ get_mcp_status(),
2152
+ ]
2153
+
2154
+ lines: list[str] = []
2155
+ lines.append("")
2156
+ lines.append(section("Services"))
2157
+ lines.append("")
2158
+
2159
+ for row in rows:
2160
+ running = row["running"]
2161
+ bullet = BULLET if running else BULLET_OFF
2162
+ status_text = success("running") if running else warn("stopped")
2163
+ detail = dim(row.get("detail", ""))
2164
+ name = value(f"{row['name']:<12}")
2165
+ lines.append(f" {bullet} {name} {status_text} {detail}")
2166
+
2167
+ lines.append("")
2168
+ animate(lines)
2169
+
2170
+
2171
+ @services.command(name="start")
2172
+ @click.argument("service", required=False, type=click.Choice(["ollama", "dashboard", "all"]), default="all")
2173
+ @click.option("--port", default=8080, show_default=True, help="Dashboard port (only used when starting dashboard)")
2174
+ def services_start(service: str, port: int) -> None:
2175
+ """Start CCE services. SERVICE: ollama | dashboard | all (default)."""
2176
+ from context_engine.services import start_ollama, start_dashboard
2177
+
2178
+ targets = ["ollama", "dashboard"] if service == "all" else [service]
2179
+
2180
+ for target in targets:
2181
+ if target == "ollama":
2182
+ ok, msg = start_ollama()
2183
+ else:
2184
+ ok, msg = start_dashboard(port=port)
2185
+ prefix = click.style("✓", fg="green") if ok else click.style("·", fg="yellow")
2186
+ click.echo(f" {prefix} {msg}")
2187
+
2188
+
2189
+ @services.command(name="stop")
2190
+ @click.argument("service", required=False, type=click.Choice(["ollama", "dashboard", "all"]), default="all")
2191
+ def services_stop(service: str) -> None:
2192
+ """Stop CCE services. SERVICE: ollama | dashboard | all (default)."""
2193
+ from context_engine.services import stop_ollama, stop_dashboard
2194
+
2195
+ targets = ["ollama", "dashboard"] if service == "all" else [service]
2196
+
2197
+ for target in targets:
2198
+ if target == "ollama":
2199
+ ok, msg = stop_ollama()
2200
+ else:
2201
+ ok, msg = stop_dashboard()
2202
+ prefix = click.style("✓", fg="green") if ok else click.style("·", fg="yellow")
2203
+ click.echo(f" {prefix} {msg}")
2204
+
2205
+
2206
+ # ── sessions command group ────────────────────────────────────────────────────
2207
+
2208
+ @main.group(invoke_without_command=True)
2209
+ @click.pass_context
2210
+ def sessions(ctx: click.Context) -> None:
2211
+ """Inspect and prune cross-session memory."""
2212
+ if ctx.invoked_subcommand is None:
2213
+ click.echo(ctx.get_help())
2214
+
2215
+
2216
+ @sessions.command(name="status")
2217
+ @click.pass_context
2218
+ def sessions_status(ctx: click.Context) -> None:
2219
+ """Show health of this project's memory: db size, counts, queue, schema.
2220
+
2221
+ Works without `cce serve` — it just opens memory.db read-only and runs
2222
+ a few queries. Useful for "is memory actually capturing anything?"
2223
+ """
2224
+ from context_engine.memory import db as memory_db
2225
+
2226
+ config = ctx.obj["config"]
2227
+ project_name = Path.cwd().name
2228
+ storage_base = Path(config.storage_path) / project_name
2229
+ db_path = memory_db.memory_db_path(storage_base)
2230
+
2231
+ click.echo(f" project: {project_name}")
2232
+ click.echo(f" storage: {storage_base}")
2233
+ if not db_path.exists():
2234
+ click.echo(
2235
+ f" {DOT} memory.db not initialised ({db_path}). Run "
2236
+ f"`cce serve` for one prompt — the schema bootstraps on first open."
2237
+ )
2238
+ return
2239
+
2240
+ size_bytes = db_path.stat().st_size
2241
+ click.echo(f" memory.db: {db_path} ({size_bytes // 1024} KB)")
2242
+ conn = memory_db.connect(db_path)
2243
+ try:
2244
+ version = memory_db.schema_version(conn)
2245
+ has_vec = memory_db.has_vec_tables(conn)
2246
+ click.echo(
2247
+ f" schema: v{version}"
2248
+ f"{' · sqlite-vec available' if has_vec else ' · vec disabled'}"
2249
+ )
2250
+
2251
+ # Sessions counts.
2252
+ sess_rows = list(conn.execute(
2253
+ "SELECT status, COUNT(*) AS n FROM sessions GROUP BY status"
2254
+ ))
2255
+ if sess_rows:
2256
+ parts = ", ".join(f"{r['status']}={r['n']}" for r in sess_rows)
2257
+ click.echo(f" sessions: {parts}")
2258
+ else:
2259
+ click.echo(f" sessions: none recorded yet")
2260
+
2261
+ # Decisions by source — biggest signal of "is capture working?"
2262
+ dec_rows = list(conn.execute(
2263
+ "SELECT source, COUNT(*) AS n FROM decisions GROUP BY source"
2264
+ ))
2265
+ if dec_rows:
2266
+ parts = ", ".join(f"{r['source']}={r['n']}" for r in dec_rows)
2267
+ click.echo(f" decisions: {parts}")
2268
+ else:
2269
+ click.echo(f" decisions: 0 ({DOT} no record_decision calls yet)")
2270
+
2271
+ # Turn summaries (compress worker output).
2272
+ turn_count = conn.execute(
2273
+ "SELECT COUNT(*) AS n FROM turn_summaries"
2274
+ ).fetchone()["n"]
2275
+ click.echo(f" turns: {turn_count} compressed summaries")
2276
+
2277
+ # Compression queue depth — a stuck worker shows up here.
2278
+ queue = conn.execute(
2279
+ "SELECT COUNT(*) AS n, COALESCE(MAX(attempts), 0) AS max_att "
2280
+ "FROM pending_compressions"
2281
+ ).fetchone()
2282
+ if queue["n"]:
2283
+ attn = (
2284
+ f" ({CROSS} max attempts={queue['max_att']})"
2285
+ if queue["max_att"] > 1 else ""
2286
+ )
2287
+ click.echo(f" queue: {queue['n']} pending{attn}")
2288
+ else:
2289
+ click.echo(f" queue: drained")
2290
+
2291
+ # Vec coverage — backfill check.
2292
+ if has_vec:
2293
+ dec_v = conn.execute(
2294
+ "SELECT COUNT(*) AS n FROM decisions_vec"
2295
+ ).fetchone()["n"]
2296
+ turn_v = conn.execute(
2297
+ "SELECT COUNT(*) AS n FROM turn_summaries_vec"
2298
+ ).fetchone()["n"]
2299
+ dec_total = sum(r["n"] for r in dec_rows) if dec_rows else 0
2300
+ click.echo(
2301
+ f" vec: decisions={dec_v}/{dec_total}, "
2302
+ f"turns={turn_v}/{turn_count}"
2303
+ )
2304
+
2305
+ # Raw payload retention — how much of memory.db is unbounded data.
2306
+ payloads = conn.execute(
2307
+ "SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS total "
2308
+ "FROM tool_event_payloads WHERE raw_input != ''"
2309
+ ).fetchone()
2310
+ if payloads["n"]:
2311
+ mb = payloads["total"] // (1024 * 1024)
2312
+ click.echo(
2313
+ f" payloads: {payloads['n']} retained "
2314
+ f"(~{mb} MB raw; `cce sessions prune` ages out >30d)"
2315
+ )
2316
+ else:
2317
+ click.echo(f" payloads: none retained")
2318
+ finally:
2319
+ conn.close()
2320
+
2321
+
2322
+ @sessions.command(name="prune")
2323
+ @click.option(
2324
+ "--threshold",
2325
+ default=100,
2326
+ show_default=True,
2327
+ type=int,
2328
+ help="Consolidate when more than this many session files exist",
2329
+ )
2330
+ @click.option(
2331
+ "--keep",
2332
+ default=50,
2333
+ show_default=True,
2334
+ type=int,
2335
+ help="Number of most-recent sessions to keep verbatim",
2336
+ )
2337
+ @click.option(
2338
+ "--retain-payloads-days",
2339
+ default=30,
2340
+ show_default=True,
2341
+ type=int,
2342
+ help=(
2343
+ "Drop raw tool inputs/outputs older than this many days from memory.db. "
2344
+ "Summaries are kept; only the unbounded raw payload bytes are nulled."
2345
+ ),
2346
+ )
2347
+ @click.pass_context
2348
+ def sessions_prune(
2349
+ ctx: click.Context, threshold: int, keep: int, retain_payloads_days: int,
2350
+ ) -> None:
2351
+ """Consolidate old session files and age out raw memory.db payloads.
2352
+
2353
+ Two independent jobs:
2354
+ 1. JSON sessions → decisions_log.json (`SessionCapture.prune_old_sessions`)
2355
+ 2. memory.db `tool_event_payloads.raw_input/raw_output` older than
2356
+ --retain-payloads-days are NULLed. Summaries (turn_summaries,
2357
+ decisions, code_areas) are untouched and stay searchable.
2358
+ """
2359
+ from context_engine.integration.session_capture import SessionCapture
2360
+ from context_engine.memory import db as memory_db
2361
+
2362
+ config = ctx.obj["config"]
2363
+ project_name = Path.cwd().name
2364
+ storage_base = Path(config.storage_path) / project_name
2365
+ sessions_dir = storage_base / "sessions"
2366
+
2367
+ if sessions_dir.exists():
2368
+ capture = SessionCapture(sessions_dir=str(sessions_dir))
2369
+ summary = capture.prune_old_sessions(threshold=threshold, keep=keep)
2370
+ pruned = summary.get("pruned", 0)
2371
+ appended = summary.get("decisions_appended", 0)
2372
+ if pruned == 0:
2373
+ reason = summary.get("reason", "")
2374
+ click.echo(f" {DOT} JSON sessions: nothing to prune ({reason}).")
2375
+ else:
2376
+ click.echo(
2377
+ f" {CHECK} JSON sessions: pruned {pruned} file(s); "
2378
+ f"archived {appended} decision(s) to decisions_log.json."
2379
+ )
2380
+ else:
2381
+ click.echo(f" {DOT} JSON sessions: no directory at {sessions_dir}")
2382
+
2383
+ db_path = memory_db.memory_db_path(storage_base)
2384
+ if not db_path.exists():
2385
+ click.echo(f" {DOT} memory.db: not initialised at {db_path}")
2386
+ return
2387
+ conn = memory_db.connect(db_path)
2388
+ try:
2389
+ out = memory_db.prune_old_payloads(conn, days=retain_payloads_days)
2390
+ finally:
2391
+ conn.close()
2392
+ n = out["payloads_pruned"]
2393
+ if n == 0:
2394
+ click.echo(
2395
+ f" {DOT} memory.db: no raw payloads older than "
2396
+ f"{retain_payloads_days}d to prune."
2397
+ )
2398
+ else:
2399
+ kb = out["bytes_freed_estimate"] // 1024
2400
+ click.echo(
2401
+ f" {CHECK} memory.db: aged out {n} raw payload(s) "
2402
+ f"(~{kb} KB freed; summaries retained)."
2403
+ )
2404
+
2405
+
2406
+ @sessions.command(name="export")
2407
+ @click.option(
2408
+ "--since", "since_iso", type=str, default=None,
2409
+ help="Only include rows created on/after this date (YYYY-MM-DD or ISO-8601).",
2410
+ )
2411
+ @click.option(
2412
+ "--until", "until_iso", type=str, default=None,
2413
+ help="Only include rows created before this date.",
2414
+ )
2415
+ @click.option(
2416
+ "--format", "fmt", type=click.Choice(["markdown", "json"]),
2417
+ default="markdown", help="Output format.",
2418
+ )
2419
+ @click.option(
2420
+ "--output", "-o", type=click.Path(dir_okay=False), default=None,
2421
+ help="Write to this file instead of stdout.",
2422
+ )
2423
+ @click.pass_context
2424
+ def sessions_export(
2425
+ ctx: click.Context,
2426
+ since_iso: str | None,
2427
+ until_iso: str | None,
2428
+ fmt: str,
2429
+ output: str | None,
2430
+ ) -> None:
2431
+ """Export decisions + turn summaries from this project's memory.db.
2432
+
2433
+ Useful for: quarterly reviews, hand-off docs, post-mortem digests,
2434
+ grepping a long-running project's history outside the index. Default
2435
+ is markdown to stdout; pass `--format json` for machine-readable.
2436
+
2437
+ Examples:
2438
+ cce sessions export --since 2026-01-01 -o q1-decisions.md
2439
+ cce sessions export --since 2026-04-01 --until 2026-04-30 --format json
2440
+ """
2441
+ import datetime
2442
+ import json as _json
2443
+ from context_engine.memory import db as memory_db
2444
+
2445
+ config = ctx.obj["config"]
2446
+ project_name = Path.cwd().name
2447
+ storage_base = Path(config.storage_path) / project_name
2448
+ db_path = memory_db.memory_db_path(storage_base)
2449
+ if not db_path.exists():
2450
+ click.echo(" No memory.db for this project — nothing to export.")
2451
+ return
2452
+
2453
+ def _parse(s: str | None) -> int | None:
2454
+ if not s:
2455
+ return None
2456
+ try:
2457
+ # Accept date-only or ISO-8601.
2458
+ if "T" in s:
2459
+ dt = datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
2460
+ else:
2461
+ dt = datetime.datetime.fromisoformat(s)
2462
+ return int(dt.timestamp())
2463
+ except ValueError:
2464
+ raise click.BadParameter(
2465
+ f"could not parse {s!r} as a date — use YYYY-MM-DD or ISO-8601"
2466
+ )
2467
+
2468
+ since_epoch = _parse(since_iso)
2469
+ until_epoch = _parse(until_iso)
2470
+
2471
+ conn = memory_db.connect(db_path)
2472
+ try:
2473
+ where, params = [], []
2474
+ if since_epoch is not None:
2475
+ where.append("created_at_epoch >= ?")
2476
+ params.append(since_epoch)
2477
+ if until_epoch is not None:
2478
+ where.append("created_at_epoch < ?")
2479
+ params.append(until_epoch)
2480
+ clause = (" WHERE " + " AND ".join(where)) if where else ""
2481
+
2482
+ decisions = list(conn.execute(
2483
+ f"SELECT decision, reason, created_at, source FROM decisions"
2484
+ f"{clause} ORDER BY created_at_epoch ASC",
2485
+ params,
2486
+ ))
2487
+ turns = list(conn.execute(
2488
+ f"SELECT session_id, prompt_number, summary, tier, created_at_epoch "
2489
+ f"FROM turn_summaries{clause} ORDER BY created_at_epoch ASC",
2490
+ params,
2491
+ ))
2492
+ finally:
2493
+ conn.close()
2494
+
2495
+ if fmt == "json":
2496
+ payload = {
2497
+ "project": project_name,
2498
+ "since": since_iso,
2499
+ "until": until_iso,
2500
+ "decisions": [dict(r) for r in decisions],
2501
+ "turn_summaries": [dict(r) for r in turns],
2502
+ }
2503
+ text = _json.dumps(payload, indent=2, default=str)
2504
+ else:
2505
+ # Markdown — readable digest. Storage form is grammar-compressed;
2506
+ # `_grammar_expand` reverses abbreviations on the way out so the
2507
+ # exported text reads naturally without needing CCE installed.
2508
+ from context_engine.memory.grammar import expand as _expand
2509
+ lines = [f"# {project_name} — session export\n"]
2510
+ if since_iso or until_iso:
2511
+ lines.append(
2512
+ f"_Window: {since_iso or '(beginning)'} → "
2513
+ f"{until_iso or '(now)'}_\n"
2514
+ )
2515
+ lines.append(f"## Decisions ({len(decisions)})\n")
2516
+ for d in decisions:
2517
+ lines.append(f"### {_expand(d['decision'])}")
2518
+ lines.append(f"_{d['created_at']} · source={d['source']}_\n")
2519
+ if d["reason"]:
2520
+ lines.append(f"{_expand(d['reason'])}\n")
2521
+ lines.append(f"\n## Turn Summaries ({len(turns)})\n")
2522
+ for t in turns:
2523
+ iso = datetime.datetime.fromtimestamp(
2524
+ t["created_at_epoch"], tz=datetime.UTC,
2525
+ ).isoformat(timespec="seconds")
2526
+ lines.append(
2527
+ f"- **{iso}** · session `{t['session_id'][:8]}` · "
2528
+ f"turn {t['prompt_number']} · {t['tier']}: "
2529
+ f"{_expand(t['summary'])}"
2530
+ )
2531
+ text = "\n".join(lines) + "\n"
2532
+
2533
+ if output:
2534
+ Path(output).write_text(text, encoding="utf-8")
2535
+ click.echo(f" ✓ Wrote {len(decisions)} decision(s) + "
2536
+ f"{len(turns)} turn(s) to {output}")
2537
+ else:
2538
+ click.echo(text)
2539
+
2540
+
2541
+ @sessions.command(name="migrate")
2542
+ @click.option(
2543
+ "--no-archive",
2544
+ is_flag=True,
2545
+ default=False,
2546
+ help="Don't archive consumed JSON files into migrated.zip after import.",
2547
+ )
2548
+ @click.pass_context
2549
+ def sessions_migrate(ctx: click.Context, no_archive: bool) -> None:
2550
+ """Import legacy per-session JSON files into the per-project memory.db.
2551
+
2552
+ Idempotent: rerun is a no-op once everything has been imported. Imported
2553
+ decisions and code areas are tagged with source='migrated' so future
2554
+ session_recall can rank them appropriately.
2555
+ """
2556
+ from context_engine.memory import db as memory_db, migrate as memory_migrate
2557
+
2558
+ config = ctx.obj["config"]
2559
+ project_name = Path.cwd().name
2560
+ storage_base = Path(config.storage_path) / project_name
2561
+ db_path = memory_db.memory_db_path(storage_base)
2562
+
2563
+ conn = memory_db.connect(db_path)
2564
+ try:
2565
+ summary = memory_migrate.migrate(
2566
+ conn,
2567
+ project_name=project_name,
2568
+ storage_base=storage_base,
2569
+ archive=not no_archive,
2570
+ )
2571
+ finally:
2572
+ conn.close()
2573
+
2574
+ if not summary.sources_scanned:
2575
+ click.echo(f" {DOT} No legacy session directories found.")
2576
+ return
2577
+
2578
+ click.echo(f" {CHECK} Scanned: {len(summary.sources_scanned)} source dir(s)")
2579
+ if summary.files_imported == 0 and summary.files_skipped > 0:
2580
+ click.echo(f" {DOT} {summary.files_skipped} file(s) already imported. Nothing to do.")
2581
+ return
2582
+ click.echo(
2583
+ f" {CHECK} Imported {summary.files_imported} file(s) → "
2584
+ f"{summary.decisions_imported} decision(s), "
2585
+ f"{summary.code_areas_imported} code area(s)."
2586
+ )
2587
+ if summary.files_archived:
2588
+ click.echo(f" {CHECK} Archived {summary.files_archived} file(s) to migrated.zip")
2589
+
2590
+
2591
+ async def _run_index(
2592
+ config,
2593
+ project_dir: str,
2594
+ full: bool = False,
2595
+ target_path: str | None = None,
2596
+ verbose: bool = False,
2597
+ ) -> None:
2598
+ """Run indexing pipeline (thin wrapper over `indexer.pipeline.run_indexing`)."""
2599
+ from context_engine.indexer.pipeline import run_indexing
2600
+
2601
+ log_fn = (lambda msg: click.echo(msg)) if verbose else None
2602
+ from context_engine.cli_style import success, warn, dim, value, CHECK, CROSS
2603
+
2604
+ _showed_progress = False
2605
+ _showed_embed_progress = False
2606
+ _bar_width = 30
2607
+
2608
+ def _render_bar(current: int, total: int, label: str) -> None:
2609
+ filled = int(_bar_width * current / total) if total else 0
2610
+ bar = (
2611
+ click.style("█" * filled, fg="cyan") +
2612
+ click.style("░" * (_bar_width - filled), fg="bright_black")
2613
+ )
2614
+ pct = click.style(f"{int(100 * current / total) if total else 0}%", fg="bright_black")
2615
+ count = click.style(f"{current}/{total}", fg="white", bold=True)
2616
+ click.echo(f"\r {bar} {count} {label} {pct}", nl=False)
2617
+
2618
+ def progress_fn(current: int, total: int) -> None:
2619
+ nonlocal _showed_progress
2620
+ if not verbose and sys.stdout.isatty():
2621
+ _render_bar(current, total, "files")
2622
+ _showed_progress = True
2623
+
2624
+ def embed_progress_fn(current: int, total: int) -> None:
2625
+ nonlocal _showed_embed_progress, _showed_progress
2626
+ if not verbose and sys.stdout.isatty():
2627
+ # First tick: close out the file bar (if any) with a newline so the
2628
+ # embed bar starts on its own line instead of overwriting it.
2629
+ if not _showed_embed_progress and _showed_progress:
2630
+ click.echo()
2631
+ _render_bar(current, total, "chunks embedded")
2632
+ _showed_embed_progress = True
2633
+
2634
+ def phase_fn(msg: str) -> None:
2635
+ """Print a status line between indexing phases.
2636
+
2637
+ Closes any in-place progress bar (chunking or embedding) on its own
2638
+ line first, so subsequent phase messages don't overwrite or mash
2639
+ into the bar. Complementary to embed_progress_fn — phase_fn is
2640
+ per-phase ("starting embedding"), embed_progress_fn is per-batch
2641
+ ("embedded 1024/32000 chunks"). Both keep large-repo indexing from
2642
+ looking like a hang.
2643
+ """
2644
+ nonlocal _showed_progress, _showed_embed_progress
2645
+ if _showed_progress or _showed_embed_progress:
2646
+ click.echo() # finalise the in-place bar line
2647
+ _showed_progress = False
2648
+ _showed_embed_progress = False
2649
+ click.echo(f" {_dim(msg)}")
2650
+
2651
+ result = await run_indexing(
2652
+ config, project_dir, full=full, target_path=target_path,
2653
+ log_fn=log_fn, progress_fn=progress_fn,
2654
+ embed_progress_fn=embed_progress_fn, phase_fn=phase_fn,
2655
+ )
2656
+
2657
+ if _showed_progress or _showed_embed_progress:
2658
+ click.echo() # newline after progress bar(s)
2659
+
2660
+ for err in result.errors:
2661
+ click.echo(f" {CROSS} {warn(f'Error: {err}')}", err=True)
2662
+
2663
+ n_files = len(result.indexed_files)
2664
+ detail_parts = []
2665
+ if result.deleted_files:
2666
+ detail_parts.append(f", pruned {warn(str(len(result.deleted_files)))} deleted")
2667
+ if result.skipped_files:
2668
+ detail_parts.append(f", skipped {dim(str(len(result.skipped_files)))} non-text")
2669
+ # Surface embedding-cache reuse so users see the speedup directly.
2670
+ if result.cache_hits > 0:
2671
+ total_embeds = result.cache_hits + result.cache_misses
2672
+ pct = int(result.cache_hits / total_embeds * 100)
2673
+ detail_parts.append(f", {dim(f'{pct}% cache hit')}")
2674
+
2675
+ click.echo(
2676
+ f" {CHECK} " +
2677
+ value(f"Indexed {result.total_chunks:,} chunks") +
2678
+ click.style(f" from {n_files:,} file{'s' if n_files != 1 else ''}", fg="white") +
2679
+ "".join(detail_parts)
2680
+ )
2681
+
2682
+ # Update full_file_tokens baseline so cce savings shows codebase size
2683
+ project_name = Path(project_dir).name
2684
+ stats_path = Path(config.storage_path) / project_name / "stats.json"
2685
+ try:
2686
+ stats = json.loads(stats_path.read_text()) if stats_path.exists() else {}
2687
+ except (json.JSONDecodeError, OSError):
2688
+ stats = {}
2689
+ total_tokens = 0
2690
+ project_root = Path(project_dir)
2691
+ from context_engine.storage.local_backend import LocalBackend
2692
+ backend = LocalBackend(base_path=str(Path(config.storage_path) / project_name))
2693
+ for rel_path in backend._vector_store.file_chunk_counts():
2694
+ fp = project_root / rel_path
2695
+ if fp.exists():
2696
+ try:
2697
+ total_tokens += len(fp.read_text(errors="ignore")) // 4
2698
+ except OSError:
2699
+ pass
2700
+ stats["full_file_tokens"] = total_tokens
2701
+ stats_path.parent.mkdir(parents=True, exist_ok=True)
2702
+ stats_path.write_text(json.dumps(stats))
2703
+
2704
+
2705
+ async def _run_serve(config) -> None:
2706
+ """Start MCP server with live file watcher."""
2707
+ import logging
2708
+ from context_engine.storage.local_backend import LocalBackend
2709
+ from context_engine.indexer.embedder import Embedder
2710
+ from context_engine.retrieval.retriever import HybridRetriever
2711
+ from context_engine.compression.compressor import Compressor
2712
+ from context_engine.integration.mcp_server import ContextEngineMCP
2713
+ from context_engine.indexer.watcher import FileWatcher
2714
+ from context_engine.indexer.pipeline import run_indexing
2715
+
2716
+ _log = logging.getLogger("context_engine.watcher")
2717
+
2718
+ project_dir = str(Path.cwd())
2719
+ project_name = Path.cwd().name
2720
+ storage_base = Path(config.storage_path) / project_name
2721
+ backend = LocalBackend(base_path=str(storage_base))
2722
+ embedder = Embedder(model_name=config.embedding_model)
2723
+ retriever = HybridRetriever(backend=backend, embedder=embedder)
2724
+ compressor = Compressor(model=config.compression_model, cache=backend)
2725
+ mcp = ContextEngineMCP(
2726
+ retriever=retriever, backend=backend, compressor=compressor,
2727
+ embedder=embedder, config=config,
2728
+ )
2729
+
2730
+ chunk_count = backend._vector_store.count()
2731
+ import sys
2732
+
2733
+ watcher = None
2734
+ worker_task = None
2735
+
2736
+ if config.indexer_watch:
2737
+ # Live file watcher — re-indexes changed files on save.
2738
+ _reindex_queue: asyncio.Queue[str] = asyncio.Queue()
2739
+ _reindex_pending: set[str] = set()
2740
+
2741
+ async def _on_file_change(file_path: str):
2742
+ """Queue the file for re-indexing, deduplicating pending entries."""
2743
+ try:
2744
+ rel = str(Path(file_path).relative_to(project_dir))
2745
+ except ValueError:
2746
+ return
2747
+ if rel not in _reindex_pending:
2748
+ _reindex_pending.add(rel)
2749
+ await _reindex_queue.put(rel)
2750
+
2751
+ async def _reindex_worker():
2752
+ """Background task that processes re-index requests sequentially."""
2753
+ while True:
2754
+ rel = await _reindex_queue.get()
2755
+ _reindex_pending.discard(rel)
2756
+ try:
2757
+ await run_indexing(config, project_dir, target_path=rel)
2758
+ _log.debug("Re-indexed: %s", rel)
2759
+ except Exception as exc:
2760
+ _log.warning("Watch re-index failed for %s: %s", rel, exc)
2761
+ _reindex_queue.task_done()
2762
+
2763
+ watcher = FileWatcher(
2764
+ watch_dir=project_dir,
2765
+ on_change=_on_file_change,
2766
+ debounce_ms=config.indexer_debounce_ms,
2767
+ ignore_patterns=config.indexer_ignore,
2768
+ )
2769
+
2770
+ loop = asyncio.get_running_loop()
2771
+ worker_task = asyncio.create_task(_reindex_worker())
2772
+ watcher.start(loop=loop)
2773
+
2774
+ # Memory hook listener — loopback HTTP for the 5 lifecycle hooks. Best
2775
+ # effort: a setup failure here must NOT prevent the MCP server starting
2776
+ # (capture is a non-critical feature; retrieval still works without it).
2777
+ hook_runner = None
2778
+ hook_port = None
2779
+ try:
2780
+ from context_engine.memory.hook_server import start_hook_server
2781
+ hook_runner, hook_port = await start_hook_server(
2782
+ storage_base=storage_base, project_name=project_name,
2783
+ )
2784
+ except Exception as exc:
2785
+ _log.warning("Memory hook server failed to start: %s", exc)
2786
+
2787
+ # Memory compression worker — drains pending_compressions in the background.
2788
+ # Each iteration opens a thread-local SQLite connection inside
2789
+ # `asyncio.to_thread`, so this loop never holds the asyncio thread while
2790
+ # an embed + SQLite write is in flight.
2791
+ compression_task = None
2792
+ auto_prune_task = None
2793
+ try:
2794
+ from context_engine.memory import db as memory_db
2795
+ from context_engine.memory.compressor import compression_loop
2796
+ compression_task = asyncio.create_task(
2797
+ compression_loop(memory_db.memory_db_path(storage_base), embedder)
2798
+ )
2799
+ except Exception as exc:
2800
+ _log.warning("Memory compression worker failed to start: %s", exc)
2801
+
2802
+ # Auto-prune — run prune_old_payloads in the background so users who
2803
+ # never invoke `cce sessions prune` manually still get bounded memory.db
2804
+ # growth.
2805
+ try:
2806
+ from context_engine.memory.db import auto_prune_loop
2807
+ auto_prune_task = asyncio.create_task(
2808
+ auto_prune_loop(storage_base, days=30)
2809
+ )
2810
+ except Exception as exc:
2811
+ _log.warning("Auto-prune worker failed to start: %s", exc)
2812
+
2813
+ watcher_label = " · live watcher active" if watcher else ""
2814
+ hook_label = f" · memory hooks :{hook_port}" if hook_port else ""
2815
+ print(
2816
+ f"CCE ready · {project_name} · {chunk_count} chunks indexed"
2817
+ f"{watcher_label}{hook_label}",
2818
+ file=sys.stderr,
2819
+ )
2820
+
2821
+ try:
2822
+ await mcp.run_stdio()
2823
+ finally:
2824
+ if watcher:
2825
+ watcher.stop()
2826
+ if worker_task:
2827
+ worker_task.cancel()
2828
+ try:
2829
+ await worker_task
2830
+ except asyncio.CancelledError:
2831
+ pass
2832
+ if compression_task is not None:
2833
+ compression_task.cancel()
2834
+ try:
2835
+ await compression_task
2836
+ except asyncio.CancelledError:
2837
+ pass
2838
+ if auto_prune_task is not None:
2839
+ auto_prune_task.cancel()
2840
+ try:
2841
+ await auto_prune_task
2842
+ except asyncio.CancelledError:
2843
+ pass
2844
+ if hook_runner is not None:
2845
+ try:
2846
+ await hook_runner.cleanup()
2847
+ except Exception:
2848
+ _log.warning("hook_runner cleanup failed", exc_info=True)