@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.
- package/.claude/settings.local.json +6 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1584 -26
- package/README.md +147 -3
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +711 -104
- package/lib/api.js +213 -49
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +333 -11
- package/lib/constants.js +372 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +84 -5
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2558 -0
- package/lib/tool_specs.js +222 -2
- package/lib/tools.js +272 -1020
- package/lib/ui/format.js +22 -1
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/background.test.js +414 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +203 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/max-iterations.test.js +216 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +163 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/result-cap.test.js +233 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- 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
|
+
};
|