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.
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/README.vi.md +441 -0
- package/bin/ctxora.mjs +147 -0
- package/package.json +45 -0
- package/pyproject.toml +59 -0
- package/src/chunking/compressor.py +104 -0
- package/src/chunking/treesitter_chunker.py +240 -0
- package/src/compact/anthropic.py +98 -0
- package/src/compact/gemini.py +88 -0
- package/src/compact/handoff.py +179 -0
- package/src/compact/openai.py +318 -0
- package/src/compact/summarizer.py +186 -0
- package/src/context/assembler.py +298 -0
- package/src/context/budgeting.py +137 -0
- package/src/context/sanitizer.py +23 -0
- package/src/evaluation/__init__.py +1 -0
- package/src/evaluation/gates.py +172 -0
- package/src/evaluation/metrics.py +41 -0
- package/src/harness_context/__init__.py +5 -0
- package/src/harness_context/adapters/__init__.py +1 -0
- package/src/harness_context/adapters/clients/__init__.py +4 -0
- package/src/harness_context/adapters/clients/formatters.py +47 -0
- package/src/harness_context/adapters/clients/profiles.py +29 -0
- package/src/harness_context/adapters/ecc/__init__.py +4 -0
- package/src/harness_context/adapters/ecc/detection.py +41 -0
- package/src/harness_context/adapters/ecc/mapping.py +32 -0
- package/src/harness_context/adapters/ecc/memory_reader.py +162 -0
- package/src/harness_context/adapters/ecc/provenance.py +16 -0
- package/src/harness_context/api/__init__.py +1 -0
- package/src/harness_context/api/v2/__init__.py +12 -0
- package/src/harness_context/api/v2/contracts.py +119 -0
- package/src/harness_context/api/v2/diagnostics.py +13 -0
- package/src/harness_context/api/v2/enums.py +17 -0
- package/src/harness_context/api/v2/errors.py +32 -0
- package/src/harness_context/api/v2/models.py +4 -0
- package/src/harness_context/api/v2/requests.py +17 -0
- package/src/harness_context/api/v2/responses.py +22 -0
- package/src/harness_context/application/__init__.py +3 -0
- package/src/harness_context/application/container.py +31 -0
- package/src/harness_context/application/context_service.py +51 -0
- package/src/harness_context/application/ecc_service.py +7 -0
- package/src/harness_context/application/handoff_service.py +11 -0
- package/src/harness_context/application/memory_service.py +9 -0
- package/src/harness_context/application/protocols.py +46 -0
- package/src/harness_context/application/refresh_service.py +25 -0
- package/src/harness_context/application/retrieval_service.py +22 -0
- package/src/harness_context/application/services.py +4 -0
- package/src/harness_context/application/workspace_service.py +18 -0
- package/src/harness_context/bootstrap.py +47 -0
- package/src/harness_context/branding.py +16 -0
- package/src/harness_context/cli/__init__.py +1 -0
- package/src/harness_context/cli/app.py +239 -0
- package/src/harness_context/cli/exit_codes.py +25 -0
- package/src/harness_context/domain/__init__.py +9 -0
- package/src/harness_context/domain/cag.py +18 -0
- package/src/harness_context/domain/chunking.py +17 -0
- package/src/harness_context/domain/planning.py +30 -0
- package/src/harness_context/domain/ports.py +24 -0
- package/src/harness_context/domain/retrieval.py +46 -0
- package/src/harness_context/engine.py +10 -0
- package/src/harness_context/free_tools.py +143 -0
- package/src/harness_context/infrastructure/__init__.py +10 -0
- package/src/harness_context/infrastructure/graph.py +26 -0
- package/src/harness_context/infrastructure/indexes.py +33 -0
- package/src/harness_context/infrastructure/local_engine.py +296 -0
- package/src/harness_context/infrastructure/parsing.py +38 -0
- package/src/harness_context/infrastructure/scanning.py +51 -0
- package/src/harness_context/installer/__init__.py +4 -0
- package/src/harness_context/installer/models.py +22 -0
- package/src/harness_context/installer/service.py +168 -0
- package/src/harness_context/mcp/__init__.py +3 -0
- package/src/harness_context/mcp/capabilities.py +11 -0
- package/src/harness_context/mcp/errors.py +8 -0
- package/src/harness_context/mcp/lifecycle.py +72 -0
- package/src/harness_context/mcp/middleware.py +57 -0
- package/src/harness_context/mcp/server.py +3 -0
- package/src/harness_context/mcp/tool_handlers/__init__.py +7 -0
- package/src/harness_context/mcp/tool_handlers/context.py +16 -0
- package/src/harness_context/mcp/tool_handlers/ecc.py +8 -0
- package/src/harness_context/mcp/tool_handlers/handoffs.py +20 -0
- package/src/harness_context/mcp/tool_handlers/memory.py +16 -0
- package/src/harness_context/mcp/tool_handlers/workspace.py +12 -0
- package/src/harness_context/mcp/tools.py +15 -0
- package/src/harness_context/observability/__init__.py +6 -0
- package/src/harness_context/observability/events.py +25 -0
- package/src/harness_context/observability/metrics.py +20 -0
- package/src/harness_context/paths.py +35 -0
- package/src/harness_context/runtime.py +127 -0
- package/src/harness_context/schemas.py +38 -0
- package/src/harness_context/security/__init__.py +3 -0
- package/src/harness_context/security/secret_patterns.py +15 -0
- package/src/harness_context/server.py +1077 -0
- package/src/harness_context/storage/__init__.py +6 -0
- package/src/harness_context/storage/migrations.py +24 -0
- package/src/harness_context/storage/pins.py +10 -0
- package/src/harness_context/storage/snapshots.py +149 -0
- package/src/harness_context/tokenize.py +12 -0
- package/src/harness_context/topology.py +65 -0
- package/src/harness_context/watcher/__init__.py +3 -0
- package/src/harness_context/watcher/service.py +32 -0
- package/src/harness_context/workspace/__init__.py +13 -0
- package/src/harness_context/workspace/identity.py +9 -0
- package/src/harness_context/workspace/lock.py +24 -0
- package/src/harness_context/workspace/policy.py +3 -0
- package/src/harness_context/workspace/roots.py +84 -0
- package/src/harness_context/workspace/state.py +35 -0
- package/src/memory/episodic.py +257 -0
- package/src/memory/vector_store.py +104 -0
- package/src/retrieval/bm25.py +23 -0
- package/src/retrieval/cache.py +76 -0
- package/src/retrieval/embeddings.py +75 -0
- package/src/retrieval/graph.py +45 -0
- package/src/retrieval/reranker.py +78 -0
- 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()
|