pi-file-injector 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/file-injector.ts +1412 -0
  4. package/package.json +62 -0
@@ -0,0 +1,1412 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ImageContent } from "@earendil-works/pi-ai";
3
+ import { resizeImage, formatDimensionNote, highlightCode, getLanguageFromPath, CONFIG_DIR_NAME, getAgentDir, type ResizedImage } from "@earendil-works/pi-coding-agent";
4
+ import { Box, Text, type Component } from "@earendil-works/pi-tui";
5
+ import { promises as fs } from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+
9
+ const FILE_INJECT_RE = /(^|(?<![\p{L}\p{N}_]))#@(\S+)/gu;
10
+ /** PRD §4.6 — markdown opt-in bare-`@` imports (the wiki-style `@path` shorthand). The lookbehind forbids a
11
+ * preceding `#` (so `#@file` is matched ONCE by FILE_INJECT_RE and NEVER here — no double candidate) AND a
12
+ * preceding word char (letter/digit/underscore — so `user@host.com`, `café@x`, `日本語@x` don't match).
13
+ * Unicode `\p{}` classes + the `u` flag mirror the shipped FILE_INJECT_RE for consistency. NOT exported.
14
+ * (PRD §4.6 literal `/(^|(?<=[^\w#]))@(\S+)/g` is the ASCII-equivalent form; this Unicode form is the
15
+ * recommendation.) Wires into scanTokens via `opts.bareAt` (markdown-only / opt-in, P1.M2). */
16
+ const BARE_AT_RE = /(^|(?<![\p{L}\p{N}_#]))@(\S+)/gu;
17
+ const MIME_BY_EXT: Record<string, string> = {
18
+ png: "image/png",
19
+ jpg: "image/jpeg",
20
+ jpeg: "image/jpeg",
21
+ gif: "image/gif",
22
+ webp: "image/webp",
23
+ bmp: "image/bmp",
24
+ };
25
+ const TRAILING_PUNCT = ".,;:!?\")]}>'";
26
+
27
+ /** §5.5 — paged-delivery budget constants (defaults pinned by PRD §5.5 "Constants (defaults to pin)"). */
28
+ const PAGED_THRESHOLD = 0.6; // inject whole if fileCost <= PAGED_THRESHOLD * remaining
29
+ const MARGIN = 8192; // safety bytes subtracted from remaining context
30
+ const HEAD_CHARS = 8192; // head block size in UTF-16 code units (matches read tool DEFAULT_MAX_LINES=2000 at ~4 chars/line)
31
+ const DEFAULT_RESERVE = 8192; // fallback for ctx.model?.maxTokens when model is absent
32
+ const READ_LIMIT = 2000; // read tool DEFAULT_MAX_LINES — page size emitted in the directive
33
+
34
+ /** §5.6.1 — approximate-CommonMark code-region detection (the markdown import exemption, PRD §4.5 rule 3).
35
+ * INLINE_CODE semantics: a backtick run (the OPENER) closed by a run of the SAME length, non-greedy,
36
+ * not followed by another backtick. Originally a single regex `/(`+)([\s\S]*?)\1(?!`)/g` run via
37
+ * content.matchAll; that form is O(n²) on a long unmatched backtick run (the backreference \1 + the
38
+ * lazy [\s\S]*? force the engine to try every run-length split — ~8s CPU at 200k backticks, reachable
39
+ * via any `#@file.md`). It is now implemented by the LINEAR-TIME `inlineCodeRanges` helper below, which
40
+ * produces byte-for-byte identical spans to the regex (verified by 500k randomized fuzz cases) while
41
+ * bounding the worst case to ~O(n) regardless of run length (200k backticks → ~1ms). An opener-length
42
+ * cap (`INLINE_CODE_MAX_OPENER`) is applied as defense-in-depth: realistic markdown never uses a
43
+ * code-span opener longer than a handful of backticks, so capping at 1024 cannot affect any real input
44
+ * but bounds the (already-linear) opener-retry loop against a hostile file. (PRD §5.6.1 notes the parser
45
+ * is "approximate"; this is the same approximation, just non-pathological.)
46
+ * FENCE_OPEN_RE: line-anchored (use against a single line, not full text); 0-3 leading spaces then a
47
+ * run of >= 3 backticks or tildes. Group 1 = the fence run (char + length drive open/close matching). */
48
+ const INLINE_CODE_MAX_OPENER = 1024; // cap on opener run length (defense-in-depth; see above)
49
+ const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/; // PRD §5.6.1 (exact form); apply per-line
50
+
51
+ /** §5.6 / §4.5 — a delivered file whose lowercased ext is md/markdown is an import source (consumed by
52
+ * the markdown branch T2.S3 adds to injectFile). Defined here so T2.S3 does not re-declare. */
53
+ const MD_EXTS = new Set(["md", "markdown"]);
54
+
55
+ /** §5.6.2 — flat fallback image token cost when resized dimensions are unavailable (raw base64 path).
56
+ * Derived from the 2000×2000 resize cap worst case: max(1,⌈2000/512⌉)·max(1,⌈2000/512⌉)·170 + 85 =
57
+ * 4·4·170 + 85 = 2805. Consumed by estimateImageTokens (T2.S2). Defined here per the constants cluster. */
58
+ const IMAGE_FALLBACK_TOKENS = 2805;
59
+
60
+ /** F3 — magic-number sniff. Routes by EXTENSION first (PRD §5.2), then validates the ACTUAL
61
+ * bytes match the declared image type before attaching. A mislabeled file (text body named
62
+ * `.png`) fails the sniff → falls through to the text/binary path instead of attaching
63
+ * decoded garbage labeled as an image. Returns true if `buf`'s header matches `mime`. */
64
+ export function hasValidImageMagic(buf: Buffer, mime: string): boolean {
65
+ // Common image signatures (first bytes only; a full parser is out of scope for routing).
66
+ if (buf.length < 12) return false;
67
+ switch (mime) {
68
+ case "image/png":
69
+ return (
70
+ buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
71
+ buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
72
+ );
73
+ case "image/jpeg":
74
+ return buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
75
+ case "image/gif":
76
+ return buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38; // "GIF8"
77
+ case "image/webp":
78
+ // "RIFF" .... "WEBP"
79
+ return (
80
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
81
+ buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
82
+ );
83
+ case "image/bmp":
84
+ return buf[0] === 0x42 && buf[1] === 0x4d; // "BM"
85
+ default:
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /** PRD §4.3 — trim every trailing char in TRAILING_PUNCT, repeatedly, until none remain. "" if empty. */
91
+ export function cleanToken(raw: string): string {
92
+ let s = raw;
93
+ while (s.length > 0 && TRAILING_PUNCT.includes(s[s.length - 1])) {
94
+ s = s.slice(0, -1);
95
+ }
96
+ return s;
97
+ }
98
+
99
+ /** PRD §4.4 — expand leading ~ / ~/ via os.homedir() (resolveReadPath is internal, unexported),
100
+ * then resolve against cwd. No cwd restriction: absolute, ~, and ../ all allowed. */
101
+ export function expandTildeAndResolve(p: string, cwd: string): string {
102
+ let expanded: string;
103
+ if (p === "~") {
104
+ expanded = os.homedir();
105
+ } else if (p.startsWith("~/")) {
106
+ expanded = path.join(os.homedir(), p.slice(2));
107
+ } else {
108
+ expanded = p;
109
+ }
110
+ return path.resolve(cwd, expanded);
111
+ }
112
+
113
+ /** PRD §5.4 / §5.1 — stat the path and return true iff it exists as a regular file. Mirrors the
114
+ * already-shipped `injectFile` stat+isFile idiom (L458-461): `await fs.stat(p)` rejects on ENOENT,
115
+ * so the `try/catch → false` is the correct "missing/non-existent" detection. Never throws.
116
+ * Used by `resolveImportPath` to test candidate paths during scan-time resolution (and, harmlessly,
117
+ * re-tested by `injectFile`/the markdown Step-3.5 pre-check on the already-resolved abs). */
118
+ export async function isRegularFile(p: string): Promise<boolean> {
119
+ try {
120
+ return (await fs.stat(p)).isFile();
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /** PRD §4.5 rule 3 — resolve a `#@<token>` import path to an existing regular file's abs path, or null.
127
+ * Resolution ladder (exact-match ALWAYS wins):
128
+ * 1. expand tilde + resolve(baseDir, token) → `abs` (the existing `expandTildeAndResolve`, NO stat);
129
+ * 2. if `abs` is an existing regular file → return `abs` (exact match wins, e.g. bare `PRD` beats `PRD.md`);
130
+ * 2b. formatting-strip fallback: if the token carries trailing markdown-emphasis GLUE (`*`/`_`) that
131
+ * `cleanToken` does NOT handle, strip that trailing run, re-clean (to drop a now-exposed trailing
132
+ * `.`), and retry the EXACT path (`@b.md.*` → `b.md`). This is a NARROWED formatting strip — it is
133
+ * NOT a `.md`-substring truncation, so a missing `report.md.backup`/`X.md.bak`/`X.md.txt`/`X.md.old`
134
+ * stays verbatim (exact-only per §4.4/§4.5). `_` inside a name (`my_file.md`) is never stripped —
135
+ * only a TRAILING run; the re-clean is load-bearing (without it `my_file.md.` would not stat).
136
+ * 3. ONLY when `tryMdExt` is true AND the cleaned token is extensionless (`path.extname(token) === ""`):
137
+ * try `abs + ".md"`, then `abs + ".markdown"` — first existing regular file wins (`#@PRD` → `PRD.md`);
138
+ * 4. otherwise → `null` (nothing resolved; the caller leaves the `#@` marker verbatim).
139
+ * `tryMdExt` is `true` for markdown imports (§4.5 shorthand) and `false` for top-level user tokens
140
+ * (§4.4 exact-only — top-level prompt behavior is byte-for-byte identical to today). Tokens already
141
+ * ending in `.md`/`.markdown` or any extension are exact-only (`#@PRD.md` never becomes `PRD.md.md`, and
142
+ * a missing `X.md.bak` is NEVER truncated to `X.md`).
143
+ * Dotfiles: `path.extname(".env") === ""` technically qualifies for the fallback, BUT exact-match-wins
144
+ * returns a bare `.env` before the `.md` fallback runs (follows PRD §4.5 literally — no dotfile exclusion).
145
+ * Pure: only stats paths, never mutates `state` (preserves the scan-then-inject separation for dedup). */
146
+ export async function resolveImportPath(
147
+ token: string,
148
+ baseDir: string,
149
+ tryMdExt: boolean,
150
+ ): Promise<string | null> {
151
+ // Candidate filenames to try, in order (most-specific first). `token` is already cleanToken()'d by the
152
+ // caller, but \S+ can still glue markdown formatting to a .md filename (an italic line like
153
+ // "*see @ARCHITECTURE.md.*" captures "ARCHITECTURE.md.*"). cleanToken only trims TRAILING_PUNCT
154
+ // (.,;:!?")]}>'), so it leaves the emphasis glue chars `*` and `_` untouched. PRD §4.4/§4.5 make a
155
+ // token that already carries an extension EXACT-ONLY (a missing `report.md.backup`/`X.md.bak` must
156
+ // stay verbatim, NEVER truncated to the base `.md`). So instead of cutting at a `.md` substring we
157
+ // NARROW the fallback to a TRAILING run of the actual glue chars (`*`/`_`): strip that run, re-clean
158
+ // (to drop a now-exposed trailing `.` — e.g. `my_file.md.*` → `my_file.md.` → `my_file.md`), and retry
159
+ // the EXACT path. This preserves GROUP-4a–4g (`@b.md.*` → `b.md`) WITHOUT turning `.bak`/`.txt`/`.old`
160
+ // into `.md`. `_` INSIDE a name is never stripped (only a trailing run). The re-clean is load-bearing.
161
+ const candidates: string[] = [token];
162
+ const fmtCut = token.replace(/[*_]+$/, ""); // strip a trailing run of the glue chars cleanToken omits
163
+ if (fmtCut !== token && fmtCut !== "") candidates.push(cleanToken(fmtCut));
164
+ for (const cand of candidates) {
165
+ const abs = expandTildeAndResolve(cand, baseDir); // §4.4 — ~ expand + resolve(baseDir) — NO stat
166
+ if (await isRegularFile(abs)) return abs; // §4.5 rule 3 — EXACT MATCH ALWAYS WINS
167
+ if (tryMdExt && path.extname(cand) === "") { // §4.5 rule 3 — extensionless shorthand ONLY
168
+ if (await isRegularFile(abs + ".md")) return abs + ".md";
169
+ if (await isRegularFile(abs + ".markdown")) return abs + ".markdown";
170
+ }
171
+ }
172
+ return null; // nothing resolved → caller leaves marker verbatim
173
+ }
174
+
175
+ /** §4.6 — config shape. markdownBareAtImports: also match bare "@path" in markdown (opt-in). Loaded on
176
+ * session_start (P1.M2.T1.S1); missing/malformed → {} → markdownBareAtImports undefined → false downstream. */
177
+ interface FileInjectorConfig { markdownBareAtImports?: boolean; }
178
+
179
+ /** §4.6 — the camelCase key under which this extension's config may live inside Pi's settings.json (a
180
+ * conventional JSON-settings key; distinct from the package `name`). settings.json is open-schema (Pi
181
+ * deep-merges and preserves unknown keys through /settings edits + flushes), so the key is stable; the
182
+ * extension reads it directly from disk (Pi exposes no public settings accessor to extensions). */
183
+ const SETTINGS_KEY = "fileInjector";
184
+
185
+ /** PRD §4.6 / §9 — read config from up to FOUR sources, shallow-merged in precedence order (each later source
186
+ * overrides the earlier; project scope overrides global; within a scope the dedicated file overrides the
187
+ * settings.json key):
188
+ * 1. GLOBAL settings.json → SETTINGS_KEY object (~/.pi/agent/settings.json via getAgentDir())
189
+ * 2. GLOBAL file-injector.json (~/.pi/agent/file-injector.json — whole file)
190
+ * 3. PROJECT settings.json → SETTINGS_KEY object (<cwd>/.pi/settings.json) — TRUSTED ONLY
191
+ * 4. PROJECT file-injector.json (<cwd>/.pi/file-injector.json) — TRUSTED ONLY
192
+ * Missing/malformed ANY source (or a missing/non-object SETTINGS_KEY key) → contributes {} (so the default
193
+ * markdownBareAtImports === undefined → false downstream). NEVER throws (every read/parse is try/caught → {}).
194
+ * The narrow {cwd, isProjectTrusted} ctx (only the two fields used) keeps it unit-testable with a literal
195
+ * mock; do NOT type it as `any` (item §3). Consumed by P1.M2.T1.S1's session_start handler. */
196
+ export async function readConfig(ctx: { cwd: string; isProjectTrusted: () => boolean }): Promise<FileInjectorConfig> {
197
+ // Read a DEDICATED config file whose WHOLE content is the FileInjectorConfig; {} on missing/malformed or a
198
+ // non-object (e.g. an array) body. JSON.parse is `any`; the object guard narrows it safely without `any`.
199
+ const tryReadCfg = async (p: string): Promise<FileInjectorConfig> => {
200
+ try {
201
+ const v = JSON.parse((await fs.readFile(p, "utf8")).trim() || "{}");
202
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as FileInjectorConfig) : {};
203
+ } catch {
204
+ return {};
205
+ }
206
+ };
207
+ // Read a Pi settings.json and extract the SETTINGS_KEY sub-object; {} if the file/key is missing or the
208
+ // key value is not a plain object. Lets users co-locate the option with their other Pi settings (PRD §4.6).
209
+ const tryReadNamespaced = async (p: string): Promise<FileInjectorConfig> => {
210
+ try {
211
+ const v = JSON.parse((await fs.readFile(p, "utf8")).trim() || "{}");
212
+ if (!v || typeof v !== "object" || Array.isArray(v)) return {};
213
+ const sub = (v as Record<string, unknown>)[SETTINGS_KEY];
214
+ return sub && typeof sub === "object" && !Array.isArray(sub) ? (sub as FileInjectorConfig) : {};
215
+ } catch {
216
+ return {};
217
+ }
218
+ };
219
+ let cfg: FileInjectorConfig = {};
220
+ // 1+2 GLOBAL: settings.json key (base), then the dedicated file (overrides the key within this scope).
221
+ cfg = { ...cfg, ...(await tryReadNamespaced(path.join(getAgentDir(), "settings.json"))) };
222
+ cfg = { ...cfg, ...(await tryReadCfg(path.join(getAgentDir(), "file-injector.json"))) };
223
+ if (ctx.isProjectTrusted()) {
224
+ // 3+4 PROJECT (trusted only): settings.json key, then the dedicated file (project overrides global).
225
+ cfg = { ...cfg, ...(await tryReadNamespaced(path.join(ctx.cwd, CONFIG_DIR_NAME, "settings.json"))) };
226
+ cfg = { ...cfg, ...(await tryReadCfg(path.join(ctx.cwd, CONFIG_DIR_NAME, "file-injector.json"))) };
227
+ }
228
+ return cfg;
229
+ }
230
+
231
+ /** PRD §5.2 — lowercase extension for MIME_BY_EXT lookup, or "" for no-dot / hidden-file (dot at 0). */
232
+ export function extOf(abs: string): string {
233
+ const base = path.basename(abs);
234
+ const dot = base.lastIndexOf(".");
235
+ if (dot <= 0) return ""; // -1 (no dot) OR 0 (hidden file like .bashrc / .env)
236
+ return base.slice(dot + 1).toLowerCase();
237
+ }
238
+
239
+ /** PRD §5.1 — scan the first min(buf.length, 8000) bytes for a 0x00 NUL byte. Routes non-image
240
+ * binaries to the binary note. Image files skip this (classified by MIME first in injectFiles). */
241
+ export function isBinary(buf: Buffer): boolean {
242
+ const n = Math.min(buf.length, 8000);
243
+ for (let i = 0; i < n; i++) {
244
+ if (buf[i] === 0) return true;
245
+ }
246
+ return false;
247
+ }
248
+
249
+ /** PRD §6.1 / processFileArguments line-59 parity — minus the trailing \n (assembly join owns it). */
250
+ export function formatTextFileBlock(abs: string, content: string): string {
251
+ return '<file name="' + abs + '">\n' + content + '\n</file>';
252
+ }
253
+
254
+ /** PRD §5.2/§6.1 / processFileArguments lines 49/52 parity. Empty/undefined hint => <file name="ABS"></file>. */
255
+ export function formatImageBlock(abs: string, resized: ResizedImage | null): string {
256
+ const hint = resized != null ? formatDimensionNote(resized) : undefined;
257
+ return '<file name="' + abs + '">' + (hint ?? "") + '</file>';
258
+ }
259
+
260
+ /** PRD §5.3/§6.1 — em dash (U+2014) is load-bearing; no built-in equivalent (deliberate improvement
261
+ * over processFileArguments, which has no binary guard). */
262
+ export function formatBinaryBlock(abs: string): string {
263
+ return '<file name="' + abs + '"><binary file \u2014 contents not injected; use the read tool if needed></file>';
264
+ }
265
+
266
+ /** F5 — a 0-byte image file attaches nothing (an empty ImageContent is rejected by providers);
267
+ * instead emit a note block so the path is still referenced in the prompt. Reuses the em dash
268
+ * (U+2014) convention from the binary note for visual consistency. */
269
+ export function formatEmptyImageBlock(abs: string): string {
270
+ return '<file name="' + abs + '"><empty image file \u2014 0 bytes; nothing to attach></file>';
271
+ }
272
+
273
+ /** Return the UTF-16 head slice, advanced past a lone high surrogate so the head block never ends
274
+ * mid-surrogate-pair (a JS string is UTF-16; a naive slice(0, HEAD_CHARS) can split a pair and emit
275
+ * a malformed trailing char). Backs up by at most 1 code unit only when the cut lands between a
276
+ * high (0xD800–0xDBFF) and low (0xDC00–0xDFFF) surrogate, leaving the pair intact on the next page. */
277
+ function headSlice(content: string): string {
278
+ let s = content.slice(0, HEAD_CHARS);
279
+ const last = s.charCodeAt(s.length - 1);
280
+ const next = content.charCodeAt(HEAD_CHARS);
281
+ if (last >= 0xD800 && last <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) {
282
+ s = s.slice(0, -1); // drop the lone high surrogate so the pair reads whole on the next page
283
+ }
284
+ return s;
285
+ }
286
+
287
+ /** Count the COMPLETE lines fully contained in the head slice, so the directive can resume at the next
288
+ * line with ZERO data loss (Pi read offset is 1-indexed). A line is complete if it ends with '\n'
289
+ * within the head; the line count therefore equals the number of newlines in the head. If the head
290
+ * ends mid-line, that final partial line is NOT counted as complete — the directive re-reads it in
291
+ * full (redundant tail, never data loss). The directive resumes at (newlineCount + 1). */
292
+ function headStartLine(head: string): number {
293
+ let n = 0;
294
+ for (let i = 0; i < head.length; i++) if (head.charCodeAt(i) === 0x0A) n++;
295
+ return n + 1; // 1-indexed: first line AFTER the complete lines delivered in the head
296
+ }
297
+
298
+ /** Count complete lines in the head (for the directive's "first N lines injected" wording). Equals
299
+ * the number of newlines, since every newline terminates a complete line. */
300
+ function headCompleteLineCount(head: string): number {
301
+ let n = 0;
302
+ for (let i = 0; i < head.length; i++) if (head.charCodeAt(i) === 0x0A) n++;
303
+ return n;
304
+ }
305
+
306
+ /** PRD §5.5 / §6.1 — directive block for a paged (oversize) text file. Emits a <file name="abs"> note
307
+ * giving the content length + complete lines already delivered in the head, and instructing the model
308
+ * to read the REMAINDER via the read tool. `startLine` is the 1-indexed line at which the model should
309
+ * resume (computed from the ACTUAL line count of the head so the directive is internally
310
+ * consistent for ANY line length — never the hardcoded 2001 that assumed 8192 chars = 2000 lines);
311
+ * Pi read offset is 1-indexed, limit is DEFAULT_MAX_LINES=READ_LIMIT=2000, and the model increments
312
+ * offset by READ_LIMIT until done. `injectedLines` is the count of complete lines in the head.
313
+ * The wording follows the PRD §6.1 template exactly: `<paged: <len> chars; head delivered <injectedLines>
314
+ * complete lines; read the rest …>` — `len` is the content LENGTH in UTF-16 code units (labeled
315
+ * "chars" per PRD §6.1, NOT "bytes"; a multibyte char is still one code unit here, so "bytes" would be
316
+ * a mislabel for magnitude). */
317
+ export function formatPagedDirectiveBlock(abs: string, len: number, startLine: number, injectedLines: number): string {
318
+ return '<file name="' + abs + '"><paged: ' + len + ' chars; head delivered ' + injectedLines + ' complete lines; read the rest with the read tool at offset:' + startLine + ', limit:' + READ_LIMIT + ', incrementing offset by ' + READ_LIMIT + ' until done></file>';
319
+ }
320
+
321
+ /** §6.3 — the INNER text of a paged directive block (the `<paged: …>` resume instructions), for the expanded view.
322
+ * Strips the wrapping `<file name="…">` opener and `</file>` closer. `formatPagedDirectiveBlock` emits
323
+ * `<file name="ABS"><paged: …></file>` (no newline), so the inner is everything between the first `>` (end of the
324
+ * opener) and the last `</file>` (the closer). Defensive: if the markers aren't found, return the block unchanged. */
325
+ function extractDirectiveInner(block: string): string {
326
+ const open = block.indexOf(">"); // end of '<file name="…">'
327
+ const close = block.lastIndexOf("</file>");
328
+ return open >= 0 && close > open ? block.slice(open + 1, close) : block;
329
+ }
330
+
331
+ /** Parse the path out of a `<file name="ABS">…</file>` block header. Returns the absolute path, or
332
+ * undefined if the block does not match the opener (defensive — image/binary/F5/paged-directive blocks
333
+ * all use the same opener, so the path is always present for real emissions). */
334
+ function blockPath(block: string): string | undefined {
335
+ const m = /^<file name="([^"]+)">/.exec(block);
336
+ return m ? m[1] : undefined;
337
+ }
338
+
339
+ /** §12.22 (P1.M2.T1.S1) — compute absolute `contentStart`/`contentLen` for every text/paged detail so the
340
+ * renderer can slice the body out of the assembled `message.content` WITHOUT duplicating file bytes into
341
+ * `details` (the body is renderer-only metadata; it must not be persisted in the custom message). Runs in
342
+ * `before_agent_start` over the FINAL `blocks` array, because the absolute offset of a detail's body within
343
+ * `blocks.join("\\n\\n")` depends on every PRIOR block's length — `emitText` cannot know it at emit time.
344
+ *
345
+ * Pairing: details and blocks are NOT 1:1 — a paged detail emits TWO blocks (head + directive) but only
346
+ * the head block carries the body. We pair each detail to the NEXT unmatched text/head block with the same
347
+ * path (matching `path` keeps imports of the same file at different depths — deduped anyway — correctly
348
+ * paired; a path can repeat across distinct files only if two files share an abs path, which is impossible).
349
+ * The body lives between the opener `<file name="ABS">\\n` and the closing `\\n</file>`; its length equals
350
+ * `block.length - headerLen - closerLen`. Image/binary/F5 details have no displayable body and are skipped.
351
+ * Idempotent + defensive: a detail whose block can't be located is left untouched (renderer falls back to
352
+ * the regex tier). Mutates `details` in place and returns it for chaining. */
353
+ export function computeDetailOffsets(blocks: string[], details: FileDetail[]): FileDetail[] {
354
+ const SEP = "\\n\\n";
355
+ // absolute char offset of each block within blocks.join("\\n\\n")
356
+ const starts: number[] = [];
357
+ let off = 0;
358
+ for (const b of blocks) { starts.push(off); off += b.length + SEP.length; }
359
+
360
+ // index of the next unmatched block, keyed by path, so a paged detail (head then directive) consumes the
361
+ // head and leaves the directive block for… nothing (directive blocks carry no body); duplicate paths at
362
+ // distinct depths are consumed in emission order.
363
+ const cursorByPath = new Map<string, number>();
364
+ for (let di = 0; di < details.length; di++) {
365
+ const d = details[di];
366
+ if (d.kind !== "text" && d.kind !== "paged") continue; // image/binary/F5 — no displayable body
367
+ const p = d.path;
368
+ let bi = cursorByPath.get(p);
369
+ if (bi === undefined) bi = 0;
370
+ // advance to the next block at/after `bi` whose header path matches AND is a body-bearing block
371
+ // (head/text — NOT a paged directive block, which is `<paged: …>` inner and carries no file body).
372
+ while (bi < blocks.length) {
373
+ const blk = blocks[bi];
374
+ const hdr = blockPath(blk);
375
+ if (hdr === p) {
376
+ // is this a body-bearing block? text/head blocks end with `\n</file>`; paged directive blocks are
377
+ // `<file name="ABS"><paged: …></file>` (no leading `\n`). The body-bearing test: the char right
378
+ // after the opener is `\n` for text/head (formatTextFileBlock always emits `>\\n` + content).
379
+ const openEnd = blk.indexOf(">") + 1; // end of '<file name="…">'
380
+ if (blk.charCodeAt(openEnd) === 0x0A) { // body-bearing text/head block
381
+ const headerLen = openEnd + 1; // include the '\n'
382
+ const closerLen = "\n</file>".length; // formatTextFileBlock appends '\n</file>'
383
+ const bodyLen = blk.length - headerLen - closerLen;
384
+ if (bodyLen >= 0) { // defensive: malformed block → leave detail untouched
385
+ d.contentStart = starts[bi] + headerLen;
386
+ d.contentLen = bodyLen;
387
+ }
388
+ cursorByPath.set(p, bi + 1); // consumed; a following paged directive block has the same path but is skipped below
389
+ break;
390
+ }
391
+ }
392
+ bi++;
393
+ }
394
+ if (bi >= blocks.length) {
395
+ // no body-bearing block found for this detail — leave contentStart/contentLen unset; the renderer's
396
+ // tier-2 (d.body) / tier-3 (regex) fallbacks handle it. (Real emission always pairs; this guards
397
+ // old/foreign/test details.)
398
+ cursorByPath.delete(p);
399
+ } else {
400
+ cursorByPath.set(p, bi + 1);
401
+ }
402
+ }
403
+ return details;
404
+ }
405
+
406
+ /**
407
+ * PRD §9 / §5.5 — core assembly. Iterate every `#@<path>` token in `text`, resolve+stat+classify+read
408
+ * each, and append a Pi-native `<file>` block (text/binary) or attach an ImageContent (image). The
409
+ * whole file ALWAYS reaches the model: injected inline when it fits the remaining context budget,
410
+ * paged via the model's `read` tool when it does not (PRD §5.5). NEVER throws (each file is isolated
411
+ * in try/catch); tokens that miss/are directories/throw are left verbatim. The original prompt text is
412
+ * NOT modified — blocks are appended after `\n\n---\n\n`, joined with `\n\n` (PRD §6.2). `ctx` carries
413
+ * the budget inputs `getContextUsage()` (tokens used) and `model` (contextWindow/maxTokens); both
414
+ * optional so a cwd-only ctx is valid. `injected` counts whole-file injections; `paged` counts files
415
+ * delivered via the page path (PRD §5.5). Return `{injected:0, paged:0}` => caller returns
416
+ * {action:"continue"}; otherwise {action:"transform", text, images}.
417
+ *
418
+ * Internal state is carried in ONE shared `State` object (PRD §9) — `blocks`, `images`,
419
+ * `injectedSet` (consolidated dedup: prior `<file>` blocks ∪ within-run deliveries), `remaining`
420
+ * (the single budget accumulator), `count`, `paged` — so a later task can thread the same state
421
+ * through a recursive import chain. The `remaining` budget currently covers TEXT injections only
422
+ * (inline whole + paged head); it is computed once here and forward-references a WHOLE-PROMPT budget
423
+ * that will also span markdown imports once they land (PRD §5.6.2) — at which point image/binary
424
+ * deliveries will likewise call `subtract`.
425
+ */
426
+ /** PRD §6.2/§6.3 per-file metadata (one entry per delivered file). Type-only export — interfaces are
427
+ * erased at runtime by jiti/TS, so this never appears in the module's runtime surface (no guard impact).
428
+ * Consumed forward by the renderer (T2.S2) to draw collapsed `read <path>` lines; it is renderer metadata
429
+ * only and is NEVER sent to the model as separate text. In S1, emitText pushes `kind: "text"` (whole +
430
+ * sub-head) and `kind: "paged"` entries; image (`kind:"image"`, dimensionHint) and binary (`kind:"binary"`)
431
+ * entries are added in injectFile in S2. The kind union is forward-looking. */
432
+ export interface FileDetail {
433
+ path: string; // absolute resolved path (the <file name=…>)
434
+ kind: "text" | "image" | "binary" | "paged";
435
+ chars?: number; // text: content length; paged: FULL content length
436
+ lines?: number; // text: total line count
437
+ range?: string; // paged: ":<startLine>-…" resume range (read-tool style)
438
+ pagedHeadLines?: number; // paged: complete lines delivered in the head
439
+ dimensionHint?: string; // image: formatDimensionNote(resized) — UNUSED in S1 (image is S2)
440
+ body?: string; // the EXACT file body embedded in the block (text/paged head), so the renderer can
441
+ // display it WITHOUT re-regexing message.content (which mis-truncates when a file's
442
+ // own content contains a literal </file>). Renderer-only metadata; never sent to the
443
+ // model. OMITTED for image/binary (no displayable body) and by old/foreign entries.
444
+ // DEPRECATED fallback (old/test entries; renderer prefers contentStart/contentLen).
445
+ // Real emission does NOT set this (§12.22 — P1.M2.T1.S1 removes the body pushes).
446
+ directive?: string; // §6.3 paged-only — the <paged: …> directive INNER text, rendered in the expanded
447
+ // view after the head body (§6.3: paged files show head + directive verbatim).
448
+ // Populated by emitText's paged branch (P1.M2.T2.S1); the directive block still
449
+ // reaches the model via message.content (display-only fix). OMITTED for non-paged.
450
+ contentStart?: number; // §12.22 — char offset of this file's body within message.content (text/paged
451
+ // only; image/binary omit). The renderer slices message.content for BUG-1-safe
452
+ // body recovery WITHOUT duplicating bytes into details (P1.M2.T1.S1). Populated
453
+ // by computeDetailOffsets in before_agent_start (absolute offset within the
454
+ // assembled blocks.join("\n\n"), which emitText cannot know at emit time).
455
+ contentLen?: number; // §12.22 — char length of the body slice (text: whole content; paged: the head).
456
+ }
457
+
458
+ interface State {
459
+ blocks: string[];
460
+ details: FileDetail[]; // PRD §6.4 — per-file metadata, parallel to blocks (index-aligned; one detail per block emission in emitText)
461
+ images: ImageContent[];
462
+ injectedSet: Set<string>; // seeded with priorPaths; holds resolved abs paths → dedup
463
+ remaining: number | null; // single budget accumulator (§5.5 / §5.6.2)
464
+ count: number;
465
+ paged: number;
466
+ bareAt: boolean; // §4.6 — markdown bare-"@" imports enabled? (top-level scan ignores this; injectMarkdown reads it)
467
+ }
468
+
469
+ /** PRD §9 — subtract a cost from the shared budget, no-op when the budget is unknown (null).
470
+ * NOT exported (internal). Used by the text paged/inline paths now; image/binary in a later task. */
471
+ function subtract(state: State, cost: number): void {
472
+ if (state.remaining !== null) {
473
+ state.remaining = Math.max(0, state.remaining - cost);
474
+ }
475
+ }
476
+
477
+ /** PRD §4.5 / §9 — markdown imports are relative-only; top-level user tokens allow absolute/tilde.
478
+ * scanTokens drops these when opts.allowAbsTilde is false. (No-op at top level: allowAbsTilde===true.)
479
+ * OWNED BY T1.S2 — T2.S1 REUSES this export (do NOT re-declare it there). */
480
+ export function isAbsoluteOrTilde(p: string): boolean {
481
+ return p.startsWith("/") || p.startsWith("~");
482
+ }
483
+
484
+ /** Escape a char for safe interpolation into a RegExp body (fenceChar is "`" or "~"). Used by
485
+ * computeCodeRanges' constructed close-line regex. NOT exported (internal helper). */
486
+ function escapeRegex(ch: string): string {
487
+ return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
488
+ }
489
+
490
+ /**
491
+ * PRD §5.6.1 — LINEAR-TIME inline-code span detector. Computes the same non-overlapping leftmost
492
+ * `[start, end)` spans the old regex `/(`+)([\s\S]*?)\1(?!`)/g` produced (verified byte-for-byte across
493
+ * 500k randomized fuzz cases), but in ~O(n) time regardless of backtick-run length — the regex was O(n²)
494
+ * on a long unmatched run (the backreference \1 + lazy [\s\S]*? forced the engine to try every
495
+ * run-length split; ~8s CPU at 200k backticks, reachable via any `#@file.md`). This function replaces
496
+ * it so a hostile/machine-generated markdown import can no longer freeze the event loop.
497
+ *
498
+ * Semantics replicated EXACTLY:
499
+ * • At a maximal backtick run of length R at position `pos`, the opener length L is tried from R DOWN
500
+ * to 1 (the regex's greedy `(`+)` capture then backtracking). The FIRST L with any valid closer wins.
501
+ * • For a fixed L, content starts at `pos + L`; the closer is the LEFTMOST position `c ≥ pos + L` where
502
+ * exactly L backticks occur and the char after them is NOT a backtick (lazy `[\s\S]*?` + `(?!`)`).
503
+ * An "exactly L" closer means the L backticks are the TRAILING L of a maximal run of length M ≥ L whose
504
+ * following char is non-backtick — so the unique closer inside a maximal run is at `runEnd − L`.
505
+ * • The matched span is `[pos, c + L)`; scanning resumes at `c + L` (non-overlapping).
506
+ * • `INLINE_CODE_MAX_OPENER` caps L (defense-in-depth; 1024 is far above any real code-span opener —
507
+ * capping cannot affect realistic input, verified by fuzz — but bounds the opener-retry loop against
508
+ * a hostile file; the closer search is already linear via whole-run skipping).
509
+ *
510
+ * Algorithm: one right-to-left pass precomputes `runEnd[i]` (exclusive end of the maximal backtick run
511
+ * containing `i`, or -1 for non-backticks). Then a left-to-right pass walks opener runs; for each viable
512
+ * L it scans content for the leftmost closer, skipping maximal runs wholesale (O(spans) amortized, not
513
+ * O(content)) — so the whole scan is ~linear in the input length. PURE: no I/O, no state. NOT exported.
514
+ *
515
+ * @param content the full markdown text (UTF-16 code-unit offsets, consistent with FILE_INJECT_RE m.index)
516
+ * @returns non-overlapping leftmost `[start, end)` inline-code spans (NOT yet filtered by fenced ranges;
517
+ * the caller drops any span fully inside a fenced range)
518
+ */
519
+ function inlineCodeRanges(content: string): [number, number][] {
520
+ const n = content.length;
521
+ if (n === 0) return [];
522
+
523
+ // runEnd[i] = exclusive end of the maximal backtick run containing index i (−1 for non-backticks).
524
+ // Built in one right-to-left pass: when s[i]==='`', extend left to the run's start and stamp [start,end).
525
+ const runEnd = new Int32Array(n).fill(-1);
526
+ for (let i = n - 1; i >= 0; ) {
527
+ if (content.charCodeAt(i) !== 0x60 /* '`' */) { i--; continue; } // backtick = U+0060
528
+ let start = i;
529
+ while (start > 0 && content.charCodeAt(start - 1) === 0x60) start--;
530
+ const end = i + 1; // run covers [start, end) — all backticks by construction
531
+ for (let k = start; k < end; k++) runEnd[k] = end;
532
+ i = start - 1;
533
+ }
534
+
535
+ const ranges: [number, number][] = [];
536
+ let i = 0;
537
+ while (i < n) {
538
+ if (content.charCodeAt(i) !== 0x60) { i++; continue; } // not an opener → advance
539
+ const runStart = i;
540
+ let runLen = 0;
541
+ while (i < n && content.charCodeAt(i) === 0x60) { runLen++; i++; } // maximal opener run
542
+
543
+ const maxL = Math.min(runLen, INLINE_CODE_MAX_OPENER);
544
+ let matched: [number, number] | null = null;
545
+ for (let L = maxL; L >= 1; L--) { // opener length: greedy-then-backtrack (regex semantics)
546
+ const contentStart = runStart + L;
547
+ let p = contentStart;
548
+ let found = -1;
549
+ while (p + L <= n) {
550
+ if (content.charCodeAt(p) !== 0x60) { p++; continue; }
551
+ const end = runEnd[p]!; // end of the maximal run containing p
552
+ // An exact-L closer (L backticks not followed by a backtick) must END at `end` — anywhere
553
+ // earlier in the run is followed by another backtick. So the unique closer start in this
554
+ // run is `end − L`, valid iff it lies at/after both p and contentStart (within the content).
555
+ const closerStart = end - L;
556
+ if (closerStart >= contentStart && closerStart >= p) {
557
+ found = closerStart; // leftmost viable closer for this run
558
+ break;
559
+ }
560
+ p = end; // this run can't host a valid closer ≥ contentStart → skip it wholesale
561
+ }
562
+ if (found !== -1) {
563
+ matched = [runStart, found + L];
564
+ break; // first (largest) L with a closer wins, mirroring the regex
565
+ }
566
+ }
567
+ if (matched) {
568
+ ranges.push(matched);
569
+ i = matched[1]; // non-overlapping: resume after this span
570
+ }
571
+ // (no match → i already past the opener run; continue scanning)
572
+ }
573
+ return ranges;
574
+ }
575
+
576
+ /**
577
+ * PRD §5.6.1 — compute a sorted list of [start, end) character-offset ranges that are CODE (fenced
578
+ * blocks + inline code), approximate-CommonMark. Used by scanTokens(opts.skipCode:true) to leave `#@`
579
+ * directives inside code verbatim (the markdown escape hatch, PRD §4.5 rule 3). PURE: no I/O, no state.
580
+ *
581
+ * Algorithm:
582
+ * 1. FENCED BLOCKS — walk lines with a running char offset. A line matching FENCE_OPEN_RE opens a block
583
+ * (fenceChar = run[0], fenceLen = run.length). From the next line, scan for a CLOSING line: after
584
+ * dropping 0-3 leading spaces, it must START with >= fenceLen of the SAME fenceChar AND the remainder
585
+ * must be whitespace-only (/^[ \t]*$/) — this is the strict-CommonMark rule (a ` ```foo ` line does
586
+ * NOT close; only the OPENING fence may carry an info string). The range runs from the opening
587
+ * fence's first char (lineStartOffset) through the end of the closing line INCLUSIVE of its trailing
588
+ * newline. If NO closing fence is found, the range runs to EOF (unterminated fences consume the rest,
589
+ * matching CommonMark). Fences of the OTHER char inside a block are literal (do not reopen).
590
+ * 2. INLINE CODE — inlineCodeRanges(content) returns the same non-overlapping leftmost spans the
591
+ * original regex `/(`+)([\s\S]*?)\1(?!`)/g` produced, but in ~O(n) time (it replaced the regex,
592
+ * which was O(n²) on a long unmatched backtick run — see its docstring). Each span [start, end)
593
+ * is a code range UNLESS it is FULLY inside a fenced range (span[0]>=fr[0] && span[1]<=fr[1]) —
594
+ * skip those so we don't double-count. (Approximate: does not model backslash escapes. Good enough
595
+ * to stop the common `#@file` doc pattern from importing.)
596
+ * 3. Sort by start. Ranges are disjoint + sorted (fenced ranges don't overlap; inline spans don't
597
+ * overlap each other; inside-fenced spans are filtered) — so inCode can binary-search them.
598
+ *
599
+ * @param content the full markdown text (UTF-16 code-unit offsets, consistent with FILE_INJECT_RE m.index)
600
+ * @returns sorted [start, end) code ranges
601
+ */
602
+ export function computeCodeRanges(content: string): [number, number][] {
603
+ const ranges: [number, number][] = [];
604
+ const lines = content.split("\n");
605
+
606
+ // Precompute the char offset of the START of each line (line i starts at sum of len(lines[k])+1 for k<i).
607
+ // The +1 accounts for the "\n" that split() removed. The final line has no trailing "\n" if content
608
+ // doesn't end in one — content.length is the safe cap for any end offset.
609
+ const lineStart: number[] = new Array(lines.length);
610
+ let acc = 0;
611
+ for (let i = 0; i < lines.length; i++) {
612
+ lineStart[i] = acc;
613
+ acc += lines[i].length + 1; // +1 for the "\n"
614
+ }
615
+
616
+ // 1. FENCED BLOCKS — state machine over lines.
617
+ let i = 0;
618
+ while (i < lines.length) {
619
+ const open = lines[i].match(FENCE_OPEN_RE);
620
+ if (!open) { i++; continue; } // not a fence opener → skip
621
+ const run = open[1];
622
+ const fenceChar = run[0]; // "`" or "~"
623
+ const fenceLen = run.length;
624
+ const openStart = lineStart[i]; // first char of the opening fence line (PRD §5.6.1)
625
+ // strict-CommonMark close: 0-3 leading spaces, then >= fenceLen of fenceChar, then whitespace-only.
626
+ const closeRe = new RegExp("^ {0,3}" + escapeRegex(fenceChar) + "{" + fenceLen + ",}[ \\t]*\\r?$");
627
+
628
+ // scan forward from the NEXT line for the closing fence
629
+ let j = i + 1;
630
+ let closedEnd: number | null = null;
631
+ while (j < lines.length) {
632
+ if (closeRe.test(lines[j])) {
633
+ // end of closing line incl. its trailing newline (cap at content.length for the final line)
634
+ closedEnd = Math.min(lineStart[j] + lines[j].length + 1, content.length);
635
+ break;
636
+ }
637
+ j++;
638
+ }
639
+ if (closedEnd !== null) {
640
+ ranges.push([openStart, closedEnd]);
641
+ i = j + 1; // resume AFTER the closing line
642
+ } else {
643
+ ranges.push([openStart, content.length]); // unterminated → EOF (CommonMark)
644
+ break; // nothing left to scan
645
+ }
646
+ }
647
+
648
+ // 2. INLINE CODE — spans NOT fully inside any fenced range. (inlineCodeRanges is the LINEAR-TIME
649
+ // replacement for the former `/(`+)([\s\S]*?)\1(?!`)/g` regex — same spans, ~O(n) regardless of
650
+ // backtick-run length; see its docstring. The old regex was O(n²) on a long unmatched run.)
651
+ for (const [spanStart, spanEnd] of inlineCodeRanges(content)) {
652
+ const insideFenced = ranges.some((fr) => spanStart >= fr[0] && spanEnd <= fr[1]);
653
+ if (!insideFenced) ranges.push([spanStart, spanEnd]);
654
+ }
655
+
656
+ // 3. sort by start (ranges are already disjoint by construction; sort for inCode's binary search).
657
+ ranges.sort((a, b) => a[0] - b[0]);
658
+ return ranges;
659
+ }
660
+
661
+ /**
662
+ * PRD §5.6.1 — true iff `index` lies inside any code range `[start, end)` (i.e. start <= index < end).
663
+ * Binary search over the sorted disjoint ranges from computeCodeRanges. `ranges` MUST be sorted by start
664
+ * and disjoint (computeCodeRanges guarantees both). O(log ranges.length).
665
+ *
666
+ * @param index the char offset to test (e.g. a FILE_INJECT_RE m.index — the position of `#`)
667
+ * @param ranges sorted [start, end) code ranges from computeCodeRanges
668
+ */
669
+ export function inCode(index: number, ranges: [number, number][]): boolean {
670
+ let lo = 0;
671
+ let hi = ranges.length - 1;
672
+ while (lo <= hi) {
673
+ const mid = (lo + hi) >> 1;
674
+ const [s, e] = ranges[mid];
675
+ if (index < s) hi = mid - 1;
676
+ else if (index >= e) lo = mid + 1;
677
+ else return true; // s <= index < e
678
+ }
679
+ return false;
680
+ }
681
+
682
+ /**
683
+ * PRD §5.6.2 — conservative image-token estimate for budget accounting. Images consume the shared
684
+ * `remaining` budget but are NEVER paged (§5.2: resized + attached). This estimate lets a later file's
685
+ * inline-vs-paged decision see a budget that already reflects the image.
686
+ *
687
+ * Formula (Anthropic Vision guide, https://docs.claude.com/en/docs/build-with-claude/vision): an image
688
+ * is tiled into 512×512 blocks; each tile ≈ 170 tokens; plus a flat 85-token base. So
689
+ * tokens = max(1, ⌈width/512⌉) · max(1, ⌈height/512⌉) · 170 + 85
690
+ * computed on the RESIZED dimensions (resizeImage caps the longest edge to 2000, so the worst case is
691
+ * ⌈2000/512⌉=4 tiles per side → 4·4·170+85 = 2805 = IMAGE_FALLBACK_TOKENS).
692
+ *
693
+ * When `resized` is null (resizeImage could not process the bytes → the raw-base64 fallback path),
694
+ * dimensions are unavailable, so return the flat `IMAGE_FALLBACK_TOKENS` (the 2000×2000 worst case — a
695
+ * CONSERVATIVE upper bound appropriate for a budget guard that must not under-count). ResizedImage.width
696
+ * and .height are required numbers (pi's ResizedImage type), so no per-field null-check is needed; the
697
+ * Math.max(1, ⌈x/512⌉) guard makes even a 0 dimension safe (→ 1 tile).
698
+ *
699
+ * PURE: no I/O, no state mutation. Exported for unit testing + the module-surface sanity list.
700
+ *
701
+ * @param resized the resizeImage result (null on the raw-base64 fallback path)
702
+ * @returns the conservative image token cost
703
+ */
704
+ export function estimateImageTokens(resized: ResizedImage | null): number {
705
+ if (resized === null) return IMAGE_FALLBACK_TOKENS;
706
+ const tilesW = Math.max(1, Math.ceil(resized.width / 512));
707
+ const tilesH = Math.max(1, Math.ceil(resized.height / 512));
708
+ return tilesW * tilesH * 170 + 85;
709
+ }
710
+
711
+ // ---------- §6.3 chat renderer (registered for "fileInjector.injected") -------------------------
712
+ // Replicates the read tool's completed-call look: a green (toolSuccessBg) Box, one `read <path>` line per
713
+ // file when collapsed, full/highlighted content when expanded. blocks (message.content) and details.files
714
+ // are co-emitted in the same pre-order emission order (PRD §6.4), so they align by index. DEFENSIVE: never
715
+ // throws (PRD §12.23) — a thrown renderer is caught by CustomMessageComponent → Pi's default purple box
716
+ // (acceptable but not the goal).
717
+ const FILE_BLOCK_RE = /<file name="([^"]+)">([\s\S]*?)<\/file>/g;
718
+
719
+ /**
720
+ * PRD §6.3 — the MessageRenderer for customType "fileInjector.injected". Draws a green (toolSuccessBg) Box
721
+ * with one `read <tildified-path>` line per file (collapsible), expanding on ctrl+o to the full/highlighted
722
+ * content.
723
+ *
724
+ * theme/message are `any` (item §3c) to avoid importing the full Theme/CustomMessage types and to keep the
725
+ * test seam simple. The fg/bg discipline is therefore enforced by code review (NOT the compiler):
726
+ * toolSuccessBg → theme.bg; toolTitle/accent/dim/warning/toolOutput → theme.fg. (To opt into compile-time
727
+ * enforcement, import Theme from pi-coding-agent and type theme: Theme — then theme.fg("toolSuccessBg")
728
+ * becomes a compile error.)
729
+ *
730
+ * DEFENSIVE (§12.23): `message?.details?.files ?? []` (old/foreign entries with no details → fallback
731
+ * line); `bodies[i] !== undefined` guard; image expanded-view short-circuit (images already attached to
732
+ * the user message, §6.4 — don't re-render); `typeof message?.content === "string"` guard. Never throws.
733
+ *
734
+ * @param message the CustomMessage (details.files: FileDetail[]; content: the joined <file> blocks string)
735
+ * @param opts { expanded } — mirrors the global ctrl+o toggle (like [skill] blocks)
736
+ * @param theme Pi Theme (fg/bg/bold)
737
+ * @returns a green Box Component (one read line per file; expanded content when opts.expanded)
738
+ */
739
+ export function renderInjectedMessage(message: any, opts: { expanded: boolean }, theme: any): Component {
740
+ const files: FileDetail[] = message?.details?.files ?? []; // defensive — old/foreign entries
741
+ // BODY derivation (3 tiers, expanded view): (1) offset slice — real injected files (post-P1.M2.T1.S1) carry
742
+ // contentStart/contentLen (char offsets into message.content), so message.content.slice(start, start+len)
743
+ // recovers the EXACT body WITHOUT duplicating file bytes into details (§12.22); (2) stored `body` — old/
744
+ // foreign/test entries (and pre-offset persisted messages) carry the deprecated body field; (3) the regex
745
+ // below — last-resort fallback for entries with neither (§6.3/§12.23 defensive rendering). The regex re-parse
746
+ // is computed UNCONDITIONALLY (cheap; needed for tier 3) but is only USED when tiers 1+2 miss. BUG-1: a file
747
+ // whose own content contains a literal `</file>` truncates the lazy `([\s\S]*?)` at the INNER `</file>`, so
748
+ // tiers 1+2 (length-derived offset / stored body) are the BUG-1-safe paths; tier 3 is regex-vulnerable but
749
+ // only fires for entries without offsets/body (test-crafted / old), where BUG-1 is not a real-world risk.
750
+ const bodies: string[] = [];
751
+ if (typeof message?.content === "string") {
752
+ let m: RegExpExecArray | null;
753
+ FILE_BLOCK_RE.lastIndex = 0; // module regex w/ g flag → reset before the loop
754
+ while ((m = FILE_BLOCK_RE.exec(message.content)) !== null) {
755
+ bodies.push(m[2].replace(/^\n|\n$/g, "")); // strip the wrapping newlines from <file>\n…\n</file>
756
+ }
757
+ }
758
+ // green Box — toolSuccessBg is ThemeBg → theme.bg (NOT theme.fg). paddingX/Y=1; bgFn paints every child line.
759
+ const box = new Box(1, 1, (t: string) => theme.bg("toolSuccessBg", t));
760
+ if (files.length === 0) { // defensive fallback (no details — old/foreign entry)
761
+ box.addChild(new Text(theme.fg("toolTitle", theme.bold("read")) + " " +
762
+ theme.fg("dim", "(injected files)") + expandHint(theme), 0, 0));
763
+ if (opts.expanded && typeof message?.content === "string") {
764
+ box.addChild(new Text(theme.fg("toolOutput", message.content), 0, 0));
765
+ }
766
+ return box;
767
+ }
768
+ for (let i = 0; i < files.length; i++) {
769
+ const d = files[i];
770
+ // one read line per file; expand hint ONCE per box (i===0), matching the [skill] precedent (PRD §6.3)
771
+ box.addChild(new Text(readLine(d, theme) + (i === 0 ? expandHint(theme) : ""), 0, 0));
772
+ if (opts.expanded) {
773
+ // 3-tier body resolution (§12.22 offset → stored body → regex fallback). Real emission (P1.M2.T1.S1)
774
+ // carries contentStart/contentLen (no duplicated bytes); tier-1 slices message.content EXACTLY — BUG-1-safe
775
+ // because the offsets are length-derived (block.length − header − footer), NOT regex: a body containing a
776
+ // literal </file> (which truncates FILE_BLOCK_RE's lazy capture) slices whole. Tier-2 (d.body) covers
777
+ // old/foreign/test entries still carrying the deprecated body field. Tier-3 (bodies[i]) is the last-resort
778
+ // regex fallback for entries with neither (§6.3/§12.23 defensive rendering).
779
+ const body = (d.contentStart != null && d.contentLen != null && typeof message?.content === "string")
780
+ ? message.content.slice(d.contentStart, d.contentStart + d.contentLen)
781
+ : (typeof d.body === "string" ? d.body : bodies[i]);
782
+ if (body !== undefined && d.kind !== "image") { // images already attached to user msg (§6.4) — skip
783
+ const lang = d.kind === "binary" ? undefined : getLanguageFromPath(d.path);
784
+ const rendered = lang ? highlightCode(body, lang).join("\n") : body;
785
+ box.addChild(new Text(theme.fg("toolOutput", rendered), 0, 0));
786
+ }
787
+ if (d.kind === "paged" && typeof d.directive === "string") { // §6.3 — paged directive after the head, expanded view only
788
+ box.addChild(new Text(theme.fg("dim", d.directive), 0, 0));
789
+ }
790
+ }
791
+ }
792
+ return box;
793
+ }
794
+
795
+ /**
796
+ * One collapsed line per file, identical in spirit to the read tool's formatReadCall:
797
+ * read <tildified-path><range-or-hint>
798
+ * toolTitle+bold for "read"; accent for the path; dim/warning for the suffix depending on kind.
799
+ */
800
+ function readLine(d: FileDetail, theme: any): string {
801
+ const title = theme.fg("toolTitle", theme.bold("read"));
802
+ const pathPart = theme.fg("accent", tildify(d.path));
803
+ if (d.kind === "binary") {
804
+ return `${title} ${pathPart} ${theme.fg("dim", "(binary — not injected)")}`;
805
+ }
806
+ if (d.kind === "image") {
807
+ return `${title} ${pathPart}${d.dimensionHint ? " " + theme.fg("dim", d.dimensionHint) : ""}`;
808
+ }
809
+ if (d.kind === "paged") {
810
+ return `${title} ${pathPart}${theme.fg("warning", d.range ?? "")}`;
811
+ }
812
+ return `${title} ${pathPart}`; // whole text (no suffix)
813
+ }
814
+
815
+ /** "(ctrl+o to expand)" — the default expand binding (PRD §12.25: keyText() is internal; ctrl+o is hardcoded). */
816
+ function expandHint(theme: any): string {
817
+ return " " + theme.fg("dim", "(ctrl+o to expand)");
818
+ }
819
+
820
+ /** §12.25 — tildify an absolute path (leading os.homedir() → ~) for readable display (read-tool parity). */
821
+ function tildify(abs: string): string {
822
+ const home = os.homedir();
823
+ return home && abs.startsWith(home + "/") ? "~" + abs.slice(home.length) : abs;
824
+ }
825
+
826
+ /** Shared ctx type for injectFiles / processTokenStream / injectFile (DRY; jiti erases at runtime). */
827
+ type Ctx = {
828
+ cwd: string;
829
+ getContextUsage?: () =>
830
+ { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
831
+ model?: { contextWindow: number; maxTokens: number } | undefined;
832
+ };
833
+
834
+ /**
835
+ * PRD §9 / §5.6 step 3 — scan a text (user prompt OR markdown content) for `#@` tokens that resolve,
836
+ * WITHOUT injecting. Pure (no I/O, no state mutation beyond the per-text localSeen set). Per-text dedup
837
+ * via localSeen; global state.injectedSet check skips already-claimed paths (prior <file> blocks OR files
838
+ * injected earlier this run / in a parent recursion). Returns { index; abs }[] in text order.
839
+ *
840
+ * opts.skipCode: when true, scanTokens precomputes code regions once and skips any `#@` match whose
841
+ * start index lies inside a fenced block or inline code span (PRD §5.6.1). null codeRanges when false →
842
+ * inCode is never called → top-level (user-prompt) behavior is unchanged. Markdown imports (T2.S3)
843
+ * pass skipCode:true.
844
+ * opts.allowAbsTilde: when false, tokens starting with / or ~ are dropped (markdown relative-only, §4.5).
845
+ * opts.tryMdExt: when true, an extensionless token whose exact path is not a regular file falls back to
846
+ * `<exact>.md` then `<exact>.markdown` (markdown-import shorthand, PRD §4.5 rule 3). Top-level user-prompt
847
+ * scan passes `false` (§4.4 exact-only → top-level behavior is byte-for-byte identical to today).
848
+ * opts.bareAt: OPTIONAL. When truthy, scanTokens ALSO matches bare `@path` markers (BARE_AT_RE, PRD §4.6)
849
+ * alongside the `#@` markers, returning a union of candidate records sorted by index. Each record carries
850
+ * `prefixLen` (2 for `#@`, 1 for a bare `@`) so a consumer can strip the correct marker width. Markdown-
851
+ * only / opt-in; the two regexes never double-match the same `#@` (BARE_AT_RE forbids a preceding `#`).
852
+ * When absent/false, ONLY `#@` matches run → byte-for-byte identical to the single-regex form.
853
+ *
854
+ * Returns { index; prefixLen; abs }[] in text order (prefixLen is the marker char width: 2 for `#@`, 1 for `@`).
855
+ */
856
+ export async function scanTokens(
857
+ text: string,
858
+ baseDir: string,
859
+ opts: { allowAbsTilde: boolean; skipCode: boolean; tryMdExt: boolean; bareAt?: boolean },
860
+ state: State,
861
+ ): Promise<{ index: number; prefixLen: number; abs: string }[]> {
862
+ const localSeen = new Set<string>();
863
+ const out: { index: number; prefixLen: number; abs: string }[] = [];
864
+ // §5.6.1 — when scanning markdown content, precompute code regions once and skip `#@` matches whose
865
+ // start index lies inside a fenced block or inline code span (the markdown escape hatch, §4.5 rule 3).
866
+ // null when skipCode:false (top-level user-prompt scan) → inCode is never called → no behavior change.
867
+ const codeRanges = opts.skipCode ? computeCodeRanges(text) : null;
868
+ // Candidate markers: `#@` always (prefixLen 2); bare `@` only when opts.bareAt (prefixLen 1). BARE_AT_RE
869
+ // forbids a preceding `#`, so `#@file` appears once (via FILE_INJECT_RE), never twice. When bareAt is
870
+ // absent/false, cands holds only the FILE_INJECT_RE matches in index-ascending order (matchAll yields
871
+ // ascending), so the sort is a no-op and the per-candidate body below is byte-for-byte identical to the
872
+ // prior single-loop form.
873
+ const cands: { idx: number; token: string; prefixLen: number }[] = [];
874
+ for (const m of text.matchAll(FILE_INJECT_RE)) cands.push({ idx: m.index!, token: m[2], prefixLen: 2 });
875
+ if (opts.bareAt) for (const m of text.matchAll(BARE_AT_RE)) cands.push({ idx: m.index!, token: m[2], prefixLen: 1 });
876
+ cands.sort((a, b) => a.idx - b.idx);
877
+ for (const c of cands) {
878
+ if (codeRanges && inCode(c.idx, codeRanges)) continue; // §5.6.1 — skip markers inside code
879
+ const token = cleanToken(c.token); // trim trailing punctuation (§4.3)
880
+ if (!token) continue; // empty after trim => skip, leave verbatim
881
+ if (!opts.allowAbsTilde && isAbsoluteOrTilde(token)) continue; // §4.5 — markdown: relative only
882
+ const abs = await resolveImportPath(token, baseDir, opts.tryMdExt); // §4.5 — exact, then .md/.markdown (stats)
883
+ if (!abs) continue; // nothing resolved → leave verbatim (missing/dir/non-regular)
884
+ if (state.injectedSet.has(abs) || localSeen.has(abs)) continue; // dedup on RESOLVED abs → leave verbatim
885
+ localSeen.add(abs);
886
+ out.push({ index: c.idx, prefixLen: c.prefixLen, abs });
887
+ }
888
+ return out;
889
+ }
890
+
891
+ /**
892
+ * PRD §9 / §12.17 — top-level processor. Scan the text ONCE (before any injection), then inject each
893
+ * resolved token depth-first via injectFile. Returns the start indices of markers that resolved, in scan
894
+ * order, for `#@` stripping by injectFiles. Scan-before-inject gives cross-subtree dedup (a later token
895
+ * whose path an earlier import claimed is left verbatim). PRIVATE — exercised indirectly via injectFiles.
896
+ *
897
+ * The belt-and-suspenders `state.injectedSet.has(r.abs)` re-check is a NO-OP at top level in T1.S2
898
+ * (scanTokens' localSeen already made each abs unique in records); it becomes load-bearing in T2 when
899
+ * injectFile recurses into markdown imports.
900
+ *
901
+ * `opts.tryMdExt` is threaded straight through to scanTokens/resolveImportPath: the top-level user-prompt
902
+ * call site passes `false` (§4.4 exact-only), and the markdown-import path passes `true` (§4.5 shorthand).
903
+ */
904
+ async function processTokenStream(
905
+ text: string,
906
+ baseDir: string,
907
+ opts: { allowAbsTilde: boolean; skipCode: boolean; tryMdExt: boolean; bareAt: boolean },
908
+ state: State,
909
+ ctx: Ctx,
910
+ ): Promise<number[]> {
911
+ const records = await scanTokens(text, baseDir, opts, state); // scan once, before any injection (opts carries tryMdExt)
912
+ const resolved: number[] = [];
913
+ for (const r of records) {
914
+ if (state.injectedSet.has(r.abs)) continue; // cross-subtree dedup since scan (no-op at top level in T1.S2)
915
+ const ok = await injectFile(r.abs, state, ctx); // claims abs, emits block(s); never throws
916
+ if (ok) resolved.push(r.index);
917
+ }
918
+ return resolved;
919
+ }
920
+
921
+ /**
922
+ * PRD §9 / §5.1-§5.3 — stat → claim → classify → emit → count. Claims `abs` in state.injectedSet AFTER
923
+ * stat+isFile succeed but BEFORE read, so a self-import or mid-recursion re-entry dedups to verbatim
924
+ * (recursion-readiness for T2 markdown; behavior-neutral at top level). The pre-read claim is REVOKED on
925
+ * the read/resize failure path (claim ⟺ delivered, PRD §12.5) so a file that fails delivery does not
926
+ * poison dedup. NEVER throws: stat miss / !isFile / read or resize error → return false (un-claimed) +
927
+ * token left verbatim (PRD §5.4 / §12.5).
928
+ *
929
+ * Classification (preserve F3/F5; NO markdown branch yet — T2.S3): (1) empty image (mime && buf.length===0)
930
+ * → F5 note; (2) real image (mime && hasValidImageMagic) → attach ImageContent + image block; (3) binary
931
+ * (isBinary) → binary note; (4) else → emitText. Budget: ONLY emitText subtracts (T2.S2 adds image/binary).
932
+ * Returns true iff a block/image was emitted (state.count bumped exactly once per claimed file).
933
+ */
934
+ export async function injectFile(abs: string, state: State, ctx: Ctx): Promise<boolean> {
935
+ let st;
936
+ try {
937
+ st = await fs.stat(abs);
938
+ } catch {
939
+ return false; // missing → leave verbatim (PRD §5.4)
940
+ }
941
+ if (!st.isFile()) return false; // directory / socket / etc. → leave verbatim (PRD §5.4)
942
+ state.injectedSet.add(abs); // CLAIM — dedup incl. self-import (recursion-readiness); REVOKED on failure (claim ⟺ delivered, see catch)
943
+
944
+ const ext = extOf(abs); // lowercase ext, "" for no-dot/hidden (S2)
945
+ const mime = MIME_BY_EXT[ext]; // undefined → not a recognized image → text/binary path
946
+ try {
947
+ const buf = await fs.readFile(abs); // read ONCE; reused by image + text/binary paths
948
+ if (mime && buf.length === 0) {
949
+ // F5 — a 0-byte image file would attach an EMPTY ImageContent (which providers reject).
950
+ // Align with the text path's empty-file handling: emit a note block, attach nothing.
951
+ const f5Block = formatEmptyImageBlock(abs);
952
+ state.blocks.push(f5Block);
953
+ state.details.push({ path: abs, kind: "image", dimensionHint: undefined }); // §6.4 — empty-image detail (parallel to the block push)
954
+ subtract(state, Math.ceil(f5Block.length / 4)); // §5.6.2 — note consumes budget
955
+ } else if (mime && hasValidImageMagic(buf, mime)) {
956
+ // F3 — validate the ACTUAL bytes match the declared image type before attaching.
957
+ // A mislabeled file (e.g. text named `.png`) fails the magic-number sniff and falls
958
+ // through to the text/binary path instead of attaching decoded garbage as an image.
959
+ const resized = await resizeImage(new Uint8Array(buf), mime); // Uint8Array; async Worker; null on failure
960
+ state.images.push({
961
+ type: "image",
962
+ data: resized?.data ?? buf.toString("base64"), // null => raw base64 of ORIGINAL bytes
963
+ mimeType: resized?.mimeType ?? mime, // null => original mime
964
+ });
965
+ state.blocks.push(formatImageBlock(abs, resized)); // null => empty-hints <file name="ABS"></file>
966
+ state.details.push({ path: abs, kind: "image", dimensionHint: resized ? formatDimensionNote(resized) ?? undefined : undefined }); // §6.4 — image detail (dimensionHint from resize)
967
+ subtract(state, estimateImageTokens(resized)); // §5.6.2 — image consumes budget (tile estimate; never paged)
968
+ } else if (MD_EXTS.has(ext)) {
969
+ // MARKDOWN (PRD §5.6) — text block + transitive imports. Markdown bypasses the §5.1 NUL/binary routing
970
+ // so import scanning always runs (§5.6 step 1). injectMarkdown (§5.6 six steps): claim self → scan
971
+ // relative-only imports outside code → strip resolved #@ markers → emit this block (paged decision on
972
+ // the STRIPPED content) → recurse depth-first (pre-order). Recursion is dedup-bounded (each abs claimed
973
+ // before its scan; cycles dedup to verbatim), NOT depth-limited. injectFile owns the count++ + the claim.
974
+ await injectMarkdown(abs, buf.toString("utf8"), state, ctx);
975
+ } else if (isBinary(buf)) {
976
+ // BINARY (PRD §5.3) — note, no decoded garbage (em dash U+2014)
977
+ const binBlock = formatBinaryBlock(abs);
978
+ state.blocks.push(binBlock);
979
+ state.details.push({ path: abs, kind: "binary" }); // §6.4 — binary detail
980
+ subtract(state, Math.ceil(binBlock.length / 4)); // §5.6.2 — note consumes budget
981
+ } else {
982
+ // PLAIN TEXT (PRD §5.1 + §5.5) — inline-vs-paged decision (lifted verbatim into emitText)
983
+ emitText(abs, buf.toString("utf8"), state);
984
+ }
985
+ state.count++; // exactly one delivery per claimed file
986
+ return true;
987
+ } catch {
988
+ state.injectedSet.delete(abs); // failure → UN-CLAIM so the path is NOT poisoned (claim ⟺ delivered; PRD §12.5)
989
+ return false; // read/resize error → leave THIS token verbatim (PRD §5.4, §12.5)
990
+ }
991
+ }
992
+
993
+ /**
994
+ * PRD §9 / §5.5 — inline-vs-paged decision for a text file. Pushes block(s) onto state.blocks and subtracts
995
+ * the block's cost from state.remaining via subtract(). Bumps state.paged on the page path (NOT count —
996
+ * injectFile bumps count once per file). Lifted VERBATIM from the former inline text branch of injectFiles
997
+ * (T1.S1): whole if budget unknown or fileCost ≤ PAGED_THRESHOLD·remaining; sub-head guard (content ≤
998
+ * HEAD_CHARS → whole, no directive, no extra subtract); else head + directive + paged++ + subtract(head cost).
999
+ */
1000
+ export function emitText(abs: string, content: string, state: State): void {
1001
+ const fileCost = Math.ceil(content.length / 4); // O-3 heuristic (no string estimator exported)
1002
+ const lineCount = (content.match(/\n/g)?.length ?? 0) + 1; // PRD §9 — total line count (for FileDetail.lines; text whole/sub-head)
1003
+ if (state.remaining === null || fileCost <= PAGED_THRESHOLD * state.remaining) {
1004
+ // INLINE (whole) — current behavior preserved (PRD §5.1)
1005
+ state.blocks.push(formatTextFileBlock(abs, content));
1006
+ state.details.push({ path: abs, kind: "text", chars: content.length, lines: lineCount }); // §12.22 — contentStart/contentLen populated by computeDetailOffsets in before_agent_start (no body duplication)
1007
+ subtract(state, fileCost);
1008
+ } else {
1009
+ // PAGED — head block (first HEAD_CHARS) + directive (PRD §5.5 Page path).
1010
+ //
1011
+ // FINDING 2: if the WHOLE content already fits in the head, there is nothing to page —
1012
+ // inject it whole and do NOT emit a directive (a sub-head-sized file that tripped the
1013
+ // page threshold only because of a tight budget would otherwise get a 'read the rest'
1014
+ // directive pointing past EOF, causing a spurious read error for content already delivered).
1015
+ //
1016
+ // FINDING 1: derive the directive's resume offset from the ACTUAL line count of the head
1017
+ // (headStartLine = newlines+1; headCompleteLineCount = newlines). The old hardcoded
1018
+ // offset:2001 assumed the 8192-char head equals 2000 lines, which is only true for ~4-char
1019
+ // lines; for realistic files it silently lost the lines between the head's real end and
1020
+ // line 2000 (up to 100% for long-lined files). The directive now points exactly past the
1021
+ // COMPLETE lines the head delivered, so no content is skipped regardless of line length
1022
+ // (a head ending mid-line re-reads that partial line — redundant tail, never data loss).
1023
+ const head = headSlice(content);
1024
+ if (content.length <= HEAD_CHARS) {
1025
+ // whole content fits the head slice → deliver inline, never page (FINDING 2).
1026
+ // The file is delivered WHOLE, so its whole cost is accounted (PRD §5.6.2 "each delivered file
1027
+ // subtracts its cost at emit time"). Earlier this branch pushed the block without subtract(),
1028
+ // which let a tight-but-positive budget never deplete across a run of small files (F1).
1029
+ state.blocks.push(formatTextFileBlock(abs, content));
1030
+ state.details.push({ path: abs, kind: "text", chars: content.length, lines: lineCount }); // §12.22 — offsets computed in before_agent_start (no body duplication)
1031
+ subtract(state, fileCost);
1032
+ } else {
1033
+ // PRD §9 — extract paged locals once (DRY); used by BOTH the directive block and the paged detail.
1034
+ const headLines = headCompleteLineCount(head);
1035
+ const startLine = headLines + 1; // == headStartLine(head)
1036
+ const directiveBlock = formatPagedDirectiveBlock(abs, content.length, startLine, headLines); // §6.3 — hoist; the directive block still reaches the model via content (display-only fix); its inner text is stored on the detail for the expanded view
1037
+ state.blocks.push(formatTextFileBlock(abs, head));
1038
+ state.blocks.push(directiveBlock);
1039
+ state.details.push({ path: abs, kind: "paged", chars: content.length, range: `:${startLine}-`, pagedHeadLines: headLines, directive: extractDirectiveInner(directiveBlock) }); // §12.22 — head offsets computed in before_agent_start (no body duplication); directive is display-only text, not file bytes
1040
+ state.paged++;
1041
+ subtract(state, Math.ceil(HEAD_CHARS / 4));
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * PRD §5.6 — markdown transitive imports (the six-step algorithm). Called by injectFile's markdown branch
1048
+ * (a delivered .md/.markdown is an import source). Recursion contract:
1049
+ * • RELATIVE-ONLY (§4.5 rule 1): imports starting with / or ~ are ignored (left verbatim) — only relative
1050
+ * tokens resolve. (Contrast: top-level user tokens allow / and ~.)
1051
+ * • CODE-EXEMPT (§5.6.1 / §4.5 rule 3): a #@ inside a fenced block or inline code span is NOT an import
1052
+ * — left verbatim, never stripped. (Detection is approximate-CommonMark, reused from scanTokens.)
1053
+ * • DEDUP-BOUNDED, NOT depth-limited (§12.13): each abs is claimed in state.injectedSet BEFORE its scan,
1054
+ * so a self-import or cycle (a.md→b.md→a.md) dedups to verbatim and cannot recurse infinitely. The set
1055
+ * of injectable files is finite and each is processed at most once — termination is guaranteed without
1056
+ * a depth counter.
1057
+ * • PRE-ORDER depth-first: this file's block is emitted (Step 5) BEFORE recursing into imports (Step 6),
1058
+ * so the model sees a parent's context before the detail it pulls in.
1059
+ *
1060
+ * Six steps (PRD §5.6):
1061
+ * 2. Claim self (idempotent: injectFile pre-claimed abs; included for contract self-containedness).
1062
+ * 3. scanTokens(content, dirname(abs), { allowAbsTilde:false, skipCode:true, bareAt:state.bareAt }) → resolved
1063
+ * import records. §4.6 markdown-only bare-@ opt-in: passes `bareAt: state.bareAt` (the seam P1.M2.T1.S1
1064
+ * created — state.bareAt is derived from cfg.markdownBareAtImports in injectFiles). bareAt:false (default)
1065
+ * → BARE_AT_RE not run → byte-for-byte identical to today (all records prefixLen 2).
1066
+ * 3.5. EXISTENCE PRE-CHECK (PRD §10 / §5.4): scanTokens records a token as soon as it RESOLVES (it does
1067
+ * NOT stat), so a markdown import resolving to a MISSING file or DIRECTORY would otherwise have its
1068
+ * '#@' marker stripped (Step 4) even though injectFile later returns false and nothing is injected for
1069
+ * it. PRD §10 requires such markers be left VERBATIM. Pre-order (Step 6) emits THIS file's block BEFORE
1070
+ * recursing, so the strip decision must be made NOW — unlike the top-level path (processTokenStream),
1071
+ * which can inject-then-strip because the user prompt is not a pre-order block. Stat each import; keep
1072
+ * only those that stat-succeed AND are regular files (isFile also rejects directories, matching §5.4
1073
+ * and injectFile's own check). injectFile re-stats harmlessly on recursion. `injectable ⊆ records`, and
1074
+ * `injectable` carries `prefixLen` (forwarded from records) so Step 4 can strip the correct marker width.
1075
+ * 4. Strip the marker from each INJECTABLE record (high→low, leaving the path) by `r.index + r.prefixLen`
1076
+ * → `stripped` = block content. prefixLen is 2 for `#@` (r.index is the '#'), 1 for bare `@` (r.index is
1077
+ * the '@'); both regexes' lookbehinds are zero-width, so r.index is always the marker's first char.
1078
+ * Missing/dir imports keep the marker verbatim. Unresolved/deduped/absolute/inside-code markers were never
1079
+ * in records and keep the marker verbatim. (byte-for-byte default: every default record has prefixLen 2,
1080
+ * so `+ r.prefixLen` == the old `+ 2`.)
1081
+ * 5. emitText(abs, stripped, state) — the paged decision runs on the STRIPPED content (so directive text
1082
+ * the model won't see does not bias the budget). emitText owns the subtract + paged bump (NOT count).
1083
+ * 6. Recurse into INJECTABLE imports in ENCOUNTER order: if not already claimed, await injectFile(abs)
1084
+ * (which claims, classifies, bumps count, and recurses again if the import is itself markdown).
1085
+ *
1086
+ * PRIVATE — exercised indirectly via injectFiles (PRD §11 cases 15-19 + 20/MD1/MD2). Does NOT bump count
1087
+ * (injectFile owns the single count++ per claimed file; imports bump count in their own injectFile).
1088
+ *
1089
+ * @param abs the importing markdown's absolute path (already claimed by injectFile; resolution base = dirname)
1090
+ * @param content the markdown's decoded UTF-8 content (buf.toString("utf8") from injectFile)
1091
+ * @param state the shared State (blocks/images/injectedSet/remaining/count/paged) threaded across the prompt
1092
+ * @param ctx threaded to the recursive injectFile calls (cwd unused — imports resolve from dirname(abs))
1093
+ */
1094
+ async function injectMarkdown(abs: string, content: string, state: State, ctx: Ctx): Promise<void> {
1095
+ // Step 2 — CLAIM SELF (idempotent: injectFile already added abs; included per PRD §5.6 step-2 contract).
1096
+ state.injectedSet.add(abs);
1097
+
1098
+ const dir = path.dirname(abs); // §4.5 rule 2 — imports resolve relative to the markdown's directory, not cwd
1099
+
1100
+ // Step 3 — scan for imports: relative-only (allowAbsTilde:false), outside code (skipCode:true),
1101
+ // markdown shorthand ON (tryMdExt:true → extensionless tokens try .md then .markdown, PRD §4.5 rule 3).
1102
+ // §4.6 — thread state.bareAt (set from cfg in injectFiles — the P1.M2.T1.S1 seam) so a markdown author who
1103
+ // opts into markdownBareAtImports can write a bare @api.md (prefixLen 1). bareAt:false (default) → BARE_AT_RE
1104
+ // is not run → records are byte-for-byte identical to today (all prefixLen 2).
1105
+ const records = await scanTokens(content, dir, { allowAbsTilde: false, skipCode: true, tryMdExt: true, bareAt: state.bareAt }, state);
1106
+
1107
+ // Step 3.5 — READABILITY PRE-CHECK (PRD §5.4 / §10 / §12.5). scanTokens records a token as soon as it
1108
+ // RESOLVES (it does NOT stat), so a markdown import resolving to a MISSING file, DIRECTORY, or a file
1109
+ // that EXISTS but is UNREADABLE would otherwise have its '#@' marker stripped (Step 4) even though
1110
+ // injectFile later returns false and nothing is injected for it. PRD §5.4/§12.5/§10 require such markers
1111
+ // be left VERBATIM. Pre-order (§5.6 step 6) emits THIS file's block BEFORE recursing, so the strip
1112
+ // decision must be made NOW (the top-level path can inject-then-strip because the user prompt is not a
1113
+ // pre-order block; the markdown path cannot). Stat each import; keep only those that stat-succeed AND are
1114
+ // regular files (isFile also rejects directories, matching injectFile's own check and §5.4: directory →
1115
+ // verbatim), AND additionally gate on readability via fs.access(R_OK) so a marker is stripped ONLY when
1116
+ // delivery will truly succeed for the text/markdown/binary case that dominates (PRD §5.4/§12.5: on any
1117
+ // error leave the token verbatim). injectFile re-stats harmlessly on recursion.
1118
+ // ACCEPTED NARROW RESIDUAL: the R_OK gate predicts readability, NOT resize success — a READABLE image
1119
+ // whose resizeImage THROWS (rather than returning null) still gets stripped, because we cannot predict
1120
+ // a resize failure without running the resize (expensive, duplicative). That is out of scope here; the
1121
+ // full closure is the structural "strip only markers whose injectFile returned true" approach (PRD calls
1122
+ // it "more invasive"). It is backstopped by injectFile's own try/catch (no crash, no block appended).
1123
+ // TOCTOU: a file could become unreadable between this access and injectFile's readFile, but that races
1124
+ // the top-level path too and is acceptable (injectFile's readFile try/catch is the final safety net).
1125
+ // TYPE-ONLY widening: records carry prefixLen (scanTokens P1.M1.T1.S1); widening the declared element type
1126
+ // lets Step 4 read r.prefixLen. The filter body (injectable.push(r)) forwards the WHOLE record unchanged —
1127
+ // do NOT build a new object literal here (that would DROP prefixLen, the codebase_delta §8.2 anti-pattern).
1128
+ // No new import: fs.constants.R_OK (=== 4) is reachable via the existing `import { promises as fs }`.
1129
+ const injectable: { index: number; prefixLen: number; abs: string }[] = [];
1130
+ for (const r of records) {
1131
+ try {
1132
+ const st = await fs.stat(r.abs);
1133
+ if (!st.isFile()) continue; // directory/socket/etc → verbatim (§5.4) — unchanged
1134
+ await fs.access(r.abs, fs.constants.R_OK); // gate strip on READABILITY (PRD §5.4 / §12.5)
1135
+ injectable.push(r);
1136
+ } catch {
1137
+ /* missing / directory / unreadable → leave verbatim (not stripped, not injected) */
1138
+ }
1139
+ }
1140
+
1141
+ // Step 4 — strip the marker from each INJECTABLE import (high→low so earlier offsets stay valid), leaving
1142
+ // the path. `stripped` becomes THIS file's block content. Missing/dir imports keep the marker verbatim.
1143
+ // §4.6 — strip by the marker's width: prefixLen 2 for `#@` (r.index is the '#'), 1 for bare `@` (r.index is
1144
+ // the '@'); both regexes' lookbehinds are zero-width, so r.index is always the marker's first char.
1145
+ let stripped = content;
1146
+ for (const r of [...injectable].sort((a, b) => b.index - a.index)) {
1147
+ stripped = stripped.slice(0, r.index) + stripped.slice(r.index + r.prefixLen);
1148
+ }
1149
+
1150
+ // Step 5 — emit THIS file's block. The paged decision runs on the STRIPPED content (§5.6 step 5).
1151
+ emitText(abs, stripped, state); // emitText owns formatTextFileBlock + subtract + the paged head/directive + state.paged++
1152
+
1153
+ // Step 6 — recurse into INJECTABLE imports, depth-first, ENCOUNTER ORDER (pre-order). Missing/dir imports
1154
+ // are absent here (they would no-op in injectFile anyway). The injectedSet re-check is belt-and-suspenders
1155
+ // (cross-subtree dedup since the scan).
1156
+ for (const r of injectable) {
1157
+ if (state.injectedSet.has(r.abs)) continue; // already claimed (e.g. by a sibling subtree meanwhile)
1158
+ await injectFile(r.abs, state, ctx); // claims abs, classifies, bumps count, recurses again if markdown
1159
+ }
1160
+ }
1161
+
1162
+ export async function injectFiles(
1163
+ text: string,
1164
+ imagesIn: ImageContent[],
1165
+ ctx: Ctx,
1166
+ bareAt = false, // §4.6 — markdown bare-@ enabled? (derived from cfg in the input handler; default false for direct unit tests)
1167
+ ): Promise<{ text: string; images: ImageContent[]; injected: number; paged: number; blocks: string[]; details: FileDetail[] }> {
1168
+ // §5.5 BUDGET — remaining context, computed ONCE (best-effort; never throws out of injectFiles).
1169
+ // The input event fires BEFORE the turn, so getContextUsage() may be undefined or its tokens
1170
+ // null (right after compaction). Either → remaining = null → O-1 fallback: inject WHOLE
1171
+ // (current behavior). When available: remaining = window - used - reserve - MARGIN, clamped ≥ 0.
1172
+ let remaining: number | null;
1173
+ try {
1174
+ const usage = ctx.getContextUsage?.();
1175
+ if (usage === undefined || usage.tokens === null) {
1176
+ remaining = null; // O-1 fallback: budget unknown → inject whole (no paging)
1177
+ } else {
1178
+ const reserve = ctx.model?.maxTokens ?? DEFAULT_RESERVE;
1179
+ remaining = Math.max(0, usage.contextWindow - usage.tokens - reserve - MARGIN);
1180
+ }
1181
+ } catch {
1182
+ remaining = null; // getContextUsage threw → O-1 fallback (PRD §12.5: never throw)
1183
+ }
1184
+
1185
+ // PRIOR-INJECTION SET (defense-in-depth — validator finding F-NEW-1, recommendation #2). Collect
1186
+ // EVERY `<file name="<path>">` already present in `text` — whether stamped by a prior copy of THIS
1187
+ // extension (under the `\n\n---\n\n` separator, PRD §6.2) or by Pi's own `@file` argv expander — so
1188
+ // per-token dedup below is robust to path-string quirks (a prior copy may have resolved against a
1189
+ // different cwd, expanded `~` differently, or been a sentinel/legacy build). This is a SUPERSET of
1190
+ // a single exact-path substring test and is strictly additive: it never suppresses a token whose
1191
+ // resolved path is NOT already in the set, so multi-file prompts (inject A then B) still work even
1192
+ // when a prior copy already injected A. NOTE: like any in-this-copy check, it cannot stop a LATER
1193
+ // non-cooperating (pre-dedup) copy from re-injecting — see README's single-copy guidance. This
1194
+ // prior set SEEDS state.injectedSet, which is also grown by each within-run delivery below — the
1195
+ // two were separate locals before; they collapse here into one accumulator ("paths already claimed").
1196
+ const priorPaths = new Set<string>();
1197
+ for (const m of text.matchAll(/<file name="([^"]+)">/g)) {
1198
+ priorPaths.add(m[1]);
1199
+ }
1200
+
1201
+ // SHARED STATE (PRD §9) — ONE object threaded through the loop (and, later, through a recursive
1202
+ // import chain). blocks/images/count/paged were scattered locals; injectedSet consolidates the
1203
+ // prior-injection set above WITH within-run dedup (Issue 1) — a per-token check of
1204
+ // injectedSet.has(abs) is equivalent to the old (priorPaths.has || injectedThisRun.has) because the
1205
+ // set at check time == {priorPaths} ∪ {abs paths successfully delivered so far this run}, grown by
1206
+ // .add(abs) at each success site. remaining is the single budget accumulator (subtract() mutates it).
1207
+ const state: State = {
1208
+ blocks: [],
1209
+ details: [], // PRD §6.4 — parallel to blocks; populated by emitText (and, in S2, injectFile's image/binary branches)
1210
+ images: [...imagesIn], // COPY — runner REPLACES the array on transform; seed originals (item §3a)
1211
+ injectedSet: priorPaths, // consolidated dedup: priorPaths ∪ within-run (added at each success)
1212
+ remaining,
1213
+ count: 0,
1214
+ paged: 0, // §5.5 paged-delivery counter — files delivered head+directive (subset of count)
1215
+ bareAt, // §4.6 — from the param; the SEAM injectMarkdown (P1.M2.T2.S1) reads via `bareAt: state.bareAt`
1216
+ };
1217
+
1218
+ // process the USER PROMPT: baseDir = cwd, absolute/tilde allowed, no code-skipping.
1219
+ // processTokenStream scans ONCE (before any injection) then calls injectFile per record (PRD §12.17),
1220
+ // returning the start indices of markers that ACTUALLY injected. Failed tokens (missing/dir/error)
1221
+ // and deduped repeats are never returned → they keep '#@' verbatim (PRD §6.2). scanTokens' per-text
1222
+ // localSeen + state.injectedSet give cross-subtree dedup (a later token whose path an earlier import
1223
+ // claimed is left verbatim); processTokenStream's belt-and-suspenders injectedSet re-check is a no-op
1224
+ // at top level in T1.S2 (each abs is already unique in records) but load-bearing for T2 recursion.
1225
+ const resolvedIdx = await processTokenStream(
1226
+ text, ctx.cwd, { allowAbsTilde: true, skipCode: false, tryMdExt: false, bareAt: false }, state, ctx);
1227
+
1228
+ if (state.count === 0) return { text, images: imagesIn, injected: 0, paged: 0, blocks: [], details: [] }; // ORIGINAL ref — nothing injected → byte-for-byte (§10 row 1)
1229
+
1230
+ // Strip the #@ trigger from each inline marker — the PATH stays put as a readable reference. The
1231
+ // model doesn't need the #@ syntax (the appended <file name="abs"> blocks carry the data), and
1232
+ // every #@ is 2 tokens of pure noise. Reached only when count > 0, so the nothing-injected path
1233
+ // above still returns the prompt byte-for-byte (missing/dir/error tokens keep their #@ verbatim).
1234
+ // §6.2 — strip the '#@' trigger ONLY from tokens that ACTUALLY injected. Failed tokens
1235
+ // (missing/dir/error) and deduped repeats were never returned, so they keep '#@' verbatim.
1236
+ // INDEX-BASED SPLICE (not substring replace): an injected match can be a prefix of another token
1237
+ // (e.g. '#@a.ts' ⊂ '#@a.ts.bak'), so a substring replace would corrupt the longer token. Group 1
1238
+ // of FILE_INJECT_RE is zero-width → m.index is exactly the '#'; removing 2 chars drops exactly
1239
+ // '#@'. Process high→low so earlier offsets stay valid.
1240
+ let strippedText = text;
1241
+ for (const i of [...resolvedIdx].sort((a, b) => b - a)) {
1242
+ strippedText = strippedText.slice(0, i) + strippedText.slice(i + 2);
1243
+ }
1244
+ // §6.4 — the user message is JUST the stripped prompt (no appended blocks, no `\n\n---\n\n`). The blocks +
1245
+ // details are returned for the caller (P1.M1.T2.S1 stashes them for the before_agent_start custom message).
1246
+ return { text: strippedText, images: state.images, injected: state.count, paged: state.paged, blocks: state.blocks, details: state.details };
1247
+ }
1248
+
1249
+ /**
1250
+ * #@file — Whole-File Injection Extension for Pi.
1251
+ *
1252
+ * Lets a user attach an ENTIRE file to their prompt by writing `#@<path>` anywhere in the submitted
1253
+ * text. On the `input` event — which fires inside `AgentSession.prompt()` for ALL input contexts
1254
+ * (interactive TUI messages, the initial CLI / `-p` / RPC message, and RPC calls alike) — every
1255
+ * `#@<path>` token is resolved, the whole file is read, and its contents are appended to the prompt in
1256
+ * Pi-native `<file name="...">...</file>` blocks below a `---` separator. Image files are attached as
1257
+ * `ImageContent` (resized to provider limits) plus a reference block; non-image binaries get a clear
1258
+ * note instead of decoded garbage.
1259
+ *
1260
+ * Trigger syntax: `#@<path>` — e.g. `#@src/index.ts`, `#@~/notes.md`, `#@pic.png`,
1261
+ * `(#@a.txt and #@b.md)`. The two-char `#@` trigger is collision-free with Pi's
1262
+ * `@file` / `@mention`, markdown `#` headings, issue `#1234`, and `user@host` email.
1263
+ *
1264
+ * Works everywhere: interactive, `pi -p "...#@file..."`, and RPC — because the hook is the `input`
1265
+ * event inside `prompt()`, not argv parsing (unlike Pi's built-in `@file` CLI expansion).
1266
+ *
1267
+ * The whole file always reaches the model: injected inline when it fits the remaining context budget,
1268
+ * or delivered as a head block plus a paging directive (instructing the model to read the rest via the
1269
+ * read tool) when it exceeds it (PRD §5.5). The budget is derived from the active model context window
1270
+ * and current usage — no user-facing config. Images are downscaled (2000×2000) because providers reject
1271
+ * oversized images. The handler short-circuits (`continue`) when the input originated
1272
+ * from an extension (loop prevention), is a mid-stream steering nudge (latency), or simply has no `#@`
1273
+ * token — and it never throws (injectFiles isolates each file in its own try/catch).
1274
+ */
1275
+ /** §4.6 — the cached file-injector.json config. MODULE-LEVEL (NOT a factory closure): the test harness's
1276
+ * captureHandler calls the factory (mod.default(pi)) fresh per capture, so a closure cfg would reset to {}
1277
+ * on each capture and the session_start→input flow could not share state. Module-level persists across
1278
+ * captures (and is identical in the real single-invocation runtime). Loaded once on session_start via
1279
+ * readConfig; read by the input handler to derive bareAt. */
1280
+ let cfg: FileInjectorConfig = {};
1281
+
1282
+ export default function (pi: ExtensionAPI) {
1283
+ // §6.2/§12.20 — one-shot handoff stash from the input handler to before_agent_start. input produces the
1284
+ // work (file I/O + blocks/details); before_agent_start publishes it as the custom message after the user
1285
+ // message. prompt() runs input → … → before_agent_start sequentially (one awaited call), so there is no race.
1286
+ // CLOSURE var (NOT module-level like cfg above): pending is a per-prompt handoff between two handlers from
1287
+ // this same factory invocation; closure scope is correct and per-session. Cleared unconditionally in
1288
+ // before_agent_start (one-shot per prompt, §12.20) so a later no-#@ prompt never re-delivers a stale stash.
1289
+ let pending: { blocks: string[]; details: FileDetail[] } | null = null;
1290
+
1291
+ // §4.6 — load file-injector.json config on session_start (provides ctx.cwd + ctx.isProjectTrusted()).
1292
+ // Registered FIRST so captureHandler("session_start").all[0] is this handler. readConfig NEVER throws
1293
+ // (tryRead → {}), so this can't break the session. A separate handler (not merged into autocomplete)
1294
+ // so the autocomplete handler + its A1 test stay byte-for-byte (A1 invokes the autocomplete handler
1295
+ // with a minimal ctx that lacks isProjectTrusted — merging would call readConfig there and throw).
1296
+ pi.on("session_start", async (_e, ctx) => {
1297
+ cfg = await readConfig(ctx);
1298
+ // §6.3 register the chat renderer ONCE (the display contract T2.S1's before_agent_start set with display:true).
1299
+ // No hasUI guard — renderers are no-ops in print/json (the renderer fn is only invoked by CustomMessageComponent
1300
+ // in TUI mode). customType MUST match before_agent_start's "fileInjector.injected" exactly (the handshake).
1301
+ pi.registerMessageRenderer("fileInjector.injected", (message, opts, theme) =>
1302
+ renderInjectedMessage(message, opts, theme));
1303
+ });
1304
+
1305
+ pi.on("input", async (event, ctx) => {
1306
+ if (event.source === "extension") return { action: "continue" }; // MANDATORY loop prevention (§12.1)
1307
+ if (event.streamingBehavior === "steer") return { action: "continue" }; // skip mid-stream steering for latency (§12.2)
1308
+ if (!event.text?.includes("#@")) return { action: "continue" }; // cheap pre-check before any regex/IO (§12.4)
1309
+
1310
+ const { text, images, injected, paged, blocks, details } = await injectFiles(event.text, event.images ?? [], ctx, cfg.markdownBareAtImports === true); // §5.5 — paged count drives the mode-aware notify below; §4.6 — bareAt derived from cached cfg; §6.2 — blocks/details stashed for before_agent_start
1311
+ if (!injected) return { action: "continue" }; // nothing injected → preserve prompt byte-for-byte (§10 row 1); injected counts whole+paged, so 0 = nothing delivered (no stash set → before_agent_start returns undefined)
1312
+
1313
+ // §6.2 hand the built blocks+details to before_agent_start (the custom message). Only stashed when
1314
+ // injected > 0 (the !injected early-return above left no stash). Cleared one-shot in before_agent_start.
1315
+ pending = { blocks, details };
1316
+
1317
+ // §5.5 Notify — surface the mode, guarded on ctx.hasUI (PRD §5.5 Notify). Unified wording: always
1318
+ // "N whole"; append ", M paged" only when paging. paged===0 → "#@ injected N whole"; paged>0 →
1319
+ // "#@ injected N whole, M paged".
1320
+ const whole = injected - paged;
1321
+ const msg = `#@ injected ${whole} whole${paged > 0 ? `, ${paged} paged` : ""}`;
1322
+ if (ctx.hasUI) ctx.ui.notify(msg, "info"); // §5.5 unified whole/paged wording; guarded for print/json headless modes (api_verification §5)
1323
+ return { action: "transform" as const, text, images }; // rewrite prompt with injected content + merged images
1324
+ });
1325
+
1326
+ // §6.2 publish the stashed files as ONE custom message, appended after the user message. Fires once per
1327
+ // prompt(), after the input handler. No stash (no #@, or short-circuited, or nothing injected) → return
1328
+ // undefined (no-op). The customType "fileInjector.injected" is the handshake with the MessageRenderer T2.S2
1329
+ // registers; until then Pi renders its default [fileInjector.injected] box (delivery to the model — via
1330
+ // convertToLlm role:custom→user — works regardless of rendering). Cleared unconditionally (one-shot, §12.20).
1331
+ pi.on("before_agent_start", async (_e, _ctx) => {
1332
+ if (!pending) return undefined;
1333
+ const { blocks, details } = pending;
1334
+ pending = null; // clear regardless — one-shot per prompt (a later no-#@ prompt never re-delivers)
1335
+ computeDetailOffsets(blocks, details); // §12.22 (P1.M2.T1.S1) — absolute body offsets so the renderer slices message.content WITHOUT duplicating file bytes into details (BUG-1-safe: length-derived, not regex)
1336
+ return {
1337
+ message: {
1338
+ customType: "fileInjector.injected", // the renderer's registered customType (T2.S2 registers it)
1339
+ content: blocks.join("\n\n"), // every <file> block → sent to the LLM (convertToLlm: custom→user)
1340
+ display: true, // render in the TUI (renderer registered in T2.S2; Pi's default
1341
+ // [fileInjector.injected] box shows until then — acceptable interim)
1342
+ details: { files: details }, // renderer metadata (NOT extra model text; convertToLlm ignores details)
1343
+ },
1344
+ };
1345
+ });
1346
+
1347
+ // ── #@ path autocomplete (TUI/RPC only) ─────────────────────────────────────
1348
+ // pi's built-in `@` completion lists files (gitignore-aware, via `fd`) but only fires when `@`
1349
+ // sits at a token boundary; `#` glued in front closes it, so `#@` gets no completion on its own
1350
+ // (verified: merely opening the shouldTriggerFileCompletion gate for `#@` yields nothing — pi's
1351
+ // @-query extraction is itself boundary-strict). So instead we REUSE pi's file engine without
1352
+ // reimplementing it: when the cursor is at `#@<partial>`, rewrite that one '#' into a space
1353
+ // (giving the built-in a clean `@<partial>` at a valid boundary), delegate getSuggestions to the
1354
+ // built-in, then remap the result back to `#@` (prefix `@<partial>` → `#@<partial>`; each item
1355
+ // value `@<path>` → `#@<path>`). applyCompletion is handled here for our `#@` prefix so insertion
1356
+ // is deterministic; everything else delegates. TUI/RPC only (headless print/json is a no-op);
1357
+ // `pi -p "...#@file..."` is unaffected.
1358
+ pi.on("session_start", (_event, ctx) => {
1359
+ if (!ctx.ui || typeof ctx.ui.addAutocompleteProvider !== "function") return; // headless print/json guard
1360
+ ctx.ui.addAutocompleteProvider((current) => ({
1361
+ triggerCharacters: ["@"], // typing the @ in #@ (re)evaluates suggestions
1362
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
1363
+ const line = lines[cursorLine] ?? "";
1364
+ const before = line.slice(0, cursorCol);
1365
+ const m = before.match(/#@([^@\s]*)$/); // our trigger ending at the cursor
1366
+ if (!m) return current.getSuggestions(lines, cursorLine, cursorCol, options); // not #@ → built-in owns it
1367
+ const partial = m[1];
1368
+
1369
+ // Rewrite the '#' immediately before our '@' into a space. The '@', the partial, and the
1370
+ // cursor position are unchanged, so the built-in lists exactly the files it would for a
1371
+ // normal `@<partial>` mention — gitignore-aware, sorted, fuzzy — via `fd`.
1372
+ const hashIdx = cursorCol - partial.length - 2; // index of '#' (before '@' + partial)
1373
+ if (hashIdx < 0) return current.getSuggestions(lines, cursorLine, cursorCol, options);
1374
+ const rewrittenLine = line.slice(0, hashIdx) + " " + line.slice(hashIdx + 1);
1375
+ const rewrittenLines = lines.slice();
1376
+ rewrittenLines[cursorLine] = rewrittenLine;
1377
+
1378
+ const inner = await current.getSuggestions(rewrittenLines, cursorLine, cursorCol, options);
1379
+ if (options.signal?.aborted || !inner || inner.items.length === 0) return inner; // nothing / aborted
1380
+ // Only remap built-in FILE suggestions (prefix `@…`). If the built-in somehow returned
1381
+ // non-@ items, pass them through untouched so we don't mangle slash-command suggestions.
1382
+ if (!inner.prefix.startsWith("@")) return inner;
1383
+
1384
+ const items = inner.items.map((it) => {
1385
+ let v = it.value;
1386
+ if (!v.startsWith("#@")) v = v.startsWith("@") ? "#" + v : "#@" + v; // @path → #@path
1387
+ return v === it.value ? it : { ...it, value: v };
1388
+ });
1389
+ return { prefix: `#@${partial}`, items };
1390
+ },
1391
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
1392
+ // Deterministic insert for our #@ items: replace `prefix` (which ends at the cursor) with
1393
+ // `item.value`, place the cursor just past it. Delegate anything else to the built-in.
1394
+ if (typeof prefix === "string" && prefix.startsWith("#@")) {
1395
+ const line = lines[cursorLine] ?? "";
1396
+ const before = line.slice(0, cursorCol);
1397
+ if (before.endsWith(prefix)) {
1398
+ const start = cursorCol - prefix.length;
1399
+ const value = typeof item?.value === "string" ? item.value : "";
1400
+ const newLines = lines.slice();
1401
+ newLines[cursorLine] = before.slice(0, start) + value + line.slice(cursorCol);
1402
+ return { lines: newLines, cursorLine, cursorCol: start + value.length };
1403
+ }
1404
+ }
1405
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
1406
+ },
1407
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
1408
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; // built-in decides
1409
+ },
1410
+ }));
1411
+ });
1412
+ }