pi-file-injector 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/file-injector.ts +105 -158
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -38,9 +38,9 @@ Diff #@a.ts vs #@b.ts
38
38
  See #@a.ts.
39
39
  ```
40
40
 
41
- On submit, each file shows up as a compact green `read <path>` line directly below your message — one line per file, indistinguishable from the `read` tool. Press `ctrl+o` to expand any of them to the full contents. The `#@` trigger is stripped from each reference, so `Review #@a.ts` appears in your message as `Review a.ts`, with the file delivered to the model underneath — never pasted into your message bubble.
41
+ On submit, each file shows up as a compact green `read <path>` line directly below your message — one line per file, indistinguishable from the `read` tool. Press `ctrl+o` to expand any of them to the full contents. `#@` triggers stay in your message exactly as you typed them (`Review #@a.ts` stays `Review #@a.ts`), so cancelling and re-opening, forking, or re-submitting re-triggers injection. The file bytes are delivered to the model underneath — never pasted into your message bubble.
42
42
 
43
- Markdown files can import other files. If `spec.md` itself contains `#@api.md`, a single `#@spec.md` delivers both — `spec.md` first, then `api.md` and the import marker is stripped from `spec.md` the same way a top-level marker is:
43
+ Markdown files can import other files. If `spec.md` itself contains `#@api.md`, a single `#@spec.md` delivers both — `spec.md` first, then `api.md`. The import marker stays in `spec.md` verbatim (same as a top-level marker):
44
44
 
45
45
  ```text
46
46
  #@spec.md # spec.md contains: see #@api.md
@@ -69,7 +69,7 @@ Text uses Pi's native block format, the same one `@file` uses:
69
69
  </file>
70
70
  ```
71
71
 
72
- That's what the model receives. You won't see it as raw text in the chat — each injected file renders as a green `read <path>` line (just like the `read` tool), with `ctrl+o` to expand. Your own message shows only what you typed.
72
+ That's what the model receives. You won't see it as raw text in the chat — each injected file renders as a green `read <path>` line (just like the `read` tool), with `ctrl+o` to expand. Your own message shows exactly what you typed — including the `#@` markers — so re-opening or forking re-triggers injection automatically.
73
73
 
74
74
  Images are matched by their real bytes, not just the extension. A text file renamed `fake.png` is injected as text, not attached as a broken image. The check cuts both ways: a real image saved with the wrong extension — a PNG named `photo.jpg`, say — is not attached, because its bytes don't match the `.jpg` signature; it's delivered as a binary note instead. Rename it to its real type to attach it. An empty (0-byte) image attaches nothing.
75
75
 
package/file-injector.ts CHANGED
@@ -340,7 +340,7 @@ function blockPath(block: string): string | undefined {
340
340
  * renderer can slice the body out of the assembled `message.content` WITHOUT duplicating file bytes into
341
341
  * `details` (the body is renderer-only metadata; it must not be persisted in the custom message). Runs in
342
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.
343
+ * `blocks.join("\n\n")` depends on every PRIOR block's length — `emitText` cannot know it at emit time.
344
344
  *
345
345
  * Pairing: details and blocks are NOT 1:1 — a paged detail emits TWO blocks (head + directive) but only
346
346
  * the head block carries the body. We pair each detail to the NEXT unmatched text/head block with the same
@@ -351,8 +351,8 @@ function blockPath(block: string): string | undefined {
351
351
  * Idempotent + defensive: a detail whose block can't be located is left untouched (renderer falls back to
352
352
  * the regex tier). Mutates `details` in place and returns it for chaining. */
353
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")
354
+ const SEP = "\n\n";
355
+ // absolute char offset of each block within blocks.join("\n\n")
356
356
  const starts: number[] = [];
357
357
  let off = 0;
358
358
  for (const b of blocks) { starts.push(off); off += b.length + SEP.length; }
@@ -408,12 +408,15 @@ export function computeDetailOffsets(blocks: string[], details: FileDetail[]): F
408
408
  * each, and append a Pi-native `<file>` block (text/binary) or attach an ImageContent (image). The
409
409
  * whole file ALWAYS reaches the model: injected inline when it fits the remaining context budget,
410
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}.
411
+ * in try/catch); tokens that miss/are directories/throw are left verbatim. VERBATIM DELIVERY (PRD
412
+ * §6.4/§13.8): the original prompt text is NOT modified nothing is appended to it. The file BYTES
413
+ * live only in the returned `blocks`/`details` (the caller stashes them for `before_agent_start`'s
414
+ * custom message); the prompt carries nothing but the user's original text. (Markers are NOT stripped,
415
+ * so cancel/fork//tree re-open re-triggers injection.) `ctx` carries the budget inputs
416
+ * `getContextUsage()` (tokens used) and `model` (contextWindow/maxTokens); both optional so a
417
+ * cwd-only ctx is valid. `injected` counts whole-file injections; `paged` counts files delivered via
418
+ * the page path (PRD §5.5). Return `{injected:0, paged:0}` => caller returns {action:"continue"};
419
+ * otherwise the input handler stashes blocks/details and returns {action:"transform", text, images}.
417
420
  *
418
421
  * Internal state is carried in ONE shared `State` object (PRD §9) — `blocks`, `images`,
419
422
  * `injectedSet` (consolidated dedup: prior `<file>` blocks ∪ within-run deliveries), `remaining`
@@ -832,12 +835,18 @@ type Ctx = {
832
835
  };
833
836
 
834
837
  /**
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.
838
+ * PRD §9 / §5.6 step 3 — scan a text (user prompt OR markdown content) for `#@`/`@` import markers,
839
+ * resolving each to an absolute path WITHOUT injecting. Pure (no I/O, no state mutation beyond the
840
+ * per-text localSeen set). Per-text dedup via localSeen; global state.injectedSet check skips already-
841
+ * claimed paths (prior <file> blocks OR files injected earlier this run / in a parent recursion).
839
842
  *
840
- * opts.skipCode: when true, scanTokens precomputes code regions once and skips any `#@` match whose
843
+ * VERBATIM DELIVERY (PRD §6.4 / §12.16): markers are detected here ONLY to resolve imports they are
844
+ * NEVER stripped from the text, so no index/prefixLen bookkeeping is returned. The returned paths are
845
+ * the resolved absolute file paths, in encounter order (the depth-first recursion in processTokenStream
846
+ * relies on this ordering). Resolution logic is unchanged: cleanToken → isAbsoluteOrTilde →
847
+ * resolveImportPath → dedup → code-region skip.
848
+ *
849
+ * opts.skipCode: when true, scanTokens precomputes code regions once and skips any marker whose
841
850
  * start index lies inside a fenced block or inline code span (PRD §5.6.1). null codeRanges when false →
842
851
  * inCode is never called → top-level (user-prompt) behavior is unchanged. Markdown imports (T2.S3)
843
852
  * pass skipCode:true.
@@ -846,33 +855,33 @@ type Ctx = {
846
855
  * `<exact>.md` then `<exact>.markdown` (markdown-import shorthand, PRD §4.5 rule 3). Top-level user-prompt
847
856
  * scan passes `false` (§4.4 exact-only → top-level behavior is byte-for-byte identical to today).
848
857
  * 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 `#`).
858
+ * alongside the `#@` markers, returning a union of candidate paths sorted by index. Markdown-only /
859
+ * opt-in; the two regexes never double-match the same `#@` (BARE_AT_RE forbids a preceding `#`).
852
860
  * When absent/false, ONLY `#@` matches run → byte-for-byte identical to the single-regex form.
853
861
  *
854
- * Returns { index; prefixLen; abs }[] in text order (prefixLen is the marker char width: 2 for `#@`, 1 for `@`).
862
+ * Returns string[] the resolved absolute paths in encounter order (markers detected, never stripped:
863
+ * PRD §6.4 / §12.16).
855
864
  */
856
865
  export async function scanTokens(
857
866
  text: string,
858
867
  baseDir: string,
859
868
  opts: { allowAbsTilde: boolean; skipCode: boolean; tryMdExt: boolean; bareAt?: boolean },
860
869
  state: State,
861
- ): Promise<{ index: number; prefixLen: number; abs: string }[]> {
870
+ ): Promise<string[]> {
862
871
  const localSeen = new Set<string>();
863
- const out: { index: number; prefixLen: number; abs: string }[] = [];
872
+ const out: string[] = [];
864
873
  // §5.6.1 — when scanning markdown content, precompute code regions once and skip `#@` matches whose
865
874
  // start index lies inside a fenced block or inline code span (the markdown escape hatch, §4.5 rule 3).
866
875
  // null when skipCode:false (top-level user-prompt scan) → inCode is never called → no behavior change.
867
876
  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 });
877
+ // Candidate markers: `#@` always; bare `@` only when opts.bareAt. BARE_AT_RE forbids a preceding `#`,
878
+ // so `#@file` appears once (via FILE_INJECT_RE), never twice. When bareAt is absent/false, cands holds
879
+ // only the FILE_INJECT_RE matches in index-ascending order (matchAll yields ascending), so the sort is
880
+ // a no-op and the per-candidate body below is byte-for-byte identical to the prior single-loop form.
881
+ // `idx` is retained because it is load-bearing for the code-region exemption (inCode(c.idx, …)).
882
+ const cands: { idx: number; token: string }[] = [];
883
+ for (const m of text.matchAll(FILE_INJECT_RE)) cands.push({ idx: m.index!, token: m[2] });
884
+ if (opts.bareAt) for (const m of text.matchAll(BARE_AT_RE)) cands.push({ idx: m.index!, token: m[2] });
876
885
  cands.sort((a, b) => a.idx - b.idx);
877
886
  for (const c of cands) {
878
887
  if (codeRanges && inCode(c.idx, codeRanges)) continue; // §5.6.1 — skip markers inside code
@@ -883,20 +892,21 @@ export async function scanTokens(
883
892
  if (!abs) continue; // nothing resolved → leave verbatim (missing/dir/non-regular)
884
893
  if (state.injectedSet.has(abs) || localSeen.has(abs)) continue; // dedup on RESOLVED abs → leave verbatim
885
894
  localSeen.add(abs);
886
- out.push({ index: c.idx, prefixLen: c.prefixLen, abs });
895
+ out.push(abs);
887
896
  }
888
897
  return out;
889
898
  }
890
899
 
891
900
  /**
892
901
  * 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
902
+ * resolved path depth-first via injectFile. Scan-before-inject gives cross-subtree dedup (a later token
895
903
  * whose path an earlier import claimed is left verbatim). PRIVATE — exercised indirectly via injectFiles.
896
904
  *
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.
905
+ * VERBATIM DELIVERY (PRD §6.4 / §12.16): the prompt text is NEVER modified here markers are detected
906
+ * only to resolve imports (see scanTokens); nothing is stripped, so there is no index accumulator to
907
+ * return. This function returns Promise<void> and injects each resolved abs depth-first. The belt-and-
908
+ * suspenders `state.injectedSet.has(abs)` re-check stays for cross-subtree dedup once injectFile recurses
909
+ * into markdown imports.
900
910
  *
901
911
  * `opts.tryMdExt` is threaded straight through to scanTokens/resolveImportPath: the top-level user-prompt
902
912
  * call site passes `false` (§4.4 exact-only), and the markdown-import path passes `true` (§4.5 shorthand).
@@ -904,18 +914,15 @@ export async function scanTokens(
904
914
  async function processTokenStream(
905
915
  text: string,
906
916
  baseDir: string,
907
- opts: { allowAbsTilde: boolean; skipCode: boolean; tryMdExt: boolean; bareAt: boolean },
917
+ opts: { allowAbsTilde: boolean; skipCode: boolean; tryMdExt: boolean; bareAt?: boolean },
908
918
  state: State,
909
919
  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);
920
+ ): Promise<void> {
921
+ const absPaths = await scanTokens(text, baseDir, opts, state); // scan once, before any injection (opts carries tryMdExt)
922
+ for (const abs of absPaths) {
923
+ if (state.injectedSet.has(abs)) continue; // cross-subtree dedup since scan
924
+ await injectFile(abs, state, ctx); // claims abs, emits block(s); never throws
917
925
  }
918
- return resolved;
919
926
  }
920
927
 
921
928
  /**
@@ -1044,7 +1051,7 @@ export function emitText(abs: string, content: string, state: State): void {
1044
1051
  }
1045
1052
 
1046
1053
  /**
1047
- * PRD §5.6 — markdown transitive imports (the six-step algorithm). Called by injectFile's markdown branch
1054
+ * PRD §5.6 — markdown transitive imports (the five-step algorithm). Called by injectFile's markdown branch
1048
1055
  * (a delivered .md/.markdown is an import source). Recursion contract:
1049
1056
  * • RELATIVE-ONLY (§4.5 rule 1): imports starting with / or ~ are ignored (left verbatim) — only relative
1050
1057
  * tokens resolve. (Contrast: top-level user tokens allow / and ~.)
@@ -1054,40 +1061,27 @@ export function emitText(abs: string, content: string, state: State): void {
1054
1061
  * so a self-import or cycle (a.md→b.md→a.md) dedups to verbatim and cannot recurse infinitely. The set
1055
1062
  * of injectable files is finite and each is processed at most once — termination is guaranteed without
1056
1063
  * a depth counter.
1057
- * • PRE-ORDER depth-first: this file's block is emitted (Step 5) BEFORE recursing into imports (Step 6),
1064
+ * • PRE-ORDER depth-first: this file's block is emitted (Step 4) BEFORE recursing into imports (Step 5),
1058
1065
  * so the model sees a parent's context before the detail it pulls in.
1066
+ * • VERBATIM DELIVERY (§6.4/§12.16): markers are detected here ONLY to resolve imports — they are NEVER
1067
+ * stripped from the delivered content. The block is emitted on the verbatim `content` exactly as read
1068
+ * from disk (import markers stay as honest references; the bytes also live in the custom message, §6.2).
1059
1069
  *
1060
- * Six steps (PRD §5.6):
1070
+ * Five steps (PRD §5.6; Step 1 read/decode is in injectFile):
1061
1071
  * 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).
1072
+ * 3. scanTokens(content, dirname(abs), { allowAbsTilde:false, skipCode:true, tryMdExt:true, bareAt:state.bareAt })
1073
+ * → absPaths: string[] (resolved import paths in encounter order). §4.6 markdown-only bare-@ opt-in:
1074
+ * passes bareAt: state.bareAt (the seam created in injectFiles — derived from cfg.markdownBareAtImports).
1075
+ * 4. emitText(abs, content, state) the paged decision runs on the VERBATIM content (§6.4: markers NOT
1076
+ * stripped). emitText owns the subtract + paged bump (NOT count).
1077
+ * 5. Recurse into absPaths in ENCOUNTER order: if not already claimed, await injectFile(abs) (which claims,
1078
+ * classifies, bumps count, and recurses again if the import is itself markdown).
1085
1079
  *
1086
1080
  * PRIVATE — exercised indirectly via injectFiles (PRD §11 cases 15-19 + 20/MD1/MD2). Does NOT bump count
1087
1081
  * (injectFile owns the single count++ per claimed file; imports bump count in their own injectFile).
1088
1082
  *
1089
1083
  * @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)
1084
+ * @param content the markdown's decoded UTF-8 content (buf.toString("utf8") from injectFile) — delivered VERBATIM
1091
1085
  * @param state the shared State (blocks/images/injectedSet/remaining/count/paged) threaded across the prompt
1092
1086
  * @param ctx threaded to the recursive injectFile calls (cwd unused — imports resolve from dirname(abs))
1093
1087
  */
@@ -1100,62 +1094,20 @@ async function injectMarkdown(abs: string, content: string, state: State, ctx: C
1100
1094
  // Step 3 — scan for imports: relative-only (allowAbsTilde:false), outside code (skipCode:true),
1101
1095
  // markdown shorthand ON (tryMdExt:true → extensionless tokens try .md then .markdown, PRD §4.5 rule 3).
1102
1096
  // §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.5READABILITY 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
1097
+ // opts into markdownBareAtImports can write a bare @api.md. scanTokens returns the resolved import paths as
1098
+ // a string[] (absPaths, in encounter order); markers are detected ONLY to resolve imports, never stripped.
1099
+ const absPaths = await scanTokens(content, dir, { allowAbsTilde: false, skipCode: true, tryMdExt: true, bareAt: state.bareAt }, state);
1100
+
1101
+ // Step 4emit THIS file's block. The paged decision runs on the VERBATIM content (import markers are NOT
1102
+ // stripped §6.4/§12.16: the file is delivered exactly as read from disk). emitText owns formatTextFileBlock
1103
+ // + subtract + the paged head/directive + state.paged++.
1104
+ emitText(abs, content, state);
1105
+
1106
+ // Step 5 recurse into the resolved imports, depth-first, ENCOUNTER ORDER (pre-order). The injectedSet
1107
+ // re-check is belt-and-suspenders (cross-subtree dedup since the scan).
1108
+ for (const abs2 of absPaths) {
1109
+ if (state.injectedSet.has(abs2)) continue; // already claimed (e.g. by a sibling subtree meanwhile)
1110
+ await injectFile(abs2, state, ctx); // claims abs, classifies, bumps count, recurses again if markdown
1159
1111
  }
1160
1112
  }
1161
1113
 
@@ -1184,9 +1136,9 @@ export async function injectFiles(
1184
1136
 
1185
1137
  // PRIOR-INJECTION SET (defense-in-depth — validator finding F-NEW-1, recommendation #2). Collect
1186
1138
  // 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
1139
+ // extension that appended to the text, by a hand-edited prompt, or by Pi's own `@file` argv expander —
1140
+ // so per-token dedup below is robust to path-string quirks (a prior copy may have resolved against a
1141
+ // different cwd, expanded `~` differently, or been a legacy build). This is a SUPERSET of
1190
1142
  // a single exact-path substring test and is strictly additive: it never suppresses a token whose
1191
1143
  // resolved path is NOT already in the set, so multi-file prompts (inject A then B) still work even
1192
1144
  // when a prior copy already injected A. NOTE: like any in-this-copy check, it cannot stop a LATER
@@ -1216,34 +1168,27 @@ export async function injectFiles(
1216
1168
  };
1217
1169
 
1218
1170
  // 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(
1171
+ // processTokenStream scans ONCE (before any injection) then calls injectFile per resolved abs (PRD §12.17);
1172
+ // it returns void — it does NOT report marker indices, because markers are NEVER stripped from the prompt
1173
+ // (§6.4/§13.8: the user message is returned byte-for-byte verbatim so cancel/fork//tree re-open re-triggers
1174
+ // injection). Failed tokens (missing/dir/error) and deduped repeats are never injected they keep '#@'
1175
+ // verbatim (§6.2). scanTokens' per-text localSeen + state.injectedSet give cross-subtree dedup (a later
1176
+ // token whose path an earlier import claimed is left verbatim); processTokenStream's belt-and-suspenders
1177
+ // injectedSet re-check is a no-op at top level (each abs is already unique in absPaths) but load-bearing
1178
+ // for markdown recursion.
1179
+ await processTokenStream(
1226
1180
  text, ctx.cwd, { allowAbsTilde: true, skipCode: false, tryMdExt: false, bareAt: false }, state, ctx);
1227
1181
 
1228
1182
  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
1183
 
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 };
1184
+ // §6.4 / §13.8 the user message is the ORIGINAL `text`, byte-for-byte VERBATIM. Markers are NEVER stripped
1185
+ // (no `#@` removal, no appended blocks, no `\n\n---\n\n`): stripping discards the triggers from the STORED
1186
+ // message, and Pi re-feeds that stored text on cancel / fork / `/tree`-navigate (`navigateTree` prefills the
1187
+ // editor from `targetEntry.message.content`, with no extension hook to override), so a stripped prompt would
1188
+ // never re-trigger injection. The file BYTES live only in the returned `blocks`/`details` (the caller the
1189
+ // input handler stashes them for `before_agent_start`'s custom message); the prompt carries nothing but the
1190
+ // user's original text. (The count===0 path above already returns this same `text` verbatim.)
1191
+ return { text, images: state.images, injected: state.count, paged: state.paged, blocks: state.blocks, details: state.details };
1247
1192
  }
1248
1193
 
1249
1194
  /**
@@ -1252,10 +1197,12 @@ export async function injectFiles(
1252
1197
  * Lets a user attach an ENTIRE file to their prompt by writing `#@<path>` anywhere in the submitted
1253
1198
  * text. On the `input` event — which fires inside `AgentSession.prompt()` for ALL input contexts
1254
1199
  * (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.
1200
+ * `#@<path>` token is resolved, the whole file is read, and delivered to the model in Pi-native
1201
+ * `<file name="...">...</file>` blocks via a separate custom message in `before_agent_start`. The
1202
+ * prompt text itself is returned byte-for-byte VERBATIM (markers are never stripped, so cancel/fork/
1203
+ * `/tree` re-open re-triggers injection). Image files are attached as `ImageContent` (resized to
1204
+ * provider limits) plus a reference block; non-image binaries get a clear note instead of decoded
1205
+ * garbage.
1259
1206
  *
1260
1207
  * Trigger syntax: `#@<path>` — e.g. `#@src/index.ts`, `#@~/notes.md`, `#@pic.png`,
1261
1208
  * `(#@a.txt and #@b.md)`. The two-char `#@` trigger is collision-free with Pi's
@@ -1320,7 +1267,7 @@ export default function (pi: ExtensionAPI) {
1320
1267
  const whole = injected - paged;
1321
1268
  const msg = `#@ injected ${whole} whole${paged > 0 ? `, ${paged} paged` : ""}`;
1322
1269
  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
1270
+ return { action: "transform" as const, text: event.text, images }; // §6.4 — text VERBATIM (event.text, unchanged; the prompt is never modified so cancel/fork/re-open re-triggers injection; §13.8)
1324
1271
  });
1325
1272
 
1326
1273
  // §6.2 publish the stashed files as ONE custom message, appended after the user message. Fires once per
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-file-injector",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "#@file — inject the whole file into your Pi prompt, every time and everywhere, only when you want it. Renders as a compact read-tool line.",
5
5
  "author": "Dustin Schultz",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  "pi-coding-agent",
24
24
  "file-injection",
25
25
  "context",
26
- "prompt",
26
+ "pi-extension",
27
27
  "ai",
28
28
  "agents",
29
29
  "cli"