@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,288 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Native Tool Output Filter"
|
|
3
|
-
category: reference
|
|
4
|
-
service: ai-toolkit
|
|
5
|
-
tags: [output-filter, hooks, recovery, telemetry, claude-code]
|
|
6
|
-
version: "1.0.0"
|
|
7
|
-
created: "2026-07-23"
|
|
8
|
-
last_updated: "2026-07-23"
|
|
9
|
-
description: "Contract, configuration, safety boundaries, recovery, CLI, and runtime support for the native ai-toolkit output filter."
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# Native Tool Output Filter
|
|
13
|
-
|
|
14
|
-
## Overview
|
|
15
|
-
|
|
16
|
-
ai-toolkit includes an original, dependency-free filter for selected
|
|
17
|
-
post-execution tool results. It is disabled by default and does not depend on,
|
|
18
|
-
vendor, execute, or copy another output-filter package.
|
|
19
|
-
|
|
20
|
-
The active adapter targets Claude Code because its `PostToolUse` contract can
|
|
21
|
-
replace a native tool response through
|
|
22
|
-
`hookSpecificOutput.updatedToolOutput`. The replacement object retains the
|
|
23
|
-
native response shape and changes only `stdout`. See the
|
|
24
|
-
[Claude Code hooks reference](https://code.claude.com/docs/en/hooks).
|
|
25
|
-
|
|
26
|
-
The filter never changes the command, arguments, environment, working
|
|
27
|
-
directory, permission decision, exit status, or signal. [PATH:
|
|
28
|
-
scripts/tool_output_filter/hook_runtime.py] [PATH:
|
|
29
|
-
scripts/tool_output_filter/engine.py]
|
|
30
|
-
|
|
31
|
-
## Modes
|
|
32
|
-
|
|
33
|
-
| Mode | Model-visible result | Recovery | Telemetry |
|
|
34
|
-
|------|----------------------|----------|-----------|
|
|
35
|
-
| `off` | Original | None | None |
|
|
36
|
-
| `observe` | Original | No raw response | Content-free decision metadata |
|
|
37
|
-
| `safe` | Replacement only after every gate passes | Exact native response saved first | Content-free decision metadata |
|
|
38
|
-
|
|
39
|
-
`off` is a shell fast path, so the Python runtime is not started. Any runtime
|
|
40
|
-
error, malformed payload, unsafe command, failed invariant, unavailable secure
|
|
41
|
-
storage, or insufficient saving leaves the original response unchanged.
|
|
42
|
-
|
|
43
|
-
Three consecutive profile, invariant, or recovery safety failures open a
|
|
44
|
-
persistent session-scoped circuit breaker. One bounded Claude system message
|
|
45
|
-
reports the bypass, then later results stay unchanged for that session.
|
|
46
|
-
|
|
47
|
-
## Configuration
|
|
48
|
-
|
|
49
|
-
Configure the project in `.softspark-toolkit.json`:
|
|
50
|
-
|
|
51
|
-
```json
|
|
52
|
-
{
|
|
53
|
-
"toolOutputFilter": {
|
|
54
|
-
"mode": "observe",
|
|
55
|
-
"profiles": ["repeat-lines", "tap-success"],
|
|
56
|
-
"maxInputBytes": 8388608,
|
|
57
|
-
"minSavingsBytes": 1024,
|
|
58
|
-
"minSavingsRatio": 0.15,
|
|
59
|
-
"recovery": {
|
|
60
|
-
"mode": "ephemeral",
|
|
61
|
-
"ttlMinutes": 60,
|
|
62
|
-
"maxSessionBytes": 33554432
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Run `ai-toolkit install --local` or `ai-toolkit update --local` to materialize
|
|
69
|
-
the effective policy as:
|
|
70
|
-
|
|
71
|
-
```text
|
|
72
|
-
<project>/.claude/ai-toolkit-output-filter.json
|
|
73
|
-
<project>/.claude/.ai-toolkit-output-filter.owner
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
The managed files use mode `0600`. The hook accepts a project policy only when
|
|
77
|
-
the project root is registered in `~/.softspark/ai-toolkit/projects.json`
|
|
78
|
-
**and** the regular owner marker matches ai-toolkit. Registration is the
|
|
79
|
-
security boundary: the owner marker is a public constant, so requiring the
|
|
80
|
-
registry stops a cloned or untrusted checkout from self-enabling filtering by
|
|
81
|
-
committing its own marker. An unregistered project, a missing or foreign
|
|
82
|
-
marker, or a symlinked project root or `.claude` directory falls back to the
|
|
83
|
-
installed global policy at
|
|
84
|
-
`~/.softspark/ai-toolkit/hooks/output-filter-policy.json`, which defaults to
|
|
85
|
-
`off`. [PATH: app/hooks/filter-tool-output.sh] [PATH:
|
|
86
|
-
scripts/install_steps/ai_tools.py]
|
|
87
|
-
|
|
88
|
-
`jq` is a required system dependency for the lifecycle hooks and is verified
|
|
89
|
-
by `python3 scripts/check_deps.py` alongside `python3`, `git`, and `node`.
|
|
90
|
-
|
|
91
|
-
Before executing anything, the hook validates the resolved Python runtime path:
|
|
92
|
-
it must be a readable regular file and must not be a symlink. A missing,
|
|
93
|
-
non-regular, unreadable, or symlinked runtime makes the hook exit silently and
|
|
94
|
-
leave the tool response unchanged, so a tampered or half-installed runtime
|
|
95
|
-
cannot be invoked. The same regular-file rule applies to every policy file the
|
|
96
|
-
hook reads. [PATH: app/hooks/filter-tool-output.sh]
|
|
97
|
-
|
|
98
|
-
Set `AI_TOOLKIT_OUTPUT_FILTER_DISABLE=1` for an immediate bypass without
|
|
99
|
-
reinstalling. `AI_TOOLKIT_OUTPUT_FILTER_POLICY` may point the hook to an
|
|
100
|
-
explicit regular policy file for controlled operational testing. The hook is
|
|
101
|
-
also skipped by the `minimal` hook profile and may be listed in
|
|
102
|
-
`AI_TOOLKIT_DISABLED_HOOKS`. `AI_TOOLKIT_OUTPUT_FILTER_HOOK_RUNTIME` is reserved
|
|
103
|
-
for controlled runtime testing; the manual and cleanup CLI remains
|
|
104
|
-
`output_filter_cli.py`.
|
|
105
|
-
|
|
106
|
-
## Eligibility
|
|
107
|
-
|
|
108
|
-
The Claude adapter considers only a completed `PostToolUse` event with:
|
|
109
|
-
|
|
110
|
-
- tool name `Bash`;
|
|
111
|
-
- a non-empty native session ID of at most 160 ASCII letters, digits,
|
|
112
|
-
underscores, or hyphens;
|
|
113
|
-
- string `stdout`;
|
|
114
|
-
- empty `stderr`;
|
|
115
|
-
- `interrupted: false`;
|
|
116
|
-
- `isImage: false`;
|
|
117
|
-
- a command that matches a strict allowlist for test, lint, typecheck, or
|
|
118
|
-
validation tools;
|
|
119
|
-
- input at or below 8 MiB;
|
|
120
|
-
- valid text without binary or terminal-control content.
|
|
121
|
-
|
|
122
|
-
The following always pass through unchanged:
|
|
123
|
-
|
|
124
|
-
- failed, interrupted, image, binary, invalid-text, TTY, or streaming results;
|
|
125
|
-
- pipes, redirects, shell chaining, substitutions, and multiline commands;
|
|
126
|
-
- deployment, release, migration, publish, destroy, audit, and security-scanner
|
|
127
|
-
commands;
|
|
128
|
-
- arbitrary Python scripts and unknown command shapes;
|
|
129
|
-
- output with non-empty stderr;
|
|
130
|
-
- unknown profiles or native payload shapes;
|
|
131
|
-
- candidates that save less than both the configured byte and ratio threshold.
|
|
132
|
-
|
|
133
|
-
The command classifier is eligibility logic only. It never parses and
|
|
134
|
-
re-executes a command. [PATH: scripts/tool_output_filter/hook_runtime.py]
|
|
135
|
-
|
|
136
|
-
## Profiles
|
|
137
|
-
|
|
138
|
-
### `repeat-lines`
|
|
139
|
-
|
|
140
|
-
Collapses only adjacent identical, non-diagnostic lines. It retains the first
|
|
141
|
-
line and adds a versioned marker with the exact number of omitted copies.
|
|
142
|
-
Warnings, failures, permissions, security diagnostics, blank lines, comments,
|
|
143
|
-
existing filter markers, and control-bearing output are not collapsed.
|
|
144
|
-
|
|
145
|
-
### `tap-success`
|
|
146
|
-
|
|
147
|
-
Accepts only a strict, complete, successful TAP stream with a single plan and
|
|
148
|
-
contiguous `ok` result numbers. It retains the TAP version, plan, directives,
|
|
149
|
-
comments, totals, duration, and other summary lines. Diagnostics, `not ok`,
|
|
150
|
-
non-zero failure summaries, malformed plans, gaps, duplicates, and unknown
|
|
151
|
-
content reject the whole profile.
|
|
152
|
-
|
|
153
|
-
Both profiles are deterministic and idempotent. A safe replacement must remain
|
|
154
|
-
smaller after the recovery marker is added. [PATH:
|
|
155
|
-
scripts/tool_output_filter/profiles/] [PATH:
|
|
156
|
-
tests/test_tool_output_filter_properties.py]
|
|
157
|
-
|
|
158
|
-
## Exact Recovery and Privacy
|
|
159
|
-
|
|
160
|
-
Before `safe` mode emits a replacement, it stores and reloads the complete
|
|
161
|
-
native tool-response object. Equality must succeed before the hook prints
|
|
162
|
-
`updatedToolOutput`.
|
|
163
|
-
|
|
164
|
-
```text
|
|
165
|
-
~/.softspark/ai-toolkit/sessions/<repo-key>/
|
|
166
|
-
└── output-filter/
|
|
167
|
-
└── <hashed-session>/
|
|
168
|
-
├── <opaque-handle>.json
|
|
169
|
-
├── .circuit-state.json
|
|
170
|
-
└── .telemetry.jsonl
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
Recovery directories use `0700`; response, state, and telemetry files use
|
|
174
|
-
`0600`. Creation and cleanup use pinned directory descriptors, no-follow
|
|
175
|
-
operations, atomic publication, opaque random handles, a per-session quota,
|
|
176
|
-
and TTL cleanup. If this secure contract is unavailable, `safe` mode returns
|
|
177
|
-
the original response.
|
|
178
|
-
|
|
179
|
-
The replacement ends with a marker similar to:
|
|
180
|
-
|
|
181
|
-
```text
|
|
182
|
-
[ai-toolkit-output-filter repeat-lines/v1; original_lines=500; emitted_lines=3; recovery=<opaque-handle>]
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
The recovery file can contain everything returned by the tool, including
|
|
186
|
-
secrets. Treat the session directory as sensitive. Telemetry never stores raw
|
|
187
|
-
output, commands, paths, environment values, session IDs, or recovery handles.
|
|
188
|
-
It contains only profile/version, input/output byte and line counts, latency,
|
|
189
|
-
outcome, and a bounded fallback reason.
|
|
190
|
-
|
|
191
|
-
Session end, explicit cleanup, and global uninstall remove only validated
|
|
192
|
-
ai-toolkit-owned filter artifacts. Foreign files and directories are
|
|
193
|
-
preserved. [PATH: scripts/tool_output_filter/recovery.py] [PATH:
|
|
194
|
-
app/hooks/session-end.sh] [PATH: scripts/uninstall.py]
|
|
195
|
-
|
|
196
|
-
## CLI
|
|
197
|
-
|
|
198
|
-
Inspect candidate savings without changing output:
|
|
199
|
-
|
|
200
|
-
```bash
|
|
201
|
-
some-test-command | ai-toolkit output-filter inspect --profile repeat-lines
|
|
202
|
-
some-tap-command | ai-toolkit output-filter inspect --profile tap-success
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
The JSON report contains counts, eligibility, outcome, and fallback reason. It
|
|
206
|
-
does not echo stdin.
|
|
207
|
-
|
|
208
|
-
Inspect the effective trusted project or global policy:
|
|
209
|
-
|
|
210
|
-
```bash
|
|
211
|
-
ai-toolkit output-filter status
|
|
212
|
-
ai-toolkit output-filter status --policy /path/to/materialized-policy.json
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
Recover the exact native response object using the handle printed in a safe
|
|
216
|
-
replacement:
|
|
217
|
-
|
|
218
|
-
```bash
|
|
219
|
-
ai-toolkit output-filter recover <opaque-handle>
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
The default lookup derives the current repository session directory. Advanced
|
|
223
|
-
or test workflows can add `--base-directory PATH` or `--session-id ID`.
|
|
224
|
-
|
|
225
|
-
Clean the ending session, expired exact responses, or all filter artifacts for
|
|
226
|
-
the current repository:
|
|
227
|
-
|
|
228
|
-
```bash
|
|
229
|
-
ai-toolkit output-filter clean --session-id <native-session-id>
|
|
230
|
-
ai-toolkit output-filter clean --session-id <native-session-id> --expired
|
|
231
|
-
ai-toolkit output-filter clean
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
`--expired` requires `--session-id`. Cleanup prints only the removed artifact
|
|
235
|
-
count and scope.
|
|
236
|
-
|
|
237
|
-
## Runtime Capability Matrix
|
|
238
|
-
|
|
239
|
-
| Runtime | Active result replacement | Capability |
|
|
240
|
-
|---------|---------------------------|------------|
|
|
241
|
-
| Claude Code | Yes, opt-in | Native `PostToolUse.updatedToolOutput` adapter |
|
|
242
|
-
| Claude Chat / Cowork | No | Plugin export explicitly excludes the Claude Code-only hook |
|
|
243
|
-
| Cursor | No | Manual `output-filter inspect` only |
|
|
244
|
-
| Windsurf / Devin | No | Manual `output-filter inspect` only |
|
|
245
|
-
| GitHub Copilot | No | Manual `output-filter inspect` only |
|
|
246
|
-
| Gemini CLI | No | Manual `output-filter inspect` only |
|
|
247
|
-
| Cline | No | Manual `output-filter inspect` only |
|
|
248
|
-
| Roo Code | No | Manual `output-filter inspect` only |
|
|
249
|
-
| Aider | No | Manual `output-filter inspect` only |
|
|
250
|
-
| Augment | No | Manual `output-filter inspect` only |
|
|
251
|
-
| Google Antigravity | No | Manual `output-filter inspect` only |
|
|
252
|
-
| Codex CLI | No | Manual `output-filter inspect` only |
|
|
253
|
-
| OpenCode | No | Manual `output-filter inspect` only |
|
|
254
|
-
|
|
255
|
-
An editor hook, extra context message, or command wrapper is not treated as
|
|
256
|
-
result replacement. A new adapter requires a verified native replacement
|
|
257
|
-
contract and dedicated native payload tests.
|
|
258
|
-
|
|
259
|
-
## Benchmark Semantics
|
|
260
|
-
|
|
261
|
-
Run the deterministic offline corpus:
|
|
262
|
-
|
|
263
|
-
```bash
|
|
264
|
-
PYTHONDONTWRITEBYTECODE=1 python3 scripts/benchmark_output_filter.py
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
The benchmark measures profile p95 latency, production Bash-wrapper latency
|
|
268
|
-
with a fresh Python process per sample in one native session, traced peak
|
|
269
|
-
allocation, and eligible-output byte reduction. It uses 100 samples by default
|
|
270
|
-
to avoid a one-sample p95 swing. The current gates are:
|
|
271
|
-
|
|
272
|
-
- at least 30% candidate byte reduction;
|
|
273
|
-
- at most 20 ms p95 for profile inputs up to 100 KiB;
|
|
274
|
-
- at most 150 ms p95 for the 8 MiB profile case;
|
|
275
|
-
- at most 75 ms p95 for a cold end-to-end hook process;
|
|
276
|
-
- peak traced allocation no greater than three input sizes plus 16 MiB.
|
|
277
|
-
|
|
278
|
-
Byte reduction is not billed-token savings and is not a whole-session cost
|
|
279
|
-
claim. Measure actual model token receipts separately before changing the
|
|
280
|
-
default mode. [PATH: scripts/benchmark_output_filter.py] [PATH:
|
|
281
|
-
benchmarks/output-filter/]
|
|
282
|
-
|
|
283
|
-
## Related
|
|
284
|
-
|
|
285
|
-
- [Hooks Catalog](hooks-catalog.md)
|
|
286
|
-
- [Supported Tools Registry](supported-tools-registry.md)
|
|
287
|
-
- [Architecture Overview](architecture-overview.md)
|
|
288
|
-
- [Output Token Discipline Plan](../history/completed/output-token-discipline-plan-20260504.md)
|
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Deterministic offline benchmarks for native output profiles."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import json
|
|
8
|
-
import math
|
|
9
|
-
import os
|
|
10
|
-
import subprocess
|
|
11
|
-
import sys
|
|
12
|
-
import tempfile
|
|
13
|
-
import time
|
|
14
|
-
import tracemalloc
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
from tool_output_filter.profiles import apply_profile
|
|
18
|
-
|
|
19
|
-
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
|
20
|
-
DEFAULT_CORPUS = (
|
|
21
|
-
REPOSITORY_ROOT / "benchmarks" / "output-filter" / "scenarios.json"
|
|
22
|
-
)
|
|
23
|
-
MEMORY_ALLOWANCE_BYTES = 16 * 1024 * 1024
|
|
24
|
-
COLD_HOOK_INPUT_BYTES = 4 * 1024
|
|
25
|
-
COLD_HOOK_MAX_P95_MS = 75.0
|
|
26
|
-
DEFAULT_ITERATIONS = 100
|
|
27
|
-
HOOK_RUNTIME = REPOSITORY_ROOT / "scripts" / "output_filter_hook.py"
|
|
28
|
-
HOOK_SCRIPT = REPOSITORY_ROOT / "app" / "hooks" / "filter-tool-output.sh"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _repeat_output(target_bytes: int, line_width: int) -> str:
|
|
32
|
-
prefix = "synthetic progress "
|
|
33
|
-
line = prefix + ("x" * max(1, line_width - len(prefix) - 1)) + "\n"
|
|
34
|
-
count = math.ceil(target_bytes / len(line.encode("utf-8")))
|
|
35
|
-
return line * count
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _tap_output(test_count: int) -> str:
|
|
39
|
-
result_lines = [
|
|
40
|
-
f"ok {number} - synthetic benchmark case {number}\n"
|
|
41
|
-
for number in range(1, test_count + 1)
|
|
42
|
-
]
|
|
43
|
-
return "".join(
|
|
44
|
-
[
|
|
45
|
-
"TAP version 13\n",
|
|
46
|
-
f"1..{test_count}\n",
|
|
47
|
-
*result_lines,
|
|
48
|
-
f"# tests {test_count}\n",
|
|
49
|
-
f"# pass {test_count}\n",
|
|
50
|
-
"# fail 0\n",
|
|
51
|
-
"# duration_ms 1\n",
|
|
52
|
-
]
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _integer_field(scenario: dict[str, object], key: str) -> int:
|
|
57
|
-
value = scenario.get(key)
|
|
58
|
-
if isinstance(value, bool) or not isinstance(value, int):
|
|
59
|
-
raise ValueError(f"benchmark field {key} must be an integer")
|
|
60
|
-
return value
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _number_field(scenario: dict[str, object], key: str) -> float:
|
|
64
|
-
value = scenario.get(key)
|
|
65
|
-
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
66
|
-
raise ValueError(f"benchmark field {key} must be numeric")
|
|
67
|
-
return float(value)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _string_field(scenario: dict[str, object], key: str) -> str:
|
|
71
|
-
value = scenario.get(key)
|
|
72
|
-
if not isinstance(value, str):
|
|
73
|
-
raise ValueError(f"benchmark field {key} must be text")
|
|
74
|
-
return value
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _scenario_input(scenario: dict[str, object]) -> str:
|
|
78
|
-
kind = _string_field(scenario, "kind")
|
|
79
|
-
if kind == "repeat":
|
|
80
|
-
return _repeat_output(
|
|
81
|
-
_integer_field(scenario, "targetBytes"),
|
|
82
|
-
_integer_field(scenario, "lineWidth"),
|
|
83
|
-
)
|
|
84
|
-
if kind == "tap":
|
|
85
|
-
return _tap_output(_integer_field(scenario, "testCount"))
|
|
86
|
-
raise ValueError(f"unknown benchmark kind: {kind}")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _p95(values: list[float]) -> float:
|
|
90
|
-
ordered = sorted(values)
|
|
91
|
-
index = max(0, math.ceil(len(ordered) * 0.95) - 1)
|
|
92
|
-
return ordered[index]
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _measure_scenario(
|
|
96
|
-
scenario: dict[str, object],
|
|
97
|
-
iterations: int,
|
|
98
|
-
) -> dict[str, object]:
|
|
99
|
-
raw_output = _scenario_input(scenario)
|
|
100
|
-
profile_id = _string_field(scenario, "profile")
|
|
101
|
-
scenario_name = _string_field(scenario, "name")
|
|
102
|
-
durations: list[float] = []
|
|
103
|
-
candidate = apply_profile(profile_id, raw_output)
|
|
104
|
-
if candidate is None or not candidate.accepted:
|
|
105
|
-
raise RuntimeError(f"profile rejected benchmark {scenario_name}")
|
|
106
|
-
|
|
107
|
-
for _ in range(iterations):
|
|
108
|
-
started = time.perf_counter_ns()
|
|
109
|
-
measured = apply_profile(profile_id, raw_output)
|
|
110
|
-
durations.append((time.perf_counter_ns() - started) / 1_000_000)
|
|
111
|
-
if measured != candidate:
|
|
112
|
-
raise RuntimeError(
|
|
113
|
-
f"non-deterministic benchmark {scenario_name}"
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
tracemalloc.start()
|
|
117
|
-
apply_profile(profile_id, raw_output)
|
|
118
|
-
_, peak_bytes = tracemalloc.get_traced_memory()
|
|
119
|
-
tracemalloc.stop()
|
|
120
|
-
|
|
121
|
-
input_bytes = len(raw_output.encode("utf-8"))
|
|
122
|
-
candidate_bytes = len(candidate.output.encode("utf-8"))
|
|
123
|
-
savings_ratio = (input_bytes - candidate_bytes) / input_bytes
|
|
124
|
-
p95_ms = _p95(durations)
|
|
125
|
-
memory_limit = input_bytes * 3 + MEMORY_ALLOWANCE_BYTES
|
|
126
|
-
passed = (
|
|
127
|
-
candidate_bytes < input_bytes
|
|
128
|
-
and savings_ratio >= 0.3
|
|
129
|
-
and p95_ms <= _number_field(scenario, "maxP95Ms")
|
|
130
|
-
and peak_bytes <= memory_limit
|
|
131
|
-
)
|
|
132
|
-
return {
|
|
133
|
-
"name": scenario_name,
|
|
134
|
-
"profile": profile_id,
|
|
135
|
-
"inputBytes": input_bytes,
|
|
136
|
-
"candidateBytes": candidate_bytes,
|
|
137
|
-
"savingsRatio": round(savings_ratio, 4),
|
|
138
|
-
"p95Ms": round(p95_ms, 3),
|
|
139
|
-
"peakBytes": peak_bytes,
|
|
140
|
-
"memoryLimitBytes": memory_limit,
|
|
141
|
-
"passed": passed,
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _hook_policy(mode: str) -> dict[str, object]:
|
|
146
|
-
return {
|
|
147
|
-
"mode": mode,
|
|
148
|
-
"profiles": ["repeat-lines"],
|
|
149
|
-
"maxInputBytes": 8 * 1024 * 1024,
|
|
150
|
-
"minSavingsBytes": 1024,
|
|
151
|
-
"minSavingsRatio": 0.15,
|
|
152
|
-
"recovery": {
|
|
153
|
-
"mode": "ephemeral",
|
|
154
|
-
"ttlMinutes": 60,
|
|
155
|
-
"maxSessionBytes": 32 * 1024 * 1024,
|
|
156
|
-
},
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _hook_payload(raw_output: str, session_id: str) -> str:
|
|
161
|
-
payload = {
|
|
162
|
-
"hook_event_name": "PostToolUse",
|
|
163
|
-
"tool_name": "Bash",
|
|
164
|
-
"session_id": session_id,
|
|
165
|
-
"cwd": str(REPOSITORY_ROOT),
|
|
166
|
-
"tool_input": {"command": "npm test"},
|
|
167
|
-
"tool_response": {
|
|
168
|
-
"stdout": raw_output,
|
|
169
|
-
"stderr": "",
|
|
170
|
-
"interrupted": False,
|
|
171
|
-
"isImage": False,
|
|
172
|
-
},
|
|
173
|
-
}
|
|
174
|
-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _hook_session_root(temporary_home: Path) -> Path:
|
|
178
|
-
repo_key = "-" + str(REPOSITORY_ROOT).replace("/", "-").lstrip("-")
|
|
179
|
-
return (
|
|
180
|
-
temporary_home
|
|
181
|
-
/ ".softspark"
|
|
182
|
-
/ "ai-toolkit"
|
|
183
|
-
/ "sessions"
|
|
184
|
-
/ repo_key
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _run_cold_hook(
|
|
189
|
-
payload: str,
|
|
190
|
-
environment: dict[str, str],
|
|
191
|
-
) -> tuple[float, int]:
|
|
192
|
-
started = time.perf_counter_ns()
|
|
193
|
-
result = subprocess.run(
|
|
194
|
-
["bash", str(HOOK_SCRIPT)],
|
|
195
|
-
input=payload,
|
|
196
|
-
text=True,
|
|
197
|
-
capture_output=True,
|
|
198
|
-
cwd=REPOSITORY_ROOT,
|
|
199
|
-
env=environment,
|
|
200
|
-
check=False,
|
|
201
|
-
)
|
|
202
|
-
duration_ms = (time.perf_counter_ns() - started) / 1_000_000
|
|
203
|
-
if result.returncode != 0 or result.stderr:
|
|
204
|
-
raise RuntimeError("cold hook invocation failed")
|
|
205
|
-
if result.stdout:
|
|
206
|
-
raise RuntimeError("observe hook unexpectedly changed output")
|
|
207
|
-
return duration_ms, len(result.stdout.encode("utf-8"))
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _measure_cold_hook(
|
|
211
|
-
iterations: int,
|
|
212
|
-
mode: str = "observe",
|
|
213
|
-
) -> dict[str, object]:
|
|
214
|
-
raw_output = _repeat_output(COLD_HOOK_INPUT_BYTES, 96)
|
|
215
|
-
input_bytes = len(raw_output.encode("utf-8"))
|
|
216
|
-
durations: list[float] = []
|
|
217
|
-
emitted_sizes: list[int] = []
|
|
218
|
-
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
219
|
-
temporary_path = Path(temporary_directory)
|
|
220
|
-
policy_path = temporary_path / "policy.json"
|
|
221
|
-
policy_path.write_text(
|
|
222
|
-
json.dumps(_hook_policy(mode), separators=(",", ":")),
|
|
223
|
-
encoding="utf-8",
|
|
224
|
-
)
|
|
225
|
-
temporary_home = temporary_path / "home"
|
|
226
|
-
session_root = _hook_session_root(temporary_home)
|
|
227
|
-
session_root.mkdir(parents=True, mode=0o700)
|
|
228
|
-
session_root.chmod(0o700)
|
|
229
|
-
environment = {
|
|
230
|
-
**os.environ,
|
|
231
|
-
"HOME": str(temporary_home),
|
|
232
|
-
"PYTHONPYCACHEPREFIX": str(temporary_path / "pycache"),
|
|
233
|
-
"AI_TOOLKIT_DISABLED_HOOKS": "",
|
|
234
|
-
"AI_TOOLKIT_OUTPUT_FILTER_HOOK_RUNTIME": str(HOOK_RUNTIME),
|
|
235
|
-
"AI_TOOLKIT_OUTPUT_FILTER_DISABLE": "0",
|
|
236
|
-
"AI_TOOLKIT_OUTPUT_FILTER_POLICY": str(policy_path),
|
|
237
|
-
"TOOLKIT_HOOK_PROFILE": "standard",
|
|
238
|
-
}
|
|
239
|
-
environment.pop("PYTHONDONTWRITEBYTECODE", None)
|
|
240
|
-
session_id = "benchmark-session"
|
|
241
|
-
for iteration in range(iterations):
|
|
242
|
-
duration_ms, emitted_bytes = _run_cold_hook(
|
|
243
|
-
_hook_payload(raw_output, session_id),
|
|
244
|
-
environment,
|
|
245
|
-
)
|
|
246
|
-
durations.append(duration_ms)
|
|
247
|
-
emitted_sizes.append(emitted_bytes)
|
|
248
|
-
if mode == "observe":
|
|
249
|
-
telemetry_files = list(session_root.rglob(".telemetry.jsonl"))
|
|
250
|
-
if len(telemetry_files) != 1:
|
|
251
|
-
raise RuntimeError(
|
|
252
|
-
"cold hook did not complete observe telemetry"
|
|
253
|
-
)
|
|
254
|
-
telemetry_lines = telemetry_files[0].read_text(
|
|
255
|
-
encoding="ascii",
|
|
256
|
-
).splitlines()
|
|
257
|
-
if len(telemetry_lines) != iterations:
|
|
258
|
-
raise RuntimeError(
|
|
259
|
-
"cold hook telemetry event count is incomplete"
|
|
260
|
-
)
|
|
261
|
-
p95_ms = _p95(durations)
|
|
262
|
-
emitted_bytes = max(emitted_sizes)
|
|
263
|
-
return {
|
|
264
|
-
"name": "claude-hook-cold-4k",
|
|
265
|
-
"mode": mode,
|
|
266
|
-
"adapter": "bash-wrapper",
|
|
267
|
-
"sessionReused": True,
|
|
268
|
-
"iterations": iterations,
|
|
269
|
-
"inputBytes": input_bytes,
|
|
270
|
-
"emittedBytes": emitted_bytes,
|
|
271
|
-
"p95Ms": round(p95_ms, 3),
|
|
272
|
-
"maxP95Ms": COLD_HOOK_MAX_P95_MS,
|
|
273
|
-
"passed": (
|
|
274
|
-
emitted_bytes < input_bytes
|
|
275
|
-
and p95_ms <= COLD_HOOK_MAX_P95_MS
|
|
276
|
-
),
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def _load_corpus(path: Path) -> list[dict[str, object]]:
|
|
281
|
-
with path.open(encoding="utf-8") as corpus_file:
|
|
282
|
-
data = json.load(corpus_file)
|
|
283
|
-
if not isinstance(data, list):
|
|
284
|
-
raise ValueError("benchmark corpus must be an array")
|
|
285
|
-
return data
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
289
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
290
|
-
parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS)
|
|
291
|
-
parser.add_argument("--iterations", type=int, default=DEFAULT_ITERATIONS)
|
|
292
|
-
parser.add_argument("--max-input-bytes", type=int)
|
|
293
|
-
parser.add_argument("--report-cold-hook-only", action="store_true")
|
|
294
|
-
parser.add_argument("--json", action="store_true")
|
|
295
|
-
return parser
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def main(argv: list[str] | None = None) -> int:
|
|
299
|
-
args = _build_parser().parse_args(argv)
|
|
300
|
-
if args.iterations <= 0:
|
|
301
|
-
return 2
|
|
302
|
-
scenarios = []
|
|
303
|
-
for definition in _load_corpus(args.corpus):
|
|
304
|
-
raw_size = len(_scenario_input(definition).encode("utf-8"))
|
|
305
|
-
if args.max_input_bytes is not None and raw_size > args.max_input_bytes:
|
|
306
|
-
continue
|
|
307
|
-
scenarios.append(_measure_scenario(definition, args.iterations))
|
|
308
|
-
cold_hook = _measure_cold_hook(args.iterations)
|
|
309
|
-
profile_gates_passed = bool(scenarios) and all(
|
|
310
|
-
scenario["passed"] for scenario in scenarios
|
|
311
|
-
)
|
|
312
|
-
cold_hook_enforced = not args.report_cold_hook_only
|
|
313
|
-
report = {
|
|
314
|
-
"passed": (
|
|
315
|
-
profile_gates_passed
|
|
316
|
-
and (
|
|
317
|
-
cold_hook["passed"]
|
|
318
|
-
or not cold_hook_enforced
|
|
319
|
-
)
|
|
320
|
-
),
|
|
321
|
-
"scenarios": scenarios,
|
|
322
|
-
"coldHook": cold_hook,
|
|
323
|
-
"coldHookEnforced": cold_hook_enforced,
|
|
324
|
-
}
|
|
325
|
-
if args.json:
|
|
326
|
-
print(json.dumps(report, separators=(",", ":")))
|
|
327
|
-
else:
|
|
328
|
-
for scenario in scenarios:
|
|
329
|
-
print(
|
|
330
|
-
f"{scenario['name']}: p95={scenario['p95Ms']}ms "
|
|
331
|
-
f"savings={scenario['savingsRatio']} "
|
|
332
|
-
f"peak={scenario['peakBytes']} passed={scenario['passed']}"
|
|
333
|
-
)
|
|
334
|
-
print(
|
|
335
|
-
f"{cold_hook['name']}: p95={cold_hook['p95Ms']}ms "
|
|
336
|
-
f"emitted={cold_hook['emittedBytes']} "
|
|
337
|
-
f"passed={cold_hook['passed']}"
|
|
338
|
-
)
|
|
339
|
-
return 0 if report["passed"] else 1
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if __name__ == "__main__":
|
|
343
|
-
sys.exit(main())
|