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,179 @@
1
+ """Persistent, no-compression conversation handoff records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ import time
10
+ import uuid
11
+
12
+ from chunking.treesitter_chunker import count_tokens
13
+
14
+ DEFAULT_HANDOFF_THRESHOLD_TOKENS = 30_000
15
+
16
+
17
+ def _detect_format(messages: list[dict]) -> str:
18
+ """Detect provider message formats without importing the retired compactor."""
19
+ if not messages:
20
+ return "unknown"
21
+ if any("parts" in message for message in messages):
22
+ return "gemini"
23
+ if any(isinstance(message.get("content"), list) for message in messages):
24
+ return "anthropic"
25
+ return "openai"
26
+
27
+
28
+ class ConversationHandoffStore:
29
+ """Store original conversation payloads for a client-created fresh task."""
30
+
31
+ def __init__(self, db_path: str | None = None):
32
+ self.db_path = (
33
+ db_path
34
+ or os.environ.get("CTXORA_HANDOFF_DB")
35
+ or os.environ.get("MCP_HARNESS_HANDOFF_DB")
36
+ or os.path.expanduser("~/.ctxora/handoffs.sqlite3")
37
+ )
38
+ if self.db_path != ":memory:":
39
+ os.makedirs(os.path.dirname(os.path.abspath(self.db_path)), exist_ok=True)
40
+ self._conn = sqlite3.connect(self.db_path, timeout=5)
41
+ self._conn.row_factory = sqlite3.Row
42
+ self._conn.execute("PRAGMA journal_mode=WAL")
43
+ self._conn.execute("PRAGMA busy_timeout=5000")
44
+ self._conn.execute("""
45
+ CREATE TABLE IF NOT EXISTS conversation_handoffs_v2 (
46
+ handoff_id TEXT PRIMARY KEY,
47
+ workspace_id TEXT NOT NULL DEFAULT 'legacy_global',
48
+ source_hash TEXT NOT NULL,
49
+ provider TEXT NOT NULL,
50
+ messages_json TEXT NOT NULL,
51
+ token_count INTEGER NOT NULL,
52
+ label TEXT NOT NULL DEFAULT '',
53
+ created_at REAL NOT NULL,
54
+ expires_at REAL,
55
+ UNIQUE(workspace_id, source_hash)
56
+ )
57
+ """)
58
+ if self._table_exists("conversation_handoffs"):
59
+ self._conn.execute("""
60
+ INSERT OR IGNORE INTO conversation_handoffs_v2 (
61
+ handoff_id, workspace_id, source_hash, provider,
62
+ messages_json, token_count, label, created_at
63
+ )
64
+ SELECT handoff_id, 'legacy_global', source_hash, provider,
65
+ messages_json, token_count, label, created_at
66
+ FROM conversation_handoffs
67
+ """)
68
+ self._conn.commit()
69
+
70
+ def _table_exists(self, name: str) -> bool:
71
+ return self._conn.execute(
72
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (name,)
73
+ ).fetchone() is not None
74
+
75
+ @staticmethod
76
+ def _parse_messages(messages_json: str) -> list[dict]:
77
+ try:
78
+ messages = json.loads(messages_json)
79
+ except (json.JSONDecodeError, ValueError) as error:
80
+ raise ValueError("messages_json must be a JSON array") from error
81
+ if not isinstance(messages, list) or not all(isinstance(item, dict) for item in messages):
82
+ raise ValueError("messages_json must be a JSON array of message objects")
83
+ return messages
84
+
85
+ def prepare(
86
+ self,
87
+ messages_json: str,
88
+ threshold_tokens: int = DEFAULT_HANDOFF_THRESHOLD_TOKENS,
89
+ label: str = "",
90
+ workspace_id: str = "legacy_global",
91
+ retention_seconds: int = 604_800,
92
+ consent: bool = True,
93
+ ) -> dict:
94
+ """Return continue below threshold or persist an exact handoff payload."""
95
+ messages = self._parse_messages(messages_json)
96
+ token_count = count_tokens(messages_json)
97
+ threshold_tokens = max(1, int(threshold_tokens))
98
+ provider = _detect_format(messages)
99
+ base = {
100
+ "history_tokens": token_count,
101
+ "threshold_tokens": threshold_tokens,
102
+ "provider": provider,
103
+ "message_count": len(messages),
104
+ }
105
+ if token_count < threshold_tokens:
106
+ return {"action": "continue", **base}
107
+ if not consent:
108
+ raise ValueError("explicit consent is required before storing raw conversations")
109
+
110
+ source_hash = hashlib.sha256(messages_json.encode("utf-8")).hexdigest()
111
+ existing = self._conn.execute(
112
+ "SELECT handoff_id, created_at FROM conversation_handoffs_v2 WHERE workspace_id = ? AND source_hash = ?",
113
+ (workspace_id, source_hash),
114
+ ).fetchone()
115
+ if existing is None:
116
+ handoff_id = str(uuid.uuid4())
117
+ created_at = time.time()
118
+ self._conn.execute("""
119
+ INSERT INTO conversation_handoffs_v2 (
120
+ handoff_id, workspace_id, source_hash, provider, messages_json,
121
+ token_count, label, created_at, expires_at
122
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
123
+ """, (
124
+ handoff_id, workspace_id, source_hash, provider, messages_json,
125
+ token_count, label, created_at, created_at + max(1, retention_seconds),
126
+ ))
127
+ self._conn.commit()
128
+ else:
129
+ handoff_id = existing["handoff_id"]
130
+ created_at = existing["created_at"]
131
+
132
+ return {
133
+ "action": "handoff",
134
+ "handoff_id": handoff_id,
135
+ "created_at": created_at,
136
+ "preserved_without_compaction": True,
137
+ "next_tool": "restore_conversation_handoff",
138
+ **base,
139
+ }
140
+
141
+ def restore(self, handoff_id: str, workspace_id: str = "legacy_global") -> dict | None:
142
+ """Return the original, unmodified JSON payload for an explicit restore."""
143
+ row = self._conn.execute(
144
+ "SELECT * FROM conversation_handoffs_v2 WHERE handoff_id = ? AND workspace_id = ? AND (expires_at IS NULL OR expires_at > ?)", (handoff_id, workspace_id, time.time())
145
+ ).fetchone()
146
+ if row is None:
147
+ return None
148
+ return {
149
+ "handoff_id": row["handoff_id"],
150
+ "workspace_id": row["workspace_id"],
151
+ "provider": row["provider"],
152
+ "history_tokens": row["token_count"],
153
+ "label": row["label"],
154
+ "created_at": row["created_at"],
155
+ "messages_json": row["messages_json"],
156
+ }
157
+
158
+ def list(self, workspace_id: str = "legacy_global", limit: int = 30) -> list[dict]:
159
+ rows = self._conn.execute(
160
+ "SELECT handoff_id, provider, token_count, label, created_at, expires_at FROM conversation_handoffs_v2 WHERE workspace_id = ? ORDER BY created_at DESC LIMIT ?",
161
+ (workspace_id, max(0, limit)),
162
+ ).fetchall()
163
+ return [dict(row) for row in rows]
164
+
165
+ def delete(self, handoff_id: str, workspace_id: str = "legacy_global") -> bool:
166
+ cursor = self._conn.execute(
167
+ "DELETE FROM conversation_handoffs_v2 WHERE handoff_id = ? AND workspace_id = ?",
168
+ (handoff_id, workspace_id),
169
+ )
170
+ self._conn.commit()
171
+ return cursor.rowcount > 0
172
+
173
+ def purge_expired(self) -> int:
174
+ cursor = self._conn.execute(
175
+ "DELETE FROM conversation_handoffs_v2 WHERE expires_at IS NOT NULL AND expires_at <= ?",
176
+ (time.time(),),
177
+ )
178
+ self._conn.commit()
179
+ return cursor.rowcount
@@ -0,0 +1,318 @@
1
+ """
2
+ compact/openai.py — OpenAI / Codex format compaction
3
+ =====================================================
4
+ Handles both Chat Completions format and legacy Completions format.
5
+
6
+ Supported model families:
7
+ - gpt-4o, gpt-4, gpt-3.5-turbo (chat)
8
+ - o1, o3, o3-mini, o4-mini (reasoning)
9
+ - codex (code-davinci-002, etc.) (legacy completions → lifted to chat)
10
+ - gpt-4.1, gpt-4.5 (latest chat)
11
+
12
+ Message roles recognised:
13
+ system | user | assistant | tool | function | developer
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Any
20
+
21
+ # ── Helpers ──────────────────────────────────────────────────────────────────
22
+
23
+ def _extract_text(content: Any) -> str:
24
+ """
25
+ Flatten OpenAI content to plain text.
26
+
27
+ OpenAI content can be:
28
+ - str
29
+ - list[dict] (multi-modal: [{type: "text", text: "..."}, {type: "image_url", ...}])
30
+ """
31
+ if isinstance(content, str):
32
+ return content
33
+ if isinstance(content, list):
34
+ parts: list[str] = []
35
+ for part in content:
36
+ if isinstance(part, dict):
37
+ t = part.get("type", "")
38
+ if t == "text":
39
+ parts.append(part.get("text", ""))
40
+ elif t == "image_url":
41
+ parts.append("[image]")
42
+ elif t == "input_audio":
43
+ parts.append("[audio]")
44
+ elif t == "refusal":
45
+ parts.append(f"[refusal: {part.get('refusal', '')}]")
46
+ return " ".join(parts)
47
+ return str(content) if content is not None else ""
48
+
49
+
50
+ def _is_openai_format(messages: list[dict]) -> bool:
51
+ """
52
+ Heuristic: at least one message has 'role' key matching OpenAI roles.
53
+ """
54
+ openai_roles = {"system", "user", "assistant", "tool",
55
+ "function", "developer"}
56
+ for m in messages[:5]:
57
+ if isinstance(m, dict) and m.get("role") in openai_roles:
58
+ return True
59
+ return False
60
+
61
+
62
+ def _summarize_chunk(messages: list[dict]) -> str:
63
+ """
64
+ Build a structured summary of the omitted message chunk.
65
+ Extracts user intents + assistant key responses without calling an LLM.
66
+ """
67
+ user_intents: list[str] = []
68
+ assistant_actions: list[str] = []
69
+ tool_calls_seen: list[str] = []
70
+ code_snippets: int = 0
71
+
72
+ for m in messages:
73
+ role = m.get("role", "")
74
+ text = _extract_text(m.get("content") or "").strip()
75
+
76
+ if role == "system":
77
+ continue # system prompts handled separately
78
+
79
+ elif role in ("user", "developer"):
80
+ if text:
81
+ # Condense: first sentence or first 120 chars
82
+ first = re.split(r"[.!?\n]", text)[0].strip()
83
+ if first:
84
+ user_intents.append(first[:120])
85
+
86
+ elif role == "assistant":
87
+ # Count code blocks as signals
88
+ code_snippets += len(re.findall(r"```", text)) // 2
89
+
90
+ # Tool calls in structured format
91
+ tool_calls = m.get("tool_calls") or []
92
+ for tc in tool_calls:
93
+ fn = tc.get("function", {}).get("name", "")
94
+ if fn:
95
+ tool_calls_seen.append(fn)
96
+
97
+ if text and not tool_calls:
98
+ first = re.split(r"[.!?\n]", text)[0].strip()
99
+ if first:
100
+ assistant_actions.append(first[:120])
101
+
102
+ elif role == "tool":
103
+ # Tool responses: just note the tool name
104
+ name = m.get("name") or ""
105
+ if name:
106
+ tool_calls_seen.append(f"{name}→result")
107
+
108
+ elif role == "function":
109
+ name = m.get("name") or ""
110
+ if name:
111
+ tool_calls_seen.append(f"{name}→result")
112
+
113
+ lines: list[str] = []
114
+ if user_intents:
115
+ lines.append(f"User requests: {'; '.join(user_intents[:5])}")
116
+ if assistant_actions:
117
+ lines.append(f"Assistant actions: {'; '.join(assistant_actions[:5])}")
118
+ if tool_calls_seen:
119
+ unique_calls = list(dict.fromkeys(tool_calls_seen))[:8]
120
+ lines.append(f"Tools used: {', '.join(unique_calls)}")
121
+ if code_snippets:
122
+ lines.append(f"Code blocks generated: {code_snippets}")
123
+
124
+ return "\n".join(lines) if lines else (
125
+ "Prior conversation turns omitted for context length optimization."
126
+ )
127
+
128
+
129
+ def _cap_summary(text: str, max_tokens: int) -> str:
130
+ """
131
+ Truncate summary to stay within max_tokens.
132
+ Uses tiktoken if available, else len/3.5 heuristic.
133
+ """
134
+ try:
135
+ import tiktoken as _tiktoken
136
+ enc = _tiktoken.get_encoding("cl100k_base")
137
+ tokens = enc.encode(text, disallowed_special=())
138
+ if len(tokens) <= max_tokens:
139
+ return text
140
+ # Decode truncated token list back to string
141
+ return enc.decode(tokens[:max_tokens]) + "..."
142
+ except Exception:
143
+ # Fallback: approx 3.5 chars/token
144
+ char_limit = max_tokens * 3
145
+ return text[:char_limit] + ("..." if len(text) > char_limit else "")
146
+
147
+
148
+ def _summary_token_cap(model: str) -> int:
149
+ """
150
+ Max tokens for the <conversation_summary> block, by model family.
151
+ Codex legacy is very small; GPT-4.x/o-series are generous; large context models can handle more.
152
+ """
153
+ ml = model.lower()
154
+ # Legacy Codex (8k context) — keep summary tiny
155
+ if any(kw in ml for kw in ("code-davinci", "code-cushman", "codex-001", "codex-002")):
156
+ return 100
157
+ # codex-mini / gpt-4o-mini / small models
158
+ if any(kw in ml for kw in ("codex-mini", "gpt-4o-mini", "gpt-3.5", "o1-mini", "o3-mini")):
159
+ return 200
160
+ # Standard GPT-4.x, o1, o3, o4-mini, gpt-5.x
161
+ if any(kw in ml for kw in ("gpt-4", "gpt-5", "o1", "o3", "o4")):
162
+ return 350
163
+ # Large-context models (Claude, Gemini) — be generous
164
+ return 500
165
+
166
+
167
+ def _count_tokens(text: str) -> int:
168
+ try:
169
+ import tiktoken as _tiktoken
170
+ enc = _tiktoken.get_encoding("cl100k_base")
171
+ return len(enc.encode(text, disallowed_special=()))
172
+ except Exception:
173
+ return max(1, int(len(text) / 3.5)) if text else 0
174
+
175
+
176
+ def _cap_text_tokens(text: str, max_tokens: int) -> str:
177
+ if max_tokens <= 0 or _count_tokens(text) <= max_tokens:
178
+ return text
179
+ try:
180
+ import tiktoken as _tiktoken
181
+ enc = _tiktoken.get_encoding("cl100k_base")
182
+ tokens = enc.encode(text, disallowed_special=())
183
+ return enc.decode(tokens[:max_tokens]) + "..."
184
+ except Exception:
185
+ return text[:max_tokens * 3] + "..."
186
+
187
+
188
+ def _compact_recent_tool_outputs(
189
+ messages: list[dict],
190
+ max_recent_tool_tokens: int,
191
+ ) -> list[dict]:
192
+ """
193
+ Keep recent message structure intact, but cap very large tool/function
194
+ payloads so retained turns cannot carry raw logs or diffs indefinitely.
195
+ """
196
+ if max_recent_tool_tokens <= 0:
197
+ return messages
198
+
199
+ compacted: list[dict] = []
200
+ for message in messages:
201
+ role = message.get("role")
202
+ if role not in ("tool", "function"):
203
+ compacted.append(message)
204
+ continue
205
+
206
+ content = message.get("content")
207
+ text = _extract_text(content)
208
+ if _count_tokens(text) <= max_recent_tool_tokens:
209
+ compacted.append(message)
210
+ continue
211
+
212
+ capped = _cap_text_tokens(text, max_recent_tool_tokens)
213
+ next_message = dict(message)
214
+ next_message["content"] = (
215
+ "[tool output compacted; original exceeded "
216
+ f"{max_recent_tool_tokens} tokens]\n{capped}"
217
+ )
218
+ compacted.append(next_message)
219
+
220
+ return compacted
221
+
222
+
223
+ # ── Public API ────────────────────────────────────────────────────────────────
224
+
225
+ def summarize_openai_history(
226
+ old_messages: list[dict],
227
+ model: str = "",
228
+ ) -> str:
229
+ """
230
+ Produce a compact plain-text summary of old_messages.
231
+ Used by compact_messages() to build the <conversation_summary> block.
232
+
233
+ :param old_messages: Messages being replaced (already sliced — excludes
234
+ the system prompt and recent retained turns).
235
+ :param model: Target model string (for any model-specific tweaks).
236
+ :returns: Plain-text summary injected into the summary message.
237
+ """
238
+ if not old_messages:
239
+ return "No prior conversation history."
240
+ return _summarize_chunk(old_messages)
241
+
242
+
243
+ def compact_openai_messages(
244
+ messages: list[dict],
245
+ retain_turns: int = 2,
246
+ model: str = "",
247
+ max_recent_tool_tokens: int = 800,
248
+ ) -> list[dict]:
249
+ """
250
+ Compact an OpenAI Chat Completions message array.
251
+
252
+ Strategy:
253
+ 1. Separate system / developer messages (always kept first).
254
+ 2. Keep the last `retain_turns` user+assistant exchange pairs intact.
255
+ 3. Replace everything in between with a single summary user message
256
+ containing a <conversation_summary> XML block.
257
+ 4. For reasoning models (o1/o3/o4) inject summary as a 'user' role
258
+ (they don't support 'system' injections in all contexts).
259
+
260
+ :param messages: Full message list in OpenAI format.
261
+ :param retain_turns: Number of recent exchange pairs to keep.
262
+ :param model: Model name for format tuning.
263
+ :param max_recent_tool_tokens: Cap retained tool/function payloads.
264
+ :returns: Compacted message list.
265
+ """
266
+ if not messages:
267
+ return messages
268
+
269
+ # ── 1. Peel off leading system/developer messages ─────────────────────────────
270
+ prefix: list[dict] = []
271
+ body: list[dict] = []
272
+ in_prefix = True
273
+ for m in messages:
274
+ if in_prefix and m.get("role") in ("system", "developer"):
275
+ prefix.append(m)
276
+ else:
277
+ in_prefix = False
278
+ body.append(m)
279
+
280
+ # ── 2. Determine retain window ────────────────────────────────────────────
281
+ # Each "turn" = one user message + one assistant message (2 items)
282
+ retain_turns = max(0, int(retain_turns))
283
+ retain_msgs = retain_turns * 2
284
+ if len(body) <= retain_msgs:
285
+ return prefix + _compact_recent_tool_outputs(
286
+ body, max_recent_tool_tokens=max_recent_tool_tokens)
287
+
288
+ if retain_msgs == 0:
289
+ old_body = body
290
+ recent_body = []
291
+ else:
292
+ old_body = body[:-retain_msgs]
293
+ recent_body = body[-retain_msgs:]
294
+
295
+ # ── 3. Build + cap summary ─────────────────────────────────────────────────
296
+ raw_summary = summarize_openai_history(old_body, model=model)
297
+ max_tok = _summary_token_cap(model)
298
+ summary_text = _cap_summary(raw_summary, max_tok)
299
+
300
+ is_reasoning = any(
301
+ tag in model.lower() for tag in ("o1", "o3", "o4")
302
+ )
303
+ # All injections use 'user' role (safe for both chat and reasoning models)
304
+ _ = is_reasoning # kept for future per-model tweaks
305
+
306
+ summary_message: dict = {
307
+ "role": "user",
308
+ "content": (
309
+ f"<conversation_summary>\n"
310
+ f"{summary_text}\n"
311
+ f"</conversation_summary>"
312
+ ),
313
+ }
314
+
315
+ recent_body = _compact_recent_tool_outputs(
316
+ recent_body, max_recent_tool_tokens=max_recent_tool_tokens)
317
+
318
+ return prefix + [summary_message] + recent_body
@@ -0,0 +1,186 @@
1
+ """
2
+ compact/summarizer.py — Auto-format conversation compaction
3
+ ============================================================
4
+ Detects OpenAI / Anthropic / Gemini message format and dispatches
5
+ to the appropriate provider-specific compaction logic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ # ── Format detection ──────────────────────────────────────────────────────────
13
+
14
+ def _detect_format(messages: list[dict]) -> str:
15
+ """
16
+ Heuristic format detection.
17
+
18
+ Returns: "openai" | "anthropic" | "gemini" | "unknown"
19
+ """
20
+ if not messages:
21
+ return "unknown"
22
+
23
+ sample = messages[:5]
24
+
25
+ # Gemini: uses 'parts' + 'role' (model | user)
26
+ for m in sample:
27
+ if isinstance(m, dict) and "parts" in m:
28
+ return "gemini"
29
+
30
+ # Anthropic: uses 'role' (user | assistant) + possible 'content' list
31
+ # with 'type' == 'text' | 'image' | 'tool_use' | 'tool_result'
32
+ for m in sample:
33
+ if not isinstance(m, dict):
34
+ continue
35
+ role = m.get("role", "")
36
+ content = m.get("content")
37
+ if role in ("user", "assistant") and isinstance(content, list):
38
+ for block in content:
39
+ if isinstance(block, dict) and block.get("type") in (
40
+ "text", "image", "tool_use", "tool_result"
41
+ ):
42
+ return "anthropic"
43
+
44
+ # OpenAI: uses 'role' with system | developer | tool | function
45
+ openai_exclusive = {"system", "developer", "tool", "function"}
46
+ for m in sample:
47
+ if isinstance(m, dict) and m.get("role") in openai_exclusive:
48
+ return "openai"
49
+
50
+ # Fallback: if messages have 'role' at all, treat as OpenAI (most common)
51
+ for m in sample:
52
+ if isinstance(m, dict) and "role" in m:
53
+ return "openai"
54
+
55
+ return "unknown"
56
+
57
+
58
+ # ── Provider-specific compaction ──────────────────────────────────────────────
59
+
60
+ def _compact_openai(
61
+ messages: list[dict],
62
+ retain_turns: int,
63
+ model: str,
64
+ max_recent_tool_tokens: int,
65
+ ) -> list[dict]:
66
+ from compact.openai import compact_openai_messages
67
+ return compact_openai_messages(
68
+ messages,
69
+ retain_turns=retain_turns,
70
+ model=model,
71
+ max_recent_tool_tokens=max_recent_tool_tokens,
72
+ )
73
+
74
+
75
+ def _compact_anthropic(messages: list[dict], retain_turns: int) -> list[dict]:
76
+ """
77
+ Compact Anthropic-format messages.
78
+ Anthropic does not have a 'system' role in the messages array (it's a
79
+ separate API param), so the body is all user/assistant turns.
80
+ """
81
+ from compact.anthropic import summarize_anthropic_history
82
+ retain_turns = max(0, int(retain_turns))
83
+ retain_msgs = retain_turns * 2
84
+ if len(messages) <= retain_msgs:
85
+ return messages
86
+
87
+ if retain_msgs == 0:
88
+ old_msgs = messages
89
+ recent = []
90
+ else:
91
+ old_msgs = messages[:-retain_msgs]
92
+ recent = messages[-retain_msgs:]
93
+
94
+ summary = summarize_anthropic_history(old_msgs)
95
+
96
+ summary_message = {
97
+ "role": "user",
98
+ "content": (
99
+ f"<conversation_summary>\n"
100
+ f"{summary}\n"
101
+ f"</conversation_summary>"
102
+ ),
103
+ }
104
+ return [summary_message] + recent
105
+
106
+
107
+ def _compact_gemini(messages: list[dict], retain_turns: int) -> list[dict]:
108
+ """
109
+ Compact Gemini-format messages (role + parts[]).
110
+ """
111
+ from compact.gemini import summarize_gemini_history
112
+ retain_turns = max(0, int(retain_turns))
113
+ retain_msgs = retain_turns * 2
114
+ if len(messages) <= retain_msgs:
115
+ return messages
116
+
117
+ if retain_msgs == 0:
118
+ old_msgs = messages
119
+ recent = []
120
+ else:
121
+ old_msgs = messages[:-retain_msgs]
122
+ recent = messages[-retain_msgs:]
123
+
124
+ summary = summarize_gemini_history(old_msgs)
125
+
126
+ summary_message = {
127
+ "role": "user",
128
+ "parts": [
129
+ {
130
+ "text": (
131
+ f"<conversation_summary>\n"
132
+ f"{summary}\n"
133
+ f"</conversation_summary>"
134
+ )
135
+ }
136
+ ],
137
+ }
138
+ return [summary_message] + recent
139
+
140
+
141
+ # ── Public entry-point ────────────────────────────────────────────────────────
142
+
143
+ def compact_messages(
144
+ messages_json: str,
145
+ retain_turns: int = 2,
146
+ model: str = "",
147
+ max_recent_tool_tokens: int = 800,
148
+ ) -> list[dict]:
149
+ """
150
+ Parse messages_json, auto-detect format, and compact in-place.
151
+
152
+ :param messages_json: JSON array string of conversation messages.
153
+ :param retain_turns: Number of recent turn-pairs (user+assistant) to keep.
154
+ :param model: Model hint for provider-specific formatting.
155
+ :param max_recent_tool_tokens: Cap retained OpenAI tool/function payloads.
156
+ :returns: Compacted message list.
157
+ """
158
+ try:
159
+ messages: list[dict] = json.loads(messages_json)
160
+ except (json.JSONDecodeError, ValueError):
161
+ return []
162
+
163
+ if not isinstance(messages, list) or not messages:
164
+ return []
165
+
166
+ fmt = _detect_format(messages)
167
+
168
+ if fmt == "openai":
169
+ return _compact_openai(
170
+ messages,
171
+ retain_turns,
172
+ model=model,
173
+ max_recent_tool_tokens=max_recent_tool_tokens,
174
+ )
175
+ elif fmt == "anthropic":
176
+ return _compact_anthropic(messages, retain_turns)
177
+ elif fmt == "gemini":
178
+ return _compact_gemini(messages, retain_turns)
179
+ else:
180
+ # Best-effort: treat as OpenAI
181
+ return _compact_openai(
182
+ messages,
183
+ retain_turns,
184
+ model=model,
185
+ max_recent_tool_tokens=max_recent_tool_tokens,
186
+ )