lazyclaw 5.0.9 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,14 +29,20 @@ Four things no other terminal agent CLI does together:
29
29
  3. **Cross-channel handoff.** `/handoff slack <channel-id>` moves the live conversation; context follows.
30
30
  4. **Six sandbox backends, one API.** `local` / `docker` / `ssh` / `singularity` / `modal` / `daytona`.
31
31
 
32
+ ## What's new in 5.0.x
33
+
34
+ The 5.0.3 → 5.0.9 maintenance line is mostly a launcher and splash overhaul. `lazyclaw` with no arguments now drops you straight into chat — the arrow-key picker has moved to `lazyclaw menu`. The splash itself is responsive: a Larry-3D gradient wordmark, braille-rendered sloth hero, a tool catalog driven by `mas/tools/registry.mjs` (no more hand-edited lists), grouped skills, and a Hermes-style status bar that auto-collapses to a single column on narrow terminals.
35
+
36
+ Per-release notes live in [CHANGELOG.md](./CHANGELOG.md).
37
+
32
38
  ## Known limitations (v5.1 roadmap)
33
39
 
34
40
  Calibrate expectations before reading the rest:
35
41
 
36
- - `recall` is callable from inside chat today; the top-level `lazyclaw recall ...` CLI shape ships in v5.1.
42
+ - Recall today is reachable via `lazyclaw loop --recall "<query>" ...` and `lazyclaw goal ... --recall "<query>"`; the `/recall` slash command and top-level `lazyclaw recall ...` CLI shape both ship in v5.1.
37
43
  - `lazyclaw sandbox` exposes `list | test | add | use`; the `sandbox run --backend ...` shape lands in v5.1.
38
44
  - `codex-cli` and `gemini-cli` provider modules are tracked but not yet registered in the main runtime.
39
- - E2E matrix ships with 32 of 48 flows marked `test.skip` pending v5.1 wiring; the min-green-set is documented in `tests/e2e/phaseH-e2e-matrix.spec.ts`.
45
+ - The E2E matrix in `tests/e2e/phaseH-e2e-matrix.spec.ts` still has a number of flows marked `test.skip` pending v5.1 wiring; the min-green-set is documented at the top of that file.
40
46
 
41
47
  ## Install
42
48
 
@@ -54,9 +60,10 @@ npm install && npm link
54
60
 
55
61
  ```bash
56
62
  lazyclaw version
57
- # → { "version": "5.0.0", "nodeVersion": "v20.11.0", "platform": "darwin" }
58
63
  ```
59
64
 
65
+ Output reports the current installed version, Node version, and platform. See [CHANGELOG.md](./CHANGELOG.md) for release notes.
66
+
60
67
  Requires **Node 18+**. macOS / Linux / WSL are first-class. Native PowerShell runs but ghost-text and the ANSI banner fall back to plain prompts.
61
68
 
62
69
  ## First run
@@ -69,6 +76,8 @@ lazyclaw onboard # arrow-key picker; defaults to claude-cli (no key)
69
76
  <img src="docs/screenshots/onboard.png" alt="lazyclaw onboard --non-interactive" width="720">
70
77
 
71
78
  ```bash
79
+ lazyclaw # no-arg → chat (since v5.0.6)
80
+ lazyclaw menu # arrow-key launcher (formerly the no-arg default)
72
81
  lazyclaw status # → { provider, model, hasApiKey }
73
82
  lazyclaw doctor # validates config + provider registry + index.db
74
83
  ```
@@ -124,10 +133,16 @@ Plus two meta-providers, usable in either slot:
124
133
 
125
134
  ## What it ships
126
135
 
127
- ### Around 50 tools plus MCP
128
- A unified registry covers fs, exec, web, os, coding, git (5 read + 2 sensitive), scheduling, delegation, media, ha, clarify, browser, and learning groups. Sensitive tools route through an approval hook. Bring external servers in over stdio MCP.
136
+ ### Tool registry 12 categories plus MCP
137
+
138
+ A single registry (`mas/tools/registry.mjs`) aggregates every first-party tool across twelve categories:
139
+
140
+ `agents` · `browser` · `coding` · `exec` · `fs` · `git` · `iot` · `learning` · `media` · `net` · `os` · `scheduling`
141
+
142
+ Tools flagged `sensitive: true` (writes, network egress, shell exec, sensitive git ops) route through an approval hook before execution. The splash renderer, the agent toolset resolver, and the runtime all read from the same registry, so a new tool group lights up everywhere at once. External servers extend the registry over stdio MCP.
129
143
 
130
144
  ### Channels that hand off
145
+
131
146
  First-class channels for Slack, Discord, Telegram, Matrix, Email, and Voice. Signal and WhatsApp ship as full implementations with external runtime dependencies (`signal-cli` for Signal; `whatsapp-web.js` browser automation with QR-on-first-run for WhatsApp). Move a live conversation between any two with `/handoff <target> <externalId>` — no other agent CLI does cross-channel handoff.
132
147
 
133
148
  ```bash
@@ -139,19 +154,23 @@ lazyclaw channels install @lazyclaw/channel-discord
139
154
  Slack is built-in and does not need `channels install`. The plugin loader expects `@lazyclaw/channel-<name>` npm package names.
140
155
 
141
156
  ### Personas that compose
157
+
142
158
  Swap personality per channel without losing session memory. Layers compose top-down: global SOUL → workspace SOUL → active personality → agent role → user model (USER.md) → skill bank → memory core → recent trajectory tail. See [docs/persona-cookbook.md](./docs/persona-cookbook.md).
143
159
 
144
160
  ### Loops and scheduled goals
161
+
145
162
  Durable foreground or `--detach` loops; cron-scheduled goals with channel fan-out. State lives in `~/.lazyclaw/loops/<id>/` and survives restart. See [docs/loop-goal-preflight.md](./docs/loop-goal-preflight.md).
146
163
 
147
164
  ### A TUI that ghosts the right answer
148
- Ink-based UI with two-column splash, sloth ASCII banner, Cursor-style ghost autocomplete (`→` accepts, `Tab` cycles), interrupt-and-redirect REPL, multiline editor, and a fixed 4-line footer with live cost rate cards.
165
+
166
+ Ink-based UI with a responsive splash (Larry-3D gradient wordmark, sloth braille hero, grouped subcommand catalog, registry-backed tool list, filename-grouped skills, Hermes-style status bar — auto-collapses to single-column on narrow terminals), Cursor-style ghost autocomplete (`→` accepts, `Tab` cycles), interrupt-and-redirect REPL, and multiline editor.
149
167
 
150
168
  ## Command reference
151
169
 
152
170
  | Command | Purpose |
153
171
  |---|---|
154
- | `lazyclaw` | Interactive menu; type a slash command or pick a subcommand |
172
+ | `lazyclaw` | No-arg drops into chat (since v5.0.6) |
173
+ | `lazyclaw menu` | Arrow-key launcher (formerly the no-arg default) |
155
174
  | `lazyclaw onboard` | Arrow-key setup; writes `~/.lazyclaw/config.json` |
156
175
  | `lazyclaw status` | Print active provider / model / masked key |
157
176
  | `lazyclaw doctor` | Validate config, provider registry, FTS5 index |
@@ -163,7 +182,9 @@ Ink-based UI with two-column splash, sloth ASCII banner, Cursor-style ghost auto
163
182
  | `lazyclaw channels install\|list\|remove` | Channel plugin lifecycle (`@lazyclaw/channel-<name>`) |
164
183
  | `lazyclaw trajectories export --format ...` | Export to atropos / axolotl / openai-ft / jsonl |
165
184
  | `lazyclaw personality use\|list\|show` | Activate / inspect personas |
185
+ | `lazyclaw dashboard` | Local web UI on `127.0.0.1:19600` |
166
186
  | `lazyclaw migrate v5` | v4 → v5 with backup |
187
+ | `lazyclaw recall` | (v5.1) Top-level recall over the FTS5 index — today, use `loop`/`goal` with `--recall "<query>"` |
167
188
  | `lazyclaw version` / `lazyclaw help` | Version + subcommand help |
168
189
 
169
190
  In-REPL slash commands include `/help`, `/status`, `/provider`, `/model`, `/skill`, `/loop`, `/goal`, `/memory`, `/agent`, `/team`, `/handoff`, `/personality`, `/exit`. Depth lives in `lazyclaw <cmd> --help` and the docs below.
@@ -189,7 +210,7 @@ Full guide: [docs/migration-v4-to-v5.md](./docs/migration-v4-to-v5.md).
189
210
  |---|---|---|
190
211
  | Start a chat | `lazyclaw chat` | `@lazyclaw <message>` |
191
212
  | Hand off to another surface | `/handoff slack <channel-id>` | `/handoff tui <thread-id>` |
192
- | Recall across history | `/recall "<query>"` inside chat (top-level CLI v5.1) | `/recall "<query>"` inside chat |
213
+ | Recall across history | `lazyclaw loop --recall "<query>" ...` (slash + top-level v5.1) | `lazyclaw loop --recall "<query>" ...` (channel-side `/recall` v5.1) |
193
214
  | Switch persona | `/personality use terse` | `/personality use terse` |
194
215
  | Switch model | `/model <name>` | `/model <name>` |
195
216
  | Show status | `lazyclaw status` | `@lazyclaw status` |
package/agents.mjs CHANGED
@@ -80,13 +80,15 @@ function defaultShape(name) {
80
80
  // for `lazyclaw agent reflect`; 'off' disables writes entirely.
81
81
  memoryWrite: 'auto',
82
82
  memoryMaxChars: 12 * 1024,
83
- // Phase 20 — self-improving skill synthesis trigger. 'manual'
84
- // (default) means a skill is only written when the user runs
85
- // `lazyclaw agent skill-synth`; 'auto' fires synthesis on terminal
86
- // `done` alongside reflection; 'off' disables it. Defaults to
87
- // 'manual' (unlike memoryWrite) because a synthesised SKILL.md
88
- // feeds every future agent's prompt, so we keep it opt-in.
89
- skillWrite: 'manual',
83
+ // v5 Group A (M3) — self-improving skill synthesis trigger.
84
+ // Default flipped to 'auto' so the learning loop actually closes
85
+ // end-to-end on a fresh install: every agent that finishes a task
86
+ // contributes a SKILL.md unless the operator explicitly opted out
87
+ // ('manual' waits for `lazyclaw agent skill-synth`; 'off' disables).
88
+ // The canonical post-task hook (mas/learning.mjs) also reads
89
+ // `(skillWrite ?? 'auto')`, so v4 records that pre-date this field
90
+ // get the new default without a forced migration.
91
+ skillWrite: 'auto',
90
92
  createdAt: new Date().toISOString(),
91
93
  updatedAt: new Date().toISOString(),
92
94
  };
@@ -114,7 +116,7 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
114
116
  if (!VALID_MEMORY_WRITE.includes(mw)) {
115
117
  throw new AgentError(`memoryWrite must be one of ${VALID_MEMORY_WRITE.join(', ')}`, 'AGENT_BAD_MEMORY_WRITE');
116
118
  }
117
- const sw = skillWrite ?? 'manual';
119
+ const sw = skillWrite ?? 'auto';
118
120
  if (!VALID_SKILL_WRITE.includes(sw)) {
119
121
  throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
120
122
  }
@@ -0,0 +1,39 @@
1
+ // Group B / M6 — chat sliding window helper.
2
+ //
3
+ // A multi-day chat session accumulates hundreds of turns; without a
4
+ // cap the prompt grows linearly and the Anthropic prompt cache
5
+ // breakpoint advances past the useful prefix. The window keeps the
6
+ // last N turns (default 20) AND honours a token budget (default ~8K
7
+ // chars-as-tokens, since `inputTokens` from the model isn't available
8
+ // pre-call). The system message at index 0 is always preserved — it's
9
+ // the agent.role + workspace + skills index that the static cache
10
+ // prefix depends on.
11
+ //
12
+ // Env overrides let operators stretch the window for long-running
13
+ // research sessions without recompiling. Lives in its own file so it
14
+ // can be imported by tests without invoking cli.mjs::main().
15
+
16
+ export const CHAT_WINDOW_TURNS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TURNS) || 20;
17
+ export const CHAT_WINDOW_TOKEN_BUDGET = Number(process.env.LAZYCLAW_CHAT_WINDOW_TOKENS) || 8000;
18
+
19
+ // Trim a hydrated messages[] array to fit the sliding window. Returns
20
+ // { messages, dropped } so the caller can log a one-shot "dropped N
21
+ // older turns" line at session start. The first message is preserved
22
+ // when its role is 'system' (cacheable static prefix). Token budget
23
+ // is approximated as 4 chars/token — accurate enough for a soft cap
24
+ // when the goal is "don't pay for messages the model won't use".
25
+ export function applyChatWindow(messages, { turns = CHAT_WINDOW_TURNS, tokens = CHAT_WINDOW_TOKEN_BUDGET } = {}) {
26
+ if (!Array.isArray(messages) || messages.length === 0) return { messages, dropped: 0 };
27
+ const out = [...messages];
28
+ const sys = out[0]?.role === 'system' ? out.shift() : null;
29
+ const before = out.length;
30
+ // Turn count cap: drop oldest non-system turns until length ≤ turns.
31
+ while (out.length > turns) out.shift();
32
+ // Token budget cap: estimate 4 chars / token, then trim from the
33
+ // front (oldest) until estimated tokens ≤ budget.
34
+ const estTokens = (arr) => Math.ceil(arr.reduce((n, m) => n + String(m.content || '').length, 0) / 4);
35
+ while (out.length > 1 && estTokens(out) > tokens) out.shift();
36
+ const dropped = before - out.length;
37
+ if (sys) out.unshift(sys);
38
+ return { messages: out, dropped };
39
+ }
package/cli.mjs CHANGED
@@ -8,6 +8,12 @@ import { pathToFileURL } from 'node:url';
8
8
  import { resolveSandbox, listBackends } from './sandbox/index.mjs';
9
9
  // Phase G: defaultConfigDir for personality subcommand (spec §9, decision C7).
10
10
  import { defaultConfigDir as _persDefaultCfg } from './memory.mjs';
11
+ // Group B / M6 — chat sliding window. Lives in its own module so
12
+ // tests can import the helper without invoking cli.mjs::main().
13
+ import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from './chat_window.mjs';
14
+ // v5 Group C (C7) — shared chat-turn streaming closure. Single source
15
+ // of truth for both the ink REPL path and the legacy readline path.
16
+ import { makeRunTurn as _chatRunTurnFactory } from './tui/run_turn.mjs';
11
17
 
12
18
  async function loadEngine() {
13
19
  return import('./workflow/persistent.mjs');
@@ -858,6 +864,58 @@ async function cmdDoctor() {
858
864
  if (cfg.provider && !PROVIDERS_HAS(_registryMod.PROVIDERS, cfg.provider)) {
859
865
  issues.push(`unknown provider "${cfg.provider}" — registered: ${Object.keys(_registryMod.PROVIDERS).join(', ')}`);
860
866
  }
867
+ // C12 — MinGit / Windows safety net. mas/tools/git.mjs shells out to
868
+ // `git`; on a stripped Windows PATH (no Git-for-Windows installed) or
869
+ // a minimal Docker base image, that spawnSync ENOENTs and any agent
870
+ // task touching the git tool fails opaquely. Probe up-front so
871
+ // `lazyclaw doctor` surfaces a clean diagnostic and the operator can
872
+ // install the binary before they trip over it.
873
+ let gitInfo = null;
874
+ try {
875
+ const { spawnSync } = await import('node:child_process');
876
+ const gitExe = process.env.GIT_EXECUTABLE || 'git';
877
+ const probe = spawnSync(gitExe, ['--version'], { encoding: 'utf8' });
878
+ if (probe.error && probe.error.code === 'ENOENT') {
879
+ issues.push('git binary not found on PATH — `mas/tools/git.mjs` will fail. Install Git: macOS `xcode-select --install`; Linux `apt install git` / `yum install git`; Windows Git-for-Windows (https://git-scm.com/download/win). Or set the GIT_EXECUTABLE env var to an explicit path.');
880
+ gitInfo = { ok: false, code: 'ENOENT' };
881
+ } else if (probe.status !== 0) {
882
+ issues.push(`git --version exited ${probe.status} (${(probe.stderr || '').trim().slice(0, 200)})`);
883
+ gitInfo = { ok: false, status: probe.status, stderr: (probe.stderr || '').trim().slice(0, 200) };
884
+ } else {
885
+ gitInfo = { ok: true, version: (probe.stdout || '').trim() };
886
+ }
887
+ } catch (e) {
888
+ gitInfo = { ok: false, error: e?.message || String(e) };
889
+ }
890
+ // m11 — stale index probe. mas/index_db.mjs write-through hooks
891
+ // log failures to <configDir>/index-failures.jsonl; surface a recent
892
+ // count so the operator notices a silently-degraded index. Best-effort:
893
+ // missing file → 0 failures (the common case).
894
+ let indexInfo = null;
895
+ try {
896
+ const cfgDir = path.dirname(configPath());
897
+ const auditFile = path.join(cfgDir, 'index-failures.jsonl');
898
+ if (fs.existsSync(auditFile)) {
899
+ const raw = fs.readFileSync(auditFile, 'utf8');
900
+ const lines = raw.split('\n').filter(Boolean);
901
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
902
+ let recent = 0;
903
+ for (const line of lines) {
904
+ try {
905
+ const entry = JSON.parse(line);
906
+ if (entry?.ts && new Date(entry.ts).getTime() >= cutoff) recent++;
907
+ } catch { /* skip malformed */ }
908
+ }
909
+ indexInfo = { failuresLast24h: recent, totalFailures: lines.length };
910
+ if (recent > 0) {
911
+ issues.push(`${recent} index write failure(s) in the last 24h — run \`lazyclaw index rebuild\` to recover.`);
912
+ }
913
+ } else {
914
+ indexInfo = { failuresLast24h: 0, totalFailures: 0 };
915
+ }
916
+ } catch (e) {
917
+ indexInfo = { error: e?.message || String(e) };
918
+ }
861
919
  // Workflow state health — informational counters that show whether
862
920
  // the user has any failed or stuck workflow runs to attend to. We
863
921
  // don't push these to `issues` (a stuck workflow doesn't break the
@@ -901,6 +959,8 @@ async function cmdDoctor() {
901
959
  issues,
902
960
  knownProviders: Object.keys(_registryMod.PROVIDERS),
903
961
  workflows,
962
+ git: gitInfo,
963
+ index: indexInfo,
904
964
  timestamp: new Date().toISOString(),
905
965
  };
906
966
  console.log(JSON.stringify(out, null, 2));
@@ -1299,7 +1359,7 @@ const HELP_DETAILS = {
1299
1359
  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
1360
  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
1361
  setup: 'Usage: lazyclaw setup [--skip-test]\n OpenClaw-style multi-step first-run wizard. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick)\n 2. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 3. Optional skill bundle install from GitHub\n 4. Optional outbound webhook (Slack / Discord)\n 5. Reachability test against the picked provider\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
1302
- dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n Tabs: Chat · Sessions · Skills · Workspace · Providers · Status. Each tab calls existing daemon endpoints.\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
1362
+ 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
1363
  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
1364
  };
1305
1365
 
@@ -1694,6 +1754,7 @@ function _printChatBanner(activeProvName, activeModel, version) {
1694
1754
  process.stdout.write(lines.join('\n') + '\n');
1695
1755
  }
1696
1756
 
1757
+
1697
1758
  // Interactive provider/model picker. Used on first run (no config) or
1698
1759
  // when the user passes --pick. Falls back to plain stdin reads when
1699
1760
  // stdout isn't a TTY (CI/script callers should pass --non-interactive
@@ -2469,9 +2530,113 @@ async function cmdChat(flags = {}) {
2469
2530
  skills: skillGroups,
2470
2531
  };
2471
2532
  void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
2533
+
2534
+ // C7 — minimal chat-session state for the ink path so runTurn can
2535
+ // talk to the provider (the legacy readline path below sets up the
2536
+ // same shape — kept duplicated here intentionally so the ink branch
2537
+ // remains self-contained and the legacy path stays byte-identical).
2538
+ // Slash commands aren't wired into the ink REPL yet (v5.1 follow-up);
2539
+ // until then, system-prompt composition / --session resume happen
2540
+ // identically to the legacy path.
2541
+ let _inkSandboxSpec = null;
2542
+ if (flags.sandbox) {
2543
+ const sb = await import('./sandbox.mjs');
2544
+ try { _inkSandboxSpec = sb.parseSandboxSpec(flags.sandbox, flags); }
2545
+ catch (err) { console.error(`error: ${err.message}`); process.exit(2); }
2546
+ }
2547
+ let _inkSessionId = flags.session || null;
2548
+ const _inkCfgDir = path.dirname(configPath());
2549
+ let _inkMessages = _inkSessionId
2550
+ ? sessionsMod.loadTurns(_inkSessionId, _inkCfgDir).map((t) => ({ role: t.role, content: t.content }))
2551
+ : [];
2552
+ if (_inkMessages.length > 0) {
2553
+ const cfgChat = cfg.chat || {};
2554
+ const winTurns = Number(cfgChat.windowTurns) || CHAT_WINDOW_TURNS;
2555
+ const winTokens = Number(cfgChat.windowTokens) || CHAT_WINDOW_TOKEN_BUDGET;
2556
+ const { messages: trimmed } = _applyChatWindow(_inkMessages, { turns: winTurns, tokens: winTokens });
2557
+ _inkMessages = trimmed;
2558
+ }
2559
+ // System prompt composition — mirrors the legacy path's sysParts logic.
2560
+ const _inkSkillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
2561
+ .split(',').map((s) => s.trim()).filter(Boolean);
2562
+ const _inkWorkspaceName = flags.workspace || cfg.workspace || '';
2563
+ const _inkSysParts = [];
2564
+ try {
2565
+ const { composePromptStack } = await import('./mas/prompt_stack.mjs');
2566
+ const stacked = composePromptStack({
2567
+ cfgDir: _inkCfgDir,
2568
+ agent: { name: 'chat', role: '' },
2569
+ workspace: _inkWorkspaceName,
2570
+ });
2571
+ if (stacked && stacked.trim()) _inkSysParts.push(stacked);
2572
+ } catch { /* never block chat start on stack composition */ }
2573
+ if (_inkWorkspaceName && !_inkMessages.some((m) => m.role === 'system')) {
2574
+ try {
2575
+ const ws = await import('./workspace.mjs');
2576
+ const wsPrompt = ws.composeWorkspacePrompt(_inkCfgDir, _inkWorkspaceName);
2577
+ if (wsPrompt) _inkSysParts.push(wsPrompt);
2578
+ } catch (err) { console.error(`workspace error: ${err.message}`); process.exit(2); }
2579
+ }
2580
+ if (_inkSkillNames.length > 0 && !_inkMessages.some((m) => m.role === 'system')) {
2581
+ try {
2582
+ const sys = skillsMod.composeSystemPrompt(_inkSkillNames, _inkCfgDir);
2583
+ if (sys) _inkSysParts.push(sys);
2584
+ } catch (err) { console.error(`skill error: ${err.message}`); process.exit(2); }
2585
+ }
2586
+ if (_inkSysParts.length && !_inkMessages.some((m) => m.role === 'system')) {
2587
+ const merged = _inkSysParts.join('\n\n---\n\n');
2588
+ _inkMessages.unshift({ role: 'system', content: merged });
2589
+ if (_inkSessionId) sessionsMod.appendTurn(_inkSessionId, 'system', merged, _inkCfgDir);
2590
+ }
2591
+ let _inkRunningUsage = null;
2592
+ const _inkAccumulateUsage = (u) => {
2593
+ if (!u) return;
2594
+ if (!_inkRunningUsage) _inkRunningUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turnsWithUsage: 0 };
2595
+ _inkRunningUsage.inputTokens += Number(u.inputTokens) || 0;
2596
+ _inkRunningUsage.outputTokens += Number(u.outputTokens) || 0;
2597
+ _inkRunningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
2598
+ _inkRunningUsage.turnsWithUsage += 1;
2599
+ };
2600
+ const _inkChatStartedAt = Date.now();
2601
+ const _inkSyntheticChatSessionId = `chat-${process.pid}-${_inkChatStartedAt}`;
2602
+ const _inkPersistTurn = (role, content) => {
2603
+ if (_inkSessionId) {
2604
+ sessionsMod.appendTurn(_inkSessionId, role, content, _inkCfgDir);
2605
+ return;
2606
+ }
2607
+ try {
2608
+ import('./memory.mjs').then((m) => {
2609
+ try { m.appendRecent(_inkSyntheticChatSessionId, role, content, _inkCfgDir); }
2610
+ catch { /* swallow */ }
2611
+ }).catch(() => {});
2612
+ } catch { /* swallow */ }
2613
+ };
2614
+ const _inkCtx = {
2615
+ cfg,
2616
+ cfgDir: _inkCfgDir,
2617
+ sandboxSpec: _inkSandboxSpec,
2618
+ syntheticChatSessionId: _inkSyntheticChatSessionId,
2619
+ getMessages: () => _inkMessages,
2620
+ getProv: () => prov,
2621
+ getActiveProvName: () => activeProvName,
2622
+ getActiveModel: () => activeModel,
2623
+ getSessionId: () => _inkSessionId,
2624
+ persistTurn: _inkPersistTurn,
2625
+ accumulateUsage: _inkAccumulateUsage,
2626
+ resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
2627
+ };
2628
+ // v5.0.10: write streamed chunks straight to process.stdout. Ink
2629
+ // owns the screen, so interleaved stdout writes can produce some
2630
+ // visual jank — accepted trade for unblocking the chat loop. v5.1
2631
+ // TODO: route through a ref'd scrollback <Static/> region in
2632
+ // ReplApp so Ink owns all output.
2633
+ const _inkRunTurn = _chatRunTurnFactory({
2634
+ ctx: _inkCtx,
2635
+ writeFn: (chunk) => process.stdout.write(chunk),
2636
+ });
2472
2637
  const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
2473
2638
  splashProps,
2474
- runTurn: async (_text, _signal) => { void _text; void _signal; },
2639
+ runTurn: _inkRunTurn,
2475
2640
  }));
2476
2641
  await ink.waitUntilExit();
2477
2642
  return;
@@ -2529,6 +2694,24 @@ async function cmdChat(flags = {}) {
2529
2694
  ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
2530
2695
  : [];
2531
2696
 
2697
+ // M6 — apply sliding window at session start. Long-running sessions
2698
+ // (50+ turns) used to ship every prior turn to the provider every
2699
+ // request; we now keep at most CHAT_WINDOW_TURNS turns (default 20)
2700
+ // plus the system message. Operators can override via env. The
2701
+ // per-session log on disk is untouched — only the in-memory prompt
2702
+ // window is trimmed. We log to stderr once at session start so the
2703
+ // user knows context was dropped.
2704
+ if (messages.length > 0) {
2705
+ const cfgChat = cfg.chat || {};
2706
+ const winTurns = Number(cfgChat.windowTurns) || CHAT_WINDOW_TURNS;
2707
+ const winTokens = Number(cfgChat.windowTokens) || CHAT_WINDOW_TOKEN_BUDGET;
2708
+ const { messages: trimmed, dropped } = _applyChatWindow(messages, { turns: winTurns, tokens: winTokens });
2709
+ if (dropped > 0) {
2710
+ process.stderr.write(`[chat] sliding window: dropped ${dropped} older turn(s), ${trimmed.length} kept\n`);
2711
+ }
2712
+ messages = trimmed;
2713
+ }
2714
+
2532
2715
  // --skill (comma-separated names) composes into a system message at the
2533
2716
  // head of the conversation. Same shape as `agent --skill`. Defaults from
2534
2717
  // config.skills array when --skill not passed. We only inject if no
@@ -2542,6 +2725,21 @@ async function cmdChat(flags = {}) {
2542
2725
  // a faithful preview.
2543
2726
  const workspaceName = flags.workspace || cfg.workspace || '';
2544
2727
  const sysParts = [];
2728
+ // v5 (canonical decision C5) — prepend the 8-layer composePromptStack
2729
+ // output. Falls back silently to no-op when the configDir has none of
2730
+ // the source files present (fresh install) so chat-start stays
2731
+ // byte-identical to the v4 shape until a user authors USER.md or a
2732
+ // personality. Wrapped in try/catch — chat start must never break on
2733
+ // a stack composition error.
2734
+ try {
2735
+ const { composePromptStack } = await import('./mas/prompt_stack.mjs');
2736
+ const stacked = composePromptStack({
2737
+ cfgDir,
2738
+ agent: { name: 'chat', role: '' },
2739
+ workspace: workspaceName,
2740
+ });
2741
+ if (stacked && stacked.trim()) sysParts.push(stacked);
2742
+ } catch { /* never block chat start on stack composition */ }
2545
2743
  if (workspaceName && !messages.some(m => m.role === 'system')) {
2546
2744
  try {
2547
2745
  const ws = await import('./workspace.mjs');
@@ -2582,11 +2780,56 @@ async function cmdChat(flags = {}) {
2582
2780
  runningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
2583
2781
  runningUsage.turnsWithUsage += 1;
2584
2782
  };
2783
+ // v5 Group A (M2): always-on synthetic session id so an unsessioned
2784
+ // chat still populates memory/recent.jsonl. Without this, the nudge
2785
+ // loop never saw repeated prompts in chat sessions that didn't pass
2786
+ // --session, and `nudge.suggest_skill` clusters silently lost ~95%
2787
+ // of their evidence. The `chat-<pid>-<startTs>` prefix keeps the
2788
+ // synthetic id distinguishable from real session ids on disk.
2789
+ const chatStartedAt = Date.now();
2790
+ const _syntheticChatSessionId = `chat-${process.pid}-${chatStartedAt}`;
2585
2791
  const persistTurn = (role, content) => {
2586
- if (!sessionId) return;
2587
- sessionsMod.appendTurn(sessionId, role, content, cfgDir);
2792
+ if (sessionId) {
2793
+ sessionsMod.appendTurn(sessionId, role, content, cfgDir);
2794
+ return;
2795
+ }
2796
+ // No --session: don't touch sessions/<id>.jsonl, but DO append to
2797
+ // memory/recent.jsonl directly so the nudge loop can cluster on
2798
+ // unsessioned chats. Best-effort — a broken memory module must not
2799
+ // break a chat turn.
2800
+ try {
2801
+ import('./memory.mjs').then((m) => {
2802
+ try { m.appendRecent(_syntheticChatSessionId, role, content, cfgDir); }
2803
+ catch { /* swallow */ }
2804
+ }).catch(() => {});
2805
+ } catch { /* swallow */ }
2588
2806
  };
2589
2807
 
2808
+ // C7 — shared runTurn closure for the legacy path. The same factory
2809
+ // backs the ink path above; both call sites get one set of bugs.
2810
+ // Getters close over the *current* binding of sessionId, prov,
2811
+ // activeProvName, activeModel — so a mid-session /provider switch
2812
+ // takes effect on the very next turn.
2813
+ const _legacyCtx = {
2814
+ cfg,
2815
+ cfgDir,
2816
+ sandboxSpec,
2817
+ syntheticChatSessionId: _syntheticChatSessionId,
2818
+ getMessages: () => messages,
2819
+ getProv: () => prov,
2820
+ getActiveProvName: () => activeProvName,
2821
+ getActiveModel: () => activeModel,
2822
+ getSessionId: () => sessionId,
2823
+ persistTurn,
2824
+ accumulateUsage,
2825
+ resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
2826
+ onCharsSent: (n) => { charsSent += n; },
2827
+ };
2828
+ const runTurn = _chatRunTurnFactory({
2829
+ ctx: _legacyCtx,
2830
+ writeFn: (chunk) => process.stdout.write(chunk),
2831
+ });
2832
+
2590
2833
  const handleSlash = async (line) => {
2591
2834
  const cmd = line.split(/\s+/)[0];
2592
2835
  switch (cmd) {
@@ -3172,6 +3415,35 @@ async function cmdChat(flags = {}) {
3172
3415
  return true;
3173
3416
  }
3174
3417
  case '/exit': {
3418
+ // v5 Group A (C4): fire one updateUserModel call before exit so
3419
+ // the Honcho-style USER.md captures the durable facts surfaced
3420
+ // in this session. Wrapped in a 3-second timeout so a slow
3421
+ // trainer never makes /exit hang. Best-effort: failure logs are
3422
+ // suppressed so we don't disturb the clean shutdown.
3423
+ try {
3424
+ const turns = sessionId
3425
+ ? sessionsMod.loadTurns(sessionId, cfgDir)
3426
+ : messages.map((t) => ({ role: t.role, content: t.content }));
3427
+ if (turns && turns.length) {
3428
+ const trainer = (typeof _registryMod?.resolveTrainer === 'function')
3429
+ ? _registryMod.resolveTrainer(cfg)
3430
+ : { provider: activeProvName, model: activeModel };
3431
+ const userModelPromise = import('./mas/user_modeler.mjs').then((m) =>
3432
+ m.updateUserModel({
3433
+ sessionTurns: turns,
3434
+ provider: trainer.provider,
3435
+ model: trainer.model,
3436
+ apiKey: _resolveAuthKey(cfg, trainer.provider),
3437
+ baseUrl: _resolveBaseUrl(trainer.provider),
3438
+ configDir: cfgDir,
3439
+ }),
3440
+ ).catch(() => null);
3441
+ await Promise.race([
3442
+ userModelPromise,
3443
+ new Promise((resolve) => setTimeout(resolve, 3000)),
3444
+ ]);
3445
+ }
3446
+ } catch { /* /exit must never hang or throw */ }
3175
3447
  return 'EXIT';
3176
3448
  }
3177
3449
  default:
@@ -3189,10 +3461,6 @@ async function cmdChat(flags = {}) {
3189
3461
  if (useTerminal) rl.prompt();
3190
3462
  continue;
3191
3463
  }
3192
- messages.push({ role: 'user', content: text });
3193
- charsSent += text.length;
3194
- persistTurn('user', text);
3195
- let acc = '';
3196
3464
  // Per-turn AbortController. Ctrl+C during a stream aborts THIS turn
3197
3465
  // and returns to the prompt instead of killing the process. Outside
3198
3466
  // a stream, Ctrl+C still terminates (we restore the default handler
@@ -3210,55 +3478,12 @@ async function cmdChat(flags = {}) {
3210
3478
  // gaps between CJK characters (visible in user-reported screen
3211
3479
  // captures of Korean replies).
3212
3480
  if (useTerminal) _ghost.suspend();
3213
- // Buffered writer — coalesce single-character streaming chunks
3214
- // into ~30 ms windows. Two reasons:
3215
- // 1. Korean / Japanese / Chinese tokens often arrive as one
3216
- // character per chunk. Each individual `process.stdout.write`
3217
- // can race against terminal redraw on a wide-cell character,
3218
- // producing the same "visible space between every character"
3219
- // symptom the suspend above also addresses.
3220
- // 2. Far fewer syscalls. A 200-char Korean reply was ~200
3221
- // separate writes; this collapses to ~7-10.
3222
- let _writeBuf = '';
3223
- let _writeTimer = null;
3224
- const _flush = () => {
3225
- if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
3226
- _writeTimer = null;
3227
- };
3228
- const _writeChunk = (s) => {
3229
- _writeBuf += s;
3230
- if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
3231
- };
3232
3481
  try {
3233
- for await (const chunk of prov.sendMessage(messages, {
3234
- apiKey: _resolveAuthKey(cfg, activeProvName),
3235
- model: activeModel,
3236
- sandbox: sandboxSpec,
3237
- signal: turnAc.signal,
3238
- onUsage: accumulateUsage,
3239
- })) {
3240
- _writeChunk(chunk);
3241
- acc += chunk;
3242
- }
3243
- // Drain anything still buffered before the trailing newline so
3244
- // the prompt lands on its own line cleanly.
3245
- if (_writeTimer) clearTimeout(_writeTimer);
3246
- _flush();
3247
- process.stdout.write('\n');
3248
- messages.push({ role: 'assistant', content: acc });
3249
- persistTurn('assistant', acc);
3250
- } catch (err) {
3251
- // Drain pending buffer so partial reply stays on screen even
3252
- // when the stream errors mid-flight.
3253
- if (_writeTimer) clearTimeout(_writeTimer);
3254
- _flush();
3255
- // ABORT errors are user-initiated; partial assistant output is
3256
- // discarded (we don't append a half-reply to the message history
3257
- // because the next turn would treat it as a complete reply and
3258
- // give odd context to the model).
3259
- if (err?.code !== 'ABORT' && !turnAc.signal.aborted) {
3260
- process.stdout.write(`error: ${err?.message || String(err)}\n`);
3261
- }
3482
+ // C7 single source of truth for the streaming + persist +
3483
+ // post-task learning loop. The factory handles the user-msg push,
3484
+ // 30 ms buffered writer (CJK-safe), assistant-msg push,
3485
+ // persistTurn for both turns, and the post-task learning hook.
3486
+ await runTurn(text, turnAc.signal);
3262
3487
  } finally {
3263
3488
  process.off('SIGINT', onSigint);
3264
3489
  if (useTerminal) _ghost.resume();