lazyclaw 6.3.1 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
package/tui/run_turn.mjs CHANGED
@@ -25,6 +25,160 @@
25
25
  // turn-completion logic (ReplApp onTurnComplete, legacy `rl.prompt`)
26
26
  // runs unconditionally.
27
27
 
28
+ import { Chalk } from 'chalk';
29
+ import { chatAgenticGet, chatPlanModeGet, effectiveChatTools } from '../config_features.mjs';
30
+ import { defaultSandboxSpec } from '../sandbox/index.mjs';
31
+
32
+ // Force ANSI on these turn-status markers regardless of stdout TTY detection:
33
+ // the Ink path routes them through React state (Ink preserves embedded SGR
34
+ // and decides display), and the legacy stdout path always targets a terminal
35
+ // for chat. Without forcing, chalk's auto-level strips color under the
36
+ // non-TTY test/pipe sink and the red error / dim abort marker vanish.
37
+ const _statusChalk = new Chalk({ level: 1 });
38
+
39
+ // Classify a provider/turn error into a one-line actionable hint (or '' when
40
+ // none applies). Checked auth → model → network so one hint at most. Advisory
41
+ // only — rendered dim under the red error line.
42
+ function _errorHint(err) {
43
+ const msg = String(err?.message || err || '');
44
+ const status = err?.status;
45
+ const code = err?.code;
46
+ if (status === 401 || status === 403 || /\b(401|403|unauthorized|forbidden|invalid[ _-]?api[ _-]?key|authentication|no api key|missing api key)\b/i.test(msg)) {
47
+ return 'hint: set a key with /provider, or `lazyclaw auth add <provider>`';
48
+ }
49
+ if (status === 404 || /\b(404|model[ _-]?not[ _-]?found|no such model|unknown model|model .* does not exist|not_found_error)\b/i.test(msg)) {
50
+ return 'hint: run /model to pick a valid model';
51
+ }
52
+ if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'EAI_AGAIN'
53
+ || /\b(ENOTFOUND|ECONNREFUSED|ETIMEDOUT|ECONNRESET|EAI_AGAIN|network|fetch failed|socket hang up|getaddrinfo)\b/i.test(msg)) {
54
+ return 'hint: check your connection / base URL (/provider to review the endpoint)';
55
+ }
56
+ return '';
57
+ }
58
+
59
+ // Plan-mode addendum — instructs the model to propose, not mutate. Appended
60
+ // to the synthetic chat agent's system prompt only while plan mode is ON.
61
+ const PLAN_MODE_ADDENDUM = 'You are in PLAN mode. Propose a plan; do not mutate '
62
+ + 'anything. List the steps you would take and the tools you would use, then stop.';
63
+
64
+ // Per-turn recall injection (roadmap #7). The streaming chat path sends a fixed
65
+ // system prompt, so it never surfaced context relevant to THIS message (only the
66
+ // agentic path, which rebuilds the prompt stack per turn, did). Prepend a fresh
67
+ // recall layer to the CURRENT user message — not the system — because a warm
68
+ // claude-cli persistent session fixes its system at spawn, and every provider
69
+ // reads the user turn. Transient: returns a COPY for the send; the stored
70
+ // session keeps the original message. recallLayer is injected (lazy-imported by
71
+ // the caller) so better-sqlite3 stays off this module's static graph.
72
+ export function _injectRecall(messages, cfgDir, recallLayerFn) {
73
+ try {
74
+ if (typeof recallLayerFn !== 'function' || !Array.isArray(messages)) return messages;
75
+ let idx = -1;
76
+ for (let i = messages.length - 1; i >= 0; i--) {
77
+ if (messages[i] && messages[i].role === 'user') { idx = i; break; }
78
+ }
79
+ if (idx < 0) return messages;
80
+ const layer = recallLayerFn(cfgDir, String(messages[idx].content || ''), 5);
81
+ if (!layer || !String(layer).trim()) return messages;
82
+ return messages.map((m, i) => (i === idx ? { ...m, content: `${layer}\n\n${m.content}` } : m));
83
+ } catch { return messages; }
84
+ }
85
+
86
+ // Fire the post-task learning loop on every successful chat turn (v5 Group A
87
+ // C1). Fire-and-forget via queueMicrotask so the next prompt is never blocked
88
+ // on trajectory write / synth. Shared by the streaming and agentic paths.
89
+ // Decide whether a chat turn is worth a learning pass. The hook otherwise
90
+ // spawns TWO extra `claude` processes (skill synth + user-model) on EVERY turn,
91
+ // tripling per-message spawns and competing with the user's next turn for the
92
+ // subscription. Skip greetings/acks, trivially short exchanges, and empty
93
+ // (aborted/failed) replies — none warrant a durable SKILL.md or a user model.
94
+ export function _shouldLearn(messages) {
95
+ const last2 = (messages || []).slice(-2);
96
+ const user = String(last2.find((m) => m && m.role === 'user')?.content || '').trim();
97
+ const reply = String(last2.find((m) => m && m.role !== 'user')?.content || '').trim();
98
+ if (!reply) return false;
99
+ if ((user + ' ' + reply).trim().length < 280) return false;
100
+ if (/^(hi|hey|hello|yo|thanks|thank you|thx|ty|ok|okay|k|sure|nice|cool|got it|ㅇㅇ|ㄱㅅ|ㄳ|고마워|감사|안녕|넵|네)\b/i.test(user)) return false;
101
+ return true;
102
+ }
103
+
104
+ function _fireLearningHook(ctx, messages, activeProvName, activeModel, transcript) {
105
+ try {
106
+ if (!_shouldLearn(messages)) return;
107
+ queueMicrotask(() => {
108
+ import('../mas/learning.mjs').then((mod) => mod.runLearning('post-task', {
109
+ agent: { name: 'chat', provider: activeProvName, model: activeModel, role: '' },
110
+ task: {
111
+ id: ctx.getSessionId() || ctx.syntheticChatSessionId,
112
+ title: '(chat turn)',
113
+ turns: messages.slice(-2).map((m) => ({
114
+ agent: m.role === 'user' ? 'user' : 'chat',
115
+ text: m.content,
116
+ ts: new Date().toISOString(),
117
+ })),
118
+ },
119
+ configDir: ctx.cfgDir,
120
+ cfg: ctx.cfg,
121
+ transcript: String(transcript || '').slice(0, 8000),
122
+ })).catch(() => { /* learning loop is best-effort */ });
123
+ });
124
+ } catch { /* never let learning hook break the chat */ }
125
+ }
126
+
127
+ // Drive one agentic chat turn through the MAS tool loop. Builds the synthetic
128
+ // chat agent record, renders compact tool-activity status lines + the final
129
+ // answer into writeChunk, and returns the final assistant text. The approval
130
+ // hook is taken from ctx.approve when present (Ink: _makeInkApprove, legacy:
131
+ // makeReadlineApprove) — when absent the fail-closed gate in tool_runner.mjs
132
+ // simply denies any sensitive tool (no silent ungated execution).
133
+ async function _runAgenticTurn({ ctx, messages, sysMsg, activeProvName, activeModel, planMode, writeChunk, signal }) {
134
+ const runAgentTurn = ctx.runAgentTurnImpl
135
+ || (await import('../mas/agent_turn.mjs')).runAgentTurn;
136
+ const tools = effectiveChatTools(ctx.cfg, { planMode });
137
+ let role = (sysMsg && sysMsg.content) || '';
138
+ if (planMode) role = role ? `${role}\n\n${PLAN_MODE_ADDENDUM}` : PLAN_MODE_ADDENDUM;
139
+ const agent = { name: 'chat', provider: activeProvName, model: activeModel, role, tools };
140
+ // History excludes the just-pushed user message and the system slot (the
141
+ // latter is threaded via agent.role); runAgentTurn appends userMessage.
142
+ const history = messages
143
+ .slice(0, -1)
144
+ .filter((m) => m.role !== 'system')
145
+ .map((m) => ({ role: m.role, content: m.content }));
146
+ const userMessage = messages[messages.length - 1]?.content || '';
147
+ let result;
148
+ try {
149
+ result = await runAgentTurn({
150
+ agent, userMessage, history,
151
+ configDir: ctx.cfgDir,
152
+ apiKey: ctx.resolveAuthKey(activeProvName),
153
+ approve: ctx.approve,
154
+ security: ctx.cfg?.security,
155
+ // Default-on isolation: an explicit --sandbox spec wins; otherwise confine
156
+ // by default (cwd-confined fs, secrets blocked, net allowed). Opt out via
157
+ // cfg.sandbox.confine=false → defaultSandboxSpec returns null (bare).
158
+ sandbox: ctx.sandboxSpec || defaultSandboxSpec(ctx.cfg, { cwd: process.cwd(), configDir: ctx.cfgDir }),
159
+ cache: true,
160
+ usePromptStack: false,
161
+ signal,
162
+ });
163
+ } catch (err) {
164
+ // No silent catch — surface as a status line; return empty so the caller
165
+ // still completes the turn (persist + learning) without a half reply.
166
+ try { writeChunk(`· agentic turn failed: ${err?.message || String(err)}\n`); } catch { /* sink */ }
167
+ return '';
168
+ }
169
+ // Compact tool-activity status lines (dim, not the red provider-error style).
170
+ for (const c of (result.toolCalls || [])) {
171
+ const ok = c.ok ? '✓' : '⚠';
172
+ try { writeChunk(`· ${ok} ${c.name}\n`); } catch { /* sink */ }
173
+ }
174
+ if (result.stoppedBy === 'budget') {
175
+ try { writeChunk(`· stopped after ${result.iterations} tool step(s)\n`); } catch { /* sink */ }
176
+ }
177
+ const text = result.text || '';
178
+ if (text) { try { writeChunk(text); } catch { /* sink */ } }
179
+ return text;
180
+ }
181
+
28
182
  /**
29
183
  * @typedef {Object} RunTurnCtx
30
184
  * @property {Object} cfg Active config.
@@ -79,17 +233,72 @@ export function makeRunTurn({ ctx, writeFn }) {
79
233
  const prov = ctx.getProv();
80
234
  const activeProvName = ctx.getActiveProvName();
81
235
  const activeModel = ctx.getActiveModel();
236
+ // Group 1 — agentic REPL. When cfg.chat.agentic (or plan mode) is ON,
237
+ // route the turn through the MAS tool loop (runAgentTurn) instead of
238
+ // the streaming sendMessage path. Plan mode forces agentic-on for the
239
+ // turn (read-only). Everything else (persistTurn, the post-task
240
+ // learning hook below) is shared. When OFF — the default — the
241
+ // streaming path below runs UNCHANGED.
242
+ const planMode = chatPlanModeGet(ctx.cfg);
243
+ if (chatAgenticGet(ctx.cfg) || planMode) {
244
+ const acc2 = await _runAgenticTurn({
245
+ ctx, messages, sysMsg, activeProvName, activeModel, planMode,
246
+ writeChunk: _writeChunk, signal,
247
+ });
248
+ if (_writeTimer) clearTimeout(_writeTimer);
249
+ _flush();
250
+ try { writeFn('\n'); } catch { /* sink failure must not kill the turn */ }
251
+ messages.push({ role: 'assistant', content: acc2 });
252
+ ctx.persistTurn('assistant', acc2);
253
+ _fireLearningHook(ctx, messages, activeProvName, activeModel, acc2);
254
+ return;
255
+ }
256
+ // Configurable max-output-tokens (config.json `maxTokens`). Only
257
+ // thread it through when it's a positive finite number; otherwise
258
+ // leave opts.maxTokens unset so each provider's DEFAULT_MAX_TOKENS
259
+ // applies.
260
+ const cfgMaxTokens = ctx.cfg?.maxTokens;
261
+ const maxTokens = (typeof cfgMaxTokens === 'number' && Number.isFinite(cfgMaxTokens) && cfgMaxTokens > 0)
262
+ ? cfgMaxTokens
263
+ : undefined;
264
+ // Roadmap #7 — per-turn recall: surface context relevant to THIS message
265
+ // by prepending a fresh recall layer to the current user turn (off via
266
+ // cfg.chat.recall=false). Best-effort + lazy-imported so an empty/missing
267
+ // index never blocks a reply and better-sqlite3 stays off the static graph.
268
+ let sendMessages = messages;
269
+ if (ctx.cfg?.chat?.recall !== false) {
270
+ try {
271
+ const { recalledLayer } = await import('../mas/prompt_stack.mjs');
272
+ sendMessages = _injectRecall(messages, ctx.cfgDir, recalledLayer);
273
+ } catch { /* recall is best-effort — never block a turn */ }
274
+ }
82
275
  // C8 — prompt-cache the static system prefix. The Anthropic
83
276
  // provider prefers `systemStatic` when present; non-Anthropic
84
277
  // providers ignore the field and fall back to the legacy
85
278
  // single-block path with `cache:true`.
86
- for await (const chunk of prov.sendMessage(messages, {
279
+ let truncated = false;
280
+ for await (const chunk of prov.sendMessage(sendMessages, {
87
281
  apiKey: ctx.resolveAuthKey(activeProvName),
88
282
  model: activeModel,
89
283
  sandbox: ctx.sandboxSpec,
90
284
  signal,
91
285
  onUsage: ctx.accumulateUsage,
286
+ // Streaming providers fire this when a turn hits the model's output-token
287
+ // limit (finish_reason 'length' / MAX_TOKENS / stop_reason 'max_tokens' /
288
+ // done_reason 'length'). Warn the user instead of presenting a truncated
289
+ // answer as complete, mirroring the agentic path (mas/agent_turn.mjs).
290
+ onTruncated: () => { truncated = true; },
92
291
  cache: true,
292
+ // Default-ON (set cfg.chat.persistentSession=false to disable): reuse one
293
+ // warm `claude` per conversation so the harness boots once, not every
294
+ // turn (measured ~1.2s/turn / ~42% faster after the cold first turn).
295
+ // claude-cli-only (other providers ignore it); the provider falls back to
296
+ // a one-shot spawn on any session failure, and the session respawns if
297
+ // the system prompt changes (plan-mode toggle) so it can't go stale.
298
+ persistent: ctx.cfg?.chat?.persistentSession !== false,
299
+ sessionKey: (ctx.getSessionId && ctx.getSessionId()) || ctx.syntheticChatSessionId,
300
+ ...(sysMsg ? { sessionSystem: sysMsg.content } : {}),
301
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
93
302
  ...(sysMsg ? { systemStatic: sysMsg.content } : {}),
94
303
  })) {
95
304
  _writeChunk(chunk);
@@ -99,39 +308,32 @@ export function makeRunTurn({ ctx, writeFn }) {
99
308
  _flush();
100
309
  try { writeFn('\n'); }
101
310
  catch { /* sink failure must not kill the turn */ }
311
+ if (truncated) {
312
+ try { writeFn('[truncated — the model hit its output-token limit; raise maxTokens]\n'); }
313
+ catch { /* sink failure must not kill the turn */ }
314
+ }
102
315
  messages.push({ role: 'assistant', content: acc });
103
316
  ctx.persistTurn('assistant', acc);
104
- // v5 Group A (C1): close the post-task learning loop on every
105
- // successful chat turn. Fire-and-forget via queueMicrotask so the
106
- // next prompt is never blocked on trajectory write / synth.
107
- try {
108
- queueMicrotask(() => {
109
- import('../mas/learning.mjs').then((mod) => mod.runLearning('post-task', {
110
- agent: { name: 'chat', provider: activeProvName, model: activeModel, role: '' },
111
- task: {
112
- id: ctx.getSessionId() || ctx.syntheticChatSessionId,
113
- title: '(chat turn)',
114
- turns: messages.slice(-2).map((m) => ({
115
- agent: m.role === 'user' ? 'user' : 'chat',
116
- text: m.content,
117
- ts: new Date().toISOString(),
118
- })),
119
- },
120
- configDir: ctx.cfgDir,
121
- cfg: ctx.cfg,
122
- transcript: acc.slice(0, 8000),
123
- })).catch(() => { /* learning loop is best-effort */ });
124
- });
125
- } catch { /* never let learning hook break the chat */ }
317
+ _fireLearningHook(ctx, messages, activeProvName, activeModel, acc);
126
318
  } catch (err) {
127
319
  if (_writeTimer) clearTimeout(_writeTimer);
128
320
  _flush();
129
321
  // ABORT errors are user-initiated; drop the partial reply (don't
130
322
  // push an incomplete assistant message — next turn would treat
131
- // it as a complete reply and confuse the model).
132
- if (err?.code !== 'ABORT' && !signal?.aborted) {
133
- try { writeFn(`error: ${err?.message || String(err)}\n`); }
323
+ // it as a complete reply and confuse the model). Emit a visible dim
324
+ // [aborted] marker so the output doesn't just silently stop (the
325
+ // host onTurnComplete sees reason:'done' because we swallow ABORT).
326
+ if (err?.code === 'ABORT' || signal?.aborted) {
327
+ try { writeFn(`${_statusChalk.dim('[aborted]')}\n`); }
328
+ catch { /* sink failure must not kill the turn */ }
329
+ } else {
330
+ // Provider errors render in the red error style (not normal amber
331
+ // assistant text — the audit gap). The red SGR is embedded so the
332
+ // Ink scrollback <Text> preserves it and the legacy stdout path shows it.
333
+ try { writeFn(`${_statusChalk.red(`error: ${err?.message || String(err)}`)}\n`); }
134
334
  catch { /* sink failure must not mask err */ }
335
+ const _hint = _errorHint(err);
336
+ if (_hint) { try { writeFn(`${_statusChalk.dim(_hint)}\n`); } catch { /* sink */ } }
135
337
  }
136
338
  }
137
339
  };
@@ -0,0 +1,159 @@
1
+ // tui/slash_args.mjs — slash-command ARGUMENT completion.
2
+ //
3
+ // Two surfaces, chosen per argument:
4
+ // • inline — candidates render directly in the popup (like the /command
5
+ // popup): ↑/↓ select, Tab/Enter fill the token. For single-value args and
6
+ // subcommand menus (/login, /hud, /memory, /config, /channels, /task, …).
7
+ // • modal — a "↹ pick" hint shows; Tab opens the drill-in modal picker.
8
+ // For 2-step provider→model specs (/model, /trainer set, /orchestrator).
9
+ //
10
+ // argSpecFor() is the pure resolver the REPL calls on every keystroke. It is
11
+ // position-aware: tokens[0] is the subcommand, later tokens are values gated on
12
+ // it. listArgCandidates() builds the inline list; runArgCompleter() drives the
13
+ // modal. Kept out of slash_commands.mjs (which must stay a pure-data module to
14
+ // avoid the tui/ → cli.mjs circular import); this module may import freely.
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { pickProviderModel } from './model_pick.mjs';
19
+ import { CLI_LOGIN_PROVIDERS } from '../providers/cli_login.mjs';
20
+ import { KNOWN_CHANNELS } from '../config_features.mjs';
21
+ import { listAgents } from '../agents.mjs';
22
+ import { listSkills } from '../skills.mjs';
23
+ import { listGoals } from '../goals.mjs';
24
+
25
+ const ONOFF = ['on', 'off'];
26
+
27
+ const sp = (kind, completer, name) => ({ kind, completer, name });
28
+
29
+ // Per-command rules: (tokens) => spec | null. `tokens` is the arg portion split
30
+ // on whitespace; the last entry is what the user is typing. A command absent
31
+ // here has no argument completion.
32
+ const ARG_RULES = {
33
+ '/login': (t) => (t.length === 1 ? sp('inline', 'loginProvider', 'provider') : null),
34
+ '/hud': (t) => (t.length === 1 ? sp('inline', 'onoff', 'on|off') : null),
35
+ '/memory': (t) => (t.length === 1 ? sp('inline', 'memoryScope', 'scope') : null),
36
+ '/context': (t) => (t.length === 1 ? sp('inline', 'contextSub', 'sub') : null),
37
+ '/task': (t) => (t.length === 1 ? sp('inline', 'taskSub', 'sub') : null),
38
+ '/team': (t) => (t.length === 1 ? sp('inline', 'teamSub', 'sub') : null),
39
+ '/handoff': (t) => (t.length === 1 ? sp('inline', 'channelName', 'channel') : null),
40
+ '/dashboard': (t) => (t.length === 1 ? sp('inline', 'dashboardSub', 'sub') : null),
41
+ '/goal': (t) => (t.length === 1 ? sp('inline', 'goalFirst', 'goal/sub')
42
+ : (t[0] === 'show' || t[0] === 'close') && t.length === 2 ? sp('inline', 'goalName', 'goal')
43
+ : t[0] === 'close' && t.length === 3 ? sp('inline', 'goalOutcome', 'outcome') : null),
44
+ '/skill': (t) => (t.length === 1 ? sp('inline', 'skillName', 'skill') : null),
45
+ '/skills': (t) => (t.length === 1 ? sp('inline', 'skillName', 'skill') : null),
46
+ '/provider': (t) => (t.length === 1 ? sp('inline', 'provider', 'provider') : null),
47
+ '/channels': (t) => (t.length === 1 ? sp('inline', 'channelFirst', 'channel/setup')
48
+ : t.length === 2 ? sp('inline', 'channelAction', 'on|off|setup') : null),
49
+ '/personality': (t) => (t.length === 1 ? sp('inline', 'personalitySub', 'sub')
50
+ : (t[0] === 'use' || t[0] === 'show' || t[0] === 'remove') && t.length === 2
51
+ ? sp('inline', 'personalityName', 'name') : null),
52
+ '/agent': (t) => (t.length === 1 ? sp('inline', 'agentSub', 'sub')
53
+ : (t[0] === 'edit' || t[0] === 'show' || t[0] === 'remove') && t.length === 2
54
+ ? sp('inline', 'agentName', 'name') : null),
55
+ '/trainer': (t) => (t.length === 1 ? sp('inline', 'trainerSub', 'sub')
56
+ : (t[0] === 'set' || t[0] === 'fallback') && t.length === 2
57
+ ? sp('modal', 'trainerSpec', 'spec') : null),
58
+ '/orchestrator': (t) => (t.length === 1 ? sp('inline', 'orchestratorSub', 'sub')
59
+ : t[0] === 'planner' && t.length === 2 ? sp('modal', 'orchestratorSpec', 'spec')
60
+ : t[0] === 'worker' && t.length === 2 ? sp('inline', 'workerAction', 'add|remove')
61
+ : t[0] === 'worker' && (t[1] === 'add' || t[1] === 'remove' || t[1] === 'rm') && t.length === 3
62
+ ? sp('modal', 'orchestratorSpec', 'spec') : null),
63
+ // 2-step provider→model drill: modal.
64
+ '/model': (t) => (t.length === 1 ? sp('modal', 'model', 'model') : null),
65
+ };
66
+
67
+ // Resolve which argument (if any) is completable for `buffer`. Returns
68
+ // { cmd, kind, completer, name, partial } or null. `catalog` is accepted for
69
+ // back-compat but unused — rules live in ARG_RULES.
70
+ export function argSpecFor(buffer, _catalog) {
71
+ if (!buffer || !buffer.startsWith('/')) return null;
72
+ const at = buffer.indexOf(' ');
73
+ if (at < 0) return null; // still typing the command itself
74
+ const cmd = buffer.slice(0, at);
75
+ const rule = ARG_RULES[cmd];
76
+ if (!rule) return null;
77
+ const tokens = buffer.slice(at + 1).split(/\s+/);
78
+ const spec = rule(tokens);
79
+ if (!spec) return null;
80
+ return { cmd, ...spec, partial: tokens[tokens.length - 1] };
81
+ }
82
+
83
+ function safe(fn) { try { return fn() || []; } catch { return []; } }
84
+ function listMdNames(dir) {
85
+ return safe(() => fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3)).sort());
86
+ }
87
+
88
+ // Inline candidate sources → {value, desc}[] (unfiltered; caller filters).
89
+ const INLINE_SOURCES = {
90
+ loginProvider: () => Object.keys(CLI_LOGIN_PROVIDERS).map((v) => ({ value: v, desc: 'connect via CLI login' })),
91
+ onoff: () => ONOFF.map((v) => ({ value: v, desc: '' })),
92
+ memoryScope: () => ['core', 'recent', 'episodic'].map((v) => ({ value: v, desc: '' })),
93
+ contextSub: () => ['status', 'turns', 'tokens'].map((v) => ({ value: v, desc: '' })),
94
+ taskSub: () => ['start', 'tick', 'list', 'show', 'transcript', 'abandon', 'done', 'remove'].map((v) => ({ value: v, desc: '' })),
95
+ teamSub: () => ['list', 'show', 'add', 'remove'].map((v) => ({ value: v, desc: '' })),
96
+ dashboardSub: () => ['stop', 'kill'].map((v) => ({ value: v, desc: '' })),
97
+ goalOutcome: () => ['done', 'abandoned'].map((v) => ({ value: v, desc: '' })),
98
+ goalName: (ctx) => safe(() => listGoals(ctx.cfgDir).map((g) => ({ value: g.name, desc: g.status || '' })).filter((i) => i.value)),
99
+ goalFirst: (ctx) => ['add', 'list', 'show', 'close'].map((v) => ({ value: v, desc: '' }))
100
+ .concat(safe(() => listGoals(ctx.cfgDir).map((g) => ({ value: g.name, desc: g.status || 'goal' })).filter((i) => i.value))),
101
+ personalitySub: () => ['list', 'show', 'install', 'remove', 'use'].map((v) => ({ value: v, desc: '' })),
102
+ agentSub: () => ['list', 'show', 'add', 'edit', 'remove'].map((v) => ({ value: v, desc: '' })),
103
+ trainerSub: () => ['show', 'set', 'fallback', 'clear'].map((v) => ({ value: v, desc: '' })),
104
+ orchestratorSub:() => ['status', 'on', 'off', 'planner', 'worker', 'maxsubtasks'].map((v) => ({ value: v, desc: '' })),
105
+ workerAction: () => ['add', 'remove'].map((v) => ({ value: v, desc: '' })),
106
+ channelName: () => KNOWN_CHANNELS.map((v) => ({ value: v, desc: '' })),
107
+ channelFirst: () => [{ value: 'setup', desc: 'set channel credentials' }].concat(KNOWN_CHANNELS.map((v) => ({ value: v, desc: '' }))),
108
+ channelAction: () => [...ONOFF, 'setup', 'test'].map((v) => ({ value: v, desc: v === 'setup' ? 'set credentials' : v === 'test' ? 'verify credentials' : '' })),
109
+ provider: (_ctx, registry) => Object.keys((registry && registry.PROVIDERS) || {})
110
+ .filter((n) => n !== 'mock').sort().map((v) => ({ value: v, desc: '' })),
111
+ agentName: (ctx) => safe(() => listAgents(ctx.cfgDir).map((a) => ({ value: a.name, desc: a.model ? `${a.provider}/${a.model}` : a.provider }))),
112
+ skillName: (ctx) => safe(() => listSkills(ctx.cfgDir).map((s) => ({ value: s.name, desc: s.summary || '' }))),
113
+ personalityName:(ctx) => listMdNames(path.join(ctx.cfgDir || '', 'personalities')).map((v) => ({ value: v, desc: '' })),
114
+ };
115
+
116
+ // Build the inline candidate list for an inline spec, filtered + prefix-sorted
117
+ // by the partial token. Returns {value, desc}[] (empty for non-inline specs).
118
+ export function listArgCandidates(spec, ctx, registry) {
119
+ if (!spec || spec.kind !== 'inline') return [];
120
+ const src = INLINE_SOURCES[spec.completer];
121
+ if (!src) return [];
122
+ const all = src(ctx || {}, registry) || [];
123
+ const q = String(spec.partial || '').toLowerCase();
124
+ const matched = q ? all.filter((i) => String(i.value).toLowerCase().includes(q)) : all.slice();
125
+ matched.sort((a, b) => {
126
+ const ap = String(a.value).toLowerCase().startsWith(q) ? 0 : 1;
127
+ const bp = String(b.value).toLowerCase().startsWith(q) ? 0 : 1;
128
+ return ap - bp;
129
+ });
130
+ return matched;
131
+ }
132
+
133
+ // Modal completers — run the drill-in picker and return the string to fill.
134
+ // Only reached for spec.kind === 'modal'.
135
+ export const ARG_COMPLETERS = {
136
+ async model(ctx, registry) {
137
+ const r = await pickProviderModel(ctx, registry, { includeSwitch: true });
138
+ if (!r || r.model == null) return null;
139
+ const active = typeof ctx.getActiveProvName === 'function' ? ctx.getActiveProvName() : '';
140
+ return r.provider && r.provider !== active ? `${r.provider}/${r.model}` : r.model;
141
+ },
142
+ async trainerSpec(ctx, registry) {
143
+ const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
144
+ if (!r || r.model == null) return null;
145
+ return r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
146
+ },
147
+ async orchestratorSpec(ctx, registry) {
148
+ const r = await pickProviderModel(ctx, registry, { exclude: ['orchestrator', 'mock'], pickProvider: true, includeDefault: true, includeSwitch: false });
149
+ if (!r || r.model == null) return null;
150
+ return r.model ? `${r.provider}:${r.model}` : r.provider;
151
+ },
152
+ };
153
+
154
+ // Run the modal completer named by spec.completer. Returns the fill string or null.
155
+ export async function runArgCompleter(spec, ctx, registry) {
156
+ const fn = spec && ARG_COMPLETERS[spec.completer];
157
+ if (!fn) return null;
158
+ return fn(ctx, registry);
159
+ }