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.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/file-injector.ts +1412 -0
- package/package.json +62 -0
package/file-injector.ts
ADDED
|
@@ -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
|
+
}
|