@kinqs/brainrouter-cli 0.3.5 → 0.3.7
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/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- package/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +224 -3
- package/dist/agent/agent.js +561 -55
- package/dist/cli/banner.d.ts +80 -0
- package/dist/cli/banner.js +232 -0
- package/dist/cli/cliPrompt.d.ts +106 -0
- package/dist/cli/cliPrompt.js +314 -0
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/guard.js +75 -10
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +19 -0
- package/dist/cli/commands/mcp.js +286 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/orchestration.js +18 -0
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +202 -91
- package/dist/cli/commands/workflow.d.ts +20 -0
- package/dist/cli/commands/workflow.js +368 -51
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +65 -0
- package/dist/cli/ink/Picker.js +133 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +571 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +73 -0
- package/dist/cli/ink/toolFormat.js +180 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +64 -646
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/spinner.d.ts +34 -0
- package/dist/cli/spinner.js +36 -0
- package/dist/cli/statusline.d.ts +67 -0
- package/dist/cli/statusline.js +204 -0
- package/dist/cli/theme.d.ts +79 -0
- package/dist/cli/theme.js +106 -0
- package/dist/cli/whereView.d.ts +81 -0
- package/dist/cli/whereView.js +245 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +52 -0
- package/dist/config/config.js +89 -75
- package/dist/index.js +215 -206
- package/dist/memory/briefing.d.ts +11 -1
- package/dist/memory/briefing.js +69 -1
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +10 -1
- package/dist/orchestration/tools.js +48 -4
- package/dist/prompt/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +128 -108
- package/dist/runtime/dangerousCommand.d.ts +53 -0
- package/dist/runtime/dangerousCommand.js +105 -0
- package/dist/runtime/mcpClient.d.ts +38 -1
- package/dist/runtime/mcpClient.js +104 -13
- package/dist/runtime/mcpPool.d.ts +162 -0
- package/dist/runtime/mcpPool.js +423 -0
- package/dist/runtime/mcpUtils.d.ts +3 -1
- package/dist/state/goalStore.d.ts +98 -17
- package/dist/state/goalStore.js +132 -42
- package/dist/state/preferencesStore.d.ts +67 -3
- package/dist/state/preferencesStore.js +84 -1
- package/dist/state/workflowArtifacts.d.ts +63 -2
- package/dist/state/workflowArtifacts.js +120 -8
- package/dist/tests/_helpers.d.ts +31 -0
- package/dist/tests/_helpers.js +91 -0
- package/package.json +12 -5
- package/.env.example +0 -109
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readPreferences } from '../state/preferencesStore.js';
|
|
3
|
+
const identity = ((s) => s);
|
|
4
|
+
function buildDark() {
|
|
5
|
+
return {
|
|
6
|
+
mode: 'dark',
|
|
7
|
+
primary: chalk.hex('#CC9166'),
|
|
8
|
+
secondary: chalk.magenta,
|
|
9
|
+
success: chalk.green,
|
|
10
|
+
warning: chalk.yellow,
|
|
11
|
+
danger: chalk.red,
|
|
12
|
+
info: chalk.cyan,
|
|
13
|
+
muted: chalk.gray,
|
|
14
|
+
dim: chalk.hex('#666666'),
|
|
15
|
+
heading: chalk.bold.hex('#CC9166'),
|
|
16
|
+
plain: identity,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function buildLight() {
|
|
20
|
+
return {
|
|
21
|
+
mode: 'light',
|
|
22
|
+
// Saturated orange-brown — still readable on white because chalk emits
|
|
23
|
+
// a TrueColor sequence the terminal renders as-is.
|
|
24
|
+
primary: chalk.hex('#A24E1F'),
|
|
25
|
+
secondary: chalk.hex('#7B2CBF'),
|
|
26
|
+
success: chalk.hex('#0F7B3E'),
|
|
27
|
+
warning: chalk.hex('#8A6300'),
|
|
28
|
+
danger: chalk.hex('#A4161A'),
|
|
29
|
+
info: chalk.hex('#005F8C'),
|
|
30
|
+
muted: chalk.hex('#4A4A4A'),
|
|
31
|
+
dim: chalk.hex('#7A7A7A'),
|
|
32
|
+
heading: chalk.bold.hex('#A24E1F'),
|
|
33
|
+
plain: identity,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function buildMono() {
|
|
37
|
+
return {
|
|
38
|
+
mode: 'mono',
|
|
39
|
+
primary: identity,
|
|
40
|
+
secondary: identity,
|
|
41
|
+
success: identity,
|
|
42
|
+
warning: identity,
|
|
43
|
+
danger: identity,
|
|
44
|
+
info: identity,
|
|
45
|
+
muted: identity,
|
|
46
|
+
dim: identity,
|
|
47
|
+
heading: chalk.bold,
|
|
48
|
+
plain: identity,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function normalizeMode(raw) {
|
|
52
|
+
if (!raw)
|
|
53
|
+
return undefined;
|
|
54
|
+
const v = raw.trim().toLowerCase();
|
|
55
|
+
if (v === 'dark' || v === 'light' || v === 'mono')
|
|
56
|
+
return v;
|
|
57
|
+
if (v === 'auto')
|
|
58
|
+
return 'dark';
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
export function buildTheme(mode) {
|
|
62
|
+
if (mode === 'light')
|
|
63
|
+
return buildLight();
|
|
64
|
+
if (mode === 'mono')
|
|
65
|
+
return buildMono();
|
|
66
|
+
return buildDark();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the active theme using env-var > preference > default precedence.
|
|
70
|
+
* Pass `workspaceRoot` to honor a per-workspace `/theme` setting; omit to
|
|
71
|
+
* resolve from env only (useful in test helpers where preferences storage
|
|
72
|
+
* might not be initialized).
|
|
73
|
+
*/
|
|
74
|
+
export function resolveTheme(workspaceRoot) {
|
|
75
|
+
const envMode = normalizeMode(process.env.BRAINROUTER_THEME);
|
|
76
|
+
if (envMode)
|
|
77
|
+
return buildTheme(envMode);
|
|
78
|
+
if (workspaceRoot) {
|
|
79
|
+
try {
|
|
80
|
+
const prefs = readPreferences(workspaceRoot);
|
|
81
|
+
const prefMode = normalizeMode(prefs.theme);
|
|
82
|
+
if (prefMode)
|
|
83
|
+
return buildTheme(prefMode);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// preferences file unreadable — fall through to default.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return buildTheme('dark');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Box-drawing characters for the startup banner and /where view. Centralized
|
|
93
|
+
* so a future ASCII-only fallback (for terminals that mangle UTF-8 box chars)
|
|
94
|
+
* is one switch instead of a sweep through render code.
|
|
95
|
+
*/
|
|
96
|
+
export const BOX = {
|
|
97
|
+
topLeft: '╭',
|
|
98
|
+
topRight: '╮',
|
|
99
|
+
bottomLeft: '╰',
|
|
100
|
+
bottomRight: '╯',
|
|
101
|
+
horizontal: '─',
|
|
102
|
+
vertical: '│',
|
|
103
|
+
midLeft: '├',
|
|
104
|
+
midRight: '┤',
|
|
105
|
+
cross: '┼',
|
|
106
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Goal } from '../state/goalStore.js';
|
|
2
|
+
import { type PlanState } from '../state/taskStore.js';
|
|
3
|
+
import { type WorkflowMeta } from '../state/workflowArtifacts.js';
|
|
4
|
+
import { type ChildSessionRecord } from '../orchestration/orchestrator.js';
|
|
5
|
+
import type { RecalledRecord } from '../memory/briefing.js';
|
|
6
|
+
import { type EffortLevel, type ExecutionMode, type ReviewPolicy } from '../state/preferencesStore.js';
|
|
7
|
+
import { type Theme } from './theme.js';
|
|
8
|
+
/**
|
|
9
|
+
* `/where` — single-screen "where am I right now" answer.
|
|
10
|
+
*
|
|
11
|
+
* Pre-0.3.6 the user had to chain `/workspace`, `/goal status`, `/plan`,
|
|
12
|
+
* `/workflows`, `/agents`, `/briefing` to reconstruct the same picture. That
|
|
13
|
+
* was four screen-fuls of output, half of which restated info already in
|
|
14
|
+
* the others. The `/where` view collapses it into one block, ordered by
|
|
15
|
+
* "what's most likely to be in your head as a question right now":
|
|
16
|
+
*
|
|
17
|
+
* 1. WORKSPACE — where am I writing files?
|
|
18
|
+
* 2. WORKFLOW — which durable folder is bound?
|
|
19
|
+
* 3. GOAL — what's the agent contractually trying to do?
|
|
20
|
+
* 4. PLAN — what are the in-flight steps?
|
|
21
|
+
* 5. RECALL — what memory rows did the briefing surface last turn?
|
|
22
|
+
* 6. AGENTS — any spawned children still alive?
|
|
23
|
+
*
|
|
24
|
+
* Sections are individually optional — a fresh workspace with no goal, no
|
|
25
|
+
* plan, no children renders only the WORKSPACE block instead of five empty
|
|
26
|
+
* boxes. That keeps the view useful at every stage of a session, not just
|
|
27
|
+
* after you've built up state.
|
|
28
|
+
*
|
|
29
|
+
* Pure renderer: input is a snapshot, output is a string. The wrapper in
|
|
30
|
+
* commands/ui.ts gathers the snapshot at call time. Tests assert against
|
|
31
|
+
* the rendered output directly.
|
|
32
|
+
*/
|
|
33
|
+
export interface WhereInputs {
|
|
34
|
+
workspaceRoot: string;
|
|
35
|
+
sessionKey: string;
|
|
36
|
+
model: string;
|
|
37
|
+
mcpProfile: string;
|
|
38
|
+
mcpTransport: string;
|
|
39
|
+
mcpOnline: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* 10c: identity of the currently-active MCP. When `'brainrouter'`, /where
|
|
42
|
+
* adds a distinct `brain` line ("brain 🟢 online · cloud") under the
|
|
43
|
+
* mcp row. When `'third-party'` the line is omitted. `'unknown'` (pre-
|
|
44
|
+
* detection) is also omitted so we don't show stale state.
|
|
45
|
+
*/
|
|
46
|
+
mcpIdentity?: 'brainrouter' | 'third-party' | 'unknown';
|
|
47
|
+
accessMode: string;
|
|
48
|
+
executionMode: ExecutionMode;
|
|
49
|
+
reviewPolicy: ReviewPolicy;
|
|
50
|
+
effort: EffortLevel;
|
|
51
|
+
effortSource: 'env' | 'preference' | 'default';
|
|
52
|
+
workflowSlug?: string;
|
|
53
|
+
workflowMeta?: WorkflowMeta;
|
|
54
|
+
goal?: Goal;
|
|
55
|
+
plan: PlanState;
|
|
56
|
+
recalledRecords: RecalledRecord[];
|
|
57
|
+
briefingSources: string[];
|
|
58
|
+
childSessions: ChildSessionRecord[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Render the full /where block. Sections are dropped when empty; a fresh
|
|
62
|
+
* workspace with no goal / plan / children renders just WORKSPACE.
|
|
63
|
+
*/
|
|
64
|
+
export declare function renderWhere(inputs: WhereInputs, theme: Theme): string;
|
|
65
|
+
/**
|
|
66
|
+
* Gather the snapshot the renderer needs. Single function so the command
|
|
67
|
+
* handler is two lines (gather + render + print).
|
|
68
|
+
*/
|
|
69
|
+
export declare function gatherWhereInputs(args: {
|
|
70
|
+
workspaceRoot: string;
|
|
71
|
+
sessionKey: string;
|
|
72
|
+
model: string;
|
|
73
|
+
mcpProfile: string;
|
|
74
|
+
mcpTransport: string;
|
|
75
|
+
mcpOnline: boolean;
|
|
76
|
+
/** 10c: pass through from `mcpClient.getIdentity()` when available. */
|
|
77
|
+
mcpIdentity?: 'brainrouter' | 'third-party' | 'unknown';
|
|
78
|
+
accessMode: string;
|
|
79
|
+
recalledRecords: RecalledRecord[];
|
|
80
|
+
briefingSources: string[];
|
|
81
|
+
}): WhereInputs;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { formatBudget, readGoal } from '../state/goalStore.js';
|
|
3
|
+
import { readPlan } from '../state/taskStore.js';
|
|
4
|
+
import { getCurrentWorkflow, listWorkflows } from '../state/workflowArtifacts.js';
|
|
5
|
+
import { listSessions } from '../orchestration/orchestrator.js';
|
|
6
|
+
import { readPreferences, resolveEffort } from '../state/preferencesStore.js';
|
|
7
|
+
import { BOX } from './theme.js';
|
|
8
|
+
const RECALL_LIMIT = 5;
|
|
9
|
+
const PLAN_LIMIT = 8;
|
|
10
|
+
const CHILDREN_LIMIT = 6;
|
|
11
|
+
function indent(line, depth = 2) {
|
|
12
|
+
return ' '.repeat(depth) + line;
|
|
13
|
+
}
|
|
14
|
+
function renderHeader(title, theme) {
|
|
15
|
+
return theme.heading(`${BOX.midLeft}${BOX.horizontal} ${title}`);
|
|
16
|
+
}
|
|
17
|
+
function renderWorkspace(inputs, theme) {
|
|
18
|
+
const base = path.basename(inputs.workspaceRoot) || inputs.workspaceRoot;
|
|
19
|
+
const dim = theme.muted;
|
|
20
|
+
// /where is the "tell me everything" surface, so we show the effort level
|
|
21
|
+
// regardless of whether it's at default — unlike the statusline, which
|
|
22
|
+
// hides medium to keep the prompt quiet. Tag the source in parens when
|
|
23
|
+
// env beat the preference so users can see why the value differs from
|
|
24
|
+
// what they set with /effort.
|
|
25
|
+
const effortLine = inputs.effortSource === 'env'
|
|
26
|
+
? `effort ${inputs.effort} ${dim('(env)')}`
|
|
27
|
+
: `effort ${inputs.effort}`;
|
|
28
|
+
const lines = [
|
|
29
|
+
renderHeader('Workspace', theme),
|
|
30
|
+
indent(theme.plain(`${base} ${dim('(' + inputs.workspaceRoot + ')')}`)),
|
|
31
|
+
indent(dim(`session ${inputs.sessionKey.slice(0, 8)} · model ${inputs.model} · mode ${inputs.accessMode}`)),
|
|
32
|
+
indent(dim(`exec ${inputs.executionMode} · review ${inputs.reviewPolicy} · ${effortLine}`)),
|
|
33
|
+
indent(dim(`mcp ${inputs.mcpProfile} · ${inputs.mcpTransport} · ${inputs.mcpOnline ? 'online' : 'offline'}`)),
|
|
34
|
+
];
|
|
35
|
+
// 10c: distinct `brain` line for the BrainRouter cloud brain. Shown
|
|
36
|
+
// unconditionally regardless of state when identity is brainrouter (vs.
|
|
37
|
+
// the statusline which hides online to stay quiet). Third-party MCPs
|
|
38
|
+
// skip the line entirely; `unknown` is pre-detection so we wait.
|
|
39
|
+
if (inputs.mcpIdentity === 'brainrouter') {
|
|
40
|
+
const brainState = inputs.mcpOnline ? '🟢 online' : '🔴 offline · cloud unreachable';
|
|
41
|
+
lines.push(indent(dim(`brain ${brainState}`)));
|
|
42
|
+
}
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
function renderWorkflow(inputs, theme) {
|
|
46
|
+
if (!inputs.workflowSlug)
|
|
47
|
+
return [];
|
|
48
|
+
const meta = inputs.workflowMeta;
|
|
49
|
+
const dim = theme.muted;
|
|
50
|
+
const lines = [renderHeader('Workflow', theme)];
|
|
51
|
+
lines.push(indent(theme.info(inputs.workflowSlug) + (meta ? ' ' + dim(`(${meta.kind} · ${meta.status})`) : '')));
|
|
52
|
+
if (meta)
|
|
53
|
+
lines.push(indent(dim(`title: ${meta.title}`)));
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
function renderGoal(inputs, theme) {
|
|
57
|
+
const goal = inputs.goal;
|
|
58
|
+
if (!goal)
|
|
59
|
+
return [];
|
|
60
|
+
const dim = theme.muted;
|
|
61
|
+
const cap = formatBudget(goal.budget.maxIterations);
|
|
62
|
+
const used = goal.budget.iterationsUsed;
|
|
63
|
+
const statusColor = goal.status === 'complete' ? theme.success :
|
|
64
|
+
goal.status === 'blocked' ? theme.danger :
|
|
65
|
+
goal.status === 'usage_limited' ? theme.warning :
|
|
66
|
+
goal.status === 'paused' ? theme.warning :
|
|
67
|
+
theme.info;
|
|
68
|
+
const lines = [renderHeader('Goal', theme)];
|
|
69
|
+
lines.push(indent(statusColor(goal.status.toUpperCase().replace('_', ' '))));
|
|
70
|
+
// Wrap the goal text at a sensible width so long objectives don't
|
|
71
|
+
// produce one giant unreadable line.
|
|
72
|
+
lines.push(indent(theme.plain(wrapText(goal.text, 76))));
|
|
73
|
+
const tokenLine = goal.budget.maxTokens
|
|
74
|
+
? ` · tokens ${(goal.budget.tokensUsed ?? 0).toLocaleString()}/${goal.budget.maxTokens.toLocaleString()}`
|
|
75
|
+
: '';
|
|
76
|
+
lines.push(indent(dim(`iterations ${used}/${cap}${tokenLine}`)));
|
|
77
|
+
if (goal.blockedReason)
|
|
78
|
+
lines.push(indent(dim(`reason: ${goal.blockedReason}`)));
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
function renderPlan(inputs, theme) {
|
|
82
|
+
if (!inputs.plan.items.length)
|
|
83
|
+
return [];
|
|
84
|
+
const dim = theme.muted;
|
|
85
|
+
const lines = [renderHeader('Plan', theme)];
|
|
86
|
+
if (inputs.plan.explanation) {
|
|
87
|
+
lines.push(indent(dim(inputs.plan.explanation)));
|
|
88
|
+
}
|
|
89
|
+
for (const item of inputs.plan.items.slice(0, PLAN_LIMIT)) {
|
|
90
|
+
const mark = item.status === 'completed' ? theme.success('✓') :
|
|
91
|
+
item.status === 'in_progress' ? theme.warning('⏳') :
|
|
92
|
+
dim('☐');
|
|
93
|
+
const text = item.status === 'completed' ? dim(item.step) : theme.plain(item.step);
|
|
94
|
+
lines.push(indent(`${mark} ${text}`));
|
|
95
|
+
}
|
|
96
|
+
if (inputs.plan.items.length > PLAN_LIMIT) {
|
|
97
|
+
lines.push(indent(dim(`…and ${inputs.plan.items.length - PLAN_LIMIT} more`)));
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
function renderRecall(inputs, theme) {
|
|
102
|
+
if (!inputs.recalledRecords.length && !inputs.briefingSources.length)
|
|
103
|
+
return [];
|
|
104
|
+
const dim = theme.muted;
|
|
105
|
+
const lines = [renderHeader('Recent recall', theme)];
|
|
106
|
+
if (inputs.briefingSources.length) {
|
|
107
|
+
lines.push(indent(dim(`sources: ${inputs.briefingSources.join(', ')}`)));
|
|
108
|
+
}
|
|
109
|
+
for (const rec of inputs.recalledRecords.slice(0, RECALL_LIMIT)) {
|
|
110
|
+
const typeTag = rec.type ? theme.secondary(`[${rec.type}] `) : '';
|
|
111
|
+
const score = typeof rec.priority === 'number' ? dim(` (p=${rec.priority.toFixed(2)})`) : '';
|
|
112
|
+
const snippet = (rec.content ?? '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
113
|
+
lines.push(indent(`${typeTag}${theme.plain(snippet || rec.recordId)}${score}`));
|
|
114
|
+
}
|
|
115
|
+
if (inputs.recalledRecords.length > RECALL_LIMIT) {
|
|
116
|
+
lines.push(indent(dim(`…and ${inputs.recalledRecords.length - RECALL_LIMIT} more`)));
|
|
117
|
+
}
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
function renderChildren(inputs, theme) {
|
|
121
|
+
// Live children = anything currently pending or running. Stale/completed
|
|
122
|
+
// are visible in /agents; /where stays focused on what's blocking attention.
|
|
123
|
+
const live = inputs.childSessions.filter((s) => s.status === 'pending' || s.status === 'running');
|
|
124
|
+
if (!live.length)
|
|
125
|
+
return [];
|
|
126
|
+
const dim = theme.muted;
|
|
127
|
+
const lines = [renderHeader(`Active children (${live.length})`, theme)];
|
|
128
|
+
for (const s of live.slice(0, CHILDREN_LIMIT)) {
|
|
129
|
+
const statusColor = s.status === 'running' ? theme.success : theme.warning;
|
|
130
|
+
const role = theme.secondary(s.role);
|
|
131
|
+
const id = theme.info(s.id);
|
|
132
|
+
const promptPreview = s.prompt
|
|
133
|
+
? ' ' + dim(s.prompt.replace(/\s+/g, ' ').slice(0, 80))
|
|
134
|
+
: '';
|
|
135
|
+
lines.push(indent(`${statusColor(s.status)} ${id} ${role}${promptPreview}`));
|
|
136
|
+
}
|
|
137
|
+
if (live.length > CHILDREN_LIMIT) {
|
|
138
|
+
lines.push(indent(dim(`…and ${live.length - CHILDREN_LIMIT} more`)));
|
|
139
|
+
}
|
|
140
|
+
return lines;
|
|
141
|
+
}
|
|
142
|
+
function wrapText(text, width) {
|
|
143
|
+
if (text.length <= width)
|
|
144
|
+
return text;
|
|
145
|
+
const words = text.split(/\s+/);
|
|
146
|
+
const lines = [];
|
|
147
|
+
let current = '';
|
|
148
|
+
for (const word of words) {
|
|
149
|
+
if (!current) {
|
|
150
|
+
current = word;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (current.length + 1 + word.length > width) {
|
|
154
|
+
lines.push(current);
|
|
155
|
+
current = word;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
current += ' ' + word;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (current)
|
|
162
|
+
lines.push(current);
|
|
163
|
+
return lines.join('\n' + ' '.repeat(2));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Render the full /where block. Sections are dropped when empty; a fresh
|
|
167
|
+
* workspace with no goal / plan / children renders just WORKSPACE.
|
|
168
|
+
*/
|
|
169
|
+
export function renderWhere(inputs, theme) {
|
|
170
|
+
const sections = [];
|
|
171
|
+
sections.push(renderWorkspace(inputs, theme));
|
|
172
|
+
const workflow = renderWorkflow(inputs, theme);
|
|
173
|
+
if (workflow.length)
|
|
174
|
+
sections.push(workflow);
|
|
175
|
+
const goal = renderGoal(inputs, theme);
|
|
176
|
+
if (goal.length)
|
|
177
|
+
sections.push(goal);
|
|
178
|
+
const plan = renderPlan(inputs, theme);
|
|
179
|
+
if (plan.length)
|
|
180
|
+
sections.push(plan);
|
|
181
|
+
const recall = renderRecall(inputs, theme);
|
|
182
|
+
if (recall.length)
|
|
183
|
+
sections.push(recall);
|
|
184
|
+
const children = renderChildren(inputs, theme);
|
|
185
|
+
if (children.length)
|
|
186
|
+
sections.push(children);
|
|
187
|
+
return sections.map((lines) => lines.join('\n')).join('\n\n');
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Gather the snapshot the renderer needs. Single function so the command
|
|
191
|
+
* handler is two lines (gather + render + print).
|
|
192
|
+
*/
|
|
193
|
+
export function gatherWhereInputs(args) {
|
|
194
|
+
const workflowSlug = (() => {
|
|
195
|
+
// 9d-bugfix: session-scoped binding so a fresh CLI shows no workflow
|
|
196
|
+
// even when an earlier session in the same workspace had one bound.
|
|
197
|
+
try {
|
|
198
|
+
return getCurrentWorkflow(args.workspaceRoot, args.sessionKey);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
const workflowMeta = workflowSlug
|
|
205
|
+
? listWorkflows(args.workspaceRoot).find((w) => w.slug === workflowSlug)
|
|
206
|
+
: undefined;
|
|
207
|
+
const goal = (() => {
|
|
208
|
+
try {
|
|
209
|
+
return readGoal(args.workspaceRoot, args.sessionKey) ?? undefined;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
const plan = (() => {
|
|
216
|
+
try {
|
|
217
|
+
return readPlan(args.workspaceRoot, args.sessionKey);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { items: [], updatedAt: '' };
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
const childSessions = (() => {
|
|
224
|
+
try {
|
|
225
|
+
return listSessions(args.workspaceRoot);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
const prefs = readPreferences(args.workspaceRoot);
|
|
232
|
+
const resolvedEffort = resolveEffort(args.workspaceRoot);
|
|
233
|
+
return {
|
|
234
|
+
...args,
|
|
235
|
+
executionMode: prefs.executionMode,
|
|
236
|
+
reviewPolicy: prefs.reviewPolicy,
|
|
237
|
+
effort: resolvedEffort.effort,
|
|
238
|
+
effortSource: resolvedEffort.source,
|
|
239
|
+
workflowSlug,
|
|
240
|
+
workflowMeta,
|
|
241
|
+
goal,
|
|
242
|
+
plan,
|
|
243
|
+
childSessions,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Theme } from '../theme.js';
|
|
2
|
+
import type { ProviderEntry } from './providers.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fetch the live model list from an OpenAI-compatible `/v1/models`
|
|
5
|
+
* endpoint and return them as a sorted, deduped string array.
|
|
6
|
+
*
|
|
7
|
+
* Every OpenAI-compatible server we ship a provider entry for honours
|
|
8
|
+
* `GET <endpoint>/models` (OpenAI, DeepSeek, OpenRouter, LM Studio,
|
|
9
|
+
* Ollama, vLLM, gateway proxies like LiteLLM). The response shape is
|
|
10
|
+
* `{ object: 'list', data: [{ id: string, owned_by?: string, ... }] }`.
|
|
11
|
+
*
|
|
12
|
+
* We derive the `/models` URL from the provider's chat endpoint by
|
|
13
|
+
* stripping the trailing `/chat/completions` path segment. That works
|
|
14
|
+
* for every endpoint shape we care about:
|
|
15
|
+
*
|
|
16
|
+
* https://api.openai.com/v1/chat/completions → /v1/models
|
|
17
|
+
* http://localhost:1234/v1/chat/completions → /v1/models
|
|
18
|
+
* https://openrouter.ai/api/v1/chat/completions → /api/v1/models
|
|
19
|
+
*
|
|
20
|
+
* 5-second timeout — if the call hangs or fails, the wizard falls
|
|
21
|
+
* back to the provider's curated static catalog so users on a slow
|
|
22
|
+
* link / behind a captive portal aren't blocked.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fetchOpenAiCompatibleModels(provider: ProviderEntry, apiKey: string, endpointOverride?: string): Promise<{
|
|
25
|
+
ok: true;
|
|
26
|
+
models: string[];
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function deriveModelsUrl(chatEndpoint: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Open the model picker — live `/v1/models` fetch with the provider's
|
|
34
|
+
* static catalog as the offline fallback. Returns the selected model id,
|
|
35
|
+
* or `undefined` when the user cancels.
|
|
36
|
+
*
|
|
37
|
+
* Used by both the onboarding wizard's Model step
|
|
38
|
+
* (`cli/wizard/runner.ts → runModelStep`) and the in-REPL `/model`
|
|
39
|
+
* quick-swap command (`cli/commands/ui.ts → /model`). Keeping a single
|
|
40
|
+
* implementation means a future enrichment (recently-used badge,
|
|
41
|
+
* cost-per-token hint, model-size group headers) lights up everywhere
|
|
42
|
+
* at once.
|
|
43
|
+
*
|
|
44
|
+
* `currentModel` (when supplied) defaults the picker cursor onto the
|
|
45
|
+
* currently active row — important for `/model` where the user almost
|
|
46
|
+
* always wants to confirm what's currently selected before changing.
|
|
47
|
+
*/
|
|
48
|
+
export interface SelectModelOptions {
|
|
49
|
+
theme: Theme;
|
|
50
|
+
provider: ProviderEntry;
|
|
51
|
+
apiKey: string;
|
|
52
|
+
/** Override the provider's default chat endpoint (custom-endpoint flow). */
|
|
53
|
+
endpointOverride?: string;
|
|
54
|
+
/** Active model id — cursor opens on this row when present. */
|
|
55
|
+
currentModel?: string;
|
|
56
|
+
/** Picker title (default: "Model"). */
|
|
57
|
+
title?: string;
|
|
58
|
+
/** Optional badge rendered next to the title. */
|
|
59
|
+
badge?: string;
|
|
60
|
+
/** Erase the picker frame after a selection (true for wizard, false for in-REPL). */
|
|
61
|
+
eraseOnClose?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface SelectModelResult {
|
|
64
|
+
model: string;
|
|
65
|
+
/** Where the picker got its list from — surfaces in the success message. */
|
|
66
|
+
source: 'live' | 'static' | 'fallback';
|
|
67
|
+
/** Number of models the live call returned (0 when source !== 'live'). */
|
|
68
|
+
liveCount: number;
|
|
69
|
+
/** Live-call error message when source !== 'live' (omitted on live success). */
|
|
70
|
+
liveError?: string;
|
|
71
|
+
}
|
|
72
|
+
export declare function selectModel(opts: SelectModelOptions): Promise<SelectModelResult | undefined>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { runPicker } from '../ink/runPicker.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fetch the live model list from an OpenAI-compatible `/v1/models`
|
|
4
|
+
* endpoint and return them as a sorted, deduped string array.
|
|
5
|
+
*
|
|
6
|
+
* Every OpenAI-compatible server we ship a provider entry for honours
|
|
7
|
+
* `GET <endpoint>/models` (OpenAI, DeepSeek, OpenRouter, LM Studio,
|
|
8
|
+
* Ollama, vLLM, gateway proxies like LiteLLM). The response shape is
|
|
9
|
+
* `{ object: 'list', data: [{ id: string, owned_by?: string, ... }] }`.
|
|
10
|
+
*
|
|
11
|
+
* We derive the `/models` URL from the provider's chat endpoint by
|
|
12
|
+
* stripping the trailing `/chat/completions` path segment. That works
|
|
13
|
+
* for every endpoint shape we care about:
|
|
14
|
+
*
|
|
15
|
+
* https://api.openai.com/v1/chat/completions → /v1/models
|
|
16
|
+
* http://localhost:1234/v1/chat/completions → /v1/models
|
|
17
|
+
* https://openrouter.ai/api/v1/chat/completions → /api/v1/models
|
|
18
|
+
*
|
|
19
|
+
* 5-second timeout — if the call hangs or fails, the wizard falls
|
|
20
|
+
* back to the provider's curated static catalog so users on a slow
|
|
21
|
+
* link / behind a captive portal aren't blocked.
|
|
22
|
+
*/
|
|
23
|
+
export async function fetchOpenAiCompatibleModels(provider, apiKey, endpointOverride) {
|
|
24
|
+
const chatEndpoint = endpointOverride ?? provider.endpoint;
|
|
25
|
+
const modelsUrl = deriveModelsUrl(chatEndpoint);
|
|
26
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
27
|
+
// Cloud providers always want a Bearer header. Local endpoints (LM
|
|
28
|
+
// Studio, Ollama) accept anything OR nothing, but sending the key
|
|
29
|
+
// through doesn't hurt — they ignore it.
|
|
30
|
+
if (apiKey.trim().length > 0) {
|
|
31
|
+
headers['Authorization'] = `Bearer ${apiKey.trim()}`;
|
|
32
|
+
}
|
|
33
|
+
else if (provider.local) {
|
|
34
|
+
// Some local servers reject requests with NO Authorization header
|
|
35
|
+
// even though they don't validate the value. A literal "local"
|
|
36
|
+
// bearer is the convention for LM Studio / Ollama config snippets.
|
|
37
|
+
headers['Authorization'] = 'Bearer local';
|
|
38
|
+
}
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(modelsUrl, {
|
|
43
|
+
method: 'GET',
|
|
44
|
+
headers,
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const text = await res.text().catch(() => '');
|
|
49
|
+
return { ok: false, error: `HTTP ${res.status} ${res.statusText} — ${text.slice(0, 200)}` };
|
|
50
|
+
}
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
const list = Array.isArray(data?.data) ? data.data : [];
|
|
53
|
+
const ids = list
|
|
54
|
+
.map((m) => (typeof m?.id === 'string' ? m.id : null))
|
|
55
|
+
.filter((s) => !!s);
|
|
56
|
+
if (ids.length === 0) {
|
|
57
|
+
return { ok: false, error: 'endpoint returned an empty model list' };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, models: dedupeAndSort(ids) };
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err?.name === 'AbortError') {
|
|
63
|
+
return { ok: false, error: 'timed out after 5s' };
|
|
64
|
+
}
|
|
65
|
+
return { ok: false, error: String(err?.message ?? err) };
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function deriveModelsUrl(chatEndpoint) {
|
|
72
|
+
// Replace a trailing `/chat/completions` (with optional trailing
|
|
73
|
+
// slash) with `/models`. If the endpoint doesn't end in
|
|
74
|
+
// `/chat/completions` (already a base URL, custom path), append
|
|
75
|
+
// `/models` directly.
|
|
76
|
+
const trimmed = chatEndpoint.replace(/\/+$/, '');
|
|
77
|
+
if (trimmed.endsWith('/chat/completions')) {
|
|
78
|
+
return trimmed.slice(0, -'/chat/completions'.length) + '/models';
|
|
79
|
+
}
|
|
80
|
+
return trimmed + '/models';
|
|
81
|
+
}
|
|
82
|
+
function dedupeAndSort(ids) {
|
|
83
|
+
return Array.from(new Set(ids)).sort((a, b) => a.localeCompare(b));
|
|
84
|
+
}
|
|
85
|
+
export async function selectModel(opts) {
|
|
86
|
+
const { provider, apiKey, endpointOverride, currentModel, theme } = opts;
|
|
87
|
+
let modelsList = provider.models;
|
|
88
|
+
let source = 'static';
|
|
89
|
+
let liveCount = 0;
|
|
90
|
+
let liveError;
|
|
91
|
+
let subtitleHint = `Pick the chat model for ${provider.label}.`;
|
|
92
|
+
// Live fetch is gated on either having a key (cloud) or running local.
|
|
93
|
+
// Skipping the fetch entirely when neither is true avoids a guaranteed
|
|
94
|
+
// 401 / network error on the loading frame.
|
|
95
|
+
if (apiKey.trim().length > 0 || provider.local) {
|
|
96
|
+
const fetched = await fetchOpenAiCompatibleModels(provider, apiKey, endpointOverride);
|
|
97
|
+
if (fetched.ok) {
|
|
98
|
+
const live = fetched.models;
|
|
99
|
+
// Default model floats to the top so "(default)" stays in the
|
|
100
|
+
// natural-first position the user expects. Then the currently-
|
|
101
|
+
// active model floats next (cursor lands here below).
|
|
102
|
+
const reordered = floatToTop(live, [provider.defaultModel]);
|
|
103
|
+
modelsList = reordered;
|
|
104
|
+
source = 'live';
|
|
105
|
+
liveCount = live.length;
|
|
106
|
+
subtitleHint = `Pick a model — ${live.length} returned by ${provider.label}'s /v1/models endpoint. Use "Other" to type any name.`;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
source = 'fallback';
|
|
110
|
+
liveError = fetched.error;
|
|
111
|
+
subtitleHint = `Pick a model. (Live list unavailable — ${fetched.error}. Showing curated short-list.) Use "Other" to type any name.`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const finalList = modelsList.length > 0 ? modelsList : [provider.defaultModel];
|
|
115
|
+
const rows = finalList.map((m) => ({
|
|
116
|
+
id: m,
|
|
117
|
+
label: m,
|
|
118
|
+
value: m === currentModel ? 'current' :
|
|
119
|
+
m === provider.defaultModel ? 'default' : '',
|
|
120
|
+
}));
|
|
121
|
+
// Cursor priority: currently-active model > provider default > top.
|
|
122
|
+
let initialCursor = 0;
|
|
123
|
+
if (currentModel) {
|
|
124
|
+
const idx = finalList.indexOf(currentModel);
|
|
125
|
+
if (idx >= 0)
|
|
126
|
+
initialCursor = idx;
|
|
127
|
+
}
|
|
128
|
+
if (initialCursor === 0 && !currentModel) {
|
|
129
|
+
const idx = finalList.indexOf(provider.defaultModel);
|
|
130
|
+
if (idx >= 0)
|
|
131
|
+
initialCursor = idx;
|
|
132
|
+
}
|
|
133
|
+
const result = await runPicker({
|
|
134
|
+
theme,
|
|
135
|
+
title: opts.title ?? 'Model',
|
|
136
|
+
subtitle: subtitleHint,
|
|
137
|
+
badge: opts.badge,
|
|
138
|
+
rows,
|
|
139
|
+
initialCursor,
|
|
140
|
+
allowOther: true,
|
|
141
|
+
otherLabel: 'Other model',
|
|
142
|
+
otherDescription: 'Type any model name supported by this endpoint',
|
|
143
|
+
eraseOnClose: opts.eraseOnClose ?? false,
|
|
144
|
+
});
|
|
145
|
+
if (result.kind === 'cancelled')
|
|
146
|
+
return undefined;
|
|
147
|
+
const model = (result.kind === 'other' ? result.text.trim() : result.id) || provider.defaultModel;
|
|
148
|
+
return { model, source, liveCount, liveError };
|
|
149
|
+
}
|
|
150
|
+
function floatToTop(list, priority) {
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const front = [];
|
|
153
|
+
for (const p of priority) {
|
|
154
|
+
if (list.includes(p) && !seen.has(p)) {
|
|
155
|
+
front.push(p);
|
|
156
|
+
seen.add(p);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const m of list) {
|
|
160
|
+
if (!seen.has(m)) {
|
|
161
|
+
front.push(m);
|
|
162
|
+
seen.add(m);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return front;
|
|
166
|
+
}
|