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,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
|
+
)
|