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.
- package/README.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +10 -21
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +254 -0
- package/commands/chat.mjs +1253 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +275 -0
- package/commands/gateway.mjs +194 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +490 -0
- package/commands/service.mjs +113 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +742 -0
- package/commands/setup_channels.mjs +207 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +42 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +435 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +165 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/dotenv_min.mjs +51 -0
- package/first_run.mjs +24 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/registry_boot.mjs +55 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +15 -3
- package/providers/probe.mjs +28 -0
- package/secure_write.mjs +46 -0
- package/tui/editor.mjs +18 -2
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
package/config_features.mjs
CHANGED
|
@@ -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';
|