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,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context/assembler.py — Provider-aware context assembly
|
|
3
|
+
=======================================================
|
|
4
|
+
Formats retrieved chunks + memory entries into a system prompt.
|
|
5
|
+
|
|
6
|
+
Format strategy by model:
|
|
7
|
+
- Claude (Anthropic): XML — <retrieved_context> / <long_term_memory>
|
|
8
|
+
- Gemini (Google): JSON — wrapped in ```json fence
|
|
9
|
+
- OpenAI Chat (GPT): Markdown — fenced code blocks, clean headings
|
|
10
|
+
- OpenAI Codex: Compact Markdown — minimise overhead tokens;
|
|
11
|
+
only emit file path + critical code snippet
|
|
12
|
+
- OpenAI Reasoning: Markdown — same as chat but no `<system>` injection
|
|
13
|
+
(summary injected as user message by compactor)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
# ── Memory formatting helper ──────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def _format_memory_compact(entries: list[dict]) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Compact single-line memory format — saves ~50 tokens per entry vs XML.
|
|
25
|
+
Format: [type|key] value
|
|
26
|
+
"""
|
|
27
|
+
if not entries:
|
|
28
|
+
return ""
|
|
29
|
+
lines = [f"[{m['type']}|{m['key']}] {m['value']}" for m in entries]
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _format_memory_xml(entries: list[dict]) -> str:
|
|
34
|
+
"""Full XML memory block — for Claude structured mode."""
|
|
35
|
+
if not entries:
|
|
36
|
+
return ""
|
|
37
|
+
lines = []
|
|
38
|
+
for m in entries:
|
|
39
|
+
lines.append(
|
|
40
|
+
f" <memory key='{m['key']}' type='{m['type']}'>"
|
|
41
|
+
f"{m['value']}</memory>"
|
|
42
|
+
)
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Model family detection ────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def _is_gemini(model: str) -> bool:
|
|
49
|
+
return "gemini" in model.lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_claude(model: str) -> bool:
|
|
53
|
+
return "claude" in model.lower() or "anthropic" in model.lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_codex(model: str) -> bool:
|
|
57
|
+
ml = model.lower()
|
|
58
|
+
return any(kw in ml for kw in (
|
|
59
|
+
"codex", "code-davinci", "code-cushman",
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_reasoning(model: str) -> bool:
|
|
64
|
+
"""OpenAI o-series reasoning models."""
|
|
65
|
+
ml = model.lower()
|
|
66
|
+
return any(ml.startswith(p) or f"-{p}" in ml for p in (
|
|
67
|
+
"o1", "o3", "o4",
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_openai(model: str) -> bool:
|
|
72
|
+
ml = model.lower()
|
|
73
|
+
return any(kw in ml for kw in ("gpt-", "o1", "o3", "o4", "codex", "code-"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Formatters ────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
class ContextAssembler:
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def assemble(
|
|
83
|
+
self,
|
|
84
|
+
base_prompt: str,
|
|
85
|
+
chunks: list,
|
|
86
|
+
memory_entries: list,
|
|
87
|
+
query: str,
|
|
88
|
+
output_mode: str,
|
|
89
|
+
model: str,
|
|
90
|
+
) -> str:
|
|
91
|
+
"""
|
|
92
|
+
Assemble the enriched system prompt.
|
|
93
|
+
|
|
94
|
+
:param base_prompt: Original system prompt.
|
|
95
|
+
:param chunks: Retrieved code/doc chunks.
|
|
96
|
+
:param memory_entries: Memory entries from long-term store.
|
|
97
|
+
:param query: Current user task.
|
|
98
|
+
:param output_mode: "concise" | "structured" | "code_only" | "minimal"
|
|
99
|
+
:param model: Target model string.
|
|
100
|
+
:returns: Assembled prompt string.
|
|
101
|
+
"""
|
|
102
|
+
if _is_gemini(model):
|
|
103
|
+
return self._assemble_gemini(
|
|
104
|
+
base_prompt, chunks, memory_entries, query, output_mode)
|
|
105
|
+
|
|
106
|
+
if _is_claude(model):
|
|
107
|
+
return self._assemble_claude(
|
|
108
|
+
base_prompt, chunks, memory_entries, query, output_mode)
|
|
109
|
+
|
|
110
|
+
if _is_codex(model):
|
|
111
|
+
return self._assemble_codex(
|
|
112
|
+
base_prompt, chunks, memory_entries, query, output_mode)
|
|
113
|
+
|
|
114
|
+
if _is_openai(model) or _is_reasoning(model):
|
|
115
|
+
return self._assemble_openai(
|
|
116
|
+
base_prompt, chunks, memory_entries, query, output_mode,
|
|
117
|
+
reasoning=_is_reasoning(model))
|
|
118
|
+
|
|
119
|
+
# Fallback → OpenAI markdown
|
|
120
|
+
return self._assemble_openai(
|
|
121
|
+
base_prompt, chunks, memory_entries, query, output_mode)
|
|
122
|
+
|
|
123
|
+
# ── Claude XML ────────────────────────────────────────────────────────────
|
|
124
|
+
def _assemble_claude(
|
|
125
|
+
self,
|
|
126
|
+
base_prompt: str,
|
|
127
|
+
chunks: list,
|
|
128
|
+
memory_entries: list,
|
|
129
|
+
query: str,
|
|
130
|
+
output_mode: str,
|
|
131
|
+
) -> str:
|
|
132
|
+
# Memory: XML only for structured mode; compact otherwise
|
|
133
|
+
if output_mode == "structured":
|
|
134
|
+
mem_block = _format_memory_xml(memory_entries)
|
|
135
|
+
mem_section = f"<long_term_memory>\n{mem_block}\n</long_term_memory>" if mem_block else ""
|
|
136
|
+
else:
|
|
137
|
+
mem_block = _format_memory_compact(memory_entries)
|
|
138
|
+
mem_section = f"<memory>\n{mem_block}\n</memory>" if mem_block else ""
|
|
139
|
+
|
|
140
|
+
chunk_xml = ""
|
|
141
|
+
for c in chunks:
|
|
142
|
+
if c.content.startswith("<context>"): # already compressed
|
|
143
|
+
chunk_xml += c.content + "\n"
|
|
144
|
+
else:
|
|
145
|
+
chunk_xml += (
|
|
146
|
+
f" <chunk path='{c.path}' symbol='{c.symbol}'>"
|
|
147
|
+
f"<content>{c.content}</content></chunk>\n"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
parts = [base_prompt]
|
|
151
|
+
if chunk_xml:
|
|
152
|
+
parts.append(f"<retrieved_context>\n{chunk_xml}</retrieved_context>")
|
|
153
|
+
if mem_section:
|
|
154
|
+
parts.append(mem_section)
|
|
155
|
+
if query:
|
|
156
|
+
parts.append(f"<task>\n{query}\n</task>")
|
|
157
|
+
return "\n\n".join(parts)
|
|
158
|
+
|
|
159
|
+
# ── Gemini JSON ───────────────────────────────────────────────────────────
|
|
160
|
+
def _assemble_gemini(
|
|
161
|
+
self,
|
|
162
|
+
base_prompt: str,
|
|
163
|
+
chunks: list,
|
|
164
|
+
memory_entries: list,
|
|
165
|
+
query: str,
|
|
166
|
+
output_mode: str,
|
|
167
|
+
) -> str:
|
|
168
|
+
context_data = {
|
|
169
|
+
"query": query,
|
|
170
|
+
"chunks": [
|
|
171
|
+
{"path": c.path, "symbol": c.symbol, "content": c.content}
|
|
172
|
+
for c in chunks
|
|
173
|
+
],
|
|
174
|
+
"memory": memory_entries,
|
|
175
|
+
}
|
|
176
|
+
ctx_str = json.dumps(context_data, ensure_ascii=False, indent=2)
|
|
177
|
+
return f"{base_prompt}\n\n[CONTEXT]\n```json\n{ctx_str}\n```"
|
|
178
|
+
|
|
179
|
+
# ── OpenAI Markdown (Chat + Reasoning) ────────────────────────────────────
|
|
180
|
+
def _assemble_openai(
|
|
181
|
+
self,
|
|
182
|
+
base_prompt: str,
|
|
183
|
+
chunks: list,
|
|
184
|
+
memory_entries: list,
|
|
185
|
+
query: str,
|
|
186
|
+
output_mode: str,
|
|
187
|
+
reasoning: bool = False,
|
|
188
|
+
) -> str:
|
|
189
|
+
"""
|
|
190
|
+
Clean Markdown format for GPT / o-series.
|
|
191
|
+
|
|
192
|
+
Uses fenced code blocks per chunk (language inferred from path).
|
|
193
|
+
Memory entries in compact single-line format to minimise token overhead.
|
|
194
|
+
"""
|
|
195
|
+
parts = [base_prompt]
|
|
196
|
+
|
|
197
|
+
if chunks:
|
|
198
|
+
parts.append("## Retrieved Context")
|
|
199
|
+
for c in chunks:
|
|
200
|
+
lang = _infer_language(c.path)
|
|
201
|
+
header = f"### `{c.path}`"
|
|
202
|
+
if c.symbol and c.symbol != "module":
|
|
203
|
+
header += f" — `{c.symbol}`"
|
|
204
|
+
if output_mode == "minimal":
|
|
205
|
+
snippet = "\n".join(c.content.splitlines()[:20])
|
|
206
|
+
parts.append(f"{header}\n```{lang}\n{snippet}\n```")
|
|
207
|
+
else:
|
|
208
|
+
parts.append(f"{header}\n```{lang}\n{c.content}\n```")
|
|
209
|
+
|
|
210
|
+
if memory_entries:
|
|
211
|
+
mem_str = _format_memory_compact(memory_entries)
|
|
212
|
+
parts.append(f"## Memory\n{mem_str}")
|
|
213
|
+
|
|
214
|
+
if query:
|
|
215
|
+
parts.append(f"## Task\n{query}")
|
|
216
|
+
|
|
217
|
+
return "\n\n".join(parts)
|
|
218
|
+
|
|
219
|
+
# ── Codex Compact Markdown ────────────────────────────────────────────────
|
|
220
|
+
def _assemble_codex(
|
|
221
|
+
self,
|
|
222
|
+
base_prompt: str,
|
|
223
|
+
chunks: list,
|
|
224
|
+
memory_entries: list,
|
|
225
|
+
query: str,
|
|
226
|
+
output_mode: str,
|
|
227
|
+
) -> str:
|
|
228
|
+
"""
|
|
229
|
+
Ultra-compact format for Codex models.
|
|
230
|
+
|
|
231
|
+
Codex has a small context window (8k legacy / 200k codex-mini).
|
|
232
|
+
For legacy Codex we minimise prose and only surface file path +
|
|
233
|
+
critical code. For codex-mini we use the standard OpenAI format.
|
|
234
|
+
"""
|
|
235
|
+
parts = [base_prompt]
|
|
236
|
+
|
|
237
|
+
if chunks:
|
|
238
|
+
parts.append("# Context")
|
|
239
|
+
for c in chunks:
|
|
240
|
+
lang = _infer_language(c.path)
|
|
241
|
+
# Only first 40 lines to save tokens for legacy Codex
|
|
242
|
+
snippet = "\n".join(c.content.splitlines()[:40])
|
|
243
|
+
symbol_tag = f" [{c.symbol}]" if c.symbol and c.symbol != "module" else ""
|
|
244
|
+
parts.append(
|
|
245
|
+
f"// {c.path}{symbol_tag}\n```{lang}\n{snippet}\n```"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if memory_entries:
|
|
249
|
+
parts.append("# Memory")
|
|
250
|
+
for m in memory_entries:
|
|
251
|
+
parts.append(f"// [{m['type']}] {m['key']}: {m['value']}")
|
|
252
|
+
|
|
253
|
+
parts.append(f"# Task\n{query}")
|
|
254
|
+
return "\n\n".join(parts)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
_EXT_LANG: dict[str, str] = {
|
|
260
|
+
".py": "python",
|
|
261
|
+
".ts": "typescript",
|
|
262
|
+
".tsx": "tsx",
|
|
263
|
+
".js": "javascript",
|
|
264
|
+
".jsx": "jsx",
|
|
265
|
+
".go": "go",
|
|
266
|
+
".rs": "rust",
|
|
267
|
+
".java": "java",
|
|
268
|
+
".kt": "kotlin",
|
|
269
|
+
".cs": "csharp",
|
|
270
|
+
".cpp": "cpp",
|
|
271
|
+
".c": "c",
|
|
272
|
+
".h": "c",
|
|
273
|
+
".hpp": "cpp",
|
|
274
|
+
".rb": "ruby",
|
|
275
|
+
".php": "php",
|
|
276
|
+
".swift": "swift",
|
|
277
|
+
".sh": "bash",
|
|
278
|
+
".zsh": "bash",
|
|
279
|
+
".md": "markdown",
|
|
280
|
+
".json": "json",
|
|
281
|
+
".yaml": "yaml",
|
|
282
|
+
".yml": "yaml",
|
|
283
|
+
".toml": "toml",
|
|
284
|
+
".html": "html",
|
|
285
|
+
".css": "css",
|
|
286
|
+
".sql": "sql",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _infer_language(path: str) -> str:
|
|
291
|
+
"""Guess a markdown fence language tag from file extension."""
|
|
292
|
+
if not path:
|
|
293
|
+
return ""
|
|
294
|
+
dot = path.rfind(".")
|
|
295
|
+
if dot == -1:
|
|
296
|
+
return ""
|
|
297
|
+
ext = path[dot:].lower()
|
|
298
|
+
return _EXT_LANG.get(ext, "")
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context/budgeting.py — Dynamic token budget per model
|
|
3
|
+
======================================================
|
|
4
|
+
Covers all major model families:
|
|
5
|
+
- Claude (Anthropic): sonnet, haiku, opus
|
|
6
|
+
- Gemini (Google): 2.5-pro, 2.5-flash, 1.5-pro
|
|
7
|
+
- OpenAI Chat: gpt-4o, gpt-4.1, gpt-4.5, gpt-4, gpt-3.5
|
|
8
|
+
- OpenAI Reasoning: o1, o1-mini, o3, o3-mini, o4-mini
|
|
9
|
+
- OpenAI Codex: codex-mini-latest, code-davinci-002, gpt-4o-mini
|
|
10
|
+
- Generic fallback: 128k context
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TokenBudget:
|
|
20
|
+
model_name: str
|
|
21
|
+
context_window: int
|
|
22
|
+
reserved_output: int
|
|
23
|
+
reserved_reasoning: int # hidden CoT tokens (o1/o3/o4 family)
|
|
24
|
+
reserved_tools: int
|
|
25
|
+
inject_budget: int # context_window - all reserves
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Model registry ────────────────────────────────────────────────────────────
|
|
29
|
+
# Each entry: (context_window, reserved_output, reserved_reasoning, reserved_tools)
|
|
30
|
+
_MODEL_TABLE: dict[str, tuple[int, int, int, int]] = {
|
|
31
|
+
# ── Claude ──────────────────────────────────────────────────────────────
|
|
32
|
+
"claude-opus": (200_000, 15_000, 0, 10_000),
|
|
33
|
+
"claude-sonnet": (200_000, 30_000, 25_000, 10_000),
|
|
34
|
+
"claude-haiku": (200_000, 10_000, 0, 8_000),
|
|
35
|
+
|
|
36
|
+
# ── Gemini ──────────────────────────────────────────────────────────────
|
|
37
|
+
"gemini-2.5-pro": (2_000_000, 64_000, 0, 20_000),
|
|
38
|
+
"gemini-2.5-flash": (1_000_000, 32_000, 0, 16_000),
|
|
39
|
+
"gemini-1.5-pro": (2_000_000, 8_192, 0, 20_000),
|
|
40
|
+
"gemini-1.5-flash": (1_000_000, 8_192, 0, 16_000),
|
|
41
|
+
"gemini": (1_000_000, 32_000, 0, 16_000), # generic
|
|
42
|
+
|
|
43
|
+
# ── OpenAI GPT-5 series ─────────────────────────────────────────────────
|
|
44
|
+
# Context windows / output limits are estimates based on API announcements.
|
|
45
|
+
# Update when OpenAI releases official specs.
|
|
46
|
+
"gpt-5.5": (1_000_000, 32_768, 0, 16_000), # multimodal flagship
|
|
47
|
+
"gpt-5.4": ( 512_000, 32_768, 0, 12_000),
|
|
48
|
+
"gpt-5-mini": ( 256_000, 16_384, 0, 8_000),
|
|
49
|
+
"gpt-5": ( 512_000, 32_768, 0, 12_000), # base gpt-5
|
|
50
|
+
|
|
51
|
+
# ── OpenAI Chat ─────────────────────────────────────────────────────────
|
|
52
|
+
"gpt-4.5": (128_000, 16_384, 0, 8_000),
|
|
53
|
+
"gpt-4.1": (128_000, 16_384, 0, 8_000),
|
|
54
|
+
"gpt-4o": (128_000, 16_384, 0, 8_000),
|
|
55
|
+
"gpt-4o-mini": (128_000, 4_096, 0, 4_000),
|
|
56
|
+
"gpt-4-turbo": (128_000, 16_384, 0, 8_000),
|
|
57
|
+
"gpt-4": ( 32_768, 4_096, 0, 4_000),
|
|
58
|
+
"gpt-3.5-turbo": ( 16_385, 4_096, 0, 3_000),
|
|
59
|
+
|
|
60
|
+
# ── OpenAI Reasoning (o-series) ──────────────────────────────────────────
|
|
61
|
+
# reserved_reasoning captures the hidden CoT budget estimate
|
|
62
|
+
"o4-mini": (200_000, 16_384, 64_000, 8_000),
|
|
63
|
+
"o3": (200_000, 16_384, 80_000, 8_000),
|
|
64
|
+
"o3-mini": (200_000, 16_384, 64_000, 8_000),
|
|
65
|
+
"o1": (200_000, 16_384, 80_000, 8_000),
|
|
66
|
+
"o1-mini": (128_000, 4_096, 32_000, 4_000),
|
|
67
|
+
"o1-preview": (128_000, 4_096, 32_000, 4_000),
|
|
68
|
+
|
|
69
|
+
# ── OpenAI Codex ────────────────────────────────────────────────────────
|
|
70
|
+
# codex-mini-latest: the new hosted reasoning code model (May 2025)
|
|
71
|
+
"codex-mini-latest": (200_000, 16_384, 64_000, 8_000),
|
|
72
|
+
"codex-mini": (200_000, 16_384, 64_000, 8_000),
|
|
73
|
+
# Legacy completion-only Codex (deprecated but still referenced)
|
|
74
|
+
"code-davinci-002": ( 8_001, 4_096, 0, 1_000),
|
|
75
|
+
"code-cushman-001": ( 2_048, 2_048, 0, 500),
|
|
76
|
+
|
|
77
|
+
# ── Generic fallback ────────────────────────────────────────────────────
|
|
78
|
+
"default": (128_000, 8_192, 0, 6_000),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _match(model: str) -> tuple[int, int, int, int]:
|
|
83
|
+
"""
|
|
84
|
+
Match a model string to the closest entry in _MODEL_TABLE.
|
|
85
|
+
Checks exact → prefix → keyword substring → default.
|
|
86
|
+
"""
|
|
87
|
+
ml = model.lower().strip()
|
|
88
|
+
|
|
89
|
+
# 1. Exact match
|
|
90
|
+
if ml in _MODEL_TABLE:
|
|
91
|
+
return _MODEL_TABLE[ml]
|
|
92
|
+
|
|
93
|
+
# 2. Prefix match (longest wins)
|
|
94
|
+
candidates = [k for k in _MODEL_TABLE if ml.startswith(k)]
|
|
95
|
+
if candidates:
|
|
96
|
+
return _MODEL_TABLE[max(candidates, key=len)]
|
|
97
|
+
|
|
98
|
+
# 3. Keyword substring scan (ordered by specificity)
|
|
99
|
+
keyword_order = [
|
|
100
|
+
"codex-mini-latest", "codex-mini", "codex",
|
|
101
|
+
"code-davinci", "code-cushman",
|
|
102
|
+
"o4-mini", "o3-mini", "o1-mini", "o1-preview",
|
|
103
|
+
"o4", "o3", "o1",
|
|
104
|
+
# GPT-5 must come before gpt-4 to avoid partial match
|
|
105
|
+
"gpt-5.5", "gpt-5.4", "gpt-5-mini", "gpt-5",
|
|
106
|
+
"gpt-4.5", "gpt-4.1", "gpt-4o-mini", "gpt-4o",
|
|
107
|
+
"gpt-4-turbo", "gpt-4",
|
|
108
|
+
"gpt-3.5",
|
|
109
|
+
"gemini-2.5-pro", "gemini-2.5-flash",
|
|
110
|
+
"gemini-1.5-pro", "gemini-1.5-flash",
|
|
111
|
+
"gemini",
|
|
112
|
+
"claude-opus", "claude-haiku", "claude-sonnet", "claude",
|
|
113
|
+
]
|
|
114
|
+
for kw in keyword_order:
|
|
115
|
+
if kw in ml:
|
|
116
|
+
return _MODEL_TABLE.get(kw, _MODEL_TABLE["default"])
|
|
117
|
+
|
|
118
|
+
return _MODEL_TABLE["default"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_budget(model: str) -> TokenBudget:
|
|
122
|
+
"""
|
|
123
|
+
Return a TokenBudget for the given model string.
|
|
124
|
+
|
|
125
|
+
:param model: Any model identifier string (case-insensitive).
|
|
126
|
+
:returns: TokenBudget with inject_budget = usable context tokens.
|
|
127
|
+
"""
|
|
128
|
+
ctx, out, res, tools = _match(model)
|
|
129
|
+
inject = max(ctx - out - res - tools, 0)
|
|
130
|
+
return TokenBudget(
|
|
131
|
+
model_name=model,
|
|
132
|
+
context_window=ctx,
|
|
133
|
+
reserved_output=out,
|
|
134
|
+
reserved_reasoning=res,
|
|
135
|
+
reserved_tools=tools,
|
|
136
|
+
inject_budget=inject,
|
|
137
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
def sanitize(text: str, escape_xml: bool = True) -> str:
|
|
2
|
+
if not text:
|
|
3
|
+
return ""
|
|
4
|
+
# simple prompt injection protection
|
|
5
|
+
bad_phrases = [
|
|
6
|
+
"ignore previous instructions",
|
|
7
|
+
"system prompt",
|
|
8
|
+
"tool override"]
|
|
9
|
+
for bp in bad_phrases:
|
|
10
|
+
text = text.replace(bp, "[REDACTED]")
|
|
11
|
+
if escape_xml:
|
|
12
|
+
text = text.replace("<", "<").replace(">", ">")
|
|
13
|
+
return text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sanitize_chunks(chunks: list):
|
|
17
|
+
for c in chunks:
|
|
18
|
+
c.content = sanitize(c.content, escape_xml=False)
|
|
19
|
+
if not getattr(c, "content_hash", ""):
|
|
20
|
+
import hashlib
|
|
21
|
+
c.content_hash = hashlib.sha256(c.content.encode("utf-8")).hexdigest()
|
|
22
|
+
if c.summary:
|
|
23
|
+
c.summary = sanitize(c.summary, escape_xml=False)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Provider-neutral release evaluation gates."""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import statistics
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from evaluation.metrics import ndcg_at_k, recall_at_k, reciprocal_rank
|
|
11
|
+
from harness_context.engine import ContextEngine
|
|
12
|
+
|
|
13
|
+
FIXTURES = Path(__file__).parents[2] / "tests" / "fixtures"
|
|
14
|
+
STRATEGIES = {
|
|
15
|
+
"rag": "hybrid_rag",
|
|
16
|
+
"full_context": "long_context",
|
|
17
|
+
"cag": "cag",
|
|
18
|
+
"hybrid": "hybrid_cag_rag",
|
|
19
|
+
"graph_augmented": "graph_augmented",
|
|
20
|
+
}
|
|
21
|
+
LATENCY_LIMIT_MS = 10_000.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def percentile(values: list[float], percent: float) -> float:
|
|
25
|
+
ordered = sorted(values)
|
|
26
|
+
index = max(0, min(len(ordered) - 1, int((len(ordered) - 1) * percent)))
|
|
27
|
+
return round(ordered[index], 3)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _measure(call, runs: int = 5) -> tuple[dict, dict]:
|
|
31
|
+
cold_started = time.perf_counter()
|
|
32
|
+
result = call()
|
|
33
|
+
cold_ms = (time.perf_counter() - cold_started) * 1000
|
|
34
|
+
warm_latencies = []
|
|
35
|
+
for _ in range(runs):
|
|
36
|
+
started = time.perf_counter()
|
|
37
|
+
result = call()
|
|
38
|
+
warm_latencies.append((time.perf_counter() - started) * 1000)
|
|
39
|
+
return result, {
|
|
40
|
+
"cold_ms": round(cold_ms, 3),
|
|
41
|
+
"warm_samples_ms": [round(value, 3) for value in warm_latencies],
|
|
42
|
+
"warm_p50_ms": round(statistics.median(warm_latencies), 3),
|
|
43
|
+
"warm_p95_ms": percentile(warm_latencies, 0.95),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _strategy_tokens(result: dict) -> int:
|
|
48
|
+
return (result.get("retrieval") or {}).get("token_count", 0) + (
|
|
49
|
+
result.get("bundle") or {}
|
|
50
|
+
).get("token_count", 0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_release_gates() -> dict:
|
|
54
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
55
|
+
root = Path(directory) / "fixture-corpus"
|
|
56
|
+
shutil.copytree(FIXTURES, root)
|
|
57
|
+
engine = ContextEngine()
|
|
58
|
+
engine.register_workspace("evaluation", [str(root)])
|
|
59
|
+
|
|
60
|
+
cold_started = time.perf_counter()
|
|
61
|
+
engine.refresh_workspace("evaluation")
|
|
62
|
+
cold_index_ms = (time.perf_counter() - cold_started) * 1000
|
|
63
|
+
warm_started = time.perf_counter()
|
|
64
|
+
engine.refresh_workspace("evaluation")
|
|
65
|
+
warm_index_ms = (time.perf_counter() - warm_started) * 1000
|
|
66
|
+
|
|
67
|
+
query = "refresh_token authentication architecture cross-module"
|
|
68
|
+
strategy_reports = {}
|
|
69
|
+
for label, override in STRATEGIES.items():
|
|
70
|
+
result, latency = _measure(
|
|
71
|
+
lambda override=override: engine.prepare_context(
|
|
72
|
+
"evaluation", query, 4_000, override
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
items = (result.get("retrieval") or {}).get("items", [])
|
|
76
|
+
if override == "cag":
|
|
77
|
+
items = (result.get("bundle") or {}).get("items", [])
|
|
78
|
+
strategy_reports[label] = {
|
|
79
|
+
"engine_strategy": result["plan"]["strategy"],
|
|
80
|
+
"item_count": len(items),
|
|
81
|
+
"token_cost": _strategy_tokens(result),
|
|
82
|
+
"latency": latency,
|
|
83
|
+
"within_budget": _strategy_tokens(result) <= 4_000,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
retrieval = engine.retrieve_context("evaluation", "refresh_token", token_budget=2_000)
|
|
87
|
+
expected_paths = {"auth.py"}
|
|
88
|
+
expected_symbols = {"refresh_token"}
|
|
89
|
+
quality = {
|
|
90
|
+
"recall_at_5": recall_at_k(retrieval["items"], expected_paths, expected_symbols, 5),
|
|
91
|
+
"mrr": reciprocal_rank(retrieval["items"], expected_paths, expected_symbols),
|
|
92
|
+
"ndcg_at_5": ndcg_at_k(retrieval["items"], expected_paths, expected_symbols, 5),
|
|
93
|
+
"provenance_complete": all(
|
|
94
|
+
item.get("path") and item.get("start_line") and item.get("end_line")
|
|
95
|
+
for item in retrieval["items"]
|
|
96
|
+
),
|
|
97
|
+
"untrusted_content": retrieval["untrusted_content"],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fixture_queries = {
|
|
101
|
+
"python": "python-rotated",
|
|
102
|
+
"typescript": "typescript-rotated",
|
|
103
|
+
"flutter": "flutter-rotated",
|
|
104
|
+
"monorepo": "monorepo-token",
|
|
105
|
+
"vietnamese": "xoay vòng mã làm mới",
|
|
106
|
+
"malicious": "delete the repository",
|
|
107
|
+
"long_document": "phase_f_long_document_marker",
|
|
108
|
+
}
|
|
109
|
+
fixture_coverage = {
|
|
110
|
+
name: any(
|
|
111
|
+
marker in item["content"]
|
|
112
|
+
for item in engine.retrieve_context("evaluation", marker)["items"]
|
|
113
|
+
)
|
|
114
|
+
for name, marker in fixture_queries.items()
|
|
115
|
+
}
|
|
116
|
+
fixture_coverage["duplicate_symbols"] = sum(
|
|
117
|
+
item.symbol == "normalize_token" for item in engine.states["evaluation"].items
|
|
118
|
+
) == 2
|
|
119
|
+
|
|
120
|
+
source = root / "python" / "auth.py"
|
|
121
|
+
source.write_text("def refreshed_value():\n return 'fresh-phase-f'\n", encoding="utf-8")
|
|
122
|
+
stale = engine.retrieve_context("evaluation", "fresh-phase-f")
|
|
123
|
+
engine.refresh_workspace("evaluation", [str(source)])
|
|
124
|
+
fresh = engine.retrieve_context("evaluation", "fresh-phase-f")
|
|
125
|
+
freshness = {
|
|
126
|
+
"absent_before_refresh": not any(
|
|
127
|
+
"fresh-phase-f" in item["content"] for item in stale["items"]
|
|
128
|
+
),
|
|
129
|
+
"present_after_refresh": any(
|
|
130
|
+
"fresh-phase-f" in item["content"] for item in fresh["items"]
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
latency_ok = max(
|
|
135
|
+
[cold_index_ms, warm_index_ms]
|
|
136
|
+
+ [report["latency"]["cold_ms"] for report in strategy_reports.values()]
|
|
137
|
+
+ [report["latency"]["warm_p95_ms"] for report in strategy_reports.values()]
|
|
138
|
+
) < LATENCY_LIMIT_MS
|
|
139
|
+
passed = (
|
|
140
|
+
quality["recall_at_5"] >= 1.0
|
|
141
|
+
and quality["mrr"] >= 0.5
|
|
142
|
+
and quality["ndcg_at_5"] >= 0.5
|
|
143
|
+
and quality["provenance_complete"]
|
|
144
|
+
and quality["untrusted_content"]
|
|
145
|
+
and all(report["item_count"] > 0 for report in strategy_reports.values())
|
|
146
|
+
and all(report["within_budget"] for report in strategy_reports.values())
|
|
147
|
+
and all(fixture_coverage.values())
|
|
148
|
+
and all(freshness.values())
|
|
149
|
+
and latency_ok
|
|
150
|
+
)
|
|
151
|
+
return {
|
|
152
|
+
"passed": passed,
|
|
153
|
+
"thresholds": {"latency_limit_ms": LATENCY_LIMIT_MS, "token_budget": 4_000},
|
|
154
|
+
"quality": quality,
|
|
155
|
+
"fixture_coverage": fixture_coverage,
|
|
156
|
+
"freshness": freshness,
|
|
157
|
+
"index_latency": {
|
|
158
|
+
"cold_ms": round(cold_index_ms, 3),
|
|
159
|
+
"warm_ms": round(warm_index_ms, 3),
|
|
160
|
+
},
|
|
161
|
+
"strategies": strategy_reports,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main() -> int:
|
|
166
|
+
report = run_release_gates()
|
|
167
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
168
|
+
return 0 if report["passed"] else 1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Small provider-neutral retrieval quality metrics for golden fixtures."""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def recall_at_k(results: list[dict], expected_paths: set[str], expected_symbols: set[str], k: int) -> float:
|
|
9
|
+
selected = results[:k]
|
|
10
|
+
found_paths = {item["path"].rsplit("/", 1)[-1] for item in selected}
|
|
11
|
+
found_symbols = {item.get("symbol", "") for item in selected}
|
|
12
|
+
signals = []
|
|
13
|
+
if expected_paths:
|
|
14
|
+
signals.append(bool(found_paths & expected_paths))
|
|
15
|
+
if expected_symbols:
|
|
16
|
+
signals.append(bool(found_symbols & expected_symbols))
|
|
17
|
+
return sum(signals) / len(signals) if signals else 1.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def coverage_score(result: dict) -> float:
|
|
21
|
+
if result.get("coverage") == "sufficient":
|
|
22
|
+
return 1.0
|
|
23
|
+
if result.get("coverage") == "partial":
|
|
24
|
+
return 0.5
|
|
25
|
+
return 0.0
|
|
26
|
+
|
|
27
|
+
def reciprocal_rank(results: list[dict], expected_paths: set[str], expected_symbols: set[str]) -> float:
|
|
28
|
+
for rank, item in enumerate(results, 1):
|
|
29
|
+
if item["path"].rsplit("/", 1)[-1] in expected_paths or item.get("symbol", "") in expected_symbols:
|
|
30
|
+
return 1.0 / rank
|
|
31
|
+
return 0.0
|
|
32
|
+
|
|
33
|
+
def ndcg_at_k(results: list[dict], expected_paths: set[str], expected_symbols: set[str], k: int) -> float:
|
|
34
|
+
relevance = [
|
|
35
|
+
int(item["path"].rsplit("/", 1)[-1] in expected_paths or item.get("symbol", "") in expected_symbols)
|
|
36
|
+
for item in results[:k]
|
|
37
|
+
]
|
|
38
|
+
dcg = sum(value / math.log2(rank + 1) for rank, value in enumerate(relevance, 1))
|
|
39
|
+
relevant_count = sum(relevance)
|
|
40
|
+
ideal = sum(1.0 / math.log2(rank + 1) for rank in range(1, relevant_count + 1))
|
|
41
|
+
return dcg / ideal if ideal else 1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional integrations that cannot change core ranking or provenance."""
|