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
package/lib/args.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // dispatcher in main() is the runtime authority; this list mirrors it.
9
9
  export const SUBCOMMANDS = [
10
10
  'run', 'resume', 'inspect', 'clear', 'validate', 'graph',
11
+ 'workflow',
11
12
  'config', 'chat', 'agent',
12
13
  'doctor', 'status', 'onboard',
13
14
  'sessions', 'skills', 'providers',
@@ -32,14 +33,20 @@ export const SUBCOMMANDS = [
32
33
  'matrix',
33
34
  // v5.0 — channels plugin loader (Phase F)
34
35
  'channels',
35
- // v4.1.0 — multi-agent slack system (Phase 9+)
36
- 'agent', 'team', 'task',
36
+ // v4.1.0 — multi-agent slack system (Phase 9+); 'agent' already listed above
37
+ 'team', 'task',
37
38
  // v5.0 Phase G — persona compose + cross-tool import (spec §9, §10)
38
39
  'personality', 'migrate', 'hermes', 'openclaw',
39
40
  // v5.0.6 — arrow-key launcher menu (was the no-arg default in v5.0.5-)
40
41
  'menu',
41
42
  // v5.0 Phase H1 — trajectory exporter (spec §2.7)
42
43
  'trajectories',
44
+ // FTS index maintenance — `index rebuild` is the documented recovery
45
+ // surface (mas/index_db.mjs, doctor failure-log) for a stale/corrupt index.
46
+ 'index',
47
+ // MCP — read-only inspection of configured/connected MCP servers. The
48
+ // servers themselves boot from cfg.mcp.servers at daemon start.
49
+ 'mcp',
43
50
  ];
44
51
 
45
52
  export const SUBCOMMAND_SUBS = {
@@ -52,7 +59,7 @@ export const SUBCOMMAND_SUBS = {
52
59
  completion: ['bash', 'zsh'],
53
60
  auth: ['list', 'add', 'remove', 'use', 'rotate'],
54
61
  pairing: ['list', 'add', 'remove'],
55
- nodes: ['list', 'register', 'remove', 'pending', 'approve', 'revoke', 'devices'],
62
+ nodes: ['list', 'register', 'remove', 'pending', 'approve', 'revoke', 'rotate', 'devices'],
56
63
  message: ['list', 'add', 'remove', 'send'],
57
64
  workspace: ['list', 'init', 'show', 'remove', 'path'],
58
65
  cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
@@ -67,6 +74,9 @@ export const SUBCOMMAND_SUBS = {
67
74
  team: ['add', 'list', 'show', 'edit', 'remove'],
68
75
  task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
69
76
  trajectories: ['export'],
77
+ index: ['rebuild', 'embed'],
78
+ workflow: ['list', 'show', 'add', 'remove', 'run'],
79
+ mcp: ['list', 'add', 'remove', 'call'],
70
80
  };
71
81
 
72
82
  // Flags whose presence is the signal — they don't consume the next arg
@@ -104,6 +114,62 @@ export const BOOLEAN_FLAGS = new Set([
104
114
  'force', // goal tick --force: bypass schedule when invoked manually
105
115
  ]);
106
116
 
117
+ // Levenshtein edit distance — small iterative DP, no allocation per char
118
+ // beyond two rows. Used by nearest() to power the "did you mean" suggestion
119
+ // on an unknown subcommand. Kept here (pure leaf) so cli.mjs and tests share it.
120
+ function editDistance(a, b) {
121
+ const m = a.length, n = b.length;
122
+ if (m === 0) return n;
123
+ if (n === 0) return m;
124
+ let prev = new Array(n + 1);
125
+ let cur = new Array(n + 1);
126
+ for (let j = 0; j <= n; j++) prev[j] = j;
127
+ for (let i = 1; i <= m; i++) {
128
+ cur[0] = i;
129
+ for (let j = 1; j <= n; j++) {
130
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
131
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
132
+ }
133
+ [prev, cur] = [cur, prev];
134
+ }
135
+ return prev[n];
136
+ }
137
+
138
+ // Closest known subcommand for `input`, or null when nothing is close enough.
139
+ // "Close" = a clear (unambiguous) prefix match, OR edit distance <= 2 with a
140
+ // single best candidate. Returns the exact entry from `candidates` so callers
141
+ // can echo a canonical spelling in the suggestion.
142
+ export function nearest(input, candidates) {
143
+ if (!input) return null;
144
+ const word = String(input).toLowerCase();
145
+ // Exact prefix wins when it's unambiguous (e.g. "provid" -> "providers").
146
+ const prefixHits = candidates.filter(c => c.toLowerCase().startsWith(word) && c.toLowerCase() !== word);
147
+ if (prefixHits.length === 1) return prefixHits[0];
148
+ let best = null, bestDist = Infinity, tie = false;
149
+ for (const c of candidates) {
150
+ const d = editDistance(word, c.toLowerCase());
151
+ if (d < bestDist) { bestDist = d; best = c; tie = false; }
152
+ else if (d === bestDist) tie = true;
153
+ }
154
+ // Gate on distance <= 2 and require a unique winner to avoid a misleading
155
+ // suggestion when several commands are equidistant from the typo.
156
+ if (best && bestDist <= 2 && !tie) return best;
157
+ return null;
158
+ }
159
+
160
+ // One-line usage hint for a known subcommand, built from the inventory we
161
+ // already own here (SUBCOMMANDS + SUBCOMMAND_SUBS). cli.mjs uses this as the
162
+ // fallback for `lazyclaw help <name>` when the subcommand has no rich entry in
163
+ // commands/setup.mjs's HELP_DETAILS — so help never errors on a real command.
164
+ // Returns null for an unknown name (the caller then offers a did-you-mean).
165
+ export function usageHint(name) {
166
+ if (!SUBCOMMANDS.includes(name)) return null;
167
+ const subs = SUBCOMMAND_SUBS[name];
168
+ return subs
169
+ ? `Usage: lazyclaw ${name} <${subs.join('|')}> ...`
170
+ : `Usage: lazyclaw ${name} ...`;
171
+ }
172
+
107
173
  export function parseArgs(argv) {
108
174
  const out = { positional: [], flags: {} };
109
175
  for (let i = 0; i < argv.length; i++) {
package/lib/config.mjs CHANGED
@@ -17,20 +17,102 @@ export function configPath() {
17
17
  return path.join(dir, 'config.json');
18
18
  }
19
19
 
20
+ // Thrown when config.json EXISTS but is not parseable JSON. We fail fast here
21
+ // instead of returning {} so a typo in the file can't masquerade as a fresh
22
+ // install (silently dropping provider/keys/channels) and — critically — so a
23
+ // downstream writeConfig never clobbers the recoverable, hand-editable bytes.
24
+ export class ConfigError extends Error {
25
+ constructor(message, { path: p, cause } = {}) {
26
+ super(message);
27
+ this.name = 'ConfigError';
28
+ this.path = p;
29
+ if (cause) this.cause = cause;
30
+ }
31
+ }
32
+
33
+ // mtime-keyed parse cache. readConfig has ~97 callers and is hit on every
34
+ // daemon HTTP request / dashboard poll / orchestrator turn; re-reading +
35
+ // re-parsing the file each time is pure waste. Cache the parsed object keyed by
36
+ // (path, mtimeMs) and hand out an independent clone so a caller mutating its
37
+ // copy can't corrupt the cache. writeConfig() invalidates; a write from another
38
+ // process is seen via the mtime check.
39
+ let _cfgCache = null; // { path, mtimeMs, parsed }
40
+ export function _invalidateConfigCache() { _cfgCache = null; }
41
+
20
42
  export function readConfig() {
21
43
  const p = configPath();
22
- if (!fs.existsSync(p)) return {};
44
+ if (!fs.existsSync(p)) { _cfgCache = null; return {}; }
23
45
  // Migrate already-deployed world/group-readable config.json (plaintext keys)
24
46
  // to 0600 the first time we touch it. Best-effort, idempotent.
25
47
  tightenIfLoose(p);
26
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
27
- catch { return {}; }
48
+ let mtimeMs = null;
49
+ try { mtimeMs = fs.statSync(p).mtimeMs; } catch { /* fall through to a fresh read */ }
50
+ if (_cfgCache && _cfgCache.path === p && mtimeMs != null && _cfgCache.mtimeMs === mtimeMs) {
51
+ return structuredClone(_cfgCache.parsed);
52
+ }
53
+ try {
54
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
55
+ if (mtimeMs != null) _cfgCache = { path: p, mtimeMs, parsed };
56
+ return structuredClone(parsed);
57
+ }
58
+ catch (e) {
59
+ // PRESENT-BUT-CORRUPT: never return {} (would look fresh and let a later
60
+ // writeConfig overwrite the file). Warn loudly + actionably, then throw so
61
+ // the CLI boot boundary fails fast with the settings still on disk.
62
+ process.stderr.write(
63
+ `\nlazyclaw: config.json is present but not valid JSON — refusing to ` +
64
+ `continue so your settings aren't lost.\n` +
65
+ ` path: ${p}\n` +
66
+ ` error: ${e.message}\n` +
67
+ ` recover: fix the JSON, or move it aside ` +
68
+ `(e.g. \`mv ${p} ${p}.bak\`) to start fresh.\n\n`,
69
+ );
70
+ throw new ConfigError(`config.json at ${p} is not valid JSON: ${e.message}`, { path: p, cause: e });
71
+ }
28
72
  }
29
73
 
30
74
  export function writeConfig(cfg) {
31
75
  // 0600 file in a 0700 dir, atomically — config.json stores plaintext API
32
76
  // keys / auth profiles, so it must be owner-only on disk.
33
77
  writeJsonSecure(configPath(), cfg);
78
+ _cfgCache = null; // invalidate the read cache after a same-process write
79
+ }
80
+
81
+ // Persist the active model selection to disk so it survives a restart
82
+ // (read-merge-write keeps unrelated keys). An empty/null name clears cfg.model
83
+ // so the provider falls back to its own default. Mirrors onto the in-memory
84
+ // `liveCfg` so the running session sees it without a re-read. Best-effort: a
85
+ // write failure must not break the in-memory switch the caller already made.
86
+ //
87
+ // Why this exists: /model (and the legacy readline path) historically mutated
88
+ // the in-memory active model only, so a model picked in one session reverted to
89
+ // cfg.model on the next launch. Routing the setter through here makes the
90
+ // choice stick.
91
+ export function persistActiveModel(liveCfg, name) {
92
+ try {
93
+ const c = readConfig();
94
+ if (name) c.model = name; else delete c.model;
95
+ writeConfig(c);
96
+ if (liveCfg && typeof liveCfg === 'object') {
97
+ if (name) liveCfg.model = name; else delete liveCfg.model;
98
+ }
99
+ } catch { /* best-effort — keep the in-memory selection */ }
100
+ }
101
+
102
+ // Persist the active provider, with one guard: orchestrator routing is owned by
103
+ // `/orchestrator on|off` (which stashes _prevProvider), so the model/provider
104
+ // setters must never write 'orchestrator' themselves nor clobber a saved
105
+ // 'orchestrator' provider. For ordinary provider switches it read-merge-writes
106
+ // cfg.provider and mirrors onto liveCfg.
107
+ export function persistActiveProvider(liveCfg, name) {
108
+ if (!name || name === 'orchestrator') return;
109
+ try {
110
+ const c = readConfig();
111
+ if (c.provider === 'orchestrator') return; // orchestrator active — leave its routing alone
112
+ c.provider = name;
113
+ writeConfig(c);
114
+ if (liveCfg && typeof liveCfg === 'object') liveCfg.provider = name;
115
+ } catch { /* best-effort */ }
34
116
  }
35
117
 
36
118
  // Injected built-in OpenAI-compat env-var resolver. registry_boot wires this
package/lib/render.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // lib/render.mjs — tiny shared pretty-printer for record-shaped command output.
2
+ // Turns an object into readable `key: value` lines instead of a raw JSON dump,
3
+ // so the *show handlers (/agent show, /team show, …) read cleanly in the chat
4
+ // scrollback. Pure, no imports.
5
+
6
+ // Format one value for a single line: empties → '(none)', arrays → [a, b],
7
+ // one-level objects → {k: v, …}, scalars → String(v), deeper → compact JSON.
8
+ function fmtValue(v) {
9
+ if (v === null || v === undefined || v === '') return '(none)';
10
+ if (Array.isArray(v)) return v.length ? `[${v.join(', ')}]` : '[]';
11
+ if (typeof v === 'object') {
12
+ const inner = Object.keys(v).map((k) => {
13
+ const iv = v[k];
14
+ const s = (iv && typeof iv === 'object') ? JSON.stringify(iv) : String(iv);
15
+ return `${k}: ${s}`;
16
+ });
17
+ return `{${inner.join(', ')}}`;
18
+ }
19
+ return String(v);
20
+ }
21
+
22
+ /**
23
+ * Render an object as `key: value` lines.
24
+ * @param {object} obj
25
+ * @param {{fields?: string[], hide?: string[]|Set<string>}} [opts]
26
+ * fields: explicit key order (keys absent on obj are skipped);
27
+ * hide: keys to omit (when fields not given).
28
+ * @returns {string}
29
+ */
30
+ export function renderRecord(obj, { fields, hide } = {}) {
31
+ if (!obj || typeof obj !== 'object') return String(obj);
32
+ const skip = hide instanceof Set ? hide : new Set(hide || []);
33
+ const keys = Array.isArray(fields)
34
+ ? fields.filter((k) => Object.prototype.hasOwnProperty.call(obj, k))
35
+ : Object.keys(obj).filter((k) => !skip.has(k));
36
+ return keys.map((k) => `${k}: ${fmtValue(obj[k])}`).join('\n');
37
+ }
38
+
39
+ // Convenience: raw JSON when `raw` is true, else the pretty render. Lets each
40
+ // handler offer a `<sub> <name> json` escape hatch in one line.
41
+ export function renderRecordOrJson(obj, opts, raw) {
42
+ return raw ? JSON.stringify(obj, null, 2) : renderRecord(obj, opts);
43
+ }
@@ -190,7 +190,21 @@ export function serviceStatus(spec, depsIn = {}) {
190
190
  const p = servicePaths(name, { home, configDir });
191
191
 
192
192
  if (backend === 'launchd') {
193
- return { backend, installed: deps.fs.existsSync(p.launchd), target: p.launchd };
193
+ const installed = deps.fs.existsSync(p.launchd);
194
+ // `launchctl list <label>` exits non-zero when the service isn't loaded
195
+ // and otherwise prints a dict that carries `"PID" = <n>;` only while the
196
+ // process is actually running (a loaded-but-idle KeepAlive job omits it).
197
+ // Parse that to report running+pid like the systemd/fallback branches.
198
+ const label = `com.lazyclaw.${name}`;
199
+ const r = deps.spawnSync('launchctl', ['list', label], { encoding: 'utf8' });
200
+ const loaded = !!r && r.status === 0;
201
+ let pid = null;
202
+ if (loaded && typeof r.stdout === 'string') {
203
+ const m = r.stdout.match(/"PID"\s*=\s*(\d+)/);
204
+ if (m) pid = parseInt(m[1], 10);
205
+ }
206
+ const running = pid != null && deps.isAlive(pid);
207
+ return { backend, installed, running, pid, target: p.launchd };
194
208
  }
195
209
  if (backend === 'systemd') {
196
210
  const installed = deps.fs.existsSync(p.systemd);
@@ -3,13 +3,13 @@
3
3
  // the model emits a final text reply (or the iteration budget runs
4
4
  // out).
5
5
  //
6
- // Provider routing:
7
- // anthropic → providers/tool_use/anthropic.mjs (Phase 12b)
8
- // openai → providers/tool_use/openai.mjs (Phase 12c — todo)
9
- // gemini → providers/tool_use/gemini.mjs (Phase 12d — todo)
10
- // claude-cli → not supported (subprocess provider Phase 12 scope
11
- // excludes it; runAgentTurn throws so callers can flag
12
- // the agent in the dashboard).
6
+ // Provider routing (all wired — see adapterFor below):
7
+ // anthropic → providers/tool_use/anthropic.mjs
8
+ // openai → providers/tool_use/openai.mjs
9
+ // gemini → providers/tool_use/gemini.mjs
10
+ // claude-cli → providers/claude_cli.mjs; the tool-use loop runs INSIDE
11
+ // the binary, so the adapter normalises every reply to
12
+ // kind:'final' (no tool_calls envelope is ever observed).
13
13
  //
14
14
  // The loop:
15
15
  // 1. Build messages = [...history, {role:user, content:input}]
@@ -27,6 +27,7 @@ import * as gemini from '../providers/tool_use/gemini.mjs';
27
27
  import * as claudeCli from '../providers/tool_use/claude_cli.mjs';
28
28
  import { put as _trajPut } from './trajectory_store.mjs';
29
29
  import { composePromptStack } from './prompt_stack.mjs';
30
+ import { emit as emitEvent } from './events.mjs';
30
31
 
31
32
  export class AgentTurnError extends Error {
32
33
  constructor(message, code) {
@@ -95,6 +96,11 @@ export async function runAgentTurn({
95
96
  signal,
96
97
  approve,
97
98
  security,
99
+ // Optional parsed sandbox spec (or null). Threaded into runTool so the
100
+ // bash tool runs inside the sandbox when one is configured. Default null
101
+ // keeps existing callers/tests byte-stable; see scope_notes for which
102
+ // callers should pass this to fully activate sandboxing.
103
+ sandbox = null,
98
104
  // v5 (Group A — C3): trajectoryRef is OPT-OUT, not opt-in. A caller
99
105
  // that doesn't pass one but DOES pass a configDir gets a
100
106
  // default-stamped record so every production agent turn lands in
@@ -132,6 +138,8 @@ export async function runAgentTurn({
132
138
  cfgDir: configDir,
133
139
  agent,
134
140
  workspace: agent.workspace || '',
141
+ // Auto-inject top-k recalled context relevant to THIS user message.
142
+ query: userMessage,
135
143
  });
136
144
  if (stacked && stacked.trim()) systemPrompt = stacked;
137
145
  } catch { /* best-effort — never block a turn on stack composition */ }
@@ -152,6 +160,15 @@ export async function runAgentTurn({
152
160
  const toolCalls = [];
153
161
  let iterations = 0;
154
162
  let lastText = '';
163
+ // Accumulate token usage across every callOnce in this turn's tool loop so
164
+ // the caller (e.g. the team router → cost cap) can account for what the turn
165
+ // spent. Stays null until an adapter actually reports usage.
166
+ const usageTotal = {
167
+ inputTokens: 0, outputTokens: 0,
168
+ cacheCreationInputTokens: 0, cacheReadInputTokens: 0, totalCostUsd: 0,
169
+ };
170
+ let usageSeen = false;
171
+ const _usage = () => (usageSeen ? { ...usageTotal } : null);
155
172
  if (trajectoryRef && typeof trajectoryRef === 'object' && !trajectoryRef.startedAt) trajectoryRef.startedAt = Date.now();
156
173
 
157
174
  const _maybePersistTrajectory = async (outcome) => {
@@ -183,17 +200,37 @@ export async function runAgentTurn({
183
200
  };
184
201
 
185
202
  while (iterations < maxIterations) {
186
- if (signal?.aborted) return { text: lastText, iterations, stoppedBy: 'abort', toolCalls };
203
+ if (signal?.aborted) return { text: lastText, iterations, stoppedBy: 'abort', toolCalls, usage: _usage() };
187
204
  iterations++;
188
205
  const resp = await adapter.callOnce({
189
206
  messages, tools, model: agent.model, apiKey, system: systemPrompt,
190
207
  fetchImpl, baseUrl, signal, cache,
191
208
  });
192
209
  if (resp.text) lastText = resp.text;
210
+ if (resp.usage) {
211
+ usageTotal.inputTokens += resp.usage.inputTokens || 0;
212
+ usageTotal.outputTokens += resp.usage.outputTokens || 0;
213
+ usageTotal.cacheCreationInputTokens += resp.usage.cacheCreationInputTokens || 0;
214
+ usageTotal.cacheReadInputTokens += resp.usage.cacheReadInputTokens || 0;
215
+ usageTotal.totalCostUsd += resp.usage.totalCostUsd || 0;
216
+ usageSeen = true;
217
+ }
218
+
219
+ // The model hit its output token ceiling: the final answer or the tool
220
+ // call(s) are partial/garbage. Stop with an explicit error rather than
221
+ // returning a cut-off answer or running an incomplete tool call.
222
+ if (resp.truncated) {
223
+ await _maybePersistTrajectory('error');
224
+ return {
225
+ text: lastText, iterations, stoppedBy: 'truncated',
226
+ error: 'response truncated at the model output token limit (raise maxTokens)',
227
+ toolCalls, usage: _usage(),
228
+ };
229
+ }
193
230
 
194
231
  if (resp.kind === 'final') {
195
232
  await _maybePersistTrajectory('done');
196
- return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls };
233
+ return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls, usage: _usage() };
197
234
  }
198
235
 
199
236
  // tool_calls path: echo the model's assistant turn back so future
@@ -216,10 +253,21 @@ export async function runAgentTurn({
216
253
  for (const call of resp.calls) {
217
254
  let result;
218
255
  let ok = true;
256
+ // The adapter could not parse this tool call's arguments (e.g. OpenAI
257
+ // emitted malformed JSON). Surface a tool error so the model can retry
258
+ // instead of silently running the tool with empty input.
259
+ if (call.parseError) {
260
+ result = { ok: false, error: call.parseError };
261
+ toolCalls.push({ id: call.id, name: call.name, input: call.input, result, ok: false });
262
+ emitEvent('tool.call', { taskId, agent: agent.name, tool: call.name, ok: false });
263
+ results.push({ id: call.id, content: result, isError: true });
264
+ toolErrored = true;
265
+ continue;
266
+ }
219
267
  try {
220
268
  result = await runTool({
221
269
  agent, tool: call.name, args: call.input,
222
- taskId, configDir, cwd, approve, security,
270
+ taskId, configDir, cwd, approve, security, sandbox,
223
271
  });
224
272
  if (result && result.ok === false) ok = false;
225
273
  } catch (err) {
@@ -231,6 +279,7 @@ export async function runAgentTurn({
231
279
  }
232
280
  }
233
281
  toolCalls.push({ id: call.id, name: call.name, input: call.input, result, ok });
282
+ emitEvent('tool.call', { taskId, agent: agent.name, tool: call.name, ok });
234
283
  results.push({ id: call.id, content: result, isError: !ok });
235
284
  if (!ok) toolErrored = true;
236
285
  }
@@ -243,10 +292,10 @@ export async function runAgentTurn({
243
292
  // provider returned a malformed envelope) bails out here.
244
293
  if (toolErrored && process.env.LAZYCLAW_TOOL_STRICT === '1') {
245
294
  await _maybePersistTrajectory('failed');
246
- return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls };
295
+ return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls, usage: _usage() };
247
296
  }
248
297
  }
249
298
 
250
299
  await _maybePersistTrajectory('abandoned');
251
- return { text: lastText, iterations, stoppedBy: 'budget', toolCalls };
300
+ return { text: lastText, iterations, stoppedBy: 'budget', toolCalls, usage: _usage() };
252
301
  }
@@ -23,6 +23,20 @@ const Z = 1.96; // 95% confidence two-sided
23
23
 
24
24
  export const DEFAULT_CROSS_CLI_DAMPEN = 0.85;
25
25
 
26
+ // Below this many trials the 95% Wilson lower bound is punitively pessimistic
27
+ // (wilsonLowerBound(1,1)=0.206, under the 0.3 archive floor), so a brand-new
28
+ // skill from one successful task would be deleted on its first recall miss. For
29
+ // small n we use the Beta(1,1) posterior mean (Laplace rule of succession,
30
+ // (s+1)/(n+2)) — a gentle, data-aware "unproven" estimate that keeps a fresh
31
+ // success above the floor while still archiving a fresh failure (0/1 → 0.33).
32
+ export const MIN_TRIALS_FOR_WILSON = 3;
33
+
34
+ export function laplaceMean(successes, trials) {
35
+ const s = Math.max(0, Number(successes) || 0);
36
+ const n = Math.max(0, Number(trials) || 0);
37
+ return (s + 1) / (n + 2);
38
+ }
39
+
26
40
  export function wilsonLowerBound(successes, trials) {
27
41
  const s = Number(successes) || 0;
28
42
  const n = Number(trials) || 0;
@@ -83,8 +97,13 @@ export function computeConfidence({
83
97
  workerProvider = null,
84
98
  halfLifeMs,
85
99
  dampenFactor,
100
+ minTrials = MIN_TRIALS_FOR_WILSON,
86
101
  } = {}) {
87
- const base = wilsonLowerBound(successes, trials);
102
+ // Too few trials for a meaningful Wilson bound → use the unproven Laplace
103
+ // prior; otherwise the conservative 95% lower bound takes over.
104
+ const base = (Number(trials) || 0) < minTrials
105
+ ? laplaceMean(successes, trials)
106
+ : wilsonLowerBound(successes, trials);
88
107
  const decayed = base * recencyDecay(ageMs, halfLifeMs);
89
108
  const dampened = crossCliDampen(decayed, trainerProvider, workerProvider, dampenFactor);
90
109
  return Math.max(0, Math.min(1, dampened));
@@ -0,0 +1,100 @@
1
+ // mas/embedder.mjs — pluggable, opt-in text-embedding source for hybrid recall.
2
+ //
3
+ // Recall (mas/index_db.mjs) blends FTS5 bm25 with semantic similarity; the query
4
+ // + document vectors come from here. This is OFF by default: getEmbedder returns
5
+ // null unless cfg.recall.embeddings.enabled === true AND a source resolves, in
6
+ // which case recall rides today's pure-FTS5 path unchanged.
7
+ //
8
+ // HONESTY (§1, §9): the bare $0 chat-subscription user (claude-cli / gemini-cli /
9
+ // codex-cli spawn local chat binaries with no embed subcommand; the Anthropic
10
+ // API has no embeddings endpoint) has NO embedding source — getEmbedder returns
11
+ // null for them and they get pure FTS5. We deliberately do NOT bundle a heavy
12
+ // local model (onnxruntime + weights are 50-150MB) to fake a semantic path for
13
+ // a user who never asked for it. Embeddings light up only when a user opts in
14
+ // with an OpenAI/Gemini API key, or a local Ollama embed model (zero npm
15
+ // footprint — it talks to the user's own ollama server).
16
+
17
+ const DEFAULTS = {
18
+ openai: { model: 'text-embedding-3-small', dims: 1536, url: 'https://api.openai.com/v1/embeddings' },
19
+ gemini: { model: 'text-embedding-004', dims: 768, base: 'https://generativelanguage.googleapis.com/v1beta' },
20
+ ollama: { model: 'nomic-embed-text', dims: 768, url: 'http://127.0.0.1:11434/api/embeddings' },
21
+ };
22
+
23
+ let _override = null;
24
+ // Test seam: inject a fake embedder ({ id, dims, embed }) or a falsy value to
25
+ // force the null (pure-FTS) path. Pass undefined to clear the override.
26
+ export function __setEmbedder(fn) { _override = fn === undefined ? null : fn; }
27
+
28
+ function toF32(arr) {
29
+ return arr instanceof Float32Array ? arr : Float32Array.from(arr || []);
30
+ }
31
+
32
+ async function _openaiEmbed(texts, { model, apiKey, url, fetchImpl }) {
33
+ const res = await fetchImpl(url, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
36
+ body: JSON.stringify({ input: texts, model }),
37
+ });
38
+ if (!res.ok) throw new Error(`openai embeddings HTTP ${res.status}`);
39
+ const json = await res.json();
40
+ return (json.data || []).map((d) => toF32(d.embedding));
41
+ }
42
+
43
+ async function _geminiEmbed(texts, { model, apiKey, base, fetchImpl }) {
44
+ const url = `${base.replace(/\/$/, '')}/models/${model}:batchEmbedContents?key=${encodeURIComponent(apiKey)}`;
45
+ const res = await fetchImpl(url, {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/json' },
48
+ body: JSON.stringify({ requests: texts.map((t) => ({ model: `models/${model}`, content: { parts: [{ text: String(t) }] } })) }),
49
+ });
50
+ if (!res.ok) throw new Error(`gemini embeddings HTTP ${res.status}`);
51
+ const json = await res.json();
52
+ return (json.embeddings || []).map((e) => toF32(e.values));
53
+ }
54
+
55
+ async function _ollamaEmbed(texts, { model, url, fetchImpl }) {
56
+ // The classic /api/embeddings endpoint takes one prompt at a time.
57
+ const out = [];
58
+ for (const t of texts) {
59
+ const res = await fetchImpl(url, {
60
+ method: 'POST',
61
+ headers: { 'content-type': 'application/json' },
62
+ body: JSON.stringify({ model, prompt: String(t) }),
63
+ });
64
+ if (!res.ok) throw new Error(`ollama embeddings HTTP ${res.status}`);
65
+ const json = await res.json();
66
+ out.push(toF32(json.embedding));
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Resolve the configured embedding source, or null (→ pure FTS5 recall).
73
+ * @returns {{ id: string, dims: number, embed: (texts: string[]) => Promise<Float32Array[]> } | null}
74
+ */
75
+ export function getEmbedder(cfg, opts = {}) {
76
+ if (_override !== null) return _override;
77
+ const e = cfg?.recall?.embeddings;
78
+ if (!e || e.enabled !== true) return null;
79
+ const provider = String(e.provider || '').toLowerCase();
80
+ const d = DEFAULTS[provider];
81
+ if (!d) return null;
82
+ const model = e.model || d.model;
83
+ const dims = Number(e.dim) || d.dims;
84
+ const apiKey = e.apiKey || opts.apiKey || cfg['api-key'] || '';
85
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
86
+ if (typeof fetchImpl !== 'function') return null;
87
+ // openai/gemini need a key; ollama is keyless (local server).
88
+ if ((provider === 'openai' || provider === 'gemini') && !apiKey) return null;
89
+ return {
90
+ id: `${provider}/${model}`,
91
+ dims,
92
+ async embed(texts) {
93
+ const list = (Array.isArray(texts) ? texts : [texts]).map((t) => String(t ?? ''));
94
+ if (!list.length) return [];
95
+ if (provider === 'openai') return _openaiEmbed(list, { model, apiKey, url: e.url || d.url, fetchImpl });
96
+ if (provider === 'gemini') return _geminiEmbed(list, { model, apiKey, base: e.baseUrl || d.base, fetchImpl });
97
+ return _ollamaEmbed(list, { model, url: e.url || d.url, fetchImpl });
98
+ },
99
+ };
100
+ }
package/mas/events.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // mas/events.mjs — tiny in-process event bus for live agent activity.
2
+ //
3
+ // Zero-dependency pub/sub plus a bounded ring buffer for replay-on-connect.
4
+ // The daemon's GET /events SSE route subscribes here and streams events to the
5
+ // dashboard; mention_router / agent_turn / delegation emit as agents work.
6
+ //
7
+ // emit() NEVER throws into the caller: an agent turn must not break because a
8
+ // dashboard subscriber errored or because the bus is busy. Events are
9
+ // process-local — in the always-on gateway/daemon (where Slack-routed tasks run
10
+ // and SSE clients connect) they reach the dashboard; in a one-shot CLI process
11
+ // with no subscriber they are simply buffered and dropped, which is harmless.
12
+
13
+ const RING_MAX = 200;
14
+ const _subs = new Set();
15
+ const _ring = [];
16
+ let _seq = 0;
17
+
18
+ /**
19
+ * Publish an event. Returns the stamped event ({seq, ts, type, ...payload}).
20
+ * @param {string} type e.g. 'turn.start' | 'tool.call' | 'delegate'
21
+ * @param {object} [payload]
22
+ */
23
+ export function emit(type, payload = {}) {
24
+ const evt = { seq: ++_seq, ts: Date.now(), type, ...payload };
25
+ _ring.push(evt);
26
+ if (_ring.length > RING_MAX) _ring.shift();
27
+ for (const fn of _subs) {
28
+ try { fn(evt); } catch { /* a bad subscriber must never break emit */ }
29
+ }
30
+ return evt;
31
+ }
32
+
33
+ /**
34
+ * Subscribe to every subsequent event. Returns an unsubscribe function.
35
+ * @param {(evt: object) => void} fn
36
+ * @returns {() => void}
37
+ */
38
+ export function subscribe(fn) {
39
+ _subs.add(fn);
40
+ return () => { _subs.delete(fn); };
41
+ }
42
+
43
+ /**
44
+ * Replay buffered events, optionally only those newer than `sinceSeq`. Used by
45
+ * the SSE route so a freshly-connected dashboard converges to current state.
46
+ * @param {number} [sinceSeq]
47
+ */
48
+ export function recent(sinceSeq = 0) {
49
+ return sinceSeq ? _ring.filter((e) => e.seq > sinceSeq) : _ring.slice();
50
+ }
51
+
52
+ /** Test seam — clear subscribers + buffer + sequence. */
53
+ export function _reset() {
54
+ _subs.clear();
55
+ _ring.length = 0;
56
+ _seq = 0;
57
+ }