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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// git — read-only inspection (status/diff/log/blame/branch) and two
|
|
2
|
+
// sensitive writes (commit/push). All shell out to git in ctx.cwd.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
function git(cwd, args, opts = {}) {
|
|
7
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
|
|
8
|
+
return { ok: r.status === 0, stdout: r.stdout, stderr: r.stderr, exitCode: r.status };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const git_status = {
|
|
12
|
+
name: 'git_status', category: 'git', sensitive: false,
|
|
13
|
+
description: 'Run `git status` in the workspace.',
|
|
14
|
+
parameters: { type: 'object', properties: {} },
|
|
15
|
+
async exec(_args, ctx) { return git(ctx?.cwd, ['status']); },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const git_diff = {
|
|
19
|
+
name: 'git_diff', category: 'git', sensitive: false,
|
|
20
|
+
description: 'Run `git diff [path]`. Pass {staged:true} for `--staged`.',
|
|
21
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, staged: { type: 'boolean' } } },
|
|
22
|
+
async exec(args, ctx) {
|
|
23
|
+
const ar = ['diff'];
|
|
24
|
+
if (args?.staged) ar.push('--staged');
|
|
25
|
+
if (args?.path) ar.push('--', args.path);
|
|
26
|
+
return git(ctx?.cwd, ar);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const git_log = {
|
|
31
|
+
name: 'git_log', category: 'git', sensitive: false,
|
|
32
|
+
description: 'Recent commits as structured objects.',
|
|
33
|
+
parameters: { type: 'object', properties: { limit: { type: 'number' } } },
|
|
34
|
+
async exec(args, ctx) {
|
|
35
|
+
const n = Math.max(1, Math.min(args?.limit || 10, 100));
|
|
36
|
+
const r = git(ctx?.cwd, ['log', `-n${n}`, '--pretty=format:%H%x09%an%x09%aI%x09%s']);
|
|
37
|
+
if (!r.ok) return r;
|
|
38
|
+
const commits = r.stdout.trim().split('\n').filter(Boolean).map(line => {
|
|
39
|
+
const [hash, author, date, subject] = line.split('\t');
|
|
40
|
+
return { hash, author, date, subject };
|
|
41
|
+
});
|
|
42
|
+
return { ok: true, commits };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const git_blame = {
|
|
47
|
+
name: 'git_blame', category: 'git', sensitive: false,
|
|
48
|
+
description: 'Run `git blame <path>`.',
|
|
49
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
50
|
+
async exec(args, ctx) { return git(ctx?.cwd, ['blame', '--', args.path]); },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const git_branch = {
|
|
54
|
+
name: 'git_branch', category: 'git', sensitive: false,
|
|
55
|
+
description: 'List branches.',
|
|
56
|
+
parameters: { type: 'object', properties: {} },
|
|
57
|
+
async exec(_args, ctx) {
|
|
58
|
+
const r = git(ctx?.cwd, ['branch', '--all', '--format=%(refname:short)%09%(upstream:short)%09%(HEAD)']);
|
|
59
|
+
if (!r.ok) return r;
|
|
60
|
+
const branches = r.stdout.trim().split('\n').filter(Boolean).map(line => {
|
|
61
|
+
const [name, upstream, head] = line.split('\t');
|
|
62
|
+
return { name, upstream, current: head === '*' };
|
|
63
|
+
});
|
|
64
|
+
return { ok: true, branches };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const git_commit = {
|
|
69
|
+
name: 'git_commit', category: 'git', sensitive: true,
|
|
70
|
+
description: 'Stage paths (or skip when omitted) and commit.',
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
message: { type: 'string' },
|
|
75
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
76
|
+
amend: { type: 'boolean' },
|
|
77
|
+
},
|
|
78
|
+
required: ['message'],
|
|
79
|
+
},
|
|
80
|
+
async exec(args, ctx) {
|
|
81
|
+
if (Array.isArray(args.paths) && args.paths.length) {
|
|
82
|
+
const a = git(ctx?.cwd, ['add', '--', ...args.paths]);
|
|
83
|
+
if (!a.ok) return a;
|
|
84
|
+
}
|
|
85
|
+
const ar = ['commit', '-m', args.message];
|
|
86
|
+
if (args.amend) ar.splice(1, 0, '--amend');
|
|
87
|
+
return git(ctx?.cwd, ar);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const git_push = {
|
|
92
|
+
name: 'git_push', category: 'git', sensitive: true,
|
|
93
|
+
description: 'Push to a remote. Refuses --force unless force=true explicitly.',
|
|
94
|
+
parameters: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
remote: { type: 'string' }, branch: { type: 'string' },
|
|
98
|
+
force: { type: 'boolean' },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
async exec(args, ctx) {
|
|
102
|
+
const ar = ['push'];
|
|
103
|
+
if (args?.force) ar.push('--force-with-lease');
|
|
104
|
+
if (args?.remote) ar.push(args.remote);
|
|
105
|
+
if (args?.branch) ar.push(args.branch);
|
|
106
|
+
return git(ctx?.cwd, ar);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const TOOLS = [git_status, git_diff, git_log, git_blame, git_branch, git_commit, git_push];
|
package/mas/tools/ha.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Home Assistant tools — STUB only in v5.0; activated in v5.1 per spec §0.2.
|
|
2
|
+
// Registered so the catalogue lists them and config / toolset definitions
|
|
3
|
+
// can reference them, but exec() returns a clear deferred message.
|
|
4
|
+
|
|
5
|
+
function deferred(name) {
|
|
6
|
+
return async () => ({ ok: false, error: `${name}: Home Assistant tools deferred to v5.1 (spec §0.2)` });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ha_call_service = {
|
|
10
|
+
name: 'ha_call_service', category: 'iot', sensitive: true,
|
|
11
|
+
description: 'STUB — Home Assistant service call deferred to v5.1.',
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
domain: { type: 'string' }, service: { type: 'string' },
|
|
16
|
+
data: { type: 'object' },
|
|
17
|
+
},
|
|
18
|
+
required: ['domain', 'service'],
|
|
19
|
+
},
|
|
20
|
+
exec: deferred('ha_call_service'),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ha_get_state = {
|
|
24
|
+
name: 'ha_get_state', category: 'iot', sensitive: true,
|
|
25
|
+
description: 'STUB — Home Assistant state read deferred to v5.1.',
|
|
26
|
+
parameters: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: { entity_id: { type: 'string' } },
|
|
29
|
+
required: ['entity_id'],
|
|
30
|
+
},
|
|
31
|
+
exec: deferred('ha_get_state'),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const TOOLS = [ha_call_service, ha_get_state];
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// learning — agent tools that read/write the skill bank, layered memory,
|
|
2
|
+
// and the persistent USER.md (Honcho-equivalent, spec §1.6, §4.10).
|
|
3
|
+
// USER.md path canonical (C6) = <configDir>/memory/USER.md.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
function resolveConfigDir(ctx) {
|
|
9
|
+
return ctx?.configDir || process.env.LAZYCLAW_CONFIG_DIR || path.join(process.env.HOME || '.', '.lazyclaw');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function skillsDir(ctx) { return path.join(resolveConfigDir(ctx), 'skills'); }
|
|
13
|
+
function memoryDir(ctx) { return path.join(resolveConfigDir(ctx), 'memory'); }
|
|
14
|
+
function userMdPath(ctx) { return path.join(memoryDir(ctx), 'USER.md'); }
|
|
15
|
+
|
|
16
|
+
const skill_view = {
|
|
17
|
+
name: 'skill_view', category: 'learning', sensitive: false,
|
|
18
|
+
description: 'Return the body of an installed skill (by name).',
|
|
19
|
+
parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
20
|
+
async exec(args, ctx) {
|
|
21
|
+
if (!args?.name) return { ok: false, error: 'skill_view: name required' };
|
|
22
|
+
const file = path.join(skillsDir(ctx), args.name, 'SKILL.md');
|
|
23
|
+
if (!fs.existsSync(file)) return { ok: false, error: `skill_view: ${args.name} not installed` };
|
|
24
|
+
return { ok: true, name: args.name, content: fs.readFileSync(file, 'utf8') };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const skill_create = {
|
|
29
|
+
name: 'skill_create', category: 'learning', sensitive: true,
|
|
30
|
+
description: 'Create a new skill at <configDir>/skills/<name>/SKILL.md. Fails if already exists.',
|
|
31
|
+
parameters: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
name: { type: 'string' }, body: { type: 'string' },
|
|
35
|
+
description: { type: 'string' }, group: { type: 'string' },
|
|
36
|
+
},
|
|
37
|
+
required: ['name', 'body'],
|
|
38
|
+
},
|
|
39
|
+
async exec(args, ctx) {
|
|
40
|
+
if (!args?.name || !args?.body) return { ok: false, error: 'skill_create: name + body required' };
|
|
41
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(args.name)) return { ok: false, error: 'skill_create: kebab-case name only' };
|
|
42
|
+
const dir = path.join(skillsDir(ctx), args.name);
|
|
43
|
+
const file = path.join(dir, 'SKILL.md');
|
|
44
|
+
if (fs.existsSync(file)) return { ok: false, error: `skill_create: ${args.name} already exists; use skill_edit` };
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
const fm = [
|
|
47
|
+
'---',
|
|
48
|
+
`name: ${args.name}`,
|
|
49
|
+
`description: ${args.description || args.body.split('\n')[0].slice(0, 200)}`,
|
|
50
|
+
`group: ${args.group || (args.name.includes('-') ? args.name.split('-')[0] : 'legacy')}`,
|
|
51
|
+
'version: 1',
|
|
52
|
+
'trained_by: user',
|
|
53
|
+
`created_at: ${new Date().toISOString().slice(0, 10)}`,
|
|
54
|
+
'---',
|
|
55
|
+
'',
|
|
56
|
+
].join('\n');
|
|
57
|
+
fs.writeFileSync(file, fm + args.body + (args.body.endsWith('\n') ? '' : '\n'));
|
|
58
|
+
return { ok: true, name: args.name, file };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const skill_edit = {
|
|
63
|
+
name: 'skill_edit', category: 'learning', sensitive: true,
|
|
64
|
+
description: 'Replace the body of an existing skill. Preserves frontmatter, bumps version.',
|
|
65
|
+
parameters: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: { name: { type: 'string' }, body: { type: 'string' } },
|
|
68
|
+
required: ['name', 'body'],
|
|
69
|
+
},
|
|
70
|
+
async exec(args, ctx) {
|
|
71
|
+
const file = path.join(skillsDir(ctx), args.name, 'SKILL.md');
|
|
72
|
+
if (!fs.existsSync(file)) return { ok: false, error: `skill_edit: ${args.name} not installed` };
|
|
73
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
74
|
+
const m = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(src);
|
|
75
|
+
if (!m) return { ok: false, error: 'skill_edit: missing frontmatter' };
|
|
76
|
+
let fm = m[1];
|
|
77
|
+
fm = fm.replace(/version:\s*(\d+)/, (_, v) => `version: ${Number(v) + 1}`);
|
|
78
|
+
fs.writeFileSync(file, `---\n${fm}\n---\n${args.body}${args.body.endsWith('\n') ? '' : '\n'}`);
|
|
79
|
+
return { ok: true, name: args.name };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const memory_write = {
|
|
84
|
+
name: 'memory_write', category: 'learning', sensitive: true,
|
|
85
|
+
description: 'Append to layered memory. kind=recent appends a JSONL line; kind=core overwrites core.md; kind=episodic writes episodic/<topic>.md.',
|
|
86
|
+
parameters: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
kind: { type: 'string', enum: ['recent', 'core', 'episodic'] },
|
|
90
|
+
content: { type: 'string' },
|
|
91
|
+
topic: { type: 'string' },
|
|
92
|
+
},
|
|
93
|
+
required: ['kind', 'content'],
|
|
94
|
+
},
|
|
95
|
+
async exec(args, ctx) {
|
|
96
|
+
const dir = memoryDir(ctx);
|
|
97
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
98
|
+
if (args.kind === 'recent') {
|
|
99
|
+
fs.appendFileSync(path.join(dir, 'recent.jsonl'), JSON.stringify({ ts: Date.now(), content: args.content }) + '\n');
|
|
100
|
+
} else if (args.kind === 'core') {
|
|
101
|
+
fs.writeFileSync(path.join(dir, 'core.md'), args.content);
|
|
102
|
+
} else if (args.kind === 'episodic') {
|
|
103
|
+
if (!args.topic) return { ok: false, error: 'memory_write: topic required for episodic' };
|
|
104
|
+
fs.mkdirSync(path.join(dir, 'episodic'), { recursive: true });
|
|
105
|
+
fs.writeFileSync(path.join(dir, 'episodic', `${args.topic}.md`), args.content);
|
|
106
|
+
} else {
|
|
107
|
+
return { ok: false, error: `memory_write: unknown kind ${args.kind}` };
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, kind: args.kind };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const memory_read = {
|
|
114
|
+
name: 'memory_read', category: 'learning', sensitive: false,
|
|
115
|
+
description: 'Read layered memory. kind=recent returns last N JSONL entries; kind=core returns core.md; kind=episodic returns episodic/<topic>.md.',
|
|
116
|
+
parameters: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
kind: { type: 'string', enum: ['recent', 'core', 'episodic'] },
|
|
120
|
+
topic: { type: 'string' },
|
|
121
|
+
limit: { type: 'number' },
|
|
122
|
+
},
|
|
123
|
+
required: ['kind'],
|
|
124
|
+
},
|
|
125
|
+
async exec(args, ctx) {
|
|
126
|
+
const dir = memoryDir(ctx);
|
|
127
|
+
if (args.kind === 'recent') {
|
|
128
|
+
const f = path.join(dir, 'recent.jsonl');
|
|
129
|
+
if (!fs.existsSync(f)) return { ok: true, entries: [] };
|
|
130
|
+
const lines = fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean);
|
|
131
|
+
const limit = Math.max(1, Math.min(200, args.limit || 20));
|
|
132
|
+
return { ok: true, entries: lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return { content: l }; } }) };
|
|
133
|
+
}
|
|
134
|
+
if (args.kind === 'core') {
|
|
135
|
+
const f = path.join(dir, 'core.md');
|
|
136
|
+
return { ok: true, content: fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '' };
|
|
137
|
+
}
|
|
138
|
+
if (args.kind === 'episodic') {
|
|
139
|
+
if (!args.topic) return { ok: false, error: 'memory_read: topic required for episodic' };
|
|
140
|
+
const f = path.join(dir, 'episodic', `${args.topic}.md`);
|
|
141
|
+
return { ok: true, content: fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '' };
|
|
142
|
+
}
|
|
143
|
+
return { ok: false, error: `memory_read: unknown kind ${args.kind}` };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const user_view = {
|
|
148
|
+
name: 'user_view', category: 'learning', sensitive: false,
|
|
149
|
+
description: 'Read the persistent USER.md (Honcho-equivalent user model).',
|
|
150
|
+
parameters: { type: 'object', properties: {} },
|
|
151
|
+
async exec(_args, ctx) {
|
|
152
|
+
const f = userMdPath(ctx);
|
|
153
|
+
return { ok: true, content: fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '' };
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const user_update = {
|
|
158
|
+
name: 'user_update', category: 'learning', sensitive: true,
|
|
159
|
+
description: 'Overwrite USER.md (the persistent user model). Use sparingly — usually the user_modeler does this.',
|
|
160
|
+
parameters: { type: 'object', properties: { content: { type: 'string' } }, required: ['content'] },
|
|
161
|
+
async exec(args, ctx) {
|
|
162
|
+
fs.mkdirSync(memoryDir(ctx), { recursive: true });
|
|
163
|
+
fs.writeFileSync(userMdPath(ctx), args.content);
|
|
164
|
+
return { ok: true, path: userMdPath(ctx) };
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const TOOLS = [skill_view, skill_create, skill_edit, memory_write, memory_read, user_view, user_update];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// media — image_describe (vision provider), image_generate (FAL/DALL-E
|
|
2
|
+
// optional via env key), tts_speak (deferred to v5.1 per spec §0.2),
|
|
3
|
+
// transcribe (whisper.cpp local OR OpenAI whisper API by env key).
|
|
4
|
+
// Provider integrations are opt-in via env vars; all return a structured
|
|
5
|
+
// "configure X" error otherwise.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { fetch } from 'undici';
|
|
9
|
+
|
|
10
|
+
const image_describe = {
|
|
11
|
+
name: 'image_describe', category: 'media', sensitive: true,
|
|
12
|
+
description: 'Describe an image. Requires OPENAI_API_KEY (gpt-4o vision) or ANTHROPIC_API_KEY (claude vision).',
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: { path: { type: 'string' }, prompt: { type: 'string' } },
|
|
16
|
+
required: ['path'],
|
|
17
|
+
},
|
|
18
|
+
async exec(args, ctx) {
|
|
19
|
+
const env = ctx?.env || process.env;
|
|
20
|
+
if (!fs.existsSync(args.path)) return { ok: false, error: `image_describe: file not found ${args.path}` };
|
|
21
|
+
const b64 = fs.readFileSync(args.path).toString('base64');
|
|
22
|
+
const prompt = args.prompt || 'Describe this image briefly.';
|
|
23
|
+
if (env.OPENAI_API_KEY) {
|
|
24
|
+
try {
|
|
25
|
+
const r = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
26
|
+
method: 'POST', headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: [
|
|
28
|
+
{ type: 'text', text: prompt },
|
|
29
|
+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } },
|
|
30
|
+
] }] }),
|
|
31
|
+
});
|
|
32
|
+
const j = await r.json();
|
|
33
|
+
return { ok: true, description: j?.choices?.[0]?.message?.content || '' };
|
|
34
|
+
} catch (e) { return { ok: false, error: `image_describe: ${e.message}` }; }
|
|
35
|
+
}
|
|
36
|
+
return { ok: false, error: 'image_describe: set OPENAI_API_KEY (gpt-4o vision)' };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const image_generate = {
|
|
41
|
+
name: 'image_generate', category: 'media', sensitive: true,
|
|
42
|
+
description: 'Generate an image. Requires OPENAI_API_KEY (DALL-E) or FAL_KEY.',
|
|
43
|
+
parameters: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: { prompt: { type: 'string' }, outPath: { type: 'string' } },
|
|
46
|
+
required: ['prompt'],
|
|
47
|
+
},
|
|
48
|
+
async exec(args, ctx) {
|
|
49
|
+
const env = ctx?.env || process.env;
|
|
50
|
+
if (!env.OPENAI_API_KEY && !env.FAL_KEY) return { ok: false, error: 'image_generate: set OPENAI_API_KEY or FAL_KEY' };
|
|
51
|
+
if (env.OPENAI_API_KEY) {
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetch('https://api.openai.com/v1/images/generations', {
|
|
54
|
+
method: 'POST', headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ model: 'gpt-image-1', prompt: args.prompt, size: '1024x1024' }),
|
|
56
|
+
});
|
|
57
|
+
const j = await r.json();
|
|
58
|
+
const b64 = j?.data?.[0]?.b64_json;
|
|
59
|
+
if (!b64) return { ok: false, error: `image_generate: no data (${JSON.stringify(j).slice(0, 200)})` };
|
|
60
|
+
if (args.outPath) fs.writeFileSync(args.outPath, Buffer.from(b64, 'base64'));
|
|
61
|
+
return { ok: true, outPath: args.outPath || null, b64: args.outPath ? null : b64 };
|
|
62
|
+
} catch (e) { return { ok: false, error: `image_generate: ${e.message}` }; }
|
|
63
|
+
}
|
|
64
|
+
return { ok: false, error: 'image_generate: FAL_KEY path not implemented in v5.0 (configure OPENAI_API_KEY)' };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const tts_speak = {
|
|
69
|
+
name: 'tts_speak', category: 'media', sensitive: true,
|
|
70
|
+
description: 'STUB — TTS reply deferred to v5.1 per spec §0.2.',
|
|
71
|
+
parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
|
72
|
+
async exec() {
|
|
73
|
+
return { ok: false, error: 'tts_speak: deferred to v5.1 (spec §0.2)' };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const transcribe = {
|
|
78
|
+
name: 'transcribe', category: 'media', sensitive: true,
|
|
79
|
+
description: 'Transcribe audio. Requires OPENAI_API_KEY (whisper) or a local whisper.cpp binary at WHISPER_CPP.',
|
|
80
|
+
parameters: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: { path: { type: 'string' }, language: { type: 'string' } },
|
|
83
|
+
required: ['path'],
|
|
84
|
+
},
|
|
85
|
+
async exec(args, ctx) {
|
|
86
|
+
const env = ctx?.env || process.env;
|
|
87
|
+
if (!fs.existsSync(args.path)) return { ok: false, error: `transcribe: file not found ${args.path}` };
|
|
88
|
+
if (env.OPENAI_API_KEY) {
|
|
89
|
+
try {
|
|
90
|
+
const fd = new FormData();
|
|
91
|
+
fd.append('file', new Blob([fs.readFileSync(args.path)]), 'audio');
|
|
92
|
+
fd.append('model', 'whisper-1');
|
|
93
|
+
if (args.language) fd.append('language', args.language);
|
|
94
|
+
const r = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
95
|
+
method: 'POST', headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}` }, body: fd,
|
|
96
|
+
});
|
|
97
|
+
const j = await r.json();
|
|
98
|
+
return { ok: true, text: j?.text || '' };
|
|
99
|
+
} catch (e) { return { ok: false, error: `transcribe: ${e.message}` }; }
|
|
100
|
+
}
|
|
101
|
+
return { ok: false, error: 'transcribe: set OPENAI_API_KEY (whisper-1)' };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const TOOLS = [image_describe, image_generate, tts_speak, transcribe];
|