@dev-sergeev/datalab-kit 0.1.5 → 0.1.6
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 +11 -0
- package/README.md +23 -1
- package/THIRD_PARTY_NOTICES.md +3 -0
- package/dist/extension.js +2 -0
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +54 -0
- package/package.json +36 -9
- package/skills/init/SKILL.md +31 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/SKILL.md +610 -0
- package/skills/skill-creator/agents/analyzer.md +291 -0
- package/skills/skill-creator/agents/comparator.md +213 -0
- package/skills/skill-creator/agents/grader.md +234 -0
- package/skills/skill-creator/assets/eval_review.html +146 -0
- package/skills/skill-creator/eval-viewer/generate_review.py +453 -0
- package/skills/skill-creator/eval-viewer/viewer.html +1329 -0
- package/skills/skill-creator/references/schemas.md +521 -0
- package/skills/skill-creator/scripts/aggregate_benchmark.py +415 -0
- package/skills/skill-creator/scripts/generate_report.py +329 -0
- package/skills/skill-creator/scripts/improve_description.py +225 -0
- package/skills/skill-creator/scripts/package_skill.py +136 -0
- package/skills/skill-creator/scripts/pi_runtime.py +177 -0
- package/skills/skill-creator/scripts/quick_validate.py +107 -0
- package/skills/skill-creator/scripts/run_eval.py +215 -0
- package/skills/skill-creator/scripts/run_loop.py +334 -0
- package/skills/skill-creator/scripts/utils.py +41 -0
- package/node_modules/node-addon-api/common 2.gypi +0 -21
- package/node_modules/node-addon-api/except 2.gypi +0 -25
- package/node_modules/node-addon-api/napi 2.h +0 -3463
- package/node_modules/node-addon-api/napi-inl 2.h +0 -7250
- package/node_modules/node-addon-api/napi-inl.deprecated 2.h +0 -186
- package/node_modules/node-addon-api/node_addon_api 2.gyp +0 -42
- package/node_modules/node-addon-api/node_api 2.gyp +0 -9
- package/node_modules/node-addon-api/noexcept 2.gypi +0 -26
- package/node_modules/pi-toggle-skills/toggle-skills 2.ts +0 -316
- package/node_modules/pi-toggle-skills/vitest.config 2.ts +0 -15
- package/node_modules/sprintf-js/.npmignore 2 +0 -1
- package/node_modules/sprintf-js/LICENSE 2 +0 -24
- package/node_modules/sprintf-js/README 2.md +0 -88
- package/node_modules/sprintf-js/bower 2.json +0 -14
- package/node_modules/sprintf-js/gruntfile 2.js +0 -36
- package/node_modules/sprintf-js/package 2.json +0 -22
- package/node_modules/tree-sitter-bash/tree-sitter-bash 2.wasm +0 -0
- package/node_modules/typebox/license 2 +0 -23
- package/node_modules/typebox/package 2.json +0 -87
- package/node_modules/typebox/readme 2.md +0 -370
- package/node_modules/zod/compile 2.cjs +0 -96
- package/node_modules/zod/compile 2.js +0 -52
- package/node_modules/zod/index 2.cjs +0 -52
- package/node_modules/zod/index 2.js +0 -7
- /package/{node_modules/node-addon-api/nothing 2.c → skills/skill-creator/scripts/__init__.py} +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Pi JSON CLI backend for the Anthropic skill-creator scripts.
|
|
2
|
+
|
|
3
|
+
Added by Datalab Kit for Pi; upstream source pinned at
|
|
4
|
+
anthropics/skills dbd4588f9e1033efb41dad4bef2f7947c8993d44 (Apache-2.0).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import selectors
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pi_command(model: str | None = None) -> list[str]:
|
|
20
|
+
"""Use the host Pi installation without importing package-local Node modules."""
|
|
21
|
+
# Keep configured extensions: they may register the selected model provider.
|
|
22
|
+
# Discovery isolation concerns skills/context, not the user's credentials.
|
|
23
|
+
command = [
|
|
24
|
+
os.environ.get("PI_EXECUTABLE_PATH") or "pi",
|
|
25
|
+
"--print", "--mode", "json", "--no-session",
|
|
26
|
+
"--no-skills", "--no-prompt-templates", "--no-themes",
|
|
27
|
+
"--no-context-files", "--no-approve",
|
|
28
|
+
# Empty explicit sources suppress global SYSTEM.md/APPEND_SYSTEM.md
|
|
29
|
+
# discovery while retaining Pi's built-in system prompt.
|
|
30
|
+
"--system-prompt", "", "--append-system-prompt", "",
|
|
31
|
+
]
|
|
32
|
+
if model:
|
|
33
|
+
command.extend(["--model", model])
|
|
34
|
+
return command
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def interrupt_on_termination():
|
|
39
|
+
"""Give CLI SIGTERM the same resource cleanup guarantees as Ctrl-C."""
|
|
40
|
+
if threading.current_thread() is not threading.main_thread():
|
|
41
|
+
yield
|
|
42
|
+
return
|
|
43
|
+
previous = signal.getsignal(signal.SIGTERM)
|
|
44
|
+
|
|
45
|
+
def terminate(signum, frame):
|
|
46
|
+
raise SystemExit(128 + signum)
|
|
47
|
+
|
|
48
|
+
signal.signal(signal.SIGTERM, terminate)
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
signal.signal(signal.SIGTERM, previous)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _stop_process(process: subprocess.Popen) -> None:
|
|
56
|
+
"""Reap Pi and terminate its subprocess group, including tool children."""
|
|
57
|
+
if os.name == "posix":
|
|
58
|
+
# Also kill descendants if the group leader has already exited.
|
|
59
|
+
try:
|
|
60
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
61
|
+
except ProcessLookupError:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
process.wait(timeout=2)
|
|
65
|
+
except subprocess.TimeoutExpired:
|
|
66
|
+
pass
|
|
67
|
+
try:
|
|
68
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
69
|
+
except ProcessLookupError:
|
|
70
|
+
pass
|
|
71
|
+
elif process.poll() is None:
|
|
72
|
+
process.kill()
|
|
73
|
+
process.wait()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_pi(
|
|
77
|
+
prompt: str,
|
|
78
|
+
model: str | None,
|
|
79
|
+
timeout: int,
|
|
80
|
+
cwd: Path,
|
|
81
|
+
options: list[str],
|
|
82
|
+
on_event=None,
|
|
83
|
+
cancel: threading.Event | None = None,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Run one fresh Pi session, optionally stopping at a decisive tool event.
|
|
86
|
+
|
|
87
|
+
An on_event callback returning True marks an intentional early completion.
|
|
88
|
+
Otherwise a valid agent_end, successful process exit and non-error assistant
|
|
89
|
+
messages are required. Provider failures can exit zero in Pi JSON mode.
|
|
90
|
+
"""
|
|
91
|
+
command = pi_command(model) + options
|
|
92
|
+
# A file-backed stdin avoids argument-size limits and pipe write deadlocks.
|
|
93
|
+
with tempfile.TemporaryFile() as stdin, tempfile.TemporaryFile() as stderr:
|
|
94
|
+
stdin.write(prompt.encode("utf-8"))
|
|
95
|
+
stdin.seek(0)
|
|
96
|
+
process = subprocess.Popen(
|
|
97
|
+
command, stdin=stdin, stdout=subprocess.PIPE, stderr=stderr,
|
|
98
|
+
cwd=cwd, start_new_session=(os.name == "posix"),
|
|
99
|
+
)
|
|
100
|
+
deadline = time.monotonic() + timeout
|
|
101
|
+
text = []
|
|
102
|
+
ended = False
|
|
103
|
+
assistant_seen = False
|
|
104
|
+
buffer = b""
|
|
105
|
+
|
|
106
|
+
def consume(line):
|
|
107
|
+
nonlocal ended, assistant_seen
|
|
108
|
+
if not line.strip():
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
event = json.loads(line)
|
|
112
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
113
|
+
raise RuntimeError("Pi emitted invalid JSON on stdout") from error
|
|
114
|
+
if not isinstance(event, dict):
|
|
115
|
+
raise RuntimeError("Pi emitted a non-object JSON event")
|
|
116
|
+
if event.get("type") == "message_end":
|
|
117
|
+
message = event.get("message", {})
|
|
118
|
+
if message.get("role") == "assistant":
|
|
119
|
+
assistant_seen = True
|
|
120
|
+
if message.get("stopReason") in ("error", "aborted"):
|
|
121
|
+
raise RuntimeError(f"Pi model failed: {message.get('errorMessage') or message['stopReason']}")
|
|
122
|
+
text.extend(block["text"] for block in message.get("content", []) if block.get("type") == "text")
|
|
123
|
+
if event.get("type") == "agent_end":
|
|
124
|
+
ended = True
|
|
125
|
+
return bool(on_event and on_event(event))
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
with selectors.DefaultSelector() as selector:
|
|
129
|
+
selector.register(process.stdout, selectors.EVENT_READ)
|
|
130
|
+
while True:
|
|
131
|
+
if cancel is not None and cancel.is_set():
|
|
132
|
+
raise RuntimeError("Pi evaluation cancelled")
|
|
133
|
+
remaining = deadline - time.monotonic()
|
|
134
|
+
if remaining <= 0:
|
|
135
|
+
raise TimeoutError(f"Pi did not complete within {timeout} seconds")
|
|
136
|
+
if not selector.select(min(remaining, 0.1)):
|
|
137
|
+
continue
|
|
138
|
+
chunk = os.read(process.stdout.fileno(), 65536)
|
|
139
|
+
if not chunk:
|
|
140
|
+
if buffer and consume(buffer):
|
|
141
|
+
return "".join(text)
|
|
142
|
+
break
|
|
143
|
+
buffer += chunk
|
|
144
|
+
while b"\n" in buffer:
|
|
145
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
146
|
+
if consume(line):
|
|
147
|
+
return "".join(text)
|
|
148
|
+
remaining = deadline - time.monotonic()
|
|
149
|
+
if remaining <= 0:
|
|
150
|
+
raise TimeoutError(f"Pi did not complete within {timeout} seconds")
|
|
151
|
+
try:
|
|
152
|
+
returncode = process.wait(timeout=remaining)
|
|
153
|
+
except subprocess.TimeoutExpired as error:
|
|
154
|
+
raise TimeoutError(f"Pi did not complete within {timeout} seconds") from error
|
|
155
|
+
if returncode != 0:
|
|
156
|
+
raise RuntimeError(f"Pi exited {returncode}")
|
|
157
|
+
if not ended or not assistant_seen:
|
|
158
|
+
raise RuntimeError("Pi exited without a complete assistant session")
|
|
159
|
+
return "".join(text)
|
|
160
|
+
except Exception as error:
|
|
161
|
+
stderr.seek(0)
|
|
162
|
+
diagnostic = stderr.read().decode("utf-8", errors="replace").strip()
|
|
163
|
+
if diagnostic:
|
|
164
|
+
raise RuntimeError(f"{error}\nPi stderr: {diagnostic}") from error
|
|
165
|
+
raise
|
|
166
|
+
finally:
|
|
167
|
+
_stop_process(process)
|
|
168
|
+
process.stdout.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def call_pi(prompt: str, model: str | None, timeout: int = 300) -> str:
|
|
172
|
+
"""Generate text without tools, skill discovery or writes in the package."""
|
|
173
|
+
with tempfile.TemporaryDirectory(prefix="pi-skill-description-") as workdir:
|
|
174
|
+
response = run_pi(prompt, model, timeout, Path(workdir), ["--no-tools"])
|
|
175
|
+
if not response.strip():
|
|
176
|
+
raise RuntimeError("Pi returned no description text")
|
|
177
|
+
return response
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Quick validation script for skills - minimal version
|
|
4
|
+
|
|
5
|
+
Changed by Datalab Kit: accept Pi's disable-model-invocation metadata.
|
|
6
|
+
Upstream: anthropics/skills dbd4588f9e1033efb41dad4bef2f7947c8993d44
|
|
7
|
+
(Apache-2.0).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import yaml
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
def validate_skill(skill_path):
|
|
17
|
+
"""Basic validation of a skill"""
|
|
18
|
+
skill_path = Path(skill_path)
|
|
19
|
+
|
|
20
|
+
# Check SKILL.md exists
|
|
21
|
+
skill_md = skill_path / 'SKILL.md'
|
|
22
|
+
if not skill_md.exists():
|
|
23
|
+
return False, "SKILL.md not found"
|
|
24
|
+
|
|
25
|
+
# Read and validate frontmatter
|
|
26
|
+
content = skill_md.read_text()
|
|
27
|
+
if not content.startswith('---'):
|
|
28
|
+
return False, "No YAML frontmatter found"
|
|
29
|
+
|
|
30
|
+
# Extract frontmatter
|
|
31
|
+
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
|
32
|
+
if not match:
|
|
33
|
+
return False, "Invalid frontmatter format"
|
|
34
|
+
|
|
35
|
+
frontmatter_text = match.group(1)
|
|
36
|
+
|
|
37
|
+
# Parse YAML frontmatter
|
|
38
|
+
try:
|
|
39
|
+
frontmatter = yaml.safe_load(frontmatter_text)
|
|
40
|
+
if not isinstance(frontmatter, dict):
|
|
41
|
+
return False, "Frontmatter must be a YAML dictionary"
|
|
42
|
+
except yaml.YAMLError as e:
|
|
43
|
+
return False, f"Invalid YAML in frontmatter: {e}"
|
|
44
|
+
|
|
45
|
+
# Define allowed properties
|
|
46
|
+
ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata', 'compatibility', 'disable-model-invocation'}
|
|
47
|
+
|
|
48
|
+
# Check for unexpected properties (excluding nested keys under metadata)
|
|
49
|
+
unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES
|
|
50
|
+
if unexpected_keys:
|
|
51
|
+
return False, (
|
|
52
|
+
f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. "
|
|
53
|
+
f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Check required fields
|
|
57
|
+
if 'name' not in frontmatter:
|
|
58
|
+
return False, "Missing 'name' in frontmatter"
|
|
59
|
+
if 'description' not in frontmatter:
|
|
60
|
+
return False, "Missing 'description' in frontmatter"
|
|
61
|
+
|
|
62
|
+
# Extract name for validation
|
|
63
|
+
name = frontmatter.get('name', '')
|
|
64
|
+
if not isinstance(name, str):
|
|
65
|
+
return False, f"Name must be a string, got {type(name).__name__}"
|
|
66
|
+
name = name.strip()
|
|
67
|
+
if name:
|
|
68
|
+
# Check naming convention (kebab-case: lowercase with hyphens)
|
|
69
|
+
if not re.match(r'^[a-z0-9-]+$', name):
|
|
70
|
+
return False, f"Name '{name}' should be kebab-case (lowercase letters, digits, and hyphens only)"
|
|
71
|
+
if name.startswith('-') or name.endswith('-') or '--' in name:
|
|
72
|
+
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
|
|
73
|
+
# Check name length (max 64 characters per spec)
|
|
74
|
+
if len(name) > 64:
|
|
75
|
+
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters."
|
|
76
|
+
|
|
77
|
+
# Extract and validate description
|
|
78
|
+
description = frontmatter.get('description', '')
|
|
79
|
+
if not isinstance(description, str):
|
|
80
|
+
return False, f"Description must be a string, got {type(description).__name__}"
|
|
81
|
+
description = description.strip()
|
|
82
|
+
if description:
|
|
83
|
+
# Check for angle brackets
|
|
84
|
+
if '<' in description or '>' in description:
|
|
85
|
+
return False, "Description cannot contain angle brackets (< or >)"
|
|
86
|
+
# Check description length (max 1024 characters per spec)
|
|
87
|
+
if len(description) > 1024:
|
|
88
|
+
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters."
|
|
89
|
+
|
|
90
|
+
# Validate compatibility field if present (optional)
|
|
91
|
+
compatibility = frontmatter.get('compatibility', '')
|
|
92
|
+
if compatibility:
|
|
93
|
+
if not isinstance(compatibility, str):
|
|
94
|
+
return False, f"Compatibility must be a string, got {type(compatibility).__name__}"
|
|
95
|
+
if len(compatibility) > 500:
|
|
96
|
+
return False, f"Compatibility is too long ({len(compatibility)} characters). Maximum is 500 characters."
|
|
97
|
+
|
|
98
|
+
return True, "Skill is valid!"
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
if len(sys.argv) != 2:
|
|
102
|
+
print("Usage: python quick_validate.py <skill_directory>")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
valid, message = validate_skill(sys.argv[1])
|
|
106
|
+
print(message)
|
|
107
|
+
sys.exit(0 if valid else 1)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run trigger evaluation for a skill description.
|
|
3
|
+
|
|
4
|
+
Tests whether a skill's description causes Pi to trigger (read the skill)
|
|
5
|
+
for a set of queries. Outputs results as JSON.
|
|
6
|
+
|
|
7
|
+
Changed by Datalab Kit: isolated Pi JSON CLI backend, error propagation and
|
|
8
|
+
cancellation cleanup. Upstream: anthropics/skills
|
|
9
|
+
dbd4588f9e1033efb41dad4bef2f7947c8993d44 (Apache-2.0).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import tempfile
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from scripts.pi_runtime import interrupt_on_termination, run_pi
|
|
23
|
+
|
|
24
|
+
from scripts.utils import parse_skill_md
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_project_root() -> Path:
|
|
28
|
+
"""Use the caller's workspace; no host-specific discovery files are written."""
|
|
29
|
+
return Path.cwd()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_single_query(
|
|
33
|
+
query: str,
|
|
34
|
+
skill_name: str,
|
|
35
|
+
skill_description: str,
|
|
36
|
+
timeout: int,
|
|
37
|
+
project_root: str,
|
|
38
|
+
model: str | None = None,
|
|
39
|
+
cancel: threading.Event | None = None,
|
|
40
|
+
) -> bool:
|
|
41
|
+
"""Evaluate natural skill selection in a fresh, isolated Pi session.
|
|
42
|
+
|
|
43
|
+
Only the candidate description is advertised, not the creator or any other
|
|
44
|
+
installed skills. The read-only tool allowlist prevents evaluation queries
|
|
45
|
+
from modifying the caller's workspace. A read of this exact SKILL.md is
|
|
46
|
+
the trigger signal; narration and reads of unrelated files do not count.
|
|
47
|
+
"""
|
|
48
|
+
with tempfile.TemporaryDirectory(prefix="pi-skill-trigger-") as workdir:
|
|
49
|
+
root = Path(workdir)
|
|
50
|
+
candidate = root / "SKILL.md"
|
|
51
|
+
frontmatter = yaml.safe_dump(
|
|
52
|
+
{"name": skill_name, "description": skill_description},
|
|
53
|
+
sort_keys=False, allow_unicode=True,
|
|
54
|
+
)
|
|
55
|
+
candidate.write_text(
|
|
56
|
+
f"---\n{frontmatter}---\n\n# {skill_name}\n\n"
|
|
57
|
+
"This is a description-trigger evaluation. The skill has been loaded.\n",
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
)
|
|
60
|
+
triggered = False
|
|
61
|
+
|
|
62
|
+
def detect_trigger(event):
|
|
63
|
+
nonlocal triggered
|
|
64
|
+
if event.get("type") != "tool_execution_start" or event.get("toolName") != "read":
|
|
65
|
+
return False
|
|
66
|
+
path = event.get("args", {}).get("path")
|
|
67
|
+
if not isinstance(path, str):
|
|
68
|
+
return False
|
|
69
|
+
requested = Path(path).expanduser()
|
|
70
|
+
if not requested.is_absolute():
|
|
71
|
+
requested = root / requested
|
|
72
|
+
triggered = requested.resolve() == candidate.resolve()
|
|
73
|
+
return triggered
|
|
74
|
+
|
|
75
|
+
run_pi(
|
|
76
|
+
query, model, timeout, root,
|
|
77
|
+
["--skill", str(candidate), "--tools", "read"],
|
|
78
|
+
on_event=detect_trigger, cancel=cancel,
|
|
79
|
+
)
|
|
80
|
+
return triggered
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_eval(
|
|
84
|
+
eval_set: list[dict],
|
|
85
|
+
skill_name: str,
|
|
86
|
+
description: str,
|
|
87
|
+
num_workers: int,
|
|
88
|
+
timeout: int,
|
|
89
|
+
project_root: Path,
|
|
90
|
+
runs_per_query: int = 1,
|
|
91
|
+
trigger_threshold: float = 0.5,
|
|
92
|
+
model: str | None = None,
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Run the full eval set and return results."""
|
|
95
|
+
results = []
|
|
96
|
+
|
|
97
|
+
cancel = threading.Event()
|
|
98
|
+
executor = ThreadPoolExecutor(max_workers=num_workers)
|
|
99
|
+
try:
|
|
100
|
+
future_to_info = {}
|
|
101
|
+
for item in eval_set:
|
|
102
|
+
for run_idx in range(runs_per_query):
|
|
103
|
+
future = executor.submit(
|
|
104
|
+
run_single_query,
|
|
105
|
+
item["query"],
|
|
106
|
+
skill_name,
|
|
107
|
+
description,
|
|
108
|
+
timeout,
|
|
109
|
+
str(project_root),
|
|
110
|
+
model,
|
|
111
|
+
cancel,
|
|
112
|
+
)
|
|
113
|
+
future_to_info[future] = (item, run_idx)
|
|
114
|
+
|
|
115
|
+
query_triggers: dict[str, list[bool]] = {}
|
|
116
|
+
query_items: dict[str, dict] = {}
|
|
117
|
+
for future in as_completed(future_to_info):
|
|
118
|
+
item, _ = future_to_info[future]
|
|
119
|
+
query = item["query"]
|
|
120
|
+
query_items[query] = item
|
|
121
|
+
if query not in query_triggers:
|
|
122
|
+
query_triggers[query] = []
|
|
123
|
+
try:
|
|
124
|
+
query_triggers[query].append(future.result())
|
|
125
|
+
except Exception as error:
|
|
126
|
+
raise RuntimeError(f"Trigger evaluation failed for {query!r}: {error}") from error
|
|
127
|
+
finally:
|
|
128
|
+
cancel.set()
|
|
129
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
130
|
+
|
|
131
|
+
for query, triggers in query_triggers.items():
|
|
132
|
+
item = query_items[query]
|
|
133
|
+
trigger_rate = sum(triggers) / len(triggers)
|
|
134
|
+
should_trigger = item["should_trigger"]
|
|
135
|
+
if should_trigger:
|
|
136
|
+
did_pass = trigger_rate >= trigger_threshold
|
|
137
|
+
else:
|
|
138
|
+
did_pass = trigger_rate < trigger_threshold
|
|
139
|
+
results.append({
|
|
140
|
+
"query": query,
|
|
141
|
+
"should_trigger": should_trigger,
|
|
142
|
+
"trigger_rate": trigger_rate,
|
|
143
|
+
"triggers": sum(triggers),
|
|
144
|
+
"runs": len(triggers),
|
|
145
|
+
"pass": did_pass,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
passed = sum(1 for r in results if r["pass"])
|
|
149
|
+
total = len(results)
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"skill_name": skill_name,
|
|
153
|
+
"description": description,
|
|
154
|
+
"results": results,
|
|
155
|
+
"summary": {
|
|
156
|
+
"total": total,
|
|
157
|
+
"passed": passed,
|
|
158
|
+
"failed": total - passed,
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main():
|
|
164
|
+
parser = argparse.ArgumentParser(description="Run trigger evaluation for a skill description")
|
|
165
|
+
parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file")
|
|
166
|
+
parser.add_argument("--skill-path", required=True, help="Path to skill directory")
|
|
167
|
+
parser.add_argument("--description", default=None, help="Override description to test")
|
|
168
|
+
parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers")
|
|
169
|
+
parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds")
|
|
170
|
+
parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query")
|
|
171
|
+
parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold")
|
|
172
|
+
parser.add_argument("--model", default=None, help="Pi model provider/id[:thinking] (default: configured model)")
|
|
173
|
+
parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
|
|
174
|
+
args = parser.parse_args()
|
|
175
|
+
|
|
176
|
+
eval_set = json.loads(Path(args.eval_set).read_text())
|
|
177
|
+
skill_path = Path(args.skill_path)
|
|
178
|
+
|
|
179
|
+
if not (skill_path / "SKILL.md").exists():
|
|
180
|
+
print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
|
|
183
|
+
name, original_description, content = parse_skill_md(skill_path)
|
|
184
|
+
description = args.description or original_description
|
|
185
|
+
project_root = find_project_root()
|
|
186
|
+
|
|
187
|
+
if args.verbose:
|
|
188
|
+
print(f"Evaluating: {description}", file=sys.stderr)
|
|
189
|
+
|
|
190
|
+
output = run_eval(
|
|
191
|
+
eval_set=eval_set,
|
|
192
|
+
skill_name=name,
|
|
193
|
+
description=description,
|
|
194
|
+
num_workers=args.num_workers,
|
|
195
|
+
timeout=args.timeout,
|
|
196
|
+
project_root=project_root,
|
|
197
|
+
runs_per_query=args.runs_per_query,
|
|
198
|
+
trigger_threshold=args.trigger_threshold,
|
|
199
|
+
model=args.model,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if args.verbose:
|
|
203
|
+
summary = output["summary"]
|
|
204
|
+
print(f"Results: {summary['passed']}/{summary['total']} passed", file=sys.stderr)
|
|
205
|
+
for r in output["results"]:
|
|
206
|
+
status = "PASS" if r["pass"] else "FAIL"
|
|
207
|
+
rate_str = f"{r['triggers']}/{r['runs']}"
|
|
208
|
+
print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:70]}", file=sys.stderr)
|
|
209
|
+
|
|
210
|
+
print(json.dumps(output, indent=2))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
with interrupt_on_termination():
|
|
215
|
+
main()
|