@softspark/ai-toolkit 4.15.1 → 4.16.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 +34 -0
- package/README.md +19 -11
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +4 -3
- package/app/hooks/_hook-io.sh +18 -3
- package/app/hooks/ai-toolkit-statusline.sh +30 -5
- package/app/hooks/filter-tool-output.sh +76 -0
- package/app/hooks/governance-capture.sh +1 -1
- package/app/hooks/guard-path.sh +2 -2
- package/app/hooks/post-tool-use.sh +5 -3
- package/app/hooks/pre-compact-save.sh +4 -3
- package/app/hooks/quality-gate.sh +12 -1
- package/app/hooks/revert-guard.sh +5 -2
- package/app/hooks/save-session.sh +4 -2
- package/app/hooks/session-end.sh +36 -4
- package/app/hooks/session-start.sh +11 -5
- package/app/hooks.json +10 -0
- package/app/output-filter-policy.json +15 -0
- package/app/skills/brand-voice/scripts/measure.py +7 -5
- package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
- package/benchmarks/output-filter/README.md +11 -0
- package/benchmarks/output-filter/scenarios.json +25 -0
- package/bin/ai-toolkit.js +2 -0
- package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
- package/kb/procedures/release-preparation-sop.md +6 -5
- package/kb/reference/architecture-overview.md +6 -5
- package/kb/reference/cli-reference.md +19 -2
- package/kb/reference/codex-cli-compatibility.md +1 -0
- package/kb/reference/copilot-compatibility.md +173 -0
- package/kb/reference/enterprise-config-guide.md +28 -2
- package/kb/reference/global-install-model.md +1 -0
- package/kb/reference/hooks-catalog.md +105 -16
- package/kb/reference/opencode-compatibility.md +1 -0
- package/kb/reference/supported-tools-registry.md +10 -5
- package/kb/reference/tool-output-filter.md +288 -0
- package/llms-full.txt +1173 -35
- package/llms.txt +3 -0
- package/manifest.json +9 -6
- package/package.json +3 -2
- package/scripts/benchmark_output_filter.py +343 -0
- package/scripts/check_deps.py +16 -0
- package/scripts/claude_app.py +30 -2
- package/scripts/config_cli.py +4 -4
- package/scripts/config_lock.py +120 -14
- package/scripts/config_merger.py +103 -20
- package/scripts/config_resolver.py +22 -2
- package/scripts/config_validator.py +268 -16
- package/scripts/doctor.py +1 -0
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_gemini_hooks.py +33 -10
- package/scripts/generate_opencode_plugin.py +28 -12
- package/scripts/install_steps/ai_tools.py +101 -2
- package/scripts/install_steps/hooks.py +25 -1
- package/scripts/output_filter_cli.py +347 -0
- package/scripts/output_filter_hook.py +23 -0
- package/scripts/plugin_schema.py +27 -1
- package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
- package/scripts/session_state.py +156 -42
- package/scripts/tool_output_filter/__init__.py +33 -0
- package/scripts/tool_output_filter/contracts.py +173 -0
- package/scripts/tool_output_filter/engine.py +260 -0
- package/scripts/tool_output_filter/hook_runtime.py +369 -0
- package/scripts/tool_output_filter/input.py +56 -0
- package/scripts/tool_output_filter/invariants.py +40 -0
- package/scripts/tool_output_filter/policy.py +153 -0
- package/scripts/tool_output_filter/profiles/__init__.py +68 -0
- package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
- package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
- package/scripts/tool_output_filter/recovery.py +846 -0
- package/scripts/tool_output_filter/telemetry.py +13 -0
- package/scripts/uninstall.py +96 -3
|
@@ -39,6 +39,66 @@
|
|
|
39
39
|
"default": "standard",
|
|
40
40
|
"description": "Installation profile controlling which modules are installed."
|
|
41
41
|
},
|
|
42
|
+
"toolOutputFilter": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"description": "Native post-execution Bash output filtering. Disabled by default.",
|
|
46
|
+
"properties": {
|
|
47
|
+
"mode": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"enum": ["off", "observe", "safe"],
|
|
50
|
+
"default": "off"
|
|
51
|
+
},
|
|
52
|
+
"profiles": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"enum": ["repeat-lines", "tap-success"]
|
|
57
|
+
},
|
|
58
|
+
"uniqueItems": true,
|
|
59
|
+
"default": ["repeat-lines", "tap-success"]
|
|
60
|
+
},
|
|
61
|
+
"maxInputBytes": {
|
|
62
|
+
"type": "integer",
|
|
63
|
+
"minimum": 1,
|
|
64
|
+
"maximum": 8388608,
|
|
65
|
+
"default": 8388608
|
|
66
|
+
},
|
|
67
|
+
"minSavingsBytes": {
|
|
68
|
+
"type": "integer",
|
|
69
|
+
"minimum": 0,
|
|
70
|
+
"maximum": 8388608,
|
|
71
|
+
"default": 1024
|
|
72
|
+
},
|
|
73
|
+
"minSavingsRatio": {
|
|
74
|
+
"type": "number",
|
|
75
|
+
"minimum": 0,
|
|
76
|
+
"maximum": 1,
|
|
77
|
+
"default": 0.15
|
|
78
|
+
},
|
|
79
|
+
"recovery": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"additionalProperties": false,
|
|
82
|
+
"properties": {
|
|
83
|
+
"mode": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"enum": ["ephemeral"],
|
|
86
|
+
"default": "ephemeral"
|
|
87
|
+
},
|
|
88
|
+
"ttlMinutes": {
|
|
89
|
+
"type": "integer",
|
|
90
|
+
"minimum": 1,
|
|
91
|
+
"default": 60
|
|
92
|
+
},
|
|
93
|
+
"maxSessionBytes": {
|
|
94
|
+
"type": "integer",
|
|
95
|
+
"minimum": 1,
|
|
96
|
+
"default": 33554432
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
42
102
|
"agents": {
|
|
43
103
|
"type": "object",
|
|
44
104
|
"additionalProperties": false,
|
|
@@ -61,6 +121,23 @@
|
|
|
61
121
|
}
|
|
62
122
|
}
|
|
63
123
|
},
|
|
124
|
+
"plugins": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"additionalProperties": false,
|
|
127
|
+
"description": "Resolved plugin enable/disable intent. Runtime installation remains explicit.",
|
|
128
|
+
"properties": {
|
|
129
|
+
"enabled": {
|
|
130
|
+
"type": "array",
|
|
131
|
+
"items": { "type": "string", "minLength": 1 },
|
|
132
|
+
"uniqueItems": true
|
|
133
|
+
},
|
|
134
|
+
"disabled": {
|
|
135
|
+
"type": "array",
|
|
136
|
+
"items": { "type": "string", "minLength": 1 },
|
|
137
|
+
"uniqueItems": true
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
64
141
|
"rules": {
|
|
65
142
|
"type": "object",
|
|
66
143
|
"additionalProperties": false,
|
|
@@ -81,7 +158,7 @@
|
|
|
81
158
|
"constitution": {
|
|
82
159
|
"type": "object",
|
|
83
160
|
"additionalProperties": false,
|
|
84
|
-
"description": "Constitution amendments. Articles I-
|
|
161
|
+
"description": "Constitution amendments. Articles I-VII are immutable. Base articles are immutable. Projects can only ADD new articles (8+).",
|
|
85
162
|
"properties": {
|
|
86
163
|
"amendments": {
|
|
87
164
|
"type": "array",
|
|
@@ -92,8 +169,8 @@
|
|
|
92
169
|
"properties": {
|
|
93
170
|
"article": {
|
|
94
171
|
"type": "integer",
|
|
95
|
-
"minimum":
|
|
96
|
-
"description": "Article number. 1-
|
|
172
|
+
"minimum": 8,
|
|
173
|
+
"description": "Article number. 1-7 are reserved (immutable). Base articles are also immutable."
|
|
97
174
|
},
|
|
98
175
|
"title": {
|
|
99
176
|
"type": "string",
|
|
@@ -120,8 +197,9 @@
|
|
|
120
197
|
},
|
|
121
198
|
"requiredPlugins": {
|
|
122
199
|
"type": "array",
|
|
123
|
-
"items": { "type": "string" },
|
|
124
|
-
"
|
|
200
|
+
"items": { "type": "string", "minLength": 1 },
|
|
201
|
+
"uniqueItems": true,
|
|
202
|
+
"description": "Plugins that must appear in resolved enabled intent. Runtime installation remains explicit."
|
|
125
203
|
},
|
|
126
204
|
"forbidOverride": {
|
|
127
205
|
"type": "array",
|
package/scripts/session_state.py
CHANGED
|
@@ -5,14 +5,16 @@ Stores per-session file edits so hooks can:
|
|
|
5
5
|
- Know which paths were touched (revert-guard, test-cohesion).
|
|
6
6
|
- Run only related tests (quality-gate ai-toolkit branch).
|
|
7
7
|
|
|
8
|
-
State
|
|
8
|
+
State files: ``~/.softspark/ai-toolkit/state/session-edits-<hash>.json``.
|
|
9
|
+
The legacy ``session-edits.json`` alias tracks the most recently active session.
|
|
9
10
|
|
|
10
11
|
Usage:
|
|
11
12
|
session_state.py reset [--session-id ID]
|
|
12
13
|
session_state.py append --tool Edit --path /abs/path [--session-id ID]
|
|
13
|
-
session_state.py was-edited /abs/path
|
|
14
|
-
session_state.py list
|
|
15
|
-
session_state.py session-id
|
|
14
|
+
session_state.py was-edited /abs/path [--session-id ID]
|
|
15
|
+
session_state.py list [--session-id ID]
|
|
16
|
+
session_state.py session-id [--session-id ID]
|
|
17
|
+
session_state.py clean --session-id ID
|
|
16
18
|
|
|
17
19
|
Exit codes:
|
|
18
20
|
0 success / true
|
|
@@ -23,9 +25,12 @@ from __future__ import annotations
|
|
|
23
25
|
|
|
24
26
|
import argparse
|
|
25
27
|
import datetime as _dt
|
|
28
|
+
import hashlib
|
|
26
29
|
import json
|
|
27
30
|
import os
|
|
31
|
+
import stat
|
|
28
32
|
import sys
|
|
33
|
+
import tempfile
|
|
29
34
|
import uuid
|
|
30
35
|
from pathlib import Path
|
|
31
36
|
|
|
@@ -42,77 +47,176 @@ def _ensure_dir() -> None:
|
|
|
42
47
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
48
|
|
|
44
49
|
|
|
45
|
-
def
|
|
46
|
-
if not
|
|
47
|
-
return
|
|
50
|
+
def _state_file(session_id: str | None) -> Path:
|
|
51
|
+
if not session_id:
|
|
52
|
+
return STATE_FILE
|
|
53
|
+
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()
|
|
54
|
+
return STATE_DIR / f"session-edits-{digest}.json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _empty_state(session_id: str = "") -> dict:
|
|
58
|
+
return {"session_id": session_id, "started_at": "", "edits": []}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validated_state(data: object, session_id: str | None) -> dict:
|
|
62
|
+
fallback = _empty_state(session_id or "")
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
return fallback
|
|
65
|
+
stored_session = data.get("session_id", session_id or "")
|
|
66
|
+
started_at = data.get("started_at", "")
|
|
67
|
+
edits = data.get("edits", [])
|
|
68
|
+
if not isinstance(stored_session, str) or not isinstance(started_at, str):
|
|
69
|
+
return fallback
|
|
70
|
+
if not isinstance(edits, list) or any(
|
|
71
|
+
not isinstance(edit, dict)
|
|
72
|
+
or not isinstance(edit.get("path", ""), str)
|
|
73
|
+
for edit in edits
|
|
74
|
+
):
|
|
75
|
+
return fallback
|
|
76
|
+
return {
|
|
77
|
+
"session_id": stored_session,
|
|
78
|
+
"started_at": started_at,
|
|
79
|
+
"edits": edits,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _load(session_id: str | None = None) -> dict:
|
|
84
|
+
state_file = _state_file(session_id)
|
|
85
|
+
if not state_file.is_file():
|
|
86
|
+
return _empty_state(session_id or "")
|
|
48
87
|
try:
|
|
49
|
-
with
|
|
88
|
+
with state_file.open(encoding="utf-8") as f:
|
|
50
89
|
data = json.load(f)
|
|
51
|
-
except (json.JSONDecodeError, OSError):
|
|
52
|
-
return
|
|
53
|
-
data
|
|
54
|
-
data.setdefault("started_at", "")
|
|
55
|
-
data.setdefault("edits", [])
|
|
56
|
-
return data
|
|
90
|
+
except (json.JSONDecodeError, OSError, UnicodeError):
|
|
91
|
+
return _empty_state(session_id or "")
|
|
92
|
+
return _validated_state(data, session_id)
|
|
57
93
|
|
|
58
94
|
|
|
59
|
-
def
|
|
95
|
+
def _atomic_save(state_file: Path, data: dict) -> None:
|
|
60
96
|
_ensure_dir()
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
97
|
+
temp_path: Path | None = None
|
|
98
|
+
try:
|
|
99
|
+
with tempfile.NamedTemporaryFile(
|
|
100
|
+
mode="w",
|
|
101
|
+
dir=STATE_DIR,
|
|
102
|
+
prefix=f".{state_file.name}.",
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
delete=False,
|
|
105
|
+
) as temp_file:
|
|
106
|
+
json.dump(data, temp_file, indent=2)
|
|
107
|
+
temp_file.write("\n")
|
|
108
|
+
temp_path = Path(temp_file.name)
|
|
109
|
+
os.replace(temp_path, state_file)
|
|
110
|
+
temp_path = None
|
|
111
|
+
finally:
|
|
112
|
+
if temp_path is not None:
|
|
113
|
+
temp_path.unlink(missing_ok=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _save(data: dict, session_id: str | None = None) -> None:
|
|
117
|
+
_atomic_save(_state_file(session_id), data)
|
|
118
|
+
if session_id:
|
|
119
|
+
# Preserve no-ID CLI behavior without making it the source of truth.
|
|
120
|
+
_atomic_save(STATE_FILE, data)
|
|
66
121
|
|
|
67
122
|
|
|
68
123
|
def cmd_reset(session_id: str | None) -> int:
|
|
69
124
|
sid = session_id or str(uuid.uuid4())
|
|
70
|
-
_save({"session_id": sid, "started_at": _now(), "edits": []})
|
|
125
|
+
_save({"session_id": sid, "started_at": _now(), "edits": []}, session_id)
|
|
71
126
|
return 0
|
|
72
127
|
|
|
73
128
|
|
|
74
129
|
def cmd_append(tool: str, path: str, session_id: str | None) -> int:
|
|
75
130
|
if not path:
|
|
76
131
|
return 0 # silently ignore tools without file_path (e.g., bash)
|
|
77
|
-
data = _load()
|
|
132
|
+
data = _load(session_id)
|
|
78
133
|
if session_id and data["session_id"] != session_id:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
data =
|
|
134
|
+
data = _empty_state(session_id)
|
|
135
|
+
if not data["started_at"]:
|
|
136
|
+
data["started_at"] = _now()
|
|
82
137
|
abs_path = os.path.abspath(path)
|
|
83
138
|
data["edits"].append({"ts": _now(), "tool": tool, "path": abs_path})
|
|
84
139
|
if len(data["edits"]) > MAX_EDITS:
|
|
85
140
|
data["edits"] = data["edits"][-MAX_EDITS:]
|
|
86
|
-
_save(data)
|
|
141
|
+
_save(data, session_id)
|
|
87
142
|
return 0
|
|
88
143
|
|
|
89
144
|
|
|
90
|
-
def cmd_was_edited(path: str) -> int:
|
|
145
|
+
def cmd_was_edited(path: str, session_id: str | None) -> int:
|
|
91
146
|
abs_path = os.path.abspath(path)
|
|
92
|
-
data = _load()
|
|
147
|
+
data = _load(session_id)
|
|
93
148
|
for edit in data["edits"]:
|
|
94
149
|
if edit.get("path") == abs_path:
|
|
95
150
|
return 0
|
|
96
151
|
return 1
|
|
97
152
|
|
|
98
153
|
|
|
99
|
-
def cmd_list() -> int:
|
|
100
|
-
data = _load()
|
|
154
|
+
def cmd_list(session_id: str | None) -> int:
|
|
155
|
+
data = _load(session_id)
|
|
101
156
|
seen: set[str] = set()
|
|
102
157
|
for edit in data["edits"]:
|
|
103
|
-
|
|
104
|
-
if
|
|
105
|
-
seen.add(
|
|
106
|
-
print(
|
|
158
|
+
path = edit.get("path", "")
|
|
159
|
+
if path and path not in seen:
|
|
160
|
+
seen.add(path)
|
|
161
|
+
print(path)
|
|
107
162
|
return 0
|
|
108
163
|
|
|
109
164
|
|
|
110
|
-
def cmd_session_id() -> int:
|
|
111
|
-
data = _load()
|
|
165
|
+
def cmd_session_id(session_id: str | None) -> int:
|
|
166
|
+
data = _load(session_id)
|
|
112
167
|
print(data.get("session_id", ""))
|
|
113
168
|
return 0
|
|
114
169
|
|
|
115
170
|
|
|
171
|
+
def cmd_clean(session_id: str) -> int:
|
|
172
|
+
"""Remove the selected hashed state and its matching legacy alias."""
|
|
173
|
+
state_file = _state_file(session_id)
|
|
174
|
+
try:
|
|
175
|
+
metadata = state_file.lstat()
|
|
176
|
+
except FileNotFoundError:
|
|
177
|
+
_clean_matching_legacy_alias(session_id)
|
|
178
|
+
return 0
|
|
179
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
180
|
+
return 2
|
|
181
|
+
try:
|
|
182
|
+
state_file.unlink()
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
pass
|
|
185
|
+
_clean_matching_legacy_alias(session_id)
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _clean_matching_legacy_alias(session_id: str) -> None:
|
|
190
|
+
"""Remove the compatibility alias only when it names this session."""
|
|
191
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(
|
|
192
|
+
os,
|
|
193
|
+
"O_NOFOLLOW",
|
|
194
|
+
0,
|
|
195
|
+
)
|
|
196
|
+
try:
|
|
197
|
+
descriptor = os.open(STATE_FILE, flags)
|
|
198
|
+
except OSError:
|
|
199
|
+
return
|
|
200
|
+
opened_metadata = os.fstat(descriptor)
|
|
201
|
+
try:
|
|
202
|
+
with os.fdopen(descriptor, encoding="utf-8") as state_handle:
|
|
203
|
+
data = json.load(state_handle)
|
|
204
|
+
except (json.JSONDecodeError, OSError, UnicodeError):
|
|
205
|
+
return
|
|
206
|
+
if isinstance(data, dict) and data.get("session_id") == session_id:
|
|
207
|
+
try:
|
|
208
|
+
current_metadata = STATE_FILE.lstat()
|
|
209
|
+
if (
|
|
210
|
+
not stat.S_ISREG(current_metadata.st_mode)
|
|
211
|
+
or current_metadata.st_dev != opened_metadata.st_dev
|
|
212
|
+
or current_metadata.st_ino != opened_metadata.st_ino
|
|
213
|
+
):
|
|
214
|
+
return
|
|
215
|
+
STATE_FILE.unlink()
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
116
220
|
def main(argv: list[str] | None = None) -> int:
|
|
117
221
|
parser = argparse.ArgumentParser(description="Session edit state tracker")
|
|
118
222
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
@@ -127,9 +231,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
127
231
|
|
|
128
232
|
was = sub.add_parser("was-edited", help="Exit 0 if path was edited this session")
|
|
129
233
|
was.add_argument("path")
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
sub.add_parser("
|
|
234
|
+
was.add_argument("--session-id", default=None)
|
|
235
|
+
|
|
236
|
+
list_parser = sub.add_parser("list", help="Print unique edited paths, one per line")
|
|
237
|
+
list_parser.add_argument("--session-id", default=None)
|
|
238
|
+
session_parser = sub.add_parser("session-id", help="Print current session id")
|
|
239
|
+
session_parser.add_argument("--session-id", default=None)
|
|
240
|
+
clean_parser = sub.add_parser(
|
|
241
|
+
"clean",
|
|
242
|
+
help="Remove one session-scoped edit state file",
|
|
243
|
+
)
|
|
244
|
+
clean_parser.add_argument("--session-id", required=True)
|
|
133
245
|
|
|
134
246
|
args = parser.parse_args(argv)
|
|
135
247
|
|
|
@@ -138,11 +250,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
138
250
|
if args.cmd == "append":
|
|
139
251
|
return cmd_append(args.tool, args.path, args.session_id)
|
|
140
252
|
if args.cmd == "was-edited":
|
|
141
|
-
return cmd_was_edited(args.path)
|
|
253
|
+
return cmd_was_edited(args.path, args.session_id)
|
|
142
254
|
if args.cmd == "list":
|
|
143
|
-
return cmd_list()
|
|
255
|
+
return cmd_list(args.session_id)
|
|
144
256
|
if args.cmd == "session-id":
|
|
145
|
-
return cmd_session_id()
|
|
257
|
+
return cmd_session_id(args.session_id)
|
|
258
|
+
if args.cmd == "clean":
|
|
259
|
+
return cmd_clean(args.session_id)
|
|
146
260
|
return 2
|
|
147
261
|
|
|
148
262
|
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
)
|