ctxora 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +441 -0
  3. package/README.vi.md +441 -0
  4. package/bin/ctxora.mjs +147 -0
  5. package/package.json +45 -0
  6. package/pyproject.toml +59 -0
  7. package/src/chunking/compressor.py +104 -0
  8. package/src/chunking/treesitter_chunker.py +240 -0
  9. package/src/compact/anthropic.py +98 -0
  10. package/src/compact/gemini.py +88 -0
  11. package/src/compact/handoff.py +179 -0
  12. package/src/compact/openai.py +318 -0
  13. package/src/compact/summarizer.py +186 -0
  14. package/src/context/assembler.py +298 -0
  15. package/src/context/budgeting.py +137 -0
  16. package/src/context/sanitizer.py +23 -0
  17. package/src/evaluation/__init__.py +1 -0
  18. package/src/evaluation/gates.py +172 -0
  19. package/src/evaluation/metrics.py +41 -0
  20. package/src/harness_context/__init__.py +5 -0
  21. package/src/harness_context/adapters/__init__.py +1 -0
  22. package/src/harness_context/adapters/clients/__init__.py +4 -0
  23. package/src/harness_context/adapters/clients/formatters.py +47 -0
  24. package/src/harness_context/adapters/clients/profiles.py +29 -0
  25. package/src/harness_context/adapters/ecc/__init__.py +4 -0
  26. package/src/harness_context/adapters/ecc/detection.py +41 -0
  27. package/src/harness_context/adapters/ecc/mapping.py +32 -0
  28. package/src/harness_context/adapters/ecc/memory_reader.py +162 -0
  29. package/src/harness_context/adapters/ecc/provenance.py +16 -0
  30. package/src/harness_context/api/__init__.py +1 -0
  31. package/src/harness_context/api/v2/__init__.py +12 -0
  32. package/src/harness_context/api/v2/contracts.py +119 -0
  33. package/src/harness_context/api/v2/diagnostics.py +13 -0
  34. package/src/harness_context/api/v2/enums.py +17 -0
  35. package/src/harness_context/api/v2/errors.py +32 -0
  36. package/src/harness_context/api/v2/models.py +4 -0
  37. package/src/harness_context/api/v2/requests.py +17 -0
  38. package/src/harness_context/api/v2/responses.py +22 -0
  39. package/src/harness_context/application/__init__.py +3 -0
  40. package/src/harness_context/application/container.py +31 -0
  41. package/src/harness_context/application/context_service.py +51 -0
  42. package/src/harness_context/application/ecc_service.py +7 -0
  43. package/src/harness_context/application/handoff_service.py +11 -0
  44. package/src/harness_context/application/memory_service.py +9 -0
  45. package/src/harness_context/application/protocols.py +46 -0
  46. package/src/harness_context/application/refresh_service.py +25 -0
  47. package/src/harness_context/application/retrieval_service.py +22 -0
  48. package/src/harness_context/application/services.py +4 -0
  49. package/src/harness_context/application/workspace_service.py +18 -0
  50. package/src/harness_context/bootstrap.py +47 -0
  51. package/src/harness_context/branding.py +16 -0
  52. package/src/harness_context/cli/__init__.py +1 -0
  53. package/src/harness_context/cli/app.py +239 -0
  54. package/src/harness_context/cli/exit_codes.py +25 -0
  55. package/src/harness_context/domain/__init__.py +9 -0
  56. package/src/harness_context/domain/cag.py +18 -0
  57. package/src/harness_context/domain/chunking.py +17 -0
  58. package/src/harness_context/domain/planning.py +30 -0
  59. package/src/harness_context/domain/ports.py +24 -0
  60. package/src/harness_context/domain/retrieval.py +46 -0
  61. package/src/harness_context/engine.py +10 -0
  62. package/src/harness_context/free_tools.py +143 -0
  63. package/src/harness_context/infrastructure/__init__.py +10 -0
  64. package/src/harness_context/infrastructure/graph.py +26 -0
  65. package/src/harness_context/infrastructure/indexes.py +33 -0
  66. package/src/harness_context/infrastructure/local_engine.py +296 -0
  67. package/src/harness_context/infrastructure/parsing.py +38 -0
  68. package/src/harness_context/infrastructure/scanning.py +51 -0
  69. package/src/harness_context/installer/__init__.py +4 -0
  70. package/src/harness_context/installer/models.py +22 -0
  71. package/src/harness_context/installer/service.py +168 -0
  72. package/src/harness_context/mcp/__init__.py +3 -0
  73. package/src/harness_context/mcp/capabilities.py +11 -0
  74. package/src/harness_context/mcp/errors.py +8 -0
  75. package/src/harness_context/mcp/lifecycle.py +72 -0
  76. package/src/harness_context/mcp/middleware.py +57 -0
  77. package/src/harness_context/mcp/server.py +3 -0
  78. package/src/harness_context/mcp/tool_handlers/__init__.py +7 -0
  79. package/src/harness_context/mcp/tool_handlers/context.py +16 -0
  80. package/src/harness_context/mcp/tool_handlers/ecc.py +8 -0
  81. package/src/harness_context/mcp/tool_handlers/handoffs.py +20 -0
  82. package/src/harness_context/mcp/tool_handlers/memory.py +16 -0
  83. package/src/harness_context/mcp/tool_handlers/workspace.py +12 -0
  84. package/src/harness_context/mcp/tools.py +15 -0
  85. package/src/harness_context/observability/__init__.py +6 -0
  86. package/src/harness_context/observability/events.py +25 -0
  87. package/src/harness_context/observability/metrics.py +20 -0
  88. package/src/harness_context/paths.py +35 -0
  89. package/src/harness_context/runtime.py +127 -0
  90. package/src/harness_context/schemas.py +38 -0
  91. package/src/harness_context/security/__init__.py +3 -0
  92. package/src/harness_context/security/secret_patterns.py +15 -0
  93. package/src/harness_context/server.py +1077 -0
  94. package/src/harness_context/storage/__init__.py +6 -0
  95. package/src/harness_context/storage/migrations.py +24 -0
  96. package/src/harness_context/storage/pins.py +10 -0
  97. package/src/harness_context/storage/snapshots.py +149 -0
  98. package/src/harness_context/tokenize.py +12 -0
  99. package/src/harness_context/topology.py +65 -0
  100. package/src/harness_context/watcher/__init__.py +3 -0
  101. package/src/harness_context/watcher/service.py +32 -0
  102. package/src/harness_context/workspace/__init__.py +13 -0
  103. package/src/harness_context/workspace/identity.py +9 -0
  104. package/src/harness_context/workspace/lock.py +24 -0
  105. package/src/harness_context/workspace/policy.py +3 -0
  106. package/src/harness_context/workspace/roots.py +84 -0
  107. package/src/harness_context/workspace/state.py +35 -0
  108. package/src/memory/episodic.py +257 -0
  109. package/src/memory/vector_store.py +104 -0
  110. package/src/retrieval/bm25.py +23 -0
  111. package/src/retrieval/cache.py +76 -0
  112. package/src/retrieval/embeddings.py +75 -0
  113. package/src/retrieval/graph.py +45 -0
  114. package/src/retrieval/reranker.py +78 -0
  115. package/src/retrieval/tokenize.py +11 -0
@@ -0,0 +1,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
+ )