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.
Files changed (54) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -508
  3. package/channels/handoff.mjs +41 -0
  4. package/channels/loader.mjs +124 -0
  5. package/channels/threads.mjs +116 -0
  6. package/cli.mjs +430 -7
  7. package/daemon.mjs +13 -0
  8. package/mas/agent_turn.mjs +28 -0
  9. package/mas/confidence.mjs +108 -0
  10. package/mas/index_db.mjs +242 -0
  11. package/mas/nudge.mjs +97 -0
  12. package/mas/prompt_stack.mjs +80 -0
  13. package/mas/skill_synth.mjs +124 -25
  14. package/mas/tool_runner.mjs +10 -61
  15. package/mas/tools/browser.mjs +77 -0
  16. package/mas/tools/clarify.mjs +36 -0
  17. package/mas/tools/coding.mjs +109 -0
  18. package/mas/tools/delegation.mjs +53 -0
  19. package/mas/tools/edit.mjs +36 -0
  20. package/mas/tools/git.mjs +110 -0
  21. package/mas/tools/ha.mjs +34 -0
  22. package/mas/tools/learning.mjs +168 -0
  23. package/mas/tools/media.mjs +105 -0
  24. package/mas/tools/os.mjs +152 -0
  25. package/mas/tools/patch.mjs +91 -0
  26. package/mas/tools/recall.mjs +103 -0
  27. package/mas/tools/registry.mjs +93 -0
  28. package/mas/tools/scheduling.mjs +62 -0
  29. package/mas/tools/web.mjs +137 -0
  30. package/mas/toolsets.mjs +64 -0
  31. package/mas/trajectory_export.mjs +169 -0
  32. package/mas/trajectory_store.mjs +179 -0
  33. package/mas/user_modeler.mjs +108 -0
  34. package/package.json +20 -3
  35. package/providers/codex_cli.mjs +200 -0
  36. package/providers/gemini_cli.mjs +179 -0
  37. package/providers/registry.mjs +61 -1
  38. package/sandbox/base.mjs +82 -0
  39. package/sandbox/confiners/bubblewrap.mjs +21 -0
  40. package/sandbox/confiners/firejail.mjs +16 -0
  41. package/sandbox/confiners/landlock.mjs +14 -0
  42. package/sandbox/confiners/seatbelt.mjs +28 -0
  43. package/sandbox/daytona.mjs +37 -0
  44. package/sandbox/docker.mjs +91 -0
  45. package/sandbox/index.mjs +67 -0
  46. package/sandbox/local.mjs +59 -0
  47. package/sandbox/modal.mjs +53 -0
  48. package/sandbox/singularity.mjs +39 -0
  49. package/sandbox/ssh.mjs +56 -0
  50. package/sandbox.mjs +11 -127
  51. package/scripts/hermes-import.mjs +111 -0
  52. package/scripts/migrate-v5.mjs +342 -0
  53. package/scripts/openclaw-import.mjs +71 -0
  54. package/sessions.mjs +20 -1
@@ -0,0 +1,53 @@
1
+ // sandbox/modal.mjs — Modal CLI + idle-hibernation wake hook.
2
+
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { Sandbox, SandboxSession, SandboxError } from './base.mjs';
5
+
6
+ export function buildModalArgv(spec, argv) {
7
+ if (!spec || !spec.app) {
8
+ throw new SandboxError('modal sandbox requires app name', 'SANDBOX_BAD_SPEC');
9
+ }
10
+ const out = ['modal', 'run', '--detach=false'];
11
+ if (spec.region) out.push('--region', spec.region);
12
+ out.push(spec.app, '--', ...argv);
13
+ return out;
14
+ }
15
+
16
+ export function idleWakeUrl(spec) {
17
+ const app = encodeURIComponent(spec.app || '');
18
+ const tok = encodeURIComponent(spec.token || '');
19
+ const host = spec.host || 'lazyclaw-edge.modal.run';
20
+ return `https://${host}/wake?app=${app}&token=${tok}`;
21
+ }
22
+
23
+ async function maybeWake(spec) {
24
+ if (!spec.idleWake || !spec.token) return;
25
+ try {
26
+ await fetch(idleWakeUrl(spec), { method: 'POST' });
27
+ } catch { /* best effort; modal cold-start handles rest */ }
28
+ }
29
+
30
+ class ModalSession extends SandboxSession {
31
+ constructor(spec) { super(); this.spec = spec; }
32
+ async exec(argv, opts = {}) {
33
+ await maybeWake(this.spec);
34
+ const a = buildModalArgv(this.spec, argv);
35
+ const r = spawnSync(a[0], a.slice(1), {
36
+ input: opts.input, env: { ...process.env, ...(opts.env || {}) },
37
+ stdio: opts.stdio || 'pipe', encoding: 'utf8',
38
+ });
39
+ return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
40
+ }
41
+ async spawn(argv, opts = {}) {
42
+ await maybeWake(this.spec);
43
+ const a = buildModalArgv(this.spec, argv);
44
+ return spawn(a[0], a.slice(1), opts);
45
+ }
46
+ async close() {}
47
+ }
48
+
49
+ export class ModalSandbox extends Sandbox {
50
+ constructor(spec) { super(spec); }
51
+ async open() { return new ModalSession(this.spec); }
52
+ describe() { return `modal · app=${this.spec.app}`; }
53
+ }
@@ -0,0 +1,39 @@
1
+ // sandbox/singularity.mjs — apptainer / singularity exec wrapper.
2
+
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { Sandbox, SandboxSession, SandboxError } from './base.mjs';
5
+
6
+ export function buildSingularityArgv(spec, argv) {
7
+ if (!spec || !spec.image) {
8
+ throw new SandboxError('singularity sandbox requires image', 'SANDBOX_BAD_SPEC');
9
+ }
10
+ const bin = spec.useApptainer === false ? 'singularity' : 'apptainer';
11
+ const out = [bin, 'exec'];
12
+ for (const b of spec.bind || []) out.push('--bind', b);
13
+ if (!spec.net) out.push('--net', '--network=none');
14
+ out.push(spec.image, ...argv);
15
+ return out;
16
+ }
17
+
18
+ class SingularitySession extends SandboxSession {
19
+ constructor(spec) { super(); this.spec = spec; }
20
+ async exec(argv, opts = {}) {
21
+ const a = buildSingularityArgv(this.spec, argv);
22
+ const r = spawnSync(a[0], a.slice(1), {
23
+ input: opts.input, env: { ...process.env, ...(opts.env || {}) },
24
+ stdio: opts.stdio || 'pipe', encoding: 'utf8',
25
+ });
26
+ return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
27
+ }
28
+ async spawn(argv, opts = {}) {
29
+ const a = buildSingularityArgv(this.spec, argv);
30
+ return spawn(a[0], a.slice(1), opts);
31
+ }
32
+ async close() {}
33
+ }
34
+
35
+ export class SingularitySandbox extends Sandbox {
36
+ constructor(spec) { super(spec); }
37
+ async open() { return new SingularitySession(this.spec); }
38
+ describe() { return `singularity · ${this.spec.image}`; }
39
+ }
@@ -0,0 +1,56 @@
1
+ // sandbox/ssh.mjs — Remote exec via OpenSSH with ControlMaster reuse.
2
+ //
3
+ // The wrapper deliberately avoids node-ssh's reconnect logic — we
4
+ // rely on OpenSSH's ControlMaster/ControlPersist so multiple exec()
5
+ // calls share one TCP connection. node-ssh is imported lazily and
6
+ // only used for streaming spawn() because spawnSync over Control-
7
+ // Master is enough for short tool calls.
8
+
9
+ import { spawn, spawnSync } from 'node:child_process';
10
+ import { Sandbox, SandboxSession, SandboxError } from './base.mjs';
11
+
12
+ export function buildSshArgv(spec, argv) {
13
+ if (!spec || !spec.host) throw new SandboxError('ssh sandbox requires host', 'SANDBOX_BAD_SPEC');
14
+ const userHost = spec.user ? `${spec.user}@${spec.host}` : spec.host;
15
+ const out = ['ssh',
16
+ '-o', 'ControlMaster=auto',
17
+ '-o', 'ControlPath=~/.ssh/cm-%h-%p-%r',
18
+ '-o', 'ControlPersist=10m',
19
+ '-o', 'StrictHostKeyChecking=accept-new',
20
+ ];
21
+ if (spec.identityFile) out.push('-i', spec.identityFile);
22
+ if (spec.port) out.push('-p', String(spec.port));
23
+ out.push(userHost, argv.join(' '));
24
+ return out;
25
+ }
26
+
27
+ class SshSession extends SandboxSession {
28
+ constructor(spec) { super(); this.spec = spec; }
29
+
30
+ async exec(argv, opts = {}) {
31
+ const sshArgv = buildSshArgv(this.spec, argv);
32
+ const r = spawnSync(sshArgv[0], sshArgv.slice(1), {
33
+ input: opts.input,
34
+ env: { ...process.env, ...(opts.env || {}) },
35
+ stdio: opts.stdio || 'pipe',
36
+ encoding: 'utf8',
37
+ });
38
+ return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
39
+ }
40
+
41
+ async spawn(argv, opts = {}) {
42
+ const sshArgv = buildSshArgv(this.spec, argv);
43
+ return spawn(sshArgv[0], sshArgv.slice(1), opts);
44
+ }
45
+
46
+ async close() { /* ControlPersist handles socket lifecycle */ }
47
+ }
48
+
49
+ export class SshSandbox extends Sandbox {
50
+ constructor(spec) { super(spec); }
51
+ async open() { return new SshSession(this.spec); }
52
+ describe() {
53
+ const u = this.spec.user ? `${this.spec.user}@${this.spec.host}` : this.spec.host;
54
+ return `ssh · ${u}`;
55
+ }
56
+ }
package/sandbox.mjs CHANGED
@@ -1,127 +1,11 @@
1
- // Sandboxwrap a child process in a Docker container.
2
- //
3
- // `lazyclaw chat --sandbox docker:<image>` (or the equivalent on
4
- // `agent`) routes the underlying `claude` CLI invocation through
5
- //
6
- // docker run --rm -i --network=<net> \
7
- // -v <cwd>:<cwd> -w <cwd> \
8
- // -e <pass-through env vars> \
9
- // <image> claude -p ...
10
- //
11
- // instead of running `claude` directly on the host. Two reasons:
12
- //
13
- // 1. Filesystem confinement. The default --workdir mount only
14
- // exposes the current working directory; tools that try to
15
- // chdir into $HOME or read /etc see an empty container fs.
16
- // 2. Network policy. By default we set --network=none so the
17
- // sandboxed agent cannot reach the public internet — useful
18
- // when handing it untrusted prompts. Pass `--network host` /
19
- // `bridge` via flags when the workflow needs outbound access
20
- // (e.g. it has to call an API).
21
- //
22
- // Caveats — call out so users aren't surprised:
23
- //
24
- // - The user's `claude` login lives in $HOME/.claude/. The sandbox
25
- // doesn't expose $HOME by default, so the wrapped CLI can't see
26
- // that auth and will prompt for login. To run sandboxed under
27
- // the user's existing subscription, mount $HOME/.claude:
28
- //
29
- // lazyclaw chat --sandbox docker:node:20 \
30
- // --sandbox-mount "$HOME/.claude:/root/.claude:ro"
31
- //
32
- // - Sandboxing only applies when the picked provider goes through
33
- // a subprocess (currently `claude-cli`). API providers
34
- // (anthropic / openai / gemini) hit the network from
35
- // *lazyclaw's* process, not a child — sandboxing them is a
36
- // no-op and we surface a warning.
37
-
38
- import { spawn } from 'node:child_process';
39
-
40
- class SandboxError extends Error {
41
- constructor(message, code) {
42
- super(message);
43
- this.name = 'SandboxError';
44
- this.code = code || 'SANDBOX_ERR';
45
- }
46
- }
47
-
48
- /**
49
- * Parse a `--sandbox` flag. Accepts:
50
- * docker:<image> — Docker with default policy
51
- * docker:<image>?<args> — query-string-style overrides
52
- * off | none | - — explicit "no sandbox"
53
- *
54
- * Returns null when sandboxing is off, or
55
- * { kind: 'docker', image, network, mounts: string[], envPassthrough: string[] }.
56
- */
57
- export function parseSandboxSpec(spec, flags = {}) {
58
- if (!spec || /^(off|none|-)$/i.test(String(spec))) return null;
59
- const m = String(spec).match(/^([a-z]+):(.+)$/i);
60
- if (!m) throw new SandboxError(`bad sandbox spec "${spec}" — expected "docker:<image>"`, 'SANDBOX_BAD_SPEC');
61
- const [, kind, rest] = m;
62
- if (kind.toLowerCase() !== 'docker') {
63
- throw new SandboxError(`unsupported sandbox kind "${kind}" — only "docker" is implemented`, 'SANDBOX_UNSUPPORTED');
64
- }
65
- return {
66
- kind: 'docker',
67
- image: rest.trim(),
68
- // Default to --network=none for safety. Override via:
69
- // --sandbox-network host (or bridge / a named network)
70
- network: flags['sandbox-network'] || 'none',
71
- // --sandbox-mount can repeat; cli.parseArgs collects repeats
72
- // into an array.
73
- mounts: arrayify(flags['sandbox-mount']),
74
- envPassthrough: arrayify(flags['sandbox-env']),
75
- };
76
- }
77
-
78
- function arrayify(v) {
79
- if (v === undefined || v === null) return [];
80
- return Array.isArray(v) ? v : [String(v)];
81
- }
82
-
83
- /**
84
- * Build the docker run argv that wraps a child invocation. The
85
- * caller hands us the original [bin, ...args] they were going to
86
- * spawn; we return [docker, ...dockerArgs] that puts the same
87
- * thing inside the container.
88
- */
89
- export function buildDockerArgs(spec, [bin, ...binArgs], opts = {}) {
90
- if (!spec || spec.kind !== 'docker') {
91
- throw new SandboxError('buildDockerArgs requires a docker spec', 'SANDBOX_BAD_SPEC');
92
- }
93
- const cwd = opts.cwd || process.cwd();
94
- const args = [
95
- 'run', '--rm', '-i',
96
- '--network', spec.network || 'none',
97
- '-v', `${cwd}:${cwd}`,
98
- '-w', cwd,
99
- ];
100
- for (const mount of spec.mounts) {
101
- if (!mount.includes(':')) {
102
- throw new SandboxError(`bad mount "${mount}" — expected host:container[:mode]`, 'SANDBOX_BAD_MOUNT');
103
- }
104
- args.push('-v', mount);
105
- }
106
- for (const envName of spec.envPassthrough) {
107
- args.push('-e', envName);
108
- }
109
- args.push(spec.image, bin, ...binArgs);
110
- return args;
111
- }
112
-
113
- /**
114
- * Spawn `bin` with `args` either bare (no sandbox) or under the
115
- * docker wrapper. Returns the child process; the caller drives
116
- * stdio and handles exit. Mirrors `child_process.spawn`'s shape.
117
- */
118
- export function spawnSandboxed(spec, bin, args, spawnOpts = {}) {
119
- if (!spec) return spawn(bin, args, spawnOpts);
120
- if (spec.kind !== 'docker') {
121
- throw new SandboxError(`unsupported kind "${spec.kind}"`, 'SANDBOX_UNSUPPORTED');
122
- }
123
- const dockerArgs = buildDockerArgs(spec, [bin, ...args], { cwd: spawnOpts.cwd });
124
- return spawn('docker', dockerArgs, spawnOpts);
125
- }
126
-
127
- export { SandboxError };
1
+ // sandbox.mjsv4 compat shim. Real implementations live under
2
+ // sandbox/. Kept so providers/{claude_cli,codex_cli,gemini_cli}.mjs
3
+ // can keep their `import { spawnSandboxed } from '../sandbox.mjs'`
4
+ // statements unchanged during the v5 phase rollout.
5
+
6
+ export {
7
+ parseSandboxSpec,
8
+ buildDockerArgs,
9
+ spawnSandboxed,
10
+ } from './sandbox/docker.mjs';
11
+ export { SandboxError } from './sandbox/base.mjs';
@@ -0,0 +1,111 @@
1
+ // scripts/hermes-import.mjs
2
+ // Detect ~/.hermes (or --from <dir>) and import into lazyclaw.
3
+ // skills/*.md → <cfgDir>/skills/*.md with trained_by: hermes-import (C4)
4
+ // USER.md → <cfgDir>/memory/USER.md (C6)
5
+ // MEMORY.md → <cfgDir>/memory/core.md (merged, append)
6
+ // channels.json → cfg.channels.* (best-effort)
7
+ // skins/<slug>.yaml → <cfgDir>/personalities/hermes-<slug>.md (C7)
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+
12
+ export function defaultHermesDir() { return path.join(os.homedir(), '.hermes'); }
13
+ export function defaultCfgDir() {
14
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
15
+ }
16
+
17
+ function injectTrainedBy(content, value) {
18
+ if (!content.startsWith('---')) {
19
+ return `---\ntrained_by: ${value}\n---\n${content}`;
20
+ }
21
+ // Replace existing trained_by or insert before closing fence
22
+ const closeRe = /\r?\n---[ \t]*(?:\r?\n|$)/;
23
+ const m = closeRe.exec(content.slice(3));
24
+ if (!m) return content;
25
+ const block = content.slice(4, 3 + m.index);
26
+ const rest = content.slice(3 + m.index + m[0].length);
27
+ if (/^trained_by:/m.test(block)) {
28
+ return `---\n${block.replace(/^trained_by:.*$/m, `trained_by: ${value}`)}\n---\n${rest}`;
29
+ }
30
+ return `---\n${block}\ntrained_by: ${value}\n---\n${rest}`;
31
+ }
32
+
33
+ function importSkills(srcDir, dstDir) {
34
+ const src = path.join(srcDir, 'skills');
35
+ if (!fs.existsSync(src)) return 0;
36
+ const dst = path.join(dstDir, 'skills');
37
+ fs.mkdirSync(dst, { recursive: true });
38
+ let n = 0;
39
+ for (const f of fs.readdirSync(src)) {
40
+ if (!f.endsWith('.md')) continue;
41
+ const content = fs.readFileSync(path.join(src, f), 'utf8');
42
+ fs.writeFileSync(path.join(dst, f), injectTrainedBy(content, 'hermes-import'));
43
+ n++;
44
+ }
45
+ return n;
46
+ }
47
+
48
+ function importMemory(srcDir, dstDir) {
49
+ const memDir = path.join(dstDir, 'memory');
50
+ fs.mkdirSync(memDir, { recursive: true });
51
+ const userSrc = path.join(srcDir, 'USER.md');
52
+ if (fs.existsSync(userSrc)) {
53
+ const incoming = fs.readFileSync(userSrc, 'utf8');
54
+ const dst = path.join(memDir, 'USER.md');
55
+ const existing = fs.existsSync(dst) ? fs.readFileSync(dst, 'utf8') : '';
56
+ fs.writeFileSync(dst, existing ? `${existing}\n\n<!-- hermes-import -->\n${incoming}` : incoming);
57
+ }
58
+ const memSrc = path.join(srcDir, 'MEMORY.md');
59
+ if (fs.existsSync(memSrc)) {
60
+ const incoming = fs.readFileSync(memSrc, 'utf8');
61
+ const dst = path.join(memDir, 'core.md');
62
+ const existing = fs.existsSync(dst) ? fs.readFileSync(dst, 'utf8') : '';
63
+ fs.writeFileSync(dst, existing ? `${existing}\n\n<!-- hermes-import -->\n${incoming}` : incoming);
64
+ }
65
+ }
66
+
67
+ function importChannels(srcDir, cfgDir) {
68
+ const src = path.join(srcDir, 'channels.json');
69
+ if (!fs.existsSync(src)) return;
70
+ let incoming;
71
+ try { incoming = JSON.parse(fs.readFileSync(src, 'utf8')); } catch { return; }
72
+ const cfgPath = path.join(cfgDir, 'config.json');
73
+ let cfg = {};
74
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {}
75
+ cfg.channels = { ...(cfg.channels || {}), ...incoming };
76
+ fs.mkdirSync(cfgDir, { recursive: true });
77
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
78
+ }
79
+
80
+ function importSkins(srcDir, dstDir) {
81
+ const src = path.join(srcDir, 'skins');
82
+ if (!fs.existsSync(src)) return 0;
83
+ const dst = path.join(dstDir, 'personalities');
84
+ fs.mkdirSync(dst, { recursive: true });
85
+ let n = 0;
86
+ for (const f of fs.readdirSync(src)) {
87
+ if (!/\.ya?ml$/.test(f)) continue;
88
+ const slug = f.replace(/\.ya?ml$/, '');
89
+ const raw = fs.readFileSync(path.join(src, f), 'utf8');
90
+ // Best-effort: extract `prompt:` flat YAML; else dump raw.
91
+ const m = /^prompt:\s*"?(.*?)"?\s*$/m.exec(raw);
92
+ const body = m ? m[1] : raw;
93
+ fs.writeFileSync(path.join(dst, `hermes-${slug}.md`), `# ${slug} (imported from Hermes)\n\n${body}\n`);
94
+ n++;
95
+ }
96
+ return n;
97
+ }
98
+
99
+ export function importHermes({ from, cfgDir } = {}) {
100
+ const src = from || defaultHermesDir();
101
+ const dst = cfgDir || defaultCfgDir();
102
+ if (!fs.existsSync(src)) throw new Error(`hermes source not found: ${src}`);
103
+ fs.mkdirSync(dst, { recursive: true });
104
+ const counts = {
105
+ skills: importSkills(src, dst),
106
+ memory: (importMemory(src, dst), 1),
107
+ channels: (importChannels(src, dst), 1),
108
+ skins: importSkins(src, dst),
109
+ };
110
+ return { src, dst, counts };
111
+ }