lazyclaw 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -508
  3. package/channels/handoff.mjs +41 -0
  4. package/channels/loader.mjs +124 -0
  5. package/channels/threads.mjs +116 -0
  6. package/cli.mjs +398 -6
  7. package/daemon.mjs +13 -0
  8. package/mas/agent_turn.mjs +28 -0
  9. package/mas/confidence.mjs +108 -0
  10. package/mas/index_db.mjs +242 -0
  11. package/mas/nudge.mjs +97 -0
  12. package/mas/prompt_stack.mjs +80 -0
  13. package/mas/skill_synth.mjs +124 -25
  14. package/mas/tool_runner.mjs +10 -61
  15. package/mas/tools/browser.mjs +77 -0
  16. package/mas/tools/clarify.mjs +36 -0
  17. package/mas/tools/coding.mjs +109 -0
  18. package/mas/tools/delegation.mjs +53 -0
  19. package/mas/tools/edit.mjs +36 -0
  20. package/mas/tools/git.mjs +110 -0
  21. package/mas/tools/ha.mjs +34 -0
  22. package/mas/tools/learning.mjs +168 -0
  23. package/mas/tools/media.mjs +105 -0
  24. package/mas/tools/os.mjs +152 -0
  25. package/mas/tools/patch.mjs +91 -0
  26. package/mas/tools/recall.mjs +103 -0
  27. package/mas/tools/registry.mjs +93 -0
  28. package/mas/tools/scheduling.mjs +62 -0
  29. package/mas/tools/web.mjs +137 -0
  30. package/mas/toolsets.mjs +64 -0
  31. package/mas/trajectory_export.mjs +169 -0
  32. package/mas/trajectory_store.mjs +179 -0
  33. package/mas/user_modeler.mjs +108 -0
  34. package/package.json +20 -3
  35. package/providers/codex_cli.mjs +200 -0
  36. package/providers/gemini_cli.mjs +179 -0
  37. package/providers/registry.mjs +61 -1
  38. package/sandbox/base.mjs +82 -0
  39. package/sandbox/confiners/bubblewrap.mjs +21 -0
  40. package/sandbox/confiners/firejail.mjs +16 -0
  41. package/sandbox/confiners/landlock.mjs +14 -0
  42. package/sandbox/confiners/seatbelt.mjs +28 -0
  43. package/sandbox/daytona.mjs +37 -0
  44. package/sandbox/docker.mjs +91 -0
  45. package/sandbox/index.mjs +67 -0
  46. package/sandbox/local.mjs +59 -0
  47. package/sandbox/modal.mjs +53 -0
  48. package/sandbox/singularity.mjs +39 -0
  49. package/sandbox/ssh.mjs +56 -0
  50. package/sandbox.mjs +11 -127
  51. package/scripts/hermes-import.mjs +111 -0
  52. package/scripts/migrate-v5.mjs +342 -0
  53. package/scripts/openclaw-import.mjs +71 -0
  54. package/sessions.mjs +20 -1
@@ -18,6 +18,8 @@
18
18
  import * as skills from '../skills.mjs';
19
19
  import { runTextCompletion } from './provider_adapters.mjs';
20
20
  import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
21
+ import { indexSkill as _indexSkill } from './index_db.mjs';
22
+ import { parseFrontmatter } from '../skills.mjs';
21
23
 
22
24
  const SECTION_RE = /^#{1,6}\s+/;
23
25
  const MAX_NAME_LEN = 48;
@@ -106,23 +108,69 @@ export function parseSynthOutput(text) {
106
108
  }
107
109
 
108
110
  // Build a complete SKILL.md: a flat-YAML frontmatter block followed by
109
- // the skill body. The frontmatter shape round-trips through
111
+ // the skill body. v5: adds trained_by / trained_on_model / trajectory_ref /
112
+ // confidence / cross_cli_tested (array) / anti_pattern (boolean) and a
113
+ // group fallback. The frontmatter shape round-trips through
110
114
  // skills.parseFrontmatter(). `ts` is injected (not read from the clock)
111
115
  // so the output is deterministic and testable.
112
- export function assembleSkillDoc({ name, description = '', createdBy = 'agent', sourceTask = '', body = '', version = 1, ts = new Date() } = {}) {
116
+ export function assembleSkillDoc({
117
+ name,
118
+ description = '',
119
+ createdBy = 'agent',
120
+ sourceTask = '',
121
+ body = '',
122
+ version = 1,
123
+ ts = new Date(),
124
+ // v5 additions:
125
+ trainedBy = null,
126
+ trainedOnModel = null,
127
+ trajectoryRef = null,
128
+ confidence = null,
129
+ crossCliTested = null, // array of {provider, model, outcome, tested_at}
130
+ outcome = 'done', // 'done' | 'failed' | 'abandoned' (spec §0.1 C1)
131
+ group = null,
132
+ } = {}) {
113
133
  const date = (ts instanceof Date ? ts : new Date(ts)).toISOString().slice(0, 10);
134
+ const isAntiPattern = outcome === 'failed';
135
+ const finalGroup = group || (isAntiPattern ? 'anti-pattern' : deriveGroup(name));
114
136
  const fm = [
115
137
  '---',
116
138
  `name: ${escapeYaml(stripControl(name))}`,
117
139
  `description: ${escapeYaml(description)}`,
118
140
  `version: ${version}`,
141
+ `group: ${escapeYaml(finalGroup)}`,
119
142
  `created_by: ${createdBy}`,
120
143
  ];
121
144
  if (sourceTask) fm.push(`source_task: ${sourceTask}`);
122
- fm.push(`created_at: ${date}`, '---', '');
145
+ fm.push(`created_at: ${date}`);
146
+ if (trainedBy) fm.push(`trained_by: ${escapeYaml(trainedBy)}`);
147
+ if (trainedOnModel) fm.push(`trained_on_model: ${escapeYaml(trainedOnModel)}`);
148
+ if (trajectoryRef) fm.push(`trajectory_ref: ${escapeYaml(trajectoryRef)}`);
149
+ if (confidence !== null && confidence !== undefined) {
150
+ fm.push(`confidence: ${Number(confidence).toFixed(2)}`);
151
+ }
152
+ if (isAntiPattern) fm.push(`anti_pattern: true`);
153
+ if (Array.isArray(crossCliTested) && crossCliTested.length) {
154
+ fm.push('cross_cli_tested:');
155
+ for (const t of crossCliTested) {
156
+ fm.push(` - provider: ${escapeYaml(t.provider || '')}`);
157
+ if (t.model) fm.push(` model: ${escapeYaml(t.model)}`);
158
+ if (t.outcome) fm.push(` outcome: ${escapeYaml(t.outcome)}`);
159
+ if (t.tested_at) fm.push(` tested_at: ${escapeYaml(t.tested_at)}`);
160
+ }
161
+ }
162
+ fm.push('---', '');
123
163
  return `${fm.join('\n')}\n${String(body).trim()}\n`;
124
164
  }
125
165
 
166
+ // Canonical fallback (spec §0.1 C5): filename hyphen prefix → 'legacy'.
167
+ function deriveGroup(name) {
168
+ const s = String(name || '');
169
+ const dash = s.indexOf('-');
170
+ if (dash > 0) return s.slice(0, dash);
171
+ return 'legacy';
172
+ }
173
+
126
174
  // Drop control characters (incl. newlines) from a single-line frontmatter
127
175
  // value so an embedded \n can't break out of its key into an injected one.
128
176
  function stripControl(v) {
@@ -157,43 +205,39 @@ export class SkillSynthError extends Error {
157
205
  // completion with no tools advertised, the agent's own role as the
158
206
  // system prompt. The difference is the ASK — a reusable skill in a
159
207
  // fixed section layout instead of free-text lessons.
160
- export async function synthesizeSkill({ agent, task, apiKey, baseUrl, fetchImpl } = {}) {
208
+ export async function synthesizeSkill({
209
+ agent, task, apiKey, baseUrl, fetchImpl,
210
+ outcome = 'done',
211
+ trainedBy = null,
212
+ trainedOnModel = null,
213
+ trajectoryRef = null,
214
+ confidence = null,
215
+ crossCliTested = null,
216
+ } = {}) {
161
217
  if (!agent || !task) throw new SkillSynthError('agent and task are required', 'SKILL_SYNTH_BAD_INPUT');
218
+ if (outcome !== 'done' && outcome !== 'failed' && outcome !== 'abandoned') {
219
+ throw new SkillSynthError(`bad outcome "${outcome}"`, 'SKILL_SYNTH_BAD_OUTCOME');
220
+ }
162
221
 
163
- // Redact secrets from the transcript BEFORE it leaves for the model,
164
- // so a token pasted into the task never reaches the LLM or the file.
165
222
  const transcript = redactSecrets(
166
223
  (Array.isArray(task.turns) ? task.turns : [])
167
224
  .map((t) => {
168
225
  const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
169
- // Defang any forged role label inside the (model-controlled) body
170
- // so a turn can't inject its own [System]/[User] authority line.
171
226
  return `[${who}] ${neutralizeRoleLabels(t.text || '')}`;
172
227
  })
173
228
  .join('\n\n') || '(no turns)'
174
229
  );
175
230
 
176
- const userMessage =
177
- `You just finished task "${task.title || '(untitled)'}" (id ${task.id}). Here is the full transcript:\n\n` +
178
- transcript +
179
- `\n\nDistil this into a REUSABLE skill that a future agent could load to handle a similar task faster. ` +
180
- `Reply in EXACTLY this format and nothing else:\n\n` +
181
- `name: <short kebab-case skill name>\n` +
182
- `description: <one line, ≤ 120 chars, describing WHEN this skill applies>\n\n` +
183
- `## When to Use\n<bullet conditions that signal this skill is relevant>\n\n` +
184
- `## Procedure\n<numbered, concrete steps — real file paths / commands where known>\n\n` +
185
- `## Pitfalls\n<gotchas and dead-ends you hit, so next time they're avoided>\n\n` +
186
- `## Verification\n<how to confirm the task actually succeeded>\n\n` +
187
- `Be concrete and specific to what happened. If the task was too trivial to be worth a reusable skill, reply with the single word NONE.`;
231
+ const userMessage = outcome === 'failed'
232
+ ? buildAntiPatternPrompt(task, transcript)
233
+ : buildSkillPrompt(task, transcript);
188
234
 
189
235
  const text = (await runTextCompletion({
190
236
  provider: agent.provider,
191
237
  model: agent.model,
192
238
  system: agent.role || '',
193
239
  userMessage,
194
- apiKey,
195
- baseUrl,
196
- fetchImpl,
240
+ apiKey, baseUrl, fetchImpl,
197
241
  })).trim();
198
242
  if (!text || /^none\b/i.test(text)) return null;
199
243
 
@@ -201,8 +245,51 @@ export async function synthesizeSkill({ agent, task, apiKey, baseUrl, fetchImpl
201
245
  const description = sanitizeDescription(parsed.description);
202
246
  const body = sanitizeSkillBody(parsed.body);
203
247
  if (!body.trim()) return null;
204
- const doc = assembleSkillDoc({ name: parsed.name, description, createdBy: 'agent', sourceTask: task.id, body });
205
- return { name: parsed.name, description, body, doc, sourceTask: task.id };
248
+ const doc = assembleSkillDoc({
249
+ name: parsed.name,
250
+ description,
251
+ createdBy: 'agent',
252
+ sourceTask: task.id,
253
+ body,
254
+ outcome,
255
+ trainedBy,
256
+ trainedOnModel,
257
+ trajectoryRef,
258
+ confidence,
259
+ crossCliTested,
260
+ });
261
+ return { name: parsed.name, description, body, doc, sourceTask: task.id, outcome };
262
+ }
263
+
264
+ function buildSkillPrompt(task, transcript) {
265
+ return (
266
+ `You just finished task "${task.title || '(untitled)'}" (id ${task.id}). Here is the full transcript:\n\n` +
267
+ transcript +
268
+ `\n\nDistil this into a REUSABLE skill that a future agent could load to handle a similar task faster. ` +
269
+ `Reply in EXACTLY this format and nothing else:\n\n` +
270
+ `name: <short kebab-case skill name>\n` +
271
+ `description: <one line, ≤ 120 chars, describing WHEN this skill applies>\n\n` +
272
+ `## When to Use\n<bullet conditions that signal this skill is relevant>\n\n` +
273
+ `## Procedure\n<numbered, concrete steps — real file paths / commands where known>\n\n` +
274
+ `## Pitfalls\n<gotchas and dead-ends you hit, so next time they're avoided>\n\n` +
275
+ `## Verification\n<how to confirm the task actually succeeded>\n\n` +
276
+ `Be concrete and specific to what happened. If the task was too trivial to be worth a reusable skill, reply with the single word NONE.`
277
+ );
278
+ }
279
+
280
+ function buildAntiPatternPrompt(task, transcript) {
281
+ return (
282
+ `Task "${task.title || '(untitled)'}" (id ${task.id}) FAILED. Transcript:\n\n` +
283
+ transcript +
284
+ `\n\nDistil this into an ANTI-PATTERN note that a future agent will read and avoid. ` +
285
+ `Reply in EXACTLY this format and nothing else:\n\n` +
286
+ `name: <short kebab-case anti-pattern name, prefixed with "avoid-">\n` +
287
+ `description: <one line, ≤ 120 chars, describing the failure mode to avoid>\n\n` +
288
+ `## What Failed\n<concrete description of what was attempted and how it broke>\n\n` +
289
+ `## Why\n<root cause, with file paths or error messages where known>\n\n` +
290
+ `## Avoid\n<the rule the next agent should follow instead>\n\n` +
291
+ `Be specific. If the failure was too transient to generalise, reply with the single word NONE.`
292
+ );
206
293
  }
207
294
 
208
295
  // Install a synthesised skill without ever clobbering a human-authored
@@ -228,5 +315,17 @@ export function installSynthesized({ name, description = '', body = '', sourceTa
228
315
  ts,
229
316
  });
230
317
  const p = skills.installSkill(finalName, doc, configDir);
318
+ // Phase A: FTS5 mirror (spec §4.4). Group fallback per canonical C5.
319
+ try {
320
+ const { meta, body: skillBody } = parseFrontmatter(doc);
321
+ const group = meta.group
322
+ || (finalName.includes('-') ? finalName.split('-')[0] : 'legacy');
323
+ _indexSkill({
324
+ skill_name: finalName,
325
+ trained_by: meta.trained_by || createdBy === 'agent' ? 'agent' : 'user',
326
+ group_name: group,
327
+ content: skillBody,
328
+ }, configDir);
329
+ } catch { /* swallow */ }
231
330
  return { skill: finalName, path: p, version };
232
331
  }
@@ -2,17 +2,8 @@
2
2
  // the agent is allowed to use the tool, runs the tool, audits the call,
3
3
  // and returns a uniform { ok, result?, error? } shape that the provider
4
4
  // adapters serialise into their respective tool-result content blocks.
5
- //
6
- // `bash`, `read`, `write`, `grep` ship with Phase 12a. `web_search`,
7
- // `web_fetch`, `slack_post` are advertised in the registry's
8
- // metadata-only entry so the dashboard can show them, but their `exec`
9
- // throws TOOL_NOT_IMPLEMENTED until later phases wire them up.
10
5
 
11
- import * as bashTool from './tools/bash.mjs';
12
- import * as readTool from './tools/read.mjs';
13
- import * as writeTool from './tools/write.mjs';
14
- import * as grepTool from './tools/grep.mjs';
15
- import * as skillViewTool from './tools/skill_view.mjs';
6
+ import * as registry from './tools/registry.mjs';
16
7
  import * as audit from './audit.mjs';
17
8
 
18
9
  export class ToolError extends Error {
@@ -23,72 +14,30 @@ export class ToolError extends Error {
23
14
  }
24
15
  }
25
16
 
26
- const TOOLS = {
27
- bash: bashTool,
28
- read: readTool,
29
- write: writeTool,
30
- grep: grepTool,
31
- skill_view: skillViewTool,
32
- };
33
-
34
- const NOT_IMPLEMENTED_TOOLS = ['web_search', 'web_fetch', 'slack_post'];
35
-
36
17
  export function listToolSchemas(names) {
37
18
  const out = [];
38
- const wanted = Array.isArray(names) && names.length ? names : Object.keys(TOOLS);
19
+ const wanted = Array.isArray(names) && names.length ? names : registry.listNames();
39
20
  for (const name of wanted) {
40
- const t = TOOLS[name];
21
+ const t = registry.lookup(name);
41
22
  if (!t) continue;
42
- out.push({ name: t.NAME, description: t.DESCRIPTION, parameters: t.PARAMETERS });
23
+ out.push({ name: t.name, description: t.description, parameters: t.parameters });
43
24
  }
44
25
  return out;
45
26
  }
46
27
 
47
- export function isImplemented(name) {
48
- return Boolean(TOOLS[name]);
49
- }
50
-
51
- export function knownTool(name) {
52
- return TOOLS[name] !== undefined || NOT_IMPLEMENTED_TOOLS.includes(name);
53
- }
54
-
55
- // Run one tool call. The agent record's `tools` field is the whitelist;
56
- // when the call falls outside it, we throw ToolError('TOOL_DENIED') so
57
- // the caller can surface a structured error back to the LLM rather than
58
- // silently dropping the call.
59
- //
60
- // Tools that mutate state / run arbitrary code. When an `approve` hook is
61
- // supplied (e.g. backed by the gateway's remote exec-approval), these are
62
- // gated on a human decision before they run; read-only tools are not.
63
- const SENSITIVE_TOOLS = new Set(['bash', 'write']);
28
+ export function isImplemented(name) { return registry.lookup(name) !== null; }
29
+ export function knownTool(name) { return registry.lookup(name) !== null; }
64
30
 
65
- // opts.cwd — where bash/read/write/grep root themselves; defaults to
66
- // process.cwd() so it can be overridden in tests.
67
- // opts.taskId — when set, every call is appended to the task's audit
68
- // log. Unit tests can omit it.
69
- // opts.approve — optional async (call) => { approved, reason }. When
70
- // present, a sensitive tool call is held until it resolves; a non-approval
71
- // blocks the tool (returns a structured error instead of executing).
72
31
  export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve } = {}) {
73
32
  if (!agent || !Array.isArray(agent.tools)) {
74
33
  throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
75
34
  }
76
- if (!knownTool(tool)) {
77
- throw new ToolError(`unknown tool "${tool}"`, 'TOOL_UNKNOWN');
78
- }
35
+ const impl = registry.lookup(tool);
36
+ if (!impl) throw new ToolError(`unknown tool "${tool}"`, 'TOOL_UNKNOWN');
79
37
  if (!agent.tools.includes(tool)) {
80
38
  throw new ToolError(`agent "${agent.name}" is not allowed to call tool "${tool}" (whitelist=[${agent.tools.join(', ')}])`, 'TOOL_DENIED');
81
39
  }
82
- const impl = TOOLS[tool];
83
- if (!impl) {
84
- // Known but not yet implemented (web_search etc.) — Phase 12+x will fill in.
85
- const result = { ok: false, error: `tool "${tool}" is registered but not implemented yet` };
86
- audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
87
- return result;
88
- }
89
- // Human-in-the-loop gate for sensitive tools. A denial (or an erroring
90
- // hook — fail closed) blocks execution and is audited like any result.
91
- if (typeof approve === 'function' && SENSITIVE_TOOLS.has(tool)) {
40
+ if (typeof approve === 'function' && impl.sensitive) {
92
41
  let verdict;
93
42
  try { verdict = await approve({ tool, args, agent: agent.name }); }
94
43
  catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
@@ -100,7 +49,7 @@ export async function runTool({ agent, tool, args, taskId, configDir, cwd, appro
100
49
  }
101
50
  let result;
102
51
  try {
103
- result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir });
52
+ result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir, taskId, agent });
104
53
  } catch (err) {
105
54
  result = { ok: false, error: `${tool} threw: ${err?.message || err}` };
106
55
  }
@@ -0,0 +1,77 @@
1
+ // browser — playwright-driven browser_navigate / click / back / screenshot.
2
+ // Playwright is already a devDep (playwright.config.ts); we lazy-import and
3
+ // return a structured error if it is missing at runtime. A persistent
4
+ // headless Chromium context is reused across calls in the same process.
5
+
6
+ let _backend = null;
7
+ export function __setBrowserBackend(b) { _backend = b; }
8
+
9
+ let _ctx = null;
10
+ async function ensureCtx() {
11
+ if (_backend) return _backend;
12
+ if (_ctx) return _ctx;
13
+ let pw;
14
+ try { pw = await import('playwright'); }
15
+ catch { throw new Error('browser: playwright not installed (npm i playwright)'); }
16
+ const browser = await pw.chromium.launch({ headless: true });
17
+ const context = await browser.newContext();
18
+ const page = await context.newPage();
19
+ _ctx = {
20
+ navigate: async (url) => { await page.goto(url, { waitUntil: 'domcontentloaded' }); return { url: page.url(), title: await page.title() }; },
21
+ click: async (sel) => { await page.click(sel); return { clicked: sel }; },
22
+ back: async () => { await page.goBack(); return { url: page.url() }; },
23
+ screenshot: async (path) => { await page.screenshot({ path, fullPage: true }); return { path }; },
24
+ };
25
+ return _ctx;
26
+ }
27
+
28
+ function safeHttp(url) {
29
+ try {
30
+ const u = new URL(url);
31
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
32
+ return true;
33
+ } catch { return false; }
34
+ }
35
+
36
+ const browser_navigate = {
37
+ name: 'browser_navigate', category: 'browser', sensitive: true,
38
+ description: 'Navigate to an http(s) URL in a headless Chromium session.',
39
+ parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
40
+ async exec(args) {
41
+ if (!safeHttp(args.url)) return { ok: false, error: 'browser_navigate: http(s) only' };
42
+ try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.navigate(args.url)) }; }
43
+ catch (e) { return { ok: false, error: `browser_navigate: ${e.message}` }; }
44
+ },
45
+ };
46
+
47
+ const browser_click = {
48
+ name: 'browser_click', category: 'browser', sensitive: true,
49
+ description: 'Click an element by CSS selector.',
50
+ parameters: { type: 'object', properties: { selector: { type: 'string' } }, required: ['selector'] },
51
+ async exec(args) {
52
+ try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.click(args.selector)) }; }
53
+ catch (e) { return { ok: false, error: `browser_click: ${e.message}` }; }
54
+ },
55
+ };
56
+
57
+ const browser_back = {
58
+ name: 'browser_back', category: 'browser', sensitive: true,
59
+ description: 'Navigate back in browser history.',
60
+ parameters: { type: 'object', properties: {} },
61
+ async exec() {
62
+ try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.back()) }; }
63
+ catch (e) { return { ok: false, error: `browser_back: ${e.message}` }; }
64
+ },
65
+ };
66
+
67
+ const browser_screenshot = {
68
+ name: 'browser_screenshot', category: 'browser', sensitive: true,
69
+ description: 'Capture a full-page PNG to <path>.',
70
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
71
+ async exec(args) {
72
+ try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.screenshot(args.path)) }; }
73
+ catch (e) { return { ok: false, error: `browser_screenshot: ${e.message}` }; }
74
+ },
75
+ };
76
+
77
+ export const TOOLS = [browser_navigate, browser_click, browser_back, browser_screenshot];
@@ -0,0 +1,36 @@
1
+ // clarify — surface a question to the human user mid-turn. The actual
2
+ // prompting mechanism is host-dependent (REPL prompt, Slack DM, gateway
3
+ // SSE), so the runtime hooks an asker via __setAsker. Returns the user's
4
+ // reply as a plain string. Pattern lifted from Hermes (spec §7 sub-12).
5
+
6
+ let _asker = null;
7
+ export function __setAsker(fn) { _asker = fn; }
8
+
9
+ export const TOOL = {
10
+ name: 'clarify',
11
+ category: 'agents',
12
+ sensitive: false,
13
+ description: 'Ask the user a clarifying question and wait for the reply.',
14
+ parameters: {
15
+ type: 'object',
16
+ properties: { question: { type: 'string' }, choices: { type: 'array', items: { type: 'string' } } },
17
+ required: ['question'],
18
+ },
19
+ async exec(args, ctx) {
20
+ if (!args?.question) return { ok: false, error: 'clarify: question required' };
21
+ if (typeof _asker === 'function') {
22
+ try {
23
+ const answer = await _asker({ question: args.question, choices: args.choices });
24
+ return { ok: true, answer };
25
+ } catch (e) { return { ok: false, error: `clarify: ${e.message}` }; }
26
+ }
27
+ if (ctx?.isTTY) {
28
+ const readline = await import('node:readline/promises');
29
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
30
+ const answer = await rl.question(`[clarify] ${args.question} > `);
31
+ rl.close();
32
+ return { ok: true, answer };
33
+ }
34
+ return { ok: false, error: 'clarify: no asker bound and not running in a TTY' };
35
+ },
36
+ };
@@ -0,0 +1,109 @@
1
+ // coding — sandboxed code runners (python_exec, node_exec), data tools
2
+ // (sql_query stub, http_request that reuses web_fetch SSRF policy),
3
+ // and a pure helper (regex_match).
4
+
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { TOOLS as webTools } from './web.mjs';
10
+
11
+ function runProc(cmd, args, opts = {}) {
12
+ return new Promise(resolve => {
13
+ let p;
14
+ try { p = spawn(cmd, args, { cwd: opts.cwd, env: opts.env || process.env }); }
15
+ catch (e) { return resolve({ ok: false, error: e.message }); }
16
+ let out = '', err = '';
17
+ const timeout = setTimeout(() => { try { p.kill('SIGKILL'); } catch {} }, opts.timeoutMs || 30_000);
18
+ p.on('error', e => { clearTimeout(timeout); resolve({ ok: false, error: e.message }); });
19
+ p.stdout?.on('data', d => out += d.toString());
20
+ p.stderr?.on('data', d => err += d.toString());
21
+ p.on('close', code => { clearTimeout(timeout); resolve({ ok: code === 0, stdout: out, stderr: err, exitCode: code }); });
22
+ if (opts.stdin != null) { p.stdin.write(opts.stdin); p.stdin.end(); }
23
+ });
24
+ }
25
+
26
+ const python_exec = {
27
+ name: 'python_exec', category: 'coding', sensitive: true,
28
+ description: 'Run a Python snippet in a sandboxed subprocess. 30s timeout.',
29
+ parameters: {
30
+ type: 'object',
31
+ properties: { code: { type: 'string' }, timeoutMs: { type: 'number' } },
32
+ required: ['code'],
33
+ },
34
+ async exec(args, ctx) {
35
+ const py = ctx?.python || process.env.LAZYCLAW_PYTHON || 'python3';
36
+ return runProc(py, ['-c', args.code], { cwd: ctx?.cwd, timeoutMs: args.timeoutMs });
37
+ },
38
+ };
39
+
40
+ const node_exec = {
41
+ name: 'node_exec', category: 'coding', sensitive: true,
42
+ description: 'Run a Node.js snippet in a sandboxed subprocess. 30s timeout.',
43
+ parameters: {
44
+ type: 'object',
45
+ properties: { code: { type: 'string' }, timeoutMs: { type: 'number' } },
46
+ required: ['code'],
47
+ },
48
+ async exec(args, ctx) {
49
+ const node = ctx?.node || process.execPath;
50
+ return runProc(node, ['-e', args.code], { cwd: ctx?.cwd, timeoutMs: args.timeoutMs });
51
+ },
52
+ };
53
+
54
+ const sql_query = {
55
+ name: 'sql_query', category: 'coding', sensitive: true,
56
+ description: 'Run a read-only SQL query against the agent\'s bound database. Returns rows.',
57
+ parameters: {
58
+ type: 'object',
59
+ properties: { sql: { type: 'string' }, params: { type: 'array' } },
60
+ required: ['sql'],
61
+ },
62
+ async exec(args, ctx) {
63
+ const db = ctx?.db || null;
64
+ if (!db) return { ok: false, error: 'sql_query: no database bound to agent context' };
65
+ try {
66
+ const stmt = db.prepare(args.sql);
67
+ const rows = stmt.all(...(args.params || []));
68
+ return { ok: true, rows };
69
+ } catch (e) { return { ok: false, error: `sql_query: ${e.message}` }; }
70
+ },
71
+ };
72
+
73
+ const http_request = {
74
+ name: 'http_request', category: 'coding', sensitive: true,
75
+ description: 'Generic HTTP client. Reuses web_fetch SSRF policy.',
76
+ parameters: {
77
+ type: 'object',
78
+ properties: {
79
+ url: { type: 'string' }, method: { type: 'string' },
80
+ headers: { type: 'object' }, body: { type: 'string' },
81
+ },
82
+ required: ['url'],
83
+ },
84
+ async exec(args, ctx) {
85
+ const wf = webTools.find(t => t.name === 'web_fetch');
86
+ return wf.exec(args, ctx);
87
+ },
88
+ };
89
+
90
+ const regex_match = {
91
+ name: 'regex_match', category: 'coding', sensitive: false,
92
+ description: 'Run a regex over a string and return the matches.',
93
+ parameters: {
94
+ type: 'object',
95
+ properties: { pattern: { type: 'string' }, flags: { type: 'string' }, text: { type: 'string' } },
96
+ required: ['pattern', 'text'],
97
+ },
98
+ async exec(args) {
99
+ try {
100
+ const re = new RegExp(args.pattern, args.flags || '');
101
+ const matches = args.flags?.includes('g')
102
+ ? [...args.text.matchAll(re)].map(m => m[0])
103
+ : (args.text.match(re) || []);
104
+ return { ok: true, matches };
105
+ } catch (e) { return { ok: false, error: `regex_match: ${e.message}` }; }
106
+ },
107
+ };
108
+
109
+ export const TOOLS = [python_exec, node_exec, sql_query, http_request, regex_match];
@@ -0,0 +1,53 @@
1
+ // delegation — task_spawn (named agent), delegate (worker provider).
2
+ // Both lazy-import providers/orchestrator.mjs / mas/agent_turn.mjs to
3
+ // avoid pulling those into every process that imports the registry.
4
+
5
+ let _dispatcher = null;
6
+ export function __setDispatcher(fn) { _dispatcher = fn; }
7
+
8
+ async function dispatchDelegate(job) {
9
+ if (_dispatcher) return _dispatcher(job);
10
+ const orch = await import('../../providers/orchestrator.mjs').catch(() => null);
11
+ if (!orch || typeof orch.dispatchWorker !== 'function') {
12
+ return { ok: false, error: 'delegate: orchestrator.dispatchWorker unavailable' };
13
+ }
14
+ return orch.dispatchWorker(job);
15
+ }
16
+
17
+ async function dispatchSpawn(job) {
18
+ const at = await import('../agent_turn.mjs').catch(() => null);
19
+ if (!at || typeof at.runAgentTurn !== 'function') {
20
+ return { ok: false, error: 'task_spawn: agent_turn.runAgentTurn unavailable' };
21
+ }
22
+ return at.runAgentTurn(job);
23
+ }
24
+
25
+ const task_spawn = {
26
+ name: 'task_spawn', category: 'agents', sensitive: true,
27
+ description: 'Spawn an agent by name with a prompt; returns the final answer.',
28
+ parameters: {
29
+ type: 'object',
30
+ properties: { agent: { type: 'string' }, prompt: { type: 'string' } },
31
+ required: ['agent', 'prompt'],
32
+ },
33
+ async exec(args) {
34
+ if (!args?.agent || !args?.prompt) return { ok: false, error: 'task_spawn: agent + prompt required' };
35
+ return dispatchSpawn({ agent: args.agent, prompt: args.prompt });
36
+ },
37
+ };
38
+
39
+ const delegate = {
40
+ name: 'delegate', category: 'agents', sensitive: true,
41
+ description: 'Dispatch a subtask to a worker provider (claude-cli, codex-cli, gemini-cli, anthropic, openai, gemini, ollama).',
42
+ parameters: {
43
+ type: 'object',
44
+ properties: { worker: { type: 'string' }, prompt: { type: 'string' }, model: { type: 'string' } },
45
+ required: ['worker', 'prompt'],
46
+ },
47
+ async exec(args) {
48
+ if (!args?.worker || !args?.prompt) return { ok: false, error: 'delegate: worker + prompt required' };
49
+ return dispatchDelegate({ worker: args.worker, prompt: args.prompt, model: args.model });
50
+ },
51
+ };
52
+
53
+ export const TOOLS = [task_spawn, delegate];
@@ -0,0 +1,36 @@
1
+ // edit — find/replace exactly one occurrence of `old` with `new` inside a file
2
+ // rooted at the agent cwd. Refuses when `old` is missing or not unique so the
3
+ // LLM cannot silently overwrite the wrong span (spec §7 sub-bullet 1).
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export const TOOL = {
9
+ name: 'edit',
10
+ category: 'fs',
11
+ sensitive: true,
12
+ description: 'Replace exactly one occurrence of `old` with `new` inside a workspace file. Fails if `old` is missing or appears more than once.',
13
+ parameters: {
14
+ type: 'object',
15
+ properties: {
16
+ path: { type: 'string', description: 'File path relative to cwd.' },
17
+ old: { type: 'string', description: 'Exact substring to replace.' },
18
+ new: { type: 'string', description: 'Replacement text.' },
19
+ },
20
+ required: ['path', 'old', 'new'],
21
+ },
22
+ async exec(args, { cwd = process.cwd() } = {}) {
23
+ if (!args || typeof args.path !== 'string') return { ok: false, error: 'edit: path required' };
24
+ if (typeof args.old !== 'string' || typeof args.new !== 'string') return { ok: false, error: 'edit: old/new strings required' };
25
+ const abs = path.resolve(cwd, args.path);
26
+ if (!abs.startsWith(path.resolve(cwd))) return { ok: false, error: 'edit: path escapes workspace' };
27
+ let src;
28
+ try { src = fs.readFileSync(abs, 'utf8'); } catch (e) { return { ok: false, error: `edit: ${e.message}` }; }
29
+ const idx = src.indexOf(args.old);
30
+ if (idx === -1) return { ok: false, error: `edit: \`old\` not found in ${args.path}` };
31
+ if (src.indexOf(args.old, idx + 1) !== -1) return { ok: false, error: `edit: \`old\` not unique in ${args.path}` };
32
+ const next = src.slice(0, idx) + args.new + src.slice(idx + args.old.length);
33
+ fs.writeFileSync(abs, next);
34
+ return { ok: true, path: args.path, bytesWritten: Buffer.byteLength(next) };
35
+ },
36
+ };