@axplusb/kepler 1.0.9 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -1,10 +1,16 @@
1
1
  /**
2
- * ANSI Terminal Renderer — zero dependencies, zero flickering.
2
+ * ANSI Terminal Renderer — cursor control, box drawing, status bars.
3
3
  *
4
- * Provides cursor control, colors, box drawing, progress bars,
5
- * in-place updates, and a persistent status bar.
4
+ * Color helpers (the `c` object) now route through the semantic palette
5
+ * (`src/ui/palette.mjs`) so the entire CLI honors the Kepler brand and
6
+ * tier fallbacks (truecolor, ansi256, ansi16, none) without touching
7
+ * each call site. Hot-swap-friendly: external semantics like `c.red`,
8
+ * `c.bold`, `c.cyan` are preserved as the legacy contract; new code
9
+ * should prefer importing `paint` directly.
6
10
  */
7
11
 
12
+ import { paint } from '../ui/palette.mjs';
13
+
8
14
  const ESC = '\x1b[';
9
15
  const write = (s) => process.stderr.write(s);
10
16
 
@@ -27,27 +33,43 @@ export const cursor = {
27
33
  };
28
34
 
29
35
  // ── Colors ──
36
+ // Legacy color names re-mapped onto semantic palette tokens. The CLI's
37
+ // branding is centralized in palette.mjs; this object is preserved only
38
+ // so existing imports keep compiling. Internal Kepler color choices are
39
+ // documented next to each mapping for the next code review.
40
+
41
+ const identity = (s) => String(s ?? '');
30
42
 
31
43
  export const c = {
32
- reset: (s) => `${ESC}0m${s}${ESC}0m`,
33
- bold: (s) => `${ESC}1m${s}${ESC}0m`,
34
- dim: (s) => `${ESC}2m${s}${ESC}0m`,
35
- italic: (s) => `${ESC}3m${s}${ESC}0m`,
36
- underline: (s) => `${ESC}4m${s}${ESC}0m`,
37
- red: (s) => `${ESC}31m${s}${ESC}0m`,
38
- green: (s) => `${ESC}32m${s}${ESC}0m`,
39
- yellow: (s) => `${ESC}33m${s}${ESC}0m`,
40
- blue: (s) => `${ESC}34m${s}${ESC}0m`,
41
- magenta: (s) => `${ESC}35m${s}${ESC}0m`,
42
- brand: (s) => `${ESC}36m${s}${ESC}0m`,
43
- cyan: (s) => `${ESC}94m${s}${ESC}0m`,
44
- cyanRegular: (s) => `${ESC}36m${s}${ESC}0m`,
45
- cyanBold: (s) => `${ESC}1;36m${s}${ESC}0m`,
46
- white: (s) => `${ESC}97m${s}${ESC}0m`,
47
- gray: (s) => `${ESC}90m${s}${ESC}0m`,
48
- bgRed: (s) => `${ESC}41m${s}${ESC}0m`,
49
- bgGreen: (s) => `${ESC}42m${s}${ESC}0m`,
50
- bgCyan: (s) => `${ESC}46m${s}${ESC}0m`,
44
+ reset: identity, // palette already wraps with RESET
45
+
46
+ // Styles — work at every tier
47
+ bold: paint.bold,
48
+ dim: paint.dim,
49
+ italic: paint.italic,
50
+ underline: paint.underline,
51
+
52
+ // State semantics
53
+ red: paint.state.danger, // failure / hard error
54
+ green: paint.state.success, // pass / aligned
55
+ yellow: paint.state.warn, // soft warn / retry
56
+
57
+ // Brand semantics
58
+ blue: paint.brand.primary, // headers, primary brand
59
+ magenta: paint.brand.accent, // attention required
60
+ brand: paint.brand.primary, // primary brand surface
61
+ cyan: paint.brand.data, // code / file paths
62
+ cyanRegular: paint.brand.data,
63
+ cyanBold: (s) => paint.bold(paint.brand.data(s)),
64
+
65
+ // Text semantics
66
+ white: paint.text.primary, // primary text
67
+ gray: paint.text.dim, // hints, metadata, dim text
68
+
69
+ // Backgrounds — kept as raw ANSI; rarely used and have no palette analog
70
+ bgRed: (s) => `${ESC}41m${String(s ?? '')}${ESC}0m`,
71
+ bgGreen: (s) => `${ESC}42m${String(s ?? '')}${ESC}0m`,
72
+ bgCyan: (s) => `${ESC}46m${String(s ?? '')}${ESC}0m`,
51
73
  };
52
74
 
53
75
  // ── Box Drawing ──
@@ -399,7 +421,7 @@ export function formatElapsed(startMs) {
399
421
 
400
422
  // ── Format Cost ──
401
423
 
402
- import { calculateCost, formatCostValue } from '../core/pricing.mjs';
424
+ import { calculateCost, formatCostValue, costToCredits, formatCredits } from '../core/pricing.mjs';
403
425
 
404
426
  /**
405
427
  * Format cost from token counts.
@@ -407,15 +429,13 @@ import { calculateCost, formatCostValue } from '../core/pricing.mjs';
407
429
  * or a single usage object with optional per-model breakdown.
408
430
  */
409
431
  export function formatCost(inputOrUsage, outputTokens) {
410
- // New API: pass a usage object directly
411
432
  if (typeof inputOrUsage === 'object' && inputOrUsage !== null) {
412
433
  const { total } = calculateCost(inputOrUsage);
413
- return formatCostValue(total);
434
+ return formatCredits(costToCredits(total));
414
435
  }
415
- // Legacy API: flat input/output token counts, default pricing
416
436
  const { total } = calculateCost({
417
437
  input_tokens: inputOrUsage || 0,
418
438
  output_tokens: outputTokens || 0,
419
439
  });
420
- return formatCostValue(total);
440
+ return formatCredits(costToCredits(total));
421
441
  }