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.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +90 -33
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +196 -80
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
results.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
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
|
-
//
|
|
325
|
-
//
|
|
326
|
-
// the
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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 ──────────────────────────────────────────────────────────
|
package/mas/mention_router.mjs
CHANGED
|
@@ -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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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)
|
|
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
|
-
//
|
|
536
|
-
|
|
537
|
-
//
|
|
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
|
}
|