lazyclaw 5.4.3 → 6.0.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/channels/handoff.mjs +36 -0
- package/cli.mjs +73 -7399
- package/daemon.mjs +23 -2085
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +3 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/sessions.mjs +0 -0
- package/tui/editor.mjs +44 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +8 -7
- package/tui/slash_dispatcher.mjs +923 -118
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- package/mas/tools/skill_view.mjs +0 -43
package/daemon.mjs
CHANGED
|
@@ -12,250 +12,21 @@
|
|
|
12
12
|
// the response is a single JSON object once the full reply has arrived.
|
|
13
13
|
|
|
14
14
|
import http from 'node:http';
|
|
15
|
-
import nodePath from 'node:path';
|
|
16
|
-
import fs from 'node:fs';
|
|
17
15
|
|
|
18
|
-
import { PROVIDERS, PROVIDER_INFO, maskApiKey } from './providers/registry.mjs';
|
|
19
|
-
import { withRateLimitRetry } from './providers/retry.mjs';
|
|
20
|
-
import { withFallback } from './providers/fallback.mjs';
|
|
21
|
-
import { withResponseCache } from './providers/cache.mjs';
|
|
22
|
-
import { costFromUsage, RATE_CARD_SHAPE } from './providers/rates.mjs';
|
|
23
|
-
import { composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, defaultConfigDir as skillsDefaultConfigDir } from './skills.mjs';
|
|
24
|
-
import * as indexDb from './mas/index_db.mjs';
|
|
25
|
-
import * as skillSynth from './mas/skill_synth.mjs';
|
|
26
|
-
import { listBackends as sandboxListBackends } from './sandbox/index.mjs';
|
|
27
16
|
import { ChallengeRegistry } from './gateway/device_auth.mjs';
|
|
28
17
|
import { createGateway } from './gateway/http_gateway.mjs';
|
|
29
18
|
import { TokenBucketLimiter } from './ratelimit.mjs';
|
|
30
19
|
import { createLogger } from './logger.mjs';
|
|
31
|
-
import { summarizeState, listSessions as listWorkflowSessions, loadStateFile as loadWorkflowState, aggregateNodeStats } from './workflow/summary.mjs';
|
|
32
|
-
import { validateConfig } from './config-validate.mjs';
|
|
33
|
-
import { validateRates } from './rates-validate.mjs';
|
|
34
20
|
import * as nudge from './mas/nudge.mjs';
|
|
35
21
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// through; mid-stream errors bubble.
|
|
42
|
-
// 3. retry — outermost so each retry covers the full chain (retry
|
|
43
|
-
// exhausts → 429 to client).
|
|
44
|
-
// `cachedByName` is a per-handler Map shared across requests so the
|
|
45
|
-
// cache state actually persists between calls. Without that the cache
|
|
46
|
-
// would be empty on every request.
|
|
47
|
-
//
|
|
48
|
-
// Returns { provider } on success or { error } when the primary or any
|
|
49
|
-
// listed fallback name is unknown.
|
|
50
|
-
function resolveProvider(body, providerName, cachedByName, logger) {
|
|
51
|
-
if (!PROVIDERS[providerName]) return { error: `unknown provider: ${providerName}` };
|
|
52
|
-
// The decorator callbacks emit one debug line each — useful for ops who
|
|
53
|
-
// set --log debug to diagnose why a request is slow or which provider
|
|
54
|
-
// actually served it. With the default level (info) these are silent.
|
|
55
|
-
const dbg = (msg, fields) => { if (logger) logger.debug(msg, fields); };
|
|
56
|
-
const wrapWithCache = (name) => {
|
|
57
|
-
if (!cachedByName) return PROVIDERS[name];
|
|
58
|
-
if (!cachedByName.has(name)) {
|
|
59
|
-
cachedByName.set(name, withResponseCache(PROVIDERS[name], {
|
|
60
|
-
maxEntries: cachedByName._opts?.maxEntries,
|
|
61
|
-
ttlMs: cachedByName._opts?.ttlMs,
|
|
62
|
-
onHit: ({ keyHash, size }) => dbg('cache.hit', { provider: name, keyHash: keyHash.slice(0, 12), size }),
|
|
63
|
-
onMiss: ({ keyHash }) => dbg('cache.miss', { provider: name, keyHash: keyHash.slice(0, 12) }),
|
|
64
|
-
}));
|
|
65
|
-
}
|
|
66
|
-
return cachedByName.get(name);
|
|
67
|
-
};
|
|
68
|
-
// Cache only when the request explicitly opts in. The handler-level
|
|
69
|
-
// Map is shared so two requests with body.cache=true to the same base
|
|
70
|
-
// provider hit the same cache.
|
|
71
|
-
const useCache = !!body?.cache;
|
|
72
|
-
let prov = useCache ? wrapWithCache(providerName) : PROVIDERS[providerName];
|
|
73
|
-
if (Array.isArray(body?.fallback) && body.fallback.length > 0) {
|
|
74
|
-
const chain = [prov];
|
|
75
|
-
for (const name of body.fallback) {
|
|
76
|
-
if (!PROVIDERS[name]) return { error: `unknown fallback provider: ${name}` };
|
|
77
|
-
chain.push(useCache ? wrapWithCache(name) : PROVIDERS[name]);
|
|
78
|
-
}
|
|
79
|
-
prov = withFallback(chain, {
|
|
80
|
-
onFallback: ({ from, to, err }) => dbg('provider.fallback', {
|
|
81
|
-
from, to, errorCode: err?.code || null, errorMsg: String(err?.message || err).slice(0, 120),
|
|
82
|
-
}),
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
const r = body?.retry;
|
|
86
|
-
if (r && Number.isFinite(r.attempts) && r.attempts > 0) {
|
|
87
|
-
prov = withRateLimitRetry(prov, {
|
|
88
|
-
attempts: r.attempts,
|
|
89
|
-
maxBackoffMs: r.maxBackoffMs,
|
|
90
|
-
onRetry: ({ attempt, retryAfterMs, err }) => dbg('provider.retry', {
|
|
91
|
-
attempt, retryAfterMs, errorCode: err?.code || null,
|
|
92
|
-
}),
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
return { provider: prov };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function fileExists(p) {
|
|
99
|
-
try { await fs.promises.access(p); return true; }
|
|
100
|
-
catch { return false; }
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function readJson(req) {
|
|
104
|
-
return new Promise((resolve, reject) => {
|
|
105
|
-
let buf = '';
|
|
106
|
-
req.setEncoding('utf8');
|
|
107
|
-
req.on('data', d => { buf += d; if (buf.length > 5 * 1024 * 1024) { reject(new Error('body too large')); req.destroy(); } });
|
|
108
|
-
req.on('end', () => {
|
|
109
|
-
if (!buf) return resolve({});
|
|
110
|
-
try { resolve(JSON.parse(buf)); }
|
|
111
|
-
catch (e) { reject(new Error(`invalid JSON body: ${e.message}`)); }
|
|
112
|
-
});
|
|
113
|
-
req.on('error', reject);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Raw body reader — used for `PUT /skills/<name>` where the body is
|
|
118
|
-
// markdown rather than JSON. Same 1 MiB cap as the CLI's `--from-url`
|
|
119
|
-
// path so HTTP can't sneak past the safeguard the CLI enforces.
|
|
120
|
-
const SKILL_MAX_BYTES = 1_048_576;
|
|
121
|
-
function readTextBody(req, maxBytes = SKILL_MAX_BYTES) {
|
|
122
|
-
return new Promise((resolve, reject) => {
|
|
123
|
-
let buf = '';
|
|
124
|
-
req.setEncoding('utf8');
|
|
125
|
-
req.on('data', d => {
|
|
126
|
-
buf += d;
|
|
127
|
-
if (buf.length > maxBytes) {
|
|
128
|
-
reject(new Error(`body exceeds ${maxBytes} bytes`));
|
|
129
|
-
req.destroy();
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
req.on('end', () => resolve(buf));
|
|
133
|
-
req.on('error', reject);
|
|
134
|
-
});
|
|
135
|
-
}
|
|
22
|
+
// Route bodies moved to daemon/routes/*; makeHandler now only needs the
|
|
23
|
+
// response/auth helpers used by the pre-switch middleware + dispatch.
|
|
24
|
+
import { readTextBody, writeJson, statusForProviderError } from './daemon/lib/respond.mjs';
|
|
25
|
+
import { isAuthorized, isOriginAllowed } from './daemon/lib/auth.mjs';
|
|
26
|
+
import { ROUTES } from './daemon/route_table.mjs';
|
|
136
27
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
res.writeHead(status, {
|
|
140
|
-
'content-type': 'application/json; charset=utf-8',
|
|
141
|
-
'content-length': Buffer.byteLength(body),
|
|
142
|
-
...extraHeaders,
|
|
143
|
-
});
|
|
144
|
-
res.end(body);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Has the cumulative cost in any capped currency reached the cap?
|
|
148
|
-
// Returns the offending currency + amount + cap so the caller can
|
|
149
|
-
// surface it cleanly, or null when no cap is breached.
|
|
150
|
-
function checkCostCap(metrics, costCap) {
|
|
151
|
-
if (!costCap) return null;
|
|
152
|
-
for (const [cur, cap] of Object.entries(costCap)) {
|
|
153
|
-
if (!Number.isFinite(cap) || cap <= 0) continue;
|
|
154
|
-
const spent = metrics.costsByCurrency[cur] || 0;
|
|
155
|
-
if (spent >= cap) return { currency: cur, spent: Math.round(spent * 1_000_000) / 1_000_000, cap };
|
|
156
|
-
}
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Bump per-handler metrics from a single request's cost+usage. Keys
|
|
161
|
-
// cost by currency so heterogeneous fleets (USD-priced anthropic, EUR
|
|
162
|
-
// regional contracts) don't silently sum mismatched numbers. Tokens
|
|
163
|
-
// are unit-free → single counter.
|
|
164
|
-
function accumulateMetricsFromCost(metrics, usage, cost) {
|
|
165
|
-
if (cost && Number.isFinite(cost.cost)) {
|
|
166
|
-
const cur = cost.currency || 'USD';
|
|
167
|
-
metrics.costsByCurrency[cur] = (metrics.costsByCurrency[cur] || 0) + cost.cost;
|
|
168
|
-
}
|
|
169
|
-
if (usage) {
|
|
170
|
-
if (Number.isFinite(usage.inputTokens)) metrics.tokensTotal.inputTokens += usage.inputTokens;
|
|
171
|
-
if (Number.isFinite(usage.outputTokens)) metrics.tokensTotal.outputTokens += usage.outputTokens;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Map provider error codes to HTTP statuses so clients can branch on
|
|
176
|
-
// res.status instead of parsing error messages. Returns
|
|
177
|
-
// { status, headers? } so 429 can attach a Retry-After.
|
|
178
|
-
//
|
|
179
|
-
// Exported for unit testing without spinning up an actual provider that
|
|
180
|
-
// would only fail under live network conditions.
|
|
181
|
-
export function statusForProviderError(err) {
|
|
182
|
-
if (err?.code === 'INVALID_KEY') return { status: 401 };
|
|
183
|
-
if (err?.code === 'RATE_LIMIT') {
|
|
184
|
-
const retrySeconds = Math.max(1, Math.ceil((err.retryAfterMs || 1000) / 1000));
|
|
185
|
-
return { status: 429, headers: { 'retry-after': String(retrySeconds) } };
|
|
186
|
-
}
|
|
187
|
-
if (err?.status && err.status >= 400 && err.status < 600) return { status: err.status };
|
|
188
|
-
return { status: 502 };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function writeSseHead(res) {
|
|
192
|
-
res.writeHead(200, {
|
|
193
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
194
|
-
'cache-control': 'no-cache, no-transform',
|
|
195
|
-
'connection': 'close',
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function writeSse(res, event, data) {
|
|
200
|
-
if (event) res.write(`event: ${event}\n`);
|
|
201
|
-
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Constant-time string equality. Plain `===` would short-circuit on the
|
|
206
|
-
* first mismatching byte, leaking timing info that lets an attacker on
|
|
207
|
-
* a shared host narrow the secret one byte at a time. We compare every
|
|
208
|
-
* byte with XOR + accumulator.
|
|
209
|
-
*/
|
|
210
|
-
function constantTimeEqual(a, b) {
|
|
211
|
-
const aStr = String(a ?? '');
|
|
212
|
-
const bStr = String(b ?? '');
|
|
213
|
-
if (aStr.length !== bStr.length) return false;
|
|
214
|
-
let diff = 0;
|
|
215
|
-
for (let i = 0; i < aStr.length; i++) {
|
|
216
|
-
diff |= aStr.charCodeAt(i) ^ bStr.charCodeAt(i);
|
|
217
|
-
}
|
|
218
|
-
return diff === 0;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function isAuthorized(req, expectedToken) {
|
|
222
|
-
if (!expectedToken) return true; // auth disabled
|
|
223
|
-
const header = req.headers['authorization'] || '';
|
|
224
|
-
const m = /^Bearer\s+(.+)$/i.exec(header);
|
|
225
|
-
if (!m) return false;
|
|
226
|
-
return constantTimeEqual(m[1].trim(), expectedToken);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Origin gate — protect against DNS-rebinding / CSRF where a page in
|
|
231
|
-
* the user's browser posts to 127.0.0.1:<our port>. Browsers always
|
|
232
|
-
* attach `Origin` for cross-origin POSTs (and increasingly for GETs);
|
|
233
|
-
* CLI tools (curl, fetch from a script) usually don't.
|
|
234
|
-
*
|
|
235
|
-
* Policy:
|
|
236
|
-
* - No `Origin` header → assume non-browser caller, allow.
|
|
237
|
-
* - `Origin` set → must be in `allowedOrigins`. Empty allowlist
|
|
238
|
-
* means "reject all browser-originated requests" — the default,
|
|
239
|
-
* because the daemon is designed for CLI/script callers.
|
|
240
|
-
* - `allowLoopback: true` (set by `lazyclaw dashboard`) additionally
|
|
241
|
-
* accepts any `Origin` that looks like loopback (`http://127.0.0.1:*`,
|
|
242
|
-
* `http://localhost:*`, `http://[::1]:*`). Safe because the daemon
|
|
243
|
-
* binds only to 127.0.0.1, so an attacker can't reach us with a
|
|
244
|
-
* loopback Origin unless they're already on the box. DNS rebinding
|
|
245
|
-
* can't forge `127.0.0.1` as a hostname — that resolves before
|
|
246
|
-
* `fetch()` ever issues the request.
|
|
247
|
-
*
|
|
248
|
-
* Returns true when the request should proceed, false when it should
|
|
249
|
-
* be rejected with 403.
|
|
250
|
-
*/
|
|
251
|
-
const LOOPBACK_ORIGIN_RE = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
|
|
252
|
-
function isOriginAllowed(req, allowedOrigins, allowLoopback) {
|
|
253
|
-
const origin = req.headers['origin'];
|
|
254
|
-
if (!origin) return true;
|
|
255
|
-
if (allowLoopback && LOOPBACK_ORIGIN_RE.test(origin)) return true;
|
|
256
|
-
if (!allowedOrigins || allowedOrigins.length === 0) return false;
|
|
257
|
-
return allowedOrigins.includes(origin);
|
|
258
|
-
}
|
|
28
|
+
// Re-exported so existing importers (cli.mjs, tests) keep their path.
|
|
29
|
+
export { statusForProviderError };
|
|
259
30
|
|
|
260
31
|
/**
|
|
261
32
|
* @param {{
|
|
@@ -494,1855 +265,22 @@ export function makeHandler(ctx) {
|
|
|
494
265
|
const workflowMatch = url.pathname.match(/^\/workflows\/([^/]+)$/);
|
|
495
266
|
const configKeyMatch = url.pathname.match(/^\/config\/([^/]+)$/);
|
|
496
267
|
const ratesKeyMatch = url.pathname.match(/^\/rates\/([^/]+)$/);
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
'content-type': 'text/html; charset=utf-8',
|
|
514
|
-
'cache-control': 'no-cache',
|
|
515
|
-
});
|
|
516
|
-
return res.end(body);
|
|
517
|
-
} catch (e) {
|
|
518
|
-
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
|
519
|
-
return res.end(
|
|
520
|
-
`lazyclaw daemon is up but the dashboard HTML wasn't found.\n` +
|
|
521
|
-
`Try \`lazyclaw version\` to confirm install integrity, or hit any /api endpoint directly.\n\n` +
|
|
522
|
-
`error: ${e?.message || e}\n`,
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
case route === 'GET /version':
|
|
527
|
-
return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
|
|
528
|
-
case route === 'POST /exec/request': {
|
|
529
|
-
// Remote exec-approval bridge. This route is AUTH-TOKEN-GATED
|
|
530
|
-
// (above), so only the trusted local operator/CLI can REQUEST an
|
|
531
|
-
// approval; a paired mobile device RESOLVES it over the gateway
|
|
532
|
-
// (POST /gateway/exec/resolve). The route long-polls: it awaits
|
|
533
|
-
// the device's decision (or the approval's timeout) and returns
|
|
534
|
-
// { approved, by, reason }.
|
|
535
|
-
let body;
|
|
536
|
-
try { body = await readJson(req); }
|
|
537
|
-
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
538
|
-
if (!body || typeof body.tool !== 'string' || !body.tool) {
|
|
539
|
-
return writeJson(res, 400, { error: 'tool is required' });
|
|
540
|
-
}
|
|
541
|
-
const { promise } = gateway.requestApproval(
|
|
542
|
-
{ tool: body.tool, args: body.args, agentId: body.agentId, summary: body.summary },
|
|
543
|
-
{ timeoutMs: Number.isFinite(+body.timeoutMs) ? +body.timeoutMs : undefined },
|
|
544
|
-
);
|
|
545
|
-
const result = await promise;
|
|
546
|
-
return writeJson(res, 200, result);
|
|
547
|
-
}
|
|
548
|
-
case route === 'GET /health':
|
|
549
|
-
case route === 'GET /healthz':
|
|
550
|
-
// Conventional liveness check — always 200 if the process
|
|
551
|
-
// is alive enough to hit the route. No config inspection
|
|
552
|
-
// (use /doctor for readiness), no provider probing — this
|
|
553
|
-
// is the "is the daemon up?" probe that load balancers
|
|
554
|
-
// and watchdog scripts expect at this path. m15: K8s
|
|
555
|
-
// readiness probes default to /healthz so we alias.
|
|
556
|
-
return writeJson(res, 200, {
|
|
557
|
-
ok: true,
|
|
558
|
-
status: 'alive',
|
|
559
|
-
uptimeMs: Date.now() - metrics.startedAtMs,
|
|
560
|
-
timestamp: new Date().toISOString(),
|
|
561
|
-
});
|
|
562
|
-
case route === 'GET /metrics': {
|
|
563
|
-
// Aggregate per-handler counters. cacheStats are pulled per
|
|
564
|
-
// wrapped provider — we report a sum across all populated
|
|
565
|
-
// entries so the figure reflects total cache activity.
|
|
566
|
-
let cacheHits = 0, cacheMisses = 0, cacheSize = 0;
|
|
567
|
-
if (cachedByName) {
|
|
568
|
-
for (const wrapped of cachedByName.values()) {
|
|
569
|
-
const s = typeof wrapped.cacheStats === 'function' ? wrapped.cacheStats() : null;
|
|
570
|
-
if (s) {
|
|
571
|
-
cacheHits += s.hits || 0;
|
|
572
|
-
cacheMisses += s.misses || 0;
|
|
573
|
-
cacheSize += s.size || 0;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
// Cumulative tokens / cost — meaningful only when callers used
|
|
578
|
-
// body.usage / body.cost. The fields are always present (zero
|
|
579
|
-
// by default) so monitoring tooling sees a stable schema.
|
|
580
|
-
const tokensTotal = { ...metrics.tokensTotal };
|
|
581
|
-
const costs = {};
|
|
582
|
-
for (const [cur, n] of Object.entries(metrics.costsByCurrency)) {
|
|
583
|
-
// Round to six decimals here too, matching costFromUsage's
|
|
584
|
-
// precision so monitoring deltas line up with per-request
|
|
585
|
-
// breakdowns.
|
|
586
|
-
costs[cur] = Math.round(n * 1_000_000) / 1_000_000;
|
|
587
|
-
}
|
|
588
|
-
// Workflow snapshot — opportunistic. We scan the state dir
|
|
589
|
-
// once per /metrics call and count per bucket. This is
|
|
590
|
-
// cheap unless the user has thousands of state files; for
|
|
591
|
-
// truly large fleets the operator can disable by passing
|
|
592
|
-
// ctx.workflowMetrics === false.
|
|
593
|
-
let workflows = null;
|
|
594
|
-
if (ctx.workflowMetrics !== false) {
|
|
595
|
-
try {
|
|
596
|
-
const stateDir = ctx.workflowStateDir();
|
|
597
|
-
if (fs.existsSync(stateDir)) {
|
|
598
|
-
const sessions = listWorkflowSessions(stateDir);
|
|
599
|
-
workflows = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
|
|
600
|
-
for (const s of sessions) {
|
|
601
|
-
if (s.summary.done) workflows.done++;
|
|
602
|
-
if (s.summary.resumable) workflows.resumable++;
|
|
603
|
-
if (s.summary.failed > 0) workflows.failed++;
|
|
604
|
-
if (s.summary.running > 0) workflows.running++;
|
|
605
|
-
}
|
|
606
|
-
} else {
|
|
607
|
-
workflows = { total: 0, done: 0, resumable: 0, failed: 0, running: 0 };
|
|
608
|
-
}
|
|
609
|
-
} catch {
|
|
610
|
-
// Don't fail /metrics because the state dir is unreadable —
|
|
611
|
-
// expose the gap as null and keep monitoring alive.
|
|
612
|
-
workflows = null;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
return writeJson(res, 200, {
|
|
616
|
-
uptimeMs: Date.now() - metrics.startedAtMs,
|
|
617
|
-
requestsTotal: metrics.requestsTotal,
|
|
618
|
-
requestsByStatus: metrics.requestsByStatus,
|
|
619
|
-
rateLimitDenied: metrics.rateLimitDenied,
|
|
620
|
-
cache: cachedByName ? { hits: cacheHits, misses: cacheMisses, size: cacheSize } : null,
|
|
621
|
-
tokensTotal,
|
|
622
|
-
costsByCurrency: costs,
|
|
623
|
-
workflows,
|
|
624
|
-
timestamp: new Date().toISOString(),
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
case route === 'GET /providers': {
|
|
628
|
-
// ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
|
|
629
|
-
// The dashboard reads `custom` / `builtinOpenAICompat` / `endpoint`
|
|
630
|
-
// / `docs` to render the right pills + tooltips; CLI callers only
|
|
631
|
-
// need `name` / `requiresApiKey` / `suggestedModels` and ignore
|
|
632
|
-
// the extras (additive change, no migration).
|
|
633
|
-
let out = Object.keys(PROVIDERS).map(name => {
|
|
634
|
-
const meta = PROVIDER_INFO[name] || { name };
|
|
635
|
-
return {
|
|
636
|
-
name,
|
|
637
|
-
requiresApiKey: !!meta.requiresApiKey,
|
|
638
|
-
defaultModel: meta.defaultModel || null,
|
|
639
|
-
suggestedModels: meta.suggestedModels || [],
|
|
640
|
-
endpoint: meta.endpoint || null,
|
|
641
|
-
docs: meta.docs || null,
|
|
642
|
-
custom: !!meta.custom,
|
|
643
|
-
builtinOpenAICompat: !!meta.builtinOpenAICompat,
|
|
644
|
-
baseUrl: meta.baseUrl || null,
|
|
645
|
-
envKey: meta.envKey || null,
|
|
646
|
-
keyPrefix: meta.keyPrefix || null,
|
|
647
|
-
};
|
|
648
|
-
});
|
|
649
|
-
const filter = url.searchParams.get('filter');
|
|
650
|
-
if (filter) {
|
|
651
|
-
const f = filter.toLowerCase();
|
|
652
|
-
out = out.filter(p => p.name.toLowerCase().includes(f));
|
|
653
|
-
}
|
|
654
|
-
const limitStr = url.searchParams.get('limit');
|
|
655
|
-
if (limitStr) {
|
|
656
|
-
const n = parseInt(limitStr, 10);
|
|
657
|
-
if (Number.isFinite(n) && n > 0) out = out.slice(0, n);
|
|
658
|
-
}
|
|
659
|
-
return writeJson(res, 200, out);
|
|
660
|
-
}
|
|
661
|
-
case req.method === 'GET' && !!providerMatch && providerMatch[1] !== 'test': {
|
|
662
|
-
// GET /providers/<name> — full per-provider metadata
|
|
663
|
-
// (mirrors CLI `lazyclaw providers info <name>`).
|
|
664
|
-
// The `name !== 'test'` guard keeps `/providers/test`
|
|
665
|
-
// (parallel batch endpoint) from being intercepted here;
|
|
666
|
-
// switch-case order ensures the literal `GET /providers/test`
|
|
667
|
-
// case runs first anyway, but the guard makes the intent
|
|
668
|
-
// explicit for future readers.
|
|
669
|
-
const name = providerMatch[1];
|
|
670
|
-
const meta = PROVIDER_INFO[name];
|
|
671
|
-
if (!meta) {
|
|
672
|
-
return writeJson(res, 404, {
|
|
673
|
-
error: 'unknown provider',
|
|
674
|
-
name,
|
|
675
|
-
knownProviders: Object.keys(PROVIDERS),
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
return writeJson(res, 200, meta);
|
|
679
|
-
}
|
|
680
|
-
case route === 'GET /providers/test': {
|
|
681
|
-
// Mirror of CLI v3.55 `lazyclaw providers test` (no name).
|
|
682
|
-
// A dashboard's "key validity" badge calls this once and
|
|
683
|
-
// gets a per-provider verdict in one round trip. HTTP
|
|
684
|
-
// status mirrors CLI exit code:
|
|
685
|
-
// 200 — every provider returned a non-empty reply
|
|
686
|
-
// 503 — at least one provider failed (Service Unavailable;
|
|
687
|
-
// "the system is partially unhealthy")
|
|
688
|
-
// 503 is the right code because a dashboard observing it
|
|
689
|
-
// can render a yellow status without parsing the body.
|
|
690
|
-
const cfg = ctx.readConfig();
|
|
691
|
-
const apiKey = cfg['api-key'] || '';
|
|
692
|
-
const sharedPrompt = url.searchParams.get('prompt') || 'ping';
|
|
693
|
-
const tAll = Date.now();
|
|
694
|
-
const results = await Promise.all(
|
|
695
|
-
Object.entries(PROVIDERS).map(async ([pid, provider]) => {
|
|
696
|
-
const meta = PROVIDER_INFO[pid] || {};
|
|
697
|
-
const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
|
|
698
|
-
const t0 = Date.now();
|
|
699
|
-
try {
|
|
700
|
-
let reply = '';
|
|
701
|
-
const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
|
|
702
|
-
for await (const chunk of stream) {
|
|
703
|
-
if (typeof chunk === 'string') reply += chunk;
|
|
704
|
-
}
|
|
705
|
-
return {
|
|
706
|
-
name: pid, ok: reply.length > 0, model,
|
|
707
|
-
durationMs: Date.now() - t0,
|
|
708
|
-
replyLength: reply.length,
|
|
709
|
-
};
|
|
710
|
-
} catch (err) {
|
|
711
|
-
return {
|
|
712
|
-
name: pid, ok: false, model,
|
|
713
|
-
durationMs: Date.now() - t0,
|
|
714
|
-
error: err?.message || String(err),
|
|
715
|
-
code: err?.code || null,
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
}),
|
|
719
|
-
);
|
|
720
|
-
const allOk = results.every(r => r.ok);
|
|
721
|
-
return writeJson(res, allOk ? 200 : 503, {
|
|
722
|
-
ok: allOk,
|
|
723
|
-
totalDurationMs: Date.now() - tAll,
|
|
724
|
-
results,
|
|
725
|
-
});
|
|
726
|
-
}
|
|
727
|
-
case req.method === 'GET' && !!providerTestMatch: {
|
|
728
|
-
// GET /providers/<name>/test — single-provider 1-token reachability
|
|
729
|
-
// probe. Same shape as one entry of GET /providers/test, but the
|
|
730
|
-
// endpoint stops on the first failure and exposes the reply body
|
|
731
|
-
// (truncated) so the dashboard can show a real signal of life.
|
|
732
|
-
const name = providerTestMatch[1];
|
|
733
|
-
const provider = PROVIDERS[name];
|
|
734
|
-
if (!provider) return writeJson(res, 404, { error: `unknown provider: ${name}` });
|
|
735
|
-
const cfg = ctx.readConfig();
|
|
736
|
-
const apiKey = cfg['api-key'] || '';
|
|
737
|
-
const meta = PROVIDER_INFO[name] || {};
|
|
738
|
-
const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
|
|
739
|
-
const prompt = url.searchParams.get('prompt') || 'ping';
|
|
740
|
-
const t0 = Date.now();
|
|
741
|
-
try {
|
|
742
|
-
let reply = '';
|
|
743
|
-
const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
|
|
744
|
-
for await (const chunk of stream) {
|
|
745
|
-
if (typeof chunk === 'string') reply += chunk;
|
|
746
|
-
}
|
|
747
|
-
return writeJson(res, reply.length > 0 ? 200 : 503, {
|
|
748
|
-
ok: reply.length > 0,
|
|
749
|
-
name, model,
|
|
750
|
-
durationMs: Date.now() - t0,
|
|
751
|
-
replyLength: reply.length,
|
|
752
|
-
reply: reply.slice(0, 500),
|
|
753
|
-
});
|
|
754
|
-
} catch (err) {
|
|
755
|
-
return writeJson(res, 503, {
|
|
756
|
-
ok: false, name, model,
|
|
757
|
-
durationMs: Date.now() - t0,
|
|
758
|
-
error: err?.message || String(err),
|
|
759
|
-
code: err?.code || null,
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
case route === 'POST /providers': {
|
|
764
|
-
// Register or overwrite a custom OpenAI-compatible provider.
|
|
765
|
-
// Body: { name, baseUrl, apiKey?, defaultModel? }. Persists into
|
|
766
|
-
// cfg.customProviders[] and hot-registers via the registry's
|
|
767
|
-
// registerCustomProviders() so the new entry is callable in this
|
|
768
|
-
// same process. 405 when the daemon was started without
|
|
769
|
-
// writeConfig (read-only mode). The same name as a built-in
|
|
770
|
-
// OpenAI-compat alias is allowed and overrides the built-in.
|
|
771
|
-
if (typeof ctx.writeConfig !== 'function') {
|
|
772
|
-
return writeJson(res, 405, { error: 'mutation disabled — daemon was started without writeConfig' });
|
|
773
|
-
}
|
|
774
|
-
let body;
|
|
775
|
-
try { body = await readJson(req); }
|
|
776
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
777
|
-
const reg = await import('./providers/registry.mjs');
|
|
778
|
-
let name;
|
|
779
|
-
try { name = reg.validateCustomProviderName(body.name); }
|
|
780
|
-
catch (e) { return writeJson(res, 400, { error: e.message }); }
|
|
781
|
-
if (!body.baseUrl || typeof body.baseUrl !== 'string' || !/^https?:\/\//i.test(body.baseUrl)) {
|
|
782
|
-
return writeJson(res, 400, { error: 'baseUrl must be a string starting with http:// or https://' });
|
|
783
|
-
}
|
|
784
|
-
const cfg = ctx.readConfig();
|
|
785
|
-
cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
|
|
786
|
-
const idx = cfg.customProviders.findIndex((p) => p && p.name === name);
|
|
787
|
-
const entry = {
|
|
788
|
-
name,
|
|
789
|
-
baseUrl: String(body.baseUrl).replace(/\/+$/, ''),
|
|
790
|
-
apiKey: body.apiKey || undefined,
|
|
791
|
-
defaultModel: body.defaultModel || undefined,
|
|
792
|
-
};
|
|
793
|
-
if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
|
|
794
|
-
else cfg.customProviders.push(entry);
|
|
795
|
-
ctx.writeConfig(cfg);
|
|
796
|
-
try { reg.registerCustomProviders(cfg); } catch { /* keep going */ }
|
|
797
|
-
const overridesBuiltin = typeof reg.isBuiltinOpenAICompatName === 'function'
|
|
798
|
-
? reg.isBuiltinOpenAICompatName(name)
|
|
799
|
-
: false;
|
|
800
|
-
return writeJson(res, 200, { ok: true, name, baseUrl: entry.baseUrl, overridesBuiltin });
|
|
801
|
-
}
|
|
802
|
-
case req.method === 'DELETE' && !!providerMatch && providerMatch[1] !== 'test': {
|
|
803
|
-
// DELETE /providers/<name> — drop a custom registration. Idempotent:
|
|
804
|
-
// 200 with `removed: false` when the name wasn't a custom entry.
|
|
805
|
-
// Built-in providers can't be deleted; their PROVIDERS row is
|
|
806
|
-
// restored on next process boot if the user previously overrode it.
|
|
807
|
-
if (typeof ctx.writeConfig !== 'function') {
|
|
808
|
-
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
809
|
-
}
|
|
810
|
-
const name = providerMatch[1];
|
|
811
|
-
const cfg = ctx.readConfig();
|
|
812
|
-
if (!Array.isArray(cfg.customProviders) || cfg.customProviders.length === 0) {
|
|
813
|
-
return writeJson(res, 200, { ok: true, name, removed: false });
|
|
814
|
-
}
|
|
815
|
-
const before = cfg.customProviders.length;
|
|
816
|
-
cfg.customProviders = cfg.customProviders.filter((p) => !(p && p.name === name));
|
|
817
|
-
const removed = cfg.customProviders.length < before;
|
|
818
|
-
if (removed) ctx.writeConfig(cfg);
|
|
819
|
-
return writeJson(res, 200, { ok: true, name, removed });
|
|
820
|
-
}
|
|
821
|
-
case route === 'GET /rates': {
|
|
822
|
-
// Read-only view of cfg.rates so a dashboard's cost panel
|
|
823
|
-
// can render the configured cards without shelling to the
|
|
824
|
-
// CLI. No write surface exposed — rate-card edits go
|
|
825
|
-
// through the CLI (operator-curated, deliberate).
|
|
826
|
-
// ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
|
|
827
|
-
const cfg = ctx.readConfig();
|
|
828
|
-
const rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
|
|
829
|
-
let entries = Object.entries(rates);
|
|
830
|
-
const filter = url.searchParams.get('filter');
|
|
831
|
-
if (filter) {
|
|
832
|
-
const f = filter.toLowerCase();
|
|
833
|
-
entries = entries.filter(([key]) => key.toLowerCase().includes(f));
|
|
834
|
-
}
|
|
835
|
-
const limitStr = url.searchParams.get('limit');
|
|
836
|
-
if (limitStr) {
|
|
837
|
-
const n = parseInt(limitStr, 10);
|
|
838
|
-
if (Number.isFinite(n) && n > 0) entries = entries.slice(0, n);
|
|
839
|
-
}
|
|
840
|
-
return writeJson(res, 200, Object.fromEntries(entries));
|
|
841
|
-
}
|
|
842
|
-
case route === 'GET /rates/validate': {
|
|
843
|
-
// Mirror of v3.30's `lazyclaw rates validate`. Same shape
|
|
844
|
-
// (single source of truth in rates-validate.mjs). HTTP
|
|
845
|
-
// status reflects ok/issues so a UI's cost-config badge
|
|
846
|
-
// can branch on HTTP code: 200 ok, 422 issues
|
|
847
|
-
// (Unprocessable Entity, same pattern as /config/validate
|
|
848
|
-
// in v3.40).
|
|
849
|
-
const cfg = ctx.readConfig();
|
|
850
|
-
const result = validateRates(cfg.rates, PROVIDERS);
|
|
851
|
-
return writeJson(res, result.ok ? 200 : 422, result);
|
|
852
|
-
}
|
|
853
|
-
case route === 'GET /rates/shape': {
|
|
854
|
-
// Mirror of `lazyclaw rates shape`. Returns the zero-filled
|
|
855
|
-
// reference rate-card template so a dashboard config panel
|
|
856
|
-
// or a script that scaffolds a new card can get the required
|
|
857
|
-
// fields without shelling to the CLI.
|
|
858
|
-
return writeJson(res, 200, RATE_CARD_SHAPE);
|
|
859
|
-
}
|
|
860
|
-
case req.method === 'PUT' && !!ratesKeyMatch && ratesKeyMatch[1] !== 'validate' && ratesKeyMatch[1] !== 'shape': {
|
|
861
|
-
// PUT /rates/<key> — set a rate card. Body is the card object
|
|
862
|
-
// ({ in, out, "cache-read"?, "cache-create"?, currency? }). The
|
|
863
|
-
// payload is merged into cfg.rates and validated as a whole;
|
|
864
|
-
// 422 on validation failure. 405 when writeConfig is unset.
|
|
865
|
-
if (typeof ctx.writeConfig !== 'function') {
|
|
866
|
-
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
867
|
-
}
|
|
868
|
-
const key = decodeURIComponent(ratesKeyMatch[1]);
|
|
869
|
-
let body;
|
|
870
|
-
try { body = await readJson(req); }
|
|
871
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
872
|
-
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be a JSON object' });
|
|
873
|
-
const cfg = ctx.readConfig();
|
|
874
|
-
cfg.rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
|
|
875
|
-
cfg.rates[key] = body;
|
|
876
|
-
const v = validateRates(cfg.rates, PROVIDERS);
|
|
877
|
-
if (!v.ok) return writeJson(res, 422, v);
|
|
878
|
-
ctx.writeConfig(cfg);
|
|
879
|
-
return writeJson(res, 200, { ok: true, key, card: cfg.rates[key] });
|
|
880
|
-
}
|
|
881
|
-
case req.method === 'DELETE' && !!ratesKeyMatch && ratesKeyMatch[1] !== 'validate' && ratesKeyMatch[1] !== 'shape': {
|
|
882
|
-
// DELETE /rates/<key> — idempotent: 200 with `removed: false`
|
|
883
|
-
// when the card didn't exist.
|
|
884
|
-
if (typeof ctx.writeConfig !== 'function') {
|
|
885
|
-
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
886
|
-
}
|
|
887
|
-
const key = decodeURIComponent(ratesKeyMatch[1]);
|
|
888
|
-
const cfg = ctx.readConfig();
|
|
889
|
-
if (!cfg.rates || typeof cfg.rates !== 'object' || !(key in cfg.rates)) {
|
|
890
|
-
return writeJson(res, 200, { ok: true, key, removed: false });
|
|
891
|
-
}
|
|
892
|
-
delete cfg.rates[key];
|
|
893
|
-
ctx.writeConfig(cfg);
|
|
894
|
-
return writeJson(res, 200, { ok: true, key, removed: true });
|
|
895
|
-
}
|
|
896
|
-
case route === 'GET /status': {
|
|
897
|
-
const cfg = ctx.readConfig();
|
|
898
|
-
// v5: surface a one-line summary of trainer / index / sandbox so
|
|
899
|
-
// the dashboard banner doesn't need three more round-trips.
|
|
900
|
-
const trainerCfg = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
|
|
901
|
-
const sandboxBackend = (cfg.sandbox && typeof cfg.sandbox === 'object' && cfg.sandbox.default) || 'local';
|
|
902
|
-
let indexRows = null;
|
|
903
|
-
try {
|
|
904
|
-
const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
|
|
905
|
-
const r = db.prepare(
|
|
906
|
-
"SELECT (SELECT COUNT(*) FROM fts_sessions) + (SELECT COUNT(*) FROM fts_skills) " +
|
|
907
|
-
" + (SELECT COUNT(*) FROM fts_trajectories) + (SELECT COUNT(*) FROM fts_memories) AS n"
|
|
908
|
-
).get();
|
|
909
|
-
indexRows = r && typeof r.n === 'number' ? r.n : null;
|
|
910
|
-
} catch { /* index may not exist yet */ }
|
|
911
|
-
// Migration backup path is conventionally <configDir>/backup-v4/.
|
|
912
|
-
let migrateBackup = null;
|
|
913
|
-
try {
|
|
914
|
-
const p = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'backup-v4');
|
|
915
|
-
if (fs.existsSync(p)) migrateBackup = p;
|
|
916
|
-
} catch { /* ignore */ }
|
|
917
|
-
return writeJson(res, 200, {
|
|
918
|
-
provider: cfg.provider || null,
|
|
919
|
-
model: cfg.model || null,
|
|
920
|
-
keyMasked: maskApiKey(cfg['api-key']),
|
|
921
|
-
v5: {
|
|
922
|
-
trainer: {
|
|
923
|
-
provider: trainerCfg.provider || null,
|
|
924
|
-
model: trainerCfg.model || null,
|
|
925
|
-
},
|
|
926
|
-
sandboxBackend,
|
|
927
|
-
indexRows,
|
|
928
|
-
migrateBackup,
|
|
929
|
-
},
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
case route === 'GET /config/validate': {
|
|
933
|
-
// Mirror of v3.39's `lazyclaw config validate`. Same shape
|
|
934
|
-
// (single source of truth in config-validate.mjs). HTTP
|
|
935
|
-
// status reflects ok/issues so a UI's "config status"
|
|
936
|
-
// badge can branch on HTTP code: 200 = ok, 422 = issues
|
|
937
|
-
// (Unprocessable Entity — semantically right for "the
|
|
938
|
-
// config you supplied is malformed").
|
|
939
|
-
const cfg = ctx.readConfig();
|
|
940
|
-
const { ok, issues, warnings } = validateConfig(cfg, PROVIDERS);
|
|
941
|
-
return writeJson(res, ok ? 200 : 422, {
|
|
942
|
-
ok,
|
|
943
|
-
keys: Object.keys(cfg),
|
|
944
|
-
issues,
|
|
945
|
-
warnings,
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
case route === 'GET /config': {
|
|
949
|
-
// Mirror of `lazyclaw config list`. Returns every stored key
|
|
950
|
-
// with the api-key value masked — lets a dashboard or script
|
|
951
|
-
// inspect the active configuration without shelling to the CLI.
|
|
952
|
-
const cfg = ctx.readConfig();
|
|
953
|
-
const safe = { ...cfg };
|
|
954
|
-
if (safe['api-key']) safe['api-key'] = maskApiKey(safe['api-key']);
|
|
955
|
-
return writeJson(res, 200, safe);
|
|
956
|
-
}
|
|
957
|
-
case req.method === 'GET' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
|
|
958
|
-
// Mirror of `lazyclaw config get <key>`. The `!== 'validate'`
|
|
959
|
-
// guard ensures the literal GET /config/validate case (above)
|
|
960
|
-
// is never shadowed by this dynamic handler.
|
|
961
|
-
const key = configKeyMatch[1];
|
|
962
|
-
const cfg = ctx.readConfig();
|
|
963
|
-
if (!(key in cfg)) {
|
|
964
|
-
return writeJson(res, 404, { error: 'key not found', key });
|
|
965
|
-
}
|
|
966
|
-
const raw = cfg[key];
|
|
967
|
-
const value = key === 'api-key' ? maskApiKey(raw) : raw;
|
|
968
|
-
return writeJson(res, 200, { key, value });
|
|
969
|
-
}
|
|
970
|
-
case req.method === 'PUT' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
|
|
971
|
-
// PUT /config/<key> body: { value: <any> }
|
|
972
|
-
// Mirror of `lazyclaw config set <key> <value>`. Re-validates the
|
|
973
|
-
// whole config after the write so we never persist a broken state.
|
|
974
|
-
// Nested cargo (customProviders / rates / authProfiles) goes
|
|
975
|
-
// through its own dedicated endpoint — guarded here so a
|
|
976
|
-
// dashboard PUT can't bypass schema validation.
|
|
977
|
-
if (typeof ctx.writeConfig !== 'function') {
|
|
978
|
-
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
979
|
-
}
|
|
980
|
-
const key = configKeyMatch[1];
|
|
981
|
-
if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
|
|
982
|
-
return writeJson(res, 400, {
|
|
983
|
-
error: `use the dedicated endpoint for "${key}" — POST /providers · PUT /rates/<key> · authProfiles via CLI`,
|
|
984
|
-
});
|
|
985
|
-
}
|
|
986
|
-
let body;
|
|
987
|
-
try { body = await readJson(req); }
|
|
988
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
989
|
-
const value = body && Object.prototype.hasOwnProperty.call(body, 'value') ? body.value : undefined;
|
|
990
|
-
const cfg = ctx.readConfig();
|
|
991
|
-
if (value === null || value === undefined) delete cfg[key];
|
|
992
|
-
else cfg[key] = value;
|
|
993
|
-
const v = validateConfig(cfg, PROVIDERS);
|
|
994
|
-
if (!v.ok) return writeJson(res, 422, v);
|
|
995
|
-
ctx.writeConfig(cfg);
|
|
996
|
-
const stored = key === 'api-key' && typeof cfg[key] === 'string' ? maskApiKey(cfg[key]) : cfg[key];
|
|
997
|
-
return writeJson(res, 200, { ok: true, key, value: stored });
|
|
998
|
-
}
|
|
999
|
-
case req.method === 'DELETE' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
|
|
1000
|
-
// DELETE /config/<key> — same as `lazyclaw config delete`.
|
|
1001
|
-
// Idempotent: 200 with `removed: false` when the key wasn't
|
|
1002
|
-
// present.
|
|
1003
|
-
if (typeof ctx.writeConfig !== 'function') {
|
|
1004
|
-
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
1005
|
-
}
|
|
1006
|
-
const key = configKeyMatch[1];
|
|
1007
|
-
if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
|
|
1008
|
-
return writeJson(res, 400, { error: `delete via the dedicated endpoint for "${key}"` });
|
|
1009
|
-
}
|
|
1010
|
-
const cfg = ctx.readConfig();
|
|
1011
|
-
if (!(key in cfg)) return writeJson(res, 200, { ok: true, key, removed: false });
|
|
1012
|
-
delete cfg[key];
|
|
1013
|
-
ctx.writeConfig(cfg);
|
|
1014
|
-
return writeJson(res, 200, { ok: true, key, removed: true });
|
|
1015
|
-
}
|
|
1016
|
-
case route === 'GET /doctor': {
|
|
1017
|
-
// Mirror the CLI doctor output — same field set so any tool that
|
|
1018
|
-
// already knows how to read CLI doctor JSON can hit this endpoint.
|
|
1019
|
-
const cfg = ctx.readConfig();
|
|
1020
|
-
const issues = [];
|
|
1021
|
-
if (!cfg.provider) issues.push('config.provider is missing');
|
|
1022
|
-
if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
|
|
1023
|
-
issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
|
|
1024
|
-
}
|
|
1025
|
-
if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
|
|
1026
|
-
issues.push(`unknown provider "${cfg.provider}"`);
|
|
1027
|
-
}
|
|
1028
|
-
// v5: FTS5 index integrity. Failure here is degraded-not-fatal —
|
|
1029
|
-
// surfaced as an issue but doesn't take the daemon down.
|
|
1030
|
-
let indexBlock = null;
|
|
1031
|
-
try {
|
|
1032
|
-
const integ = indexDb.integrityCheck(gwConfigDir);
|
|
1033
|
-
const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
|
|
1034
|
-
const rowCounts = {};
|
|
1035
|
-
for (const t of ['fts_sessions', 'fts_skills', 'fts_trajectories', 'fts_memories']) {
|
|
1036
|
-
try { rowCounts[t] = db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n; }
|
|
1037
|
-
catch { rowCounts[t] = null; }
|
|
1038
|
-
}
|
|
1039
|
-
indexBlock = {
|
|
1040
|
-
ok: !!integ.ok,
|
|
1041
|
-
result: integ.result || null,
|
|
1042
|
-
rowCounts,
|
|
1043
|
-
lastRebuiltAt: ctx.indexLastRebuiltAt || null,
|
|
1044
|
-
};
|
|
1045
|
-
if (!integ.ok) issues.push(`FTS5 index integrity_check returned ${integ.result}`);
|
|
1046
|
-
} catch (e) {
|
|
1047
|
-
indexBlock = { ok: false, error: e?.message || String(e), rowCounts: {}, lastRebuiltAt: null };
|
|
1048
|
-
issues.push(`FTS5 index unavailable: ${e?.message || e}`);
|
|
1049
|
-
}
|
|
1050
|
-
const ok = issues.length === 0;
|
|
1051
|
-
return writeJson(res, ok ? 200 : 503, {
|
|
1052
|
-
ok,
|
|
1053
|
-
provider: cfg.provider || null,
|
|
1054
|
-
model: cfg.model || null,
|
|
1055
|
-
hasApiKey: !!cfg['api-key'],
|
|
1056
|
-
nodeVersion: process.version,
|
|
1057
|
-
platform: `${process.platform}-${process.arch}`,
|
|
1058
|
-
issues,
|
|
1059
|
-
knownProviders: Object.keys(PROVIDERS),
|
|
1060
|
-
index: indexBlock,
|
|
1061
|
-
timestamp: new Date().toISOString(),
|
|
1062
|
-
});
|
|
1063
|
-
}
|
|
1064
|
-
case route === 'GET /sessions': {
|
|
1065
|
-
// ?filter=<substr> case-insensitive id substring;
|
|
1066
|
-
// ?limit=<N> caps post-filter count.
|
|
1067
|
-
// Same composition (filter then limit) as v3.33's CLI flag.
|
|
1068
|
-
// ?withTurnCount=true mirrors CLI v3.59's --with-turn-count;
|
|
1069
|
-
// opt-in because it loads each session file.
|
|
1070
|
-
// ?sortBy=mtime|turn-count|bytes|id mirrors CLI v3.60's --sort-by;
|
|
1071
|
-
// turn-count implicitly enables turn-count loading.
|
|
1072
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1073
|
-
let list = ctx.sessionsMod.listSessions(cfgDir);
|
|
1074
|
-
const filter = url.searchParams.get('filter');
|
|
1075
|
-
if (filter) {
|
|
1076
|
-
const f = filter.toLowerCase();
|
|
1077
|
-
list = list.filter(s => s.id.toLowerCase().includes(f));
|
|
1078
|
-
}
|
|
1079
|
-
const limitStr = url.searchParams.get('limit');
|
|
1080
|
-
if (limitStr) {
|
|
1081
|
-
const n = parseInt(limitStr, 10);
|
|
1082
|
-
if (Number.isFinite(n) && n > 0) list = list.slice(0, n);
|
|
1083
|
-
}
|
|
1084
|
-
const sortBy = url.searchParams.get('sortBy');
|
|
1085
|
-
const validSortBy = new Set(['mtime', 'turn-count', 'bytes', 'id']);
|
|
1086
|
-
if (sortBy && !validSortBy.has(sortBy)) {
|
|
1087
|
-
return writeJson(res, 400, { error: `invalid sortBy: ${sortBy} (expected: mtime, turn-count, bytes, id)` });
|
|
1088
|
-
}
|
|
1089
|
-
const withCount = url.searchParams.get('withTurnCount') === 'true' || sortBy === 'turn-count';
|
|
1090
|
-
// v5: surface trainerHandled / agentName / trajectoryId per row.
|
|
1091
|
-
// Source: the last turn's metadata if persisted; otherwise null.
|
|
1092
|
-
// We're surgical here — we read the metadata only when withTurnCount
|
|
1093
|
-
// is already paying the load cost, OR when the dashboard explicitly
|
|
1094
|
-
// asks via ?withV5=true. Keeps the default GET /sessions cheap.
|
|
1095
|
-
const withV5 = url.searchParams.get('withV5') === 'true' || withCount;
|
|
1096
|
-
let out = list.map(s => {
|
|
1097
|
-
const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
|
|
1098
|
-
if (withCount) {
|
|
1099
|
-
try { base.turnCount = ctx.sessionsMod.loadTurns(s.id, cfgDir).length; }
|
|
1100
|
-
catch { base.turnCount = null; }
|
|
1101
|
-
}
|
|
1102
|
-
if (withV5) {
|
|
1103
|
-
try {
|
|
1104
|
-
const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
|
|
1105
|
-
// Newest turn carries the freshest annotations. Fall back to
|
|
1106
|
-
// any earlier turn that has the field set so a long session
|
|
1107
|
-
// doesn't drop its trainer/agent attribution.
|
|
1108
|
-
let trainerHandled = false;
|
|
1109
|
-
let trainedBy = null;
|
|
1110
|
-
let agentName = null;
|
|
1111
|
-
let trajectoryId = null;
|
|
1112
|
-
for (let i = turns.length - 1; i >= 0; i--) {
|
|
1113
|
-
const t = turns[i] || {};
|
|
1114
|
-
if (!trajectoryId && t.trajectoryId) trajectoryId = String(t.trajectoryId);
|
|
1115
|
-
if (!agentName && t.agent) agentName = String(t.agent);
|
|
1116
|
-
if (!trainedBy && t.trainedBy) { trainedBy = String(t.trainedBy); trainerHandled = true; }
|
|
1117
|
-
if (t.trainerHandled) trainerHandled = true;
|
|
1118
|
-
if (trajectoryId && agentName && trainedBy) break;
|
|
1119
|
-
}
|
|
1120
|
-
base.trainerHandled = !!trainerHandled;
|
|
1121
|
-
base.trainedBy = trainedBy;
|
|
1122
|
-
base.agentName = agentName;
|
|
1123
|
-
base.trajectoryId = trajectoryId;
|
|
1124
|
-
} catch { /* missing metadata is non-fatal */ }
|
|
1125
|
-
}
|
|
1126
|
-
return base;
|
|
1127
|
-
});
|
|
1128
|
-
if (sortBy) {
|
|
1129
|
-
const cmp = {
|
|
1130
|
-
mtime: (a, b) => b._mtimeMs - a._mtimeMs,
|
|
1131
|
-
'turn-count': (a, b) => (b.turnCount ?? 0) - (a.turnCount ?? 0),
|
|
1132
|
-
bytes: (a, b) => b.bytes - a.bytes,
|
|
1133
|
-
id: (a, b) => a.id.localeCompare(b.id),
|
|
1134
|
-
};
|
|
1135
|
-
out.sort(cmp[sortBy]);
|
|
1136
|
-
}
|
|
1137
|
-
return writeJson(res, 200, out.map(({ _mtimeMs, ...rest }) => rest));
|
|
1138
|
-
}
|
|
1139
|
-
case route === 'GET /sessions/search': {
|
|
1140
|
-
// Mirror of `lazyclaw sessions search <query> [--regex]`.
|
|
1141
|
-
// ?q=<query> required; ?regex=true switches to regex mode.
|
|
1142
|
-
// Returns { query, regex, matches: [{ id, mtime, matchCount, excerpt }] }
|
|
1143
|
-
// — same shape the CLI prints. A dashboard rendering the
|
|
1144
|
-
// search box can use the same parser for both surfaces.
|
|
1145
|
-
const q = url.searchParams.get('q');
|
|
1146
|
-
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
1147
|
-
const useRegex = url.searchParams.get('regex') === 'true';
|
|
1148
|
-
let matcher;
|
|
1149
|
-
if (useRegex) {
|
|
1150
|
-
try { matcher = new RegExp(q, 'i'); }
|
|
1151
|
-
catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
|
|
1152
|
-
} else {
|
|
1153
|
-
const ql = q.toLowerCase();
|
|
1154
|
-
matcher = { test: (s) => String(s).toLowerCase().includes(ql) };
|
|
1155
|
-
}
|
|
1156
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1157
|
-
const list = ctx.sessionsMod.listSessions(cfgDir);
|
|
1158
|
-
const matches = [];
|
|
1159
|
-
for (const s of list) {
|
|
1160
|
-
const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
|
|
1161
|
-
let matchCount = 0;
|
|
1162
|
-
let firstExcerpt = null;
|
|
1163
|
-
for (const t of turns) {
|
|
1164
|
-
if (typeof t?.content !== 'string') continue;
|
|
1165
|
-
if (matcher.test(t.content)) {
|
|
1166
|
-
matchCount++;
|
|
1167
|
-
if (firstExcerpt === null) {
|
|
1168
|
-
const c = t.content;
|
|
1169
|
-
let pos = useRegex ? c.search(matcher) : c.toLowerCase().indexOf(q.toLowerCase());
|
|
1170
|
-
if (pos < 0) pos = 0;
|
|
1171
|
-
const start = Math.max(0, pos - 40);
|
|
1172
|
-
const end = Math.min(c.length, pos + q.length + 40);
|
|
1173
|
-
firstExcerpt = (start > 0 ? '…' : '') + c.slice(start, end) + (end < c.length ? '…' : '');
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
if (matchCount > 0) {
|
|
1178
|
-
matches.push({
|
|
1179
|
-
id: s.id,
|
|
1180
|
-
mtime: new Date(s.mtimeMs).toISOString(),
|
|
1181
|
-
matchCount,
|
|
1182
|
-
excerpt: firstExcerpt,
|
|
1183
|
-
});
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
return writeJson(res, 200, { query: q, regex: useRegex, matches });
|
|
1187
|
-
}
|
|
1188
|
-
case req.method === 'GET' && !!sessionExportMatch: {
|
|
1189
|
-
// GET /sessions/<id>/export?format=md|json|text — same body
|
|
1190
|
-
// the CLI's `lazyclaw sessions export <id> --format ...`
|
|
1191
|
-
// produces, with the appropriate content-type. The dashboard
|
|
1192
|
-
// can offer a "download as ..." button without spawning the
|
|
1193
|
-
// CLI.
|
|
1194
|
-
const id = sessionExportMatch[1];
|
|
1195
|
-
try {
|
|
1196
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1197
|
-
const file = ctx.sessionsMod.sessionPath(id, cfgDir);
|
|
1198
|
-
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
|
|
1199
|
-
const fmt = (url.searchParams.get('format') || 'md').toLowerCase();
|
|
1200
|
-
const FORMATS = {
|
|
1201
|
-
md: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
|
|
1202
|
-
markdown: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
|
|
1203
|
-
json: { fn: ctx.sessionsMod.exportJson, mime: 'application/json; charset=utf-8' },
|
|
1204
|
-
text: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
|
|
1205
|
-
txt: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
|
|
1206
|
-
};
|
|
1207
|
-
const f = FORMATS[fmt];
|
|
1208
|
-
if (!f) {
|
|
1209
|
-
return writeJson(res, 400, {
|
|
1210
|
-
error: `unknown format: ${fmt}`,
|
|
1211
|
-
expected: ['md', 'json', 'text'],
|
|
1212
|
-
});
|
|
1213
|
-
}
|
|
1214
|
-
const body = f.fn(id, cfgDir);
|
|
1215
|
-
res.writeHead(200, {
|
|
1216
|
-
'content-type': f.mime,
|
|
1217
|
-
'content-length': Buffer.byteLength(body),
|
|
1218
|
-
});
|
|
1219
|
-
return res.end(body);
|
|
1220
|
-
} catch (err) {
|
|
1221
|
-
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
case req.method === 'GET' && !!sessionMatch: {
|
|
1225
|
-
// GET /sessions/<id> — full turn log. Returns 404 when missing
|
|
1226
|
-
// rather than an empty array so the caller can distinguish
|
|
1227
|
-
// "session does not exist" from "session is empty".
|
|
1228
|
-
const id = sessionMatch[1];
|
|
1229
|
-
try {
|
|
1230
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1231
|
-
const file = ctx.sessionsMod.sessionPath(id, cfgDir);
|
|
1232
|
-
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
|
|
1233
|
-
const turns = ctx.sessionsMod.loadTurns(id, cfgDir);
|
|
1234
|
-
return writeJson(res, 200, { id, turns });
|
|
1235
|
-
} catch (err) {
|
|
1236
|
-
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
case route === 'GET /workflows/aggregate': {
|
|
1240
|
-
// Mirror of CLI v3.48 `lazyclaw inspect --aggregate`. Per-node
|
|
1241
|
-
// statistics across every persisted session in the state
|
|
1242
|
-
// directory. Answers "which node tends to be slow / fail
|
|
1243
|
-
// across all my runs?" — needs no workflow file, just state.
|
|
1244
|
-
// ?filter=<substr> applies before aggregation (v3.50).
|
|
1245
|
-
const stateDir = ctx.workflowStateDir();
|
|
1246
|
-
const qFilter = url.searchParams.get('filter');
|
|
1247
|
-
const qNode = url.searchParams.get('node');
|
|
1248
|
-
try {
|
|
1249
|
-
const stats = aggregateNodeStats(stateDir, { filter: qFilter });
|
|
1250
|
-
// ?node=<id>: drill into one node's cross-session stats
|
|
1251
|
-
// (mirrors CLI v3.52 --aggregate --node). 404 when the
|
|
1252
|
-
// node never appeared in any session.
|
|
1253
|
-
if (qNode) {
|
|
1254
|
-
const nodeStat = stats.nodeStats[qNode];
|
|
1255
|
-
if (!nodeStat) {
|
|
1256
|
-
return writeJson(res, 404, {
|
|
1257
|
-
error: 'node not found across sessions',
|
|
1258
|
-
nodeId: qNode,
|
|
1259
|
-
knownNodes: Object.keys(stats.nodeStats),
|
|
1260
|
-
});
|
|
1261
|
-
}
|
|
1262
|
-
return writeJson(res, 200, {
|
|
1263
|
-
dir: stateDir,
|
|
1264
|
-
filter: qFilter || null,
|
|
1265
|
-
sessionCount: stats.sessionCount,
|
|
1266
|
-
nodeId: qNode,
|
|
1267
|
-
...nodeStat,
|
|
1268
|
-
});
|
|
1269
|
-
}
|
|
1270
|
-
return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, ...stats });
|
|
1271
|
-
} catch (err) {
|
|
1272
|
-
if (err?.code === 'ENOENT') {
|
|
1273
|
-
return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, sessionCount: 0, nodeStats: {} });
|
|
1274
|
-
}
|
|
1275
|
-
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
case route === 'GET /workflows': {
|
|
1279
|
-
// List every persisted workflow session in the configured
|
|
1280
|
-
// state dir, newest activity first. Mirrors `lazyclaw inspect`
|
|
1281
|
-
// (no-arg) exactly so a dashboard can use the same renderer
|
|
1282
|
-
// for CLI and HTTP outputs. Per-node `nodes` map is omitted —
|
|
1283
|
-
// call /workflows/<sessionId> for full detail.
|
|
1284
|
-
//
|
|
1285
|
-
// ?status=done|resumable|failed|running mirrors the CLI's
|
|
1286
|
-
// --status flag — one shared predicate so a UI can paginate
|
|
1287
|
-
// by bucket without pulling the full list.
|
|
1288
|
-
const stateDir = ctx.workflowStateDir();
|
|
1289
|
-
const qStatus = url.searchParams.get('status');
|
|
1290
|
-
if (qStatus) {
|
|
1291
|
-
const valid = new Set(['done', 'resumable', 'failed', 'running']);
|
|
1292
|
-
if (!valid.has(qStatus)) {
|
|
1293
|
-
return writeJson(res, 400, {
|
|
1294
|
-
error: `invalid status: ${qStatus}`,
|
|
1295
|
-
expected: [...valid],
|
|
1296
|
-
});
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
try {
|
|
1300
|
-
let sessions = listWorkflowSessions(stateDir);
|
|
1301
|
-
if (qStatus) {
|
|
1302
|
-
sessions = sessions.filter(s => {
|
|
1303
|
-
if (qStatus === 'done') return s.summary.done;
|
|
1304
|
-
if (qStatus === 'resumable') return s.summary.resumable;
|
|
1305
|
-
if (qStatus === 'failed') return s.summary.failed > 0;
|
|
1306
|
-
if (qStatus === 'running') return s.summary.running > 0;
|
|
1307
|
-
return true;
|
|
1308
|
-
});
|
|
1309
|
-
}
|
|
1310
|
-
// ?filter=<substr>&limit=<N> mirror v3.33 sessions/skills list flags.
|
|
1311
|
-
const qFilter = url.searchParams.get('filter');
|
|
1312
|
-
if (qFilter) {
|
|
1313
|
-
const f = qFilter.toLowerCase();
|
|
1314
|
-
sessions = sessions.filter(s => s.sessionId.toLowerCase().includes(f));
|
|
1315
|
-
}
|
|
1316
|
-
const qLimit = url.searchParams.get('limit');
|
|
1317
|
-
if (qLimit) {
|
|
1318
|
-
const n = parseInt(qLimit, 10);
|
|
1319
|
-
if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, n);
|
|
1320
|
-
}
|
|
1321
|
-
return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions });
|
|
1322
|
-
} catch (err) {
|
|
1323
|
-
if (err?.code === 'ENOENT') {
|
|
1324
|
-
// Empty dir is a valid state (no workflows ever ran). The
|
|
1325
|
-
// CLI distinguishes "missing dir" from "empty dir" — the
|
|
1326
|
-
// daemon collapses both to an empty array so a fresh
|
|
1327
|
-
// process doesn't 404 a UI poll loop.
|
|
1328
|
-
return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions: [] });
|
|
1329
|
-
}
|
|
1330
|
-
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
case req.method === 'GET' && !!workflowMatch: {
|
|
1334
|
-
// GET /workflows/<sessionId> — full state of a single
|
|
1335
|
-
// workflow run. Same shape as `lazyclaw inspect <id>` (the
|
|
1336
|
-
// engine's persisted object plus a derived summary block).
|
|
1337
|
-
// 404 when the state file is missing.
|
|
1338
|
-
const sid = workflowMatch[1];
|
|
1339
|
-
const stateDir = ctx.workflowStateDir();
|
|
1340
|
-
let state;
|
|
1341
|
-
try {
|
|
1342
|
-
state = loadWorkflowState(sid, stateDir);
|
|
1343
|
-
} catch (err) {
|
|
1344
|
-
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
1345
|
-
}
|
|
1346
|
-
if (!state) return writeJson(res, 404, { error: 'workflow not found', sessionId: sid });
|
|
1347
|
-
// ?node=<id> drills into one node's state — same shape as
|
|
1348
|
-
// `lazyclaw inspect <session> --node <id>` (v3.41). The
|
|
1349
|
-
// HTTP status reflects the node's lifecycle (mirrors the
|
|
1350
|
-
// CLI exit codes): 200 success/pending/running, 410 Gone
|
|
1351
|
-
// for failed (request was valid, but the resource is in a
|
|
1352
|
-
// failed state), 404 for unknown node id.
|
|
1353
|
-
// ?slowest=<N>: top N nodes by durationMs. Same shape as
|
|
1354
|
-
// CLI v3.44 — pure state-file analysis, no deps needed.
|
|
1355
|
-
const qSlowest = url.searchParams.get('slowest');
|
|
1356
|
-
if (qSlowest) {
|
|
1357
|
-
const n = parseInt(qSlowest, 10);
|
|
1358
|
-
if (!Number.isFinite(n) || n <= 0) {
|
|
1359
|
-
return writeJson(res, 400, {
|
|
1360
|
-
error: `slowest must be a positive integer (got ${JSON.stringify(qSlowest)})`,
|
|
1361
|
-
});
|
|
1362
|
-
}
|
|
1363
|
-
const entries = Object.entries(state.nodes || {}).map(([id, ns]) => ({
|
|
1364
|
-
id,
|
|
1365
|
-
status: ns?.status || 'pending',
|
|
1366
|
-
durationMs: Number.isFinite(ns?.durationMs) ? ns.durationMs : 0,
|
|
1367
|
-
attempts: ns?.attempts ?? 0,
|
|
1368
|
-
}));
|
|
1369
|
-
entries.sort((a, b) => (b.durationMs - a.durationMs) || a.id.localeCompare(b.id));
|
|
1370
|
-
return writeJson(res, 200, {
|
|
1371
|
-
sessionId: state.sessionId,
|
|
1372
|
-
top: entries.slice(0, n),
|
|
1373
|
-
});
|
|
1374
|
-
}
|
|
1375
|
-
const qNode = url.searchParams.get('node');
|
|
1376
|
-
if (qNode) {
|
|
1377
|
-
const ns = state.nodes?.[qNode];
|
|
1378
|
-
if (!ns) {
|
|
1379
|
-
return writeJson(res, 404, {
|
|
1380
|
-
error: 'node not found',
|
|
1381
|
-
sessionId: sid,
|
|
1382
|
-
nodeId: qNode,
|
|
1383
|
-
knownNodes: Object.keys(state.nodes || {}),
|
|
1384
|
-
});
|
|
1385
|
-
}
|
|
1386
|
-
return writeJson(res, ns.status === 'failed' ? 410 : 200, {
|
|
1387
|
-
sessionId: state.sessionId,
|
|
1388
|
-
nodeId: qNode,
|
|
1389
|
-
...ns,
|
|
1390
|
-
});
|
|
1391
|
-
}
|
|
1392
|
-
const { summary, failedNodes } = summarizeState(state);
|
|
1393
|
-
// ?summary=true trims the per-node `nodes` map and `order`
|
|
1394
|
-
// array, matching v3.17's CLI `inspect --summary` shape and
|
|
1395
|
-
// the per-session shape that list-mode produces. A UI fetching
|
|
1396
|
-
// this endpoint to render a status badge doesn't want the
|
|
1397
|
-
// full per-node payload — `?summary=true` keeps the wire
|
|
1398
|
-
// small for high-frequency polls.
|
|
1399
|
-
const compact = url.searchParams.get('summary') === 'true';
|
|
1400
|
-
const body = compact
|
|
1401
|
-
? {
|
|
1402
|
-
sessionId: state.sessionId,
|
|
1403
|
-
dir: stateDir,
|
|
1404
|
-
summary,
|
|
1405
|
-
failedNodes,
|
|
1406
|
-
startedAt: state.startedAt,
|
|
1407
|
-
updatedAt: state.updatedAt,
|
|
1408
|
-
}
|
|
1409
|
-
: {
|
|
1410
|
-
sessionId: state.sessionId,
|
|
1411
|
-
dir: stateDir,
|
|
1412
|
-
summary,
|
|
1413
|
-
failedNodes,
|
|
1414
|
-
order: state.order,
|
|
1415
|
-
nodes: state.nodes,
|
|
1416
|
-
startedAt: state.startedAt,
|
|
1417
|
-
updatedAt: state.updatedAt,
|
|
1418
|
-
};
|
|
1419
|
-
return writeJson(res, 200, body);
|
|
1420
|
-
}
|
|
1421
|
-
case route === 'GET /skills': {
|
|
1422
|
-
// List installed skills with their first-line summary so a UI
|
|
1423
|
-
// can render them without a follow-up read for each one.
|
|
1424
|
-
// ?filter=<substr>&limit=<N> mirror the v3.33 CLI flags.
|
|
1425
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1426
|
-
let items = listSkills(cfgDir);
|
|
1427
|
-
const filter = url.searchParams.get('filter');
|
|
1428
|
-
if (filter) {
|
|
1429
|
-
const f = filter.toLowerCase();
|
|
1430
|
-
items = items.filter(s => s.name.toLowerCase().includes(f));
|
|
1431
|
-
}
|
|
1432
|
-
const limitStr = url.searchParams.get('limit');
|
|
1433
|
-
if (limitStr) {
|
|
1434
|
-
const n = parseInt(limitStr, 10);
|
|
1435
|
-
if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
|
|
1436
|
-
}
|
|
1437
|
-
// v5: include frontmatter fields the dashboard renders as badges.
|
|
1438
|
-
// listSkills() currently only surfaces name/summary/etc., so we
|
|
1439
|
-
// re-parse the body once per skill to extract trained_by /
|
|
1440
|
-
// confidence / cross_cli_tested / group. Cheap (markdown files,
|
|
1441
|
-
// already cached by the OS).
|
|
1442
|
-
const out = items.map((s) => {
|
|
1443
|
-
let meta = {};
|
|
1444
|
-
try {
|
|
1445
|
-
const body = loadSkill(s.name, cfgDir);
|
|
1446
|
-
meta = parseFrontmatter(body).meta || {};
|
|
1447
|
-
} catch { /* unreadable → empty meta */ }
|
|
1448
|
-
const xcli = meta.cross_cli_tested;
|
|
1449
|
-
return {
|
|
1450
|
-
name: s.name,
|
|
1451
|
-
bytes: s.bytes,
|
|
1452
|
-
summary: s.summary,
|
|
1453
|
-
description: meta.description || s.description || '',
|
|
1454
|
-
group: meta.group || '',
|
|
1455
|
-
trained_by: meta.trained_by || (meta.created_by === 'agent' ? 'agent' : ''),
|
|
1456
|
-
confidence: meta.confidence !== undefined && meta.confidence !== ''
|
|
1457
|
-
? Number(meta.confidence)
|
|
1458
|
-
: null,
|
|
1459
|
-
cross_cli_tested: xcli === 'true' || xcli === true ? true
|
|
1460
|
-
: (Array.isArray(xcli) ? xcli : null),
|
|
1461
|
-
version: meta.version || s.version || '',
|
|
1462
|
-
created_by: meta.created_by || '',
|
|
1463
|
-
};
|
|
1464
|
-
});
|
|
1465
|
-
return writeJson(res, 200, out);
|
|
1466
|
-
}
|
|
1467
|
-
case route === 'GET /skills/suggestions': {
|
|
1468
|
-
// Ring buffer of nudge.suggest_skill events. Dashboard polls this
|
|
1469
|
-
// since the SSE bus is deferred to v5.1.
|
|
1470
|
-
return writeJson(res, 200, { suggestions: nudgeSuggestionsRing.slice(0, 20) });
|
|
1471
|
-
}
|
|
1472
|
-
case route === 'POST /skills/synth': {
|
|
1473
|
-
// Body: { sessionId: '<id>' [, outcome] [, trainedBy] [, model] }
|
|
1474
|
-
// Runs mas/skill_synth.synthesizeSkill against the named session.
|
|
1475
|
-
// We assemble a minimal agent stub from cfg.provider/model and an
|
|
1476
|
-
// empty role — the synth pipeline expects an agent object, but
|
|
1477
|
-
// most callers will want "use my default provider".
|
|
1478
|
-
let body;
|
|
1479
|
-
try { body = await readJson(req); }
|
|
1480
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
1481
|
-
const sessionId = body && String(body.sessionId || '').trim();
|
|
1482
|
-
if (!sessionId) return writeJson(res, 400, { error: 'sessionId is required' });
|
|
1483
|
-
const cfg = ctx.readConfig();
|
|
1484
|
-
const provider = body.provider || cfg.provider;
|
|
1485
|
-
const model = body.model || cfg.model;
|
|
1486
|
-
if (!provider) return writeJson(res, 400, { error: 'no provider configured — set cfg.provider or pass body.provider' });
|
|
1487
|
-
// Pull the session turns and build a minimal task shape.
|
|
1488
|
-
let turns;
|
|
1489
|
-
try { turns = ctx.sessionsMod.loadTurns(sessionId, gwConfigDir); }
|
|
1490
|
-
catch (e) { return writeJson(res, 404, { error: `session not found: ${sessionId}` }); }
|
|
1491
|
-
if (!Array.isArray(turns) || turns.length === 0) {
|
|
1492
|
-
return writeJson(res, 400, { error: `session "${sessionId}" has no turns` });
|
|
1493
|
-
}
|
|
1494
|
-
const task = {
|
|
1495
|
-
id: sessionId,
|
|
1496
|
-
title: turns[0]?.content?.slice(0, 80) || sessionId,
|
|
1497
|
-
turns: turns.map((t) => ({
|
|
1498
|
-
agent: t.role === 'user' ? 'user' : (t.role === 'system' ? 'system' : 'assistant'),
|
|
1499
|
-
text: String(t.content || ''),
|
|
1500
|
-
})),
|
|
1501
|
-
};
|
|
1502
|
-
const agent = { provider, model, role: '' };
|
|
1503
|
-
try {
|
|
1504
|
-
const apiKey = cfg['api-key'] || null;
|
|
1505
|
-
const result = await skillSynth.synthesizeSkill({
|
|
1506
|
-
agent, task, apiKey,
|
|
1507
|
-
outcome: body.outcome || 'done',
|
|
1508
|
-
trainedBy: body.trainedBy || null,
|
|
1509
|
-
trainedOnModel: model || null,
|
|
1510
|
-
});
|
|
1511
|
-
if (!result) return writeJson(res, 200, { ok: false, message: 'synth produced no skill (model returned NONE)' });
|
|
1512
|
-
// Mirror the CLI synth flow: installSynthesized() handles slug
|
|
1513
|
-
// reservation, agent-overwrite protection, and FTS5 mirror.
|
|
1514
|
-
const install = skillSynth.installSynthesized({
|
|
1515
|
-
name: result.name,
|
|
1516
|
-
description: result.description,
|
|
1517
|
-
body: result.body,
|
|
1518
|
-
sourceTask: sessionId,
|
|
1519
|
-
createdBy: 'agent',
|
|
1520
|
-
}, gwConfigDir);
|
|
1521
|
-
return writeJson(res, 200, {
|
|
1522
|
-
ok: true,
|
|
1523
|
-
name: install?.skill || result.name,
|
|
1524
|
-
description: result.description,
|
|
1525
|
-
path: install?.path || null,
|
|
1526
|
-
});
|
|
1527
|
-
} catch (e) {
|
|
1528
|
-
return writeJson(res, 500, { error: e?.message || String(e), code: e?.code });
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
case route === 'GET /skills/search': {
|
|
1532
|
-
// Mirror of `lazyclaw skills search`. ?q=<query> required;
|
|
1533
|
-
// ?regex=true switches to regex mode. Returns
|
|
1534
|
-
// { query, regex, matches: [{ name, bytes, matchCount, excerpt }] }
|
|
1535
|
-
// — same shape the CLI prints. A dashboard skill picker can
|
|
1536
|
-
// hit this endpoint instead of pulling every skill body and
|
|
1537
|
-
// searching client-side.
|
|
1538
|
-
const q = url.searchParams.get('q');
|
|
1539
|
-
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
1540
|
-
const useRegex = url.searchParams.get('regex') === 'true';
|
|
1541
|
-
let matcher;
|
|
1542
|
-
if (useRegex) {
|
|
1543
|
-
try { matcher = new RegExp(q, 'gi'); }
|
|
1544
|
-
catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
|
|
1545
|
-
}
|
|
1546
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1547
|
-
const items = listSkills(cfgDir);
|
|
1548
|
-
const matches = [];
|
|
1549
|
-
for (const s of items) {
|
|
1550
|
-
let body;
|
|
1551
|
-
try { body = loadSkill(s.name, cfgDir); } catch { continue; }
|
|
1552
|
-
let matchCount = 0;
|
|
1553
|
-
let firstExcerpt = null;
|
|
1554
|
-
if (useRegex) {
|
|
1555
|
-
for (const m of body.matchAll(matcher)) {
|
|
1556
|
-
matchCount++;
|
|
1557
|
-
if (firstExcerpt === null) {
|
|
1558
|
-
const pos = m.index ?? 0;
|
|
1559
|
-
const start = Math.max(0, pos - 40);
|
|
1560
|
-
const end = Math.min(body.length, pos + m[0].length + 40);
|
|
1561
|
-
firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
} else {
|
|
1565
|
-
const lower = body.toLowerCase();
|
|
1566
|
-
const ql = q.toLowerCase();
|
|
1567
|
-
let pos = 0;
|
|
1568
|
-
while (true) {
|
|
1569
|
-
const i = lower.indexOf(ql, pos);
|
|
1570
|
-
if (i < 0) break;
|
|
1571
|
-
matchCount++;
|
|
1572
|
-
if (firstExcerpt === null) {
|
|
1573
|
-
const start = Math.max(0, i - 40);
|
|
1574
|
-
const end = Math.min(body.length, i + ql.length + 40);
|
|
1575
|
-
firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
1576
|
-
}
|
|
1577
|
-
pos = i + ql.length;
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
if (matchCount > 0) {
|
|
1581
|
-
matches.push({ name: s.name, bytes: s.bytes, matchCount, excerpt: firstExcerpt });
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
return writeJson(res, 200, { query: q, regex: useRegex, matches });
|
|
1585
|
-
}
|
|
1586
|
-
case req.method === 'GET' && !!skillMatch: {
|
|
1587
|
-
// GET /skills/<name> — full markdown body as text/markdown.
|
|
1588
|
-
// 404 when the file is missing so the caller can branch.
|
|
1589
|
-
// 400 when the name fails skillPath validation (path traversal,
|
|
1590
|
-
// dotfile, etc.) — same protections as the CLI.
|
|
1591
|
-
// m13 — decodeURIComponent before validation (see PUT below).
|
|
1592
|
-
let name;
|
|
1593
|
-
try { name = decodeURIComponent(skillMatch[1]); }
|
|
1594
|
-
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
1595
|
-
try {
|
|
1596
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1597
|
-
const file = skillPath(name, cfgDir);
|
|
1598
|
-
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'skill not found', name });
|
|
1599
|
-
const body = loadSkill(name, cfgDir);
|
|
1600
|
-
res.writeHead(200, {
|
|
1601
|
-
'content-type': 'text/markdown; charset=utf-8',
|
|
1602
|
-
'content-length': Buffer.byteLength(body),
|
|
1603
|
-
});
|
|
1604
|
-
return res.end(body);
|
|
1605
|
-
} catch (err) {
|
|
1606
|
-
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
case req.method === 'PUT' && !!skillMatch: {
|
|
1610
|
-
// PUT /skills/<name> body = markdown text
|
|
1611
|
-
// 201 on first write, 200 on overwrite (caller can branch on
|
|
1612
|
-
// the status if they care about idempotency vs newness).
|
|
1613
|
-
// 400 on invalid name (skillPath validation) or oversize body.
|
|
1614
|
-
// m13 — decodeURIComponent the segment before validation so
|
|
1615
|
-
// a request like `PUT /skills/foo%2Fbar` is rejected as a path-
|
|
1616
|
-
// separator (slash) rather than letting the literal-percent
|
|
1617
|
-
// filename slip through.
|
|
1618
|
-
let name;
|
|
1619
|
-
try { name = decodeURIComponent(skillMatch[1]); }
|
|
1620
|
-
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
1621
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1622
|
-
let priorExists = false;
|
|
1623
|
-
try {
|
|
1624
|
-
// Validate name before reading the body so a bogus name fails
|
|
1625
|
-
// fast and we don't waste bandwidth.
|
|
1626
|
-
const file = skillPath(name, cfgDir);
|
|
1627
|
-
priorExists = await fileExists(file);
|
|
1628
|
-
} catch (err) {
|
|
1629
|
-
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1630
|
-
}
|
|
1631
|
-
let body;
|
|
1632
|
-
try { body = await readTextBody(req); }
|
|
1633
|
-
catch (err) { return writeJson(res, 400, { error: err?.message || String(err) }); }
|
|
1634
|
-
try {
|
|
1635
|
-
const written = installSkill(name, body, cfgDir);
|
|
1636
|
-
return writeJson(res, priorExists ? 200 : 201, {
|
|
1637
|
-
ok: true, name, path: written, bytes: body.length, replaced: priorExists,
|
|
1638
|
-
});
|
|
1639
|
-
} catch (err) {
|
|
1640
|
-
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
case req.method === 'DELETE' && !!skillMatch: {
|
|
1644
|
-
// DELETE /skills/<name> idempotent: 200 whether the file
|
|
1645
|
-
// existed or not, mirroring DELETE /sessions/<id>. The body
|
|
1646
|
-
// reports `removed: true|false` so callers can branch when
|
|
1647
|
-
// they care.
|
|
1648
|
-
// m13 — decodeURIComponent before validation (see PUT below).
|
|
1649
|
-
let name;
|
|
1650
|
-
try { name = decodeURIComponent(skillMatch[1]); }
|
|
1651
|
-
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
1652
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1653
|
-
try {
|
|
1654
|
-
const file = skillPath(name, cfgDir);
|
|
1655
|
-
const existed = await fileExists(file);
|
|
1656
|
-
removeSkill(name, cfgDir);
|
|
1657
|
-
return writeJson(res, 200, { ok: true, name, removed: existed });
|
|
1658
|
-
} catch (err) {
|
|
1659
|
-
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
case req.method === 'DELETE' && !!sessionMatch: {
|
|
1663
|
-
// DELETE /sessions/<id> — idempotent. 200 on both "deleted" and
|
|
1664
|
-
// "didn't exist" so callers can use it as a reset without checking
|
|
1665
|
-
// first. m16: include `removed: <bool>` for shape parity with
|
|
1666
|
-
// sibling DELETEs (/skills, /workflows).
|
|
1667
|
-
const id = sessionMatch[1];
|
|
1668
|
-
try {
|
|
1669
|
-
// Use the sessions module's path resolver to check existence
|
|
1670
|
-
// BEFORE clearSession (which is unconditional unlink-if-exists).
|
|
1671
|
-
const sessDir = ctx.sessionsDirGetter();
|
|
1672
|
-
let existedBefore = false;
|
|
1673
|
-
try {
|
|
1674
|
-
const sessPath = ctx.sessionsMod.sessionPath
|
|
1675
|
-
? ctx.sessionsMod.sessionPath(id, sessDir)
|
|
1676
|
-
: null;
|
|
1677
|
-
if (sessPath) existedBefore = fs.existsSync(sessPath);
|
|
1678
|
-
} catch { /* sessionPath unavailable → leave as unknown */ }
|
|
1679
|
-
ctx.sessionsMod.clearSession(id, sessDir);
|
|
1680
|
-
return writeJson(res, 200, { ok: true, id, removed: existedBefore });
|
|
1681
|
-
} catch (err) {
|
|
1682
|
-
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
case req.method === 'DELETE' && !!workflowMatch: {
|
|
1686
|
-
// DELETE /workflows/<sessionId> — idempotent: 200 with
|
|
1687
|
-
// `removed: true|false`. Same protection as the rest of the
|
|
1688
|
-
// delete routes — only files inside the configured state dir
|
|
1689
|
-
// are touched. The path matcher already rejects `..` and `/`,
|
|
1690
|
-
// and we re-resolve via path.join so a sessionId that resolves
|
|
1691
|
-
// outside the dir is rejected with 400.
|
|
1692
|
-
const sid = workflowMatch[1];
|
|
1693
|
-
const stateDir = ctx.workflowStateDir();
|
|
1694
|
-
// Note: `path` is shadowed inside this handler by the URL path
|
|
1695
|
-
// variable above — use `nodePath` (aliased import) for fs ops.
|
|
1696
|
-
const file = nodePath.join(stateDir, `${sid}.json`);
|
|
1697
|
-
// Confined-path check: file must resolve under stateDir. fs.realpathSync
|
|
1698
|
-
// would resolve symlinks too, but the dir may not exist yet — use
|
|
1699
|
-
// the resolved string-prefix check, which is enough since stateDir
|
|
1700
|
-
// is operator-controlled.
|
|
1701
|
-
const resolvedDir = nodePath.resolve(stateDir);
|
|
1702
|
-
const resolvedFile = nodePath.resolve(file);
|
|
1703
|
-
if (!resolvedFile.startsWith(resolvedDir + nodePath.sep) && resolvedFile !== resolvedDir) {
|
|
1704
|
-
return writeJson(res, 400, { error: 'invalid sessionId' });
|
|
1705
|
-
}
|
|
1706
|
-
try {
|
|
1707
|
-
const existed = fs.existsSync(resolvedFile);
|
|
1708
|
-
if (existed) fs.unlinkSync(resolvedFile);
|
|
1709
|
-
return writeJson(res, 200, { ok: true, sessionId: sid, removed: existed });
|
|
1710
|
-
} catch (err) {
|
|
1711
|
-
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
case route === 'POST /chat': {
|
|
1715
|
-
// Cost-cap gate: short-circuit before parsing the body so the
|
|
1716
|
-
// 402 fires fast and we don't pay for body buffering on a
|
|
1717
|
-
// request we're refusing.
|
|
1718
|
-
const breach = checkCostCap(metrics, costCap);
|
|
1719
|
-
if (breach) {
|
|
1720
|
-
return writeJson(res, 402, {
|
|
1721
|
-
error: 'cost cap exceeded',
|
|
1722
|
-
currency: breach.currency,
|
|
1723
|
-
spent: breach.spent,
|
|
1724
|
-
cap: breach.cap,
|
|
1725
|
-
});
|
|
1726
|
-
}
|
|
1727
|
-
// Full message-array input, single response (or stream). Useful when
|
|
1728
|
-
// the caller already has a message history and doesn't want to use
|
|
1729
|
-
// the disk-persisted session model.
|
|
1730
|
-
const body = await readJson(req);
|
|
1731
|
-
const cfg = ctx.readConfig();
|
|
1732
|
-
const provName = body.provider || cfg.provider || 'mock';
|
|
1733
|
-
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
1734
|
-
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
1735
|
-
const prov = resolved.provider;
|
|
1736
|
-
const messages = Array.isArray(body.messages) ? body.messages.filter(m => m && typeof m.role === 'string' && typeof m.content === 'string') : null;
|
|
1737
|
-
if (!messages || messages.length === 0) return writeJson(res, 400, { error: 'messages array required' });
|
|
1738
|
-
const thinkingBudget = Number(body.thinkingBudget) || 0;
|
|
1739
|
-
// Usage capture: opt-in via body.usage. The provider only does
|
|
1740
|
-
// the extra work (and pays the wire cost on OpenAI) when the
|
|
1741
|
-
// caller asks for it.
|
|
1742
|
-
let captured = null;
|
|
1743
|
-
const sendOpts = {
|
|
1744
|
-
apiKey: cfg['api-key'],
|
|
1745
|
-
model: body.model || cfg.model,
|
|
1746
|
-
thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
|
|
1747
|
-
onUsage: body.usage ? (u) => { captured = u; } : undefined,
|
|
1748
|
-
};
|
|
1749
|
-
// Cost lookup: body.cost:true asks the daemon to attach a cost
|
|
1750
|
-
// block when usage was captured AND cfg.rates has a card for
|
|
1751
|
-
// the active provider/model. Pure arithmetic — no extra wire
|
|
1752
|
-
// calls. Inline rather than helper-extract because the two
|
|
1753
|
-
// response paths (stream / non-stream) need to bind it
|
|
1754
|
-
// differently (SSE event vs JSON field).
|
|
1755
|
-
const computeCost = () => {
|
|
1756
|
-
if (!body.cost || !captured || !cfg.rates) return null;
|
|
1757
|
-
try {
|
|
1758
|
-
const c = costFromUsage(
|
|
1759
|
-
{ provider: provName, model: body.model || cfg.model, usage: captured },
|
|
1760
|
-
cfg.rates,
|
|
1761
|
-
);
|
|
1762
|
-
if (c) accumulateMetricsFromCost(metrics, captured, c);
|
|
1763
|
-
return c;
|
|
1764
|
-
} catch { return null; }
|
|
1765
|
-
};
|
|
1766
|
-
if (body.stream === true) {
|
|
1767
|
-
writeSseHead(res);
|
|
1768
|
-
try {
|
|
1769
|
-
for await (const chunk of prov.sendMessage(messages, sendOpts)) {
|
|
1770
|
-
writeSse(res, 'token', { text: chunk });
|
|
1771
|
-
await new Promise(r => setImmediate(r));
|
|
1772
|
-
}
|
|
1773
|
-
if (captured) writeSse(res, 'usage', captured);
|
|
1774
|
-
const cost = computeCost();
|
|
1775
|
-
if (cost) writeSse(res, 'cost', cost);
|
|
1776
|
-
writeSse(res, 'done', { ok: true });
|
|
1777
|
-
return res.end();
|
|
1778
|
-
} catch (err) {
|
|
1779
|
-
writeSse(res, 'error', { message: err?.message || String(err) });
|
|
1780
|
-
return res.end();
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
let acc = '';
|
|
1784
|
-
try {
|
|
1785
|
-
for await (const chunk of prov.sendMessage(messages, sendOpts)) acc += chunk;
|
|
1786
|
-
const cost = computeCost();
|
|
1787
|
-
const out = { reply: acc };
|
|
1788
|
-
if (captured) out.usage = captured;
|
|
1789
|
-
if (cost) out.cost = cost;
|
|
1790
|
-
return writeJson(res, 200, out);
|
|
1791
|
-
} catch (err) {
|
|
1792
|
-
const m = statusForProviderError(err);
|
|
1793
|
-
return writeJson(res, m.status, {
|
|
1794
|
-
error: err?.message || String(err),
|
|
1795
|
-
code: err?.code || null,
|
|
1796
|
-
...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
1797
|
-
}, m.headers || {});
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
case route === 'POST /inbound': {
|
|
1801
|
-
// Generic inbound bridge — a stable, channel-agnostic relay
|
|
1802
|
-
// target so ANY platform (a Discord/WhatsApp/etc. bot the user
|
|
1803
|
-
// runs elsewhere) can forward a message in and get one reply,
|
|
1804
|
-
// without lazyclaw shipping that platform's SDK. Auth-token
|
|
1805
|
-
// gated like every non-gateway route; additionally pairing-gated
|
|
1806
|
-
// on senderId when a pairing allowlist is configured.
|
|
1807
|
-
const breach = checkCostCap(metrics, costCap);
|
|
1808
|
-
if (breach) return writeJson(res, 402, { error: 'cost cap exceeded', currency: breach.currency, spent: breach.spent, cap: breach.cap });
|
|
1809
|
-
let body;
|
|
1810
|
-
try { body = await readJson(req); }
|
|
1811
|
-
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
1812
|
-
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
1813
|
-
if (!text) return writeJson(res, 400, { error: 'text is required' });
|
|
1814
|
-
const cfg = ctx.readConfig();
|
|
1815
|
-
// Pairing gate: when the operator has paired any senders, the
|
|
1816
|
-
// relay must identify an allowlisted senderId.
|
|
1817
|
-
const allow = Array.isArray(cfg.pairing) ? cfg.pairing.map((p) => String(p && p.id)) : [];
|
|
1818
|
-
if (allow.length > 0) {
|
|
1819
|
-
const sender = String(body.senderId || '');
|
|
1820
|
-
if (!sender || !allow.includes(sender)) return writeJson(res, 403, { error: 'sender not paired' });
|
|
1821
|
-
}
|
|
1822
|
-
const provName = body.provider || cfg.provider || 'mock';
|
|
1823
|
-
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
1824
|
-
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
1825
|
-
let acc = '';
|
|
1826
|
-
let inboundUsage = null;
|
|
1827
|
-
try {
|
|
1828
|
-
for await (const chunk of resolved.provider.sendMessage(
|
|
1829
|
-
[{ role: 'user', content: text }],
|
|
1830
|
-
{ apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
|
|
1831
|
-
)) acc += chunk;
|
|
1832
|
-
} catch (err) {
|
|
1833
|
-
const m = statusForProviderError(err);
|
|
1834
|
-
return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
|
|
1835
|
-
}
|
|
1836
|
-
// Feed the running spend total so the cost cap can actually trip
|
|
1837
|
-
// on /inbound traffic (mirrors POST /agent / POST /chat).
|
|
1838
|
-
if (inboundUsage && cfg.rates) {
|
|
1839
|
-
try {
|
|
1840
|
-
const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
|
|
1841
|
-
if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
|
|
1842
|
-
} catch { /* cost is best-effort; never block a reply on it */ }
|
|
1843
|
-
}
|
|
1844
|
-
return writeJson(res, 200, { reply: acc, threadId: body.threadId || null });
|
|
1845
|
-
}
|
|
1846
|
-
case route === 'POST /agent': {
|
|
1847
|
-
const breach = checkCostCap(metrics, costCap);
|
|
1848
|
-
if (breach) {
|
|
1849
|
-
return writeJson(res, 402, {
|
|
1850
|
-
error: 'cost cap exceeded',
|
|
1851
|
-
currency: breach.currency,
|
|
1852
|
-
spent: breach.spent,
|
|
1853
|
-
cap: breach.cap,
|
|
1854
|
-
});
|
|
1855
|
-
}
|
|
1856
|
-
const body = await readJson(req);
|
|
1857
|
-
const cfg = ctx.readConfig();
|
|
1858
|
-
const provName = body.provider || cfg.provider || 'mock';
|
|
1859
|
-
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
1860
|
-
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
1861
|
-
const prov = resolved.provider;
|
|
1862
|
-
const prompt = String(body.prompt ?? '').trim();
|
|
1863
|
-
if (!prompt) return writeJson(res, 400, { error: 'prompt required' });
|
|
1864
|
-
const model = body.model || cfg.model;
|
|
1865
|
-
const thinkingBudget = Number(body.thinkingBudget) || 0;
|
|
1866
|
-
|
|
1867
|
-
// Session hydration if sessionId provided.
|
|
1868
|
-
const sid = body.sessionId || null;
|
|
1869
|
-
const cfgDir = ctx.sessionsDirGetter();
|
|
1870
|
-
let messages = sid ? ctx.sessionsMod.loadTurns(sid, cfgDir).map(t => ({ role: t.role, content: t.content })) : [];
|
|
1871
|
-
// Skill composition: body.skills can be a comma-separated string
|
|
1872
|
-
// ("a,b") or an array (["a","b"]). Compose only when no system
|
|
1873
|
-
// message already exists in the message array (so re-runs of
|
|
1874
|
-
// the same session don't double-prepend).
|
|
1875
|
-
const skillNames = Array.isArray(body.skills)
|
|
1876
|
-
? body.skills
|
|
1877
|
-
: (typeof body.skills === 'string' ? body.skills.split(',').map(s => s.trim()).filter(Boolean) : []);
|
|
1878
|
-
if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
|
|
1879
|
-
try {
|
|
1880
|
-
const sys = composeSystemPrompt(skillNames, cfgDir);
|
|
1881
|
-
if (sys) messages.unshift({ role: 'system', content: sys });
|
|
1882
|
-
} catch (err) {
|
|
1883
|
-
return writeJson(res, 400, { error: `skill error: ${err?.message || String(err)}` });
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
messages.push({ role: 'user', content: prompt });
|
|
1887
|
-
if (sid) ctx.sessionsMod.appendTurn(sid, 'user', prompt, cfgDir);
|
|
1888
|
-
|
|
1889
|
-
// body.usage opt-in mirrors POST /chat — provider only does the
|
|
1890
|
-
// extra work when the caller asks for it.
|
|
1891
|
-
let agentCaptured = null;
|
|
1892
|
-
const agentSendOpts = {
|
|
1893
|
-
apiKey: cfg['api-key'],
|
|
1894
|
-
model,
|
|
1895
|
-
thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
|
|
1896
|
-
onUsage: body.usage ? (u) => { agentCaptured = u; } : undefined,
|
|
1897
|
-
};
|
|
1898
|
-
const computeAgentCost = () => {
|
|
1899
|
-
if (!body.cost || !agentCaptured || !cfg.rates) return null;
|
|
1900
|
-
try {
|
|
1901
|
-
const c = costFromUsage(
|
|
1902
|
-
{ provider: provName, model, usage: agentCaptured },
|
|
1903
|
-
cfg.rates,
|
|
1904
|
-
);
|
|
1905
|
-
if (c) accumulateMetricsFromCost(metrics, agentCaptured, c);
|
|
1906
|
-
return c;
|
|
1907
|
-
} catch { return null; }
|
|
1908
|
-
};
|
|
1909
|
-
|
|
1910
|
-
if (body.stream === true) {
|
|
1911
|
-
writeSseHead(res);
|
|
1912
|
-
// Forward client disconnect to the provider so we don't keep
|
|
1913
|
-
// burning tokens after the consumer has gone away.
|
|
1914
|
-
const ac = new AbortController();
|
|
1915
|
-
req.on('aborted', () => ac.abort());
|
|
1916
|
-
res.on('close', () => { if (!res.writableEnded) ac.abort(); });
|
|
1917
|
-
let acc = '';
|
|
1918
|
-
try {
|
|
1919
|
-
for await (const chunk of prov.sendMessage(messages, { ...agentSendOpts, signal: ac.signal })) {
|
|
1920
|
-
if (ac.signal.aborted) break;
|
|
1921
|
-
acc += chunk;
|
|
1922
|
-
writeSse(res, 'token', { text: chunk });
|
|
1923
|
-
// Backpressure: yield so the caller can read each frame.
|
|
1924
|
-
await new Promise(r => setImmediate(r));
|
|
1925
|
-
}
|
|
1926
|
-
if (sid && !ac.signal.aborted) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
|
|
1927
|
-
if (!ac.signal.aborted) {
|
|
1928
|
-
if (agentCaptured) writeSse(res, 'usage', agentCaptured);
|
|
1929
|
-
const cost = computeAgentCost();
|
|
1930
|
-
if (cost) writeSse(res, 'cost', cost);
|
|
1931
|
-
writeSse(res, 'done', { ok: true });
|
|
1932
|
-
}
|
|
1933
|
-
return res.end();
|
|
1934
|
-
} catch (err) {
|
|
1935
|
-
if (err?.code === 'ABORT' || ac.signal.aborted) {
|
|
1936
|
-
// Client gave up — partial assistant turn is discarded.
|
|
1937
|
-
return res.end();
|
|
1938
|
-
}
|
|
1939
|
-
writeSse(res, 'error', { message: err?.message || String(err) });
|
|
1940
|
-
return res.end();
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
|
-
// Non-streaming: collect then return once. Reuse agentSendOpts
|
|
1945
|
-
// (carrying the optional onUsage capture) so usage lands in the
|
|
1946
|
-
// response when body.usage was set.
|
|
1947
|
-
let acc = '';
|
|
1948
|
-
try {
|
|
1949
|
-
for await (const chunk of prov.sendMessage(messages, agentSendOpts)) acc += chunk;
|
|
1950
|
-
if (sid) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
|
|
1951
|
-
const cost = computeAgentCost();
|
|
1952
|
-
const out = { reply: acc };
|
|
1953
|
-
if (agentCaptured) out.usage = agentCaptured;
|
|
1954
|
-
if (cost) out.cost = cost;
|
|
1955
|
-
return writeJson(res, 200, out);
|
|
1956
|
-
} catch (err) {
|
|
1957
|
-
const m = statusForProviderError(err);
|
|
1958
|
-
return writeJson(res, m.status, {
|
|
1959
|
-
error: err?.message || String(err),
|
|
1960
|
-
code: err?.code || null,
|
|
1961
|
-
...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
1962
|
-
}, m.headers || {});
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
// ──── Multi-agent dashboard surface (Phase 15) ────────────────
|
|
1966
|
-
// Routes share the same JSON-only shape the rest of the daemon
|
|
1967
|
-
// uses. The on-disk state is owned by agents.mjs / teams.mjs /
|
|
1968
|
-
// tasks.mjs; we don't touch the files directly here so the CLI
|
|
1969
|
-
// and the dashboard stay coherent.
|
|
1970
|
-
case route === 'GET /agents': {
|
|
1971
|
-
const mod = await import('./agents.mjs');
|
|
1972
|
-
return writeJson(res, 200, mod.listAgents());
|
|
1973
|
-
}
|
|
1974
|
-
case route === 'POST /agents': {
|
|
1975
|
-
const mod = await import('./agents.mjs');
|
|
1976
|
-
let body;
|
|
1977
|
-
try { body = await readJson(req); }
|
|
1978
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
1979
|
-
try { return writeJson(res, 200, mod.registerAgent(body)); }
|
|
1980
|
-
catch (err) {
|
|
1981
|
-
const code = err?.code === 'AGENT_EXISTS' ? 409 : 400;
|
|
1982
|
-
return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
|
|
1983
|
-
}
|
|
1984
|
-
}
|
|
1985
|
-
case req.method === 'GET' && /^\/agents\/([^/]+)$/.test(url.pathname): {
|
|
1986
|
-
const name = url.pathname.split('/').pop();
|
|
1987
|
-
const mod = await import('./agents.mjs');
|
|
1988
|
-
const a = mod.getAgent(name);
|
|
1989
|
-
if (!a) return writeJson(res, 404, { error: `no agent "${name}"` });
|
|
1990
|
-
return writeJson(res, 200, a);
|
|
1991
|
-
}
|
|
1992
|
-
case req.method === 'PATCH' && /^\/agents\/([^/]+)$/.test(url.pathname): {
|
|
1993
|
-
const name = url.pathname.split('/').pop();
|
|
1994
|
-
const mod = await import('./agents.mjs');
|
|
1995
|
-
let body;
|
|
1996
|
-
try { body = await readJson(req); }
|
|
1997
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
1998
|
-
try { return writeJson(res, 200, mod.patchAgent(name, body)); }
|
|
1999
|
-
catch (err) {
|
|
2000
|
-
const code = err?.code === 'AGENT_NO_AGENT' ? 404 : 400;
|
|
2001
|
-
return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
case req.method === 'DELETE' && /^\/agents\/([^/]+)$/.test(url.pathname): {
|
|
2005
|
-
const name = url.pathname.split('/').pop();
|
|
2006
|
-
const mod = await import('./agents.mjs');
|
|
2007
|
-
try { return writeJson(res, 200, mod.removeAgent(name)); }
|
|
2008
|
-
catch (err) {
|
|
2009
|
-
return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
case req.method === 'GET' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
|
|
2013
|
-
// M13 — 404 when the agent is not registered. The historical
|
|
2014
|
-
// behaviour silently returned an empty body, which made
|
|
2015
|
-
// typos indistinguishable from "no memory yet" and let the
|
|
2016
|
-
// dashboard render a stub for a non-existent agent.
|
|
2017
|
-
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
2018
|
-
const agentsMod = await import('./agents.mjs');
|
|
2019
|
-
if (!agentsMod.getAgent(name)) {
|
|
2020
|
-
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
2021
|
-
}
|
|
2022
|
-
const memMod = await import('./mas/agent_memory.mjs');
|
|
2023
|
-
const text = memMod.readMemory(name);
|
|
2024
|
-
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-cache' });
|
|
2025
|
-
return res.end(text);
|
|
2026
|
-
}
|
|
2027
|
-
case req.method === 'PUT' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
|
|
2028
|
-
// M13 — 404 when the agent is not registered. Without this
|
|
2029
|
-
// check, writeRaw happily created memory.md for a misspelled
|
|
2030
|
-
// agent name and the orphan file lived forever.
|
|
2031
|
-
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
2032
|
-
const agentsMod = await import('./agents.mjs');
|
|
2033
|
-
if (!agentsMod.getAgent(name)) {
|
|
2034
|
-
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
2035
|
-
}
|
|
2036
|
-
const memMod = await import('./mas/agent_memory.mjs');
|
|
2037
|
-
// Read raw text body — content-type defaults to text/markdown
|
|
2038
|
-
// but JSON {"text": "..."} is also accepted for tooling that
|
|
2039
|
-
// prefers structured bodies.
|
|
2040
|
-
let body = '';
|
|
2041
|
-
await new Promise((resolve) => {
|
|
2042
|
-
req.on('data', (c) => { body += c.toString(); });
|
|
2043
|
-
req.on('end', resolve);
|
|
2044
|
-
});
|
|
2045
|
-
let text = body;
|
|
2046
|
-
if (req.headers['content-type']?.includes('application/json')) {
|
|
2047
|
-
try { text = (JSON.parse(body || '{}').text) || ''; } catch { /* leave raw */ }
|
|
2048
|
-
}
|
|
2049
|
-
try {
|
|
2050
|
-
const p = memMod.writeRaw(name, text);
|
|
2051
|
-
return writeJson(res, 200, { path: p, bytes: Buffer.byteLength(text, 'utf8') });
|
|
2052
|
-
} catch (err) {
|
|
2053
|
-
return writeJson(res, 400, { error: err?.message || String(err), code: err?.code });
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
case req.method === 'DELETE' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
|
|
2057
|
-
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
2058
|
-
const agentsMod = await import('./agents.mjs');
|
|
2059
|
-
if (!agentsMod.getAgent(name)) {
|
|
2060
|
-
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
2061
|
-
}
|
|
2062
|
-
const memMod = await import('./mas/agent_memory.mjs');
|
|
2063
|
-
const removed = memMod.clear(name);
|
|
2064
|
-
return writeJson(res, 200, { name, cleared: removed });
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
case route === 'GET /teams': {
|
|
2068
|
-
const mod = await import('./teams.mjs');
|
|
2069
|
-
return writeJson(res, 200, mod.listTeams());
|
|
2070
|
-
}
|
|
2071
|
-
case route === 'POST /teams': {
|
|
2072
|
-
const mod = await import('./teams.mjs');
|
|
2073
|
-
let body;
|
|
2074
|
-
try { body = await readJson(req); }
|
|
2075
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
2076
|
-
try { return writeJson(res, 200, mod.registerTeam(body)); }
|
|
2077
|
-
catch (err) {
|
|
2078
|
-
const code = err?.code === 'TEAM_EXISTS' ? 409 : 400;
|
|
2079
|
-
return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
case req.method === 'GET' && /^\/teams\/([^/]+)$/.test(url.pathname): {
|
|
2083
|
-
const name = url.pathname.split('/').pop();
|
|
2084
|
-
const mod = await import('./teams.mjs');
|
|
2085
|
-
const t = mod.getTeam(name);
|
|
2086
|
-
if (!t) return writeJson(res, 404, { error: `no team "${name}"` });
|
|
2087
|
-
return writeJson(res, 200, t);
|
|
2088
|
-
}
|
|
2089
|
-
case req.method === 'PATCH' && /^\/teams\/([^/]+)$/.test(url.pathname): {
|
|
2090
|
-
const name = url.pathname.split('/').pop();
|
|
2091
|
-
const mod = await import('./teams.mjs');
|
|
2092
|
-
let body;
|
|
2093
|
-
try { body = await readJson(req); }
|
|
2094
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
2095
|
-
try { return writeJson(res, 200, mod.patchTeam(name, body)); }
|
|
2096
|
-
catch (err) {
|
|
2097
|
-
const code = err?.code === 'TEAM_NO_TEAM' ? 404 : 400;
|
|
2098
|
-
return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
|
-
case req.method === 'DELETE' && /^\/teams\/([^/]+)$/.test(url.pathname): {
|
|
2102
|
-
const name = url.pathname.split('/').pop();
|
|
2103
|
-
const mod = await import('./teams.mjs');
|
|
2104
|
-
try { return writeJson(res, 200, mod.removeTeam(name)); }
|
|
2105
|
-
catch (err) {
|
|
2106
|
-
return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
|
|
2107
|
-
}
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
case route === 'GET /tasks': {
|
|
2111
|
-
const mod = await import('./tasks.mjs');
|
|
2112
|
-
return writeJson(res, 200, mod.listTasks());
|
|
2113
|
-
}
|
|
2114
|
-
case req.method === 'GET' && /^\/tasks\/([^/]+)\/transcript$/.test(url.pathname): {
|
|
2115
|
-
const m = url.pathname.match(/^\/tasks\/([^/]+)\/transcript$/);
|
|
2116
|
-
const id = m[1];
|
|
2117
|
-
const mod = await import('./tasks.mjs');
|
|
2118
|
-
const t = mod.getTask(id);
|
|
2119
|
-
if (!t) return writeJson(res, 404, { error: `no task "${id}"` });
|
|
2120
|
-
const fmt = String(url.searchParams.get('format') || 'text');
|
|
2121
|
-
if (fmt === 'json') return writeJson(res, 200, t);
|
|
2122
|
-
const body = mod.formatTranscript(t, fmt);
|
|
2123
|
-
const mime = fmt === 'md' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
2124
|
-
res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-cache' });
|
|
2125
|
-
return res.end(body);
|
|
2126
|
-
}
|
|
2127
|
-
case req.method === 'GET' && /^\/tasks\/([^/]+)$/.test(url.pathname): {
|
|
2128
|
-
const id = url.pathname.split('/').pop();
|
|
2129
|
-
const mod = await import('./tasks.mjs');
|
|
2130
|
-
const t = mod.getTask(id);
|
|
2131
|
-
if (!t) return writeJson(res, 404, { error: `no task "${id}"` });
|
|
2132
|
-
return writeJson(res, 200, t);
|
|
2133
|
-
}
|
|
2134
|
-
case req.method === 'DELETE' && /^\/tasks\/([^/]+)$/.test(url.pathname): {
|
|
2135
|
-
const id = url.pathname.split('/').pop();
|
|
2136
|
-
const mod = await import('./tasks.mjs');
|
|
2137
|
-
try { return writeJson(res, 200, mod.removeTask(id)); }
|
|
2138
|
-
catch (err) {
|
|
2139
|
-
return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
case req.method === 'POST' && /^\/tasks\/([^/]+)\/(done|abandon)$/.test(url.pathname): {
|
|
2143
|
-
const m = url.pathname.match(/^\/tasks\/([^/]+)\/(done|abandon)$/);
|
|
2144
|
-
const id = m[1];
|
|
2145
|
-
const action = m[2];
|
|
2146
|
-
const mod = await import('./tasks.mjs');
|
|
2147
|
-
try {
|
|
2148
|
-
const next = mod.patchTask(id, { status: action === 'done' ? 'done' : 'abandoned' });
|
|
2149
|
-
return writeJson(res, 200, next);
|
|
2150
|
-
} catch (err) {
|
|
2151
|
-
return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
// ── v5 dashboard surfaces ────────────────────────────────────
|
|
2156
|
-
case route === 'GET /trainer/status': {
|
|
2157
|
-
// Reads cfg.trainer.{provider, model, schedule, budget, recipe}
|
|
2158
|
-
// and reports last-run state from <configDir>/trainer-state.json
|
|
2159
|
-
// if present. No standalone trainer module yet; this is a thin
|
|
2160
|
-
// config-surface endpoint the dashboard reads at refresh.
|
|
2161
|
-
const cfg = ctx.readConfig();
|
|
2162
|
-
const t = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
|
|
2163
|
-
let lastRunAt = null, callsToday = null;
|
|
2164
|
-
try {
|
|
2165
|
-
const statePath = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'trainer-state.json');
|
|
2166
|
-
if (fs.existsSync(statePath)) {
|
|
2167
|
-
const st = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
2168
|
-
lastRunAt = st?.lastRunAt || null;
|
|
2169
|
-
// callsToday: count entries whose ts is within the current
|
|
2170
|
-
// UTC day. State writer is the (future) trainer; reader
|
|
2171
|
-
// tolerates absence.
|
|
2172
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
2173
|
-
if (Array.isArray(st?.calls)) {
|
|
2174
|
-
callsToday = st.calls.filter((c) => String(c.ts || '').startsWith(today)).length;
|
|
2175
|
-
} else if (typeof st?.callsToday === 'number') {
|
|
2176
|
-
callsToday = st.callsToday;
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
} catch { /* missing/corrupt state → null */ }
|
|
2180
|
-
return writeJson(res, 200, {
|
|
2181
|
-
provider: t.provider || null,
|
|
2182
|
-
model: t.model || null,
|
|
2183
|
-
schedule: t.schedule || null,
|
|
2184
|
-
budget: t.budget != null ? Number(t.budget) : null,
|
|
2185
|
-
recipe: t.recipe || null,
|
|
2186
|
-
lastRunAt,
|
|
2187
|
-
callsToday,
|
|
2188
|
-
});
|
|
2189
|
-
}
|
|
2190
|
-
case route === 'POST /trainer/sync': {
|
|
2191
|
-
// Stub: a real trainer scheduler lands in v5.1. For now we
|
|
2192
|
-
// record the trigger in trainer-state.json so the dashboard's
|
|
2193
|
-
// "Sync now" button has feedback, and a future trainer reads
|
|
2194
|
-
// the queue. Surfacing it here keeps the API stable across the
|
|
2195
|
-
// transition.
|
|
2196
|
-
try {
|
|
2197
|
-
const dir = gwConfigDir || skillsDefaultConfigDir();
|
|
2198
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
2199
|
-
const statePath = nodePath.join(dir, 'trainer-state.json');
|
|
2200
|
-
let st = {};
|
|
2201
|
-
if (fs.existsSync(statePath)) {
|
|
2202
|
-
try { st = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { st = {}; }
|
|
2203
|
-
}
|
|
2204
|
-
st.lastSyncRequestAt = new Date().toISOString();
|
|
2205
|
-
st.syncQueued = (st.syncQueued || 0) + 1;
|
|
2206
|
-
fs.writeFileSync(statePath, JSON.stringify(st, null, 2));
|
|
2207
|
-
return writeJson(res, 200, { ok: true, message: 'sync queued', queued: st.syncQueued });
|
|
2208
|
-
} catch (e) {
|
|
2209
|
-
return writeJson(res, 500, { error: e?.message || String(e) });
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
case route === 'GET /recall': {
|
|
2213
|
-
// GET /recall?q=...&scope=sessions|skills|trajectories|memories|all&k=N
|
|
2214
|
-
const q = url.searchParams.get('q');
|
|
2215
|
-
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
2216
|
-
const scopeParam = url.searchParams.get('scope') || 'all';
|
|
2217
|
-
const scope = scopeParam === 'all'
|
|
2218
|
-
? ['sessions', 'skills', 'trajectories', 'memories']
|
|
2219
|
-
: scopeParam.split(',').map((x) => x.trim()).filter(Boolean);
|
|
2220
|
-
const kParam = url.searchParams.get('k');
|
|
2221
|
-
const k = kParam ? Math.max(1, Math.min(50, parseInt(kParam, 10) || 10)) : 10;
|
|
2222
|
-
try {
|
|
2223
|
-
const r = indexDb.recall(q, { configDir: gwConfigDir, scope, k });
|
|
2224
|
-
return writeJson(res, 200, r);
|
|
2225
|
-
} catch (e) {
|
|
2226
|
-
return writeJson(res, 500, { error: e?.message || String(e) });
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
case route === 'GET /sandbox': {
|
|
2230
|
-
const cfg = ctx.readConfig();
|
|
2231
|
-
const sb = (cfg.sandbox && typeof cfg.sandbox === 'object') ? cfg.sandbox : {};
|
|
2232
|
-
const active = sb.default || 'local';
|
|
2233
|
-
const profiles = sandboxListBackends().map((name) => {
|
|
2234
|
-
const section = sb[name];
|
|
2235
|
-
const configured = !!section && typeof section === 'object';
|
|
2236
|
-
let summary = '';
|
|
2237
|
-
if (configured) {
|
|
2238
|
-
if (name === 'docker' && section.image) summary = `image: ${section.image}`;
|
|
2239
|
-
else if (name === 'ssh' && section.host) summary = `host: ${section.host}`;
|
|
2240
|
-
else if (name === 'singularity' && section.image) summary = `image: ${section.image}`;
|
|
2241
|
-
else if (name === 'modal' && section.app) summary = `app: ${section.app}`;
|
|
2242
|
-
else if (name === 'daytona' && section.workspace) summary = `workspace: ${section.workspace}`;
|
|
2243
|
-
else if (name === 'local' && section.confiner) summary = `confiner: ${section.confiner}`;
|
|
2244
|
-
}
|
|
2245
|
-
return { name, configured, summary };
|
|
2246
|
-
});
|
|
2247
|
-
return writeJson(res, 200, { profiles, active });
|
|
2248
|
-
}
|
|
2249
|
-
case req.method === 'POST' && /^\/sandbox\/([^/]+)\/test$/.test(url.pathname): {
|
|
2250
|
-
// POST /sandbox/<name>/test — opens a session against the named
|
|
2251
|
-
// backend, runs `echo hello`, returns { ok, durationMs, stdout }.
|
|
2252
|
-
const name = url.pathname.match(/^\/sandbox\/([^/]+)\/test$/)[1];
|
|
2253
|
-
try {
|
|
2254
|
-
const sandboxMod = await import('./sandbox/index.mjs');
|
|
2255
|
-
const cfg = ctx.readConfig();
|
|
2256
|
-
// Synthesise a one-off cfg.sandbox.default override so we can
|
|
2257
|
-
// test a backend without mutating the user's persisted choice.
|
|
2258
|
-
const probeCfg = {
|
|
2259
|
-
...cfg,
|
|
2260
|
-
sandbox: { ...(cfg.sandbox || {}), default: name },
|
|
2261
|
-
};
|
|
2262
|
-
const t0 = Date.now();
|
|
2263
|
-
const box = sandboxMod.resolveSandbox(probeCfg, null);
|
|
2264
|
-
const sess = await box.open();
|
|
2265
|
-
let result;
|
|
2266
|
-
try {
|
|
2267
|
-
result = await sess.exec(['echo', 'hello'], { stdio: 'pipe' });
|
|
2268
|
-
} finally {
|
|
2269
|
-
try { await sess.close(); } catch { /* ignore */ }
|
|
2270
|
-
}
|
|
2271
|
-
const durationMs = Date.now() - t0;
|
|
2272
|
-
const ok = result.code === 0;
|
|
2273
|
-
return writeJson(res, ok ? 200 : 500, {
|
|
2274
|
-
ok,
|
|
2275
|
-
durationMs,
|
|
2276
|
-
code: result.code,
|
|
2277
|
-
stdout: String(result.stdout || '').slice(0, 200),
|
|
2278
|
-
stderr: String(result.stderr || '').slice(0, 200),
|
|
2279
|
-
});
|
|
2280
|
-
} catch (e) {
|
|
2281
|
-
return writeJson(res, 500, { ok: false, error: e?.message || String(e), code: e?.code });
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
case route === 'POST /sandbox/use': {
|
|
2285
|
-
if (typeof ctx.writeConfig !== 'function') {
|
|
2286
|
-
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
2287
|
-
}
|
|
2288
|
-
let body;
|
|
2289
|
-
try { body = await readJson(req); }
|
|
2290
|
-
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
2291
|
-
const name = body && String(body.name || '').trim();
|
|
2292
|
-
if (!name) return writeJson(res, 400, { error: 'name is required' });
|
|
2293
|
-
if (!sandboxListBackends().includes(name)) {
|
|
2294
|
-
return writeJson(res, 400, { error: `unknown sandbox backend: ${name}` });
|
|
2295
|
-
}
|
|
2296
|
-
const cfg = ctx.readConfig();
|
|
2297
|
-
cfg.sandbox = { ...(cfg.sandbox || {}), default: name };
|
|
2298
|
-
ctx.writeConfig(cfg);
|
|
2299
|
-
return writeJson(res, 200, { ok: true, active: name });
|
|
2300
|
-
}
|
|
2301
|
-
case route === 'GET /channels': {
|
|
2302
|
-
// Aggregate cfg.channels.<name> + any channel-specific runtime
|
|
2303
|
-
// state we expose. Keeps the dashboard from having to know
|
|
2304
|
-
// each channel module's shape.
|
|
2305
|
-
const cfg = ctx.readConfig();
|
|
2306
|
-
const chCfg = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
2307
|
-
// Known built-in channel names (matches channels/ + channels-*).
|
|
2308
|
-
const KNOWN = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
|
|
2309
|
-
const out = [];
|
|
2310
|
-
for (const name of KNOWN) {
|
|
2311
|
-
const sec = chCfg[name];
|
|
2312
|
-
if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
|
|
2313
|
-
out.push({
|
|
2314
|
-
name,
|
|
2315
|
-
enabled: !!(sec && (sec.enabled !== false)),
|
|
2316
|
-
lastInboundAt: sec?.lastInboundAt || null,
|
|
2317
|
-
boundAgent: sec?.agent || sec?.boundAgent || null,
|
|
2318
|
-
});
|
|
2319
|
-
}
|
|
2320
|
-
// Surface any additional configured channels we didn't enumerate.
|
|
2321
|
-
for (const name of Object.keys(chCfg)) {
|
|
2322
|
-
if (KNOWN.includes(name)) continue;
|
|
2323
|
-
const sec = chCfg[name] || {};
|
|
2324
|
-
out.push({
|
|
2325
|
-
name,
|
|
2326
|
-
enabled: sec.enabled !== false,
|
|
2327
|
-
lastInboundAt: sec.lastInboundAt || null,
|
|
2328
|
-
boundAgent: sec.agent || sec.boundAgent || null,
|
|
2329
|
-
});
|
|
2330
|
-
}
|
|
2331
|
-
return writeJson(res, 200, { channels: out });
|
|
2332
|
-
}
|
|
2333
|
-
case route === 'POST /index/rebuild': {
|
|
2334
|
-
try {
|
|
2335
|
-
indexDb.rebuild(gwConfigDir);
|
|
2336
|
-
ctx.indexLastRebuiltAt = new Date().toISOString();
|
|
2337
|
-
return writeJson(res, 200, { ok: true, rebuiltAt: ctx.indexLastRebuiltAt });
|
|
2338
|
-
} catch (e) {
|
|
2339
|
-
return writeJson(res, 500, { ok: false, error: e?.message || String(e) });
|
|
2340
|
-
}
|
|
2341
|
-
}
|
|
2342
|
-
|
|
2343
|
-
default:
|
|
2344
|
-
return writeJson(res, 404, { error: 'not found', route });
|
|
2345
|
-
} /* eslint-disable-line no-fallthrough */
|
|
268
|
+
// Per-request dispatch context. Carries the handler-scoped state
|
|
269
|
+
// (ctx/logger/metrics/gateway/...) plus the per-request locals and
|
|
270
|
+
// pre-computed path-param matches so each route module can run with
|
|
271
|
+
// its original closure variables intact.
|
|
272
|
+
const c = {
|
|
273
|
+
ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir,
|
|
274
|
+
nudgeSuggestionsRing, workflowStateDir,
|
|
275
|
+
req, res, method, path, route, url,
|
|
276
|
+
sessionMatch, providerMatch, providerTestMatch, sessionExportMatch,
|
|
277
|
+
skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch,
|
|
278
|
+
};
|
|
279
|
+
// First-match dispatch — ROUTES order mirrors the old switch.
|
|
280
|
+
for (const r of ROUTES) {
|
|
281
|
+
if (r.m(c)) return await r.h(c);
|
|
282
|
+
}
|
|
283
|
+
return writeJson(res, 404, { error: 'not found', route });
|
|
2346
284
|
} catch (err) {
|
|
2347
285
|
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
2348
286
|
}
|