lazyclaw 4.3.0 → 5.0.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.
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 +398 -6
  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.',
@@ -2300,6 +2375,38 @@ async function cmdChat(flags = {}) {
2300
2375
 
2301
2376
  // Top-of-session banner so the user can see at a glance what they're
2302
2377
  // talking to. Cheap (no provider call) and TTY-only.
2378
+ // v5 ink splash + REPL when stdin is a real TTY and the user has not
2379
+ // opted out via LAZYCLAW_NO_INK=1. Non-TTY pipelines and the opt-out
2380
+ // env var fall through to the v4 figlet + readline path unchanged.
2381
+ const __useInkSplash = process.stdout.isTTY && !process.env.LAZYCLAW_NO_INK;
2382
+ if (__useInkSplash) {
2383
+ try {
2384
+ const React = (await import('react')).default;
2385
+ const { render } = await import('ink');
2386
+ const { ReplApp } = await import('./tui/repl.mjs');
2387
+ const { renderSplashToString } = await import('./tui/splash.mjs');
2388
+ // narrow-terminal fallback: <60 cols falls back to v4
2389
+ if ((process.stdout.columns || 80) < 60) throw new Error('narrow-terminal');
2390
+ const splashProps = {
2391
+ provider: activeProvName, model: activeModel,
2392
+ trainer: {}, sessionId: flags.session || '',
2393
+ cwd: process.cwd(),
2394
+ tools: [], skills: [],
2395
+ };
2396
+ void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
2397
+ const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
2398
+ splashProps,
2399
+ runTurn: async (_text, _signal) => { void _text; void _signal; },
2400
+ }));
2401
+ await ink.waitUntilExit();
2402
+ return;
2403
+ } catch (e) {
2404
+ // Fall through to legacy path on any ink failure (missing import,
2405
+ // narrow terminal, sandboxed stdout).
2406
+ if (process.env.LAZYCLAW_DEBUG) console.error('[ink] fallback:', e.message);
2407
+ }
2408
+ }
2409
+ // ─── legacy v4 path (unchanged) ─────────────────────────────────
2303
2410
  _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
2304
2411
 
2305
2412
  const readline = await import('node:readline');
@@ -2481,8 +2588,8 @@ async function cmdChat(flags = {}) {
2481
2588
  }
2482
2589
  // Honor unified provider/model: `/model anthropic/claude-opus-4-7`
2483
2590
  // splits and switches both.
2484
- const { parseProviderModel } = _registryMod;
2485
- const parsed = parseProviderModel(arg);
2591
+ const { parseSlashProviderModel } = _registryMod;
2592
+ const parsed = parseSlashProviderModel(arg);
2486
2593
  if (parsed.provider) {
2487
2594
  const next = lookupProv(parsed.provider);
2488
2595
  if (!next) {
@@ -2942,6 +3049,53 @@ async function cmdChat(flags = {}) {
2942
3049
  }
2943
3050
  return true;
2944
3051
  }
3052
+ case '/handoff': {
3053
+ // /handoff <target-channel> <externalId> [--note=...] — migrates the
3054
+ // active thread (bound to replState.channel / replState.externalId)
3055
+ // to a new channel and posts transition stubs on both sides. In the
3056
+ // local-only chat REPL there is no bound channel, so we surface a
3057
+ // clear error and stay in the REPL (acceptance test §F).
3058
+ const parts = line.trim().split(/\s+/).slice(1);
3059
+ if (parts.length < 2) {
3060
+ process.stderr.write('usage: /handoff <target-channel> <externalId> [--note=...]\n');
3061
+ return true;
3062
+ }
3063
+ const target = parts[0];
3064
+ const externalId = parts[1];
3065
+ const note = (parts.find(p => p.startsWith('--note=')) || '').slice(7);
3066
+ try {
3067
+ const { openThreads } = await import('./channels/threads.mjs');
3068
+ const { runHandoff } = await import('./channels/handoff.mjs');
3069
+ const threads = openThreads(cfgDir);
3070
+ const replState = globalThis.__lazyclawReplState || {};
3071
+ const cur = replState.channel && replState.externalId
3072
+ ? threads.findByExternal(replState.channel, replState.externalId)
3073
+ : null;
3074
+ if (!cur) {
3075
+ process.stderr.write(
3076
+ `handoff: no thread bound to ${replState.channel || '(none)'}:${replState.externalId || '(none)'}\n`,
3077
+ );
3078
+ return true;
3079
+ }
3080
+ const next = await runHandoff({
3081
+ threads, channels: replState.channels || {},
3082
+ threadId: cur.threadId, target, externalId, note,
3083
+ });
3084
+ process.stdout.write(`handoff -> ${next.channel}:${next.externalId} (session ${next.sessionId})\n`);
3085
+ replState.channel = next.channel;
3086
+ replState.externalId = next.externalId;
3087
+ } catch (e) {
3088
+ process.stderr.write(`handoff failed: ${e.code || 'ERR'}: ${e.message}\n`);
3089
+ }
3090
+ return true;
3091
+ }
3092
+ case '/personality': {
3093
+ // Phase G: thin slash wrapper over cmdPersonality.
3094
+ const tail = line.slice('/personality'.length).trim();
3095
+ const parts = tail.split(/\s+/).filter(Boolean);
3096
+ await cmdPersonality(parts[0] || 'list', parts[1], parts[2]);
3097
+ return true;
3098
+ }
2945
3099
  case '/exit': {
2946
3100
  return 'EXIT';
2947
3101
  }
@@ -5961,10 +6115,118 @@ async function cmdSessions(sub, positional, flags = {}) {
5961
6115
  }
5962
6116
  }
5963
6117
 
6118
+ // sandbox subcommands — list/test/add/use (Phase D).
6119
+ async function cmdSandbox(args, flags = {}) {
6120
+ const sub = args[0];
6121
+
6122
+ if (!sub || sub === 'list') {
6123
+ for (const kind of listBackends()) process.stdout.write(`${kind}\n`);
6124
+ return 0;
6125
+ }
6126
+
6127
+ if (sub === 'test') {
6128
+ const name = args[1];
6129
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox test <kind|profile>\n'); return 2; }
6130
+ const cfg = _sandboxLoadConfigOrEmpty();
6131
+ // If `name` looks like a known kind, route to that kind. If it
6132
+ // is not a known kind AND not a profile in cfg, treat as an
6133
+ // unknown identifier and report SANDBOX_BAD_KIND.
6134
+ const isKind = listBackends().includes(name);
6135
+ const profile = cfg.sandbox && cfg.sandbox.profiles && cfg.sandbox.profiles[name];
6136
+ if (!isKind && !profile) {
6137
+ process.stderr.write(`SANDBOX_BAD_KIND: unknown sandbox kind or profile "${name}"\n`);
6138
+ return 1;
6139
+ }
6140
+ let sb;
6141
+ try {
6142
+ const synthCfg = isKind
6143
+ ? { sandbox: { default: name, ...cfg.sandbox } }
6144
+ : cfg;
6145
+ sb = resolveSandbox(synthCfg);
6146
+ } catch (e) {
6147
+ process.stderr.write(`${e.code || 'SANDBOX_ERR'}: ${e.message}\n`); return 1;
6148
+ }
6149
+ if (sb.spec.kind !== 'local' && sb.spec.kind !== 'docker') {
6150
+ // Remote/serverless backends just construct argv in unit tests;
6151
+ // we report "shape-ok" without actually executing.
6152
+ process.stdout.write(`ok ${sb.spec.kind} (argv-shape)\n`);
6153
+ return 0;
6154
+ }
6155
+ const sess = await sb.open();
6156
+ try {
6157
+ const r = await sess.exec(['echo', 'lazyclaw-sandbox-test']);
6158
+ if (r.code !== 0 || !/lazyclaw-sandbox-test/.test(r.stdout)) {
6159
+ process.stderr.write(`fail ${name}: exit=${r.code} stdout=${r.stdout}\n`); return 1;
6160
+ }
6161
+ process.stdout.write(`ok ${name}\n`);
6162
+ return 0;
6163
+ } finally { await sess.close(); }
6164
+ }
6165
+
6166
+ if (sub === 'add') {
6167
+ const name = args[1];
6168
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox add <name> --kind <kind> [...]\n'); return 2; }
6169
+ const opts = {};
6170
+ if (flags.kind) opts.kind = flags.kind;
6171
+ if (flags.image) opts.image = flags.image;
6172
+ if (flags.host) opts.host = flags.host;
6173
+ if (flags.user) opts.user = flags.user;
6174
+ if (flags.workspace) opts.workspace = flags.workspace;
6175
+ if (flags.app) opts.app = flags.app;
6176
+ if (flags.confiner) opts.confiner = flags.confiner;
6177
+ if (!listBackends().includes(opts.kind)) {
6178
+ process.stderr.write(`unknown kind "${opts.kind}"\n`); return 1;
6179
+ }
6180
+ const cfg = _sandboxLoadConfigOrEmpty();
6181
+ cfg.sandbox = cfg.sandbox || {};
6182
+ cfg.sandbox.profiles = cfg.sandbox.profiles || {};
6183
+ cfg.sandbox.profiles[name] = opts;
6184
+ _sandboxSaveConfig(cfg);
6185
+ process.stdout.write(`added profile ${name} (${opts.kind})\n`);
6186
+ return 0;
6187
+ }
6188
+
6189
+ if (sub === 'use') {
6190
+ const name = args[1];
6191
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox use <profile>\n'); return 2; }
6192
+ const cfg = _sandboxLoadConfigOrEmpty();
6193
+ const prof = cfg.sandbox && cfg.sandbox.profiles && cfg.sandbox.profiles[name];
6194
+ if (!prof) { process.stderr.write(`no profile "${name}"\n`); return 1; }
6195
+ cfg.sandbox = cfg.sandbox || {};
6196
+ cfg.sandbox.default = prof.kind;
6197
+ cfg.sandbox[prof.kind] = { ...(cfg.sandbox[prof.kind] || {}), ...prof, kind: undefined };
6198
+ delete cfg.sandbox[prof.kind].kind;
6199
+ _sandboxSaveConfig(cfg);
6200
+ process.stdout.write(`using profile ${name} (${prof.kind})\n`);
6201
+ return 0;
6202
+ }
6203
+
6204
+ process.stderr.write(`unknown subcommand "${sub}". Try: list | test | add | use\n`);
6205
+ return 2;
6206
+ }
6207
+
6208
+ function _sandboxLoadConfigOrEmpty() {
6209
+ const p = process.env.LAZYCLAW_CONFIG || configPath();
6210
+ try {
6211
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
6212
+ } catch { return {}; }
6213
+ }
6214
+
6215
+ function _sandboxSaveConfig(cfg) {
6216
+ const p = process.env.LAZYCLAW_CONFIG || configPath();
6217
+ fs.mkdirSync(path.dirname(p), { recursive: true });
6218
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
6219
+ }
6220
+
5964
6221
  function cmdConfigGet(key) {
5965
6222
  const cfg = readConfig();
5966
- if (key) console.log(JSON.stringify({ key, value: cfg[key] ?? null }));
5967
- else console.log(JSON.stringify(cfg));
6223
+ if (!key) { console.log(JSON.stringify(cfg)); return; }
6224
+ let value = cfg;
6225
+ for (const seg of String(key).split('.')) {
6226
+ if (value && typeof value === 'object' && seg in value) value = value[seg];
6227
+ else { value = null; break; }
6228
+ }
6229
+ console.log(JSON.stringify({ key, value }));
5968
6230
  }
5969
6231
 
5970
6232
  // Structural integrity check across the whole config. Distinct from
@@ -6325,6 +6587,8 @@ async function _dispatchMenuChoice(argv) {
6325
6587
  case 'inspect': return await cmdInspect(rest[0], {});
6326
6588
  case 'export': return await cmdExport({});
6327
6589
  case 'version': return await cmdVersion();
6590
+ // Phase G — persona compose subcommand (spec §9, decision C7).
6591
+ case 'personality': return await cmdPersonality(rest[0], rest[1], rest[2]);
6328
6592
  // help <cmd> is the safe fallback for commands that need real
6329
6593
  // arguments (run / resume / clear / validate / graph / daemon /
6330
6594
  // import / completion). Print the usage so the user can re-launch
@@ -6761,6 +7025,105 @@ async function main() {
6761
7025
  }
6762
7026
  break;
6763
7027
  }
7028
+ case 'personality': {
7029
+ // Phase G: persona compose subcommand (spec §9, decision C7).
7030
+ process.exit(await cmdPersonality(rest.positional[0], rest.positional[1], rest.positional[2]));
7031
+ break;
7032
+ }
7033
+ case 'migrate': {
7034
+ // Phase A baseline accepts `lazyclaw migrate v5`; Phase G adds the
7035
+ // bare `lazyclaw migrate` and `lazyclaw migrate rollback` forms.
7036
+ const target = rest.positional[0];
7037
+ if (target === 'rollback') {
7038
+ const mod = await import('./scripts/migrate-v5.mjs');
7039
+ try {
7040
+ const { restoredFrom } = mod.rollback();
7041
+ console.log(`rolled back from ${restoredFrom}`);
7042
+ process.exit(0);
7043
+ } catch (e) {
7044
+ console.error(`migrate failed: ${e.message}`);
7045
+ process.exit(1);
7046
+ }
7047
+ break;
7048
+ }
7049
+ const mod = await import('./scripts/migrate-v5.mjs');
7050
+ // `migrate v5` keeps the Phase-A behaviour (verbose JSON); the
7051
+ // bare `migrate` form uses the Phase-G human summary.
7052
+ if (target === 'v5') {
7053
+ const r = await mod.migrateV5();
7054
+ console.log(JSON.stringify(r, null, 2));
7055
+ process.exit(r.ok ? 0 : 1);
7056
+ }
7057
+ try {
7058
+ const { backupDir } = mod.migrate();
7059
+ console.log(`migrated; backup at ${backupDir}`);
7060
+ process.exit(0);
7061
+ } catch (e) {
7062
+ console.error(`migrate failed: ${e.message}`);
7063
+ process.exit(1);
7064
+ }
7065
+ break;
7066
+ }
7067
+ case 'hermes': {
7068
+ // Phase G: import a Hermes Agent install (spec §10).
7069
+ if (rest.positional[0] !== 'import') {
7070
+ console.error('Usage: lazyclaw hermes import [--from <dir>]');
7071
+ process.exit(2);
7072
+ }
7073
+ const from = rest.flags.from;
7074
+ const mod = await import('./scripts/hermes-import.mjs');
7075
+ try {
7076
+ const { src, dst, counts } = mod.importHermes({ from });
7077
+ console.log(`hermes import: ${src} → ${dst}`);
7078
+ console.log(` skills: ${counts.skills} skins: ${counts.skins}`);
7079
+ process.exit(0);
7080
+ } catch (e) { console.error(`hermes import failed: ${e.message}`); process.exit(1); }
7081
+ break;
7082
+ }
7083
+ case 'openclaw': {
7084
+ // Phase G: import an OpenClaw install (spec §10).
7085
+ if (rest.positional[0] !== 'import') {
7086
+ console.error('Usage: lazyclaw openclaw import [--from <dir>]');
7087
+ process.exit(2);
7088
+ }
7089
+ const from = rest.flags.from;
7090
+ const mod = await import('./scripts/openclaw-import.mjs');
7091
+ try {
7092
+ const { src, dst, counts } = mod.importOpenclaw({ from });
7093
+ console.log(`openclaw import: ${src} → ${dst} skills:${counts.skills}`);
7094
+ process.exit(0);
7095
+ } catch (e) { console.error(`openclaw import failed: ${e.message}`); process.exit(1); }
7096
+ break;
7097
+ }
7098
+ case 'trajectories': {
7099
+ // Phase H1: read-only trajectory exporter (spec §2.7).
7100
+ // Usage: lazyclaw trajectories export --format <atropos|axolotl|openai-ft|jsonl>
7101
+ // [--since 7d] [--filter "outcome=done"] [--out ./dir]
7102
+ if (rest.positional[0] !== 'export') {
7103
+ console.error('Usage: lazyclaw trajectories export --format <atropos|axolotl|openai-ft|jsonl> [--since 7d] [--filter "outcome=done"] [--out <dir>]');
7104
+ process.exit(2);
7105
+ }
7106
+ const mod = await import('./mas/trajectory_export.mjs');
7107
+ const format = rest.flags.format || 'jsonl';
7108
+ if (!mod.FORMATS.includes(format)) {
7109
+ console.error(`trajectories export: unknown format "${format}" — choose ${mod.FORMATS.join('|')}`);
7110
+ process.exit(2);
7111
+ }
7112
+ try {
7113
+ const r = await mod.exportTrajectories({
7114
+ format,
7115
+ since: rest.flags.since,
7116
+ filter: mod.parseFilterArg(rest.flags.filter),
7117
+ outDir: rest.flags.out,
7118
+ });
7119
+ console.log(`exported ${r.count} trajectories (${r.format}) → ${r.outFile}`);
7120
+ process.exit(0);
7121
+ } catch (e) {
7122
+ console.error(`trajectories export failed: ${e.message}`);
7123
+ process.exit(1);
7124
+ }
7125
+ break;
7126
+ }
6764
7127
  case 'chat': {
6765
7128
  await cmdChat(rest.flags);
6766
7129
  break;
@@ -6790,6 +7153,10 @@ async function main() {
6790
7153
  await cmdRates(sub, rest.positional.slice(1), rest.flags);
6791
7154
  break;
6792
7155
  }
7156
+ case 'sandbox': {
7157
+ process.exit(await cmdSandbox(rest.positional, rest.flags));
7158
+ break;
7159
+ }
6793
7160
  case 'auth': {
6794
7161
  const sub = rest.positional[0];
6795
7162
  await cmdAuth(sub, rest.positional.slice(1), rest.flags);
@@ -6877,6 +7244,31 @@ async function main() {
6877
7244
  await cmdDashboard(rest.flags);
6878
7245
  break;
6879
7246
  }
7247
+ case 'channels': {
7248
+ const sub = (rest.positional[0] || 'list').toLowerCase();
7249
+ const { createLoader, listInstalled } = await import('./channels/loader.mjs');
7250
+ const cfgDir = path.dirname(configPath());
7251
+ const loader = createLoader({ configDir: cfgDir });
7252
+ if (sub === 'install') {
7253
+ const name = rest.positional[1];
7254
+ if (!name) { process.stderr.write('usage: lazyclaw channels install <@lazyclaw/channel-name>\n'); process.exit(2); }
7255
+ const info = await loader.install(name);
7256
+ process.stdout.write(`installed ${info.name}@${info.version}\n`);
7257
+ break;
7258
+ }
7259
+ if (sub === 'remove' || sub === 'uninstall') {
7260
+ const name = rest.positional[1];
7261
+ if (!name) { process.stderr.write('usage: lazyclaw channels remove <@lazyclaw/channel-name>\n'); process.exit(2); }
7262
+ await loader.remove(name);
7263
+ process.stdout.write(`removed ${name}\n`);
7264
+ break;
7265
+ }
7266
+ // list
7267
+ const rows = listInstalled(cfgDir);
7268
+ if (!rows.length) { process.stdout.write('no channel plugins installed\n'); break; }
7269
+ for (const r of rows) process.stdout.write(`${r.name}\t${r.version}\n`);
7270
+ break;
7271
+ }
6880
7272
  case 'daemon': {
6881
7273
  await cmdDaemon(rest.flags);
6882
7274
  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
  }