@aaroncql/pim-agent 0.0.1

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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,243 @@
1
+ import { OutputBudget } from "../../shared/OutputBudget";
2
+ import { Paths } from "../../shared/Paths";
3
+ import type { GrepLineRange, GrepMatch } from "./grep";
4
+ import type { GrepOutputMode, GrepPathFormat } from "./schema";
5
+
6
+ export type RenderOutcome = {
7
+ readonly body: string;
8
+ readonly totalItems: number;
9
+ readonly visibleItems: number;
10
+ readonly truncated: boolean;
11
+ readonly fileCount: number;
12
+ readonly totalMatches: number;
13
+ readonly itemNoun: string;
14
+ };
15
+
16
+ export type RenderOptions = {
17
+ readonly cwd: string;
18
+ readonly pathFormat: GrepPathFormat;
19
+ readonly context: number;
20
+ };
21
+
22
+ const renderers: Record<
23
+ GrepOutputMode,
24
+ {
25
+ readonly itemNoun: string;
26
+ readonly toLines: (
27
+ matches: readonly GrepMatch[],
28
+ options: RenderOptions
29
+ ) => readonly string[];
30
+ }
31
+ > = {
32
+ files_with_matches: { itemNoun: "files", toLines: renderFiles },
33
+ content: { itemNoun: "lines", toLines: renderContent },
34
+ count: { itemNoun: "files", toLines: renderCounts },
35
+ };
36
+
37
+ export function renderMatches(
38
+ matches: readonly GrepMatch[],
39
+ outputMode: GrepOutputMode,
40
+ headLimit: number,
41
+ options: RenderOptions
42
+ ): RenderOutcome {
43
+ const totalMatches = matches.reduce(
44
+ (sum, match) => sum + matchCount(match),
45
+ 0
46
+ );
47
+ const { itemNoun, toLines } = renderers[outputMode];
48
+ const lines = toLines(matches, options);
49
+
50
+ if (lines.length === 0) {
51
+ return {
52
+ body: "No matches.",
53
+ totalItems: 0,
54
+ visibleItems: 0,
55
+ truncated: false,
56
+ fileCount: 0,
57
+ totalMatches: 0,
58
+ itemNoun,
59
+ };
60
+ }
61
+
62
+ const headCapped = lines.slice(0, headLimit);
63
+ const { visible } = OutputBudget.applyByteCap(headCapped);
64
+ const truncated = visible.length < lines.length;
65
+
66
+ return {
67
+ body: visible.join("\n"),
68
+ totalItems: lines.length,
69
+ visibleItems: visible.length,
70
+ truncated,
71
+ fileCount: matches.length,
72
+ totalMatches,
73
+ itemNoun,
74
+ };
75
+ }
76
+
77
+ export type TitleOptions = {
78
+ readonly pattern: string | undefined;
79
+ readonly path: string | undefined;
80
+ readonly glob: string | undefined;
81
+ readonly cwd: string;
82
+ readonly fileCount?: number;
83
+ };
84
+
85
+ export function formatTitle(options: TitleOptions): string {
86
+ const pattern = formatPattern(options.pattern);
87
+ const resolvedPath =
88
+ options.path === undefined
89
+ ? undefined
90
+ : Paths.resolve(options.path, options.cwd);
91
+ const target = Paths.titleOr(resolvedPath, options.cwd, ".");
92
+ const glob = options.glob ? ` ${options.glob}` : "";
93
+ const suffix =
94
+ options.fileCount === undefined
95
+ ? ""
96
+ : ` (${options.fileCount} ${options.fileCount === 1 ? "file" : "files"})`;
97
+ return `${pattern} in ${target}${glob}${suffix}`;
98
+ }
99
+
100
+ function formatPattern(pattern: string | undefined): string {
101
+ return pattern === undefined ? "..." : `/${pattern}/`;
102
+ }
103
+
104
+ function renderFiles(
105
+ matches: readonly GrepMatch[],
106
+ options: RenderOptions
107
+ ): readonly string[] {
108
+ return byRecency(matches).map((match) => formatPath(match.filePath, options));
109
+ }
110
+
111
+ function renderContent(
112
+ matches: readonly GrepMatch[],
113
+ options: RenderOptions
114
+ ): readonly string[] {
115
+ if (options.context > 0) {
116
+ return byRecency(matches).flatMap((match) =>
117
+ renderContextContent(match, options)
118
+ );
119
+ }
120
+
121
+ return byRecency(matches).flatMap((match) =>
122
+ match.lines.map(
123
+ (line) =>
124
+ `${formatPath(match.filePath, options)}:${line.lineNumber}:${OutputBudget.truncateLine(line.text)}`
125
+ )
126
+ );
127
+ }
128
+
129
+ function renderContextContent(
130
+ match: GrepMatch,
131
+ options: RenderOptions
132
+ ): readonly string[] {
133
+ const path = formatPath(match.filePath, options);
134
+ const blocks = contextBlocks(
135
+ match.ranges,
136
+ match.fileLines.length,
137
+ options.context
138
+ );
139
+ const lines: string[] = [];
140
+
141
+ for (const [blockIndex, block] of blocks.entries()) {
142
+ if (blockIndex > 0) {
143
+ lines.push("--");
144
+ }
145
+
146
+ for (
147
+ let lineNumber = block.startLineNumber;
148
+ lineNumber <= block.endLineNumber;
149
+ lineNumber += 1
150
+ ) {
151
+ const marker = isMatchLine(match.ranges, lineNumber) ? ">" : " ";
152
+ const text = OutputBudget.truncateLine(
153
+ match.fileLines[lineNumber - 1] ?? ""
154
+ );
155
+ lines.push(`${marker} ${path}:${lineNumber}:${text}`);
156
+ }
157
+ }
158
+
159
+ return lines;
160
+ }
161
+
162
+ function renderCounts(
163
+ matches: readonly GrepMatch[],
164
+ options: RenderOptions
165
+ ): readonly string[] {
166
+ return [...matches]
167
+ .sort(
168
+ (left, right) =>
169
+ matchCount(right) - matchCount(left) ||
170
+ right.mtime - left.mtime ||
171
+ comparePaths(left.filePath, right.filePath)
172
+ )
173
+ .map(
174
+ (match) => `${formatPath(match.filePath, options)}:${matchCount(match)}`
175
+ );
176
+ }
177
+
178
+ function contextBlocks(
179
+ ranges: readonly GrepLineRange[],
180
+ lineCount: number,
181
+ context: number
182
+ ): readonly GrepLineRange[] {
183
+ const blocks: GrepLineRange[] = [];
184
+
185
+ for (const range of ranges) {
186
+ const expanded = {
187
+ startLineNumber: Math.max(1, range.startLineNumber - context),
188
+ endLineNumber: Math.min(lineCount, range.endLineNumber + context),
189
+ };
190
+ const previous = blocks.at(-1);
191
+
192
+ if (
193
+ previous !== undefined &&
194
+ expanded.startLineNumber <= previous.endLineNumber + 1
195
+ ) {
196
+ blocks[blocks.length - 1] = {
197
+ startLineNumber: previous.startLineNumber,
198
+ endLineNumber: Math.max(previous.endLineNumber, expanded.endLineNumber),
199
+ };
200
+ } else {
201
+ blocks.push(expanded);
202
+ }
203
+ }
204
+
205
+ return blocks;
206
+ }
207
+
208
+ function matchCount(match: GrepMatch): number {
209
+ return match.ranges.length;
210
+ }
211
+
212
+ function isMatchLine(
213
+ ranges: readonly GrepLineRange[],
214
+ lineNumber: number
215
+ ): boolean {
216
+ return ranges.some(
217
+ (range) =>
218
+ lineNumber >= range.startLineNumber && lineNumber <= range.endLineNumber
219
+ );
220
+ }
221
+
222
+ function formatPath(filePath: string, options: RenderOptions): string {
223
+ return options.pathFormat === "absolute"
224
+ ? filePath
225
+ : Paths.displayRelative(filePath, options.cwd);
226
+ }
227
+
228
+ function byRecency(matches: readonly GrepMatch[]): readonly GrepMatch[] {
229
+ return [...matches].sort(
230
+ (left, right) =>
231
+ right.mtime - left.mtime || comparePaths(left.filePath, right.filePath)
232
+ );
233
+ }
234
+
235
+ function comparePaths(left: string, right: string): number {
236
+ if (left < right) {
237
+ return -1;
238
+ }
239
+ if (left > right) {
240
+ return 1;
241
+ }
242
+ return 0;
243
+ }
@@ -0,0 +1,92 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { type Static, Type } from "typebox";
3
+
4
+ export const GREP_HEAD_LIMIT_MAX = 1000;
5
+ export const GREP_CONTEXT_MAX = 20;
6
+
7
+ export const GREP_OUTPUT_MODES = [
8
+ "files_with_matches",
9
+ "content",
10
+ "count",
11
+ ] as const;
12
+
13
+ export const GREP_PATH_FORMATS = ["relative", "absolute"] as const;
14
+
15
+ export const grepSchema = Type.Object({
16
+ pattern: Type.String({
17
+ description: "JavaScript regex source, without /.../ delimiters.",
18
+ }),
19
+ path: Type.Optional(
20
+ Type.String({
21
+ description:
22
+ "Absolute or relative path to file/directory (resolved against cwd). Defaults to cwd.",
23
+ })
24
+ ),
25
+ glob: Type.Optional(
26
+ Type.String({
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.",
29
+ })
30
+ ),
31
+ exclude: Type.Optional(
32
+ Type.Array(Type.String(), {
33
+ description:
34
+ "Glob patterns to exclude from directory searches, relative to path (eg. **/*.test.ts or dist/**).",
35
+ })
36
+ ),
37
+ outputMode: Type.Optional(
38
+ StringEnum(GREP_OUTPUT_MODES, {
39
+ description:
40
+ "`files_with_matches` (default): returns file paths. `content`: returns path:line:text. `count`: returns path:count.",
41
+ })
42
+ ),
43
+ matchAcrossLines: Type.Optional(
44
+ Type.Boolean({
45
+ description:
46
+ "Search whole files so one match can span line breaks. Defaults to false.",
47
+ })
48
+ ),
49
+ context: Type.Optional(
50
+ Type.Integer({
51
+ minimum: 0,
52
+ maximum: GREP_CONTEXT_MAX,
53
+ description:
54
+ "For outputMode='content', include this many lines before and after each match. Defaults to 0.",
55
+ })
56
+ ),
57
+ includeDotfiles: Type.Optional(
58
+ Type.Boolean({
59
+ description:
60
+ "Include dot-prefixed files and directories such as .env or .github during directory scans. Defaults to false.",
61
+ })
62
+ ),
63
+ includeIgnored: Type.Optional(
64
+ Type.Boolean({
65
+ description:
66
+ "Include gitignored and normally ignored paths such as node_modules during directory scans. Defaults to false.",
67
+ })
68
+ ),
69
+ pathFormat: Type.Optional(
70
+ StringEnum(GREP_PATH_FORMATS, {
71
+ description:
72
+ "`relative` (default): output paths relative to cwd when possible. `absolute`: output absolute paths.",
73
+ })
74
+ ),
75
+ caseInsensitive: Type.Optional(
76
+ Type.Boolean({
77
+ description: "Search case-insensitively. Defaults to false.",
78
+ })
79
+ ),
80
+ headLimit: Type.Optional(
81
+ Type.Integer({
82
+ minimum: 1,
83
+ maximum: GREP_HEAD_LIMIT_MAX,
84
+ description: `Maximum returned items. Defaults to ${GREP_HEAD_LIMIT_MAX}.`,
85
+ })
86
+ ),
87
+ });
88
+
89
+ export type GrepInput = Static<typeof grepSchema>;
90
+
91
+ export type GrepOutputMode = (typeof GREP_OUTPUT_MODES)[number];
92
+ export type GrepPathFormat = (typeof GREP_PATH_FORMATS)[number];
@@ -0,0 +1,84 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Paths } from "../../shared/Paths";
3
+ import { Renderer } from "../../shared/Renderer";
4
+ import { Tools } from "../../shared/Tools";
5
+ import { buildReadRange, readFile } from "./read";
6
+ import { formatTitlePath } from "./render";
7
+ import { type ReadInput, readSchema } from "./schema";
8
+
9
+ const PREVIEW_LINES = 10;
10
+
11
+ export default function (pi: ExtensionAPI): void {
12
+ Tools.register(pi, {
13
+ name: "read",
14
+ label: "read",
15
+ 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.",
18
+ parameters: readSchema,
19
+ renderShell: "self",
20
+ executionMode: "parallel",
21
+ async execute(_id, params, signal, _onUpdate, ctx) {
22
+ const { path, start, end } = params as ReadInput;
23
+
24
+ if (signal?.aborted) {
25
+ throw new Error("Read aborted before execution.");
26
+ }
27
+
28
+ const range = buildReadRange(start, end);
29
+ const absolutePath = Paths.resolve(path, ctx.cwd);
30
+ const outcome = await readFile(absolutePath, range);
31
+
32
+ const content: Array<{ type: "text"; text: string }> = [
33
+ { type: "text", text: outcome.body },
34
+ ];
35
+
36
+ if (outcome.truncatedByEnd && outcome.nextStart !== undefined) {
37
+ content.push({
38
+ type: "text",
39
+ text: `[read tool: showing lines ${outcome.visibleStart}-${outcome.visibleEnd} of ${outcome.totalLines}; call read again with start=${outcome.nextStart} to continue.]`,
40
+ });
41
+ }
42
+
43
+ return {
44
+ content,
45
+ details: {
46
+ absolutePath,
47
+ totalLines: outcome.totalLines,
48
+ visibleStart: outcome.visibleStart,
49
+ visibleEnd: outcome.visibleEnd,
50
+ truncatedByByteCap: outcome.truncatedByByteCap,
51
+ truncatedByEnd: outcome.truncatedByEnd,
52
+ hadBom: outcome.hadBom,
53
+ ...(outcome.nextStart === undefined
54
+ ? {}
55
+ : { nextStart: outcome.nextStart }),
56
+ },
57
+ };
58
+ },
59
+ renderCall(args, theme, context) {
60
+ 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
+ });
67
+ return Renderer.renderToolCallTitle({
68
+ label: "Read",
69
+ title,
70
+ theme,
71
+ context,
72
+ });
73
+ },
74
+ renderResult(result, options, theme, context) {
75
+ return Renderer.renderBorderedResult({
76
+ result,
77
+ options,
78
+ theme,
79
+ context,
80
+ previewLines: PREVIEW_LINES,
81
+ });
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,177 @@
1
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, describe, expect, test } from "bun:test";
5
+ import { OutputBudget } from "../../shared/OutputBudget";
6
+ import { buildReadRange, readFile } from "./read";
7
+
8
+ const MAX_LINE_LENGTH = OutputBudget.maxLineLength;
9
+
10
+ const tempRoots: string[] = [];
11
+
12
+ const tempRoot = async (): Promise<string> => {
13
+ const root = await mkdtemp(join(tmpdir(), "pim-read-tool-"));
14
+ tempRoots.push(root);
15
+ return root;
16
+ };
17
+
18
+ afterAll(async () => {
19
+ await Promise.all(
20
+ tempRoots.map((root) => rm(root, { force: true, recursive: true }))
21
+ );
22
+ });
23
+
24
+ describe("readFile", () => {
25
+ test("emits inclusive LINE:CONTENT ranges", async () => {
26
+ const root = await tempRoot();
27
+ const path = join(root, "notes.txt");
28
+ await writeFile(path, "alpha\nbeta\ngamma", "utf8");
29
+
30
+ const outcome = await readFile(path, buildReadRange(2, 2));
31
+ expect(outcome.body).toBe("2:beta");
32
+ expect(outcome.totalLines).toBe(3);
33
+ expect(outcome.visibleStart).toBe(2);
34
+ expect(outcome.visibleEnd).toBe(2);
35
+ expect(outcome.truncatedByByteCap).toBe(false);
36
+ expect(outcome.truncatedByEnd).toBe(true);
37
+ expect(outcome.nextStart).toBe(3);
38
+ });
39
+
40
+ test("does not surface a phantom final line for files ending in a newline", async () => {
41
+ const root = await tempRoot();
42
+ const path = join(root, "notes.txt");
43
+ await writeFile(path, "alpha\nbeta\ngamma\n", "utf8");
44
+
45
+ const outcome = await readFile(path, buildReadRange(undefined, undefined));
46
+ expect(outcome.body).toBe(["1:alpha", "2:beta", "3:gamma"].join("\n"));
47
+ expect(outcome.totalLines).toBe(3);
48
+ expect(outcome.truncatedByEnd).toBe(false);
49
+ });
50
+
51
+ test("strips UTF-8 BOM from output and reports it in details", async () => {
52
+ const root = await tempRoot();
53
+ const path = join(root, "bom.txt");
54
+ await writeFile(path, "\uFEFFalpha\nbeta", "utf8");
55
+
56
+ const outcome = await readFile(path, buildReadRange(undefined, undefined));
57
+ expect(outcome.body).toBe("1:alpha\n2:beta");
58
+ expect(outcome.hadBom).toBe(true);
59
+ });
60
+
61
+ test("truncates very long individual lines", async () => {
62
+ const root = await tempRoot();
63
+ const path = join(root, "long-line.txt");
64
+ await writeFile(path, `${"x".repeat(MAX_LINE_LENGTH + 10)}\nshort`, "utf8");
65
+
66
+ const outcome = await readFile(path, buildReadRange(1, 1));
67
+ expect(outcome.body).toBe(
68
+ `1:${"x".repeat(MAX_LINE_LENGTH)}... (line truncated to ${MAX_LINE_LENGTH} chars)`
69
+ );
70
+ });
71
+
72
+ test("throws on empty files and out-of-range starts", async () => {
73
+ const root = await tempRoot();
74
+ const path = join(root, "empty.txt");
75
+ await writeFile(path, "", "utf8");
76
+
77
+ await expect(
78
+ readFile(path, buildReadRange(undefined, undefined))
79
+ ).rejects.toThrow("File is empty. Use the write tool to create content.");
80
+
81
+ const populated = join(root, "small.txt");
82
+ await writeFile(populated, "alpha\nbeta", "utf8");
83
+ await expect(
84
+ readFile(populated, buildReadRange(99, undefined))
85
+ ).rejects.toThrow(
86
+ "Start 99 is beyond end of file (2 lines total). Use start=1 to read from the beginning, or start=2 to read the last line."
87
+ );
88
+ });
89
+
90
+ test("rejects directories and binary files", async () => {
91
+ const root = await tempRoot();
92
+ const nested = join(root, "nested");
93
+ const binary = join(root, "data.bin");
94
+
95
+ await mkdir(nested);
96
+ await Bun.write(binary, new Uint8Array([1, 0, 2]));
97
+
98
+ await expect(
99
+ readFile(nested, buildReadRange(undefined, undefined))
100
+ ).rejects.toThrow(`Path is a directory: ${nested}`);
101
+
102
+ await expect(
103
+ readFile(binary, buildReadRange(undefined, undefined))
104
+ ).rejects.toThrow("Read only supports UTF-8 text files");
105
+ });
106
+
107
+ test("returns missing-file error with sibling suggestions", async () => {
108
+ const root = await tempRoot();
109
+ const missing = join(root, "note.txt");
110
+ await writeFile(join(root, "notes.txt"), "alpha", "utf8");
111
+
112
+ await expect(
113
+ readFile(missing, buildReadRange(undefined, undefined))
114
+ ).rejects.toThrow(/Did you mean one of these\?[\s\S]*notes\.txt/);
115
+ });
116
+
117
+ test("surfaces permission-denied as a structured error", async () => {
118
+ if (process.getuid?.() === 0) {
119
+ return;
120
+ }
121
+
122
+ const root = await tempRoot();
123
+ const path = join(root, "locked.txt");
124
+ await writeFile(path, "secret", "utf8");
125
+ await chmod(path, 0o000);
126
+
127
+ try {
128
+ await expect(
129
+ readFile(path, buildReadRange(undefined, undefined))
130
+ ).rejects.toThrow(`Permission denied reading ${path}.`);
131
+ } finally {
132
+ await chmod(path, 0o644);
133
+ }
134
+ });
135
+
136
+ test("applies a 32 KiB head-only byte cap and reports pagination metadata", async () => {
137
+ const root = await tempRoot();
138
+ const path = join(root, "long.txt");
139
+ const lines = Array.from(
140
+ { length: 80 },
141
+ (_, index) => `${index + 1}: ${"x".repeat(500)}`
142
+ );
143
+ await writeFile(path, lines.join("\n"), "utf8");
144
+
145
+ const outcome = await readFile(path, buildReadRange(undefined, undefined));
146
+ expect(Buffer.byteLength(outcome.body, "utf8")).toBeLessThanOrEqual(
147
+ 32 * 1024
148
+ );
149
+ expect(outcome.truncatedByByteCap).toBe(true);
150
+ expect(outcome.truncatedByEnd).toBe(true);
151
+ expect(outcome.nextStart).toBe(outcome.visibleEnd + 1);
152
+ expect(outcome.totalLines).toBe(80);
153
+ });
154
+ });
155
+
156
+ describe("buildReadRange", () => {
157
+ test("rejects end before start", () => {
158
+ expect(() => buildReadRange(5, 4)).toThrow(
159
+ "Read end line 4 must be >= start line 5."
160
+ );
161
+ });
162
+
163
+ test("rejects non-positive integers", () => {
164
+ expect(() => buildReadRange(0, undefined)).toThrow(
165
+ "Read start 0 must be a positive integer."
166
+ );
167
+ expect(() => buildReadRange(undefined, -1)).toThrow(
168
+ "Read end -1 must be a positive integer."
169
+ );
170
+ });
171
+
172
+ test("defaults start to 1", () => {
173
+ expect(buildReadRange(undefined, undefined)).toEqual({
174
+ start: 1,
175
+ });
176
+ });
177
+ });