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 +29 -8
- package/agents.mjs +10 -8
- package/chat_window.mjs +39 -0
- package/cli.mjs +281 -56
- package/daemon.mjs +464 -11
- package/mas/agent_turn.mjs +81 -6
- package/mas/index_db.mjs +62 -2
- package/mas/learning.mjs +371 -0
- package/mas/mention_router.mjs +94 -13
- package/mas/orchestra.mjs +56 -0
- package/mas/skill_synth.mjs +36 -2
- package/mas/tool_runner.mjs +8 -1
- package/mas/tools/git.mjs +18 -1
- package/mas/tools/recall.mjs +64 -0
- package/package.json +2 -1
- package/providers/anthropic.mjs +26 -10
- package/providers/orchestrator.mjs +99 -21
- package/providers/registry.mjs +6 -0
- package/providers/tool_use/anthropic.mjs +43 -7
- package/skills.mjs +42 -3
- package/tasks.mjs +24 -1
- package/tui/repl.mjs +6 -0
- package/tui/run_turn.mjs +138 -0
- package/tui/splash.mjs +229 -32
- package/web/dashboard.html +383 -24
package/README.md
CHANGED
|
@@ -29,14 +29,20 @@ Four things no other terminal agent CLI does together:
|
|
|
29
29
|
3. **Cross-channel handoff.** `/handoff slack <channel-id>` moves the live conversation; context follows.
|
|
30
30
|
4. **Six sandbox backends, one API.** `local` / `docker` / `ssh` / `singularity` / `modal` / `daytona`.
|
|
31
31
|
|
|
32
|
+
## What's new in 5.0.x
|
|
33
|
+
|
|
34
|
+
The 5.0.3 → 5.0.9 maintenance line is mostly a launcher and splash overhaul. `lazyclaw` with no arguments now drops you straight into chat — the arrow-key picker has moved to `lazyclaw menu`. The splash itself is responsive: a Larry-3D gradient wordmark, braille-rendered sloth hero, a tool catalog driven by `mas/tools/registry.mjs` (no more hand-edited lists), grouped skills, and a Hermes-style status bar that auto-collapses to a single column on narrow terminals.
|
|
35
|
+
|
|
36
|
+
Per-release notes live in [CHANGELOG.md](./CHANGELOG.md).
|
|
37
|
+
|
|
32
38
|
## Known limitations (v5.1 roadmap)
|
|
33
39
|
|
|
34
40
|
Calibrate expectations before reading the rest:
|
|
35
41
|
|
|
36
|
-
- `recall
|
|
42
|
+
- Recall today is reachable via `lazyclaw loop --recall "<query>" ...` and `lazyclaw goal ... --recall "<query>"`; the `/recall` slash command and top-level `lazyclaw recall ...` CLI shape both ship in v5.1.
|
|
37
43
|
- `lazyclaw sandbox` exposes `list | test | add | use`; the `sandbox run --backend ...` shape lands in v5.1.
|
|
38
44
|
- `codex-cli` and `gemini-cli` provider modules are tracked but not yet registered in the main runtime.
|
|
39
|
-
- E2E matrix
|
|
45
|
+
- The E2E matrix in `tests/e2e/phaseH-e2e-matrix.spec.ts` still has a number of flows marked `test.skip` pending v5.1 wiring; the min-green-set is documented at the top of that file.
|
|
40
46
|
|
|
41
47
|
## Install
|
|
42
48
|
|
|
@@ -54,9 +60,10 @@ npm install && npm link
|
|
|
54
60
|
|
|
55
61
|
```bash
|
|
56
62
|
lazyclaw version
|
|
57
|
-
# → { "version": "5.0.0", "nodeVersion": "v20.11.0", "platform": "darwin" }
|
|
58
63
|
```
|
|
59
64
|
|
|
65
|
+
Output reports the current installed version, Node version, and platform. See [CHANGELOG.md](./CHANGELOG.md) for release notes.
|
|
66
|
+
|
|
60
67
|
Requires **Node 18+**. macOS / Linux / WSL are first-class. Native PowerShell runs but ghost-text and the ANSI banner fall back to plain prompts.
|
|
61
68
|
|
|
62
69
|
## First run
|
|
@@ -69,6 +76,8 @@ lazyclaw onboard # arrow-key picker; defaults to claude-cli (no key)
|
|
|
69
76
|
<img src="docs/screenshots/onboard.png" alt="lazyclaw onboard --non-interactive" width="720">
|
|
70
77
|
|
|
71
78
|
```bash
|
|
79
|
+
lazyclaw # no-arg → chat (since v5.0.6)
|
|
80
|
+
lazyclaw menu # arrow-key launcher (formerly the no-arg default)
|
|
72
81
|
lazyclaw status # → { provider, model, hasApiKey }
|
|
73
82
|
lazyclaw doctor # validates config + provider registry + index.db
|
|
74
83
|
```
|
|
@@ -124,10 +133,16 @@ Plus two meta-providers, usable in either slot:
|
|
|
124
133
|
|
|
125
134
|
## What it ships
|
|
126
135
|
|
|
127
|
-
###
|
|
128
|
-
|
|
136
|
+
### Tool registry — 12 categories plus MCP
|
|
137
|
+
|
|
138
|
+
A single registry (`mas/tools/registry.mjs`) aggregates every first-party tool across twelve categories:
|
|
139
|
+
|
|
140
|
+
`agents` · `browser` · `coding` · `exec` · `fs` · `git` · `iot` · `learning` · `media` · `net` · `os` · `scheduling`
|
|
141
|
+
|
|
142
|
+
Tools flagged `sensitive: true` (writes, network egress, shell exec, sensitive git ops) route through an approval hook before execution. The splash renderer, the agent toolset resolver, and the runtime all read from the same registry, so a new tool group lights up everywhere at once. External servers extend the registry over stdio MCP.
|
|
129
143
|
|
|
130
144
|
### Channels that hand off
|
|
145
|
+
|
|
131
146
|
First-class channels for Slack, Discord, Telegram, Matrix, Email, and Voice. Signal and WhatsApp ship as full implementations with external runtime dependencies (`signal-cli` for Signal; `whatsapp-web.js` browser automation with QR-on-first-run for WhatsApp). Move a live conversation between any two with `/handoff <target> <externalId>` — no other agent CLI does cross-channel handoff.
|
|
132
147
|
|
|
133
148
|
```bash
|
|
@@ -139,19 +154,23 @@ lazyclaw channels install @lazyclaw/channel-discord
|
|
|
139
154
|
Slack is built-in and does not need `channels install`. The plugin loader expects `@lazyclaw/channel-<name>` npm package names.
|
|
140
155
|
|
|
141
156
|
### Personas that compose
|
|
157
|
+
|
|
142
158
|
Swap personality per channel without losing session memory. Layers compose top-down: global SOUL → workspace SOUL → active personality → agent role → user model (USER.md) → skill bank → memory core → recent trajectory tail. See [docs/persona-cookbook.md](./docs/persona-cookbook.md).
|
|
143
159
|
|
|
144
160
|
### Loops and scheduled goals
|
|
161
|
+
|
|
145
162
|
Durable foreground or `--detach` loops; cron-scheduled goals with channel fan-out. State lives in `~/.lazyclaw/loops/<id>/` and survives restart. See [docs/loop-goal-preflight.md](./docs/loop-goal-preflight.md).
|
|
146
163
|
|
|
147
164
|
### A TUI that ghosts the right answer
|
|
148
|
-
|
|
165
|
+
|
|
166
|
+
Ink-based UI with a responsive splash (Larry-3D gradient wordmark, sloth braille hero, grouped subcommand catalog, registry-backed tool list, filename-grouped skills, Hermes-style status bar — auto-collapses to single-column on narrow terminals), Cursor-style ghost autocomplete (`→` accepts, `Tab` cycles), interrupt-and-redirect REPL, and multiline editor.
|
|
149
167
|
|
|
150
168
|
## Command reference
|
|
151
169
|
|
|
152
170
|
| Command | Purpose |
|
|
153
171
|
|---|---|
|
|
154
|
-
| `lazyclaw` |
|
|
172
|
+
| `lazyclaw` | No-arg → drops into chat (since v5.0.6) |
|
|
173
|
+
| `lazyclaw menu` | Arrow-key launcher (formerly the no-arg default) |
|
|
155
174
|
| `lazyclaw onboard` | Arrow-key setup; writes `~/.lazyclaw/config.json` |
|
|
156
175
|
| `lazyclaw status` | Print active provider / model / masked key |
|
|
157
176
|
| `lazyclaw doctor` | Validate config, provider registry, FTS5 index |
|
|
@@ -163,7 +182,9 @@ Ink-based UI with two-column splash, sloth ASCII banner, Cursor-style ghost auto
|
|
|
163
182
|
| `lazyclaw channels install\|list\|remove` | Channel plugin lifecycle (`@lazyclaw/channel-<name>`) |
|
|
164
183
|
| `lazyclaw trajectories export --format ...` | Export to atropos / axolotl / openai-ft / jsonl |
|
|
165
184
|
| `lazyclaw personality use\|list\|show` | Activate / inspect personas |
|
|
185
|
+
| `lazyclaw dashboard` | Local web UI on `127.0.0.1:19600` |
|
|
166
186
|
| `lazyclaw migrate v5` | v4 → v5 with backup |
|
|
187
|
+
| `lazyclaw recall` | (v5.1) Top-level recall over the FTS5 index — today, use `loop`/`goal` with `--recall "<query>"` |
|
|
167
188
|
| `lazyclaw version` / `lazyclaw help` | Version + subcommand help |
|
|
168
189
|
|
|
169
190
|
In-REPL slash commands include `/help`, `/status`, `/provider`, `/model`, `/skill`, `/loop`, `/goal`, `/memory`, `/agent`, `/team`, `/handoff`, `/personality`, `/exit`. Depth lives in `lazyclaw <cmd> --help` and the docs below.
|
|
@@ -189,7 +210,7 @@ Full guide: [docs/migration-v4-to-v5.md](./docs/migration-v4-to-v5.md).
|
|
|
189
210
|
|---|---|---|
|
|
190
211
|
| Start a chat | `lazyclaw chat` | `@lazyclaw <message>` |
|
|
191
212
|
| Hand off to another surface | `/handoff slack <channel-id>` | `/handoff tui <thread-id>` |
|
|
192
|
-
| Recall across history |
|
|
213
|
+
| Recall across history | `lazyclaw loop --recall "<query>" ...` (slash + top-level v5.1) | `lazyclaw loop --recall "<query>" ...` (channel-side `/recall` v5.1) |
|
|
193
214
|
| Switch persona | `/personality use terse` | `/personality use terse` |
|
|
194
215
|
| Switch model | `/model <name>` | `/model <name>` |
|
|
195
216
|
| Show status | `lazyclaw status` | `@lazyclaw status` |
|
package/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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// 'manual'
|
|
88
|
-
//
|
|
89
|
-
skillWrite
|
|
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 ?? '
|
|
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
|
}
|
package/chat_window.mjs
ADDED
|
@@ -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
|
|
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:
|
|
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 (
|
|
2587
|
-
|
|
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
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
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();
|