@axplusb/kepler 1.0.10 → 2.0.2
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/package.json +5 -2
- package/pulse/app/api/benchmark/route.ts +113 -0
- package/pulse/app/api/benchmarks/route.ts +195 -0
- package/pulse/app/benchmarks/page.tsx +224 -0
- package/pulse/components/layout/bottom-nav.tsx +2 -1
- package/pulse/components/layout/sidebar.tsx +2 -1
- package/src/context/retriever.mjs +42 -4
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/approval.mjs +154 -95
- package/src/core/backend-url.mjs +2 -2
- package/src/core/headless.mjs +5 -0
- package/src/core/risk-tier.mjs +245 -0
- package/src/core/stream-client.mjs +24 -1
- package/src/core/tool-executor.mjs +58 -5
- package/src/onboarding/preflight.mjs +292 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/terminal/ansi.mjs +44 -22
- package/src/terminal/repl.mjs +487 -133
- package/src/tools/project-overview.mjs +109 -16
- package/src/ui/approval.mjs +167 -0
- package/src/ui/banner.mjs +133 -122
- package/src/ui/dock.mjs +88 -0
- package/src/ui/icons.mjs +164 -0
- package/src/ui/mission-report.mjs +264 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/status-bar.mjs +275 -0
- package/src/ui/sub-agent.mjs +152 -0
- package/src/ui/term.mjs +159 -0
- package/src/ui/tool-card.mjs +322 -0
- package/src/ui/tool-details.mjs +277 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight diagnostic — Mission Control (PRD-055 §9).
|
|
3
|
+
*
|
|
4
|
+
* Prints a non-blocking summary of the runtime environment before the REPL
|
|
5
|
+
* starts so the user can see what is and is not aligned:
|
|
6
|
+
*
|
|
7
|
+
* 🔭 Kepler v1.0.4 · initializing orbit
|
|
8
|
+
*
|
|
9
|
+
* [✓] Auth token
|
|
10
|
+
* [✓] OpenRouter key
|
|
11
|
+
* [✓] Backend http://127.0.0.1:8000
|
|
12
|
+
* [✓] Git repository main · clean
|
|
13
|
+
* [⚠] Linter (ruff) not found → /install ruff to enable lint_check
|
|
14
|
+
* [✓] Project map 142 files, Python + TypeScript
|
|
15
|
+
*
|
|
16
|
+
* All systems aligned. What are we building today?
|
|
17
|
+
*
|
|
18
|
+
* Checks are non-blocking. A failure shows a one-line next-step hint.
|
|
19
|
+
*
|
|
20
|
+
* Exposed via `runPreflight()` (called from REPL startup) and `/preflight`
|
|
21
|
+
* (registered as a slash command).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { execSync } from 'node:child_process';
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
import https from 'node:https';
|
|
29
|
+
import { URL } from 'node:url';
|
|
30
|
+
import { paint } from '../ui/palette.mjs';
|
|
31
|
+
import { icons } from '../ui/icons.mjs';
|
|
32
|
+
import { term } from '../ui/term.mjs';
|
|
33
|
+
|
|
34
|
+
const OK = (s) => `${paint.state.success('[✓]')} ${s}`;
|
|
35
|
+
const WARN = (s) => `${paint.state.warn('[⚠]')} ${s}`;
|
|
36
|
+
const FAIL = (s) => `${paint.state.danger('[✗]')} ${s}`;
|
|
37
|
+
|
|
38
|
+
// ── Individual checks (each returns { status, label, hint? }) ──────────
|
|
39
|
+
|
|
40
|
+
async function checkAuthAndBackend(auth, { timeoutMs = 2500 } = {}) {
|
|
41
|
+
const creds = auth.loadCredentials();
|
|
42
|
+
const hasToken = !!creds.token;
|
|
43
|
+
const url = creds.backendUrl;
|
|
44
|
+
|
|
45
|
+
// No token: just probe whether the backend is reachable so we can hint
|
|
46
|
+
// /login when it makes sense.
|
|
47
|
+
if (!hasToken) {
|
|
48
|
+
const reachable = url ? await ping(url, timeoutMs).catch(() => false) : false;
|
|
49
|
+
return reachable
|
|
50
|
+
? { status: 'warn', label: 'Not signed in · backend ready', hint: '/login to sign in' }
|
|
51
|
+
: { status: 'warn', label: 'Not signed in · backend offline', hint: '/login once the network is back' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Token present: real authenticated round-trip against /api/user/me.
|
|
55
|
+
// Three outcomes: valid (200), expired (401/403), unreachable (network).
|
|
56
|
+
try {
|
|
57
|
+
const ctrl = new AbortController();
|
|
58
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
59
|
+
let resp;
|
|
60
|
+
try {
|
|
61
|
+
resp = await fetch(`${url}/api/user/me`, {
|
|
62
|
+
headers: { 'Authorization': `Bearer ${creds.token}` },
|
|
63
|
+
signal: ctrl.signal,
|
|
64
|
+
});
|
|
65
|
+
} finally { clearTimeout(t); }
|
|
66
|
+
|
|
67
|
+
if (resp.ok) {
|
|
68
|
+
const user = await resp.json().catch(() => null);
|
|
69
|
+
const who = user?.github_username || user?.email || 'user';
|
|
70
|
+
return { status: 'ok', label: `Signed in as ${who} · connected` };
|
|
71
|
+
}
|
|
72
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
73
|
+
return { status: 'warn', label: 'Token expired · connected', hint: '/login again to refresh' };
|
|
74
|
+
}
|
|
75
|
+
return { status: 'warn', label: `Backend returned ${resp.status}`, hint: 'try again shortly' };
|
|
76
|
+
} catch {
|
|
77
|
+
return { status: 'warn', label: 'Signed in · backend offline', hint: 'check network or try again shortly' };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkGit(cwd) {
|
|
82
|
+
if (!hasGitDir(cwd)) return { status: 'warn', label: 'Not a git repository', hint: '`git init` to enable diff / checkpoints' };
|
|
83
|
+
try {
|
|
84
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { cwd, encoding: 'utf-8' }).trim();
|
|
85
|
+
const status = execSync('git status --porcelain 2>/dev/null', { cwd, encoding: 'utf-8' });
|
|
86
|
+
const dirty = status.split('\n').filter(Boolean).length;
|
|
87
|
+
const summary = dirty > 0
|
|
88
|
+
? `${branch} · ${paint.state.warn(`${dirty} dirty`)}`
|
|
89
|
+
: `${branch} · clean`;
|
|
90
|
+
return { status: 'ok', label: `Git repository ${summary}` };
|
|
91
|
+
} catch {
|
|
92
|
+
return { status: 'warn', label: 'Git repository present but unreadable' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkLinters(cwd) {
|
|
97
|
+
const present = [];
|
|
98
|
+
const missing = [];
|
|
99
|
+
for (const linter of LINTERS) {
|
|
100
|
+
if (which(linter.bin)) present.push(linter);
|
|
101
|
+
else if (projectUses(cwd, linter.kind)) missing.push(linter);
|
|
102
|
+
}
|
|
103
|
+
if (present.length === 0 && missing.length === 0) {
|
|
104
|
+
return { status: 'ok', label: 'Linters none required' };
|
|
105
|
+
}
|
|
106
|
+
if (missing.length === 0) {
|
|
107
|
+
return { status: 'ok', label: `Linters ${present.map(p => p.bin).join(', ')}` };
|
|
108
|
+
}
|
|
109
|
+
// Honest install command per linter. Falls back to "install via your
|
|
110
|
+
// package manager" when there is no clean one-liner (e.g. cargo).
|
|
111
|
+
const hint = missing.map(m => m.install
|
|
112
|
+
? `${m.bin}: ${m.install}`
|
|
113
|
+
: `install ${m.bin} for ${m.kind} support`
|
|
114
|
+
).join(' · ');
|
|
115
|
+
return { status: 'warn', label: `Linter (${missing.map(m => m.bin).join(', ')}) not found`, hint };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LINTERS = [
|
|
119
|
+
{ bin: 'ruff', kind: 'python', install: 'pip install ruff' },
|
|
120
|
+
{ bin: 'eslint', kind: 'javascript', install: 'npm i -g eslint' },
|
|
121
|
+
{ bin: 'tsc', kind: 'typescript', install: 'npm i -g typescript' },
|
|
122
|
+
// cargo ships with rustup; no clean one-liner — surface the warning
|
|
123
|
+
// without a misleading "/install" command.
|
|
124
|
+
{ bin: 'cargo', kind: 'rust', install: null },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
function projectUses(cwd, kind) {
|
|
128
|
+
try {
|
|
129
|
+
const files = fs.readdirSync(cwd);
|
|
130
|
+
switch (kind) {
|
|
131
|
+
case 'python': return files.some(f => /\.py$/.test(f)) || files.includes('pyproject.toml') || files.includes('requirements.txt');
|
|
132
|
+
case 'javascript': return files.includes('package.json');
|
|
133
|
+
case 'typescript': return files.includes('tsconfig.json');
|
|
134
|
+
case 'rust': return files.includes('Cargo.toml');
|
|
135
|
+
default: return false;
|
|
136
|
+
}
|
|
137
|
+
} catch { return false; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkProjectMap(cwd) {
|
|
141
|
+
try {
|
|
142
|
+
const counts = quickFileCount(cwd, { max: 5000 });
|
|
143
|
+
if (!counts.total) return { status: 'warn', label: 'Project map no files indexed yet' };
|
|
144
|
+
const langs = topLanguages(counts.byExt, 2);
|
|
145
|
+
const langStr = langs.length ? langs.join(' + ') : 'mixed';
|
|
146
|
+
return { status: 'ok', label: `Project map ${counts.total} files, ${langStr}` };
|
|
147
|
+
} catch {
|
|
148
|
+
return { status: 'warn', label: 'Project map unreadable' };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function shorten(s, n) {
|
|
155
|
+
const str = String(s || '');
|
|
156
|
+
return str.length <= n ? str : str.slice(0, n - 1) + '…';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasGitDir(cwd) {
|
|
160
|
+
let dir = cwd;
|
|
161
|
+
for (let i = 0; i < 6; i++) {
|
|
162
|
+
if (fs.existsSync(path.join(dir, '.git'))) return true;
|
|
163
|
+
const parent = path.dirname(dir);
|
|
164
|
+
if (parent === dir) break;
|
|
165
|
+
dir = parent;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function which(name) {
|
|
171
|
+
try {
|
|
172
|
+
execSync(`command -v ${name}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
173
|
+
return true;
|
|
174
|
+
} catch { return false; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function ping(url, timeoutMs) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let u;
|
|
180
|
+
try { u = new URL(url); } catch { resolve(false); return; }
|
|
181
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
182
|
+
const req = lib.request({
|
|
183
|
+
hostname: u.hostname,
|
|
184
|
+
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
185
|
+
path: u.pathname || '/',
|
|
186
|
+
method: 'GET',
|
|
187
|
+
timeout: timeoutMs,
|
|
188
|
+
}, (res) => {
|
|
189
|
+
// Any response means the host is reachable, even 404.
|
|
190
|
+
res.resume();
|
|
191
|
+
resolve(true);
|
|
192
|
+
});
|
|
193
|
+
req.on('error', () => resolve(false));
|
|
194
|
+
req.on('timeout', () => { try { req.destroy(); } catch {} resolve(false); });
|
|
195
|
+
req.end();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const EXT_TO_LANG = {
|
|
200
|
+
'.py': 'Python', '.ts': 'TypeScript', '.tsx': 'TypeScript', '.js': 'JavaScript',
|
|
201
|
+
'.jsx': 'JavaScript', '.mjs': 'JavaScript', '.go': 'Go', '.rs': 'Rust',
|
|
202
|
+
'.java': 'Java', '.rb': 'Ruby', '.php': 'PHP', '.swift': 'Swift', '.kt': 'Kotlin',
|
|
203
|
+
'.c': 'C', '.cc': 'C++', '.cpp': 'C++', '.h': 'C/C++', '.hpp': 'C++',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
function topLanguages(byExt, n) {
|
|
207
|
+
const ranked = Object.entries(byExt)
|
|
208
|
+
.map(([ext, count]) => [EXT_TO_LANG[ext], count])
|
|
209
|
+
.filter(([lang]) => lang)
|
|
210
|
+
.reduce((acc, [lang, count]) => { acc.set(lang, (acc.get(lang) || 0) + count); return acc; }, new Map());
|
|
211
|
+
return [...ranked.entries()]
|
|
212
|
+
.sort((a, b) => b[1] - a[1])
|
|
213
|
+
.slice(0, n)
|
|
214
|
+
.map(([lang]) => lang);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function quickFileCount(cwd, { max = 5000 } = {}) {
|
|
218
|
+
// Shallow walk: skip node_modules, .git, dist, build, .venv, __pycache__.
|
|
219
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.venv', 'venv', '__pycache__', '.kepler', '.terraform']);
|
|
220
|
+
const byExt = {};
|
|
221
|
+
let total = 0;
|
|
222
|
+
const stack = [cwd];
|
|
223
|
+
while (stack.length && total < max) {
|
|
224
|
+
const dir = stack.pop();
|
|
225
|
+
let entries;
|
|
226
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
227
|
+
catch { continue; }
|
|
228
|
+
for (const e of entries) {
|
|
229
|
+
if (e.name.startsWith('.') && e.name !== '.kepler') continue;
|
|
230
|
+
if (SKIP.has(e.name)) continue;
|
|
231
|
+
const full = path.join(dir, e.name);
|
|
232
|
+
if (e.isDirectory()) stack.push(full);
|
|
233
|
+
else if (e.isFile()) {
|
|
234
|
+
total++;
|
|
235
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
236
|
+
if (ext) byExt[ext] = (byExt[ext] || 0) + 1;
|
|
237
|
+
if (total >= max) break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { total, byExt };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Renderer ────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
function formatRow(check) {
|
|
247
|
+
switch (check.status) {
|
|
248
|
+
case 'ok': return ` ${OK(paint.text.primary(check.label))}`;
|
|
249
|
+
case 'warn': return ` ${WARN(paint.text.primary(check.label))}` +
|
|
250
|
+
(check.hint ? ` ${paint.text.dim('→ ' + check.hint)}` : '');
|
|
251
|
+
case 'fail': return ` ${FAIL(paint.text.primary(check.label))}` +
|
|
252
|
+
(check.hint ? ` ${paint.text.dim('→ ' + check.hint)}` : '');
|
|
253
|
+
default: return ` ${paint.text.dim(check.label)}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Run the preflight diagnostic. Writes to stderr and resolves with the
|
|
259
|
+
* collected check results.
|
|
260
|
+
*
|
|
261
|
+
* @param {object} opts
|
|
262
|
+
* @param {object} opts.auth — TarangAuth instance
|
|
263
|
+
* @param {string} opts.cwd — working directory
|
|
264
|
+
* @param {string} opts.version — package version string
|
|
265
|
+
* @param {boolean} [opts.silent] — if true, do not write (useful for tests)
|
|
266
|
+
*/
|
|
267
|
+
export async function runPreflight({ auth, cwd, version, silent = false } = {}) {
|
|
268
|
+
const t = term();
|
|
269
|
+
const write = (s) => { if (!silent) process.stderr.write(s); };
|
|
270
|
+
|
|
271
|
+
const header = `${icons.search} ${paint.bold(paint.brand.primary('Kepler v' + (version || '?')))} ${paint.text.dim('· initializing orbit')}`;
|
|
272
|
+
write('\n' + header + '\n\n');
|
|
273
|
+
|
|
274
|
+
const checks = [];
|
|
275
|
+
checks.push(await checkAuthAndBackend(auth));
|
|
276
|
+
checks.push(checkGit(cwd));
|
|
277
|
+
checks.push(checkLinters(cwd));
|
|
278
|
+
checks.push(checkProjectMap(cwd));
|
|
279
|
+
|
|
280
|
+
for (const c of checks) write(formatRow(c) + '\n');
|
|
281
|
+
|
|
282
|
+
const fails = checks.filter(c => c.status === 'fail').length;
|
|
283
|
+
const warns = checks.filter(c => c.status === 'warn').length;
|
|
284
|
+
const tail = fails === 0 && warns === 0
|
|
285
|
+
? paint.state.success('All systems aligned.')
|
|
286
|
+
: fails > 0
|
|
287
|
+
? paint.state.danger(`${fails} blocker${fails === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'} — see hints above.`)
|
|
288
|
+
: paint.state.warn(`${warns} warning${warns === 1 ? '' : 's'} — non-blocking.`);
|
|
289
|
+
|
|
290
|
+
write('\n ' + tail + '\n\n');
|
|
291
|
+
return checks;
|
|
292
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orbit state machine — Mission Control (PRD-055 §5.2).
|
|
3
|
+
*
|
|
4
|
+
* The "orbit" is the current phase of the session. The status bar reads
|
|
5
|
+
* from this module; the REPL pushes events into it. It is intentionally a
|
|
6
|
+
* pure state machine — no I/O, no side effects, no globals. Each REPL
|
|
7
|
+
* creates one instance.
|
|
8
|
+
*
|
|
9
|
+
* const orbit = createOrbit();
|
|
10
|
+
* orbit.on('change', state => statusBar.render(state));
|
|
11
|
+
* orbit.onEvent({ type: 'tool_call', data: { tool: 'edit_file' } });
|
|
12
|
+
*
|
|
13
|
+
* States (PRD §5.2):
|
|
14
|
+
* IDLE — waiting for user input
|
|
15
|
+
* DISCOVERY — first message until first plan or edit
|
|
16
|
+
* PLANNING — preflight plan running OR plan() sub-agent active
|
|
17
|
+
* EXECUTION — write/edit/shell tools firing
|
|
18
|
+
* ALIGNMENT — tests / validators running
|
|
19
|
+
* AWAITING — approval required
|
|
20
|
+
* PAUSED — user pressed `p`
|
|
21
|
+
*
|
|
22
|
+
* Transitions are derived from existing backend SSE events. We never
|
|
23
|
+
* teach the backend about orbits; the CLI infers them from tool activity.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Tool families used for orbit inference. Mirrors src/ui/icons.mjs but
|
|
27
|
+
// scoped to the few orbits actually need.
|
|
28
|
+
const PLANNING_TOOLS = new Set(['plan']);
|
|
29
|
+
const EXECUTION_TOOLS = new Set(['edit_file', 'write_file', 'write_project', 'shell', 'delete_file']);
|
|
30
|
+
const ALIGNMENT_TOOLS = new Set(['run_tests', 'validate_build', 'lint_check', 'validate_file', 'validate_structure', 'git_diff', 'git_status']);
|
|
31
|
+
const RESEARCH_TOOLS = new Set(['search_code', 'search_files', 'grep', 'read_file', 'read_files', 'list_files', 'analyze_code', 'get_project_overview', 'explore']);
|
|
32
|
+
|
|
33
|
+
export const ORBITS = Object.freeze({
|
|
34
|
+
IDLE: 'IDLE',
|
|
35
|
+
DISCOVERY: 'DISCOVERY',
|
|
36
|
+
PLANNING: 'PLANNING',
|
|
37
|
+
EXECUTION: 'EXECUTION',
|
|
38
|
+
ALIGNMENT: 'ALIGNMENT',
|
|
39
|
+
AWAITING: 'AWAITING',
|
|
40
|
+
PAUSED: 'PAUSED',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @returns the snapshot consumed by the status bar.
|
|
45
|
+
*/
|
|
46
|
+
function snapshot(s) {
|
|
47
|
+
return {
|
|
48
|
+
orbit: s.orbit,
|
|
49
|
+
task: s.task || '',
|
|
50
|
+
turn: s.turn,
|
|
51
|
+
maxTurn: s.maxTurn,
|
|
52
|
+
cost: s.cost,
|
|
53
|
+
activeTool: s.activeTool || '',
|
|
54
|
+
subAgents: s.subAgents,
|
|
55
|
+
paused: s.paused,
|
|
56
|
+
awaitingTier: s.awaitingTier || null,
|
|
57
|
+
awaitingTool: s.awaitingTool || '',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createOrbit() {
|
|
62
|
+
const state = {
|
|
63
|
+
orbit: ORBITS.IDLE,
|
|
64
|
+
task: '',
|
|
65
|
+
turn: 0,
|
|
66
|
+
maxTurn: 0,
|
|
67
|
+
cost: 0,
|
|
68
|
+
activeTool: '',
|
|
69
|
+
subAgents: 0, // count of currently-active sub-agents
|
|
70
|
+
paused: false,
|
|
71
|
+
awaitingTool: '',
|
|
72
|
+
awaitingTier: null,
|
|
73
|
+
_hasEdited: false, // for DISCOVERY → EXECUTION transition
|
|
74
|
+
_resumeOrbit: null, // remembered orbit when paused
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const listeners = new Set();
|
|
78
|
+
|
|
79
|
+
function emit() {
|
|
80
|
+
const snap = snapshot(state);
|
|
81
|
+
for (const fn of listeners) {
|
|
82
|
+
try { fn(snap); } catch {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function setOrbit(next) {
|
|
87
|
+
if (state.paused && next !== ORBITS.PAUSED && next !== ORBITS.IDLE) {
|
|
88
|
+
// While paused, remember the orbit that would have applied but stay paused.
|
|
89
|
+
state._resumeOrbit = next;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (state.orbit === next) return;
|
|
93
|
+
state.orbit = next;
|
|
94
|
+
emit();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function inferOrbitFromTool(toolName) {
|
|
98
|
+
if (state.paused) return null;
|
|
99
|
+
if (state.subAgents > 0 && PLANNING_TOOLS.has(toolName)) return ORBITS.PLANNING;
|
|
100
|
+
if (PLANNING_TOOLS.has(toolName)) return ORBITS.PLANNING;
|
|
101
|
+
if (EXECUTION_TOOLS.has(toolName)) {
|
|
102
|
+
state._hasEdited = true;
|
|
103
|
+
return ORBITS.EXECUTION;
|
|
104
|
+
}
|
|
105
|
+
if (ALIGNMENT_TOOLS.has(toolName)) return ORBITS.ALIGNMENT;
|
|
106
|
+
if (RESEARCH_TOOLS.has(toolName)) {
|
|
107
|
+
// Stay in DISCOVERY until first edit; afterwards research stays in
|
|
108
|
+
// current orbit (EXECUTION) so the status doesn't flicker back.
|
|
109
|
+
return state._hasEdited ? null : ORBITS.DISCOVERY;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
state: () => snapshot(state),
|
|
116
|
+
|
|
117
|
+
on(event, fn) {
|
|
118
|
+
if (event !== 'change') return () => {};
|
|
119
|
+
listeners.add(fn);
|
|
120
|
+
return () => listeners.delete(fn);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// ── Inbound events from the REPL ──────────────────────────────────
|
|
124
|
+
|
|
125
|
+
onUserInput(text) {
|
|
126
|
+
// First user message of the session OR a new turn opens DISCOVERY.
|
|
127
|
+
state.turn++;
|
|
128
|
+
state.task = (text || '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
129
|
+
state._hasEdited = false;
|
|
130
|
+
state.activeTool = '';
|
|
131
|
+
setOrbit(ORBITS.DISCOVERY);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
onMaxTurn(n) {
|
|
135
|
+
if (typeof n === 'number' && n > 0) {
|
|
136
|
+
state.maxTurn = n;
|
|
137
|
+
emit();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
onTask(text) {
|
|
142
|
+
if (!text) return;
|
|
143
|
+
state.task = String(text).replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
144
|
+
emit();
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
onCost(value) {
|
|
148
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
149
|
+
state.cost = value;
|
|
150
|
+
emit();
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
onToolCall(toolName) {
|
|
155
|
+
state.activeTool = toolName || '';
|
|
156
|
+
const next = inferOrbitFromTool(toolName);
|
|
157
|
+
if (next) setOrbit(next);
|
|
158
|
+
else emit();
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
onToolResult() {
|
|
162
|
+
state.activeTool = '';
|
|
163
|
+
emit();
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
onSubAgentStart() {
|
|
167
|
+
state.subAgents = Math.max(0, state.subAgents) + 1;
|
|
168
|
+
setOrbit(ORBITS.PLANNING);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
onSubAgentEnd() {
|
|
172
|
+
state.subAgents = Math.max(0, state.subAgents - 1);
|
|
173
|
+
// Fall back to whatever the parent was doing — we don't track that
|
|
174
|
+
// precisely, so go to EXECUTION if an edit has happened, else
|
|
175
|
+
// DISCOVERY. The next tool_call event will refine.
|
|
176
|
+
if (state.subAgents === 0) {
|
|
177
|
+
setOrbit(state._hasEdited ? ORBITS.EXECUTION : ORBITS.DISCOVERY);
|
|
178
|
+
} else {
|
|
179
|
+
emit();
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
onApprovalRequired({ tool, tier } = {}) {
|
|
184
|
+
state.awaitingTool = tool || '';
|
|
185
|
+
state.awaitingTier = tier || null;
|
|
186
|
+
setOrbit(ORBITS.AWAITING);
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
onApprovalResolved() {
|
|
190
|
+
state.awaitingTool = '';
|
|
191
|
+
state.awaitingTier = null;
|
|
192
|
+
// Drop back to the inferred orbit for the active tool, or EXECUTION
|
|
193
|
+
// if we have an active tool but can't classify, or IDLE.
|
|
194
|
+
const inferred = inferOrbitFromTool(state.activeTool) || (state._hasEdited ? ORBITS.EXECUTION : ORBITS.DISCOVERY);
|
|
195
|
+
setOrbit(inferred);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
onComplete({ cost } = {}) {
|
|
199
|
+
if (typeof cost === 'number') state.cost = cost;
|
|
200
|
+
state.activeTool = '';
|
|
201
|
+
setOrbit(ORBITS.IDLE);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
onPause() {
|
|
205
|
+
if (state.paused) return;
|
|
206
|
+
state.paused = true;
|
|
207
|
+
state._resumeOrbit = state.orbit;
|
|
208
|
+
state.orbit = ORBITS.PAUSED;
|
|
209
|
+
emit();
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
onResume() {
|
|
213
|
+
if (!state.paused) return;
|
|
214
|
+
state.paused = false;
|
|
215
|
+
const resume = state._resumeOrbit || ORBITS.IDLE;
|
|
216
|
+
state._resumeOrbit = null;
|
|
217
|
+
state.orbit = resume;
|
|
218
|
+
emit();
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Generic event router so the REPL can feed raw SSE events without a
|
|
223
|
+
* giant switch in this module. Returns true if the event was handled.
|
|
224
|
+
*/
|
|
225
|
+
onEvent(event) {
|
|
226
|
+
if (!event || !event.type) return false;
|
|
227
|
+
const { type, data } = event;
|
|
228
|
+
switch (type) {
|
|
229
|
+
case 'tool_call':
|
|
230
|
+
case 'tool_request':
|
|
231
|
+
this.onToolCall(data?.tool || '');
|
|
232
|
+
return true;
|
|
233
|
+
case 'tool_result':
|
|
234
|
+
case 'tool_done':
|
|
235
|
+
this.onToolResult();
|
|
236
|
+
return true;
|
|
237
|
+
case 'sub_agent_start':
|
|
238
|
+
this.onSubAgentStart();
|
|
239
|
+
return true;
|
|
240
|
+
case 'sub_agent_complete':
|
|
241
|
+
this.onSubAgentEnd();
|
|
242
|
+
return true;
|
|
243
|
+
case 'approval_required':
|
|
244
|
+
this.onApprovalRequired({ tool: data?.tool, tier: data?.tier });
|
|
245
|
+
return true;
|
|
246
|
+
case 'approval_granted':
|
|
247
|
+
case 'approval_denied':
|
|
248
|
+
this.onApprovalResolved();
|
|
249
|
+
return true;
|
|
250
|
+
case 'complete': {
|
|
251
|
+
const usage = data?.usage || {};
|
|
252
|
+
this.onComplete({ cost: usage.total_cost_usd ?? usage.cost_usd });
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
case 'plan_created':
|
|
256
|
+
this.onTask(data?.title || data?.task || '');
|
|
257
|
+
return true;
|
|
258
|
+
default:
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verbosity modes — Mission Control (PRD-055 §12).
|
|
3
|
+
*
|
|
4
|
+
* quiet Folded summary only. Sub-agent inner tools hidden.
|
|
5
|
+
* default Folded summary. Sub-agent header shown, inner tools folded.
|
|
6
|
+
* verbose Folded summary. Sub-agent inner tools shown.
|
|
7
|
+
* surgical Expanded tool details + raw model reasoning.
|
|
8
|
+
*
|
|
9
|
+
* Persisted to `~/.kepler/config.json` under the `verbosity` key so the
|
|
10
|
+
* choice survives across sessions.
|
|
11
|
+
*
|
|
12
|
+
* import { getVerbosity, setVerbosity, showSubAgentTools, showReasoning } from './verbosity.mjs';
|
|
13
|
+
*
|
|
14
|
+
* No imports from the REPL — this module is pure state + filesystem.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const MODES = Object.freeze({
|
|
22
|
+
QUIET: 'quiet',
|
|
23
|
+
DEFAULT: 'default',
|
|
24
|
+
VERBOSE: 'verbose',
|
|
25
|
+
SURGICAL: 'surgical',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const VALID = new Set(Object.values(MODES));
|
|
29
|
+
|
|
30
|
+
const CONFIG_DIR = process.env.KEPLER_HOME || path.join(os.homedir(), '.kepler');
|
|
31
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
32
|
+
|
|
33
|
+
let _cached = null;
|
|
34
|
+
|
|
35
|
+
function readConfig() {
|
|
36
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); }
|
|
37
|
+
catch { return {}; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeConfig(obj) {
|
|
41
|
+
try {
|
|
42
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
43
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(obj, null, 2));
|
|
44
|
+
} catch { /* best effort */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read the current mode (falls back to default). */
|
|
48
|
+
export function getVerbosity() {
|
|
49
|
+
if (_cached) return _cached;
|
|
50
|
+
const v = readConfig().verbosity;
|
|
51
|
+
_cached = VALID.has(v) ? v : MODES.DEFAULT;
|
|
52
|
+
return _cached;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Update the persisted mode. Returns the new mode. */
|
|
56
|
+
export function setVerbosity(mode) {
|
|
57
|
+
if (!VALID.has(mode)) throw new Error(`Unknown verbosity mode: ${mode}`);
|
|
58
|
+
const cfg = readConfig();
|
|
59
|
+
cfg.verbosity = mode;
|
|
60
|
+
writeConfig(cfg);
|
|
61
|
+
_cached = mode;
|
|
62
|
+
return mode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Force-reload from disk (used by tests). */
|
|
66
|
+
export function _resetCache() { _cached = null; }
|
|
67
|
+
|
|
68
|
+
// ── Predicates — let other modules ask "should I render X?" ─────────────
|
|
69
|
+
|
|
70
|
+
/** Should sub-agent inner tool cards be printed? */
|
|
71
|
+
export function showSubAgentTools(mode = getVerbosity()) {
|
|
72
|
+
return mode === MODES.VERBOSE || mode === MODES.SURGICAL;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Should raw model reasoning be printed? */
|
|
76
|
+
export function showReasoning(mode = getVerbosity()) {
|
|
77
|
+
return mode === MODES.SURGICAL;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Should tool cards default to expanded instead of folded? */
|
|
81
|
+
export function defaultExpanded(mode = getVerbosity()) {
|
|
82
|
+
return mode === MODES.SURGICAL;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Should markdown be rendered? (only `surgical` shows raw, others render) */
|
|
86
|
+
export function renderMarkdown(mode = getVerbosity()) {
|
|
87
|
+
return mode !== MODES.SURGICAL;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Per-mode label for /help and status display. */
|
|
91
|
+
export function label(mode = getVerbosity()) {
|
|
92
|
+
switch (mode) {
|
|
93
|
+
case MODES.QUIET: return 'quiet (compact)';
|
|
94
|
+
case MODES.DEFAULT: return 'default';
|
|
95
|
+
case MODES.VERBOSE: return 'verbose (sub-agent tools visible)';
|
|
96
|
+
case MODES.SURGICAL: return 'surgical (everything shown)';
|
|
97
|
+
default: return String(mode || 'default');
|
|
98
|
+
}
|
|
99
|
+
}
|