lazyclaw 5.0.8 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,14 +29,20 @@ Four things no other terminal agent CLI does together:
29
29
  3. **Cross-channel handoff.** `/handoff slack <channel-id>` moves the live conversation; context follows.
30
30
  4. **Six sandbox backends, one API.** `local` / `docker` / `ssh` / `singularity` / `modal` / `daytona`.
31
31
 
32
+ ## What's new in 5.0.x
33
+
34
+ The 5.0.3 → 5.0.9 maintenance line is mostly a launcher and splash overhaul. `lazyclaw` with no arguments now drops you straight into chat — the arrow-key picker has moved to `lazyclaw menu`. The splash itself is responsive: a Larry-3D gradient wordmark, braille-rendered sloth hero, a tool catalog driven by `mas/tools/registry.mjs` (no more hand-edited lists), grouped skills, and a Hermes-style status bar that auto-collapses to a single column on narrow terminals.
35
+
36
+ Per-release notes live in [CHANGELOG.md](./CHANGELOG.md).
37
+
32
38
  ## Known limitations (v5.1 roadmap)
33
39
 
34
40
  Calibrate expectations before reading the rest:
35
41
 
36
- - `recall` is callable from inside chat today; the top-level `lazyclaw recall ...` CLI shape ships in v5.1.
42
+ - Recall today is reachable via `lazyclaw loop --recall "<query>" ...` and `lazyclaw goal ... --recall "<query>"`; the `/recall` slash command and top-level `lazyclaw recall ...` CLI shape both ship in v5.1.
37
43
  - `lazyclaw sandbox` exposes `list | test | add | use`; the `sandbox run --backend ...` shape lands in v5.1.
38
44
  - `codex-cli` and `gemini-cli` provider modules are tracked but not yet registered in the main runtime.
39
- - E2E matrix ships with 32 of 48 flows marked `test.skip` pending v5.1 wiring; the min-green-set is documented in `tests/e2e/phaseH-e2e-matrix.spec.ts`.
45
+ - The E2E matrix in `tests/e2e/phaseH-e2e-matrix.spec.ts` still has a number of flows marked `test.skip` pending v5.1 wiring; the min-green-set is documented at the top of that file.
40
46
 
41
47
  ## Install
42
48
 
@@ -54,9 +60,10 @@ npm install && npm link
54
60
 
55
61
  ```bash
56
62
  lazyclaw version
57
- # → { "version": "5.0.0", "nodeVersion": "v20.11.0", "platform": "darwin" }
58
63
  ```
59
64
 
65
+ Output reports the current installed version, Node version, and platform. See [CHANGELOG.md](./CHANGELOG.md) for release notes.
66
+
60
67
  Requires **Node 18+**. macOS / Linux / WSL are first-class. Native PowerShell runs but ghost-text and the ANSI banner fall back to plain prompts.
61
68
 
62
69
  ## First run
@@ -69,6 +76,8 @@ lazyclaw onboard # arrow-key picker; defaults to claude-cli (no key)
69
76
  <img src="docs/screenshots/onboard.png" alt="lazyclaw onboard --non-interactive" width="720">
70
77
 
71
78
  ```bash
79
+ lazyclaw # no-arg → chat (since v5.0.6)
80
+ lazyclaw menu # arrow-key launcher (formerly the no-arg default)
72
81
  lazyclaw status # → { provider, model, hasApiKey }
73
82
  lazyclaw doctor # validates config + provider registry + index.db
74
83
  ```
@@ -124,10 +133,16 @@ Plus two meta-providers, usable in either slot:
124
133
 
125
134
  ## What it ships
126
135
 
127
- ### Around 50 tools plus MCP
128
- A unified registry covers fs, exec, web, os, coding, git (5 read + 2 sensitive), scheduling, delegation, media, ha, clarify, browser, and learning groups. Sensitive tools route through an approval hook. Bring external servers in over stdio MCP.
136
+ ### Tool registry 12 categories plus MCP
137
+
138
+ A single registry (`mas/tools/registry.mjs`) aggregates every first-party tool across twelve categories:
139
+
140
+ `agents` · `browser` · `coding` · `exec` · `fs` · `git` · `iot` · `learning` · `media` · `net` · `os` · `scheduling`
141
+
142
+ Tools flagged `sensitive: true` (writes, network egress, shell exec, sensitive git ops) route through an approval hook before execution. The splash renderer, the agent toolset resolver, and the runtime all read from the same registry, so a new tool group lights up everywhere at once. External servers extend the registry over stdio MCP.
129
143
 
130
144
  ### Channels that hand off
145
+
131
146
  First-class channels for Slack, Discord, Telegram, Matrix, Email, and Voice. Signal and WhatsApp ship as full implementations with external runtime dependencies (`signal-cli` for Signal; `whatsapp-web.js` browser automation with QR-on-first-run for WhatsApp). Move a live conversation between any two with `/handoff <target> <externalId>` — no other agent CLI does cross-channel handoff.
132
147
 
133
148
  ```bash
@@ -139,19 +154,23 @@ lazyclaw channels install @lazyclaw/channel-discord
139
154
  Slack is built-in and does not need `channels install`. The plugin loader expects `@lazyclaw/channel-<name>` npm package names.
140
155
 
141
156
  ### Personas that compose
157
+
142
158
  Swap personality per channel without losing session memory. Layers compose top-down: global SOUL → workspace SOUL → active personality → agent role → user model (USER.md) → skill bank → memory core → recent trajectory tail. See [docs/persona-cookbook.md](./docs/persona-cookbook.md).
143
159
 
144
160
  ### Loops and scheduled goals
161
+
145
162
  Durable foreground or `--detach` loops; cron-scheduled goals with channel fan-out. State lives in `~/.lazyclaw/loops/<id>/` and survives restart. See [docs/loop-goal-preflight.md](./docs/loop-goal-preflight.md).
146
163
 
147
164
  ### A TUI that ghosts the right answer
148
- Ink-based UI with two-column splash, sloth ASCII banner, Cursor-style ghost autocomplete (`→` accepts, `Tab` cycles), interrupt-and-redirect REPL, multiline editor, and a fixed 4-line footer with live cost rate cards.
165
+
166
+ Ink-based UI with a responsive splash (Larry-3D gradient wordmark, sloth braille hero, grouped subcommand catalog, registry-backed tool list, filename-grouped skills, Hermes-style status bar — auto-collapses to single-column on narrow terminals), Cursor-style ghost autocomplete (`→` accepts, `Tab` cycles), interrupt-and-redirect REPL, and multiline editor.
149
167
 
150
168
  ## Command reference
151
169
 
152
170
  | Command | Purpose |
153
171
  |---|---|
154
- | `lazyclaw` | Interactive menu; type a slash command or pick a subcommand |
172
+ | `lazyclaw` | No-arg drops into chat (since v5.0.6) |
173
+ | `lazyclaw menu` | Arrow-key launcher (formerly the no-arg default) |
155
174
  | `lazyclaw onboard` | Arrow-key setup; writes `~/.lazyclaw/config.json` |
156
175
  | `lazyclaw status` | Print active provider / model / masked key |
157
176
  | `lazyclaw doctor` | Validate config, provider registry, FTS5 index |
@@ -163,7 +182,9 @@ Ink-based UI with two-column splash, sloth ASCII banner, Cursor-style ghost auto
163
182
  | `lazyclaw channels install\|list\|remove` | Channel plugin lifecycle (`@lazyclaw/channel-<name>`) |
164
183
  | `lazyclaw trajectories export --format ...` | Export to atropos / axolotl / openai-ft / jsonl |
165
184
  | `lazyclaw personality use\|list\|show` | Activate / inspect personas |
185
+ | `lazyclaw dashboard` | Local web UI on `127.0.0.1:19600` |
166
186
  | `lazyclaw migrate v5` | v4 → v5 with backup |
187
+ | `lazyclaw recall` | (v5.1) Top-level recall over the FTS5 index — today, use `loop`/`goal` with `--recall "<query>"` |
167
188
  | `lazyclaw version` / `lazyclaw help` | Version + subcommand help |
168
189
 
169
190
  In-REPL slash commands include `/help`, `/status`, `/provider`, `/model`, `/skill`, `/loop`, `/goal`, `/memory`, `/agent`, `/team`, `/handoff`, `/personality`, `/exit`. Depth lives in `lazyclaw <cmd> --help` and the docs below.
@@ -189,7 +210,7 @@ Full guide: [docs/migration-v4-to-v5.md](./docs/migration-v4-to-v5.md).
189
210
  |---|---|---|
190
211
  | Start a chat | `lazyclaw chat` | `@lazyclaw <message>` |
191
212
  | Hand off to another surface | `/handoff slack <channel-id>` | `/handoff tui <thread-id>` |
192
- | Recall across history | `/recall "<query>"` inside chat (top-level CLI v5.1) | `/recall "<query>"` inside chat |
213
+ | Recall across history | `lazyclaw loop --recall "<query>" ...` (slash + top-level v5.1) | `lazyclaw loop --recall "<query>" ...` (channel-side `/recall` v5.1) |
193
214
  | Switch persona | `/personality use terse` | `/personality use terse` |
194
215
  | Switch model | `/model <name>` | `/model <name>` |
195
216
  | Show status | `lazyclaw status` | `@lazyclaw status` |
package/cli.mjs CHANGED
@@ -1299,7 +1299,7 @@ const HELP_DETAILS = {
1299
1299
  browse: 'Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--user-agent <ua>] [--meta]\n Fetches the URL and emits Markdown on stdout. Pipes cleanly into `agent`:\n lazyclaw browse https://example.com/docs | lazyclaw agent -\n Strips <script>/<style>/<svg>/comments, prefers <main>/<article>, falls back to <body>.\n --max-bytes caps the body read (default 2 MB) so a misconfigured upstream can\'t OOM the process.\n --meta prints { url, title, bytes, truncated } as JSON to stderr alongside the markdown on stdout.',
1300
1300
  cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
1301
1301
  setup: 'Usage: lazyclaw setup [--skip-test]\n OpenClaw-style multi-step first-run wizard. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick)\n 2. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 3. Optional skill bundle install from GitHub\n 4. Optional outbound webhook (Slack / Discord)\n 5. Reachability test against the picked provider\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
1302
- dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n Tabs: Chat · Sessions · Skills · Workspace · Providers · Status. Each tab calls existing daemon endpoints.\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
1302
+ dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n See web/dashboard.html for the current tab set (v5: Chat / Sessions / Workflows / Skills / Providers / Rates / Metrics / Doctor / Config / Status / Agents / Teams / Tasks / Trainer / Recall / Sandbox / Channels).\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
1303
1303
  orchestrator: 'Usage: lazyclaw orchestrator <status | set-planner <provider[:model]> | workers add <spec> | workers remove <spec> | workers set <spec,spec,...> | workers clear | set-max-subtasks <N> | clear>\n Read/write cfg.orchestrator without editing config.json by hand.\n status — print {planner, workers, maxSubtasks} as JSON; lists registered providers for reference.\n set-planner — replace the planner spec ("provider" or "provider:model"). "orchestrator" itself is rejected (self-recursion).\n workers add — append a worker (idempotent — duplicates skipped).\n workers remove — drop a worker by exact match. Idempotent.\n workers set — replace the whole list (comma-separated specs).\n workers clear — empty the workers list.\n set-max-subtasks <N> — cap subtasks per request, clamped 1..10 (default 5).\n clear — delete the cfg.orchestrator block entirely.\n Pair with: `lazyclaw config set provider orchestrator` to route chats through it.',
1304
1304
  };
1305
1305
 
@@ -1658,7 +1658,17 @@ async function _renderV5Banner(version) {
1658
1658
  if (!a) return _renderBanner(version); // missing tarball asset → v4 figlet
1659
1659
  const v = String(version || '?.?.?');
1660
1660
  const rows = [];
1661
- for (const r of a.wordmark.rows) rows.push(_orange(' ' + r));
1661
+ const palette = a.wordmark.palette || [];
1662
+ const gradient = a.wordmark.gradient || [];
1663
+ function tint(idx, s) {
1664
+ const hex = palette[idx] || '#FFB347';
1665
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
1666
+ if (!m) return _orange(s);
1667
+ const [, r, g, b] = m;
1668
+ const R = parseInt(r, 16), G = parseInt(g, 16), B = parseInt(b, 16);
1669
+ return `\x1b[38;2;${R};${G};${B}m${s}\x1b[0m`;
1670
+ }
1671
+ a.wordmark.rows.forEach((r, i) => rows.push(tint(gradient[i] ?? 1, ' ' + r)));
1662
1672
  rows.push('');
1663
1673
  for (const r of a.banner.rows) rows.push(_orange(' ' + r));
1664
1674
  rows.push(_orange(' ' + `lazyclaw v${v}`));
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,22 @@ 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 = [];
334
342
  const _nudgeLoop = nudge.startNudgeLoop({
335
343
  configDir: gwConfigDir,
336
344
  emit: (event) => {
337
345
  try { gateway.broadcast?.('nudge.suggest_skill', event); }
338
346
  catch (err) { logger?.warn?.('nudge_emit_failed', { err: err.message }); }
347
+ try {
348
+ nudgeSuggestionsRing.unshift(event);
349
+ if (nudgeSuggestionsRing.length > SUGG_RING_MAX) {
350
+ nudgeSuggestionsRing.length = SUGG_RING_MAX;
351
+ }
352
+ } catch { /* ignore */ }
339
353
  },
340
354
  logger,
341
355
  });
@@ -843,10 +857,38 @@ export function makeHandler(ctx) {
843
857
  }
844
858
  case route === 'GET /status': {
845
859
  const cfg = ctx.readConfig();
860
+ // v5: surface a one-line summary of trainer / index / sandbox so
861
+ // the dashboard banner doesn't need three more round-trips.
862
+ const trainerCfg = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
863
+ const sandboxBackend = (cfg.sandbox && typeof cfg.sandbox === 'object' && cfg.sandbox.default) || 'local';
864
+ let indexRows = null;
865
+ try {
866
+ const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
867
+ const r = db.prepare(
868
+ "SELECT (SELECT COUNT(*) FROM fts_sessions) + (SELECT COUNT(*) FROM fts_skills) " +
869
+ " + (SELECT COUNT(*) FROM fts_trajectories) + (SELECT COUNT(*) FROM fts_memories) AS n"
870
+ ).get();
871
+ indexRows = r && typeof r.n === 'number' ? r.n : null;
872
+ } catch { /* index may not exist yet */ }
873
+ // Migration backup path is conventionally <configDir>/backup-v4/.
874
+ let migrateBackup = null;
875
+ try {
876
+ const p = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'backup-v4');
877
+ if (fs.existsSync(p)) migrateBackup = p;
878
+ } catch { /* ignore */ }
846
879
  return writeJson(res, 200, {
847
880
  provider: cfg.provider || null,
848
881
  model: cfg.model || null,
849
882
  keyMasked: maskApiKey(cfg['api-key']),
883
+ v5: {
884
+ trainer: {
885
+ provider: trainerCfg.provider || null,
886
+ model: trainerCfg.model || null,
887
+ },
888
+ sandboxBackend,
889
+ indexRows,
890
+ migrateBackup,
891
+ },
850
892
  });
851
893
  }
852
894
  case route === 'GET /config/validate': {
@@ -945,6 +987,28 @@ export function makeHandler(ctx) {
945
987
  if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
946
988
  issues.push(`unknown provider "${cfg.provider}"`);
947
989
  }
990
+ // v5: FTS5 index integrity. Failure here is degraded-not-fatal —
991
+ // surfaced as an issue but doesn't take the daemon down.
992
+ let indexBlock = null;
993
+ try {
994
+ const integ = indexDb.integrityCheck(gwConfigDir);
995
+ const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
996
+ const rowCounts = {};
997
+ for (const t of ['fts_sessions', 'fts_skills', 'fts_trajectories', 'fts_memories']) {
998
+ try { rowCounts[t] = db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n; }
999
+ catch { rowCounts[t] = null; }
1000
+ }
1001
+ indexBlock = {
1002
+ ok: !!integ.ok,
1003
+ result: integ.result || null,
1004
+ rowCounts,
1005
+ lastRebuiltAt: ctx.indexLastRebuiltAt || null,
1006
+ };
1007
+ if (!integ.ok) issues.push(`FTS5 index integrity_check returned ${integ.result}`);
1008
+ } catch (e) {
1009
+ indexBlock = { ok: false, error: e?.message || String(e), rowCounts: {}, lastRebuiltAt: null };
1010
+ issues.push(`FTS5 index unavailable: ${e?.message || e}`);
1011
+ }
948
1012
  const ok = issues.length === 0;
949
1013
  return writeJson(res, ok ? 200 : 503, {
950
1014
  ok,
@@ -955,6 +1019,7 @@ export function makeHandler(ctx) {
955
1019
  platform: `${process.platform}-${process.arch}`,
956
1020
  issues,
957
1021
  knownProviders: Object.keys(PROVIDERS),
1022
+ index: indexBlock,
958
1023
  timestamp: new Date().toISOString(),
959
1024
  });
960
1025
  }
@@ -984,12 +1049,42 @@ export function makeHandler(ctx) {
984
1049
  return writeJson(res, 400, { error: `invalid sortBy: ${sortBy} (expected: mtime, turn-count, bytes, id)` });
985
1050
  }
986
1051
  const withCount = url.searchParams.get('withTurnCount') === 'true' || sortBy === 'turn-count';
1052
+ // v5: surface trainerHandled / agentName / trajectoryId per row.
1053
+ // Source: the last turn's metadata if persisted; otherwise null.
1054
+ // We're surgical here — we read the metadata only when withTurnCount
1055
+ // is already paying the load cost, OR when the dashboard explicitly
1056
+ // asks via ?withV5=true. Keeps the default GET /sessions cheap.
1057
+ const withV5 = url.searchParams.get('withV5') === 'true' || withCount;
987
1058
  let out = list.map(s => {
988
1059
  const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
989
1060
  if (withCount) {
990
1061
  try { base.turnCount = ctx.sessionsMod.loadTurns(s.id, cfgDir).length; }
991
1062
  catch { base.turnCount = null; }
992
1063
  }
1064
+ if (withV5) {
1065
+ try {
1066
+ const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
1067
+ // Newest turn carries the freshest annotations. Fall back to
1068
+ // any earlier turn that has the field set so a long session
1069
+ // doesn't drop its trainer/agent attribution.
1070
+ let trainerHandled = false;
1071
+ let trainedBy = null;
1072
+ let agentName = null;
1073
+ let trajectoryId = null;
1074
+ for (let i = turns.length - 1; i >= 0; i--) {
1075
+ const t = turns[i] || {};
1076
+ if (!trajectoryId && t.trajectoryId) trajectoryId = String(t.trajectoryId);
1077
+ if (!agentName && t.agent) agentName = String(t.agent);
1078
+ if (!trainedBy && t.trainedBy) { trainedBy = String(t.trainedBy); trainerHandled = true; }
1079
+ if (t.trainerHandled) trainerHandled = true;
1080
+ if (trajectoryId && agentName && trainedBy) break;
1081
+ }
1082
+ base.trainerHandled = !!trainerHandled;
1083
+ base.trainedBy = trainedBy;
1084
+ base.agentName = agentName;
1085
+ base.trajectoryId = trajectoryId;
1086
+ } catch { /* missing metadata is non-fatal */ }
1087
+ }
993
1088
  return base;
994
1089
  });
995
1090
  if (sortBy) {
@@ -1301,9 +1396,99 @@ export function makeHandler(ctx) {
1301
1396
  const n = parseInt(limitStr, 10);
1302
1397
  if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
1303
1398
  }
1304
- return writeJson(res, 200, items.map(s => ({
1305
- name: s.name, bytes: s.bytes, summary: s.summary,
1306
- })));
1399
+ // v5: include frontmatter fields the dashboard renders as badges.
1400
+ // listSkills() currently only surfaces name/summary/etc., so we
1401
+ // re-parse the body once per skill to extract trained_by /
1402
+ // confidence / cross_cli_tested / group. Cheap (markdown files,
1403
+ // already cached by the OS).
1404
+ const out = items.map((s) => {
1405
+ let meta = {};
1406
+ try {
1407
+ const body = loadSkill(s.name, cfgDir);
1408
+ meta = parseFrontmatter(body).meta || {};
1409
+ } catch { /* unreadable → empty meta */ }
1410
+ const xcli = meta.cross_cli_tested;
1411
+ return {
1412
+ name: s.name,
1413
+ bytes: s.bytes,
1414
+ summary: s.summary,
1415
+ description: meta.description || s.description || '',
1416
+ group: meta.group || '',
1417
+ trained_by: meta.trained_by || (meta.created_by === 'agent' ? 'agent' : ''),
1418
+ confidence: meta.confidence !== undefined && meta.confidence !== ''
1419
+ ? Number(meta.confidence)
1420
+ : null,
1421
+ cross_cli_tested: xcli === 'true' || xcli === true ? true
1422
+ : (Array.isArray(xcli) ? xcli : null),
1423
+ version: meta.version || s.version || '',
1424
+ created_by: meta.created_by || '',
1425
+ };
1426
+ });
1427
+ return writeJson(res, 200, out);
1428
+ }
1429
+ case route === 'GET /skills/suggestions': {
1430
+ // Ring buffer of nudge.suggest_skill events. Dashboard polls this
1431
+ // since the SSE bus is deferred to v5.1.
1432
+ return writeJson(res, 200, { suggestions: nudgeSuggestionsRing.slice(0, 20) });
1433
+ }
1434
+ case route === 'POST /skills/synth': {
1435
+ // Body: { sessionId: '<id>' [, outcome] [, trainedBy] [, model] }
1436
+ // Runs mas/skill_synth.synthesizeSkill against the named session.
1437
+ // We assemble a minimal agent stub from cfg.provider/model and an
1438
+ // empty role — the synth pipeline expects an agent object, but
1439
+ // most callers will want "use my default provider".
1440
+ let body;
1441
+ try { body = await readJson(req); }
1442
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
1443
+ const sessionId = body && String(body.sessionId || '').trim();
1444
+ if (!sessionId) return writeJson(res, 400, { error: 'sessionId is required' });
1445
+ const cfg = ctx.readConfig();
1446
+ const provider = body.provider || cfg.provider;
1447
+ const model = body.model || cfg.model;
1448
+ if (!provider) return writeJson(res, 400, { error: 'no provider configured — set cfg.provider or pass body.provider' });
1449
+ // Pull the session turns and build a minimal task shape.
1450
+ let turns;
1451
+ try { turns = ctx.sessionsMod.loadTurns(sessionId, gwConfigDir); }
1452
+ catch (e) { return writeJson(res, 404, { error: `session not found: ${sessionId}` }); }
1453
+ if (!Array.isArray(turns) || turns.length === 0) {
1454
+ return writeJson(res, 400, { error: `session "${sessionId}" has no turns` });
1455
+ }
1456
+ const task = {
1457
+ id: sessionId,
1458
+ title: turns[0]?.content?.slice(0, 80) || sessionId,
1459
+ turns: turns.map((t) => ({
1460
+ agent: t.role === 'user' ? 'user' : (t.role === 'system' ? 'system' : 'assistant'),
1461
+ text: String(t.content || ''),
1462
+ })),
1463
+ };
1464
+ const agent = { provider, model, role: '' };
1465
+ try {
1466
+ const apiKey = cfg['api-key'] || null;
1467
+ const result = await skillSynth.synthesizeSkill({
1468
+ agent, task, apiKey,
1469
+ outcome: body.outcome || 'done',
1470
+ trainedBy: body.trainedBy || null,
1471
+ trainedOnModel: model || null,
1472
+ });
1473
+ if (!result) return writeJson(res, 200, { ok: false, message: 'synth produced no skill (model returned NONE)' });
1474
+ // Mirror the CLI synth flow: installSynthesized() handles slug
1475
+ // reservation, agent-overwrite protection, and FTS5 mirror.
1476
+ const install = skillSynth.installSynthesized({
1477
+ name: result.name,
1478
+ description: result.description,
1479
+ body: result.body,
1480
+ sourceTask: sessionId,
1481
+ createdBy: 'agent',
1482
+ }, gwConfigDir);
1483
+ return writeJson(res, 200, {
1484
+ ok: true,
1485
+ name: install?.skill || result.name,
1486
+ description: result.description,
1487
+ path: install?.path || null,
1488
+ });
1489
+ } catch (e) {
1490
+ return writeJson(res, 500, { error: e?.message || String(e), code: e?.code });
1491
+ }
1307
1492
  }
1308
1493
  case route === 'GET /skills/search': {
1309
1494
  // Mirror of `lazyclaw skills search`. ?q=<query> required;
@@ -1887,6 +2072,194 @@ export function makeHandler(ctx) {
1887
2072
  }
1888
2073
  }
1889
2074
 
2075
+ // ── v5 dashboard surfaces ────────────────────────────────────
2076
+ case route === 'GET /trainer/status': {
2077
+ // Reads cfg.trainer.{provider, model, schedule, budget, recipe}
2078
+ // and reports last-run state from <configDir>/trainer-state.json
2079
+ // if present. No standalone trainer module yet; this is a thin
2080
+ // config-surface endpoint the dashboard reads at refresh.
2081
+ const cfg = ctx.readConfig();
2082
+ const t = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
2083
+ let lastRunAt = null, callsToday = null;
2084
+ try {
2085
+ const statePath = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'trainer-state.json');
2086
+ if (fs.existsSync(statePath)) {
2087
+ const st = JSON.parse(fs.readFileSync(statePath, 'utf8'));
2088
+ lastRunAt = st?.lastRunAt || null;
2089
+ // callsToday: count entries whose ts is within the current
2090
+ // UTC day. State writer is the (future) trainer; reader
2091
+ // tolerates absence.
2092
+ const today = new Date().toISOString().slice(0, 10);
2093
+ if (Array.isArray(st?.calls)) {
2094
+ callsToday = st.calls.filter((c) => String(c.ts || '').startsWith(today)).length;
2095
+ } else if (typeof st?.callsToday === 'number') {
2096
+ callsToday = st.callsToday;
2097
+ }
2098
+ }
2099
+ } catch { /* missing/corrupt state → null */ }
2100
+ return writeJson(res, 200, {
2101
+ provider: t.provider || null,
2102
+ model: t.model || null,
2103
+ schedule: t.schedule || null,
2104
+ budget: t.budget != null ? Number(t.budget) : null,
2105
+ recipe: t.recipe || null,
2106
+ lastRunAt,
2107
+ callsToday,
2108
+ });
2109
+ }
2110
+ case route === 'POST /trainer/sync': {
2111
+ // Stub: a real trainer scheduler lands in v5.1. For now we
2112
+ // record the trigger in trainer-state.json so the dashboard's
2113
+ // "Sync now" button has feedback, and a future trainer reads
2114
+ // the queue. Surfacing it here keeps the API stable across the
2115
+ // transition.
2116
+ try {
2117
+ const dir = gwConfigDir || skillsDefaultConfigDir();
2118
+ fs.mkdirSync(dir, { recursive: true });
2119
+ const statePath = nodePath.join(dir, 'trainer-state.json');
2120
+ let st = {};
2121
+ if (fs.existsSync(statePath)) {
2122
+ try { st = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { st = {}; }
2123
+ }
2124
+ st.lastSyncRequestAt = new Date().toISOString();
2125
+ st.syncQueued = (st.syncQueued || 0) + 1;
2126
+ fs.writeFileSync(statePath, JSON.stringify(st, null, 2));
2127
+ return writeJson(res, 200, { ok: true, message: 'sync queued', queued: st.syncQueued });
2128
+ } catch (e) {
2129
+ return writeJson(res, 500, { error: e?.message || String(e) });
2130
+ }
2131
+ }
2132
+ case route === 'GET /recall': {
2133
+ // GET /recall?q=...&scope=sessions|skills|trajectories|memories|all&k=N
2134
+ const q = url.searchParams.get('q');
2135
+ if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
2136
+ const scopeParam = url.searchParams.get('scope') || 'all';
2137
+ const scope = scopeParam === 'all'
2138
+ ? ['sessions', 'skills', 'trajectories', 'memories']
2139
+ : scopeParam.split(',').map((x) => x.trim()).filter(Boolean);
2140
+ const kParam = url.searchParams.get('k');
2141
+ const k = kParam ? Math.max(1, Math.min(50, parseInt(kParam, 10) || 10)) : 10;
2142
+ try {
2143
+ const r = indexDb.recall(q, { configDir: gwConfigDir, scope, k });
2144
+ return writeJson(res, 200, r);
2145
+ } catch (e) {
2146
+ return writeJson(res, 500, { error: e?.message || String(e) });
2147
+ }
2148
+ }
2149
+ case route === 'GET /sandbox': {
2150
+ const cfg = ctx.readConfig();
2151
+ const sb = (cfg.sandbox && typeof cfg.sandbox === 'object') ? cfg.sandbox : {};
2152
+ const active = sb.default || 'local';
2153
+ const profiles = sandboxListBackends().map((name) => {
2154
+ const section = sb[name];
2155
+ const configured = !!section && typeof section === 'object';
2156
+ let summary = '';
2157
+ if (configured) {
2158
+ if (name === 'docker' && section.image) summary = `image: ${section.image}`;
2159
+ else if (name === 'ssh' && section.host) summary = `host: ${section.host}`;
2160
+ else if (name === 'singularity' && section.image) summary = `image: ${section.image}`;
2161
+ else if (name === 'modal' && section.app) summary = `app: ${section.app}`;
2162
+ else if (name === 'daytona' && section.workspace) summary = `workspace: ${section.workspace}`;
2163
+ else if (name === 'local' && section.confiner) summary = `confiner: ${section.confiner}`;
2164
+ }
2165
+ return { name, configured, summary };
2166
+ });
2167
+ return writeJson(res, 200, { profiles, active });
2168
+ }
2169
+ case req.method === 'POST' && /^\/sandbox\/([^/]+)\/test$/.test(url.pathname): {
2170
+ // POST /sandbox/<name>/test — opens a session against the named
2171
+ // backend, runs `echo hello`, returns { ok, durationMs, stdout }.
2172
+ const name = url.pathname.match(/^\/sandbox\/([^/]+)\/test$/)[1];
2173
+ try {
2174
+ const sandboxMod = await import('./sandbox/index.mjs');
2175
+ const cfg = ctx.readConfig();
2176
+ // Synthesise a one-off cfg.sandbox.default override so we can
2177
+ // test a backend without mutating the user's persisted choice.
2178
+ const probeCfg = {
2179
+ ...cfg,
2180
+ sandbox: { ...(cfg.sandbox || {}), default: name },
2181
+ };
2182
+ const t0 = Date.now();
2183
+ const box = sandboxMod.resolveSandbox(probeCfg, null);
2184
+ const sess = await box.open();
2185
+ let result;
2186
+ try {
2187
+ result = await sess.exec(['echo', 'hello'], { stdio: 'pipe' });
2188
+ } finally {
2189
+ try { await sess.close(); } catch { /* ignore */ }
2190
+ }
2191
+ const durationMs = Date.now() - t0;
2192
+ const ok = result.code === 0;
2193
+ return writeJson(res, ok ? 200 : 500, {
2194
+ ok,
2195
+ durationMs,
2196
+ code: result.code,
2197
+ stdout: String(result.stdout || '').slice(0, 200),
2198
+ stderr: String(result.stderr || '').slice(0, 200),
2199
+ });
2200
+ } catch (e) {
2201
+ return writeJson(res, 500, { ok: false, error: e?.message || String(e), code: e?.code });
2202
+ }
2203
+ }
2204
+ case route === 'POST /sandbox/use': {
2205
+ if (typeof ctx.writeConfig !== 'function') {
2206
+ return writeJson(res, 405, { error: 'mutation disabled' });
2207
+ }
2208
+ let body;
2209
+ try { body = await readJson(req); }
2210
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
2211
+ const name = body && String(body.name || '').trim();
2212
+ if (!name) return writeJson(res, 400, { error: 'name is required' });
2213
+ if (!sandboxListBackends().includes(name)) {
2214
+ return writeJson(res, 400, { error: `unknown sandbox backend: ${name}` });
2215
+ }
2216
+ const cfg = ctx.readConfig();
2217
+ cfg.sandbox = { ...(cfg.sandbox || {}), default: name };
2218
+ ctx.writeConfig(cfg);
2219
+ return writeJson(res, 200, { ok: true, active: name });
2220
+ }
2221
+ case route === 'GET /channels': {
2222
+ // Aggregate cfg.channels.<name> + any channel-specific runtime
2223
+ // state we expose. Keeps the dashboard from having to know
2224
+ // each channel module's shape.
2225
+ const cfg = ctx.readConfig();
2226
+ const chCfg = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
2227
+ // Known built-in channel names (matches channels/ + channels-*).
2228
+ const KNOWN = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
2229
+ const out = [];
2230
+ for (const name of KNOWN) {
2231
+ const sec = chCfg[name];
2232
+ if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
2233
+ out.push({
2234
+ name,
2235
+ enabled: !!(sec && (sec.enabled !== false)),
2236
+ lastInboundAt: sec?.lastInboundAt || null,
2237
+ boundAgent: sec?.agent || sec?.boundAgent || null,
2238
+ });
2239
+ }
2240
+ // Surface any additional configured channels we didn't enumerate.
2241
+ for (const name of Object.keys(chCfg)) {
2242
+ if (KNOWN.includes(name)) continue;
2243
+ const sec = chCfg[name] || {};
2244
+ out.push({
2245
+ name,
2246
+ enabled: sec.enabled !== false,
2247
+ lastInboundAt: sec.lastInboundAt || null,
2248
+ boundAgent: sec.agent || sec.boundAgent || null,
2249
+ });
2250
+ }
2251
+ return writeJson(res, 200, { channels: out });
2252
+ }
2253
+ case route === 'POST /index/rebuild': {
2254
+ try {
2255
+ indexDb.rebuild(gwConfigDir);
2256
+ ctx.indexLastRebuiltAt = new Date().toISOString();
2257
+ return writeJson(res, 200, { ok: true, rebuiltAt: ctx.indexLastRebuiltAt });
2258
+ } catch (e) {
2259
+ return writeJson(res, 500, { ok: false, error: e?.message || String(e) });
2260
+ }
2261
+ }
2262
+
1890
2263
  default:
1891
2264
  return writeJson(res, 404, { error: 'not found', route });
1892
2265
  } /* eslint-disable-line no-fallthrough */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.0.8",
3
+ "version": "5.1.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",