lazyclaw 4.3.0 → 5.0.1

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.
Files changed (54) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -508
  3. package/channels/handoff.mjs +41 -0
  4. package/channels/loader.mjs +124 -0
  5. package/channels/threads.mjs +116 -0
  6. package/cli.mjs +430 -7
  7. package/daemon.mjs +13 -0
  8. package/mas/agent_turn.mjs +28 -0
  9. package/mas/confidence.mjs +108 -0
  10. package/mas/index_db.mjs +242 -0
  11. package/mas/nudge.mjs +97 -0
  12. package/mas/prompt_stack.mjs +80 -0
  13. package/mas/skill_synth.mjs +124 -25
  14. package/mas/tool_runner.mjs +10 -61
  15. package/mas/tools/browser.mjs +77 -0
  16. package/mas/tools/clarify.mjs +36 -0
  17. package/mas/tools/coding.mjs +109 -0
  18. package/mas/tools/delegation.mjs +53 -0
  19. package/mas/tools/edit.mjs +36 -0
  20. package/mas/tools/git.mjs +110 -0
  21. package/mas/tools/ha.mjs +34 -0
  22. package/mas/tools/learning.mjs +168 -0
  23. package/mas/tools/media.mjs +105 -0
  24. package/mas/tools/os.mjs +152 -0
  25. package/mas/tools/patch.mjs +91 -0
  26. package/mas/tools/recall.mjs +103 -0
  27. package/mas/tools/registry.mjs +93 -0
  28. package/mas/tools/scheduling.mjs +62 -0
  29. package/mas/tools/web.mjs +137 -0
  30. package/mas/toolsets.mjs +64 -0
  31. package/mas/trajectory_export.mjs +169 -0
  32. package/mas/trajectory_store.mjs +179 -0
  33. package/mas/user_modeler.mjs +108 -0
  34. package/package.json +20 -3
  35. package/providers/codex_cli.mjs +200 -0
  36. package/providers/gemini_cli.mjs +179 -0
  37. package/providers/registry.mjs +61 -1
  38. package/sandbox/base.mjs +82 -0
  39. package/sandbox/confiners/bubblewrap.mjs +21 -0
  40. package/sandbox/confiners/firejail.mjs +16 -0
  41. package/sandbox/confiners/landlock.mjs +14 -0
  42. package/sandbox/confiners/seatbelt.mjs +28 -0
  43. package/sandbox/daytona.mjs +37 -0
  44. package/sandbox/docker.mjs +91 -0
  45. package/sandbox/index.mjs +67 -0
  46. package/sandbox/local.mjs +59 -0
  47. package/sandbox/modal.mjs +53 -0
  48. package/sandbox/singularity.mjs +39 -0
  49. package/sandbox/ssh.mjs +56 -0
  50. package/sandbox.mjs +11 -127
  51. package/scripts/hermes-import.mjs +111 -0
  52. package/scripts/migrate-v5.mjs +342 -0
  53. package/scripts/openclaw-import.mjs +71 -0
  54. package/sessions.mjs +20 -1
package/cli.mjs CHANGED
@@ -4,6 +4,10 @@ import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import { pathToFileURL } from 'node:url';
7
+ // sandbox subcommands — list/test/add/use (Phase D).
8
+ import { resolveSandbox, listBackends } from './sandbox/index.mjs';
9
+ // Phase G: defaultConfigDir for personality subcommand (spec §9, decision C7).
10
+ import { defaultConfigDir as _persDefaultCfg } from './memory.mjs';
7
11
 
8
12
  async function loadEngine() {
9
13
  return import('./workflow/persistent.mjs');
@@ -639,6 +643,66 @@ async function cmdResume(sessionId, file, opts = {}) {
639
643
  }
640
644
  }
641
645
 
646
+ // --- Phase G: personality subcommand (spec §9, decision C7) -------------
647
+ async function cmdPersonality(sub, a, b) {
648
+ const cfgDir = process.env.LAZYCLAW_CONFIG_DIR || _persDefaultCfg();
649
+ const dir = path.join(cfgDir, 'personalities');
650
+ fs.mkdirSync(dir, { recursive: true });
651
+
652
+ if (!sub || sub === 'list') {
653
+ const names = fs.existsSync(dir)
654
+ ? fs.readdirSync(dir).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3))
655
+ : [];
656
+ if (!names.length) { console.log('No personalities installed'); return 0; }
657
+ for (const n of names.sort()) console.log(n);
658
+ return 0;
659
+ }
660
+
661
+ if (sub === 'show') {
662
+ if (!a) { console.error('Usage: lazyclaw personality show <name>'); return 2; }
663
+ const p = path.join(dir, `${a}.md`);
664
+ if (!fs.existsSync(p)) { console.error(`personality not found: ${a}`); return 1; }
665
+ process.stdout.write(fs.readFileSync(p, 'utf8'));
666
+ return 0;
667
+ }
668
+
669
+ if (sub === 'install') {
670
+ if (!a || !b) { console.error('Usage: lazyclaw personality install <name> <file>'); return 2; }
671
+ const dst = path.join(dir, `${a}.md`);
672
+ if (fs.existsSync(dst)) { console.error(`personality already installed: ${a}`); return 1; }
673
+ if (!fs.existsSync(b)) { console.error(`source file not found: ${b}`); return 1; }
674
+ fs.writeFileSync(dst, fs.readFileSync(b, 'utf8'));
675
+ console.log(`installed ${a}`);
676
+ return 0;
677
+ }
678
+
679
+ if (sub === 'remove') {
680
+ if (!a) { console.error('Usage: lazyclaw personality remove <name>'); return 2; }
681
+ const p = path.join(dir, `${a}.md`);
682
+ if (!fs.existsSync(p)) { console.error(`personality not installed: ${a}`); return 1; }
683
+ fs.unlinkSync(p);
684
+ console.log(`removed ${a}`);
685
+ return 0;
686
+ }
687
+
688
+ if (sub === 'use') {
689
+ if (!a) { console.error('Usage: lazyclaw personality use <name>'); return 2; }
690
+ const p = path.join(dir, `${a}.md`);
691
+ if (!fs.existsSync(p)) { console.error(`personality not installed: ${a}`); return 1; }
692
+ const cfgPath = path.join(cfgDir, 'config.json');
693
+ let cfg = {};
694
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {}
695
+ cfg.persona = { ...(cfg.persona || {}), personality: a };
696
+ fs.mkdirSync(cfgDir, { recursive: true });
697
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
698
+ console.log(`active personality: ${a}`);
699
+ return 0;
700
+ }
701
+
702
+ console.error(`Unknown personality subcommand: ${sub}`);
703
+ return 2;
704
+ }
705
+
642
706
  async function cmdConfigEdit() {
643
707
  // Open config.json in $EDITOR (or sensible default), then validate
644
708
  // the result before letting the user walk away believing the edit
@@ -685,10 +749,10 @@ function cmdConfigSet(key, value) {
685
749
  function applyOnboardConfig(currentCfg, flags) {
686
750
  // Honors the OpenClaw-style unified provider/model string ("anthropic/claude-opus-4-7")
687
751
  // by splitting it, but explicit --provider always wins.
688
- const { parseProviderModel } = require_registry_sync();
752
+ const { parseSlashProviderModel } = require_registry_sync();
689
753
  const next = { ...currentCfg };
690
754
  if (flags.model) {
691
- const parsed = parseProviderModel(flags.model);
755
+ const parsed = parseSlashProviderModel(flags.model);
692
756
  if (parsed.provider && !flags.provider) next.provider = parsed.provider;
693
757
  next.model = parsed.model || flags.model;
694
758
  }
@@ -925,6 +989,8 @@ const SUBCOMMANDS = [
925
989
  'daemon', 'version', 'completion', 'help',
926
990
  'export', 'import',
927
991
  'rates',
992
+ // v5.0 — sandbox 6-backend (Phase D)
993
+ 'sandbox',
928
994
  // OpenClaw-parity subsurfaces (v3.93–v3.98)
929
995
  'auth', 'pairing', 'nodes', 'message', 'workspace', 'browse', 'cron',
930
996
  // v3.99.6 — multi-step setup wizard + lazyclaw-only dashboard
@@ -939,8 +1005,14 @@ const SUBCOMMANDS = [
939
1005
  'telegram',
940
1006
  // v4.3.0 — Matrix /sync long-poll listener
941
1007
  'matrix',
1008
+ // v5.0 — channels plugin loader (Phase F)
1009
+ 'channels',
942
1010
  // v4.1.0 — multi-agent slack system (Phase 9+)
943
1011
  'agent', 'team', 'task',
1012
+ // v5.0 Phase G — persona compose + cross-tool import (spec §9, §10)
1013
+ 'personality', 'migrate', 'hermes', 'openclaw',
1014
+ // v5.0 Phase H1 — trajectory exporter (spec §2.7)
1015
+ 'trajectories',
944
1016
  ];
945
1017
 
946
1018
  const SUBCOMMAND_SUBS = {
@@ -949,6 +1021,7 @@ const SUBCOMMAND_SUBS = {
949
1021
  skills: ['list', 'show', 'install', 'remove', 'search', 'curate', 'classify'],
950
1022
  providers: ['list', 'info', 'test', 'add', 'remove', 'models'],
951
1023
  rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
1024
+ sandbox: ['list', 'test', 'add', 'use'],
952
1025
  completion: ['bash', 'zsh'],
953
1026
  auth: ['list', 'add', 'remove', 'use', 'rotate'],
954
1027
  pairing: ['list', 'add', 'remove'],
@@ -966,6 +1039,7 @@ const SUBCOMMAND_SUBS = {
966
1039
  agent: ['add', 'list', 'show', 'edit', 'remove'],
967
1040
  team: ['add', 'list', 'show', 'edit', 'remove'],
968
1041
  task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
1042
+ trajectories: ['export'],
969
1043
  };
970
1044
 
971
1045
  function bashCompletion() {
@@ -1214,6 +1288,7 @@ const HELP_DETAILS = {
1214
1288
  export: 'Usage: lazyclaw export [--include-secrets] [--include-sessions] > bundle.json\n --include-secrets keeps the raw api-key in the bundle (default redacts it).\n --include-sessions adds full turn content (default keeps metadata only).',
1215
1289
  import: 'Usage: lazyclaw import [--from <path>] [--overwrite-skills] [--no-overwrite-config] [--import-sessions]\n Reads JSON from stdin (or --from <path>). Sessions are NEVER overwritten.\n Redacted api-keys (***REDACTED***) are dropped, never written.',
1216
1290
  rates: 'Usage: lazyclaw rates <list [--filter <substr>] [--limit <N>] | set <provider/model> --input <N> --output <N> [--cache-read <N>] [--cache-create <N>] [--currency USD] | delete <key> | shape | validate | copy <src> <dst> [--force]>\n Rates are per million tokens. costFromUsage uses cfg.rates to compute the cost block in /usage and body.cost.\n `list` accepts --filter (case-insensitive key substring) and --limit (post-filter cap), same shape sessions/skills/workflows lists use.\n `shape` prints the reference template (zero-filled) you can copy into config.\n `validate` checks the cfg.rates shape: required fields, non-negative numbers, known providers (warn-only).\n `copy` clones an existing card to a new key (use when a new model launches at the same price as an old one).',
1291
+ sandbox: 'Usage: lazyclaw sandbox <list|test|add|use> [args]\n list show 6 backends (local, docker, ssh, singularity, modal, daytona)\n test <kind> run echo through the backend (or argv-shape check for remote)\n add <name> --kind <kind> [--image|--host|--user|--workspace|--app|--confiner ...]\n use <profile> set the profile as cfg.sandbox.default',
1217
1292
  auth: 'Usage: lazyclaw auth <list <provider> | add <provider> <key> [--label <name>] | remove <provider> <label> | use <provider> <label> | rotate <provider>>\n Multiple keys per provider for rate-limit rotation. The active label is sent on every chat / agent call.\n `rotate` advances the cursor to the next label; pair with a 429 hook for auto-failover.',
1218
1293
  pairing: 'Usage: lazyclaw pairing <list | add <id> [--label <name>] | remove <id>>\n Sender allowlist for the messaging surface. Inbound senders not on this list are rejected.\n Sender ids are opaque per-channel: Slack member id, Discord user id, phone number for SMS, etc.',
1219
1294
  nodes: 'Usage: lazyclaw nodes <list | register <id> [--platform macos|ios|android|web|cli] [--label <name>] | remove <id>>\n Companion device registration table. CLI only — the actual mobile / menu-bar apps are out of scope here.\n Platform is free-form lower-case; future surfaces (iOS / Android nodes) authenticate against the daemon using these ids.',
@@ -1559,6 +1634,33 @@ function _renderBanner(version) {
1559
1634
  ];
1560
1635
  }
1561
1636
 
1637
+ // v5 sloth banner — shared with the ink splash (tui/splash.mjs).
1638
+ // Single-tone orange like _renderBanner so the no-arg launcher and the
1639
+ // chat splash share a visual identity. Opt out with LAZYCLAW_LEGACY_MENU=1
1640
+ // to fall back to the boxed figlet variant above.
1641
+ let _v5BannerRowsCache = null;
1642
+ async function _renderV5Banner(version) {
1643
+ if (!_v5BannerRowsCache) {
1644
+ try {
1645
+ const { banner } = await import('./tui/banner.generated.mjs');
1646
+ _v5BannerRowsCache = banner.rows;
1647
+ } catch {
1648
+ _v5BannerRowsCache = null;
1649
+ }
1650
+ }
1651
+ if (!_v5BannerRowsCache) return _renderBanner(version);
1652
+ const v = String(version || '?.?.?');
1653
+ return _v5BannerRowsCache.map((row, i) => {
1654
+ if (i === 8) {
1655
+ // Caption row — overlay "lazyclaw v<version>" centered on the 24-wide art
1656
+ const cap = `lazyclaw v${v}`;
1657
+ const pad = Math.max(0, Math.floor((24 - cap.length) / 2));
1658
+ return _orange(' '.repeat(pad) + cap + ' '.repeat(24 - pad - cap.length));
1659
+ }
1660
+ return _orange(row);
1661
+ });
1662
+ }
1663
+
1562
1664
  function _printChatBanner(activeProvName, activeModel, version) {
1563
1665
  if (!process.stdout.isTTY) return;
1564
1666
  // Single-hue header: labels dim-orange, values/emphasis full-orange, so the
@@ -2300,6 +2402,38 @@ async function cmdChat(flags = {}) {
2300
2402
 
2301
2403
  // Top-of-session banner so the user can see at a glance what they're
2302
2404
  // talking to. Cheap (no provider call) and TTY-only.
2405
+ // v5 ink splash + REPL when stdin is a real TTY and the user has not
2406
+ // opted out via LAZYCLAW_NO_INK=1. Non-TTY pipelines and the opt-out
2407
+ // env var fall through to the v4 figlet + readline path unchanged.
2408
+ const __useInkSplash = process.stdout.isTTY && !process.env.LAZYCLAW_NO_INK;
2409
+ if (__useInkSplash) {
2410
+ try {
2411
+ const React = (await import('react')).default;
2412
+ const { render } = await import('ink');
2413
+ const { ReplApp } = await import('./tui/repl.mjs');
2414
+ const { renderSplashToString } = await import('./tui/splash.mjs');
2415
+ // narrow-terminal fallback: <60 cols falls back to v4
2416
+ if ((process.stdout.columns || 80) < 60) throw new Error('narrow-terminal');
2417
+ const splashProps = {
2418
+ provider: activeProvName, model: activeModel,
2419
+ trainer: {}, sessionId: flags.session || '',
2420
+ cwd: process.cwd(),
2421
+ tools: [], skills: [],
2422
+ };
2423
+ void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
2424
+ const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
2425
+ splashProps,
2426
+ runTurn: async (_text, _signal) => { void _text; void _signal; },
2427
+ }));
2428
+ await ink.waitUntilExit();
2429
+ return;
2430
+ } catch (e) {
2431
+ // Fall through to legacy path on any ink failure (missing import,
2432
+ // narrow terminal, sandboxed stdout).
2433
+ if (process.env.LAZYCLAW_DEBUG) console.error('[ink] fallback:', e.message);
2434
+ }
2435
+ }
2436
+ // ─── legacy v4 path (unchanged) ─────────────────────────────────
2303
2437
  _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
2304
2438
 
2305
2439
  const readline = await import('node:readline');
@@ -2481,8 +2615,8 @@ async function cmdChat(flags = {}) {
2481
2615
  }
2482
2616
  // Honor unified provider/model: `/model anthropic/claude-opus-4-7`
2483
2617
  // splits and switches both.
2484
- const { parseProviderModel } = _registryMod;
2485
- const parsed = parseProviderModel(arg);
2618
+ const { parseSlashProviderModel } = _registryMod;
2619
+ const parsed = parseSlashProviderModel(arg);
2486
2620
  if (parsed.provider) {
2487
2621
  const next = lookupProv(parsed.provider);
2488
2622
  if (!next) {
@@ -2942,6 +3076,53 @@ async function cmdChat(flags = {}) {
2942
3076
  }
2943
3077
  return true;
2944
3078
  }
3079
+ case '/handoff': {
3080
+ // /handoff <target-channel> <externalId> [--note=...] — migrates the
3081
+ // active thread (bound to replState.channel / replState.externalId)
3082
+ // to a new channel and posts transition stubs on both sides. In the
3083
+ // local-only chat REPL there is no bound channel, so we surface a
3084
+ // clear error and stay in the REPL (acceptance test §F).
3085
+ const parts = line.trim().split(/\s+/).slice(1);
3086
+ if (parts.length < 2) {
3087
+ process.stderr.write('usage: /handoff <target-channel> <externalId> [--note=...]\n');
3088
+ return true;
3089
+ }
3090
+ const target = parts[0];
3091
+ const externalId = parts[1];
3092
+ const note = (parts.find(p => p.startsWith('--note=')) || '').slice(7);
3093
+ try {
3094
+ const { openThreads } = await import('./channels/threads.mjs');
3095
+ const { runHandoff } = await import('./channels/handoff.mjs');
3096
+ const threads = openThreads(cfgDir);
3097
+ const replState = globalThis.__lazyclawReplState || {};
3098
+ const cur = replState.channel && replState.externalId
3099
+ ? threads.findByExternal(replState.channel, replState.externalId)
3100
+ : null;
3101
+ if (!cur) {
3102
+ process.stderr.write(
3103
+ `handoff: no thread bound to ${replState.channel || '(none)'}:${replState.externalId || '(none)'}\n`,
3104
+ );
3105
+ return true;
3106
+ }
3107
+ const next = await runHandoff({
3108
+ threads, channels: replState.channels || {},
3109
+ threadId: cur.threadId, target, externalId, note,
3110
+ });
3111
+ process.stdout.write(`handoff -> ${next.channel}:${next.externalId} (session ${next.sessionId})\n`);
3112
+ replState.channel = next.channel;
3113
+ replState.externalId = next.externalId;
3114
+ } catch (e) {
3115
+ process.stderr.write(`handoff failed: ${e.code || 'ERR'}: ${e.message}\n`);
3116
+ }
3117
+ return true;
3118
+ }
3119
+ case '/personality': {
3120
+ // Phase G: thin slash wrapper over cmdPersonality.
3121
+ const tail = line.slice('/personality'.length).trim();
3122
+ const parts = tail.split(/\s+/).filter(Boolean);
3123
+ await cmdPersonality(parts[0] || 'list', parts[1], parts[2]);
3124
+ return true;
3125
+ }
2945
3126
  case '/exit': {
2946
3127
  return 'EXIT';
2947
3128
  }
@@ -5961,10 +6142,118 @@ async function cmdSessions(sub, positional, flags = {}) {
5961
6142
  }
5962
6143
  }
5963
6144
 
6145
+ // sandbox subcommands — list/test/add/use (Phase D).
6146
+ async function cmdSandbox(args, flags = {}) {
6147
+ const sub = args[0];
6148
+
6149
+ if (!sub || sub === 'list') {
6150
+ for (const kind of listBackends()) process.stdout.write(`${kind}\n`);
6151
+ return 0;
6152
+ }
6153
+
6154
+ if (sub === 'test') {
6155
+ const name = args[1];
6156
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox test <kind|profile>\n'); return 2; }
6157
+ const cfg = _sandboxLoadConfigOrEmpty();
6158
+ // If `name` looks like a known kind, route to that kind. If it
6159
+ // is not a known kind AND not a profile in cfg, treat as an
6160
+ // unknown identifier and report SANDBOX_BAD_KIND.
6161
+ const isKind = listBackends().includes(name);
6162
+ const profile = cfg.sandbox && cfg.sandbox.profiles && cfg.sandbox.profiles[name];
6163
+ if (!isKind && !profile) {
6164
+ process.stderr.write(`SANDBOX_BAD_KIND: unknown sandbox kind or profile "${name}"\n`);
6165
+ return 1;
6166
+ }
6167
+ let sb;
6168
+ try {
6169
+ const synthCfg = isKind
6170
+ ? { sandbox: { default: name, ...cfg.sandbox } }
6171
+ : cfg;
6172
+ sb = resolveSandbox(synthCfg);
6173
+ } catch (e) {
6174
+ process.stderr.write(`${e.code || 'SANDBOX_ERR'}: ${e.message}\n`); return 1;
6175
+ }
6176
+ if (sb.spec.kind !== 'local' && sb.spec.kind !== 'docker') {
6177
+ // Remote/serverless backends just construct argv in unit tests;
6178
+ // we report "shape-ok" without actually executing.
6179
+ process.stdout.write(`ok ${sb.spec.kind} (argv-shape)\n`);
6180
+ return 0;
6181
+ }
6182
+ const sess = await sb.open();
6183
+ try {
6184
+ const r = await sess.exec(['echo', 'lazyclaw-sandbox-test']);
6185
+ if (r.code !== 0 || !/lazyclaw-sandbox-test/.test(r.stdout)) {
6186
+ process.stderr.write(`fail ${name}: exit=${r.code} stdout=${r.stdout}\n`); return 1;
6187
+ }
6188
+ process.stdout.write(`ok ${name}\n`);
6189
+ return 0;
6190
+ } finally { await sess.close(); }
6191
+ }
6192
+
6193
+ if (sub === 'add') {
6194
+ const name = args[1];
6195
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox add <name> --kind <kind> [...]\n'); return 2; }
6196
+ const opts = {};
6197
+ if (flags.kind) opts.kind = flags.kind;
6198
+ if (flags.image) opts.image = flags.image;
6199
+ if (flags.host) opts.host = flags.host;
6200
+ if (flags.user) opts.user = flags.user;
6201
+ if (flags.workspace) opts.workspace = flags.workspace;
6202
+ if (flags.app) opts.app = flags.app;
6203
+ if (flags.confiner) opts.confiner = flags.confiner;
6204
+ if (!listBackends().includes(opts.kind)) {
6205
+ process.stderr.write(`unknown kind "${opts.kind}"\n`); return 1;
6206
+ }
6207
+ const cfg = _sandboxLoadConfigOrEmpty();
6208
+ cfg.sandbox = cfg.sandbox || {};
6209
+ cfg.sandbox.profiles = cfg.sandbox.profiles || {};
6210
+ cfg.sandbox.profiles[name] = opts;
6211
+ _sandboxSaveConfig(cfg);
6212
+ process.stdout.write(`added profile ${name} (${opts.kind})\n`);
6213
+ return 0;
6214
+ }
6215
+
6216
+ if (sub === 'use') {
6217
+ const name = args[1];
6218
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox use <profile>\n'); return 2; }
6219
+ const cfg = _sandboxLoadConfigOrEmpty();
6220
+ const prof = cfg.sandbox && cfg.sandbox.profiles && cfg.sandbox.profiles[name];
6221
+ if (!prof) { process.stderr.write(`no profile "${name}"\n`); return 1; }
6222
+ cfg.sandbox = cfg.sandbox || {};
6223
+ cfg.sandbox.default = prof.kind;
6224
+ cfg.sandbox[prof.kind] = { ...(cfg.sandbox[prof.kind] || {}), ...prof, kind: undefined };
6225
+ delete cfg.sandbox[prof.kind].kind;
6226
+ _sandboxSaveConfig(cfg);
6227
+ process.stdout.write(`using profile ${name} (${prof.kind})\n`);
6228
+ return 0;
6229
+ }
6230
+
6231
+ process.stderr.write(`unknown subcommand "${sub}". Try: list | test | add | use\n`);
6232
+ return 2;
6233
+ }
6234
+
6235
+ function _sandboxLoadConfigOrEmpty() {
6236
+ const p = process.env.LAZYCLAW_CONFIG || configPath();
6237
+ try {
6238
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
6239
+ } catch { return {}; }
6240
+ }
6241
+
6242
+ function _sandboxSaveConfig(cfg) {
6243
+ const p = process.env.LAZYCLAW_CONFIG || configPath();
6244
+ fs.mkdirSync(path.dirname(p), { recursive: true });
6245
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
6246
+ }
6247
+
5964
6248
  function cmdConfigGet(key) {
5965
6249
  const cfg = readConfig();
5966
- if (key) console.log(JSON.stringify({ key, value: cfg[key] ?? null }));
5967
- else console.log(JSON.stringify(cfg));
6250
+ if (!key) { console.log(JSON.stringify(cfg)); return; }
6251
+ let value = cfg;
6252
+ for (const seg of String(key).split('.')) {
6253
+ if (value && typeof value === 'object' && seg in value) value = value[seg];
6254
+ else { value = null; break; }
6255
+ }
6256
+ console.log(JSON.stringify({ key, value }));
5968
6257
  }
5969
6258
 
5970
6259
  // Structural integrity check across the whole config. Distinct from
@@ -6325,6 +6614,8 @@ async function _dispatchMenuChoice(argv) {
6325
6614
  case 'inspect': return await cmdInspect(rest[0], {});
6326
6615
  case 'export': return await cmdExport({});
6327
6616
  case 'version': return await cmdVersion();
6617
+ // Phase G — persona compose subcommand (spec §9, decision C7).
6618
+ case 'personality': return await cmdPersonality(rest[0], rest[1], rest[2]);
6328
6619
  // help <cmd> is the safe fallback for commands that need real
6329
6620
  // arguments (run / resume / clear / validate / graph / daemon /
6330
6621
  // import / completion). Print the usage so the user can re-launch
@@ -6455,9 +6746,13 @@ async function cmdLauncher() {
6455
6746
  process.stdin.resume();
6456
6747
  process.stdin.ref();
6457
6748
 
6749
+ const useLegacyBanner = !!process.env.LAZYCLAW_LEGACY_MENU;
6750
+ const bannerRowsCached = useLegacyBanner
6751
+ ? _renderBanner(readVersionFromRepo())
6752
+ : await _renderV5Banner(readVersionFromRepo());
6458
6753
  const draw = () => {
6459
6754
  process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
6460
- _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
6755
+ bannerRowsCached.forEach((l) => process.stdout.write(l + '\n'));
6461
6756
  process.stdout.write('\n');
6462
6757
  process.stdout.write(` ${dim('provider ·')} ${ok(provider)}\n`);
6463
6758
  process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
@@ -6761,6 +7056,105 @@ async function main() {
6761
7056
  }
6762
7057
  break;
6763
7058
  }
7059
+ case 'personality': {
7060
+ // Phase G: persona compose subcommand (spec §9, decision C7).
7061
+ process.exit(await cmdPersonality(rest.positional[0], rest.positional[1], rest.positional[2]));
7062
+ break;
7063
+ }
7064
+ case 'migrate': {
7065
+ // Phase A baseline accepts `lazyclaw migrate v5`; Phase G adds the
7066
+ // bare `lazyclaw migrate` and `lazyclaw migrate rollback` forms.
7067
+ const target = rest.positional[0];
7068
+ if (target === 'rollback') {
7069
+ const mod = await import('./scripts/migrate-v5.mjs');
7070
+ try {
7071
+ const { restoredFrom } = mod.rollback();
7072
+ console.log(`rolled back from ${restoredFrom}`);
7073
+ process.exit(0);
7074
+ } catch (e) {
7075
+ console.error(`migrate failed: ${e.message}`);
7076
+ process.exit(1);
7077
+ }
7078
+ break;
7079
+ }
7080
+ const mod = await import('./scripts/migrate-v5.mjs');
7081
+ // `migrate v5` keeps the Phase-A behaviour (verbose JSON); the
7082
+ // bare `migrate` form uses the Phase-G human summary.
7083
+ if (target === 'v5') {
7084
+ const r = await mod.migrateV5();
7085
+ console.log(JSON.stringify(r, null, 2));
7086
+ process.exit(r.ok ? 0 : 1);
7087
+ }
7088
+ try {
7089
+ const { backupDir } = mod.migrate();
7090
+ console.log(`migrated; backup at ${backupDir}`);
7091
+ process.exit(0);
7092
+ } catch (e) {
7093
+ console.error(`migrate failed: ${e.message}`);
7094
+ process.exit(1);
7095
+ }
7096
+ break;
7097
+ }
7098
+ case 'hermes': {
7099
+ // Phase G: import a Hermes Agent install (spec §10).
7100
+ if (rest.positional[0] !== 'import') {
7101
+ console.error('Usage: lazyclaw hermes import [--from <dir>]');
7102
+ process.exit(2);
7103
+ }
7104
+ const from = rest.flags.from;
7105
+ const mod = await import('./scripts/hermes-import.mjs');
7106
+ try {
7107
+ const { src, dst, counts } = mod.importHermes({ from });
7108
+ console.log(`hermes import: ${src} → ${dst}`);
7109
+ console.log(` skills: ${counts.skills} skins: ${counts.skins}`);
7110
+ process.exit(0);
7111
+ } catch (e) { console.error(`hermes import failed: ${e.message}`); process.exit(1); }
7112
+ break;
7113
+ }
7114
+ case 'openclaw': {
7115
+ // Phase G: import an OpenClaw install (spec §10).
7116
+ if (rest.positional[0] !== 'import') {
7117
+ console.error('Usage: lazyclaw openclaw import [--from <dir>]');
7118
+ process.exit(2);
7119
+ }
7120
+ const from = rest.flags.from;
7121
+ const mod = await import('./scripts/openclaw-import.mjs');
7122
+ try {
7123
+ const { src, dst, counts } = mod.importOpenclaw({ from });
7124
+ console.log(`openclaw import: ${src} → ${dst} skills:${counts.skills}`);
7125
+ process.exit(0);
7126
+ } catch (e) { console.error(`openclaw import failed: ${e.message}`); process.exit(1); }
7127
+ break;
7128
+ }
7129
+ case 'trajectories': {
7130
+ // Phase H1: read-only trajectory exporter (spec §2.7).
7131
+ // Usage: lazyclaw trajectories export --format <atropos|axolotl|openai-ft|jsonl>
7132
+ // [--since 7d] [--filter "outcome=done"] [--out ./dir]
7133
+ if (rest.positional[0] !== 'export') {
7134
+ console.error('Usage: lazyclaw trajectories export --format <atropos|axolotl|openai-ft|jsonl> [--since 7d] [--filter "outcome=done"] [--out <dir>]');
7135
+ process.exit(2);
7136
+ }
7137
+ const mod = await import('./mas/trajectory_export.mjs');
7138
+ const format = rest.flags.format || 'jsonl';
7139
+ if (!mod.FORMATS.includes(format)) {
7140
+ console.error(`trajectories export: unknown format "${format}" — choose ${mod.FORMATS.join('|')}`);
7141
+ process.exit(2);
7142
+ }
7143
+ try {
7144
+ const r = await mod.exportTrajectories({
7145
+ format,
7146
+ since: rest.flags.since,
7147
+ filter: mod.parseFilterArg(rest.flags.filter),
7148
+ outDir: rest.flags.out,
7149
+ });
7150
+ console.log(`exported ${r.count} trajectories (${r.format}) → ${r.outFile}`);
7151
+ process.exit(0);
7152
+ } catch (e) {
7153
+ console.error(`trajectories export failed: ${e.message}`);
7154
+ process.exit(1);
7155
+ }
7156
+ break;
7157
+ }
6764
7158
  case 'chat': {
6765
7159
  await cmdChat(rest.flags);
6766
7160
  break;
@@ -6790,6 +7184,10 @@ async function main() {
6790
7184
  await cmdRates(sub, rest.positional.slice(1), rest.flags);
6791
7185
  break;
6792
7186
  }
7187
+ case 'sandbox': {
7188
+ process.exit(await cmdSandbox(rest.positional, rest.flags));
7189
+ break;
7190
+ }
6793
7191
  case 'auth': {
6794
7192
  const sub = rest.positional[0];
6795
7193
  await cmdAuth(sub, rest.positional.slice(1), rest.flags);
@@ -6877,6 +7275,31 @@ async function main() {
6877
7275
  await cmdDashboard(rest.flags);
6878
7276
  break;
6879
7277
  }
7278
+ case 'channels': {
7279
+ const sub = (rest.positional[0] || 'list').toLowerCase();
7280
+ const { createLoader, listInstalled } = await import('./channels/loader.mjs');
7281
+ const cfgDir = path.dirname(configPath());
7282
+ const loader = createLoader({ configDir: cfgDir });
7283
+ if (sub === 'install') {
7284
+ const name = rest.positional[1];
7285
+ if (!name) { process.stderr.write('usage: lazyclaw channels install <@lazyclaw/channel-name>\n'); process.exit(2); }
7286
+ const info = await loader.install(name);
7287
+ process.stdout.write(`installed ${info.name}@${info.version}\n`);
7288
+ break;
7289
+ }
7290
+ if (sub === 'remove' || sub === 'uninstall') {
7291
+ const name = rest.positional[1];
7292
+ if (!name) { process.stderr.write('usage: lazyclaw channels remove <@lazyclaw/channel-name>\n'); process.exit(2); }
7293
+ await loader.remove(name);
7294
+ process.stdout.write(`removed ${name}\n`);
7295
+ break;
7296
+ }
7297
+ // list
7298
+ const rows = listInstalled(cfgDir);
7299
+ if (!rows.length) { process.stdout.write('no channel plugins installed\n'); break; }
7300
+ for (const r of rows) process.stdout.write(`${r.name}\t${r.version}\n`);
7301
+ break;
7302
+ }
6880
7303
  case 'daemon': {
6881
7304
  await cmdDaemon(rest.flags);
6882
7305
  break;
package/daemon.mjs CHANGED
@@ -28,6 +28,7 @@ import { createLogger } from './logger.mjs';
28
28
  import { summarizeState, listSessions as listWorkflowSessions, loadStateFile as loadWorkflowState, aggregateNodeStats } from './workflow/summary.mjs';
29
29
  import { validateConfig } from './config-validate.mjs';
30
30
  import { validateRates } from './rates-validate.mjs';
31
+ import * as nudge from './mas/nudge.mjs';
31
32
 
32
33
  // Resolve the provider for a request. Composes opt-in wrappers in this
33
34
  // order (innermost first):
@@ -328,6 +329,18 @@ export function makeHandler(ctx) {
328
329
  // one, so it must outlive a single call.
329
330
  const gwConfigDir = typeof ctx.sessionsDirGetter === 'function' ? ctx.sessionsDirGetter() : undefined;
330
331
  const gateway = createGateway({ configDir: gwConfigDir, challengeRegistry: new ChallengeRegistry(), heartbeatMs: 25000 });
332
+ // Phase B nudge loop — scans recent.jsonl every 5 min and pushes
333
+ // nudge.suggest_skill onto the SSE bus so the curator can prompt.
334
+ const _nudgeLoop = nudge.startNudgeLoop({
335
+ configDir: gwConfigDir,
336
+ emit: (event) => {
337
+ try { gateway.broadcast?.('nudge.suggest_skill', event); }
338
+ catch (err) { logger?.warn?.('nudge_emit_failed', { err: err.message }); }
339
+ },
340
+ logger,
341
+ });
342
+ process.on('SIGTERM', () => { _nudgeLoop.stop(); });
343
+ process.on('SIGINT', () => { _nudgeLoop.stop(); });
331
344
  return async function handler(req, res) {
332
345
  // Capture method+path before any handler logic runs; req.url survives
333
346
  // the response but capturing now keeps the log line stable even if a
@@ -25,6 +25,7 @@ import * as anthropic from '../providers/tool_use/anthropic.mjs';
25
25
  import * as openai from '../providers/tool_use/openai.mjs';
26
26
  import * as gemini from '../providers/tool_use/gemini.mjs';
27
27
  import * as claudeCli from '../providers/tool_use/claude_cli.mjs';
28
+ import { put as _trajPut } from './trajectory_store.mjs';
28
29
 
29
30
  export class AgentTurnError extends Error {
30
31
  constructor(message, code) {
@@ -71,6 +72,7 @@ export async function runAgentTurn({
71
72
  maxIterations = DEFAULT_MAX_ITERATIONS,
72
73
  signal,
73
74
  approve,
75
+ trajectoryRef,
74
76
  } = {}) {
75
77
  if (!agent) throw new AgentTurnError('agent is required', 'NO_AGENT');
76
78
  const adapter = adapterFor(agent.provider);
@@ -92,6 +94,29 @@ export async function runAgentTurn({
92
94
  const toolCalls = [];
93
95
  let iterations = 0;
94
96
  let lastText = '';
97
+ if (trajectoryRef && !trajectoryRef.startedAt) trajectoryRef.startedAt = Date.now();
98
+
99
+ const _maybePersistTrajectory = async (outcome) => {
100
+ if (!trajectoryRef) return;
101
+ try {
102
+ await _trajPut({
103
+ taskId, agentName: agent.name || 'agent',
104
+ workerProvider: agent.provider, workerModel: agent.model,
105
+ startedAt: trajectoryRef.startedAt || Date.now(),
106
+ endedAt: Date.now(),
107
+ systemPrompt: agent.role || '',
108
+ userMessages: userMessage ? [String(userMessage)] : [],
109
+ turns: toolCalls.map((c, i) => ({
110
+ turnIdx: i, role: 'tool', content: '',
111
+ toolCalls: [{ name: c.name, args: c.input, result: JSON.stringify(c.result), success: c.ok, durationMs: 0 }],
112
+ })).concat(lastText ? [{
113
+ turnIdx: toolCalls.length, role: 'assistant', content: lastText, toolCalls: [],
114
+ }] : []),
115
+ finalAnswer: lastText,
116
+ outcome,
117
+ }, { configDir });
118
+ } catch { /* trajectory failure must not break the agent turn */ }
119
+ };
95
120
 
96
121
  while (iterations < maxIterations) {
97
122
  if (signal?.aborted) return { text: lastText, iterations, stoppedBy: 'abort', toolCalls };
@@ -103,6 +128,7 @@ export async function runAgentTurn({
103
128
  if (resp.text) lastText = resp.text;
104
129
 
105
130
  if (resp.kind === 'final') {
131
+ await _maybePersistTrajectory('done');
106
132
  return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls };
107
133
  }
108
134
 
@@ -140,9 +166,11 @@ export async function runAgentTurn({
140
166
  // model so it can recover. Only an extraordinary error (e.g. the
141
167
  // provider returned a malformed envelope) bails out here.
142
168
  if (toolErrored && process.env.LAZYCLAW_TOOL_STRICT === '1') {
169
+ await _maybePersistTrajectory('failed');
143
170
  return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls };
144
171
  }
145
172
  }
146
173
 
174
+ await _maybePersistTrajectory('abandoned');
147
175
  return { text: lastText, iterations, stoppedBy: 'budget', toolCalls };
148
176
  }