lazyclaw 4.3.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +44 -0
- package/README.md +172 -508
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +430 -7
- package/daemon.mjs +13 -0
- package/mas/agent_turn.mjs +28 -0
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/skill_synth.mjs +124 -25
- package/mas/tool_runner.mjs +10 -61
- 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/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 +20 -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
|
@@ -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];
|
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
|
+
}
|