@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.
@@ -9,8 +9,8 @@
9
9
 
10
10
  const BACKEND_URLS = {
11
11
  local: 'http://127.0.0.1:8000',
12
- development: 'https://tarang-backend-development.up.railway.app',
13
- production: 'https://tarang-backend-intl-web-app-production.up.railway.app',
12
+ development: 'https://codekepler-backend-dev.kindisland-9034322d.eastus.azurecontainerapps.io',
13
+ production: 'https://codekepler-backend-prod.redsky-6d31f3e5.eastus.azurecontainerapps.io',
14
14
  };
15
15
 
16
16
  // Aliases
@@ -188,6 +188,11 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
188
188
  toolBreakdown[t.tool] = (toolBreakdown[t.tool] || 0) + 1;
189
189
  }
190
190
 
191
+ // Include sub-agent tool counts in the total
192
+ for (const sa of subAgents) {
193
+ toolCount += sa.tool_calls || 0;
194
+ }
195
+
191
196
  emit({
192
197
  type: 'complete',
193
198
  tools: toolCount,
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Risk tier classifier — Mission Control (PRD-055 §8.1).
3
+ *
4
+ * import { classify, TIERS, behavior } from './risk-tier.mjs';
5
+ * const tier = classify('shell', { command: 'rm -rf node_modules' });
6
+ * // → 'shell-dangerous'
7
+ *
8
+ * Pure: no I/O, no async. Tested in isolation; the rest of the CLI relies on
9
+ * the return value to decide whether to auto-approve, auto-approve with
10
+ * checkpoint, or hold for explicit approval.
11
+ *
12
+ * The CLI never asks the backend for the tier. The backend's job is to say
13
+ * "this is what I want to run"; we map that to a tier locally so dangerous
14
+ * intent can't be hidden behind a friendly description.
15
+ */
16
+
17
+ // ── Tier enum ────────────────────────────────────────────────────────────
18
+
19
+ export const TIERS = Object.freeze({
20
+ READ: 'read',
21
+ LOCAL_EDIT: 'local-edit',
22
+ SHELL_SAFE: 'shell-safe',
23
+ SHELL_MEDIUM: 'shell-medium',
24
+ SHELL_DANGEROUS: 'shell-dangerous',
25
+ DESTRUCTIVE: 'destructive',
26
+ NETWORK: 'network',
27
+ });
28
+
29
+ /**
30
+ * Default behavior by tier:
31
+ * auto — proceed silently
32
+ * auto-with-undo — proceed but record a checkpoint first
33
+ * prompt-safe — prompt with Enter=approve default
34
+ * prompt-explicit — magenta-bordered prompt, no default
35
+ */
36
+ export const BEHAVIOR = Object.freeze({
37
+ [TIERS.READ]: 'auto',
38
+ [TIERS.LOCAL_EDIT]: 'auto-with-undo',
39
+ [TIERS.SHELL_SAFE]: 'auto',
40
+ [TIERS.SHELL_MEDIUM]: 'prompt-safe',
41
+ [TIERS.SHELL_DANGEROUS]: 'prompt-explicit',
42
+ [TIERS.DESTRUCTIVE]: 'prompt-explicit',
43
+ [TIERS.NETWORK]: 'prompt-safe',
44
+ });
45
+
46
+ export function behavior(tier) {
47
+ return BEHAVIOR[tier] || 'prompt-safe';
48
+ }
49
+
50
+ // ── Tool → tier (non-shell) ─────────────────────────────────────────────
51
+
52
+ const READ_TOOLS = new Set([
53
+ 'read_file', 'read_files',
54
+ 'search_code', 'search_files', 'grep',
55
+ 'list_files', 'get_file_info', 'get_project_overview',
56
+ 'git_status', 'git_diff',
57
+ 'analyze_code',
58
+ 'validate_file', 'validate_structure',
59
+ ]);
60
+
61
+ const LOCAL_EDIT_TOOLS = new Set([
62
+ 'edit_file', 'write_file', 'write_project',
63
+ ]);
64
+
65
+ const DESTRUCTIVE_TOOLS = new Set([
66
+ 'delete_file',
67
+ ]);
68
+
69
+ const NETWORK_TOOLS = new Set([
70
+ 'WebFetch', 'fetch_url',
71
+ ]);
72
+
73
+ // ── Shell sub-classifier ────────────────────────────────────────────────
74
+
75
+ const SHELL_SAFE_RE = [
76
+ // Inspection / read-only + harmless shell navigation built-ins.
77
+ // `cd` / `pushd` / `popd` only change the process working directory; if
78
+ // chained with something dangerous, the multi-segment classifier still
79
+ // catches the danger (`cd /x && rm -rf .` → SHELL_DANGEROUS).
80
+ /^\s*(cd|pushd|popd|ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|fd|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)\b/i,
81
+ // mkdir -p / touch are creation primitives but harmless in scope.
82
+ /^\s*mkdir\s+-p\b/i,
83
+ /^\s*touch\s/i,
84
+ /^\s*git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|blame|shortlog|describe|rev-parse|ls-files|ls-tree|config\s+--get)\b/i,
85
+ // Test-only invocations
86
+ /^\s*(npm|pnpm|yarn)\s+(test|run\s+test|run\s+lint|list|ls|view|info|outdated)\b/i,
87
+ /^\s*node\s+--check\b/i,
88
+ /^\s*python3?\s+-m\s+py_compile\b/i,
89
+ /^\s*pytest\b(?!.*--?(delete|rm|destructive))/i,
90
+ /^\s*cargo\s+(check|test|clippy|build)\b/i,
91
+ /^\s*go\s+(test|vet|build)\b/i,
92
+ /^\s*make\s+(test|check|lint|build)\b/i,
93
+ ];
94
+
95
+ const SHELL_DANGEROUS_RE = [
96
+ /\brm\s+-r/i,
97
+ /\brm\s+--recursive/i,
98
+ /\brm\s+-rf?\b/i,
99
+ /\bunlink\s/i,
100
+ /\brmdir\s+-/i,
101
+ /\bgit\s+push.*--force/i,
102
+ /\bgit\s+push.*-f\b/i,
103
+ /\bgit\s+reset\s+--hard/i,
104
+ /\bgit\s+clean\s+-f/i,
105
+ /\bgit\s+checkout\s+\./i,
106
+ /\bgit\s+stash\s+drop/i,
107
+ /\bgit\s+branch\s+-D/i,
108
+ /\bgit\s+filter-branch/i,
109
+ /\bsudo\b/i,
110
+ /\bsu\s+-/i,
111
+ /\bcurl\b.*\|\s*(sh|bash|zsh)/i,
112
+ /\bwget\b.*\|\s*(sh|bash|zsh)/i,
113
+ /\beval\s+["'$(]/i,
114
+ /\bkubectl\s+delete/i,
115
+ /\bdocker\s+(rm|rmi|system\s+prune|volume\s+rm|network\s+rm)/i,
116
+ /\bdrop\s+(table|database|schema)/i,
117
+ /\btruncate\s+table/i,
118
+ /\bmkfs\b/i,
119
+ /\bdd\s+if=/i,
120
+ /:\s*\(\s*\)\s*\{.*:\|/i, // fork bomb
121
+ />\s*\/dev\/sda/i,
122
+ ];
123
+
124
+ const SHELL_MEDIUM_RE = [
125
+ /^\s*(npm|pnpm|yarn)\s+(install|i|add|remove|uninstall|update|upgrade|publish|deploy)\b/i,
126
+ /^\s*pip\s+(install|uninstall|--upgrade)\b/i,
127
+ /^\s*pipx\s+(install|uninstall)\b/i,
128
+ /^\s*brew\s+(install|uninstall|upgrade|update)\b/i,
129
+ /^\s*apt(-get)?\s+(install|remove|upgrade|update)\b/i,
130
+ /^\s*cargo\s+(install|uninstall|publish|run)\b/i,
131
+ /^\s*go\s+(install|get|mod\s+tidy|mod\s+download)\b/i,
132
+ /^\s*make(\s|$)/i,
133
+ /^\s*git\s+(commit|push|pull|merge|rebase|fetch|checkout(?!\s+\.)|cherry-pick|revert|tag|stash(?!\s+drop))/i,
134
+ /^\s*docker\s+(build|run|exec|compose|pull|push|tag)/i,
135
+ ];
136
+
137
+ export function classifyShell(command) {
138
+ const cmd = String(command || '').trim();
139
+ if (!cmd) return TIERS.SHELL_MEDIUM;
140
+
141
+ // Dangerous wins over safe — never let a safe-looking prefix mask `&& rm -rf`.
142
+ if (SHELL_DANGEROUS_RE.some(re => re.test(cmd))) return TIERS.SHELL_DANGEROUS;
143
+
144
+ // For a chained command, classify each segment and take the riskiest —
145
+ // never let a safe-looking prefix mask `&& npm install` or worse.
146
+ if (/&&|\|\||;|\|(?!\|)/.test(cmd)) {
147
+ const segments = splitShellSegments(cmd);
148
+ if (segments.length > 1) {
149
+ let worst = TIERS.SHELL_SAFE;
150
+ for (const seg of segments) {
151
+ const t = classifyShell(seg);
152
+ worst = riskier(worst, t);
153
+ if (worst === TIERS.SHELL_DANGEROUS) return worst;
154
+ }
155
+ return worst;
156
+ }
157
+ }
158
+
159
+ if (SHELL_MEDIUM_RE.some(re => re.test(cmd))) return TIERS.SHELL_MEDIUM;
160
+ if (SHELL_SAFE_RE.some(re => re.test(cmd))) return TIERS.SHELL_SAFE;
161
+ return TIERS.SHELL_MEDIUM;
162
+ }
163
+
164
+ function splitShellSegments(cmd) {
165
+ // Split on top-level &&, ||, ;, | — naive but enough for the classifier.
166
+ return cmd.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean);
167
+ }
168
+
169
+ const TIER_ORDER = [
170
+ TIERS.READ,
171
+ TIERS.SHELL_SAFE,
172
+ TIERS.LOCAL_EDIT,
173
+ TIERS.NETWORK,
174
+ TIERS.SHELL_MEDIUM,
175
+ TIERS.DESTRUCTIVE,
176
+ TIERS.SHELL_DANGEROUS,
177
+ ];
178
+
179
+ function riskier(a, b) {
180
+ return TIER_ORDER.indexOf(b) > TIER_ORDER.indexOf(a) ? b : a;
181
+ }
182
+
183
+ // ── Top-level classify ──────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Classify a tool call into a risk tier. Always returns one of TIERS.
187
+ *
188
+ * @param {string} tool Tool name (e.g. 'shell', 'edit_file')
189
+ * @param {object} args Tool arguments (e.g. { command: 'rm -rf x' })
190
+ */
191
+ export function classify(tool, args = {}) {
192
+ if (!tool) return TIERS.SHELL_MEDIUM;
193
+
194
+ if (READ_TOOLS.has(tool)) return TIERS.READ;
195
+ if (LOCAL_EDIT_TOOLS.has(tool)) return TIERS.LOCAL_EDIT;
196
+ if (DESTRUCTIVE_TOOLS.has(tool)) return TIERS.DESTRUCTIVE;
197
+ if (NETWORK_TOOLS.has(tool)) return TIERS.NETWORK;
198
+
199
+ if (tool === 'shell' || tool === 'run_tests' || tool === 'validate_build' || tool === 'lint_check') {
200
+ return classifyShell(args.command || args.cmd || '');
201
+ }
202
+
203
+ // Sub-agents inherit their parent's risk (read-ish by default).
204
+ if (['explore', 'plan', 'verify', 'debug', 'refactor', 'analyze_code'].includes(tool)) {
205
+ return TIERS.READ;
206
+ }
207
+
208
+ // MCP tools: assume network unless the name implies read.
209
+ if (tool.startsWith('mcp')) {
210
+ return /(?:read|get|list|search|describe|info|status)/i.test(tool) ? TIERS.READ : TIERS.NETWORK;
211
+ }
212
+
213
+ return TIERS.SHELL_MEDIUM;
214
+ }
215
+
216
+ /**
217
+ * Convenience: human label for a tier (used in approval prompts and the
218
+ * status bar). Returned strings already uppercased / hyphenated.
219
+ */
220
+ export function label(tier) {
221
+ switch (tier) {
222
+ case TIERS.READ: return 'READ';
223
+ case TIERS.LOCAL_EDIT: return 'LOCAL-EDIT';
224
+ case TIERS.SHELL_SAFE: return 'SHELL-SAFE';
225
+ case TIERS.SHELL_MEDIUM: return 'SHELL-MEDIUM';
226
+ case TIERS.SHELL_DANGEROUS: return 'SHELL-DANGEROUS';
227
+ case TIERS.DESTRUCTIVE: return 'DESTRUCTIVE';
228
+ case TIERS.NETWORK: return 'NETWORK';
229
+ default: return String(tier || '').toUpperCase();
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Whether the tool needs an explicit human keystroke before running.
235
+ */
236
+ export function requiresExplicitApproval(tier) {
237
+ return behavior(tier) === 'prompt-explicit';
238
+ }
239
+
240
+ /**
241
+ * Whether the tier should auto-create an undo checkpoint before running.
242
+ */
243
+ export function requiresCheckpoint(tier) {
244
+ return behavior(tier) === 'auto-with-undo';
245
+ }
@@ -93,14 +93,23 @@ export class TarangStreamClient {
93
93
  };
94
94
  if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
95
95
 
96
+ // Abort controller so cancel() can break out of a stalled reader
97
+ // instead of waiting for the next SSE event to notice _cancelled.
98
+ this._abort = new AbortController();
99
+
96
100
  let response;
97
101
  try {
98
102
  response = await fetch(url, {
99
103
  method: 'POST',
100
104
  headers,
101
105
  body: JSON.stringify(body),
106
+ signal: this._abort.signal,
102
107
  });
103
108
  } catch (err) {
109
+ if (err.name === 'AbortError') {
110
+ yield { type: EVENT_TYPES.STATUS, data: { message: 'Cancelled by user.' } };
111
+ return;
112
+ }
104
113
  yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, fatal: true } };
105
114
  return;
106
115
  }
@@ -175,7 +184,15 @@ export class TarangStreamClient {
175
184
 
176
185
  try {
177
186
  while (true) {
178
- const { done, value } = await reader.read();
187
+ let read;
188
+ try {
189
+ read = await reader.read();
190
+ } catch (err) {
191
+ // Aborted via cancel() — treat as a clean end-of-stream.
192
+ if (err && (err.name === 'AbortError' || this._cancelled)) break;
193
+ throw err;
194
+ }
195
+ const { done, value } = read;
179
196
  if (done) break;
180
197
 
181
198
  buffer += decoder.decode(value, { stream: true });
@@ -335,6 +352,7 @@ export class TarangStreamClient {
335
352
  /** Cancel the current stream. */
336
353
  async cancel() {
337
354
  this._cancelled = true;
355
+ // Best-effort backend POST — the stream may already be torn down.
338
356
  if (this.currentTaskId) {
339
357
  try {
340
358
  await fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
@@ -343,6 +361,11 @@ export class TarangStreamClient {
343
361
  });
344
362
  } catch { /* best effort */ }
345
363
  }
364
+ // Force the in-flight SSE reader to abort so the REPL returns to the
365
+ // prompt immediately instead of waiting on a parked reader.read().
366
+ if (this._abort) {
367
+ try { this._abort.abort(); } catch {}
368
+ }
346
369
  }
347
370
 
348
371
  /** Pause the current stream. */
@@ -28,6 +28,7 @@ import { execSync } from 'node:child_process';
28
28
  export function createToolExecutor({
29
29
  projectRegistry = new ProjectRegistry(),
30
30
  skillsLoader = new SkillsLoader().load(process.cwd()),
31
+ checkpoints = null,
31
32
  } = {}) {
32
33
  const occRegistry = createToolRegistry();
33
34
  const skillTool = occRegistry.get('Skill');
@@ -90,6 +91,12 @@ export function createToolExecutor({
90
91
  '.rs': (file) => `rustfmt --check "${file}" 2>&1`,
91
92
  };
92
93
 
94
+ // tsc --pretty and eslint emit ANSI codes (including background-red
95
+ // highlights) which bleed when our renderer slices the first 80 chars.
96
+ // Strip color codes so the stored lint string is always plain text.
97
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
98
+ function stripAnsi(s) { return String(s || '').replace(ANSI_RE, ''); }
99
+
93
100
  function autoLint(filePath) {
94
101
  const ext = path.extname(filePath);
95
102
  const cmdFn = LINT_COMMANDS[ext];
@@ -101,13 +108,14 @@ export function createToolExecutor({
101
108
  timeout: 15_000,
102
109
  cwd: process.cwd(),
103
110
  stdio: ['pipe', 'pipe', 'pipe'],
111
+ env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb' },
104
112
  });
105
- const trimmed = output.trim();
113
+ const trimmed = stripAnsi(output).trim();
106
114
  if (!trimmed) return null;
107
115
  return trimmed;
108
116
  } catch (err) {
109
117
  // Non-zero exit means lint errors found
110
- const output = (err.stderr || err.stdout || '').trim();
118
+ const output = stripAnsi(err.stderr || err.stdout || '').trim();
111
119
  if (!output) return null;
112
120
  return output;
113
121
  }
@@ -132,6 +140,30 @@ export function createToolExecutor({
132
140
  return parts.length ? `\n--- Verify ---\n${parts.join('\n')}` : '';
133
141
  }
134
142
 
143
+ // ── Solution nudge after exploration ───────────────────────
144
+ // After the agent has read enough code, nudge it to formulate
145
+ // a solution based on the goal — not to blindly edit, but to
146
+ // synthesize what it learned into a fix approach.
147
+ let _codeReadsCount = 0;
148
+ let _hasEdited = false;
149
+
150
+ function solutionNudge(filePath) {
151
+ const ext = path.extname(filePath).toLowerCase();
152
+ const isCode = ['.py', '.js', '.ts', '.tsx', '.mjs', '.go', '.rs', '.java', '.rb'].includes(ext);
153
+ if (!isCode || _hasEdited) return '';
154
+
155
+ _codeReadsCount++;
156
+ if (_codeReadsCount < 4) return '';
157
+
158
+ // Only nudge once at threshold, not every read after
159
+ if (_codeReadsCount === 4) {
160
+ return '\n\n--- You have explored enough code to formulate a solution. ' +
161
+ 'Based on what you have read, determine the fix and apply it. ' +
162
+ 'If the approach is unclear, call plan() with your findings. ---';
163
+ }
164
+ return '';
165
+ }
166
+
135
167
  // ── Tool mapping table ──────────────────────────────────────
136
168
 
137
169
  const toolMap = {
@@ -258,10 +290,11 @@ export function createToolExecutor({
258
290
  });
259
291
  const output = typeof result === 'string' ? result : String(result);
260
292
  const content = output.replace(/^\s*\d+[→\t]/gm, '');
293
+ const actNudge = solutionNudge(filePath);
261
294
  return {
262
295
  success: !isError(output),
263
296
  content,
264
- output: output + nudge,
297
+ output: output + nudge + actNudge,
265
298
  _tool: 'read_file',
266
299
  _output_type: 'file_content',
267
300
  };
@@ -284,6 +317,10 @@ export function createToolExecutor({
284
317
  await occRegistry.call('Read', { file_path: filePath, limit: 1 });
285
318
  }
286
319
  } catch { /* file may not exist yet */ }
320
+ // Checkpoint before overwrite so /undo can restore the previous content.
321
+ if (checkpoints && fs.existsSync(filePath)) {
322
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
323
+ }
287
324
  const result = await occRegistry.call('Write', {
288
325
  file_path: filePath,
289
326
  content: args.content,
@@ -380,6 +417,11 @@ export function createToolExecutor({
380
417
  await occRegistry.call('Read', { file_path: filePath, limit: 1 });
381
418
  } catch { /* best effort */ }
382
419
 
420
+ // Checkpoint before edit so /undo can restore the previous content.
421
+ if (checkpoints) {
422
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
423
+ }
424
+
383
425
  let result;
384
426
  try {
385
427
  result = await occRegistry.call('Edit', {
@@ -418,6 +460,7 @@ print('OK: replaced')
418
460
 
419
461
  const wrapped = wrapResult(result, 'edit_file');
420
462
  updateProjectIndex(filePath);
463
+ _hasEdited = true;
421
464
 
422
465
  // Auto-lint the edited file
423
466
  const lintOutput = autoLint(filePath);
@@ -484,9 +527,16 @@ print('OK: replaced')
484
527
  }
485
528
  } catch { /* rg not found or no results */ }
486
529
 
487
- // Layer 2: BM25semantic relevance (finds related code even without exact match)
530
+ // Layer 2: Symbol search AST-extracted functions/classes with signatures
488
531
  if (project?.retriever) {
489
532
  if (!project.retriever.index) project.retriever.loadIndex();
533
+ const symbols = project.retriever.searchSymbols(query, 5);
534
+ if (symbols.length > 0) {
535
+ const symOutput = project.retriever.formatSymbolResults(symbols);
536
+ parts.push(`## Symbols (functions/classes)\n${symOutput}`);
537
+ }
538
+
539
+ // Layer 3: BM25 chunks — broader context when symbols aren't enough
490
540
  const chunks = project.retriever.retrieve(query, 5);
491
541
  if (chunks.length > 0) {
492
542
  const bm25Output = chunks.map(c => {
@@ -609,7 +659,7 @@ print('OK: replaced')
609
659
  return { success: true, files: results, _tool: 'read_files' };
610
660
  },
611
661
 
612
- // 9. delete_file + safety check
662
+ // 9. delete_file + safety check + checkpoint for undo
613
663
  delete_file: async (args) => {
614
664
  try {
615
665
  const filePath = resolvePath(args.file_path || args.path, args);
@@ -617,6 +667,9 @@ print('OK: replaced')
617
667
  if (!delCheck.safe) {
618
668
  return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
619
669
  }
670
+ if (checkpoints) {
671
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
672
+ }
620
673
  fs.unlinkSync(filePath);
621
674
  updateProjectIndex(filePath);
622
675
  return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };