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/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
- return writeJson(res, 200, items.map(s => ({
1305
- name: s.name, bytes: s.bytes, summary: s.summary,
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
- const name = skillMatch[1];
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
- const name = skillMatch[1];
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
- const name = skillMatch[1];
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
- ctx.sessionsMod.clearSession(id, ctx.sessionsDirGetter());
1434
- return writeJson(res, 200, { ok: true, id });
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 */