lazyclaw 6.4.0 → 6.6.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.
@@ -0,0 +1,716 @@
1
+ // commands/chat_legacy_slash.mjs — legacy (non-Ink) readline slash router.
2
+ //
3
+ // Extracted VERBATIM from commands/chat.mjs's `handleSlash` closure to keep that
4
+ // file under its file-size ceiling (lint:size). PURE module-extraction, no
5
+ // behaviour change. The handler closes over a lot of mutable chat state
6
+ // (activeProvName, prov, messages, sessionId, …) which it both reads AND
7
+ // reassigns; to avoid a circular import back into ./chat.mjs we receive that
8
+ // state through a factory `ctx` carrying getter/setter accessors. cmdChat builds
9
+ // the handler once (after rl/_ghost exist, just before the read loop) with a ctx
10
+ // wired to its own `let` bindings, so a /provider or /goal switch propagates to
11
+ // the very next turn exactly as before.
12
+ //
13
+ // This module MUST NOT import from ./chat.mjs (cycle). It owns legacySlashRoute
14
+ // and LEGACY_DELEGATED_SLASHES; chat.mjs re-exports legacySlashRoute for tests.
15
+ import { renderRecord } from '../lib/render.mjs';
16
+ import { getRegistry } from '../lib/registry_boot.mjs';
17
+ import { _resolveAuthKey, _resolveBaseUrl } from '../lib/config.mjs';
18
+ import {
19
+ _pauseChatForSubMenu, _pickModelInteractive, _pickProviderInteractive,
20
+ } from '../tui/pickers.mjs';
21
+ import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from '../tui/slash_dispatcher.mjs';
22
+ import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
23
+ import { wrapInteractiveProv } from './chat_hardening.mjs';
24
+
25
+ // Legacy (non-Ink) slash routing for dispatcher-style, ctx-only commands.
26
+ // The Ink REPL routes every slash through _dispatchSlash/SLASH_HANDLERS, but
27
+ // the legacy readline path uses a hand-written switch (below). This exported
28
+ // helper is the wiring BOTH that switch and the regression test drive.
29
+ // Returns 'EXIT' to break the loop, undefined when not owned here.
30
+ export function legacySlashRoute(cmd, ctx) {
31
+ switch (cmd) {
32
+ // Legacy readline path has no modal picker, so BOTH /setup and /config
33
+ // route to the full wizard here (the Ink path gives /config its
34
+ // single-setting picker via tui/config_picker.mjs).
35
+ case '/config':
36
+ case '/setup':
37
+ ctx.requestSetup = true;
38
+ return 'EXIT';
39
+ default:
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ // Dispatcher commands the legacy readline path's default branch may delegate to
45
+ // _dispatchSlash. Kept to ctx-safe handlers only (no _inkCtx-only setters /
46
+ // openPicker / version), so legacy doesn't silently degrade. /channels has a
47
+ // lib/config fallback so it's safe; add others only after confirming ctx-safety.
48
+ const LEGACY_DELEGATED_SLASHES = new Set(['/channels', '/orchestrator', '/context']);
49
+
50
+ // Factory: builds the legacy readline slash handler bound to cmdChat's mutable
51
+ // state via `ctx`. `ctx` exposes the stable deps (cfg, cfgDir, lookupProv,
52
+ // persistTurn, accumulateUsage, legacyCtx, useTerminal, sandboxSpec, rl, ghost)
53
+ // and getX/setX accessors for the reassignable bindings. cmdChat builds the
54
+ // handler AFTER rl/_ghost are created (just before the read loop), matching the
55
+ // original call-time read of those bindings, so rl/ghost are captured live.
56
+ export function makeLegacySlashHandler(ctx) {
57
+ const { cfg, cfgDir, lookupProv, persistTurn, accumulateUsage } = ctx;
58
+ const _legacyCtx = ctx.legacyCtx;
59
+ const useTerminal = ctx.useTerminal;
60
+ const sandboxSpec = ctx.sandboxSpec;
61
+ const rl = ctx.rl;
62
+ const _ghost = ctx.ghost;
63
+
64
+ const _legacyHandleSlash = async (line) => {
65
+ const cmd = line.split(/\s+/)[0];
66
+ switch (cmd) {
67
+ case '/help': {
68
+ process.stdout.write('slash commands:\n');
69
+ for (const c of SLASH_COMMANDS) process.stdout.write(` ${c.cmd.padEnd(8)} — ${c.help}\n`);
70
+ return true;
71
+ }
72
+ case '/status': {
73
+ const out = {
74
+ provider: ctx.getActiveProvName(),
75
+ model: ctx.getActiveModel(),
76
+ keyMasked: getRegistry().maskApiKey(cfg['api-key']),
77
+ messageCount: ctx.getMessages().length,
78
+ };
79
+ process.stdout.write(JSON.stringify(out) + '\n');
80
+ return true;
81
+ }
82
+ case '/provider': {
83
+ // `/provider <name>` switches the active provider for subsequent
84
+ // turns. The conversation history stays put — the next user
85
+ // message goes to the new provider with the existing context.
86
+ // `/provider` (no arg) opens the family/provider/model picker so
87
+ // the user can switch with arrow keys instead of memorising names.
88
+ const arg = line.slice('/provider'.length).trim();
89
+ if (!arg) {
90
+ if (!useTerminal) {
91
+ process.stdout.write(`provider: ${ctx.getActiveProvName()}\n`);
92
+ return true;
93
+ }
94
+ await _pauseChatForSubMenu(rl, _ghost, async () => {
95
+ const picked = await _pickProviderInteractive();
96
+ if (picked && picked.provider) {
97
+ const next = lookupProv(picked.provider);
98
+ if (!next) {
99
+ process.stdout.write(`unknown provider: ${picked.provider}\n`);
100
+ return;
101
+ }
102
+ ctx.setActiveProvName(picked.provider);
103
+ ctx.setProv(wrapInteractiveProv(next));
104
+ if (picked.model) ctx.setActiveModel(picked.model);
105
+ process.stdout.write(`provider → ${ctx.getActiveProvName()}${picked.model ? ` · model → ${picked.model}` : ''}\n`);
106
+ }
107
+ });
108
+ return true;
109
+ }
110
+ const next = lookupProv(arg);
111
+ if (!next) {
112
+ process.stdout.write(`unknown provider: ${arg} (known: ${Object.keys(getRegistry().PROVIDERS).join(', ')})\n`);
113
+ return true;
114
+ }
115
+ ctx.setActiveProvName(arg);
116
+ ctx.setProv(wrapInteractiveProv(next));
117
+ process.stdout.write(`provider → ${arg}\n`);
118
+ return true;
119
+ }
120
+ case '/model': {
121
+ // `/model <name>` updates the active model without touching the
122
+ // provider. `/model` (no arg) opens the per-provider model picker
123
+ // — same UX as setup step 3, scoped to the active provider.
124
+ const arg = line.slice('/model'.length).trim();
125
+ if (!arg) {
126
+ if (!useTerminal) {
127
+ process.stdout.write(`model: ${ctx.getActiveModel() || '(default)'}\n`);
128
+ return true;
129
+ }
130
+ await _pauseChatForSubMenu(rl, _ghost, async () => {
131
+ const chosen = await _pickModelInteractive(ctx.getActiveProvName(), { titlePrefix: 'LazyClaw chat —' });
132
+ if (chosen === 'CANCEL' || chosen === 'BACK' || !chosen) return;
133
+ ctx.setActiveModel(chosen);
134
+ process.stdout.write(`model → ${ctx.getActiveModel()}\n`);
135
+ });
136
+ return true;
137
+ }
138
+ // Honor unified provider/model: `/model anthropic/claude-opus-4-7`
139
+ // splits and switches both.
140
+ const { parseSlashProviderModel } = getRegistry();
141
+ const parsed = parseSlashProviderModel(arg);
142
+ if (parsed.provider) {
143
+ const next = lookupProv(parsed.provider);
144
+ if (!next) {
145
+ process.stdout.write(`unknown provider: ${parsed.provider}\n`);
146
+ return true;
147
+ }
148
+ ctx.setActiveProvName(parsed.provider);
149
+ ctx.setProv(wrapInteractiveProv(next));
150
+ }
151
+ ctx.setActiveModel(parsed.model || arg);
152
+ process.stdout.write(`model → ${ctx.getActiveModel()}${parsed.provider ? ` (provider → ${parsed.provider})` : ''}\n`);
153
+ return true;
154
+ }
155
+ case '/new':
156
+ case '/reset':
157
+ case '/clear': {
158
+ // /clear is dispatcher-only (no explicit legacy case before this),
159
+ // so without it /clear fell through to `default:` → _dispatchSlash →
160
+ // _newReset, which clears via ctx.set* setters that _legacyCtx does
161
+ // NOT expose — returning 'cleared' while the closure's messages/
162
+ // charsSent/runningUsage stayed intact (a lying no-op). Alias /clear
163
+ // to the /new+/reset direct-mutation body, matching the dispatcher's
164
+ // /clear → /new/reset session-reset aliasing.
165
+ ctx.setMessages([]);
166
+ ctx.setCharsSent(0);
167
+ ctx.setRunningUsage(null);
168
+ if (ctx.getSessionId()) {
169
+ const sm = await import('../sessions.mjs');
170
+ sm.resetSession(ctx.getSessionId(), cfgDir);
171
+ }
172
+ process.stdout.write('cleared — new conversation\n');
173
+ return true;
174
+ }
175
+ case '/usage': {
176
+ const out = { messageCount: ctx.getMessages().length, charsSent: ctx.getCharsSent() };
177
+ if (ctx.getRunningUsage()) out.tokens = ctx.getRunningUsage();
178
+ // When cfg.rates has a card for the active provider/model AND
179
+ // we accumulated real usage, surface the running cost too. The
180
+ // computation is local (pure arithmetic), no extra network.
181
+ if (ctx.getRunningUsage() && cfg.rates && typeof cfg.rates === 'object') {
182
+ try {
183
+ const { costFromUsage } = await import('../providers/rates.mjs');
184
+ const r = costFromUsage(
185
+ { provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: ctx.getRunningUsage() },
186
+ cfg.rates,
187
+ );
188
+ if (r) out.cost = r;
189
+ } catch { /* never let cost-card lookup fail the slash */ }
190
+ }
191
+ process.stdout.write(JSON.stringify(out) + '\n');
192
+ return true;
193
+ }
194
+ case '/skill': {
195
+ // `/skill name1,name2` — replace the active system message with a
196
+ // composition of the named skills. `/skill` (no arg) clears the
197
+ // system message. The replacement happens in-place on the
198
+ // messages array; the prior system turn (if any) is dropped so
199
+ // we don't end up with two stacked system messages talking past
200
+ // each other. When --session is set we persist the new system
201
+ // message so the next invocation resumes with the same context.
202
+ const arg = line.slice('/skill'.length).trim();
203
+ const names = arg.split(',').map(s => s.trim()).filter(Boolean);
204
+ const sysIdx = ctx.getMessages().findIndex(m => m.role === 'system');
205
+ if (names.length === 0) {
206
+ if (sysIdx >= 0) ctx.getMessages().splice(sysIdx, 1);
207
+ if (ctx.getSessionId()) {
208
+ // Persistent session: rewrite the file from scratch so the
209
+ // dropped system turn doesn't linger as a stale entry.
210
+ const sm = await import('../sessions.mjs');
211
+ sm.resetSession(ctx.getSessionId(), cfgDir);
212
+ for (const m of ctx.getMessages()) sm.appendTurn(ctx.getSessionId(), m.role, m.content, cfgDir);
213
+ }
214
+ process.stdout.write('cleared system prompt (no active skills)\n');
215
+ return true;
216
+ }
217
+ try {
218
+ const sys = await (async () => {
219
+ const mod = await import('../skills.mjs');
220
+ return mod.composeSystemPrompt(names, cfgDir);
221
+ })();
222
+ if (!sys) {
223
+ process.stdout.write('no skill content composed (empty input?)\n');
224
+ return true;
225
+ }
226
+ if (sysIdx >= 0) ctx.getMessages()[sysIdx] = { role: 'system', content: sys };
227
+ else ctx.getMessages().unshift({ role: 'system', content: sys });
228
+ if (ctx.getSessionId()) {
229
+ const sm = await import('../sessions.mjs');
230
+ sm.resetSession(ctx.getSessionId(), cfgDir);
231
+ for (const m of ctx.getMessages()) sm.appendTurn(ctx.getSessionId(), m.role, m.content, cfgDir);
232
+ }
233
+ process.stdout.write(`active skills: ${names.join(', ')}\n`);
234
+ } catch (e) {
235
+ process.stdout.write(`skill error: ${e?.message || e}\n`);
236
+ }
237
+ return true;
238
+ }
239
+ case '/loop': {
240
+ // `/loop <prompt> [--max N] [--until "<regex>"]` — repeats one
241
+ // user prompt against the active provider in the current session.
242
+ // Default --max 3, hard cap 50. --until short-circuits when its
243
+ // regex matches the latest assistant turn. Ctrl+C aborts the
244
+ // current stream AND the whole loop (not just the in-flight
245
+ // turn). Implementation lives in loop-engine.mjs; here we wire
246
+ // it to the same provider streaming + buffered-writer used by a
247
+ // normal user turn.
248
+ const arg = line.slice('/loop'.length).trim();
249
+ const loopMod = await import('../loop-engine.mjs');
250
+ if (!arg) {
251
+ process.stdout.write(`usage: /loop <prompt> [--max N] [--until "<regex>"]\n`);
252
+ process.stdout.write(` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}\n`);
253
+ process.stdout.write(` session: ${ctx.getSessionId() || '(none — turns will not be persisted)'}\n`);
254
+ return true;
255
+ }
256
+ let parsed;
257
+ try { parsed = loopMod.parseLoopArgs(arg); }
258
+ catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
259
+ let untilRe = null;
260
+ try { untilRe = loopMod.compileUntil(parsed.until); }
261
+ catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
262
+
263
+ // Per-loop AbortController. Ctrl+C aborts the current provider
264
+ // call (via signal) AND prevents the next iteration (the engine
265
+ // sees signal.aborted on its loop check). Same handler shape as
266
+ // the normal-turn path; symmetry keeps `/exit` clean afterwards.
267
+ const loopAc = new AbortController();
268
+ const onSigint = () => {
269
+ loopAc.abort();
270
+ process.stdout.write('\n^C interrupted — loop aborted\n');
271
+ };
272
+ process.on('SIGINT', onSigint);
273
+
274
+ const sendOnce = async (msgs, signal) => {
275
+ let acc = '';
276
+ let _writeBuf = '';
277
+ let _writeTimer = null;
278
+ const _flush = () => {
279
+ if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
280
+ _writeTimer = null;
281
+ };
282
+ const _writeChunk = (s) => {
283
+ _writeBuf += s;
284
+ if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
285
+ };
286
+ try {
287
+ for await (const chunk of ctx.getProv().sendMessage(msgs, {
288
+ apiKey: _resolveAuthKey(cfg, ctx.getActiveProvName()),
289
+ model: ctx.getActiveModel(),
290
+ sandbox: sandboxSpec,
291
+ signal,
292
+ onUsage: accumulateUsage,
293
+ })) {
294
+ _writeChunk(chunk);
295
+ acc += chunk;
296
+ }
297
+ if (_writeTimer) clearTimeout(_writeTimer);
298
+ _flush();
299
+ process.stdout.write('\n');
300
+ return acc;
301
+ } catch (err) {
302
+ if (_writeTimer) clearTimeout(_writeTimer);
303
+ _flush();
304
+ throw err;
305
+ }
306
+ };
307
+
308
+ if (useTerminal) _ghost.suspend();
309
+ // Capture the chat's existing system message (workspace / skill
310
+ // composition) before we let the engine touch it; we restore it
311
+ // after the loop so the chat continues with the same system.
312
+ const _sysBefore = ctx.getMessages().find(m => m.role === 'system')?.content ?? null;
313
+ const memMod = (parsed.useMemory || parsed.recall) ? await import('../memory.mjs') : null;
314
+ const buildSystem = memMod ? (() => {
315
+ // Called per iteration: memory.loadCore + recall re-read from
316
+ // disk every call so a parallel writer mutating core.md /
317
+ // episodic/* between iterations is reflected immediately.
318
+ const parts = [];
319
+ if (parsed.useMemory) {
320
+ const core = memMod.loadCore(cfgDir);
321
+ if (core && core.trim()) parts.push(core);
322
+ }
323
+ if (parsed.recall) {
324
+ const text = memMod.recall(parsed.recall, { topN: 3 }, cfgDir);
325
+ if (text && text.trim()) parts.push(text);
326
+ }
327
+ if (_sysBefore) parts.push(_sysBefore);
328
+ return parts.join('\n\n---\n\n');
329
+ }) : null;
330
+ try {
331
+ const result = await loopMod.runLoop({
332
+ prompt: parsed.prompt,
333
+ max: parsed.max,
334
+ until: untilRe,
335
+ messages: ctx.getMessages(),
336
+ sendOnce,
337
+ persist: (role, content) => persistTurn(role, content),
338
+ onIteration: ({ i, max }) => {
339
+ process.stderr.write(`\x1b[2m ↻ loop iteration ${i}/${max}\x1b[22m\n`);
340
+ },
341
+ signal: loopAc.signal,
342
+ buildSystem,
343
+ });
344
+ ctx.setCharsSent(ctx.getCharsSent() + (parsed.prompt.length * result.iterations));
345
+ if (result.stoppedBy === 'until') {
346
+ process.stderr.write(`\x1b[2m ✓ loop stopped by --until\x1b[22m\n`);
347
+ } else if (result.stoppedBy === 'abort') {
348
+ process.stderr.write(`\x1b[2m ⊘ loop aborted after ${result.iterations}/${parsed.max} iteration(s)\x1b[22m\n`);
349
+ }
350
+ } catch (err) {
351
+ process.stdout.write(`loop error: ${err?.message || String(err)}\n`);
352
+ } finally {
353
+ process.off('SIGINT', onSigint);
354
+ if (useTerminal) _ghost.resume();
355
+ // Restore the chat's prior system message. The engine may have
356
+ // overwritten messages[0] with the per-iter memory composition;
357
+ // we put the original (workspace / skill) back so the
358
+ // subsequent free-form chat turn sees the same system the user
359
+ // configured before /loop ran.
360
+ if (buildSystem) {
361
+ const sysIdx = ctx.getMessages().findIndex(m => m.role === 'system');
362
+ if (_sysBefore) {
363
+ if (sysIdx >= 0) ctx.getMessages()[sysIdx] = { role: 'system', content: _sysBefore };
364
+ else ctx.getMessages().unshift({ role: 'system', content: _sysBefore });
365
+ } else if (sysIdx >= 0) {
366
+ ctx.getMessages().splice(sysIdx, 1);
367
+ }
368
+ }
369
+ }
370
+ return true;
371
+ }
372
+ case '/goal': {
373
+ // /goal → list active goals
374
+ // /goal <name> → switch chat context to goal:<name>
375
+ // /goal add <name> [--desc "..."] [--cron "<spec>"]
376
+ // /goal list → JSON of all goals
377
+ // /goal show <name> → JSON of one
378
+ // /goal close <name> [done|abandoned]
379
+ const rawArg = line.slice('/goal'.length).trim();
380
+ const goalsMod = await import('../goals.mjs');
381
+ const loopMod = await import('../loop-engine.mjs');
382
+ if (!rawArg) {
383
+ const items = goalsMod.listGoals(cfgDir).filter(g => g.status === 'active');
384
+ if (!items.length) { process.stdout.write('no active goals\n'); }
385
+ else {
386
+ for (const g of items) {
387
+ process.stdout.write(` ${g.name}${g.description ? ' — ' + g.description : ''}${g.schedule ? ' (cron: ' + g.schedule + ')' : ''}\n`);
388
+ }
389
+ }
390
+ return true;
391
+ }
392
+ let tokens;
393
+ try { tokens = loopMod.splitArgs(rawArg); }
394
+ catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); return true; }
395
+ const sub = tokens[0];
396
+ const rest = tokens.slice(1);
397
+ if (sub === 'add') {
398
+ let name = null, desc = '', cron = null;
399
+ for (let i = 0; i < rest.length; i++) {
400
+ const t = rest[i];
401
+ if (t === '--desc') desc = rest[++i] || '';
402
+ else if (t === '--cron') cron = rest[++i] || null;
403
+ else if (t.startsWith('--')) { process.stdout.write(`goal error: unknown flag ${t}\n`); return true; }
404
+ else if (!name) name = t;
405
+ else { process.stdout.write(`goal error: unexpected arg "${t}"\n`); return true; }
406
+ }
407
+ if (!name) { process.stdout.write('usage: /goal add <name> [--desc "..."] [--cron "<spec>"]\n'); return true; }
408
+ try {
409
+ const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, cfgDir);
410
+ if (cron) {
411
+ try { await (await import('../commands/automation.mjs'))._attachGoalCron(name, cron); }
412
+ catch (e) { process.stdout.write(`goal warning: cron attach failed (${e?.message || e})\n`); }
413
+ }
414
+ process.stdout.write(`✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})\n`);
415
+ } catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
416
+ return true;
417
+ }
418
+ if (sub === 'list') {
419
+ process.stdout.write(JSON.stringify(goalsMod.listGoals(cfgDir), null, 2) + '\n');
420
+ return true;
421
+ }
422
+ if (sub === 'show') {
423
+ const name = rest[0];
424
+ if (!name) { process.stdout.write('usage: /goal show <name>\n'); return true; }
425
+ const g = goalsMod.getGoal(name, cfgDir);
426
+ if (!g) { process.stdout.write(`no goal "${name}"\n`); return true; }
427
+ process.stdout.write(JSON.stringify(g, null, 2) + '\n');
428
+ return true;
429
+ }
430
+ if (sub === 'close') {
431
+ const name = rest[0];
432
+ const outcome = rest[1] || 'done';
433
+ if (!name) { process.stdout.write('usage: /goal close <name> [done|abandoned]\n'); return true; }
434
+ try {
435
+ const g = goalsMod.closeGoal(name, outcome, cfgDir);
436
+ try { await (await import('../commands/automation.mjs'))._detachGoalCron(name); }
437
+ catch (e) { process.stdout.write(`goal warning: cron detach failed (${e?.message || e})\n`); }
438
+ process.stdout.write(`✓ goal ${g.name} closed (status: ${g.status})\n`);
439
+ } catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
440
+ return true;
441
+ }
442
+ // Single-arg branch: switch context to goal:<name>.
443
+ const goalName = sub;
444
+ const g = goalsMod.getGoal(goalName, cfgDir);
445
+ if (!g) {
446
+ process.stdout.write(`no goal "${goalName}" — try: /goal add ${goalName} --desc "..."\n`);
447
+ return true;
448
+ }
449
+ if (g.status !== 'active') {
450
+ process.stdout.write(`goal "${goalName}" is ${g.status}; cannot switch\n`);
451
+ return true;
452
+ }
453
+ // Switch: replace the chat's active session id and reload turns
454
+ // from the goal's session. The provider, model, workspace, and
455
+ // skill state stay put — only the conversation surface changes.
456
+ ctx.setSessionId(g.sessionId);
457
+ ctx.setActiveGoalName(g.name);
458
+ const prior = ctx.sessionsMod.loadTurns(ctx.getSessionId(), cfgDir);
459
+ ctx.setMessages(prior.map(t => ({ role: t.role, content: t.content })));
460
+ // Prepend a one-line goal note to the system message so the
461
+ // model sees the current objective without us having to mutate
462
+ // any persistent record on every switch.
463
+ const sysIdx = ctx.getMessages().findIndex(m => m.role === 'system');
464
+ const goalNote = `## Goal: ${g.description || g.name}`;
465
+ if (sysIdx >= 0) {
466
+ ctx.getMessages()[sysIdx] = { role: 'system', content: `${goalNote}\n\n${ctx.getMessages()[sysIdx].content}` };
467
+ } else {
468
+ ctx.getMessages().unshift({ role: 'system', content: goalNote });
469
+ }
470
+ process.stdout.write(`✓ switched to goal: ${g.name} (session: ${ctx.getSessionId()}, ${prior.length} prior turn(s))\n`);
471
+ return true;
472
+ }
473
+ case '/memory': {
474
+ const arg = line.slice('/memory'.length).trim();
475
+ const memMod = await import('../memory.mjs');
476
+ const tokens = arg.split(/\s+/).filter(Boolean);
477
+ const which = tokens[0] || 'core';
478
+ if (which === 'core') {
479
+ const body = memMod.loadCore(cfgDir);
480
+ process.stdout.write(body || '(empty core memory)\n');
481
+ return true;
482
+ }
483
+ if (which === 'recent') {
484
+ const items = memMod.loadRecent(20, cfgDir);
485
+ process.stdout.write(JSON.stringify(items, null, 2) + '\n');
486
+ return true;
487
+ }
488
+ if (which === 'episodic') {
489
+ const topic = tokens[1];
490
+ if (topic) {
491
+ const body = memMod.loadEpisodic(topic, cfgDir);
492
+ process.stdout.write(body || `(no episodic file "${topic}")\n`);
493
+ } else {
494
+ process.stdout.write(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2) + '\n');
495
+ }
496
+ return true;
497
+ }
498
+ process.stdout.write('usage: /memory [core|recent|episodic [topic]]\n');
499
+ return true;
500
+ }
501
+ case '/dream': {
502
+ const memMod = await import('../memory.mjs');
503
+ process.stdout.write(' ↯ dreaming…\n');
504
+ try {
505
+ const r = await memMod.dream(ctx.getSessionId(), {
506
+ provider: ctx.getProv(),
507
+ model: ctx.getActiveModel(),
508
+ apiKey: _resolveAuthKey(cfg, ctx.getActiveProvName()),
509
+ }, cfgDir);
510
+ process.stdout.write(`✓ wrote ${r.topics.length} episodic file(s): ${r.topics.join(', ') || '(none)'}\n`);
511
+ } catch (e) { process.stdout.write(`dream error: ${e?.message || e}\n`); }
512
+ return true;
513
+ }
514
+ case '/agent': {
515
+ const rawArg = line.slice('/agent'.length).trim();
516
+ const agentsMod = await import('../agents.mjs');
517
+ const loopMod = await import('../loop-engine.mjs');
518
+ let tokens;
519
+ try { tokens = loopMod.splitArgs(rawArg); }
520
+ catch (e) { process.stdout.write(`/agent error: ${e?.message || e}\n`); return true; }
521
+ const sub = tokens[0];
522
+ const rest = tokens.slice(1);
523
+ const aname = rest[0];
524
+ try {
525
+ if (!sub || sub === 'list') {
526
+ const agents = agentsMod.listAgents(cfgDir);
527
+ if (agents.length === 0) process.stdout.write('no agents registered. /agent add <name> [...] to create.\n');
528
+ else for (const a of agents) {
529
+ const provLine = a.model ? `${a.provider}/${a.model}` : a.provider;
530
+ process.stdout.write(`• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]\n`);
531
+ }
532
+ } else if (sub === 'show') {
533
+ if (!aname) { process.stdout.write('usage: /agent show <name> [json]\n'); return true; }
534
+ const a = agentsMod.getAgent(aname, cfgDir);
535
+ if (!a) process.stdout.write(`no agent "${aname}"\n`);
536
+ else if (rest[1] === 'json') process.stdout.write(JSON.stringify(a, null, 2) + '\n');
537
+ else process.stdout.write(renderRecord(a, { fields: ['name', 'displayName', 'provider', 'model', 'role', 'tools', 'tags', 'iconEmoji', 'memoryWrite', 'skillWrite', 'createdAt', 'updatedAt'] }) + '\n');
538
+ } else if (sub === 'add') {
539
+ if (!aname) { process.stdout.write('usage: /agent add <name> [role text…]\n'); return true; }
540
+ const roleText = rest.slice(1).join(' ').trim();
541
+ const a = agentsMod.registerAgent({ name: aname, role: roleText }, cfgDir);
542
+ process.stdout.write(`✓ added agent ${a.name} (tools=${a.tools.join(',')})\n`);
543
+ } else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
544
+ if (!aname) { process.stdout.write('usage: /agent remove <name>\n'); return true; }
545
+ agentsMod.removeAgent(aname, cfgDir);
546
+ process.stdout.write(`✓ removed agent ${aname}\n`);
547
+ } else {
548
+ process.stdout.write(`/agent: unknown sub "${sub}" — list|show|add|remove\n`);
549
+ }
550
+ } catch (e) {
551
+ process.stdout.write(`/agent error: ${e?.message || e}\n`);
552
+ }
553
+ return true;
554
+ }
555
+ case '/team': {
556
+ const rawArg = line.slice('/team'.length).trim();
557
+ const teamsMod = await import('../teams.mjs');
558
+ const loopMod = await import('../loop-engine.mjs');
559
+ let tokens;
560
+ try { tokens = loopMod.splitArgs(rawArg); }
561
+ catch (e) { process.stdout.write(`/team error: ${e?.message || e}\n`); return true; }
562
+ const sub = tokens[0];
563
+ const rest = tokens.slice(1);
564
+ const tname = rest[0];
565
+ try {
566
+ if (!sub || sub === 'list') {
567
+ const teams = teamsMod.listTeams(cfgDir);
568
+ if (teams.length === 0) process.stdout.write('no teams registered. /team add <name> --agents a,b --lead a [--channel #x]\n');
569
+ else for (const t of teams) {
570
+ const chLine = t.slackChannel ? ` — ${t.slackChannel}` : '';
571
+ process.stdout.write(`• ${t.name} — ${t.displayName} — lead=${t.lead} — agents=[${t.agents.join(',')}]${chLine}\n`);
572
+ }
573
+ } else if (sub === 'show') {
574
+ if (!tname) { process.stdout.write('usage: /team show <name>\n'); return true; }
575
+ const t = teamsMod.getTeam(tname, cfgDir);
576
+ if (!t) process.stdout.write(`no team "${tname}"\n`);
577
+ else process.stdout.write(JSON.stringify(t, null, 2) + '\n');
578
+ } else if (sub === 'add') {
579
+ // /team add <name> --agents a,b,c [--lead a] [--channel #x]
580
+ if (!tname) { process.stdout.write('usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]\n'); return true; }
581
+ let agentsCsv = null, lead = null, channel = '';
582
+ for (let i = 1; i < rest.length; i++) {
583
+ const t = rest[i];
584
+ if (t === '--agents') agentsCsv = rest[++i] || '';
585
+ else if (t === '--lead') lead = rest[++i] || null;
586
+ else if (t === '--channel') channel = rest[++i] || '';
587
+ else { process.stdout.write(`/team error: unknown token "${t}"\n`); return true; }
588
+ }
589
+ if (!agentsCsv) { process.stdout.write('/team add: --agents is required\n'); return true; }
590
+ const agents = teamsMod.parseListFlag(agentsCsv);
591
+ const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
592
+ botToken: process.env.SLACK_BOT_TOKEN || null,
593
+ apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
594
+ logger: () => {},
595
+ }) : '';
596
+ const team = teamsMod.registerTeam({ name: tname, agents, lead, slackChannel: ch }, cfgDir);
597
+ process.stdout.write(`✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})\n`);
598
+ } else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
599
+ if (!tname) { process.stdout.write('usage: /team remove <name>\n'); return true; }
600
+ teamsMod.removeTeam(tname, cfgDir);
601
+ process.stdout.write(`✓ removed team ${tname}\n`);
602
+ } else {
603
+ process.stdout.write(`/team: unknown sub "${sub}" — list|show|add|remove\n`);
604
+ }
605
+ } catch (e) {
606
+ process.stdout.write(`/team error: ${e?.message || e}\n`);
607
+ }
608
+ return true;
609
+ }
610
+ case '/handoff': {
611
+ // /handoff <target-channel> <externalId> [--note=...] — migrates the
612
+ // active thread (bound to replState.channel / replState.externalId)
613
+ // to a new channel and posts transition stubs on both sides. In the
614
+ // local-only chat REPL there is no bound channel, so we surface a
615
+ // clear error and stay in the REPL (acceptance test §F).
616
+ const parts = line.trim().split(/\s+/).slice(1);
617
+ if (parts.length < 2) {
618
+ process.stderr.write('usage: /handoff <target-channel> <externalId> [--note=...]\n');
619
+ return true;
620
+ }
621
+ const target = parts[0];
622
+ const externalId = parts[1];
623
+ const note = (parts.find(p => p.startsWith('--note=')) || '').slice(7);
624
+ try {
625
+ const { openThreads } = await import('../channels/threads.mjs');
626
+ const { runHandoff } = await import('../channels/handoff.mjs');
627
+ const threads = openThreads(cfgDir);
628
+ const replState = globalThis.__lazyclawReplState || {};
629
+ const cur = replState.channel && replState.externalId
630
+ ? threads.findByExternal(replState.channel, replState.externalId)
631
+ : null;
632
+ if (!cur) {
633
+ process.stderr.write(
634
+ `handoff: no thread bound to ${replState.channel || '(none)'}:${replState.externalId || '(none)'}\n`,
635
+ );
636
+ return true;
637
+ }
638
+ const next = await runHandoff({
639
+ threads, channels: replState.channels || {},
640
+ threadId: cur.threadId, target, externalId, note,
641
+ });
642
+ process.stdout.write(`handoff -> ${next.channel}:${next.externalId} (session ${next.sessionId})\n`);
643
+ replState.channel = next.channel;
644
+ replState.externalId = next.externalId;
645
+ } catch (e) {
646
+ process.stderr.write(`handoff failed: ${e.code || 'ERR'}: ${e.message}\n`);
647
+ }
648
+ return true;
649
+ }
650
+ case '/personality': {
651
+ // Phase G: thin slash wrapper over cmdPersonality.
652
+ const tail = line.slice('/personality'.length).trim();
653
+ const parts = tail.split(/\s+/).filter(Boolean);
654
+ await (await import('../commands/config.mjs')).cmdPersonality(parts[0] || 'list', parts[1], parts[2]);
655
+ return true;
656
+ }
657
+ case '/exit': {
658
+ // v5 Group A (C4): fire one updateUserModel call before exit so
659
+ // the Honcho-style USER.md captures the durable facts surfaced
660
+ // in this session. Wrapped in a 3-second timeout so a slow
661
+ // trainer never makes /exit hang. Best-effort: failure logs are
662
+ // suppressed so we don't disturb the clean shutdown.
663
+ try {
664
+ const turns = ctx.getSessionId()
665
+ ? ctx.sessionsMod.loadTurns(ctx.getSessionId(), cfgDir)
666
+ : ctx.getMessages().map((t) => ({ role: t.role, content: t.content }));
667
+ if (turns && turns.length) {
668
+ const trainer = (typeof getRegistry()?.resolveTrainer === 'function')
669
+ ? getRegistry().resolveTrainer(cfg)
670
+ : { provider: ctx.getActiveProvName(), model: ctx.getActiveModel() };
671
+ const userModelPromise = import('../mas/user_modeler.mjs').then((m) =>
672
+ m.updateUserModel({
673
+ sessionTurns: turns,
674
+ provider: trainer.provider,
675
+ model: trainer.model,
676
+ apiKey: _resolveAuthKey(cfg, trainer.provider),
677
+ baseUrl: _resolveBaseUrl(trainer.provider),
678
+ configDir: cfgDir,
679
+ }),
680
+ ).catch(() => null);
681
+ await Promise.race([
682
+ userModelPromise,
683
+ new Promise((resolve) => setTimeout(resolve, 3000)),
684
+ ]);
685
+ }
686
+ } catch { /* /exit must never hang or throw */ }
687
+ return 'EXIT';
688
+ }
689
+ case '/config':
690
+ case '/setup': {
691
+ // Shared legacySlashRoute wiring (tests/f-config-slash-splash.test.mjs):
692
+ // sets requestSetup + 'EXIT'; the post-loop guard runs the wizard.
693
+ return legacySlashRoute(cmd, _legacyCtx);
694
+ }
695
+ default: {
696
+ // Delegate ONLY ctx-safe dispatcher commands to the shared handler.
697
+ // _legacyCtx is intentionally thin (no setters / openPicker / version),
698
+ // so a blanket delegation would silently degrade picker- or setter-
699
+ // driven handlers (/menu, /clear, /version, …). /channels is ctx-safe
700
+ // (it has a lib/config fallback); everything else stays an honest
701
+ // "unknown slash". 'EXIT' breaks the loop; a returned string prints.
702
+ if (LEGACY_DELEGATED_SLASHES.has(cmd)) {
703
+ const { args } = _parseSlashLine(line);
704
+ const r = await _dispatchSlash(cmd, args, _legacyCtx, (s) => process.stdout.write(s));
705
+ if (r === 'EXIT') return 'EXIT';
706
+ if (typeof r === 'string' && r.length) process.stdout.write(r + '\n');
707
+ return true;
708
+ }
709
+ process.stdout.write(`unknown slash: ${cmd} (try /help)\n`);
710
+ return true;
711
+ }
712
+ }
713
+ };
714
+
715
+ return _legacyHandleSlash;
716
+ }