lazyclaw 6.3.1 → 6.5.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 +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- 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 +41 -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 +27 -3
- 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 +68 -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 +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- 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 +29 -2
- package/providers/tool_use/openai.mjs +35 -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/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- 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/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- 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/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
|
-
'
|
|
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
|
-
|
|
27
|
-
|
|
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
|
+
}
|
package/lib/service_install.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/mas/agent_turn.mjs
CHANGED
|
@@ -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
|
|
8
|
-
// openai
|
|
9
|
-
// gemini
|
|
10
|
-
// claude-cli →
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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,44 @@ 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,
|
|
207
|
+
// Forward-compat: thread an explicit per-agent output cap when the agent
|
|
208
|
+
// record carries one. No current config/setup path populates
|
|
209
|
+
// agent.maxTokens / agent.maxOutputTokens, so this is `undefined` today —
|
|
210
|
+
// each adapter then falls back to its own DEFAULT (anthropic/openai) or
|
|
211
|
+
// leaves the cap unset (gemini), keeping existing turns byte-stable. When
|
|
212
|
+
// a future config does set it, the cap reaches gemini (FIX D) too.
|
|
213
|
+
maxTokens: agent.maxTokens ?? agent.maxOutputTokens,
|
|
190
214
|
fetchImpl, baseUrl, signal, cache,
|
|
191
215
|
});
|
|
192
216
|
if (resp.text) lastText = resp.text;
|
|
217
|
+
if (resp.usage) {
|
|
218
|
+
usageTotal.inputTokens += resp.usage.inputTokens || 0;
|
|
219
|
+
usageTotal.outputTokens += resp.usage.outputTokens || 0;
|
|
220
|
+
usageTotal.cacheCreationInputTokens += resp.usage.cacheCreationInputTokens || 0;
|
|
221
|
+
usageTotal.cacheReadInputTokens += resp.usage.cacheReadInputTokens || 0;
|
|
222
|
+
usageTotal.totalCostUsd += resp.usage.totalCostUsd || 0;
|
|
223
|
+
usageSeen = true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The model hit its output token ceiling: the final answer or the tool
|
|
227
|
+
// call(s) are partial/garbage. Stop with an explicit error rather than
|
|
228
|
+
// returning a cut-off answer or running an incomplete tool call.
|
|
229
|
+
if (resp.truncated) {
|
|
230
|
+
await _maybePersistTrajectory('error');
|
|
231
|
+
return {
|
|
232
|
+
text: lastText, iterations, stoppedBy: 'truncated',
|
|
233
|
+
error: 'response truncated at the model output token limit (raise maxTokens)',
|
|
234
|
+
toolCalls, usage: _usage(),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
193
237
|
|
|
194
238
|
if (resp.kind === 'final') {
|
|
195
239
|
await _maybePersistTrajectory('done');
|
|
196
|
-
return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls };
|
|
240
|
+
return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls, usage: _usage() };
|
|
197
241
|
}
|
|
198
242
|
|
|
199
243
|
// tool_calls path: echo the model's assistant turn back so future
|
|
@@ -216,10 +260,21 @@ export async function runAgentTurn({
|
|
|
216
260
|
for (const call of resp.calls) {
|
|
217
261
|
let result;
|
|
218
262
|
let ok = true;
|
|
263
|
+
// The adapter could not parse this tool call's arguments (e.g. OpenAI
|
|
264
|
+
// emitted malformed JSON). Surface a tool error so the model can retry
|
|
265
|
+
// instead of silently running the tool with empty input.
|
|
266
|
+
if (call.parseError) {
|
|
267
|
+
result = { ok: false, error: call.parseError };
|
|
268
|
+
toolCalls.push({ id: call.id, name: call.name, input: call.input, result, ok: false });
|
|
269
|
+
emitEvent('tool.call', { taskId, agent: agent.name, tool: call.name, ok: false });
|
|
270
|
+
results.push({ id: call.id, content: result, isError: true });
|
|
271
|
+
toolErrored = true;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
219
274
|
try {
|
|
220
275
|
result = await runTool({
|
|
221
276
|
agent, tool: call.name, args: call.input,
|
|
222
|
-
taskId, configDir, cwd, approve, security,
|
|
277
|
+
taskId, configDir, cwd, approve, security, sandbox,
|
|
223
278
|
});
|
|
224
279
|
if (result && result.ok === false) ok = false;
|
|
225
280
|
} catch (err) {
|
|
@@ -231,6 +286,7 @@ export async function runAgentTurn({
|
|
|
231
286
|
}
|
|
232
287
|
}
|
|
233
288
|
toolCalls.push({ id: call.id, name: call.name, input: call.input, result, ok });
|
|
289
|
+
emitEvent('tool.call', { taskId, agent: agent.name, tool: call.name, ok });
|
|
234
290
|
results.push({ id: call.id, content: result, isError: !ok });
|
|
235
291
|
if (!ok) toolErrored = true;
|
|
236
292
|
}
|
|
@@ -243,10 +299,10 @@ export async function runAgentTurn({
|
|
|
243
299
|
// provider returned a malformed envelope) bails out here.
|
|
244
300
|
if (toolErrored && process.env.LAZYCLAW_TOOL_STRICT === '1') {
|
|
245
301
|
await _maybePersistTrajectory('failed');
|
|
246
|
-
return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls };
|
|
302
|
+
return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls, usage: _usage() };
|
|
247
303
|
}
|
|
248
304
|
}
|
|
249
305
|
|
|
250
306
|
await _maybePersistTrajectory('abandoned');
|
|
251
|
-
return { text: lastText, iterations, stoppedBy: 'budget', toolCalls };
|
|
307
|
+
return { text: lastText, iterations, stoppedBy: 'budget', toolCalls, usage: _usage() };
|
|
252
308
|
}
|
package/mas/confidence.mjs
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/mas/embedder.mjs
ADDED
|
@@ -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
|
+
}
|