@kinqs/brainrouter-cli 0.3.4
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/.env.example +109 -0
- package/README.md +185 -0
- package/dist/agent/agent.d.ts +765 -0
- package/dist/agent/agent.js +1977 -0
- package/dist/cli/cliPrompt.d.ts +15 -0
- package/dist/cli/cliPrompt.js +62 -0
- package/dist/cli/commands/_context.d.ts +53 -0
- package/dist/cli/commands/_context.js +14 -0
- package/dist/cli/commands/_helpers.d.ts +45 -0
- package/dist/cli/commands/_helpers.js +140 -0
- package/dist/cli/commands/guard.d.ts +6 -0
- package/dist/cli/commands/guard.js +292 -0
- package/dist/cli/commands/memory.d.ts +12 -0
- package/dist/cli/commands/memory.js +263 -0
- package/dist/cli/commands/obs.d.ts +6 -0
- package/dist/cli/commands/obs.js +208 -0
- package/dist/cli/commands/orchestration.d.ts +6 -0
- package/dist/cli/commands/orchestration.js +218 -0
- package/dist/cli/commands/session.d.ts +6 -0
- package/dist/cli/commands/session.js +191 -0
- package/dist/cli/commands/ui.d.ts +6 -0
- package/dist/cli/commands/ui.js +477 -0
- package/dist/cli/commands/workflow.d.ts +6 -0
- package/dist/cli/commands/workflow.js +691 -0
- package/dist/cli/repl.d.ts +12 -0
- package/dist/cli/repl.js +894 -0
- package/dist/config/config.d.ts +22 -0
- package/dist/config/config.js +105 -0
- package/dist/config/workspace.d.ts +7 -0
- package/dist/config/workspace.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +610 -0
- package/dist/memory/briefing.d.ts +46 -0
- package/dist/memory/briefing.js +152 -0
- package/dist/memory/consolidation.d.ts +60 -0
- package/dist/memory/consolidation.js +208 -0
- package/dist/memory/formatters.d.ts +38 -0
- package/dist/memory/formatters.js +102 -0
- package/dist/memory/mentions.d.ts +10 -0
- package/dist/memory/mentions.js +72 -0
- package/dist/orchestration/orchestrator.d.ts +36 -0
- package/dist/orchestration/orchestrator.js +71 -0
- package/dist/orchestration/roles.d.ts +11 -0
- package/dist/orchestration/roles.js +117 -0
- package/dist/orchestration/tools.d.ts +244 -0
- package/dist/orchestration/tools.js +528 -0
- package/dist/prompt/breadthHint.d.ts +48 -0
- package/dist/prompt/breadthHint.js +93 -0
- package/dist/prompt/compactor.d.ts +31 -0
- package/dist/prompt/compactor.js +112 -0
- package/dist/prompt/initAgentMd.d.ts +13 -0
- package/dist/prompt/initAgentMd.js +194 -0
- package/dist/prompt/skillRunner.d.ts +34 -0
- package/dist/prompt/skillRunner.js +146 -0
- package/dist/prompt/systemPrompt.d.ts +10 -0
- package/dist/prompt/systemPrompt.js +171 -0
- package/dist/runtime/clipboard.d.ts +17 -0
- package/dist/runtime/clipboard.js +52 -0
- package/dist/runtime/llmSemaphore.d.ts +30 -0
- package/dist/runtime/llmSemaphore.js +67 -0
- package/dist/runtime/loopRunner.d.ts +25 -0
- package/dist/runtime/loopRunner.js +79 -0
- package/dist/runtime/mcpClient.d.ts +156 -0
- package/dist/runtime/mcpClient.js +234 -0
- package/dist/runtime/mcpUtils.d.ts +36 -0
- package/dist/runtime/mcpUtils.js +64 -0
- package/dist/runtime/sandbox.d.ts +48 -0
- package/dist/runtime/sandbox.js +156 -0
- package/dist/runtime/tracing.d.ts +25 -0
- package/dist/runtime/tracing.js +91 -0
- package/dist/state/cliState.d.ts +59 -0
- package/dist/state/cliState.js +311 -0
- package/dist/state/goalStore.d.ts +174 -0
- package/dist/state/goalStore.js +410 -0
- package/dist/state/hookifyStore.d.ts +80 -0
- package/dist/state/hookifyStore.js +237 -0
- package/dist/state/hooksStore.d.ts +42 -0
- package/dist/state/hooksStore.js +71 -0
- package/dist/state/preferencesStore.d.ts +41 -0
- package/dist/state/preferencesStore.js +25 -0
- package/dist/state/sessionStore.d.ts +42 -0
- package/dist/state/sessionStore.js +193 -0
- package/dist/state/taskStore.d.ts +23 -0
- package/dist/state/taskStore.js +80 -0
- package/dist/state/workflowArtifacts.d.ts +33 -0
- package/dist/state/workflowArtifacts.js +139 -0
- package/package.json +71 -0
package/dist/cli/repl.js
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { exec } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { marked } from 'marked';
|
|
9
|
+
import { markedTerminal } from 'marked-terminal';
|
|
10
|
+
import { expandMentions } from '../memory/mentions.js';
|
|
11
|
+
import { addGoalTokens, buildBudgetSteeringMessage, goalHasBudgetLeft, goalIsOnFinalBudgetTurn, readGoal, tickGoalIteration, usageLimitGoal } from '../state/goalStore.js';
|
|
12
|
+
import { readPreferences } from '../state/preferencesStore.js';
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
import { listSessions } from '../orchestration/orchestrator.js';
|
|
15
|
+
import { setActiveReadline } from './cliPrompt.js';
|
|
16
|
+
// Category dispatch — extracted slash-command handlers. Each module exports
|
|
17
|
+
// a tryHandleX(ctx) that returns true iff it matched the command. Walked
|
|
18
|
+
// in order; first match wins, no match falls through to the legacy switch.
|
|
19
|
+
import { tryHandleMemoryCommand } from './commands/memory.js';
|
|
20
|
+
import { tryHandleUiCommand } from './commands/ui.js';
|
|
21
|
+
import { tryHandleWorkflowCommand } from './commands/workflow.js';
|
|
22
|
+
import { tryHandleObsCommand } from './commands/obs.js';
|
|
23
|
+
import { tryHandleOrchestrationCommand } from './commands/orchestration.js';
|
|
24
|
+
import { tryHandleSessionCommand } from './commands/session.js';
|
|
25
|
+
import { tryHandleGuardCommand } from './commands/guard.js';
|
|
26
|
+
const execPromise = promisify(exec);
|
|
27
|
+
// Setup marked terminal rendering
|
|
28
|
+
marked.use(markedTerminal({
|
|
29
|
+
showSectionPrefix: false,
|
|
30
|
+
}));
|
|
31
|
+
/**
|
|
32
|
+
* All slash commands the REPL recognizes. Used for tab autocomplete and for
|
|
33
|
+
* the readline completer. Keep alphabetically grouped roughly by surface area.
|
|
34
|
+
*/
|
|
35
|
+
const SLASH_COMMANDS = [
|
|
36
|
+
'/help', '/status', '/workspace', '/tools', '/skills', '/plan', '/transcript',
|
|
37
|
+
'/doctor', '/config', '/diff', '/commit', '/clear', '/compact', '/exit', '/quit',
|
|
38
|
+
'/roles', '/agents', '/agent', '/spawn', '/wait',
|
|
39
|
+
'/spec', '/feature-dev', '/review', '/implement-plan', '/skill', '/workflows', '/approve',
|
|
40
|
+
'/memory', '/recall', '/briefing', '/scenes', '/working', '/forget',
|
|
41
|
+
'/init', '/sessions', '/resume', '/model', '/mcp',
|
|
42
|
+
'/goal', '/copy', '/fork', '/rename', '/permissions', '/hooks', '/hookify', '/loop',
|
|
43
|
+
'/continue', '/auto-review', '/vim', '/statusline',
|
|
44
|
+
'/handover', '/explain', '/trace', '/failed', '/verify', '/audit',
|
|
45
|
+
'/export', '/import', '/persona', '/skill-hints', '/diagnostics',
|
|
46
|
+
'/tokens', '/watch', '/yolo', '/sandbox', '/kill',
|
|
47
|
+
// workflow & ergonomics commands
|
|
48
|
+
'/theme', '/title', '/personality', '/new', '/side', '/btw', '/raw',
|
|
49
|
+
'/feedback', '/rollout', '/ps', '/stop', '/logout', '/apps', '/plugins',
|
|
50
|
+
'/experimental', '/memories', '/debug-config', '/mention', '/keymap', '/ide',
|
|
51
|
+
];
|
|
52
|
+
export function startREPL(agent, mcpClient, config, workspace) {
|
|
53
|
+
console.log(chalk.bold.hex('#CC9166')('\n🧠 BRAINROUTER TERMINAL AGENT CLIENT v0.3.4'));
|
|
54
|
+
console.log(chalk.gray('Midnight Ledger / Obsidian Surface theme active.'));
|
|
55
|
+
console.log(chalk.gray(`Workspace root: ${workspace?.workspaceRoot || process.cwd()}`));
|
|
56
|
+
// Surface offline mode prominently — easy to miss the warning that scrolled
|
|
57
|
+
// by during startup, and the user needs to know memory tools won't fire.
|
|
58
|
+
if (!mcpClient.isConnected()) {
|
|
59
|
+
console.log(chalk.yellow('⚠️ OFFLINE MODE — MCP server unreachable. Local tools only; memory recall / skills disabled.'));
|
|
60
|
+
}
|
|
61
|
+
console.log(chalk.gray('Type ') + chalk.cyan('/help') + chalk.gray(' for commands, or start typing your prompt.\n'));
|
|
62
|
+
const rl = readline.createInterface({
|
|
63
|
+
input: process.stdin,
|
|
64
|
+
output: process.stdout,
|
|
65
|
+
prompt: chalk.hex('#CC9166')('brainrouter> '),
|
|
66
|
+
// Tab-completion: complete slash commands when the line begins with "/"
|
|
67
|
+
// and complete workspace file paths when the user is mid-`@mention`.
|
|
68
|
+
completer: (line) => {
|
|
69
|
+
const atMatch = line.match(/@([^\s]*)$/);
|
|
70
|
+
if (atMatch) {
|
|
71
|
+
const partial = atMatch[1];
|
|
72
|
+
const candidates = completeWorkspacePath(agent.workspaceRoot, partial);
|
|
73
|
+
return [candidates.map((c) => `@${c}`), `@${partial}`];
|
|
74
|
+
}
|
|
75
|
+
if (line.startsWith('/')) {
|
|
76
|
+
const hits = SLASH_COMMANDS.filter((cmd) => cmd.startsWith(line));
|
|
77
|
+
return [hits.length ? hits : SLASH_COMMANDS.slice(), line];
|
|
78
|
+
}
|
|
79
|
+
return [[], line];
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
// GitHub PR detection cache. `gh pr view` takes ~300ms and prompts often
|
|
83
|
+
// refresh many times per turn; cache the result for 30s. Returns either
|
|
84
|
+
// a string like "#42" or null when there's no PR / gh not installed.
|
|
85
|
+
let prCache = null;
|
|
86
|
+
const PR_CACHE_TTL_MS = 30_000;
|
|
87
|
+
const detectGitHubPR = (cwd) => {
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
if (prCache && now - prCache.cachedAt < PR_CACHE_TTL_MS)
|
|
90
|
+
return prCache.value;
|
|
91
|
+
let value = null;
|
|
92
|
+
try {
|
|
93
|
+
const out = execSync('gh pr view --json number,title 2>/dev/null', {
|
|
94
|
+
cwd,
|
|
95
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
96
|
+
timeout: 1500,
|
|
97
|
+
}).toString().trim();
|
|
98
|
+
if (out) {
|
|
99
|
+
const parsed = JSON.parse(out);
|
|
100
|
+
if (typeof parsed.number === 'number')
|
|
101
|
+
value = `#${parsed.number}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// gh missing, not a PR branch, or not a github repo — fine.
|
|
106
|
+
}
|
|
107
|
+
prCache = { value, cachedAt: now };
|
|
108
|
+
return value;
|
|
109
|
+
};
|
|
110
|
+
// Reflect the current access mode and any configured statusline segments
|
|
111
|
+
// in the prompt. Configurable via /statusline; default just shows the mode.
|
|
112
|
+
const renderStatusline = () => {
|
|
113
|
+
const prefs = readPreferences(agent.workspaceRoot);
|
|
114
|
+
const segments = prefs.statusline.split(',').map((s) => s.trim()).filter(Boolean);
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const seg of segments) {
|
|
117
|
+
if (seg === 'mode')
|
|
118
|
+
out.push(agent.getAccessMode());
|
|
119
|
+
else if (seg === 'model')
|
|
120
|
+
out.push(agent.getModel());
|
|
121
|
+
else if (seg === 'tokens') {
|
|
122
|
+
const u = agent.lastTurnUsage;
|
|
123
|
+
if (u.calls > 0)
|
|
124
|
+
out.push(`${u.promptTokens}↑${u.completionTokens}↓`);
|
|
125
|
+
}
|
|
126
|
+
else if (seg === 'session') {
|
|
127
|
+
const k = agent.sessionKey;
|
|
128
|
+
out.push(k.length > 22 ? `${k.slice(0, 22)}…` : k);
|
|
129
|
+
}
|
|
130
|
+
else if (seg === 'branch' || seg === 'dirty') {
|
|
131
|
+
try {
|
|
132
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: agent.workspaceRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
133
|
+
const dirty = execSync('git status --porcelain', { cwd: agent.workspaceRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() !== '';
|
|
134
|
+
if (seg === 'branch')
|
|
135
|
+
out.push(branch);
|
|
136
|
+
else if (seg === 'dirty' && dirty)
|
|
137
|
+
out.push('*');
|
|
138
|
+
}
|
|
139
|
+
catch { /* not a git repo */ }
|
|
140
|
+
}
|
|
141
|
+
else if (seg === 'pr') {
|
|
142
|
+
// Detect open GitHub PR for the current branch — 30s cache so the
|
|
143
|
+
// prompt refresh is cheap (re-shells out only on a stale window).
|
|
144
|
+
const pr = detectGitHubPR(agent.workspaceRoot);
|
|
145
|
+
if (pr)
|
|
146
|
+
out.push(pr);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out.filter(Boolean).join(' · ');
|
|
150
|
+
};
|
|
151
|
+
const refreshPromptForMode = () => {
|
|
152
|
+
const mode = agent.getAccessMode();
|
|
153
|
+
const accent = mode === 'shell' ? chalk.red : mode === 'write' ? chalk.hex('#CC9166') : chalk.green;
|
|
154
|
+
const line = renderStatusline();
|
|
155
|
+
rl.setPrompt(accent(`brainrouter[${line}]> `));
|
|
156
|
+
// The terminal title shares the same trigger conditions as the prompt:
|
|
157
|
+
// any time the prompt redraws (mode change, post-turn, post-spawn), the
|
|
158
|
+
// awaiting-input count or active model may have shifted. Cheap call.
|
|
159
|
+
refreshTerminalTitle();
|
|
160
|
+
};
|
|
161
|
+
// When a /goal is active, after each turn we schedule the NEXT turn
|
|
162
|
+
// automatically. The flag tracks whether such a continuation is pending so
|
|
163
|
+
// user input (next line typed) cancels it before it fires. Declared early
|
|
164
|
+
// so refreshTerminalTitle() (below) can read it for the awaiting-count.
|
|
165
|
+
let pendingContinuation = false;
|
|
166
|
+
// Returns the number of child agents currently in `pending` or `running`
|
|
167
|
+
// status — used by the tab title to surface "needs attention" counts.
|
|
168
|
+
const getRunningChildCount = () => {
|
|
169
|
+
try {
|
|
170
|
+
const sessions = listSessions(agent.workspaceRoot);
|
|
171
|
+
return sessions.filter((s) => s.status === 'pending' || s.status === 'running').length;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
// Dynamic terminal tab title. Refreshed at startup AND whenever the agent
|
|
178
|
+
// count / awaiting state changes (after each turn, post-spawn). Prefixes
|
|
179
|
+
// a "(N) " count when there's work requiring attention — pending
|
|
180
|
+
// continuation OR a running child — so background tabs surface attention
|
|
181
|
+
// without focus.
|
|
182
|
+
const refreshTerminalTitle = () => {
|
|
183
|
+
try {
|
|
184
|
+
const prefs = readPreferences(agent.workspaceRoot);
|
|
185
|
+
const cfg = prefs.terminalTitle ?? 'model,session';
|
|
186
|
+
if (cfg.toLowerCase() === 'off')
|
|
187
|
+
return;
|
|
188
|
+
const segs = cfg.split(',').map((s) => s.trim()).filter(Boolean);
|
|
189
|
+
const parts = [];
|
|
190
|
+
for (const seg of segs) {
|
|
191
|
+
if (seg === 'model')
|
|
192
|
+
parts.push(agent.getModel());
|
|
193
|
+
else if (seg === 'session')
|
|
194
|
+
parts.push(agent.sessionKey.slice(0, 24));
|
|
195
|
+
else if (seg === 'mode')
|
|
196
|
+
parts.push(agent.getAccessMode());
|
|
197
|
+
else if (seg === 'branch') {
|
|
198
|
+
try {
|
|
199
|
+
parts.push(execSync('git rev-parse --abbrev-ref HEAD', {
|
|
200
|
+
cwd: agent.workspaceRoot,
|
|
201
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
202
|
+
}).toString().trim());
|
|
203
|
+
}
|
|
204
|
+
catch { /* not a git repo */ }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (parts.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
// Awaiting-input prefix: pendingContinuation OR any running children.
|
|
210
|
+
const awaitingCount = (pendingContinuation ? 1 : 0) + getRunningChildCount();
|
|
211
|
+
const prefix = awaitingCount > 0 ? `(${awaitingCount}) ` : '';
|
|
212
|
+
process.stdout.write(`\x1b]0;${prefix}brainrouter · ${parts.join(' · ')}\x07`);
|
|
213
|
+
}
|
|
214
|
+
catch { /* terminal doesn't support OSC titles */ }
|
|
215
|
+
};
|
|
216
|
+
refreshPromptForMode();
|
|
217
|
+
refreshTerminalTitle();
|
|
218
|
+
// Vim mode: readline supports editorMode 'vi' via setRawMode + tty.
|
|
219
|
+
// We honor the persisted preference at startup so users don't have to
|
|
220
|
+
// re-toggle it each session.
|
|
221
|
+
const initialPrefs = readPreferences(agent.workspaceRoot);
|
|
222
|
+
if (initialPrefs.editorMode === 'vi') {
|
|
223
|
+
process.stdout.write(chalk.gray('Vim mode enabled (composer uses vi keybindings). Toggle with /vim.\n'));
|
|
224
|
+
// Node's readline doesn't natively expose vi mode; we approximate by
|
|
225
|
+
// emitting a hint and trusting the user's terminal/inputrc. A future
|
|
226
|
+
// pass can swap in a custom keypress handler for full Vim semantics.
|
|
227
|
+
}
|
|
228
|
+
// Shift+Tab cycles the access mode.
|
|
229
|
+
// Order: read → write → shell → read …
|
|
230
|
+
if (process.stdin.isTTY) {
|
|
231
|
+
try {
|
|
232
|
+
process.stdin.setRawMode?.(false);
|
|
233
|
+
}
|
|
234
|
+
catch { /* noop */ }
|
|
235
|
+
}
|
|
236
|
+
process.stdin.on('keypress', (_str, key) => {
|
|
237
|
+
if (key && key.name === 'tab' && key.shift) {
|
|
238
|
+
const cycle = ['read', 'write', 'shell'];
|
|
239
|
+
const current = agent.getAccessMode();
|
|
240
|
+
const next = cycle[(cycle.indexOf(current) + 1) % cycle.length];
|
|
241
|
+
agent.setAccessMode(next);
|
|
242
|
+
refreshPromptForMode();
|
|
243
|
+
process.stdout.write(`\n${chalk.gray(`Access mode → ${next}`)}\n`);
|
|
244
|
+
rl.prompt();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
// Publish the rl interface to cliPrompt.ts so out-of-scope helpers
|
|
248
|
+
// (askYesNo, safePrintAbovePrompt) can talk to the same stdin/stdout
|
|
249
|
+
// pair the REPL owns. Cleared on close.
|
|
250
|
+
setActiveReadline(rl);
|
|
251
|
+
rl.on('close', () => { setActiveReadline(undefined); });
|
|
252
|
+
rl.prompt();
|
|
253
|
+
let isProcessing = false;
|
|
254
|
+
// (pendingContinuation declared earlier alongside the title refresh helpers.)
|
|
255
|
+
/**
|
|
256
|
+
* Prompt the agent receives for iterations 2..N of an active goal —
|
|
257
|
+
* fired automatically by the post-turn loop after each completed turn.
|
|
258
|
+
* Orients the model around the active objective, forces an evidence
|
|
259
|
+
* audit, and refuses prose-only "I will continue" answers.
|
|
260
|
+
*
|
|
261
|
+
* Distinct from `buildGoalKickoffPrompt` (in `commands/_helpers.ts`),
|
|
262
|
+
* which is the FIRST-turn prompt fired by `/goal <text>` and `/goal resume`.
|
|
263
|
+
*/
|
|
264
|
+
const buildGoalContinuationPrompt = (goal, lastPrompt, lastAnswer) => {
|
|
265
|
+
const iter = goal.budget.iterationsUsed + 1;
|
|
266
|
+
const remaining = Math.max(0, goal.budget.maxIterations - iter);
|
|
267
|
+
return [
|
|
268
|
+
`[GOAL CONTINUATION — iteration ${iter}/${goal.budget.maxIterations}, ${remaining} remaining]`,
|
|
269
|
+
'',
|
|
270
|
+
`Your active goal is: ${goal.text}`,
|
|
271
|
+
'',
|
|
272
|
+
`Last user message: ${lastPrompt || '(none)'}`,
|
|
273
|
+
`Your previous response (truncated): ${lastAnswer.slice(0, 600)}${lastAnswer.length > 600 ? '…' : ''}`,
|
|
274
|
+
'',
|
|
275
|
+
'## What to do this turn',
|
|
276
|
+
'1. **Audit the evidence in this thread** against the goal\'s outcome. Look at files you wrote, tests you ran, tools that returned ok=true.',
|
|
277
|
+
'2. **Decide one of three:**',
|
|
278
|
+
' - If the outcome is met with concrete evidence (file paths, test names, command outputs), **write the user-visible answer / analysis / summary as prose AND THEN call `goal_complete` with a short 1–2 sentence proof — in the SAME response.** The proof is audit metadata; the prose is what the user reads. Skipping the prose means the user sees a placeholder.',
|
|
279
|
+
' - If no defensible path forward remains without user input or missing materials, **write the user-visible explanation as prose AND THEN call `goal_blocked` with a reason + needed input.**',
|
|
280
|
+
' - Otherwise (mid-goal), take the **next concrete tool action** (read a file, write code, spawn a worker child, run a verifier). Do NOT respond with prose like "I will now do X" — that\'s a no-op and the CLI will stop the continuation. Anti-spin applies to mid-goal turns; the final goal-completing turn requires prose.',
|
|
281
|
+
'3. Use update_plan to track progress if you haven\'t already.',
|
|
282
|
+
'',
|
|
283
|
+
'Reminder: budget is finite. Pick the highest-leverage action that moves the goal forward.',
|
|
284
|
+
].join('\n');
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* Print a line of output while the readline prompt is showing without
|
|
288
|
+
* clobbering whatever the user is mid-typing. Used by child-agent callbacks
|
|
289
|
+
* that fire AFTER the parent's runTurn returned — the agent's tool events
|
|
290
|
+
* keep streaming for a while because children run detached, and naive
|
|
291
|
+
* console.log + spinner.start() would steal the input row.
|
|
292
|
+
*/
|
|
293
|
+
const safePrintAbovePrompt = (msg) => {
|
|
294
|
+
if (!process.stdout.isTTY) {
|
|
295
|
+
console.log(msg);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// \r → column 0, \x1b[2K → clear the whole line, including any prompt + typed text.
|
|
299
|
+
process.stdout.write('\r\x1b[2K');
|
|
300
|
+
console.log(msg);
|
|
301
|
+
// Redraw the prompt and re-render the in-progress input buffer.
|
|
302
|
+
try {
|
|
303
|
+
rl._refreshLine?.();
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
rl.prompt(true);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
/** Run a turn programmatically (used by `/continue` and the line handler). */
|
|
310
|
+
const runAgentTurn = async (rawInput) => {
|
|
311
|
+
if (isProcessing) {
|
|
312
|
+
console.log(chalk.yellow('\nA previous turn is still running.\n'));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
isProcessing = true;
|
|
316
|
+
rl.pause();
|
|
317
|
+
const { expanded, mentions } = expandMentions(rawInput, agent.workspaceRoot);
|
|
318
|
+
if (mentions.length > 0) {
|
|
319
|
+
console.log(chalk.gray(`📎 Attached ${mentions.length} file${mentions.length === 1 ? '' : 's'}: ${mentions.map((m) => m.token).join(', ')}`));
|
|
320
|
+
}
|
|
321
|
+
const startedAt = Date.now();
|
|
322
|
+
const spinner = ora(chalk.gray('Agent starting...')).start();
|
|
323
|
+
// Once the parent's runTurn returns, child agents may still emit tool
|
|
324
|
+
// events asynchronously. After this flag flips, we MUST NOT touch the
|
|
325
|
+
// spinner (which is already .succeeded) — restarting it would steal the
|
|
326
|
+
// readline row and the user would feel like they can't type.
|
|
327
|
+
let parentDone = false;
|
|
328
|
+
const tickStatus = (status) => {
|
|
329
|
+
if (parentDone)
|
|
330
|
+
return;
|
|
331
|
+
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
332
|
+
const u = agent.lastTurnUsage;
|
|
333
|
+
const tokens = u.calls > 0 ? ` ${u.promptTokens.toLocaleString()}↑ ${u.completionTokens.toLocaleString()}↓` : '';
|
|
334
|
+
spinner.text = chalk.gray(`${status} ${elapsed}s${tokens}`);
|
|
335
|
+
};
|
|
336
|
+
try {
|
|
337
|
+
const answer = await agent.runTurn(expanded, {
|
|
338
|
+
onStatusUpdate: tickStatus,
|
|
339
|
+
onToolStart: (name, args) => {
|
|
340
|
+
// Render spawn_agent / spawn_agents specially — a one-liner
|
|
341
|
+
// ("Ran agent <role> — <one-line task>") so a fan-out of 5
|
|
342
|
+
// children produces 5 clean lines instead of 5 JSON dumps. The
|
|
343
|
+
// raw JSON is still in the transcript for debugging.
|
|
344
|
+
let line;
|
|
345
|
+
if (name === 'spawn_agent') {
|
|
346
|
+
const role = chalk.magenta(String(args?.role ?? 'agent'));
|
|
347
|
+
const label = args?.label ? chalk.gray(` [${args.label}]`) : '';
|
|
348
|
+
const task = String(args?.prompt ?? '').replace(/\s+/g, ' ').trim();
|
|
349
|
+
const preview = chalk.gray(task.length > 100 ? task.slice(0, 99) + '…' : task);
|
|
350
|
+
line = chalk.gray('🤖 Spawning agent: ') + role + label + chalk.gray(' — ') + preview;
|
|
351
|
+
}
|
|
352
|
+
else if (name === 'spawn_agents') {
|
|
353
|
+
const agents = Array.isArray(args?.agents) ? args.agents : [];
|
|
354
|
+
const summary = agents
|
|
355
|
+
.map((a) => chalk.magenta(String(a?.role ?? 'agent')))
|
|
356
|
+
.join(chalk.gray(', '));
|
|
357
|
+
line = chalk.gray(`🤖 Spawning ${agents.length} agent${agents.length === 1 ? '' : 's'} in parallel: `) + summary;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
line = chalk.gray('🛞 Calling tool: ') + chalk.cyan(name) + chalk.gray(`(${JSON.stringify(args).slice(0, 240)})`);
|
|
361
|
+
}
|
|
362
|
+
if (parentDone) {
|
|
363
|
+
safePrintAbovePrompt(line);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
spinner.stop();
|
|
367
|
+
console.log(line);
|
|
368
|
+
},
|
|
369
|
+
onToolEnd: (name, result) => {
|
|
370
|
+
const line = result.success
|
|
371
|
+
? chalk.green('✓ Tool ') + chalk.cyan(name) + chalk.green(' completed: ') + chalk.gray(result.summary)
|
|
372
|
+
: chalk.red('❌ Tool ') + chalk.cyan(name) + chalk.red(' failed: ') + chalk.yellow(result.summary);
|
|
373
|
+
// Inspection-tool preview: indented under the summary so the user
|
|
374
|
+
// sees the actual result (directory listing, grep matches, glob
|
|
375
|
+
// paths) even when the LLM later replies with only a stub like
|
|
376
|
+
// "I have listed the directory." Capped to a handful of lines in
|
|
377
|
+
// getToolPreview itself.
|
|
378
|
+
const previewBlock = result.preview
|
|
379
|
+
? '\n' + result.preview.split('\n').map((l) => chalk.gray(' ' + l)).join('\n')
|
|
380
|
+
: '';
|
|
381
|
+
const composed = line + previewBlock;
|
|
382
|
+
if (parentDone) {
|
|
383
|
+
safePrintAbovePrompt(composed);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
console.log(composed);
|
|
387
|
+
tickStatus('Thinking');
|
|
388
|
+
spinner.start();
|
|
389
|
+
},
|
|
390
|
+
onPlanUpdate: (items, explanation) => {
|
|
391
|
+
if (parentDone) {
|
|
392
|
+
safePrintAbovePrompt(chalk.gray(`📋 Plan updated (${items.length} item${items.length === 1 ? '' : 's'})`));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
spinner.stop();
|
|
396
|
+
console.log(chalk.gray('📋 Plan updated:'));
|
|
397
|
+
if (explanation)
|
|
398
|
+
console.log(chalk.gray(` ${explanation}`));
|
|
399
|
+
for (const item of items) {
|
|
400
|
+
const mark = item.status === 'completed' ? chalk.green('✓')
|
|
401
|
+
: item.status === 'in_progress' ? chalk.yellow('⏳')
|
|
402
|
+
: chalk.gray('☐');
|
|
403
|
+
const text = item.status === 'completed' ? chalk.gray(item.step) : item.step;
|
|
404
|
+
console.log(` ${mark} ${text}`);
|
|
405
|
+
}
|
|
406
|
+
tickStatus('Thinking');
|
|
407
|
+
spinner.start();
|
|
408
|
+
},
|
|
409
|
+
onChildComplete: (event) => {
|
|
410
|
+
const head = event.status === 'completed'
|
|
411
|
+
? chalk.green(`🏁 Agent ${event.childId} (${event.role}) completed`)
|
|
412
|
+
: chalk.red(`💥 Agent ${event.childId} (${event.role}) failed`);
|
|
413
|
+
const tail = event.status === 'completed' && event.preview
|
|
414
|
+
? chalk.gray(` — ${event.preview}`)
|
|
415
|
+
: event.error ? chalk.yellow(` — ${event.error}`) : '';
|
|
416
|
+
const line = head + tail;
|
|
417
|
+
if (parentDone) {
|
|
418
|
+
safePrintAbovePrompt(line);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
spinner.stop();
|
|
422
|
+
console.log(line);
|
|
423
|
+
tickStatus('Thinking');
|
|
424
|
+
spinner.start();
|
|
425
|
+
},
|
|
426
|
+
onMemoryEvent: (event) => {
|
|
427
|
+
let line;
|
|
428
|
+
if (event.kind === 'briefing') {
|
|
429
|
+
const src = event.sources.length > 0 ? event.sources.join(', ') : '(none)';
|
|
430
|
+
line = chalk.gray(`🧠 Briefing: ${event.recordCount} record${event.recordCount === 1 ? '' : 's'} from ${src}`);
|
|
431
|
+
}
|
|
432
|
+
else if (event.kind === 'capture') {
|
|
433
|
+
// Truthful capture line: show sensory rows actually written, and
|
|
434
|
+
// — critically — flag when extraction silently failed. The old
|
|
435
|
+
// line said "Captured turn → memory" even when 0 cognitive
|
|
436
|
+
// records came out the other end, which made the user think
|
|
437
|
+
// their conversation was searchable when nothing of the sort
|
|
438
|
+
// was happening.
|
|
439
|
+
const sensory = event.sensoryRecorded ?? event.messageCount;
|
|
440
|
+
const extracted = event.extractedCount;
|
|
441
|
+
const triggered = event.extractionTriggered;
|
|
442
|
+
const sk = event.sessionKey.slice(0, 12);
|
|
443
|
+
if (event.extractionWarning) {
|
|
444
|
+
line = chalk.yellow(`💾 Captured ${sensory} sensory msg(s) in ${sk}… — ⚠️ ${event.extractionWarning}`);
|
|
445
|
+
}
|
|
446
|
+
else if (triggered && typeof extracted === 'number') {
|
|
447
|
+
if (extracted > 0) {
|
|
448
|
+
line = chalk.gray(`💾 Captured ${sensory} msg(s) → ${extracted} cognitive record(s) extracted (${sk}…)`);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
// LLM ran successfully but found nothing notable to promote
|
|
452
|
+
// (greeting, trivial exchange, all-duplicates). NOT an error.
|
|
453
|
+
line = chalk.gray(`💾 Captured ${sensory} msg(s) → no new memories worth promoting (${sk}…)`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else if (triggered === false) {
|
|
457
|
+
// Sensory landed; extractor below the every-N-turn threshold.
|
|
458
|
+
line = chalk.gray(`💾 Captured ${sensory} msg(s) → sensory buffer (${sk}…)`);
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
line = chalk.gray(`💾 Captured ${sensory} msg(s) → memory (${sk}…)`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
else if (event.kind === 'citation' && event.recordIds.length > 0) {
|
|
465
|
+
line = chalk.gray(`📌 Reinforced ${event.recordIds.length} record${event.recordIds.length === 1 ? '' : 's'}: ${event.recordIds.slice(0, 3).join(', ')}${event.recordIds.length > 3 ? '…' : ''}`);
|
|
466
|
+
}
|
|
467
|
+
else if (event.kind === 'contradiction') {
|
|
468
|
+
line = chalk.yellow(`⚠️ Memory contradiction: ${event.warning.slice(0, 140)}`);
|
|
469
|
+
}
|
|
470
|
+
if (!line)
|
|
471
|
+
return;
|
|
472
|
+
if (parentDone) {
|
|
473
|
+
safePrintAbovePrompt(line);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
spinner.stop();
|
|
477
|
+
console.log(line);
|
|
478
|
+
tickStatus('Thinking');
|
|
479
|
+
spinner.start();
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
483
|
+
const u = agent.lastTurnUsage;
|
|
484
|
+
const tokenSummary = u.calls > 0
|
|
485
|
+
? chalk.gray(` · ${u.promptTokens.toLocaleString()} in / ${u.completionTokens.toLocaleString()} out across ${u.calls} call${u.calls === 1 ? '' : 's'}`)
|
|
486
|
+
: '';
|
|
487
|
+
parentDone = true;
|
|
488
|
+
spinner.succeed(chalk.green(`Done!${chalk.gray(` ${elapsed}s`)}${tokenSummary}`));
|
|
489
|
+
const prefsForRender = readPreferences(agent.workspaceRoot);
|
|
490
|
+
const rendered = prefsForRender.rawScrollback ? answer : marked.parse(answer);
|
|
491
|
+
console.log('\n' + rendered + '\n');
|
|
492
|
+
const warning = agent.takeContradictionWarning();
|
|
493
|
+
if (warning) {
|
|
494
|
+
console.log(chalk.yellow(`⚠️ Memory: ${warning}`));
|
|
495
|
+
console.log(chalk.gray(` Use /memory or /briefing to investigate, /forget <id> to archive obsolete records.\n`));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
parentDone = true;
|
|
500
|
+
spinner.fail(chalk.red('Execution failed'));
|
|
501
|
+
console.error(chalk.red(`\nError: ${err.message}\n`));
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
isProcessing = false;
|
|
505
|
+
// Clear any active skill latched by /skill / /feature-dev / /spec /
|
|
506
|
+
// /review / /implement-plan so subsequent plain prompts don't keep
|
|
507
|
+
// spiking the same skill. The skill memetic potential still decays
|
|
508
|
+
// server-side on its own half-life; this just stops attribution.
|
|
509
|
+
agent.activeSkill = undefined;
|
|
510
|
+
// Auto-continuation logic. Rules:
|
|
511
|
+
// - the goal must be active (not paused / complete / blocked / usage_limited)
|
|
512
|
+
// - the turn made at least one tool call (prose-only turns are anti-spin)
|
|
513
|
+
// - we still have iteration AND token budget left
|
|
514
|
+
// - the agent didn't call goal_complete / goal_blocked this turn
|
|
515
|
+
//
|
|
516
|
+
// BEFORE checking, accumulate this turn's tokens into the goal's
|
|
517
|
+
// running tally. If that tips us over a token cap, transition to
|
|
518
|
+
// `usage_limited` instead of continuing — same effect as exhausting
|
|
519
|
+
// the iteration cap, but distinguishable in status.
|
|
520
|
+
let goalAfter = readGoal(agent.workspaceRoot, agent.sessionKey);
|
|
521
|
+
if (goalAfter && goalAfter.budget.maxTokens) {
|
|
522
|
+
const delta = (agent.lastTurnUsage?.promptTokens ?? 0) + (agent.lastTurnUsage?.completionTokens ?? 0);
|
|
523
|
+
if (delta > 0) {
|
|
524
|
+
const updated = addGoalTokens(agent.workspaceRoot, agent.sessionKey, delta);
|
|
525
|
+
if (updated)
|
|
526
|
+
goalAfter = updated;
|
|
527
|
+
}
|
|
528
|
+
if (goalAfter &&
|
|
529
|
+
goalAfter.status === 'active' &&
|
|
530
|
+
typeof goalAfter.budget.maxTokens === 'number' &&
|
|
531
|
+
(goalAfter.budget.tokensUsed ?? 0) >= goalAfter.budget.maxTokens) {
|
|
532
|
+
const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, `Token budget reached: ${(goalAfter.budget.tokensUsed ?? 0).toLocaleString()} of ${goalAfter.budget.maxTokens.toLocaleString()} used.`);
|
|
533
|
+
if (limited)
|
|
534
|
+
goalAfter = limited;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const shouldContinue = !!goalAfter &&
|
|
538
|
+
goalAfter.status === 'active' &&
|
|
539
|
+
goalHasBudgetLeft(goalAfter) &&
|
|
540
|
+
agent.lastTurnToolCalls > 0 &&
|
|
541
|
+
agent.lastGoalTransition === undefined;
|
|
542
|
+
if (goalAfter && goalAfter.status === 'complete') {
|
|
543
|
+
console.log(chalk.green(`\n🎯 Goal achieved — ${goalAfter.blockedReason ?? 'evidence on record.'}\n`));
|
|
544
|
+
}
|
|
545
|
+
else if (goalAfter && goalAfter.status === 'blocked') {
|
|
546
|
+
console.log(chalk.yellow(`\n🚧 Goal blocked: ${goalAfter.blockedReason ?? '(no reason)'}\n`));
|
|
547
|
+
console.log(chalk.gray(` Resolve the blocker, then /goal resume to continue.\n`));
|
|
548
|
+
}
|
|
549
|
+
else if (goalAfter && goalAfter.status === 'usage_limited') {
|
|
550
|
+
console.log(chalk.yellow(`\n⏸ Goal hit usage limit: ${goalAfter.blockedReason ?? 'budget exhausted'}.`));
|
|
551
|
+
console.log(chalk.gray(` Raise the cap with /goal budget <n> or /goal tokens <n>, then /goal resume.\n`));
|
|
552
|
+
}
|
|
553
|
+
else if (goalAfter && goalAfter.status === 'active' && !goalHasBudgetLeft(goalAfter)) {
|
|
554
|
+
// Iteration cap reached — transition to usage_limited so the user
|
|
555
|
+
// gets a consistent resumable state regardless of which cap tripped.
|
|
556
|
+
const reason = `Iteration budget exhausted (${goalAfter.budget.iterationsUsed}/${goalAfter.budget.maxIterations}).`;
|
|
557
|
+
const limited = usageLimitGoal(agent.workspaceRoot, agent.sessionKey, reason);
|
|
558
|
+
console.log(chalk.yellow(`\n⏸ ${reason} Extend with /goal budget <n> and /goal resume, mark /goal complete, or /goal clear.\n`));
|
|
559
|
+
if (limited)
|
|
560
|
+
goalAfter = limited;
|
|
561
|
+
}
|
|
562
|
+
else if (goalAfter && goalAfter.status === 'active' && agent.lastTurnToolCalls === 0) {
|
|
563
|
+
console.log(chalk.gray(`(goal continuation suppressed: last turn made no tool calls — anti-spin)\n`));
|
|
564
|
+
}
|
|
565
|
+
rl.resume();
|
|
566
|
+
refreshPromptForMode(); // pick up token-meter / branch updates
|
|
567
|
+
rl.prompt();
|
|
568
|
+
if (shouldContinue && goalAfter) {
|
|
569
|
+
pendingContinuation = true;
|
|
570
|
+
const next = goalAfter.budget.iterationsUsed + 1;
|
|
571
|
+
// Pre-tick steering: if the NEXT turn would be the final one inside
|
|
572
|
+
// the budget, inject a wrap-up directive so the model lands soft
|
|
573
|
+
// instead of being cut off mid-thought.
|
|
574
|
+
//
|
|
575
|
+
// CRITICAL: also drop any stale steering when the next turn is NOT
|
|
576
|
+
// final. Without this, a previously-injected "wrap up gracefully"
|
|
577
|
+
// message would persist after the user extended the budget via
|
|
578
|
+
// /goal budget or /goal tokens, telling the model "this is your
|
|
579
|
+
// last turn" for every subsequent turn. The removal is idempotent
|
|
580
|
+
// — if no steering was set, this is a no-op.
|
|
581
|
+
const finalBudgetTurn = goalIsOnFinalBudgetTurn(goalAfter);
|
|
582
|
+
if (finalBudgetTurn) {
|
|
583
|
+
agent.replaceTaggedSystemMessage('goal-budget-steering', buildBudgetSteeringMessage(goalAfter));
|
|
584
|
+
console.log(chalk.gray(`(final budget turn — wrap-up steering injected)`));
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
agent.removeTaggedSystemMessage('goal-budget-steering');
|
|
588
|
+
}
|
|
589
|
+
console.log(chalk.gray(`(goal continuation queued — iteration ${next}/${goalAfter.budget.maxIterations}; type anything to cancel)`));
|
|
590
|
+
const followUp = buildGoalContinuationPrompt(goalAfter, agent.lastUserPrompt, agent.lastAnswer);
|
|
591
|
+
setImmediate(() => {
|
|
592
|
+
if (!pendingContinuation || isProcessing)
|
|
593
|
+
return; // user cancelled or busy
|
|
594
|
+
pendingContinuation = false;
|
|
595
|
+
tickGoalIteration(agent.workspaceRoot, agent.sessionKey);
|
|
596
|
+
void runAgentTurn(followUp);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
rl.on('line', async (line) => {
|
|
602
|
+
// User typed: any pending goal continuation is cancelled.
|
|
603
|
+
if (pendingContinuation) {
|
|
604
|
+
pendingContinuation = false;
|
|
605
|
+
console.log(chalk.gray('(goal continuation cancelled by user input)'));
|
|
606
|
+
}
|
|
607
|
+
const input = line.trim();
|
|
608
|
+
if (!input) {
|
|
609
|
+
rl.prompt();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (input.startsWith('/')) {
|
|
613
|
+
// Split on any whitespace, not a literal space. Without this, a slash
|
|
614
|
+
// command followed by a tab (autocomplete completion that wasn't
|
|
615
|
+
// consumed) or a trailing newline ends up as command="/help\t" which
|
|
616
|
+
// would fall through to "Unknown slash command".
|
|
617
|
+
const parts = input.trim().split(/\s+/);
|
|
618
|
+
const command = parts[0].toLowerCase();
|
|
619
|
+
const args = parts.slice(1);
|
|
620
|
+
// Wrap the slash-command dispatcher so a thrown error or rejected
|
|
621
|
+
// promise can never leave the REPL without a prompt. Without this, a
|
|
622
|
+
// bug inside any /command (file write, MCP call, etc.) bricks the
|
|
623
|
+
// session because the user never sees the prompt come back.
|
|
624
|
+
try {
|
|
625
|
+
await handleSlashCommand(command, args, agent, mcpClient, config, rl, {
|
|
626
|
+
refreshPromptForMode,
|
|
627
|
+
isProcessing: () => isProcessing,
|
|
628
|
+
runAgentTurn: (prompt) => { void runAgentTurn(prompt); },
|
|
629
|
+
runAgentTurnAsync: (prompt) => runAgentTurn(prompt),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
console.error(chalk.red(`\nSlash command "${command}" failed: ${err?.message ?? err}\n`));
|
|
634
|
+
}
|
|
635
|
+
finally {
|
|
636
|
+
// The /continue and /side/btw cases own their own prompt cycle via
|
|
637
|
+
// runAgentTurn — only re-prompt if no turn is in flight.
|
|
638
|
+
if (!isProcessing)
|
|
639
|
+
rl.prompt();
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (isProcessing) {
|
|
644
|
+
console.log(chalk.yellow('\nA previous turn is still running. Wait for the prompt before sending another message.\n'));
|
|
645
|
+
rl.prompt();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
await runAgentTurn(input);
|
|
649
|
+
});
|
|
650
|
+
rl.on('SIGINT', async () => {
|
|
651
|
+
console.log(chalk.yellow('\nExiting session...'));
|
|
652
|
+
rl.close();
|
|
653
|
+
});
|
|
654
|
+
rl.on('close', async () => {
|
|
655
|
+
await mcpClient.close();
|
|
656
|
+
console.log(chalk.bold.hex('#CC9166')('Goodbye!\n'));
|
|
657
|
+
process.exit(0);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
const HELP_CATEGORIES = [
|
|
661
|
+
{
|
|
662
|
+
key: 'session',
|
|
663
|
+
title: 'Session & State',
|
|
664
|
+
entries: [
|
|
665
|
+
{ cmd: '/status', desc: 'Connection status, LLM config, DB stats' },
|
|
666
|
+
{ cmd: '/workspace', desc: 'Active workspace and session identity' },
|
|
667
|
+
{ cmd: '/doctor', desc: 'Config, connection, memory extraction health' },
|
|
668
|
+
{ cmd: '/config', desc: 'View active configuration profile' },
|
|
669
|
+
{ cmd: '/clear', desc: 'Clear chat history for the active session' },
|
|
670
|
+
{ cmd: '/compact', desc: 'LLM-driven compaction of the active session' },
|
|
671
|
+
{ cmd: '/new [label]', desc: 'Start a new chat with a fresh session key' },
|
|
672
|
+
{ cmd: '/fork [label]', desc: 'Fork this chat into a new session, keep prior context' },
|
|
673
|
+
{ cmd: '/rename <label>', desc: 'Rename the current session' },
|
|
674
|
+
{ cmd: '/resume <id>', desc: 'Resume a previous session by sessionKey' },
|
|
675
|
+
{ cmd: '/sessions', desc: 'List persisted sessions for this workspace' },
|
|
676
|
+
{ cmd: '/side <q> /btw <q>', desc: 'Ephemeral side conversation in a forked session' },
|
|
677
|
+
{ cmd: '/init', desc: 'Create AGENT.md in the workspace' },
|
|
678
|
+
{ cmd: '/exit /quit', desc: 'Close MCP connection and exit' },
|
|
679
|
+
],
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
key: 'memory',
|
|
683
|
+
title: 'Memory & Recall',
|
|
684
|
+
entries: [
|
|
685
|
+
{ cmd: '/memory <query>', desc: 'Search long-term memory (memory_search)' },
|
|
686
|
+
{ cmd: '/recall <query>', desc: 'Explicit cognitive recall (no LLM turn)' },
|
|
687
|
+
{ cmd: '/briefing', desc: 'Show what was recalled before the most recent turn' },
|
|
688
|
+
{ cmd: '/scenes', desc: 'List active focus scenes' },
|
|
689
|
+
{ cmd: '/working', desc: 'Show the working-memory canvas' },
|
|
690
|
+
{ cmd: '/working reset confirm', desc: 'Clear the canvas' },
|
|
691
|
+
{ cmd: '/forget <recordId>', desc: 'Archive a memory record by ID' },
|
|
692
|
+
{ cmd: '/memories', desc: 'Manage memory pipeline + consolidate to filesystem' },
|
|
693
|
+
{ cmd: '/handover', desc: 'Generate continuation note for next session' },
|
|
694
|
+
{ cmd: '/explain <query>', desc: 'Why recall returned what it did' },
|
|
695
|
+
{ cmd: '/failed [area]', desc: 'Past failed attempts for a problem area' },
|
|
696
|
+
{ cmd: '/verify <id> [status]', desc: 'Re-verify a memory record' },
|
|
697
|
+
{ cmd: '/audit', desc: 'Recent memory audit log' },
|
|
698
|
+
{ cmd: '/export [path]', desc: 'Dump memory + evidence + ops to JSON' },
|
|
699
|
+
{ cmd: '/import <path>', desc: 'Import a BrainRouter memory envelope' },
|
|
700
|
+
{ cmd: '/persona <name>', desc: 'Fetch a persona definition' },
|
|
701
|
+
{ cmd: '/skill-hints <skill> <hints>', desc: 'Register extraction hints' },
|
|
702
|
+
{ cmd: '/diagnostics', desc: 'Scrubbed runtime + DB stats bundle' },
|
|
703
|
+
],
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
key: 'workflow',
|
|
707
|
+
title: 'Workflows & Skills',
|
|
708
|
+
entries: [
|
|
709
|
+
{ cmd: '/spec <title>', desc: 'Produce spec.md (spec-driven-skill)' },
|
|
710
|
+
{ cmd: '/feature-dev <feat>', desc: 'Multi-agent feature dev with spec + tasks' },
|
|
711
|
+
{ cmd: '/review [scope]', desc: 'Multi-agent code review → review.md' },
|
|
712
|
+
{ cmd: '/implement-plan', desc: 'Execute next plan item; append walkthrough' },
|
|
713
|
+
{ cmd: '/approve [slug]', desc: 'Approve workflow + kick off implementation' },
|
|
714
|
+
{ cmd: '/workflows', desc: 'List durable workflow folders' },
|
|
715
|
+
{ cmd: '/skill <name> [input]', desc: 'Run any catalogued skill' },
|
|
716
|
+
{ cmd: '/skills', desc: 'List installed BrainRouter skills' },
|
|
717
|
+
{ cmd: '/plan', desc: 'Show the durable CLI task plan' },
|
|
718
|
+
{ cmd: '/tools', desc: 'List local + MCP tools available to the agent' },
|
|
719
|
+
{ cmd: '/goal [text|clear|complete|pause|resume|budget <n>]', desc: 'Sticky goal' },
|
|
720
|
+
{ cmd: '/continue', desc: 'Resume after a loop-limit abort' },
|
|
721
|
+
{ cmd: '/loop <interval> <prompt> /loop stop', desc: 'Repeat a prompt on cadence' },
|
|
722
|
+
{ cmd: '/commit', desc: 'Generate message, stage, and git commit' },
|
|
723
|
+
{ cmd: '/diff', desc: 'Show git changes (stream-paginated)' },
|
|
724
|
+
],
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
key: 'orchestration',
|
|
728
|
+
title: 'Multi-Agent Orchestration',
|
|
729
|
+
entries: [
|
|
730
|
+
{ cmd: '/roles', desc: 'List available agent roles' },
|
|
731
|
+
{ cmd: '/agents [--json]', desc: 'List child agent sessions' },
|
|
732
|
+
{ cmd: '/agent <id> [--full]', desc: 'Detail + recent transcript of a child' },
|
|
733
|
+
{ cmd: '/spawn <role> <prompt>', desc: 'Spawn a child agent' },
|
|
734
|
+
{ cmd: '/wait <id> [ms]', desc: 'Wait for a child to finish' },
|
|
735
|
+
{ cmd: '/kill <agent-id>', desc: 'Stop a running child' },
|
|
736
|
+
{ cmd: '/auto-review [on|off]', desc: 'Auto-run reviewer after every worker' },
|
|
737
|
+
{ cmd: '/ps', desc: 'List background tasks (loop + running children)' },
|
|
738
|
+
{ cmd: '/stop', desc: 'Stop the running loop, mark stale children' },
|
|
739
|
+
],
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
key: 'guard',
|
|
743
|
+
title: 'Guardrails & Permissions',
|
|
744
|
+
entries: [
|
|
745
|
+
{ cmd: '/permissions [read|write|shell]', desc: 'View or set agent access mode' },
|
|
746
|
+
{ cmd: '/yolo [on|off]', desc: 'Auto-approve run_command' },
|
|
747
|
+
{ cmd: '/sandbox [status|add-read|add-write|remove|clear]', desc: 'Sandbox grants' },
|
|
748
|
+
{ cmd: '/hooks [list|add|remove|enable|disable]', desc: 'Lifecycle shell hooks' },
|
|
749
|
+
{ cmd: '/hookify [list|create|enable|disable|remove]', desc: 'Markdown rule guards' },
|
|
750
|
+
{ cmd: '/logout', desc: 'Clear API keys from the active profile' },
|
|
751
|
+
],
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
key: 'obs',
|
|
755
|
+
title: 'Observability',
|
|
756
|
+
entries: [
|
|
757
|
+
{ cmd: '/tokens', desc: 'Session token usage + memory-savings estimate' },
|
|
758
|
+
{ cmd: '/watch', desc: 'Tail trace log (BRAINROUTER_TRACE_LOG required)' },
|
|
759
|
+
{ cmd: '/trace save <desc> /trace search <q>', desc: 'Debug-trace store' },
|
|
760
|
+
{ cmd: '/transcript [main|sessionKey]', desc: 'Recent persisted transcript' },
|
|
761
|
+
{ cmd: '/rollout', desc: 'Print the transcript file path' },
|
|
762
|
+
{ cmd: '/debug-config', desc: 'Show config layers, env, preferences' },
|
|
763
|
+
],
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
key: 'ui',
|
|
767
|
+
title: 'UI & Ergonomics',
|
|
768
|
+
entries: [
|
|
769
|
+
{ cmd: '/theme [auto|light|dark|mono]', desc: 'Markdown output theme' },
|
|
770
|
+
{ cmd: '/title <segments>', desc: 'Terminal title (model,session,branch,mode)' },
|
|
771
|
+
{ cmd: '/statusline <segments>', desc: 'Prompt (mode,branch,dirty,model,tokens,session,pr)' },
|
|
772
|
+
{ cmd: '/personality <style>', desc: 'concise | standard | detailed | pair-programmer' },
|
|
773
|
+
{ cmd: '/raw [on|off]', desc: 'Toggle raw scrollback' },
|
|
774
|
+
{ cmd: '/vim', desc: 'Toggle vi-mode for the composer' },
|
|
775
|
+
{ cmd: '/keymap [json]', desc: 'Show built-in bindings and set overrides' },
|
|
776
|
+
{ cmd: '/copy', desc: 'Copy last assistant response to clipboard' },
|
|
777
|
+
{ cmd: '/mention [partial]', desc: 'Suggest files for @ mentions' },
|
|
778
|
+
{ cmd: '/model <name>', desc: 'Switch the LLM model in-session' },
|
|
779
|
+
{ cmd: '/mcp', desc: 'Show the active MCP server and tool namespaces' },
|
|
780
|
+
{ cmd: '/ide', desc: 'Show detected IDE host' },
|
|
781
|
+
{ cmd: '/apps /plugins', desc: 'List workspace skills and plugin folders' },
|
|
782
|
+
{ cmd: '/feedback [message]', desc: 'Append feedback entry' },
|
|
783
|
+
{ cmd: '/experimental [on|off]', desc: 'Toggle experimental features' },
|
|
784
|
+
],
|
|
785
|
+
},
|
|
786
|
+
];
|
|
787
|
+
export function renderHelp(category) {
|
|
788
|
+
// Match by key OR by leading char of title (allowing /help m → memory).
|
|
789
|
+
const wantedCategory = category
|
|
790
|
+
? HELP_CATEGORIES.find((c) => c.key === category || c.title.toLowerCase().startsWith(category))
|
|
791
|
+
: undefined;
|
|
792
|
+
// Special case: show a single category if the user asked for one explicitly.
|
|
793
|
+
if (category && wantedCategory) {
|
|
794
|
+
printHelpCategory(wantedCategory);
|
|
795
|
+
console.log(chalk.gray('\nTry /help to see all categories. Tab autocompletes commands; @ mentions files.\n'));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (category && !wantedCategory) {
|
|
799
|
+
console.log(chalk.red(`\nUnknown help category "${category}". Available:`));
|
|
800
|
+
for (const c of HELP_CATEGORIES) {
|
|
801
|
+
console.log(` ${chalk.cyan('/help ' + c.key)} ${chalk.gray(c.title)}`);
|
|
802
|
+
}
|
|
803
|
+
console.log();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
// No category → decide between full dump and index based on terminal height.
|
|
807
|
+
const totalLines = HELP_CATEGORIES.reduce((n, c) => n + c.entries.length + 2, 0);
|
|
808
|
+
const rows = process.stdout.rows ?? 9999;
|
|
809
|
+
if (rows >= totalLines + 6) {
|
|
810
|
+
// Tall enough — show everything.
|
|
811
|
+
for (const c of HELP_CATEGORIES)
|
|
812
|
+
printHelpCategory(c);
|
|
813
|
+
console.log(chalk.gray('\nTips: @ mentions files · Tab autocompletes · Shift+Tab cycles access mode (read → write → shell).\n'));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
// Small terminal — show index + per-category command count.
|
|
817
|
+
console.log(chalk.bold('\nAvailable command categories:'));
|
|
818
|
+
for (const c of HELP_CATEGORIES) {
|
|
819
|
+
console.log(` ${chalk.cyan('/help ' + c.key.padEnd(14))} ${chalk.gray(`${c.title} (${c.entries.length} commands)`)}`);
|
|
820
|
+
}
|
|
821
|
+
console.log(chalk.gray('\nYour terminal is short — run /help <category> to drill in. Resize and re-run /help to see all at once.\n'));
|
|
822
|
+
}
|
|
823
|
+
function printHelpCategory(c) {
|
|
824
|
+
console.log(chalk.bold(`\n${c.title}:`));
|
|
825
|
+
// Find max command-column width for alignment.
|
|
826
|
+
const colWidth = Math.min(40, c.entries.reduce((w, e) => Math.max(w, e.cmd.length), 0));
|
|
827
|
+
for (const e of c.entries) {
|
|
828
|
+
console.log(` ${chalk.cyan(e.cmd.padEnd(colWidth))} ${chalk.gray(e.desc)}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
async function handleSlashCommand(command, args, agent, mcpClient, config, rl, ctx) {
|
|
832
|
+
// Category dispatch — each extracted module returns true iff it matched
|
|
833
|
+
// the command. New categories should be added here as they're extracted
|
|
834
|
+
// from the giant switch below. Long-term goal: shrink the switch to
|
|
835
|
+
// nothing so this dispatch is the only entrypoint.
|
|
836
|
+
const cmdCtx = { command, args, agent, mcpClient, config, rl, repl: ctx };
|
|
837
|
+
if (await tryHandleMemoryCommand(cmdCtx))
|
|
838
|
+
return;
|
|
839
|
+
if (await tryHandleUiCommand(cmdCtx))
|
|
840
|
+
return;
|
|
841
|
+
if (await tryHandleWorkflowCommand(cmdCtx))
|
|
842
|
+
return;
|
|
843
|
+
if (await tryHandleObsCommand(cmdCtx))
|
|
844
|
+
return;
|
|
845
|
+
if (await tryHandleOrchestrationCommand(cmdCtx))
|
|
846
|
+
return;
|
|
847
|
+
if (await tryHandleSessionCommand(cmdCtx))
|
|
848
|
+
return;
|
|
849
|
+
if (await tryHandleGuardCommand(cmdCtx))
|
|
850
|
+
return;
|
|
851
|
+
// All commands extracted to category files above. Anything that reaches
|
|
852
|
+
// here didn't match any handler.
|
|
853
|
+
console.log(chalk.red(`\nUnknown slash command: ${command}. Type /help for assistance.\n`));
|
|
854
|
+
}
|
|
855
|
+
// runOrchestrationPrompt was the second-class turn pipeline used by /spawn,
|
|
856
|
+
// /wait, /kill, /commit, /approve, /spec, /feature-dev, /review,
|
|
857
|
+
// /implement-plan, /skill. It lacked goal continuation, the isProcessing
|
|
858
|
+
// lock, /raw honoring, contradiction surfacing, and token summary — so any
|
|
859
|
+
// command that took the second-class path felt visibly weaker than a plain
|
|
860
|
+
// prompt. Removed in favor of routing every command through the closure's
|
|
861
|
+
// runAgentTurn (which has all of the above).
|
|
862
|
+
/**
|
|
863
|
+
* Tab-completion source for `@path/to/file` mentions. Given a partial workspace
|
|
864
|
+
* path, return the matching files and directories one level deep. Stays inside
|
|
865
|
+
* the workspace and ignores noise dirs to keep the completion list useful.
|
|
866
|
+
*/
|
|
867
|
+
export function completeWorkspacePath(workspaceRoot, partial) {
|
|
868
|
+
const ignore = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'coverage', '.brainrouter']);
|
|
869
|
+
// Split partial into "dir/" + "prefix" so we only enumerate one directory at a time.
|
|
870
|
+
const lastSlash = partial.lastIndexOf('/');
|
|
871
|
+
const subdir = lastSlash >= 0 ? partial.slice(0, lastSlash + 1) : '';
|
|
872
|
+
const prefix = lastSlash >= 0 ? partial.slice(lastSlash + 1) : partial;
|
|
873
|
+
let absDir;
|
|
874
|
+
try {
|
|
875
|
+
absDir = path.resolve(workspaceRoot, subdir || '.');
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return [];
|
|
879
|
+
}
|
|
880
|
+
// Don't escape the workspace.
|
|
881
|
+
if (path.relative(workspaceRoot, absDir).startsWith('..'))
|
|
882
|
+
return [];
|
|
883
|
+
let entries;
|
|
884
|
+
try {
|
|
885
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
return [];
|
|
889
|
+
}
|
|
890
|
+
return entries
|
|
891
|
+
.filter((e) => !ignore.has(e.name) && e.name.startsWith(prefix))
|
|
892
|
+
.map((e) => `${subdir}${e.name}${e.isDirectory() ? '/' : ''}`)
|
|
893
|
+
.sort();
|
|
894
|
+
}
|