@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +11 -16
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/hooks/session-end.sh +1 -13
  5. package/app/hooks.json +0 -10
  6. package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
  7. package/bin/ai-toolkit.js +0 -2
  8. package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
  9. package/kb/reference/architecture-overview.md +2 -3
  10. package/kb/reference/cli-reference.md +3 -13
  11. package/kb/reference/enterprise-config-guide.md +1 -21
  12. package/kb/reference/hooks-catalog.md +3 -60
  13. package/kb/reference/supported-tools-registry.md +0 -4
  14. package/llms-full.txt +143 -395
  15. package/llms.txt +1 -1
  16. package/manifest.json +147 -36
  17. package/package.json +1 -2
  18. package/scripts/claude_app.py +2 -21
  19. package/scripts/config_cli.py +4 -0
  20. package/scripts/config_merger.py +0 -17
  21. package/scripts/config_validator.py +11 -138
  22. package/scripts/doctor.py +3 -20
  23. package/scripts/generate_copilot.py +35 -4
  24. package/scripts/install.py +7 -2
  25. package/scripts/install_steps/ai_tools.py +28 -99
  26. package/scripts/install_steps/hooks.py +26 -24
  27. package/scripts/merge-hooks.py +33 -2
  28. package/scripts/output_filter_retirement.py +395 -0
  29. package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
  30. package/scripts/uninstall.py +13 -27
  31. package/app/hooks/filter-tool-output.sh +0 -76
  32. package/app/output-filter-policy.json +0 -15
  33. package/benchmarks/output-filter/README.md +0 -11
  34. package/benchmarks/output-filter/scenarios.json +0 -25
  35. package/kb/reference/tool-output-filter.md +0 -288
  36. package/scripts/benchmark_output_filter.py +0 -343
  37. package/scripts/output_filter_cli.py +0 -347
  38. package/scripts/output_filter_hook.py +0 -23
  39. package/scripts/tool_output_filter/__init__.py +0 -33
  40. package/scripts/tool_output_filter/contracts.py +0 -173
  41. package/scripts/tool_output_filter/engine.py +0 -260
  42. package/scripts/tool_output_filter/hook_runtime.py +0 -369
  43. package/scripts/tool_output_filter/input.py +0 -56
  44. package/scripts/tool_output_filter/invariants.py +0 -40
  45. package/scripts/tool_output_filter/policy.py +0 -153
  46. package/scripts/tool_output_filter/profiles/__init__.py +0 -68
  47. package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
  48. package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
  49. package/scripts/tool_output_filter/recovery.py +0 -846
  50. 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
- )