@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.
@@ -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');
@@ -113,6 +114,49 @@ export function createToolExecutor({
113
114
  }
114
115
  }
115
116
 
117
+ // ── Post-edit verification hint ──────────────────────────────
118
+ // Appended to edit_file/write_file results so the model knows
119
+ // exactly how to verify. Uses detected project commands.
120
+
121
+ function verificationHint(filePath) {
122
+ const project = projectRegistry.projectForPath(filePath);
123
+ const commands = project?.resource?.commands || {};
124
+ const parts = [];
125
+ if (commands.test) {
126
+ parts.push(`Run tests: ${commands.test}`);
127
+ }
128
+ if (parts.length === 0) {
129
+ const ext = path.extname(filePath);
130
+ if (ext === '.py') parts.push('Run tests: python -m pytest');
131
+ else if (['.js', '.ts', '.tsx', '.mjs'].includes(ext)) parts.push('Run tests: npm test');
132
+ }
133
+ return parts.length ? `\n--- Verify ---\n${parts.join('\n')}` : '';
134
+ }
135
+
136
+ // ── Solution nudge after exploration ───────────────────────
137
+ // After the agent has read enough code, nudge it to formulate
138
+ // a solution based on the goal — not to blindly edit, but to
139
+ // synthesize what it learned into a fix approach.
140
+ let _codeReadsCount = 0;
141
+ let _hasEdited = false;
142
+
143
+ function solutionNudge(filePath) {
144
+ const ext = path.extname(filePath).toLowerCase();
145
+ const isCode = ['.py', '.js', '.ts', '.tsx', '.mjs', '.go', '.rs', '.java', '.rb'].includes(ext);
146
+ if (!isCode || _hasEdited) return '';
147
+
148
+ _codeReadsCount++;
149
+ if (_codeReadsCount < 4) return '';
150
+
151
+ // Only nudge once at threshold, not every read after
152
+ if (_codeReadsCount === 4) {
153
+ return '\n\n--- You have explored enough code to formulate a solution. ' +
154
+ 'Based on what you have read, determine the fix and apply it. ' +
155
+ 'If the approach is unclear, call plan() with your findings. ---';
156
+ }
157
+ return '';
158
+ }
159
+
116
160
  // ── Tool mapping table ──────────────────────────────────────
117
161
 
118
162
  const toolMap = {
@@ -239,10 +283,11 @@ export function createToolExecutor({
239
283
  });
240
284
  const output = typeof result === 'string' ? result : String(result);
241
285
  const content = output.replace(/^\s*\d+[→\t]/gm, '');
286
+ const actNudge = solutionNudge(filePath);
242
287
  return {
243
288
  success: !isError(output),
244
289
  content,
245
- output: output + nudge,
290
+ output: output + nudge + actNudge,
246
291
  _tool: 'read_file',
247
292
  _output_type: 'file_content',
248
293
  };
@@ -265,6 +310,10 @@ export function createToolExecutor({
265
310
  await occRegistry.call('Read', { file_path: filePath, limit: 1 });
266
311
  }
267
312
  } catch { /* file may not exist yet */ }
313
+ // Checkpoint before overwrite so /undo can restore the previous content.
314
+ if (checkpoints && fs.existsSync(filePath)) {
315
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
316
+ }
268
317
  const result = await occRegistry.call('Write', {
269
318
  file_path: filePath,
270
319
  content: args.content,
@@ -275,10 +324,14 @@ export function createToolExecutor({
275
324
  // Auto-lint the written file
276
325
  const lintOutput = autoLint(filePath);
277
326
  if (lintOutput) {
278
- wrapped.output += `\n\n--- Lint result ---\n${lintOutput}`;
327
+ wrapped.output += `\n\n--- Lint ---\n${lintOutput}`;
279
328
  wrapped.lint = lintOutput;
280
329
  }
281
330
 
331
+ // Nudge: tell the model how to verify
332
+ const hint = verificationHint(filePath);
333
+ if (hint) wrapped.output += hint;
334
+
282
335
  return wrapped;
283
336
  },
284
337
 
@@ -357,6 +410,11 @@ export function createToolExecutor({
357
410
  await occRegistry.call('Read', { file_path: filePath, limit: 1 });
358
411
  } catch { /* best effort */ }
359
412
 
413
+ // Checkpoint before edit so /undo can restore the previous content.
414
+ if (checkpoints) {
415
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
416
+ }
417
+
360
418
  let result;
361
419
  try {
362
420
  result = await occRegistry.call('Edit', {
@@ -395,14 +453,19 @@ print('OK: replaced')
395
453
 
396
454
  const wrapped = wrapResult(result, 'edit_file');
397
455
  updateProjectIndex(filePath);
456
+ _hasEdited = true;
398
457
 
399
458
  // Auto-lint the edited file
400
459
  const lintOutput = autoLint(filePath);
401
460
  if (lintOutput) {
402
- wrapped.output += `\n\n--- Lint result ---\n${lintOutput}`;
461
+ wrapped.output += `\n\n--- Lint ---\n${lintOutput}`;
403
462
  wrapped.lint = lintOutput;
404
463
  }
405
464
 
465
+ // Nudge: tell the model how to verify
466
+ const hint = verificationHint(filePath);
467
+ if (hint) wrapped.output += hint;
468
+
406
469
  return wrapped;
407
470
  },
408
471
 
@@ -457,9 +520,16 @@ print('OK: replaced')
457
520
  }
458
521
  } catch { /* rg not found or no results */ }
459
522
 
460
- // Layer 2: BM25semantic relevance (finds related code even without exact match)
523
+ // Layer 2: Symbol search AST-extracted functions/classes with signatures
461
524
  if (project?.retriever) {
462
525
  if (!project.retriever.index) project.retriever.loadIndex();
526
+ const symbols = project.retriever.searchSymbols(query, 5);
527
+ if (symbols.length > 0) {
528
+ const symOutput = project.retriever.formatSymbolResults(symbols);
529
+ parts.push(`## Symbols (functions/classes)\n${symOutput}`);
530
+ }
531
+
532
+ // Layer 3: BM25 chunks — broader context when symbols aren't enough
463
533
  const chunks = project.retriever.retrieve(query, 5);
464
534
  if (chunks.length > 0) {
465
535
  const bm25Output = chunks.map(c => {
@@ -582,7 +652,7 @@ print('OK: replaced')
582
652
  return { success: true, files: results, _tool: 'read_files' };
583
653
  },
584
654
 
585
- // 9. delete_file + safety check
655
+ // 9. delete_file + safety check + checkpoint for undo
586
656
  delete_file: async (args) => {
587
657
  try {
588
658
  const filePath = resolvePath(args.file_path || args.path, args);
@@ -590,6 +660,9 @@ print('OK: replaced')
590
660
  if (!delCheck.safe) {
591
661
  return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
592
662
  }
663
+ if (checkpoints) {
664
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
665
+ }
593
666
  fs.unlinkSync(filePath);
594
667
  updateProjectIndex(filePath);
595
668
  return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
@@ -0,0 +1,274 @@
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
+ function checkAuthToken(auth) {
41
+ const creds = auth.loadCredentials();
42
+ if (creds.token) return { status: 'ok', label: `Auth token` };
43
+ return { status: 'warn', label: 'Auth token missing', hint: '/login to sign in' };
44
+ }
45
+
46
+ function checkProviderKey(auth) {
47
+ const creds = auth.loadCredentials();
48
+ if (creds.openRouterKey) return { status: 'ok', label: 'OpenRouter key' };
49
+ if (creds.anthropicKey) return { status: 'ok', label: 'Anthropic key' };
50
+ if (creds.openaiKey) return { status: 'ok', label: 'OpenAI key' };
51
+ if (creds.googleKey) return { status: 'ok', label: 'Google key' };
52
+ return { status: 'warn', label: 'No model provider key configured', hint: 'set OPENROUTER_API_KEY or run /config' };
53
+ }
54
+
55
+ async function checkBackend(auth, { timeoutMs = 1500 } = {}) {
56
+ const creds = auth.loadCredentials();
57
+ const url = creds.backendUrl;
58
+ if (!url) return { status: 'warn', label: 'Backend not configured' };
59
+ try {
60
+ const reachable = await ping(url, timeoutMs);
61
+ if (reachable) return { status: 'ok', label: `Backend ${shorten(url, 48)}` };
62
+ return { status: 'warn', label: `Backend ${shorten(url, 48)}`, hint: 'unreachable — check network or start backend' };
63
+ } catch {
64
+ return { status: 'warn', label: `Backend ${shorten(url, 48)}`, hint: 'unreachable' };
65
+ }
66
+ }
67
+
68
+ function checkGit(cwd) {
69
+ if (!hasGitDir(cwd)) return { status: 'warn', label: 'Not a git repository', hint: '`git init` to enable diff / checkpoints' };
70
+ try {
71
+ const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { cwd, encoding: 'utf-8' }).trim();
72
+ const status = execSync('git status --porcelain 2>/dev/null', { cwd, encoding: 'utf-8' });
73
+ const dirty = status.split('\n').filter(Boolean).length;
74
+ const summary = dirty > 0
75
+ ? `${branch} · ${paint.state.warn(`${dirty} dirty`)}`
76
+ : `${branch} · clean`;
77
+ return { status: 'ok', label: `Git repository ${summary}` };
78
+ } catch {
79
+ return { status: 'warn', label: 'Git repository present but unreadable' };
80
+ }
81
+ }
82
+
83
+ function checkLinters(cwd) {
84
+ const present = [];
85
+ const missing = [];
86
+ for (const [name, kind] of LINTERS) {
87
+ if (which(name)) present.push({ name, kind });
88
+ else if (projectUses(cwd, kind)) missing.push({ name, kind });
89
+ }
90
+ if (present.length === 0 && missing.length === 0) {
91
+ return { status: 'ok', label: 'Linters none required' };
92
+ }
93
+ if (missing.length === 0) {
94
+ return { status: 'ok', label: `Linters ${present.map(p => p.name).join(', ')}` };
95
+ }
96
+ const hint = missing.map(m => `/install ${m.name} to enable lint_check for ${m.kind}`).join(' · ');
97
+ return { status: 'warn', label: `Linter (${missing.map(m => m.name).join(', ')}) not found`, hint };
98
+ }
99
+
100
+ const LINTERS = [
101
+ ['ruff', 'python'],
102
+ ['eslint', 'javascript'],
103
+ ['tsc', 'typescript'],
104
+ ['cargo', 'rust'],
105
+ ];
106
+
107
+ function projectUses(cwd, kind) {
108
+ try {
109
+ const files = fs.readdirSync(cwd);
110
+ switch (kind) {
111
+ case 'python': return files.some(f => /\.py$/.test(f)) || files.includes('pyproject.toml') || files.includes('requirements.txt');
112
+ case 'javascript': return files.includes('package.json');
113
+ case 'typescript': return files.includes('tsconfig.json');
114
+ case 'rust': return files.includes('Cargo.toml');
115
+ default: return false;
116
+ }
117
+ } catch { return false; }
118
+ }
119
+
120
+ function checkProjectMap(cwd) {
121
+ try {
122
+ const counts = quickFileCount(cwd, { max: 5000 });
123
+ if (!counts.total) return { status: 'warn', label: 'Project map no files indexed yet' };
124
+ const langs = topLanguages(counts.byExt, 2);
125
+ const langStr = langs.length ? langs.join(' + ') : 'mixed';
126
+ return { status: 'ok', label: `Project map ${counts.total} files, ${langStr}` };
127
+ } catch {
128
+ return { status: 'warn', label: 'Project map unreadable' };
129
+ }
130
+ }
131
+
132
+ // ── Helpers ─────────────────────────────────────────────────────────────
133
+
134
+ function shorten(s, n) {
135
+ const str = String(s || '');
136
+ return str.length <= n ? str : str.slice(0, n - 1) + '…';
137
+ }
138
+
139
+ function hasGitDir(cwd) {
140
+ let dir = cwd;
141
+ for (let i = 0; i < 6; i++) {
142
+ if (fs.existsSync(path.join(dir, '.git'))) return true;
143
+ const parent = path.dirname(dir);
144
+ if (parent === dir) break;
145
+ dir = parent;
146
+ }
147
+ return false;
148
+ }
149
+
150
+ function which(name) {
151
+ try {
152
+ execSync(`command -v ${name}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
153
+ return true;
154
+ } catch { return false; }
155
+ }
156
+
157
+ function ping(url, timeoutMs) {
158
+ return new Promise((resolve) => {
159
+ let u;
160
+ try { u = new URL(url); } catch { resolve(false); return; }
161
+ const lib = u.protocol === 'https:' ? https : http;
162
+ const req = lib.request({
163
+ hostname: u.hostname,
164
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
165
+ path: u.pathname || '/',
166
+ method: 'GET',
167
+ timeout: timeoutMs,
168
+ }, (res) => {
169
+ // Any response means the host is reachable, even 404.
170
+ res.resume();
171
+ resolve(true);
172
+ });
173
+ req.on('error', () => resolve(false));
174
+ req.on('timeout', () => { try { req.destroy(); } catch {} resolve(false); });
175
+ req.end();
176
+ });
177
+ }
178
+
179
+ const EXT_TO_LANG = {
180
+ '.py': 'Python', '.ts': 'TypeScript', '.tsx': 'TypeScript', '.js': 'JavaScript',
181
+ '.jsx': 'JavaScript', '.mjs': 'JavaScript', '.go': 'Go', '.rs': 'Rust',
182
+ '.java': 'Java', '.rb': 'Ruby', '.php': 'PHP', '.swift': 'Swift', '.kt': 'Kotlin',
183
+ '.c': 'C', '.cc': 'C++', '.cpp': 'C++', '.h': 'C/C++', '.hpp': 'C++',
184
+ };
185
+
186
+ function topLanguages(byExt, n) {
187
+ const ranked = Object.entries(byExt)
188
+ .map(([ext, count]) => [EXT_TO_LANG[ext], count])
189
+ .filter(([lang]) => lang)
190
+ .reduce((acc, [lang, count]) => { acc.set(lang, (acc.get(lang) || 0) + count); return acc; }, new Map());
191
+ return [...ranked.entries()]
192
+ .sort((a, b) => b[1] - a[1])
193
+ .slice(0, n)
194
+ .map(([lang]) => lang);
195
+ }
196
+
197
+ function quickFileCount(cwd, { max = 5000 } = {}) {
198
+ // Shallow walk: skip node_modules, .git, dist, build, .venv, __pycache__.
199
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.venv', 'venv', '__pycache__', '.kepler', '.terraform']);
200
+ const byExt = {};
201
+ let total = 0;
202
+ const stack = [cwd];
203
+ while (stack.length && total < max) {
204
+ const dir = stack.pop();
205
+ let entries;
206
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
207
+ catch { continue; }
208
+ for (const e of entries) {
209
+ if (e.name.startsWith('.') && e.name !== '.kepler') continue;
210
+ if (SKIP.has(e.name)) continue;
211
+ const full = path.join(dir, e.name);
212
+ if (e.isDirectory()) stack.push(full);
213
+ else if (e.isFile()) {
214
+ total++;
215
+ const ext = path.extname(e.name).toLowerCase();
216
+ if (ext) byExt[ext] = (byExt[ext] || 0) + 1;
217
+ if (total >= max) break;
218
+ }
219
+ }
220
+ }
221
+ return { total, byExt };
222
+ }
223
+
224
+ // ── Renderer ────────────────────────────────────────────────────────────
225
+
226
+ function formatRow(check) {
227
+ switch (check.status) {
228
+ case 'ok': return ` ${OK(paint.text.primary(check.label))}`;
229
+ case 'warn': return ` ${WARN(paint.text.primary(check.label))}` +
230
+ (check.hint ? ` ${paint.text.dim('→ ' + check.hint)}` : '');
231
+ case 'fail': return ` ${FAIL(paint.text.primary(check.label))}` +
232
+ (check.hint ? ` ${paint.text.dim('→ ' + check.hint)}` : '');
233
+ default: return ` ${paint.text.dim(check.label)}`;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Run the preflight diagnostic. Writes to stderr and resolves with the
239
+ * collected check results.
240
+ *
241
+ * @param {object} opts
242
+ * @param {object} opts.auth — TarangAuth instance
243
+ * @param {string} opts.cwd — working directory
244
+ * @param {string} opts.version — package version string
245
+ * @param {boolean} [opts.silent] — if true, do not write (useful for tests)
246
+ */
247
+ export async function runPreflight({ auth, cwd, version, silent = false } = {}) {
248
+ const t = term();
249
+ const write = (s) => { if (!silent) process.stderr.write(s); };
250
+
251
+ const header = `${icons.search} ${paint.bold(paint.brand.primary('Kepler v' + (version || '?')))} ${paint.text.dim('· initializing orbit')}`;
252
+ write('\n' + header + '\n\n');
253
+
254
+ const checks = [];
255
+ checks.push(checkAuthToken(auth));
256
+ checks.push(checkProviderKey(auth));
257
+ checks.push(await checkBackend(auth));
258
+ checks.push(checkGit(cwd));
259
+ checks.push(checkLinters(cwd));
260
+ checks.push(checkProjectMap(cwd));
261
+
262
+ for (const c of checks) write(formatRow(c) + '\n');
263
+
264
+ const fails = checks.filter(c => c.status === 'fail').length;
265
+ const warns = checks.filter(c => c.status === 'warn').length;
266
+ const tail = fails === 0 && warns === 0
267
+ ? paint.state.success('All systems aligned.')
268
+ : fails > 0
269
+ ? paint.state.danger(`${fails} blocker${fails === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'} — see hints above.`)
270
+ : paint.state.warn(`${warns} warning${warns === 1 ? '' : 's'} — non-blocking.`);
271
+
272
+ write('\n ' + tail + '\n\n');
273
+ return checks;
274
+ }