lazyclaw 6.0.0 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/dotenv_min.mjs +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/package.json +14 -1
- package/secure_write.mjs +46 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
// commands/chat.mjs — interactive chat REPL (cmdChat), extracted from cli.mjs (D7).
|
|
2
|
+
// Verbatim move: the Ink/React path, the readline loop, and in-chat slash
|
|
3
|
+
// handling are unchanged. Only dynamic-import paths were rebased ./ -> ../,
|
|
4
|
+
// and the single cmdSetup call now lazy-imports ./setup.mjs so the
|
|
5
|
+
// chat <-> setup cycle is broken at the dynamic-import boundary.
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
configPath, readConfig, writeConfig,
|
|
9
|
+
_resolveAuthKey, _resolveBaseUrl, readVersionFromRepo,
|
|
10
|
+
} from '../lib/config.mjs';
|
|
11
|
+
import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
12
|
+
import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
|
|
13
|
+
import {
|
|
14
|
+
_attachGhostAutocomplete, _fetchModelsForProvider, _pauseChatForSubMenu,
|
|
15
|
+
_pickModelInteractive, _pickProviderInteractive, _printChatBanner,
|
|
16
|
+
_quickPrompt, _renderBanner, _renderV5Banner,
|
|
17
|
+
} from '../tui/pickers.mjs';
|
|
18
|
+
import { firstRunMode as _firstRunMode } from '../first_run.mjs';
|
|
19
|
+
import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
|
|
20
|
+
import { makeRunTurn as _chatRunTurnFactory } from '../tui/run_turn.mjs';
|
|
21
|
+
import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from '../tui/slash_dispatcher.mjs';
|
|
22
|
+
import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
|
|
23
|
+
|
|
24
|
+
export async function cmdChat(flags = {}) {
|
|
25
|
+
await ensureRegistry();
|
|
26
|
+
const sessionsMod = await import('../sessions.mjs');
|
|
27
|
+
const skillsMod = await import('../skills.mjs');
|
|
28
|
+
let cfg = readConfig();
|
|
29
|
+
// Mutable in-REPL state: /provider and /model edit these without
|
|
30
|
+
// touching config.json on disk. The CLI flag form (`chat --provider X`)
|
|
31
|
+
// would normally seed these via cfg, but we leave that to a future
|
|
32
|
+
// iteration; today the slash commands work against the on-disk default.
|
|
33
|
+
let activeProvName = cfg.provider || '';
|
|
34
|
+
let activeModel = cfg.model || null;
|
|
35
|
+
const lookupProv = (name) => getRegistry().PROVIDERS[name];
|
|
36
|
+
// First-run routing: a genuine fresh install (no provider, interactive)
|
|
37
|
+
// gets the full 5-step guided setup (provider+model, workspace, skills) —
|
|
38
|
+
// not just the provider picker. `chat --pick` stays a lightweight re-pick.
|
|
39
|
+
const _mode = _firstRunMode({
|
|
40
|
+
hasProvider: !!activeProvName,
|
|
41
|
+
flagPick: !!flags.pick,
|
|
42
|
+
isTTY: !!process.stdin.isTTY,
|
|
43
|
+
});
|
|
44
|
+
if (_mode === 'setup') {
|
|
45
|
+
try { await (await import('./setup.mjs')).cmdSetup(undefined, [], {}); }
|
|
46
|
+
catch (e) { if (process.env.LAZYCLAW_DEBUG) console.error('[setup] fell through:', e?.message); }
|
|
47
|
+
// Re-read the config the wizard just wrote so this session uses it.
|
|
48
|
+
cfg = readConfig();
|
|
49
|
+
activeProvName = cfg.provider || activeProvName;
|
|
50
|
+
activeModel = cfg.model || activeModel;
|
|
51
|
+
} else if (_mode === 'pick') {
|
|
52
|
+
const picked = await _pickProviderInteractive();
|
|
53
|
+
if (picked && picked.provider) {
|
|
54
|
+
activeProvName = picked.provider;
|
|
55
|
+
if (picked.model) activeModel = picked.model;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Last-resort safety net. v5.3.2 stopped falling through to 'mock' (which
|
|
59
|
+
// silently degraded a wiped config into garbage replies); default to the
|
|
60
|
+
// keyless claude-cli, but say so instead of switching silently.
|
|
61
|
+
if (!activeProvName) {
|
|
62
|
+
if (process.stdout.isTTY) {
|
|
63
|
+
process.stdout.write(' setup not completed — defaulting to claude-cli (keyless subscription). Run `lazyclaw setup` to configure a provider/model, workspace, and skills.\n');
|
|
64
|
+
}
|
|
65
|
+
activeProvName = 'claude-cli';
|
|
66
|
+
}
|
|
67
|
+
let prov = lookupProv(activeProvName);
|
|
68
|
+
if (!prov) { console.error(`unknown provider: ${activeProvName}`); process.exit(2); }
|
|
69
|
+
|
|
70
|
+
// Top-of-session banner so the user can see at a glance what they're
|
|
71
|
+
// talking to. Cheap (no provider call) and TTY-only.
|
|
72
|
+
// v5 ink splash + REPL when stdin is a real TTY and the user has not
|
|
73
|
+
// opted out via LAZYCLAW_NO_INK=1. Non-TTY pipelines and the opt-out
|
|
74
|
+
// env var fall through to the v4 figlet + readline path unchanged.
|
|
75
|
+
const __useInkSplash = process.stdout.isTTY && !process.env.LAZYCLAW_NO_INK;
|
|
76
|
+
if (__useInkSplash) {
|
|
77
|
+
try {
|
|
78
|
+
const React = (await import('react')).default;
|
|
79
|
+
const { render } = await import('ink');
|
|
80
|
+
const { ReplApp } = await import('../tui/repl.mjs');
|
|
81
|
+
const { renderSplashToString } = await import('../tui/splash.mjs');
|
|
82
|
+
// narrow-terminal fallback: <60 cols falls back to v4
|
|
83
|
+
if ((process.stdout.columns || 80) < 60) throw new Error('narrow-terminal');
|
|
84
|
+
|
|
85
|
+
// Tool groups — read the v5 registry and collapse to one row per category.
|
|
86
|
+
let toolGroups = [];
|
|
87
|
+
try {
|
|
88
|
+
const registry = await import('../mas/tools/registry.mjs');
|
|
89
|
+
const byCat = registry.byCategory();
|
|
90
|
+
toolGroups = Object.entries(byCat).map(([category, items]) => ({
|
|
91
|
+
category,
|
|
92
|
+
sensitive: items.some(t => t.sensitive),
|
|
93
|
+
verbs: items.map(t => t.name.replace(/^[a-z]+_/, '')).slice(0, 6),
|
|
94
|
+
})).sort((a, b) => a.category.localeCompare(b.category));
|
|
95
|
+
} catch { /* registry unavailable → empty list */ }
|
|
96
|
+
|
|
97
|
+
// Skill groups — group installed skills by filename hyphen-prefix
|
|
98
|
+
// (canonical C5 fallback: <group>-<name>.md → group; bare names → 'general').
|
|
99
|
+
let skillGroups = [];
|
|
100
|
+
try {
|
|
101
|
+
const { listSkills } = await import('../skills.mjs');
|
|
102
|
+
// Use the resolved config dir, not the module default, so a
|
|
103
|
+
// LAZYCLAW_CONFIG_DIR override surfaces that install's skills.
|
|
104
|
+
const flat = listSkills(path.dirname(configPath()));
|
|
105
|
+
const byGroup = new Map();
|
|
106
|
+
for (const s of flat) {
|
|
107
|
+
const i = s.name.indexOf('-');
|
|
108
|
+
const group = i > 0 ? s.name.slice(0, i) : 'general';
|
|
109
|
+
const sub = i > 0 ? s.name.slice(i + 1) : s.name;
|
|
110
|
+
if (!byGroup.has(group)) byGroup.set(group, []);
|
|
111
|
+
byGroup.get(group).push(sub);
|
|
112
|
+
}
|
|
113
|
+
skillGroups = [...byGroup.entries()]
|
|
114
|
+
.map(([group, names]) => ({ group, names: names.slice(0, 6) }))
|
|
115
|
+
.sort((a, b) => a.group.localeCompare(b.group));
|
|
116
|
+
} catch { /* skills dir unavailable → empty list */ }
|
|
117
|
+
|
|
118
|
+
const splashProps = {
|
|
119
|
+
provider: activeProvName, model: activeModel,
|
|
120
|
+
trainer: {}, sessionId: flags.session || '',
|
|
121
|
+
cwd: process.cwd(),
|
|
122
|
+
version: readVersionFromRepo(),
|
|
123
|
+
tools: toolGroups,
|
|
124
|
+
skills: skillGroups,
|
|
125
|
+
};
|
|
126
|
+
void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
|
|
127
|
+
|
|
128
|
+
// C7 — minimal chat-session state for the ink path so runTurn can
|
|
129
|
+
// talk to the provider (the legacy readline path below sets up the
|
|
130
|
+
// same shape — kept duplicated here intentionally so the ink branch
|
|
131
|
+
// remains self-contained and the legacy path stays byte-identical).
|
|
132
|
+
// Slash commands aren't wired into the ink REPL yet (v5.1 follow-up);
|
|
133
|
+
// until then, system-prompt composition / --session resume happen
|
|
134
|
+
// identically to the legacy path.
|
|
135
|
+
let _inkSandboxSpec = null;
|
|
136
|
+
if (flags.sandbox) {
|
|
137
|
+
const sb = await import('../sandbox.mjs');
|
|
138
|
+
try { _inkSandboxSpec = sb.parseSandboxSpec(flags.sandbox, flags); }
|
|
139
|
+
catch (err) { console.error(`error: ${err.message}`); process.exit(2); }
|
|
140
|
+
}
|
|
141
|
+
let _inkSessionId = flags.session || null;
|
|
142
|
+
const _inkCfgDir = path.dirname(configPath());
|
|
143
|
+
let _inkMessages = _inkSessionId
|
|
144
|
+
? sessionsMod.loadTurns(_inkSessionId, _inkCfgDir).map((t) => ({ role: t.role, content: t.content }))
|
|
145
|
+
: [];
|
|
146
|
+
if (_inkMessages.length > 0) {
|
|
147
|
+
const cfgChat = cfg.chat || {};
|
|
148
|
+
const winTurns = Number(cfgChat.windowTurns) || CHAT_WINDOW_TURNS;
|
|
149
|
+
const winTokens = Number(cfgChat.windowTokens) || CHAT_WINDOW_TOKEN_BUDGET;
|
|
150
|
+
const { messages: trimmed } = _applyChatWindow(_inkMessages, { turns: winTurns, tokens: winTokens });
|
|
151
|
+
_inkMessages = trimmed;
|
|
152
|
+
}
|
|
153
|
+
// System prompt composition — mirrors the legacy path's sysParts logic.
|
|
154
|
+
const _inkSkillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
|
|
155
|
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
156
|
+
const _inkWorkspaceName = flags.workspace || cfg.workspace || '';
|
|
157
|
+
const _inkSysParts = [];
|
|
158
|
+
try {
|
|
159
|
+
const { composePromptStack } = await import('../mas/prompt_stack.mjs');
|
|
160
|
+
const stacked = composePromptStack({
|
|
161
|
+
cfgDir: _inkCfgDir,
|
|
162
|
+
agent: { name: 'chat', role: '' },
|
|
163
|
+
workspace: _inkWorkspaceName,
|
|
164
|
+
});
|
|
165
|
+
if (stacked && stacked.trim()) _inkSysParts.push(stacked);
|
|
166
|
+
} catch { /* never block chat start on stack composition */ }
|
|
167
|
+
if (_inkWorkspaceName && !_inkMessages.some((m) => m.role === 'system')) {
|
|
168
|
+
try {
|
|
169
|
+
const ws = await import('../workspace.mjs');
|
|
170
|
+
const wsPrompt = ws.composeWorkspacePrompt(_inkCfgDir, _inkWorkspaceName);
|
|
171
|
+
if (wsPrompt) _inkSysParts.push(wsPrompt);
|
|
172
|
+
} catch (err) { console.error(`workspace error: ${err.message}`); process.exit(2); }
|
|
173
|
+
}
|
|
174
|
+
if (_inkSkillNames.length > 0 && !_inkMessages.some((m) => m.role === 'system')) {
|
|
175
|
+
try {
|
|
176
|
+
const sys = skillsMod.composeSystemPrompt(_inkSkillNames, _inkCfgDir);
|
|
177
|
+
if (sys) _inkSysParts.push(sys);
|
|
178
|
+
} catch (err) { console.error(`skill error: ${err.message}`); process.exit(2); }
|
|
179
|
+
}
|
|
180
|
+
if (_inkSysParts.length && !_inkMessages.some((m) => m.role === 'system')) {
|
|
181
|
+
const merged = _inkSysParts.join('\n\n---\n\n');
|
|
182
|
+
_inkMessages.unshift({ role: 'system', content: merged });
|
|
183
|
+
if (_inkSessionId) sessionsMod.appendTurn(_inkSessionId, 'system', merged, _inkCfgDir);
|
|
184
|
+
}
|
|
185
|
+
let _inkRunningUsage = null;
|
|
186
|
+
const _inkAccumulateUsage = (u) => {
|
|
187
|
+
if (!u) return;
|
|
188
|
+
if (!_inkRunningUsage) _inkRunningUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turnsWithUsage: 0 };
|
|
189
|
+
_inkRunningUsage.inputTokens += Number(u.inputTokens) || 0;
|
|
190
|
+
_inkRunningUsage.outputTokens += Number(u.outputTokens) || 0;
|
|
191
|
+
_inkRunningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
|
|
192
|
+
_inkRunningUsage.turnsWithUsage += 1;
|
|
193
|
+
};
|
|
194
|
+
const _inkChatStartedAt = Date.now();
|
|
195
|
+
const _inkSyntheticChatSessionId = `chat-${process.pid}-${_inkChatStartedAt}`;
|
|
196
|
+
const _inkPersistTurn = (role, content) => {
|
|
197
|
+
if (_inkSessionId) {
|
|
198
|
+
sessionsMod.appendTurn(_inkSessionId, role, content, _inkCfgDir);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
import('../memory.mjs').then((m) => {
|
|
203
|
+
try { m.appendRecent(_inkSyntheticChatSessionId, role, content, _inkCfgDir); }
|
|
204
|
+
catch { /* swallow */ }
|
|
205
|
+
}).catch(() => {});
|
|
206
|
+
} catch { /* swallow */ }
|
|
207
|
+
};
|
|
208
|
+
// v5.4: chars-sent counter for the Ink chat path. Mirrors the legacy
|
|
209
|
+
// path's `charsSent` so /usage in Ink reports the same number.
|
|
210
|
+
let _inkCharsSent = 0;
|
|
211
|
+
const _inkCtx = {
|
|
212
|
+
cfg,
|
|
213
|
+
cfgDir: _inkCfgDir,
|
|
214
|
+
sandboxSpec: _inkSandboxSpec,
|
|
215
|
+
syntheticChatSessionId: _inkSyntheticChatSessionId,
|
|
216
|
+
version: readVersionFromRepo(),
|
|
217
|
+
registryMod: getRegistry(),
|
|
218
|
+
sessionsMod,
|
|
219
|
+
// Pre-imported so dispatcher avoids a dynamic import per /skill call.
|
|
220
|
+
skillsMod,
|
|
221
|
+
getMessages: () => _inkMessages,
|
|
222
|
+
setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
|
|
223
|
+
getProv: () => prov,
|
|
224
|
+
setProv: (next) => { prov = next; },
|
|
225
|
+
getActiveProvName: () => activeProvName,
|
|
226
|
+
setActiveProvName: (name) => { activeProvName = name; },
|
|
227
|
+
getActiveModel: () => activeModel,
|
|
228
|
+
setActiveModel: (name) => { activeModel = name; },
|
|
229
|
+
getSessionId: () => _inkSessionId,
|
|
230
|
+
setSessionId: (id) => { _inkSessionId = id; },
|
|
231
|
+
getCharsSent: () => _inkCharsSent,
|
|
232
|
+
setCharsSent: (n) => { _inkCharsSent = Number(n) || 0; },
|
|
233
|
+
getRunningUsage: () => _inkRunningUsage,
|
|
234
|
+
setRunningUsage: (u) => { _inkRunningUsage = u; },
|
|
235
|
+
persistTurn: _inkPersistTurn,
|
|
236
|
+
accumulateUsage: _inkAccumulateUsage,
|
|
237
|
+
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
238
|
+
resolveBaseUrl: (providerName) => _resolveBaseUrl(providerName),
|
|
239
|
+
onCharsSent: (n) => { _inkCharsSent += Number(n) || 0; },
|
|
240
|
+
// P2 — let /provider add register a custom OpenAI-compatible endpoint
|
|
241
|
+
// by read-merge-writing config.json from inside the Ink session.
|
|
242
|
+
readConfig: () => readConfig(),
|
|
243
|
+
writeConfig: (next) => writeConfig(next),
|
|
244
|
+
};
|
|
245
|
+
// v5.4.3 — ReplApp exposes an openPicker(opts) → Promise<id|null>
|
|
246
|
+
// via this ref. The slash dispatcher reads it through ctx.openPicker
|
|
247
|
+
// to drive /provider, /model, /personality without forking off raw
|
|
248
|
+
// stdin from Ink. When ReplApp hasn't populated the ref yet (early
|
|
249
|
+
// mount / non-Ink path) the dispatcher falls back to its hint
|
|
250
|
+
// string so users aren't stranded.
|
|
251
|
+
const _inkPickerRef = { current: null };
|
|
252
|
+
_inkCtx.openPicker = (opts) => {
|
|
253
|
+
const api = _inkPickerRef.current;
|
|
254
|
+
return api && typeof api.openPicker === 'function'
|
|
255
|
+
? api.openPicker(opts)
|
|
256
|
+
: Promise.resolve(null);
|
|
257
|
+
};
|
|
258
|
+
// v5.0.10: write streamed chunks straight to process.stdout. Ink
|
|
259
|
+
// owns the screen, so interleaved stdout writes can produce some
|
|
260
|
+
// visual jank — accepted trade for unblocking the chat loop. v5.1
|
|
261
|
+
// TODO: route through a ref'd scrollback <Static/> region in
|
|
262
|
+
// ReplApp so Ink owns all output.
|
|
263
|
+
const _inkRunTurn = _chatRunTurnFactory({
|
|
264
|
+
ctx: _inkCtx,
|
|
265
|
+
writeFn: (chunk) => process.stdout.write(chunk),
|
|
266
|
+
});
|
|
267
|
+
// v5.4: full slash-command dispatch via tui/slash_dispatcher.mjs.
|
|
268
|
+
// Dispatcher returns a string (rendered to scrollback by ReplApp),
|
|
269
|
+
// 'EXIT' (caller unmounts), or void (streamed via write). /exit and
|
|
270
|
+
// /quit are also intercepted earlier inside ReplApp.handleSubmit so
|
|
271
|
+
// either path terminates cleanly.
|
|
272
|
+
const _inkSlashHandler = async (line, signal) => {
|
|
273
|
+
const { cmd, args } = _parseSlashLine(line);
|
|
274
|
+
// Thread the REPL's abort signal so Esc/Ctrl-C can stop a /loop.
|
|
275
|
+
_inkCtx.loopSignal = signal || null;
|
|
276
|
+
return _dispatchSlash(cmd, args, _inkCtx, (chunk) => {
|
|
277
|
+
try { process.stdout.write(chunk); } catch { /* swallow */ }
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
// v5.4.1: splash renders INSIDE the alt-buffer (not pre-printed to
|
|
281
|
+
// primary). The v5.4.0 pre-print made the screen go blank during
|
|
282
|
+
// chat because alt-buffer cleared it on enter. Splash lives in the
|
|
283
|
+
// Static scrollback now regardless of alt-buffer state.
|
|
284
|
+
const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
|
|
285
|
+
splashProps,
|
|
286
|
+
statusInfo: { provider: activeProvName, model: activeModel },
|
|
287
|
+
// P3 — live status: read the current provider/model + token gauge so
|
|
288
|
+
// the StatusBar refreshes after a /provider or /model switch and each
|
|
289
|
+
// turn, instead of showing the values captured at mount.
|
|
290
|
+
getStatus: () => ({
|
|
291
|
+
provider: activeProvName,
|
|
292
|
+
model: activeModel,
|
|
293
|
+
ctxUsed: _inkRunningUsage ? _inkRunningUsage.totalTokens : undefined,
|
|
294
|
+
ctxTotal: CHAT_WINDOW_TOKEN_BUDGET,
|
|
295
|
+
}),
|
|
296
|
+
runTurn: _inkRunTurn,
|
|
297
|
+
onSlashCommand: _inkSlashHandler,
|
|
298
|
+
pickerRef: _inkPickerRef,
|
|
299
|
+
}), { exitOnCtrlC: true, patchConsole: true });
|
|
300
|
+
await ink.waitUntilExit();
|
|
301
|
+
return;
|
|
302
|
+
} catch (e) {
|
|
303
|
+
// Fall through to legacy path on any ink failure (missing import,
|
|
304
|
+
// narrow terminal, sandboxed stdout).
|
|
305
|
+
if (process.env.LAZYCLAW_DEBUG) console.error('[ink] fallback:', e.message);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// ─── legacy v4 path (unchanged) ─────────────────────────────────
|
|
309
|
+
_printChatBanner(activeProvName, activeModel, readVersionFromRepo());
|
|
310
|
+
|
|
311
|
+
const readline = await import('node:readline');
|
|
312
|
+
// Use terminal:true when we're attached to a TTY so the prompt shows
|
|
313
|
+
// and ghost-text autocomplete (below) can render. Falls back to the
|
|
314
|
+
// plain non-terminal mode for piped/non-TTY callers.
|
|
315
|
+
const useTerminal = !!process.stdin.isTTY;
|
|
316
|
+
// The readline interface is created *adjacent* to the for-await loop below
|
|
317
|
+
// (after all the async setup), not here. On node 20 a piped (non-TTY)
|
|
318
|
+
// stdin emits its lines + EOF during the `await import(...)` setup that
|
|
319
|
+
// runs before the loop; if the interface already exists, the async
|
|
320
|
+
// iterator hasn't attached yet and those lines are dropped — the chat
|
|
321
|
+
// produced no output on Linux CI (node 20) while passing on macOS (node
|
|
322
|
+
// 22, which tolerates the gap). Declaring rl/_ghost here keeps handleSlash's
|
|
323
|
+
// closures resolvable; the actual createInterface happens just-in-time.
|
|
324
|
+
let rl;
|
|
325
|
+
let _ghost = { dispose: () => {}, suspend: () => {}, resume: () => {} };
|
|
326
|
+
|
|
327
|
+
// --sandbox docker:<image> wraps subprocess-providers (claude-cli)
|
|
328
|
+
// in a docker container. Parsed once up front so a slash-command
|
|
329
|
+
// model switch doesn't have to re-parse every turn.
|
|
330
|
+
let sandboxSpec = null;
|
|
331
|
+
if (flags.sandbox) {
|
|
332
|
+
const sb = await import('../sandbox.mjs');
|
|
333
|
+
try { sandboxSpec = sb.parseSandboxSpec(flags.sandbox, flags); }
|
|
334
|
+
catch (e) { console.error(`error: ${e.message}`); process.exit(2); }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Persistent session ID. When --session is set we hydrate prior turns from
|
|
338
|
+
// <configDir>/sessions/<id>.jsonl and append every new turn back to it.
|
|
339
|
+
// Without --session, chat is in-memory only (matches phase 4 behavior).
|
|
340
|
+
// Mutable so /goal <name> can switch the working context mid-session.
|
|
341
|
+
let sessionId = flags.session || null;
|
|
342
|
+
// Currently-active goal name when the user has switched context via
|
|
343
|
+
// /goal <name>. Tracked so /status can surface it and so future ticks
|
|
344
|
+
// know which goal to attribute new turns to.
|
|
345
|
+
let activeGoalName = null;
|
|
346
|
+
const cfgDir = path.dirname(configPath());
|
|
347
|
+
let messages = sessionId
|
|
348
|
+
? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
|
|
349
|
+
: [];
|
|
350
|
+
|
|
351
|
+
// M6 — apply sliding window at session start. Long-running sessions
|
|
352
|
+
// (50+ turns) used to ship every prior turn to the provider every
|
|
353
|
+
// request; we now keep at most CHAT_WINDOW_TURNS turns (default 20)
|
|
354
|
+
// plus the system message. Operators can override via env. The
|
|
355
|
+
// per-session log on disk is untouched — only the in-memory prompt
|
|
356
|
+
// window is trimmed. We log to stderr once at session start so the
|
|
357
|
+
// user knows context was dropped.
|
|
358
|
+
if (messages.length > 0) {
|
|
359
|
+
const cfgChat = cfg.chat || {};
|
|
360
|
+
const winTurns = Number(cfgChat.windowTurns) || CHAT_WINDOW_TURNS;
|
|
361
|
+
const winTokens = Number(cfgChat.windowTokens) || CHAT_WINDOW_TOKEN_BUDGET;
|
|
362
|
+
const { messages: trimmed, dropped } = _applyChatWindow(messages, { turns: winTurns, tokens: winTokens });
|
|
363
|
+
if (dropped > 0) {
|
|
364
|
+
process.stderr.write(`[chat] sliding window: dropped ${dropped} older turn(s), ${trimmed.length} kept\n`);
|
|
365
|
+
}
|
|
366
|
+
messages = trimmed;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// --skill (comma-separated names) composes into a system message at the
|
|
370
|
+
// head of the conversation. Same shape as `agent --skill`. Defaults from
|
|
371
|
+
// config.skills array when --skill not passed. We only inject if no
|
|
372
|
+
// system message is already present (so resuming a session doesn't
|
|
373
|
+
// double-prepend skills that the prior invocation already added).
|
|
374
|
+
const skillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
|
|
375
|
+
.split(',').map(s => s.trim()).filter(Boolean);
|
|
376
|
+
// --workspace <name> sits at the head of the system prompt, then
|
|
377
|
+
// any --skill block. The two compose with the same `\n---\n`
|
|
378
|
+
// separator the agent path uses, so `lazyclaw workspace show` is
|
|
379
|
+
// a faithful preview.
|
|
380
|
+
const workspaceName = flags.workspace || cfg.workspace || '';
|
|
381
|
+
const sysParts = [];
|
|
382
|
+
// v5 (canonical decision C5) — prepend the 8-layer composePromptStack
|
|
383
|
+
// output. Falls back silently to no-op when the configDir has none of
|
|
384
|
+
// the source files present (fresh install) so chat-start stays
|
|
385
|
+
// byte-identical to the v4 shape until a user authors USER.md or a
|
|
386
|
+
// personality. Wrapped in try/catch — chat start must never break on
|
|
387
|
+
// a stack composition error.
|
|
388
|
+
try {
|
|
389
|
+
const { composePromptStack } = await import('../mas/prompt_stack.mjs');
|
|
390
|
+
const stacked = composePromptStack({
|
|
391
|
+
cfgDir,
|
|
392
|
+
agent: { name: 'chat', role: '' },
|
|
393
|
+
workspace: workspaceName,
|
|
394
|
+
});
|
|
395
|
+
if (stacked && stacked.trim()) sysParts.push(stacked);
|
|
396
|
+
} catch { /* never block chat start on stack composition */ }
|
|
397
|
+
if (workspaceName && !messages.some(m => m.role === 'system')) {
|
|
398
|
+
try {
|
|
399
|
+
const ws = await import('../workspace.mjs');
|
|
400
|
+
const wsPrompt = ws.composeWorkspacePrompt(cfgDir, workspaceName);
|
|
401
|
+
if (wsPrompt) sysParts.push(wsPrompt);
|
|
402
|
+
} catch (e) { console.error(`workspace error: ${e.message}`); process.exit(2); }
|
|
403
|
+
}
|
|
404
|
+
if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
|
|
405
|
+
try {
|
|
406
|
+
const sys = skillsMod.composeSystemPrompt(skillNames, cfgDir);
|
|
407
|
+
if (sys) sysParts.push(sys);
|
|
408
|
+
} catch (e) {
|
|
409
|
+
console.error(`skill error: ${e.message}`);
|
|
410
|
+
process.exit(2);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (sysParts.length && !messages.some(m => m.role === 'system')) {
|
|
414
|
+
const merged = sysParts.join('\n\n---\n\n');
|
|
415
|
+
messages.unshift({ role: 'system', content: merged });
|
|
416
|
+
if (sessionId) sessionsMod.appendTurn(sessionId, 'system', merged, cfgDir);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let charsSent = messages.reduce((n, m) => n + (m.role === 'user' ? String(m.content || '').length : 0), 0);
|
|
420
|
+
if (sessionId && messages.length > (skillNames.length > 0 ? 1 : 0)) {
|
|
421
|
+
process.stdout.write(`resumed session ${sessionId} with ${messages.length} prior turn(s)\n`);
|
|
422
|
+
}
|
|
423
|
+
// Running usage accumulator. /usage reports both the cheap local
|
|
424
|
+
// estimate (messageCount + charsSent) AND the provider-reported
|
|
425
|
+
// totals when the provider emits them on each turn. Mock provider
|
|
426
|
+
// doesn't emit usage, so usage stays null there — no surprise.
|
|
427
|
+
/** @type {{ inputTokens: number, outputTokens: number, totalTokens: number, turnsWithUsage: number } | null} */
|
|
428
|
+
let runningUsage = null;
|
|
429
|
+
const accumulateUsage = (u) => {
|
|
430
|
+
if (!u) return;
|
|
431
|
+
if (!runningUsage) runningUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turnsWithUsage: 0 };
|
|
432
|
+
runningUsage.inputTokens += Number(u.inputTokens) || 0;
|
|
433
|
+
runningUsage.outputTokens += Number(u.outputTokens) || 0;
|
|
434
|
+
runningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
|
|
435
|
+
runningUsage.turnsWithUsage += 1;
|
|
436
|
+
};
|
|
437
|
+
// v5 Group A (M2): always-on synthetic session id so an unsessioned
|
|
438
|
+
// chat still populates memory/recent.jsonl. Without this, the nudge
|
|
439
|
+
// loop never saw repeated prompts in chat sessions that didn't pass
|
|
440
|
+
// --session, and `nudge.suggest_skill` clusters silently lost ~95%
|
|
441
|
+
// of their evidence. The `chat-<pid>-<startTs>` prefix keeps the
|
|
442
|
+
// synthetic id distinguishable from real session ids on disk.
|
|
443
|
+
const chatStartedAt = Date.now();
|
|
444
|
+
const _syntheticChatSessionId = `chat-${process.pid}-${chatStartedAt}`;
|
|
445
|
+
const persistTurn = (role, content) => {
|
|
446
|
+
if (sessionId) {
|
|
447
|
+
sessionsMod.appendTurn(sessionId, role, content, cfgDir);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// No --session: don't touch sessions/<id>.jsonl, but DO append to
|
|
451
|
+
// memory/recent.jsonl directly so the nudge loop can cluster on
|
|
452
|
+
// unsessioned chats. Best-effort — a broken memory module must not
|
|
453
|
+
// break a chat turn.
|
|
454
|
+
try {
|
|
455
|
+
import('../memory.mjs').then((m) => {
|
|
456
|
+
try { m.appendRecent(_syntheticChatSessionId, role, content, cfgDir); }
|
|
457
|
+
catch { /* swallow */ }
|
|
458
|
+
}).catch(() => {});
|
|
459
|
+
} catch { /* swallow */ }
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// C7 — shared runTurn closure for the legacy path. The same factory
|
|
463
|
+
// backs the ink path above; both call sites get one set of bugs.
|
|
464
|
+
// Getters close over the *current* binding of sessionId, prov,
|
|
465
|
+
// activeProvName, activeModel — so a mid-session /provider switch
|
|
466
|
+
// takes effect on the very next turn.
|
|
467
|
+
const _legacyCtx = {
|
|
468
|
+
cfg,
|
|
469
|
+
cfgDir,
|
|
470
|
+
sandboxSpec,
|
|
471
|
+
syntheticChatSessionId: _syntheticChatSessionId,
|
|
472
|
+
getMessages: () => messages,
|
|
473
|
+
getProv: () => prov,
|
|
474
|
+
getActiveProvName: () => activeProvName,
|
|
475
|
+
getActiveModel: () => activeModel,
|
|
476
|
+
getSessionId: () => sessionId,
|
|
477
|
+
persistTurn,
|
|
478
|
+
accumulateUsage,
|
|
479
|
+
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
480
|
+
onCharsSent: (n) => { charsSent += n; },
|
|
481
|
+
};
|
|
482
|
+
const runTurn = _chatRunTurnFactory({
|
|
483
|
+
ctx: _legacyCtx,
|
|
484
|
+
writeFn: (chunk) => process.stdout.write(chunk),
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const handleSlash = async (line) => {
|
|
488
|
+
const cmd = line.split(/\s+/)[0];
|
|
489
|
+
switch (cmd) {
|
|
490
|
+
case '/help': {
|
|
491
|
+
process.stdout.write('slash commands:\n');
|
|
492
|
+
for (const c of SLASH_COMMANDS) process.stdout.write(` ${c.cmd.padEnd(8)} — ${c.help}\n`);
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
case '/status': {
|
|
496
|
+
const out = {
|
|
497
|
+
provider: activeProvName,
|
|
498
|
+
model: activeModel,
|
|
499
|
+
keyMasked: getRegistry().maskApiKey(cfg['api-key']),
|
|
500
|
+
messageCount: messages.length,
|
|
501
|
+
};
|
|
502
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
case '/provider': {
|
|
506
|
+
// `/provider <name>` switches the active provider for subsequent
|
|
507
|
+
// turns. The conversation history stays put — the next user
|
|
508
|
+
// message goes to the new provider with the existing context.
|
|
509
|
+
// `/provider` (no arg) opens the family/provider/model picker so
|
|
510
|
+
// the user can switch with arrow keys instead of memorising names.
|
|
511
|
+
const arg = line.slice('/provider'.length).trim();
|
|
512
|
+
if (!arg) {
|
|
513
|
+
if (!useTerminal) {
|
|
514
|
+
process.stdout.write(`provider: ${activeProvName}\n`);
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
await _pauseChatForSubMenu(rl, _ghost, async () => {
|
|
518
|
+
const picked = await _pickProviderInteractive();
|
|
519
|
+
if (picked && picked.provider) {
|
|
520
|
+
const next = lookupProv(picked.provider);
|
|
521
|
+
if (!next) {
|
|
522
|
+
process.stdout.write(`unknown provider: ${picked.provider}\n`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
activeProvName = picked.provider;
|
|
526
|
+
prov = next;
|
|
527
|
+
if (picked.model) activeModel = picked.model;
|
|
528
|
+
process.stdout.write(`provider → ${activeProvName}${picked.model ? ` · model → ${picked.model}` : ''}\n`);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
const next = lookupProv(arg);
|
|
534
|
+
if (!next) {
|
|
535
|
+
process.stdout.write(`unknown provider: ${arg} (known: ${Object.keys(getRegistry().PROVIDERS).join(', ')})\n`);
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
activeProvName = arg;
|
|
539
|
+
prov = next;
|
|
540
|
+
process.stdout.write(`provider → ${arg}\n`);
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
case '/model': {
|
|
544
|
+
// `/model <name>` updates the active model without touching the
|
|
545
|
+
// provider. `/model` (no arg) opens the per-provider model picker
|
|
546
|
+
// — same UX as setup step 3, scoped to the active provider.
|
|
547
|
+
const arg = line.slice('/model'.length).trim();
|
|
548
|
+
if (!arg) {
|
|
549
|
+
if (!useTerminal) {
|
|
550
|
+
process.stdout.write(`model: ${activeModel || '(default)'}\n`);
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
await _pauseChatForSubMenu(rl, _ghost, async () => {
|
|
554
|
+
const chosen = await _pickModelInteractive(activeProvName, { titlePrefix: 'LazyClaw chat —' });
|
|
555
|
+
if (chosen === 'CANCEL' || chosen === 'BACK' || !chosen) return;
|
|
556
|
+
activeModel = chosen;
|
|
557
|
+
process.stdout.write(`model → ${activeModel}\n`);
|
|
558
|
+
});
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
// Honor unified provider/model: `/model anthropic/claude-opus-4-7`
|
|
562
|
+
// splits and switches both.
|
|
563
|
+
const { parseSlashProviderModel } = getRegistry();
|
|
564
|
+
const parsed = parseSlashProviderModel(arg);
|
|
565
|
+
if (parsed.provider) {
|
|
566
|
+
const next = lookupProv(parsed.provider);
|
|
567
|
+
if (!next) {
|
|
568
|
+
process.stdout.write(`unknown provider: ${parsed.provider}\n`);
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
activeProvName = parsed.provider;
|
|
572
|
+
prov = next;
|
|
573
|
+
}
|
|
574
|
+
activeModel = parsed.model || arg;
|
|
575
|
+
process.stdout.write(`model → ${activeModel}${parsed.provider ? ` (provider → ${parsed.provider})` : ''}\n`);
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
case '/new':
|
|
579
|
+
case '/reset': {
|
|
580
|
+
messages = [];
|
|
581
|
+
charsSent = 0;
|
|
582
|
+
runningUsage = null;
|
|
583
|
+
if (sessionId) {
|
|
584
|
+
const sm = await import('../sessions.mjs');
|
|
585
|
+
sm.resetSession(sessionId, cfgDir);
|
|
586
|
+
}
|
|
587
|
+
process.stdout.write('cleared — new conversation\n');
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
case '/usage': {
|
|
591
|
+
const out = { messageCount: messages.length, charsSent };
|
|
592
|
+
if (runningUsage) out.tokens = runningUsage;
|
|
593
|
+
// When cfg.rates has a card for the active provider/model AND
|
|
594
|
+
// we accumulated real usage, surface the running cost too. The
|
|
595
|
+
// computation is local (pure arithmetic), no extra network.
|
|
596
|
+
if (runningUsage && cfg.rates && typeof cfg.rates === 'object') {
|
|
597
|
+
try {
|
|
598
|
+
const { costFromUsage } = await import('../providers/rates.mjs');
|
|
599
|
+
const r = costFromUsage(
|
|
600
|
+
{ provider: activeProvName, model: activeModel, usage: runningUsage },
|
|
601
|
+
cfg.rates,
|
|
602
|
+
);
|
|
603
|
+
if (r) out.cost = r;
|
|
604
|
+
} catch { /* never let cost-card lookup fail the slash */ }
|
|
605
|
+
}
|
|
606
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
case '/skill': {
|
|
610
|
+
// `/skill name1,name2` — replace the active system message with a
|
|
611
|
+
// composition of the named skills. `/skill` (no arg) clears the
|
|
612
|
+
// system message. The replacement happens in-place on the
|
|
613
|
+
// messages array; the prior system turn (if any) is dropped so
|
|
614
|
+
// we don't end up with two stacked system messages talking past
|
|
615
|
+
// each other. When --session is set we persist the new system
|
|
616
|
+
// message so the next invocation resumes with the same context.
|
|
617
|
+
const arg = line.slice('/skill'.length).trim();
|
|
618
|
+
const names = arg.split(',').map(s => s.trim()).filter(Boolean);
|
|
619
|
+
const sysIdx = messages.findIndex(m => m.role === 'system');
|
|
620
|
+
if (names.length === 0) {
|
|
621
|
+
if (sysIdx >= 0) messages.splice(sysIdx, 1);
|
|
622
|
+
if (sessionId) {
|
|
623
|
+
// Persistent session: rewrite the file from scratch so the
|
|
624
|
+
// dropped system turn doesn't linger as a stale entry.
|
|
625
|
+
const sm = await import('../sessions.mjs');
|
|
626
|
+
sm.resetSession(sessionId, cfgDir);
|
|
627
|
+
for (const m of messages) sm.appendTurn(sessionId, m.role, m.content, cfgDir);
|
|
628
|
+
}
|
|
629
|
+
process.stdout.write('cleared system prompt (no active skills)\n');
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const sys = await (async () => {
|
|
634
|
+
const mod = await import('../skills.mjs');
|
|
635
|
+
return mod.composeSystemPrompt(names, cfgDir);
|
|
636
|
+
})();
|
|
637
|
+
if (!sys) {
|
|
638
|
+
process.stdout.write('no skill content composed (empty input?)\n');
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: sys };
|
|
642
|
+
else messages.unshift({ role: 'system', content: sys });
|
|
643
|
+
if (sessionId) {
|
|
644
|
+
const sm = await import('../sessions.mjs');
|
|
645
|
+
sm.resetSession(sessionId, cfgDir);
|
|
646
|
+
for (const m of messages) sm.appendTurn(sessionId, m.role, m.content, cfgDir);
|
|
647
|
+
}
|
|
648
|
+
process.stdout.write(`active skills: ${names.join(', ')}\n`);
|
|
649
|
+
} catch (e) {
|
|
650
|
+
process.stdout.write(`skill error: ${e?.message || e}\n`);
|
|
651
|
+
}
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
case '/loop': {
|
|
655
|
+
// `/loop <prompt> [--max N] [--until "<regex>"]` — repeats one
|
|
656
|
+
// user prompt against the active provider in the current session.
|
|
657
|
+
// Default --max 3, hard cap 50. --until short-circuits when its
|
|
658
|
+
// regex matches the latest assistant turn. Ctrl+C aborts the
|
|
659
|
+
// current stream AND the whole loop (not just the in-flight
|
|
660
|
+
// turn). Implementation lives in loop-engine.mjs; here we wire
|
|
661
|
+
// it to the same provider streaming + buffered-writer used by a
|
|
662
|
+
// normal user turn.
|
|
663
|
+
const arg = line.slice('/loop'.length).trim();
|
|
664
|
+
const loopMod = await import('../loop-engine.mjs');
|
|
665
|
+
if (!arg) {
|
|
666
|
+
process.stdout.write(`usage: /loop <prompt> [--max N] [--until "<regex>"]\n`);
|
|
667
|
+
process.stdout.write(` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}\n`);
|
|
668
|
+
process.stdout.write(` session: ${sessionId || '(none — turns will not be persisted)'}\n`);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
let parsed;
|
|
672
|
+
try { parsed = loopMod.parseLoopArgs(arg); }
|
|
673
|
+
catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
|
|
674
|
+
let untilRe = null;
|
|
675
|
+
try { untilRe = loopMod.compileUntil(parsed.until); }
|
|
676
|
+
catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
|
|
677
|
+
|
|
678
|
+
// Per-loop AbortController. Ctrl+C aborts the current provider
|
|
679
|
+
// call (via signal) AND prevents the next iteration (the engine
|
|
680
|
+
// sees signal.aborted on its loop check). Same handler shape as
|
|
681
|
+
// the normal-turn path; symmetry keeps `/exit` clean afterwards.
|
|
682
|
+
const loopAc = new AbortController();
|
|
683
|
+
const onSigint = () => {
|
|
684
|
+
loopAc.abort();
|
|
685
|
+
process.stdout.write('\n^C interrupted — loop aborted\n');
|
|
686
|
+
};
|
|
687
|
+
process.on('SIGINT', onSigint);
|
|
688
|
+
|
|
689
|
+
const sendOnce = async (msgs, signal) => {
|
|
690
|
+
let acc = '';
|
|
691
|
+
let _writeBuf = '';
|
|
692
|
+
let _writeTimer = null;
|
|
693
|
+
const _flush = () => {
|
|
694
|
+
if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
|
|
695
|
+
_writeTimer = null;
|
|
696
|
+
};
|
|
697
|
+
const _writeChunk = (s) => {
|
|
698
|
+
_writeBuf += s;
|
|
699
|
+
if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
|
|
700
|
+
};
|
|
701
|
+
try {
|
|
702
|
+
for await (const chunk of prov.sendMessage(msgs, {
|
|
703
|
+
apiKey: _resolveAuthKey(cfg, activeProvName),
|
|
704
|
+
model: activeModel,
|
|
705
|
+
sandbox: sandboxSpec,
|
|
706
|
+
signal,
|
|
707
|
+
onUsage: accumulateUsage,
|
|
708
|
+
})) {
|
|
709
|
+
_writeChunk(chunk);
|
|
710
|
+
acc += chunk;
|
|
711
|
+
}
|
|
712
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
713
|
+
_flush();
|
|
714
|
+
process.stdout.write('\n');
|
|
715
|
+
return acc;
|
|
716
|
+
} catch (err) {
|
|
717
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
718
|
+
_flush();
|
|
719
|
+
throw err;
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
if (useTerminal) _ghost.suspend();
|
|
724
|
+
// Capture the chat's existing system message (workspace / skill
|
|
725
|
+
// composition) before we let the engine touch it; we restore it
|
|
726
|
+
// after the loop so the chat continues with the same system.
|
|
727
|
+
const _sysBefore = messages.find(m => m.role === 'system')?.content ?? null;
|
|
728
|
+
const memMod = (parsed.useMemory || parsed.recall) ? await import('../memory.mjs') : null;
|
|
729
|
+
const buildSystem = memMod ? (() => {
|
|
730
|
+
// Called per iteration: memory.loadCore + recall re-read from
|
|
731
|
+
// disk every call so a parallel writer mutating core.md /
|
|
732
|
+
// episodic/* between iterations is reflected immediately.
|
|
733
|
+
const parts = [];
|
|
734
|
+
if (parsed.useMemory) {
|
|
735
|
+
const core = memMod.loadCore(cfgDir);
|
|
736
|
+
if (core && core.trim()) parts.push(core);
|
|
737
|
+
}
|
|
738
|
+
if (parsed.recall) {
|
|
739
|
+
const text = memMod.recall(parsed.recall, { topN: 3 }, cfgDir);
|
|
740
|
+
if (text && text.trim()) parts.push(text);
|
|
741
|
+
}
|
|
742
|
+
if (_sysBefore) parts.push(_sysBefore);
|
|
743
|
+
return parts.join('\n\n---\n\n');
|
|
744
|
+
}) : null;
|
|
745
|
+
try {
|
|
746
|
+
const result = await loopMod.runLoop({
|
|
747
|
+
prompt: parsed.prompt,
|
|
748
|
+
max: parsed.max,
|
|
749
|
+
until: untilRe,
|
|
750
|
+
messages,
|
|
751
|
+
sendOnce,
|
|
752
|
+
persist: (role, content) => persistTurn(role, content),
|
|
753
|
+
onIteration: ({ i, max }) => {
|
|
754
|
+
process.stderr.write(`\x1b[2m ↻ loop iteration ${i}/${max}\x1b[22m\n`);
|
|
755
|
+
},
|
|
756
|
+
signal: loopAc.signal,
|
|
757
|
+
buildSystem,
|
|
758
|
+
});
|
|
759
|
+
charsSent += parsed.prompt.length * result.iterations;
|
|
760
|
+
if (result.stoppedBy === 'until') {
|
|
761
|
+
process.stderr.write(`\x1b[2m ✓ loop stopped by --until\x1b[22m\n`);
|
|
762
|
+
} else if (result.stoppedBy === 'abort') {
|
|
763
|
+
process.stderr.write(`\x1b[2m ⊘ loop aborted after ${result.iterations}/${parsed.max} iteration(s)\x1b[22m\n`);
|
|
764
|
+
}
|
|
765
|
+
} catch (err) {
|
|
766
|
+
process.stdout.write(`loop error: ${err?.message || String(err)}\n`);
|
|
767
|
+
} finally {
|
|
768
|
+
process.off('SIGINT', onSigint);
|
|
769
|
+
if (useTerminal) _ghost.resume();
|
|
770
|
+
// Restore the chat's prior system message. The engine may have
|
|
771
|
+
// overwritten messages[0] with the per-iter memory composition;
|
|
772
|
+
// we put the original (workspace / skill) back so the
|
|
773
|
+
// subsequent free-form chat turn sees the same system the user
|
|
774
|
+
// configured before /loop ran.
|
|
775
|
+
if (buildSystem) {
|
|
776
|
+
const sysIdx = messages.findIndex(m => m.role === 'system');
|
|
777
|
+
if (_sysBefore) {
|
|
778
|
+
if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: _sysBefore };
|
|
779
|
+
else messages.unshift({ role: 'system', content: _sysBefore });
|
|
780
|
+
} else if (sysIdx >= 0) {
|
|
781
|
+
messages.splice(sysIdx, 1);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
case '/goal': {
|
|
788
|
+
// /goal → list active goals
|
|
789
|
+
// /goal <name> → switch chat context to goal:<name>
|
|
790
|
+
// /goal add <name> [--desc "..."] [--cron "<spec>"]
|
|
791
|
+
// /goal list → JSON of all goals
|
|
792
|
+
// /goal show <name> → JSON of one
|
|
793
|
+
// /goal close <name> [done|abandoned]
|
|
794
|
+
const rawArg = line.slice('/goal'.length).trim();
|
|
795
|
+
const goalsMod = await import('../goals.mjs');
|
|
796
|
+
const loopMod = await import('../loop-engine.mjs');
|
|
797
|
+
if (!rawArg) {
|
|
798
|
+
const items = goalsMod.listGoals(cfgDir).filter(g => g.status === 'active');
|
|
799
|
+
if (!items.length) { process.stdout.write('no active goals\n'); }
|
|
800
|
+
else {
|
|
801
|
+
for (const g of items) {
|
|
802
|
+
process.stdout.write(` ${g.name}${g.description ? ' — ' + g.description : ''}${g.schedule ? ' (cron: ' + g.schedule + ')' : ''}\n`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
let tokens;
|
|
808
|
+
try { tokens = loopMod.splitArgs(rawArg); }
|
|
809
|
+
catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); return true; }
|
|
810
|
+
const sub = tokens[0];
|
|
811
|
+
const rest = tokens.slice(1);
|
|
812
|
+
if (sub === 'add') {
|
|
813
|
+
let name = null, desc = '', cron = null;
|
|
814
|
+
for (let i = 0; i < rest.length; i++) {
|
|
815
|
+
const t = rest[i];
|
|
816
|
+
if (t === '--desc') desc = rest[++i] || '';
|
|
817
|
+
else if (t === '--cron') cron = rest[++i] || null;
|
|
818
|
+
else if (t.startsWith('--')) { process.stdout.write(`goal error: unknown flag ${t}\n`); return true; }
|
|
819
|
+
else if (!name) name = t;
|
|
820
|
+
else { process.stdout.write(`goal error: unexpected arg "${t}"\n`); return true; }
|
|
821
|
+
}
|
|
822
|
+
if (!name) { process.stdout.write('usage: /goal add <name> [--desc "..."] [--cron "<spec>"]\n'); return true; }
|
|
823
|
+
try {
|
|
824
|
+
const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, cfgDir);
|
|
825
|
+
if (cron) {
|
|
826
|
+
try { await (await import('../commands/automation.mjs'))._attachGoalCron(name, cron); }
|
|
827
|
+
catch (e) { process.stdout.write(`goal warning: cron attach failed (${e?.message || e})\n`); }
|
|
828
|
+
}
|
|
829
|
+
process.stdout.write(`✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})\n`);
|
|
830
|
+
} catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
if (sub === 'list') {
|
|
834
|
+
process.stdout.write(JSON.stringify(goalsMod.listGoals(cfgDir), null, 2) + '\n');
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
if (sub === 'show') {
|
|
838
|
+
const name = rest[0];
|
|
839
|
+
if (!name) { process.stdout.write('usage: /goal show <name>\n'); return true; }
|
|
840
|
+
const g = goalsMod.getGoal(name, cfgDir);
|
|
841
|
+
if (!g) { process.stdout.write(`no goal "${name}"\n`); return true; }
|
|
842
|
+
process.stdout.write(JSON.stringify(g, null, 2) + '\n');
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
if (sub === 'close') {
|
|
846
|
+
const name = rest[0];
|
|
847
|
+
const outcome = rest[1] || 'done';
|
|
848
|
+
if (!name) { process.stdout.write('usage: /goal close <name> [done|abandoned]\n'); return true; }
|
|
849
|
+
try {
|
|
850
|
+
const g = goalsMod.closeGoal(name, outcome, cfgDir);
|
|
851
|
+
try { await (await import('../commands/automation.mjs'))._detachGoalCron(name); }
|
|
852
|
+
catch (e) { process.stdout.write(`goal warning: cron detach failed (${e?.message || e})\n`); }
|
|
853
|
+
process.stdout.write(`✓ goal ${g.name} closed (status: ${g.status})\n`);
|
|
854
|
+
} catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
// Single-arg branch: switch context to goal:<name>.
|
|
858
|
+
const goalName = sub;
|
|
859
|
+
const g = goalsMod.getGoal(goalName, cfgDir);
|
|
860
|
+
if (!g) {
|
|
861
|
+
process.stdout.write(`no goal "${goalName}" — try: /goal add ${goalName} --desc "..."\n`);
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
if (g.status !== 'active') {
|
|
865
|
+
process.stdout.write(`goal "${goalName}" is ${g.status}; cannot switch\n`);
|
|
866
|
+
return true;
|
|
867
|
+
}
|
|
868
|
+
// Switch: replace the chat's active session id and reload turns
|
|
869
|
+
// from the goal's session. The provider, model, workspace, and
|
|
870
|
+
// skill state stay put — only the conversation surface changes.
|
|
871
|
+
sessionId = g.sessionId;
|
|
872
|
+
activeGoalName = g.name;
|
|
873
|
+
const prior = sessionsMod.loadTurns(sessionId, cfgDir);
|
|
874
|
+
messages = prior.map(t => ({ role: t.role, content: t.content }));
|
|
875
|
+
// Prepend a one-line goal note to the system message so the
|
|
876
|
+
// model sees the current objective without us having to mutate
|
|
877
|
+
// any persistent record on every switch.
|
|
878
|
+
const sysIdx = messages.findIndex(m => m.role === 'system');
|
|
879
|
+
const goalNote = `## Goal: ${g.description || g.name}`;
|
|
880
|
+
if (sysIdx >= 0) {
|
|
881
|
+
messages[sysIdx] = { role: 'system', content: `${goalNote}\n\n${messages[sysIdx].content}` };
|
|
882
|
+
} else {
|
|
883
|
+
messages.unshift({ role: 'system', content: goalNote });
|
|
884
|
+
}
|
|
885
|
+
process.stdout.write(`✓ switched to goal: ${g.name} (session: ${sessionId}, ${prior.length} prior turn(s))\n`);
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
case '/memory': {
|
|
889
|
+
const arg = line.slice('/memory'.length).trim();
|
|
890
|
+
const memMod = await import('../memory.mjs');
|
|
891
|
+
const tokens = arg.split(/\s+/).filter(Boolean);
|
|
892
|
+
const which = tokens[0] || 'core';
|
|
893
|
+
if (which === 'core') {
|
|
894
|
+
const body = memMod.loadCore(cfgDir);
|
|
895
|
+
process.stdout.write(body || '(empty core memory)\n');
|
|
896
|
+
return true;
|
|
897
|
+
}
|
|
898
|
+
if (which === 'recent') {
|
|
899
|
+
const items = memMod.loadRecent(20, cfgDir);
|
|
900
|
+
process.stdout.write(JSON.stringify(items, null, 2) + '\n');
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
if (which === 'episodic') {
|
|
904
|
+
const topic = tokens[1];
|
|
905
|
+
if (topic) {
|
|
906
|
+
const body = memMod.loadEpisodic(topic, cfgDir);
|
|
907
|
+
process.stdout.write(body || `(no episodic file "${topic}")\n`);
|
|
908
|
+
} else {
|
|
909
|
+
process.stdout.write(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2) + '\n');
|
|
910
|
+
}
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
process.stdout.write('usage: /memory [core|recent|episodic [topic]]\n');
|
|
914
|
+
return true;
|
|
915
|
+
}
|
|
916
|
+
case '/dream': {
|
|
917
|
+
const memMod = await import('../memory.mjs');
|
|
918
|
+
process.stdout.write(' ↯ dreaming…\n');
|
|
919
|
+
try {
|
|
920
|
+
const r = await memMod.dream(sessionId, {
|
|
921
|
+
provider: prov,
|
|
922
|
+
model: activeModel,
|
|
923
|
+
apiKey: _resolveAuthKey(cfg, activeProvName),
|
|
924
|
+
}, cfgDir);
|
|
925
|
+
process.stdout.write(`✓ wrote ${r.topics.length} episodic file(s): ${r.topics.join(', ') || '(none)'}\n`);
|
|
926
|
+
} catch (e) { process.stdout.write(`dream error: ${e?.message || e}\n`); }
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
case '/agent': {
|
|
930
|
+
const rawArg = line.slice('/agent'.length).trim();
|
|
931
|
+
const agentsMod = await import('../agents.mjs');
|
|
932
|
+
const loopMod = await import('../loop-engine.mjs');
|
|
933
|
+
let tokens;
|
|
934
|
+
try { tokens = loopMod.splitArgs(rawArg); }
|
|
935
|
+
catch (e) { process.stdout.write(`/agent error: ${e?.message || e}\n`); return true; }
|
|
936
|
+
const sub = tokens[0];
|
|
937
|
+
const rest = tokens.slice(1);
|
|
938
|
+
const aname = rest[0];
|
|
939
|
+
try {
|
|
940
|
+
if (!sub || sub === 'list') {
|
|
941
|
+
const agents = agentsMod.listAgents(cfgDir);
|
|
942
|
+
if (agents.length === 0) process.stdout.write('no agents registered. /agent add <name> [...] to create.\n');
|
|
943
|
+
else for (const a of agents) {
|
|
944
|
+
const provLine = a.model ? `${a.provider}/${a.model}` : a.provider;
|
|
945
|
+
process.stdout.write(`• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]\n`);
|
|
946
|
+
}
|
|
947
|
+
} else if (sub === 'show') {
|
|
948
|
+
if (!aname) { process.stdout.write('usage: /agent show <name>\n'); return true; }
|
|
949
|
+
const a = agentsMod.getAgent(aname, cfgDir);
|
|
950
|
+
if (!a) process.stdout.write(`no agent "${aname}"\n`);
|
|
951
|
+
else process.stdout.write(JSON.stringify(a, null, 2) + '\n');
|
|
952
|
+
} else if (sub === 'add') {
|
|
953
|
+
if (!aname) { process.stdout.write('usage: /agent add <name> [role text…]\n'); return true; }
|
|
954
|
+
const roleText = rest.slice(1).join(' ').trim();
|
|
955
|
+
const a = agentsMod.registerAgent({ name: aname, role: roleText }, cfgDir);
|
|
956
|
+
process.stdout.write(`✓ added agent ${a.name} (tools=${a.tools.join(',')})\n`);
|
|
957
|
+
} else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
958
|
+
if (!aname) { process.stdout.write('usage: /agent remove <name>\n'); return true; }
|
|
959
|
+
agentsMod.removeAgent(aname, cfgDir);
|
|
960
|
+
process.stdout.write(`✓ removed agent ${aname}\n`);
|
|
961
|
+
} else {
|
|
962
|
+
process.stdout.write(`/agent: unknown sub "${sub}" — list|show|add|remove\n`);
|
|
963
|
+
}
|
|
964
|
+
} catch (e) {
|
|
965
|
+
process.stdout.write(`/agent error: ${e?.message || e}\n`);
|
|
966
|
+
}
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
case '/team': {
|
|
970
|
+
const rawArg = line.slice('/team'.length).trim();
|
|
971
|
+
const teamsMod = await import('../teams.mjs');
|
|
972
|
+
const loopMod = await import('../loop-engine.mjs');
|
|
973
|
+
let tokens;
|
|
974
|
+
try { tokens = loopMod.splitArgs(rawArg); }
|
|
975
|
+
catch (e) { process.stdout.write(`/team error: ${e?.message || e}\n`); return true; }
|
|
976
|
+
const sub = tokens[0];
|
|
977
|
+
const rest = tokens.slice(1);
|
|
978
|
+
const tname = rest[0];
|
|
979
|
+
try {
|
|
980
|
+
if (!sub || sub === 'list') {
|
|
981
|
+
const teams = teamsMod.listTeams(cfgDir);
|
|
982
|
+
if (teams.length === 0) process.stdout.write('no teams registered. /team add <name> --agents a,b --lead a [--channel #x]\n');
|
|
983
|
+
else for (const t of teams) {
|
|
984
|
+
const chLine = t.slackChannel ? ` — ${t.slackChannel}` : '';
|
|
985
|
+
process.stdout.write(`• ${t.name} — ${t.displayName} — lead=${t.lead} — agents=[${t.agents.join(',')}]${chLine}\n`);
|
|
986
|
+
}
|
|
987
|
+
} else if (sub === 'show') {
|
|
988
|
+
if (!tname) { process.stdout.write('usage: /team show <name>\n'); return true; }
|
|
989
|
+
const t = teamsMod.getTeam(tname, cfgDir);
|
|
990
|
+
if (!t) process.stdout.write(`no team "${tname}"\n`);
|
|
991
|
+
else process.stdout.write(JSON.stringify(t, null, 2) + '\n');
|
|
992
|
+
} else if (sub === 'add') {
|
|
993
|
+
// /team add <name> --agents a,b,c [--lead a] [--channel #x]
|
|
994
|
+
if (!tname) { process.stdout.write('usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]\n'); return true; }
|
|
995
|
+
let agentsCsv = null, lead = null, channel = '';
|
|
996
|
+
for (let i = 1; i < rest.length; i++) {
|
|
997
|
+
const t = rest[i];
|
|
998
|
+
if (t === '--agents') agentsCsv = rest[++i] || '';
|
|
999
|
+
else if (t === '--lead') lead = rest[++i] || null;
|
|
1000
|
+
else if (t === '--channel') channel = rest[++i] || '';
|
|
1001
|
+
else { process.stdout.write(`/team error: unknown token "${t}"\n`); return true; }
|
|
1002
|
+
}
|
|
1003
|
+
if (!agentsCsv) { process.stdout.write('/team add: --agents is required\n'); return true; }
|
|
1004
|
+
const agents = teamsMod.parseListFlag(agentsCsv);
|
|
1005
|
+
const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
|
|
1006
|
+
botToken: process.env.SLACK_BOT_TOKEN || null,
|
|
1007
|
+
apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
|
|
1008
|
+
logger: () => {},
|
|
1009
|
+
}) : '';
|
|
1010
|
+
const team = teamsMod.registerTeam({ name: tname, agents, lead, slackChannel: ch }, cfgDir);
|
|
1011
|
+
process.stdout.write(`✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})\n`);
|
|
1012
|
+
} else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
1013
|
+
if (!tname) { process.stdout.write('usage: /team remove <name>\n'); return true; }
|
|
1014
|
+
teamsMod.removeTeam(tname, cfgDir);
|
|
1015
|
+
process.stdout.write(`✓ removed team ${tname}\n`);
|
|
1016
|
+
} else {
|
|
1017
|
+
process.stdout.write(`/team: unknown sub "${sub}" — list|show|add|remove\n`);
|
|
1018
|
+
}
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
process.stdout.write(`/team error: ${e?.message || e}\n`);
|
|
1021
|
+
}
|
|
1022
|
+
return true;
|
|
1023
|
+
}
|
|
1024
|
+
case '/handoff': {
|
|
1025
|
+
// /handoff <target-channel> <externalId> [--note=...] — migrates the
|
|
1026
|
+
// active thread (bound to replState.channel / replState.externalId)
|
|
1027
|
+
// to a new channel and posts transition stubs on both sides. In the
|
|
1028
|
+
// local-only chat REPL there is no bound channel, so we surface a
|
|
1029
|
+
// clear error and stay in the REPL (acceptance test §F).
|
|
1030
|
+
const parts = line.trim().split(/\s+/).slice(1);
|
|
1031
|
+
if (parts.length < 2) {
|
|
1032
|
+
process.stderr.write('usage: /handoff <target-channel> <externalId> [--note=...]\n');
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
const target = parts[0];
|
|
1036
|
+
const externalId = parts[1];
|
|
1037
|
+
const note = (parts.find(p => p.startsWith('--note=')) || '').slice(7);
|
|
1038
|
+
try {
|
|
1039
|
+
const { openThreads } = await import('../channels/threads.mjs');
|
|
1040
|
+
const { runHandoff } = await import('../channels/handoff.mjs');
|
|
1041
|
+
const threads = openThreads(cfgDir);
|
|
1042
|
+
const replState = globalThis.__lazyclawReplState || {};
|
|
1043
|
+
const cur = replState.channel && replState.externalId
|
|
1044
|
+
? threads.findByExternal(replState.channel, replState.externalId)
|
|
1045
|
+
: null;
|
|
1046
|
+
if (!cur) {
|
|
1047
|
+
process.stderr.write(
|
|
1048
|
+
`handoff: no thread bound to ${replState.channel || '(none)'}:${replState.externalId || '(none)'}\n`,
|
|
1049
|
+
);
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
const next = await runHandoff({
|
|
1053
|
+
threads, channels: replState.channels || {},
|
|
1054
|
+
threadId: cur.threadId, target, externalId, note,
|
|
1055
|
+
});
|
|
1056
|
+
process.stdout.write(`handoff -> ${next.channel}:${next.externalId} (session ${next.sessionId})\n`);
|
|
1057
|
+
replState.channel = next.channel;
|
|
1058
|
+
replState.externalId = next.externalId;
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
process.stderr.write(`handoff failed: ${e.code || 'ERR'}: ${e.message}\n`);
|
|
1061
|
+
}
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
case '/personality': {
|
|
1065
|
+
// Phase G: thin slash wrapper over cmdPersonality.
|
|
1066
|
+
const tail = line.slice('/personality'.length).trim();
|
|
1067
|
+
const parts = tail.split(/\s+/).filter(Boolean);
|
|
1068
|
+
await (await import('../commands/config.mjs')).cmdPersonality(parts[0] || 'list', parts[1], parts[2]);
|
|
1069
|
+
return true;
|
|
1070
|
+
}
|
|
1071
|
+
case '/exit': {
|
|
1072
|
+
// v5 Group A (C4): fire one updateUserModel call before exit so
|
|
1073
|
+
// the Honcho-style USER.md captures the durable facts surfaced
|
|
1074
|
+
// in this session. Wrapped in a 3-second timeout so a slow
|
|
1075
|
+
// trainer never makes /exit hang. Best-effort: failure logs are
|
|
1076
|
+
// suppressed so we don't disturb the clean shutdown.
|
|
1077
|
+
try {
|
|
1078
|
+
const turns = sessionId
|
|
1079
|
+
? sessionsMod.loadTurns(sessionId, cfgDir)
|
|
1080
|
+
: messages.map((t) => ({ role: t.role, content: t.content }));
|
|
1081
|
+
if (turns && turns.length) {
|
|
1082
|
+
const trainer = (typeof getRegistry()?.resolveTrainer === 'function')
|
|
1083
|
+
? getRegistry().resolveTrainer(cfg)
|
|
1084
|
+
: { provider: activeProvName, model: activeModel };
|
|
1085
|
+
const userModelPromise = import('../mas/user_modeler.mjs').then((m) =>
|
|
1086
|
+
m.updateUserModel({
|
|
1087
|
+
sessionTurns: turns,
|
|
1088
|
+
provider: trainer.provider,
|
|
1089
|
+
model: trainer.model,
|
|
1090
|
+
apiKey: _resolveAuthKey(cfg, trainer.provider),
|
|
1091
|
+
baseUrl: _resolveBaseUrl(trainer.provider),
|
|
1092
|
+
configDir: cfgDir,
|
|
1093
|
+
}),
|
|
1094
|
+
).catch(() => null);
|
|
1095
|
+
await Promise.race([
|
|
1096
|
+
userModelPromise,
|
|
1097
|
+
new Promise((resolve) => setTimeout(resolve, 3000)),
|
|
1098
|
+
]);
|
|
1099
|
+
}
|
|
1100
|
+
} catch { /* /exit must never hang or throw */ }
|
|
1101
|
+
return 'EXIT';
|
|
1102
|
+
}
|
|
1103
|
+
default:
|
|
1104
|
+
process.stdout.write(`unknown slash: ${cmd} (try /help)\n`);
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
// Create the readline interface here — immediately before iterating, with
|
|
1110
|
+
// no `await` between — so a non-TTY pipe's buffered lines reach the async
|
|
1111
|
+
// iterator (see the note at the rl/_ghost declaration above).
|
|
1112
|
+
rl = readline.createInterface({
|
|
1113
|
+
input: process.stdin,
|
|
1114
|
+
output: useTerminal ? process.stdout : undefined,
|
|
1115
|
+
terminal: useTerminal,
|
|
1116
|
+
prompt: useTerminal ? '\x1b[38;5;208m›\x1b[0m ' : '',
|
|
1117
|
+
});
|
|
1118
|
+
if (useTerminal) {
|
|
1119
|
+
// Cursor-style ghost autocomplete: when the buffer starts with `/`,
|
|
1120
|
+
// render the longest matching command after the cursor in dim grey.
|
|
1121
|
+
// Right-arrow at end-of-line accepts. Tab still cycles via the existing
|
|
1122
|
+
// handleSlash branch; this only adds the inline preview.
|
|
1123
|
+
_ghost = _attachGhostAutocomplete(rl) || _ghost;
|
|
1124
|
+
rl.prompt();
|
|
1125
|
+
}
|
|
1126
|
+
try { for await (const line of rl) {
|
|
1127
|
+
const text = line.trim();
|
|
1128
|
+
if (!text) { if (useTerminal) rl.prompt(); continue; }
|
|
1129
|
+
if (text.startsWith('/')) {
|
|
1130
|
+
const r = await handleSlash(text);
|
|
1131
|
+
if (r === 'EXIT') break;
|
|
1132
|
+
if (useTerminal) rl.prompt();
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
// Per-turn AbortController. Ctrl+C during a stream aborts THIS turn
|
|
1136
|
+
// and returns to the prompt instead of killing the process. Outside
|
|
1137
|
+
// a stream, Ctrl+C still terminates (we restore the default handler
|
|
1138
|
+
// below, after the try/finally).
|
|
1139
|
+
const turnAc = new AbortController();
|
|
1140
|
+
const onSigint = () => {
|
|
1141
|
+
turnAc.abort();
|
|
1142
|
+
process.stdout.write('\n^C interrupted — prompt is back\n');
|
|
1143
|
+
};
|
|
1144
|
+
process.on('SIGINT', onSigint);
|
|
1145
|
+
// Pause the ghost-autocomplete keypress handler while the
|
|
1146
|
+
// provider is streaming. Without this, every stale stdin event
|
|
1147
|
+
// would trigger `\x1b[s\x1b[K\x1b[u` cursor save/restore writes
|
|
1148
|
+
// that interleave with the streamed text and surface as visible
|
|
1149
|
+
// gaps between CJK characters (visible in user-reported screen
|
|
1150
|
+
// captures of Korean replies).
|
|
1151
|
+
if (useTerminal) _ghost.suspend();
|
|
1152
|
+
try {
|
|
1153
|
+
// C7 — single source of truth for the streaming + persist +
|
|
1154
|
+
// post-task learning loop. The factory handles the user-msg push,
|
|
1155
|
+
// 30 ms buffered writer (CJK-safe), assistant-msg push,
|
|
1156
|
+
// persistTurn for both turns, and the post-task learning hook.
|
|
1157
|
+
await runTurn(text, turnAc.signal);
|
|
1158
|
+
} finally {
|
|
1159
|
+
process.off('SIGINT', onSigint);
|
|
1160
|
+
if (useTerminal) _ghost.resume();
|
|
1161
|
+
}
|
|
1162
|
+
if (useTerminal) rl.prompt();
|
|
1163
|
+
} } finally {
|
|
1164
|
+
// Clean shutdown — without this, /exit "worked" but the process
|
|
1165
|
+
// hung for ~3-5 s while Node waited for stdin's keypress listener
|
|
1166
|
+
// and raw mode to release. Tearing them down explicitly drops the
|
|
1167
|
+
// exit time to <100 ms.
|
|
1168
|
+
try { _ghost.dispose(); } catch (_) {}
|
|
1169
|
+
try { rl.close(); } catch (_) {}
|
|
1170
|
+
if (useTerminal && process.stdin.isTTY && process.stdin.setRawMode) {
|
|
1171
|
+
try { process.stdin.setRawMode(false); } catch (_) {}
|
|
1172
|
+
}
|
|
1173
|
+
// process.stdin keeps the event loop alive in raw / readline mode.
|
|
1174
|
+
// Pause + unref releases the hold so the process can exit cleanly
|
|
1175
|
+
// from natural completion (no need for a hard process.exit).
|
|
1176
|
+
try { process.stdin.pause(); } catch (_) {}
|
|
1177
|
+
try { process.stdin.unref(); } catch (_) {}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Light wrapper around the daemon — meant for users who installed
|
|
1182
|
+
// via npm and don't want to remember `daemon` flags. Boots the
|
|
1183
|
+
// daemon on a fixed default port (override with --port), then opens
|
|
1184
|
+
// the dashboard URL in the user's default browser.
|
|
1185
|
+
//
|
|
1186
|
+
// Why a separate command: typing `lazyclaw daemon` works too, but
|
|
1187
|
+
// `dashboard` is the discoverable name and it auto-opens the browser
|
|
1188
|
+
// (which the bare daemon doesn't, since most daemon callers are
|
|
1189
|
+
// scripts).
|
|
1190
|
+
// Best-effort port-occupant kill — macOS / Linux only. Returns true when
|
|
1191
|
+
// at least one PID was signalled. Used by cmdDashboard so a leftover
|
|
1192
|
+
// listener from a previous run doesn't crash the launch with EADDRINUSE.
|
|
1193
|
+
// Mirrors the Python server's auto-kill behaviour described in CLAUDE.md.
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
// sandbox subcommands — list/test/add/use (Phase D).
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
// Interactive launcher — fired when the user types `lazyclaw` with
|
|
1201
|
+
// no subcommand AND we're attached to a TTY. OpenClaw's launcher
|
|
1202
|
+
// pattern: ASCII banner + provider/model status + arrow-key menu of
|
|
1203
|
+
// every common action. Selecting a row drops the user into the
|
|
1204
|
+
// matching subcommand via process.argv mutation + main() re-entry,
|
|
1205
|
+
// so chat / agent / etc. behave bit-identically to typing them
|
|
1206
|
+
// directly. Non-TTY (piped, scripted) callers still see the
|
|
1207
|
+
// classic "Usage: …" line so automation isn't surprised.
|
|
1208
|
+
// Multi-step setup wizard — OpenClaw-style first-run experience.
|
|
1209
|
+
// Provider/model/key + optional workspace + optional sample skill
|
|
1210
|
+
// + reachability ping. Each step can be skipped (Enter on prompt /
|
|
1211
|
+
// "n" on yes-no). Re-runnable safely: existing state is reused, not
|
|
1212
|
+
// clobbered, except when the user explicitly opts in.
|
|
1213
|
+
//
|
|
1214
|
+
// `lazyclaw setup` exposes this directly so users can re-run the
|
|
1215
|
+
// wizard any time. The first-run code path also funnels through it
|
|
1216
|
+
// so a fresh install sees the same flow whether they typed
|
|
1217
|
+
// `lazyclaw` or `lazyclaw setup`.
|