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/mas/index_db.mjs CHANGED
@@ -13,6 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import os from 'node:os';
15
15
  import { redactSecrets } from './redact.mjs';
16
+ import { f32ToBlob, blobToF32, blendHybrid } from './recall_blend.mjs';
16
17
 
17
18
  const SCHEMA_VERSION = 1;
18
19
  const _handles = new Map(); // configDir → { db, stmts }
@@ -64,6 +65,13 @@ function ensureSchema(db) {
64
65
  topic UNINDEXED, kind UNINDEXED
65
66
  );
66
67
  CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
68
+ -- Opt-in hybrid recall (roadmap #4): doc embeddings keyed to the FTS row's
69
+ -- implicit rowid + scope. Stored as a Float32 BLOB; cosine runs in JS (no
70
+ -- native vector extension). Empty/unused unless cfg.recall.embeddings is on.
71
+ CREATE TABLE IF NOT EXISTS embeddings (
72
+ scope TEXT, rowid_ref INTEGER, vec BLOB,
73
+ PRIMARY KEY (scope, rowid_ref)
74
+ );
67
75
  `);
68
76
  const cur = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
69
77
  if (!cur) {
@@ -95,26 +103,31 @@ function prepareStatements(db) {
95
103
  deleteSkill: db.prepare(`DELETE FROM fts_skills WHERE skill_name = ?`),
96
104
  deleteTrajectory: db.prepare(`DELETE FROM fts_trajectories WHERE trajectory_id = ?`),
97
105
  deleteMemory: db.prepare(`DELETE FROM fts_memories WHERE topic = ? AND kind = ?`),
106
+ // Hybrid recall: query/insert doc embeddings by (scope, FTS rowid).
107
+ getEmbedding: db.prepare(`SELECT vec FROM embeddings WHERE scope = ? AND rowid_ref = ?`),
108
+ putEmbedding: db.prepare(`INSERT OR REPLACE INTO embeddings(scope, rowid_ref, vec) VALUES (?, ?, ?)`),
98
109
  queries: {
110
+ // `rowid` (FTS5 implicit) is selected so a stored embedding can be joined
111
+ // back to its row for the cosine blend; recall() strips it from metadata.
99
112
  sessions: db.prepare(
100
113
  `SELECT 'sessions' AS scope, bm25(fts_sessions) AS bm25,
101
114
  snippet(fts_sessions, 0, '<mark>', '</mark>', '...', 16) AS snippet,
102
- session_id, turn_idx, role, ts
115
+ rowid AS rowid, session_id, turn_idx, role, ts
103
116
  FROM fts_sessions WHERE content MATCH ? ORDER BY bm25 LIMIT ?`),
104
117
  skills: db.prepare(
105
118
  `SELECT 'skills' AS scope, bm25(fts_skills) AS bm25,
106
119
  snippet(fts_skills, 0, '<mark>', '</mark>', '...', 16) AS snippet,
107
- skill_name, trained_by, group_name
120
+ rowid AS rowid, skill_name, trained_by, group_name
108
121
  FROM fts_skills WHERE content MATCH ? ORDER BY bm25 LIMIT ?`),
109
122
  trajectories: db.prepare(
110
123
  `SELECT 'trajectories' AS scope, bm25(fts_trajectories) AS bm25,
111
124
  snippet(fts_trajectories, 0, '<mark>', '</mark>', '...', 16) AS snippet,
112
- trajectory_id, agent, outcome
125
+ rowid AS rowid, trajectory_id, agent, outcome
113
126
  FROM fts_trajectories WHERE content MATCH ? ORDER BY bm25 LIMIT ?`),
114
127
  memories: db.prepare(
115
128
  `SELECT 'memories' AS scope, bm25(fts_memories) AS bm25,
116
129
  snippet(fts_memories, 0, '<mark>', '</mark>', '...', 16) AS snippet,
117
- topic, kind
130
+ rowid AS rowid, topic, kind
118
131
  FROM fts_memories WHERE content MATCH ? ORDER BY bm25 LIMIT ?`),
119
132
  },
120
133
  };
@@ -148,7 +161,12 @@ export function closeIndex(configDir = defaultConfigDir()) {
148
161
  }
149
162
 
150
163
  function _stmts(configDir) {
151
- if (!_handles.has(configDir)) openIndex(configDir);
164
+ // Hot path (recall + session/trajectory writes). Skip the O(index-size)
165
+ // PRAGMA integrity_check here — because each turn is a fresh process, paying
166
+ // it on the first index touch stalls the first user turn by 50-80ms+ (and
167
+ // growing). It is reserved for `doctor` / `index rebuild`, which open/run it
168
+ // explicitly; corruption then surfaces at query time (logged) instead.
169
+ if (!_handles.has(configDir)) openIndex(configDir, { runIntegrityCheck: false });
152
170
  return _handles.get(configDir).stmts;
153
171
  }
154
172
 
@@ -183,6 +201,22 @@ export function indexSkill(row, configDir = defaultConfigDir()) {
183
201
  }
184
202
  }
185
203
 
204
+ // Remove a skill's FTS row so an archived/removed skill stops surfacing in
205
+ // recall. Until now deleteSkill only ran as an upsert prelude inside
206
+ // indexSkill; skills.removeSkill unlinks the .md but left the stale FTS row
207
+ // recallable. Best-effort like the index writers — a delete hiccup must
208
+ // never fail the caller's archive/remove path.
209
+ export function deleteSkill(skillName, configDir = defaultConfigDir()) {
210
+ try {
211
+ const s = _stmts(configDir);
212
+ s.deleteSkill.run(String(skillName || ''));
213
+ } catch (e) {
214
+ _logIndexFailure(configDir, 'skills', e);
215
+ // eslint-disable-next-line no-console
216
+ console.warn('[index_db] deleteSkill failed:', e.message);
217
+ }
218
+ }
219
+
186
220
  export function indexTrajectory(row, configDir = defaultConfigDir()) {
187
221
  try {
188
222
  const s = _stmts(configDir);
@@ -260,15 +294,27 @@ export function recall(query, opts = {}) {
260
294
  // Over-fetch when a where-filter is set: post-filter can drop most
261
295
  // hits, so request 2x k from FTS5 so the final trimmed slice has
262
296
  // enough to fill k. Capped at the FTS5-side 200 hard limit.
263
- const fetchK = whereKeys.length ? Math.min(200, k * 2) : k;
297
+ // Hybrid blend over the same candidates needs a wider FTS pool to re-rank.
298
+ const blend = !!(opts.queryVector && opts.queryVector.length);
299
+ const fetchK = blend ? Math.min(200, Math.max(k * 4, 40))
300
+ : (whereKeys.length ? Math.min(200, k * 2) : k);
264
301
  const hits = [];
302
+ const rowmeta = []; // parallel to hits: { scope, rowid } for the cosine join
265
303
  for (const sc of scope) {
266
304
  const stmt = s.queries[sc];
267
305
  if (!stmt) continue;
268
306
  try {
269
307
  const rows = stmt.all(safeQuery, fetchK);
270
308
  for (const r of rows) {
271
- const { scope: sc2, bm25, snippet, ...metadata } = r;
309
+ // `rowid` is destructured OUT so it never leaks into the returned
310
+ // metadata (keeps the hit shape byte-stable for non-blend callers).
311
+ const { scope: sc2, bm25, snippet, rowid, ...metadata } = r;
312
+ // snippet() wraps the matched term in <mark>..</mark>; recall hits
313
+ // are fed verbatim into agent prompts, where the markup is noise.
314
+ // Return plain text — strip the tags FTS5 injected.
315
+ const plainSnippet = typeof snippet === 'string'
316
+ ? snippet.replace(/<\/?mark>/g, '')
317
+ : snippet;
272
318
  // Apply where-filter at row level — keeps the bm25 ordering
273
319
  // intact and avoids re-fetching after sort.
274
320
  if (whereKeys.length) {
@@ -278,7 +324,8 @@ export function recall(query, opts = {}) {
278
324
  }
279
325
  if (skip) continue;
280
326
  }
281
- hits.push({ scope: sc2, rank: hits.length, bm25, snippet, metadata });
327
+ hits.push({ scope: sc2, rank: hits.length, bm25, snippet: plainSnippet, metadata });
328
+ rowmeta.push({ scope: sc2, rowid });
282
329
  }
283
330
  } catch (e) {
284
331
  // FTS5 MATCH syntax errors are caller mistakes; skip silently.
@@ -287,13 +334,59 @@ export function recall(query, opts = {}) {
287
334
  if (!/syntax error|no such column/i.test(e.message)) throw e;
288
335
  }
289
336
  }
290
- hits.sort((a, b) => a.bm25 - b.bm25);
291
- const trimmed = hits.slice(0, k);
337
+ // Opt-in hybrid re-rank when the caller supplies a query vector; otherwise
338
+ // the exact pure-FTS bm25 order (today's behavior) is preserved.
339
+ const getVec = (sc, rid) => { const row = s.getEmbedding.get(sc, rid); return row ? blobToF32(row.vec) : null; };
340
+ const ordered = blend
341
+ ? blendHybrid(hits, rowmeta, opts.queryVector, getVec, opts.weights)
342
+ : hits.sort((a, b) => a.bm25 - b.bm25);
343
+ const trimmed = ordered.slice(0, k);
292
344
  for (let i = 0; i < trimmed.length; i++) trimmed[i].rank = i;
293
345
  const elapsedNs = process.hrtime.bigint() - t0;
294
346
  return { query, hits: trimmed, latencyMs: Number(elapsedNs) / 1e6 };
295
347
  }
296
348
 
349
+ // Backfill doc embeddings for FTS rows that don't have one yet. Opt-in: a no-op
350
+ // (returns 0) when cfg.recall.embeddings is off or no embedder resolves (the
351
+ // default / $0 path). Runs OFF the chat write hot path — call it explicitly
352
+ // (a reindex, a CLI command, or a background pass) so a network embedding call
353
+ // never lands on a reply. Best-effort: embed/store failures are swallowed and
354
+ // logged so a flaky embedder can never corrupt the FTS index. Returns the
355
+ // number of rows embedded.
356
+ export async function backfillEmbeddings(configDir = defaultConfigDir(), cfg = {}, opts = {}) {
357
+ const { getEmbedder } = await import('./embedder.mjs');
358
+ const embedder = getEmbedder(cfg, opts);
359
+ if (!embedder) return 0;
360
+ const db = openIndex(configDir, { runIntegrityCheck: false });
361
+ const s = _stmts(configDir);
362
+ const limit = Number(opts.limit) || 500;
363
+ const SCOPES = {
364
+ sessions: 'fts_sessions', skills: 'fts_skills',
365
+ trajectories: 'fts_trajectories', memories: 'fts_memories',
366
+ };
367
+ let embedded = 0;
368
+ for (const [scope, table] of Object.entries(SCOPES)) {
369
+ let rows;
370
+ try {
371
+ rows = db.prepare(
372
+ `SELECT f.rowid AS rowid, f.content AS content FROM ${table} f
373
+ WHERE f.rowid NOT IN (SELECT rowid_ref FROM embeddings WHERE scope = ?)
374
+ LIMIT ?`).all(scope, limit);
375
+ } catch { continue; }
376
+ if (!rows.length) continue;
377
+ let vecs;
378
+ try { vecs = await embedder.embed(rows.map((r) => r.content)); }
379
+ catch (e) { _logIndexFailure(configDir, `embed:${scope}`, e); continue; }
380
+ for (let i = 0; i < rows.length; i++) {
381
+ const v = vecs[i];
382
+ if (!v || !v.length) continue;
383
+ try { s.putEmbedding.run(scope, rows[i].rowid, f32ToBlob(v)); embedded++; }
384
+ catch { /* swallow — never break the backfill on a single bad row */ }
385
+ }
386
+ }
387
+ return embedded;
388
+ }
389
+
297
390
  export function integrityCheck(configDir = defaultConfigDir()) {
298
391
  const db = openIndex(configDir);
299
392
  const r = db.pragma('integrity_check', { simple: true });
package/mas/learning.mjs CHANGED
@@ -19,10 +19,10 @@
19
19
  // active-recall-miss — a skill was recalled but failed to apply.
20
20
  // Decrement its confidence; archive when it
21
21
  // falls below the activation threshold.
22
- // periodic-curation — cron-driven skills_curator replay. Phase 2
23
- // full implementation; v5.0.10 emits a warning
24
- // and returns a no-op result so a cron entry
25
- // configured today does not crash.
22
+ // periodic-curation — cron-driven skills_curator replay. Runs the
23
+ // real curator (archives agent-authored skills
24
+ // idle >90d) against ctx.configDir; the clock is
25
+ // injectable (ctx.now) for deterministic tests.
26
26
  //
27
27
  // Hard contract: a single broken handler MUST NOT poison the others.
28
28
  // Each sub-routine is wrapped in its own try/catch so e.g. a missing
@@ -33,6 +33,7 @@ import * as skillSynth from './skill_synth.mjs';
33
33
  import * as userModeler from './user_modeler.mjs';
34
34
  import * as confidence from './confidence.mjs';
35
35
  import * as skills from '../skills.mjs';
36
+ import * as skillsCurator from '../skills_curator.mjs';
36
37
  import { resolveTrainer } from '../providers/registry.mjs';
37
38
  import { hasClaudeCliSession } from '../providers/claude_cli_detect.mjs';
38
39
 
@@ -120,70 +121,78 @@ export async function _runPostTask(ctx, logger) {
120
121
  errors.push({ step: 'confidence', error: String(e?.message || e) });
121
122
  }
122
123
 
123
- // 3. synthesizeSkill (best-effort needs an agent + provider key).
124
- // All v5 frontmatter fields land here (trained_by, confidence,
125
- // trajectory_ref) so the produced SKILL.md is ready for recall +
126
- // cross-CLI dampening without a follow-up patch.
127
- if (ctx.agent && ctx.task) {
128
- try {
129
- results.skill = await skillSynth.synthesizeSkill({
130
- agent: { ...ctx.agent, provider: trainer.provider, model: trainer.model },
131
- task: ctx.task,
132
- apiKey: ctx.apiKey,
133
- baseUrl: ctx.baseUrl,
134
- fetchImpl: ctx.fetchImpl,
135
- outcome: 'done',
136
- trainedBy: trainer.provider,
137
- trainedOnModel: trainer.model,
138
- trajectoryRef: results.trajectory?.id || null,
139
- confidence: results.confidence,
140
- });
141
- // Persist the produced SKILL.md into the shared skills/ dir so the
142
- // next agent turn's recall surfaces it. installSynthesized forwards
143
- // every v5 frontmatter field (see Group A — C6 fix in skill_synth).
144
- if (results.skill && ctx.configDir) {
145
- try {
146
- results.installed = skillSynth.installSynthesized({
147
- name: results.skill.name,
148
- description: results.skill.description,
149
- body: results.skill.body,
150
- sourceTask: ctx.task.id || '',
151
- createdBy: 'agent',
152
- trainedBy: trainer.provider,
153
- trainedOnModel: trainer.model,
154
- trajectoryRef: results.trajectory?.id || null,
155
- confidence: results.confidence,
156
- outcome: 'done',
157
- }, ctx.configDir);
158
- } catch (e) {
159
- errors.push({ step: 'skillInstall', error: String(e?.message || e) });
160
- logger(`[learning] skill install failed: ${e?.message || e}\n`);
124
+ // 3 + 4. synthesizeSkill and updateUserModel are independent trainer/LLM
125
+ // calls updateUserModel does not consume results.skill — so run them
126
+ // concurrently instead of awaiting one whole network round-trip before
127
+ // starting the other. Each branch keeps its own try/catch so a failure in
128
+ // one can never poison the other (module contract, top of file), and they
129
+ // write disjoint keys of the shared results/errors objects.
130
+ await Promise.allSettled([
131
+ // 3. synthesizeSkill (best-effort — needs an agent + provider key). All v5
132
+ // frontmatter fields land here (trained_by, confidence, trajectory_ref)
133
+ // so the produced SKILL.md is ready for recall + cross-CLI dampening.
134
+ (async () => {
135
+ if (!(ctx.agent && ctx.task)) return;
136
+ try {
137
+ results.skill = await skillSynth.synthesizeSkill({
138
+ agent: { ...ctx.agent, provider: trainer.provider, model: trainer.model },
139
+ task: ctx.task,
140
+ apiKey: ctx.apiKey,
141
+ baseUrl: ctx.baseUrl,
142
+ fetchImpl: ctx.fetchImpl,
143
+ outcome: 'done',
144
+ trainedBy: trainer.provider,
145
+ trainedOnModel: trainer.model,
146
+ trajectoryRef: results.trajectory?.id || null,
147
+ confidence: results.confidence,
148
+ });
149
+ // Persist the produced SKILL.md into the shared skills/ dir so the
150
+ // next agent turn's recall surfaces it. installSynthesized forwards
151
+ // every v5 frontmatter field (see Group A — C6 fix in skill_synth).
152
+ if (results.skill && ctx.configDir) {
153
+ try {
154
+ results.installed = skillSynth.installSynthesized({
155
+ name: results.skill.name,
156
+ description: results.skill.description,
157
+ body: results.skill.body,
158
+ sourceTask: ctx.task.id || '',
159
+ createdBy: 'agent',
160
+ trainedBy: trainer.provider,
161
+ trainedOnModel: trainer.model,
162
+ trajectoryRef: results.trajectory?.id || null,
163
+ confidence: results.confidence,
164
+ outcome: 'done',
165
+ }, ctx.configDir);
166
+ } catch (e) {
167
+ errors.push({ step: 'skillInstall', error: String(e?.message || e) });
168
+ logger(`[learning] skill install failed: ${e?.message || e}\n`);
169
+ }
161
170
  }
171
+ } catch (e) {
172
+ errors.push({ step: 'skill', error: String(e?.message || e) });
173
+ logger(`[learning] skill synth failed: ${e?.message || e}\n`);
162
174
  }
163
- } catch (e) {
164
- errors.push({ step: 'skill', error: String(e?.message || e) });
165
- logger(`[learning] skill synth failed: ${e?.message || e}\n`);
166
- }
167
- }
168
-
169
- // 4. updateUserModel needs session turns (any normalised
170
- // [{role, content}] list). Falls back to task.turns translated.
171
- if (ctx.sessionTurns || ctx.task?.turns) {
172
- try {
173
- results.userModel = await userModeler.updateUserModel({
174
- sessionTurns: ctx.sessionTurns || _toSessionTurns(ctx.task.turns),
175
- provider: trainer.provider,
176
- model: trainer.model,
177
- apiKey: ctx.apiKey,
178
- baseUrl: ctx.baseUrl,
179
- fetchImpl: ctx.fetchImpl,
180
- configDir: ctx.configDir,
181
- });
182
- } catch (e) {
183
- errors.push({ step: 'userModel', error: String(e?.message || e) });
184
- logger(`[learning] user model update failed: ${e?.message || e}\n`);
185
- }
186
- }
175
+ })(),
176
+ // 4. updateUserModel needs session turns (any normalised [{role,content}]
177
+ // list). Falls back to task.turns translated.
178
+ (async () => {
179
+ if (!(ctx.sessionTurns || ctx.task?.turns)) return;
180
+ try {
181
+ results.userModel = await userModeler.updateUserModel({
182
+ sessionTurns: ctx.sessionTurns || _toSessionTurns(ctx.task.turns),
183
+ provider: trainer.provider,
184
+ model: trainer.model,
185
+ apiKey: ctx.apiKey,
186
+ baseUrl: ctx.baseUrl,
187
+ fetchImpl: ctx.fetchImpl,
188
+ configDir: ctx.configDir,
189
+ });
190
+ } catch (e) {
191
+ errors.push({ step: 'userModel', error: String(e?.message || e) });
192
+ logger(`[learning] user model update failed: ${e?.message || e}\n`);
193
+ }
194
+ })(),
195
+ ]);
187
196
 
188
197
  return { trigger: 'post-task', results, errors };
189
198
  }
@@ -321,12 +330,27 @@ export async function _runActiveRecallMiss(ctx, logger) {
321
330
 
322
331
  // ── periodic-curation ────────────────────────────────────────────────
323
332
  //
324
- // Phase 2 full implementation. v5.0.10 emits a structured warning so a
325
- // cron entry created today returns a stable shape and operators can see
326
- // the hook fired without blowing up.
327
- export async function _runPeriodicCuration(_ctx, logger) {
328
- logger('[learning] periodic-curation is a Phase 2 hook no-op for now.\n');
329
- return { trigger: 'periodic-curation', results: { stub: true }, errors: [] };
333
+ // Run the real skills_curator sweep: archive agent-authored skills idle
334
+ // for 90+ days so the recall index stops bloating the system prompt. The
335
+ // clock is injectable (ctx.now) so the 90d boundary is reproducible in
336
+ // tests; it defaults to the wall-clock for the cron caller. curate()
337
+ // never throws for a single bad skill, but we still wrap the whole pass
338
+ // so an unreadable store can't poison the funnel.
339
+ //
340
+ // ctx: { configDir, now?, curateImpl? }
341
+ export async function _runPeriodicCuration(ctx, logger) {
342
+ // curateImpl is an injection seam for tests; production uses the real
343
+ // curator. Default the clock here, not in curate(), which demands a
344
+ // finite number.
345
+ const curate = typeof ctx.curateImpl === 'function' ? ctx.curateImpl : skillsCurator.curate;
346
+ const now = Number.isFinite(ctx.now) ? ctx.now : Date.now();
347
+ try {
348
+ const results = curate(ctx.configDir, now);
349
+ return { trigger: 'periodic-curation', results, errors: [] };
350
+ } catch (e) {
351
+ logger(`[learning] periodic-curation failed: ${e?.message || e}\n`);
352
+ return { trigger: 'periodic-curation', results: {}, errors: [{ step: 'curate', error: String(e?.message || e) }] };
353
+ }
330
354
  }
331
355
 
332
356
  // ── helpers ──────────────────────────────────────────────────────────
@@ -26,6 +26,9 @@ import * as agentMemory from './agent_memory.mjs';
26
26
  import * as skillSynth from './skill_synth.mjs';
27
27
  import * as skills from '../skills.mjs';
28
28
  import { composePromptStack } from './prompt_stack.mjs';
29
+ import { finalizeTerminalStop } from './router_termination.mjs';
30
+ import { postToThread, postTypingPlaceholder, clearTypingPlaceholder } from './router_posting.mjs';
31
+ import { emit as emitEvent } from './events.mjs';
29
32
 
30
33
  export class MentionRouterError extends Error {
31
34
  constructor(message, code) {
@@ -110,6 +113,8 @@ export function buildTurnContext({ task, team, agent, agentRecord, teammates, co
110
113
  cfgDir: configDir,
111
114
  agent: agentRecord,
112
115
  workspace: task?.workspace || agentRecord.workspace || '',
116
+ // Recall prior sessions/trajectories/memories relevant to this task.
117
+ query: [task?.title, task?.description].filter(Boolean).join(' ').slice(0, 500),
113
118
  }) || '';
114
119
  } catch { /* best-effort — see comment above */ }
115
120
  // When composePromptStack emitted a Role layer (layer 3) we drop the
@@ -216,82 +221,10 @@ export function buildSkillsBlock(configDir) {
216
221
  // silently ignores them when the scope is missing). The message text
217
222
  // is no longer manually prefixed with the agent name because the
218
223
  // custom username already shows it in Slack's UI.
219
- async function postToThread({ task, agentRecord, text, logger = () => {}, sender }) {
220
- if (!task.slackChannel || !task.slackThreadTs) return null;
221
- let slack = sender;
222
- let owned = false;
223
- if (!slack) {
224
- const { SlackChannel } = await import('../channels/slack.mjs');
225
- slack = new SlackChannel({ requireInbound: false });
226
- owned = true;
227
- try {
228
- await slack.start(async () => '', {});
229
- } catch (err) {
230
- logger(`[router] slack start failed: ${err?.message || err}\n`);
231
- return null;
232
- }
233
- }
234
- const threadId = `${task.slackChannel}:${task.slackThreadTs}`;
235
- // When we have a real agent persona, push the text under that
236
- // persona's username + icon. Otherwise (user message or system note)
237
- // fall back to the bot's default identity with no decoration.
238
- let body;
239
- let sendOpts = {};
240
- if (agentRecord) {
241
- body = String(text);
242
- if (agentRecord.displayName) sendOpts.username = agentRecord.displayName;
243
- if (agentRecord.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
244
- } else {
245
- body = String(text);
246
- }
247
- try {
248
- const res = await slack.send(threadId, body, sendOpts);
249
- return res?.ts || null;
250
- } catch (err) {
251
- logger(`[router] slack send failed: ${err?.message || err}\n`);
252
- return null;
253
- } finally {
254
- if (owned) await slack.stop().catch(() => {});
255
- }
256
- }
257
-
258
- // "X is thinking…" placeholder posted into the thread before an agent
259
- // turn so a human reader knows work is happening. Returns the ts of
260
- // the placeholder so the caller can delete it once the real reply is
261
- // in. No-op when Slack isn't wired or the post fails.
262
- async function postTypingPlaceholder({ task, agentRecord, logger = () => {}, sender }) {
263
- if (!task.slackChannel || !task.slackThreadTs) return { ts: null, sender: null };
264
- let slack = sender;
265
- let owned = false;
266
- if (!slack) {
267
- const { SlackChannel } = await import('../channels/slack.mjs');
268
- slack = new SlackChannel({ requireInbound: false });
269
- owned = true;
270
- try {
271
- await slack.start(async () => '', {});
272
- } catch (err) {
273
- logger(`[router] slack start failed: ${err?.message || err}\n`);
274
- return { ts: null, sender: null };
275
- }
276
- }
277
- const threadId = `${task.slackChannel}:${task.slackThreadTs}`;
278
- const sendOpts = {};
279
- if (agentRecord?.displayName) sendOpts.username = agentRecord.displayName;
280
- if (agentRecord?.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
281
- try {
282
- const res = await slack.send(threadId, `_:hourglass_flowing_sand: thinking…_`, sendOpts);
283
- return { ts: res?.ts || null, sender: slack, owned, channel: task.slackChannel };
284
- } catch (err) {
285
- logger(`[router] slack typing post failed: ${err?.message || err}\n`);
286
- if (owned) await slack.stop().catch(() => {});
287
- return { ts: null, sender: null, owned: false };
288
- }
289
- }
224
+ //
225
+ // The Slack thread I/O helpers (postToThread / postTypingPlaceholder /
226
+ // clearTypingPlaceholder) live in ./router_posting.mjs.
290
227
 
291
- // Fire one reflection LLM call per agent that actually spoke during
292
- // this task. Each successful reflection is prepended to the agent's
293
- // memory file. Failures are logged but never thrown — a sticky
294
- // transcript that won't reflect shouldn't poison the user's terminal.
295
228
  // The agents that actually spoke during a task — the set both the
296
229
  // reflection and skill-synthesis post-task hooks iterate. 'user' and
297
230
  // 'system' pseudo-agents are excluded so an agent who never spoke
@@ -359,15 +292,6 @@ async function autoSynthSkills({ task, agentsById, apiKey, baseUrl, fetchImpl, c
359
292
  }
360
293
  }
361
294
 
362
- async function clearTypingPlaceholder(placeholder, logger) {
363
- if (!placeholder?.ts || !placeholder?.channel || !placeholder?.sender) return;
364
- const slack = placeholder.sender;
365
- try { await slack.deleteMessage(placeholder.channel, placeholder.ts); }
366
- catch (err) { logger(`[router] slack typing delete failed: ${err?.message || err}\n`); }
367
- // Only stop a client we created here; a shared sender is closed once by run().
368
- finally { if (placeholder.owned) await slack.stop().catch(() => {}); }
369
- }
370
-
371
295
  // Run agents in this team until the queue empties or budget runs out.
372
296
  //
373
297
  // Returns { task, iterations, stoppedBy: 'idle' | 'done' | 'budget' }.
@@ -389,6 +313,14 @@ export async function runTaskTurn({
389
313
  signal,
390
314
  approve,
391
315
  security,
316
+ // Fired with { provider, model, usage } after each agent turn that reported
317
+ // usage, so the caller prices each turn against that agent's rate card and
318
+ // feeds the cost cap (mixed-provider teams account correctly). No-op default.
319
+ onUsage,
320
+ // Default-on isolation — the flat sandbox spec applied to every tool the
321
+ // agents run this task. Threaded from the entrypoint (task tick / REPL),
322
+ // which builds it via defaultSandboxSpec. undefined → bare (byte-stable).
323
+ sandbox,
392
324
  // E3 — a long-lived caller (e.g. the daemon) can pass a pre-started
393
325
  // SlackChannel to reuse across many task turns; run() then neither
394
326
  // creates nor stops it. When omitted, run() opens + closes its own.
@@ -445,6 +377,10 @@ export async function runTaskTurn({
445
377
  let iterations = 0;
446
378
  let stoppedBy = 'idle';
447
379
 
380
+ // Live activity events for the dashboard (mas/events bus → GET /events SSE).
381
+ // emit() never throws, so these are pure side-channels that can't affect the turn.
382
+ emitEvent('task.start', { taskId: current.id, team: team.name, title: current.title || '' });
383
+
448
384
  while (queue.length > 0 && iterations < maxAgentTurns) {
449
385
  if (signal?.aborted) { stoppedBy = 'abort'; break; }
450
386
  const speaker = queue.shift();
@@ -463,6 +399,9 @@ export async function runTaskTurn({
463
399
  // thread.
464
400
  const typing = await postTypingPlaceholder({ task: current, agentRecord, logger, sender: slackSender });
465
401
 
402
+ emitEvent('turn.start', { taskId: current.id, agent: speaker, provider: agentRecord.provider, model: agentRecord.model });
403
+ emitEvent('agent.status', { agent: speaker, status: 'working' });
404
+
466
405
  let result;
467
406
  try {
468
407
  result = await agentTurn.runAgentTurn({
@@ -474,7 +413,7 @@ export async function runTaskTurn({
474
413
  userMessage: '',
475
414
  history: ctx.history,
476
415
  taskId: current.id,
477
- configDir, cwd, apiKey, fetchImpl, baseUrl, signal, approve, security,
416
+ configDir, cwd, apiKey, fetchImpl, baseUrl, signal, approve, security, sandbox,
478
417
  // C9 — enable Anthropic prompt caching for the static system
479
418
  // prefix + tool definitions. Non-anthropic adapters ignore
480
419
  // the flag (it's a no-op for OpenAI/Gemini/claude-cli).
@@ -484,13 +423,24 @@ export async function runTaskTurn({
484
423
  await clearTypingPlaceholder(typing, logger);
485
424
  logger(`[router] agent "${speaker}" threw: ${err?.message || err}\n`);
486
425
  current = tasksMod.appendTurn(current.id, { agent: speaker, text: `(error: ${err?.message || err})`, ts: new Date().toISOString(), error: true }, configDir);
426
+ emitEvent('turn.end', { taskId: current.id, agent: speaker, stoppedBy: 'error' });
427
+ emitEvent('agent.status', { agent: speaker, status: 'idle' });
487
428
  continue;
488
429
  }
489
430
  await clearTypingPlaceholder(typing, logger);
490
431
 
432
+ // Report this turn's spend with its provider+model. Best-effort — a
433
+ // throwing/absent callback never affects the loop.
434
+ if (result.usage && typeof onUsage === 'function') {
435
+ try { onUsage({ provider: agentRecord.provider, model: agentRecord.model, usage: result.usage }); }
436
+ catch { /* never let cost accounting break the turn */ }
437
+ }
438
+
491
439
  const replyText = (result.text || '').trim();
492
440
  const ts = new Date().toISOString();
493
441
  current = tasksMod.appendTurn(current.id, { agent: speaker, text: replyText, ts, toolCalls: result.toolCalls?.length ? result.toolCalls : undefined }, configDir);
442
+ emitEvent('turn.end', { taskId: current.id, agent: speaker, stoppedBy: result.stoppedBy });
443
+ emitEvent('agent.status', { agent: speaker, status: 'idle' });
494
444
 
495
445
  // Slack mirror — only the user-visible text, with the agent name
496
446
  // prefixed so a human reader can follow who said what.
@@ -498,6 +448,7 @@ export async function runTaskTurn({
498
448
 
499
449
  if (replyText.includes(DONE_MARKER)) {
500
450
  current = tasksMod.patchTask(current.id, { status: 'done' }, configDir);
451
+ emitEvent('task.done', { taskId: current.id, status: 'done' });
501
452
  await postToThread({ task: current, agentRecord: null, text: `:white_check_mark: ${DONE_MARKER} — task closed by *${agentRecord.displayName || speaker}*.`, logger, sender: slackSender });
502
453
  stoppedBy = 'done';
503
454
  // Phase 18: fire one reflection LLM call per participating agent
@@ -521,7 +472,10 @@ export async function runTaskTurn({
521
472
  }
522
473
 
523
474
  const mentions = extractMentions(replyText, team.agents, speaker);
524
- for (const m of mentions) queue.push(m);
475
+ for (const m of mentions) {
476
+ emitEvent('delegate', { taskId: current.id, from: speaker, to: m });
477
+ queue.push(m);
478
+ }
525
479
 
526
480
  // When a non-lead speaker doesn't hand off, return control to the
527
481
  // lead so the conversation doesn't strand mid-team. The lead can
@@ -530,11 +484,15 @@ export async function runTaskTurn({
530
484
  queue.push(team.lead);
531
485
  }
532
486
  }
533
-
534
487
  if (iterations >= maxAgentTurns) stoppedBy = 'budget';
535
- // Close the Slack client once for the whole run but only if WE opened
536
- // it. A caller-provided sender is left running for the caller to reuse.
537
- // All exit paths (done/budget/abort/idle) fall through to this return.
488
+ // C3 strand-proof a non-DONE exit: terminal status + stop note (no-op for 'done'); task activates post-failure learning.
489
+ current = await finalizeTerminalStop({ stoppedBy, iterations, current, configDir, tasksMod, postToThread, slackSender, logger, task: current });
490
+ // A non-DONE exit (idle/budget/abort) still ends the task — the DONE path
491
+ // already emitted task.done above, so only emit here for the other exits.
492
+ if (stoppedBy !== 'done') {
493
+ emitEvent('task.done', { taskId: current.id, status: current.status, stoppedBy });
494
+ }
495
+ // Close the Slack client for the whole run, but only if WE opened it.
538
496
  if (ownSlackSender && slackSender) await slackSender.stop().catch(() => {});
539
497
  return { task: current, iterations, stoppedBy };
540
498
  }