@nmzpy/pi-ember-stack 0.2.1 → 0.2.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.
- package/README.md +83 -83
- package/package.json +63 -59
- package/plugins/devin-auth/extensions/index.ts +23 -0
- package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
- package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
- package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
- package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
- package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
- package/plugins/devin-auth/src/oauth/types.ts +71 -71
- package/plugins/devin-auth/src/stream.ts +1 -1
- package/plugins/pi-compact-tools/index.ts +19 -1
- package/plugins/pi-compact-tools/renderer.ts +231 -61
- package/plugins/pi-custom-agents/index.ts +310 -102
- package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
- package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
- package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
- package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
- package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
- package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
- package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
- package/plugins/pi-ember-fff/index.ts +275 -17
- package/plugins/pi-ember-fff/query.ts +170 -10
- package/plugins/pi-ember-fff/test/query.test.ts +157 -1
- package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
- package/plugins/pi-ember-tps/index.ts +27 -5
- package/plugins/pi-ember-ui/ember.json +3 -0
- package/plugins/pi-ember-ui/index.ts +223 -36
- package/plugins/pi-ember-ui/mode-colors.ts +36 -8
|
@@ -21,8 +21,14 @@ import type {
|
|
|
21
21
|
} from "@ff-labs/fff-node";
|
|
22
22
|
import { FileFinder } from "@ff-labs/fff-node";
|
|
23
23
|
import { Type } from "@sinclair/typebox";
|
|
24
|
-
import {
|
|
25
|
-
|
|
24
|
+
import {
|
|
25
|
+
buildExternalAllowlist,
|
|
26
|
+
buildQuery,
|
|
27
|
+
resolveExternalTarget,
|
|
28
|
+
type ExternalAllowlist,
|
|
29
|
+
type ExternalTarget,
|
|
30
|
+
} from "./query.ts";
|
|
31
|
+
import { getSharedRenderer, bashGrepInfo } from "../pi-compact-tools/index.ts";
|
|
26
32
|
|
|
27
33
|
// ---------------------------------------------------------------------------
|
|
28
34
|
// Constants
|
|
@@ -37,12 +43,17 @@ const MENTION_MAX_RESULTS = 20;
|
|
|
37
43
|
// Cursor store — simple bounded Map for pagination cursors
|
|
38
44
|
// ---------------------------------------------------------------------------
|
|
39
45
|
|
|
40
|
-
|
|
46
|
+
interface CachedGrepCursor {
|
|
47
|
+
cursor: GrepCursor;
|
|
48
|
+
externalDir?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cursorCache = new Map<string, CachedGrepCursor>();
|
|
41
52
|
let cursorCounter = 0;
|
|
42
53
|
|
|
43
|
-
function storeCursor(cursor: GrepCursor): string {
|
|
54
|
+
function storeCursor(cursor: GrepCursor, externalDir?: string): string {
|
|
44
55
|
const id = `fff_c${++cursorCounter}`;
|
|
45
|
-
cursorCache.set(id, cursor);
|
|
56
|
+
cursorCache.set(id, { cursor, externalDir });
|
|
46
57
|
if (cursorCache.size > 200) {
|
|
47
58
|
const first = cursorCache.keys().next().value;
|
|
48
59
|
if (first) cursorCache.delete(first);
|
|
@@ -50,7 +61,7 @@ function storeCursor(cursor: GrepCursor): string {
|
|
|
50
61
|
return id;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
|
-
function getCursor(id: string):
|
|
64
|
+
function getCursor(id: string): CachedGrepCursor | undefined {
|
|
54
65
|
return cursorCache.get(id);
|
|
55
66
|
}
|
|
56
67
|
|
|
@@ -59,6 +70,7 @@ interface FindCursor {
|
|
|
59
70
|
pattern: string;
|
|
60
71
|
pageSize: number;
|
|
61
72
|
nextPageIndex: number;
|
|
73
|
+
externalDir?: string;
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
const findCursorCache = new Map<string, FindCursor>();
|
|
@@ -231,6 +243,11 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
231
243
|
let finderPromise: Promise<FileFinder> | null = null;
|
|
232
244
|
let activeCwd = process.cwd();
|
|
233
245
|
|
|
246
|
+
let externalFinder: FileFinder | null = null;
|
|
247
|
+
let externalFinderDir: string | null = null;
|
|
248
|
+
let externalFinderPromise: Promise<FileFinder> | null = null;
|
|
249
|
+
let externalAllowlist: ExternalAllowlist = buildExternalAllowlist();
|
|
250
|
+
|
|
234
251
|
const frecencyDbPath =
|
|
235
252
|
(pi.getFlag("fff-frecency-db") as string | undefined) ??
|
|
236
253
|
process.env.FFF_FRECENCY_DB ??
|
|
@@ -252,6 +269,15 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
252
269
|
"FFF_ENABLE_ROOT_SCAN",
|
|
253
270
|
);
|
|
254
271
|
|
|
272
|
+
const enableExternalAllow = (() => {
|
|
273
|
+
const flag = pi.getFlag("fff-external-allow");
|
|
274
|
+
if (typeof flag === "boolean") return flag;
|
|
275
|
+
if (typeof flag === "string") return flag === "true" || flag === "1";
|
|
276
|
+
const env = process.env.FFF_EXTERNAL_ALLOW;
|
|
277
|
+
if (env !== undefined) return env === "1" || env === "true";
|
|
278
|
+
return true; // default ON
|
|
279
|
+
})();
|
|
280
|
+
|
|
255
281
|
function ensureFinder(cwd: string): Promise<FileFinder> {
|
|
256
282
|
if (finder && !finder.isDestroyed && finderCwd === cwd)
|
|
257
283
|
return Promise.resolve(finder);
|
|
@@ -295,6 +321,76 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
295
321
|
}
|
|
296
322
|
}
|
|
297
323
|
|
|
324
|
+
function ensureExternalFinder(dir: string): Promise<FileFinder> {
|
|
325
|
+
if (externalFinder && !externalFinder.isDestroyed && externalFinderDir === dir)
|
|
326
|
+
return Promise.resolve(externalFinder);
|
|
327
|
+
if (externalFinderPromise) return externalFinderPromise;
|
|
328
|
+
|
|
329
|
+
externalFinderPromise = (async () => {
|
|
330
|
+
if (externalFinder && !externalFinder.isDestroyed) {
|
|
331
|
+
externalFinder.destroy();
|
|
332
|
+
externalFinder = null;
|
|
333
|
+
externalFinderDir = null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = FileFinder.create({
|
|
337
|
+
basePath: dir,
|
|
338
|
+
aiMode: true,
|
|
339
|
+
enableHomeDirScanning: false,
|
|
340
|
+
enableFsRootScanning: false,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (!result.ok)
|
|
344
|
+
throw new Error(`Failed to create external FFF file finder: ${result.error}`);
|
|
345
|
+
|
|
346
|
+
externalFinder = result.value;
|
|
347
|
+
externalFinderDir = dir;
|
|
348
|
+
await externalFinder.waitForScan(15000);
|
|
349
|
+
return externalFinder;
|
|
350
|
+
})().finally(() => {
|
|
351
|
+
externalFinderPromise = null;
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
return externalFinderPromise;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function destroyExternalFinder() {
|
|
358
|
+
if (externalFinder && !externalFinder.isDestroyed) {
|
|
359
|
+
externalFinder.destroy();
|
|
360
|
+
externalFinder = null;
|
|
361
|
+
externalFinderDir = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* If params.path targets an allowlisted external directory, return the
|
|
367
|
+
* external finder and the query scoped to that dir. Otherwise return
|
|
368
|
+
* the workspace finder and workspace query.
|
|
369
|
+
*/
|
|
370
|
+
async function resolveFinderAndQuery(
|
|
371
|
+
pathParam: string | undefined,
|
|
372
|
+
pattern: string,
|
|
373
|
+
exclude: string | string[] | undefined,
|
|
374
|
+
): Promise<{ finder: FileFinder; query: string }> {
|
|
375
|
+
if (enableExternalAllow && externalAllowlist.entries.length > 0) {
|
|
376
|
+
const target = resolveExternalTarget(pathParam, externalAllowlist);
|
|
377
|
+
if (target) {
|
|
378
|
+
const f = await ensureExternalFinder(target.entry.dir);
|
|
379
|
+
const query = buildQuery(
|
|
380
|
+
target.relativePath || undefined,
|
|
381
|
+
pattern,
|
|
382
|
+
exclude,
|
|
383
|
+
target.entry.dir,
|
|
384
|
+
externalAllowlist,
|
|
385
|
+
);
|
|
386
|
+
return { finder: f, query };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const f = await ensureFinder(activeCwd);
|
|
390
|
+
const query = buildQuery(pathParam, pattern, exclude, activeCwd, externalAllowlist);
|
|
391
|
+
return { finder: f, query };
|
|
392
|
+
}
|
|
393
|
+
|
|
298
394
|
async function getMentionItems(
|
|
299
395
|
query: string,
|
|
300
396
|
signal: AbortSignal,
|
|
@@ -379,6 +475,12 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
379
475
|
type: "boolean",
|
|
380
476
|
});
|
|
381
477
|
|
|
478
|
+
pi.registerFlag("fff-external-allow", {
|
|
479
|
+
description:
|
|
480
|
+
"Allow grep/find to search the auto-detected @earendil-works/pi-coding-agent package directory via the ./pi-coding-agent alias (also: FFF_EXTERNAL_ALLOW env; default: true)",
|
|
481
|
+
type: "boolean",
|
|
482
|
+
});
|
|
483
|
+
|
|
382
484
|
pi.on("session_start", async (_event, ctx) => {
|
|
383
485
|
try {
|
|
384
486
|
activeCwd = ctx.cwd;
|
|
@@ -394,6 +496,134 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
394
496
|
|
|
395
497
|
pi.on("session_shutdown", async () => {
|
|
396
498
|
destroyFinder();
|
|
499
|
+
destroyExternalFinder();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// --- bash grep → ripgrep rewrite ---
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Rewrite a bash grep command to an equivalent rg (ripgrep) command.
|
|
506
|
+
* Returns the rewritten command, or undefined if the command is not a
|
|
507
|
+
* simple grep invocation that can be safely converted.
|
|
508
|
+
*/
|
|
509
|
+
function rewriteGrepToRg(command: string): string | undefined {
|
|
510
|
+
const cdMatch = /^(\s*cd\s+([^\s&]+)\s*&&\s*)(.*)$/.exec(command);
|
|
511
|
+
const prefix = cdMatch?.[1] ?? "";
|
|
512
|
+
const body = cdMatch?.[3] ?? command;
|
|
513
|
+
if (!/^\s*grep\b/.test(body)) return undefined;
|
|
514
|
+
const beforePipe = body.split(/\s+\|/)[0].trim();
|
|
515
|
+
const afterGrep = beforePipe.replace(/^\s*grep\s+/, "");
|
|
516
|
+
// Strip stderr redirects (2>/dev/null, 2>&1, etc.) so they don't
|
|
517
|
+
// become false path arguments.
|
|
518
|
+
const cleaned = afterGrep.replace(/\s+2>(?:&\d+|\/dev\/null|\S+)/g, "").trim();
|
|
519
|
+
const tokens = cleaned.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
|
|
520
|
+
if (!tokens) return undefined;
|
|
521
|
+
|
|
522
|
+
const rgArgs: string[] = ["rg"];
|
|
523
|
+
let pattern: string | undefined;
|
|
524
|
+
let paths: string[] = [];
|
|
525
|
+
let includeGlobs: string[] = [];
|
|
526
|
+
let excludeGlobs: string[] = [];
|
|
527
|
+
let caseInsensitive = false;
|
|
528
|
+
let fixedStrings = false;
|
|
529
|
+
let wordRegex = false;
|
|
530
|
+
let countOnly = false;
|
|
531
|
+
let filesOnly = false;
|
|
532
|
+
let invertMatch = false;
|
|
533
|
+
let contextAfter = 0;
|
|
534
|
+
let contextBefore = 0;
|
|
535
|
+
let contextBoth = 0;
|
|
536
|
+
let lineNumber = false;
|
|
537
|
+
let noFilename = false;
|
|
538
|
+
|
|
539
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
540
|
+
const tok = tokens[i];
|
|
541
|
+
if (tok === "-i" || tok === "--ignore-case") { caseInsensitive = true; continue; }
|
|
542
|
+
if (tok === "-F" || tok === "--fixed-strings") { fixedStrings = true; continue; }
|
|
543
|
+
if (tok === "-w" || tok === "--word-regexp") { wordRegex = true; continue; }
|
|
544
|
+
if (tok === "-c" || tok === "--count") { countOnly = true; continue; }
|
|
545
|
+
if (tok === "-l" || tok === "--files-with-matches") { filesOnly = true; continue; }
|
|
546
|
+
if (tok === "-v" || tok === "--invert-match") { invertMatch = true; continue; }
|
|
547
|
+
if (tok === "-n" || tok === "--line-number") { lineNumber = true; continue; }
|
|
548
|
+
if (tok === "-h" || tok === "--no-filename") { noFilename = true; continue; }
|
|
549
|
+
if (tok === "-E" || tok === "--extended-regexp") { continue; }
|
|
550
|
+
if (tok === "-r" || tok === "-R" || tok === "--recursive") { continue; }
|
|
551
|
+
if (tok === "-s" || tok === "--no-messages") { continue; }
|
|
552
|
+
if (tok === "-A") { contextAfter = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
|
|
553
|
+
if (tok === "-B") { contextBefore = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
|
|
554
|
+
if (tok === "-C") { contextBoth = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
|
|
555
|
+
if (tok.startsWith("-A")) { contextAfter = parseInt(tok.slice(2), 10) || 0; continue; }
|
|
556
|
+
if (tok.startsWith("-B")) { contextBefore = parseInt(tok.slice(2), 10) || 0; continue; }
|
|
557
|
+
if (tok.startsWith("-C")) { contextBoth = parseInt(tok.slice(2), 10) || 0; continue; }
|
|
558
|
+
if (tok === "--include") { includeGlobs.push(tokens[++i] ?? ""); continue; }
|
|
559
|
+
if (tok.startsWith("--include=")) { includeGlobs.push(tok.slice(10)); continue; }
|
|
560
|
+
if (tok === "--exclude") { excludeGlobs.push(tokens[++i] ?? ""); continue; }
|
|
561
|
+
if (tok.startsWith("--exclude=")) { excludeGlobs.push(tok.slice(10)); continue; }
|
|
562
|
+
if (tok === "--exclude-dir") { excludeGlobs.push(`${tokens[++i] ?? ""}/`); continue; }
|
|
563
|
+
if (tok.startsWith("--exclude-dir=")) { excludeGlobs.push(`${tok.slice(13)}/`); continue; }
|
|
564
|
+
// Handle combined short flags like -rn, -in, -rnI, etc.
|
|
565
|
+
if (/^-[a-zA-Z]{2,}$/.test(tok)) {
|
|
566
|
+
let bail = false;
|
|
567
|
+
for (const ch of tok.slice(1)) {
|
|
568
|
+
switch (ch) {
|
|
569
|
+
case "i": caseInsensitive = true; break;
|
|
570
|
+
case "F": fixedStrings = true; break;
|
|
571
|
+
case "w": wordRegex = true; break;
|
|
572
|
+
case "c": countOnly = true; break;
|
|
573
|
+
case "l": filesOnly = true; break;
|
|
574
|
+
case "v": invertMatch = true; break;
|
|
575
|
+
case "n": lineNumber = true; break;
|
|
576
|
+
case "h": noFilename = true; break;
|
|
577
|
+
case "E": case "r": case "R": case "s": break;
|
|
578
|
+
default: bail = true; break;
|
|
579
|
+
}
|
|
580
|
+
if (bail) break;
|
|
581
|
+
}
|
|
582
|
+
if (bail) return undefined;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (tok.startsWith("-")) {
|
|
586
|
+
// Unknown flag — bail to be safe.
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
if (pattern === undefined) {
|
|
590
|
+
pattern = tok.replace(/^["']|["']$/g, "");
|
|
591
|
+
} else {
|
|
592
|
+
paths.push(tok.replace(/^["']|["']$/g, ""));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (pattern === undefined) return undefined;
|
|
596
|
+
|
|
597
|
+
if (caseInsensitive) rgArgs.push("-i");
|
|
598
|
+
if (fixedStrings) rgArgs.push("-F");
|
|
599
|
+
if (wordRegex) rgArgs.push("-w");
|
|
600
|
+
if (countOnly) rgArgs.push("-c");
|
|
601
|
+
if (filesOnly) rgArgs.push("-l");
|
|
602
|
+
if (invertMatch) rgArgs.push("-v");
|
|
603
|
+
if (lineNumber || noFilename) rgArgs.push("-n");
|
|
604
|
+
if (noFilename) rgArgs.push("--no-filename");
|
|
605
|
+
if (contextAfter > 0) rgArgs.push("-A", String(contextAfter));
|
|
606
|
+
if (contextBefore > 0) rgArgs.push("-B", String(contextBefore));
|
|
607
|
+
if (contextBoth > 0) rgArgs.push("-C", String(contextBoth));
|
|
608
|
+
for (const g of includeGlobs) rgArgs.push("-g", g);
|
|
609
|
+
for (const g of excludeGlobs) rgArgs.push("-g", `!${g}`);
|
|
610
|
+
// rg is recursive by default; add -- to separate pattern from paths.
|
|
611
|
+
rgArgs.push("--", pattern);
|
|
612
|
+
for (const p of paths) rgArgs.push(p);
|
|
613
|
+
|
|
614
|
+
const rgCmd = rgArgs.map((a) => {
|
|
615
|
+
return /[\s'"!]/.test(a) ? `'${a.replace(/'/g, "'\\''")}'` : a;
|
|
616
|
+
}).join(" ");
|
|
617
|
+
return prefix + rgCmd;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
pi.on("tool_call", (event: any) => {
|
|
621
|
+
if (event.toolName !== "bash") return;
|
|
622
|
+
const command = event.input?.command;
|
|
623
|
+
if (typeof command !== "string") return;
|
|
624
|
+
if (!bashGrepInfo(command)) return;
|
|
625
|
+
const rewritten = rewriteGrepToRg(command);
|
|
626
|
+
if (rewritten) event.input.command = rewritten;
|
|
397
627
|
});
|
|
398
628
|
|
|
399
629
|
// --- grep tool ---
|
|
@@ -405,7 +635,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
405
635
|
path: Type.Optional(
|
|
406
636
|
Type.String({
|
|
407
637
|
description:
|
|
408
|
-
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
|
|
638
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Use ./pi-coding-agent to search the installed @earendil-works/pi-coding-agent package docs and examples.",
|
|
409
639
|
}),
|
|
410
640
|
),
|
|
411
641
|
exclude: Type.Optional(
|
|
@@ -450,9 +680,24 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
450
680
|
async execute(_toolCallId, params, signal) {
|
|
451
681
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
452
682
|
|
|
453
|
-
const
|
|
683
|
+
const cachedGrepCursor = params.cursor ? getCursor(params.cursor) : undefined;
|
|
684
|
+
let f: FileFinder;
|
|
685
|
+
let query: string;
|
|
686
|
+
if (cachedGrepCursor) {
|
|
687
|
+
f = cachedGrepCursor.externalDir
|
|
688
|
+
? await ensureExternalFinder(cachedGrepCursor.externalDir)
|
|
689
|
+
: await ensureFinder(activeCwd);
|
|
690
|
+
query = buildQuery(params.path, params.pattern, params.exclude, activeCwd, externalAllowlist);
|
|
691
|
+
} else {
|
|
692
|
+
const resolved = await resolveFinderAndQuery(
|
|
693
|
+
params.path,
|
|
694
|
+
params.pattern,
|
|
695
|
+
params.exclude,
|
|
696
|
+
);
|
|
697
|
+
f = resolved.finder;
|
|
698
|
+
query = resolved.query;
|
|
699
|
+
}
|
|
454
700
|
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
455
|
-
const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
|
456
701
|
const hasRegexSyntax =
|
|
457
702
|
params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
458
703
|
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
|
@@ -489,7 +734,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
489
734
|
mode,
|
|
490
735
|
smartCase,
|
|
491
736
|
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
492
|
-
cursor:
|
|
737
|
+
cursor: cachedGrepCursor?.cursor ?? null,
|
|
493
738
|
beforeContext: params.context ?? 0,
|
|
494
739
|
afterContext: params.context ?? 0,
|
|
495
740
|
classifyDefinitions: true,
|
|
@@ -523,7 +768,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
523
768
|
notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
|
|
524
769
|
}
|
|
525
770
|
if (result.nextCursor) {
|
|
526
|
-
notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
|
|
771
|
+
notices.push(`Continue with cursor="${storeCursor(result.nextCursor, f === externalFinder ? externalFinderDir ?? undefined : undefined)}"`);
|
|
527
772
|
}
|
|
528
773
|
|
|
529
774
|
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
@@ -557,7 +802,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
557
802
|
path: Type.Optional(
|
|
558
803
|
Type.String({
|
|
559
804
|
description:
|
|
560
|
-
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
|
|
805
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Use ./pi-coding-agent to search the installed @earendil-works/pi-coding-agent package docs and examples.",
|
|
561
806
|
}),
|
|
562
807
|
),
|
|
563
808
|
exclude: Type.Optional(
|
|
@@ -595,15 +840,27 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
595
840
|
async execute(_toolCallId, params, signal) {
|
|
596
841
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
597
842
|
|
|
598
|
-
const f = await ensureFinder(activeCwd);
|
|
599
|
-
|
|
600
843
|
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
|
601
844
|
const effectiveLimit = resumed
|
|
602
845
|
? resumed.pageSize
|
|
603
846
|
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
847
|
+
|
|
848
|
+
let f: FileFinder;
|
|
849
|
+
let query: string;
|
|
850
|
+
if (resumed) {
|
|
851
|
+
f = resumed.externalDir
|
|
852
|
+
? await ensureExternalFinder(resumed.externalDir)
|
|
853
|
+
: await ensureFinder(activeCwd);
|
|
854
|
+
query = resumed.query;
|
|
855
|
+
} else {
|
|
856
|
+
const resolved = await resolveFinderAndQuery(
|
|
857
|
+
params.path,
|
|
858
|
+
params.pattern,
|
|
859
|
+
params.exclude,
|
|
860
|
+
);
|
|
861
|
+
f = resolved.finder;
|
|
862
|
+
query = resolved.query;
|
|
863
|
+
}
|
|
607
864
|
const pattern = resumed ? resumed.pattern : params.pattern;
|
|
608
865
|
const pageIndex = resumed?.nextPageIndex ?? 0;
|
|
609
866
|
|
|
@@ -634,6 +891,7 @@ export default function emberFffExtension(pi: ExtensionAPI) {
|
|
|
634
891
|
pattern,
|
|
635
892
|
pageSize: effectiveLimit,
|
|
636
893
|
nextPageIndex: pageIndex + 1,
|
|
894
|
+
externalDir: f === externalFinder ? externalFinderDir ?? undefined : undefined,
|
|
637
895
|
});
|
|
638
896
|
notices.push(
|
|
639
897
|
`${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
|
|
@@ -1,21 +1,179 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// External allowlist — auto-detected pi-coding-agent package directory
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** The default alias for the pi-coding-agent package. */
|
|
9
|
+
export const PI_CODING_AGENT_ALIAS = "pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
export type ExternalAllowlistEntry = {
|
|
12
|
+
/** User-facing alias, e.g. "pi-coding-agent" (no leading ./). */
|
|
13
|
+
alias: string;
|
|
14
|
+
/** Auto-detected absolute directory. */
|
|
15
|
+
dir: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ExternalAllowlist = {
|
|
19
|
+
entries: readonly ExternalAllowlistEntry[];
|
|
20
|
+
/** Resolve a user-facing path constraint to an allowlisted dir, or undefined. */
|
|
21
|
+
resolve(pathConstraint: string): ExternalAllowlistEntry | undefined;
|
|
22
|
+
/** True if an absolute path is inside an allowlisted dir. */
|
|
23
|
+
covers(absolutePath: string): boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ExternalTarget = {
|
|
27
|
+
entry: ExternalAllowlistEntry;
|
|
28
|
+
/** Path relative to entry.dir, forward-slashed, no leading ./, or "" for the whole dir. */
|
|
29
|
+
relativePath: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Auto-detect the installed @earendil-works/pi-coding-agent package root.
|
|
34
|
+
* Returns undefined if detection fails (package not installed or
|
|
35
|
+
* import.meta.resolve unavailable).
|
|
36
|
+
*/
|
|
37
|
+
function detect_pi_package_dir(): string | undefined {
|
|
38
|
+
try {
|
|
39
|
+
const resolved = import.meta.resolve("@earendil-works/pi-coding-agent");
|
|
40
|
+
const entryPath = fileURLToPath(resolved); // .../dist/index.js
|
|
41
|
+
return path.dirname(path.dirname(entryPath)); // package root (parent of dist/)
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the external allowlist by auto-detecting the installed
|
|
49
|
+
* @earendil-works/pi-coding-agent package directory. Returns an empty
|
|
50
|
+
* allowlist if detection fails (fail-safe: no external access).
|
|
51
|
+
*/
|
|
52
|
+
export function buildExternalAllowlist(): ExternalAllowlist {
|
|
53
|
+
const dir = detect_pi_package_dir();
|
|
54
|
+
const entries: ExternalAllowlistEntry[] = [];
|
|
55
|
+
if (dir) {
|
|
56
|
+
entries.push({ alias: PI_CODING_AGENT_ALIAS, dir });
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
entries,
|
|
60
|
+
resolve(pathConstraint: string): ExternalAllowlistEntry | undefined {
|
|
61
|
+
let trimmed = pathConstraint.trim();
|
|
62
|
+
if (!trimmed) return undefined;
|
|
63
|
+
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (trimmed === entry.alias) return entry;
|
|
66
|
+
if (
|
|
67
|
+
trimmed.startsWith(entry.alias + "/") ||
|
|
68
|
+
trimmed.startsWith(entry.alias + path.sep)
|
|
69
|
+
) {
|
|
70
|
+
return entry;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
},
|
|
75
|
+
covers(absolutePath: string): boolean {
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const rel = path.relative(entry.dir, absolutePath);
|
|
78
|
+
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* If `pathConstraint` targets an allowlisted external directory, return
|
|
89
|
+
* the resolved entry and the sub-path relative to that dir. Otherwise
|
|
90
|
+
* undefined. This lets the tool layer route to a secondary FileFinder
|
|
91
|
+
* before normalization.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveExternalTarget(
|
|
94
|
+
pathConstraint: string | undefined,
|
|
95
|
+
allowlist: ExternalAllowlist,
|
|
96
|
+
): ExternalTarget | undefined {
|
|
97
|
+
if (!pathConstraint) return undefined;
|
|
98
|
+
let trimmed = pathConstraint.trim();
|
|
99
|
+
if (!trimmed) return undefined;
|
|
100
|
+
|
|
101
|
+
// Absolute path — check if it's under an allowlisted dir.
|
|
102
|
+
if (path.isAbsolute(trimmed)) {
|
|
103
|
+
if (!allowlist.covers(trimmed)) return undefined;
|
|
104
|
+
for (const entry of allowlist.entries) {
|
|
105
|
+
const rel = path.relative(entry.dir, trimmed).replaceAll(path.sep, "/");
|
|
106
|
+
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
|
|
107
|
+
return { entry, relativePath: rel };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Strip leading ./
|
|
114
|
+
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
|
115
|
+
|
|
116
|
+
// Alias-prefixed relative path
|
|
117
|
+
for (const entry of allowlist.entries) {
|
|
118
|
+
if (trimmed === entry.alias) {
|
|
119
|
+
return { entry, relativePath: "" };
|
|
120
|
+
}
|
|
121
|
+
// Check alias/ or alias\ as a prefix (cross-platform)
|
|
122
|
+
const aliasPrefixFwd = entry.alias + "/";
|
|
123
|
+
const aliasPrefixSep = entry.alias + path.sep;
|
|
124
|
+
if (trimmed.startsWith(aliasPrefixFwd) || trimmed.startsWith(aliasPrefixSep)) {
|
|
125
|
+
// Normalize to forward slashes for FFF
|
|
126
|
+
const remainder = trimmed.startsWith(aliasPrefixFwd)
|
|
127
|
+
? trimmed.slice(aliasPrefixFwd.length)
|
|
128
|
+
: trimmed.slice(aliasPrefixSep.length).replaceAll(path.sep, "/");
|
|
129
|
+
return { entry, relativePath: remainder };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Path constraint normalization
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
2
139
|
|
|
3
140
|
export function normalizePathConstraint(
|
|
4
141
|
pathConstraint: string,
|
|
5
142
|
cwd = process.cwd(),
|
|
143
|
+
allowlist?: ExternalAllowlist,
|
|
6
144
|
): string | null {
|
|
7
145
|
let trimmed = pathConstraint.trim();
|
|
8
146
|
if (!trimmed) return trimmed;
|
|
9
147
|
|
|
10
148
|
if (path.isAbsolute(trimmed)) {
|
|
11
|
-
|
|
12
|
-
if (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
149
|
+
// Allowlisted external absolute path — do not throw.
|
|
150
|
+
if (allowlist?.covers(trimmed)) {
|
|
151
|
+
const target = resolveExternalTarget(trimmed, allowlist);
|
|
152
|
+
if (target) {
|
|
153
|
+
// Return the relative path within the allowlisted dir.
|
|
154
|
+
// If bare dir, return null (whole dir). Otherwise return the
|
|
155
|
+
// relative subpath forward-slashed.
|
|
156
|
+
if (!target.relativePath) return null;
|
|
157
|
+
trimmed = target.relativePath;
|
|
158
|
+
// Jump to the post-absolute-path normalization logic below.
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
|
|
162
|
+
if (relative === "") return null;
|
|
163
|
+
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Path constraint must be relative to the workspace: ${pathConstraint}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
trimmed = relative;
|
|
169
|
+
}
|
|
170
|
+
} else if (allowlist) {
|
|
171
|
+
// Check for alias-prefixed relative path (e.g. ./pi-coding-agent/docs).
|
|
172
|
+
const target = resolveExternalTarget(trimmed, allowlist);
|
|
173
|
+
if (target) {
|
|
174
|
+
if (!target.relativePath) return null;
|
|
175
|
+
trimmed = target.relativePath;
|
|
17
176
|
}
|
|
18
|
-
trimmed = relative;
|
|
19
177
|
}
|
|
20
178
|
|
|
21
179
|
if (trimmed === "." || trimmed === "./") return null;
|
|
@@ -52,6 +210,7 @@ export function normalizePathConstraint(
|
|
|
52
210
|
export function normalizeExcludes(
|
|
53
211
|
exclude: string | string[] | undefined,
|
|
54
212
|
cwd = process.cwd(),
|
|
213
|
+
allowlist?: ExternalAllowlist,
|
|
55
214
|
): string[] {
|
|
56
215
|
if (!exclude) return [];
|
|
57
216
|
const list = Array.isArray(exclude) ? exclude : [exclude];
|
|
@@ -63,7 +222,7 @@ export function normalizeExcludes(
|
|
|
63
222
|
.filter(Boolean);
|
|
64
223
|
for (const p of parts) {
|
|
65
224
|
const stripped = p.startsWith("!") ? p.slice(1) : p;
|
|
66
|
-
const normalized = normalizePathConstraint(stripped, cwd);
|
|
225
|
+
const normalized = normalizePathConstraint(stripped, cwd, allowlist);
|
|
67
226
|
if (normalized) out.push(`!${normalized}`);
|
|
68
227
|
}
|
|
69
228
|
}
|
|
@@ -75,13 +234,14 @@ export function buildQuery(
|
|
|
75
234
|
pattern: string,
|
|
76
235
|
exclude?: string | string[],
|
|
77
236
|
cwd = process.cwd(),
|
|
237
|
+
allowlist?: ExternalAllowlist,
|
|
78
238
|
): string {
|
|
79
239
|
const parts: string[] = [];
|
|
80
240
|
if (path) {
|
|
81
|
-
const pathConstraint = normalizePathConstraint(path, cwd);
|
|
241
|
+
const pathConstraint = normalizePathConstraint(path, cwd, allowlist);
|
|
82
242
|
if (pathConstraint) parts.push(pathConstraint);
|
|
83
243
|
}
|
|
84
|
-
parts.push(...normalizeExcludes(exclude, cwd));
|
|
244
|
+
parts.push(...normalizeExcludes(exclude, cwd, allowlist));
|
|
85
245
|
parts.push(pattern);
|
|
86
246
|
return parts.join(" ");
|
|
87
247
|
}
|