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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Daemon route handlers (config), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
|
|
6
|
+
export async function configValidate(c) {
|
|
7
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
8
|
+
// Mirror of v3.39's `lazyclaw config validate`. Same shape
|
|
9
|
+
// (single source of truth in config-validate.mjs). HTTP
|
|
10
|
+
// status reflects ok/issues so a UI's "config status"
|
|
11
|
+
// badge can branch on HTTP code: 200 = ok, 422 = issues
|
|
12
|
+
// (Unprocessable Entity — semantically right for "the
|
|
13
|
+
// config you supplied is malformed").
|
|
14
|
+
const cfg = ctx.readConfig();
|
|
15
|
+
const { ok, issues, warnings } = validateConfig(cfg, PROVIDERS);
|
|
16
|
+
return writeJson(res, ok ? 200 : 422, {
|
|
17
|
+
ok,
|
|
18
|
+
keys: Object.keys(cfg),
|
|
19
|
+
issues,
|
|
20
|
+
warnings,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function configGet(c) {
|
|
25
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
26
|
+
// Mirror of `lazyclaw config list`. Returns every stored key
|
|
27
|
+
// with the api-key value masked — lets a dashboard or script
|
|
28
|
+
// inspect the active configuration without shelling to the CLI.
|
|
29
|
+
const cfg = ctx.readConfig();
|
|
30
|
+
const safe = { ...cfg };
|
|
31
|
+
if (safe['api-key']) safe['api-key'] = maskApiKey(safe['api-key']);
|
|
32
|
+
return writeJson(res, 200, safe);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function configKeyGet(c) {
|
|
36
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
37
|
+
// Mirror of `lazyclaw config get <key>`. The `!== 'validate'`
|
|
38
|
+
// guard ensures the literal GET /config/validate case (above)
|
|
39
|
+
// is never shadowed by this dynamic handler.
|
|
40
|
+
const key = configKeyMatch[1];
|
|
41
|
+
const cfg = ctx.readConfig();
|
|
42
|
+
if (!(key in cfg)) {
|
|
43
|
+
return writeJson(res, 404, { error: 'key not found', key });
|
|
44
|
+
}
|
|
45
|
+
const raw = cfg[key];
|
|
46
|
+
const value = key === 'api-key' ? maskApiKey(raw) : raw;
|
|
47
|
+
return writeJson(res, 200, { key, value });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function configKeyPut(c) {
|
|
51
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
52
|
+
// PUT /config/<key> body: { value: <any> }
|
|
53
|
+
// Mirror of `lazyclaw config set <key> <value>`. Re-validates the
|
|
54
|
+
// whole config after the write so we never persist a broken state.
|
|
55
|
+
// Nested cargo (customProviders / rates / authProfiles) goes
|
|
56
|
+
// through its own dedicated endpoint — guarded here so a
|
|
57
|
+
// dashboard PUT can't bypass schema validation.
|
|
58
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
59
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
60
|
+
}
|
|
61
|
+
const key = configKeyMatch[1];
|
|
62
|
+
if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
|
|
63
|
+
return writeJson(res, 400, {
|
|
64
|
+
error: `use the dedicated endpoint for "${key}" — POST /providers · PUT /rates/<key> · authProfiles via CLI`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
let body;
|
|
68
|
+
try { body = await readJson(req); }
|
|
69
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
70
|
+
const value = body && Object.prototype.hasOwnProperty.call(body, 'value') ? body.value : undefined;
|
|
71
|
+
const cfg = ctx.readConfig();
|
|
72
|
+
if (value === null || value === undefined) delete cfg[key];
|
|
73
|
+
else cfg[key] = value;
|
|
74
|
+
const v = validateConfig(cfg, PROVIDERS);
|
|
75
|
+
if (!v.ok) return writeJson(res, 422, v);
|
|
76
|
+
ctx.writeConfig(cfg);
|
|
77
|
+
const stored = key === 'api-key' && typeof cfg[key] === 'string' ? maskApiKey(cfg[key]) : cfg[key];
|
|
78
|
+
return writeJson(res, 200, { ok: true, key, value: stored });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function configKeyDelete(c) {
|
|
82
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
83
|
+
// DELETE /config/<key> — same as `lazyclaw config delete`.
|
|
84
|
+
// Idempotent: 200 with `removed: false` when the key wasn't
|
|
85
|
+
// present.
|
|
86
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
87
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
88
|
+
}
|
|
89
|
+
const key = configKeyMatch[1];
|
|
90
|
+
if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
|
|
91
|
+
return writeJson(res, 400, { error: `delete via the dedicated endpoint for "${key}"` });
|
|
92
|
+
}
|
|
93
|
+
const cfg = ctx.readConfig();
|
|
94
|
+
if (!(key in cfg)) return writeJson(res, 200, { ok: true, key, removed: false });
|
|
95
|
+
delete cfg[key];
|
|
96
|
+
ctx.writeConfig(cfg);
|
|
97
|
+
return writeJson(res, 200, { ok: true, key, removed: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// Daemon route handlers (conversation), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider, openThreads, handoffWithRollback, openDedup, enqueueLearning } from './_deps.mjs';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// F5 — mint a fresh session id for a newly-seen channel:externalId binding.
|
|
8
|
+
// Kept filename-local (threads.mjs's newThreadId isn't exported); the `ib_`
|
|
9
|
+
// hex form satisfies sessions.mjs sessionPath validation (no / \ . ..).
|
|
10
|
+
function newInboundSessionId() {
|
|
11
|
+
return 'ib_' + randomBytes(8).toString('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function execRequest(c) {
|
|
15
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
16
|
+
// Remote exec-approval bridge. This route is AUTH-TOKEN-GATED
|
|
17
|
+
// (above), so only the trusted local operator/CLI can REQUEST an
|
|
18
|
+
// approval; a paired mobile device RESOLVES it over the gateway
|
|
19
|
+
// (POST /gateway/exec/resolve). The route long-polls: it awaits
|
|
20
|
+
// the device's decision (or the approval's timeout) and returns
|
|
21
|
+
// { approved, by, reason }.
|
|
22
|
+
let body;
|
|
23
|
+
try { body = await readJson(req); }
|
|
24
|
+
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
25
|
+
if (!body || typeof body.tool !== 'string' || !body.tool) {
|
|
26
|
+
return writeJson(res, 400, { error: 'tool is required' });
|
|
27
|
+
}
|
|
28
|
+
const { promise } = gateway.requestApproval(
|
|
29
|
+
{ tool: body.tool, args: body.args, agentId: body.agentId, summary: body.summary },
|
|
30
|
+
{ timeoutMs: Number.isFinite(+body.timeoutMs) ? +body.timeoutMs : undefined },
|
|
31
|
+
);
|
|
32
|
+
const result = await promise;
|
|
33
|
+
return writeJson(res, 200, result);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function chat(c) {
|
|
37
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
38
|
+
// Cost-cap gate: short-circuit before parsing the body so the
|
|
39
|
+
// 402 fires fast and we don't pay for body buffering on a
|
|
40
|
+
// request we're refusing.
|
|
41
|
+
const breach = checkCostCap(metrics, costCap);
|
|
42
|
+
if (breach) {
|
|
43
|
+
return writeJson(res, 402, {
|
|
44
|
+
error: 'cost cap exceeded',
|
|
45
|
+
currency: breach.currency,
|
|
46
|
+
spent: breach.spent,
|
|
47
|
+
cap: breach.cap,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Full message-array input, single response (or stream). Useful when
|
|
51
|
+
// the caller already has a message history and doesn't want to use
|
|
52
|
+
// the disk-persisted session model.
|
|
53
|
+
const body = await readJson(req);
|
|
54
|
+
const cfg = ctx.readConfig();
|
|
55
|
+
const provName = body.provider || cfg.provider || 'mock';
|
|
56
|
+
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
57
|
+
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
58
|
+
const prov = resolved.provider;
|
|
59
|
+
const messages = Array.isArray(body.messages) ? body.messages.filter(m => m && typeof m.role === 'string' && typeof m.content === 'string') : null;
|
|
60
|
+
if (!messages || messages.length === 0) return writeJson(res, 400, { error: 'messages array required' });
|
|
61
|
+
const thinkingBudget = Number(body.thinkingBudget) || 0;
|
|
62
|
+
// Usage capture: opt-in via body.usage. The provider only does
|
|
63
|
+
// the extra work (and pays the wire cost on OpenAI) when the
|
|
64
|
+
// caller asks for it.
|
|
65
|
+
let captured = null;
|
|
66
|
+
const sendOpts = {
|
|
67
|
+
apiKey: cfg['api-key'],
|
|
68
|
+
model: body.model || cfg.model,
|
|
69
|
+
thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
|
|
70
|
+
onUsage: body.usage ? (u) => { captured = u; } : undefined,
|
|
71
|
+
};
|
|
72
|
+
// Cost lookup: body.cost:true asks the daemon to attach a cost
|
|
73
|
+
// block when usage was captured AND cfg.rates has a card for
|
|
74
|
+
// the active provider/model. Pure arithmetic — no extra wire
|
|
75
|
+
// calls. Inline rather than helper-extract because the two
|
|
76
|
+
// response paths (stream / non-stream) need to bind it
|
|
77
|
+
// differently (SSE event vs JSON field).
|
|
78
|
+
const computeCost = () => {
|
|
79
|
+
if (!body.cost || !captured || !cfg.rates) return null;
|
|
80
|
+
try {
|
|
81
|
+
const c = costFromUsage(
|
|
82
|
+
{ provider: provName, model: body.model || cfg.model, usage: captured },
|
|
83
|
+
cfg.rates,
|
|
84
|
+
);
|
|
85
|
+
if (c) accumulateMetricsFromCost(metrics, captured, c);
|
|
86
|
+
return c;
|
|
87
|
+
} catch { return null; }
|
|
88
|
+
};
|
|
89
|
+
if (body.stream === true) {
|
|
90
|
+
writeSseHead(res);
|
|
91
|
+
// Abort the provider when the SSE client disconnects — otherwise it
|
|
92
|
+
// keeps generating (burning tokens/cost) for a caller that's gone.
|
|
93
|
+
// Mirrors the /agent stream path.
|
|
94
|
+
const ac = new AbortController();
|
|
95
|
+
req.on('aborted', () => ac.abort());
|
|
96
|
+
res.on('close', () => { if (!res.writableEnded) ac.abort(); });
|
|
97
|
+
try {
|
|
98
|
+
for await (const chunk of prov.sendMessage(messages, { ...sendOpts, signal: ac.signal })) {
|
|
99
|
+
if (ac.signal.aborted) break;
|
|
100
|
+
writeSse(res, 'token', { text: chunk });
|
|
101
|
+
await new Promise(r => setImmediate(r));
|
|
102
|
+
}
|
|
103
|
+
if (!ac.signal.aborted) {
|
|
104
|
+
if (captured) writeSse(res, 'usage', captured);
|
|
105
|
+
const cost = computeCost();
|
|
106
|
+
if (cost) writeSse(res, 'cost', cost);
|
|
107
|
+
writeSse(res, 'done', { ok: true });
|
|
108
|
+
}
|
|
109
|
+
return res.end();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err?.code === 'ABORT' || ac.signal.aborted) return res.end();
|
|
112
|
+
writeSse(res, 'error', { message: err?.message || String(err) });
|
|
113
|
+
return res.end();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
let acc = '';
|
|
117
|
+
try {
|
|
118
|
+
for await (const chunk of prov.sendMessage(messages, sendOpts)) acc += chunk;
|
|
119
|
+
const cost = computeCost();
|
|
120
|
+
const out = { reply: acc };
|
|
121
|
+
if (captured) out.usage = captured;
|
|
122
|
+
if (cost) out.cost = cost;
|
|
123
|
+
return writeJson(res, 200, out);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const m = statusForProviderError(err);
|
|
126
|
+
return writeJson(res, m.status, {
|
|
127
|
+
error: err?.message || String(err),
|
|
128
|
+
code: err?.code || null,
|
|
129
|
+
...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
130
|
+
}, m.headers || {});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function inbound(c) {
|
|
135
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
136
|
+
// Generic inbound bridge — a stable, channel-agnostic relay
|
|
137
|
+
// target so ANY platform (a Discord/WhatsApp/etc. bot the user
|
|
138
|
+
// runs elsewhere) can forward a message in and get one reply,
|
|
139
|
+
// without lazyclaw shipping that platform's SDK. Auth-token
|
|
140
|
+
// gated like every non-gateway route; additionally pairing-gated
|
|
141
|
+
// on senderId when a pairing allowlist is configured.
|
|
142
|
+
const breach = checkCostCap(metrics, costCap);
|
|
143
|
+
if (breach) return writeJson(res, 402, { error: 'cost cap exceeded', currency: breach.currency, spent: breach.spent, cap: breach.cap });
|
|
144
|
+
let body;
|
|
145
|
+
try { body = await readJson(req); }
|
|
146
|
+
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
147
|
+
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
148
|
+
if (!text) return writeJson(res, 400, { error: 'text is required' });
|
|
149
|
+
const cfg = ctx.readConfig();
|
|
150
|
+
// Pairing gate: when the operator has paired any senders, the
|
|
151
|
+
// relay must identify an allowlisted senderId.
|
|
152
|
+
const allow = Array.isArray(cfg.pairing) ? cfg.pairing.map((p) => String(p && p.id)) : [];
|
|
153
|
+
if (allow.length > 0) {
|
|
154
|
+
const sender = String(body.senderId || '');
|
|
155
|
+
if (!sender || !allow.includes(sender)) return writeJson(res, 403, { error: 'sender not paired' });
|
|
156
|
+
}
|
|
157
|
+
const provName = body.provider || cfg.provider || 'mock';
|
|
158
|
+
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
159
|
+
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
160
|
+
// F5 — when the relaying bot identifies its channel + external
|
|
161
|
+
// conversation id, bind {channel, externalId} -> a persistent
|
|
162
|
+
// session so context follows across channels (and across a
|
|
163
|
+
// /handoff). Without those fields we keep the original stateless
|
|
164
|
+
// one-shot behavior, byte-compatible with existing callers.
|
|
165
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
166
|
+
const channel = (typeof body.channel === 'string' && body.channel) ? body.channel : null;
|
|
167
|
+
const externalId = (body.externalId != null && String(body.externalId)) ? String(body.externalId) : null;
|
|
168
|
+
// Phase 4 — idempotency. When the relay supplies the native message
|
|
169
|
+
// id, claim a key BEFORE any turn is persisted: a recorded duplicate
|
|
170
|
+
// replays the prior reply (no provider call, no double appendTurn);
|
|
171
|
+
// an in-flight duplicate answers empty so the listener stays silent
|
|
172
|
+
// while the first request finishes. The key is scoped to the
|
|
173
|
+
// CONVERSATION (`channel:externalId:messageId`) so a colliding or
|
|
174
|
+
// forged messageId from another conversation can never replay this
|
|
175
|
+
// one's reply or sessionId.
|
|
176
|
+
const messageId = (body.messageId != null && String(body.messageId)) ? String(body.messageId) : null;
|
|
177
|
+
let dedup = null;
|
|
178
|
+
let dedupKey = null;
|
|
179
|
+
if (channel && messageId) {
|
|
180
|
+
dedup = openDedup(cfgDir);
|
|
181
|
+
dedupKey = `${channel}:${externalId || ''}:${messageId}`;
|
|
182
|
+
const seen = dedup.claim(dedupKey);
|
|
183
|
+
if (seen.dup) {
|
|
184
|
+
if (seen.pending) return writeJson(res, 200, { reply: '', threadId: body.threadId || null, duplicate: true });
|
|
185
|
+
const e = seen.entry;
|
|
186
|
+
const dupOut = { reply: e.reply, threadId: e.threadId, duplicate: true };
|
|
187
|
+
if (e.sessionId) dupOut.sessionId = e.sessionId;
|
|
188
|
+
return writeJson(res, 200, dupOut);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Everything from here to record() must free the pending claim on
|
|
192
|
+
// ANY failure (not just provider errors) — otherwise a thrown
|
|
193
|
+
// loadTurns/appendTurn would wedge the message id for the whole
|
|
194
|
+
// pending TTL and retries would be silently dropped.
|
|
195
|
+
let dedupRecorded = false;
|
|
196
|
+
try {
|
|
197
|
+
let threads = null;
|
|
198
|
+
let bound = null;
|
|
199
|
+
let sessionId = null;
|
|
200
|
+
if (channel && externalId) {
|
|
201
|
+
threads = openThreads(cfgDir);
|
|
202
|
+
bound = threads.findByExternal(channel, externalId);
|
|
203
|
+
if (bound) {
|
|
204
|
+
sessionId = bound.sessionId;
|
|
205
|
+
} else {
|
|
206
|
+
sessionId = newInboundSessionId();
|
|
207
|
+
threads.upsert({ channel, externalId, sessionId });
|
|
208
|
+
bound = threads.findByExternal(channel, externalId);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Hydrate prior turns for a bound session (mirrors POST /agent).
|
|
212
|
+
const messages = sessionId
|
|
213
|
+
? ctx.sessionsMod.loadTurns(sessionId, cfgDir).map((t) => ({ role: t.role, content: t.content }))
|
|
214
|
+
: [];
|
|
215
|
+
messages.push({ role: 'user', content: text });
|
|
216
|
+
if (sessionId) ctx.sessionsMod.appendTurn(sessionId, 'user', text, cfgDir);
|
|
217
|
+
let acc = '';
|
|
218
|
+
let inboundUsage = null;
|
|
219
|
+
try {
|
|
220
|
+
for await (const chunk of resolved.provider.sendMessage(
|
|
221
|
+
messages,
|
|
222
|
+
{ apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
|
|
223
|
+
)) acc += chunk;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
// A provider failure must stay retryable, not poison the message
|
|
226
|
+
// id — the outer finally releases the claim.
|
|
227
|
+
const m = statusForProviderError(err);
|
|
228
|
+
return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
|
|
229
|
+
}
|
|
230
|
+
if (sessionId) {
|
|
231
|
+
ctx.sessionsMod.appendTurn(sessionId, 'assistant', acc, cfgDir);
|
|
232
|
+
// Refresh lastTurnAt on the binding.
|
|
233
|
+
threads.upsert({ channel, externalId, sessionId, threadId: bound.threadId });
|
|
234
|
+
}
|
|
235
|
+
// Feed the running spend total so the cost cap can actually trip
|
|
236
|
+
// on /inbound traffic (mirrors POST /agent / POST /chat).
|
|
237
|
+
if (inboundUsage && cfg.rates) {
|
|
238
|
+
try {
|
|
239
|
+
const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
|
|
240
|
+
if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
|
|
241
|
+
} catch { /* cost is best-effort; never block a reply on it */ }
|
|
242
|
+
}
|
|
243
|
+
const out = { reply: acc, threadId: bound ? bound.threadId : (body.threadId || null) };
|
|
244
|
+
if (sessionId) out.sessionId = sessionId;
|
|
245
|
+
if (dedup) { dedup.record(dedupKey, { reply: acc, threadId: out.threadId, sessionId }); dedupRecorded = true; }
|
|
246
|
+
if (sessionId) {
|
|
247
|
+
// Phase 4 — close the post-task learning loop on channel turns,
|
|
248
|
+
// mirroring the chat REPL's fire-and-forget hook (run_turn.mjs).
|
|
249
|
+
// Only session-bound turns learn (a stateless one-shot relay is
|
|
250
|
+
// not a conversation); trainer resolution inside runLearning
|
|
251
|
+
// handles cfg.trainer 'auto' -> claude-cli ($0) routing. The
|
|
252
|
+
// dedup short-circuit above guarantees at most one learning pass
|
|
253
|
+
// per native message id, and enqueueLearning serialises the runs
|
|
254
|
+
// (concurrency 1, bounded depth) so a message burst can't fan out
|
|
255
|
+
// unbounded trainer LLM calls / claude-cli subprocesses.
|
|
256
|
+
const learnTurns = [
|
|
257
|
+
{ agent: 'user', text, ts: new Date().toISOString() },
|
|
258
|
+
{ agent: 'chat', text: acc, ts: new Date().toISOString() },
|
|
259
|
+
];
|
|
260
|
+
enqueueLearning(() =>
|
|
261
|
+
import('../../mas/learning.mjs')
|
|
262
|
+
.then((mod) => mod.runLearning('post-task', {
|
|
263
|
+
agent: { name: 'chat', provider: provName, model: body.model || cfg.model, role: '' },
|
|
264
|
+
task: { id: sessionId, title: '(channel turn)', turns: learnTurns },
|
|
265
|
+
configDir: cfgDir,
|
|
266
|
+
cfg,
|
|
267
|
+
})));
|
|
268
|
+
}
|
|
269
|
+
return writeJson(res, 200, out);
|
|
270
|
+
} finally {
|
|
271
|
+
if (dedup && !dedupRecorded) dedup.release(dedupKey);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function handoff(c) {
|
|
276
|
+
const { ctx, res, req } = c;
|
|
277
|
+
// F6 — re-point a thread to a new channel/externalId so a later
|
|
278
|
+
// inbound on the target resumes the SAME session (context follows).
|
|
279
|
+
// When the in-process gateway registered a live sender for the
|
|
280
|
+
// target channel (ctx.channelSenders), the target gets a resume
|
|
281
|
+
// marker and a FAILED notify rolls the binding back (502). Without
|
|
282
|
+
// a sender (bare daemon) the migration persists silently — the
|
|
283
|
+
// session still follows on the next inbound.
|
|
284
|
+
let body;
|
|
285
|
+
try { body = await readJson(req); }
|
|
286
|
+
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
287
|
+
const threadId = typeof body.threadId === 'string' ? body.threadId.trim() : '';
|
|
288
|
+
const target = typeof body.target === 'string' ? body.target.trim() : '';
|
|
289
|
+
const externalId = body.externalId != null ? String(body.externalId).trim() : '';
|
|
290
|
+
if (!threadId || !target || !externalId) {
|
|
291
|
+
return writeJson(res, 400, { error: 'threadId, target, and externalId are required' });
|
|
292
|
+
}
|
|
293
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
294
|
+
const threads = openThreads(cfgDir);
|
|
295
|
+
const liveSend = (ctx.channelSenders && typeof ctx.channelSenders.get === 'function')
|
|
296
|
+
? ctx.channelSenders.get(target)
|
|
297
|
+
: undefined;
|
|
298
|
+
try {
|
|
299
|
+
const next = await handoffWithRollback({
|
|
300
|
+
threads, threadId, target, externalId,
|
|
301
|
+
note: typeof body.note === 'string' ? body.note : '',
|
|
302
|
+
send: liveSend,
|
|
303
|
+
});
|
|
304
|
+
return writeJson(res, 200, {
|
|
305
|
+
threadId: next.threadId, channel: next.channel,
|
|
306
|
+
externalId: next.externalId, sessionId: next.sessionId,
|
|
307
|
+
});
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (err?.code === 'THREAD_NOT_FOUND') return writeJson(res, 404, { error: err.message, code: 'THREAD_NOT_FOUND' });
|
|
310
|
+
if (err?.code === 'HANDOFF_SEND_FAILED') return writeJson(res, 502, { error: err.message, code: 'HANDOFF_SEND_FAILED' });
|
|
311
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function agent(c) {
|
|
316
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
317
|
+
const breach = checkCostCap(metrics, costCap);
|
|
318
|
+
if (breach) {
|
|
319
|
+
return writeJson(res, 402, {
|
|
320
|
+
error: 'cost cap exceeded',
|
|
321
|
+
currency: breach.currency,
|
|
322
|
+
spent: breach.spent,
|
|
323
|
+
cap: breach.cap,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const body = await readJson(req);
|
|
327
|
+
const cfg = ctx.readConfig();
|
|
328
|
+
const provName = body.provider || cfg.provider || 'mock';
|
|
329
|
+
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
330
|
+
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
331
|
+
const prov = resolved.provider;
|
|
332
|
+
const prompt = String(body.prompt ?? '').trim();
|
|
333
|
+
if (!prompt) return writeJson(res, 400, { error: 'prompt required' });
|
|
334
|
+
const model = body.model || cfg.model;
|
|
335
|
+
const thinkingBudget = Number(body.thinkingBudget) || 0;
|
|
336
|
+
|
|
337
|
+
// Session hydration if sessionId provided.
|
|
338
|
+
const sid = body.sessionId || null;
|
|
339
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
340
|
+
let messages = sid ? ctx.sessionsMod.loadTurns(sid, cfgDir).map(t => ({ role: t.role, content: t.content })) : [];
|
|
341
|
+
// Skill composition: body.skills can be a comma-separated string
|
|
342
|
+
// ("a,b") or an array (["a","b"]). Compose only when no system
|
|
343
|
+
// message already exists in the message array (so re-runs of
|
|
344
|
+
// the same session don't double-prepend).
|
|
345
|
+
const skillNames = Array.isArray(body.skills)
|
|
346
|
+
? body.skills
|
|
347
|
+
: (typeof body.skills === 'string' ? body.skills.split(',').map(s => s.trim()).filter(Boolean) : []);
|
|
348
|
+
if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
|
|
349
|
+
try {
|
|
350
|
+
const sys = composeSystemPrompt(skillNames, cfgDir);
|
|
351
|
+
if (sys) messages.unshift({ role: 'system', content: sys });
|
|
352
|
+
} catch (err) {
|
|
353
|
+
return writeJson(res, 400, { error: `skill error: ${err?.message || String(err)}` });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
messages.push({ role: 'user', content: prompt });
|
|
357
|
+
if (sid) ctx.sessionsMod.appendTurn(sid, 'user', prompt, cfgDir);
|
|
358
|
+
|
|
359
|
+
// body.usage opt-in mirrors POST /chat — provider only does the
|
|
360
|
+
// extra work when the caller asks for it.
|
|
361
|
+
let agentCaptured = null;
|
|
362
|
+
const agentSendOpts = {
|
|
363
|
+
apiKey: cfg['api-key'],
|
|
364
|
+
model,
|
|
365
|
+
thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
|
|
366
|
+
onUsage: body.usage ? (u) => { agentCaptured = u; } : undefined,
|
|
367
|
+
};
|
|
368
|
+
const computeAgentCost = () => {
|
|
369
|
+
if (!body.cost || !agentCaptured || !cfg.rates) return null;
|
|
370
|
+
try {
|
|
371
|
+
const c = costFromUsage(
|
|
372
|
+
{ provider: provName, model, usage: agentCaptured },
|
|
373
|
+
cfg.rates,
|
|
374
|
+
);
|
|
375
|
+
if (c) accumulateMetricsFromCost(metrics, agentCaptured, c);
|
|
376
|
+
return c;
|
|
377
|
+
} catch { return null; }
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (body.stream === true) {
|
|
381
|
+
writeSseHead(res);
|
|
382
|
+
// Forward client disconnect to the provider so we don't keep
|
|
383
|
+
// burning tokens after the consumer has gone away.
|
|
384
|
+
const ac = new AbortController();
|
|
385
|
+
req.on('aborted', () => ac.abort());
|
|
386
|
+
res.on('close', () => { if (!res.writableEnded) ac.abort(); });
|
|
387
|
+
let acc = '';
|
|
388
|
+
try {
|
|
389
|
+
for await (const chunk of prov.sendMessage(messages, { ...agentSendOpts, signal: ac.signal })) {
|
|
390
|
+
if (ac.signal.aborted) break;
|
|
391
|
+
acc += chunk;
|
|
392
|
+
writeSse(res, 'token', { text: chunk });
|
|
393
|
+
// Backpressure: yield so the caller can read each frame.
|
|
394
|
+
await new Promise(r => setImmediate(r));
|
|
395
|
+
}
|
|
396
|
+
if (sid && !ac.signal.aborted) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
|
|
397
|
+
if (!ac.signal.aborted) {
|
|
398
|
+
if (agentCaptured) writeSse(res, 'usage', agentCaptured);
|
|
399
|
+
const cost = computeAgentCost();
|
|
400
|
+
if (cost) writeSse(res, 'cost', cost);
|
|
401
|
+
writeSse(res, 'done', { ok: true });
|
|
402
|
+
}
|
|
403
|
+
return res.end();
|
|
404
|
+
} catch (err) {
|
|
405
|
+
if (err?.code === 'ABORT' || ac.signal.aborted) {
|
|
406
|
+
// Client gave up — partial assistant turn is discarded.
|
|
407
|
+
return res.end();
|
|
408
|
+
}
|
|
409
|
+
writeSse(res, 'error', { message: err?.message || String(err) });
|
|
410
|
+
return res.end();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Non-streaming: collect then return once. Reuse agentSendOpts
|
|
415
|
+
// (carrying the optional onUsage capture) so usage lands in the
|
|
416
|
+
// response when body.usage was set.
|
|
417
|
+
let acc = '';
|
|
418
|
+
try {
|
|
419
|
+
for await (const chunk of prov.sendMessage(messages, agentSendOpts)) acc += chunk;
|
|
420
|
+
if (sid) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
|
|
421
|
+
const cost = computeAgentCost();
|
|
422
|
+
const out = { reply: acc };
|
|
423
|
+
if (agentCaptured) out.usage = agentCaptured;
|
|
424
|
+
if (cost) out.cost = cost;
|
|
425
|
+
return writeJson(res, 200, out);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
const m = statusForProviderError(err);
|
|
428
|
+
return writeJson(res, m.status, {
|
|
429
|
+
error: err?.message || String(err),
|
|
430
|
+
code: err?.code || null,
|
|
431
|
+
...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
432
|
+
}, m.headers || {});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|