lazyclaw 6.3.1 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +90 -33
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +196 -80
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
@@ -2,9 +2,21 @@
2
2
  // Both lazy-import providers/orchestrator.mjs / mas/agent_turn.mjs to
3
3
  // avoid pulling those into every process that imports the registry.
4
4
 
5
+ // events.mjs is featherweight (zero-dep pub/sub), so a static import is fine —
6
+ // it does not pull agent_turn/orchestrator into the registry load path.
7
+ import { emit as emitEvent } from '../events.mjs';
8
+
5
9
  let _dispatcher = null;
6
10
  export function __setDispatcher(fn) { _dispatcher = fn; }
7
11
 
12
+ // Test seam for dispatchSpawn's turn runner, mirroring __setDispatcher.
13
+ // When set, dispatchSpawn calls it with the SAME args it would hand
14
+ // runAgentTurn ({ agent: <record>, userMessage, configDir }) so tests can
15
+ // assert a resolved agent RECORD (with .provider) reaches the runner —
16
+ // without spinning up a real provider.
17
+ let _turnRunner = null;
18
+ export function __setTurnRunner(fn) { _turnRunner = fn; }
19
+
8
20
  async function dispatchDelegate(job) {
9
21
  if (_dispatcher) return _dispatcher(job);
10
22
  const orch = await import('../../providers/orchestrator.mjs').catch(() => null);
@@ -14,12 +26,27 @@ async function dispatchDelegate(job) {
14
26
  return orch.dispatchWorker(job);
15
27
  }
16
28
 
29
+ // job = { agent: '<name>', prompt, configDir }
30
+ // runAgentTurn expects an agent RECORD (reads .provider/.tools), not a name
31
+ // string. Resolve the name → record via getAgent first, then invoke the
32
+ // runner with the shape it actually consumes and surface the final text.
17
33
  async function dispatchSpawn(job) {
34
+ const agentName = String(job?.agent || '');
18
35
  const at = await import('../agent_turn.mjs').catch(() => null);
19
- if (!at || typeof at.runAgentTurn !== 'function') {
36
+ const runner = _turnRunner || (at && typeof at.runAgentTurn === 'function' ? at.runAgentTurn : null);
37
+ if (!runner) {
20
38
  return { ok: false, error: 'task_spawn: agent_turn.runAgentTurn unavailable' };
21
39
  }
22
- return at.runAgentTurn(job);
40
+ const agents = await import('../../agents.mjs').catch(() => null);
41
+ if (!agents || typeof agents.getAgent !== 'function') {
42
+ return { ok: false, error: 'task_spawn: agents.getAgent unavailable' };
43
+ }
44
+ const record = agents.getAgent(agentName, job?.configDir);
45
+ if (!record) {
46
+ return { ok: false, error: `task_spawn: unknown agent ${agentName}` };
47
+ }
48
+ const result = await runner({ agent: record, userMessage: job?.prompt, configDir: job?.configDir, sandbox: job?.sandbox });
49
+ return { ok: true, text: result?.text || '', stoppedBy: result?.stoppedBy, iterations: result?.iterations };
23
50
  }
24
51
 
25
52
  const task_spawn = {
@@ -30,9 +57,12 @@ const task_spawn = {
30
57
  properties: { agent: { type: 'string' }, prompt: { type: 'string' } },
31
58
  required: ['agent', 'prompt'],
32
59
  },
33
- async exec(args) {
60
+ async exec(args, ctx = {}) {
34
61
  if (!args?.agent || !args?.prompt) return { ok: false, error: 'task_spawn: agent + prompt required' };
35
- return dispatchSpawn({ agent: args.agent, prompt: args.prompt });
62
+ // Live A→B delegation event for the dashboard (caller → spawned agent).
63
+ emitEvent('delegate', { taskId: ctx.taskId, from: ctx.agent?.name, to: args.agent });
64
+ // Propagate the outer turn's sandbox so a spawned sub-agent stays confined.
65
+ return dispatchSpawn({ agent: args.agent, prompt: args.prompt, configDir: ctx.configDir, sandbox: ctx.sandbox });
36
66
  },
37
67
  };
38
68
 
package/mas/tools/git.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // sensitive writes (commit/push). All shell out to git in ctx.cwd.
3
3
 
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { spawnSyncSandboxed } from '../../sandbox.mjs';
5
6
 
6
7
  function git(cwd, args, opts = {}) {
7
8
  // M11 / C12 — detect a missing git binary (Windows without
@@ -10,8 +11,17 @@ function git(cwd, args, opts = {}) {
10
11
  // {ok:false} with a cryptic spawn error, which made the
11
12
  // "lazyclaw doctor" path the only reliable signal. Now any agent
12
13
  // touching this tool also gets a one-line diagnostic.
14
+ //
15
+ // CAPABILITY-ONLY sandbox seam: when opts.sandbox is truthy the git
16
+ // invocation is routed through the synchronous sandbox dispatcher
17
+ // (default-on isolation, step iii). No current caller threads a spec, so
18
+ // the bare spawnSync path below is byte-identical to the historical call.
13
19
  const exe = process.env.GIT_EXECUTABLE || 'git';
14
- const r = spawnSync(exe, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
20
+ const r = opts.sandbox
21
+ ? (opts._spawnSyncSandboxed || spawnSyncSandboxed)(
22
+ opts.sandbox, exe, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
23
+ )
24
+ : spawnSync(exe, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
15
25
  if (r.error && r.error.code === 'ENOENT') {
16
26
  return {
17
27
  ok: false,
@@ -29,7 +39,7 @@ const git_status = {
29
39
  name: 'git_status', category: 'git', sensitive: false,
30
40
  description: 'Run `git status` in the workspace.',
31
41
  parameters: { type: 'object', properties: {} },
32
- async exec(_args, ctx) { return git(ctx?.cwd, ['status']); },
42
+ async exec(_args, ctx) { return git(ctx?.cwd, ['status'], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed }); },
33
43
  };
34
44
 
35
45
  const git_diff = {
@@ -40,7 +50,7 @@ const git_diff = {
40
50
  const ar = ['diff'];
41
51
  if (args?.staged) ar.push('--staged');
42
52
  if (args?.path) ar.push('--', args.path);
43
- return git(ctx?.cwd, ar);
53
+ return git(ctx?.cwd, ar, { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
44
54
  },
45
55
  };
46
56
 
@@ -50,7 +60,7 @@ const git_log = {
50
60
  parameters: { type: 'object', properties: { limit: { type: 'number' } } },
51
61
  async exec(args, ctx) {
52
62
  const n = Math.max(1, Math.min(args?.limit || 10, 100));
53
- const r = git(ctx?.cwd, ['log', `-n${n}`, '--pretty=format:%H%x09%an%x09%aI%x09%s']);
63
+ const r = git(ctx?.cwd, ['log', `-n${n}`, '--pretty=format:%H%x09%an%x09%aI%x09%s'], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
54
64
  if (!r.ok) return r;
55
65
  const commits = r.stdout.trim().split('\n').filter(Boolean).map(line => {
56
66
  const [hash, author, date, subject] = line.split('\t');
@@ -64,7 +74,7 @@ const git_blame = {
64
74
  name: 'git_blame', category: 'git', sensitive: false,
65
75
  description: 'Run `git blame <path>`.',
66
76
  parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
67
- async exec(args, ctx) { return git(ctx?.cwd, ['blame', '--', args.path]); },
77
+ async exec(args, ctx) { return git(ctx?.cwd, ['blame', '--', args.path], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed }); },
68
78
  };
69
79
 
70
80
  const git_branch = {
@@ -72,7 +82,7 @@ const git_branch = {
72
82
  description: 'List branches.',
73
83
  parameters: { type: 'object', properties: {} },
74
84
  async exec(_args, ctx) {
75
- const r = git(ctx?.cwd, ['branch', '--all', '--format=%(refname:short)%09%(upstream:short)%09%(HEAD)']);
85
+ const r = git(ctx?.cwd, ['branch', '--all', '--format=%(refname:short)%09%(upstream:short)%09%(HEAD)'], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
76
86
  if (!r.ok) return r;
77
87
  const branches = r.stdout.trim().split('\n').filter(Boolean).map(line => {
78
88
  const [name, upstream, head] = line.split('\t');
@@ -96,12 +106,12 @@ const git_commit = {
96
106
  },
97
107
  async exec(args, ctx) {
98
108
  if (Array.isArray(args.paths) && args.paths.length) {
99
- const a = git(ctx?.cwd, ['add', '--', ...args.paths]);
109
+ const a = git(ctx?.cwd, ['add', '--', ...args.paths], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
100
110
  if (!a.ok) return a;
101
111
  }
102
112
  const ar = ['commit', '-m', args.message];
103
113
  if (args.amend) ar.splice(1, 0, '--amend');
104
- return git(ctx?.cwd, ar);
114
+ return git(ctx?.cwd, ar, { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
105
115
  },
106
116
  };
107
117
 
@@ -120,7 +130,7 @@ const git_push = {
120
130
  if (args?.force) ar.push('--force-with-lease');
121
131
  if (args?.remote) ar.push(args.remote);
122
132
  if (args?.branch) ar.push(args.branch);
123
- return git(ctx?.cwd, ar);
133
+ return git(ctx?.cwd, ar, { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
124
134
  },
125
135
  };
126
136
 
package/mas/tools/ha.mjs CHANGED
@@ -8,6 +8,7 @@ function deferred(name) {
8
8
 
9
9
  const ha_call_service = {
10
10
  name: 'ha_call_service', category: 'iot', sensitive: true,
11
+ unavailable: true, // not implemented (deferred to v5.1) — hidden from tool schemas until wired
11
12
  description: 'STUB — Home Assistant service call deferred to v5.1.',
12
13
  parameters: {
13
14
  type: 'object',
@@ -22,6 +23,7 @@ const ha_call_service = {
22
23
 
23
24
  const ha_get_state = {
24
25
  name: 'ha_get_state', category: 'iot', sensitive: true,
26
+ unavailable: true, // not implemented (deferred to v5.1) — hidden from tool schemas until wired
25
27
  description: 'STUB — Home Assistant state read deferred to v5.1.',
26
28
  parameters: {
27
29
  type: 'object',
@@ -13,6 +13,38 @@ function resolveConfigDir(ctx) {
13
13
  function memoryDir(ctx) { return path.join(resolveConfigDir(ctx), 'memory'); }
14
14
  function userMdPath(ctx) { return path.join(memoryDir(ctx), 'USER.md'); }
15
15
 
16
+ // Best-effort FTS5 mirrors (spec §4.4) — same shape skill_synth /
17
+ // user_modeler use: dynamic import, openIndex, then index*. An index
18
+ // failure NEVER fails the tool; index_db's own write hooks already log
19
+ // to index-failures.jsonl, so we swallow here.
20
+ async function _indexSkillSafe(row, configDir) {
21
+ try {
22
+ const idx = await import('../index_db.mjs');
23
+ idx.openIndex(configDir);
24
+ idx.indexSkill(row, configDir);
25
+ } catch { /* non-fatal */ }
26
+ }
27
+
28
+ async function _indexMemorySafe(row, ctx) {
29
+ try {
30
+ const configDir = resolveConfigDir(ctx);
31
+ const idx = await import('../index_db.mjs');
32
+ idx.openIndex(configDir);
33
+ idx.indexMemory(row, configDir);
34
+ } catch { /* non-fatal */ }
35
+ }
36
+
37
+ // Minimal frontmatter key reader — skill_edit only needs trained_by/group
38
+ // off the existing frontmatter block to keep the FTS metadata stable.
39
+ function _parseFm(fm) {
40
+ const meta = {};
41
+ for (const line of String(fm || '').split('\n')) {
42
+ const i = line.indexOf(':');
43
+ if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
44
+ }
45
+ return meta;
46
+ }
47
+
16
48
  const skill_view = {
17
49
  name: 'skill_view', category: 'learning', sensitive: false,
18
50
  description: 'Return the body of an installed skill (by name).',
@@ -64,6 +96,15 @@ const skill_create = {
64
96
  '',
65
97
  ].join('\n');
66
98
  const file = skills.installSkill(args.name, fm + args.body + (args.body.endsWith('\n') ? '' : '\n'), configDir);
99
+ // FTS5 write-through (spec §4.4): mirror the new skill so recall finds
100
+ // it without waiting for a reindexAll. Best-effort — an index error must
101
+ // never fail the create. trained_by is always 'user' for this tool.
102
+ await _indexSkillSafe({
103
+ skill_name: args.name,
104
+ trained_by: 'user',
105
+ group_name: args.group || (args.name.includes('-') ? args.name.split('-')[0] : 'legacy'),
106
+ content: args.body,
107
+ }, configDir);
67
108
  return { ok: true, name: args.name, file };
68
109
  },
69
110
  };
@@ -86,6 +127,16 @@ const skill_edit = {
86
127
  let fm = m[1];
87
128
  fm = fm.replace(/version:\s*(\d+)/, (_, v) => `version: ${Number(v) + 1}`);
88
129
  skills.installSkill(args.name, `---\n${fm}\n---\n${args.body}${args.body.endsWith('\n') ? '' : '\n'}`, configDir);
130
+ // FTS5 write-through (spec §4.4): re-index the edited body so recall
131
+ // surfaces the new content. Preserve the skill's existing frontmatter
132
+ // trained_by / group; best-effort, never fails the edit.
133
+ const meta = _parseFm(fm);
134
+ await _indexSkillSafe({
135
+ skill_name: args.name,
136
+ trained_by: meta.trained_by || 'user',
137
+ group_name: meta.group || (args.name.includes('-') ? args.name.split('-')[0] : 'legacy'),
138
+ content: args.body,
139
+ }, configDir);
89
140
  return { ok: true, name: args.name };
90
141
  },
91
142
  };
@@ -107,12 +158,16 @@ const memory_write = {
107
158
  fs.mkdirSync(dir, { recursive: true });
108
159
  if (args.kind === 'recent') {
109
160
  fs.appendFileSync(path.join(dir, 'recent.jsonl'), JSON.stringify({ ts: Date.now(), content: args.content }) + '\n');
161
+ // recent.jsonl is not an FTS scope (no fts_recent table); skip indexing.
110
162
  } else if (args.kind === 'core') {
111
163
  fs.writeFileSync(path.join(dir, 'core.md'), args.content);
164
+ // FTS5 write-through (spec §4.4): mirror core memory so recall finds it.
165
+ await _indexMemorySafe({ topic: 'core', kind: 'core', content: args.content }, ctx);
112
166
  } else if (args.kind === 'episodic') {
113
167
  if (!args.topic) return { ok: false, error: 'memory_write: topic required for episodic' };
114
168
  fs.mkdirSync(path.join(dir, 'episodic'), { recursive: true });
115
169
  fs.writeFileSync(path.join(dir, 'episodic', `${args.topic}.md`), args.content);
170
+ await _indexMemorySafe({ topic: args.topic, kind: 'episodic', content: args.content }, ctx);
116
171
  } else {
117
172
  return { ok: false, error: `memory_write: unknown kind ${args.kind}` };
118
173
  }
@@ -5,11 +5,23 @@
5
5
  // "configure X" error otherwise.
6
6
 
7
7
  import fs from 'node:fs';
8
+ import path from 'node:path';
8
9
  import { fetch } from 'undici';
9
10
 
11
+ // Confine a model-supplied path to the working directory. image_describe
12
+ // UPLOADS the bytes to a third party (OpenAI), so — unlike `read` — it must
13
+ // not be coaxed into exfiltrating an arbitrary host file (~/.ssh/id_rsa,
14
+ // /etc/passwd). Returns the resolved path or null when it escapes cwd.
15
+ function confineToCwd(p, cwd) {
16
+ const root = path.resolve(cwd || process.cwd());
17
+ const resolved = path.resolve(root, String(p || ''));
18
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
19
+ return resolved;
20
+ }
21
+
10
22
  const image_describe = {
11
23
  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).',
24
+ description: 'Describe an image at a path inside the working directory. Requires OPENAI_API_KEY (gpt-4o vision).',
13
25
  parameters: {
14
26
  type: 'object',
15
27
  properties: { path: { type: 'string' }, prompt: { type: 'string' } },
@@ -17,8 +29,10 @@ const image_describe = {
17
29
  },
18
30
  async exec(args, ctx) {
19
31
  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');
32
+ const safePath = confineToCwd(args.path, ctx?.cwd);
33
+ if (!safePath) return { ok: false, error: `image_describe: path escapes the working directory: ${args.path}` };
34
+ if (!fs.existsSync(safePath)) return { ok: false, error: `image_describe: file not found ${args.path}` };
35
+ const b64 = fs.readFileSync(safePath).toString('base64');
22
36
  const prompt = args.prompt || 'Describe this image briefly.';
23
37
  if (env.OPENAI_API_KEY) {
24
38
  try {
@@ -39,6 +53,9 @@ const image_describe = {
39
53
 
40
54
  const image_generate = {
41
55
  name: 'image_generate', category: 'media', sensitive: true,
56
+ // Working tool: the OPENAI_API_KEY path makes a real images/generations call
57
+ // (gpt-image-1); only the FAL_KEY path is unimplemented. NOT a stub — stays
58
+ // advertised so the 'media' toolset works on OpenAI-key deployments.
42
59
  description: 'Generate an image. Requires OPENAI_API_KEY (DALL-E) or FAL_KEY.',
43
60
  parameters: {
44
61
  type: 'object',
@@ -67,6 +84,7 @@ const image_generate = {
67
84
 
68
85
  const tts_speak = {
69
86
  name: 'tts_speak', category: 'media', sensitive: true,
87
+ unavailable: true, // not implemented (deferred to v5.1) — hidden from tool schemas until wired
70
88
  description: 'STUB — TTS reply deferred to v5.1 per spec §0.2.',
71
89
  parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
72
90
  async exec() {
package/mas/tools/os.mjs CHANGED
@@ -6,12 +6,28 @@ import { spawn, spawnSync } from 'node:child_process';
6
6
  import fs from 'node:fs';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
+ import { spawnSandboxed, spawnSyncSandboxed } from '../../sandbox.mjs';
9
10
 
10
11
  function platformOf(ctx) { return ctx?.platform || process.platform; }
11
12
 
13
+ // Strip the sandbox-control keys (and our async-only `stdin` helper) from the
14
+ // options bag before forwarding to the (sandboxed) spawner, so only real
15
+ // child_process options reach it. The bare path keeps the historical `opts`
16
+ // object verbatim for byte-stability.
17
+ function spawnOptsOf(opts) {
18
+ const { sandbox, _spawnSandboxed, _spawnSyncSandboxed, stdin, ...rest } = opts;
19
+ return rest;
20
+ }
21
+
22
+ // CAPABILITY-ONLY sandbox seam (default-on isolation, step iv-b). When
23
+ // opts.sandbox is truthy the child is created through the sandbox dispatcher
24
+ // (containment ADDED); a null/absent spec keeps the byte-identical bare spawn
25
+ // path. _spawnSandboxed is a test injection seam — defaults to the real impl.
12
26
  function runCmd(cmd, args, opts = {}) {
13
27
  return new Promise((resolve) => {
14
- const child = spawn(cmd, args, opts);
28
+ const child = opts.sandbox
29
+ ? (opts._spawnSandboxed || spawnSandboxed)(opts.sandbox, cmd, args, spawnOptsOf(opts))
30
+ : spawn(cmd, args, opts);
15
31
  let out = '', err = '';
16
32
  child.stdout?.on('data', d => out += d.toString());
17
33
  child.stderr?.on('data', d => err += d.toString());
@@ -21,19 +37,38 @@ function runCmd(cmd, args, opts = {}) {
21
37
  });
22
38
  }
23
39
 
40
+ // Synchronous mirror of runCmd. When opts.sandbox is truthy the invocation is
41
+ // routed through spawnSyncSandboxed (injectable via opts._spawnSyncSandboxed);
42
+ // otherwise the bare spawnSync path is byte-identical to the historical call.
43
+ function runCmdSync(cmd, args, opts = {}) {
44
+ return opts.sandbox
45
+ ? (opts._spawnSyncSandboxed || spawnSyncSandboxed)(opts.sandbox, cmd, args, spawnOptsOf(opts))
46
+ : spawnSync(cmd, args, spawnOptsOf(opts));
47
+ }
48
+
49
+ // Pull the sandbox seam out of ctx so each tool can thread it uniformly.
50
+ function sandboxSeam(ctx) {
51
+ return {
52
+ sandbox: ctx?.sandbox,
53
+ _spawnSandboxed: ctx?._spawnSandboxed,
54
+ _spawnSyncSandboxed: ctx?._spawnSyncSandboxed,
55
+ };
56
+ }
57
+
24
58
  const clipboard_read = {
25
59
  name: 'clipboard_read', category: 'os', sensitive: true,
26
60
  description: 'Read the OS clipboard (text).',
27
61
  parameters: { type: 'object', properties: {} },
28
62
  async exec(_args, ctx) {
29
63
  const p = platformOf(ctx);
64
+ const seam = sandboxSeam(ctx);
30
65
  if (p === 'darwin') {
31
- const r = spawnSync('pbpaste', [], { encoding: 'utf8' });
66
+ const r = runCmdSync('pbpaste', [], { encoding: 'utf8', ...seam });
32
67
  return r.status === 0 ? { ok: true, text: r.stdout } : { ok: false, error: r.stderr || 'pbpaste failed' };
33
68
  }
34
69
  if (p === 'linux') {
35
70
  for (const [bin, args] of [['wl-paste', []], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]]) {
36
- const r = spawnSync(bin, args, { encoding: 'utf8' });
71
+ const r = runCmdSync(bin, args, { encoding: 'utf8', ...seam });
37
72
  if (r.status === 0) return { ok: true, text: r.stdout };
38
73
  }
39
74
  return { ok: false, error: 'clipboard_read: no clipboard helper (install wl-clipboard or xclip)' };
@@ -48,10 +83,11 @@ const clipboard_write = {
48
83
  parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
49
84
  async exec(args, ctx) {
50
85
  const p = platformOf(ctx);
51
- if (p === 'darwin') return runCmd('pbcopy', [], { stdin: args.text });
86
+ const seam = sandboxSeam(ctx);
87
+ if (p === 'darwin') return runCmd('pbcopy', [], { stdin: args.text, ...seam });
52
88
  if (p === 'linux') {
53
89
  for (const [bin, ar] of [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]]) {
54
- const r = await runCmd(bin, ar, { stdin: args.text });
90
+ const r = await runCmd(bin, ar, { stdin: args.text, ...seam });
55
91
  if (r.ok) return { ok: true };
56
92
  }
57
93
  return { ok: false, error: 'clipboard_write: no clipboard helper' };
@@ -67,13 +103,14 @@ const screenshot = {
67
103
  async exec(args, ctx) {
68
104
  const p = platformOf(ctx);
69
105
  const out = args?.path || path.join(os.tmpdir(), `lzc-${Date.now()}.png`);
106
+ const seam = sandboxSeam(ctx);
70
107
  if (p === 'darwin') {
71
- const r = spawnSync('screencapture', ['-x', out]);
108
+ const r = runCmdSync('screencapture', ['-x', out], { ...seam });
72
109
  return r.status === 0 ? { ok: true, path: out } : { ok: false, error: 'screencapture failed' };
73
110
  }
74
111
  if (p === 'linux') {
75
112
  for (const [bin, ar] of [['grim', [out]], ['gnome-screenshot', ['-f', out]], ['scrot', [out]]]) {
76
- const r = spawnSync(bin, ar);
113
+ const r = runCmdSync(bin, ar, { ...seam });
77
114
  if (r.status === 0) return { ok: true, path: out };
78
115
  }
79
116
  return { ok: false, error: 'screenshot: no helper found (grim/gnome-screenshot/scrot)' };
@@ -92,14 +129,15 @@ const notify = {
92
129
  },
93
130
  async exec(args, ctx) {
94
131
  const p = platformOf(ctx);
132
+ const seam = sandboxSeam(ctx);
95
133
  const title = args.title;
96
134
  const body = args.body || '';
97
135
  if (p === 'darwin') {
98
- spawnSync('osascript', ['-e', `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`]);
136
+ runCmdSync('osascript', ['-e', `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`], { ...seam });
99
137
  return { ok: true };
100
138
  }
101
139
  if (p === 'linux') {
102
- spawnSync('notify-send', [title, body]);
140
+ runCmdSync('notify-send', [title, body], { ...seam });
103
141
  return { ok: true };
104
142
  }
105
143
  return { ok: false, error: `notify: unsupported platform ${p}` };
@@ -116,12 +154,29 @@ const open_url = {
116
154
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: 'open_url: http(s) only' };
117
155
  } catch { return { ok: false, error: 'open_url: bad URL' }; }
118
156
  const p = platformOf(ctx);
119
- if (p === 'darwin') return runCmd('open', [args.url]);
120
- if (p === 'linux') return runCmd('xdg-open', [args.url]);
157
+ const seam = sandboxSeam(ctx);
158
+ if (p === 'darwin') return runCmd('open', [args.url], { ...seam });
159
+ if (p === 'linux') return runCmd('xdg-open', [args.url], { ...seam });
121
160
  return { ok: false, error: `open_url: unsupported platform ${p}` };
122
161
  },
123
162
  };
124
163
 
164
+ // Build the osascript `choose file` expression. The prompt is model-supplied
165
+ // and gets interpolated into an AppleScript STRING literal, so it must escape
166
+ // backslashes (first) and double-quotes, and strip newlines/control chars — a
167
+ // raw `"`/`\`/newline would otherwise break out of the literal and run an
168
+ // injected AppleScript expression (e.g. `do shell script`). Exported for tests.
169
+ export function _buildChooseScript(kind, prompt) {
170
+ const fallback = kind === 'save' ? 'Save' : 'Choose';
171
+ const safe = String(prompt || fallback)
172
+ .replace(/[\r\n\t\v\f\0]+/g, ' ')
173
+ .replace(/\\/g, '\\\\')
174
+ .replace(/"/g, '\\"');
175
+ return kind === 'save'
176
+ ? `POSIX path of (choose file name with prompt "${safe}")`
177
+ : `POSIX path of (choose file with prompt "${safe}")`;
178
+ }
179
+
125
180
  const file_dialog = {
126
181
  name: 'file_dialog', category: 'os', sensitive: true,
127
182
  description: 'Show an OS file picker. Returns selected path (or null on cancel).',
@@ -131,17 +186,16 @@ const file_dialog = {
131
186
  },
132
187
  async exec(args, ctx) {
133
188
  const p = platformOf(ctx);
189
+ const seam = sandboxSeam(ctx);
134
190
  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' });
191
+ const script = _buildChooseScript(args.kind, args.prompt);
192
+ const r = runCmdSync('osascript', ['-e', script], { encoding: 'utf8', ...seam });
139
193
  if (r.status !== 0) return { ok: true, path: null };
140
194
  return { ok: true, path: r.stdout.trim() };
141
195
  }
142
196
  if (p === 'linux') {
143
197
  const ar = args.kind === 'save' ? ['--file-selection', '--save'] : ['--file-selection'];
144
- const r = spawnSync('zenity', ar, { encoding: 'utf8' });
198
+ const r = runCmdSync('zenity', ar, { encoding: 'utf8', ...seam });
145
199
  if (r.status !== 0) return { ok: true, path: null };
146
200
  return { ok: true, path: r.stdout.trim() };
147
201
  }
@@ -46,6 +46,13 @@ function _defaultConfigDir() {
46
46
  return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
47
47
  }
48
48
 
49
+ // Read config.json from the tool's own configDir (not the env), so hybrid
50
+ // recall reads the right config when the dir is passed explicitly. Best-effort.
51
+ function _readCfg(configDir) {
52
+ try { return JSON.parse(fs.readFileSync(path.join(configDir || _defaultConfigDir(), 'config.json'), 'utf8')); }
53
+ catch { return {}; }
54
+ }
55
+
49
56
  // Minimal frontmatter parser — read just the cross_cli_tested provider
50
57
  // list for a skill by name. Returns [] when the file is missing or
51
58
  // parse fails (best-effort ranking helper, not a strict loader).
@@ -105,8 +112,21 @@ export async function exec(args, { configDir } = {}) {
105
112
  } catch (err) {
106
113
  return { ok: false, error: `recall: openIndex failed — ${err?.message || err}` };
107
114
  }
115
+ // Hybrid recall: when cfg.recall.embeddings is enabled, embed the query and
116
+ // pass the vector so index_db re-ranks candidates by semantic similarity.
117
+ // Opt-in + best-effort — any embed failure falls back to pure FTS5.
118
+ let queryVector, weights;
119
+ try {
120
+ const cfg = _readCfg(configDir);
121
+ const { getEmbedder } = await import('../embedder.mjs');
122
+ const embedder = getEmbedder(cfg);
123
+ if (embedder) {
124
+ const [v] = await embedder.embed([query]);
125
+ if (v && v.length) { queryVector = v; weights = cfg.recall?.embeddings?.weights; }
126
+ }
127
+ } catch { /* fall back to pure FTS */ }
108
128
  try {
109
- out = indexRecall(query, { configDir, scope: scopes, k });
129
+ out = indexRecall(query, { configDir, scope: scopes, k, ...(queryVector ? { queryVector, weights } : {}) });
110
130
  } catch (err) {
111
131
  return { ok: false, error: `recall: query failed — ${err?.message || err}` };
112
132
  }
@@ -120,7 +140,13 @@ export async function exec(args, { configDir } = {}) {
120
140
  const meta = h.metadata || {};
121
141
  for (const key of filterKeys) {
122
142
  if (key === 'since') {
123
- if (Number(meta.ts || 0) < Number(filter.since)) return false;
143
+ // Only fts_sessions carries a ts column; skills/trajectories/
144
+ // memories have no ts. A "since" bound must NOT silently drop
145
+ // those ts-less hits — only drop a hit that HAS a ts older than
146
+ // the bound. (meta.ts === undefined / '' means "no timestamp".)
147
+ if (meta.ts !== undefined && meta.ts !== null && meta.ts !== '') {
148
+ if (Number(meta.ts) < Number(filter.since)) return false;
149
+ }
124
150
  } else if (String(meta[key] ?? '') !== String(filter[key])) {
125
151
  return false;
126
152
  }
package/mcp/client.mjs CHANGED
@@ -59,7 +59,14 @@ export async function startServer({ name, command, args = [], env = {}, allowGlo
59
59
  .filter(c => c.type === 'text')
60
60
  .map(c => c.text)
61
61
  .join('\n');
62
- return { ok: true, text, raw: res };
62
+ // MCP reports TOOL-level failures as { isError: true } WITHOUT
63
+ // throwing (only protocol/transport faults throw). Surface it as a
64
+ // failure so the agent doesn't reason forward on a failed call.
65
+ if (res?.isError) {
66
+ return { ok: false, error: `${toolName}: ${text || 'tool reported isError'}`, raw: res };
67
+ }
68
+ // Pass structuredContent through when the server provides it.
69
+ return { ok: true, text, structured: res?.structuredContent, raw: res };
63
70
  } catch (e) {
64
71
  return { ok: false, error: `${toolName}: ${e.message}` };
65
72
  }
@@ -12,7 +12,11 @@ export async function startConfigured(cfg) {
12
12
  const servers = cfg?.mcp?.servers || [];
13
13
  const results = [];
14
14
  for (const s of servers) {
15
- try { results.push(await startServer(s)); }
15
+ // §8: MCP tools invoke external server code, so they MUST stay behind the
16
+ // fail-closed approval gate. Force sensitive:true at boot — config can
17
+ // narrow exposure (allowGlob) but can never downgrade an MCP tool to
18
+ // ungated. A config-supplied sensitive:false is ignored, not honored.
19
+ try { results.push(await startServer({ ...s, sensitive: true })); }
16
20
  catch (e) { results.push({ ok: false, name: s.name, error: e.message }); }
17
21
  }
18
22
  return results;
package/memory.mjs CHANGED
@@ -18,11 +18,22 @@
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import os from 'node:os';
21
+ import { tightenIfLoose } from './secure_write.mjs';
21
22
 
22
23
  const MEMORY_DIRNAME = 'memory';
23
24
  const RECENT_CAP = 200;
24
25
  const RECENT_KEEP_AFTER_DREAM = 50;
25
26
 
27
+ // Best-effort, lazy write-through into the durable FTS recall index. Lazy so
28
+ // better-sqlite3 stays off the chat hot path (this leaf module is touched on
29
+ // every turn via appendRecent) until something actually curates memory; the
30
+ // promise is swallowed so an index hiccup never breaks a memory write.
31
+ function _indexMemorySafe(row, configDir) {
32
+ import('./mas/index_db.mjs')
33
+ .then((idx) => { try { idx.indexMemory(row, configDir); } catch { /* best-effort */ } })
34
+ .catch(() => { /* index module unavailable — recall just stays stale */ });
35
+ }
36
+
26
37
  export function defaultConfigDir() {
27
38
  return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
28
39
  }
@@ -41,6 +52,10 @@ export function loadCore(configDir = defaultConfigDir()) {
41
52
  export function setCore(text, configDir = defaultConfigDir()) {
42
53
  fs.mkdirSync(memoryDir(configDir), { recursive: true });
43
54
  fs.writeFileSync(corePath(configDir), String(text || ''));
55
+ tightenIfLoose(corePath(configDir)); // curated memory may hold pasted secrets → owner-only
56
+ // Mirror into the recall index so curated core memory is durably searchable
57
+ // (the README "durable recall over … memory" claim) — not just readable.
58
+ _indexMemorySafe({ topic: 'core', kind: 'core', content: String(text || '') }, configDir);
44
59
  }
45
60
 
46
61
  export function loadRecent(n = 20, configDir = defaultConfigDir()) {
@@ -78,6 +93,7 @@ export function appendRecent(sessionId, role, content, configDir = defaultConfig
78
93
  ts: Date.now(),
79
94
  }) + '\n';
80
95
  fs.appendFileSync(recentPath(configDir), line);
96
+ tightenIfLoose(recentPath(configDir)); // recency log may hold pasted secrets → owner-only
81
97
  // Cheap stat-based check to avoid read-rewrite on every append.
82
98
  const st = fs.statSync(recentPath(configDir));
83
99
  if (st.size > 1_000_000) {
@@ -125,11 +141,18 @@ export async function dream(sessionId, { provider, model, apiKey } = {}, configD
125
141
 
126
142
  fs.mkdirSync(episodicDir(configDir), { recursive: true });
127
143
  const written = [];
144
+ // Index the episodic topics in the same pass. dream() is async, so we await
145
+ // the (lazy) index import once for a deterministic write-through; a failure
146
+ // is swallowed so consolidation still succeeds even if recall can't index.
147
+ let indexDb = null;
148
+ try { indexDb = await import('./mas/index_db.mjs'); } catch { /* recall stays stale */ }
128
149
  for (const t of topics) {
129
150
  if (!t || !t.topic) continue;
130
151
  const slug = String(t.topic).toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'untitled';
131
152
  const p = path.join(episodicDir(configDir), `${slug}.md`);
132
153
  fs.writeFileSync(p, `# ${slug}\n\n${t.summary || ''}\n`);
154
+ tightenIfLoose(p); // episodic summaries are conversation-derived → owner-only
155
+ if (indexDb) { try { indexDb.indexMemory({ topic: slug, kind: 'episodic', content: String(t.summary || '') }, configDir); } catch { /* best-effort */ } }
133
156
  written.push(slug);
134
157
  }
135
158
 
@@ -139,6 +162,7 @@ export async function dream(sessionId, { provider, model, apiKey } = {}, configD
139
162
  const lines = fs.readFileSync(rp, 'utf8').split('\n').filter(Boolean);
140
163
  if (lines.length > RECENT_KEEP_AFTER_DREAM) {
141
164
  fs.writeFileSync(rp, lines.slice(-RECENT_KEEP_AFTER_DREAM).join('\n') + '\n');
165
+ tightenIfLoose(rp);
142
166
  }
143
167
  }
144
168
  return { topics: written };