vamp-cli 0.1.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.
- vamp_cli/__init__.py +0 -0
- vamp_cli/agent/__init__.py +0 -0
- vamp_cli/agent/context_sync.py +72 -0
- vamp_cli/agent/graph.py +159 -0
- vamp_cli/agent/instructions.py +18 -0
- vamp_cli/agent/nodes.py +375 -0
- vamp_cli/agent/roles.py +8 -0
- vamp_cli/agent/runtime/__init__.py +0 -0
- vamp_cli/agent/runtime/runtime.py +133 -0
- vamp_cli/agent/state.py +55 -0
- vamp_cli/agent/status.py +17 -0
- vamp_cli/config/__init__.py +0 -0
- vamp_cli/config/loader.py +152 -0
- vamp_cli/config/schema.py +82 -0
- vamp_cli/llm/__init__.py +0 -0
- vamp_cli/llm/adapter.py +52 -0
- vamp_cli/llm/adapter_registry.py +26 -0
- vamp_cli/llm/debug_callbacks.py +18 -0
- vamp_cli/llm/router.py +247 -0
- vamp_cli/llm/usage.py +170 -0
- vamp_cli/logging_setup/logging_setup.py +37 -0
- vamp_cli/main.py +1023 -0
- vamp_cli/persistence/__init__.py +29 -0
- vamp_cli/persistence/compaction.py +372 -0
- vamp_cli/persistence/emitter.py +371 -0
- vamp_cli/persistence/event_log.py +303 -0
- vamp_cli/persistence/fork.py +81 -0
- vamp_cli/persistence/loader.py +616 -0
- vamp_cli/persistence/transcript.py +160 -0
- vamp_cli/prompts/prompts.py +182 -0
- vamp_cli/sandbox/__init__.py +0 -0
- vamp_cli/sandbox/base.py +22 -0
- vamp_cli/sandbox/config.py +16 -0
- vamp_cli/sandbox/docker.py +293 -0
- vamp_cli/sandbox/manager.py +31 -0
- vamp_cli/sandbox/result.py +14 -0
- vamp_cli/sandbox/terminal.py +151 -0
- vamp_cli/tools/__init__.py +0 -0
- vamp_cli/tools/base.py +44 -0
- vamp_cli/tools/diagnostic.py +120 -0
- vamp_cli/tools/execute_cmd.py +16 -0
- vamp_cli/tools/files.py +652 -0
- vamp_cli/tools/registry.py +163 -0
- vamp_cli/tools/tools_classes.py +622 -0
- vamp_cli/utils/__init__.py +0 -0
- vamp_cli/utils/error_codes.py +7 -0
- vamp_cli/utils/ignore.py +36 -0
- vamp_cli/utils/sha_256.py +32 -0
- vamp_cli/utils/token_tracker.py +31 -0
- vamp_cli/workspace/__init__.py +0 -0
- vamp_cli/workspace/workspace.py +16 -0
- vamp_cli-0.1.0.dist-info/METADATA +317 -0
- vamp_cli-0.1.0.dist-info/RECORD +57 -0
- vamp_cli-0.1.0.dist-info/WHEEL +5 -0
- vamp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vamp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- vamp_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from vamp_cli.persistence.event_log import EventLog, uuid7, iter_events
|
|
2
|
+
from vamp_cli.persistence.emitter import EventEmitter
|
|
3
|
+
from vamp_cli.persistence.loader import (
|
|
4
|
+
ResumedSession, load_session, load_chain, load_all_events, list_sessions,
|
|
5
|
+
derive_display_state, detect_pending_interrupt,
|
|
6
|
+
reconstruct_agent_state, current_thread_id,
|
|
7
|
+
list_user_messages, list_leaves, build_shadow_map,
|
|
8
|
+
_repair_orphaned_tool_calls
|
|
9
|
+
)
|
|
10
|
+
from vamp_cli.persistence.fork import fork_at_event
|
|
11
|
+
from vamp_cli.persistence.compaction import (
|
|
12
|
+
compact_session, plan_compaction_range,
|
|
13
|
+
build_summary_transcript, DEFAULT_KEEP_TURNS, auto_compact_if_needed, DEFAULT_COMPACT_THRESHOLD
|
|
14
|
+
)
|
|
15
|
+
from vamp_cli.persistence.transcript import render_transcript, print_transcript
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"EventLog", "EventEmitter", "uuid7", "iter_events",
|
|
19
|
+
"ResumedSession", "load_session", "load_chain", "load_all_events",
|
|
20
|
+
"list_sessions", "derive_display_state", "detect_pending_interrupt",
|
|
21
|
+
"reconstruct_agent_state", "current_thread_id",
|
|
22
|
+
"list_user_messages", "list_leaves", "build_shadow_map",
|
|
23
|
+
"fork_at_event",
|
|
24
|
+
"compact_session", "plan_compaction_range",
|
|
25
|
+
"build_summary_transcript", "DEFAULT_KEEP_TURNS",
|
|
26
|
+
"render_transcript", "print_transcript",
|
|
27
|
+
"auto_compact_if_needed", "DEFAULT_COMPACT_THRESHOLD",
|
|
28
|
+
"_repair_orphaned_tool_calls"
|
|
29
|
+
]
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compaction: summarize a middle range of the current branch into a single
|
|
3
|
+
`compaction` event. Nothing is deleted; the range is shadowed on read.
|
|
4
|
+
|
|
5
|
+
Only one compaction is expected to be "live" at a time: each new
|
|
6
|
+
compaction's range starts at the first post-prompt user message and ends
|
|
7
|
+
just before the keep boundary, so it naturally subsumes prior
|
|
8
|
+
compactions that fall inside the range.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from langchain_core.messages import HumanMessage, RemoveMessage, SystemMessage
|
|
17
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
18
|
+
from langchain_core.runnables import RunnableConfig
|
|
19
|
+
|
|
20
|
+
from vamp_cli.persistence.event_log import EventLog
|
|
21
|
+
from vamp_cli.persistence.loader import (
|
|
22
|
+
reconstruct_agent_state, build_shadow_map,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_KEEP_TURNS = 4
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Range planning
|
|
30
|
+
def plan_compaction_range(
|
|
31
|
+
chain: list[dict],
|
|
32
|
+
*,
|
|
33
|
+
keep_turns: int = DEFAULT_KEEP_TURNS,
|
|
34
|
+
) -> dict[str, Any] | None:
|
|
35
|
+
"""
|
|
36
|
+
Decide which contiguous slice of the chain to summarize.
|
|
37
|
+
|
|
38
|
+
Returns None if there is nothing compactable.
|
|
39
|
+
"""
|
|
40
|
+
user_events = [e for e in chain if e.get("type") == "user_message"]
|
|
41
|
+
if len(user_events) <= keep_turns:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
first_keep_index = len(user_events) - keep_turns
|
|
45
|
+
first_to_compact = user_events[0]
|
|
46
|
+
boundary_user = user_events[first_keep_index]
|
|
47
|
+
|
|
48
|
+
start_uuid = first_to_compact["uuid"]
|
|
49
|
+
end_uuid = boundary_user["parentUuid"]
|
|
50
|
+
if not end_uuid:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
# Collect uuids in chain order between start and end (inclusive).
|
|
54
|
+
collecting = False
|
|
55
|
+
range_uuids: list[str] = []
|
|
56
|
+
for e in chain:
|
|
57
|
+
u = e.get("uuid")
|
|
58
|
+
if u == start_uuid:
|
|
59
|
+
collecting = True
|
|
60
|
+
if collecting and u:
|
|
61
|
+
range_uuids.append(u)
|
|
62
|
+
if u == end_uuid:
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
if not range_uuids:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
range_set = set(range_uuids)
|
|
69
|
+
range_events = [e for e in chain if e.get("uuid") in range_set]
|
|
70
|
+
shadowed, _ = build_shadow_map(range_events)
|
|
71
|
+
|
|
72
|
+
# Approximate token cost of the visible (non-shadowed) events.
|
|
73
|
+
est_chars = 0
|
|
74
|
+
for e in range_events:
|
|
75
|
+
if e.get("uuid") in shadowed:
|
|
76
|
+
continue
|
|
77
|
+
d = e.get("data") or {}
|
|
78
|
+
est_chars += len(d.get("content") or "")
|
|
79
|
+
for tc in d.get("tool_calls") or []:
|
|
80
|
+
est_chars += len(json.dumps(tc.get("args") or {}))
|
|
81
|
+
est_tokens = max(1, est_chars // 4)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"start_uuid": start_uuid,
|
|
85
|
+
"end_uuid": end_uuid,
|
|
86
|
+
"range_uuids": range_uuids,
|
|
87
|
+
"event_count": len(range_events),
|
|
88
|
+
"est_tokens": est_tokens,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Summarization input + call
|
|
94
|
+
def build_summary_transcript(chain: list[dict], range_uuids: list[str]) -> str:
|
|
95
|
+
"""Render the range as a compact, LLM-friendly transcript."""
|
|
96
|
+
range_set = set(range_uuids)
|
|
97
|
+
lines: list[str] = []
|
|
98
|
+
for e in chain:
|
|
99
|
+
if e.get("uuid") not in range_set:
|
|
100
|
+
continue
|
|
101
|
+
t = e.get("type")
|
|
102
|
+
d = e.get("data") or {}
|
|
103
|
+
if t == "user_message":
|
|
104
|
+
lines.append(f"USER: {d.get('content', '').strip()}")
|
|
105
|
+
elif t == "assistant_message":
|
|
106
|
+
content = (d.get("content") or "").strip()
|
|
107
|
+
if content:
|
|
108
|
+
lines.append(f"ASSISTANT: {content}")
|
|
109
|
+
for tc in d.get("tool_calls") or []:
|
|
110
|
+
args = json.dumps(tc.get("args") or {}, separators=(",", ":"))[:300]
|
|
111
|
+
lines.append(f" [tool_call {tc.get('name')} {args}]")
|
|
112
|
+
# elif t == "tool_result":
|
|
113
|
+
# content = (d.get("content") or "")[:500].replace("\n", " ")
|
|
114
|
+
# lines.append(
|
|
115
|
+
# f" [tool_result {d.get('tool_name')} "
|
|
116
|
+
# f"status={d.get('status')}] {content}"
|
|
117
|
+
# )
|
|
118
|
+
elif t == "mode_switch":
|
|
119
|
+
lines.append(f"[mode: {d.get('from')} -> {d.get('to')}]")
|
|
120
|
+
elif t == "interrupt_request":
|
|
121
|
+
lines.append(f"[interrupt requested: {d.get('kind')}]")
|
|
122
|
+
elif t == "interrupt_response":
|
|
123
|
+
verdict = "approved" if d.get("approved") else "denied"
|
|
124
|
+
lines.append(f"[user {verdict} {d.get('kind')}]")
|
|
125
|
+
elif t == "compaction":
|
|
126
|
+
# prior compaction inside this range: include its summary
|
|
127
|
+
summary = (d.get("summary") or "").strip()
|
|
128
|
+
if summary:
|
|
129
|
+
lines.append(f"[earlier summary] {summary}")
|
|
130
|
+
return "\n".join(lines)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_SUMMARY_PROMPT = """\
|
|
134
|
+
You are a conversation summarizer for an AI coding assistant session.
|
|
135
|
+
|
|
136
|
+
Read the transcript and produce a summary that another AI coding agent
|
|
137
|
+
will use in place of the original messages. Capture:
|
|
138
|
+
|
|
139
|
+
- What the user asked for and what was accomplished
|
|
140
|
+
- Key decisions and concrete code changes (files, functions touched)
|
|
141
|
+
- Technologies, constraints, and any user preferences expressed
|
|
142
|
+
- Current state and open questions / next steps
|
|
143
|
+
|
|
144
|
+
Be concise but information-dense. Use short bullet points grouped by
|
|
145
|
+
topic. Do not editorialize, do not add interpretation. Preserve any
|
|
146
|
+
concrete identifiers (file paths, function names, command names,
|
|
147
|
+
error strings) verbatim.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def summarize_transcript(model, transcript: str) -> str:
|
|
152
|
+
msgs = [
|
|
153
|
+
SystemMessage(content=_SUMMARY_PROMPT),
|
|
154
|
+
HumanMessage(content=f"TRANSCRIPT:\n\n{transcript}\n\nSUMMARY:"),
|
|
155
|
+
]
|
|
156
|
+
response = await model.ainvoke(msgs)
|
|
157
|
+
content = response.content
|
|
158
|
+
return content if isinstance(content, str) else str(content)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Checkpointer refresh
|
|
162
|
+
async def apply_compaction_to_checkpointer(
|
|
163
|
+
graph,
|
|
164
|
+
session_config: dict | RunnableConfig,
|
|
165
|
+
new_state: dict,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Replace the message channels on the checkpointer with the compacted
|
|
169
|
+
view. Uses REMOVE_ALL_MESSAGES to override add_messages' append
|
|
170
|
+
reducer, so we end up with exactly the new lists.
|
|
171
|
+
"""
|
|
172
|
+
updates = {
|
|
173
|
+
"coder_messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)] + list(new_state["coder_messages"]),
|
|
174
|
+
"planner_messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)] + list(new_state["planner_messages"]),
|
|
175
|
+
"plan": new_state.get("plan"),
|
|
176
|
+
"todo": new_state.get("todo") or [],
|
|
177
|
+
}
|
|
178
|
+
await graph.aupdate_state(session_config, updates)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Public entry
|
|
182
|
+
async def compact_session(
|
|
183
|
+
log: EventLog,
|
|
184
|
+
*,
|
|
185
|
+
chain: list[dict],
|
|
186
|
+
graph,
|
|
187
|
+
session_config: dict | RunnableConfig,
|
|
188
|
+
model,
|
|
189
|
+
keep_turns: int = DEFAULT_KEEP_TURNS,
|
|
190
|
+
channel: str = "both",
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""
|
|
193
|
+
Compact the middle of the current branch.
|
|
194
|
+
|
|
195
|
+
Returns a result dict; raises ValueError if there is nothing to do.
|
|
196
|
+
"""
|
|
197
|
+
plan = plan_compaction_range(chain, keep_turns=keep_turns)
|
|
198
|
+
if plan is None:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"nothing to compact (need > {keep_turns} user turns)"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
transcript = build_summary_transcript(chain, plan["range_uuids"])
|
|
204
|
+
if not transcript.strip():
|
|
205
|
+
raise ValueError("compaction range produced empty transcript")
|
|
206
|
+
|
|
207
|
+
summary = await summarize_transcript(model, transcript)
|
|
208
|
+
if not summary.strip():
|
|
209
|
+
raise ValueError("summarizer returned empty summary")
|
|
210
|
+
|
|
211
|
+
est_after = max(1, len(summary) // 4)
|
|
212
|
+
|
|
213
|
+
await log.append("compaction", {
|
|
214
|
+
"range_uuids": plan["range_uuids"],
|
|
215
|
+
"start_uuid": plan["start_uuid"],
|
|
216
|
+
"end_uuid": plan["end_uuid"],
|
|
217
|
+
"channel": channel,
|
|
218
|
+
"summary": summary,
|
|
219
|
+
"event_count": plan["event_count"],
|
|
220
|
+
"tokens_before": plan["est_tokens"],
|
|
221
|
+
"tokens_after": est_after,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
# Rebuild state from the updated chain (now shadowed) and push it
|
|
225
|
+
# into the checkpointer so the next turn sees the compacted view.
|
|
226
|
+
updated_chain = chain + [{
|
|
227
|
+
"type": "compaction",
|
|
228
|
+
"uuid": log.head_uuid,
|
|
229
|
+
"parentUuid": chain[-1].get("uuid") if chain else None,
|
|
230
|
+
"data": {
|
|
231
|
+
"range_uuids": plan["range_uuids"],
|
|
232
|
+
"summary": summary,
|
|
233
|
+
"channel": channel,
|
|
234
|
+
},
|
|
235
|
+
}]
|
|
236
|
+
start_ev = next(
|
|
237
|
+
(e for e in chain if e.get("type") == "session_start"), None
|
|
238
|
+
)
|
|
239
|
+
prompts = (start_ev or {}).get("data", {}).get("system_prompts")
|
|
240
|
+
new_state = reconstruct_agent_state(updated_chain, system_prompts=prompts)
|
|
241
|
+
|
|
242
|
+
await apply_compaction_to_checkpointer(graph, session_config, new_state)
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"event_count": plan["event_count"],
|
|
246
|
+
"tokens_before": plan["est_tokens"],
|
|
247
|
+
"tokens_after": est_after,
|
|
248
|
+
"summary": summary,
|
|
249
|
+
"new_state": new_state,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Auto-compaction threshold
|
|
255
|
+
DEFAULT_COMPACT_THRESHOLD = 0.90
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_model_context_window(model) -> int:
|
|
259
|
+
"""
|
|
260
|
+
Best-effort context window for a LangChain chat model.
|
|
261
|
+
|
|
262
|
+
Order of preference:
|
|
263
|
+
1. model.profile['max_input_tokens'] (langchain-model-profiles)
|
|
264
|
+
2. model.profile['context_window']
|
|
265
|
+
3. model.get_context_window() (newer LC)
|
|
266
|
+
4. hardcoded fallback by model name
|
|
267
|
+
"""
|
|
268
|
+
profile = getattr(model, "profile", None)
|
|
269
|
+
if isinstance(profile, dict):
|
|
270
|
+
for key in ("max_input_tokens", "context_window", "max_tokens"):
|
|
271
|
+
val = profile.get(key)
|
|
272
|
+
if isinstance(val, int) and val > 0:
|
|
273
|
+
return val
|
|
274
|
+
|
|
275
|
+
getter = getattr(model, "get_context_window", None)
|
|
276
|
+
if callable(getter):
|
|
277
|
+
try:
|
|
278
|
+
val = getter()
|
|
279
|
+
if isinstance(val, int) and val > 0:
|
|
280
|
+
return val
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
name = (
|
|
285
|
+
getattr(model, "model_name", None)
|
|
286
|
+
or getattr(model, "model", None)
|
|
287
|
+
or ""
|
|
288
|
+
).lower()
|
|
289
|
+
|
|
290
|
+
fallbacks = {
|
|
291
|
+
"gpt-4o": 128_000,
|
|
292
|
+
"gpt-4o-mini": 128_000,
|
|
293
|
+
"gpt-4-turbo": 128_000,
|
|
294
|
+
"gpt-4": 8_192,
|
|
295
|
+
"gpt-3.5": 4_096,
|
|
296
|
+
"claude-3-5-sonnet": 200_000,
|
|
297
|
+
"claude-3-opus": 200_000,
|
|
298
|
+
"claude-3-haiku": 200_000,
|
|
299
|
+
"gemini-1.5-pro": 1_048_576,
|
|
300
|
+
"gemini-1.5-flash":1_048_576,
|
|
301
|
+
"deepseek": 64_000,
|
|
302
|
+
}
|
|
303
|
+
for prefix, size in fallbacks.items():
|
|
304
|
+
if prefix in name:
|
|
305
|
+
return size
|
|
306
|
+
|
|
307
|
+
return 128_000 # default
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def should_compact(
|
|
311
|
+
last_input_tokens: int,
|
|
312
|
+
context_window: int,
|
|
313
|
+
threshold: float = DEFAULT_COMPACT_THRESHOLD,
|
|
314
|
+
) -> bool:
|
|
315
|
+
if context_window <= 0:
|
|
316
|
+
return False
|
|
317
|
+
return last_input_tokens / context_window >= threshold
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def auto_compact_if_needed(
|
|
321
|
+
log: EventLog,
|
|
322
|
+
*,
|
|
323
|
+
chain: list[dict],
|
|
324
|
+
graph,
|
|
325
|
+
session_config: dict | RunnableConfig,
|
|
326
|
+
model,
|
|
327
|
+
last_input_tokens: int,
|
|
328
|
+
context_window: int | None = None,
|
|
329
|
+
threshold: float = DEFAULT_COMPACT_THRESHOLD,
|
|
330
|
+
keep_turns: int = DEFAULT_KEEP_TURNS,
|
|
331
|
+
channel: str = "both",
|
|
332
|
+
) -> dict | None:
|
|
333
|
+
"""
|
|
334
|
+
Run compaction if the last prompt is at/above the threshold.
|
|
335
|
+
|
|
336
|
+
Returns the compaction result dict, or None if no compaction was run.
|
|
337
|
+
Call this synchronously from the turn boundary, before invoking the graph.
|
|
338
|
+
"""
|
|
339
|
+
context_window = context_window or get_model_context_window(model)
|
|
340
|
+
|
|
341
|
+
if not should_compact(last_input_tokens, context_window, threshold):
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
print(
|
|
345
|
+
f"\n[auto-compact] context at "
|
|
346
|
+
f"{last_input_tokens:,}/{context_window:,} tokens "
|
|
347
|
+
f"({last_input_tokens / context_window:.1%}) - summarizing..."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
result = await compact_session(
|
|
352
|
+
log,
|
|
353
|
+
chain=chain,
|
|
354
|
+
graph=graph,
|
|
355
|
+
session_config=session_config,
|
|
356
|
+
model=model,
|
|
357
|
+
keep_turns=keep_turns,
|
|
358
|
+
channel=channel,
|
|
359
|
+
)
|
|
360
|
+
except ValueError as e:
|
|
361
|
+
# Nothing to compact
|
|
362
|
+
print(f"[auto-compact] skipped: {e}")
|
|
363
|
+
return None
|
|
364
|
+
except Exception as e:
|
|
365
|
+
print(f"[auto-compact] failed: {e!r}")
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
print(
|
|
369
|
+
f"[auto-compact] done: {result['event_count']} events, "
|
|
370
|
+
f"~{result['tokens_before']:,} → ~{result['tokens_after']:,} tokens\n"
|
|
371
|
+
)
|
|
372
|
+
return result
|