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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""FastAPI dashboard server for CCE index inspection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from context_engine.config import Config
|
|
18
|
+
from context_engine.dashboard._page import PAGE_HTML
|
|
19
|
+
from context_engine.indexer.pipeline import PathOutsideProjectError, run_indexing
|
|
20
|
+
from context_engine.storage.local_backend import LocalBackend
|
|
21
|
+
|
|
22
|
+
# Mutating HTTP methods require a same-origin browser request OR a non-browser
|
|
23
|
+
# client (Sec-Fetch-Site absent). This blocks CSRF from a malicious local page
|
|
24
|
+
# without breaking the dashboard's own fetch() calls or curl/tests.
|
|
25
|
+
_MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
26
|
+
# Optional bearer token for mutating endpoints. When CCE_DASHBOARD_TOKEN is set
|
|
27
|
+
# in the environment, mutating requests must include `Authorization: Bearer
|
|
28
|
+
# <token>` (the dashboard JS picks the token up from a `?token=` URL param so
|
|
29
|
+
# users can paste a single URL into a browser). When the env var is unset the
|
|
30
|
+
# dashboard remains open like before — the CSRF check above is the only guard.
|
|
31
|
+
_DASHBOARD_TOKEN_ENV = "CCE_DASHBOARD_TOKEN"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReindexRequest(BaseModel):
|
|
35
|
+
full: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CompressionRequest(BaseModel):
|
|
39
|
+
level: Literal["off", "lite", "standard", "max"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_app(config: Config, project_dir: Path) -> FastAPI:
|
|
43
|
+
"""Build and return the FastAPI application.
|
|
44
|
+
|
|
45
|
+
All route handlers close over `storage_base` and `project_dir` so the
|
|
46
|
+
app is self-contained and trivial to test with TestClient.
|
|
47
|
+
"""
|
|
48
|
+
project_name = project_dir.name
|
|
49
|
+
storage_base = Path(config.storage_path) / project_name
|
|
50
|
+
|
|
51
|
+
app = FastAPI(title="CCE Dashboard", docs_url=None, redoc_url=None)
|
|
52
|
+
|
|
53
|
+
expected_token = (os.environ.get(_DASHBOARD_TOKEN_ENV) or "").strip() or None
|
|
54
|
+
|
|
55
|
+
@app.middleware("http")
|
|
56
|
+
async def csrf_and_auth(request: Request, call_next):
|
|
57
|
+
if request.method in _MUTATING_METHODS:
|
|
58
|
+
# CSRF: browser cross-origin requests are rejected. Non-browser
|
|
59
|
+
# clients (curl, tests) don't send Sec-Fetch-Site at all and are
|
|
60
|
+
# allowed to proceed to the auth check.
|
|
61
|
+
sfs = request.headers.get("sec-fetch-site")
|
|
62
|
+
if sfs is not None and sfs != "same-origin":
|
|
63
|
+
return JSONResponse(
|
|
64
|
+
{"error": "cross-origin requests not allowed"},
|
|
65
|
+
status_code=403,
|
|
66
|
+
)
|
|
67
|
+
# Auth: only enforced when CCE_DASHBOARD_TOKEN is set. Use
|
|
68
|
+
# constant-time comparison so a token-guessing attacker can't
|
|
69
|
+
# learn anything from response timing.
|
|
70
|
+
if expected_token is not None:
|
|
71
|
+
auth = request.headers.get("authorization", "")
|
|
72
|
+
presented = ""
|
|
73
|
+
if auth.startswith("Bearer "):
|
|
74
|
+
presented = auth[len("Bearer "):]
|
|
75
|
+
if not presented or not hmac.compare_digest(presented, expected_token):
|
|
76
|
+
return JSONResponse(
|
|
77
|
+
{"error": "invalid or missing bearer token"},
|
|
78
|
+
status_code=401,
|
|
79
|
+
)
|
|
80
|
+
return await call_next(request)
|
|
81
|
+
|
|
82
|
+
backend = LocalBackend(base_path=str(storage_base))
|
|
83
|
+
|
|
84
|
+
# ── helpers ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def _read_json(path: Path) -> dict:
|
|
87
|
+
if path.exists():
|
|
88
|
+
try:
|
|
89
|
+
return json.loads(path.read_text())
|
|
90
|
+
except (json.JSONDecodeError, OSError):
|
|
91
|
+
pass
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
def _read_manifest() -> dict[str, str]:
|
|
95
|
+
"""Return {file_path: content_hash} regardless of on-disk schema.
|
|
96
|
+
|
|
97
|
+
Manifest.save() writes {"__schema_version": 2, "files": {...},
|
|
98
|
+
"last_git_sha": ...}. Older installs may have left the flat
|
|
99
|
+
{file_path: hash} form. Both shapes collapse to the same dict here.
|
|
100
|
+
"""
|
|
101
|
+
raw = _read_json(storage_base / "manifest.json")
|
|
102
|
+
if isinstance(raw.get("files"), dict):
|
|
103
|
+
return raw["files"]
|
|
104
|
+
# Legacy flat manifest, or empty / unreadable file.
|
|
105
|
+
return raw if raw and "__schema_version" not in raw else {}
|
|
106
|
+
|
|
107
|
+
def _read_stats() -> dict:
|
|
108
|
+
return _read_json(storage_base / "stats.json")
|
|
109
|
+
|
|
110
|
+
def _read_state() -> dict:
|
|
111
|
+
return _read_json(storage_base / "state.json")
|
|
112
|
+
|
|
113
|
+
def _read_sessions(limit: int = 20) -> list[dict]:
|
|
114
|
+
sessions_dir = storage_base / "sessions"
|
|
115
|
+
if not sessions_dir.exists():
|
|
116
|
+
return []
|
|
117
|
+
files = sorted(sessions_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
118
|
+
result = []
|
|
119
|
+
for f in files[:limit]:
|
|
120
|
+
try:
|
|
121
|
+
result.append(json.loads(f.read_text()))
|
|
122
|
+
except (json.JSONDecodeError, OSError):
|
|
123
|
+
pass
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
# ── routes ─────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
@app.get("/", response_class=HTMLResponse)
|
|
129
|
+
async def serve_page() -> str:
|
|
130
|
+
return PAGE_HTML
|
|
131
|
+
|
|
132
|
+
@app.get("/api/status")
|
|
133
|
+
async def get_status() -> dict:
|
|
134
|
+
stats = _read_stats()
|
|
135
|
+
manifest = _read_manifest()
|
|
136
|
+
state = _read_state()
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
chunks = backend.count_chunks()
|
|
140
|
+
except Exception:
|
|
141
|
+
chunks = 0
|
|
142
|
+
|
|
143
|
+
full_file = stats.get("full_file_tokens", 0)
|
|
144
|
+
served = stats.get("served_tokens", 0)
|
|
145
|
+
baseline = full_file if full_file > 0 else stats.get("raw_tokens", 0)
|
|
146
|
+
saved_pct = max(0, int((1 - served / baseline) * 100)) if baseline > 0 else 0
|
|
147
|
+
|
|
148
|
+
output_level = state.get("output_level", config.output_compression)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"project": project_name,
|
|
152
|
+
"initialized": bool(manifest),
|
|
153
|
+
"chunks": chunks,
|
|
154
|
+
"files": len(manifest),
|
|
155
|
+
"queries": stats.get("queries", 0),
|
|
156
|
+
"tokens_saved_pct": saved_pct,
|
|
157
|
+
"output_level": output_level,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@app.get("/api/files")
|
|
161
|
+
async def get_files() -> list:
|
|
162
|
+
manifest = _read_manifest()
|
|
163
|
+
if not manifest:
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
chunk_counts = backend.file_chunk_counts()
|
|
167
|
+
|
|
168
|
+
result = []
|
|
169
|
+
for rel_path, stored_hash in sorted(manifest.items()):
|
|
170
|
+
abs_path = project_dir / rel_path
|
|
171
|
+
if not abs_path.exists():
|
|
172
|
+
status = "missing"
|
|
173
|
+
else:
|
|
174
|
+
try:
|
|
175
|
+
current = abs_path.read_text(encoding="utf-8", errors="strict")
|
|
176
|
+
current_hash = hashlib.sha256(current.encode("utf-8")).hexdigest()
|
|
177
|
+
status = "ok" if current_hash == stored_hash else "stale"
|
|
178
|
+
except (UnicodeDecodeError, OSError):
|
|
179
|
+
status = "ok" # binary file, trust the manifest
|
|
180
|
+
result.append({
|
|
181
|
+
"path": rel_path,
|
|
182
|
+
"chunks": chunk_counts.get(rel_path, 0),
|
|
183
|
+
"status": status,
|
|
184
|
+
})
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
@app.get("/api/sessions")
|
|
188
|
+
async def get_sessions() -> list:
|
|
189
|
+
return _read_sessions()
|
|
190
|
+
|
|
191
|
+
# ── memory.db API (PR 5) ────────────────────────────────────────────────
|
|
192
|
+
# These endpoints expose the per-project memory.db introduced in PRs 1–4.
|
|
193
|
+
# All queries open a fresh connection (cheap on SQLite WAL) so the
|
|
194
|
+
# dashboard process never holds a long-lived handle that would block the
|
|
195
|
+
# MCP server's writes.
|
|
196
|
+
|
|
197
|
+
def _open_memory_conn():
|
|
198
|
+
from context_engine.memory import db as memory_db
|
|
199
|
+
path = memory_db.memory_db_path(storage_base)
|
|
200
|
+
if not path.exists():
|
|
201
|
+
return None
|
|
202
|
+
return memory_db.connect(path)
|
|
203
|
+
|
|
204
|
+
@app.get("/api/memory/sessions")
|
|
205
|
+
async def memory_sessions_list(limit: int = 50) -> list:
|
|
206
|
+
"""Sessions list: most-recent first. Limited to `limit` rows.
|
|
207
|
+
|
|
208
|
+
Stored `rollup_summary` was passed through grammar.compress on write;
|
|
209
|
+
we expand here so the dashboard renders natural prose, not the
|
|
210
|
+
article-stripped storage form.
|
|
211
|
+
"""
|
|
212
|
+
from context_engine.memory.grammar import expand as _grammar_expand
|
|
213
|
+
conn = _open_memory_conn()
|
|
214
|
+
if conn is None:
|
|
215
|
+
return []
|
|
216
|
+
try:
|
|
217
|
+
rows = list(conn.execute(
|
|
218
|
+
"SELECT id, project, started_at, ended_at, status, "
|
|
219
|
+
"prompt_count, rollup_summary FROM sessions "
|
|
220
|
+
"ORDER BY started_at_epoch DESC LIMIT ?",
|
|
221
|
+
(max(1, min(limit, 500)),),
|
|
222
|
+
))
|
|
223
|
+
finally:
|
|
224
|
+
conn.close()
|
|
225
|
+
out = []
|
|
226
|
+
for r in rows:
|
|
227
|
+
d = dict(r)
|
|
228
|
+
if d.get("rollup_summary"):
|
|
229
|
+
d["rollup_summary"] = _grammar_expand(d["rollup_summary"])
|
|
230
|
+
out.append(d)
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
@app.get("/api/memory/sessions/{session_id}/timeline")
|
|
234
|
+
async def memory_session_timeline(session_id: str) -> dict:
|
|
235
|
+
"""A single session's turn summaries plus header metadata."""
|
|
236
|
+
from context_engine.memory.grammar import expand as _grammar_expand
|
|
237
|
+
conn = _open_memory_conn()
|
|
238
|
+
if conn is None:
|
|
239
|
+
return {"session": None, "turns": []}
|
|
240
|
+
try:
|
|
241
|
+
session_row = conn.execute(
|
|
242
|
+
"SELECT id, project, started_at, ended_at, status, "
|
|
243
|
+
"prompt_count, rollup_summary FROM sessions WHERE id = ?",
|
|
244
|
+
(session_id,),
|
|
245
|
+
).fetchone()
|
|
246
|
+
if session_row is None:
|
|
247
|
+
return {"session": None, "turns": []}
|
|
248
|
+
turn_rows = list(conn.execute(
|
|
249
|
+
"SELECT prompt_number, summary, tier, created_at_epoch "
|
|
250
|
+
"FROM turn_summaries WHERE session_id = ? "
|
|
251
|
+
"ORDER BY prompt_number ASC",
|
|
252
|
+
(session_id,),
|
|
253
|
+
))
|
|
254
|
+
finally:
|
|
255
|
+
conn.close()
|
|
256
|
+
session_d = dict(session_row)
|
|
257
|
+
if session_d.get("rollup_summary"):
|
|
258
|
+
session_d["rollup_summary"] = _grammar_expand(session_d["rollup_summary"])
|
|
259
|
+
turns = []
|
|
260
|
+
for r in turn_rows:
|
|
261
|
+
d = dict(r)
|
|
262
|
+
if d.get("summary"):
|
|
263
|
+
d["summary"] = _grammar_expand(d["summary"])
|
|
264
|
+
turns.append(d)
|
|
265
|
+
return {"session": session_d, "turns": turns}
|
|
266
|
+
|
|
267
|
+
@app.get("/api/memory/decisions")
|
|
268
|
+
async def memory_decisions_search(
|
|
269
|
+
q: str = "", source: str | None = None, limit: int = 50
|
|
270
|
+
) -> list:
|
|
271
|
+
"""FTS5 search over decisions, optionally filtered by source."""
|
|
272
|
+
conn = _open_memory_conn()
|
|
273
|
+
if conn is None:
|
|
274
|
+
return []
|
|
275
|
+
limit = max(1, min(limit, 500))
|
|
276
|
+
try:
|
|
277
|
+
params: list = []
|
|
278
|
+
sql = (
|
|
279
|
+
"SELECT d.id, d.session_id, d.decision, d.reason, d.source, "
|
|
280
|
+
"d.created_at FROM decisions d "
|
|
281
|
+
)
|
|
282
|
+
if q.strip():
|
|
283
|
+
sql += (
|
|
284
|
+
"JOIN decisions_fts ON decisions_fts.rowid = d.id "
|
|
285
|
+
"WHERE decisions_fts MATCH ? "
|
|
286
|
+
)
|
|
287
|
+
# Wrap in a phrase quote so FTS5's tokeniser treats user
|
|
288
|
+
# input as literal text — without this, characters like '-'
|
|
289
|
+
# parse as operators (`bge-small` becomes "bge AND NOT
|
|
290
|
+
# small") and queries silently miss real hits.
|
|
291
|
+
params.append('"' + q.strip().replace('"', '""') + '"')
|
|
292
|
+
else:
|
|
293
|
+
sql += "WHERE 1=1 "
|
|
294
|
+
if source in {"manual", "auto", "migrated"}:
|
|
295
|
+
sql += "AND d.source = ? "
|
|
296
|
+
params.append(source)
|
|
297
|
+
sql += "ORDER BY d.created_at_epoch DESC LIMIT ?"
|
|
298
|
+
params.append(limit)
|
|
299
|
+
try:
|
|
300
|
+
rows = list(conn.execute(sql, params))
|
|
301
|
+
except Exception:
|
|
302
|
+
# FTS5 syntax errors on weird queries — fall back to unranked.
|
|
303
|
+
rows = list(conn.execute(
|
|
304
|
+
"SELECT id, session_id, decision, reason, source, "
|
|
305
|
+
"created_at FROM decisions ORDER BY created_at_epoch "
|
|
306
|
+
"DESC LIMIT ?",
|
|
307
|
+
(limit,),
|
|
308
|
+
))
|
|
309
|
+
finally:
|
|
310
|
+
conn.close()
|
|
311
|
+
# Stored decision/reason are compressed; expand for display.
|
|
312
|
+
from context_engine.memory.grammar import expand as _grammar_expand
|
|
313
|
+
out = []
|
|
314
|
+
for r in rows:
|
|
315
|
+
d = dict(r)
|
|
316
|
+
if d.get("decision"):
|
|
317
|
+
d["decision"] = _grammar_expand(d["decision"])
|
|
318
|
+
if d.get("reason"):
|
|
319
|
+
d["reason"] = _grammar_expand(d["reason"])
|
|
320
|
+
out.append(d)
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
@app.get("/api/savings")
|
|
324
|
+
async def get_savings() -> dict:
|
|
325
|
+
stats = _read_stats()
|
|
326
|
+
full_file = stats.get("full_file_tokens", 0)
|
|
327
|
+
served = stats.get("served_tokens", 0)
|
|
328
|
+
raw = stats.get("raw_tokens", 0)
|
|
329
|
+
baseline = full_file if full_file > 0 else raw
|
|
330
|
+
saved = max(0, baseline - served)
|
|
331
|
+
pct = int(saved / baseline * 100) if baseline > 0 else 0
|
|
332
|
+
return {
|
|
333
|
+
"queries": stats.get("queries", 0),
|
|
334
|
+
"baseline_tokens": baseline,
|
|
335
|
+
"served_tokens": served,
|
|
336
|
+
"tokens_saved": saved,
|
|
337
|
+
"savings_pct": pct,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
# ── action routes ──────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
@app.post("/api/reindex")
|
|
343
|
+
async def reindex(req: ReindexRequest) -> dict:
|
|
344
|
+
result = await run_indexing(config, project_dir, full=req.full)
|
|
345
|
+
return {
|
|
346
|
+
"total_chunks": result.total_chunks,
|
|
347
|
+
"indexed_files": result.indexed_files,
|
|
348
|
+
"deleted_files": result.deleted_files,
|
|
349
|
+
"skipped_files": result.skipped_files,
|
|
350
|
+
"errors": result.errors,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
@app.post("/api/reindex/{file_path:path}", response_model=None)
|
|
354
|
+
async def reindex_file(file_path: str) -> dict | JSONResponse:
|
|
355
|
+
try:
|
|
356
|
+
result = await run_indexing(config, project_dir, target_path=file_path)
|
|
357
|
+
except PathOutsideProjectError:
|
|
358
|
+
return JSONResponse({"error": "invalid file_path"}, status_code=400)
|
|
359
|
+
return {
|
|
360
|
+
"total_chunks": result.total_chunks,
|
|
361
|
+
"indexed_files": result.indexed_files,
|
|
362
|
+
"deleted_files": result.deleted_files,
|
|
363
|
+
"skipped_files": result.skipped_files,
|
|
364
|
+
"errors": result.errors,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
@app.post("/api/clear")
|
|
368
|
+
async def clear_index() -> dict:
|
|
369
|
+
await backend.clear()
|
|
370
|
+
from context_engine.utils import atomic_write_text
|
|
371
|
+
|
|
372
|
+
atomic_write_text(
|
|
373
|
+
storage_base / "manifest.json",
|
|
374
|
+
json.dumps({"__schema_version": 2, "files": {}, "last_git_sha": None}),
|
|
375
|
+
)
|
|
376
|
+
atomic_write_text(
|
|
377
|
+
storage_base / "stats.json",
|
|
378
|
+
json.dumps({"queries": 0, "raw_tokens": 0, "served_tokens": 0, "full_file_tokens": 0}),
|
|
379
|
+
)
|
|
380
|
+
return {"ok": True}
|
|
381
|
+
|
|
382
|
+
@app.delete("/api/files/{file_path:path}", response_model=None)
|
|
383
|
+
async def delete_file(file_path: str) -> dict | JSONResponse:
|
|
384
|
+
# Reject absolute paths and traversal — the manifest stores project-relative
|
|
385
|
+
# paths, so anything else is either an attacker probe or a bug.
|
|
386
|
+
if file_path.startswith("/") or ".." in Path(file_path).parts:
|
|
387
|
+
return JSONResponse({"error": "invalid file_path"}, status_code=400)
|
|
388
|
+
files = _read_manifest()
|
|
389
|
+
if file_path not in files:
|
|
390
|
+
return JSONResponse({"error": "file not indexed"}, status_code=404)
|
|
391
|
+
await backend.delete_by_file(file_path)
|
|
392
|
+
files.pop(file_path, None)
|
|
393
|
+
# Preserve schema fields (last_git_sha) when rewriting.
|
|
394
|
+
raw = _read_json(storage_base / "manifest.json")
|
|
395
|
+
if isinstance(raw.get("files"), dict):
|
|
396
|
+
raw["files"] = files
|
|
397
|
+
payload = raw
|
|
398
|
+
else:
|
|
399
|
+
payload = {"__schema_version": 2, "files": files, "last_git_sha": None}
|
|
400
|
+
from context_engine.utils import atomic_write_text
|
|
401
|
+
|
|
402
|
+
atomic_write_text(storage_base / "manifest.json", json.dumps(payload))
|
|
403
|
+
return {"ok": True, "deleted": file_path}
|
|
404
|
+
|
|
405
|
+
@app.post("/api/compression")
|
|
406
|
+
async def set_compression(req: CompressionRequest) -> dict:
|
|
407
|
+
state = _read_state()
|
|
408
|
+
state["output_level"] = req.level
|
|
409
|
+
(storage_base / "state.json").write_text(json.dumps(state))
|
|
410
|
+
return {"level": req.level}
|
|
411
|
+
|
|
412
|
+
@app.get("/api/export")
|
|
413
|
+
async def export_data():
|
|
414
|
+
payload = {
|
|
415
|
+
"project": project_name,
|
|
416
|
+
"stats": _read_stats(),
|
|
417
|
+
"manifest": _read_manifest(),
|
|
418
|
+
"sessions": _read_sessions(),
|
|
419
|
+
}
|
|
420
|
+
safe_name = quote(project_name, safe="")
|
|
421
|
+
return Response(
|
|
422
|
+
content=json.dumps(payload, indent=2),
|
|
423
|
+
media_type="application/json",
|
|
424
|
+
headers={
|
|
425
|
+
"Content-Disposition": f"attachment; filename={safe_name}-cce-export.json"
|
|
426
|
+
},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return app
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Multi-editor MCP configuration.
|
|
2
|
+
|
|
3
|
+
Detects installed editors and writes MCP server config in each editor's
|
|
4
|
+
format. Supports Claude Code, VS Code/Copilot, Cursor, Gemini CLI, and
|
|
5
|
+
OpenAI Codex CLI.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from context_engine.utils import atomic_write_text, resolve_cce_binary
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── Editor definitions ────────────────────────────────────────────────
|
|
16
|
+
# format: "json" (default) or "toml" for Codex
|
|
17
|
+
|
|
18
|
+
EDITORS: dict[str, dict] = {
|
|
19
|
+
"claude": {
|
|
20
|
+
"name": "Claude Code",
|
|
21
|
+
"config_path": ".mcp.json",
|
|
22
|
+
"servers_key": "mcpServers",
|
|
23
|
+
"format": "json",
|
|
24
|
+
"detect": [".mcp.json"],
|
|
25
|
+
},
|
|
26
|
+
"vscode": {
|
|
27
|
+
"name": "VS Code / Copilot",
|
|
28
|
+
"config_path": ".vscode/mcp.json",
|
|
29
|
+
"servers_key": "servers",
|
|
30
|
+
"format": "json",
|
|
31
|
+
"detect": [".vscode"],
|
|
32
|
+
},
|
|
33
|
+
"cursor": {
|
|
34
|
+
"name": "Cursor",
|
|
35
|
+
"config_path": ".cursor/mcp.json",
|
|
36
|
+
"servers_key": "mcpServers",
|
|
37
|
+
"format": "json",
|
|
38
|
+
"detect": [".cursor", ".cursorrules"],
|
|
39
|
+
},
|
|
40
|
+
"gemini": {
|
|
41
|
+
"name": "Gemini CLI",
|
|
42
|
+
"config_path": ".gemini/settings.json",
|
|
43
|
+
"servers_key": "mcpServers",
|
|
44
|
+
"format": "json",
|
|
45
|
+
"detect": [".gemini", "GEMINI.md"],
|
|
46
|
+
},
|
|
47
|
+
"codex": {
|
|
48
|
+
"name": "OpenAI Codex",
|
|
49
|
+
"config_path": ".codex/config.toml",
|
|
50
|
+
"format": "toml",
|
|
51
|
+
"detect": [".codex"],
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# ── Instruction file definitions ──────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
# Editor-agnostic CCE instructions (no "Claude Code" references)
|
|
58
|
+
_CCE_INSTRUCTIONS = """\
|
|
59
|
+
## Context Engine (CCE)
|
|
60
|
+
|
|
61
|
+
This project uses Code Context Engine for intelligent code retrieval and
|
|
62
|
+
cross-session memory.
|
|
63
|
+
|
|
64
|
+
### Searching the codebase
|
|
65
|
+
|
|
66
|
+
**Use `context_search` instead of reading files directly** when exploring
|
|
67
|
+
the codebase, answering questions about code, or understanding how things
|
|
68
|
+
work. `context_search` returns the most relevant code chunks with
|
|
69
|
+
confidence scores instead of whole files.
|
|
70
|
+
|
|
71
|
+
When to use `context_search`:
|
|
72
|
+
- Answering questions about the codebase ("how does X work?", "where is Y?")
|
|
73
|
+
- Exploring structure or architecture
|
|
74
|
+
- Finding related code, functions, or patterns
|
|
75
|
+
|
|
76
|
+
Other tools:
|
|
77
|
+
- `expand_chunk` for full source of a compressed result
|
|
78
|
+
- `related_context` for what calls/imports a function
|
|
79
|
+
- `session_recall` to recall past decisions
|
|
80
|
+
|
|
81
|
+
### Cross-session memory
|
|
82
|
+
|
|
83
|
+
Call `session_recall("topic phrase")` before answering non-trivial questions.
|
|
84
|
+
Call `record_decision(decision="...", reason="...")` after making choices.
|
|
85
|
+
Call `record_code_area(file_path="...", description="...")` after meaningful work.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
INSTRUCTION_FILES: dict[str, dict] = {
|
|
89
|
+
"cursorrules": {
|
|
90
|
+
"name": ".cursorrules",
|
|
91
|
+
"path": ".cursorrules",
|
|
92
|
+
"detect": [".cursor", ".cursorrules"],
|
|
93
|
+
},
|
|
94
|
+
"gemini": {
|
|
95
|
+
"name": "GEMINI.md",
|
|
96
|
+
"path": "GEMINI.md",
|
|
97
|
+
"detect": [".gemini", "GEMINI.md"],
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def detect_editors(project_dir: Path) -> list[str]:
|
|
105
|
+
"""Return list of editor keys detected in the project directory."""
|
|
106
|
+
found = []
|
|
107
|
+
for key, editor in EDITORS.items():
|
|
108
|
+
for marker in editor["detect"]:
|
|
109
|
+
if (project_dir / marker).exists():
|
|
110
|
+
found.append(key)
|
|
111
|
+
break
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _codex_toml_block(command: str, project_dir: str) -> str:
|
|
116
|
+
"""Generate the TOML block for Codex CLI's config.toml."""
|
|
117
|
+
args_toml = ", ".join(f'"{a}"' for a in ["serve", "--project-dir", project_dir])
|
|
118
|
+
return f'[mcp_servers.context-engine]\ncommand = "{command}"\nargs = [{args_toml}]\n'
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def configure_mcp(project_dir: Path, editor_key: str) -> bool:
|
|
122
|
+
"""Write MCP config for a specific editor. Returns True if changed."""
|
|
123
|
+
editor = EDITORS[editor_key]
|
|
124
|
+
config_path = project_dir / editor["config_path"]
|
|
125
|
+
command = resolve_cce_binary()
|
|
126
|
+
|
|
127
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
|
|
129
|
+
if editor.get("format") == "toml":
|
|
130
|
+
return _configure_toml(config_path, command, str(project_dir))
|
|
131
|
+
|
|
132
|
+
servers_key = editor["servers_key"]
|
|
133
|
+
entry = {"command": command, "args": ["serve", "--project-dir", str(project_dir)]}
|
|
134
|
+
|
|
135
|
+
if config_path.exists():
|
|
136
|
+
try:
|
|
137
|
+
data = json.loads(config_path.read_text())
|
|
138
|
+
except (json.JSONDecodeError, OSError):
|
|
139
|
+
data = {}
|
|
140
|
+
else:
|
|
141
|
+
data = {}
|
|
142
|
+
|
|
143
|
+
servers = data.setdefault(servers_key, {})
|
|
144
|
+
if "context-engine" in servers:
|
|
145
|
+
existing = servers["context-engine"]
|
|
146
|
+
if existing.get("command") == command and existing.get("args") == entry["args"]:
|
|
147
|
+
return False
|
|
148
|
+
servers["context-engine"] = entry
|
|
149
|
+
atomic_write_text(config_path, json.dumps(data, indent=2) + "\n")
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
servers["context-engine"] = entry
|
|
153
|
+
atomic_write_text(config_path, json.dumps(data, indent=2) + "\n")
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _configure_toml(config_path: Path, command: str, project_dir: str) -> bool:
|
|
158
|
+
"""Add CCE to a TOML config file (Codex). Returns True if changed."""
|
|
159
|
+
block = _codex_toml_block(command, project_dir)
|
|
160
|
+
marker = "[mcp_servers.context-engine]"
|
|
161
|
+
|
|
162
|
+
if config_path.exists():
|
|
163
|
+
content = config_path.read_text()
|
|
164
|
+
if marker in content:
|
|
165
|
+
return False # already configured
|
|
166
|
+
config_path.write_text(content.rstrip() + "\n\n" + block)
|
|
167
|
+
else:
|
|
168
|
+
config_path.write_text(block)
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def remove_mcp(project_dir: Path, editor_key: str) -> str | None:
|
|
173
|
+
"""Remove CCE from an editor's MCP config. Returns status message or None."""
|
|
174
|
+
editor = EDITORS[editor_key]
|
|
175
|
+
config_path = project_dir / editor["config_path"]
|
|
176
|
+
|
|
177
|
+
if not config_path.exists():
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
if editor.get("format") == "toml":
|
|
181
|
+
return _remove_toml(config_path, editor["config_path"])
|
|
182
|
+
|
|
183
|
+
servers_key = editor["servers_key"]
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(config_path.read_text())
|
|
186
|
+
servers = data.get(servers_key, {})
|
|
187
|
+
if "context-engine" not in servers:
|
|
188
|
+
return None
|
|
189
|
+
del servers["context-engine"]
|
|
190
|
+
if servers:
|
|
191
|
+
config_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
192
|
+
return f"Removed context-engine from {editor['config_path']}"
|
|
193
|
+
else:
|
|
194
|
+
config_path.unlink()
|
|
195
|
+
return f"Removed {editor['config_path']}"
|
|
196
|
+
except (json.JSONDecodeError, OSError):
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _remove_toml(config_path: Path, display_path: str) -> str | None:
|
|
201
|
+
"""Remove CCE block from a TOML config file."""
|
|
202
|
+
import re
|
|
203
|
+
content = config_path.read_text()
|
|
204
|
+
marker = "[mcp_servers.context-engine]"
|
|
205
|
+
if marker not in content:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
# Remove the [mcp_servers.context-engine] block (until next section header or EOF)
|
|
209
|
+
pattern = r"\[mcp_servers\.context-engine\].*?(?=\n\[|$)"
|
|
210
|
+
new_content = re.sub(pattern, "", content, flags=re.DOTALL).strip()
|
|
211
|
+
if new_content:
|
|
212
|
+
config_path.write_text(new_content + "\n")
|
|
213
|
+
return f"Removed context-engine from {display_path}"
|
|
214
|
+
else:
|
|
215
|
+
config_path.unlink()
|
|
216
|
+
return f"Removed {display_path}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def write_instruction_file(project_dir: Path, file_key: str) -> bool:
|
|
220
|
+
"""Write CCE instructions to an editor's instruction file. Returns True if written."""
|
|
221
|
+
info = INSTRUCTION_FILES[file_key]
|
|
222
|
+
path = project_dir / info["path"]
|
|
223
|
+
marker = "## Context Engine (CCE)"
|
|
224
|
+
|
|
225
|
+
if path.exists():
|
|
226
|
+
content = path.read_text()
|
|
227
|
+
if marker in content:
|
|
228
|
+
return False # already has CCE block
|
|
229
|
+
# Append
|
|
230
|
+
path.write_text(content.rstrip() + "\n\n" + _CCE_INSTRUCTIONS)
|
|
231
|
+
else:
|
|
232
|
+
path.write_text(_CCE_INSTRUCTIONS)
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def remove_instruction_file(project_dir: Path, file_key: str) -> str | None:
|
|
237
|
+
"""Remove CCE block from an editor's instruction file. Returns status or None."""
|
|
238
|
+
info = INSTRUCTION_FILES[file_key]
|
|
239
|
+
path = project_dir / info["path"]
|
|
240
|
+
marker = "## Context Engine (CCE)"
|
|
241
|
+
|
|
242
|
+
if not path.exists():
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
content = path.read_text()
|
|
246
|
+
if marker not in content:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
# Remove the CCE block
|
|
250
|
+
start = content.index(marker)
|
|
251
|
+
# Find the next ## heading or end of file
|
|
252
|
+
rest = content[start + len(marker):]
|
|
253
|
+
next_heading = rest.find("\n## ")
|
|
254
|
+
if next_heading >= 0:
|
|
255
|
+
end = start + len(marker) + next_heading
|
|
256
|
+
else:
|
|
257
|
+
end = len(content)
|
|
258
|
+
|
|
259
|
+
new_content = (content[:start] + content[end:]).strip()
|
|
260
|
+
if new_content:
|
|
261
|
+
path.write_text(new_content + "\n")
|
|
262
|
+
return f"Removed CCE block from {info['name']}"
|
|
263
|
+
else:
|
|
264
|
+
path.unlink()
|
|
265
|
+
return f"Removed {info['name']}"
|