lazyclaw 5.0.9 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -8
- package/agents.mjs +10 -8
- package/chat_window.mjs +39 -0
- package/cli.mjs +281 -56
- package/daemon.mjs +464 -11
- package/mas/agent_turn.mjs +81 -6
- package/mas/index_db.mjs +62 -2
- package/mas/learning.mjs +371 -0
- package/mas/mention_router.mjs +94 -13
- package/mas/orchestra.mjs +56 -0
- package/mas/skill_synth.mjs +36 -2
- package/mas/tool_runner.mjs +8 -1
- package/mas/tools/git.mjs +18 -1
- package/mas/tools/recall.mjs +64 -0
- package/package.json +2 -1
- package/providers/anthropic.mjs +26 -10
- package/providers/orchestrator.mjs +99 -21
- package/providers/registry.mjs +6 -0
- package/providers/tool_use/anthropic.mjs +43 -7
- package/skills.mjs +42 -3
- package/tasks.mjs +24 -1
- package/tui/repl.mjs +6 -0
- package/tui/run_turn.mjs +138 -0
- package/tui/splash.mjs +229 -32
- package/web/dashboard.html +383 -24
package/daemon.mjs
CHANGED
|
@@ -20,7 +20,10 @@ import { withRateLimitRetry } from './providers/retry.mjs';
|
|
|
20
20
|
import { withFallback } from './providers/fallback.mjs';
|
|
21
21
|
import { withResponseCache } from './providers/cache.mjs';
|
|
22
22
|
import { costFromUsage, RATE_CARD_SHAPE } from './providers/rates.mjs';
|
|
23
|
-
import { composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill } from './skills.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';
|
|
24
27
|
import { ChallengeRegistry } from './gateway/device_auth.mjs';
|
|
25
28
|
import { createGateway } from './gateway/http_gateway.mjs';
|
|
26
29
|
import { TokenBucketLimiter } from './ratelimit.mjs';
|
|
@@ -331,11 +334,58 @@ export function makeHandler(ctx) {
|
|
|
331
334
|
const gateway = createGateway({ configDir: gwConfigDir, challengeRegistry: new ChallengeRegistry(), heartbeatMs: 25000 });
|
|
332
335
|
// Phase B nudge loop — scans recent.jsonl every 5 min and pushes
|
|
333
336
|
// nudge.suggest_skill onto the SSE bus so the curator can prompt.
|
|
337
|
+
// v5 dashboard polls GET /skills/suggestions; we also keep a small
|
|
338
|
+
// ring buffer here so the UI can fetch the most recent N without
|
|
339
|
+
// having to subscribe to the SSE bus.
|
|
340
|
+
const SUGG_RING_MAX = 50;
|
|
341
|
+
const nudgeSuggestionsRing = [];
|
|
342
|
+
// v5 Group A (M2): when the operator opts in via
|
|
343
|
+
// cfg.orchestra.learning.autoSynthOnNudge, every emitted cluster
|
|
344
|
+
// also triggers runLearning('nudge', { cluster }) so the canonical
|
|
345
|
+
// funnel can distill the repeated prompt into a SKILL.md. The SSE
|
|
346
|
+
// broadcast and ring buffer still fire unconditionally so the
|
|
347
|
+
// dashboard sees suggestions even when auto-synth is disabled
|
|
348
|
+
// (the conservative default — we never want a noisy chat to bloat
|
|
349
|
+
// the skills/ dir without the operator's blessing).
|
|
350
|
+
let _learningHub = null;
|
|
351
|
+
const _readAutoSynthOnNudge = () => {
|
|
352
|
+
try {
|
|
353
|
+
const cfg = typeof ctx.readConfig === 'function' ? ctx.readConfig() : {};
|
|
354
|
+
const orch = cfg?.orchestra || cfg?.orchestrator || {};
|
|
355
|
+
return !!(orch.learning && orch.learning.autoSynthOnNudge);
|
|
356
|
+
} catch { return false; }
|
|
357
|
+
};
|
|
334
358
|
const _nudgeLoop = nudge.startNudgeLoop({
|
|
335
359
|
configDir: gwConfigDir,
|
|
336
360
|
emit: (event) => {
|
|
337
361
|
try { gateway.broadcast?.('nudge.suggest_skill', event); }
|
|
338
362
|
catch (err) { logger?.warn?.('nudge_emit_failed', { err: err.message }); }
|
|
363
|
+
try {
|
|
364
|
+
nudgeSuggestionsRing.unshift(event);
|
|
365
|
+
if (nudgeSuggestionsRing.length > SUGG_RING_MAX) {
|
|
366
|
+
nudgeSuggestionsRing.length = SUGG_RING_MAX;
|
|
367
|
+
}
|
|
368
|
+
} catch { /* ignore */ }
|
|
369
|
+
if (_readAutoSynthOnNudge()) {
|
|
370
|
+
(async () => {
|
|
371
|
+
try {
|
|
372
|
+
if (!_learningHub) _learningHub = await import('./mas/learning.mjs');
|
|
373
|
+
const cfg = typeof ctx.readConfig === 'function' ? ctx.readConfig() : {};
|
|
374
|
+
// Wrap the single cluster in the items[] shape runLearning('nudge')
|
|
375
|
+
// expects so the representative task is the cluster's sample
|
|
376
|
+
// prompt.
|
|
377
|
+
const itemTask = { id: `nudge-${event.ts || Date.now()}`, title: 'nudge cluster', turns: [{ agent: 'user', text: event.cluster?.sample || '' }] };
|
|
378
|
+
await _learningHub.runLearning('nudge', {
|
|
379
|
+
cluster: { items: [itemTask] },
|
|
380
|
+
configDir: gwConfigDir,
|
|
381
|
+
cfg,
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
try { logger?.warn?.('nudge_learning_failed', { err: err.message }); }
|
|
385
|
+
catch { /* ignore */ }
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
}
|
|
339
389
|
},
|
|
340
390
|
logger,
|
|
341
391
|
});
|
|
@@ -496,11 +546,13 @@ export function makeHandler(ctx) {
|
|
|
496
546
|
return writeJson(res, 200, result);
|
|
497
547
|
}
|
|
498
548
|
case route === 'GET /health':
|
|
549
|
+
case route === 'GET /healthz':
|
|
499
550
|
// Conventional liveness check — always 200 if the process
|
|
500
551
|
// is alive enough to hit the route. No config inspection
|
|
501
552
|
// (use /doctor for readiness), no provider probing — this
|
|
502
553
|
// is the "is the daemon up?" probe that load balancers
|
|
503
|
-
// and watchdog scripts expect at this path.
|
|
554
|
+
// and watchdog scripts expect at this path. m15: K8s
|
|
555
|
+
// readiness probes default to /healthz so we alias.
|
|
504
556
|
return writeJson(res, 200, {
|
|
505
557
|
ok: true,
|
|
506
558
|
status: 'alive',
|
|
@@ -843,10 +895,38 @@ export function makeHandler(ctx) {
|
|
|
843
895
|
}
|
|
844
896
|
case route === 'GET /status': {
|
|
845
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 */ }
|
|
846
917
|
return writeJson(res, 200, {
|
|
847
918
|
provider: cfg.provider || null,
|
|
848
919
|
model: cfg.model || null,
|
|
849
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
|
+
},
|
|
850
930
|
});
|
|
851
931
|
}
|
|
852
932
|
case route === 'GET /config/validate': {
|
|
@@ -945,6 +1025,28 @@ export function makeHandler(ctx) {
|
|
|
945
1025
|
if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
|
|
946
1026
|
issues.push(`unknown provider "${cfg.provider}"`);
|
|
947
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
|
+
}
|
|
948
1050
|
const ok = issues.length === 0;
|
|
949
1051
|
return writeJson(res, ok ? 200 : 503, {
|
|
950
1052
|
ok,
|
|
@@ -955,6 +1057,7 @@ export function makeHandler(ctx) {
|
|
|
955
1057
|
platform: `${process.platform}-${process.arch}`,
|
|
956
1058
|
issues,
|
|
957
1059
|
knownProviders: Object.keys(PROVIDERS),
|
|
1060
|
+
index: indexBlock,
|
|
958
1061
|
timestamp: new Date().toISOString(),
|
|
959
1062
|
});
|
|
960
1063
|
}
|
|
@@ -984,12 +1087,42 @@ export function makeHandler(ctx) {
|
|
|
984
1087
|
return writeJson(res, 400, { error: `invalid sortBy: ${sortBy} (expected: mtime, turn-count, bytes, id)` });
|
|
985
1088
|
}
|
|
986
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;
|
|
987
1096
|
let out = list.map(s => {
|
|
988
1097
|
const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
|
|
989
1098
|
if (withCount) {
|
|
990
1099
|
try { base.turnCount = ctx.sessionsMod.loadTurns(s.id, cfgDir).length; }
|
|
991
1100
|
catch { base.turnCount = null; }
|
|
992
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
|
+
}
|
|
993
1126
|
return base;
|
|
994
1127
|
});
|
|
995
1128
|
if (sortBy) {
|
|
@@ -1301,9 +1434,99 @@ export function makeHandler(ctx) {
|
|
|
1301
1434
|
const n = parseInt(limitStr, 10);
|
|
1302
1435
|
if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
|
|
1303
1436
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
+
}
|
|
1307
1530
|
}
|
|
1308
1531
|
case route === 'GET /skills/search': {
|
|
1309
1532
|
// Mirror of `lazyclaw skills search`. ?q=<query> required;
|
|
@@ -1365,7 +1588,10 @@ export function makeHandler(ctx) {
|
|
|
1365
1588
|
// 404 when the file is missing so the caller can branch.
|
|
1366
1589
|
// 400 when the name fails skillPath validation (path traversal,
|
|
1367
1590
|
// dotfile, etc.) — same protections as the CLI.
|
|
1368
|
-
|
|
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' }); }
|
|
1369
1595
|
try {
|
|
1370
1596
|
const cfgDir = ctx.sessionsDirGetter();
|
|
1371
1597
|
const file = skillPath(name, cfgDir);
|
|
@@ -1385,7 +1611,13 @@ export function makeHandler(ctx) {
|
|
|
1385
1611
|
// 201 on first write, 200 on overwrite (caller can branch on
|
|
1386
1612
|
// the status if they care about idempotency vs newness).
|
|
1387
1613
|
// 400 on invalid name (skillPath validation) or oversize body.
|
|
1388
|
-
|
|
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' }); }
|
|
1389
1621
|
const cfgDir = ctx.sessionsDirGetter();
|
|
1390
1622
|
let priorExists = false;
|
|
1391
1623
|
try {
|
|
@@ -1413,7 +1645,10 @@ export function makeHandler(ctx) {
|
|
|
1413
1645
|
// existed or not, mirroring DELETE /sessions/<id>. The body
|
|
1414
1646
|
// reports `removed: true|false` so callers can branch when
|
|
1415
1647
|
// they care.
|
|
1416
|
-
|
|
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' }); }
|
|
1417
1652
|
const cfgDir = ctx.sessionsDirGetter();
|
|
1418
1653
|
try {
|
|
1419
1654
|
const file = skillPath(name, cfgDir);
|
|
@@ -1427,11 +1662,22 @@ export function makeHandler(ctx) {
|
|
|
1427
1662
|
case req.method === 'DELETE' && !!sessionMatch: {
|
|
1428
1663
|
// DELETE /sessions/<id> — idempotent. 200 on both "deleted" and
|
|
1429
1664
|
// "didn't exist" so callers can use it as a reset without checking
|
|
1430
|
-
// first.
|
|
1665
|
+
// first. m16: include `removed: <bool>` for shape parity with
|
|
1666
|
+
// sibling DELETEs (/skills, /workflows).
|
|
1431
1667
|
const id = sessionMatch[1];
|
|
1432
1668
|
try {
|
|
1433
|
-
|
|
1434
|
-
|
|
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 });
|
|
1435
1681
|
} catch (err) {
|
|
1436
1682
|
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1437
1683
|
}
|
|
@@ -1764,14 +2010,29 @@ export function makeHandler(ctx) {
|
|
|
1764
2010
|
}
|
|
1765
2011
|
}
|
|
1766
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.
|
|
1767
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
|
+
}
|
|
1768
2022
|
const memMod = await import('./mas/agent_memory.mjs');
|
|
1769
2023
|
const text = memMod.readMemory(name);
|
|
1770
2024
|
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-cache' });
|
|
1771
2025
|
return res.end(text);
|
|
1772
2026
|
}
|
|
1773
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.
|
|
1774
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
|
+
}
|
|
1775
2036
|
const memMod = await import('./mas/agent_memory.mjs');
|
|
1776
2037
|
// Read raw text body — content-type defaults to text/markdown
|
|
1777
2038
|
// but JSON {"text": "..."} is also accepted for tooling that
|
|
@@ -1794,6 +2055,10 @@ export function makeHandler(ctx) {
|
|
|
1794
2055
|
}
|
|
1795
2056
|
case req.method === 'DELETE' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
|
|
1796
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
|
+
}
|
|
1797
2062
|
const memMod = await import('./mas/agent_memory.mjs');
|
|
1798
2063
|
const removed = memMod.clear(name);
|
|
1799
2064
|
return writeJson(res, 200, { name, cleared: removed });
|
|
@@ -1887,6 +2152,194 @@ export function makeHandler(ctx) {
|
|
|
1887
2152
|
}
|
|
1888
2153
|
}
|
|
1889
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
|
+
|
|
1890
2343
|
default:
|
|
1891
2344
|
return writeJson(res, 404, { error: 'not found', route });
|
|
1892
2345
|
} /* eslint-disable-line no-fallthrough */
|