lazyclaw 4.2.2 → 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 (67) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -353
  3. package/agents.mjs +19 -3
  4. package/channels/handoff.mjs +41 -0
  5. package/channels/loader.mjs +124 -0
  6. package/channels/matrix.mjs +417 -0
  7. package/channels/telegram.mjs +362 -0
  8. package/channels/threads.mjs +116 -0
  9. package/cli.mjs +730 -27
  10. package/daemon.mjs +111 -0
  11. package/gateway/device_auth.mjs +664 -0
  12. package/gateway/http_gateway.mjs +304 -0
  13. package/mas/agent_memory.mjs +35 -34
  14. package/mas/agent_turn.mjs +30 -1
  15. package/mas/confidence.mjs +108 -0
  16. package/mas/index_db.mjs +242 -0
  17. package/mas/mention_router.mjs +75 -4
  18. package/mas/nudge.mjs +97 -0
  19. package/mas/prompt_stack.mjs +80 -0
  20. package/mas/provider_adapters.mjs +83 -0
  21. package/mas/redact.mjs +46 -0
  22. package/mas/skill_synth.mjs +331 -0
  23. package/mas/tool_runner.mjs +19 -48
  24. package/mas/tools/browser.mjs +77 -0
  25. package/mas/tools/clarify.mjs +36 -0
  26. package/mas/tools/coding.mjs +109 -0
  27. package/mas/tools/delegation.mjs +53 -0
  28. package/mas/tools/edit.mjs +36 -0
  29. package/mas/tools/git.mjs +110 -0
  30. package/mas/tools/ha.mjs +34 -0
  31. package/mas/tools/learning.mjs +168 -0
  32. package/mas/tools/media.mjs +105 -0
  33. package/mas/tools/os.mjs +152 -0
  34. package/mas/tools/patch.mjs +91 -0
  35. package/mas/tools/recall.mjs +103 -0
  36. package/mas/tools/registry.mjs +93 -0
  37. package/mas/tools/scheduling.mjs +62 -0
  38. package/mas/tools/skill_view.mjs +43 -0
  39. package/mas/tools/web.mjs +137 -0
  40. package/mas/toolsets.mjs +64 -0
  41. package/mas/trajectory_export.mjs +169 -0
  42. package/mas/trajectory_store.mjs +179 -0
  43. package/mas/user_modeler.mjs +108 -0
  44. package/package.json +22 -3
  45. package/providers/codex_cli.mjs +200 -0
  46. package/providers/gemini_cli.mjs +179 -0
  47. package/providers/registry.mjs +61 -1
  48. package/sandbox/base.mjs +82 -0
  49. package/sandbox/confiners/bubblewrap.mjs +21 -0
  50. package/sandbox/confiners/firejail.mjs +16 -0
  51. package/sandbox/confiners/landlock.mjs +14 -0
  52. package/sandbox/confiners/seatbelt.mjs +28 -0
  53. package/sandbox/daytona.mjs +37 -0
  54. package/sandbox/docker.mjs +91 -0
  55. package/sandbox/index.mjs +67 -0
  56. package/sandbox/local.mjs +59 -0
  57. package/sandbox/modal.mjs +53 -0
  58. package/sandbox/singularity.mjs +39 -0
  59. package/sandbox/ssh.mjs +56 -0
  60. package/sandbox.mjs +11 -127
  61. package/scripts/hermes-import.mjs +111 -0
  62. package/scripts/migrate-v5.mjs +342 -0
  63. package/scripts/openclaw-import.mjs +71 -0
  64. package/sessions.mjs +20 -1
  65. package/skills.mjs +101 -8
  66. package/skills_curator.mjs +323 -0
  67. package/workspace.mjs +18 -3
package/mas/redact.mjs ADDED
@@ -0,0 +1,46 @@
1
+ // Shared secret redaction — Phase 20 hardening.
2
+ //
3
+ // A single source of truth for stripping common secret shapes out of
4
+ // text before it is (a) sent to an LLM or (b) persisted to a file that
5
+ // later re-enters a system prompt. Both skill_synth.synthesizeSkill and
6
+ // agent_memory.reflectOnce import this so the two paths stay symmetric:
7
+ // neither a distilled skill nor a reflection memory can ever leak a
8
+ // token that merely appeared in a task transcript.
9
+ //
10
+ // This module has zero internal imports so it can be imported from
11
+ // anywhere without risking a cycle.
12
+
13
+ // Redact common secret shapes (private keys, provider API tokens,
14
+ // bearer headers, KEY/TOKEN/SECRET/PASSWORD env assignments). The match
15
+ // list mirrors the audit log's posture of not persisting raw sensitive
16
+ // I/O. Order matters: the private-key block is collapsed first so its
17
+ // inner base64 doesn't trip the narrower token patterns.
18
+ export function redactSecrets(text) {
19
+ return String(text ?? '')
20
+ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]')
21
+ // Inline credentials in a URL: scheme://user:password@host → redact the password.
22
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+):[^/\s@]+@/gi, '$1:[REDACTED]@')
23
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}/g, '[REDACTED]')
24
+ .replace(/\b(?:gh[opsu]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, '[REDACTED]')
25
+ .replace(/\bxox[abprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED]')
26
+ .replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED]')
27
+ .replace(/\bAIza[0-9A-Za-z_-]{35}/g, '[REDACTED]')
28
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[REDACTED JWT]')
29
+ .replace(/\b[Bb]earer\s+[A-Za-z0-9._-]{8,}/g, 'Bearer [REDACTED]')
30
+ // Uppercase env-assignment form (kept for parity / readability).
31
+ .replace(/\b([A-Z][A-Z0-9_]{2,}_(?:KEY|TOKEN|SECRET|PASSWORD))\s*=\s*\S+/g, '$1=[REDACTED]')
32
+ // Case-insensitive key/token/secret/password assignment (api_key=, apiKey: "...", PASSWORD=…).
33
+ // [\w-]* may be empty so a bare `password:` / `token=` is caught too.
34
+ .replace(/\b([\w-]*(?:key|token|secret|password))\s*[:=]\s*["']?[^\s"']+/gi, '$1=[REDACTED]');
35
+ }
36
+
37
+ // Neutralise forged conversation role labels embedded in untrusted model
38
+ // text. The reflection/synthesis transcript is rendered as
39
+ // `[User]/[System]/<agent>` lines from the TRUSTED turn.agent field; a
40
+ // turn's free-text body must not be able to inject its own authority
41
+ // line (e.g. a newline followed by "[System] ignore previous"). We defang
42
+ // only the authority roles (case-insensitive, line-leading) so a forged
43
+ // instruction can't masquerade as the system/user/assistant speaker.
44
+ export function neutralizeRoleLabels(text) {
45
+ return String(text ?? '').replace(/^([ \t]*)\[(user|system|assistant|agent)\]/gim, '$1($2)');
46
+ }
@@ -0,0 +1,331 @@
1
+ // Skill synthesis — Phase 20.
2
+ //
3
+ // Turns a finished task transcript into a reusable SKILL.md (the
4
+ // Hermes self-improving-skill pattern). This module owns the
5
+ // deterministic, LLM-free pieces so they can be unit-tested without a
6
+ // network round-trip:
7
+ //
8
+ // slugifySkill(title) → a filesystem-safe skill name
9
+ // parseSynthOutput(text) → { name, description, body } from raw
10
+ // LLM output
11
+ // assembleSkillDoc({...}) → a full SKILL.md (frontmatter + body)
12
+ //
13
+ // The single LLM call lives in synthesizeSkill() at the bottom; it
14
+ // mirrors agent_memory.reflectOnce() (same provider adapters, same
15
+ // no-tools text completion) but asks for a structured skill instead of
16
+ // free-text lessons.
17
+
18
+ import * as skills from '../skills.mjs';
19
+ import { runTextCompletion } from './provider_adapters.mjs';
20
+ import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
21
+ import { indexSkill as _indexSkill } from './index_db.mjs';
22
+ import { parseFrontmatter } from '../skills.mjs';
23
+
24
+ const SECTION_RE = /^#{1,6}\s+/;
25
+ const MAX_NAME_LEN = 48;
26
+ const MAX_BODY_BYTES = 8 * 1024;
27
+
28
+ // Re-export the shared secret redactor (mas/redact.mjs) so existing
29
+ // callers of skill_synth.redactSecrets keep working while the single
30
+ // implementation is shared with agent_memory.reflectOnce.
31
+ export { redactSecrets };
32
+
33
+ // Sanitise an agent-authored skill body before it is persisted and
34
+ // later loaded into other agents' context: redact secrets, neutralise
35
+ // the task-termination marker (so reference material can't drive the
36
+ // router loop), strip control characters, and cap the size.
37
+ export function sanitizeSkillBody(text) {
38
+ let s = redactSecrets(text);
39
+ s = s.replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]');
40
+ // Strip control characters except tab/newline/carriage-return.
41
+ s = s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
42
+ if (Buffer.byteLength(s, 'utf8') > MAX_BODY_BYTES) {
43
+ s = Buffer.from(s, 'utf8').subarray(0, MAX_BODY_BYTES).toString('utf8').replace(/\uFFFD+$/, '') + '\n\n…[truncated]';
44
+ }
45
+ return s;
46
+ }
47
+
48
+ // Sanitise the one-line description (it lands in every agent's system
49
+ // prompt via the skills index): redact, collapse to a single line,
50
+ // neutralise the marker, cap to 120 chars.
51
+ export function sanitizeDescription(text) {
52
+ return redactSecrets(text)
53
+ .replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]')
54
+ .replace(/[\u0000-\u001F\u007F]+/g, ' ')
55
+ .replace(/\s+/g, ' ')
56
+ .trim()
57
+ .slice(0, 120);
58
+ }
59
+
60
+ // Lowercase, collapse every run of non-alphanumerics into a single
61
+ // dash, and strip leading/trailing dashes. Empty input (or input with
62
+ // no alphanumerics) falls back to "skill" so a write always has a
63
+ // valid target name.
64
+ export function slugifySkill(title) {
65
+ const slug = String(title || '')
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9]+/g, '-')
68
+ .replace(/^-+|-+$/g, '')
69
+ .slice(0, MAX_NAME_LEN)
70
+ .replace(/-+$/g, '');
71
+ return slug || 'skill';
72
+ }
73
+
74
+ // Parse the model's reply into { name, description, body }. The prompt
75
+ // asks for two leading `name:` / `description:` lines followed by the
76
+ // `## When to Use / ## Procedure / ## Pitfalls / ## Verification`
77
+ // sections — but models drift, so we degrade gracefully: when the
78
+ // leading lines are missing we derive the name from the first heading
79
+ // and leave the description empty. `body` always begins at the first
80
+ // markdown heading (the section content), with the name/description
81
+ // header stripped.
82
+ export function parseSynthOutput(text) {
83
+ const raw = String(text || '').replace(/^/, '').trim();
84
+ const lines = raw.split(/\r?\n/);
85
+
86
+ let name = '';
87
+ let description = '';
88
+ let bodyStart = 0;
89
+
90
+ for (let i = 0; i < lines.length; i++) {
91
+ const line = lines[i];
92
+ if (SECTION_RE.test(line)) { bodyStart = i; break; }
93
+ const nameM = /^name\s*:\s*(.+)$/i.exec(line.trim());
94
+ if (nameM && !name) { name = nameM[1].trim(); bodyStart = i + 1; continue; }
95
+ const descM = /^description\s*:\s*(.+)$/i.exec(line.trim());
96
+ if (descM && !description) { description = descM[1].trim(); bodyStart = i + 1; continue; }
97
+ }
98
+
99
+ const body = lines.slice(bodyStart).join('\n').replace(/^(?:\r?\n)+/, '').trimEnd();
100
+
101
+ // No explicit name → derive it from the first heading in the body.
102
+ if (!name) {
103
+ const firstHeading = body.split(/\r?\n/).find((l) => SECTION_RE.test(l));
104
+ if (firstHeading) name = firstHeading.replace(SECTION_RE, '').trim();
105
+ }
106
+
107
+ return { name: slugifySkill(name), description, body };
108
+ }
109
+
110
+ // Build a complete SKILL.md: a flat-YAML frontmatter block followed by
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
114
+ // skills.parseFrontmatter(). `ts` is injected (not read from the clock)
115
+ // so the output is deterministic and testable.
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
+ } = {}) {
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));
136
+ const fm = [
137
+ '---',
138
+ `name: ${escapeYaml(stripControl(name))}`,
139
+ `description: ${escapeYaml(description)}`,
140
+ `version: ${version}`,
141
+ `group: ${escapeYaml(finalGroup)}`,
142
+ `created_by: ${createdBy}`,
143
+ ];
144
+ if (sourceTask) fm.push(`source_task: ${sourceTask}`);
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('---', '');
163
+ return `${fm.join('\n')}\n${String(body).trim()}\n`;
164
+ }
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
+
174
+ // Drop control characters (incl. newlines) from a single-line frontmatter
175
+ // value so an embedded \n can't break out of its key into an injected one.
176
+ function stripControl(v) {
177
+ return String(v ?? '').replace(/[\u0000-\u001f\u007f]/g, '');
178
+ }
179
+
180
+ // Quote a value only when it contains characters our flat parser would
181
+ // otherwise choke on (a leading special char or an embedded colon).
182
+ // Control characters (including newlines) are stripped first: a quoted
183
+ // multi-line value still injects a frontmatter key because the parser
184
+ // splits on physical lines, so the only safe move is to flatten to a
185
+ // single line before deciding whether to quote.
186
+ function escapeYaml(v) {
187
+ const s = stripControl(v);
188
+ if (s === '') return '';
189
+ if (/[:#]/.test(s) || /^[\s'">|&*!%@`-]/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
190
+ return s;
191
+ }
192
+
193
+ export class SkillSynthError extends Error {
194
+ constructor(message, code) {
195
+ super(message);
196
+ this.name = 'SkillSynthError';
197
+ this.code = code || 'SKILL_SYNTH_ERR';
198
+ }
199
+ }
200
+
201
+ // Run one synthesis LLM call for an agent that just finished a task and
202
+ // return { name, description, doc } — a complete SKILL.md ready to
203
+ // install — or null when the model produced nothing usable. Mirrors
204
+ // agent_memory.reflectOnce(): same provider adapters, a pure text
205
+ // completion with no tools advertised, the agent's own role as the
206
+ // system prompt. The difference is the ASK — a reusable skill in a
207
+ // fixed section layout instead of free-text lessons.
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
+ } = {}) {
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
+ }
221
+
222
+ const transcript = redactSecrets(
223
+ (Array.isArray(task.turns) ? task.turns : [])
224
+ .map((t) => {
225
+ const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
226
+ return `[${who}] ${neutralizeRoleLabels(t.text || '')}`;
227
+ })
228
+ .join('\n\n') || '(no turns)'
229
+ );
230
+
231
+ const userMessage = outcome === 'failed'
232
+ ? buildAntiPatternPrompt(task, transcript)
233
+ : buildSkillPrompt(task, transcript);
234
+
235
+ const text = (await runTextCompletion({
236
+ provider: agent.provider,
237
+ model: agent.model,
238
+ system: agent.role || '',
239
+ userMessage,
240
+ apiKey, baseUrl, fetchImpl,
241
+ })).trim();
242
+ if (!text || /^none\b/i.test(text)) return null;
243
+
244
+ const parsed = parseSynthOutput(text);
245
+ const description = sanitizeDescription(parsed.description);
246
+ const body = sanitizeSkillBody(parsed.body);
247
+ if (!body.trim()) return null;
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
+ );
293
+ }
294
+
295
+ // Install a synthesised skill without ever clobbering a human-authored
296
+ // one: reserveSynthName picks a collision-free target (or our own prior
297
+ // agent skill, which we then version-bump). Body/description are
298
+ // re-sanitised here too so a direct caller (CLI/router) can't bypass
299
+ // the redaction + cap. Returns { skill, path, version }.
300
+ export function installSynthesized({ name, description = '', body = '', sourceTask = '', createdBy = 'agent' } = {}, configDir, ts = new Date()) {
301
+ // Slugify BEFORE reserving the name so a direct caller (CLI/router)
302
+ // can't smuggle a newline/colon into the filename or inject a second
303
+ // frontmatter key. parseSynthOutput already slugifies, but this path
304
+ // is also reachable directly with an arbitrary name.
305
+ const finalName = skills.reserveSynthName(slugifySkill(name), configDir);
306
+ const overwritingOwn = skills.skillExists(finalName, configDir);
307
+ const version = overwritingOwn ? skills.skillVersion(finalName, configDir) + 1 : 1;
308
+ const doc = assembleSkillDoc({
309
+ name: finalName,
310
+ description: sanitizeDescription(description),
311
+ createdBy,
312
+ sourceTask,
313
+ body: sanitizeSkillBody(body),
314
+ version,
315
+ ts,
316
+ });
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 */ }
330
+ return { skill: finalName, path: p, version };
331
+ }
@@ -2,16 +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';
6
+ import * as registry from './tools/registry.mjs';
15
7
  import * as audit from './audit.mjs';
16
8
 
17
9
  export class ToolError extends Error {
@@ -22,63 +14,42 @@ export class ToolError extends Error {
22
14
  }
23
15
  }
24
16
 
25
- const TOOLS = {
26
- bash: bashTool,
27
- read: readTool,
28
- write: writeTool,
29
- grep: grepTool,
30
- };
31
-
32
- const NOT_IMPLEMENTED_TOOLS = ['web_search', 'web_fetch', 'slack_post'];
33
-
34
17
  export function listToolSchemas(names) {
35
18
  const out = [];
36
- const wanted = Array.isArray(names) && names.length ? names : Object.keys(TOOLS);
19
+ const wanted = Array.isArray(names) && names.length ? names : registry.listNames();
37
20
  for (const name of wanted) {
38
- const t = TOOLS[name];
21
+ const t = registry.lookup(name);
39
22
  if (!t) continue;
40
- out.push({ name: t.NAME, description: t.DESCRIPTION, parameters: t.PARAMETERS });
23
+ out.push({ name: t.name, description: t.description, parameters: t.parameters });
41
24
  }
42
25
  return out;
43
26
  }
44
27
 
45
- export function isImplemented(name) {
46
- return Boolean(TOOLS[name]);
47
- }
48
-
49
- export function knownTool(name) {
50
- return TOOLS[name] !== undefined || NOT_IMPLEMENTED_TOOLS.includes(name);
51
- }
28
+ export function isImplemented(name) { return registry.lookup(name) !== null; }
29
+ export function knownTool(name) { return registry.lookup(name) !== null; }
52
30
 
53
- // Run one tool call. The agent record's `tools` field is the whitelist;
54
- // when the call falls outside it, we throw ToolError('TOOL_DENIED') so
55
- // the caller can surface a structured error back to the LLM rather than
56
- // silently dropping the call.
57
- //
58
- // opts.cwd — where bash/read/write/grep root themselves; defaults to
59
- // process.cwd() so it can be overridden in tests.
60
- // opts.taskId — when set, every call is appended to the task's audit
61
- // log. Unit tests can omit it.
62
- export async function runTool({ agent, tool, args, taskId, configDir, cwd } = {}) {
31
+ export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve } = {}) {
63
32
  if (!agent || !Array.isArray(agent.tools)) {
64
33
  throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
65
34
  }
66
- if (!knownTool(tool)) {
67
- throw new ToolError(`unknown tool "${tool}"`, 'TOOL_UNKNOWN');
68
- }
35
+ const impl = registry.lookup(tool);
36
+ if (!impl) throw new ToolError(`unknown tool "${tool}"`, 'TOOL_UNKNOWN');
69
37
  if (!agent.tools.includes(tool)) {
70
38
  throw new ToolError(`agent "${agent.name}" is not allowed to call tool "${tool}" (whitelist=[${agent.tools.join(', ')}])`, 'TOOL_DENIED');
71
39
  }
72
- const impl = TOOLS[tool];
73
- if (!impl) {
74
- // Known but not yet implemented (web_search etc.) Phase 12+x will fill in.
75
- const result = { ok: false, error: `tool "${tool}" is registered but not implemented yet` };
76
- audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
77
- return result;
40
+ if (typeof approve === 'function' && impl.sensitive) {
41
+ let verdict;
42
+ try { verdict = await approve({ tool, args, agent: agent.name }); }
43
+ catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
44
+ if (!verdict || !verdict.approved) {
45
+ const result = { ok: false, error: `tool "${tool}" denied by operator${verdict?.reason ? `: ${verdict.reason}` : ''}`, code: 'TOOL_DENIED_APPROVAL' };
46
+ audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
47
+ return result;
48
+ }
78
49
  }
79
50
  let result;
80
51
  try {
81
- result = await impl.exec(args || {}, { cwd: cwd || process.cwd() });
52
+ result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir, taskId, agent });
82
53
  } catch (err) {
83
54
  result = { ok: false, error: `${tool} threw: ${err?.message || err}` };
84
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
+ };