pi-file-injector 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/file-injector.ts +105 -158
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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("
|
|
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 = "
|
|
355
|
-
// absolute char offset of each block within blocks.join("
|
|
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.
|
|
412
|
-
*
|
|
413
|
-
* the
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
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
|
|
836
|
-
* WITHOUT injecting. Pure (no I/O, no state mutation beyond the
|
|
837
|
-
* via localSeen; global state.injectedSet check skips already-
|
|
838
|
-
* injected earlier this run / in a parent recursion).
|
|
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
|
-
*
|
|
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
|
|
850
|
-
*
|
|
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
|
|
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<
|
|
870
|
+
): Promise<string[]> {
|
|
862
871
|
const localSeen = new Set<string>();
|
|
863
|
-
const out:
|
|
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
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
const cands: { idx: number; token: string
|
|
874
|
-
for (const m of text.matchAll(FILE_INJECT_RE)) cands.push({ idx: m.index!, token: m[2]
|
|
875
|
-
if (opts.bareAt) for (const m of text.matchAll(BARE_AT_RE)) cands.push({ idx: m.index!, token: m[2]
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
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
|
|
917
|
+
opts: { allowAbsTilde: boolean; skipCode: boolean; tryMdExt: boolean; bareAt?: boolean },
|
|
908
918
|
state: State,
|
|
909
919
|
ctx: Ctx,
|
|
910
|
-
): Promise<
|
|
911
|
-
const
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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 })
|
|
1063
|
-
* import
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
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
|
|
1104
|
-
//
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
// Step
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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 4 — emit 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
|
|
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
|
|
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
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
|
|
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
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
|
|
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
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
*
|
|
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 }; //
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|