@softspark/ai-toolkit 4.16.0 → 4.17.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/CHANGELOG.md +41 -0
- package/README.md +11 -16
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/hooks/session-end.sh +1 -13
- package/app/hooks.json +0 -10
- package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
- package/bin/ai-toolkit.js +0 -2
- package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
- package/kb/reference/architecture-overview.md +2 -3
- package/kb/reference/cli-reference.md +3 -13
- package/kb/reference/enterprise-config-guide.md +1 -21
- package/kb/reference/hooks-catalog.md +3 -60
- package/kb/reference/supported-tools-registry.md +0 -4
- package/llms-full.txt +143 -395
- package/llms.txt +1 -1
- package/manifest.json +147 -36
- package/package.json +1 -2
- package/scripts/claude_app.py +2 -21
- package/scripts/config_cli.py +4 -0
- package/scripts/config_merger.py +0 -17
- package/scripts/config_validator.py +11 -138
- package/scripts/doctor.py +3 -20
- package/scripts/generate_copilot.py +35 -4
- package/scripts/install.py +7 -2
- package/scripts/install_steps/ai_tools.py +28 -99
- package/scripts/install_steps/hooks.py +26 -24
- package/scripts/merge-hooks.py +33 -2
- package/scripts/output_filter_retirement.py +395 -0
- package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
- package/scripts/uninstall.py +13 -27
- package/app/hooks/filter-tool-output.sh +0 -76
- package/app/output-filter-policy.json +0 -15
- package/benchmarks/output-filter/README.md +0 -11
- package/benchmarks/output-filter/scenarios.json +0 -25
- package/kb/reference/tool-output-filter.md +0 -288
- package/scripts/benchmark_output_filter.py +0 -343
- package/scripts/output_filter_cli.py +0 -347
- package/scripts/output_filter_hook.py +0 -23
- package/scripts/tool_output_filter/__init__.py +0 -33
- package/scripts/tool_output_filter/contracts.py +0 -173
- package/scripts/tool_output_filter/engine.py +0 -260
- package/scripts/tool_output_filter/hook_runtime.py +0 -369
- package/scripts/tool_output_filter/input.py +0 -56
- package/scripts/tool_output_filter/invariants.py +0 -40
- package/scripts/tool_output_filter/policy.py +0 -153
- package/scripts/tool_output_filter/profiles/__init__.py +0 -68
- package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
- package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
- package/scripts/tool_output_filter/recovery.py +0 -846
- package/scripts/tool_output_filter/telemetry.py +0 -13
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Manual and hook entry points for native tool-output filtering."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import math
|
|
8
|
-
import os
|
|
9
|
-
import sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import TYPE_CHECKING
|
|
12
|
-
|
|
13
|
-
from tool_output_filter.contracts import FilterMode, FilterRequest
|
|
14
|
-
from tool_output_filter.contracts import (
|
|
15
|
-
DEFAULT_MAX_INPUT_BYTES,
|
|
16
|
-
DEFAULT_MIN_SAVINGS_BYTES,
|
|
17
|
-
DEFAULT_MIN_SAVINGS_RATIO,
|
|
18
|
-
)
|
|
19
|
-
from tool_output_filter.engine import filter_output
|
|
20
|
-
from tool_output_filter.hook_runtime import run_hook
|
|
21
|
-
from tool_output_filter.input import read_bounded_text
|
|
22
|
-
from tool_output_filter.policy import OutputFilterPolicy, load_policy
|
|
23
|
-
from tool_output_filter.recovery import (
|
|
24
|
-
EphemeralRecoveryStore,
|
|
25
|
-
clean_owned_repo_recovery,
|
|
26
|
-
clean_session,
|
|
27
|
-
recover_by_handle,
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
if TYPE_CHECKING:
|
|
31
|
-
import argparse
|
|
32
|
-
|
|
33
|
-
_OWNER_MARKER = "ai-toolkit-output-filter-policy-v1"
|
|
34
|
-
_INSPECT_PROFILES = frozenset({"repeat-lines", "tap-success"})
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _infer_repo_root(start: Path) -> Path | None:
|
|
38
|
-
# realpath keeps the repo key aligned with the bash hooks, which resolve
|
|
39
|
-
# symlinks via `git rev-parse --show-toplevel`.
|
|
40
|
-
candidate_path = Path(
|
|
41
|
-
os.path.realpath(os.path.expanduser(str(start)))
|
|
42
|
-
)
|
|
43
|
-
if not candidate_path.is_dir():
|
|
44
|
-
return None
|
|
45
|
-
for candidate in (candidate_path, *candidate_path.parents):
|
|
46
|
-
git_marker = candidate / ".git"
|
|
47
|
-
if git_marker.is_dir() or git_marker.is_file():
|
|
48
|
-
return candidate
|
|
49
|
-
return candidate_path
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _session_base_for_repo(repo_root: Path) -> Path:
|
|
53
|
-
repo_key = "-" + str(repo_root).replace("/", "-").lstrip("-")
|
|
54
|
-
return (
|
|
55
|
-
Path.home()
|
|
56
|
-
/ ".softspark"
|
|
57
|
-
/ "ai-toolkit"
|
|
58
|
-
/ "sessions"
|
|
59
|
-
/ repo_key
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _manual_session_base() -> Path | None:
|
|
64
|
-
project_directory = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
65
|
-
repo_root = _infer_repo_root(
|
|
66
|
-
Path(project_directory) if project_directory else Path.cwd()
|
|
67
|
-
)
|
|
68
|
-
return _session_base_for_repo(repo_root) if repo_root is not None else None
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _is_regular_file(path: Path) -> bool:
|
|
72
|
-
return path.is_file() and not path.is_symlink()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _is_registered_project(project_root: Path) -> bool:
|
|
76
|
-
"""Trust only projects registered via `ai-toolkit install --local`.
|
|
77
|
-
|
|
78
|
-
The owner marker is a public constant, so a cloned repo must never be
|
|
79
|
-
able to self-enable filtering with it. Mirrors filter-tool-output.sh.
|
|
80
|
-
"""
|
|
81
|
-
registry = (
|
|
82
|
-
Path.home() / ".softspark" / "ai-toolkit" / "projects.json"
|
|
83
|
-
)
|
|
84
|
-
if not _is_regular_file(registry):
|
|
85
|
-
return False
|
|
86
|
-
try:
|
|
87
|
-
data = json.loads(registry.read_text(encoding="utf-8"))
|
|
88
|
-
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
89
|
-
return False
|
|
90
|
-
projects = data.get("projects") if isinstance(data, dict) else None
|
|
91
|
-
if not isinstance(projects, list):
|
|
92
|
-
return False
|
|
93
|
-
root_text = str(project_root)
|
|
94
|
-
return any(
|
|
95
|
-
isinstance(entry, dict) and entry.get("path") == root_text
|
|
96
|
-
for entry in projects
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _trusted_project_policy(project_root: Path) -> Path | None:
|
|
101
|
-
managed_directory = project_root / ".claude"
|
|
102
|
-
if project_root.is_symlink() or managed_directory.is_symlink():
|
|
103
|
-
return None
|
|
104
|
-
if not _is_registered_project(project_root):
|
|
105
|
-
return None
|
|
106
|
-
policy_path = managed_directory / "ai-toolkit-output-filter.json"
|
|
107
|
-
owner_path = managed_directory / ".ai-toolkit-output-filter.owner"
|
|
108
|
-
if not _is_regular_file(policy_path) or not _is_regular_file(owner_path):
|
|
109
|
-
return None
|
|
110
|
-
try:
|
|
111
|
-
owner_text = owner_path.read_text(encoding="utf-8")
|
|
112
|
-
except OSError:
|
|
113
|
-
return None
|
|
114
|
-
# The bash hook's $(<file) strips trailing newlines; accept the same.
|
|
115
|
-
if owner_text.rstrip("\n") != _OWNER_MARKER:
|
|
116
|
-
return None
|
|
117
|
-
return policy_path
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _resolve_policy_path(explicit_path: Path | None) -> Path | None:
|
|
121
|
-
if explicit_path is not None:
|
|
122
|
-
return explicit_path
|
|
123
|
-
# Same resolution as filter-tool-output.sh: the project directory only,
|
|
124
|
-
# never its parents, so `status` reports what the hook will actually do.
|
|
125
|
-
project_directory = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
126
|
-
project_policy = _trusted_project_policy(Path(project_directory))
|
|
127
|
-
if project_policy is not None:
|
|
128
|
-
return project_policy
|
|
129
|
-
global_policy = (
|
|
130
|
-
Path.home()
|
|
131
|
-
/ ".softspark"
|
|
132
|
-
/ "ai-toolkit"
|
|
133
|
-
/ "hooks"
|
|
134
|
-
/ "output-filter-policy.json"
|
|
135
|
-
)
|
|
136
|
-
return global_policy if _is_regular_file(global_policy) else None
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _default_policy() -> OutputFilterPolicy:
|
|
140
|
-
return OutputFilterPolicy(
|
|
141
|
-
mode=FilterMode.OFF,
|
|
142
|
-
profiles=("repeat-lines", "tap-success"),
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _run_status(policy_path: Path | None) -> int:
|
|
147
|
-
resolved_path = _resolve_policy_path(policy_path)
|
|
148
|
-
if resolved_path is None:
|
|
149
|
-
policy = _default_policy()
|
|
150
|
-
else:
|
|
151
|
-
try:
|
|
152
|
-
policy = load_policy(resolved_path)
|
|
153
|
-
except (OSError, TypeError, ValueError, KeyError):
|
|
154
|
-
return 2
|
|
155
|
-
output = {
|
|
156
|
-
"mode": policy.mode.value,
|
|
157
|
-
"profiles": list(policy.profiles),
|
|
158
|
-
"maxInputBytes": policy.max_input_bytes,
|
|
159
|
-
"minSavingsBytes": policy.min_savings_bytes,
|
|
160
|
-
"minSavingsRatio": policy.min_savings_ratio,
|
|
161
|
-
"recovery": {
|
|
162
|
-
"mode": "ephemeral",
|
|
163
|
-
"ttlMinutes": policy.ttl_minutes,
|
|
164
|
-
"maxSessionBytes": policy.max_session_bytes,
|
|
165
|
-
},
|
|
166
|
-
}
|
|
167
|
-
print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
|
|
168
|
-
return 0
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _run_inspect(args: argparse.Namespace) -> int:
|
|
172
|
-
if not _valid_inspect_arguments(args):
|
|
173
|
-
return 2
|
|
174
|
-
try:
|
|
175
|
-
raw_output = read_bounded_text(
|
|
176
|
-
sys.stdin.buffer,
|
|
177
|
-
max_bytes=args.max_input_bytes,
|
|
178
|
-
)
|
|
179
|
-
except (TypeError, UnicodeDecodeError, ValueError):
|
|
180
|
-
return 2
|
|
181
|
-
if raw_output is None:
|
|
182
|
-
report = {
|
|
183
|
-
"profile": args.profile,
|
|
184
|
-
"eligible": False,
|
|
185
|
-
"outcome": "passthrough",
|
|
186
|
-
"fallbackReason": "input-too-large",
|
|
187
|
-
"inputBytesAtLeast": args.max_input_bytes + 1,
|
|
188
|
-
"maxInputBytes": args.max_input_bytes,
|
|
189
|
-
}
|
|
190
|
-
print(json.dumps(report, separators=(",", ":")))
|
|
191
|
-
return 0
|
|
192
|
-
request = FilterRequest(
|
|
193
|
-
output=raw_output,
|
|
194
|
-
mode=FilterMode.OBSERVE,
|
|
195
|
-
profile_id=args.profile,
|
|
196
|
-
max_input_bytes=args.max_input_bytes,
|
|
197
|
-
min_savings_bytes=args.min_savings_bytes,
|
|
198
|
-
min_savings_ratio=args.min_savings_ratio,
|
|
199
|
-
)
|
|
200
|
-
result = filter_output(request)
|
|
201
|
-
telemetry = result.telemetry
|
|
202
|
-
input_bytes = len(raw_output.encode("utf-8"))
|
|
203
|
-
report = {
|
|
204
|
-
"profile": args.profile,
|
|
205
|
-
"eligible": result.outcome == "observed",
|
|
206
|
-
"outcome": result.outcome,
|
|
207
|
-
"fallbackReason": result.fallback_reason,
|
|
208
|
-
"inputBytes": input_bytes,
|
|
209
|
-
"candidateBytes": (
|
|
210
|
-
telemetry.output_bytes if telemetry is not None else input_bytes
|
|
211
|
-
),
|
|
212
|
-
"inputLines": len(raw_output.splitlines()),
|
|
213
|
-
"candidateLines": (
|
|
214
|
-
telemetry.output_lines
|
|
215
|
-
if telemetry is not None
|
|
216
|
-
else len(raw_output.splitlines())
|
|
217
|
-
),
|
|
218
|
-
}
|
|
219
|
-
print(json.dumps(report, ensure_ascii=False, separators=(",", ":")))
|
|
220
|
-
return 0
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def _valid_inspect_arguments(args: argparse.Namespace) -> bool:
|
|
224
|
-
"""Reject unsafe manual limits before consuming stdin."""
|
|
225
|
-
return (
|
|
226
|
-
args.profile in _INSPECT_PROFILES
|
|
227
|
-
and 1 <= args.max_input_bytes <= DEFAULT_MAX_INPUT_BYTES
|
|
228
|
-
and 0 <= args.min_savings_bytes <= DEFAULT_MAX_INPUT_BYTES
|
|
229
|
-
and math.isfinite(args.min_savings_ratio)
|
|
230
|
-
and 0.0 <= args.min_savings_ratio <= 1.0
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def _run_recover(args: argparse.Namespace) -> int:
|
|
235
|
-
base_directory = args.base_directory or _manual_session_base()
|
|
236
|
-
if base_directory is None:
|
|
237
|
-
return 2
|
|
238
|
-
try:
|
|
239
|
-
if args.session_id:
|
|
240
|
-
with EphemeralRecoveryStore(
|
|
241
|
-
base_directory,
|
|
242
|
-
session_identifier=args.session_id,
|
|
243
|
-
) as recovery:
|
|
244
|
-
response = recovery.load(args.handle)
|
|
245
|
-
else:
|
|
246
|
-
response = recover_by_handle(base_directory, args.handle)
|
|
247
|
-
except (OSError, RuntimeError, TypeError, ValueError):
|
|
248
|
-
return 2
|
|
249
|
-
if response is None:
|
|
250
|
-
return 1
|
|
251
|
-
print(json.dumps(response, ensure_ascii=False, separators=(",", ":")))
|
|
252
|
-
return 0
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def _run_clean(args: argparse.Namespace) -> int:
|
|
256
|
-
base_directory = args.base_directory or _manual_session_base()
|
|
257
|
-
if base_directory is None:
|
|
258
|
-
return 2
|
|
259
|
-
try:
|
|
260
|
-
if args.session_id:
|
|
261
|
-
if args.expired:
|
|
262
|
-
with EphemeralRecoveryStore(
|
|
263
|
-
base_directory,
|
|
264
|
-
session_identifier=args.session_id,
|
|
265
|
-
) as recovery:
|
|
266
|
-
removed = recovery.clean_expired()
|
|
267
|
-
scope = "expired"
|
|
268
|
-
else:
|
|
269
|
-
removed = clean_session(
|
|
270
|
-
base_directory,
|
|
271
|
-
args.session_id,
|
|
272
|
-
)
|
|
273
|
-
scope = "all"
|
|
274
|
-
elif args.expired:
|
|
275
|
-
return 2
|
|
276
|
-
else:
|
|
277
|
-
removed = clean_owned_repo_recovery(base_directory)
|
|
278
|
-
scope = "all"
|
|
279
|
-
except (OSError, RuntimeError, TypeError, ValueError):
|
|
280
|
-
return 2
|
|
281
|
-
print(json.dumps({"removed": removed, "scope": scope}, separators=(",", ":")))
|
|
282
|
-
return 0
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
286
|
-
import argparse
|
|
287
|
-
|
|
288
|
-
parser = argparse.ArgumentParser(
|
|
289
|
-
description="Inspect and safely filter supported tool output"
|
|
290
|
-
)
|
|
291
|
-
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
292
|
-
hook = subparsers.add_parser("hook")
|
|
293
|
-
hook.add_argument("--policy", type=Path, required=True)
|
|
294
|
-
status = subparsers.add_parser("status")
|
|
295
|
-
status.add_argument("--policy", type=Path)
|
|
296
|
-
inspect = subparsers.add_parser("inspect")
|
|
297
|
-
inspect.add_argument("--profile", required=True)
|
|
298
|
-
inspect.add_argument(
|
|
299
|
-
"--max-input-bytes",
|
|
300
|
-
type=int,
|
|
301
|
-
default=DEFAULT_MAX_INPUT_BYTES,
|
|
302
|
-
)
|
|
303
|
-
inspect.add_argument(
|
|
304
|
-
"--min-savings-bytes",
|
|
305
|
-
type=int,
|
|
306
|
-
default=DEFAULT_MIN_SAVINGS_BYTES,
|
|
307
|
-
)
|
|
308
|
-
inspect.add_argument(
|
|
309
|
-
"--min-savings-ratio",
|
|
310
|
-
type=float,
|
|
311
|
-
default=DEFAULT_MIN_SAVINGS_RATIO,
|
|
312
|
-
)
|
|
313
|
-
recover = subparsers.add_parser("recover")
|
|
314
|
-
recover.add_argument("handle")
|
|
315
|
-
recover.add_argument("--base-directory", type=Path)
|
|
316
|
-
recover.add_argument("--session-id")
|
|
317
|
-
clean = subparsers.add_parser("clean")
|
|
318
|
-
clean.add_argument("--base-directory", type=Path)
|
|
319
|
-
clean.add_argument("--session-id")
|
|
320
|
-
clean.add_argument("--expired", action="store_true")
|
|
321
|
-
return parser
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def main(argv: list[str] | None = None) -> int:
|
|
325
|
-
arguments = sys.argv[1:] if argv is None else argv
|
|
326
|
-
if (
|
|
327
|
-
len(arguments) == 3
|
|
328
|
-
and arguments[0] == "hook"
|
|
329
|
-
and arguments[1] == "--policy"
|
|
330
|
-
):
|
|
331
|
-
return run_hook(Path(arguments[2]))
|
|
332
|
-
args = _build_parser().parse_args(arguments)
|
|
333
|
-
if args.command == "hook":
|
|
334
|
-
return run_hook(args.policy)
|
|
335
|
-
if args.command == "status":
|
|
336
|
-
return _run_status(args.policy)
|
|
337
|
-
if args.command == "inspect":
|
|
338
|
-
return _run_inspect(args)
|
|
339
|
-
if args.command == "recover":
|
|
340
|
-
return _run_recover(args)
|
|
341
|
-
if args.command == "clean":
|
|
342
|
-
return _run_clean(args)
|
|
343
|
-
return 2
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if __name__ == "__main__":
|
|
347
|
-
sys.exit(main())
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Lean process entry point for the Claude output-filter hook."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import sys
|
|
7
|
-
|
|
8
|
-
from tool_output_filter.hook_runtime import run_hook
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def main(argv: list[str] | None = None) -> int:
|
|
12
|
-
arguments = sys.argv[1:] if argv is None else argv
|
|
13
|
-
if (
|
|
14
|
-
len(arguments) != 3
|
|
15
|
-
or arguments[0] != "hook"
|
|
16
|
-
or arguments[1] != "--policy"
|
|
17
|
-
):
|
|
18
|
-
return 2
|
|
19
|
-
return run_hook(arguments[2])
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if __name__ == "__main__":
|
|
23
|
-
sys.exit(main())
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"""Dependency-free post-execution tool-output filtering."""
|
|
2
|
-
|
|
3
|
-
from .contracts import FilterMode, FilterRequest, FilterResult
|
|
4
|
-
from .engine import filter_output
|
|
5
|
-
from .invariants import SessionCircuitBreaker
|
|
6
|
-
from .recovery import (
|
|
7
|
-
EphemeralRecoveryStore,
|
|
8
|
-
RecoveryStore,
|
|
9
|
-
RecoveryUnavailableError,
|
|
10
|
-
clean_owned_recovery_tree,
|
|
11
|
-
clean_owned_repo_recovery,
|
|
12
|
-
clean_session,
|
|
13
|
-
count_owned_recovery_artifacts,
|
|
14
|
-
recover_by_handle,
|
|
15
|
-
)
|
|
16
|
-
from .telemetry import TelemetrySink
|
|
17
|
-
|
|
18
|
-
__all__ = [
|
|
19
|
-
"EphemeralRecoveryStore",
|
|
20
|
-
"FilterMode",
|
|
21
|
-
"FilterRequest",
|
|
22
|
-
"FilterResult",
|
|
23
|
-
"RecoveryStore",
|
|
24
|
-
"RecoveryUnavailableError",
|
|
25
|
-
"SessionCircuitBreaker",
|
|
26
|
-
"TelemetrySink",
|
|
27
|
-
"clean_owned_recovery_tree",
|
|
28
|
-
"clean_owned_repo_recovery",
|
|
29
|
-
"clean_session",
|
|
30
|
-
"count_owned_recovery_artifacts",
|
|
31
|
-
"filter_output",
|
|
32
|
-
"recover_by_handle",
|
|
33
|
-
]
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
"""Public data contracts for the tool-output filter."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from enum import Enum
|
|
6
|
-
|
|
7
|
-
DEFAULT_MAX_INPUT_BYTES = 8 * 1024 * 1024
|
|
8
|
-
DEFAULT_MIN_SAVINGS_BYTES = 1024
|
|
9
|
-
DEFAULT_MIN_SAVINGS_RATIO = 0.15
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class _ImmutableSlots:
|
|
13
|
-
"""Allow one assignment per declared slot."""
|
|
14
|
-
|
|
15
|
-
__slots__ = ()
|
|
16
|
-
|
|
17
|
-
def __setattr__(self, name: str, value: object) -> None:
|
|
18
|
-
if hasattr(self, name):
|
|
19
|
-
raise AttributeError(f"{type(self).__name__} is immutable")
|
|
20
|
-
object.__setattr__(self, name, value)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class FilterMode(str, Enum):
|
|
24
|
-
"""Supported activation modes."""
|
|
25
|
-
|
|
26
|
-
OFF = "off"
|
|
27
|
-
OBSERVE = "observe"
|
|
28
|
-
SAFE = "safe"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class FilterRequest(_ImmutableSlots):
|
|
32
|
-
"""Normalized successful textual tool output."""
|
|
33
|
-
|
|
34
|
-
__slots__ = (
|
|
35
|
-
"interrupted",
|
|
36
|
-
"is_image",
|
|
37
|
-
"is_streaming",
|
|
38
|
-
"max_input_bytes",
|
|
39
|
-
"min_savings_bytes",
|
|
40
|
-
"min_savings_ratio",
|
|
41
|
-
"mode",
|
|
42
|
-
"output",
|
|
43
|
-
"profile_id",
|
|
44
|
-
"raw_response",
|
|
45
|
-
"stderr",
|
|
46
|
-
"successful",
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
def __init__(
|
|
50
|
-
self,
|
|
51
|
-
output: str,
|
|
52
|
-
mode: FilterMode,
|
|
53
|
-
profile_id: str,
|
|
54
|
-
raw_response: object | None = None,
|
|
55
|
-
successful: bool = True,
|
|
56
|
-
stderr: str = "",
|
|
57
|
-
interrupted: bool = False,
|
|
58
|
-
is_image: bool = False,
|
|
59
|
-
is_streaming: bool = False,
|
|
60
|
-
max_input_bytes: int = DEFAULT_MAX_INPUT_BYTES,
|
|
61
|
-
min_savings_bytes: int = DEFAULT_MIN_SAVINGS_BYTES,
|
|
62
|
-
min_savings_ratio: float = DEFAULT_MIN_SAVINGS_RATIO,
|
|
63
|
-
) -> None:
|
|
64
|
-
self.output = output
|
|
65
|
-
self.mode = mode
|
|
66
|
-
self.profile_id = profile_id
|
|
67
|
-
self.raw_response = raw_response
|
|
68
|
-
self.successful = successful
|
|
69
|
-
self.stderr = stderr
|
|
70
|
-
self.interrupted = interrupted
|
|
71
|
-
self.is_image = is_image
|
|
72
|
-
self.is_streaming = is_streaming
|
|
73
|
-
self.max_input_bytes = max_input_bytes
|
|
74
|
-
self.min_savings_bytes = min_savings_bytes
|
|
75
|
-
self.min_savings_ratio = min_savings_ratio
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class FilterTelemetry(_ImmutableSlots):
|
|
79
|
-
"""Content-free measurements for one filter decision."""
|
|
80
|
-
|
|
81
|
-
__slots__ = (
|
|
82
|
-
"duration_ms",
|
|
83
|
-
"fallback_reason",
|
|
84
|
-
"input_bytes",
|
|
85
|
-
"input_lines",
|
|
86
|
-
"outcome",
|
|
87
|
-
"output_bytes",
|
|
88
|
-
"output_lines",
|
|
89
|
-
"profile_id",
|
|
90
|
-
"profile_version",
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
def __init__(
|
|
94
|
-
self,
|
|
95
|
-
profile_id: str,
|
|
96
|
-
profile_version: int,
|
|
97
|
-
input_bytes: int,
|
|
98
|
-
output_bytes: int,
|
|
99
|
-
input_lines: int,
|
|
100
|
-
output_lines: int,
|
|
101
|
-
duration_ms: float = 0.0,
|
|
102
|
-
outcome: str = "",
|
|
103
|
-
fallback_reason: str | None = None,
|
|
104
|
-
) -> None:
|
|
105
|
-
self.profile_id = profile_id
|
|
106
|
-
self.profile_version = profile_version
|
|
107
|
-
self.input_bytes = input_bytes
|
|
108
|
-
self.output_bytes = output_bytes
|
|
109
|
-
self.input_lines = input_lines
|
|
110
|
-
self.output_lines = output_lines
|
|
111
|
-
self.duration_ms = duration_ms
|
|
112
|
-
self.outcome = outcome
|
|
113
|
-
self.fallback_reason = fallback_reason
|
|
114
|
-
|
|
115
|
-
def with_runtime(
|
|
116
|
-
self,
|
|
117
|
-
*,
|
|
118
|
-
duration_ms: float,
|
|
119
|
-
outcome: str,
|
|
120
|
-
fallback_reason: str | None,
|
|
121
|
-
) -> FilterTelemetry:
|
|
122
|
-
return FilterTelemetry(
|
|
123
|
-
self.profile_id,
|
|
124
|
-
self.profile_version,
|
|
125
|
-
self.input_bytes,
|
|
126
|
-
self.output_bytes,
|
|
127
|
-
self.input_lines,
|
|
128
|
-
self.output_lines,
|
|
129
|
-
duration_ms,
|
|
130
|
-
outcome,
|
|
131
|
-
fallback_reason,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
def as_dict(self) -> dict[str, object]:
|
|
135
|
-
return {
|
|
136
|
-
slot: getattr(self, slot)
|
|
137
|
-
for slot in self.__slots__
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
class FilterResult(_ImmutableSlots):
|
|
142
|
-
"""Observable result returned by the filtering engine."""
|
|
143
|
-
|
|
144
|
-
__slots__ = (
|
|
145
|
-
"changed",
|
|
146
|
-
"fallback_reason",
|
|
147
|
-
"outcome",
|
|
148
|
-
"output",
|
|
149
|
-
"telemetry",
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
def __init__(
|
|
153
|
-
self,
|
|
154
|
-
output: str,
|
|
155
|
-
changed: bool,
|
|
156
|
-
outcome: str,
|
|
157
|
-
telemetry: FilterTelemetry | None = None,
|
|
158
|
-
fallback_reason: str | None = None,
|
|
159
|
-
) -> None:
|
|
160
|
-
self.output = output
|
|
161
|
-
self.changed = changed
|
|
162
|
-
self.outcome = outcome
|
|
163
|
-
self.telemetry = telemetry
|
|
164
|
-
self.fallback_reason = fallback_reason
|
|
165
|
-
|
|
166
|
-
def with_telemetry(self, telemetry: FilterTelemetry) -> FilterResult:
|
|
167
|
-
return FilterResult(
|
|
168
|
-
self.output,
|
|
169
|
-
self.changed,
|
|
170
|
-
self.outcome,
|
|
171
|
-
telemetry,
|
|
172
|
-
self.fallback_reason,
|
|
173
|
-
)
|