@aaroncql/pim-agent 0.1.0 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +92 -65
  2. package/package.json +6 -6
  3. package/src/extensions/apply-patch/coordinator.ts +67 -0
  4. package/src/extensions/apply-patch/executor.ts +566 -0
  5. package/src/extensions/apply-patch/index.ts +75 -0
  6. package/src/extensions/apply-patch/matcher.ts +66 -0
  7. package/src/extensions/apply-patch/model.ts +34 -0
  8. package/src/extensions/apply-patch/parser.ts +381 -0
  9. package/src/extensions/apply-patch/render.ts +261 -0
  10. package/src/extensions/apply-patch/schema.ts +43 -0
  11. package/src/extensions/apply-patch/types.ts +30 -0
  12. package/src/extensions/bash/index.ts +3 -3
  13. package/src/extensions/edit/index.ts +2 -1
  14. package/src/extensions/file-picker/FilePickerSuggestionEngine.ts +14 -0
  15. package/src/extensions/file-picker/InProcessFilePickerSuggestionEngine.ts +52 -0
  16. package/src/extensions/file-picker/WorkerFilePickerSuggestionEngine.ts +268 -0
  17. package/src/extensions/file-picker/catalog.ts +38 -33
  18. package/src/extensions/file-picker/filePickerWorker.ts +72 -0
  19. package/src/extensions/file-picker/filePickerWorkerMessages.ts +39 -0
  20. package/src/extensions/file-picker/index.ts +138 -83
  21. package/src/extensions/file-picker/ranker.ts +180 -12
  22. package/src/extensions/glob/index.ts +3 -1
  23. package/src/extensions/glob/schema.ts +2 -1
  24. package/src/extensions/grep/grep.ts +45 -2
  25. package/src/extensions/grep/index.ts +3 -1
  26. package/src/extensions/grep/render.ts +18 -4
  27. package/src/extensions/grep/schema.ts +1 -1
  28. package/src/extensions/read/index.ts +36 -9
  29. package/src/extensions/read/render.ts +31 -3
  30. package/src/extensions/subagent/index.ts +4 -1
  31. package/src/extensions/todo/index.ts +4 -3
  32. package/src/extensions/web-search/index.ts +2 -1
  33. package/src/extensions/write/index.ts +2 -1
  34. package/src/shared/FileEnumerator.ts +492 -0
  35. package/src/shared/FileScanner.ts +15 -17
  36. package/src/shared/PatchSummary.ts +82 -0
  37. package/src/telegram/Renderer.ts +190 -4
  38. package/src/shared/GitignoreFilter.ts +0 -142
@@ -1,5 +1,5 @@
1
- import { resolve } from "node:path";
2
- import { GitignoreFilter } from "./GitignoreFilter";
1
+ import { join, resolve } from "node:path";
2
+ import { FileEnumerator } from "./FileEnumerator";
3
3
  import { GlobExclusions } from "./GlobExclusions";
4
4
 
5
5
  export type FileScanOptions = {
@@ -15,25 +15,23 @@ export class FileScanner {
15
15
  options: FileScanOptions
16
16
  ): Promise<readonly string[]> {
17
17
  const absoluteRoot = resolve(root);
18
- const filter = options.includeIgnored
19
- ? undefined
20
- : await GitignoreFilter.for(absoluteRoot);
18
+ const relativePaths = await FileEnumerator.enumerate(absoluteRoot, {
19
+ includeDotfiles: options.includeDotfiles,
20
+ includeIgnored: options.includeIgnored,
21
+ });
22
+ const matcher = new Bun.Glob(pattern);
21
23
  const excludes = GlobExclusions.compile(options.exclude);
22
- const glob = new Bun.Glob(pattern);
23
24
  const files: string[] = [];
24
25
 
25
- for await (const filePath of glob.scan({
26
- cwd: absoluteRoot,
27
- absolute: true,
28
- onlyFiles: true,
29
- dot: options.includeDotfiles,
30
- })) {
31
- if (
32
- (filter === undefined || !filter.ignores(filePath)) &&
33
- !GlobExclusions.ignores(excludes, absoluteRoot, filePath)
34
- ) {
35
- files.push(filePath);
26
+ for (const relativePath of relativePaths) {
27
+ if (!matcher.match(relativePath)) {
28
+ continue;
36
29
  }
30
+ const absolutePath = join(absoluteRoot, relativePath);
31
+ if (GlobExclusions.ignores(excludes, absoluteRoot, absolutePath)) {
32
+ continue;
33
+ }
34
+ files.push(absolutePath);
37
35
  }
38
36
 
39
37
  return files;
@@ -0,0 +1,82 @@
1
+ export type PatchOp = {
2
+ readonly kind: "add" | "delete" | "update";
3
+ readonly path: string;
4
+ readonly movePath?: string;
5
+ };
6
+
7
+ const MOVE_TO_MARKER = "*** Move to:";
8
+ const FILE_MARKERS: ReadonlyArray<readonly [PatchOp["kind"], string]> = [
9
+ ["add", "*** Add File: "],
10
+ ["delete", "*** Delete File: "],
11
+ ["update", "*** Update File: "],
12
+ ];
13
+
14
+ type MutableOp = {
15
+ readonly kind: PatchOp["kind"];
16
+ readonly path: string;
17
+ movePath?: string;
18
+ };
19
+
20
+ /**
21
+ * Lightweight, fault-tolerant scan of V4A patch text into a per-file summary.
22
+ * For renderers that need to label a patch without depending on the apply-patch
23
+ * grammar parser; unrecognized lines are ignored rather than throwing.
24
+ */
25
+ export class PatchSummary {
26
+ public static fromText(input: string): readonly PatchOp[] {
27
+ const ops: MutableOp[] = [];
28
+
29
+ for (const raw of input.split("\n")) {
30
+ const line = raw.trim();
31
+ const op = PatchSummary.fileOp(line);
32
+ if (op) {
33
+ ops.push(op);
34
+ } else if (line.startsWith(MOVE_TO_MARKER)) {
35
+ const current = ops.at(-1);
36
+ if (current?.kind === "update") {
37
+ current.movePath = PatchSummary.clean(
38
+ line.slice(MOVE_TO_MARKER.length)
39
+ );
40
+ }
41
+ }
42
+ }
43
+
44
+ return ops;
45
+ }
46
+
47
+ // The first affected path, without building the full per-file summary — for
48
+ // callers that only need a title (e.g. a tool-call header on the render path).
49
+ public static firstPath(input: string): string | undefined {
50
+ for (const raw of input.split("\n")) {
51
+ const op = PatchSummary.fileOp(raw.trim());
52
+ if (op) {
53
+ return op.path;
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ private static fileOp(line: string): MutableOp | undefined {
60
+ for (const [kind, marker] of FILE_MARKERS) {
61
+ if (line.startsWith(marker)) {
62
+ return { kind, path: PatchSummary.clean(line.slice(marker.length)) };
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ private static clean(raw: string): string {
69
+ let path = raw.trim();
70
+ if (path.startsWith("@")) {
71
+ path = path.slice(1).trim();
72
+ }
73
+ if (path.length >= 2) {
74
+ const first = path[0]!;
75
+ const last = path.at(-1)!;
76
+ if ((first === '"' || first === "'" || first === "`") && first === last) {
77
+ path = path.slice(1, -1);
78
+ }
79
+ }
80
+ return path;
81
+ }
82
+ }
@@ -2,8 +2,12 @@ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
2
  import { GrammyError, type Api } from "grammy";
3
3
  import { basename } from "node:path";
4
4
 
5
+ import type { ApplyEntry } from "../extensions/apply-patch/executor";
5
6
  import type { SubagentDetails } from "../extensions/subagent/subagent";
6
7
  import type { TodoInput } from "../extensions/todo/schema";
8
+ import type { ToolDiff } from "../shared/DiffLines";
9
+ import { DiffView, type DiffStats } from "../shared/DiffView";
10
+ import { type PatchOp, PatchSummary } from "../shared/PatchSummary";
7
11
  import type { LogsMode } from "./Config";
8
12
  import { Markdown } from "./Markdown";
9
13
  import type { Session, SessionId } from "./Session";
@@ -15,15 +19,30 @@ type TurnState = TurnEndState | "running";
15
19
  type TrackerEntry = {
16
20
  readonly key: string;
17
21
  readonly kind: "tool" | "todo" | "thinking" | "narration";
18
- readonly emoji: string;
22
+ emoji: string;
19
23
  label: string;
20
24
  state: "running" | "ok" | "error";
25
+ // Plaintext "+4/-3" appended after the label once the tool finishes.
26
+ stats?: string;
21
27
  };
22
28
 
29
+ type ApplyOp = {
30
+ readonly emoji: string;
31
+ readonly text: string;
32
+ };
33
+
34
+ const EDIT_EMOJI = "✏️";
35
+ const DELETE_EMOJI = "🗑️";
36
+ const ARROW = "➝";
37
+
38
+ // Keys an apply_patch tool call may carry its patch text under (canonical first).
39
+ const PATCH_TEXT_KEYS = ["input", "patch", "patchText", "patch_text"] as const;
40
+
23
41
  const TOOL_EMOJI: Record<string, string> = {
24
42
  read: "📄",
25
- edit: "✏️",
26
- write: "✏️",
43
+ edit: EDIT_EMOJI,
44
+ write: EDIT_EMOJI,
45
+ apply_patch: EDIT_EMOJI,
27
46
  bash: "⚡️",
28
47
  grep: "🔎",
29
48
  glob: "🔎",
@@ -130,6 +149,9 @@ export class Renderer {
130
149
  return;
131
150
  }
132
151
  this.updateSubagentLabel(event.toolCallId, event.toolName, event.result);
152
+ if (!event.isError) {
153
+ this.applyDiffStats(event.toolCallId, event.toolName, event.result);
154
+ }
133
155
  const idx = this.toolIndex.get(event.toolCallId);
134
156
  if (idx !== undefined) {
135
157
  this.entries[idx]!.state = event.isError ? "error" : "ok";
@@ -179,6 +201,21 @@ export class Renderer {
179
201
  this.scheduleEdit();
180
202
  return;
181
203
  }
204
+ if (name === "apply_patch") {
205
+ const { emoji, label } = Renderer.buildApplyEntry(
206
+ Renderer.applyOpsFromArgs(args)
207
+ );
208
+ this.entries.push({
209
+ key: toolCallId,
210
+ kind: "tool",
211
+ emoji,
212
+ label,
213
+ state: "running",
214
+ });
215
+ this.toolIndex.set(toolCallId, this.entries.length - 1);
216
+ this.scheduleEdit();
217
+ return;
218
+ }
182
219
  const emoji = TOOL_EMOJI[name] ?? "⚙️";
183
220
  const label = Renderer.toolLabel(toolName, args);
184
221
  if (name === "subagent") {
@@ -234,6 +271,42 @@ export class Renderer {
234
271
  this.scheduleEdit();
235
272
  }
236
273
 
274
+ private applyDiffStats(
275
+ toolCallId: string,
276
+ toolName: string,
277
+ result: unknown
278
+ ): void {
279
+ const idx = this.toolIndex.get(toolCallId);
280
+ if (idx === undefined) {
281
+ return;
282
+ }
283
+ const name = toolName.toLowerCase();
284
+ const details = (result as { readonly details?: unknown } | null)?.details;
285
+ if (name === "edit" || name === "write") {
286
+ const diff = (details as { readonly diff?: ToolDiff } | undefined)?.diff;
287
+ const stats = Renderer.formatPlainStats(DiffView.countStats(diff));
288
+ if (stats) {
289
+ this.entries[idx]!.stats = stats;
290
+ this.scheduleEdit();
291
+ }
292
+ return;
293
+ }
294
+ if (name === "apply_patch") {
295
+ const entries = (
296
+ details as { readonly entries?: readonly ApplyEntry[] } | undefined
297
+ )?.entries;
298
+ if (!entries) {
299
+ return;
300
+ }
301
+ const built = Renderer.buildApplyEntry(
302
+ Renderer.applyOpsFromEntries(entries)
303
+ );
304
+ this.entries[idx]!.emoji = built.emoji;
305
+ this.entries[idx]!.label = built.label;
306
+ this.scheduleEdit();
307
+ }
308
+ }
309
+
237
310
  private flushThinking(): void {
238
311
  if (this.logsMode !== "verbose") {
239
312
  this.thinking = "";
@@ -370,7 +443,8 @@ export class Renderer {
370
443
  } else if (state === "running" && isLastEntry) {
371
444
  suffix = " 🟡";
372
445
  }
373
- pieces.push(`${entry.emoji} ${entry.label}${suffix}`);
446
+ const stats = entry.stats ? ` ${entry.stats}` : "";
447
+ pieces.push(`${entry.emoji} ${entry.label}${stats}${suffix}`);
374
448
  }
375
449
  const next = visible[i + 1];
376
450
  if (next) {
@@ -490,6 +564,118 @@ export class Renderer {
490
564
  }
491
565
  }
492
566
 
567
+ private static buildApplyEntry(ops: readonly ApplyOp[]): {
568
+ readonly emoji: string;
569
+ readonly label: string;
570
+ } {
571
+ const [first, ...rest] = ops;
572
+ if (!first) {
573
+ return { emoji: EDIT_EMOJI, label: "" };
574
+ }
575
+ const label = [
576
+ first.text,
577
+ ...rest.map((op) => `${op.emoji} ${op.text}`),
578
+ ].join("\n");
579
+ return { emoji: first.emoji, label };
580
+ }
581
+
582
+ private static applyOpsFromArgs(args: unknown): readonly ApplyOp[] {
583
+ const text = Renderer.patchTextFromArgs(args);
584
+ if (!text) {
585
+ return [];
586
+ }
587
+ return PatchSummary.fromText(text).map((op) => Renderer.opFromSummary(op));
588
+ }
589
+
590
+ private static applyOpsFromEntries(
591
+ entries: readonly ApplyEntry[]
592
+ ): readonly ApplyOp[] {
593
+ return entries
594
+ .filter(
595
+ (entry) => !(entry.action.kind === "update" && entry.diff === undefined)
596
+ )
597
+ .map((entry) => Renderer.opFromEntry(entry));
598
+ }
599
+
600
+ private static opFromSummary(op: PatchOp): ApplyOp {
601
+ const isMove = op.movePath !== undefined && op.movePath !== op.path;
602
+ return Renderer.applyOp({
603
+ kind: isMove ? "move" : op.kind,
604
+ path: op.path,
605
+ movePath: op.movePath,
606
+ });
607
+ }
608
+
609
+ private static opFromEntry(entry: ApplyEntry): ApplyOp {
610
+ return Renderer.applyOp({
611
+ kind: entry.action.kind,
612
+ path: entry.action.path,
613
+ movePath: entry.action.movePath,
614
+ stats: Renderer.formatPlainStats(DiffView.countStats(entry.diff)),
615
+ });
616
+ }
617
+
618
+ private static applyOp(params: {
619
+ readonly kind: "add" | "delete" | "move" | "update";
620
+ readonly path: string;
621
+ readonly movePath?: string;
622
+ readonly stats?: string;
623
+ }): ApplyOp {
624
+ const suffix = params.stats ? ` ${params.stats}` : "";
625
+ if (params.kind === "delete") {
626
+ return {
627
+ emoji: DELETE_EMOJI,
628
+ text: `${Renderer.codeName(params.path)}${suffix}`,
629
+ };
630
+ }
631
+ if (params.kind === "move") {
632
+ return {
633
+ emoji: EDIT_EMOJI,
634
+ text: `${Renderer.moveText(params.path, params.movePath ?? params.path)}${suffix}`,
635
+ };
636
+ }
637
+ return {
638
+ emoji: EDIT_EMOJI,
639
+ text: `${Renderer.codeName(params.path)}${suffix}`,
640
+ };
641
+ }
642
+
643
+ private static moveText(from: string, to: string): string {
644
+ return `${Renderer.codeName(from)} ${ARROW} ${Renderer.codeName(to)}`;
645
+ }
646
+
647
+ private static codeName(path: string): string {
648
+ return `<code>${Markdown.escape(basename(path))}</code>`;
649
+ }
650
+
651
+ private static patchTextFromArgs(args: unknown): string | undefined {
652
+ if (typeof args === "string") {
653
+ return args;
654
+ }
655
+ if (!args || typeof args !== "object") {
656
+ return undefined;
657
+ }
658
+ const record = args as Record<string, unknown>;
659
+ for (const key of PATCH_TEXT_KEYS) {
660
+ const value = record[key];
661
+ if (typeof value === "string" && value) {
662
+ return value;
663
+ }
664
+ }
665
+ return undefined;
666
+ }
667
+
668
+ private static formatPlainStats(stats: DiffStats): string {
669
+ const parts: string[] = [];
670
+ if (stats.added > 0) {
671
+ parts.push(`+${stats.added}`);
672
+ }
673
+ if (stats.removed > 0) {
674
+ parts.push(`-${stats.removed}`);
675
+ }
676
+ return parts.join("/");
677
+ }
678
+
493
679
  private static toolLabel(toolName: string, args: unknown): string {
494
680
  const obj =
495
681
  args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -1,142 +0,0 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { dirname, isAbsolute, parse, relative, resolve } from "node:path";
3
- import ignore, { type Ignore } from "ignore";
4
- import { FsErrors } from "./FsErrors";
5
- import { Paths } from "./Paths";
6
-
7
- type IgnoreMatcher = {
8
- readonly baseDirectory: string;
9
- readonly matcher: Ignore;
10
- };
11
-
12
- export class GitignoreFilter {
13
- private static readonly alwaysIgnoredPatterns = [
14
- ".git/",
15
- "node_modules/",
16
- "dist/",
17
- "build/",
18
- "out/",
19
- "target/",
20
- "coverage/",
21
- ".next/",
22
- ".cache/",
23
- ".turbo/",
24
- ".vercel/",
25
- ".svelte-kit/",
26
- ] as const;
27
-
28
- private readonly matchers: readonly IgnoreMatcher[];
29
-
30
- private constructor(matchers: readonly IgnoreMatcher[]) {
31
- this.matchers = matchers;
32
- }
33
-
34
- public static async for(root: string): Promise<GitignoreFilter> {
35
- const absoluteRoot = resolve(root);
36
- const rootDirectory =
37
- await GitignoreFilter.containingDirectory(absoluteRoot);
38
- const directories =
39
- await GitignoreFilter.gitignoreDirectories(rootDirectory);
40
- const contents = await Promise.all(
41
- directories.map((directory) => GitignoreFilter.readGitignore(directory))
42
- );
43
- const matchers: IgnoreMatcher[] = [
44
- {
45
- baseDirectory: rootDirectory,
46
- matcher: ignore().add([...GitignoreFilter.alwaysIgnoredPatterns]),
47
- },
48
- ];
49
-
50
- for (const [index, directory] of directories.entries()) {
51
- const body = contents[index];
52
-
53
- if (body !== undefined) {
54
- matchers.push({
55
- baseDirectory: directory,
56
- matcher: ignore().add(body),
57
- });
58
- }
59
- }
60
-
61
- return new GitignoreFilter(matchers);
62
- }
63
-
64
- public ignores(absolutePath: string): boolean {
65
- if (!isAbsolute(absolutePath)) {
66
- throw new Error(`Expected absolute path: ${absolutePath}`);
67
- }
68
-
69
- for (const { baseDirectory, matcher } of this.matchers) {
70
- const candidate = this.relativePath(baseDirectory, absolutePath);
71
-
72
- if (candidate !== undefined && matcher.ignores(candidate)) {
73
- return true;
74
- }
75
- }
76
-
77
- return false;
78
- }
79
-
80
- private static async containingDirectory(path: string): Promise<string> {
81
- const metadata = await stat(path);
82
-
83
- return metadata.isDirectory() ? path : dirname(path);
84
- }
85
-
86
- private static async gitignoreDirectories(
87
- root: string
88
- ): Promise<readonly string[]> {
89
- const directories: string[] = [];
90
- const filesystemRoot = parse(root).root;
91
- let current = root;
92
-
93
- while (true) {
94
- directories.push(current);
95
-
96
- if (await Bun.file(resolve(current, ".git")).exists()) {
97
- break;
98
- }
99
-
100
- if (current === filesystemRoot) {
101
- break;
102
- }
103
-
104
- current = dirname(current);
105
- }
106
-
107
- return directories;
108
- }
109
-
110
- private static async readGitignore(
111
- directory: string
112
- ): Promise<string | undefined> {
113
- const path = resolve(directory, ".gitignore");
114
-
115
- try {
116
- return await readFile(path, "utf8");
117
- } catch (error) {
118
- if (FsErrors.code(error) === "ENOENT") {
119
- return undefined;
120
- }
121
-
122
- throw error;
123
- }
124
- }
125
-
126
- private relativePath(
127
- baseDirectory: string,
128
- absolutePath: string
129
- ): string | undefined {
130
- const candidate = relative(baseDirectory, absolutePath);
131
-
132
- if (
133
- candidate.length === 0 ||
134
- candidate.startsWith("..") ||
135
- isAbsolute(candidate)
136
- ) {
137
- return undefined;
138
- }
139
-
140
- return Paths.toForwardSlashes(candidate);
141
- }
142
- }