lazyclaw 6.0.0 → 6.1.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 (73) hide show
  1. package/README.ko.md +88 -25
  2. package/README.md +121 -190
  3. package/channels/handoff.mjs +18 -5
  4. package/channels/matrix.mjs +23 -5
  5. package/channels/slack.mjs +83 -50
  6. package/channels/slack_env.mjs +45 -0
  7. package/channels/telegram.mjs +49 -6
  8. package/channels-discord/index.mjs +76 -0
  9. package/channels-discord/package.json +14 -0
  10. package/channels-email/index.mjs +109 -0
  11. package/channels-email/package.json +14 -0
  12. package/channels-signal/index.mjs +69 -0
  13. package/channels-signal/package.json +9 -0
  14. package/channels-voice/index.mjs +81 -0
  15. package/channels-voice/package.json +9 -0
  16. package/channels-whatsapp/index.mjs +70 -0
  17. package/channels-whatsapp/package.json +13 -0
  18. package/cli.mjs +10 -21
  19. package/commands/agents.mjs +669 -0
  20. package/commands/auth_nodes.mjs +323 -0
  21. package/commands/automation.mjs +582 -0
  22. package/commands/channels.mjs +254 -0
  23. package/commands/chat.mjs +1253 -0
  24. package/commands/config.mjs +315 -0
  25. package/commands/daemon.mjs +275 -0
  26. package/commands/gateway.mjs +194 -0
  27. package/commands/misc.mjs +128 -0
  28. package/commands/providers.mjs +490 -0
  29. package/commands/service.mjs +113 -0
  30. package/commands/sessions.mjs +343 -0
  31. package/commands/setup.mjs +742 -0
  32. package/commands/setup_channels.mjs +207 -0
  33. package/commands/skills.mjs +218 -0
  34. package/commands/workflow.mjs +661 -0
  35. package/config_features.mjs +106 -0
  36. package/daemon/lib/auth.mjs +58 -0
  37. package/daemon/lib/cost.mjs +30 -0
  38. package/daemon/lib/inbound_dedup.mjs +108 -0
  39. package/daemon/lib/learn_queue.mjs +46 -0
  40. package/daemon/lib/provider.mjs +69 -0
  41. package/daemon/lib/respond.mjs +83 -0
  42. package/daemon/route_table.mjs +96 -0
  43. package/daemon/routes/_deps.mjs +42 -0
  44. package/daemon/routes/config.mjs +99 -0
  45. package/daemon/routes/conversation.mjs +435 -0
  46. package/daemon/routes/meta.mjs +239 -0
  47. package/daemon/routes/ops.mjs +165 -0
  48. package/daemon/routes/providers.mjs +211 -0
  49. package/daemon/routes/rates.mjs +90 -0
  50. package/daemon/routes/registry.mjs +223 -0
  51. package/daemon/routes/sessions.mjs +213 -0
  52. package/daemon/routes/skills.mjs +260 -0
  53. package/daemon/routes/workflows.mjs +224 -0
  54. package/dotenv_min.mjs +51 -0
  55. package/first_run.mjs +24 -0
  56. package/goals_cron.mjs +37 -0
  57. package/lib/args.mjs +216 -0
  58. package/lib/config.mjs +113 -0
  59. package/lib/gateway_guard.mjs +100 -0
  60. package/lib/inbound_client.mjs +108 -0
  61. package/lib/registry_boot.mjs +55 -0
  62. package/lib/service_install.mjs +207 -0
  63. package/package.json +15 -3
  64. package/providers/probe.mjs +28 -0
  65. package/secure_write.mjs +46 -0
  66. package/tui/editor.mjs +18 -2
  67. package/tui/repl.mjs +25 -4
  68. package/tui/slash_commands.mjs +4 -0
  69. package/tui/slash_dispatcher.mjs +118 -0
  70. package/tui/splash_props.mjs +52 -0
  71. package/web/dashboard.css +6 -12
  72. package/web/dashboard.html +2 -34
  73. package/web/dashboard.js +3 -3
@@ -239,3 +239,109 @@ export async function messageSend(cfg, name, text, opts = {}) {
239
239
  }
240
240
  return { ok: true, kind: hook.kind, status: res.status };
241
241
  }
242
+
243
+ // ── Channels — built-in channel config (cfg.channels.<name>) ────────────
244
+ // KNOWN_CHANNELS mirrors channels/ (built-in) + channels-* (plugins). Single
245
+ // source of truth: the daemon /channels route, the CLI `channels` command, and
246
+ // the in-chat /channels slash all read it so the three views can't drift.
247
+ export const KNOWN_CHANNELS = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
248
+
249
+ // A channel counts as "configured" when it has a cfg.channels.<name> section
250
+ // or a legacy <name>-bot-token / <name>-token key. Returns one row per
251
+ // configured channel: { name, enabled, boundAgent, lastInboundAt }. Matches
252
+ // the daemon /channels route's enabled semantics exactly.
253
+ export function channelStatusList(cfg) {
254
+ const chCfg = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
255
+ const out = [];
256
+ for (const name of KNOWN_CHANNELS) {
257
+ const sec = chCfg[name];
258
+ if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
259
+ out.push({
260
+ name,
261
+ enabled: !!(sec && (sec.enabled !== false)),
262
+ boundAgent: sec?.agent || sec?.boundAgent || null,
263
+ lastInboundAt: sec?.lastInboundAt || null,
264
+ });
265
+ }
266
+ // Surface any additional configured channels not in the known list.
267
+ for (const name of Object.keys(chCfg)) {
268
+ if (KNOWN_CHANNELS.includes(name)) continue;
269
+ const sec = chCfg[name] || {};
270
+ out.push({
271
+ name,
272
+ enabled: sec.enabled !== false,
273
+ boundAgent: sec.agent || sec.boundAgent || null,
274
+ lastInboundAt: sec.lastInboundAt || null,
275
+ });
276
+ }
277
+ return out;
278
+ }
279
+
280
+ // Enable/disable a channel. Mutates cfg.channels.<name>.enabled; the caller
281
+ // persists via writeConfig. Returns cfg for chaining.
282
+ export function channelSetEnabled(cfg, name, enabled) {
283
+ if (!name) throw new Error('channel name is required');
284
+ cfg.channels = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
285
+ cfg.channels[name] = { ...(cfg.channels[name] || {}), enabled: !!enabled };
286
+ return cfg;
287
+ }
288
+
289
+ // ── Chat context window (cfg.chat.window{Turns,Tokens}) ─────────────────
290
+ // The sliding history budget sent to the model each turn (NOT the model's hard
291
+ // context limit). Shared by the /context slash, the setup step, the status bar,
292
+ // and applyChatWindow so all four agree. Defaults mirror chat_window.mjs.
293
+ const _CTX_DEFAULT_TURNS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TURNS) || 20;
294
+ const _CTX_DEFAULT_TOKENS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TOKENS) || 8000;
295
+ export function chatWindowGet(cfg) {
296
+ const c = (cfg && cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
297
+ return { turns: Number(c.windowTurns) || _CTX_DEFAULT_TURNS, tokens: Number(c.windowTokens) || _CTX_DEFAULT_TOKENS };
298
+ }
299
+ export function chatWindowSet(cfg, { turns, tokens } = {}) {
300
+ cfg.chat = (cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
301
+ if (turns !== undefined) cfg.chat.windowTurns = turns;
302
+ if (tokens !== undefined) cfg.chat.windowTokens = tokens;
303
+ return cfg;
304
+ }
305
+
306
+ // ── Orchestrator — multi-agent config (cfg.orchestrator) ────────────────
307
+ // Shared by the setup wizard, the /orchestrator slash, and the CLI so the
308
+ // "planner + workers" config has one shape. Orchestration is ACTIVE only when
309
+ // cfg.provider === 'orchestrator' AND there is at least one worker.
310
+ export function orchestratorGet(cfg) {
311
+ const o = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
312
+ const workers = Array.isArray(o.workers) ? o.workers : [];
313
+ return {
314
+ planner: o.planner || null,
315
+ workers,
316
+ maxSubtasks: Number.isFinite(o.maxSubtasks) ? o.maxSubtasks : 5,
317
+ active: cfg.provider === 'orchestrator' && workers.length > 0,
318
+ };
319
+ }
320
+
321
+ // Merge planner / workers / maxSubtasks into cfg.orchestrator (only the keys
322
+ // provided). Returns cfg.
323
+ export function orchestratorSet(cfg, { planner, workers, maxSubtasks } = {}) {
324
+ cfg.orchestrator = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
325
+ if (planner !== undefined) cfg.orchestrator.planner = planner;
326
+ if (workers !== undefined) cfg.orchestrator.workers = workers;
327
+ if (maxSubtasks !== undefined) cfg.orchestrator.maxSubtasks = maxSubtasks;
328
+ return cfg;
329
+ }
330
+
331
+ // Turn orchestration on/off by routing cfg.provider. Enabling stashes the
332
+ // previous real provider so disabling can restore it (else falls back to the
333
+ // planner's base provider, then claude-cli).
334
+ export function orchestratorEnable(cfg, enabled) {
335
+ if (enabled) {
336
+ if (cfg.provider && cfg.provider !== 'orchestrator') {
337
+ cfg.orchestrator = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
338
+ cfg.orchestrator._prevProvider = cfg.provider;
339
+ }
340
+ cfg.provider = 'orchestrator';
341
+ } else {
342
+ const o = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
343
+ const plannerBase = String(o.planner || '').split(':')[0];
344
+ cfg.provider = o._prevProvider || plannerBase || 'claude-cli';
345
+ }
346
+ return cfg;
347
+ }
@@ -0,0 +1,58 @@
1
+ // Auth-token + origin gate helpers for the daemon. Pure — used only by
2
+ // the pre-switch middleware in makeHandler.
3
+
4
+ /**
5
+ * Constant-time string equality. Plain `===` would short-circuit on the
6
+ * first mismatching byte, leaking timing info that lets an attacker on
7
+ * a shared host narrow the secret one byte at a time. We compare every
8
+ * byte with XOR + accumulator.
9
+ */
10
+ export function constantTimeEqual(a, b) {
11
+ const aStr = String(a ?? '');
12
+ const bStr = String(b ?? '');
13
+ if (aStr.length !== bStr.length) return false;
14
+ let diff = 0;
15
+ for (let i = 0; i < aStr.length; i++) {
16
+ diff |= aStr.charCodeAt(i) ^ bStr.charCodeAt(i);
17
+ }
18
+ return diff === 0;
19
+ }
20
+
21
+ export function isAuthorized(req, expectedToken) {
22
+ if (!expectedToken) return true; // auth disabled
23
+ const header = req.headers['authorization'] || '';
24
+ const m = /^Bearer\s+(.+)$/i.exec(header);
25
+ if (!m) return false;
26
+ return constantTimeEqual(m[1].trim(), expectedToken);
27
+ }
28
+
29
+ /**
30
+ * Origin gate — protect against DNS-rebinding / CSRF where a page in
31
+ * the user's browser posts to 127.0.0.1:<our port>. Browsers always
32
+ * attach `Origin` for cross-origin POSTs (and increasingly for GETs);
33
+ * CLI tools (curl, fetch from a script) usually don't.
34
+ *
35
+ * Policy:
36
+ * - No `Origin` header → assume non-browser caller, allow.
37
+ * - `Origin` set → must be in `allowedOrigins`. Empty allowlist
38
+ * means "reject all browser-originated requests" — the default,
39
+ * because the daemon is designed for CLI/script callers.
40
+ * - `allowLoopback: true` (set by `lazyclaw dashboard`) additionally
41
+ * accepts any `Origin` that looks like loopback (`http://127.0.0.1:*`,
42
+ * `http://localhost:*`, `http://[::1]:*`). Safe because the daemon
43
+ * binds only to 127.0.0.1, so an attacker can't reach us with a
44
+ * loopback Origin unless they're already on the box. DNS rebinding
45
+ * can't forge `127.0.0.1` as a hostname — that resolves before
46
+ * `fetch()` ever issues the request.
47
+ *
48
+ * Returns true when the request should proceed, false when it should
49
+ * be rejected with 403.
50
+ */
51
+ const LOOPBACK_ORIGIN_RE = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
52
+ export function isOriginAllowed(req, allowedOrigins, allowLoopback) {
53
+ const origin = req.headers['origin'];
54
+ if (!origin) return true;
55
+ if (allowLoopback && LOOPBACK_ORIGIN_RE.test(origin)) return true;
56
+ if (!allowedOrigins || allowedOrigins.length === 0) return false;
57
+ return allowedOrigins.includes(origin);
58
+ }
@@ -0,0 +1,30 @@
1
+ // Cost-cap enforcement + per-handler metrics accumulation for the daemon.
2
+ // Pure — operates only on the passed-in metrics/costCap objects.
3
+
4
+ // Has the cumulative cost in any capped currency reached the cap?
5
+ // Returns the offending currency + amount + cap so the caller can
6
+ // surface it cleanly, or null when no cap is breached.
7
+ export function checkCostCap(metrics, costCap) {
8
+ if (!costCap) return null;
9
+ for (const [cur, cap] of Object.entries(costCap)) {
10
+ if (!Number.isFinite(cap) || cap <= 0) continue;
11
+ const spent = metrics.costsByCurrency[cur] || 0;
12
+ if (spent >= cap) return { currency: cur, spent: Math.round(spent * 1_000_000) / 1_000_000, cap };
13
+ }
14
+ return null;
15
+ }
16
+
17
+ // Bump per-handler metrics from a single request's cost+usage. Keys
18
+ // cost by currency so heterogeneous fleets (USD-priced anthropic, EUR
19
+ // regional contracts) don't silently sum mismatched numbers. Tokens
20
+ // are unit-free → single counter.
21
+ export function accumulateMetricsFromCost(metrics, usage, cost) {
22
+ if (cost && Number.isFinite(cost.cost)) {
23
+ const cur = cost.currency || 'USD';
24
+ metrics.costsByCurrency[cur] = (metrics.costsByCurrency[cur] || 0) + cost.cost;
25
+ }
26
+ if (usage) {
27
+ if (Number.isFinite(usage.inputTokens)) metrics.tokensTotal.inputTokens += usage.inputTokens;
28
+ if (Number.isFinite(usage.outputTokens)) metrics.tokensTotal.outputTokens += usage.outputTokens;
29
+ }
30
+ }
@@ -0,0 +1,108 @@
1
+ // daemon/lib/inbound_dedup.mjs — idempotency store for POST /inbound.
2
+ //
3
+ // A channel retry (Slack event redelivery, a listener restart replaying its
4
+ // backlog, app_mention+message double-fire arriving from two listener
5
+ // processes) must not run the provider twice or append duplicate turns to the
6
+ // session. Callers claim `${channel}:${messageId}` BEFORE persisting the user
7
+ // turn, record the reply after, and a duplicate replays the recorded reply.
8
+ //
9
+ // Persistence: append-only JSONL at <cfgDir>/inbound_seen.jsonl (0600 — the
10
+ // recorded replies are conversation content), loaded on open, compacted to
11
+ // the newest `cap` entries when the file grows past 4×cap lines. Pending
12
+ // (claimed-but-unrecorded) keys are memory-only with a TTL so a crash between
13
+ // claim and record can never permanently wedge a message id.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const DEFAULT_CAP = 500;
19
+ const DEFAULT_PENDING_TTL_MS = 120_000;
20
+ // Replays only need enough of the reply to repost it; cap what we persist so
21
+ // a pathological multi-MB reply can't balloon the store.
22
+ const MAX_PERSISTED_REPLY = 16_384;
23
+
24
+ // One instance per config dir — the daemon is long-lived and the store keeps
25
+ // its working set in memory; reopening per request would re-read the file.
26
+ const _instances = new Map();
27
+ export function _resetDedupCache() { _instances.clear(); }
28
+
29
+ export function openDedup(cfgDir, { cap = DEFAULT_CAP, pendingTtlMs = DEFAULT_PENDING_TTL_MS, now = Date.now } = {}) {
30
+ const existing = _instances.get(cfgDir);
31
+ if (existing) return existing;
32
+
33
+ const file = path.join(cfgDir, 'inbound_seen.jsonl');
34
+ /** @type {Map<string, {reply: string, threadId: string|null, sessionId: string|null, at: number}>} */
35
+ const entries = new Map(); // insertion order == age order
36
+ /** @type {Map<string, number>} pending claim -> claimed-at ms */
37
+ const pending = new Map();
38
+
39
+ const load = () => {
40
+ let raw;
41
+ try { raw = fs.readFileSync(file, 'utf8'); }
42
+ catch { return; }
43
+ for (const line of raw.split('\n')) {
44
+ if (!line.trim()) continue;
45
+ appendedLines++;
46
+ let rec;
47
+ try { rec = JSON.parse(line); } catch { continue; } // skip corrupt lines
48
+ if (!rec || typeof rec.key !== 'string') continue;
49
+ entries.delete(rec.key); // re-insert to refresh age order
50
+ entries.set(rec.key, { reply: rec.reply ?? '', threadId: rec.threadId ?? null, sessionId: rec.sessionId ?? null, at: rec.at ?? 0 });
51
+ }
52
+ trim();
53
+ };
54
+
55
+ const trim = () => {
56
+ while (entries.size > cap) {
57
+ const oldest = entries.keys().next().value;
58
+ entries.delete(oldest);
59
+ }
60
+ };
61
+
62
+ // Appended-lines counter (seeded by load) instead of re-reading the file on
63
+ // every record — the store may see one append per inbound message.
64
+ let appendedLines = 0;
65
+ const compactIfNeeded = () => {
66
+ if (appendedLines <= cap * 4) return;
67
+ const out = [...entries.entries()]
68
+ .map(([key, e]) => JSON.stringify({ key, ...e }))
69
+ .join('\n') + '\n';
70
+ try {
71
+ fs.writeFileSync(file, out, { mode: 0o600 });
72
+ appendedLines = entries.size;
73
+ } catch { /* compaction is best-effort */ }
74
+ };
75
+
76
+ const store = {
77
+ claim(key) {
78
+ const hit = entries.get(key);
79
+ if (hit) return { dup: true, pending: false, entry: hit };
80
+ const claimedAt = pending.get(key);
81
+ if (claimedAt != null && now() - claimedAt < pendingTtlMs) {
82
+ return { dup: true, pending: true, entry: null };
83
+ }
84
+ pending.set(key, now());
85
+ return { dup: false, pending: false, entry: null };
86
+ },
87
+ record(key, { reply = '', threadId = null, sessionId = null } = {}) {
88
+ pending.delete(key);
89
+ const bounded = String(reply).length > MAX_PERSISTED_REPLY
90
+ ? String(reply).slice(0, MAX_PERSISTED_REPLY) + '\n…(truncated for replay)'
91
+ : reply;
92
+ const entry = { reply: bounded, threadId, sessionId, at: now() };
93
+ entries.delete(key);
94
+ entries.set(key, entry);
95
+ trim();
96
+ try {
97
+ fs.appendFileSync(file, JSON.stringify({ key, ...entry }) + '\n', { mode: 0o600 });
98
+ appendedLines++;
99
+ } catch { /* persistence is best-effort; memory dedup still holds */ }
100
+ compactIfNeeded();
101
+ },
102
+ release(key) { pending.delete(key); },
103
+ };
104
+
105
+ load();
106
+ _instances.set(cfgDir, store);
107
+ return store;
108
+ }
@@ -0,0 +1,46 @@
1
+ // daemon/lib/learn_queue.mjs — bounded, serialised runner for the /inbound
2
+ // post-task learning hook.
3
+ //
4
+ // runLearning fires up to two trainer LLM completions (skill synthesis + the
5
+ // user model) and, with trainer 'auto', may spawn a claude-cli subprocess —
6
+ // per call, with no throttle of its own. A channel message burst hitting
7
+ // POST /inbound must not fan that out unbounded, so learning jobs run one at
8
+ // a time and the waiting line is capped: when it is full the newest job is
9
+ // DROPPED (learning is best-effort; the trajectory of a dropped turn is
10
+ // simply not recorded — the next turn learns again).
11
+
12
+ const MAX_DEPTH = 8;
13
+
14
+ let _running = false;
15
+ const _waiting = [];
16
+ let _dropped = 0;
17
+
18
+ function _drain() {
19
+ if (_running) return;
20
+ const job = _waiting.shift();
21
+ if (!job) return;
22
+ _running = true;
23
+ Promise.resolve()
24
+ .then(job)
25
+ .catch(() => { /* learning is best-effort */ })
26
+ .finally(() => { _running = false; _drain(); });
27
+ }
28
+
29
+ // Enqueue a learning job (a function returning a promise). Returns true when
30
+ // accepted, false when the queue was full and the job was dropped.
31
+ export function enqueueLearning(job) {
32
+ if (_waiting.length >= MAX_DEPTH) {
33
+ _dropped++;
34
+ if (_dropped === 1 || _dropped % 50 === 0) {
35
+ process.stderr.write(`[learn] queue full — dropped ${_dropped} learning job(s) so far (burst protection)\n`);
36
+ }
37
+ return false;
38
+ }
39
+ _waiting.push(job);
40
+ _drain();
41
+ return true;
42
+ }
43
+
44
+ // Test hooks.
45
+ export function _learnQueueStats() { return { running: _running, waiting: _waiting.length, dropped: _dropped }; }
46
+ export function _resetLearnQueue() { _waiting.length = 0; _running = false; _dropped = 0; }
@@ -0,0 +1,69 @@
1
+ // Provider resolution for the daemon: composes the base provider with
2
+ // opt-in cache / fallback / retry wrappers per request.
3
+
4
+ import { PROVIDERS } from '../../providers/registry.mjs';
5
+ import { withRateLimitRetry } from '../../providers/retry.mjs';
6
+ import { withFallback } from '../../providers/fallback.mjs';
7
+ import { withResponseCache } from '../../providers/cache.mjs';
8
+
9
+ // Resolve the provider for a request. Composes opt-in wrappers in this
10
+ // order (innermost first):
11
+ // 1. cache — wraps the base provider so cache hits never trigger
12
+ // fallback or retry (a hit is a successful response).
13
+ // 2. fallback — chain of alternates; pre-yield recoverable errors fall
14
+ // through; mid-stream errors bubble.
15
+ // 3. retry — outermost so each retry covers the full chain (retry
16
+ // exhausts → 429 to client).
17
+ // `cachedByName` is a per-handler Map shared across requests so the
18
+ // cache state actually persists between calls. Without that the cache
19
+ // would be empty on every request.
20
+ //
21
+ // Returns { provider } on success or { error } when the primary or any
22
+ // listed fallback name is unknown.
23
+ export function resolveProvider(body, providerName, cachedByName, logger) {
24
+ if (!PROVIDERS[providerName]) return { error: `unknown provider: ${providerName}` };
25
+ // The decorator callbacks emit one debug line each — useful for ops who
26
+ // set --log debug to diagnose why a request is slow or which provider
27
+ // actually served it. With the default level (info) these are silent.
28
+ const dbg = (msg, fields) => { if (logger) logger.debug(msg, fields); };
29
+ const wrapWithCache = (name) => {
30
+ if (!cachedByName) return PROVIDERS[name];
31
+ if (!cachedByName.has(name)) {
32
+ cachedByName.set(name, withResponseCache(PROVIDERS[name], {
33
+ maxEntries: cachedByName._opts?.maxEntries,
34
+ ttlMs: cachedByName._opts?.ttlMs,
35
+ onHit: ({ keyHash, size }) => dbg('cache.hit', { provider: name, keyHash: keyHash.slice(0, 12), size }),
36
+ onMiss: ({ keyHash }) => dbg('cache.miss', { provider: name, keyHash: keyHash.slice(0, 12) }),
37
+ }));
38
+ }
39
+ return cachedByName.get(name);
40
+ };
41
+ // Cache only when the request explicitly opts in. The handler-level
42
+ // Map is shared so two requests with body.cache=true to the same base
43
+ // provider hit the same cache.
44
+ const useCache = !!body?.cache;
45
+ let prov = useCache ? wrapWithCache(providerName) : PROVIDERS[providerName];
46
+ if (Array.isArray(body?.fallback) && body.fallback.length > 0) {
47
+ const chain = [prov];
48
+ for (const name of body.fallback) {
49
+ if (!PROVIDERS[name]) return { error: `unknown fallback provider: ${name}` };
50
+ chain.push(useCache ? wrapWithCache(name) : PROVIDERS[name]);
51
+ }
52
+ prov = withFallback(chain, {
53
+ onFallback: ({ from, to, err }) => dbg('provider.fallback', {
54
+ from, to, errorCode: err?.code || null, errorMsg: String(err?.message || err).slice(0, 120),
55
+ }),
56
+ });
57
+ }
58
+ const r = body?.retry;
59
+ if (r && Number.isFinite(r.attempts) && r.attempts > 0) {
60
+ prov = withRateLimitRetry(prov, {
61
+ attempts: r.attempts,
62
+ maxBackoffMs: r.maxBackoffMs,
63
+ onRetry: ({ attempt, retryAfterMs, err }) => dbg('provider.retry', {
64
+ attempt, retryAfterMs, errorCode: err?.code || null,
65
+ }),
66
+ });
67
+ }
68
+ return { provider: prov };
69
+ }
@@ -0,0 +1,83 @@
1
+ // HTTP request/response helpers for the daemon: body readers, JSON/SSE
2
+ // writers, provider-error status mapping, and a small fs existence check.
3
+ // Pure — no daemon state — so any route module can import these freely.
4
+
5
+ import fs from 'node:fs';
6
+
7
+ export async function fileExists(p) {
8
+ try { await fs.promises.access(p); return true; }
9
+ catch { return false; }
10
+ }
11
+
12
+ export function readJson(req) {
13
+ return new Promise((resolve, reject) => {
14
+ let buf = '';
15
+ req.setEncoding('utf8');
16
+ req.on('data', d => { buf += d; if (buf.length > 5 * 1024 * 1024) { reject(new Error('body too large')); req.destroy(); } });
17
+ req.on('end', () => {
18
+ if (!buf) return resolve({});
19
+ try { resolve(JSON.parse(buf)); }
20
+ catch (e) { reject(new Error(`invalid JSON body: ${e.message}`)); }
21
+ });
22
+ req.on('error', reject);
23
+ });
24
+ }
25
+
26
+ // Raw body reader — used for `PUT /skills/<name>` where the body is
27
+ // markdown rather than JSON. Same 1 MiB cap as the CLI's `--from-url`
28
+ // path so HTTP can't sneak past the safeguard the CLI enforces.
29
+ export const SKILL_MAX_BYTES = 1_048_576;
30
+ export function readTextBody(req, maxBytes = SKILL_MAX_BYTES) {
31
+ return new Promise((resolve, reject) => {
32
+ let buf = '';
33
+ req.setEncoding('utf8');
34
+ req.on('data', d => {
35
+ buf += d;
36
+ if (buf.length > maxBytes) {
37
+ reject(new Error(`body exceeds ${maxBytes} bytes`));
38
+ req.destroy();
39
+ }
40
+ });
41
+ req.on('end', () => resolve(buf));
42
+ req.on('error', reject);
43
+ });
44
+ }
45
+
46
+ export function writeJson(res, status, obj, extraHeaders = {}) {
47
+ const body = JSON.stringify(obj);
48
+ res.writeHead(status, {
49
+ 'content-type': 'application/json; charset=utf-8',
50
+ 'content-length': Buffer.byteLength(body),
51
+ ...extraHeaders,
52
+ });
53
+ res.end(body);
54
+ }
55
+
56
+ // Map provider error codes to HTTP statuses so clients can branch on
57
+ // res.status instead of parsing error messages. Returns
58
+ // { status, headers? } so 429 can attach a Retry-After.
59
+ //
60
+ // Exported for unit testing without spinning up an actual provider that
61
+ // would only fail under live network conditions.
62
+ export function statusForProviderError(err) {
63
+ if (err?.code === 'INVALID_KEY') return { status: 401 };
64
+ if (err?.code === 'RATE_LIMIT') {
65
+ const retrySeconds = Math.max(1, Math.ceil((err.retryAfterMs || 1000) / 1000));
66
+ return { status: 429, headers: { 'retry-after': String(retrySeconds) } };
67
+ }
68
+ if (err?.status && err.status >= 400 && err.status < 600) return { status: err.status };
69
+ return { status: 502 };
70
+ }
71
+
72
+ export function writeSseHead(res) {
73
+ res.writeHead(200, {
74
+ 'content-type': 'text/event-stream; charset=utf-8',
75
+ 'cache-control': 'no-cache, no-transform',
76
+ 'connection': 'close',
77
+ });
78
+ }
79
+
80
+ export function writeSse(res, event, data) {
81
+ if (event) res.write(`event: ${event}\n`);
82
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
83
+ }
@@ -0,0 +1,96 @@
1
+ // Ordered route table for the daemon. The dispatch loop in makeHandler
2
+ // walks this array top-to-bottom and runs the first entry whose `m`
3
+ // predicate matches the request — FIRST MATCH WINS.
4
+ //
5
+ // The order here is IDENTICAL to the original makeHandler `switch (true)`
6
+ // case order. Several predicates are exclusion-based (e.g.
7
+ // `providerMatch[1] !== 'test'`, `configKeyMatch[1] !== 'validate'`) and
8
+ // rely on that ordering to stay correct. DO NOT REORDER.
9
+ //
10
+ // `m(c)` receives the per-request dispatch context built in makeHandler
11
+ // (route/method/url + the pre-computed path-param regex matches). `h(c)`
12
+ // is the matching route handler.
13
+
14
+ import * as meta from './routes/meta.mjs';
15
+ import * as providers from './routes/providers.mjs';
16
+ import * as rates from './routes/rates.mjs';
17
+ import * as config from './routes/config.mjs';
18
+ import * as sessions from './routes/sessions.mjs';
19
+ import * as workflows from './routes/workflows.mjs';
20
+ import * as skills from './routes/skills.mjs';
21
+ import * as conversation from './routes/conversation.mjs';
22
+ import * as registry from './routes/registry.mjs';
23
+ import * as ops from './routes/ops.mjs';
24
+
25
+ export const ROUTES = [
26
+ { m: (c) => c.route === 'GET /' || c.route === 'GET /dashboard', h: meta.dashboard },
27
+ { m: (c) => c.route === 'GET /dashboard.css', h: meta.dashboardCss },
28
+ { m: (c) => c.route === 'GET /dashboard.js', h: meta.dashboardJs },
29
+ { m: (c) => c.route === 'GET /version', h: meta.version },
30
+ { m: (c) => c.route === 'POST /exec/request', h: conversation.execRequest },
31
+ { m: (c) => c.route === 'GET /health' || c.route === 'GET /healthz', h: meta.health },
32
+ { m: (c) => c.route === 'GET /metrics', h: meta.metrics },
33
+ { m: (c) => c.route === 'GET /providers', h: providers.providersList },
34
+ { m: (c) => c.req.method === 'GET' && !!c.providerMatch && c.providerMatch[1] !== 'test', h: providers.providerGet },
35
+ { m: (c) => c.route === 'GET /providers/test', h: providers.providersTest },
36
+ { m: (c) => c.req.method === 'GET' && !!c.providerTestMatch, h: providers.providerTest },
37
+ { m: (c) => c.route === 'POST /providers', h: providers.providersCreate },
38
+ { m: (c) => c.req.method === 'DELETE' && !!c.providerMatch && c.providerMatch[1] !== 'test', h: providers.providerDelete },
39
+ { m: (c) => c.route === 'GET /rates', h: rates.ratesList },
40
+ { m: (c) => c.route === 'GET /rates/validate', h: rates.ratesValidate },
41
+ { m: (c) => c.route === 'GET /rates/shape', h: rates.ratesShape },
42
+ { m: (c) => c.req.method === 'PUT' && !!c.ratesKeyMatch && c.ratesKeyMatch[1] !== 'validate' && c.ratesKeyMatch[1] !== 'shape', h: rates.ratePut },
43
+ { m: (c) => c.req.method === 'DELETE' && !!c.ratesKeyMatch && c.ratesKeyMatch[1] !== 'validate' && c.ratesKeyMatch[1] !== 'shape', h: rates.rateDelete },
44
+ { m: (c) => c.route === 'GET /status', h: meta.status },
45
+ { m: (c) => c.route === 'GET /config/validate', h: config.configValidate },
46
+ { m: (c) => c.route === 'GET /config', h: config.configGet },
47
+ { m: (c) => c.req.method === 'GET' && !!c.configKeyMatch && c.configKeyMatch[1] !== 'validate', h: config.configKeyGet },
48
+ { m: (c) => c.req.method === 'PUT' && !!c.configKeyMatch && c.configKeyMatch[1] !== 'validate', h: config.configKeyPut },
49
+ { m: (c) => c.req.method === 'DELETE' && !!c.configKeyMatch && c.configKeyMatch[1] !== 'validate', h: config.configKeyDelete },
50
+ { m: (c) => c.route === 'GET /doctor', h: meta.doctor },
51
+ { m: (c) => c.route === 'GET /sessions', h: sessions.sessionsList },
52
+ { m: (c) => c.route === 'GET /sessions/search', h: sessions.sessionsSearch },
53
+ { m: (c) => c.req.method === 'GET' && !!c.sessionExportMatch, h: sessions.sessionExport },
54
+ { m: (c) => c.req.method === 'GET' && !!c.sessionMatch, h: sessions.sessionGet },
55
+ { m: (c) => c.route === 'GET /workflows/aggregate', h: workflows.workflowsAggregate },
56
+ { m: (c) => c.route === 'GET /workflows', h: workflows.workflowsList },
57
+ { m: (c) => c.req.method === 'GET' && !!c.workflowMatch, h: workflows.workflowGet },
58
+ { m: (c) => c.route === 'GET /skills', h: skills.skillsList },
59
+ { m: (c) => c.route === 'GET /skills/suggestions', h: skills.skillsSuggestions },
60
+ { m: (c) => c.route === 'POST /skills/synth', h: skills.skillsSynth },
61
+ { m: (c) => c.route === 'GET /skills/search', h: skills.skillsSearch },
62
+ { m: (c) => c.req.method === 'GET' && !!c.skillMatch, h: skills.skillGet },
63
+ { m: (c) => c.req.method === 'PUT' && !!c.skillMatch, h: skills.skillPut },
64
+ { m: (c) => c.req.method === 'DELETE' && !!c.skillMatch, h: skills.skillDelete },
65
+ { m: (c) => c.req.method === 'DELETE' && !!c.sessionMatch, h: sessions.sessionDelete },
66
+ { m: (c) => c.req.method === 'DELETE' && !!c.workflowMatch, h: workflows.workflowDelete },
67
+ { m: (c) => c.route === 'POST /chat', h: conversation.chat },
68
+ { m: (c) => c.route === 'POST /inbound', h: conversation.inbound },
69
+ { m: (c) => c.route === 'POST /handoff', h: conversation.handoff },
70
+ { m: (c) => c.route === 'POST /agent', h: conversation.agent },
71
+ { m: (c) => c.route === 'GET /agents', h: registry.agentsList },
72
+ { m: (c) => c.route === 'POST /agents', h: registry.agentsCreate },
73
+ { m: (c) => c.req.method === 'GET' && /^\/agents\/([^/]+)$/.test(c.url.pathname), h: registry.agentGet },
74
+ { m: (c) => c.req.method === 'PATCH' && /^\/agents\/([^/]+)$/.test(c.url.pathname), h: registry.agentPatch },
75
+ { m: (c) => c.req.method === 'DELETE' && /^\/agents\/([^/]+)$/.test(c.url.pathname), h: registry.agentDelete },
76
+ { m: (c) => c.req.method === 'GET' && /^\/agents\/([^/]+)\/memory$/.test(c.url.pathname), h: registry.agentMemoryGet },
77
+ { m: (c) => c.req.method === 'PUT' && /^\/agents\/([^/]+)\/memory$/.test(c.url.pathname), h: registry.agentMemoryPut },
78
+ { m: (c) => c.req.method === 'DELETE' && /^\/agents\/([^/]+)\/memory$/.test(c.url.pathname), h: registry.agentMemoryDelete },
79
+ { m: (c) => c.route === 'GET /teams', h: registry.teamsList },
80
+ { m: (c) => c.route === 'POST /teams', h: registry.teamsCreate },
81
+ { m: (c) => c.req.method === 'GET' && /^\/teams\/([^/]+)$/.test(c.url.pathname), h: registry.teamGet },
82
+ { m: (c) => c.req.method === 'PATCH' && /^\/teams\/([^/]+)$/.test(c.url.pathname), h: registry.teamPatch },
83
+ { m: (c) => c.req.method === 'DELETE' && /^\/teams\/([^/]+)$/.test(c.url.pathname), h: registry.teamDelete },
84
+ { m: (c) => c.route === 'GET /tasks', h: registry.tasksList },
85
+ { m: (c) => c.req.method === 'GET' && /^\/tasks\/([^/]+)\/transcript$/.test(c.url.pathname), h: registry.taskTranscript },
86
+ { m: (c) => c.req.method === 'GET' && /^\/tasks\/([^/]+)$/.test(c.url.pathname), h: registry.taskGet },
87
+ { m: (c) => c.req.method === 'DELETE' && /^\/tasks\/([^/]+)$/.test(c.url.pathname), h: registry.taskDelete },
88
+ { m: (c) => c.req.method === 'POST' && /^\/tasks\/([^/]+)\/(done|abandon)$/.test(c.url.pathname), h: registry.taskAction },
89
+ { m: (c) => c.route === 'GET /trainer/status', h: ops.trainerStatus },
90
+ { m: (c) => c.route === 'GET /recall', h: ops.recall },
91
+ { m: (c) => c.route === 'GET /sandbox', h: ops.sandboxList },
92
+ { m: (c) => c.req.method === 'POST' && /^\/sandbox\/([^/]+)\/test$/.test(c.url.pathname), h: ops.sandboxTest },
93
+ { m: (c) => c.route === 'POST /sandbox/use', h: ops.sandboxUse },
94
+ { m: (c) => c.route === 'GET /channels', h: ops.channels },
95
+ { m: (c) => c.route === 'POST /index/rebuild', h: ops.indexRebuild },
96
+ ];
@@ -0,0 +1,42 @@
1
+ // Dependency barrel for daemon route modules. Re-exports every
2
+ // module-level helper/import that route handler bodies reference, so each
3
+ // route module can pull them with a single import and the handler bodies
4
+ // stay byte-identical to their original form in makeHandler.
5
+
6
+ import fs from 'node:fs';
7
+ import nodePath from 'node:path';
8
+ export { fs, nodePath };
9
+
10
+ export { PROVIDERS, PROVIDER_INFO, maskApiKey } from '../../providers/registry.mjs';
11
+ export { costFromUsage, RATE_CARD_SHAPE } from '../../providers/rates.mjs';
12
+ export {
13
+ composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill,
14
+ removeSkill, parseFrontmatter, defaultConfigDir as skillsDefaultConfigDir,
15
+ } from '../../skills.mjs';
16
+ export * as indexDb from '../../mas/index_db.mjs';
17
+ export * as skillSynth from '../../mas/skill_synth.mjs';
18
+ export { listBackends as sandboxListBackends } from '../../sandbox/index.mjs';
19
+ export {
20
+ summarizeState, listSessions as listWorkflowSessions,
21
+ loadStateFile as loadWorkflowState, aggregateNodeStats,
22
+ } from '../../workflow/summary.mjs';
23
+ export { validateConfig } from '../../config-validate.mjs';
24
+ export { validateRates } from '../../rates-validate.mjs';
25
+
26
+ export {
27
+ fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse,
28
+ statusForProviderError,
29
+ } from '../lib/respond.mjs';
30
+ export { checkCostCap, accumulateMetricsFromCost } from '../lib/cost.mjs';
31
+ export { resolveProvider } from '../lib/provider.mjs';
32
+ // F5/F6 — cross-channel handoff: the threads store + the rollback-aware
33
+ // migration helper, so the conversation routes can bind inbound messages to a
34
+ // persistent thread/session and re-point them across channels.
35
+ export { openThreads } from '../../channels/threads.mjs';
36
+ export { handoffWithRollback } from '../../channels/handoff.mjs';
37
+ // Phase 4 — /inbound idempotency: dedup retried/redelivered channel messages
38
+ // by their native message id so the provider runs once per message.
39
+ export { openDedup } from '../lib/inbound_dedup.mjs';
40
+ // Phase 4 — serialised, depth-capped runner for the /inbound learning hook
41
+ // (a message burst must not fan out unbounded trainer calls).
42
+ export { enqueueLearning } from '../lib/learn_queue.mjs';