@axplusb/kepler 2.0.0 → 2.0.3

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.
@@ -8,6 +8,35 @@ import { buildProjectSkeleton } from '../context/skeleton.mjs';
8
8
  import { indexDir as getIndexDir } from '../core/paths.mjs';
9
9
 
10
10
  const RESOURCE_FILE = 'project-resource.json';
11
+
12
+ /**
13
+ * Expand "~" and trim surrounding quotes/whitespace. Does NOT unescape shell
14
+ * meta characters — that is a separate, last-resort step done only if the
15
+ * literal path does not resolve.
16
+ */
17
+ function normalizePathInput(p) {
18
+ let s = String(p || '').trim();
19
+ // Trim balanced surrounding quotes.
20
+ if ((s.startsWith('"') && s.endsWith('"')) ||
21
+ (s.startsWith("'") && s.endsWith("'"))) {
22
+ s = s.slice(1, -1);
23
+ }
24
+ // Tilde expansion (~ or ~/...).
25
+ if (s === '~' || s.startsWith('~/')) {
26
+ s = path.join(os.homedir(), s.slice(1));
27
+ }
28
+ return s;
29
+ }
30
+
31
+ /**
32
+ * Replace common shell escape sequences with their literal characters. Used
33
+ * as a fallback when the literal path does not resolve — the agent may have
34
+ * pasted a copy of what they would type at a shell prompt.
35
+ */
36
+ function unescapeShellPath(p) {
37
+ return String(p || '').replace(/\\([ \t()&$;'"])/g, '$1');
38
+ }
39
+
11
40
  const LANGUAGE_EXTENSIONS = new Map([
12
41
  ['.py', 'Python'],
13
42
  ['.js', 'JavaScript'],
@@ -280,6 +309,12 @@ export class ProjectRegistry {
280
309
  if (!rawPath) {
281
310
  throw new Error('get_project_overview requires a project path');
282
311
  }
312
+
313
+ // LLM sometimes passes shell-escaped paths ("Tarang\ Orca") or paths
314
+ // beginning with "~". Normalize defensively so the tool does not bounce
315
+ // back a "not found" error on a path that's correct apart from quoting.
316
+ rawPath = normalizePathInput(rawPath);
317
+
283
318
  if (!path.isAbsolute(rawPath)) {
284
319
  rawPath = path.resolve(process.cwd(), rawPath);
285
320
  }
@@ -288,7 +323,15 @@ export class ProjectRegistry {
288
323
  try {
289
324
  root = fs.realpathSync(rawPath);
290
325
  } catch {
291
- throw new Error(`Project path not found: ${rawPath}`);
326
+ // Try the unescaped variant explicitly so the error message can
327
+ // tell the agent what it actually attempted.
328
+ const unescaped = unescapeShellPath(rawPath);
329
+ if (unescaped !== rawPath) {
330
+ try { root = fs.realpathSync(unescaped); }
331
+ catch { throw new Error(`Project path not found: ${rawPath} (also tried ${unescaped})`); }
332
+ } else {
333
+ throw new Error(`Project path not found: ${rawPath}`);
334
+ }
292
335
  }
293
336
  if (!fs.statSync(root).isDirectory()) {
294
337
  throw new Error(`Project path is not a directory: ${root}`);
@@ -377,25 +420,64 @@ export class ProjectRegistry {
377
420
  if (!rawPath) {
378
421
  if (root) return root;
379
422
  if (this.projects.size === 1) return this.resources()[0].root;
380
- throw new Error('Path requires project_id when multiple or no projects are registered');
423
+ // Fall back to the first registered project when the model omits
424
+ // both path and project_id. Beats throwing on an inferable case.
425
+ const first = this.resources()[0];
426
+ if (first) return first.root;
427
+ throw new Error('No projects registered. Call get_project_overview first.');
381
428
  }
382
429
 
383
- let candidate;
384
- if (path.isAbsolute(rawPath)) {
385
- candidate = canonicalizeCandidate(path.resolve(rawPath));
386
- } else {
430
+ // LLM frequently passes shell-quoted paths copied from a terminal,
431
+ // e.g. "Tarang\ Orca/src/app/\(kepler\)/page.tsx". Normalize here so
432
+ // every tool benefits, not just get_project_overview.
433
+ rawPath = normalizePathInput(rawPath);
434
+
435
+ const buildCandidate = (input) => {
436
+ if (path.isAbsolute(input)) {
437
+ return canonicalizeCandidate(path.resolve(input));
438
+ }
387
439
  if (!root) {
388
- if (this.projects.size !== 1) {
389
- throw new Error('Relative path requires project_id when multiple or no projects are registered');
440
+ if (this.projects.size === 1) {
441
+ return canonicalizeCandidate(path.resolve(this.resources()[0].root, input));
390
442
  }
391
- root = this.resources()[0].root;
443
+ if (this.projects.size > 1) {
444
+ throw new Error('Relative path requires project_id when multiple projects are registered. Pass project_id or use an absolute path.');
445
+ }
446
+ throw new Error('No projects registered. Call get_project_overview first.');
392
447
  }
393
- candidate = canonicalizeCandidate(path.resolve(root, rawPath));
394
- }
448
+ return canonicalizeCandidate(path.resolve(root, input));
449
+ };
395
450
 
396
- const containingProject = [...this.projects.values()].find(({ resource }) =>
397
- isWithin(resource.root, candidate)
451
+ let candidate = buildCandidate(rawPath);
452
+
453
+ const findContaining = (cand) => [...this.projects.values()].find(({ resource }) =>
454
+ isWithin(resource.root, cand)
398
455
  );
456
+
457
+ let containingProject = findContaining(candidate);
458
+
459
+ // Two reasons to try the unescaped variant:
460
+ // (1) candidate is outside every project root (literal "Tarang\ Orca"
461
+ // does not contain a real project), or
462
+ // (2) candidate is inside a root but does not exist on disk because
463
+ // a path segment like "\(kepler\)" only resolves once unescaped.
464
+ // We retry once on the unescaped form before raising.
465
+ const needsRetry = !containingProject ||
466
+ (!allowMissing && !fs.existsSync(candidate));
467
+ if (needsRetry) {
468
+ const unescaped = unescapeShellPath(rawPath);
469
+ if (unescaped !== rawPath) {
470
+ try {
471
+ const altCandidate = buildCandidate(unescaped);
472
+ const altProject = findContaining(altCandidate);
473
+ if (altProject && (allowMissing || fs.existsSync(altCandidate))) {
474
+ candidate = altCandidate;
475
+ containingProject = altProject;
476
+ }
477
+ } catch { /* fall through to the original error */ }
478
+ }
479
+ }
480
+
399
481
  if (!containingProject) {
400
482
  throw new Error(`Path is outside registered project roots: ${rawPath}`);
401
483
  }
@@ -406,10 +488,21 @@ export class ProjectRegistry {
406
488
  }
407
489
 
408
490
  projectForPath(filePath) {
409
- const candidate = canonicalizeCandidate(path.resolve(filePath));
410
- return [...this.projects.values()].find(({ resource }) =>
491
+ const normalized = normalizePathInput(filePath);
492
+ const candidate = canonicalizeCandidate(path.resolve(normalized));
493
+ const direct = [...this.projects.values()].find(({ resource }) =>
411
494
  isWithin(resource.root, candidate)
412
- ) || null;
495
+ );
496
+ if (direct) return direct;
497
+ // Same unescape fallback used in resolvePath.
498
+ const unescaped = unescapeShellPath(normalized);
499
+ if (unescaped !== normalized) {
500
+ const altCandidate = canonicalizeCandidate(path.resolve(unescaped));
501
+ return [...this.projects.values()].find(({ resource }) =>
502
+ isWithin(resource.root, altCandidate)
503
+ ) || null;
504
+ }
505
+ return null;
413
506
  }
414
507
 
415
508
  reset() {
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { paint, width as visibleWidth } from './palette.mjs';
25
- import { icon, toolFamily } from './icons.mjs';
25
+ import { toolFamily } from './icons.mjs';
26
26
  import { term } from './term.mjs';
27
27
  import {
28
28
  toolDisplayLabel,
@@ -129,12 +129,20 @@ export function summarizeResult(tool, data) {
129
129
  return { text: head || 'ok', tone: 'success' };
130
130
  }
131
131
 
132
+ case 'analyze_code': {
133
+ // Backend returns "filename (N lines, ext)" — the filename already
134
+ // appears in the card head, so strip it and keep just the metadata.
135
+ const head = firstOutputLine(data);
136
+ const m = head.match(/\((\d+)\s+lines?,?\s+([^)]+)\)/);
137
+ if (m) return { text: `${m[1]} lines · ${m[2].trim()}`, tone: 'success' };
138
+ return { text: head.slice(0, 80) || 'done', tone: 'success' };
139
+ }
140
+
132
141
  case 'plan':
133
142
  case 'explore':
134
143
  case 'verify':
135
144
  case 'debug':
136
- case 'refactor':
137
- case 'analyze_code': {
145
+ case 'refactor': {
138
146
  const head = firstOutputLine(data).slice(0, 100);
139
147
  return { text: head || 'done', tone: 'success' };
140
148
  }
@@ -195,7 +203,15 @@ function tone(text, t) {
195
203
  // ── Card head (printed at invocation) ────────────────────────────────────
196
204
 
197
205
  /**
198
- * Render the leading half of a card — icon + colored label + args.
206
+ * Render the leading half of a card — colored verb + args.
207
+ *
208
+ * v2.0.3: dropped the leading tool icon (🔭/🛠️/⚙️). The label itself is a
209
+ * present-progressive verb so the line reads like prose:
210
+ * "Reading src/ui/banner.mjs · lines 31-65 — 36 lines"
211
+ * The icon was decorative noise that broke the conversational feel.
212
+ * Mission report and sub-agent renderers still use the icons in their
213
+ * own contexts.
214
+ *
199
215
  * Width-aware: truncates args from the left when the line would overflow.
200
216
  */
201
217
  export function formatCardHead(tool, args, opts = {}) {
@@ -203,15 +219,14 @@ export function formatCardHead(tool, args, opts = {}) {
203
219
  const cols = opts.columns || term().columns || 120;
204
220
  const indent = opts.indent || ' ';
205
221
 
206
- const iconText = icon(tool);
207
222
  const label = toolDisplayLabel(tool);
208
223
  const argsText = formatArgs(tool, args, cwd);
209
224
 
210
- const leadVisible = visibleWidth(`${indent}${iconText} ${label}`);
225
+ const leadVisible = visibleWidth(`${indent}${label}`);
211
226
  const budget = Math.max(20, cols - leadVisible - 4);
212
227
  const argsTruncated = truncateMiddle(argsText, budget);
213
228
 
214
- const head = `${indent}${iconText} ${paintLabel(tool, label)}`;
229
+ const head = `${indent}${paintLabel(tool, label)}`;
215
230
  return argsTruncated ? `${head} ${argsTruncated}` : head;
216
231
  }
217
232
 
@@ -232,9 +247,12 @@ export function formatCard({ tool, args, result, durationMs, indent, columns, cw
232
247
 
233
248
  if (!summary.text && !duration) return head;
234
249
 
235
- const arrow = paint.text.dim('');
250
+ const arrow = paint.text.dim('');
236
251
  const body = summary.text ? tone(summary.text, summary.tone) : '';
237
- const tail = duration ? paint.text.dim(` · ${duration}`) : '';
252
+ // Hide the duration tail when the tool was effectively instant (<200ms).
253
+ // For fast reads, "1ms" / "0ms" was noise that broke the prose feel.
254
+ const showDuration = duration && (durationMs == null || durationMs >= 200);
255
+ const tail = showDuration ? paint.text.dim(` · ${duration}`) : '';
238
256
 
239
257
  const candidate = `${head} ${arrow} ${body}${tail}`;
240
258
  if (visibleWidth(candidate) <= cols) return candidate;