nine-hundred 0.0.1

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 (107) hide show
  1. package/README.md +43 -0
  2. package/dist/agent/config/index.js +85 -0
  3. package/dist/agent/context/compress.js +145 -0
  4. package/dist/agent/context/index.js +68 -0
  5. package/dist/agent/context/token.js +30 -0
  6. package/dist/agent/hooks/config.js +41 -0
  7. package/dist/agent/hooks/core.js +177 -0
  8. package/dist/agent/hooks/events/index.js +9 -0
  9. package/dist/agent/hooks/events/post-tool-use-hook.js +7 -0
  10. package/dist/agent/hooks/events/pre-tool-use-hook.js +8 -0
  11. package/dist/agent/hooks/events/session-start-hook.js +8 -0
  12. package/dist/agent/hooks/events/stop-hook.js +10 -0
  13. package/dist/agent/hooks/events/user-prompt-submit-hook.js +7 -0
  14. package/dist/agent/hooks/index.js +7 -0
  15. package/dist/agent/hooks/types.js +32 -0
  16. package/dist/agent/index.js +465 -0
  17. package/dist/agent/llm/index.js +35 -0
  18. package/dist/agent/mcp/client.js +87 -0
  19. package/dist/agent/mcp/config.js +51 -0
  20. package/dist/agent/mcp/index.js +90 -0
  21. package/dist/agent/mcp/schema.js +56 -0
  22. package/dist/agent/permission/exec.js +71 -0
  23. package/dist/agent/permission/network.js +37 -0
  24. package/dist/agent/permission/read.js +27 -0
  25. package/dist/agent/permission/util/command-changes-directory.js +29 -0
  26. package/dist/agent/permission/util/dangerous-path.json +116 -0
  27. package/dist/agent/permission/util/detect-dangerous-operation.js +230 -0
  28. package/dist/agent/permission/util/detect-language-interpreter.js +66 -0
  29. package/dist/agent/permission/util/detect-safe-command.js +61 -0
  30. package/dist/agent/permission/util/index.js +16 -0
  31. package/dist/agent/permission/util/is-dangerous-path.js +98 -0
  32. package/dist/agent/permission/util/is-inside-cwd.js +83 -0
  33. package/dist/agent/permission/util/is-safe-domains.js +47 -0
  34. package/dist/agent/permission/write.js +31 -0
  35. package/dist/agent/prompt/index.js +42 -0
  36. package/dist/agent/skills/planner/SKILL.md +90 -0
  37. package/dist/agent/skills/programmer-resume/SKILL.md +113 -0
  38. package/dist/agent/skills/skill-creator/LICENSE.txt +202 -0
  39. package/dist/agent/skills/skill-creator/SKILL.md +495 -0
  40. package/dist/agent/skills/skill-creator/agents/analyzer.md +274 -0
  41. package/dist/agent/skills/skill-creator/agents/comparator.md +202 -0
  42. package/dist/agent/skills/skill-creator/agents/grader.md +223 -0
  43. package/dist/agent/skills/skill-creator/assets/eval_review.html +146 -0
  44. package/dist/agent/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  45. package/dist/agent/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  46. package/dist/agent/skills/skill-creator/references/schemas.md +430 -0
  47. package/dist/agent/skills/skill-creator/scripts/__init__.py +0 -0
  48. package/dist/agent/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  49. package/dist/agent/skills/skill-creator/scripts/generate_report.py +326 -0
  50. package/dist/agent/skills/skill-creator/scripts/improve_description.py +247 -0
  51. package/dist/agent/skills/skill-creator/scripts/package_skill.py +136 -0
  52. package/dist/agent/skills/skill-creator/scripts/quick_validate.py +103 -0
  53. package/dist/agent/skills/skill-creator/scripts/run_eval.py +310 -0
  54. package/dist/agent/skills/skill-creator/scripts/run_loop.py +328 -0
  55. package/dist/agent/skills/skill-creator/scripts/utils.py +47 -0
  56. package/dist/agent/skills.js +129 -0
  57. package/dist/agent/tools/agent_tool/agent_tool.test.js +64 -0
  58. package/dist/agent/tools/agent_tool/index.js +33 -0
  59. package/dist/agent/tools/exec_tool/exec_tool.test.js +48 -0
  60. package/dist/agent/tools/exec_tool/index.js +44 -0
  61. package/dist/agent/tools/load_skill_tool/index.js +5 -0
  62. package/dist/agent/tools/load_skill_tool/load_skill_tool.test.js +122 -0
  63. package/dist/agent/tools/memory_create_tool/index.js +8 -0
  64. package/dist/agent/tools/memory_create_tool/memory_create_tool.test.js +54 -0
  65. package/dist/agent/tools/memory_delete_tool/index.js +10 -0
  66. package/dist/agent/tools/memory_delete_tool/memory_delete_tool.test.js +39 -0
  67. package/dist/agent/tools/memory_retrieve_tool/index.js +61 -0
  68. package/dist/agent/tools/memory_retrieve_tool/memory_retrieve_tool.test.js +102 -0
  69. package/dist/agent/tools/profile_update_tool/index.js +30 -0
  70. package/dist/agent/tools/profile_update_tool/profile_update_tool.test.js +49 -0
  71. package/dist/agent/tools/read_file_tool/index.js +24 -0
  72. package/dist/agent/tools/read_file_tool/read_file_tool.test.js +43 -0
  73. package/dist/agent/tools/run_js_tool/index.js +48 -0
  74. package/dist/agent/tools/run_js_tool/run_js_tool.test.js +67 -0
  75. package/dist/agent/tools/run_py_tool/index.js +48 -0
  76. package/dist/agent/tools/run_py_tool/run_py_tool.test.js +67 -0
  77. package/dist/agent/tools/tool_logger.js +16 -0
  78. package/dist/agent/tools/tool_logger.test.js +22 -0
  79. package/dist/agent/tools/web_fetch_url/index.js +76 -0
  80. package/dist/agent/tools/web_fetch_url/web_fetch_url.test.js +102 -0
  81. package/dist/agent/tools/web_search_tool/index.js +26 -0
  82. package/dist/agent/tools/web_search_tool/web_search_tool.test.js +61 -0
  83. package/dist/agent/tools/write_file_tool/index.js +22 -0
  84. package/dist/agent/tools/write_file_tool/write_file_tool.test.js +46 -0
  85. package/dist/agent/tools.js +218 -0
  86. package/dist/cli/command/compact/index.js +14 -0
  87. package/dist/cli/command/index.js +62 -0
  88. package/dist/cli/command/invalid/index.js +4 -0
  89. package/dist/cli/command/new/chat-session.js +10 -0
  90. package/dist/cli/command/new/index.js +8 -0
  91. package/dist/cli/command/rewind/index.js +19 -0
  92. package/dist/cli/command/rewind/rewind-command.test.js +22 -0
  93. package/dist/cli/command/session/format.js +32 -0
  94. package/dist/cli/command/session/index.js +32 -0
  95. package/dist/cli/command/session/session-command.test.js +49 -0
  96. package/dist/cli/command/unknown/index.js +4 -0
  97. package/dist/cli/index.js +144 -0
  98. package/dist/db/checkpointer.js +15 -0
  99. package/dist/db/index.js +2 -0
  100. package/dist/db/path.js +8 -0
  101. package/dist/db/sessions.js +81 -0
  102. package/dist/db/tables/memory.js +12 -0
  103. package/dist/db/tables/memory_fts.js +29 -0
  104. package/dist/index.js +87 -0
  105. package/dist/install.js +154 -0
  106. package/package.json +51 -0
  107. package/pnpm-workspace.yaml +3 -0
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ """Run the eval + improve loop until all pass or max iterations reached.
3
+
4
+ Combines run_eval.py and improve_description.py in a loop, tracking history
5
+ and returning the best description found. Supports train/test split to prevent
6
+ overfitting.
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import random
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import webbrowser
16
+ from pathlib import Path
17
+
18
+ from scripts.generate_report import generate_html
19
+ from scripts.improve_description import improve_description
20
+ from scripts.run_eval import find_project_root, run_eval
21
+ from scripts.utils import parse_skill_md
22
+
23
+
24
+ def split_eval_set(eval_set: list[dict], holdout: float, seed: int = 42) -> tuple[list[dict], list[dict]]:
25
+ """Split eval set into train and test sets, stratified by should_trigger."""
26
+ random.seed(seed)
27
+
28
+ # Separate by should_trigger
29
+ trigger = [e for e in eval_set if e["should_trigger"]]
30
+ no_trigger = [e for e in eval_set if not e["should_trigger"]]
31
+
32
+ # Shuffle each group
33
+ random.shuffle(trigger)
34
+ random.shuffle(no_trigger)
35
+
36
+ # Calculate split points
37
+ n_trigger_test = max(1, int(len(trigger) * holdout))
38
+ n_no_trigger_test = max(1, int(len(no_trigger) * holdout))
39
+
40
+ # Split
41
+ test_set = trigger[:n_trigger_test] + no_trigger[:n_no_trigger_test]
42
+ train_set = trigger[n_trigger_test:] + no_trigger[n_no_trigger_test:]
43
+
44
+ return train_set, test_set
45
+
46
+
47
+ def run_loop(
48
+ eval_set: list[dict],
49
+ skill_path: Path,
50
+ description_override: str | None,
51
+ num_workers: int,
52
+ timeout: int,
53
+ max_iterations: int,
54
+ runs_per_query: int,
55
+ trigger_threshold: float,
56
+ holdout: float,
57
+ model: str,
58
+ verbose: bool,
59
+ live_report_path: Path | None = None,
60
+ log_dir: Path | None = None,
61
+ ) -> dict:
62
+ """Run the eval + improvement loop."""
63
+ project_root = find_project_root()
64
+ name, original_description, content = parse_skill_md(skill_path)
65
+ current_description = description_override or original_description
66
+
67
+ # Split into train/test if holdout > 0
68
+ if holdout > 0:
69
+ train_set, test_set = split_eval_set(eval_set, holdout)
70
+ if verbose:
71
+ print(f"Split: {len(train_set)} train, {len(test_set)} test (holdout={holdout})", file=sys.stderr)
72
+ else:
73
+ train_set = eval_set
74
+ test_set = []
75
+
76
+ history = []
77
+ exit_reason = "unknown"
78
+
79
+ for iteration in range(1, max_iterations + 1):
80
+ if verbose:
81
+ print(f"\n{'='*60}", file=sys.stderr)
82
+ print(f"Iteration {iteration}/{max_iterations}", file=sys.stderr)
83
+ print(f"Description: {current_description}", file=sys.stderr)
84
+ print(f"{'='*60}", file=sys.stderr)
85
+
86
+ # Evaluate train + test together in one batch for parallelism
87
+ all_queries = train_set + test_set
88
+ t0 = time.time()
89
+ all_results = run_eval(
90
+ eval_set=all_queries,
91
+ skill_name=name,
92
+ description=current_description,
93
+ num_workers=num_workers,
94
+ timeout=timeout,
95
+ project_root=project_root,
96
+ runs_per_query=runs_per_query,
97
+ trigger_threshold=trigger_threshold,
98
+ model=model,
99
+ )
100
+ eval_elapsed = time.time() - t0
101
+
102
+ # Split results back into train/test by matching queries
103
+ train_queries_set = {q["query"] for q in train_set}
104
+ train_result_list = [r for r in all_results["results"] if r["query"] in train_queries_set]
105
+ test_result_list = [r for r in all_results["results"] if r["query"] not in train_queries_set]
106
+
107
+ train_passed = sum(1 for r in train_result_list if r["pass"])
108
+ train_total = len(train_result_list)
109
+ train_summary = {"passed": train_passed, "failed": train_total - train_passed, "total": train_total}
110
+ train_results = {"results": train_result_list, "summary": train_summary}
111
+
112
+ if test_set:
113
+ test_passed = sum(1 for r in test_result_list if r["pass"])
114
+ test_total = len(test_result_list)
115
+ test_summary = {"passed": test_passed, "failed": test_total - test_passed, "total": test_total}
116
+ test_results = {"results": test_result_list, "summary": test_summary}
117
+ else:
118
+ test_results = None
119
+ test_summary = None
120
+
121
+ history.append({
122
+ "iteration": iteration,
123
+ "description": current_description,
124
+ "train_passed": train_summary["passed"],
125
+ "train_failed": train_summary["failed"],
126
+ "train_total": train_summary["total"],
127
+ "train_results": train_results["results"],
128
+ "test_passed": test_summary["passed"] if test_summary else None,
129
+ "test_failed": test_summary["failed"] if test_summary else None,
130
+ "test_total": test_summary["total"] if test_summary else None,
131
+ "test_results": test_results["results"] if test_results else None,
132
+ # For backward compat with report generator
133
+ "passed": train_summary["passed"],
134
+ "failed": train_summary["failed"],
135
+ "total": train_summary["total"],
136
+ "results": train_results["results"],
137
+ })
138
+
139
+ # Write live report if path provided
140
+ if live_report_path:
141
+ partial_output = {
142
+ "original_description": original_description,
143
+ "best_description": current_description,
144
+ "best_score": "in progress",
145
+ "iterations_run": len(history),
146
+ "holdout": holdout,
147
+ "train_size": len(train_set),
148
+ "test_size": len(test_set),
149
+ "history": history,
150
+ }
151
+ live_report_path.write_text(generate_html(partial_output, auto_refresh=True, skill_name=name))
152
+
153
+ if verbose:
154
+ def print_eval_stats(label, results, elapsed):
155
+ pos = [r for r in results if r["should_trigger"]]
156
+ neg = [r for r in results if not r["should_trigger"]]
157
+ tp = sum(r["triggers"] for r in pos)
158
+ pos_runs = sum(r["runs"] for r in pos)
159
+ fn = pos_runs - tp
160
+ fp = sum(r["triggers"] for r in neg)
161
+ neg_runs = sum(r["runs"] for r in neg)
162
+ tn = neg_runs - fp
163
+ total = tp + tn + fp + fn
164
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
165
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0
166
+ accuracy = (tp + tn) / total if total > 0 else 0.0
167
+ print(f"{label}: {tp+tn}/{total} correct, precision={precision:.0%} recall={recall:.0%} accuracy={accuracy:.0%} ({elapsed:.1f}s)", file=sys.stderr)
168
+ for r in results:
169
+ status = "PASS" if r["pass"] else "FAIL"
170
+ rate_str = f"{r['triggers']}/{r['runs']}"
171
+ print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:60]}", file=sys.stderr)
172
+
173
+ print_eval_stats("Train", train_results["results"], eval_elapsed)
174
+ if test_summary:
175
+ print_eval_stats("Test ", test_results["results"], 0)
176
+
177
+ if train_summary["failed"] == 0:
178
+ exit_reason = f"all_passed (iteration {iteration})"
179
+ if verbose:
180
+ print(f"\nAll train queries passed on iteration {iteration}!", file=sys.stderr)
181
+ break
182
+
183
+ if iteration == max_iterations:
184
+ exit_reason = f"max_iterations ({max_iterations})"
185
+ if verbose:
186
+ print(f"\nMax iterations reached ({max_iterations}).", file=sys.stderr)
187
+ break
188
+
189
+ # Improve the description based on train results
190
+ if verbose:
191
+ print(f"\nImproving description...", file=sys.stderr)
192
+
193
+ t0 = time.time()
194
+ # Strip test scores from history so improvement model can't see them
195
+ blinded_history = [
196
+ {k: v for k, v in h.items() if not k.startswith("test_")}
197
+ for h in history
198
+ ]
199
+ new_description = improve_description(
200
+ skill_name=name,
201
+ skill_content=content,
202
+ current_description=current_description,
203
+ eval_results=train_results,
204
+ history=blinded_history,
205
+ model=model,
206
+ log_dir=log_dir,
207
+ iteration=iteration,
208
+ )
209
+ improve_elapsed = time.time() - t0
210
+
211
+ if verbose:
212
+ print(f"Proposed ({improve_elapsed:.1f}s): {new_description}", file=sys.stderr)
213
+
214
+ current_description = new_description
215
+
216
+ # Find the best iteration by TEST score (or train if no test set)
217
+ if test_set:
218
+ best = max(history, key=lambda h: h["test_passed"] or 0)
219
+ best_score = f"{best['test_passed']}/{best['test_total']}"
220
+ else:
221
+ best = max(history, key=lambda h: h["train_passed"])
222
+ best_score = f"{best['train_passed']}/{best['train_total']}"
223
+
224
+ if verbose:
225
+ print(f"\nExit reason: {exit_reason}", file=sys.stderr)
226
+ print(f"Best score: {best_score} (iteration {best['iteration']})", file=sys.stderr)
227
+
228
+ return {
229
+ "exit_reason": exit_reason,
230
+ "original_description": original_description,
231
+ "best_description": best["description"],
232
+ "best_score": best_score,
233
+ "best_train_score": f"{best['train_passed']}/{best['train_total']}",
234
+ "best_test_score": f"{best['test_passed']}/{best['test_total']}" if test_set else None,
235
+ "final_description": current_description,
236
+ "iterations_run": len(history),
237
+ "holdout": holdout,
238
+ "train_size": len(train_set),
239
+ "test_size": len(test_set),
240
+ "history": history,
241
+ }
242
+
243
+
244
+ def main():
245
+ parser = argparse.ArgumentParser(description="Run eval + improve loop")
246
+ parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file")
247
+ parser.add_argument("--skill-path", required=True, help="Path to skill directory")
248
+ parser.add_argument("--description", default=None, help="Override starting description")
249
+ parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers")
250
+ parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds")
251
+ parser.add_argument("--max-iterations", type=int, default=5, help="Max improvement iterations")
252
+ parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query")
253
+ parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold")
254
+ parser.add_argument("--holdout", type=float, default=0.4, help="Fraction of eval set to hold out for testing (0 to disable)")
255
+ parser.add_argument("--model", required=True, help="Model for improvement")
256
+ parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
257
+ parser.add_argument("--report", default="auto", help="Generate HTML report at this path (default: 'auto' for temp file, 'none' to disable)")
258
+ parser.add_argument("--results-dir", default=None, help="Save all outputs (results.json, report.html, log.txt) to a timestamped subdirectory here")
259
+ args = parser.parse_args()
260
+
261
+ eval_set = json.loads(Path(args.eval_set).read_text())
262
+ skill_path = Path(args.skill_path)
263
+
264
+ if not (skill_path / "SKILL.md").exists():
265
+ print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
266
+ sys.exit(1)
267
+
268
+ name, _, _ = parse_skill_md(skill_path)
269
+
270
+ # Set up live report path
271
+ if args.report != "none":
272
+ if args.report == "auto":
273
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
274
+ live_report_path = Path(tempfile.gettempdir()) / f"skill_description_report_{skill_path.name}_{timestamp}.html"
275
+ else:
276
+ live_report_path = Path(args.report)
277
+ # Open the report immediately so the user can watch
278
+ live_report_path.write_text("<html><body><h1>Starting optimization loop...</h1><meta http-equiv='refresh' content='5'></body></html>")
279
+ webbrowser.open(str(live_report_path))
280
+ else:
281
+ live_report_path = None
282
+
283
+ # Determine output directory (create before run_loop so logs can be written)
284
+ if args.results_dir:
285
+ timestamp = time.strftime("%Y-%m-%d_%H%M%S")
286
+ results_dir = Path(args.results_dir) / timestamp
287
+ results_dir.mkdir(parents=True, exist_ok=True)
288
+ else:
289
+ results_dir = None
290
+
291
+ log_dir = results_dir / "logs" if results_dir else None
292
+
293
+ output = run_loop(
294
+ eval_set=eval_set,
295
+ skill_path=skill_path,
296
+ description_override=args.description,
297
+ num_workers=args.num_workers,
298
+ timeout=args.timeout,
299
+ max_iterations=args.max_iterations,
300
+ runs_per_query=args.runs_per_query,
301
+ trigger_threshold=args.trigger_threshold,
302
+ holdout=args.holdout,
303
+ model=args.model,
304
+ verbose=args.verbose,
305
+ live_report_path=live_report_path,
306
+ log_dir=log_dir,
307
+ )
308
+
309
+ # Save JSON output
310
+ json_output = json.dumps(output, indent=2)
311
+ print(json_output)
312
+ if results_dir:
313
+ (results_dir / "results.json").write_text(json_output)
314
+
315
+ # Write final HTML report (without auto-refresh)
316
+ if live_report_path:
317
+ live_report_path.write_text(generate_html(output, auto_refresh=False, skill_name=name))
318
+ print(f"\nReport: {live_report_path}", file=sys.stderr)
319
+
320
+ if results_dir and live_report_path:
321
+ (results_dir / "report.html").write_text(generate_html(output, auto_refresh=False, skill_name=name))
322
+
323
+ if results_dir:
324
+ print(f"Results saved to: {results_dir}", file=sys.stderr)
325
+
326
+
327
+ if __name__ == "__main__":
328
+ main()
@@ -0,0 +1,47 @@
1
+ """Shared utilities for skill-creator scripts."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+
7
+ def parse_skill_md(skill_path: Path) -> tuple[str, str, str]:
8
+ """Parse a SKILL.md file, returning (name, description, full_content)."""
9
+ content = (skill_path / "SKILL.md").read_text()
10
+ lines = content.split("\n")
11
+
12
+ if lines[0].strip() != "---":
13
+ raise ValueError("SKILL.md missing frontmatter (no opening ---)")
14
+
15
+ end_idx = None
16
+ for i, line in enumerate(lines[1:], start=1):
17
+ if line.strip() == "---":
18
+ end_idx = i
19
+ break
20
+
21
+ if end_idx is None:
22
+ raise ValueError("SKILL.md missing frontmatter (no closing ---)")
23
+
24
+ name = ""
25
+ description = ""
26
+ frontmatter_lines = lines[1:end_idx]
27
+ i = 0
28
+ while i < len(frontmatter_lines):
29
+ line = frontmatter_lines[i]
30
+ if line.startswith("name:"):
31
+ name = line[len("name:"):].strip().strip('"').strip("'")
32
+ elif line.startswith("description:"):
33
+ value = line[len("description:"):].strip()
34
+ # Handle YAML multiline indicators (>, |, >-, |-)
35
+ if value in (">", "|", ">-", "|-"):
36
+ continuation_lines: list[str] = []
37
+ i += 1
38
+ while i < len(frontmatter_lines) and (frontmatter_lines[i].startswith(" ") or frontmatter_lines[i].startswith("\t")):
39
+ continuation_lines.append(frontmatter_lines[i].strip())
40
+ i += 1
41
+ description = " ".join(continuation_lines)
42
+ continue
43
+ else:
44
+ description = value.strip('"').strip("'")
45
+ i += 1
46
+
47
+ return name, description, content
@@ -0,0 +1,129 @@
1
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export function getDefaultSkillCreationRoot() {
5
+ return path.join(os.homedir(), '.900', 'skills');
6
+ }
7
+ function stripQuotes(value) {
8
+ const trimmedValue = value.trim();
9
+ if ((trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ||
10
+ (trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))) {
11
+ return trimmedValue.slice(1, -1);
12
+ }
13
+ return trimmedValue;
14
+ }
15
+ function parseSkillFrontmatter(markdown, skillFilePath) {
16
+ const lines = markdown.split(/\r?\n/);
17
+ if (lines[0] !== '---') {
18
+ throw new Error(`Skill file is missing frontmatter: ${skillFilePath}`);
19
+ }
20
+ const endIndex = lines.findIndex((line, index) => index > 0 && line === '---');
21
+ if (endIndex === -1) {
22
+ throw new Error(`Skill file has unterminated frontmatter: ${skillFilePath}`);
23
+ }
24
+ const values = new Map();
25
+ for (const line of lines.slice(1, endIndex)) {
26
+ const separatorIndex = line.indexOf(':');
27
+ if (separatorIndex === -1)
28
+ continue;
29
+ const key = line.slice(0, separatorIndex).trim();
30
+ const value = stripQuotes(line.slice(separatorIndex + 1));
31
+ values.set(key, value);
32
+ }
33
+ const name = values.get('name')?.trim();
34
+ const description = values.get('description')?.trim();
35
+ if (!name) {
36
+ throw new Error(`Skill file is missing name: ${skillFilePath}`);
37
+ }
38
+ if (!description) {
39
+ throw new Error(`Skill file is missing description: ${skillFilePath}`);
40
+ }
41
+ return { name, description };
42
+ }
43
+ function assertInsideRoot(root, targetPath) {
44
+ const realRoot = realpathSync(root);
45
+ const realTarget = realpathSync(targetPath);
46
+ const relativePath = path.relative(realRoot, realTarget);
47
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
48
+ throw new Error(`Skill file must be inside skills root: ${targetPath}`);
49
+ }
50
+ }
51
+ export function loadSkillsFromRoot(skillsRoot) {
52
+ if (!existsSync(skillsRoot)) {
53
+ return { skillsRoot, skills: [] };
54
+ }
55
+ const rootStats = statSync(skillsRoot);
56
+ if (!rootStats.isDirectory()) {
57
+ throw new Error(`Skills root must be a directory: ${skillsRoot}`);
58
+ }
59
+ const skills = [];
60
+ const seenNames = new Set();
61
+ for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
62
+ if (!entry.isDirectory())
63
+ continue;
64
+ const skillFilePath = path.join(skillsRoot, entry.name, 'SKILL.md');
65
+ if (!existsSync(skillFilePath))
66
+ continue;
67
+ assertInsideRoot(skillsRoot, skillFilePath);
68
+ const markdown = readFileSync(skillFilePath, 'utf8');
69
+ const summary = parseSkillFrontmatter(markdown, skillFilePath);
70
+ if (seenNames.has(summary.name)) {
71
+ throw new Error(`Duplicate skill name: ${summary.name}`);
72
+ }
73
+ seenNames.add(summary.name);
74
+ skills.push({ ...summary, skillFilePath, root: skillsRoot });
75
+ }
76
+ skills.sort((a, b) => a.name.localeCompare(b.name));
77
+ return { skillsRoot, skills };
78
+ }
79
+ export function getUserSkillRoots() {
80
+ const home = os.homedir();
81
+ return [
82
+ path.join(home, '.agents', 'skills'),
83
+ path.join(home, '.900', '.agents', 'skills'),
84
+ path.join(home, '.900', 'skills'),
85
+ ];
86
+ }
87
+ export function mergeSkillRegistries(registries) {
88
+ const skillMap = new Map();
89
+ let lastRoot = getDefaultSkillCreationRoot();
90
+ for (const registry of registries) {
91
+ if (registry.skills.length > 0) {
92
+ lastRoot = registry.skillsRoot;
93
+ }
94
+ for (const skill of registry.skills) {
95
+ skillMap.set(skill.name, skill);
96
+ }
97
+ }
98
+ const skills = Array.from(skillMap.values()).toSorted((a, b) => a.name.localeCompare(b.name));
99
+ return { skillsRoot: lastRoot, skills };
100
+ }
101
+ const defaultRegistry = mergeSkillRegistries(getUserSkillRoots().map(loadSkillsFromRoot));
102
+ export function getSkillSummaries(registry = defaultRegistry) {
103
+ return registry.skills.map(({ name, description }) => ({ name, description }));
104
+ }
105
+ export function formatSkillsForSystemPrompt(registry = defaultRegistry) {
106
+ const summaries = getSkillSummaries(registry);
107
+ if (summaries.length === 0) {
108
+ return '';
109
+ }
110
+ const skillLines = summaries.map((skill) => `- ${skill.name}: ${skill.description}`).join('\n');
111
+ return `Available skills:\nThe following skills are available. Each entry contains only the skill name and description. When a user request matches a skill, call load_skill_tool with exactly one skillName before applying that skill.\n\n${skillLines}`;
112
+ }
113
+ export function formatSkillCreationInstructions() {
114
+ const skillFilePath = path.join(getDefaultSkillCreationRoot(), '<kebab-case-skill-name>', 'SKILL.md');
115
+ return `Argos skill creation rules:\nWhen the user asks to create a new Argos skill, create actual files under ${getDefaultSkillCreationRoot()}. Use write_file_tool to write the completed skill to ${skillFilePath}. Do not merely draft the skill in chat unless the user explicitly asks for a draft only. The SKILL.md file must include YAML frontmatter with name and description. Put optional bundled resources under the same skill directory. After writing the files, tell the user that Argos scans skills at startup, so they should restart Argos before the new skill appears in Available skills.`;
116
+ }
117
+ export function loadSkillByName(skillName, registry = defaultRegistry) {
118
+ const trimmedSkillName = skillName.trim();
119
+ if (!trimmedSkillName) {
120
+ throw new Error('Skill name is required.');
121
+ }
122
+ const skill = registry.skills.find((item) => item.name === trimmedSkillName);
123
+ if (!skill) {
124
+ const availableSkills = registry.skills.map((item) => item.name).join(', ') || 'none';
125
+ throw new Error(`Unknown skill: ${trimmedSkillName}. Available skills: ${availableSkills}.`);
126
+ }
127
+ assertInsideRoot(skill.root, skill.skillFilePath);
128
+ return readFileSync(skill.skillFilePath, 'utf8');
129
+ }
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { agentTool, registerSubagentRunner, resetAgentToolForTesting, } from './index.js';
3
+ import { getToolsForRunMode, subagentTools, tools } from '../../tools.js';
4
+ beforeEach(() => {
5
+ resetAgentToolForTesting();
6
+ });
7
+ describe('agentTool', () => {
8
+ it('rejects empty prompts', async () => {
9
+ const runner = vi.fn(async () => 'done');
10
+ registerSubagentRunner(runner);
11
+ await expect(agentTool('')).rejects.toThrow('Prompt is required.');
12
+ await expect(agentTool(' ')).rejects.toThrow('Prompt is required.');
13
+ expect(runner).not.toHaveBeenCalled();
14
+ });
15
+ it('rejects when the subagent runner is not registered', async () => {
16
+ await expect(agentTool('do work')).rejects.toThrow('Subagent runner is not registered.');
17
+ });
18
+ it('trims the prompt before invoking the runner', async () => {
19
+ const runner = vi.fn(async () => 'subagent result');
20
+ registerSubagentRunner(runner);
21
+ await expect(agentTool(' summarize package.json ', 'parent-1')).resolves.toBe('subagent result');
22
+ expect(runner).toHaveBeenCalledWith({
23
+ prompt: 'summarize package.json',
24
+ parentThreadId: 'parent-1',
25
+ });
26
+ });
27
+ it('returns the runner result', async () => {
28
+ const runner = vi.fn(async () => 'final answer');
29
+ registerSubagentRunner(runner);
30
+ await expect(agentTool('do work')).resolves.toBe('final answer');
31
+ });
32
+ it('allows only one active subagent at a time', async () => {
33
+ let resolveFirst;
34
+ const firstRun = new Promise((resolve) => {
35
+ resolveFirst = resolve;
36
+ });
37
+ const runner = vi.fn(() => firstRun);
38
+ registerSubagentRunner(runner);
39
+ const first = agentTool('first');
40
+ await expect(agentTool('second')).rejects.toThrow('A subagent is already running.');
41
+ resolveFirst('first done');
42
+ await expect(first).resolves.toBe('first done');
43
+ expect(runner).toHaveBeenCalledTimes(1);
44
+ });
45
+ it('releases the active subagent lock when the runner fails', async () => {
46
+ const runner = vi
47
+ .fn()
48
+ .mockRejectedValueOnce(new Error('boom'))
49
+ .mockResolvedValueOnce('recovered');
50
+ registerSubagentRunner(runner);
51
+ await expect(agentTool('fail first')).rejects.toThrow('boom');
52
+ await expect(agentTool('try again')).resolves.toBe('recovered');
53
+ expect(runner).toHaveBeenCalledTimes(2);
54
+ });
55
+ });
56
+ describe('agent_tool registration', () => {
57
+ it('registers agent_tool as an exec tool for main agents only', () => {
58
+ const mainAgentTool = tools.find((t) => t.name === 'agent_tool');
59
+ expect(mainAgentTool?.permission_level).toBe('exec');
60
+ expect(subagentTools.some((t) => t.name === 'agent_tool')).toBe(false);
61
+ expect(getToolsForRunMode('main').some((t) => t.name === 'agent_tool')).toBe(true);
62
+ expect(getToolsForRunMode('subagent').some((t) => t.name === 'agent_tool')).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ export const agentToolSchema = z.object({
3
+ prompt: z.string().describe('Plain text prompt for the subagent. Do not include chat history.'),
4
+ });
5
+ let subagentRunner = null;
6
+ let activeSubagent = null;
7
+ export function registerSubagentRunner(runner) {
8
+ subagentRunner = runner;
9
+ }
10
+ export async function agentTool(prompt, parentThreadId) {
11
+ const trimmedPrompt = prompt.trim();
12
+ if (!trimmedPrompt) {
13
+ throw new Error('Prompt is required.');
14
+ }
15
+ if (!subagentRunner) {
16
+ throw new Error('Subagent runner is not registered.');
17
+ }
18
+ if (activeSubagent) {
19
+ throw new Error('A subagent is already running. Wait for it to finish before starting another one.');
20
+ }
21
+ const task = subagentRunner({ prompt: trimmedPrompt, parentThreadId });
22
+ activeSubagent = task;
23
+ try {
24
+ return await task;
25
+ }
26
+ finally {
27
+ activeSubagent = null;
28
+ }
29
+ }
30
+ export function resetAgentToolForTesting() {
31
+ subagentRunner = null;
32
+ activeSubagent = null;
33
+ }
@@ -0,0 +1,48 @@
1
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { execTool } from './index.js';
6
+ let originalCwd;
7
+ let tempRoot;
8
+ let currentDir;
9
+ let outsideDir;
10
+ beforeEach(async () => {
11
+ originalCwd = process.cwd();
12
+ tempRoot = await mkdtemp(path.join(tmpdir(), 'argos-exec-'));
13
+ currentDir = path.join(tempRoot, 'current');
14
+ outsideDir = path.join(tempRoot, 'outside');
15
+ await mkdir(currentDir);
16
+ await mkdir(outsideDir);
17
+ process.chdir(currentDir);
18
+ });
19
+ afterEach(async () => {
20
+ process.chdir(originalCwd);
21
+ await rm(tempRoot, { recursive: true, force: true });
22
+ });
23
+ describe('execTool', () => {
24
+ it('executes a safe command in the current working directory', async () => {
25
+ await mkdir('src');
26
+ await writeFile(path.join('src', 'hello.ts'), 'export {};');
27
+ await expect(execTool('ls src')).resolves.toContain('hello.ts');
28
+ });
29
+ it('executes a safe command in a nested working directory', async () => {
30
+ await mkdir('src');
31
+ await writeFile(path.join('src', 'hello.ts'), 'export {};');
32
+ await expect(execTool('ls', 'src')).resolves.toContain('hello.ts');
33
+ });
34
+ it('rejects path traversal outside the current working directory', async () => {
35
+ await expect(execTool('ls', '..')).rejects.toThrow('Can only execute commands inside the current working directory.');
36
+ });
37
+ it('rejects absolute working directories outside the current working directory', async () => {
38
+ await expect(execTool('ls', outsideDir)).rejects.toThrow('Can only execute commands inside the current working directory.');
39
+ });
40
+ it('rejects symlink working directories that point outside the current working directory', async () => {
41
+ await symlink(outsideDir, 'outside-link');
42
+ await expect(execTool('ls', 'outside-link')).rejects.toThrow('Can only execute commands inside the current working directory.');
43
+ });
44
+ it('rejects empty commands', async () => {
45
+ await expect(execTool('')).rejects.toThrow('Command is required.');
46
+ await expect(execTool(' ')).rejects.toThrow('Command is required.');
47
+ });
48
+ });