lazyclaw 6.0.0 → 6.0.1
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/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/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -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 +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -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 +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/package.json +14 -1
- package/secure_write.mjs +46 -0
|
@@ -0,0 +1,371 @@
|
|
|
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 } 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
|
+
let threads = null;
|
|
169
|
+
let bound = null;
|
|
170
|
+
let sessionId = null;
|
|
171
|
+
if (channel && externalId) {
|
|
172
|
+
threads = openThreads(cfgDir);
|
|
173
|
+
bound = threads.findByExternal(channel, externalId);
|
|
174
|
+
if (bound) {
|
|
175
|
+
sessionId = bound.sessionId;
|
|
176
|
+
} else {
|
|
177
|
+
sessionId = newInboundSessionId();
|
|
178
|
+
threads.upsert({ channel, externalId, sessionId });
|
|
179
|
+
bound = threads.findByExternal(channel, externalId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Hydrate prior turns for a bound session (mirrors POST /agent).
|
|
183
|
+
const messages = sessionId
|
|
184
|
+
? ctx.sessionsMod.loadTurns(sessionId, cfgDir).map((t) => ({ role: t.role, content: t.content }))
|
|
185
|
+
: [];
|
|
186
|
+
messages.push({ role: 'user', content: text });
|
|
187
|
+
if (sessionId) ctx.sessionsMod.appendTurn(sessionId, 'user', text, cfgDir);
|
|
188
|
+
let acc = '';
|
|
189
|
+
let inboundUsage = null;
|
|
190
|
+
try {
|
|
191
|
+
for await (const chunk of resolved.provider.sendMessage(
|
|
192
|
+
messages,
|
|
193
|
+
{ apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
|
|
194
|
+
)) acc += chunk;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const m = statusForProviderError(err);
|
|
197
|
+
return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
|
|
198
|
+
}
|
|
199
|
+
if (sessionId) {
|
|
200
|
+
ctx.sessionsMod.appendTurn(sessionId, 'assistant', acc, cfgDir);
|
|
201
|
+
// Refresh lastTurnAt on the binding.
|
|
202
|
+
threads.upsert({ channel, externalId, sessionId, threadId: bound.threadId });
|
|
203
|
+
}
|
|
204
|
+
// Feed the running spend total so the cost cap can actually trip
|
|
205
|
+
// on /inbound traffic (mirrors POST /agent / POST /chat).
|
|
206
|
+
if (inboundUsage && cfg.rates) {
|
|
207
|
+
try {
|
|
208
|
+
const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
|
|
209
|
+
if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
|
|
210
|
+
} catch { /* cost is best-effort; never block a reply on it */ }
|
|
211
|
+
}
|
|
212
|
+
const out = { reply: acc, threadId: bound ? bound.threadId : (body.threadId || null) };
|
|
213
|
+
if (sessionId) out.sessionId = sessionId;
|
|
214
|
+
return writeJson(res, 200, out);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function handoff(c) {
|
|
218
|
+
const { ctx, res, req } = c;
|
|
219
|
+
// F6 — re-point a thread to a new channel/externalId so a later
|
|
220
|
+
// inbound on the target resumes the SAME session (context follows).
|
|
221
|
+
// The daemon has no live per-channel send map, so it migrates the
|
|
222
|
+
// binding directly with no notifier; once a send adapter is injected,
|
|
223
|
+
// a failed target notify rolls the binding back (channels/handoff.mjs).
|
|
224
|
+
let body;
|
|
225
|
+
try { body = await readJson(req); }
|
|
226
|
+
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
227
|
+
const threadId = typeof body.threadId === 'string' ? body.threadId.trim() : '';
|
|
228
|
+
const target = typeof body.target === 'string' ? body.target.trim() : '';
|
|
229
|
+
const externalId = body.externalId != null ? String(body.externalId).trim() : '';
|
|
230
|
+
if (!threadId || !target || !externalId) {
|
|
231
|
+
return writeJson(res, 400, { error: 'threadId, target, and externalId are required' });
|
|
232
|
+
}
|
|
233
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
234
|
+
const threads = openThreads(cfgDir);
|
|
235
|
+
try {
|
|
236
|
+
const next = await handoffWithRollback({
|
|
237
|
+
threads, threadId, target, externalId,
|
|
238
|
+
note: typeof body.note === 'string' ? body.note : '',
|
|
239
|
+
});
|
|
240
|
+
return writeJson(res, 200, {
|
|
241
|
+
threadId: next.threadId, channel: next.channel,
|
|
242
|
+
externalId: next.externalId, sessionId: next.sessionId,
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (err?.code === 'THREAD_NOT_FOUND') return writeJson(res, 404, { error: err.message, code: 'THREAD_NOT_FOUND' });
|
|
246
|
+
if (err?.code === 'HANDOFF_SEND_FAILED') return writeJson(res, 502, { error: err.message, code: 'HANDOFF_SEND_FAILED' });
|
|
247
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function agent(c) {
|
|
252
|
+
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;
|
|
253
|
+
const breach = checkCostCap(metrics, costCap);
|
|
254
|
+
if (breach) {
|
|
255
|
+
return writeJson(res, 402, {
|
|
256
|
+
error: 'cost cap exceeded',
|
|
257
|
+
currency: breach.currency,
|
|
258
|
+
spent: breach.spent,
|
|
259
|
+
cap: breach.cap,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const body = await readJson(req);
|
|
263
|
+
const cfg = ctx.readConfig();
|
|
264
|
+
const provName = body.provider || cfg.provider || 'mock';
|
|
265
|
+
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
266
|
+
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
267
|
+
const prov = resolved.provider;
|
|
268
|
+
const prompt = String(body.prompt ?? '').trim();
|
|
269
|
+
if (!prompt) return writeJson(res, 400, { error: 'prompt required' });
|
|
270
|
+
const model = body.model || cfg.model;
|
|
271
|
+
const thinkingBudget = Number(body.thinkingBudget) || 0;
|
|
272
|
+
|
|
273
|
+
// Session hydration if sessionId provided.
|
|
274
|
+
const sid = body.sessionId || null;
|
|
275
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
276
|
+
let messages = sid ? ctx.sessionsMod.loadTurns(sid, cfgDir).map(t => ({ role: t.role, content: t.content })) : [];
|
|
277
|
+
// Skill composition: body.skills can be a comma-separated string
|
|
278
|
+
// ("a,b") or an array (["a","b"]). Compose only when no system
|
|
279
|
+
// message already exists in the message array (so re-runs of
|
|
280
|
+
// the same session don't double-prepend).
|
|
281
|
+
const skillNames = Array.isArray(body.skills)
|
|
282
|
+
? body.skills
|
|
283
|
+
: (typeof body.skills === 'string' ? body.skills.split(',').map(s => s.trim()).filter(Boolean) : []);
|
|
284
|
+
if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
|
|
285
|
+
try {
|
|
286
|
+
const sys = composeSystemPrompt(skillNames, cfgDir);
|
|
287
|
+
if (sys) messages.unshift({ role: 'system', content: sys });
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return writeJson(res, 400, { error: `skill error: ${err?.message || String(err)}` });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
messages.push({ role: 'user', content: prompt });
|
|
293
|
+
if (sid) ctx.sessionsMod.appendTurn(sid, 'user', prompt, cfgDir);
|
|
294
|
+
|
|
295
|
+
// body.usage opt-in mirrors POST /chat — provider only does the
|
|
296
|
+
// extra work when the caller asks for it.
|
|
297
|
+
let agentCaptured = null;
|
|
298
|
+
const agentSendOpts = {
|
|
299
|
+
apiKey: cfg['api-key'],
|
|
300
|
+
model,
|
|
301
|
+
thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
|
|
302
|
+
onUsage: body.usage ? (u) => { agentCaptured = u; } : undefined,
|
|
303
|
+
};
|
|
304
|
+
const computeAgentCost = () => {
|
|
305
|
+
if (!body.cost || !agentCaptured || !cfg.rates) return null;
|
|
306
|
+
try {
|
|
307
|
+
const c = costFromUsage(
|
|
308
|
+
{ provider: provName, model, usage: agentCaptured },
|
|
309
|
+
cfg.rates,
|
|
310
|
+
);
|
|
311
|
+
if (c) accumulateMetricsFromCost(metrics, agentCaptured, c);
|
|
312
|
+
return c;
|
|
313
|
+
} catch { return null; }
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
if (body.stream === true) {
|
|
317
|
+
writeSseHead(res);
|
|
318
|
+
// Forward client disconnect to the provider so we don't keep
|
|
319
|
+
// burning tokens after the consumer has gone away.
|
|
320
|
+
const ac = new AbortController();
|
|
321
|
+
req.on('aborted', () => ac.abort());
|
|
322
|
+
res.on('close', () => { if (!res.writableEnded) ac.abort(); });
|
|
323
|
+
let acc = '';
|
|
324
|
+
try {
|
|
325
|
+
for await (const chunk of prov.sendMessage(messages, { ...agentSendOpts, signal: ac.signal })) {
|
|
326
|
+
if (ac.signal.aborted) break;
|
|
327
|
+
acc += chunk;
|
|
328
|
+
writeSse(res, 'token', { text: chunk });
|
|
329
|
+
// Backpressure: yield so the caller can read each frame.
|
|
330
|
+
await new Promise(r => setImmediate(r));
|
|
331
|
+
}
|
|
332
|
+
if (sid && !ac.signal.aborted) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
|
|
333
|
+
if (!ac.signal.aborted) {
|
|
334
|
+
if (agentCaptured) writeSse(res, 'usage', agentCaptured);
|
|
335
|
+
const cost = computeAgentCost();
|
|
336
|
+
if (cost) writeSse(res, 'cost', cost);
|
|
337
|
+
writeSse(res, 'done', { ok: true });
|
|
338
|
+
}
|
|
339
|
+
return res.end();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
if (err?.code === 'ABORT' || ac.signal.aborted) {
|
|
342
|
+
// Client gave up — partial assistant turn is discarded.
|
|
343
|
+
return res.end();
|
|
344
|
+
}
|
|
345
|
+
writeSse(res, 'error', { message: err?.message || String(err) });
|
|
346
|
+
return res.end();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Non-streaming: collect then return once. Reuse agentSendOpts
|
|
351
|
+
// (carrying the optional onUsage capture) so usage lands in the
|
|
352
|
+
// response when body.usage was set.
|
|
353
|
+
let acc = '';
|
|
354
|
+
try {
|
|
355
|
+
for await (const chunk of prov.sendMessage(messages, agentSendOpts)) acc += chunk;
|
|
356
|
+
if (sid) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
|
|
357
|
+
const cost = computeAgentCost();
|
|
358
|
+
const out = { reply: acc };
|
|
359
|
+
if (agentCaptured) out.usage = agentCaptured;
|
|
360
|
+
if (cost) out.cost = cost;
|
|
361
|
+
return writeJson(res, 200, out);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
const m = statusForProviderError(err);
|
|
364
|
+
return writeJson(res, m.status, {
|
|
365
|
+
error: err?.message || String(err),
|
|
366
|
+
code: err?.code || null,
|
|
367
|
+
...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
368
|
+
}, m.headers || {});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Daemon route handlers (meta), 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
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
// Serve a static asset that lives alongside the dashboard HTML in web/.
|
|
8
|
+
// Used for the split-out dashboard.css / dashboard.js (CLAUDE.md §7: one
|
|
9
|
+
// file = one responsibility). Same loopback origin as the JSON API, so no
|
|
10
|
+
// CORS handling is needed. Returns 404 (not the dashboard's 503 install
|
|
11
|
+
// hint) since a missing asset is a narrower failure.
|
|
12
|
+
function serveWebFile(c, filename, contentType) {
|
|
13
|
+
const { res } = c;
|
|
14
|
+
try {
|
|
15
|
+
const here = nodePath.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const filePath = nodePath.join(here, '..', '..', 'web', filename);
|
|
17
|
+
const body = fs.readFileSync(filePath);
|
|
18
|
+
res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-cache' });
|
|
19
|
+
return res.end(body);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
22
|
+
return res.end(`not found: ${filename} (${e?.message || e})\n`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function dashboardCss(c) {
|
|
27
|
+
return serveWebFile(c, 'dashboard.css', 'text/css; charset=utf-8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function dashboardJs(c) {
|
|
31
|
+
return serveWebFile(c, 'dashboard.js', 'text/javascript; charset=utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function dashboard(c) {
|
|
35
|
+
const { res } = c;
|
|
36
|
+
// Serve the lazyclaw-only web dashboard (a single static
|
|
37
|
+
// HTML in src/lazyclaw/web/). Co-resident with the JSON
|
|
38
|
+
// API so a single port handles both — no CORS song and
|
|
39
|
+
// dance, no separate static server. Falls back to a
|
|
40
|
+
// helpful text response when the file is missing (someone
|
|
41
|
+
// ran the daemon out of a partial install).
|
|
42
|
+
try {
|
|
43
|
+
const fs = await import('node:fs');
|
|
44
|
+
const path = await import('node:path');
|
|
45
|
+
const url = await import('node:url');
|
|
46
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
47
|
+
const htmlPath = path.join(here, '..', '..', 'web', 'dashboard.html');
|
|
48
|
+
const body = fs.readFileSync(htmlPath, 'utf8');
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
'content-type': 'text/html; charset=utf-8',
|
|
51
|
+
'cache-control': 'no-cache',
|
|
52
|
+
});
|
|
53
|
+
return res.end(body);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
|
56
|
+
return res.end(
|
|
57
|
+
`lazyclaw daemon is up but the dashboard HTML wasn't found.\n` +
|
|
58
|
+
`Try \`lazyclaw version\` to confirm install integrity, or hit any /api endpoint directly.\n\n` +
|
|
59
|
+
`error: ${e?.message || e}\n`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function version(c) {
|
|
65
|
+
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;
|
|
66
|
+
return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function health(c) {
|
|
70
|
+
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;
|
|
71
|
+
// Conventional liveness check — always 200 if the process
|
|
72
|
+
// is alive enough to hit the route. No config inspection
|
|
73
|
+
// (use /doctor for readiness), no provider probing — this
|
|
74
|
+
// is the "is the daemon up?" probe that load balancers
|
|
75
|
+
// and watchdog scripts expect at this path. m15: K8s
|
|
76
|
+
// readiness probes default to /healthz so we alias.
|
|
77
|
+
return writeJson(res, 200, {
|
|
78
|
+
ok: true,
|
|
79
|
+
status: 'alive',
|
|
80
|
+
uptimeMs: Date.now() - metrics.startedAtMs,
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function metrics(c) {
|
|
86
|
+
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;
|
|
87
|
+
// Aggregate per-handler counters. cacheStats are pulled per
|
|
88
|
+
// wrapped provider — we report a sum across all populated
|
|
89
|
+
// entries so the figure reflects total cache activity.
|
|
90
|
+
let cacheHits = 0, cacheMisses = 0, cacheSize = 0;
|
|
91
|
+
if (cachedByName) {
|
|
92
|
+
for (const wrapped of cachedByName.values()) {
|
|
93
|
+
const s = typeof wrapped.cacheStats === 'function' ? wrapped.cacheStats() : null;
|
|
94
|
+
if (s) {
|
|
95
|
+
cacheHits += s.hits || 0;
|
|
96
|
+
cacheMisses += s.misses || 0;
|
|
97
|
+
cacheSize += s.size || 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Cumulative tokens / cost — meaningful only when callers used
|
|
102
|
+
// body.usage / body.cost. The fields are always present (zero
|
|
103
|
+
// by default) so monitoring tooling sees a stable schema.
|
|
104
|
+
const tokensTotal = { ...metrics.tokensTotal };
|
|
105
|
+
const costs = {};
|
|
106
|
+
for (const [cur, n] of Object.entries(metrics.costsByCurrency)) {
|
|
107
|
+
// Round to six decimals here too, matching costFromUsage's
|
|
108
|
+
// precision so monitoring deltas line up with per-request
|
|
109
|
+
// breakdowns.
|
|
110
|
+
costs[cur] = Math.round(n * 1_000_000) / 1_000_000;
|
|
111
|
+
}
|
|
112
|
+
// Workflow snapshot — opportunistic. We scan the state dir
|
|
113
|
+
// once per /metrics call and count per bucket. This is
|
|
114
|
+
// cheap unless the user has thousands of state files; for
|
|
115
|
+
// truly large fleets the operator can disable by passing
|
|
116
|
+
// ctx.workflowMetrics === false.
|
|
117
|
+
let workflows = null;
|
|
118
|
+
if (ctx.workflowMetrics !== false) {
|
|
119
|
+
try {
|
|
120
|
+
const stateDir = ctx.workflowStateDir();
|
|
121
|
+
if (fs.existsSync(stateDir)) {
|
|
122
|
+
const sessions = listWorkflowSessions(stateDir);
|
|
123
|
+
workflows = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
|
|
124
|
+
for (const s of sessions) {
|
|
125
|
+
if (s.summary.done) workflows.done++;
|
|
126
|
+
if (s.summary.resumable) workflows.resumable++;
|
|
127
|
+
if (s.summary.failed > 0) workflows.failed++;
|
|
128
|
+
if (s.summary.running > 0) workflows.running++;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
workflows = { total: 0, done: 0, resumable: 0, failed: 0, running: 0 };
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Don't fail /metrics because the state dir is unreadable —
|
|
135
|
+
// expose the gap as null and keep monitoring alive.
|
|
136
|
+
workflows = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return writeJson(res, 200, {
|
|
140
|
+
uptimeMs: Date.now() - metrics.startedAtMs,
|
|
141
|
+
requestsTotal: metrics.requestsTotal,
|
|
142
|
+
requestsByStatus: metrics.requestsByStatus,
|
|
143
|
+
rateLimitDenied: metrics.rateLimitDenied,
|
|
144
|
+
cache: cachedByName ? { hits: cacheHits, misses: cacheMisses, size: cacheSize } : null,
|
|
145
|
+
tokensTotal,
|
|
146
|
+
costsByCurrency: costs,
|
|
147
|
+
workflows,
|
|
148
|
+
timestamp: new Date().toISOString(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function status(c) {
|
|
153
|
+
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;
|
|
154
|
+
const cfg = ctx.readConfig();
|
|
155
|
+
// v5: surface a one-line summary of trainer / index / sandbox so
|
|
156
|
+
// the dashboard banner doesn't need three more round-trips.
|
|
157
|
+
const trainerCfg = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
|
|
158
|
+
const sandboxBackend = (cfg.sandbox && typeof cfg.sandbox === 'object' && cfg.sandbox.default) || 'local';
|
|
159
|
+
let indexRows = null;
|
|
160
|
+
try {
|
|
161
|
+
const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
|
|
162
|
+
const r = db.prepare(
|
|
163
|
+
"SELECT (SELECT COUNT(*) FROM fts_sessions) + (SELECT COUNT(*) FROM fts_skills) " +
|
|
164
|
+
" + (SELECT COUNT(*) FROM fts_trajectories) + (SELECT COUNT(*) FROM fts_memories) AS n"
|
|
165
|
+
).get();
|
|
166
|
+
indexRows = r && typeof r.n === 'number' ? r.n : null;
|
|
167
|
+
} catch { /* index may not exist yet */ }
|
|
168
|
+
// Migration backup path is conventionally <configDir>/backup-v4/.
|
|
169
|
+
let migrateBackup = null;
|
|
170
|
+
try {
|
|
171
|
+
const p = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'backup-v4');
|
|
172
|
+
if (fs.existsSync(p)) migrateBackup = p;
|
|
173
|
+
} catch { /* ignore */ }
|
|
174
|
+
return writeJson(res, 200, {
|
|
175
|
+
provider: cfg.provider || null,
|
|
176
|
+
model: cfg.model || null,
|
|
177
|
+
keyMasked: maskApiKey(cfg['api-key']),
|
|
178
|
+
v5: {
|
|
179
|
+
trainer: {
|
|
180
|
+
provider: trainerCfg.provider || null,
|
|
181
|
+
model: trainerCfg.model || null,
|
|
182
|
+
},
|
|
183
|
+
sandboxBackend,
|
|
184
|
+
indexRows,
|
|
185
|
+
migrateBackup,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function doctor(c) {
|
|
191
|
+
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;
|
|
192
|
+
// Mirror the CLI doctor output — same field set so any tool that
|
|
193
|
+
// already knows how to read CLI doctor JSON can hit this endpoint.
|
|
194
|
+
const cfg = ctx.readConfig();
|
|
195
|
+
const issues = [];
|
|
196
|
+
if (!cfg.provider) issues.push('config.provider is missing');
|
|
197
|
+
if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
|
|
198
|
+
issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
|
|
199
|
+
}
|
|
200
|
+
if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
|
|
201
|
+
issues.push(`unknown provider "${cfg.provider}"`);
|
|
202
|
+
}
|
|
203
|
+
// v5: FTS5 index integrity. Failure here is degraded-not-fatal —
|
|
204
|
+
// surfaced as an issue but doesn't take the daemon down.
|
|
205
|
+
let indexBlock = null;
|
|
206
|
+
try {
|
|
207
|
+
const integ = indexDb.integrityCheck(gwConfigDir);
|
|
208
|
+
const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
|
|
209
|
+
const rowCounts = {};
|
|
210
|
+
for (const t of ['fts_sessions', 'fts_skills', 'fts_trajectories', 'fts_memories']) {
|
|
211
|
+
try { rowCounts[t] = db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n; }
|
|
212
|
+
catch { rowCounts[t] = null; }
|
|
213
|
+
}
|
|
214
|
+
indexBlock = {
|
|
215
|
+
ok: !!integ.ok,
|
|
216
|
+
result: integ.result || null,
|
|
217
|
+
rowCounts,
|
|
218
|
+
lastRebuiltAt: ctx.indexLastRebuiltAt || null,
|
|
219
|
+
};
|
|
220
|
+
if (!integ.ok) issues.push(`FTS5 index integrity_check returned ${integ.result}`);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
indexBlock = { ok: false, error: e?.message || String(e), rowCounts: {}, lastRebuiltAt: null };
|
|
223
|
+
issues.push(`FTS5 index unavailable: ${e?.message || e}`);
|
|
224
|
+
}
|
|
225
|
+
const ok = issues.length === 0;
|
|
226
|
+
return writeJson(res, ok ? 200 : 503, {
|
|
227
|
+
ok,
|
|
228
|
+
provider: cfg.provider || null,
|
|
229
|
+
model: cfg.model || null,
|
|
230
|
+
hasApiKey: !!cfg['api-key'],
|
|
231
|
+
nodeVersion: process.version,
|
|
232
|
+
platform: `${process.platform}-${process.arch}`,
|
|
233
|
+
issues,
|
|
234
|
+
knownProviders: Object.keys(PROVIDERS),
|
|
235
|
+
index: indexBlock,
|
|
236
|
+
timestamp: new Date().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|