axion-code 1.0.0__py3-none-any.whl
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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Claude Pro/Max subscription OAuth — bypass API billing using your subscription.
|
|
2
|
+
|
|
3
|
+
This is the same OAuth flow Claude Code uses. When authenticated via subscription,
|
|
4
|
+
requests are billed against your Claude Pro/Max plan instead of pay-per-token API.
|
|
5
|
+
|
|
6
|
+
Flow (paste-style, like Claude Code):
|
|
7
|
+
1. Open https://claude.ai/oauth/authorize?...&redirect_uri=https://platform.claude.com/oauth/code/success?app=claude-code
|
|
8
|
+
2. User logs in and authorizes
|
|
9
|
+
3. claude.ai redirects to platform.claude.com/oauth/code/success which displays the code
|
|
10
|
+
4. User copies the code and pastes it into the CLI
|
|
11
|
+
5. We exchange the code for an access token at console.anthropic.com
|
|
12
|
+
6. Use the token as `Authorization: Bearer <token>` with `anthropic-beta: oauth-2025-04-20`
|
|
13
|
+
|
|
14
|
+
Tokens are saved to ~/.axion/credentials/anthropic-oauth.json and auto-refreshed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
import urllib.parse
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from axion.runtime.oauth import (
|
|
25
|
+
OAuthTokenSet,
|
|
26
|
+
PkceCodePair,
|
|
27
|
+
clear_oauth_credentials,
|
|
28
|
+
generate_pkce_pair,
|
|
29
|
+
generate_state,
|
|
30
|
+
load_oauth_credentials,
|
|
31
|
+
open_browser,
|
|
32
|
+
save_oauth_credentials,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Claude Code's well-known OAuth client ID for subscription auth
|
|
38
|
+
CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
39
|
+
|
|
40
|
+
# Subscription OAuth endpoints
|
|
41
|
+
CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
|
42
|
+
CLAUDE_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
43
|
+
|
|
44
|
+
# Registered redirect URI for Claude Code's OAuth client.
|
|
45
|
+
# When `code=true` is set in the authorize URL, claude.ai displays the code
|
|
46
|
+
# on https://platform.claude.com/oauth/code/success?app=claude-code instead
|
|
47
|
+
# of actually redirecting — but the OAuth `redirect_uri` parameter must still
|
|
48
|
+
# match what's registered for this client.
|
|
49
|
+
CLAUDE_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
|
50
|
+
|
|
51
|
+
# OAuth requires this beta header on Messages API requests
|
|
52
|
+
SUBSCRIPTION_BETA_HEADER = "oauth-2025-04-20"
|
|
53
|
+
|
|
54
|
+
# Scopes required for subscription-based inference
|
|
55
|
+
SUBSCRIPTION_SCOPES = ["org:create_api_key", "user:profile", "user:inference"]
|
|
56
|
+
|
|
57
|
+
# Provider key for credential storage
|
|
58
|
+
SUBSCRIPTION_PROVIDER = "anthropic-oauth"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class SubscriptionAuthResult:
|
|
63
|
+
"""Result of a subscription OAuth login attempt."""
|
|
64
|
+
|
|
65
|
+
success: bool
|
|
66
|
+
token_set: OAuthTokenSet | None = None
|
|
67
|
+
error: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_subscription_authorize_url(pkce: PkceCodePair, state: str) -> str:
|
|
71
|
+
"""Build the claude.ai authorize URL for subscription auth."""
|
|
72
|
+
params = {
|
|
73
|
+
"code": "true", # Tells claude.ai this is for paste-style flow
|
|
74
|
+
"client_id": CLAUDE_CODE_CLIENT_ID,
|
|
75
|
+
"response_type": "code",
|
|
76
|
+
"redirect_uri": CLAUDE_REDIRECT_URI,
|
|
77
|
+
"scope": " ".join(SUBSCRIPTION_SCOPES),
|
|
78
|
+
"state": state,
|
|
79
|
+
"code_challenge": pkce.code_challenge,
|
|
80
|
+
"code_challenge_method": "S256",
|
|
81
|
+
}
|
|
82
|
+
return f"{CLAUDE_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_pasted_code(pasted: str) -> tuple[str, str | None]:
|
|
86
|
+
"""Parse the code from whatever the user pasted.
|
|
87
|
+
|
|
88
|
+
Handles all common paste formats:
|
|
89
|
+
- `code` — just the code
|
|
90
|
+
- `code#state` — Claude's success page format ("Paste this into Claude Code")
|
|
91
|
+
- `https://platform.claude.com/oauth/code/callback?code=ABC&state=XYZ` — full URL
|
|
92
|
+
- `?code=ABC&state=XYZ` — query string only
|
|
93
|
+
- URLs with the authorize endpoint (user pasted wrong thing) — return empty
|
|
94
|
+
"""
|
|
95
|
+
pasted = pasted.strip()
|
|
96
|
+
|
|
97
|
+
if not pasted:
|
|
98
|
+
return "", None
|
|
99
|
+
|
|
100
|
+
# If they pasted the AUTHORIZE URL by mistake, reject it
|
|
101
|
+
if "/oauth/authorize" in pasted:
|
|
102
|
+
return "", None
|
|
103
|
+
|
|
104
|
+
# If they pasted a full URL or query string, extract code & state
|
|
105
|
+
if pasted.startswith(("http://", "https://", "?")):
|
|
106
|
+
from urllib.parse import parse_qs, urlparse
|
|
107
|
+
|
|
108
|
+
if pasted.startswith("?"):
|
|
109
|
+
# Just a query string
|
|
110
|
+
qs = pasted[1:]
|
|
111
|
+
else:
|
|
112
|
+
qs = urlparse(pasted).query
|
|
113
|
+
|
|
114
|
+
params = parse_qs(qs)
|
|
115
|
+
code = (params.get("code") or [""])[0].strip()
|
|
116
|
+
state = (params.get("state") or [None])[0]
|
|
117
|
+
if state:
|
|
118
|
+
state = state.strip() or None
|
|
119
|
+
return code, state
|
|
120
|
+
|
|
121
|
+
# Claude's success page format: code#state
|
|
122
|
+
if "#" in pasted:
|
|
123
|
+
code, _, state = pasted.partition("#")
|
|
124
|
+
return code.strip(), state.strip() or None
|
|
125
|
+
|
|
126
|
+
# Plain code
|
|
127
|
+
return pasted, None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def exchange_subscription_code(
|
|
131
|
+
code: str,
|
|
132
|
+
code_verifier: str,
|
|
133
|
+
state: str,
|
|
134
|
+
) -> OAuthTokenSet:
|
|
135
|
+
"""Exchange an authorization code for subscription tokens."""
|
|
136
|
+
import httpx
|
|
137
|
+
|
|
138
|
+
payload = {
|
|
139
|
+
"grant_type": "authorization_code",
|
|
140
|
+
"code": code,
|
|
141
|
+
"code_verifier": code_verifier,
|
|
142
|
+
"client_id": CLAUDE_CODE_CLIENT_ID,
|
|
143
|
+
"redirect_uri": CLAUDE_REDIRECT_URI,
|
|
144
|
+
"state": state,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
148
|
+
response = await client.post(
|
|
149
|
+
CLAUDE_TOKEN_URL,
|
|
150
|
+
json=payload,
|
|
151
|
+
headers={"Content-Type": "application/json"},
|
|
152
|
+
)
|
|
153
|
+
if response.status_code != 200:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
f"Token exchange failed ({response.status_code}): {response.text[:500]}"
|
|
156
|
+
)
|
|
157
|
+
data = response.json()
|
|
158
|
+
|
|
159
|
+
expires_in = data.get("expires_in")
|
|
160
|
+
expires_at = int(time.time()) + expires_in if expires_in else None
|
|
161
|
+
|
|
162
|
+
return OAuthTokenSet(
|
|
163
|
+
access_token=data["access_token"],
|
|
164
|
+
refresh_token=data.get("refresh_token"),
|
|
165
|
+
expires_at=expires_at,
|
|
166
|
+
scopes=data.get("scope", "").split() if data.get("scope") else SUBSCRIPTION_SCOPES,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def refresh_subscription_token(refresh_token_str: str) -> OAuthTokenSet:
|
|
171
|
+
"""Refresh an expired subscription access token."""
|
|
172
|
+
import httpx
|
|
173
|
+
|
|
174
|
+
payload = {
|
|
175
|
+
"grant_type": "refresh_token",
|
|
176
|
+
"refresh_token": refresh_token_str,
|
|
177
|
+
"client_id": CLAUDE_CODE_CLIENT_ID,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
181
|
+
response = await client.post(
|
|
182
|
+
CLAUDE_TOKEN_URL,
|
|
183
|
+
json=payload,
|
|
184
|
+
headers={"Content-Type": "application/json"},
|
|
185
|
+
)
|
|
186
|
+
if response.status_code != 200:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"Token refresh failed ({response.status_code}): {response.text[:500]}"
|
|
189
|
+
)
|
|
190
|
+
data = response.json()
|
|
191
|
+
|
|
192
|
+
expires_in = data.get("expires_in")
|
|
193
|
+
expires_at = int(time.time()) + expires_in if expires_in else None
|
|
194
|
+
|
|
195
|
+
return OAuthTokenSet(
|
|
196
|
+
access_token=data["access_token"],
|
|
197
|
+
refresh_token=data.get("refresh_token", refresh_token_str),
|
|
198
|
+
expires_at=expires_at,
|
|
199
|
+
scopes=data.get("scope", "").split() if data.get("scope") else SUBSCRIPTION_SCOPES,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def begin_subscription_login(
|
|
204
|
+
*, open_browser_automatically: bool = True
|
|
205
|
+
) -> tuple[str, PkceCodePair, str]:
|
|
206
|
+
"""Start the subscription login flow.
|
|
207
|
+
|
|
208
|
+
Returns (auth_url, pkce_pair, state). Caller should:
|
|
209
|
+
1. Tell the user to open auth_url (we try to open the browser)
|
|
210
|
+
2. After authorizing, the user will see a code on the success page
|
|
211
|
+
3. Caller prompts the user to paste the code
|
|
212
|
+
4. Caller calls complete_subscription_login(code, pkce, state)
|
|
213
|
+
"""
|
|
214
|
+
pkce = generate_pkce_pair()
|
|
215
|
+
state = generate_state()
|
|
216
|
+
auth_url = build_subscription_authorize_url(pkce, state)
|
|
217
|
+
|
|
218
|
+
if open_browser_automatically:
|
|
219
|
+
open_browser(auth_url)
|
|
220
|
+
|
|
221
|
+
return auth_url, pkce, state
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def complete_subscription_login(
|
|
225
|
+
pasted: str,
|
|
226
|
+
pkce: PkceCodePair,
|
|
227
|
+
expected_state: str,
|
|
228
|
+
) -> SubscriptionAuthResult:
|
|
229
|
+
"""Finish subscription login using the pasted code from the success page."""
|
|
230
|
+
# Detect if user pasted the authorize URL by mistake
|
|
231
|
+
if "/oauth/authorize" in pasted:
|
|
232
|
+
return SubscriptionAuthResult(
|
|
233
|
+
success=False,
|
|
234
|
+
error="That's the authorize URL, not the code. After logging in, the success "
|
|
235
|
+
"page shows an 'Authentication Code'. Copy that and paste it here.",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
code, pasted_state = parse_pasted_code(pasted)
|
|
239
|
+
|
|
240
|
+
if not code:
|
|
241
|
+
return SubscriptionAuthResult(
|
|
242
|
+
success=False,
|
|
243
|
+
error="No code found in what you pasted. Copy the value from the "
|
|
244
|
+
"'Authentication Code' box on the success page.",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# If the user pasted code#state, verify state for CSRF protection
|
|
248
|
+
if pasted_state and pasted_state != expected_state:
|
|
249
|
+
return SubscriptionAuthResult(
|
|
250
|
+
success=False,
|
|
251
|
+
error="State mismatch — the code didn't come from the login you started. "
|
|
252
|
+
"Try again from scratch.",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
token_set = await exchange_subscription_code(
|
|
257
|
+
code=code,
|
|
258
|
+
code_verifier=pkce.code_verifier,
|
|
259
|
+
state=expected_state,
|
|
260
|
+
)
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
return SubscriptionAuthResult(
|
|
263
|
+
success=False,
|
|
264
|
+
error=f"Token exchange failed: {exc}",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
save_oauth_credentials(SUBSCRIPTION_PROVIDER, token_set)
|
|
268
|
+
return SubscriptionAuthResult(success=True, token_set=token_set)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def get_valid_subscription_token() -> str | None:
|
|
272
|
+
"""Get a valid subscription access token, refreshing if needed.
|
|
273
|
+
|
|
274
|
+
Returns None if no subscription credentials are saved.
|
|
275
|
+
"""
|
|
276
|
+
creds = load_oauth_credentials(SUBSCRIPTION_PROVIDER)
|
|
277
|
+
if creds is None:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
# If expired, refresh
|
|
281
|
+
if creds.is_expired() and creds.refresh_token:
|
|
282
|
+
try:
|
|
283
|
+
new_creds = await refresh_subscription_token(creds.refresh_token)
|
|
284
|
+
save_oauth_credentials(SUBSCRIPTION_PROVIDER, new_creds)
|
|
285
|
+
return new_creds.access_token
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
logger.warning("Subscription token refresh failed: %s", exc)
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
return creds.access_token
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def has_subscription_credentials() -> bool:
|
|
294
|
+
"""Check if subscription credentials are saved (without validating)."""
|
|
295
|
+
return load_oauth_credentials(SUBSCRIPTION_PROVIDER) is not None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def logout_subscription() -> None:
|
|
299
|
+
"""Remove subscription credentials."""
|
|
300
|
+
clear_oauth_credentials(SUBSCRIPTION_PROVIDER)
|
axion/runtime/compact.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Session compaction.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/compact.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from axion.runtime.session import (
|
|
13
|
+
ConversationMessage,
|
|
14
|
+
MessageRole,
|
|
15
|
+
Session,
|
|
16
|
+
SessionCompaction,
|
|
17
|
+
TextBlock,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from axion.api.client import ProviderClient
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CompactionConfig:
|
|
28
|
+
"""Configuration for session compaction."""
|
|
29
|
+
|
|
30
|
+
max_tokens: int = 100_000
|
|
31
|
+
preserve_recent_messages: int = 4
|
|
32
|
+
summary_max_tokens: int = 2000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CompactionResult:
|
|
37
|
+
"""Result of a compaction operation."""
|
|
38
|
+
|
|
39
|
+
removed_count: int
|
|
40
|
+
summary: str
|
|
41
|
+
estimated_tokens_before: int
|
|
42
|
+
estimated_tokens_after: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def estimate_session_tokens(session: Session) -> int:
|
|
46
|
+
"""Rough estimate of token count in a session.
|
|
47
|
+
|
|
48
|
+
Uses ~4 chars per token heuristic.
|
|
49
|
+
"""
|
|
50
|
+
total_chars = 0
|
|
51
|
+
for msg in session.messages:
|
|
52
|
+
for block in msg.blocks:
|
|
53
|
+
if isinstance(block, TextBlock):
|
|
54
|
+
total_chars += len(block.text)
|
|
55
|
+
else:
|
|
56
|
+
total_chars += 100 # Rough estimate for non-text blocks
|
|
57
|
+
return total_chars // 4
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def compact_session(
|
|
61
|
+
session: Session,
|
|
62
|
+
config: CompactionConfig | None = None,
|
|
63
|
+
) -> CompactionResult | None:
|
|
64
|
+
"""Compact a session by summarizing old messages.
|
|
65
|
+
|
|
66
|
+
Preserves the most recent messages and replaces older ones
|
|
67
|
+
with a summary message.
|
|
68
|
+
"""
|
|
69
|
+
cfg = config or CompactionConfig()
|
|
70
|
+
|
|
71
|
+
estimated_tokens = estimate_session_tokens(session)
|
|
72
|
+
if estimated_tokens < cfg.max_tokens:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
total_messages = len(session.messages)
|
|
76
|
+
if total_messages <= cfg.preserve_recent_messages:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# Split into old and recent
|
|
80
|
+
split_idx = total_messages - cfg.preserve_recent_messages
|
|
81
|
+
old_messages = session.messages[:split_idx]
|
|
82
|
+
recent_messages = session.messages[split_idx:]
|
|
83
|
+
|
|
84
|
+
# Build summary from old messages
|
|
85
|
+
summary_parts: list[str] = []
|
|
86
|
+
for msg in old_messages:
|
|
87
|
+
for block in msg.blocks:
|
|
88
|
+
if isinstance(block, TextBlock):
|
|
89
|
+
# Truncate long texts
|
|
90
|
+
text = block.text[:200] + "..." if len(block.text) > 200 else block.text
|
|
91
|
+
summary_parts.append(f"[{msg.role.value}] {text}")
|
|
92
|
+
|
|
93
|
+
summary = "\n".join(summary_parts)
|
|
94
|
+
if len(summary) > cfg.summary_max_tokens * 4:
|
|
95
|
+
summary = summary[: cfg.summary_max_tokens * 4] + "\n... (summary truncated)"
|
|
96
|
+
|
|
97
|
+
# Replace old messages with summary
|
|
98
|
+
summary_message = ConversationMessage(
|
|
99
|
+
role=MessageRole.USER,
|
|
100
|
+
blocks=[TextBlock(text=f"[Session compacted. Summary of {split_idx} earlier messages:]\n{summary}")],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
session.messages = [summary_message] + recent_messages
|
|
104
|
+
|
|
105
|
+
# Update compaction metadata
|
|
106
|
+
compaction_count = (session.compaction.count + 1) if session.compaction else 1
|
|
107
|
+
session.compaction = SessionCompaction(
|
|
108
|
+
count=compaction_count,
|
|
109
|
+
removed_message_count=split_idx,
|
|
110
|
+
summary=summary[:500],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
tokens_after = estimate_session_tokens(session)
|
|
114
|
+
|
|
115
|
+
return CompactionResult(
|
|
116
|
+
removed_count=split_idx,
|
|
117
|
+
summary=summary[:200],
|
|
118
|
+
estimated_tokens_before=estimated_tokens,
|
|
119
|
+
estimated_tokens_after=tokens_after,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Model-based intelligent compaction
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def model_compact_session(
|
|
129
|
+
session: Session,
|
|
130
|
+
provider: "ProviderClient",
|
|
131
|
+
model: str,
|
|
132
|
+
config: CompactionConfig | None = None,
|
|
133
|
+
) -> CompactionResult | None:
|
|
134
|
+
"""Use a model to produce an intelligent summary for compaction.
|
|
135
|
+
|
|
136
|
+
1. Builds a summary request asking the model to summarize old messages.
|
|
137
|
+
2. Sends it to the API.
|
|
138
|
+
3. Replaces old messages with the model-generated summary.
|
|
139
|
+
|
|
140
|
+
Falls back to the heuristic ``compact_session`` if the API call fails.
|
|
141
|
+
"""
|
|
142
|
+
from axion.api.client import max_tokens_for_model, resolve_model_alias
|
|
143
|
+
from axion.api.types import InputMessage, MessageRequest, TextInputBlock
|
|
144
|
+
|
|
145
|
+
cfg = config or CompactionConfig()
|
|
146
|
+
|
|
147
|
+
estimated_tokens = estimate_session_tokens(session)
|
|
148
|
+
if estimated_tokens < cfg.max_tokens:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
total_messages = len(session.messages)
|
|
152
|
+
if total_messages <= cfg.preserve_recent_messages:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
split_idx = total_messages - cfg.preserve_recent_messages
|
|
156
|
+
old_messages = session.messages[:split_idx]
|
|
157
|
+
recent_messages = session.messages[split_idx:]
|
|
158
|
+
|
|
159
|
+
# Build text representation of old messages for the summariser
|
|
160
|
+
old_text_parts: list[str] = []
|
|
161
|
+
for msg in old_messages:
|
|
162
|
+
for block in msg.blocks:
|
|
163
|
+
if isinstance(block, TextBlock):
|
|
164
|
+
text = block.text[:500] if len(block.text) > 500 else block.text
|
|
165
|
+
old_text_parts.append(f"[{msg.role.value}] {text}")
|
|
166
|
+
|
|
167
|
+
old_text = "\n".join(old_text_parts)
|
|
168
|
+
|
|
169
|
+
# Build a summarisation request
|
|
170
|
+
summary_prompt = (
|
|
171
|
+
"Summarise the following conversation history concisely. "
|
|
172
|
+
"Preserve key decisions, tool outputs, file paths, and any context "
|
|
173
|
+
"the assistant will need to continue the conversation.\n\n"
|
|
174
|
+
f"{old_text}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
resolved_model = resolve_model_alias(model)
|
|
178
|
+
request = MessageRequest(
|
|
179
|
+
model=resolved_model,
|
|
180
|
+
max_tokens=min(cfg.summary_max_tokens, max_tokens_for_model(resolved_model)),
|
|
181
|
+
messages=[
|
|
182
|
+
InputMessage(
|
|
183
|
+
role="user",
|
|
184
|
+
content=[TextInputBlock(text=summary_prompt)],
|
|
185
|
+
)
|
|
186
|
+
],
|
|
187
|
+
system="You are a conversation summariser. Produce a concise summary.",
|
|
188
|
+
stream=False,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
response = await provider.send_message(request)
|
|
193
|
+
# Extract text from the response
|
|
194
|
+
summary_text = ""
|
|
195
|
+
if response.content:
|
|
196
|
+
for block in response.content:
|
|
197
|
+
if hasattr(block, "text"):
|
|
198
|
+
summary_text += block.text
|
|
199
|
+
if not summary_text:
|
|
200
|
+
raise ValueError("Empty summary from model")
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
logger.warning(
|
|
203
|
+
"Model-based compaction failed, falling back to heuristic: %s", exc
|
|
204
|
+
)
|
|
205
|
+
return compact_session(session, cfg)
|
|
206
|
+
|
|
207
|
+
# Replace old messages with model-generated summary
|
|
208
|
+
summary_message = ConversationMessage(
|
|
209
|
+
role=MessageRole.USER,
|
|
210
|
+
blocks=[
|
|
211
|
+
TextBlock(
|
|
212
|
+
text=f"[Session compacted by model. Summary of {split_idx} earlier messages:]\n{summary_text}"
|
|
213
|
+
)
|
|
214
|
+
],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
session.messages = [summary_message] + recent_messages
|
|
218
|
+
|
|
219
|
+
compaction_count = (session.compaction.count + 1) if session.compaction else 1
|
|
220
|
+
session.compaction = SessionCompaction(
|
|
221
|
+
count=compaction_count,
|
|
222
|
+
removed_message_count=split_idx,
|
|
223
|
+
summary=summary_text[:500],
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
tokens_after = estimate_session_tokens(session)
|
|
227
|
+
|
|
228
|
+
return CompactionResult(
|
|
229
|
+
removed_count=split_idx,
|
|
230
|
+
summary=summary_text[:200],
|
|
231
|
+
estimated_tokens_before=estimated_tokens,
|
|
232
|
+
estimated_tokens_after=tokens_after,
|
|
233
|
+
)
|