ctxora 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +441 -0
  3. package/README.vi.md +441 -0
  4. package/bin/ctxora.mjs +147 -0
  5. package/package.json +45 -0
  6. package/pyproject.toml +59 -0
  7. package/src/chunking/compressor.py +104 -0
  8. package/src/chunking/treesitter_chunker.py +240 -0
  9. package/src/compact/anthropic.py +98 -0
  10. package/src/compact/gemini.py +88 -0
  11. package/src/compact/handoff.py +179 -0
  12. package/src/compact/openai.py +318 -0
  13. package/src/compact/summarizer.py +186 -0
  14. package/src/context/assembler.py +298 -0
  15. package/src/context/budgeting.py +137 -0
  16. package/src/context/sanitizer.py +23 -0
  17. package/src/evaluation/__init__.py +1 -0
  18. package/src/evaluation/gates.py +172 -0
  19. package/src/evaluation/metrics.py +41 -0
  20. package/src/harness_context/__init__.py +5 -0
  21. package/src/harness_context/adapters/__init__.py +1 -0
  22. package/src/harness_context/adapters/clients/__init__.py +4 -0
  23. package/src/harness_context/adapters/clients/formatters.py +47 -0
  24. package/src/harness_context/adapters/clients/profiles.py +29 -0
  25. package/src/harness_context/adapters/ecc/__init__.py +4 -0
  26. package/src/harness_context/adapters/ecc/detection.py +41 -0
  27. package/src/harness_context/adapters/ecc/mapping.py +32 -0
  28. package/src/harness_context/adapters/ecc/memory_reader.py +162 -0
  29. package/src/harness_context/adapters/ecc/provenance.py +16 -0
  30. package/src/harness_context/api/__init__.py +1 -0
  31. package/src/harness_context/api/v2/__init__.py +12 -0
  32. package/src/harness_context/api/v2/contracts.py +119 -0
  33. package/src/harness_context/api/v2/diagnostics.py +13 -0
  34. package/src/harness_context/api/v2/enums.py +17 -0
  35. package/src/harness_context/api/v2/errors.py +32 -0
  36. package/src/harness_context/api/v2/models.py +4 -0
  37. package/src/harness_context/api/v2/requests.py +17 -0
  38. package/src/harness_context/api/v2/responses.py +22 -0
  39. package/src/harness_context/application/__init__.py +3 -0
  40. package/src/harness_context/application/container.py +31 -0
  41. package/src/harness_context/application/context_service.py +51 -0
  42. package/src/harness_context/application/ecc_service.py +7 -0
  43. package/src/harness_context/application/handoff_service.py +11 -0
  44. package/src/harness_context/application/memory_service.py +9 -0
  45. package/src/harness_context/application/protocols.py +46 -0
  46. package/src/harness_context/application/refresh_service.py +25 -0
  47. package/src/harness_context/application/retrieval_service.py +22 -0
  48. package/src/harness_context/application/services.py +4 -0
  49. package/src/harness_context/application/workspace_service.py +18 -0
  50. package/src/harness_context/bootstrap.py +47 -0
  51. package/src/harness_context/branding.py +16 -0
  52. package/src/harness_context/cli/__init__.py +1 -0
  53. package/src/harness_context/cli/app.py +239 -0
  54. package/src/harness_context/cli/exit_codes.py +25 -0
  55. package/src/harness_context/domain/__init__.py +9 -0
  56. package/src/harness_context/domain/cag.py +18 -0
  57. package/src/harness_context/domain/chunking.py +17 -0
  58. package/src/harness_context/domain/planning.py +30 -0
  59. package/src/harness_context/domain/ports.py +24 -0
  60. package/src/harness_context/domain/retrieval.py +46 -0
  61. package/src/harness_context/engine.py +10 -0
  62. package/src/harness_context/free_tools.py +143 -0
  63. package/src/harness_context/infrastructure/__init__.py +10 -0
  64. package/src/harness_context/infrastructure/graph.py +26 -0
  65. package/src/harness_context/infrastructure/indexes.py +33 -0
  66. package/src/harness_context/infrastructure/local_engine.py +296 -0
  67. package/src/harness_context/infrastructure/parsing.py +38 -0
  68. package/src/harness_context/infrastructure/scanning.py +51 -0
  69. package/src/harness_context/installer/__init__.py +4 -0
  70. package/src/harness_context/installer/models.py +22 -0
  71. package/src/harness_context/installer/service.py +168 -0
  72. package/src/harness_context/mcp/__init__.py +3 -0
  73. package/src/harness_context/mcp/capabilities.py +11 -0
  74. package/src/harness_context/mcp/errors.py +8 -0
  75. package/src/harness_context/mcp/lifecycle.py +72 -0
  76. package/src/harness_context/mcp/middleware.py +57 -0
  77. package/src/harness_context/mcp/server.py +3 -0
  78. package/src/harness_context/mcp/tool_handlers/__init__.py +7 -0
  79. package/src/harness_context/mcp/tool_handlers/context.py +16 -0
  80. package/src/harness_context/mcp/tool_handlers/ecc.py +8 -0
  81. package/src/harness_context/mcp/tool_handlers/handoffs.py +20 -0
  82. package/src/harness_context/mcp/tool_handlers/memory.py +16 -0
  83. package/src/harness_context/mcp/tool_handlers/workspace.py +12 -0
  84. package/src/harness_context/mcp/tools.py +15 -0
  85. package/src/harness_context/observability/__init__.py +6 -0
  86. package/src/harness_context/observability/events.py +25 -0
  87. package/src/harness_context/observability/metrics.py +20 -0
  88. package/src/harness_context/paths.py +35 -0
  89. package/src/harness_context/runtime.py +127 -0
  90. package/src/harness_context/schemas.py +38 -0
  91. package/src/harness_context/security/__init__.py +3 -0
  92. package/src/harness_context/security/secret_patterns.py +15 -0
  93. package/src/harness_context/server.py +1077 -0
  94. package/src/harness_context/storage/__init__.py +6 -0
  95. package/src/harness_context/storage/migrations.py +24 -0
  96. package/src/harness_context/storage/pins.py +10 -0
  97. package/src/harness_context/storage/snapshots.py +149 -0
  98. package/src/harness_context/tokenize.py +12 -0
  99. package/src/harness_context/topology.py +65 -0
  100. package/src/harness_context/watcher/__init__.py +3 -0
  101. package/src/harness_context/watcher/service.py +32 -0
  102. package/src/harness_context/workspace/__init__.py +13 -0
  103. package/src/harness_context/workspace/identity.py +9 -0
  104. package/src/harness_context/workspace/lock.py +24 -0
  105. package/src/harness_context/workspace/policy.py +3 -0
  106. package/src/harness_context/workspace/roots.py +84 -0
  107. package/src/harness_context/workspace/state.py +35 -0
  108. package/src/memory/episodic.py +257 -0
  109. package/src/memory/vector_store.py +104 -0
  110. package/src/retrieval/bm25.py +23 -0
  111. package/src/retrieval/cache.py +76 -0
  112. package/src/retrieval/embeddings.py +75 -0
  113. package/src/retrieval/graph.py +45 -0
  114. package/src/retrieval/reranker.py +78 -0
  115. package/src/retrieval/tokenize.py +11 -0
@@ -0,0 +1,1077 @@
1
+ """
2
+ server.py — CTXORA MCP
3
+ ======================
4
+ Local-first context engine for coding agents.
5
+
6
+ MCP Tools exposed:
7
+ retrieve_context — hybrid retrieval (semantic + BM25 + graph)
8
+ handoff_conversation — no-compression history handoff
9
+ restore_conversation_handoff — restore a raw handoff payload
10
+ memory_save — save to episodic / semantic / procedural memory
11
+ memory_search — search across memory tiers
12
+ memory_inject — inject relevant memory into prompt
13
+ memory_delete — delete by key
14
+ memory_list — list recent entries
15
+ memory_evict — LRU eviction
16
+ memory_stats — DB statistics
17
+ estimate_tokens — token count utility
18
+ get_token_budget — model-specific budget info
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import sys
27
+ import time
28
+ from copy import deepcopy
29
+
30
+ from mcp.server.fastmcp import FastMCP
31
+
32
+ from chunking.compressor import ContextCompressor
33
+ from chunking.treesitter_chunker import ASTChunker, Chunk, count_tokens
34
+ from compact.handoff import DEFAULT_HANDOFF_THRESHOLD_TOKENS, ConversationHandoffStore
35
+ from context.assembler import ContextAssembler
36
+ from context.budgeting import get_budget
37
+ from context.sanitizer import sanitize, sanitize_chunks
38
+ from harness_context.engine import ContextEngine
39
+ from harness_context.schemas import HarnessError
40
+ from memory.episodic import EpisodicMemory, MemoryStore, ProceduralMemory, SemanticMemory
41
+ from memory.vector_store import VectorStore
42
+ from retrieval.bm25 import BM25Index
43
+ from retrieval.cache import RetrievalCache
44
+ from retrieval.embeddings import EmbeddingEngine
45
+ from retrieval.graph import DependencyGraph
46
+ from retrieval.reranker import Reranker
47
+
48
+ sys.path.insert(0, os.path.dirname(__file__))
49
+
50
+
51
+ # ── Sub-modules ─────────────────────────────────────────────────────────
52
+
53
+
54
+ # ── Logging ─────────────────────────────────────────────────────────────
55
+ # Primary CTXORA MCP log.
56
+ LOG_DIR = os.path.expanduser("~/.ctxora/logs")
57
+ LOG_FILE = os.path.join(LOG_DIR, "ctxora-mcp.log")
58
+ os.makedirs(LOG_DIR, exist_ok=True)
59
+
60
+ # Legacy symlink: keep ~/.gemini/mcp-harness-v3.log pointing to the real file
61
+ # so existing `tail -f` commands still work.
62
+ _LEGACY_DIR = os.path.expanduser("~/.gemini")
63
+ _LEGACY_LINK = os.path.join(_LEGACY_DIR, "mcp-harness-v3.log")
64
+ try:
65
+ os.makedirs(_LEGACY_DIR, exist_ok=True)
66
+ if not os.path.exists(_LEGACY_LINK):
67
+ os.symlink(LOG_FILE, _LEGACY_LINK)
68
+ except OSError:
69
+ pass # non-critical — skip if symlink can't be created
70
+
71
+ _log_handlers: list[logging.Handler] = [
72
+ logging.FileHandler(LOG_FILE, encoding="utf-8"),
73
+ ]
74
+
75
+ # If a stale legacy file already exists and is not the primary log symlink,
76
+ # write to it as well so older `tail -f ~/.gemini/...` commands keep working.
77
+ try:
78
+ if os.path.exists(_LEGACY_LINK) and not os.path.samefile(LOG_FILE, _LEGACY_LINK):
79
+ _log_handlers.append(logging.FileHandler(_LEGACY_LINK, encoding="utf-8"))
80
+ except OSError:
81
+ pass
82
+
83
+ logging.basicConfig(
84
+ level=logging.INFO,
85
+ format="[%(levelname)s] %(message)s",
86
+ handlers=_log_handlers,
87
+ )
88
+ logger = logging.getLogger("ctxora.server")
89
+
90
+ DEFAULT_MAX_PROMPT_TOKENS = 4_000
91
+ DEFAULT_INCLUDE_REPORT = False
92
+
93
+
94
+ def _coerce_target_ratio(target_ratio: float) -> float:
95
+ """Return a sane prompt target ratio in (0, 1]."""
96
+ try:
97
+ ratio = float(target_ratio)
98
+ except (TypeError, ValueError):
99
+ return 0.20
100
+ if ratio <= 0:
101
+ return 0.20
102
+ return min(ratio, 1.0)
103
+
104
+
105
+ def _prompt_token_limit(
106
+ *,
107
+ context_window: int,
108
+ inject_budget: int,
109
+ target_ratio: float,
110
+ max_prompt_tokens: int,
111
+ ) -> int:
112
+ """Resolve the global prompt cap, never exceeding the inject budget."""
113
+ if max_prompt_tokens and max_prompt_tokens > 0:
114
+ return max(1, min(int(max_prompt_tokens), inject_budget))
115
+
116
+ ratio_limit = int(context_window * _coerce_target_ratio(target_ratio))
117
+ return max(1, min(ratio_limit, inject_budget))
118
+
119
+
120
+ def _optimizer_report_comment(
121
+ *,
122
+ before_tokens: int,
123
+ after_tokens: int,
124
+ target_tokens: int,
125
+ chunks_before: int,
126
+ chunks_after: int,
127
+ memory_before: int,
128
+ memory_after: int,
129
+ status: str,
130
+ ) -> str:
131
+ saved_tokens = max(before_tokens - after_tokens, 0)
132
+ saved_percent = (
133
+ round(saved_tokens * 100 / before_tokens, 1)
134
+ if before_tokens > 0 else 0.0
135
+ )
136
+ return (
137
+ "<!-- prompt_optimizer "
138
+ f"status={status} before={before_tokens} after={after_tokens} "
139
+ f"saved={saved_tokens} saved_percent={saved_percent}% "
140
+ f"target={target_tokens} chunks={chunks_after}/{chunks_before} "
141
+ f"memory={memory_after}/{memory_before} -->"
142
+ )
143
+
144
+
145
+ def _with_optimizer_report(
146
+ prompt: str,
147
+ *,
148
+ before_tokens: int,
149
+ target_tokens: int,
150
+ chunks_before: int,
151
+ chunks_after: int,
152
+ memory_before: int,
153
+ memory_after: int,
154
+ status: str,
155
+ ) -> str:
156
+ """Prepend a compact report and make its `after` token count exact."""
157
+ provisional = _optimizer_report_comment(
158
+ before_tokens=before_tokens,
159
+ after_tokens=count_tokens(prompt),
160
+ target_tokens=target_tokens,
161
+ chunks_before=chunks_before,
162
+ chunks_after=chunks_after,
163
+ memory_before=memory_before,
164
+ memory_after=memory_after,
165
+ status=status,
166
+ )
167
+ after_tokens = count_tokens(f"{provisional}\n{prompt}")
168
+ final = _optimizer_report_comment(
169
+ before_tokens=before_tokens,
170
+ after_tokens=after_tokens,
171
+ target_tokens=target_tokens,
172
+ chunks_before=chunks_before,
173
+ chunks_after=chunks_after,
174
+ memory_before=memory_before,
175
+ memory_after=memory_after,
176
+ status=status,
177
+ )
178
+ return f"{final}\n{prompt}"
179
+
180
+
181
+ def _fit_prompt_to_limit(
182
+ *,
183
+ base_prompt: str,
184
+ chunks: list[Chunk],
185
+ memory_entries: list[dict],
186
+ query: str,
187
+ output_mode: str,
188
+ model: str,
189
+ target_tokens: int,
190
+ before_tokens: int,
191
+ include_report: bool,
192
+ base_prompt_policy: str,
193
+ ) -> tuple[str, list[Chunk], list[dict], str]:
194
+ """
195
+ Enforce the global prompt cap against the fully assembled output.
196
+
197
+ If retrieved context and memory cannot be trimmed enough because the base
198
+ prompt itself exceeds the cap, return a clear rejection unless callers opt
199
+ into unsafe base prompt truncation with base_prompt_policy="truncate".
200
+ """
201
+ selected_chunks = list(chunks)
202
+ selected_memory = list(memory_entries)
203
+ chunks_before = len(selected_chunks)
204
+ memory_before = len(selected_memory)
205
+
206
+ def render(status: str) -> tuple[str, int]:
207
+ prompt = assembler.assemble(
208
+ base_prompt=base_prompt,
209
+ chunks=selected_chunks,
210
+ memory_entries=selected_memory,
211
+ query=query,
212
+ output_mode=output_mode,
213
+ model=model,
214
+ )
215
+ if include_report:
216
+ prompt = _with_optimizer_report(
217
+ prompt,
218
+ before_tokens=before_tokens,
219
+ target_tokens=target_tokens,
220
+ chunks_before=chunks_before,
221
+ chunks_after=len(selected_chunks),
222
+ memory_before=memory_before,
223
+ memory_after=len(selected_memory),
224
+ status=status,
225
+ )
226
+ return prompt, count_tokens(prompt)
227
+
228
+ did_trim = False
229
+ prompt, tokens = render("ok")
230
+ while tokens > target_tokens and (selected_chunks or selected_memory):
231
+ did_trim = True
232
+ if selected_chunks:
233
+ selected_chunks.pop()
234
+ elif selected_memory:
235
+ selected_memory.pop()
236
+ prompt, tokens = render("trimmed")
237
+
238
+ if tokens <= target_tokens:
239
+ return prompt, selected_chunks, selected_memory, "trimmed" if did_trim else "ok"
240
+
241
+ if base_prompt_policy.lower() == "truncate":
242
+ return _fit_with_truncated_base_prompt(
243
+ base_prompt=base_prompt,
244
+ chunks=[],
245
+ memory_entries=[],
246
+ query=query,
247
+ output_mode=output_mode,
248
+ model=model,
249
+ target_tokens=target_tokens,
250
+ before_tokens=before_tokens,
251
+ include_report=include_report,
252
+ chunks_before=chunks_before,
253
+ memory_before=memory_before,
254
+ )
255
+
256
+ base_tokens = count_tokens(base_prompt)
257
+ rejection = (
258
+ "Error: prompt_optimizer rejected output because the base prompt alone "
259
+ "does not fit the global prompt target.\n"
260
+ f"base_prompt_tokens={base_tokens:,}; target_tokens={target_tokens:,}; "
261
+ "set a higher max_prompt_tokens/target_ratio, reduce base_prompt, or "
262
+ "use base_prompt_policy='truncate' if lossy truncation is acceptable."
263
+ )
264
+ if include_report:
265
+ rejection = _with_optimizer_report(
266
+ rejection,
267
+ before_tokens=before_tokens,
268
+ target_tokens=target_tokens,
269
+ chunks_before=chunks_before,
270
+ chunks_after=0,
271
+ memory_before=memory_before,
272
+ memory_after=0,
273
+ status="rejected_base_prompt",
274
+ )
275
+ return rejection, [], [], "rejected_base_prompt"
276
+
277
+
278
+ def _fit_with_truncated_base_prompt(
279
+ *,
280
+ base_prompt: str,
281
+ chunks: list[Chunk],
282
+ memory_entries: list[dict],
283
+ query: str,
284
+ output_mode: str,
285
+ model: str,
286
+ target_tokens: int,
287
+ before_tokens: int,
288
+ include_report: bool,
289
+ chunks_before: int,
290
+ memory_before: int,
291
+ ) -> tuple[str, list[Chunk], list[dict], str]:
292
+ """Last-resort lossy base prompt truncation."""
293
+ marker = "\n\n[base_prompt truncated by prompt_optimizer]\n"
294
+ available = max(1, target_tokens - count_tokens(query) - 120)
295
+ while available > 0:
296
+ truncated_base = _truncate_to_tokens(base_prompt, available) + marker
297
+ prompt = assembler.assemble(
298
+ base_prompt=truncated_base,
299
+ chunks=chunks,
300
+ memory_entries=memory_entries,
301
+ query=query,
302
+ output_mode=output_mode,
303
+ model=model,
304
+ )
305
+ if include_report:
306
+ prompt = _with_optimizer_report(
307
+ prompt,
308
+ before_tokens=before_tokens,
309
+ target_tokens=target_tokens,
310
+ chunks_before=chunks_before,
311
+ chunks_after=0,
312
+ memory_before=memory_before,
313
+ memory_after=0,
314
+ status="truncated_base_prompt",
315
+ )
316
+ if count_tokens(prompt) <= target_tokens:
317
+ return prompt, [], [], "truncated_base_prompt"
318
+ available = int(available * 0.8)
319
+
320
+ rejection = (
321
+ "Error: prompt_optimizer could not fit even the truncated base prompt "
322
+ f"under target_tokens={target_tokens:,}."
323
+ )
324
+ return rejection, [], [], "rejected_base_prompt"
325
+
326
+
327
+ def _truncate_to_tokens(text: str, max_tokens: int) -> str:
328
+ """Token-aware truncation with a character fallback."""
329
+ if count_tokens(text) <= max_tokens:
330
+ return text
331
+ try:
332
+ import tiktoken as _tiktoken
333
+ enc = _tiktoken.get_encoding("cl100k_base")
334
+ return enc.decode(enc.encode(text, disallowed_special=())[:max_tokens])
335
+ except Exception:
336
+ return text[:max_tokens * 3]
337
+
338
+
339
+ # ── Singletons ──────────────────────────────────────────────────────────
340
+ mcp = FastMCP("CTXORA MCP")
341
+ embedder = EmbeddingEngine()
342
+ bm25 = BM25Index()
343
+ reranker = Reranker()
344
+ graph = DependencyGraph()
345
+ cache = RetrievalCache(ttl=600)
346
+ chunker = ASTChunker()
347
+ compressor = ContextCompressor()
348
+ handoff_store = ConversationHandoffStore()
349
+ vec_store = VectorStore()
350
+ assembler = ContextAssembler()
351
+ mem_store = MemoryStore.instance()
352
+ episodic = EpisodicMemory(mem_store)
353
+ semantic = SemanticMemory(mem_store)
354
+ procedural = ProceduralMemory(mem_store)
355
+ context_engine = ContextEngine()
356
+
357
+ # ── In-memory chunk index ───────────────────────────────────────────────
358
+ _indexed_paths: set[str] = set()
359
+ _indexed_fingerprints: dict[str, str] = {}
360
+ _all_chunks: list[Chunk] = []
361
+
362
+
363
+ def _normalise_path(path: str) -> str:
364
+ return os.path.abspath(path)
365
+
366
+
367
+ def _chunk_belongs_to_path(chunk: Chunk, source_path: str) -> bool:
368
+ """Return whether a chunk must be replaced when source_path is reindexed."""
369
+ chunk_path = _normalise_path(chunk.path)
370
+ root = _normalise_path(source_path)
371
+ if os.path.isdir(root) or any(
372
+ _normalise_path(existing.path).startswith(f"{root}{os.sep}")
373
+ for existing in _all_chunks
374
+ ):
375
+ try:
376
+ return os.path.commonpath([chunk_path, root]) == root
377
+ except ValueError:
378
+ return False
379
+ return chunk_path == root
380
+
381
+
382
+ def _chunks_for_paths(chunks: list[Chunk], paths: list[str]) -> list[Chunk]:
383
+ """Keep compatibility retrieval scoped to the current request's paths."""
384
+ return [chunk for chunk in chunks if any(_chunk_belongs_to_path(chunk, path) for path in paths)]
385
+
386
+
387
+ def _rebuild_indexes() -> None:
388
+ """Rebuild every derived index from the canonical chunk list atomically."""
389
+ texts = [chunk.content[:512] for chunk in _all_chunks]
390
+ vectors = embedder.rebuild(texts)
391
+ for chunk, vector in zip(_all_chunks, vectors):
392
+ chunk.embedding = vector
393
+ vec_store.rebuild(_all_chunks)
394
+ bm25.build(_all_chunks)
395
+ graph.build(_all_chunks)
396
+
397
+
398
+ def _ensure_indexed(
399
+ paths: list[str],
400
+ force_reindex: bool = False) -> list[Chunk]:
401
+ """
402
+ Build/update the chunk index for the given paths.
403
+ Skips paths already indexed unless force_reindex=True.
404
+ """
405
+ new_paths = []
406
+ for path in paths:
407
+ normalised = _normalise_path(path)
408
+ fingerprint = cache.path_fingerprint(path)
409
+ if (
410
+ force_reindex
411
+ or normalised not in _indexed_paths
412
+ or _indexed_fingerprints.get(normalised) != fingerprint
413
+ ):
414
+ new_paths.append(path)
415
+ if not new_paths and _all_chunks:
416
+ return _all_chunks
417
+
418
+ t0 = time.time()
419
+ chunks = chunker.chunk_paths(new_paths)
420
+ sanitize_chunks(chunks)
421
+
422
+ # Remove stale chunks before adding replacements. This handles changed and
423
+ # deleted files without accumulating duplicate UUID-based chunk records.
424
+ _all_chunks[:] = [
425
+ chunk for chunk in _all_chunks
426
+ if not any(_chunk_belongs_to_path(chunk, path) for path in new_paths)
427
+ ]
428
+ _all_chunks.extend(chunks)
429
+ _rebuild_indexes()
430
+
431
+ for path in new_paths:
432
+ normalised = _normalise_path(path)
433
+ _indexed_paths.add(normalised)
434
+ _indexed_fingerprints[normalised] = cache.path_fingerprint(path)
435
+ cache.invalidate_all()
436
+ logger.info(
437
+ f"[Server] refreshed {len(chunks)} chunks in "
438
+ f"{time.time() - t0:.1f}s | total={len(_all_chunks)}")
439
+ return _all_chunks
440
+
441
+
442
+ # ══════════════════════════════════════════════════════════════════════════════
443
+ # MCP TOOLS
444
+ # ══════════════════════════════════════════════════════════════════════════════
445
+
446
+ @mcp.tool(name="retrieve_context_legacy")
447
+ def retrieve_context_legacy(
448
+ base_prompt: str,
449
+ paths: list[str],
450
+ query: str,
451
+ model: str = "claude-sonnet-4",
452
+ output_mode: str = "concise",
453
+ retrieve_top_n: int = 50,
454
+ rerank_top_k: int = 12,
455
+ memory_top_k: int = 4,
456
+ min_memory_sim: float = 0.18,
457
+ graph_expand: bool = True,
458
+ compress: bool = True,
459
+ force_reindex: bool = False,
460
+ target_ratio: float = 0.20,
461
+ max_prompt_tokens: int = DEFAULT_MAX_PROMPT_TOKENS,
462
+ include_report: bool = DEFAULT_INCLUDE_REPORT,
463
+ base_prompt_policy: str = "reject",
464
+ ) -> str:
465
+ """
466
+ DEPRECATED compatibility tool — assembled prompt retrieval pipeline.
467
+
468
+ Pipeline: index → hybrid score → rerank → graph expand → compress → assemble
469
+ → enforce global prompt budget.
470
+
471
+ :param base_prompt: System prompt to build on.
472
+ :param paths: File/folder paths to index and retrieve from.
473
+ :param query: Current user task or question.
474
+ :param model: Target model (affects token budget + output format).
475
+ :param output_mode: "concise" | "structured" | "code_only" | "minimal"
476
+ :param retrieve_top_n: Candidate pool before reranking (default 50).
477
+ :param rerank_top_k: Final chunks after reranking (default 12).
478
+ :param memory_top_k: Memory entries to inject (default 4).
479
+ :param min_memory_sim: Minimum similarity for memory injection.
480
+ :param graph_expand: Expand retrieval via dependency graph.
481
+ :param compress: Compress long chunks before injecting.
482
+ :param force_reindex: Force re-chunking even for already-indexed paths.
483
+ :param target_ratio: Max share of model context window for final prompt.
484
+ Ignored when max_prompt_tokens > 0.
485
+ :param max_prompt_tokens: Absolute final prompt cap. Defaults to 4,000;
486
+ pass 0 to use target_ratio instead.
487
+ :param include_report: Prepend token telemetry only when explicitly enabled.
488
+ :param base_prompt_policy: "reject" or "truncate" if base_prompt alone
489
+ exceeds the global cap.
490
+ """
491
+ try:
492
+ budget = get_budget(model)
493
+ target_tokens = _prompt_token_limit(
494
+ context_window=budget.context_window,
495
+ inject_budget=budget.inject_budget,
496
+ target_ratio=target_ratio,
497
+ max_prompt_tokens=max_prompt_tokens,
498
+ )
499
+ logger.info(
500
+ f"model={model!r} budget={budget.context_window:,} "
501
+ f"inject={budget.inject_budget:,} target={target_tokens:,}")
502
+ logger.info(f"retrieve_context | query={query[:100]!r} | paths={paths}")
503
+
504
+ # ── 1. Refresh the canonical index before looking up cached selections.
505
+ # A source fingerprint change invalidates cache and refreshes its chunks.
506
+ all_chunks = _chunks_for_paths(_ensure_indexed(paths, force_reindex), paths)
507
+ cache_options = {
508
+ "retrieve_top_n": retrieve_top_n,
509
+ "rerank_top_k": rerank_top_k,
510
+ "graph_expand": graph_expand,
511
+ }
512
+ cached = None if force_reindex else cache.get(query, paths, cache_options)
513
+ if cached:
514
+ logger.info(f" [Cache HIT] {len(cached)} chunks")
515
+ final_chunks = cached
516
+ else:
517
+ # ── 2. Retrieve from the fresh index ──────────────────────────────
518
+ if not all_chunks:
519
+ logger.info(" No chunks indexed from provided paths")
520
+ final_chunks = []
521
+ else:
522
+ # ── 3. Hybrid scoring ────────────────────────────────────────
523
+ query_vec = embedder.embed_text(query)
524
+ vec_results = vec_store.search(query_vec, top_k=max(retrieve_top_n, len(all_chunks)))
525
+ bm25_scores = bm25.score(query)
526
+
527
+ # Build chunk lookup by id
528
+ chunk_by_id = {c.id: c for c in all_chunks}
529
+ bm25_by_idx = {c.id: s for c, s in zip(bm25.chunks, bm25_scores)}
530
+
531
+ requested_ids = {chunk.id for chunk in all_chunks}
532
+ candidates_by_id: dict[str, Chunk] = {}
533
+ for r in vec_results:
534
+ c = chunk_by_id.get(r["id"])
535
+ if c is None or c.id not in requested_ids:
536
+ continue
537
+
538
+ sem_score = float(r.get("score", 0))
539
+ bm25_score = bm25_by_idx.get(c.id, 0.0)
540
+ rec_score = 0.0 # recency not tracked per chunk
541
+ prio_score = c.priority / 10.0
542
+ grph_score = graph.symbol_score(query, c)
543
+
544
+ final = (
545
+ sem_score * 0.55 +
546
+ bm25_score * 0.20 +
547
+ rec_score * 0.10 +
548
+ prio_score * 0.10 +
549
+ grph_score * 0.05
550
+ )
551
+ c._score = final # type: ignore[attr-defined]
552
+ candidates_by_id[c.id] = c
553
+
554
+ # Keep a lexical-only candidate from being excluded by a
555
+ # semantic pre-filter. Both pools are fused before reranking.
556
+ lexical_order = sorted(
557
+ (chunk for chunk in bm25.chunks if chunk.id in requested_ids),
558
+ key=lambda chunk: bm25_by_idx.get(chunk.id, 0.0),
559
+ reverse=True,
560
+ )[:retrieve_top_n]
561
+ for rank, c in enumerate(lexical_order, 1):
562
+ if c.id not in candidates_by_id:
563
+ c._score = bm25_by_idx.get(c.id, 0.0) / (60 + rank) # type: ignore[attr-defined]
564
+ candidates_by_id[c.id] = c
565
+
566
+ candidates = list(candidates_by_id.values())
567
+
568
+ candidates.sort(
569
+ key=lambda x: getattr(
570
+ x, "_score", 0), reverse=True)
571
+
572
+ # ── 4. Rerank top-N ──────────────────────────────────────────
573
+ reranked = reranker.rerank(
574
+ query, candidates[:retrieve_top_n], top_k=rerank_top_k)
575
+
576
+ # ── 5. Graph expansion ───────────────────────────────────────
577
+ if graph_expand:
578
+ extras = graph.expand(reranked, max_extra=4)
579
+ for e in extras:
580
+ if e.id not in {c.id for c in reranked}:
581
+ e._score = 0.3 # type: ignore[attr-defined]
582
+ reranked.append(e)
583
+
584
+ # Cache raw copies. Compression below is presentation-specific
585
+ # and must never mutate the source index or a cache entry.
586
+ cache.set(query, paths, reranked, cache_options)
587
+ final_chunks = deepcopy(reranked)
588
+
589
+ # ── 6. Compress candidates (before budget enforcement) ───────────────
590
+ # Compress first so budget is enforced on ACTUAL output token counts,
591
+ # not inflated pre-compression counts.
592
+ if compress:
593
+ compressed_strings = compressor.compress(final_chunks, model=model)
594
+ for c, cs in zip(final_chunks, compressed_strings):
595
+ c.content = cs
596
+ c.tokens = count_tokens(cs) # re-count post-compression
597
+
598
+ # ── 7. Memory retrieval ──────────────────────────────────────────────
599
+ memory_entries = mem_store.search(
600
+ query, top_k=memory_top_k, min_sim=min_memory_sim)
601
+ logger.info(f" {len(memory_entries)} memory entries injected")
602
+
603
+ # ── 8. Assemble once without trimming for before/after telemetry ─────
604
+ before_prompt = assembler.assemble(
605
+ base_prompt=base_prompt,
606
+ chunks=final_chunks,
607
+ memory_entries=memory_entries,
608
+ query=query,
609
+ output_mode=output_mode,
610
+ model=model,
611
+ )
612
+ before_tokens = count_tokens(before_prompt)
613
+
614
+ # ── 9. Enforce global prompt budget on the actual assembled output ───
615
+ result, selected_chunks, selected_memory, status = _fit_prompt_to_limit(
616
+ base_prompt=base_prompt,
617
+ chunks=final_chunks,
618
+ memory_entries=memory_entries,
619
+ query=query,
620
+ output_mode=output_mode,
621
+ model=model,
622
+ target_tokens=target_tokens,
623
+ before_tokens=before_tokens,
624
+ include_report=include_report,
625
+ base_prompt_policy=base_prompt_policy,
626
+ )
627
+ after_tokens = count_tokens(result)
628
+ saved_tokens = max(before_tokens - after_tokens, 0)
629
+ saved_percent = (
630
+ round(saved_tokens * 100 / before_tokens, 1)
631
+ if before_tokens > 0 else 0.0
632
+ )
633
+ logger.info(
634
+ " prompt_optimizer | "
635
+ f"status={status} before={before_tokens:,} after={after_tokens:,} "
636
+ f"saved={saved_tokens:,} saved_percent={saved_percent}% "
637
+ f"target={target_tokens:,} chunks={len(selected_chunks)}/{len(final_chunks)} "
638
+ f"memory={len(selected_memory)}/{len(memory_entries)}"
639
+ )
640
+ return result
641
+
642
+ except Exception as e:
643
+ logger.error(f" ❌ {e}", exc_info=True)
644
+ return f"Error in retrieve_context: {e}"
645
+
646
+
647
+ @mcp.tool()
648
+ def handoff_conversation(
649
+ messages_json: str,
650
+ threshold_tokens: int = DEFAULT_HANDOFF_THRESHOLD_TOKENS,
651
+ label: str = "",
652
+ workspace_id: str = "legacy_global",
653
+ retention_seconds: int = 604_800,
654
+ consent: bool = True,
655
+ ) -> str:
656
+ """
657
+ Prepare an automatic no-compression handoff when history is too long.
658
+
659
+ The original provider-format JSON is stored unchanged only after the token
660
+ threshold is reached. The MCP client creates the fresh task and can call
661
+ restore_conversation_handoff with the returned ID when it needs history.
662
+
663
+ :param messages_json: Original OpenAI, Anthropic, or Gemini message array.
664
+ :param threshold_tokens: Handoff threshold; defaults to the 30,000-token
665
+ project session budget.
666
+ :param label: Optional client-visible label for the handoff record.
667
+ """
668
+ try:
669
+ result = handoff_store.prepare(messages_json, threshold_tokens, label, workspace_id, retention_seconds, consent)
670
+ logger.info(
671
+ "handoff_conversation | "
672
+ f"action={result['action']} tokens={result['history_tokens']:,} "
673
+ f"threshold={result['threshold_tokens']:,}"
674
+ )
675
+ return json.dumps(result, ensure_ascii=False)
676
+ except Exception as e:
677
+ logger.error(f" ❌ {e}")
678
+ return f"Error: {e}"
679
+
680
+
681
+ def _v2_result(operation):
682
+ try:
683
+ return operation()
684
+ except HarnessError as error:
685
+ raise ValueError(json.dumps({"code": error.code, "message": str(error)})) from error
686
+
687
+
688
+ @mcp.tool()
689
+ def register_workspace(
690
+ workspace_id: str,
691
+ roots: list[str],
692
+ max_files: int = 10_000,
693
+ max_file_bytes: int = 2_000_000,
694
+ max_total_bytes: int = 200_000_000,
695
+ ) -> dict:
696
+ """Register canonical allowed roots and indexing limits."""
697
+ return _v2_result(lambda: context_engine.register_workspace(
698
+ workspace_id, roots, max_files=max_files, max_file_bytes=max_file_bytes,
699
+ max_total_bytes=max_total_bytes,
700
+ ))
701
+
702
+
703
+ @mcp.tool()
704
+ def refresh_workspace(workspace_id: str, paths: list[str] | None = None) -> dict:
705
+ """Incrementally refresh authorized chunks, embeddings and code graph."""
706
+ return _v2_result(lambda: context_engine.refresh_workspace(workspace_id, paths))
707
+
708
+
709
+ @mcp.tool()
710
+ def plan_context(
711
+ workspace_id: str,
712
+ query: str,
713
+ available_input_tokens: int,
714
+ strategy_override: str = "",
715
+ ) -> dict:
716
+ """Choose an explainable, overridable context strategy."""
717
+ return _v2_result(lambda: context_engine.plan_context(
718
+ workspace_id, query, available_input_tokens, strategy_override,
719
+ ))
720
+
721
+
722
+ @mcp.tool()
723
+ def retrieve_context(
724
+ workspace_id: str,
725
+ query: str,
726
+ top_k: int = 12,
727
+ graph_expand: bool = True,
728
+ token_budget: int = 4_000,
729
+ ) -> dict:
730
+ """Return structured provider-neutral evidence and coverage diagnostics."""
731
+ return _v2_result(lambda: context_engine.retrieve_context(
732
+ workspace_id, query, top_k, graph_expand, token_budget,
733
+ ))
734
+
735
+
736
+ @mcp.tool()
737
+ def prepare_context(
738
+ workspace_id: str,
739
+ query: str,
740
+ available_input_tokens: int,
741
+ strategy_override: str = "",
742
+ ) -> dict:
743
+ """Execute the selected CAG, RAG, long-context or graph strategy."""
744
+ return _v2_result(lambda: context_engine.prepare_context(
745
+ workspace_id, query, available_input_tokens, strategy_override,
746
+ ))
747
+
748
+
749
+ @mcp.tool()
750
+ def context_stats(workspace_id: str) -> dict:
751
+ """Return workspace-scoped index, graph, bundle and token statistics."""
752
+ return _v2_result(lambda: context_engine.stats(workspace_id))
753
+
754
+
755
+ @mcp.tool()
756
+ def invalidate_context(workspace_id: str, target: str = "all") -> dict:
757
+ """Invalidate one workspace index or bundle store."""
758
+ return _v2_result(lambda: context_engine.invalidate(workspace_id, target))
759
+
760
+
761
+ @mcp.tool()
762
+ def restore_conversation_handoff(handoff_id: str, workspace_id: str = "legacy_global") -> str:
763
+ """Restore an explicitly requested, unmodified conversation handoff."""
764
+ try:
765
+ result = handoff_store.restore(handoff_id, workspace_id)
766
+ if result is None:
767
+ return f"Error: handoff not found: {handoff_id}"
768
+ return json.dumps(result, ensure_ascii=False)
769
+ except Exception as e:
770
+ logger.error(f" ❌ {e}")
771
+ return f"Error: {e}"
772
+
773
+ @mcp.tool()
774
+ def list_conversation_handoffs(workspace_id: str = "legacy_global", limit: int = 30) -> str:
775
+ """List auditable handoffs for one workspace."""
776
+ return json.dumps(handoff_store.list(workspace_id, limit), ensure_ascii=False)
777
+
778
+ @mcp.tool()
779
+ def delete_conversation_handoff(handoff_id: str, workspace_id: str = "legacy_global") -> str:
780
+ """Delete one handoff without affecting other workspaces."""
781
+ return json.dumps({"deleted": handoff_store.delete(handoff_id, workspace_id), "handoff_id": handoff_id})
782
+
783
+ @mcp.tool()
784
+ def purge_expired_handoffs() -> str:
785
+ """Purge handoffs past their configured retention window."""
786
+ return json.dumps({"purged": handoff_store.purge_expired()})
787
+
788
+
789
+ # ── Memory tools ────────────────────────────────────────────────────────
790
+
791
+ @mcp.tool()
792
+ def memory_save(
793
+ key: str,
794
+ value: str,
795
+ mtype: str = "semantic",
796
+ tags: str = "",
797
+ workspace_id: str = "legacy_global",
798
+ scope: str = "workspace",
799
+ source: str = "user",
800
+ confidence: float = 1.0,
801
+ expires_at: float | None = None,
802
+ ) -> str:
803
+ """
804
+ Save knowledge to long-term memory.
805
+
806
+ :param key: Short identifying key (used for search).
807
+ :param value: Content to remember.
808
+ :param mtype: Memory tier — "episodic" | "semantic" | "procedural"
809
+ :param tags: Comma-separated labels for filtering.
810
+ """
811
+ try:
812
+ value = sanitize(value, escape_xml=False)
813
+ from memory.episodic import MemoryType
814
+ result = mem_store.save(
815
+ MemoryType(mtype), key, value, tags, workspace_id=workspace_id,
816
+ scope=scope, source=source, confidence=confidence, expires_at=expires_at,
817
+ )
818
+ return json.dumps(result, ensure_ascii=False)
819
+ except Exception as e:
820
+ return f"Error: {e}"
821
+
822
+
823
+ @mcp.tool()
824
+ def memory_search(
825
+ query: str,
826
+ mtype: str = "",
827
+ top_k: int = 5,
828
+ min_sim: float = 0.12,
829
+ workspace_id: str = "legacy_global",
830
+ ) -> str:
831
+ """
832
+ Search long-term memory by semantic similarity.
833
+
834
+ :param query: Search query.
835
+ :param mtype: Filter by tier: "episodic" | "semantic" | "procedural" | "" (all)
836
+ :param top_k: Max results.
837
+ :param min_sim: Minimum similarity threshold (0–1).
838
+ """
839
+ try:
840
+ from memory.episodic import MemoryType
841
+ mt = None
842
+ if mtype:
843
+ try:
844
+ mt = MemoryType(mtype)
845
+ except ValueError as error:
846
+ raise ValueError(f"invalid memory type: {mtype}") from error
847
+ results = mem_store.search(
848
+ query, mtype=mt, top_k=top_k, min_sim=min_sim,
849
+ workspace_id=workspace_id)
850
+ return json.dumps(results, ensure_ascii=False)
851
+ except Exception as e:
852
+ return f"Error: {e}"
853
+
854
+
855
+ @mcp.tool()
856
+ def memory_inject(
857
+ base_prompt: str,
858
+ query: str,
859
+ top_k: int = 4,
860
+ min_sim: float = 0.18,
861
+ output_mode: str = "concise",
862
+ model: str = "claude-sonnet-4",
863
+ target_ratio: float = 0.20,
864
+ max_prompt_tokens: int = DEFAULT_MAX_PROMPT_TOKENS,
865
+ include_report: bool = DEFAULT_INCLUDE_REPORT,
866
+ base_prompt_policy: str = "reject",
867
+ workspace_id: str = "legacy_global",
868
+ ) -> str:
869
+ """
870
+ Inject relevant memory entries into a system prompt (without file retrieval).
871
+ Use when you only need memory context, not workspace files.
872
+
873
+ :param base_prompt: System prompt.
874
+ :param query: Current task.
875
+ :param top_k: Max memory entries.
876
+ :param min_sim: Minimum similarity.
877
+ :param output_mode: "concise" | "structured" | "code_only" | "minimal"
878
+ :param model: Target model for format selection.
879
+ :param target_ratio: Max share of model context window for final prompt.
880
+ :param max_prompt_tokens: Absolute final prompt cap. Defaults to 4,000;
881
+ pass 0 to use target_ratio instead.
882
+ :param include_report: Prepend token telemetry only when explicitly enabled.
883
+ :param base_prompt_policy: "reject" or "truncate" for oversized base_prompt.
884
+ """
885
+ try:
886
+ entries = mem_store.search(query, top_k=top_k, min_sim=min_sim, workspace_id=workspace_id)
887
+ budget = get_budget(model)
888
+ target_tokens = _prompt_token_limit(
889
+ context_window=budget.context_window,
890
+ inject_budget=budget.inject_budget,
891
+ target_ratio=target_ratio,
892
+ max_prompt_tokens=max_prompt_tokens,
893
+ )
894
+ before_prompt = assembler.assemble(
895
+ base_prompt=base_prompt, chunks=[], memory_entries=entries,
896
+ query=query, output_mode=output_mode, model=model,
897
+ )
898
+ result, _, _, status = _fit_prompt_to_limit(
899
+ base_prompt=base_prompt,
900
+ chunks=[],
901
+ memory_entries=entries,
902
+ query=query,
903
+ output_mode=output_mode,
904
+ model=model,
905
+ target_tokens=target_tokens,
906
+ before_tokens=count_tokens(before_prompt),
907
+ include_report=include_report,
908
+ base_prompt_policy=base_prompt_policy,
909
+ )
910
+ logger.info(
911
+ " memory_inject optimizer | "
912
+ f"status={status} after={count_tokens(result):,} "
913
+ f"target={target_tokens:,}"
914
+ )
915
+ return result
916
+ except Exception as e:
917
+ return f"Error: {e}"
918
+
919
+
920
+ @mcp.tool()
921
+ def memory_delete(key: str, mtype: str = "semantic", workspace_id: str = "legacy_global") -> str:
922
+ """Delete a memory entry by exact key and type."""
923
+ try:
924
+ from memory.episodic import MemoryType
925
+ mt = MemoryType(mtype)
926
+ deleted = mem_store.delete(mt, key, workspace_id=workspace_id)
927
+ return json.dumps({"deleted": deleted, "key": key, "mtype": mtype})
928
+ except Exception as e:
929
+ return f"Error: {e}"
930
+
931
+
932
+ @mcp.tool()
933
+ def memory_list(mtype: str = "", limit: int = 30, workspace_id: str = "legacy_global") -> str:
934
+ """List recent memory entries, optionally filtered by type."""
935
+ try:
936
+ from memory.episodic import MemoryType
937
+ mt = MemoryType(mtype) if mtype else None
938
+ return json.dumps(mem_store.list_keys(mt, limit, workspace_id), ensure_ascii=False)
939
+ except Exception as e:
940
+ return f"Error: {e}"
941
+
942
+
943
+ @mcp.tool()
944
+ def memory_evict(keep_top: int = 300) -> str:
945
+ """Evict least-recently-used memory entries to free space."""
946
+ try:
947
+ before = mem_store.stats()
948
+ removed = mem_store.evict_lru(keep_top)
949
+ after = mem_store.stats()
950
+ return json.dumps(
951
+ {"removed": removed, "before": before, "after": after})
952
+ except Exception as e:
953
+ return f"Error: {e}"
954
+
955
+
956
+ @mcp.tool()
957
+ def memory_stats() -> str:
958
+ """Return statistics per memory tier (count, tokens, hits)."""
959
+ try:
960
+ return json.dumps(mem_store.stats(), ensure_ascii=False)
961
+ except Exception as e:
962
+ return f"Error: {e}"
963
+
964
+
965
+ @mcp.tool()
966
+ def estimate_tokens(text: str) -> str:
967
+ """
968
+ Estimate token count for any text.
969
+ Useful for checking prompt size before sending to model.
970
+ """
971
+ tok = count_tokens(text)
972
+ chars = len(text)
973
+ # Representative models across all supported providers
974
+ _benchmark_models = [
975
+ # Claude
976
+ "claude-sonnet-4",
977
+ "claude-opus-4",
978
+ # Gemini
979
+ "gemini-2.5-pro",
980
+ # OpenAI GPT-5
981
+ "gpt-5.5",
982
+ "gpt-5.4",
983
+ "gpt-5",
984
+ # OpenAI Chat
985
+ "gpt-4o",
986
+ "gpt-4.1",
987
+ "gpt-4o-mini",
988
+ # OpenAI Reasoning
989
+ "o3",
990
+ "o1",
991
+ "o4-mini",
992
+ # OpenAI Codex
993
+ "codex-mini-latest",
994
+ ]
995
+ budgets = {
996
+ m: get_budget(m).inject_budget
997
+ for m in _benchmark_models
998
+ }
999
+ return json.dumps({
1000
+ "tokens": tok,
1001
+ "chars": chars,
1002
+ "ratio_chars_per_tok": round(chars / tok, 2) if tok > 0 else 0,
1003
+ "fits_in": {
1004
+ m: tok <= b
1005
+ for m, b in budgets.items()
1006
+ },
1007
+ }, ensure_ascii=False)
1008
+
1009
+
1010
+ @mcp.tool()
1011
+ def get_token_budget(model: str = "claude-sonnet-4") -> str:
1012
+ """
1013
+ Return the dynamic token budget for a specific model.
1014
+ Shows context window, reserved headroom, and available inject budget.
1015
+ """
1016
+ try:
1017
+ b = get_budget(model)
1018
+ return json.dumps({
1019
+ "model": b.model_name,
1020
+ "context_window": b.context_window,
1021
+ "reserved_output": b.reserved_output,
1022
+ "reserved_reasoning": b.reserved_reasoning,
1023
+ "reserved_tools": b.reserved_tools,
1024
+ "inject_budget": b.inject_budget,
1025
+ }, ensure_ascii=False)
1026
+ except Exception as e:
1027
+ return f"Error: {e}"
1028
+
1029
+
1030
+ @mcp.tool()
1031
+ def invalidate_cache() -> str:
1032
+ """Clear the retrieval cache. Call when workspace files have changed."""
1033
+ cache.invalidate_all()
1034
+ return json.dumps({"status": "cache cleared"})
1035
+
1036
+
1037
+ @mcp.tool()
1038
+ def reindex_paths(paths: list[str]) -> str:
1039
+ """
1040
+ Force re-chunking and re-indexing of given paths.
1041
+ Use after significant file edits to refresh the vector index.
1042
+ """
1043
+ logger.info(f"[Server] force reindex: {paths}")
1044
+ try:
1045
+ chunks = _ensure_indexed(paths, force_reindex=True)
1046
+ return json.dumps({"status": "reindexed", "total_chunks": len(chunks)})
1047
+ except Exception as e:
1048
+ return f"Error: {e}"
1049
+
1050
+
1051
+ # ── Entrypoint ──────────────────────────────────────────────────────────
1052
+
1053
+ def main() -> None:
1054
+ logger.info("═" * 70)
1055
+ logger.info("CTXORA MCP starting")
1056
+ logger.info(f" Log (primary) : {LOG_FILE}")
1057
+ logger.info(f" Log (legacy) : {_LEGACY_LINK} → symlink")
1058
+ logger.info(f" tail -f {LOG_FILE}")
1059
+ logger.info(" Tools : retrieve_context · handoff_conversation")
1060
+ logger.info(" restore_conversation_handoff")
1061
+ logger.info(" memory_save · memory_search · memory_inject")
1062
+ logger.info(
1063
+ " memory_delete · memory_list · memory_evict · memory_stats")
1064
+ logger.info(" estimate_tokens · get_token_budget")
1065
+ logger.info(" invalidate_cache · reindex_paths")
1066
+ logger.info(
1067
+ " Models : Claude · Gemini · GPT-4o/4.1/4.5 · o1/o3/o4 · Codex")
1068
+ logger.info(
1069
+ " Stack : numpy vector store + BM25 + AST chunker")
1070
+ logger.info(
1071
+ " local reranker + dependency graph + safety layer")
1072
+ logger.info("═" * 70)
1073
+ mcp.run()
1074
+
1075
+
1076
+ if __name__ == "__main__":
1077
+ main()