@semalt-ai/code 1.8.5 → 1.19.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 (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,403 @@
1
+ 'use strict';
2
+
3
+ // The interactive chat command (cmdChat) and its login flow, extracted from
4
+ // lib/commands.js in Task 1.5. cmdChat builds the session `ctx` (the getter/
5
+ // setter hub) and wires together the extracted chat modules: chat-session
6
+ // (state + dashboard sync + pickers), chat-slash (slash handlers), and chat-turn
7
+ // (the input/agent turn handler). Bodies are unchanged. Runtime-injected deps
8
+ // (config/api/ui/permissions/agent) arrive via `deps`; leaf modules are required
9
+ // directly here as cmdChat did before the split.
10
+
11
+ const fs = require('fs');
12
+
13
+ const { CONFIG_PATH, TAG_REGISTRY } = require('../constants');
14
+ const { configShow, isNativeToolsActive } = require('../config');
15
+ const { getSystemPrompt } = require('../prompts');
16
+ const { SessionStorage } = require('../storage');
17
+ const { setUIActive } = require('../tools');
18
+ const { AUDIT_LOG } = require('../audit');
19
+ const { formatToolLine } = require('../ui/format');
20
+ const writerModule = require('../ui/writer');
21
+ const msgs = require('../ui/messages');
22
+ const dbg = require('../debug');
23
+ const { resolveCommand, helpText, commandNames, registerCustomCommands, registerSkills } = require('./registry');
24
+ const { discoverCustomCommands } = require('./custom');
25
+ const { discoverSkills } = require('../skills');
26
+ const { cleanOrphanedToolMessages, reconstructLoadedMessage } = require('./history-utils');
27
+ const { createChatSession } = require('./chat-session');
28
+ const { createSlashHandlers } = require('./chat-slash');
29
+ const { createTurnHandler } = require('./chat-turn');
30
+ const { createMcpManager } = require('../mcp/client');
31
+
32
+ const LOGIN_POLL_INTERVAL_MS = 2000;
33
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
34
+
35
+ function createChatCommand(deps) {
36
+ const {
37
+ getConfig, setConfig, permissionManager, ui, apiClient,
38
+ runAgentLoop, readFileContext, agentExecShell,
39
+ resolveTokenLimit, ensureDefaultModel, confirmCatastrophicShell,
40
+ checkpointStore,
41
+ } = deps;
42
+ const {
43
+ BOLD, BG_SELECTED, FG_BLUE, FG_CYAN, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_TEAL, FG_YELLOW, RST,
44
+ approxTokens, getCols, boxLine, interactiveSelect, createUI,
45
+ } = ui;
46
+ const {
47
+ chatStream, chatSync, dashboardCreateChat, dashboardGetChat, dashboardGetModelForCli,
48
+ dashboardListChats, dashboardListModels, dashboardLogout, dashboardSaveMessages, dashboardWhoAmI,
49
+ estimateTokens, getCliLoginStatus, requestCliLogin, setActiveModelProfile,
50
+ } = apiClient;
51
+
52
+ async function cmdChat(opts) {
53
+ await ensureDefaultModel();
54
+
55
+ // Build the three end-of-session artifacts that teardown emits as
56
+ // scrollback. Returning them as a plain object lets both exit paths
57
+ // (/exit submit and Ctrl+C onInterrupt) route through writer.teardown,
58
+ // which is the only place that can append them below the erased live
59
+ // region in a single atomic write.
60
+ function buildExitArtifacts() {
61
+ return {
62
+ summary: sessionMetrics ? sessionMetrics.summary() : '',
63
+ resumeHint: currentChatId !== null
64
+ ? ` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`
65
+ : '',
66
+ goodbye: ` ${FG_GRAY}Goodbye!${RST}`,
67
+ };
68
+ }
69
+
70
+ const { chatHistory, statusBar, inputField, layout, destroy, redrawFixed } = createUI({
71
+ showThink: opts.showThink || false,
72
+ onInterrupt: (destroyFn) => {
73
+ saveSession();
74
+ destroyFn(buildExitArtifacts());
75
+ process.exit(0);
76
+ },
77
+ });
78
+
79
+ setUIActive(true);
80
+
81
+ const writer = require('../ui/writer');
82
+ permissionManager.setUICallbacks({
83
+ onAddMessage: (msg) => chatHistory.addMessage(msg),
84
+ onRerenderMessage: (id) => chatHistory.rerenderById(id),
85
+ onCollapseMessage: (id) => chatHistory.collapseById(id),
86
+ onRemoveMessage: (id) => chatHistory.removeById(id),
87
+ // Modal-region API: setModal replaces the modal live band above the
88
+ // status region; clearModal drops it. Arrow-key redraws go through
89
+ // setModal only — no scrollback churn. When the picker resolves we
90
+ // clear the modal and push a single summary line to scrollback.
91
+ onShowModal: (lines) => writer.setModal(lines),
92
+ onCloseModal: (summary) => {
93
+ writer.clearModal();
94
+ if (summary) chatHistory.addMessage({ role: 'system', content: summary });
95
+ },
96
+ onCaptureNavigation: (handler) => {
97
+ inputField.captureNavigation(handler);
98
+ return () => inputField.releaseNavigation();
99
+ },
100
+ captureSelect: (menu) => inputField.captureSelect(menu),
101
+ });
102
+
103
+ inputField.on('expand', () => chatHistory.toggleLastExpand());
104
+
105
+ const cwd = process.cwd();
106
+ let currentModel = opts.model || getConfig().default_model;
107
+ let resolvedTokenLimit = await resolveTokenLimit(currentModel);
108
+ statusBar.setModel(currentModel);
109
+ // Seed the context indicator with the profile's limit up-front so it
110
+ // renders "0 / 200,000 tok (0%)" before the first API response, instead
111
+ // of appearing out of thin air once a turn completes.
112
+ statusBar.setContextLimit(resolvedTokenLimit);
113
+ let sessionMetrics = null;
114
+ // system prompt is prepended fresh on every API call in agent.js — never stored in history
115
+ let messages = [];
116
+ let currentChatId = null;
117
+ let savedUpTo = 0;
118
+ // The agent loop's per-iteration `formatDebugBlock` runs whenever any
119
+ // debug mode is active. In simple mode the block is rendered as a TUI
120
+ // chat bubble (cb.onDebug → addMessage). In file mode emitDebug routes
121
+ // the block to the debug file instead, keeping the TUI clean.
122
+ let debugMode = dbg.isActive();
123
+ // Plan mode (Task 2.5): /plan toggles it; while on, the agent loop withholds
124
+ // mutating tools until the user approves (toggles it back off). Seeded from
125
+ // the --plan flag for a plan-first session.
126
+ let planMode = !!opts.plan;
127
+
128
+ // Resolve system prompt override from --system-prompt file if provided
129
+ let resolvedSystemPrompt = null;
130
+ if (opts.systemPromptFile) {
131
+ try {
132
+ resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8');
133
+ } catch (err) {
134
+ // will be shown after UI initializes
135
+ }
136
+ }
137
+ const storage = new SessionStorage();
138
+ const sessionStart = Date.now();
139
+ let session = {
140
+ id: storage.generateId(),
141
+ created_at: sessionStart,
142
+ model: currentModel,
143
+ messages: [],
144
+ stats: { total_tokens: 0, duration_sec: 0 },
145
+ };
146
+ // Checkpoints & rewind (Task 4.3): align the per-process checkpoint store
147
+ // with this chat's session id so its checkpoint directory and `/rewind` view
148
+ // match the session. Done before any turn runs (no captures yet).
149
+ if (checkpointStore && typeof checkpointStore.setSession === 'function') {
150
+ checkpointStore.setSession(session.id);
151
+ }
152
+ let pendingAction = null;
153
+ const PAGE_SIZE = 5;
154
+
155
+ // MCP manager (Task 3.3). Connects to configured servers on startup and
156
+ // registers their tools into the shared registry so they dispatch through
157
+ // the agent loop like built-ins. Connection failures are isolated per-server
158
+ // and never block chat startup.
159
+ const mcpManager = createMcpManager({
160
+ getConfig,
161
+ logger: (m) => chatHistory.addMessage({ role: 'system', content: `⚠ ${m}` }),
162
+ });
163
+
164
+ // Shared chat-session context handed to the extracted chat modules. Mutable
165
+ // fields are exposed via getters/setters backed by the locals above, so the
166
+ // cmdChat shell keeps using bare names while moved module bodies read/write
167
+ // through ctx. The per-group functions are attached onto ctx below.
168
+ const ctx = {
169
+ get messages() { return messages; }, set messages(v) { messages = v; },
170
+ get currentModel() { return currentModel; }, set currentModel(v) { currentModel = v; },
171
+ get currentChatId() { return currentChatId; }, set currentChatId(v) { currentChatId = v; },
172
+ get savedUpTo() { return savedUpTo; }, set savedUpTo(v) { savedUpTo = v; },
173
+ get resolvedTokenLimit() { return resolvedTokenLimit; }, set resolvedTokenLimit(v) { resolvedTokenLimit = v; },
174
+ get resolvedSystemPrompt() { return resolvedSystemPrompt; }, set resolvedSystemPrompt(v) { resolvedSystemPrompt = v; },
175
+ get debugMode() { return debugMode; }, set debugMode(v) { debugMode = v; },
176
+ get planMode() { return planMode; }, set planMode(v) { planMode = v; },
177
+ get pendingAction() { return pendingAction; }, set pendingAction(v) { pendingAction = v; },
178
+ get sessionMetrics() { return sessionMetrics; }, set sessionMetrics(v) { sessionMetrics = v; },
179
+ get session() { return session; }, set session(v) { session = v; },
180
+ get resolveExit() { return resolveExit; },
181
+ // Multimodal image input (Task 5.4): images staged by /image, consumed and
182
+ // cleared by the next user turn (chat-turn.js).
183
+ pendingImages: [],
184
+ // stable handles / deps / helpers
185
+ opts, cwd, storage, sessionStart, PAGE_SIZE, mcpManager,
186
+ chatHistory, statusBar, inputField, writer, writerModule, destroy, redrawFixed, buildExitArtifacts,
187
+ getConfig, setConfig, permissionManager, runAgentLoop, readFileContext, agentExecShell, checkpointStore,
188
+ approxTokens, getCols, boxLine, interactiveSelect, msgs, dbg, fs,
189
+ resolveTokenLimit, ensureDefaultModel, confirmCatastrophicShell, loginFlow: _loginFlow,
190
+ helpText, resolveCommand, configShow, getSystemPrompt, isNativeToolsActive,
191
+ TAG_REGISTRY, formatToolLine, estimateTokens, AUDIT_LOG,
192
+ cleanOrphanedToolMessages, reconstructLoadedMessage,
193
+ chatStream, chatSync, dashboardCreateChat, dashboardGetChat, dashboardGetModelForCli,
194
+ dashboardListChats, dashboardListModels, dashboardLogout, dashboardWhoAmI, dashboardSaveMessages,
195
+ BOLD, BG_SELECTED, FG_BLUE, FG_CYAN, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_TEAL, FG_YELLOW, RST,
196
+ };
197
+
198
+ // Session/state management (local + dashboard history sync, pickers).
199
+ const chatSessionFns = createChatSession(ctx);
200
+ Object.assign(ctx, chatSessionFns);
201
+ const {
202
+ refreshInputSearchItems, saveSession,
203
+ displayLoadedMessages, seedContextFromMessages, emitCleanupWarning,
204
+ } = chatSessionFns;
205
+
206
+ refreshInputSearchItems();
207
+
208
+ // Banner — emit once as scrollback above the live region. In the
209
+ // bottom-anchored live-region TUI, scrollback flows into terminal
210
+ // scrollback naturally, so no absolute positioning or scroll-region
211
+ // trickery is needed here.
212
+ if (layout) {
213
+ const w = Math.min(getCols() - 4, 60);
214
+ const banner = [
215
+ ``,
216
+ ` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`,
217
+ boxLine('', w),
218
+ boxLine(`${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`, w),
219
+ boxLine(`${FG_GRAY}Self-hosted AI coding assistant${RST}`, w),
220
+ boxLine('', w),
221
+ ` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`,
222
+ ``,
223
+ ].join('\n');
224
+ writer.scrollback(banner);
225
+ redrawFixed();
226
+ }
227
+
228
+ // Welcome message
229
+ chatHistory.addMessage({
230
+ role: 'system',
231
+ content: `◆ Semalt.AI · ${currentModel} · ${cwd}\nType /help for commands.`,
232
+ });
233
+
234
+ // Fail-loud (memory truncation): warn once at startup if a loaded project
235
+ // memory file (AGENTS.md/CLAUDE.md) was truncated at the cap — path, loaded
236
+ // vs original size, and dropped %. The content still loads normally; this is
237
+ // a user-facing chat system line only, never injected into the model prompt.
238
+ try {
239
+ const { loadProjectMemory, memoryTruncationWarnings } = require('../memory');
240
+ for (const w of memoryTruncationWarnings(loadProjectMemory({ cwd }))) {
241
+ chatHistory.addMessage({ role: 'system', content: w });
242
+ }
243
+ } catch { /* best-effort; never block chat startup */ }
244
+
245
+ // Discover & register Markdown-defined custom slash commands (Task 3.1). The
246
+ // registry stays the single source of truth — customs are registered into it
247
+ // so resolveCommand/completion/help see them. Built-ins win on collision;
248
+ // project overrides global (resolved in discoverCustomCommands). Invocation
249
+ // is handled inline by the turn handler: the rendered template is submitted
250
+ // to the agent as a user prompt, never executed as code.
251
+ try {
252
+ const discovered = discoverCustomCommands({ cwd });
253
+ const { registered, warnings } = registerCustomCommands(discovered);
254
+ for (const w of warnings) chatHistory.addMessage({ role: 'system', content: `⚠ ${w}` });
255
+ if (registered.length) {
256
+ chatHistory.addMessage({
257
+ role: 'system',
258
+ content: `✓ Loaded ${registered.length} custom command(s): ${registered.map((c) => c.name).join(', ')}`,
259
+ });
260
+ }
261
+ } catch { /* custom commands are best-effort; never block chat startup */ }
262
+
263
+ // Discover & register skills (Task 3.5). Only metadata (name + description)
264
+ // is registered here and injected into the system prompt — the body is read
265
+ // on demand when the skill is invoked (progressive disclosure), so the prompt
266
+ // stays lean. Built-ins win on collision; project overrides global (resolved
267
+ // in discoverSkills). Invocation is handled inline by the turn handler.
268
+ try {
269
+ const discovered = discoverSkills({ cwd });
270
+ const { registered, warnings } = registerSkills(discovered);
271
+ for (const w of warnings) chatHistory.addMessage({ role: 'system', content: `⚠ ${w}` });
272
+ if (registered.length) {
273
+ chatHistory.addMessage({
274
+ role: 'system',
275
+ content: `✓ Loaded ${registered.length} skill(s): ${registered.map((s) => s.name).join(', ')}`,
276
+ });
277
+ }
278
+ } catch { /* skills are best-effort; never block chat startup */ }
279
+
280
+ // Connect configured MCP servers and register their tools (Task 3.3). No-op
281
+ // when none are configured. Best-effort: a failure to load the SDK or reach a
282
+ // server is reported but never blocks chat startup.
283
+ try {
284
+ const cfgServers = (getConfig().mcp && getConfig().mcp.servers) || {};
285
+ if (Object.keys(cfgServers).length) {
286
+ const status = await mcpManager.connectAll();
287
+ const connected = status.filter((s) => s.state === 'connected');
288
+ const toolCount = connected.reduce((n, s) => n + s.tools.length, 0);
289
+ if (connected.length) {
290
+ chatHistory.addMessage({
291
+ role: 'system',
292
+ content: `✓ MCP: connected ${connected.length} server(s), ${toolCount} tool(s). Use /mcp for status.`,
293
+ });
294
+ }
295
+ }
296
+ } catch (err) {
297
+ chatHistory.addMessage({ role: 'system', content: `⚠ MCP init failed: ${err.message}`, isError: true });
298
+ }
299
+
300
+ if (opts.systemPromptFile && resolvedSystemPrompt === null) {
301
+ chatHistory.addMessage({ role: 'system', content: `✗ Could not read system prompt file: ${opts.systemPromptFile}`, isError: true });
302
+ } else if (opts.systemPromptFile && resolvedSystemPrompt !== null) {
303
+ chatHistory.addMessage({ role: 'system', content: `✓ Using system prompt from: ${opts.systemPromptFile}` });
304
+ }
305
+
306
+ // --resume: load previous chat
307
+ if (opts.resume) {
308
+ const resumeId = parseInt(opts.resume, 10);
309
+ if (!isNaN(resumeId)) {
310
+ try {
311
+ const chatData = await dashboardGetChat(resumeId);
312
+ const loaded = chatData && chatData.messages ? chatData.messages : [];
313
+ for (const m of loaded) messages.push(reconstructLoadedMessage(m));
314
+ const cleanup = cleanOrphanedToolMessages(messages);
315
+ messages = cleanup.messages;
316
+ currentChatId = resumeId;
317
+ savedUpTo = messages.length;
318
+ const title = chatData.chat && chatData.chat.title ? chatData.chat.title : `#${resumeId}`;
319
+ displayLoadedMessages(loaded);
320
+ chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${title} (${loaded.length} messages)` });
321
+ emitCleanupWarning(cleanup);
322
+ seedContextFromMessages();
323
+ } catch (error) {
324
+ chatHistory.addMessage({ role: 'system', content: `✗ Could not resume chat: ${error.message}`, isError: true });
325
+ }
326
+ }
327
+ }
328
+
329
+ let resolveExit;
330
+ const exitPromise = new Promise((r) => { resolveExit = r; });
331
+
332
+
333
+ statusBar.update('idle');
334
+
335
+ // Slash-command handlers (lib/commands/chat-slash.js), keyed by the canonical
336
+ // registry name. The parity check below guarantees registry ↔ handler stay
337
+ // in lockstep.
338
+ const slashHandlers = createSlashHandlers(ctx);
339
+
340
+ // Parity guard: every registry command must have a handler here, and every
341
+ // handler must correspond to a registry command. Adding a command is then a
342
+ // single registry entry + its handler — a missing pair fails loudly at startup.
343
+ {
344
+ const handlerNames = new Set(Object.keys(slashHandlers));
345
+ const registryNames = commandNames();
346
+ for (const name of registryNames) {
347
+ if (!handlerNames.has(name)) throw new Error(`Slash command "${name}" is in the registry but has no handler`);
348
+ }
349
+ for (const name of handlerNames) {
350
+ if (!registryNames.includes(name)) throw new Error(`Slash handler "${name}" has no registry entry`);
351
+ }
352
+ }
353
+
354
+ // The turn handler (picker text-fallback, slash dispatch, agent run + TUI
355
+ // callbacks) lives in lib/commands/chat-turn.js.
356
+ inputField.onSubmit(createTurnHandler(ctx, slashHandlers));
357
+
358
+ // Wait until user exits. The /exit submit handler already ran
359
+ // destroy(buildExitArtifacts()), so the session summary, resume hint,
360
+ // and goodbye have been emitted as scrollback inside teardown's
361
+ // single atomic write. Nothing more to print here.
362
+ await exitPromise;
363
+ setUIActive(false);
364
+ saveSession();
365
+ try { await mcpManager.shutdown(); } catch { /* best-effort cleanup */ }
366
+ }
367
+
368
+ async function _loginFlow(chatHistory, statusBar) {
369
+ let loginRequest;
370
+ try { loginRequest = await requestCliLogin(); }
371
+ catch (err) {
372
+ chatHistory.addMessage({ role: 'system', content: `✗ Login failed: ${err.message}`, isError: true });
373
+ return;
374
+ }
375
+ chatHistory.addMessage({ role: 'system', content: `Open this URL to authorize:\n ${loginRequest.verification_url}\n\nWaiting for confirmation...` });
376
+ statusBar.update('waiting', 'Waiting for browser auth...');
377
+ const startedAt = Date.now();
378
+ while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
379
+ await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
380
+ let status;
381
+ try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
382
+ catch (err) {
383
+ if (err.statusCode === 404 || err.statusCode === 410) { chatHistory.addMessage({ role: 'system', content: '✗ Login token is no longer valid.', isError: true }); return; }
384
+ continue;
385
+ }
386
+ if (status.status === 'authorized') {
387
+ const config = getConfig();
388
+ setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token });
389
+ chatHistory.addMessage({ role: 'system', content: `✓ CLI token saved to ${CONFIG_PATH}` });
390
+ return;
391
+ }
392
+ if (status.status === 'expired') {
393
+ chatHistory.addMessage({ role: 'system', content: '✗ Login token expired. Run /login again.', isError: true });
394
+ return;
395
+ }
396
+ }
397
+ chatHistory.addMessage({ role: 'system', content: '⚠ Login timed out.' });
398
+ }
399
+
400
+ return { cmdChat };
401
+ }
402
+
403
+ module.exports = { createChatCommand };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Custom slash commands from Markdown (Task 3.1).
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // Users define workflows as Markdown files — no code. The filename is the
8
+ // command name (`review.md` → `/review`). Optional YAML-ish frontmatter
9
+ // (`description`, `argument-hint`, `aliases`) configures the command; the body
10
+ // is the prompt template. Discovered commands are registered into the existing
11
+ // slash-command registry (lib/commands/registry.js) so resolution, completion,
12
+ // and /help all see them through one source of truth.
13
+ //
14
+ // Discovery scans two locations, lowest precedence first:
15
+ // 1. global ~/.semalt-ai/commands/*.md
16
+ // 2. project <nearest>/.semalt/commands/*.md (upward from cwd, bounded by
17
+ // the repo root — the .git holder — mirroring Task 2.2 config
18
+ // discovery)
19
+ // On a name collision project wins over global. Built-ins always win over both
20
+ // (enforced at registration time in registry.js).
21
+ //
22
+ // Rendering substitutes `$ARGUMENTS` (the full argument string) and `$1`/`$2`/…
23
+ // (whitespace-split positionals). The rendered text is submitted to the agent
24
+ // as an ordinary user prompt — never executed as code.
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+
30
+ // Walk up from startDir for the nearest `.semalt/commands` directory, bounded
31
+ // by the repo root (the directory holding `.git` is the last one checked).
32
+ // Mirrors Task 2.2's findProjectConfigPath. Returns the directory path or null.
33
+ function findProjectCommandsDir(startDir) {
34
+ let dir = path.resolve(startDir);
35
+ while (true) {
36
+ const candidate = path.join(dir, '.semalt', 'commands');
37
+ try { if (fs.statSync(candidate).isDirectory()) return candidate; } catch {}
38
+ let atRepoRoot = false;
39
+ try { atRepoRoot = fs.existsSync(path.join(dir, '.git')); } catch {}
40
+ if (atRepoRoot) break;
41
+ const parent = path.dirname(dir);
42
+ if (parent === dir) break; // filesystem root
43
+ dir = parent;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // Split a frontmatter alias value into a list. Accepts an inline YAML flow list
49
+ // (`[a, b]`) or a comma-separated string (`a, b`), with optional quotes.
50
+ function parseAliasList(val) {
51
+ if (!val) return [];
52
+ let s = val.trim();
53
+ if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
54
+ return s
55
+ .split(',')
56
+ .map((a) => a.trim().replace(/^['"]|['"]$/g, '').trim())
57
+ .filter(Boolean);
58
+ }
59
+
60
+ // Parse optional frontmatter delimited by `---` lines at the very top of the
61
+ // file. Only `description`, `argument-hint`, and `aliases` are recognized;
62
+ // unknown keys are ignored. Returns { meta, body }. With no frontmatter the
63
+ // whole text is the body. Pure.
64
+ function parseFrontmatter(text) {
65
+ const meta = { description: '', argumentHint: '', aliases: [] };
66
+ if (typeof text !== 'string') return { meta, body: '' };
67
+ const src = text.replace(/^/, '').replace(/\r\n/g, '\n');
68
+ const m = /^---\n([\s\S]*?)\n---[ \t]*\n?/.exec(src);
69
+ if (!m) return { meta, body: src };
70
+ const fmBody = m[1];
71
+ const body = src.slice(m[0].length);
72
+ for (const rawLine of fmBody.split('\n')) {
73
+ const line = rawLine.trim();
74
+ if (!line || line.startsWith('#')) continue;
75
+ const idx = line.indexOf(':');
76
+ if (idx < 0) continue;
77
+ const key = line.slice(0, idx).trim().toLowerCase();
78
+ let val = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '');
79
+ if (key === 'description') meta.description = val;
80
+ else if (key === 'argument-hint' || key === 'argument_hint') meta.argumentHint = val;
81
+ else if (key === 'aliases' || key === 'alias') meta.aliases = parseAliasList(line.slice(idx + 1).trim());
82
+ }
83
+ return { meta, body };
84
+ }
85
+
86
+ // Substitute `$ARGUMENTS` (full argument string) and `$1`/`$2`/… (whitespace-
87
+ // split positionals) into a template. Done in a single pass so text injected by
88
+ // `$ARGUMENTS` is never re-expanded. Unprovided positionals render as empty.
89
+ // Pure.
90
+ function renderTemplate(template, argString) {
91
+ const args = String(argString == null ? '' : argString).trim();
92
+ const positionals = args.length ? args.split(/\s+/) : [];
93
+ return String(template == null ? '' : template).replace(
94
+ /\$ARGUMENTS\b|\$(\d+)/g,
95
+ (match, digits) => {
96
+ if (digits === undefined) return args; // $ARGUMENTS
97
+ const i = parseInt(digits, 10) - 1;
98
+ return i >= 0 && i < positionals.length ? positionals[i] : '';
99
+ },
100
+ );
101
+ }
102
+
103
+ // Read every *.md command file in `dir` into a spec list. Filenames are sorted
104
+ // for deterministic order. Unreadable / non-file entries are skipped.
105
+ function loadCommandsFromDir(dir, source) {
106
+ const out = [];
107
+ let entries;
108
+ try { entries = fs.readdirSync(dir); } catch { return out; }
109
+ for (const entry of entries.slice().sort()) {
110
+ if (!/\.md$/i.test(entry)) continue;
111
+ const filePath = path.join(dir, entry);
112
+ let raw;
113
+ try {
114
+ if (!fs.statSync(filePath).isFile()) continue;
115
+ raw = fs.readFileSync(filePath, 'utf8');
116
+ } catch { continue; }
117
+ const base = entry.replace(/\.md$/i, '');
118
+ if (!base) continue;
119
+ const { meta, body } = parseFrontmatter(raw);
120
+ const aliases = meta.aliases.map((a) => (a.startsWith('/') ? a : '/' + a));
121
+ out.push({
122
+ name: '/' + base,
123
+ aliases,
124
+ description: meta.description,
125
+ argumentHint: meta.argumentHint,
126
+ template: body,
127
+ source,
128
+ filePath,
129
+ });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ // Discover Markdown-defined commands for a (home, cwd). Global commands load
135
+ // first, then the nearest project commands; on a name collision project wins.
136
+ // Returns an ordered, de-duplicated spec list (project entries first so they
137
+ // take precedence). fs reads only; home/cwd are injectable for tests.
138
+ function discoverCustomCommands(opts = {}) {
139
+ const home = opts.home || os.homedir();
140
+ const cwd = opts.cwd || process.cwd();
141
+ const global = loadCommandsFromDir(path.join(home, '.semalt-ai', 'commands'), 'global');
142
+ const projectDir = findProjectCommandsDir(cwd);
143
+ const project = projectDir ? loadCommandsFromDir(projectDir, 'project') : [];
144
+ const byName = new Map();
145
+ for (const c of project) if (!byName.has(c.name)) byName.set(c.name, c);
146
+ for (const c of global) if (!byName.has(c.name)) byName.set(c.name, c);
147
+ return Array.from(byName.values());
148
+ }
149
+
150
+ module.exports = {
151
+ findProjectCommandsDir,
152
+ parseAliasList,
153
+ parseFrontmatter,
154
+ renderTemplate,
155
+ loadCommandsFromDir,
156
+ discoverCustomCommands,
157
+ };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ // Pure helpers for loading / sanitising saved-chat message history. No I/O, no
4
+ // UI, no config — extracted from lib/commands.js in Task 1.5 so they can be unit
5
+ // tested and reused. Bodies are unchanged.
6
+
7
+ // Drop assistant.tool_calls and role:tool messages whose ids don't pair up.
8
+ // A loaded chat may contain role:tool with empty/missing tool_call_id (legacy
9
+ // rows, dropped fields in transit) or assistant.tool_calls without a matching
10
+ // tool response (truncated turn). Either side without its partner produces a
11
+ // 400 from strict providers like MiniMax — the validator in api.js will throw
12
+ // — so we strip both sides of the orphan pair before sending.
13
+ function cleanOrphanedToolMessages(msgs) {
14
+ const calledIds = new Set();
15
+ const respondedIds = new Set();
16
+ for (const m of msgs) {
17
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
18
+ for (const tc of m.tool_calls) {
19
+ if (tc && tc.id) calledIds.add(tc.id);
20
+ }
21
+ } else if (m.role === 'tool' && m.tool_call_id) {
22
+ respondedIds.add(m.tool_call_id);
23
+ }
24
+ }
25
+ const paired = new Set();
26
+ for (const id of calledIds) if (respondedIds.has(id)) paired.add(id);
27
+
28
+ let droppedTool = 0;
29
+ let droppedAssistantCalls = 0;
30
+ let droppedAssistantMsgs = 0;
31
+ const out = [];
32
+ for (const m of msgs) {
33
+ if (m.role === 'tool') {
34
+ if (!m.tool_call_id || !paired.has(m.tool_call_id)) { droppedTool++; continue; }
35
+ out.push(m);
36
+ } else if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
37
+ const kept = m.tool_calls.filter((tc) => tc && tc.id && paired.has(tc.id));
38
+ droppedAssistantCalls += m.tool_calls.length - kept.length;
39
+ const hasContent = typeof m.content === 'string' && m.content.trim().length > 0;
40
+ if (kept.length === 0 && !hasContent) { droppedAssistantMsgs++; continue; }
41
+ const next = { ...m };
42
+ if (kept.length > 0) next.tool_calls = kept;
43
+ else delete next.tool_calls;
44
+ out.push(next);
45
+ } else {
46
+ out.push(m);
47
+ }
48
+ }
49
+ return { messages: out, droppedTool, droppedAssistantCalls, droppedAssistantMsgs };
50
+ }
51
+
52
+ function reconstructLoadedMessage(m) {
53
+ const msg = { role: m.role, content: m.content };
54
+ if (m.tool_call_id !== undefined && m.tool_call_id !== null && m.tool_call_id !== '') {
55
+ msg.tool_call_id = m.tool_call_id;
56
+ }
57
+ if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
58
+ msg.tool_calls = m.tool_calls;
59
+ }
60
+ return msg;
61
+ }
62
+
63
+ module.exports = {
64
+ cleanOrphanedToolMessages,
65
+ reconstructLoadedMessage,
66
+ };