trimtok 4.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.
- trimtok/__init__.py +838 -0
- trimtok/__main__.py +189 -0
- trimtok/ccr.py +596 -0
- trimtok/core.py +369 -0
- trimtok/dedup.py +766 -0
- trimtok/delta_engine.py +293 -0
- trimtok/mcp_install.py +414 -0
- trimtok/mcp_server.py +330 -0
- trimtok/pruners.py +449 -0
- trimtok/signals.py +302 -0
- trimtok/toon_codec.py +414 -0
- trimtok-4.1.0.dist-info/METADATA +451 -0
- trimtok-4.1.0.dist-info/RECORD +17 -0
- trimtok-4.1.0.dist-info/WHEEL +5 -0
- trimtok-4.1.0.dist-info/entry_points.txt +3 -0
- trimtok-4.1.0.dist-info/licenses/LICENSE +21 -0
- trimtok-4.1.0.dist-info/top_level.txt +1 -0
trimtok/__init__.py
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
"""trimtok Hermes plugin adapter and Python package root. Glue over core/ccr/dedup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .core import VERSION, compress_terminal
|
|
14
|
+
from .ccr import (
|
|
15
|
+
default_dir,
|
|
16
|
+
get as ccr_get,
|
|
17
|
+
purge as ccr_purge,
|
|
18
|
+
put as ccr_put,
|
|
19
|
+
stats as ccr_stats,
|
|
20
|
+
resolve_hash,
|
|
21
|
+
)
|
|
22
|
+
from .dedup import (
|
|
23
|
+
clear_session,
|
|
24
|
+
compress_tool_result,
|
|
25
|
+
expire_old_turns,
|
|
26
|
+
mask_old_turns,
|
|
27
|
+
note_turn,
|
|
28
|
+
remember,
|
|
29
|
+
)
|
|
30
|
+
from .delta_engine import clear_session as delta_clear_session
|
|
31
|
+
from .signals import (
|
|
32
|
+
Deadline,
|
|
33
|
+
VERBATIM_TOOLS,
|
|
34
|
+
estimate_tokens,
|
|
35
|
+
is_important,
|
|
36
|
+
)
|
|
37
|
+
from .pruners import (
|
|
38
|
+
group_search_hits,
|
|
39
|
+
prune_traceback,
|
|
40
|
+
prune_unified_diff,
|
|
41
|
+
skeletonize_code,
|
|
42
|
+
)
|
|
43
|
+
from .toon_codec import fold_json_to_toon
|
|
44
|
+
|
|
45
|
+
_MAX_GREP_PATTERN_CHARS = 256
|
|
46
|
+
_MAX_GREP_INPUT_CHARS = 1_000_000
|
|
47
|
+
_MAX_GREP_MATCHES = 1000
|
|
48
|
+
_MAX_GREP_OUTPUT_CHARS = 100_000
|
|
49
|
+
_MAX_AUDIT_BYTES = 1_048_576
|
|
50
|
+
_MAX_USAGE_BYTES = 1_048_576
|
|
51
|
+
_AUDIT_LOCK = threading.Lock()
|
|
52
|
+
|
|
53
|
+
RETRIEVE_SCHEMA = {
|
|
54
|
+
"name": "trimtok_retrieve",
|
|
55
|
+
"description": (
|
|
56
|
+
"Retrieve the uncompressed original content of a previously folded or windowed "
|
|
57
|
+
"tool result by its content reference: the short '#ref' shown in [tt:...] markers, "
|
|
58
|
+
"or a legacy 12-char hash. Supports optional line pagination (offset/limit), "
|
|
59
|
+
"focused section slicing (errors/diff/summary/tail), "
|
|
60
|
+
"and grep regex filtering to inspect specific details without flooding context."
|
|
61
|
+
),
|
|
62
|
+
"parameters": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"hash": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "The content ref shown in the [tt:... #ref] marker (e.g. '#c1'), or a legacy hex hash.",
|
|
68
|
+
},
|
|
69
|
+
"section": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"enum": ["errors", "diff", "summary", "tail"],
|
|
72
|
+
"description": (
|
|
73
|
+
"Optional focused section to retrieve: 'errors' (error lines ± context), "
|
|
74
|
+
"'diff' (git/unified diff hunks ± context), 'summary' (stats + head/tail + top errors), "
|
|
75
|
+
"or 'tail' (last N lines)."
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
"context_lines": {
|
|
79
|
+
"type": "integer",
|
|
80
|
+
"description": (
|
|
81
|
+
"Optional context lines around matched lines (0-20, default 3). "
|
|
82
|
+
"Used with section='errors' or 'diff'."
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
"offset": {
|
|
86
|
+
"type": "integer",
|
|
87
|
+
"description": "Optional 1-indexed line number to start reading from.",
|
|
88
|
+
},
|
|
89
|
+
"limit": {
|
|
90
|
+
"type": "integer",
|
|
91
|
+
"description": "Optional maximum number of lines to return.",
|
|
92
|
+
},
|
|
93
|
+
"grep": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": (
|
|
96
|
+
"Optional case-insensitive safe regex (no groups or multiple repetitions); "
|
|
97
|
+
"limited to 256 pattern characters, 1 MB input, 1000 matches, and 100 KB output."
|
|
98
|
+
),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
"required": ["hash"],
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _audit_path() -> Path:
|
|
107
|
+
return default_dir() / "audit.jsonl"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _usage_path() -> Path:
|
|
111
|
+
return default_dir() / "usage.jsonl"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _usage(entry: dict, ctx: Any = None) -> None:
|
|
115
|
+
if ctx is not None and not _bool_cfg(ctx, "usage_enabled", True):
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
path = _usage_path()
|
|
119
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
|
121
|
+
if len(line.encode("utf-8")) > _MAX_USAGE_BYTES:
|
|
122
|
+
line = json.dumps({"event": "usage_entry_oversize"}) + "\n"
|
|
123
|
+
with _AUDIT_LOCK:
|
|
124
|
+
backup = path.with_name(path.name + ".1")
|
|
125
|
+
if backup.exists() and backup.stat().st_size > _MAX_USAGE_BYTES:
|
|
126
|
+
backup.unlink(missing_ok=True)
|
|
127
|
+
if path.exists() and path.stat().st_size > _MAX_USAGE_BYTES:
|
|
128
|
+
path.write_text(json.dumps({"event": "usage_history_truncated"}) + "\n", encoding="utf-8")
|
|
129
|
+
if path.exists() and path.stat().st_size + len(line.encode("utf-8")) > _MAX_USAGE_BYTES:
|
|
130
|
+
os.replace(path, backup)
|
|
131
|
+
with path.open("a", encoding="utf-8") as f:
|
|
132
|
+
f.write(line)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _terminal_shape(stats: dict) -> str:
|
|
138
|
+
if str(stats.get("kind") or "") == "json":
|
|
139
|
+
return "json"
|
|
140
|
+
if stats.get("partial") or stats.get("omitted_errors"):
|
|
141
|
+
return "err-dense"
|
|
142
|
+
if (stats.get("hidden_lines") or 0) > 0:
|
|
143
|
+
return "repeat"
|
|
144
|
+
if (stats.get("dropped_lines") or 0) > 0:
|
|
145
|
+
return "unique"
|
|
146
|
+
return "mixed"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _tool_shape(stats: dict) -> str:
|
|
150
|
+
if (stats.get("fold_saved") or 0) > 0 or (stats.get("intra_saved") or 0) > 0:
|
|
151
|
+
return "repeat"
|
|
152
|
+
if (stats.get("headtail_saved") or 0) > 0:
|
|
153
|
+
return "unique-large"
|
|
154
|
+
if (stats.get("skeleton_saved") or 0) > 0:
|
|
155
|
+
return "code-ast"
|
|
156
|
+
if (stats.get("minify_saved") or 0) > 0 or (stats.get("toon_saved") or 0) > 0:
|
|
157
|
+
return "json"
|
|
158
|
+
if stats.get("gate") == "reverted_gate":
|
|
159
|
+
return "unique"
|
|
160
|
+
return "mixed"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _usage_terminal(command: Any, output: Any, stats: dict, ctx: Any) -> None:
|
|
164
|
+
try:
|
|
165
|
+
out = output if isinstance(output, str) else ""
|
|
166
|
+
_usage({
|
|
167
|
+
"ts": int(time.time()),
|
|
168
|
+
"hook": "terminal",
|
|
169
|
+
"cmd": str(command or "")[:120],
|
|
170
|
+
"kind": stats.get("kind"),
|
|
171
|
+
"gate": stats.get("gate"),
|
|
172
|
+
"before": len(out),
|
|
173
|
+
"after": int(stats.get("after_chars") or 0),
|
|
174
|
+
"saved": int(stats.get("saved_chars") or 0),
|
|
175
|
+
"partial": bool(stats.get("partial", False)),
|
|
176
|
+
"omitted": int(stats.get("omitted_errors") or 0),
|
|
177
|
+
"ms": round(float(stats.get("ms", 0.0)), 2),
|
|
178
|
+
"hash": str(stats.get("digest") or ""),
|
|
179
|
+
"shape": _terminal_shape(stats),
|
|
180
|
+
}, ctx=ctx)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _usage_tool(tool_name: Any, result: Any, stats: dict, ctx: Any) -> None:
|
|
186
|
+
try:
|
|
187
|
+
res = result if isinstance(result, str) else ""
|
|
188
|
+
_usage({
|
|
189
|
+
"ts": int(time.time()),
|
|
190
|
+
"hook": "tool",
|
|
191
|
+
"tool": str(tool_name or "tool"),
|
|
192
|
+
"gate": stats.get("gate"),
|
|
193
|
+
"before": len(res),
|
|
194
|
+
"after": int(stats.get("after_chars") or 0),
|
|
195
|
+
"saved": int(stats.get("saved_chars") or 0),
|
|
196
|
+
"ms": round(float(stats.get("ms", 0.0)), 2),
|
|
197
|
+
"hash": str(stats.get("digest") or ""),
|
|
198
|
+
"shape": _tool_shape(stats),
|
|
199
|
+
"minify": int(stats.get("minify_saved") or 0),
|
|
200
|
+
"fold": int(stats.get("fold_saved") or 0),
|
|
201
|
+
"intra": int(stats.get("intra_saved") or 0),
|
|
202
|
+
"skeleton": int(stats.get("skeleton_saved") or 0),
|
|
203
|
+
}, ctx=ctx)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _usage_retrieve(args: dict, *, hit: bool, ctx: Any = None, **extra: Any) -> None:
|
|
209
|
+
try:
|
|
210
|
+
entry: dict = {
|
|
211
|
+
"ts": int(time.time()),
|
|
212
|
+
"hook": "retrieve",
|
|
213
|
+
"hash": str((args or {}).get("hash") or "")[:16],
|
|
214
|
+
"hit": bool(hit),
|
|
215
|
+
}
|
|
216
|
+
entry.update({k: v for k, v in extra.items() if v is not None})
|
|
217
|
+
_usage(entry, ctx=ctx)
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _audit(entry: dict, ctx: Any = None) -> None:
|
|
223
|
+
if ctx is not None and not _bool_cfg(ctx, "audit_enabled", True):
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
path = _audit_path()
|
|
227
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
|
229
|
+
if len(line.encode("utf-8")) > _MAX_AUDIT_BYTES:
|
|
230
|
+
line = json.dumps({"event": "audit_entry_oversize"}) + "\n"
|
|
231
|
+
with _AUDIT_LOCK:
|
|
232
|
+
backup = path.with_name(path.name + ".1")
|
|
233
|
+
if backup.exists() and backup.stat().st_size > _MAX_AUDIT_BYTES:
|
|
234
|
+
backup.unlink(missing_ok=True)
|
|
235
|
+
if path.exists() and path.stat().st_size > _MAX_AUDIT_BYTES:
|
|
236
|
+
path.write_text(json.dumps({"event": "audit_history_truncated"}) + "\n", encoding="utf-8")
|
|
237
|
+
if path.exists() and path.stat().st_size + len(line.encode("utf-8")) > _MAX_AUDIT_BYTES:
|
|
238
|
+
os.replace(path, backup)
|
|
239
|
+
with path.open("a", encoding="utf-8") as f:
|
|
240
|
+
f.write(line)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _config_value(ctx: Any, key: str, default: Any) -> Any:
|
|
246
|
+
try:
|
|
247
|
+
return ctx.get_config(key, default)
|
|
248
|
+
except Exception:
|
|
249
|
+
return default
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _int_cfg(ctx: Any, key: str, default: int, low: int, high: int) -> int:
|
|
253
|
+
try:
|
|
254
|
+
value = int(_config_value(ctx, key, default))
|
|
255
|
+
except Exception:
|
|
256
|
+
return default
|
|
257
|
+
return max(low, min(high, value))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _float_cfg(ctx: Any, key: str, default: float, low: float, high: float) -> float:
|
|
261
|
+
try:
|
|
262
|
+
value = float(_config_value(ctx, key, default))
|
|
263
|
+
except Exception:
|
|
264
|
+
return default
|
|
265
|
+
return max(low, min(high, value))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _bool_cfg(ctx: Any, key: str, default: bool) -> bool:
|
|
269
|
+
value = _config_value(ctx, key, default)
|
|
270
|
+
return value if isinstance(value, bool) else default
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _list_cfg(ctx: Any, key: str) -> list[str]:
|
|
274
|
+
value = _config_value(ctx, key, [])
|
|
275
|
+
if not isinstance(value, list):
|
|
276
|
+
return []
|
|
277
|
+
return [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _excluded_command(command: Any, ctx: Any) -> bool:
|
|
281
|
+
normalized = " ".join(str(command or "").split()).casefold()
|
|
282
|
+
if not normalized:
|
|
283
|
+
return False
|
|
284
|
+
for excluded in _list_cfg(ctx, "exclude_commands"):
|
|
285
|
+
prefix = " ".join(excluded.split()).casefold()
|
|
286
|
+
if normalized == prefix or normalized.startswith(prefix + " "):
|
|
287
|
+
return True
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _excluded_tool(tool_name: Any, ctx: Any) -> bool:
|
|
292
|
+
normalized = str(tool_name or "").strip().casefold()
|
|
293
|
+
return bool(normalized) and any(
|
|
294
|
+
normalized == excluded.casefold() for excluded in _list_cfg(ctx, "exclude_tools")
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _store_original(
|
|
299
|
+
original: str, compressed: str, digest: str, ctx: Any
|
|
300
|
+
) -> tuple[str, str] | None:
|
|
301
|
+
stored = ccr_put(
|
|
302
|
+
original,
|
|
303
|
+
max_entries=_int_cfg(ctx, "ccr_max_entries", 1000, 10, 100000),
|
|
304
|
+
max_bytes=_int_cfg(ctx, "ccr_max_bytes", 50 * 1024 * 1024, 1024, 1_000_000_000),
|
|
305
|
+
ttl_hours=_int_cfg(ctx, "ccr_ttl_hours", 24, 1, 24 * 30),
|
|
306
|
+
)
|
|
307
|
+
if not stored:
|
|
308
|
+
return None
|
|
309
|
+
if digest:
|
|
310
|
+
old_ref = f"#{digest}"
|
|
311
|
+
if stored != old_ref:
|
|
312
|
+
compressed = compressed.replace(old_ref, stored)
|
|
313
|
+
return compressed, stored
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _compile_grep(source: str) -> re.Pattern[str]:
|
|
317
|
+
if len(source) > _MAX_GREP_PATTERN_CHARS:
|
|
318
|
+
raise ValueError(f"pattern exceeds {_MAX_GREP_PATTERN_CHARS} characters")
|
|
319
|
+
|
|
320
|
+
repeats = 0
|
|
321
|
+
in_class = False
|
|
322
|
+
escaped = False
|
|
323
|
+
last_was_repeat = False
|
|
324
|
+
for char in source:
|
|
325
|
+
if escaped:
|
|
326
|
+
if char.isdigit():
|
|
327
|
+
raise ValueError("backreferences are not supported in grep patterns")
|
|
328
|
+
escaped = False
|
|
329
|
+
last_was_repeat = False
|
|
330
|
+
continue
|
|
331
|
+
if char == "\\":
|
|
332
|
+
escaped = True
|
|
333
|
+
last_was_repeat = False
|
|
334
|
+
continue
|
|
335
|
+
if in_class:
|
|
336
|
+
if char == "]":
|
|
337
|
+
in_class = False
|
|
338
|
+
continue
|
|
339
|
+
if char == "[":
|
|
340
|
+
in_class = True
|
|
341
|
+
last_was_repeat = False
|
|
342
|
+
continue
|
|
343
|
+
if char in "(){}":
|
|
344
|
+
raise ValueError("groups and counted repetition are not supported in grep patterns")
|
|
345
|
+
if char in "*+?":
|
|
346
|
+
if char == "?" and last_was_repeat:
|
|
347
|
+
last_was_repeat = False
|
|
348
|
+
continue
|
|
349
|
+
repeats += 1
|
|
350
|
+
if repeats > 1:
|
|
351
|
+
raise ValueError("only one repetition operator is supported in grep patterns")
|
|
352
|
+
last_was_repeat = True
|
|
353
|
+
else:
|
|
354
|
+
last_was_repeat = False
|
|
355
|
+
return re.compile(source, re.IGNORECASE)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _finalize(
|
|
359
|
+
raw: str,
|
|
360
|
+
compressed: str,
|
|
361
|
+
stats: dict,
|
|
362
|
+
ctx: Any,
|
|
363
|
+
audit_base: dict,
|
|
364
|
+
) -> str | None:
|
|
365
|
+
if stats.get("gate") != "ok":
|
|
366
|
+
return None
|
|
367
|
+
stored = _store_original(raw, compressed, str(stats.get("digest", "")), ctx)
|
|
368
|
+
if stored is None:
|
|
369
|
+
return None
|
|
370
|
+
new_text, digest = stored
|
|
371
|
+
_audit({
|
|
372
|
+
**audit_base,
|
|
373
|
+
"before_chars": stats.get("before_chars"),
|
|
374
|
+
"after_chars": stats.get("after_chars"),
|
|
375
|
+
"saved_chars": stats.get("saved_chars"),
|
|
376
|
+
"gate": "ok",
|
|
377
|
+
"hash": digest,
|
|
378
|
+
"ms": round(float(stats.get("ms", 0.0)), 2),
|
|
379
|
+
}, ctx=ctx)
|
|
380
|
+
return new_text
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _on_transform(
|
|
384
|
+
command: Any = None,
|
|
385
|
+
output: Any = None,
|
|
386
|
+
returncode: Any = 0,
|
|
387
|
+
**kw: Any,
|
|
388
|
+
) -> str | None:
|
|
389
|
+
ctx = _on_transform._ctx # type: ignore[attr-defined]
|
|
390
|
+
if not _bool_cfg(ctx, "enabled", True):
|
|
391
|
+
return None
|
|
392
|
+
if not isinstance(output, str):
|
|
393
|
+
return None
|
|
394
|
+
if _excluded_command(command, ctx):
|
|
395
|
+
return None
|
|
396
|
+
min_chars = _int_cfg(ctx, "min_chars", 2048, 0, 10_000_000)
|
|
397
|
+
new_output, stats = compress_terminal(
|
|
398
|
+
str(command or ""),
|
|
399
|
+
output,
|
|
400
|
+
min_chars=min_chars,
|
|
401
|
+
max_lines=_int_cfg(ctx, "max_lines", 120, 10, 10000),
|
|
402
|
+
session_id=str(kw.get("session_id") or kw.get("task_id") or ""),
|
|
403
|
+
delta_enabled=_bool_cfg(ctx, "delta_enabled", True),
|
|
404
|
+
delta_threshold=_float_cfg(ctx, "delta_threshold", 0.65, 0.5, 1.0),
|
|
405
|
+
deadline=Deadline(_float_cfg(ctx, "guard_ms", 50.0, 1.0, 1000.0)),
|
|
406
|
+
)
|
|
407
|
+
_usage_terminal(command, output, stats, ctx)
|
|
408
|
+
return _finalize(output, new_output, stats, ctx, {
|
|
409
|
+
"tool": "terminal",
|
|
410
|
+
"command": str(command or "")[:120],
|
|
411
|
+
"kind": stats.get("kind"),
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _on_tool_result(
|
|
416
|
+
tool_name: Any = None,
|
|
417
|
+
args: Any = None,
|
|
418
|
+
result: Any = None,
|
|
419
|
+
session_id: Any = None,
|
|
420
|
+
task_id: Any = None,
|
|
421
|
+
turn_id: Any = None,
|
|
422
|
+
**kw: Any,
|
|
423
|
+
) -> str | None:
|
|
424
|
+
ctx = _on_tool_result._ctx # type: ignore[attr-defined]
|
|
425
|
+
if not _bool_cfg(ctx, "enabled", True):
|
|
426
|
+
return None
|
|
427
|
+
if not _bool_cfg(ctx, "phase2_enabled", True):
|
|
428
|
+
return None
|
|
429
|
+
if not isinstance(result, str):
|
|
430
|
+
return None
|
|
431
|
+
if _excluded_tool(tool_name, ctx):
|
|
432
|
+
return None
|
|
433
|
+
verbatim_name = str(tool_name or "").strip()
|
|
434
|
+
if verbatim_name in VERBATIM_TOOLS and verbatim_name != "read_file":
|
|
435
|
+
return None
|
|
436
|
+
sid = str(session_id or task_id or "default")
|
|
437
|
+
turn = str(turn_id or "")
|
|
438
|
+
new_result, stats = compress_tool_result(
|
|
439
|
+
str(tool_name or ""),
|
|
440
|
+
result,
|
|
441
|
+
session_id=sid,
|
|
442
|
+
min_chars=_int_cfg(ctx, "min_chars", 2048, 0, 10_000_000),
|
|
443
|
+
toon_enabled=_bool_cfg(ctx, "toon_enabled", True),
|
|
444
|
+
deadline=Deadline(_float_cfg(ctx, "guard_ms", 50.0, 1.0, 1000.0)),
|
|
445
|
+
)
|
|
446
|
+
_usage_tool(tool_name, result, stats, ctx)
|
|
447
|
+
out = _finalize(result, new_result, stats, ctx, {
|
|
448
|
+
"tool": str(tool_name or "tool"),
|
|
449
|
+
"kind": "tool_result",
|
|
450
|
+
})
|
|
451
|
+
if out is not None:
|
|
452
|
+
note_turn(result, str(stats.get("digest", "")), session_id=sid, turn_id=turn)
|
|
453
|
+
return out
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _on_post_llm(
|
|
457
|
+
session_id: Any = None,
|
|
458
|
+
task_id: Any = None,
|
|
459
|
+
turn_id: Any = None,
|
|
460
|
+
**kw: Any,
|
|
461
|
+
) -> None:
|
|
462
|
+
ctx = _on_post_llm._ctx # type: ignore[attr-defined]
|
|
463
|
+
if not _bool_cfg(ctx, "enabled", True) or not _bool_cfg(ctx, "mask_enabled", True):
|
|
464
|
+
return None
|
|
465
|
+
sid = str(session_id or task_id or "default")
|
|
466
|
+
try:
|
|
467
|
+
expired = expire_old_turns(
|
|
468
|
+
sid,
|
|
469
|
+
keep_turns=_int_cfg(ctx, "mask_keep_turns", 2, 0, 32),
|
|
470
|
+
)
|
|
471
|
+
if expired:
|
|
472
|
+
_audit({"tool": "mask", "expired_turns": expired, "gate": "ok", "ms": 0.0}, ctx=ctx)
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _parse_ref(raw: Any) -> str:
|
|
479
|
+
ref = str(raw or "").strip().strip("<>").strip()
|
|
480
|
+
ref = ref.removeprefix("tt:").split(",")[0].strip()
|
|
481
|
+
ref = ref.removeprefix("retrieve:").strip()
|
|
482
|
+
if ref.startswith("#"):
|
|
483
|
+
return "#" + re.sub(r"[^0-9A-Za-z]", "", ref[1:])[:16]
|
|
484
|
+
if re.match(r"^c[0-9A-Za-z]+$", ref) and len(ref) <= 8:
|
|
485
|
+
return "#" + ref
|
|
486
|
+
hex_ref = "".join(c for c in ref if c in "0123456789abcdef")[:16]
|
|
487
|
+
if hex_ref:
|
|
488
|
+
return hex_ref
|
|
489
|
+
return re.sub(r"[^0-9A-Za-z]", "", ref)[:16]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _safe_int(val: Any, default: int) -> int:
|
|
493
|
+
try:
|
|
494
|
+
if val is None or isinstance(val, bool):
|
|
495
|
+
return default
|
|
496
|
+
return int(val)
|
|
497
|
+
except Exception:
|
|
498
|
+
return default
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _render_context_intervals(
|
|
502
|
+
lines: list[str], match_indices: list[int], ctx_lines: int
|
|
503
|
+
) -> tuple[str, int]:
|
|
504
|
+
total_lines = len(lines)
|
|
505
|
+
intervals: list[tuple[int, int]] = []
|
|
506
|
+
for idx in match_indices:
|
|
507
|
+
s = max(0, idx - ctx_lines)
|
|
508
|
+
e = min(total_lines, idx + ctx_lines + 1)
|
|
509
|
+
if not intervals:
|
|
510
|
+
intervals.append((s, e))
|
|
511
|
+
else:
|
|
512
|
+
last_s, last_e = intervals[-1]
|
|
513
|
+
if s <= last_e:
|
|
514
|
+
intervals[-1] = (last_s, max(last_e, e))
|
|
515
|
+
else:
|
|
516
|
+
intervals.append((s, e))
|
|
517
|
+
chunks: list[str] = []
|
|
518
|
+
returned_lines = 0
|
|
519
|
+
for s, e in intervals:
|
|
520
|
+
chunk_lines = [f"{i + 1}|{lines[i]}" for i in range(s, e)]
|
|
521
|
+
returned_lines += len(chunk_lines)
|
|
522
|
+
chunks.append("\n".join(chunk_lines))
|
|
523
|
+
return "\n[...]\n".join(chunks), returned_lines
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _handle_retrieve(args: dict, **kw: Any) -> str:
|
|
527
|
+
ref = _parse_ref((args or {}).get("hash"))
|
|
528
|
+
if not ref:
|
|
529
|
+
return _err("hash is required (a '#ref' from a [tt:...] marker, or a legacy hex hash)")
|
|
530
|
+
ctx = getattr(_handle_retrieve, "_ctx", None)
|
|
531
|
+
ttl_hours = _int_cfg(ctx, "ccr_ttl_hours", 24, 1, 24 * 30) if ctx is not None else 24
|
|
532
|
+
content = ccr_get(ref, ttl_hours=ttl_hours)
|
|
533
|
+
if content is None:
|
|
534
|
+
_usage_retrieve(args, hit=False, ctx=ctx)
|
|
535
|
+
return _err("Not found or expired (TTL passed). Re-run the original command instead.")
|
|
536
|
+
|
|
537
|
+
grep = str((args or {}).get("grep") or "").strip()
|
|
538
|
+
if grep:
|
|
539
|
+
try:
|
|
540
|
+
if len(content) > _MAX_GREP_INPUT_CHARS:
|
|
541
|
+
return _err(
|
|
542
|
+
f"Content exceeds the {_MAX_GREP_INPUT_CHARS}-character grep limit; retrieve a smaller slice."
|
|
543
|
+
)
|
|
544
|
+
pattern = _compile_grep(grep)
|
|
545
|
+
lines = content.splitlines()
|
|
546
|
+
total_lines = len(lines)
|
|
547
|
+
matched: list[str] = []
|
|
548
|
+
output_chars = 0
|
|
549
|
+
matches_count = 0
|
|
550
|
+
truncated = False
|
|
551
|
+
for i, line in enumerate(lines):
|
|
552
|
+
if not pattern.search(line):
|
|
553
|
+
continue
|
|
554
|
+
matches_count += 1
|
|
555
|
+
rendered = f"{i + 1}|{line}"
|
|
556
|
+
extra = len(rendered) + (1 if matched else 0)
|
|
557
|
+
if len(matched) >= _MAX_GREP_MATCHES or output_chars + extra > _MAX_GREP_OUTPUT_CHARS:
|
|
558
|
+
truncated = True
|
|
559
|
+
continue
|
|
560
|
+
matched.append(rendered)
|
|
561
|
+
output_chars += extra
|
|
562
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="grep",
|
|
563
|
+
matches=matches_count, truncated=truncated)
|
|
564
|
+
return _ok({
|
|
565
|
+
"matches": "\n".join(matched) if matched else "(no matches found)",
|
|
566
|
+
"matches_count": matches_count,
|
|
567
|
+
"total_lines": total_lines,
|
|
568
|
+
"truncated": truncated,
|
|
569
|
+
})
|
|
570
|
+
except Exception as e:
|
|
571
|
+
return _err(f"Invalid grep pattern: {e}")
|
|
572
|
+
|
|
573
|
+
lines = content.splitlines()
|
|
574
|
+
total_lines = len(lines)
|
|
575
|
+
|
|
576
|
+
section = str((args or {}).get("section") or "").strip().lower()
|
|
577
|
+
if section:
|
|
578
|
+
ctx_lines = max(0, min(20, _safe_int((args or {}).get("context_lines"), 3)))
|
|
579
|
+
if section == "errors":
|
|
580
|
+
match_indices = [i for i, line in enumerate(lines) if is_important(line)]
|
|
581
|
+
if not match_indices:
|
|
582
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="section_errors", matches=0)
|
|
583
|
+
return _ok({
|
|
584
|
+
"section": "errors",
|
|
585
|
+
"error_matches": 0,
|
|
586
|
+
"total_lines": total_lines,
|
|
587
|
+
"lines": "(no error lines detected)",
|
|
588
|
+
})
|
|
589
|
+
formatted, returned_lines = _render_context_intervals(lines, match_indices, ctx_lines)
|
|
590
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="section_errors",
|
|
591
|
+
matches=len(match_indices), returned=returned_lines, total=total_lines)
|
|
592
|
+
return _ok({
|
|
593
|
+
"section": "errors",
|
|
594
|
+
"error_matches": len(match_indices),
|
|
595
|
+
"returned_lines": returned_lines,
|
|
596
|
+
"total_lines": total_lines,
|
|
597
|
+
"lines": formatted,
|
|
598
|
+
})
|
|
599
|
+
if section == "diff":
|
|
600
|
+
diff_indices = []
|
|
601
|
+
diff_prefixes = ("diff --git ", "--- ", "+++ ", "index ", "similarity index ", "new file mode ", "deleted file mode ")
|
|
602
|
+
for i, line in enumerate(lines):
|
|
603
|
+
if line.startswith(diff_prefixes) or line.startswith("@@ "):
|
|
604
|
+
diff_indices.append(i)
|
|
605
|
+
elif (line.startswith("+") and not line.startswith("+++")) or (line.startswith("-") and not line.startswith("---")):
|
|
606
|
+
diff_indices.append(i)
|
|
607
|
+
if not diff_indices:
|
|
608
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="section_diff", matches=0)
|
|
609
|
+
return _ok({
|
|
610
|
+
"section": "diff",
|
|
611
|
+
"diff_matches": 0,
|
|
612
|
+
"total_lines": total_lines,
|
|
613
|
+
"lines": "(no diff hunks or changes detected)",
|
|
614
|
+
})
|
|
615
|
+
formatted, returned_lines = _render_context_intervals(lines, diff_indices, ctx_lines)
|
|
616
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="section_diff",
|
|
617
|
+
matches=len(diff_indices), returned=returned_lines, total=total_lines)
|
|
618
|
+
return _ok({
|
|
619
|
+
"section": "diff",
|
|
620
|
+
"diff_matches": len(diff_indices),
|
|
621
|
+
"returned_lines": returned_lines,
|
|
622
|
+
"total_lines": total_lines,
|
|
623
|
+
"lines": formatted,
|
|
624
|
+
})
|
|
625
|
+
if section == "summary":
|
|
626
|
+
error_indices = [i for i, line in enumerate(lines) if is_important(line)]
|
|
627
|
+
head_count = min(15, total_lines)
|
|
628
|
+
head_lines = [f"{i + 1}|{lines[i]}" for i in range(head_count)]
|
|
629
|
+
tail_start = max(head_count, total_lines - 25)
|
|
630
|
+
tail_lines = [f"{i + 1}|{lines[i]}" for i in range(tail_start, total_lines)]
|
|
631
|
+
top_errors = [f"{i + 1}|{lines[i]}" for i in error_indices[:20]]
|
|
632
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="section_summary",
|
|
633
|
+
errors=len(error_indices), total=total_lines)
|
|
634
|
+
return _ok({
|
|
635
|
+
"section": "summary",
|
|
636
|
+
"total_lines": total_lines,
|
|
637
|
+
"total_chars": len(content),
|
|
638
|
+
"error_lines_count": len(error_indices),
|
|
639
|
+
"head_lines": "\n".join(head_lines),
|
|
640
|
+
"tail_lines": "\n".join(tail_lines),
|
|
641
|
+
"top_errors": "\n".join(top_errors) if top_errors else "(none)",
|
|
642
|
+
})
|
|
643
|
+
if section == "tail":
|
|
644
|
+
limit = _safe_int((args or {}).get("limit"), 50)
|
|
645
|
+
cnt = max(1, min(1000, limit))
|
|
646
|
+
tail_start = max(0, total_lines - cnt)
|
|
647
|
+
tail_slice = [f"{tail_start + i + 1}|{line}" for i, line in enumerate(lines[tail_start:])]
|
|
648
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="section_tail",
|
|
649
|
+
returned=len(tail_slice), total=total_lines)
|
|
650
|
+
return _ok({
|
|
651
|
+
"section": "tail",
|
|
652
|
+
"returned_lines": len(tail_slice),
|
|
653
|
+
"total_lines": total_lines,
|
|
654
|
+
"lines": "\n".join(tail_slice),
|
|
655
|
+
})
|
|
656
|
+
return _err(f"Invalid section '{section}'. Allowed: 'errors', 'diff', 'summary', 'tail'")
|
|
657
|
+
|
|
658
|
+
offset = (args or {}).get("offset")
|
|
659
|
+
limit = (args or {}).get("limit")
|
|
660
|
+
if offset is not None or limit is not None:
|
|
661
|
+
start = max(0, _safe_int(offset, 1) - 1)
|
|
662
|
+
cnt = _safe_int(limit, 1000)
|
|
663
|
+
end = min(total_lines, start + max(1, cnt))
|
|
664
|
+
sliced = [f"{i + 1}|{lines[i]}" for i in range(start, end)]
|
|
665
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="slice",
|
|
666
|
+
offset=start + 1, returned=len(sliced), total=total_lines)
|
|
667
|
+
return _ok({
|
|
668
|
+
"lines": "\n".join(sliced),
|
|
669
|
+
"offset": start + 1,
|
|
670
|
+
"total_lines": total_lines,
|
|
671
|
+
"returned_lines": len(sliced),
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
_usage_retrieve(args, hit=True, ctx=ctx, mode="full", chars=len(content))
|
|
675
|
+
return _ok({"original_content": content, "original_chars": len(content), "total_lines": total_lines})
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _err(message: str) -> str:
|
|
679
|
+
try:
|
|
680
|
+
from tools.registry import tool_error # type: ignore
|
|
681
|
+
return tool_error(message)
|
|
682
|
+
except Exception:
|
|
683
|
+
return json.dumps({"error": message}, ensure_ascii=False)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _ok(data: dict) -> str:
|
|
687
|
+
try:
|
|
688
|
+
from tools.registry import tool_result # type: ignore
|
|
689
|
+
return tool_result(data)
|
|
690
|
+
except Exception:
|
|
691
|
+
return json.dumps(data, ensure_ascii=False)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _usage_summary() -> str:
|
|
695
|
+
try:
|
|
696
|
+
path = _usage_path()
|
|
697
|
+
backup = path.with_name(path.name + ".1")
|
|
698
|
+
rows = []
|
|
699
|
+
with _AUDIT_LOCK:
|
|
700
|
+
for f in (backup, path):
|
|
701
|
+
if f.exists():
|
|
702
|
+
for ln in f.read_text(encoding="utf-8").splitlines():
|
|
703
|
+
s = ln.strip()
|
|
704
|
+
if s.startswith("{"):
|
|
705
|
+
try:
|
|
706
|
+
rows.append(json.loads(s))
|
|
707
|
+
except Exception:
|
|
708
|
+
pass
|
|
709
|
+
if not rows:
|
|
710
|
+
return f"trimtok v{VERSION}: no usage logged yet."
|
|
711
|
+
n_ok = sum(1 for r in rows if r.get("gate") == "ok")
|
|
712
|
+
saved = sum(int(r.get("saved") or 0) for r in rows if r.get("hook") in ("terminal", "tool"))
|
|
713
|
+
gates: dict = {}
|
|
714
|
+
shapes: dict = {}
|
|
715
|
+
cmds: dict = {}
|
|
716
|
+
for r in rows:
|
|
717
|
+
if r.get("hook") in ("terminal", "tool"):
|
|
718
|
+
g = str(r.get("gate") or "?")
|
|
719
|
+
gates[g] = gates.get(g, 0) + 1
|
|
720
|
+
sh = str(r.get("shape") or "?")
|
|
721
|
+
shapes[sh] = shapes.get(sh, 0) + 1
|
|
722
|
+
c = str(r.get("cmd") or r.get("tool") or "?")[:40]
|
|
723
|
+
cmds[c] = [cmds.get(c, [0, 0])[0] + 1, cmds.get(c, [0, 0])[1] + int(r.get("saved") or 0)]
|
|
724
|
+
retr = [r for r in rows if r.get("hook") == "retrieve"]
|
|
725
|
+
hits = sum(1 for r in retr if r.get("hit"))
|
|
726
|
+
top_g = sorted(gates.items(), key=lambda kv: -kv[1])[:5]
|
|
727
|
+
top_s = sorted(shapes.items(), key=lambda kv: -kv[1])[:5]
|
|
728
|
+
top_c = sorted(cmds.items(), key=lambda kv: -kv[1][1])[:5]
|
|
729
|
+
out = [f"trimtok v{VERSION} usage: {len(rows)} events, {n_ok} folds, ~{saved:,} chars saved."]
|
|
730
|
+
out.append("gates: " + ", ".join(f"{k}={v}" for k, v in top_g))
|
|
731
|
+
out.append("shapes: " + ", ".join(f"{k}={v}" for k, v in top_s))
|
|
732
|
+
out.append("top-saved: " + "; ".join(f"{k} (~{v[1]:,}c)" for k, v in top_c))
|
|
733
|
+
out.append(f"retrieve: {hits}/{len(retr)} hits" if retr else "retrieve: none yet")
|
|
734
|
+
return "\n".join(out)
|
|
735
|
+
except Exception:
|
|
736
|
+
return f"trimtok v{VERSION}: usage unavailable."
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _handle_ts_command(raw_args: str) -> str:
|
|
740
|
+
arg = (raw_args or "").strip().lower()
|
|
741
|
+
if arg in ("", "status"):
|
|
742
|
+
try:
|
|
743
|
+
path = _audit_path()
|
|
744
|
+
backup = path.with_name(path.name + ".1")
|
|
745
|
+
lines = []
|
|
746
|
+
with _AUDIT_LOCK:
|
|
747
|
+
for audit_file in (backup, path):
|
|
748
|
+
if audit_file.exists():
|
|
749
|
+
lines.extend(audit_file.read_text(encoding="utf-8").splitlines())
|
|
750
|
+
saved = sum((json.loads(ln).get("saved_chars") or 0) for ln in lines if ln.strip().startswith("{"))
|
|
751
|
+
ctx = getattr(_handle_ts_command, "_ctx", None)
|
|
752
|
+
ttl_hours = _int_cfg(ctx, "ccr_ttl_hours", 24, 1, 24 * 30) if ctx is not None else 24
|
|
753
|
+
c_stat = ccr_stats()
|
|
754
|
+
return (
|
|
755
|
+
f"trimtok v{VERSION}: {len(lines)} transforms logged (recent window), ~{saved:,} chars saved.\n"
|
|
756
|
+
f"CCR Cache: {c_stat.get('entries', 0)} entries (~{c_stat.get('bytes', 0) // 1024} KB)."
|
|
757
|
+
)
|
|
758
|
+
except Exception:
|
|
759
|
+
return f"trimtok v{VERSION}: status unavailable."
|
|
760
|
+
if arg == "stats":
|
|
761
|
+
return _handle_ts_command("status")
|
|
762
|
+
if arg == "usage":
|
|
763
|
+
return _usage_summary()
|
|
764
|
+
if arg == "purge":
|
|
765
|
+
purged = ccr_purge()
|
|
766
|
+
return f"trimtok v{VERSION}: purged {purged} cached original files."
|
|
767
|
+
if arg.startswith("retrieve"):
|
|
768
|
+
h = arg.partition(" ")[2].strip()
|
|
769
|
+
parts = h.split()
|
|
770
|
+
if len(parts) >= 2 and parts[1] in ("errors", "diff", "summary", "tail"):
|
|
771
|
+
return _handle_retrieve({"hash": parts[0], "section": parts[1]})
|
|
772
|
+
return _handle_retrieve({"hash": parts[0] if parts else ""})
|
|
773
|
+
return "Usage: /trimtok [status|stats|usage|purge|retrieve <hash> [section]]"
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _on_session_reset(session_id: Any = None, **kw: Any) -> None:
|
|
777
|
+
clear_session(str(session_id or ""))
|
|
778
|
+
delta_clear_session(str(session_id or ""))
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def compress_agent_payload(
|
|
782
|
+
payload: dict | str, mode: str = "tool", **kw: Any
|
|
783
|
+
) -> tuple[dict | str, dict]:
|
|
784
|
+
try:
|
|
785
|
+
if isinstance(payload, str):
|
|
786
|
+
if mode == "terminal":
|
|
787
|
+
return compress_terminal(kw.get("command", ""), payload)
|
|
788
|
+
return compress_tool_result(
|
|
789
|
+
kw.get("tool_name", "agent"), payload,
|
|
790
|
+
session_id=str(kw.get("session_id", "")),
|
|
791
|
+
)
|
|
792
|
+
if isinstance(payload, dict):
|
|
793
|
+
for key in ("content", "output", "stdout", "result"):
|
|
794
|
+
if isinstance(payload.get(key), str):
|
|
795
|
+
if mode == "terminal":
|
|
796
|
+
new_text, stats = compress_terminal(kw.get("command", ""), payload[key])
|
|
797
|
+
else:
|
|
798
|
+
new_text, stats = compress_tool_result(
|
|
799
|
+
str(payload.get("tool_name") or kw.get("tool_name", "agent")),
|
|
800
|
+
payload[key],
|
|
801
|
+
session_id=str(payload.get("session_id") or kw.get("session_id", "")),
|
|
802
|
+
)
|
|
803
|
+
return {**payload, key: new_text}, stats
|
|
804
|
+
return payload, {"gate": "passthrough_non_str"}
|
|
805
|
+
except Exception:
|
|
806
|
+
return payload, {"gate": "passthrough_exception"}
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def register(ctx: Any) -> None:
|
|
810
|
+
_on_transform._ctx = ctx # type: ignore[attr-defined]
|
|
811
|
+
_on_tool_result._ctx = ctx # type: ignore[attr-defined]
|
|
812
|
+
_on_post_llm._ctx = ctx # type: ignore[attr-defined]
|
|
813
|
+
_handle_retrieve._ctx = ctx # type: ignore[attr-defined]
|
|
814
|
+
_handle_ts_command._ctx = ctx # type: ignore[attr-defined]
|
|
815
|
+
ctx.register_hook("transform_terminal_output", _on_transform)
|
|
816
|
+
ctx.register_hook("transform_tool_result", _on_tool_result)
|
|
817
|
+
ctx.register_hook("post_llm_call", _on_post_llm)
|
|
818
|
+
ctx.register_hook("on_session_start", _on_session_reset)
|
|
819
|
+
ctx.register_hook("on_session_end", _on_session_reset)
|
|
820
|
+
ctx.register_tool(
|
|
821
|
+
name="trimtok_retrieve",
|
|
822
|
+
toolset="trimtok",
|
|
823
|
+
schema=RETRIEVE_SCHEMA,
|
|
824
|
+
handler=_handle_retrieve,
|
|
825
|
+
emoji="\U0001f9f9",
|
|
826
|
+
)
|
|
827
|
+
if hasattr(ctx, "register_command"):
|
|
828
|
+
ctx.register_command(
|
|
829
|
+
"trimtok",
|
|
830
|
+
_handle_ts_command,
|
|
831
|
+
"View trimtok token savings, status, or retrieve cached originals",
|
|
832
|
+
)
|
|
833
|
+
elif hasattr(ctx, "register_slash_command"):
|
|
834
|
+
ctx.register_slash_command(
|
|
835
|
+
name="trimtok",
|
|
836
|
+
description="View trimtok token savings, status, or retrieve cached originals",
|
|
837
|
+
handler=_handle_ts_command,
|
|
838
|
+
)
|