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.
- package/README.ko.md +44 -0
- package/README.md +172 -353
- package/agents.mjs +19 -3
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +730 -27
- package/daemon.mjs +111 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +30 -1
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/mention_router.mjs +75 -4
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +331 -0
- package/mas/tool_runner.mjs +19 -48
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/skill_view.mjs +43 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +22 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
package/mas/tools/os.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// os — clipboard, screenshot, notify, open_url, file_dialog. macOS and
|
|
2
|
+
// linux paths implemented; everything else returns "unsupported".
|
|
3
|
+
// ctx.platform overrideable for tests.
|
|
4
|
+
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
function platformOf(ctx) { return ctx?.platform || process.platform; }
|
|
11
|
+
|
|
12
|
+
function runCmd(cmd, args, opts = {}) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const child = spawn(cmd, args, opts);
|
|
15
|
+
let out = '', err = '';
|
|
16
|
+
child.stdout?.on('data', d => out += d.toString());
|
|
17
|
+
child.stderr?.on('data', d => err += d.toString());
|
|
18
|
+
child.on('error', e => resolve({ ok: false, error: e.message }));
|
|
19
|
+
child.on('close', code => resolve({ ok: code === 0, stdout: out, stderr: err, exitCode: code }));
|
|
20
|
+
if (opts.stdin) { child.stdin.write(opts.stdin); child.stdin.end(); }
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const clipboard_read = {
|
|
25
|
+
name: 'clipboard_read', category: 'os', sensitive: true,
|
|
26
|
+
description: 'Read the OS clipboard (text).',
|
|
27
|
+
parameters: { type: 'object', properties: {} },
|
|
28
|
+
async exec(_args, ctx) {
|
|
29
|
+
const p = platformOf(ctx);
|
|
30
|
+
if (p === 'darwin') {
|
|
31
|
+
const r = spawnSync('pbpaste', [], { encoding: 'utf8' });
|
|
32
|
+
return r.status === 0 ? { ok: true, text: r.stdout } : { ok: false, error: r.stderr || 'pbpaste failed' };
|
|
33
|
+
}
|
|
34
|
+
if (p === 'linux') {
|
|
35
|
+
for (const [bin, args] of [['wl-paste', []], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]]) {
|
|
36
|
+
const r = spawnSync(bin, args, { encoding: 'utf8' });
|
|
37
|
+
if (r.status === 0) return { ok: true, text: r.stdout };
|
|
38
|
+
}
|
|
39
|
+
return { ok: false, error: 'clipboard_read: no clipboard helper (install wl-clipboard or xclip)' };
|
|
40
|
+
}
|
|
41
|
+
return { ok: false, error: `clipboard_read: unsupported platform ${p}` };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const clipboard_write = {
|
|
46
|
+
name: 'clipboard_write', category: 'os', sensitive: true,
|
|
47
|
+
description: 'Write text to the OS clipboard.',
|
|
48
|
+
parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
|
49
|
+
async exec(args, ctx) {
|
|
50
|
+
const p = platformOf(ctx);
|
|
51
|
+
if (p === 'darwin') return runCmd('pbcopy', [], { stdin: args.text });
|
|
52
|
+
if (p === 'linux') {
|
|
53
|
+
for (const [bin, ar] of [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]]) {
|
|
54
|
+
const r = await runCmd(bin, ar, { stdin: args.text });
|
|
55
|
+
if (r.ok) return { ok: true };
|
|
56
|
+
}
|
|
57
|
+
return { ok: false, error: 'clipboard_write: no clipboard helper' };
|
|
58
|
+
}
|
|
59
|
+
return { ok: false, error: `clipboard_write: unsupported platform ${p}` };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const screenshot = {
|
|
64
|
+
name: 'screenshot', category: 'os', sensitive: true,
|
|
65
|
+
description: 'Capture a screenshot and write it to a PNG path (defaults to a tmpfile). Returns {path}.',
|
|
66
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } } },
|
|
67
|
+
async exec(args, ctx) {
|
|
68
|
+
const p = platformOf(ctx);
|
|
69
|
+
const out = args?.path || path.join(os.tmpdir(), `lzc-${Date.now()}.png`);
|
|
70
|
+
if (p === 'darwin') {
|
|
71
|
+
const r = spawnSync('screencapture', ['-x', out]);
|
|
72
|
+
return r.status === 0 ? { ok: true, path: out } : { ok: false, error: 'screencapture failed' };
|
|
73
|
+
}
|
|
74
|
+
if (p === 'linux') {
|
|
75
|
+
for (const [bin, ar] of [['grim', [out]], ['gnome-screenshot', ['-f', out]], ['scrot', [out]]]) {
|
|
76
|
+
const r = spawnSync(bin, ar);
|
|
77
|
+
if (r.status === 0) return { ok: true, path: out };
|
|
78
|
+
}
|
|
79
|
+
return { ok: false, error: 'screenshot: no helper found (grim/gnome-screenshot/scrot)' };
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, error: `screenshot: unsupported platform ${p}` };
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const notify = {
|
|
86
|
+
name: 'notify', category: 'os', sensitive: false,
|
|
87
|
+
description: 'Post a desktop notification. Best-effort; failures do not surface to the user.',
|
|
88
|
+
parameters: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: { title: { type: 'string' }, body: { type: 'string' } },
|
|
91
|
+
required: ['title'],
|
|
92
|
+
},
|
|
93
|
+
async exec(args, ctx) {
|
|
94
|
+
const p = platformOf(ctx);
|
|
95
|
+
const title = args.title;
|
|
96
|
+
const body = args.body || '';
|
|
97
|
+
if (p === 'darwin') {
|
|
98
|
+
spawnSync('osascript', ['-e', `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`]);
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
if (p === 'linux') {
|
|
102
|
+
spawnSync('notify-send', [title, body]);
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
return { ok: false, error: `notify: unsupported platform ${p}` };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const open_url = {
|
|
110
|
+
name: 'open_url', category: 'os', sensitive: true,
|
|
111
|
+
description: 'Open a public URL in the default browser. http(s) only.',
|
|
112
|
+
parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
|
|
113
|
+
async exec(args, ctx) {
|
|
114
|
+
try {
|
|
115
|
+
const u = new URL(args.url);
|
|
116
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: 'open_url: http(s) only' };
|
|
117
|
+
} catch { return { ok: false, error: 'open_url: bad URL' }; }
|
|
118
|
+
const p = platformOf(ctx);
|
|
119
|
+
if (p === 'darwin') return runCmd('open', [args.url]);
|
|
120
|
+
if (p === 'linux') return runCmd('xdg-open', [args.url]);
|
|
121
|
+
return { ok: false, error: `open_url: unsupported platform ${p}` };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const file_dialog = {
|
|
126
|
+
name: 'file_dialog', category: 'os', sensitive: true,
|
|
127
|
+
description: 'Show an OS file picker. Returns selected path (or null on cancel).',
|
|
128
|
+
parameters: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: { kind: { type: 'string', enum: ['open', 'save'] }, prompt: { type: 'string' } },
|
|
131
|
+
},
|
|
132
|
+
async exec(args, ctx) {
|
|
133
|
+
const p = platformOf(ctx);
|
|
134
|
+
if (p === 'darwin') {
|
|
135
|
+
const script = (args.kind === 'save')
|
|
136
|
+
? 'POSIX path of (choose file name with prompt "' + (args.prompt || 'Save').replace(/"/g, '\\"') + '")'
|
|
137
|
+
: 'POSIX path of (choose file with prompt "' + (args.prompt || 'Choose').replace(/"/g, '\\"') + '")';
|
|
138
|
+
const r = spawnSync('osascript', ['-e', script], { encoding: 'utf8' });
|
|
139
|
+
if (r.status !== 0) return { ok: true, path: null };
|
|
140
|
+
return { ok: true, path: r.stdout.trim() };
|
|
141
|
+
}
|
|
142
|
+
if (p === 'linux') {
|
|
143
|
+
const ar = args.kind === 'save' ? ['--file-selection', '--save'] : ['--file-selection'];
|
|
144
|
+
const r = spawnSync('zenity', ar, { encoding: 'utf8' });
|
|
145
|
+
if (r.status !== 0) return { ok: true, path: null };
|
|
146
|
+
return { ok: true, path: r.stdout.trim() };
|
|
147
|
+
}
|
|
148
|
+
return { ok: false, error: `file_dialog: unsupported platform ${p}` };
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const TOOLS = [clipboard_read, clipboard_write, screenshot, notify, open_url, file_dialog];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// patch — apply a unified-diff to one or more files. Uses a strict parser:
|
|
2
|
+
// a hunk must match context lines exactly or the entire patch is rejected
|
|
3
|
+
// so partial application can't corrupt the workspace.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const TOOL = {
|
|
9
|
+
name: 'patch',
|
|
10
|
+
category: 'fs',
|
|
11
|
+
sensitive: true,
|
|
12
|
+
description: 'Apply a unified-diff patch (multi-file allowed). Fails atomically on any context mismatch.',
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: { diff: { type: 'string', description: 'Unified diff body.' } },
|
|
16
|
+
required: ['diff'],
|
|
17
|
+
},
|
|
18
|
+
async exec(args, { cwd = process.cwd() } = {}) {
|
|
19
|
+
if (!args || typeof args.diff !== 'string' || !args.diff.trim()) return { ok: false, error: 'patch: diff required' };
|
|
20
|
+
const files = parseUnifiedDiff(args.diff);
|
|
21
|
+
if (!files.length) return { ok: false, error: 'patch: no file hunks parsed' };
|
|
22
|
+
const stage = []; // {abs, content}
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const abs = path.resolve(cwd, file.path);
|
|
25
|
+
if (!abs.startsWith(path.resolve(cwd))) return { ok: false, error: `patch: path escapes workspace: ${file.path}` };
|
|
26
|
+
let src;
|
|
27
|
+
try { src = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : ''; }
|
|
28
|
+
catch (e) { return { ok: false, error: `patch: read ${file.path}: ${e.message}` }; }
|
|
29
|
+
const applied = applyHunks(src, file.hunks);
|
|
30
|
+
if (!applied.ok) return { ok: false, error: `patch: ${file.path}: ${applied.error}` };
|
|
31
|
+
stage.push({ abs, content: applied.content });
|
|
32
|
+
}
|
|
33
|
+
for (const s of stage) fs.writeFileSync(s.abs, s.content);
|
|
34
|
+
return { ok: true, filesWritten: stage.length };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function parseUnifiedDiff(diff) {
|
|
39
|
+
const lines = diff.split('\n');
|
|
40
|
+
const out = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < lines.length) {
|
|
43
|
+
if (lines[i].startsWith('--- ') && lines[i + 1]?.startsWith('+++ ')) {
|
|
44
|
+
const newPath = lines[i + 1].slice(4).replace(/^b\//, '').trim();
|
|
45
|
+
i += 2;
|
|
46
|
+
const hunks = [];
|
|
47
|
+
while (i < lines.length && lines[i].startsWith('@@')) {
|
|
48
|
+
const header = lines[i++];
|
|
49
|
+
const body = [];
|
|
50
|
+
while (i < lines.length && !lines[i].startsWith('@@') && !lines[i].startsWith('--- ')) {
|
|
51
|
+
body.push(lines[i++]);
|
|
52
|
+
}
|
|
53
|
+
hunks.push({ header, body });
|
|
54
|
+
}
|
|
55
|
+
out.push({ path: newPath, hunks });
|
|
56
|
+
} else {
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function applyHunks(src, hunks) {
|
|
64
|
+
let lines = src.split('\n');
|
|
65
|
+
let cursor = 0;
|
|
66
|
+
for (const hunk of hunks) {
|
|
67
|
+
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(hunk.header);
|
|
68
|
+
if (!m) return { ok: false, error: `bad hunk header: ${hunk.header}` };
|
|
69
|
+
let lineIdx = Math.max(0, parseInt(m[1], 10) - 1);
|
|
70
|
+
const out = lines.slice(0, lineIdx);
|
|
71
|
+
for (const b of hunk.body) {
|
|
72
|
+
if (b === '') continue;
|
|
73
|
+
const op = b[0];
|
|
74
|
+
const text = b.slice(1);
|
|
75
|
+
if (op === ' ') {
|
|
76
|
+
if ((lines[lineIdx] ?? '') !== text) return { ok: false, error: `context mismatch at line ${lineIdx + 1}` };
|
|
77
|
+
out.push(text); lineIdx++;
|
|
78
|
+
} else if (op === '-') {
|
|
79
|
+
if ((lines[lineIdx] ?? '') !== text) return { ok: false, error: `delete mismatch at line ${lineIdx + 1}` };
|
|
80
|
+
lineIdx++;
|
|
81
|
+
} else if (op === '+') {
|
|
82
|
+
out.push(text);
|
|
83
|
+
} else if (op === '\\') {
|
|
84
|
+
// — ignore
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
lines = out.concat(lines.slice(lineIdx));
|
|
88
|
+
cursor = lineIdx;
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, content: lines.join('\n') };
|
|
91
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// recall tool — Phase B (v5 §4.5).
|
|
2
|
+
//
|
|
3
|
+
// FTS5-backed cross-scope recall. Reads from mas/index_db.mjs (the
|
|
4
|
+
// SQLite mirror populated by Phase A's write-through hooks).
|
|
5
|
+
//
|
|
6
|
+
// Args:
|
|
7
|
+
// query: required string
|
|
8
|
+
// scope: optional array of 'sessions'|'skills'|'trajectories'|'memories'
|
|
9
|
+
// (default: all four)
|
|
10
|
+
// k: optional integer, default 10, hard-capped at 50
|
|
11
|
+
// summarize: optional boolean (v5.1+ wires the trainer; v5.0 leaves
|
|
12
|
+
// summary null when set, so the agent gets raw hits.)
|
|
13
|
+
// filter: optional object of UNINDEXED column equality filters
|
|
14
|
+
// (session_id, agent, outcome, trained_by, group_name, kind, since)
|
|
15
|
+
|
|
16
|
+
import { openIndex, recall as indexRecall } from '../index_db.mjs';
|
|
17
|
+
|
|
18
|
+
export const NAME = 'recall';
|
|
19
|
+
export const DESCRIPTION =
|
|
20
|
+
'Search prior sessions, skills, trajectories, and memories by FTS5 query. Returns ranked snippets with metadata. Use this BEFORE asking the user to repeat themselves or before solving a problem from scratch.';
|
|
21
|
+
export const PARAMETERS = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
query: { type: 'string', description: 'FTS5 MATCH query. Plain words are AND-ed.' },
|
|
25
|
+
scope: { type: 'array', items: { type: 'string', enum: ['sessions', 'skills', 'trajectories', 'memories'] } },
|
|
26
|
+
k: { type: 'integer', minimum: 1, maximum: 50 },
|
|
27
|
+
summarize: { type: 'boolean' },
|
|
28
|
+
filter: { type: 'object', additionalProperties: true },
|
|
29
|
+
},
|
|
30
|
+
required: ['query'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DEFAULT_SCOPES = ['sessions', 'skills', 'trajectories', 'memories'];
|
|
34
|
+
const MAX_K = 50;
|
|
35
|
+
|
|
36
|
+
let _stubRecall = null;
|
|
37
|
+
export function __setRecall(fn) { _stubRecall = typeof fn === 'function' ? fn : null; }
|
|
38
|
+
|
|
39
|
+
export async function exec(args, { configDir } = {}) {
|
|
40
|
+
if (!args || typeof args.query !== 'string' || !args.query.trim()) {
|
|
41
|
+
return { ok: false, error: 'recall: query is required' };
|
|
42
|
+
}
|
|
43
|
+
const query = args.query.trim();
|
|
44
|
+
const scopes = Array.isArray(args.scope) && args.scope.length ? args.scope : DEFAULT_SCOPES;
|
|
45
|
+
const k = Math.max(1, Math.min(MAX_K, Number(args.k) || 10));
|
|
46
|
+
const filter = args.filter && typeof args.filter === 'object' ? args.filter : {};
|
|
47
|
+
const t0 = Date.now();
|
|
48
|
+
|
|
49
|
+
let out;
|
|
50
|
+
if (_stubRecall) {
|
|
51
|
+
try {
|
|
52
|
+
out = await _stubRecall(query, { scope: scopes, k });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { ok: false, error: `recall: stub threw — ${err?.message || err}` };
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
try {
|
|
58
|
+
openIndex(configDir);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return { ok: false, error: `recall: openIndex failed — ${err?.message || err}` };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
out = indexRecall(query, { configDir, scope: scopes, k });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { ok: false, error: `recall: query failed — ${err?.message || err}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Apply optional UNINDEXED filter (metadata-level equality / since predicate).
|
|
70
|
+
let hits = Array.isArray(out.hits) ? out.hits : [];
|
|
71
|
+
const filterKeys = Object.keys(filter);
|
|
72
|
+
if (filterKeys.length) {
|
|
73
|
+
hits = hits.filter((h) => {
|
|
74
|
+
const meta = h.metadata || {};
|
|
75
|
+
for (const key of filterKeys) {
|
|
76
|
+
if (key === 'since') {
|
|
77
|
+
if (Number(meta.ts || 0) < Number(filter.since)) return false;
|
|
78
|
+
} else if (String(meta[key] ?? '') !== String(filter[key])) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
query,
|
|
89
|
+
hits: hits.slice(0, k),
|
|
90
|
+
summary: null, // v5.0: raw hits only; v5.1 wires trainer.
|
|
91
|
+
summarizedBy: null,
|
|
92
|
+
latencyMs: Date.now() - t0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const TOOL = {
|
|
97
|
+
name: NAME,
|
|
98
|
+
category: 'learning',
|
|
99
|
+
sensitive: false,
|
|
100
|
+
description: DESCRIPTION,
|
|
101
|
+
parameters: PARAMETERS,
|
|
102
|
+
exec,
|
|
103
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Tool registry — aggregates every first-party tool group plus any MCP-imported
|
|
2
|
+
// tools so callers (tool_runner, splash renderer, agent toolset resolver) can
|
|
3
|
+
// ask for them by name without knowing which file they live in.
|
|
4
|
+
//
|
|
5
|
+
// Each tool record: {name, category, sensitive, description, parameters, exec}
|
|
6
|
+
// - name: unique key (mcp tools use "mcp:<server>:<tool>")
|
|
7
|
+
// - category: 'exec' | 'fs' | 'net' | 'data' | 'agents' | 'learning' | ...
|
|
8
|
+
// - sensitive: when true, tool_runner requires `approve` hook before exec
|
|
9
|
+
// - parameters: JSON-Schema object (same shape as Phase 12a)
|
|
10
|
+
// - exec(args, ctx) -> {ok, ...}
|
|
11
|
+
|
|
12
|
+
import * as bashTool from './bash.mjs';
|
|
13
|
+
import * as readTool from './read.mjs';
|
|
14
|
+
import * as writeTool from './write.mjs';
|
|
15
|
+
import * as grepTool from './grep.mjs';
|
|
16
|
+
|
|
17
|
+
function adaptLegacy(mod, { category, sensitive }) {
|
|
18
|
+
return {
|
|
19
|
+
name: mod.NAME,
|
|
20
|
+
category,
|
|
21
|
+
sensitive,
|
|
22
|
+
description: mod.DESCRIPTION,
|
|
23
|
+
parameters: mod.PARAMETERS,
|
|
24
|
+
exec: mod.exec,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Built-in (Phase 12a) tools, adapted to v5 shape.
|
|
29
|
+
const BUILTINS = [
|
|
30
|
+
adaptLegacy(bashTool, { category: 'exec', sensitive: true }),
|
|
31
|
+
adaptLegacy(readTool, { category: 'fs', sensitive: false }),
|
|
32
|
+
adaptLegacy(writeTool, { category: 'fs', sensitive: true }),
|
|
33
|
+
adaptLegacy(grepTool, { category: 'fs', sensitive: false }),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
import { TOOL as editTool } from './edit.mjs';
|
|
37
|
+
import { TOOL as patchTool } from './patch.mjs';
|
|
38
|
+
import { TOOL as recallTool } from './recall.mjs';
|
|
39
|
+
import { TOOLS as learningTools } from './learning.mjs';
|
|
40
|
+
import { TOOLS as webTools } from './web.mjs';
|
|
41
|
+
import { TOOLS as osTools } from './os.mjs';
|
|
42
|
+
import { TOOLS as codingTools } from './coding.mjs';
|
|
43
|
+
import { TOOLS as gitGroupTools } from './git.mjs';
|
|
44
|
+
import { TOOLS as schedTools } from './scheduling.mjs';
|
|
45
|
+
import { TOOLS as delTools } from './delegation.mjs';
|
|
46
|
+
import { TOOLS as mediaTools } from './media.mjs';
|
|
47
|
+
import { TOOLS as haTools } from './ha.mjs';
|
|
48
|
+
import { TOOL as clarifyTool } from './clarify.mjs';
|
|
49
|
+
import { TOOLS as browserTools } from './browser.mjs';
|
|
50
|
+
|
|
51
|
+
BUILTINS.push(editTool, patchTool);
|
|
52
|
+
BUILTINS.push(recallTool);
|
|
53
|
+
for (const t of learningTools) BUILTINS.push(t);
|
|
54
|
+
for (const t of webTools) BUILTINS.push(t);
|
|
55
|
+
for (const t of osTools) BUILTINS.push(t);
|
|
56
|
+
for (const t of codingTools) BUILTINS.push(t);
|
|
57
|
+
for (const t of gitGroupTools) BUILTINS.push(t);
|
|
58
|
+
for (const t of schedTools) BUILTINS.push(t);
|
|
59
|
+
for (const t of delTools) BUILTINS.push(t);
|
|
60
|
+
for (const t of mediaTools) BUILTINS.push(t);
|
|
61
|
+
for (const t of haTools) BUILTINS.push(t);
|
|
62
|
+
BUILTINS.push(clarifyTool);
|
|
63
|
+
for (const t of browserTools) BUILTINS.push(t);
|
|
64
|
+
|
|
65
|
+
// Mutable; new groups (Tasks 2-14) push here; MCP client (Task 15) also pushes.
|
|
66
|
+
const TOOLS = new Map();
|
|
67
|
+
for (const t of BUILTINS) TOOLS.set(t.name, t);
|
|
68
|
+
|
|
69
|
+
export function register(tool) {
|
|
70
|
+
if (!tool || typeof tool.name !== 'string') throw new Error('registry.register: tool.name required');
|
|
71
|
+
if (typeof tool.exec !== 'function') throw new Error(`registry.register(${tool.name}): exec required`);
|
|
72
|
+
if (typeof tool.sensitive !== 'boolean') throw new Error(`registry.register(${tool.name}): sensitive required`);
|
|
73
|
+
TOOLS.set(tool.name, tool);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerGroup(group) {
|
|
77
|
+
if (!Array.isArray(group)) throw new Error('registry.registerGroup: array required');
|
|
78
|
+
for (const t of group) register(t);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function unregister(name) { return TOOLS.delete(name); }
|
|
82
|
+
|
|
83
|
+
export function lookup(name) { return TOOLS.get(name) || null; }
|
|
84
|
+
|
|
85
|
+
export function listAll() { return [...TOOLS.values()]; }
|
|
86
|
+
|
|
87
|
+
export function listNames() { return [...TOOLS.keys()]; }
|
|
88
|
+
|
|
89
|
+
export function byCategory() {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const t of TOOLS.values()) (out[t.category] ||= []).push(t);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// scheduling — cron_add / cron_remove / cron_list. Wraps cron.mjs but the
|
|
2
|
+
// backend is overridable for tests via __setCronBackend.
|
|
3
|
+
|
|
4
|
+
let _backend = null;
|
|
5
|
+
export function __setCronBackend(b) { _backend = b; }
|
|
6
|
+
|
|
7
|
+
async function getBackend() {
|
|
8
|
+
if (_backend) return _backend;
|
|
9
|
+
const cron = await import('../../cron.mjs').catch(() => null);
|
|
10
|
+
if (!cron) throw new Error('scheduling: cron.mjs not available');
|
|
11
|
+
return {
|
|
12
|
+
add: async (j) => cron.add ? cron.add(j) : { ok: false, error: 'cron.add missing' },
|
|
13
|
+
list: async () => cron.list ? cron.list() : [],
|
|
14
|
+
remove: async (n) => cron.remove ? cron.remove(n) : { ok: false, error: 'cron.remove missing' },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Field-count validator independent of cron.mjs internals so we get a clean error.
|
|
19
|
+
function looksLikeCronSpec(s) {
|
|
20
|
+
return typeof s === 'string' && s.trim().split(/\s+/).length === 5;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cron_add = {
|
|
24
|
+
name: 'cron_add', category: 'scheduling', sensitive: true,
|
|
25
|
+
description: 'Schedule a recurring agent run or shell command.',
|
|
26
|
+
parameters: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
name: { type: 'string' },
|
|
30
|
+
spec: { type: 'string', description: '5-field cron spec.' },
|
|
31
|
+
command: { type: 'string' },
|
|
32
|
+
},
|
|
33
|
+
required: ['name', 'spec', 'command'],
|
|
34
|
+
},
|
|
35
|
+
async exec(args) {
|
|
36
|
+
if (!looksLikeCronSpec(args.spec)) return { ok: false, error: `cron_add: bad cron spec "${args.spec}"` };
|
|
37
|
+
const b = await getBackend();
|
|
38
|
+
return b.add({ name: args.name, spec: args.spec, command: args.command });
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const cron_remove = {
|
|
43
|
+
name: 'cron_remove', category: 'scheduling', sensitive: true,
|
|
44
|
+
description: 'Remove a scheduled job by name.',
|
|
45
|
+
parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
46
|
+
async exec(args) {
|
|
47
|
+
const b = await getBackend();
|
|
48
|
+
return b.remove(args.name);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const cron_list = {
|
|
53
|
+
name: 'cron_list', category: 'scheduling', sensitive: true,
|
|
54
|
+
description: 'List scheduled jobs.',
|
|
55
|
+
parameters: { type: 'object', properties: {} },
|
|
56
|
+
async exec() {
|
|
57
|
+
const b = await getBackend();
|
|
58
|
+
return { ok: true, jobs: await b.list() };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const TOOLS = [cron_add, cron_remove, cron_list];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// skill_view tool — Phase 20.
|
|
2
|
+
//
|
|
3
|
+
// Read-only progressive-disclosure loader: the mention-router injects a
|
|
4
|
+
// compact skills *index* (name + one-line summary) into the system
|
|
5
|
+
// prompt, and the agent calls skill_view to pull the FULL text of a
|
|
6
|
+
// skill only when it decides one is relevant. This keeps skill bodies
|
|
7
|
+
// out of the prompt until they're actually needed.
|
|
8
|
+
//
|
|
9
|
+
// Unlike bash/read/write/grep this tool is rooted at the lazyclaw
|
|
10
|
+
// config dir (where skills live), not the task cwd, so it takes
|
|
11
|
+
// configDir from the tool-runner opts rather than cwd.
|
|
12
|
+
|
|
13
|
+
import * as skills from '../../skills.mjs';
|
|
14
|
+
|
|
15
|
+
export const NAME = 'skill_view';
|
|
16
|
+
export const DESCRIPTION =
|
|
17
|
+
'Load the full text of a named skill from the skills index (its When-to-Use, Procedure, Pitfalls and Verification sections). Call this when a skill listed in the index looks relevant to the current task.';
|
|
18
|
+
export const PARAMETERS = {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
name: { type: 'string', description: 'The skill name exactly as shown in the skills index.' },
|
|
22
|
+
},
|
|
23
|
+
required: ['name'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function exec(args, { configDir } = {}) {
|
|
27
|
+
if (!args || typeof args.name !== 'string' || !args.name.trim()) {
|
|
28
|
+
return { ok: false, error: 'skill_view: name is required' };
|
|
29
|
+
}
|
|
30
|
+
const name = args.name.trim();
|
|
31
|
+
try {
|
|
32
|
+
const content = skills.loadSkill(name, configDir);
|
|
33
|
+
// Record the recall so the curator can age out never-used skills.
|
|
34
|
+
// Best-effort: a usage-write hiccup must never fail the tool call.
|
|
35
|
+
try {
|
|
36
|
+
const curator = await import('../../skills_curator.mjs');
|
|
37
|
+
curator.recordUsage(name, configDir, Date.now());
|
|
38
|
+
} catch { /* non-fatal */ }
|
|
39
|
+
return { ok: true, name, content };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { ok: false, error: `skill_view: ${err?.message || err}` };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// web — web_fetch (undici, SSRF block), web_search (Brave/Tavily/SerpAPI when
|
|
2
|
+
// an API key env var is set), url_extract (extract links from HTML).
|
|
3
|
+
// SSRF policy: reject loopback, RFC1918 private, link-local, file:, ftp:,
|
|
4
|
+
// and any non-http(s) scheme.
|
|
5
|
+
|
|
6
|
+
import { fetch } from 'undici';
|
|
7
|
+
import dns from 'node:dns/promises';
|
|
8
|
+
|
|
9
|
+
const PRIVATE_V4 = [
|
|
10
|
+
/^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[0-1])\./,
|
|
11
|
+
/^127\./, /^169\.254\./, /^0\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
async function isSafeUrl(url) {
|
|
15
|
+
let u;
|
|
16
|
+
try { u = new URL(url); } catch { return { ok: false, error: 'bad URL' }; }
|
|
17
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: `scheme ${u.protocol} blocked` };
|
|
18
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
19
|
+
if (host === 'localhost' || host === '0.0.0.0') return { ok: false, error: 'loopback blocked (SSRF)' };
|
|
20
|
+
if (PRIVATE_V4.some(re => re.test(host))) return { ok: false, error: 'private address blocked (SSRF)' };
|
|
21
|
+
if (host.includes(':')) return { ok: false, error: 'IPv6 disabled' };
|
|
22
|
+
if (!/^[a-z0-9.-]+$/i.test(host)) return { ok: false, error: 'bad host' };
|
|
23
|
+
try {
|
|
24
|
+
const addrs = await dns.lookup(host, { all: true });
|
|
25
|
+
for (const a of addrs) {
|
|
26
|
+
if (PRIVATE_V4.some(re => re.test(a.address))) return { ok: false, error: 'resolves to private address (SSRF)' };
|
|
27
|
+
if (a.address === '127.0.0.1' || a.address === '::1') return { ok: false, error: 'resolves to loopback (SSRF)' };
|
|
28
|
+
}
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return { ok: false, error: `dns: ${e.message}` };
|
|
31
|
+
}
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const web_fetch = {
|
|
36
|
+
name: 'web_fetch', category: 'net', sensitive: true,
|
|
37
|
+
description: 'Fetch a public URL. Loopback / private / non-http(s) URLs are blocked.',
|
|
38
|
+
parameters: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
url: { type: 'string' },
|
|
42
|
+
method: { type: 'string', enum: ['GET', 'POST'] },
|
|
43
|
+
headers: { type: 'object' },
|
|
44
|
+
body: { type: 'string' },
|
|
45
|
+
maxBytes:{ type: 'number' },
|
|
46
|
+
},
|
|
47
|
+
required: ['url'],
|
|
48
|
+
},
|
|
49
|
+
async exec(args) {
|
|
50
|
+
const safe = await isSafeUrl(args.url);
|
|
51
|
+
if (!safe.ok) return { ok: false, error: `web_fetch: ${safe.error}` };
|
|
52
|
+
const maxBytes = Math.min(args.maxBytes || 2_000_000, 5_000_000);
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(args.url, {
|
|
55
|
+
method: args.method || 'GET',
|
|
56
|
+
headers: args.headers || {},
|
|
57
|
+
body: args.body,
|
|
58
|
+
redirect: 'follow',
|
|
59
|
+
});
|
|
60
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
61
|
+
const truncated = buf.length > maxBytes;
|
|
62
|
+
return {
|
|
63
|
+
ok: true, status: res.status,
|
|
64
|
+
headers: Object.fromEntries(res.headers),
|
|
65
|
+
body: buf.slice(0, maxBytes).toString('utf8'),
|
|
66
|
+
truncated,
|
|
67
|
+
};
|
|
68
|
+
} catch (e) { return { ok: false, error: `web_fetch: ${e.message}` }; }
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const web_search = {
|
|
73
|
+
name: 'web_search', category: 'net', sensitive: false,
|
|
74
|
+
description: 'Search the public web via Brave (BRAVE_API_KEY), Tavily (TAVILY_API_KEY), or SerpAPI (SERPAPI_API_KEY).',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: { query: { type: 'string' }, k: { type: 'number' } },
|
|
78
|
+
required: ['query'],
|
|
79
|
+
},
|
|
80
|
+
async exec(args, ctx) {
|
|
81
|
+
const env = ctx?.env || process.env;
|
|
82
|
+
if (env.BRAVE_API_KEY) return braveSearch(args, env.BRAVE_API_KEY);
|
|
83
|
+
if (env.TAVILY_API_KEY) return tavilySearch(args, env.TAVILY_API_KEY);
|
|
84
|
+
if (env.SERPAPI_API_KEY) return serpApiSearch(args, env.SERPAPI_API_KEY);
|
|
85
|
+
return { ok: false, error: 'web_search: no provider configured (set BRAVE_API_KEY / TAVILY_API_KEY / SERPAPI_API_KEY)' };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
async function braveSearch({ query, k = 5 }, key) {
|
|
90
|
+
try {
|
|
91
|
+
const r = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${k}`, {
|
|
92
|
+
headers: { 'X-Subscription-Token': key, 'Accept': 'application/json' },
|
|
93
|
+
});
|
|
94
|
+
const j = await r.json();
|
|
95
|
+
return { ok: true, results: (j?.web?.results || []).slice(0, k).map(x => ({ title: x.title, url: x.url, snippet: x.description })) };
|
|
96
|
+
} catch (e) { return { ok: false, error: `brave: ${e.message}` }; }
|
|
97
|
+
}
|
|
98
|
+
async function tavilySearch({ query, k = 5 }, key) {
|
|
99
|
+
try {
|
|
100
|
+
const r = await fetch('https://api.tavily.com/search', {
|
|
101
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ api_key: key, query, max_results: k }),
|
|
103
|
+
});
|
|
104
|
+
const j = await r.json();
|
|
105
|
+
return { ok: true, results: (j?.results || []).slice(0, k).map(x => ({ title: x.title, url: x.url, snippet: x.content })) };
|
|
106
|
+
} catch (e) { return { ok: false, error: `tavily: ${e.message}` }; }
|
|
107
|
+
}
|
|
108
|
+
async function serpApiSearch({ query, k = 5 }, key) {
|
|
109
|
+
try {
|
|
110
|
+
const r = await fetch(`https://serpapi.com/search.json?q=${encodeURIComponent(query)}&num=${k}&api_key=${key}`);
|
|
111
|
+
const j = await r.json();
|
|
112
|
+
return { ok: true, results: (j?.organic_results || []).slice(0, k).map(x => ({ title: x.title, url: x.link, snippet: x.snippet })) };
|
|
113
|
+
} catch (e) { return { ok: false, error: `serpapi: ${e.message}` }; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const url_extract = {
|
|
117
|
+
name: 'url_extract', category: 'net', sensitive: false,
|
|
118
|
+
description: 'Extract all href URLs from an HTML string.',
|
|
119
|
+
parameters: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: { html: { type: 'string' }, base: { type: 'string' } },
|
|
122
|
+
required: ['html'],
|
|
123
|
+
},
|
|
124
|
+
async exec(args) {
|
|
125
|
+
const urls = new Set();
|
|
126
|
+
const re = /href\s*=\s*["']([^"']+)["']/gi;
|
|
127
|
+
let m;
|
|
128
|
+
while ((m = re.exec(args.html))) {
|
|
129
|
+
try {
|
|
130
|
+
urls.add(args.base ? new URL(m[1], args.base).toString() : m[1]);
|
|
131
|
+
} catch { urls.add(m[1]); }
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, urls: [...urls] };
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const TOOLS = [web_fetch, web_search, url_extract];
|