@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.
- package/package.json +1 -1
- 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/core/risk-tier.mjs +8 -2
- package/src/core/stream-client.mjs +24 -1
- package/src/core/tool-executor.mjs +9 -2
- package/src/onboarding/preflight.mjs +51 -33
- package/src/terminal/repl.mjs +156 -48
- package/src/terminal/tool-display.mjs +29 -26
- package/src/tools/project-overview.mjs +109 -16
- package/src/ui/tool-card.mjs +27 -9
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
389
|
-
|
|
440
|
+
if (this.projects.size === 1) {
|
|
441
|
+
return canonicalizeCandidate(path.resolve(this.resources()[0].root, input));
|
|
390
442
|
}
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
}
|
|
448
|
+
return canonicalizeCandidate(path.resolve(root, input));
|
|
449
|
+
};
|
|
395
450
|
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
410
|
-
|
|
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
|
-
)
|
|
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() {
|
package/src/ui/tool-card.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { paint, width as visibleWidth } from './palette.mjs';
|
|
25
|
-
import {
|
|
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 —
|
|
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}${
|
|
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}${
|
|
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
|
-
|
|
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;
|