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,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
chunking/compressor.py — Context compressor
|
|
3
|
+
============================================
|
|
4
|
+
Compresses chunks into a compact representation depending on the target
|
|
5
|
+
model provider:
|
|
6
|
+
- Claude → XML <context> blocks
|
|
7
|
+
- OpenAI → Markdown comments (minimal token overhead)
|
|
8
|
+
- Codex → Single-line summary + truncated code (max 30 lines)
|
|
9
|
+
- Gemini → JSON object
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContextCompressor:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def compress(
|
|
22
|
+
self,
|
|
23
|
+
chunks: list,
|
|
24
|
+
model: str = "",
|
|
25
|
+
) -> list[str]:
|
|
26
|
+
"""
|
|
27
|
+
Compress each chunk to its minimal useful representation.
|
|
28
|
+
|
|
29
|
+
:param chunks: List of Chunk objects.
|
|
30
|
+
:param model: Target model string for format selection.
|
|
31
|
+
:returns: List of compressed strings (one per chunk).
|
|
32
|
+
"""
|
|
33
|
+
ml = model.lower()
|
|
34
|
+
|
|
35
|
+
if "gemini" in ml:
|
|
36
|
+
return [self._compress_gemini(c) for c in chunks]
|
|
37
|
+
if any(kw in ml for kw in ("codex", "code-davinci", "code-cushman")):
|
|
38
|
+
return [self._compress_codex(c) for c in chunks]
|
|
39
|
+
if "claude" in ml or "anthropic" in ml:
|
|
40
|
+
return [self._compress_claude(c) for c in chunks]
|
|
41
|
+
# Default: OpenAI markdown (also used for gpt-*, o1/o3/o4)
|
|
42
|
+
return [self._compress_openai(c) for c in chunks]
|
|
43
|
+
|
|
44
|
+
# ── Claude XML ────────────────────────────────────────────────────────────
|
|
45
|
+
def _compress_claude(self, c) -> str:
|
|
46
|
+
summary = c.summary if c.summary else f"{c.type} {c.symbol}"
|
|
47
|
+
critical = "\n".join(c.content.splitlines()[:60])
|
|
48
|
+
return (
|
|
49
|
+
f"<context>\n"
|
|
50
|
+
f" <summary>{summary}</summary>\n"
|
|
51
|
+
f" <critical_code>\n{critical}\n </critical_code>\n"
|
|
52
|
+
f"</context>"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# ── OpenAI Markdown ───────────────────────────────────────────────────────
|
|
56
|
+
def _compress_openai(self, c) -> str:
|
|
57
|
+
summary = c.summary if c.summary else f"{c.type} `{c.symbol}`"
|
|
58
|
+
critical = "\n".join(c.content.splitlines()[:60])
|
|
59
|
+
lang = _ext_to_lang(c.path)
|
|
60
|
+
return (
|
|
61
|
+
f"<!-- {c.path} | {summary} -->\n"
|
|
62
|
+
f"```{lang}\n{critical}\n```"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ── Codex — ultra-compact ─────────────────────────────────────────────────
|
|
66
|
+
def _compress_codex(self, c) -> str:
|
|
67
|
+
summary = c.summary if c.summary else f"{c.type} {c.symbol}"
|
|
68
|
+
# Only 30 lines for legacy Codex (tiny context)
|
|
69
|
+
critical = "\n".join(c.content.splitlines()[:30])
|
|
70
|
+
lang = _ext_to_lang(c.path)
|
|
71
|
+
return (
|
|
72
|
+
f"// {c.path} [{summary}]\n"
|
|
73
|
+
f"```{lang}\n{critical}\n```"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# ── Gemini JSON ───────────────────────────────────────────────────────────
|
|
77
|
+
def _compress_gemini(self, c) -> str:
|
|
78
|
+
return json.dumps({
|
|
79
|
+
"path": c.path,
|
|
80
|
+
"symbol": c.symbol,
|
|
81
|
+
"summary": c.summary or f"{c.type} {c.symbol}",
|
|
82
|
+
"snippet": "\n".join(c.content.splitlines()[:60]),
|
|
83
|
+
}, ensure_ascii=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
_EXT_LANG: dict[str, str] = {
|
|
89
|
+
".py": "python", ".ts": "typescript", ".tsx": "tsx",
|
|
90
|
+
".js": "javascript", ".jsx": "jsx", ".go": "go",
|
|
91
|
+
".rs": "rust", ".java": "java", ".cs": "csharp",
|
|
92
|
+
".cpp": "cpp", ".c": "c", ".h": "c",
|
|
93
|
+
".rb": "ruby", ".php": "php", ".swift": "swift",
|
|
94
|
+
".sh": "bash", ".zsh": "bash", ".md": "markdown",
|
|
95
|
+
".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
|
96
|
+
".toml": "toml", ".html": "html", ".css": "css", ".sql": "sql",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ext_to_lang(path: str) -> str:
|
|
101
|
+
if not path:
|
|
102
|
+
return ""
|
|
103
|
+
dot = path.rfind(".")
|
|
104
|
+
return _EXT_LANG.get(path[dot:].lower(), "") if dot != -1 else ""
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
chunking/treesitter_chunker.py — AST-based code chunker
|
|
3
|
+
=========================================================
|
|
4
|
+
Uses tree-sitter to split files into function/class-level chunks.
|
|
5
|
+
Falls back to file-level chunking for unsupported languages.
|
|
6
|
+
|
|
7
|
+
Token counting:
|
|
8
|
+
- tiktoken (cl100k_base) for accurate GPT/Codex token counts
|
|
9
|
+
- Falls back to len//3.5 heuristic if tiktoken unavailable
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
# ── Token counting ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import tiktoken as _tiktoken
|
|
23
|
+
# cl100k_base covers: gpt-4, gpt-3.5-turbo, gpt-4o, codex-mini, o1/o3/o4
|
|
24
|
+
# o200k_base covers: gpt-4o (newer), but cl100k_base is ≥95% accurate for both
|
|
25
|
+
_ENCODER = _tiktoken.get_encoding("cl100k_base")
|
|
26
|
+
_HAS_TIKTOKEN = True
|
|
27
|
+
except Exception:
|
|
28
|
+
_HAS_TIKTOKEN = False
|
|
29
|
+
_ENCODER = None # type: ignore[assignment]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def count_tokens(text: str) -> int:
|
|
33
|
+
"""
|
|
34
|
+
Count tokens in text.
|
|
35
|
+
|
|
36
|
+
Uses tiktoken (cl100k_base) for OpenAI-compatible accuracy.
|
|
37
|
+
Falls back to len/3.5 heuristic if tiktoken is unavailable.
|
|
38
|
+
|
|
39
|
+
cl100k_base is accurate for:
|
|
40
|
+
- GPT-4, GPT-4o, GPT-4.1, GPT-4.5, GPT-5.x
|
|
41
|
+
- GPT-3.5-turbo
|
|
42
|
+
- o1, o3, o4-mini
|
|
43
|
+
- codex-mini-latest
|
|
44
|
+
- Claude (close approximation, actual tokenizer differs ~5%)
|
|
45
|
+
- Gemini (close approximation)
|
|
46
|
+
"""
|
|
47
|
+
if not text:
|
|
48
|
+
return 0
|
|
49
|
+
if _HAS_TIKTOKEN and _ENCODER is not None:
|
|
50
|
+
return len(_ENCODER.encode(text, disallowed_special=()))
|
|
51
|
+
# Fallback: code typically has ratio 3.2–3.8 chars/token; use 3.5
|
|
52
|
+
return max(1, int(len(text) / 3.5))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Chunk dataclass ───────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Chunk:
|
|
59
|
+
id: str
|
|
60
|
+
path: str
|
|
61
|
+
type: str
|
|
62
|
+
symbol: str
|
|
63
|
+
content: str
|
|
64
|
+
summary: str
|
|
65
|
+
tokens: int
|
|
66
|
+
embedding: list[float] = field(default_factory=list)
|
|
67
|
+
imports: list[str] = field(default_factory=list)
|
|
68
|
+
exports: list[str] = field(default_factory=list)
|
|
69
|
+
priority: int = 5
|
|
70
|
+
workspace_id: str = "legacy_global"
|
|
71
|
+
start_line: int = 1
|
|
72
|
+
end_line: int = 1
|
|
73
|
+
content_hash: str = ""
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def stable_id(path: str, kind: str, symbol: str, start_line: int, end_line: int, content: str) -> str:
|
|
77
|
+
payload = f"{path}\0{kind}\0{symbol}\0{start_line}\0{end_line}\0{hashlib.sha256(content.encode('utf-8')).hexdigest()}"
|
|
78
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── AST Chunker ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
class ASTChunker:
|
|
84
|
+
def __init__(self):
|
|
85
|
+
try:
|
|
86
|
+
import tree_sitter
|
|
87
|
+
import tree_sitter_javascript
|
|
88
|
+
import tree_sitter_python
|
|
89
|
+
import tree_sitter_typescript
|
|
90
|
+
self.ts = tree_sitter
|
|
91
|
+
self.lang_py = tree_sitter.Language(tree_sitter_python.language())
|
|
92
|
+
self.lang_js = tree_sitter.Language(
|
|
93
|
+
tree_sitter_javascript.language())
|
|
94
|
+
self.lang_ts = tree_sitter.Language(
|
|
95
|
+
tree_sitter_typescript.language_typescript())
|
|
96
|
+
except ImportError:
|
|
97
|
+
self.ts = None
|
|
98
|
+
|
|
99
|
+
def get_parser(self, ext: str):
|
|
100
|
+
if not self.ts:
|
|
101
|
+
return None
|
|
102
|
+
parser = self.ts.Parser()
|
|
103
|
+
if ext == ".py":
|
|
104
|
+
parser.language = self.lang_py
|
|
105
|
+
elif ext in [".js", ".jsx"]:
|
|
106
|
+
parser.language = self.lang_js
|
|
107
|
+
elif ext in [".ts", ".tsx"]:
|
|
108
|
+
parser.language = self.lang_ts
|
|
109
|
+
else:
|
|
110
|
+
return None
|
|
111
|
+
return parser
|
|
112
|
+
|
|
113
|
+
def chunk_paths(self, paths: list[str]) -> list[Chunk]:
|
|
114
|
+
chunks = []
|
|
115
|
+
for path in paths:
|
|
116
|
+
if not os.path.exists(path):
|
|
117
|
+
continue
|
|
118
|
+
if os.path.isdir(path):
|
|
119
|
+
for root, _, files in os.walk(path):
|
|
120
|
+
for file in files:
|
|
121
|
+
filepath = os.path.join(root, file)
|
|
122
|
+
chunks.extend(self._chunk_file(filepath))
|
|
123
|
+
else:
|
|
124
|
+
chunks.extend(self._chunk_file(path))
|
|
125
|
+
return chunks
|
|
126
|
+
|
|
127
|
+
def _chunk_file(self, filepath: str) -> list[Chunk]:
|
|
128
|
+
chunks = []
|
|
129
|
+
ext = os.path.splitext(filepath)[1]
|
|
130
|
+
try:
|
|
131
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
132
|
+
content = f.read()
|
|
133
|
+
except Exception:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
if ext == ".py":
|
|
137
|
+
try:
|
|
138
|
+
tree = ast.parse(content)
|
|
139
|
+
except SyntaxError:
|
|
140
|
+
tree = None
|
|
141
|
+
if tree is not None:
|
|
142
|
+
lines = content.splitlines()
|
|
143
|
+
import_nodes = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))]
|
|
144
|
+
import_end = max((node.end_lineno or node.lineno for node in import_nodes), default=0)
|
|
145
|
+
import_context = "\n".join(lines[:import_end])
|
|
146
|
+
python_chunks = []
|
|
147
|
+
for node in tree.body:
|
|
148
|
+
if not isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
149
|
+
continue
|
|
150
|
+
start_line = min([node.lineno] + [decorator.lineno for decorator in node.decorator_list])
|
|
151
|
+
end_line = node.end_lineno or start_line
|
|
152
|
+
body = "\n".join(lines[start_line - 1:end_line])
|
|
153
|
+
chunk_content = f"{import_context}\n\n{body}" if import_context else body
|
|
154
|
+
kind = "class_definition" if isinstance(node, ast.ClassDef) else "function_definition"
|
|
155
|
+
python_chunks.append(Chunk(
|
|
156
|
+
id=Chunk.stable_id(filepath, kind, node.name, start_line, end_line, chunk_content),
|
|
157
|
+
path=filepath, type=kind, symbol=node.name, content=chunk_content,
|
|
158
|
+
summary=f"{kind} {node.name}", tokens=count_tokens(chunk_content),
|
|
159
|
+
start_line=start_line, end_line=end_line,
|
|
160
|
+
content_hash=hashlib.sha256(chunk_content.encode("utf-8")).hexdigest(),
|
|
161
|
+
))
|
|
162
|
+
if python_chunks:
|
|
163
|
+
return python_chunks
|
|
164
|
+
|
|
165
|
+
parser = self.get_parser(ext)
|
|
166
|
+
if parser is None:
|
|
167
|
+
# Fallback to file-level chunk
|
|
168
|
+
return [
|
|
169
|
+
Chunk(
|
|
170
|
+
id=Chunk.stable_id(filepath, "file", "module", 1, len(content.splitlines()), content),
|
|
171
|
+
path=filepath,
|
|
172
|
+
type="file",
|
|
173
|
+
symbol="module",
|
|
174
|
+
content=content,
|
|
175
|
+
summary="",
|
|
176
|
+
tokens=count_tokens(content),
|
|
177
|
+
start_line=1,
|
|
178
|
+
end_line=max(1, len(content.splitlines())),
|
|
179
|
+
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
180
|
+
)
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
tree = parser.parse(bytes(content, "utf8"))
|
|
184
|
+
root_node = tree.root_node
|
|
185
|
+
content_bytes = bytes(content, "utf8")
|
|
186
|
+
|
|
187
|
+
def traverse(node):
|
|
188
|
+
if node.type in [
|
|
189
|
+
"class_definition",
|
|
190
|
+
"function_definition",
|
|
191
|
+
"class_declaration",
|
|
192
|
+
"function_declaration",
|
|
193
|
+
"method_definition",
|
|
194
|
+
]:
|
|
195
|
+
chunk_content = content_bytes[
|
|
196
|
+
node.start_byte:node.end_byte
|
|
197
|
+
].decode("utf-8")
|
|
198
|
+
symbol_name = "unknown"
|
|
199
|
+
for child in node.children:
|
|
200
|
+
if child.type == "identifier":
|
|
201
|
+
symbol_name = content_bytes[
|
|
202
|
+
child.start_byte:child.end_byte
|
|
203
|
+
].decode("utf-8")
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
start_line = node.start_point[0] + 1
|
|
207
|
+
end_line = node.end_point[0] + 1
|
|
208
|
+
chunks.append(Chunk(
|
|
209
|
+
id=Chunk.stable_id(filepath, node.type, symbol_name, start_line, end_line, chunk_content),
|
|
210
|
+
path=filepath,
|
|
211
|
+
type=node.type,
|
|
212
|
+
symbol=symbol_name,
|
|
213
|
+
content=chunk_content,
|
|
214
|
+
summary=f"{node.type} {symbol_name}",
|
|
215
|
+
tokens=count_tokens(chunk_content),
|
|
216
|
+
start_line=start_line,
|
|
217
|
+
end_line=end_line,
|
|
218
|
+
content_hash=hashlib.sha256(chunk_content.encode("utf-8")).hexdigest(),
|
|
219
|
+
))
|
|
220
|
+
for child in node.children:
|
|
221
|
+
traverse(child)
|
|
222
|
+
|
|
223
|
+
traverse(root_node)
|
|
224
|
+
|
|
225
|
+
# If no chunks extracted, add file as a module chunk
|
|
226
|
+
if not chunks:
|
|
227
|
+
chunks.append(Chunk(
|
|
228
|
+
id=Chunk.stable_id(filepath, "module", "module", 1, len(content.splitlines()), content),
|
|
229
|
+
path=filepath,
|
|
230
|
+
type="module",
|
|
231
|
+
symbol="module",
|
|
232
|
+
content=content,
|
|
233
|
+
summary="",
|
|
234
|
+
tokens=count_tokens(content),
|
|
235
|
+
start_line=1,
|
|
236
|
+
end_line=max(1, len(content.splitlines())),
|
|
237
|
+
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
return chunks
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
compact/anthropic.py — Anthropic-specific format helpers
|
|
3
|
+
=========================================================
|
|
4
|
+
Provides a standalone function for summarizing Anthropic-format
|
|
5
|
+
old messages. The main compaction entry-point is in summarizer.py.
|
|
6
|
+
|
|
7
|
+
Anthropic content blocks may be:
|
|
8
|
+
- str
|
|
9
|
+
- list[dict] with type: "text" | "image" | "tool_use" | "tool_result"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _extract_text(content: Any) -> str:
|
|
19
|
+
"""Flatten Anthropic content to plain text."""
|
|
20
|
+
if isinstance(content, str):
|
|
21
|
+
return content
|
|
22
|
+
if isinstance(content, list):
|
|
23
|
+
parts: list[str] = []
|
|
24
|
+
for block in content:
|
|
25
|
+
if not isinstance(block, dict):
|
|
26
|
+
continue
|
|
27
|
+
t = block.get("type", "")
|
|
28
|
+
if t == "text":
|
|
29
|
+
parts.append(block.get("text", ""))
|
|
30
|
+
elif t == "image":
|
|
31
|
+
parts.append("[image]")
|
|
32
|
+
elif t == "tool_use":
|
|
33
|
+
parts.append(f"[tool: {block.get('name', '')}]")
|
|
34
|
+
elif t == "tool_result":
|
|
35
|
+
inner = block.get("content", "")
|
|
36
|
+
if isinstance(inner, str):
|
|
37
|
+
parts.append(f"[tool_result: {inner[:80]}]")
|
|
38
|
+
return " ".join(parts)
|
|
39
|
+
return str(content) if content is not None else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def summarize_anthropic_history(
|
|
43
|
+
old_messages: list[dict],
|
|
44
|
+
model: str = "",
|
|
45
|
+
) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Produce a compact plain-text summary of old_messages.
|
|
48
|
+
|
|
49
|
+
:param old_messages: Messages being replaced (excludes retained turns).
|
|
50
|
+
:param model: Target Claude model string (for future tuning).
|
|
51
|
+
:returns: Plain-text summary injected into the summary message.
|
|
52
|
+
"""
|
|
53
|
+
if not old_messages:
|
|
54
|
+
return "No prior conversation history."
|
|
55
|
+
|
|
56
|
+
user_intents: list[str] = []
|
|
57
|
+
assistant_actions: list[str] = []
|
|
58
|
+
tool_calls_seen: list[str] = []
|
|
59
|
+
|
|
60
|
+
for m in old_messages:
|
|
61
|
+
role = m.get("role", "")
|
|
62
|
+
text = _extract_text(m.get("content") or "").strip()
|
|
63
|
+
|
|
64
|
+
if role == "user" and text:
|
|
65
|
+
first = re.split(r"[.!?\n]", text)[0].strip()
|
|
66
|
+
if first:
|
|
67
|
+
user_intents.append(first[:120])
|
|
68
|
+
|
|
69
|
+
elif role == "assistant":
|
|
70
|
+
content = m.get("content", [])
|
|
71
|
+
if isinstance(content, list):
|
|
72
|
+
for block in content:
|
|
73
|
+
if isinstance(block, dict):
|
|
74
|
+
if block.get("type") == "tool_use":
|
|
75
|
+
tool_calls_seen.append(block.get("name", ""))
|
|
76
|
+
elif block.get("type") == "text":
|
|
77
|
+
t = block.get("text", "").strip()
|
|
78
|
+
if t:
|
|
79
|
+
first = re.split(r"[.!?\n]", t)[0].strip()
|
|
80
|
+
if first:
|
|
81
|
+
assistant_actions.append(first[:120])
|
|
82
|
+
elif text:
|
|
83
|
+
first = re.split(r"[.!?\n]", text)[0].strip()
|
|
84
|
+
if first:
|
|
85
|
+
assistant_actions.append(first[:120])
|
|
86
|
+
|
|
87
|
+
lines: list[str] = []
|
|
88
|
+
if user_intents:
|
|
89
|
+
lines.append(f"User requests: {'; '.join(user_intents[:5])}")
|
|
90
|
+
if assistant_actions:
|
|
91
|
+
lines.append(f"Assistant actions: {'; '.join(assistant_actions[:5])}")
|
|
92
|
+
if tool_calls_seen:
|
|
93
|
+
unique = list(dict.fromkeys(tool_calls_seen))[:8]
|
|
94
|
+
lines.append(f"Tools used: {', '.join(unique)}")
|
|
95
|
+
|
|
96
|
+
return "\n".join(lines) if lines else (
|
|
97
|
+
"Prior conversation turns omitted for context length optimization (Anthropic)."
|
|
98
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
compact/gemini.py — Gemini-specific format helpers
|
|
3
|
+
===================================================
|
|
4
|
+
Gemini uses role + parts[] instead of role + content.
|
|
5
|
+
Parts can be: text str, inline_data (images), function_call, function_response.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _extract_text_from_parts(parts: Any) -> str:
|
|
15
|
+
"""Flatten Gemini parts array to plain text."""
|
|
16
|
+
if not isinstance(parts, list):
|
|
17
|
+
return str(parts) if parts else ""
|
|
18
|
+
texts: list[str] = []
|
|
19
|
+
for p in parts:
|
|
20
|
+
if isinstance(p, str):
|
|
21
|
+
texts.append(p)
|
|
22
|
+
elif isinstance(p, dict):
|
|
23
|
+
if "text" in p:
|
|
24
|
+
texts.append(p["text"])
|
|
25
|
+
elif p.get("inline_data"):
|
|
26
|
+
texts.append("[inline_data]")
|
|
27
|
+
elif p.get("function_call"):
|
|
28
|
+
fn = p["function_call"].get("name", "")
|
|
29
|
+
texts.append(f"[function_call: {fn}]")
|
|
30
|
+
elif p.get("function_response"):
|
|
31
|
+
fn = p["function_response"].get("name", "")
|
|
32
|
+
texts.append(f"[function_response: {fn}]")
|
|
33
|
+
return " ".join(texts)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def summarize_gemini_history(
|
|
37
|
+
old_messages: list[dict],
|
|
38
|
+
model: str = "",
|
|
39
|
+
) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Produce a compact plain-text summary of old_messages in Gemini format.
|
|
42
|
+
|
|
43
|
+
:param old_messages: Messages being replaced (excludes retained turns).
|
|
44
|
+
:param model: Target Gemini model string.
|
|
45
|
+
:returns: Plain-text summary.
|
|
46
|
+
"""
|
|
47
|
+
if not old_messages:
|
|
48
|
+
return "No prior conversation history."
|
|
49
|
+
|
|
50
|
+
user_intents: list[str] = []
|
|
51
|
+
model_actions: list[str] = []
|
|
52
|
+
fn_calls_seen: list[str] = []
|
|
53
|
+
|
|
54
|
+
for m in old_messages:
|
|
55
|
+
role = m.get("role", "") # "user" | "model"
|
|
56
|
+
parts = m.get("parts", [])
|
|
57
|
+
|
|
58
|
+
# Collect function calls
|
|
59
|
+
for p in parts if isinstance(parts, list) else []:
|
|
60
|
+
if isinstance(p, dict):
|
|
61
|
+
if p.get("function_call"):
|
|
62
|
+
fn = p["function_call"].get("name", "")
|
|
63
|
+
if fn:
|
|
64
|
+
fn_calls_seen.append(fn)
|
|
65
|
+
|
|
66
|
+
text = _extract_text_from_parts(parts).strip()
|
|
67
|
+
|
|
68
|
+
if role == "user" and text:
|
|
69
|
+
first = re.split(r"[.!?\n]", text)[0].strip()
|
|
70
|
+
if first:
|
|
71
|
+
user_intents.append(first[:120])
|
|
72
|
+
elif role == "model" and text:
|
|
73
|
+
first = re.split(r"[.!?\n]", text)[0].strip()
|
|
74
|
+
if first:
|
|
75
|
+
model_actions.append(first[:120])
|
|
76
|
+
|
|
77
|
+
lines: list[str] = []
|
|
78
|
+
if user_intents:
|
|
79
|
+
lines.append(f"User requests: {'; '.join(user_intents[:5])}")
|
|
80
|
+
if model_actions:
|
|
81
|
+
lines.append(f"Model actions: {'; '.join(model_actions[:5])}")
|
|
82
|
+
if fn_calls_seen:
|
|
83
|
+
unique = list(dict.fromkeys(fn_calls_seen))[:8]
|
|
84
|
+
lines.append(f"Functions called: {', '.join(unique)}")
|
|
85
|
+
|
|
86
|
+
return "\n".join(lines) if lines else (
|
|
87
|
+
"Prior conversation turns omitted for context length optimization (Gemini)."
|
|
88
|
+
)
|