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 +29 -8
- package/cli.mjs +12 -2
- package/daemon.mjs +377 -4
- package/package.json +1 -1
- package/tui/splash.mjs +283 -38
- package/tui/wordmark.mjs +21 -3
- package/web/dashboard.html +383 -24
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
|
|
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
|
|
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
|
-
###
|
|
128
|
-
|
|
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
|
-
|
|
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` |
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1305
|
-
|
|
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
|
|
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",
|