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.
- code_context_engine-0.4.0.dist-info/METADATA +389 -0
- code_context_engine-0.4.0.dist-info/RECORD +63 -0
- code_context_engine-0.4.0.dist-info/WHEEL +5 -0
- code_context_engine-0.4.0.dist-info/entry_points.txt +4 -0
- code_context_engine-0.4.0.dist-info/licenses/LICENSE +21 -0
- code_context_engine-0.4.0.dist-info/top_level.txt +1 -0
- context_engine/__init__.py +3 -0
- context_engine/cli.py +2848 -0
- context_engine/cli_style.py +66 -0
- context_engine/compression/__init__.py +0 -0
- context_engine/compression/compressor.py +144 -0
- context_engine/compression/ollama_client.py +33 -0
- context_engine/compression/output_rules.py +77 -0
- context_engine/compression/prompts.py +9 -0
- context_engine/compression/quality.py +37 -0
- context_engine/config.py +198 -0
- context_engine/dashboard/__init__.py +0 -0
- context_engine/dashboard/_page.py +1548 -0
- context_engine/dashboard/server.py +429 -0
- context_engine/editors.py +265 -0
- context_engine/event_bus.py +24 -0
- context_engine/indexer/__init__.py +0 -0
- context_engine/indexer/chunker.py +147 -0
- context_engine/indexer/embedder.py +154 -0
- context_engine/indexer/embedding_cache.py +168 -0
- context_engine/indexer/git_hooks.py +73 -0
- context_engine/indexer/git_indexer.py +136 -0
- context_engine/indexer/ignorefile.py +96 -0
- context_engine/indexer/manifest.py +78 -0
- context_engine/indexer/pipeline.py +624 -0
- context_engine/indexer/secrets.py +332 -0
- context_engine/indexer/watcher.py +109 -0
- context_engine/integration/__init__.py +0 -0
- context_engine/integration/bootstrap.py +76 -0
- context_engine/integration/git_context.py +132 -0
- context_engine/integration/mcp_server.py +1825 -0
- context_engine/integration/session_capture.py +306 -0
- context_engine/memory/__init__.py +6 -0
- context_engine/memory/compressor.py +344 -0
- context_engine/memory/db.py +922 -0
- context_engine/memory/extractive.py +106 -0
- context_engine/memory/grammar.py +419 -0
- context_engine/memory/hook_installer.py +258 -0
- context_engine/memory/hook_server.py +83 -0
- context_engine/memory/hooks.py +327 -0
- context_engine/memory/migrate.py +268 -0
- context_engine/models.py +96 -0
- context_engine/pricing.py +104 -0
- context_engine/project_commands.py +296 -0
- context_engine/retrieval/__init__.py +0 -0
- context_engine/retrieval/confidence.py +47 -0
- context_engine/retrieval/query_parser.py +105 -0
- context_engine/retrieval/retriever.py +199 -0
- context_engine/serve_http.py +208 -0
- context_engine/services.py +252 -0
- context_engine/storage/__init__.py +0 -0
- context_engine/storage/backend.py +39 -0
- context_engine/storage/fts_store.py +112 -0
- context_engine/storage/graph_store.py +219 -0
- context_engine/storage/local_backend.py +109 -0
- context_engine/storage/remote_backend.py +117 -0
- context_engine/storage/vector_store.py +357 -0
- 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)
|