@selesai/code 0.13.34 → 0.13.37
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/CHANGELOG.md +22 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/core/remote-catalog-provider.js +2 -2
- package/dist/core/remote-catalog-provider.test.js +20 -1
- package/dist/extensions/capability-gateway/index.ts +2 -1
- package/dist/extensions/jev/decisions.ts +14 -1
- package/dist/extensions/jev-ask-tool.test.ts +70 -5
- package/dist/extensions/jev-ask-tool.ts +375 -10
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +6 -4
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +26 -1
- package/dist/extensions/pi-subagents/src/runs/shared/jev-subagent-routing.ts +255 -0
- package/dist/extensions/pi-subagents/test/unit/jev-subagent-routing.test.ts +117 -0
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +3 -1
- package/dist/extensions/ponytail/index.js +7 -4
- package/dist/extensions/ponytail/ponytail-config.cjs +52 -26
- package/dist/extensions/ponytail/ponytail-instructions.cjs +3 -17
- package/dist/extensions/ponytail/test/extension.test.js +37 -16
- package/dist/extensions/ponytail/test/helpers.test.js +54 -51
- package/dist/skills/ponytail/SKILL.md +5 -7
- package/dist/skills/ponytail-help/SKILL.md +10 -14
- package/docs/settings.md +22 -4
- package/package.json +1 -1
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
* tool's own abort signal. Every part is clipped to the route's payload budget
|
|
14
14
|
* before the call, so an oversized request is trimmed rather than abstained.
|
|
15
15
|
*/
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
16
17
|
import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
17
|
-
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
18
|
+
import { basename, isAbsolute, normalize, relative, resolve } from "node:path";
|
|
18
19
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
19
20
|
import {
|
|
20
21
|
createLocalBashOperations,
|
|
22
|
+
ensureTool,
|
|
21
23
|
type ExtensionAPI,
|
|
22
24
|
type ExtensionContext,
|
|
23
25
|
getSettingsPath,
|
|
@@ -339,6 +341,247 @@ export function renderAskAnswers(input: {
|
|
|
339
341
|
return [...header, ...lines, ...footer].join("\n");
|
|
340
342
|
}
|
|
341
343
|
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// jev_find: ripgrep gathers candidates, Jev judges each one
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
/** The file finder's registered name. */
|
|
349
|
+
export const JEV_FIND_TOOL = "jev_find";
|
|
350
|
+
/** ponytail: 16 nouls per request (the JevPDF batch size); raise once Jev is measured on larger blocks. */
|
|
351
|
+
export const FIND_BATCH = 16;
|
|
352
|
+
/** Three batches, sent in parallel, so a find costs one Jev round trip. */
|
|
353
|
+
export const MAX_FIND_CANDIDATES = 48;
|
|
354
|
+
const FIND_MATCH_LINES = 6;
|
|
355
|
+
const FIND_DEFAULT_LIMIT = 8;
|
|
356
|
+
const FIND_MIN_RELEVANCE = 0.5;
|
|
357
|
+
/** Room each batch keeps for the questions and JSON scaffolding. */
|
|
358
|
+
const FIND_QUESTION_RESERVE = 6 * 1024;
|
|
359
|
+
|
|
360
|
+
export interface FindCandidate {
|
|
361
|
+
path: string;
|
|
362
|
+
/** Matched line numbers (pattern mode only). */
|
|
363
|
+
lines: number[];
|
|
364
|
+
/** What Jev reads: the matched lines, or the head of the file. */
|
|
365
|
+
snippet: string;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Run ripgrep with an argument list (no shell). Exit 1 is "no matches"; an overfull buffer keeps what arrived. */
|
|
369
|
+
function runRg(bin: string, args: string[], cwd: string, signal: AbortSignal | undefined): Promise<string> {
|
|
370
|
+
return new Promise((done, fail) => {
|
|
371
|
+
execFile(
|
|
372
|
+
bin,
|
|
373
|
+
args,
|
|
374
|
+
{ cwd, signal, maxBuffer: 8 * 1024 * 1024, timeout: ASK_COMMAND_TIMEOUT_SECONDS * 1000 },
|
|
375
|
+
(error, stdout) => {
|
|
376
|
+
const code = error ? (error as { code?: unknown }).code : 0;
|
|
377
|
+
if (!error || code === 1 || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") done(String(stdout));
|
|
378
|
+
else fail(error);
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Filler words that would match nearly every file. */
|
|
385
|
+
const FIND_STOP_WORDS = new Set(
|
|
386
|
+
"the and for with where what which when who how does did this that from into its are was can should would there file files code find implement implemented implements".split(" "),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
/** Question words worth searching for: 3+ letters, not filler, at most eight. */
|
|
390
|
+
function questionWords(question: string): string[] {
|
|
391
|
+
const words = question.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
|
|
392
|
+
return [...new Set(words)].filter((word) => !FIND_STOP_WORDS.has(word)).slice(0, 8);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** How much of each listed file is scanned for question words. */
|
|
396
|
+
const FIND_SCAN_BYTES = 256 * 1024;
|
|
397
|
+
const FIND_EXCERPT_LINES = 12;
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* What Jev reads of a listed file: the lines holding the most distinct question words, in file
|
|
401
|
+
* order and numbered, or the head of the file when none match. A file's head is usually its
|
|
402
|
+
* doc comment, which rarely shows where the asked-about behavior lives.
|
|
403
|
+
*/
|
|
404
|
+
export function excerpt(text: string, words: readonly string[], maxBytes: number): { lines: number[]; snippet: string } {
|
|
405
|
+
const rows = text.split("\n");
|
|
406
|
+
const best = rows
|
|
407
|
+
.map((row, index) => {
|
|
408
|
+
const lower = row.toLowerCase();
|
|
409
|
+
return { index, count: words.filter((word) => lower.includes(word)).length };
|
|
410
|
+
})
|
|
411
|
+
.filter((hit) => hit.count > 0)
|
|
412
|
+
.sort((a, b) => b.count - a.count || a.index - b.index)
|
|
413
|
+
.slice(0, FIND_EXCERPT_LINES);
|
|
414
|
+
if (best.length === 0) return { lines: [], snippet: clip(text, maxBytes).text };
|
|
415
|
+
const inOrder = [...best].sort((a, b) => a.index - b.index);
|
|
416
|
+
const snippet = inOrder.map((hit) => `L${hit.index + 1}: ${rows[hit.index].trim().slice(0, 240)}`).join("\n");
|
|
417
|
+
// Pointers lead with the best-matching lines.
|
|
418
|
+
return { lines: best.map((hit) => hit.index + 1), snippet: clip(snippet, maxBytes).text };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Path parts (3+ letters) sharing a stem with a question word; orders a file listing before it is capped. */
|
|
422
|
+
function pathOverlap(path: string, words: readonly string[]): number {
|
|
423
|
+
const parts = path.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
|
|
424
|
+
return parts.filter((part) => words.some((word) => word.includes(part) || part.includes(word))).length;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Candidate files under `root`, most promising first. With `pattern`, the files ripgrep matches
|
|
429
|
+
* (most matches first) with their matched lines as the snippet; without it, every file ripgrep
|
|
430
|
+
* lists (question words in the path first) with the head of the file as the snippet. Respects
|
|
431
|
+
* .gitignore, and never offers a file `askPath` would refuse.
|
|
432
|
+
*/
|
|
433
|
+
export async function findCandidates(
|
|
434
|
+
options: { rg: string; cwd: string; root: string; question: string; pattern?: string; glob?: string; ignoreCase?: boolean },
|
|
435
|
+
snippetBytes: number,
|
|
436
|
+
signal: AbortSignal | undefined,
|
|
437
|
+
): Promise<{ candidates: FindCandidate[]; total: number }> {
|
|
438
|
+
// Always name the root: with no path and a piped stdin, rg searches stdin and hangs.
|
|
439
|
+
// Paths come back as `./src/x.ts`, so they are normalized below.
|
|
440
|
+
const root = ["--", options.root];
|
|
441
|
+
const filters = [...(options.glob ? ["--glob", options.glob] : []), ...(options.ignoreCase ? ["-i"] : [])];
|
|
442
|
+
const allowed = (path: string) => "full" in askPath(path, options.cwd);
|
|
443
|
+
if (options.pattern) {
|
|
444
|
+
const out = await runRg(
|
|
445
|
+
options.rg,
|
|
446
|
+
["--null", "--line-number", "--no-heading", "--color=never", "--max-count", String(FIND_MATCH_LINES), "--max-columns", "240", ...filters, "-e", options.pattern, ...root],
|
|
447
|
+
options.cwd,
|
|
448
|
+
signal,
|
|
449
|
+
);
|
|
450
|
+
const byFile = new Map<string, { lines: number[]; text: string[] }>();
|
|
451
|
+
for (const row of out.split("\n")) {
|
|
452
|
+
const nul = row.indexOf("\0");
|
|
453
|
+
if (nul < 0) continue;
|
|
454
|
+
const path = normalize(row.slice(0, nul));
|
|
455
|
+
const match = /^(\d+):(.*)$/.exec(row.slice(nul + 1));
|
|
456
|
+
if (!match) continue;
|
|
457
|
+
const entry = byFile.get(path) ?? { lines: [], text: [] };
|
|
458
|
+
entry.lines.push(Number(match[1]));
|
|
459
|
+
entry.text.push(`L${match[1]}: ${match[2].trim()}`);
|
|
460
|
+
byFile.set(path, entry);
|
|
461
|
+
}
|
|
462
|
+
const files = [...byFile].filter(([path]) => allowed(path)).sort((a, b) => b[1].lines.length - a[1].lines.length);
|
|
463
|
+
return {
|
|
464
|
+
total: files.length,
|
|
465
|
+
candidates: files.slice(0, MAX_FIND_CANDIDATES).map(([path, entry]) => ({
|
|
466
|
+
path,
|
|
467
|
+
lines: entry.lines,
|
|
468
|
+
snippet: clip(entry.text.join("\n"), snippetBytes).text,
|
|
469
|
+
})),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const words = questionWords(options.question);
|
|
473
|
+
const out = await runRg(options.rg, ["--files", "--color=never", ...filters, ...root], options.cwd, signal);
|
|
474
|
+
const listed = out
|
|
475
|
+
.split("\n")
|
|
476
|
+
.filter((path) => path !== "")
|
|
477
|
+
.map((path) => normalize(path))
|
|
478
|
+
.filter(allowed);
|
|
479
|
+
const score = new Map(listed.map((path) => [path, pathOverlap(path, words)]));
|
|
480
|
+
if (listed.length > MAX_FIND_CANDIDATES && words.length > 0) {
|
|
481
|
+
// More files than Jev judges: a file scores once per distinct question word its content holds,
|
|
482
|
+
// one fixed-string ripgrep per word, in parallel.
|
|
483
|
+
const hits = await Promise.all(
|
|
484
|
+
words.map((word) =>
|
|
485
|
+
runRg(options.rg, ["-l", "-i", "-F", "--color=never", ...filters, "-e", word, ...root], options.cwd, signal),
|
|
486
|
+
),
|
|
487
|
+
);
|
|
488
|
+
for (const listing of hits) {
|
|
489
|
+
for (const raw of listing.split("\n")) {
|
|
490
|
+
const path = raw === "" ? "" : normalize(raw);
|
|
491
|
+
const current = score.get(path);
|
|
492
|
+
if (current !== undefined) score.set(path, current + 1);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const files = listed.map((path) => ({ path })).sort((a, b) => (score.get(b.path) ?? 0) - (score.get(a.path) ?? 0));
|
|
497
|
+
const candidates: FindCandidate[] = [];
|
|
498
|
+
for (const { path } of files.slice(0, MAX_FIND_CANDIDATES)) {
|
|
499
|
+
const file = readBounded(resolve(options.cwd, path), FIND_SCAN_BYTES);
|
|
500
|
+
if ("error" in file) continue;
|
|
501
|
+
candidates.push({ path, ...excerpt(file.text, words, snippetBytes) });
|
|
502
|
+
}
|
|
503
|
+
return { candidates, total: files.length };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** One batch as a decisions request: the shared snippets as state, one noul per file. */
|
|
507
|
+
export function buildFindPayload(question: string, batch: readonly FindCandidate[]): Record<string, unknown> {
|
|
508
|
+
return buildAskPayload(
|
|
509
|
+
batch.map((candidate, index) => ({
|
|
510
|
+
name: `f${index}`,
|
|
511
|
+
type: "noul" as const,
|
|
512
|
+
instructions: `Is the file "${candidate.path}" relevant to the request, judged by its excerpt in state.files?`,
|
|
513
|
+
criteria: {
|
|
514
|
+
true: "The excerpt shows this file implements, defines, configures, or directly answers the request.",
|
|
515
|
+
false: "The file only mentions related words, or is about something else.",
|
|
516
|
+
},
|
|
517
|
+
})),
|
|
518
|
+
{ request: question, files: Object.fromEntries(batch.map((candidate) => [candidate.path, candidate.snippet])) },
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The batch's request, its snippets shrunk until the serialized request fits `maxBytes`: code
|
|
524
|
+
* grows under JSON escaping, so a byte budget per snippet alone does not guarantee a fit.
|
|
525
|
+
*/
|
|
526
|
+
export function fitFindPayload(question: string, batch: readonly FindCandidate[], maxBytes: number): Record<string, unknown> {
|
|
527
|
+
let limit = Math.max(0, ...batch.map((candidate) => Buffer.byteLength(candidate.snippet, "utf-8")));
|
|
528
|
+
for (;;) {
|
|
529
|
+
const payload = buildFindPayload(
|
|
530
|
+
question,
|
|
531
|
+
batch.map((candidate) => ({ ...candidate, snippet: clip(candidate.snippet, limit).text })),
|
|
532
|
+
);
|
|
533
|
+
if (limit < 64 || serializeJevRequest(payload, maxBytes) !== undefined) return payload;
|
|
534
|
+
limit = Math.floor(limit * 0.75);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Pointers only: path, matched lines, relevance. File contents never reach the agent. */
|
|
539
|
+
export function renderFindResults(input: {
|
|
540
|
+
ranked: ReadonlyArray<FindCandidate & { relevance?: number }>;
|
|
541
|
+
total: number;
|
|
542
|
+
judged: number;
|
|
543
|
+
limit: number;
|
|
544
|
+
model: string;
|
|
545
|
+
elapsedMs: number;
|
|
546
|
+
failure?: string;
|
|
547
|
+
}): string {
|
|
548
|
+
const pointer = (candidate: FindCandidate) =>
|
|
549
|
+
candidate.lines.length > 0 ? `${candidate.path}:${candidate.lines.slice(0, 4).join(",")}` : candidate.path;
|
|
550
|
+
if (input.total === 0) return "jev_find: no candidate files. Loosen `pattern`, `glob`, or `path`.";
|
|
551
|
+
const lines: string[] = [];
|
|
552
|
+
if (input.judged === 0) {
|
|
553
|
+
lines.push(
|
|
554
|
+
`jev_find: Jev did not judge (${input.failure ?? "missing"}); ${input.total} candidate file(s), unranked by relevance:`,
|
|
555
|
+
...input.ranked.slice(0, input.limit).map((candidate) => `- ${pointer(candidate)}`),
|
|
556
|
+
);
|
|
557
|
+
} else {
|
|
558
|
+
const strong = input.ranked.filter((candidate) => (candidate.relevance ?? 0) >= FIND_MIN_RELEVANCE);
|
|
559
|
+
lines.push(`jev_find: judged ${input.judged} of ${input.total} candidate file(s) via ${input.model} in ${input.elapsedMs}ms`);
|
|
560
|
+
const shown = strong.length > 0 ? strong.slice(0, input.limit) : input.ranked.slice(0, 3);
|
|
561
|
+
if (strong.length === 0) lines.push("No file is a confident match; the closest were:");
|
|
562
|
+
for (const candidate of shown) {
|
|
563
|
+
lines.push(`- ${pointer(candidate)} (${candidate.relevance === undefined ? "not judged" : candidate.relevance.toFixed(2)})`);
|
|
564
|
+
}
|
|
565
|
+
if (strong.length > shown.length) lines.push(`${strong.length - shown.length} more relevant file(s) past the limit.`);
|
|
566
|
+
}
|
|
567
|
+
if (input.total > MAX_FIND_CANDIDATES) {
|
|
568
|
+
lines.push(`${input.total - MAX_FIND_CANDIDATES} candidate(s) were not judged; narrow with \`pattern\`, \`glob\`, or \`path\`.`);
|
|
569
|
+
}
|
|
570
|
+
lines.push("Only pointers are returned; read the files you need.");
|
|
571
|
+
return lines.join("\n");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const JevFindParams = Type.Object({
|
|
575
|
+
question: Type.String({ description: "What you are looking for, in plain words (e.g. 'where is the retry backoff configured')." }),
|
|
576
|
+
pattern: Type.Optional(
|
|
577
|
+
Type.String({ description: "ripgrep regex to narrow candidates to files that match; omit to judge every listed file." }),
|
|
578
|
+
),
|
|
579
|
+
glob: Type.Optional(Type.String({ description: "File glob filter, e.g. '*.ts' or 'src/**/*.md'." })),
|
|
580
|
+
path: Type.Optional(Type.String({ description: "Directory to search, inside the working directory (default: '.')." })),
|
|
581
|
+
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive pattern." })),
|
|
582
|
+
limit: Type.Optional(Type.Number({ description: `Most results to return (default ${FIND_DEFAULT_LIMIT}).` })),
|
|
583
|
+
});
|
|
584
|
+
|
|
342
585
|
// ---------------------------------------------------------------------------
|
|
343
586
|
// Tool
|
|
344
587
|
// ---------------------------------------------------------------------------
|
|
@@ -562,25 +805,147 @@ export default function jevAskToolExtension(pi: ExtensionAPI): void {
|
|
|
562
805
|
},
|
|
563
806
|
});
|
|
564
807
|
|
|
565
|
-
|
|
566
|
-
|
|
808
|
+
pi.registerTool({
|
|
809
|
+
name: JEV_FIND_TOOL,
|
|
810
|
+
label: "Jev Find",
|
|
811
|
+
description: [
|
|
812
|
+
"Find which files answer a question without reading them: ripgrep gathers candidate files (those matching",
|
|
813
|
+
"`pattern`, or every file under `path` filtered by `glob`), and Jev judges each file's excerpt against",
|
|
814
|
+
"`question` in one parallel round trip. Returns ranked `path:lines (relevance)` pointers, never contents.",
|
|
815
|
+
`Judges up to ${MAX_FIND_CANDIDATES} files per call; respects .gitignore; secret-named files are never sent.`,
|
|
816
|
+
].join(" "),
|
|
817
|
+
promptSnippet: "Find the files that answer a question: ripgrep candidates ranked by Jev, pointers only",
|
|
818
|
+
promptGuidelines: [
|
|
819
|
+
"When you look for code by what it does rather than by an exact identifier you already know, call jev_find first — before grep, find, ls, or reading candidate files. One call ranks up to 48 files in about a second and returns file:line pointers; then read only the top ones. Pass `question` in plain words; add `pattern` when you know a likely identifier, `glob`/`path` to scope it.",
|
|
820
|
+
"Use grep for an exact string or identifier you already know; use jev_find when you would otherwise grep several guesses or read files to see which one is relevant.",
|
|
821
|
+
],
|
|
822
|
+
discovery: {
|
|
823
|
+
summary: "Semantic file finder: ripgrep candidates ranked by Jev relevance",
|
|
824
|
+
aliases: ["jevgrep", "find", "search", "locate"],
|
|
825
|
+
category: "Decisions",
|
|
826
|
+
},
|
|
827
|
+
parameters: JevFindParams,
|
|
828
|
+
|
|
829
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
|
|
830
|
+
const text = (value: string, details: Record<string, unknown> = {}) => ({
|
|
831
|
+
content: [{ type: "text" as const, text: value }],
|
|
832
|
+
details,
|
|
833
|
+
});
|
|
834
|
+
const config = readJevAdvisoryConfig(getSettingsPath());
|
|
835
|
+
const route = config.routes.ask;
|
|
836
|
+
if (!route.enabled) {
|
|
837
|
+
return text(`jev_find is disabled (jevAdvisory.routes.ask.enabled is false in ${getSettingsPath()}).`);
|
|
838
|
+
}
|
|
839
|
+
const question = params.question?.trim() ?? "";
|
|
840
|
+
if (question === "") return text("jev_find needs a non-empty `question`.");
|
|
841
|
+
|
|
842
|
+
const rootArg = params.path?.trim() || ".";
|
|
843
|
+
const rootFull = resolve(ctx.cwd, rootArg);
|
|
844
|
+
const inside = relative(resolve(ctx.cwd), rootFull);
|
|
845
|
+
if (inside.startsWith("..") || isAbsolute(inside)) {
|
|
846
|
+
return text(`jev_find: ${rootArg} is outside the working directory.`);
|
|
847
|
+
}
|
|
848
|
+
const rg = await ensureTool("rg");
|
|
849
|
+
if (!rg) return text("jev_find needs ripgrep (rg), which is not available; use grep instead.");
|
|
850
|
+
|
|
851
|
+
const snippetBytes = Math.max(256, Math.floor((route.payloadBytes - FIND_QUESTION_RESERVE) / FIND_BATCH));
|
|
852
|
+
let found: { candidates: FindCandidate[]; total: number };
|
|
853
|
+
try {
|
|
854
|
+
found = await findCandidates(
|
|
855
|
+
{
|
|
856
|
+
rg,
|
|
857
|
+
cwd: ctx.cwd,
|
|
858
|
+
root: inside === "" ? "." : inside,
|
|
859
|
+
question,
|
|
860
|
+
pattern: params.pattern || undefined,
|
|
861
|
+
glob: params.glob || undefined,
|
|
862
|
+
ignoreCase: params.ignoreCase,
|
|
863
|
+
},
|
|
864
|
+
snippetBytes,
|
|
865
|
+
signal,
|
|
866
|
+
);
|
|
867
|
+
} catch (error) {
|
|
868
|
+
return text(`jev_find: ripgrep failed (${error instanceof Error ? error.message.split("\n")[0] : String(error)}).`);
|
|
869
|
+
}
|
|
870
|
+
const limit = Math.max(1, Math.floor(params.limit ?? FIND_DEFAULT_LIMIT));
|
|
871
|
+
const { candidates, total } = found;
|
|
872
|
+
if (candidates.length === 0) {
|
|
873
|
+
return text(renderFindResults({ ranked: [], total: 0, judged: 0, limit, model: config.model, elapsedMs: 0 }));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Without Jev the candidates are still worth returning: the call is never wasted.
|
|
877
|
+
const connection = jevConnection(config, route);
|
|
878
|
+
const unreachable = await jevUnavailable(ctx, connection);
|
|
879
|
+
const batches: FindCandidate[][] = [];
|
|
880
|
+
for (let start = 0; start < candidates.length; start += FIND_BATCH) {
|
|
881
|
+
batches.push(candidates.slice(start, start + FIND_BATCH));
|
|
882
|
+
}
|
|
883
|
+
const started = Date.now();
|
|
884
|
+
const results = unreachable
|
|
885
|
+
? []
|
|
886
|
+
: await Promise.all(
|
|
887
|
+
batches.map((batch) =>
|
|
888
|
+
askJevAnswers(ctx, connection, {
|
|
889
|
+
payload: fitFindPayload(question, batch, route.payloadBytes),
|
|
890
|
+
maxBytes: route.payloadBytes,
|
|
891
|
+
}),
|
|
892
|
+
),
|
|
893
|
+
);
|
|
894
|
+
const elapsedMs = Date.now() - started;
|
|
895
|
+
|
|
896
|
+
const scored: Array<FindCandidate & { relevance?: number }> = [];
|
|
897
|
+
let judged = 0;
|
|
898
|
+
batches.forEach((batch, b) => {
|
|
899
|
+
const answers = results[b]?.answers ?? {};
|
|
900
|
+
batch.forEach((candidate, i) => {
|
|
901
|
+
const answer = answers[`f${i}`];
|
|
902
|
+
const relevance = isRecord(answer) && typeof answer.noul === "number" ? answer.noul : undefined;
|
|
903
|
+
if (relevance !== undefined) judged += 1;
|
|
904
|
+
scored.push({ ...candidate, ...(relevance === undefined ? {} : { relevance }) });
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
// Judged files by relevance; unjudged ones keep ripgrep's order behind them.
|
|
908
|
+
const ranked = judged > 0 ? [...scored].sort((a, b) => (b.relevance ?? -1) - (a.relevance ?? -1)) : scored;
|
|
909
|
+
const failure = unreachable ?? results.find((result) => result.failure)?.failure;
|
|
910
|
+
|
|
911
|
+
emitJevTelemetry(pi.events, "decision", {
|
|
912
|
+
route: "find",
|
|
913
|
+
outcome: judged > 0 ? "jev" : "fallback",
|
|
914
|
+
candidates: candidates.length,
|
|
915
|
+
confidence: confidenceBucket(ranked[0]?.relevance),
|
|
916
|
+
elapsedMs,
|
|
917
|
+
...(judged > 0 ? {} : { reason: failure ?? "missing" }),
|
|
918
|
+
});
|
|
919
|
+
return text(
|
|
920
|
+
renderFindResults({ ranked, total, judged, limit, model: config.model, elapsedMs, failure }),
|
|
921
|
+
// Shape only, like ask_jev: details persist in the session.
|
|
922
|
+
{ candidates: candidates.length, total, judged, elapsedMs, ...(failure ? { failure } : {}) },
|
|
923
|
+
);
|
|
924
|
+
},
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// Offer the tools only while Jev can answer: a tool that always fails costs the agent a turn
|
|
928
|
+
// every time it reaches for it. Checked before every run, so `/tokenin add` brings them back
|
|
567
929
|
// without a reload. Silent on purpose: the one warning comes from whichever Jev path first
|
|
568
930
|
// needs the missing credential, not from every session of a user without a subscription.
|
|
569
931
|
// Only a removal made here is ever undone, so a loadout the user trimmed stays trimmed.
|
|
570
|
-
|
|
932
|
+
const jevTools = [ASK_JEV_TOOL, JEV_FIND_TOOL];
|
|
933
|
+
const hidden = new Set<string>();
|
|
571
934
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
572
935
|
const config = readJevAdvisoryConfig(getSettingsPath());
|
|
573
936
|
const route = config.routes.ask;
|
|
574
937
|
const offline = !route.enabled || (await jevUnavailable(ctx, jevConnection(config, route))) !== undefined;
|
|
575
938
|
const active = pi.getActiveTools();
|
|
576
939
|
if (offline) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
940
|
+
const dropping = jevTools.filter((name) => active.includes(name));
|
|
941
|
+
if (dropping.length > 0) {
|
|
942
|
+
pi.setActiveTools(active.filter((name) => !dropping.includes(name)));
|
|
943
|
+
for (const name of dropping) hidden.add(name);
|
|
580
944
|
}
|
|
581
|
-
} else if (hidden) {
|
|
582
|
-
|
|
583
|
-
|
|
945
|
+
} else if (hidden.size > 0) {
|
|
946
|
+
const restoring = [...hidden].filter((name) => !active.includes(name));
|
|
947
|
+
if (restoring.length > 0) pi.setActiveTools([...active, ...restoring]);
|
|
948
|
+
hidden.clear();
|
|
584
949
|
}
|
|
585
950
|
return undefined;
|
|
586
951
|
});
|
|
@@ -201,17 +201,19 @@ export function normalizePublicSubagentExecution<T extends PublicSubagentExecuti
|
|
|
201
201
|
return { ok: false, error: "Structured single-child execution cannot be combined with workflow, workflowScript, or workflowScriptPath.", mode: "workflow" };
|
|
202
202
|
}
|
|
203
203
|
if (params.agent !== undefined || params.task !== undefined) {
|
|
204
|
-
if (typeof params.agent !== "string" || !params.agent.trim()) {
|
|
205
|
-
return { ok: false, error: "Structured single-child execution requires agent to be a non-empty string.", mode: "workflow" };
|
|
206
|
-
}
|
|
207
204
|
if (params.task !== undefined && typeof params.task !== "string") {
|
|
208
205
|
return { ok: false, error: "Structured single-child task must be a string when provided.", mode: "workflow" };
|
|
209
206
|
}
|
|
207
|
+
// A task without an agent is left for Jev subagent routing; the executor rejects it when routing cannot choose.
|
|
208
|
+
const agentless = params.agent === undefined && typeof params.task === "string" && params.task.trim() !== "";
|
|
209
|
+
if (!agentless && (typeof params.agent !== "string" || !params.agent.trim())) {
|
|
210
|
+
return { ok: false, error: "Structured single-child execution requires agent to be a non-empty string.", mode: "workflow" };
|
|
211
|
+
}
|
|
210
212
|
return {
|
|
211
213
|
ok: true,
|
|
212
214
|
params: {
|
|
213
215
|
...params,
|
|
214
|
-
agent: params.agent.trim(),
|
|
216
|
+
...(typeof params.agent === "string" ? { agent: params.agent.trim() } : {}),
|
|
215
217
|
output: params.output === undefined ? true : params.output,
|
|
216
218
|
} as T,
|
|
217
219
|
};
|
|
@@ -281,7 +281,7 @@ const ControlOverrides = Type.Object({
|
|
|
281
281
|
|
|
282
282
|
const SubagentParamProperties = {
|
|
283
283
|
agent: Type.Optional(Type.String({ description: "Agent for one-child execution, or target for agent management actions." })),
|
|
284
|
-
task: Type.Optional(Type.String({ description: "Optional one-child task. Requires agent; cannot combine with action, workflowScript, or workflowScriptPath." })),
|
|
284
|
+
task: Type.Optional(Type.String({ description: "Optional one-child task. Requires agent unless Jev subagent routing is enabled, in which case agent may be omitted to let Jev choose by function; cannot combine with action, workflowScript, or workflowScriptPath." })),
|
|
285
285
|
extensionBindings: Type.Optional(Type.Unsafe({ type: "object", maxProperties: 16, additionalProperties: true, description: "Namespaced, bounded plain-JSON metadata delivered only to the child runtime. Namespace keys use package.name/1 syntax." })),
|
|
286
286
|
// Management action (when present, tool operates in management mode)
|
|
287
287
|
action: Type.Optional(Type.String({ minLength: 1,
|
|
@@ -66,6 +66,7 @@ import { retainLiveForegroundNestedRoute } from "../../integrations/pi-web-sessi
|
|
|
66
66
|
import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
|
|
67
67
|
import { usageBudgetExceededMessage, usageBudgetState, validateUsageBudgetConfig } from "../shared/usage-budget.ts";
|
|
68
68
|
import { intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
|
|
69
|
+
import { describeSubagentRouting, emitSubagentRoutingTelemetry, readSubagentRoutingSettings, routeSubagentLaunch, warnJevUnavailableOnce } from "../shared/jev-subagent-routing.ts";
|
|
69
70
|
import { isAgentContract } from "../shared/agent-contract.ts";
|
|
70
71
|
import { normalizeExtensionBindings, type ExtensionBindings } from "../shared/extension-bindings.ts";
|
|
71
72
|
import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeSingleOutputOverride, outputPathMappingFromTask, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
@@ -6637,11 +6638,35 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
6637
6638
|
if (cwdError) return buildRequestedModeError(effectiveParams, cwdError);
|
|
6638
6639
|
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
6639
6640
|
const discovered = deps.discoverAgents(effectiveCwd, scope, requestParentModel?.provider);
|
|
6640
|
-
|
|
6641
|
+
let discoveredAgents = discovered.agents;
|
|
6641
6642
|
const unknownAgentDiagnosticContext = diagnosticContextFromDiscovery(discovered, effectiveCwd, scope);
|
|
6642
6643
|
const canonicalParams = canonicalizeExecutionParams(effectiveParams, discoveredAgents, discovered.agentDiagnostics, unknownAgentDiagnosticContext);
|
|
6643
6644
|
if (canonicalParams.error) return buildRequestedModeError(effectiveParams, canonicalParams.error);
|
|
6644
6645
|
effectiveParams = canonicalParams.params!;
|
|
6646
|
+
// Jev fills in what a public single-child call left open (agent, tools, model tier); opt-in.
|
|
6647
|
+
const routedSingle = publicExecution && !effectiveParams.chain?.length && !effectiveParams.tasks?.length && typeof effectiveParams.task === "string" && effectiveParams.task.trim() !== "";
|
|
6648
|
+
const jevRouting = routedSingle ? readSubagentRoutingSettings() : undefined;
|
|
6649
|
+
if (jevRouting) {
|
|
6650
|
+
const routed = await routeSubagentLaunch({
|
|
6651
|
+
task: effectiveParams.task!,
|
|
6652
|
+
agent: effectiveParams.agent,
|
|
6653
|
+
model: effectiveParams.model,
|
|
6654
|
+
agents: discoveredAgents,
|
|
6655
|
+
ceiling: intersectSubagentCapabilityCeilings(effectiveParams.capabilityCeiling, resolveCurrentSubagentCapabilityCeiling(resolveCurrentSessionId(ctx.sessionManager))),
|
|
6656
|
+
// pi-subagents' ModelRegistry typings predate `complete`; the host registry has it, and a missing one is an abstention.
|
|
6657
|
+
}, ctx as unknown as Parameters<typeof routeSubagentLaunch>[1], jevRouting);
|
|
6658
|
+
emitSubagentRoutingTelemetry(deps.pi.events, routed);
|
|
6659
|
+
if (routed.abstained === "no-credential" && ctx.hasUI) warnJevUnavailableOnce(ctx.ui, jevRouting.connection.provider);
|
|
6660
|
+
discoveredAgents = routed.agents;
|
|
6661
|
+
if (routed.agent !== undefined) effectiveParams = { ...effectiveParams, agent: routed.agent };
|
|
6662
|
+
const summary = describeSubagentRouting(routed, jevRouting.tiers);
|
|
6663
|
+
if (summary && ctx.hasUI) ctx.ui.notify(summary, "info");
|
|
6664
|
+
}
|
|
6665
|
+
if (routedSingle && !effectiveParams.agent) {
|
|
6666
|
+
return buildRequestedModeError(effectiveParams, jevRouting
|
|
6667
|
+
? "Jev could not choose an agent for this task. Name one: call subagent with { action: \"list\", capabilities: true } to see them."
|
|
6668
|
+
: "Structured single-child execution requires agent; Jev subagent routing (jevAdvisory.routes.subagent.enabled) is off. Name an agent.");
|
|
6669
|
+
}
|
|
6645
6670
|
if (effectiveParams.worktree === undefined && deps.config.worktree !== undefined) {
|
|
6646
6671
|
effectiveParams = { ...effectiveParams, worktree: deps.config.worktree };
|
|
6647
6672
|
}
|