@aaroncql/pim-agent 0.0.1 → 0.2.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 (84) hide show
  1. package/README.md +94 -66
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/apply-patch/coordinator.ts +49 -0
  6. package/src/extensions/apply-patch/executor.ts +566 -0
  7. package/src/extensions/apply-patch/index.ts +74 -0
  8. package/src/extensions/apply-patch/matcher.ts +66 -0
  9. package/src/extensions/apply-patch/model.ts +34 -0
  10. package/src/extensions/apply-patch/parser.ts +381 -0
  11. package/src/extensions/apply-patch/render.ts +261 -0
  12. package/src/extensions/apply-patch/schema.ts +43 -0
  13. package/src/extensions/apply-patch/types.ts +30 -0
  14. package/src/extensions/bash/index.ts +3 -3
  15. package/src/extensions/edit/index.ts +2 -1
  16. package/src/extensions/glob/index.ts +3 -1
  17. package/src/extensions/glob/schema.ts +2 -1
  18. package/src/extensions/grep/index.ts +3 -1
  19. package/src/extensions/grep/render.ts +18 -4
  20. package/src/extensions/grep/schema.ts +1 -1
  21. package/src/extensions/read/index.ts +36 -9
  22. package/src/extensions/read/render.ts +31 -3
  23. package/src/extensions/subagent/index.ts +4 -1
  24. package/src/extensions/todo/index.ts +4 -3
  25. package/src/extensions/web-search/index.ts +2 -1
  26. package/src/extensions/write/index.ts +2 -1
  27. package/src/shared/PatchSummary.ts +82 -0
  28. package/src/telegram/Renderer.ts +190 -4
  29. package/src/extensions/bash/capture.test.ts +0 -126
  30. package/src/extensions/bash/format.test.ts +0 -240
  31. package/src/extensions/bash/run.test.ts +0 -262
  32. package/src/extensions/command-picker/ranker.test.ts +0 -46
  33. package/src/extensions/edit/edit.test.ts +0 -285
  34. package/src/extensions/file-picker/catalog.test.ts +0 -263
  35. package/src/extensions/file-picker/index.test.ts +0 -168
  36. package/src/extensions/file-picker/ranker.test.ts +0 -94
  37. package/src/extensions/footer/git.test.ts +0 -76
  38. package/src/extensions/footer/index.test.ts +0 -161
  39. package/src/extensions/footer/segments.test.ts +0 -164
  40. package/src/extensions/glob/glob.test.ts +0 -171
  41. package/src/extensions/glob/index.test.ts +0 -68
  42. package/src/extensions/glob/render.test.ts +0 -126
  43. package/src/extensions/grep/grep.test.ts +0 -387
  44. package/src/extensions/grep/index.test.ts +0 -68
  45. package/src/extensions/grep/render.test.ts +0 -269
  46. package/src/extensions/read/read.test.ts +0 -177
  47. package/src/extensions/read/render.test.ts +0 -61
  48. package/src/extensions/subagent/index.test.ts +0 -44
  49. package/src/extensions/subagent/render.test.ts +0 -292
  50. package/src/extensions/subagent/subagent.test.ts +0 -315
  51. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  52. package/src/extensions/todo/index.test.ts +0 -244
  53. package/src/extensions/todo/render.test.ts +0 -180
  54. package/src/extensions/todo/todo.test.ts +0 -222
  55. package/src/extensions/tps/index.test.ts +0 -254
  56. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  57. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  58. package/src/extensions/web-fetch/render.test.ts +0 -56
  59. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  60. package/src/extensions/web-search/render.test.ts +0 -21
  61. package/src/extensions/web-search/search.test.ts +0 -53
  62. package/src/extensions/working-indicator/index.test.ts +0 -21
  63. package/src/extensions/write/render.test.ts +0 -64
  64. package/src/extensions/write/write.test.ts +0 -108
  65. package/src/shared/DiffLines.test.ts +0 -193
  66. package/src/shared/DiffRenderer.test.ts +0 -206
  67. package/src/shared/EditMatcher.test.ts +0 -123
  68. package/src/shared/FileScanner.test.ts +0 -158
  69. package/src/shared/FuzzyMatcher.test.ts +0 -114
  70. package/src/shared/GitignoreFilter.test.ts +0 -64
  71. package/src/shared/Lines.test.ts +0 -25
  72. package/src/shared/McpClient.test.ts +0 -235
  73. package/src/shared/OutputBudget.test.ts +0 -99
  74. package/src/shared/Paths.test.ts +0 -51
  75. package/src/shared/PimSettings.test.ts +0 -90
  76. package/src/shared/Renderer.test.ts +0 -190
  77. package/src/shared/SpillCache.test.ts +0 -94
  78. package/src/shared/Tools.test.ts +0 -392
  79. package/src/telegram/Config.test.ts +0 -275
  80. package/src/telegram/Markdown.test.ts +0 -143
  81. package/src/telegram/Renderer.test.ts +0 -216
  82. package/src/telegram/SessionRegistry.test.ts +0 -89
  83. package/src/telegram/TaskScheduler.test.ts +0 -278
  84. package/src/telegram/TaskTool.test.ts +0 -179
@@ -0,0 +1,43 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ export const applyPatchSchema = Type.Object({
4
+ input: Type.String({
5
+ description: "Patch text wrapped in *** Begin Patch / *** End Patch.",
6
+ }),
7
+ });
8
+
9
+ export type ApplyPatchInput = Static<typeof applyPatchSchema>;
10
+
11
+ const ALIAS_KEYS = ["patch", "patchText", "patch_text"] as const;
12
+
13
+ /**
14
+ * Forgive the JSON-key choice, trust the grammar. Accepts `{input}` (canonical,
15
+ * handled above), `{patch}`, `{patchText}`/`{patch_text}`, or a bare string,
16
+ * normalizing to `{input}` and stripping the alias key so the unknown-key
17
+ * rejection in `Tools.wrap` passes. Validation of the actual envelope happens
18
+ * in the parser.
19
+ */
20
+ export function prepareApplyPatchArguments(rawArgs: unknown): ApplyPatchInput {
21
+ if (typeof rawArgs === "string") {
22
+ return { input: rawArgs };
23
+ }
24
+
25
+ if (rawArgs === null || typeof rawArgs !== "object") {
26
+ return rawArgs as ApplyPatchInput;
27
+ }
28
+
29
+ const record = rawArgs as Record<string, unknown>;
30
+ if (typeof record.input === "string") {
31
+ return rawArgs as ApplyPatchInput;
32
+ }
33
+
34
+ for (const key of ALIAS_KEYS) {
35
+ const value = record[key];
36
+ if (typeof value === "string") {
37
+ const { [key]: _dropped, ...rest } = record;
38
+ return { ...rest, input: value } as ApplyPatchInput;
39
+ }
40
+ }
41
+
42
+ return rawArgs as ApplyPatchInput;
43
+ }
@@ -0,0 +1,30 @@
1
+ export type UpdateChunk = {
2
+ readonly changeContext: string | undefined;
3
+ readonly oldLines: readonly string[];
4
+ readonly newLines: readonly string[];
5
+ readonly isEndOfFile: boolean;
6
+ };
7
+
8
+ export type AddHunk = {
9
+ readonly kind: "add";
10
+ readonly path: string;
11
+ readonly contents: string;
12
+ };
13
+
14
+ export type DeleteHunk = {
15
+ readonly kind: "delete";
16
+ readonly path: string;
17
+ };
18
+
19
+ export type UpdateHunk = {
20
+ readonly kind: "update";
21
+ readonly path: string;
22
+ readonly movePath: string | undefined;
23
+ readonly chunks: readonly UpdateChunk[];
24
+ };
25
+
26
+ export type Hunk = AddHunk | DeleteHunk | UpdateHunk;
27
+
28
+ export type Patch = {
29
+ readonly hunks: readonly Hunk[];
30
+ };
@@ -37,9 +37,9 @@ export default function (pi: ExtensionAPI): void {
37
37
  name: "bash",
38
38
  label: "bash",
39
39
  description:
40
- `Execute a bash command in the cwd. ` +
41
- `Returns exit code, signal (if any), and stdout/stderr captured separately. ` +
42
- `Prefer commands that emit only what you need; keep output as small as possible.`,
40
+ "Execute a bash command in the cwd. " +
41
+ "Returns exit code, signal (if any), and stdout/stderr captured separately. " +
42
+ "Prefer commands that emit only what you need; keep output as small as possible.",
43
43
  parameters: bashSchema,
44
44
  renderShell: "self",
45
45
  executionMode: "sequential",
@@ -12,7 +12,8 @@ export default function (pi: ExtensionAPI): void {
12
12
  name: "edit",
13
13
  label: "edit",
14
14
  description:
15
- "Replace strings in a UTF-8 text file. Prefer edit over write for changes to existing files.",
15
+ "Replace strings in a UTF-8 text file. " +
16
+ "Prefer edit over write for changes to existing files.",
16
17
  parameters: editSchema,
17
18
  renderShell: "self",
18
19
  executionMode: "sequential",
@@ -52,7 +52,9 @@ export default function (pi: ExtensionAPI): void {
52
52
  name: "glob",
53
53
  label: "glob",
54
54
  description:
55
- "Find files by glob pattern under a directory, sorted newest first. Skips gitignored paths and dotfiles unless requested. Use glob to enumerate files instead of bash with find, fd, ls -R, or similar.",
55
+ "Find files by glob pattern under a directory, sorted newest first. " +
56
+ "Skips gitignored paths and dotfiles unless requested. " +
57
+ "Use glob to enumerate files instead of bash with find, fd, ls -R, or similar.",
56
58
  parameters: globSchema,
57
59
  renderShell: "self",
58
60
  executionMode: "parallel",
@@ -7,7 +7,8 @@ export const GLOB_PATH_FORMATS = ["relative", "absolute"] as const;
7
7
 
8
8
  export const globSchema = Type.Object({
9
9
  pattern: Type.String({
10
- description: "Glob pattern relative to path (eg. **/*.ts).",
10
+ description:
11
+ "Glob pattern relative to path (eg. **/*.ts). Brace expansion spans sibling dirs (eg. {src,docs}/**/*.ts).",
11
12
  }),
12
13
  path: Type.Optional(
13
14
  Type.String({
@@ -55,7 +55,9 @@ export default function (pi: ExtensionAPI): void {
55
55
  name: "grep",
56
56
  label: "grep",
57
57
  description:
58
- "Search UTF-8 text files with a JavaScript regex. Directory scans skip binary files, gitignored paths, and dotfiles unless requested; direct file paths are always searched. Use grep to search file contents instead of bash with grep, rg, ag, find -exec, or similar.",
58
+ "Search UTF-8 text files with a JavaScript regex. " +
59
+ "Directory scans skip binary files, gitignored paths, and dotfiles unless requested; direct file paths are always searched. " +
60
+ "Use grep to search file contents instead of bash with grep, rg, ag, find -exec, or similar.",
59
61
  parameters: grepSchema,
60
62
  renderShell: "self",
61
63
  executionMode: "parallel",
@@ -84,17 +84,31 @@ export type TitleOptions = {
84
84
 
85
85
  export function formatTitle(options: TitleOptions): string {
86
86
  const pattern = formatPattern(options.pattern);
87
- const resolvedPath =
87
+ const resolved =
88
88
  options.path === undefined
89
89
  ? undefined
90
90
  : Paths.resolve(options.path, options.cwd);
91
- const target = Paths.titleOr(resolvedPath, options.cwd, ".");
92
- const glob = options.glob ? ` ${options.glob}` : "";
91
+ const dir =
92
+ resolved === undefined || resolved === options.cwd
93
+ ? undefined
94
+ : Paths.displayRelative(resolved, options.cwd);
95
+ const target = joinTarget(dir, options.glob);
96
+ const location = target ? ` in ${target}` : "";
93
97
  const suffix =
94
98
  options.fileCount === undefined
95
99
  ? ""
96
100
  : ` (${options.fileCount} ${options.fileCount === 1 ? "file" : "files"})`;
97
- return `${pattern} in ${target}${glob}${suffix}`;
101
+ return `${pattern}${location}${suffix}`;
102
+ }
103
+
104
+ function joinTarget(
105
+ dir: string | undefined,
106
+ glob: string | undefined
107
+ ): string | undefined {
108
+ if (glob === undefined) {
109
+ return dir;
110
+ }
111
+ return dir === undefined ? glob : `${dir}/${glob}`;
98
112
  }
99
113
 
100
114
  function formatPattern(pattern: string | undefined): string {
@@ -25,7 +25,7 @@ export const grepSchema = Type.Object({
25
25
  glob: Type.Optional(
26
26
  Type.String({
27
27
  description:
28
- "Relative glob filter under path when path is a directory. Gitignored files and dotfiles are skipped during directory scans unless includeIgnored/includeDotfiles is true.",
28
+ "Relative glob filter under path when path is a directory. Brace expansion spans sibling dirs (eg. {src,docs}/**/*.ts). Gitignored files and dotfiles are skipped during directory scans unless includeIgnored/includeDotfiles is true.",
29
29
  })
30
30
  ),
31
31
  exclude: Type.Optional(
@@ -3,18 +3,23 @@ import { Paths } from "../../shared/Paths";
3
3
  import { Renderer } from "../../shared/Renderer";
4
4
  import { Tools } from "../../shared/Tools";
5
5
  import { buildReadRange, readFile } from "./read";
6
- import { formatTitlePath } from "./render";
6
+ import { renderTitlePath, type ReadTitleOutcome } from "./render";
7
7
  import { type ReadInput, readSchema } from "./schema";
8
8
 
9
9
  const PREVIEW_LINES = 10;
10
10
 
11
+ type ReadRenderState = {
12
+ outcome?: ReadTitleOutcome;
13
+ };
14
+
11
15
  export default function (pi: ExtensionAPI): void {
12
16
  Tools.register(pi, {
13
17
  name: "read",
14
18
  label: "read",
15
19
  description:
16
- "Read a local UTF-8 text file. Output is `LINE:CONTENT` with no space after the colon. Capped at 32KB per call; lines longer than 2000 chars are truncated.",
17
- promptSnippet: "Read text files.",
20
+ "Read a local UTF-8 text file. " +
21
+ "Output is `LINE:CONTENT` with no space after the colon. " +
22
+ "Capped at 32KB per call; lines longer than 2000 chars are truncated.",
18
23
  parameters: readSchema,
19
24
  renderShell: "self",
20
25
  executionMode: "parallel",
@@ -58,12 +63,17 @@ export default function (pi: ExtensionAPI): void {
58
63
  },
59
64
  renderCall(args, theme, context) {
60
65
  const input = (args ?? {}) as Partial<ReadInput>;
61
- const title = formatTitlePath({
62
- path: input.path,
63
- cwd: context.cwd,
64
- start: input.start,
65
- end: input.end,
66
- });
66
+ const state = context.state as ReadRenderState;
67
+ const title = renderTitlePath(
68
+ {
69
+ path: input.path,
70
+ cwd: context.cwd,
71
+ start: input.start,
72
+ end: input.end,
73
+ outcome: state.outcome,
74
+ },
75
+ theme
76
+ );
67
77
  return Renderer.renderToolCallTitle({
68
78
  label: "Read",
69
79
  title,
@@ -72,6 +82,23 @@ export default function (pi: ExtensionAPI): void {
72
82
  });
73
83
  },
74
84
  renderResult(result, options, theme, context) {
85
+ const state = context.state as ReadRenderState;
86
+
87
+ if (!options.isPartial && state.outcome === undefined) {
88
+ const details = result.details as ReadTitleOutcome | undefined;
89
+
90
+ if (
91
+ typeof details?.visibleStart === "number" &&
92
+ typeof details.visibleEnd === "number"
93
+ ) {
94
+ state.outcome = {
95
+ visibleStart: details.visibleStart,
96
+ visibleEnd: details.visibleEnd,
97
+ };
98
+ context.invalidate();
99
+ }
100
+ }
101
+
75
102
  return Renderer.renderBorderedResult({
76
103
  result,
77
104
  options,
@@ -1,24 +1,52 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
1
2
  import { Paths } from "../../shared/Paths";
2
3
 
4
+ export type ReadTitleOutcome = {
5
+ readonly visibleStart: number;
6
+ readonly visibleEnd: number;
7
+ };
8
+
3
9
  export type TitlePathOptions = {
4
10
  readonly path: string | undefined;
5
11
  readonly cwd: string;
6
12
  readonly start: number | undefined;
7
13
  readonly end: number | undefined;
14
+ readonly outcome?: ReadTitleOutcome;
8
15
  };
9
16
 
10
17
  export function formatTitlePath(options: TitlePathOptions): string {
18
+ const { path, range } = formatTitlePathParts(options);
19
+ return `${path}${range}`;
20
+ }
21
+
22
+ export function renderTitlePath(
23
+ options: TitlePathOptions,
24
+ theme: Theme
25
+ ): string {
26
+ const { path, range } = formatTitlePathParts(options);
27
+ return `${path}${range === "" ? "" : theme.fg("muted", range)}`;
28
+ }
29
+
30
+ function formatTitlePathParts(options: TitlePathOptions): {
31
+ readonly path: string;
32
+ readonly range: string;
33
+ } {
11
34
  const path = options.path
12
35
  ? Paths.displayRelative(options.path, options.cwd)
13
36
  : "...";
14
- const range = formatRange(options.start, options.end);
15
- return `${path}${range}`;
37
+ const range = formatRange(options.start, options.end, options.outcome);
38
+ return { path, range };
16
39
  }
17
40
 
18
41
  function formatRange(
19
42
  start: number | undefined,
20
- end: number | undefined
43
+ end: number | undefined,
44
+ outcome: ReadTitleOutcome | undefined
21
45
  ): string {
46
+ if (outcome !== undefined) {
47
+ return `:${outcome.visibleStart}-${outcome.visibleEnd}`;
48
+ }
49
+
22
50
  if (start === undefined && end === undefined) {
23
51
  return "";
24
52
  }
@@ -9,7 +9,10 @@ export default function (pi: ExtensionAPI): void {
9
9
  name: "subagent",
10
10
  label: "subagent",
11
11
  description:
12
- "Delegate a task to an isolated subagent to keep your main context clean. The subagent inherits your currently active tools (except subagent itself) and runs in a fresh in-memory session. Multiple subagent calls in one turn run in parallel. Subagent responses are capped at 32KB; the full output is preserved in tool details.",
12
+ "Run a task in an isolated subagent with a fresh context. " +
13
+ "The subagent inherits the currently active tools, except subagent itself. " +
14
+ "Multiple subagent calls in one turn run in parallel. " +
15
+ "Subagent output returned to the main agent is capped at 32KB.",
13
16
  parameters: subagentSchema,
14
17
  renderShell: "self",
15
18
  executionMode: "parallel",
@@ -61,9 +61,10 @@ export default function (pi: ExtensionAPI): void {
61
61
  name: "todo",
62
62
  label: "todo",
63
63
  description:
64
- "Manage your to-dos. ALWAYS use for tasks with 3+ steps; skip only for trivial one-step tasks. " +
65
- "Each call replaces the entire list; include every item in priority order. " +
66
- "Keep at most one item in_progress, mark items completed immediately after finishing, and preserve skipped work as cancelled.",
64
+ "Update the session task list. " +
65
+ "Each call replaces the entire list; include every item that should remain, in priority order. " +
66
+ "Status values are pending, in_progress, completed, and cancelled. " +
67
+ "At most one item may be in_progress.",
67
68
  parameters: todoSchema,
68
69
  renderShell: "self",
69
70
  executionMode: "sequential",
@@ -49,7 +49,8 @@ export default function (pi: ExtensionAPI): void {
49
49
  name: "web_search",
50
50
  label: "web_search",
51
51
  description:
52
- "Search the web. Returns ranked results with title, URL, and a short snippet.",
52
+ "Search the web. " +
53
+ "Returns ranked results with title, URL, and a short snippet.",
53
54
  parameters: webSearchSchema,
54
55
  renderShell: "self",
55
56
  executionMode: "parallel",
@@ -12,7 +12,8 @@ export default function (pi: ExtensionAPI): void {
12
12
  name: "write",
13
13
  label: "write",
14
14
  description:
15
- "Create or overwrite UTF-8 text files. Use write only for new files or full rewrites - prefer edit for changes to existing files.",
15
+ "Create or overwrite UTF-8 text files. " +
16
+ "Use write only for new files or full rewrites.",
16
17
  parameters: writeSchema,
17
18
  renderShell: "self",
18
19
  executionMode: "sequential",
@@ -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
+ }