pi-file-injector
v0.1.4
Published
#@file — inject the whole file into your Pi prompt, every time and everywhere, only when you want it. Renders as a compact read-tool line.
Maintainers
Readme
#@file
A Pi extension that injects whole files and web pages into your prompt. Write #@ before a file path, or # before a URL. The file reaches the model before it replies, the whole file always reaches the model — injected whole when it fits the remaining context, paged via the read tool when it doesn't — with no configuration.
Why
Pi's built-in @file injects a file only when you pass it on the command line before a session starts. In the editor, @ is path autocomplete, and the model has to call a tool to read the file itself.
#@ always delivers the entire file to the model, in every context: the editor, a pi -p one-shot, and RPC. When the file fits the remaining context it is injected whole; when it exceeds the budget it is delivered as a head block plus a paging directive that the model reads through.
#@spec.md pulls in everything spec.md references with the same #@ directive — spec-and-its-dependencies in one token, loop-safe via dedup (each file is injected at most once).
Install
Published on npm:
pi install npm:pi-file-injectorOr, directly from git:
pi install git:github.com/dabstractor/pi-file-injectorRestart Pi if a session is already open. To uninstall, run pi remove npm:pi-file-injector (or pi remove git:github.com/dabstractor/pi-file-injector / the local path you used with -e for git/local installs).
⚠️ Only one copy at a time. This extension deduplicates injections within a single loaded copy; it cannot deduplicate against a second, concurrently-loaded copy. If two copies are active at once (e.g. a global npm/git install and a
-e ./file-injector.tsdev copy), every#@fileis injected twice and image cost doubles. If you are upgrading from an olderpi-file-injector, remove the prior install (pi remove …for every install kind — npm, git, and local path) before installing this version. Check withpi listand confirmpi-file-injectorappears only once.
Usage
Write #@ and a path anywhere in your prompt:
Review #@a.ts
Review #@a.ts:10
Describe #@pic.png
Summarize #@~/notes.md
Diff #@a.ts vs #@b.ts
See #@a.ts.Optional line range (1-indexed, inclusive):
#@a.ts:10— only line 10#@a.ts:10-15— lines 10 through 15
Exact filenames win: a file literally named a.ts:10 still resolves as-is.
On submit, each file shows up as a compact green read <path> line directly below your message — one line per file, indistinguishable from the read tool. Press ctrl+o to expand any of them to the full contents. #@ triggers stay in your message exactly as you typed them (Review #@a.ts stays Review #@a.ts), so cancelling and re-opening, forking, or re-submitting re-triggers injection. The file bytes are delivered to the model underneath — never pasted into your message bubble.
URLs
Write # before a URL anywhere in your prompt to fetch the page, extract it to markdown, and inject that — the same way #@ injects a file:
Summarize #example.com
What does #https://example.com/api return?
Diff #https://news.ycombinator.com vs #@local-notes.mdBoth #example.com (bare domain) and #https://example.com/api (full URL) work. On submit the page is fetched, the boilerplate is stripped, and the main content is converted to markdown by defuddle before it reaches the model. Each URL renders as a green read <url> line — identical to the read tool and to #@file — and ctrl+o expands it to the extracted markdown. The # trigger stays in your message exactly as you typed it, so cancelling and re-opening, forking, or re-submitting re-fetches the page.
Before / after:
# you type:
Summarize #example.com
# you see (green line, ctrl+o to expand):
read https://example.com (ctrl+o to expand)
# the model receives:
<file name="https://example.com">
# Page Title
…extracted markdown body…
</file>By content type:
- HTML pages → extracted to markdown (boilerplate/nav/scripts stripped) and injected.
- Raw text, JSON, XML, RSS, Atom → injected verbatim (no extraction).
- Images (
#https://example.com/cat.png) → attached as an image, same as#@image. An image URL whose body comes back empty (0 bytes) attaches nothing — it delivers the same "empty image file — 0 bytes" note a 0-byte local image does. - Anything else (e.g. a PDF, unknown content type) → left as written; nothing is injected.
The extraction libraries (defuddle and friends) are bundled with the package — you install nothing beyond pi install. See Limits for the fetch timeout, size cap, and the no-paging / no-caching behavior. # is disjoint from #@, so #@file.txt and #https://example.com in the same prompt both work.
Markdown files can import other files. If spec.md itself contains #@api.md, a single #@spec.md delivers both — spec.md first, then api.md. The import marker stays in spec.md verbatim (same as a top-level marker):
#@spec.md # spec.md contains: see #@api.mdPath completion works in the editor. Type #@ and the same file list Pi shows for @ appears; Tab completes it as #@<path>.
Bare @ is unchanged, so Review @a.ts behaves as before.
What gets injected
| File | Result |
|---|---|
| Text (.ts, .md, .json, .log, etc.) | Entire contents injected when they fit remaining context. Oversize files are delivered as a head block plus a paging directive — the model reads the rest via the read tool. Never silently truncated. |
| Image (.png, .jpg, .jpeg, .gif, .webp, .bmp) | Attached as an image. |
| Other binary | Not injected. A short note says it was skipped. |
| Missing file, directory, or permission error | Left as written. Nothing is injected. |
A delivered markdown file (.md or .markdown) is also scanned for relative #@ imports. Each import it references is delivered as its own block, and is scanned in turn if it is also markdown — so a single #@spec.md can pull in a whole tree of docs. The same file-type rules (text / image / binary / missing) apply to each import unchanged.
Text uses Pi's native block format, the same one @file uses:
<file name="/abs/path/to/file.ts">
<entire file contents>
</file>That's what the model receives. You won't see it as raw text in the chat — each injected file renders as a green read <path> line (just like the read tool), with ctrl+o to expand. Your own message shows exactly what you typed — including the #@ markers — so re-opening or forking re-triggers injection automatically.
Images are matched by their real bytes, not just the extension. A text file renamed fake.png is injected as text, not attached as a broken image. The check cuts both ways: a real image saved with the wrong extension — a PNG named photo.jpg, say — is not attached, because its bytes don't match the .jpg signature; it's delivered as a binary note instead. Rename it to its real type to attach it. An empty (0-byte) image attaches nothing.
Syntax
#@<path> is the trigger #@ followed by a path. The path is a run of non-whitespace characters.
Where it matches: at the start of the prompt, or right after a non-word character (a space, (, [, >, etc.). It does not match mid-word, so foo#@bar injects nothing. This holds in any language: café#@x, Öster#@x, and 日本語#@x inject nothing.
Line range. #@a.ts:10 delivers only line 10. #@a.ts:10-15 delivers lines 10–15 inclusive. The collapsed read line shows the range that was actually delivered: read a.ts:10 or read a.ts:10-15 — and read a.ts:2-5 for #@a.ts:2-100000 on a 5-line file, since an end past the file clamps to the last line.
A closed range that exceeds the remaining context budget is delivered as a head slice plus a paging directive, exactly like a whole file (no paging past the selection unless the budget demands it) — the directive resumes reading at the file's absolute line, so the model continues exactly where the head slice stopped. Images/binaries ignore :N / :N-M and dedup on the path alone: #@pic.png #@pic.png:3 attaches the image once — identical bytes are never delivered twice.
Different ranges of the same file each inject: #@a.ts:10 #@a.ts:20 is two blocks; #@a.ts:10 #@a.ts is the slice plus the whole file.
The same path+range still collapses to one (#@a.ts:10 #@a.ts:10).
A malformed range — :0, or :5-3 (end before start) — injects nothing: the token is left exactly as you typed it and a one-line warning explains why, both in your prompt and inside a delivered markdown file. A start past the end of the file fails the same way — the token stays verbatim, the warning names the file's line count, and you never get an empty <file> block.
Trailing punctuation is trimmed. #@a.ts. resolves to a.ts. #@a.ts:10. resolves to line 10 of a.ts. (#@a.txt) resolves to a.txt. Trimmed characters:
. , ; : ! ? " ' ) ] } >Extensions are exact. A reference that already ends in a file extension is matched by that exact name. A missing #@report.md.bak is left as written — it never silently resolves to an existing report.md, so the model never receives a different file than the one you named. (Markdown formatting glued to a name is different: emphasis like *@b.md* or **@b.md** is still trimmed, so the file resolves.)
Paths: relative (against your current directory), absolute (#@/etc/hosts), tilde (#@~/notes.md), and ../ all work.
Markdown imports: a #@ inside a delivered .md or .markdown file is itself an import, using the same grammar. Five rules narrow it:
- Relative paths only. Imports resolve against the markdown file's own directory, not your current directory. Absolute (
#@/etc/hosts) and tilde (#@~/notes.md) imports inside a markdown file are ignored and left verbatim. If the same name exists in both the importing file's directory and your current directory, the importing file's directory wins — so#@file2.mdinsidedir/otherdir/some/file.mdresolves todir/otherdir/some/file2.md, never./file2.md. This holds at every nesting depth; your current directory is never consulted for an in-file import. - Extension shorthand. A markdown import may omit the
.md/.markdownextension:#@PRDresolves toPRD.md(thenPRD.markdown) when no barePRDexists. Exact match wins (a barereadmebeatsreadme.md), and a token already ending in any extension is left as-is (so#@PRD.mdnever becomesPRD.md.md). This is a markdown-import convenience only — at the prompt you type the full name. - Code is the escape hatch. A
#@inside a fenced or inline code span is not an import — it stays verbatim. So a doc can show`#@example.ts`as an example without importing anything. Fenced-code detection is line-ending agnostic — Windows (CRLF) and Unix (LF) markdown files detect code fences identically. - Each path+range is injected at most once.
#@a.tstwice is one block;#@a.ts:10and#@a.ts:20are two. Shared dependencies and cycles still terminate. - Shared budget. Imports draw on the same context budget as the top-level prompt. When the running total exceeds the window, later files page (head block plus a
read-tool directive) instead of overflow.
Optional: bare-@ markdown imports
Off by default — #@ works with no config at all, and stays the only thing that ever triggers injection at the prompt. This is the one opt-in.
If your docs already reference files as a bare @file.md (no #), you can make a delivered markdown file treat that the same way as #@file.md. Set markdownBareAtImports to true. It can live in either of two forms — a dedicated file, or under the fileInjector key inside Pi's own settings.json, alongside your other Pi settings:
// ~/.pi/agent/file-injector.json — dedicated file
{ "markdownBareAtImports": true }// or, inside ~/.pi/agent/settings.json — namespaced key
{
"defaultModel": "anthropic/claude-sonnet-4",
"fileInjector": { "markdownBareAtImports": true }
}Both forms are read from a global and a project location and shallow-merged in this order (each later one wins; within the same scope the dedicated file beats the settings.json key):
- Global
~/.pi/agent/settings.json→fileInjector - Global
~/.pi/agent/file-injector.json - Project
.pi/settings.json→fileInjector— trusted project only - Project
.pi/file-injector.json— trusted project only
The project sources are honored only in a trusted project, so an untrusted checkout can't turn it on. (settings.json is open-schema, so Pi preserves the fileInjector key through /settings edits.)
When it's on, a bare @api.md inside a delivered markdown file imports exactly like #@api.md: relative-only paths, extension shorthand, code-exempt, deduped against everything else, and drawing on the same shared budget. #@ keeps working unchanged and is never matched twice — a #@api.md is one import, not two. A missing or malformed source (or one that doesn't set the key) leaves everything at the default, so it never errors. This is uniform at every depth: the first file a top-level #@ token pulls in is not special-cased — its bare @ imports are honored exactly like those in files deeper in the chain.
It affects markdown content only — a bare @path you type in your prompt is never injected. See Limits.
URLs: enableUrls (network egress)
URL injection is on by default. Set enableUrls to false to disable all network egress — every #<url> token is then left verbatim and no request is made (the air-gapped opt-out):
// ~/.pi/agent/settings.json — namespaced key
{
"fileInjector": { "enableUrls": false }
}// or, ~/.pi/agent/file-injector.json — dedicated file
{ "enableUrls": false }enableUrls is read from the same four sources and precedence as markdownBareAtImports (above): global settings.json → global file-injector.json → project settings.json → project file-injector.json (project sources honored only in a trusted project). It is read once when a session starts and cached for that session. The default is true, so #example.com works with no configuration at all.
Limits
- No size knob.
#@has no user-facing size setting. Oversize text files are delivered as a head block (first ~8 KB) plus a paging directive; the model reads the rest via thereadtool. The model never holds a file larger than its context window all at once — that is a property of the medium, not of this extension. - No spaces in paths. A space ends the path, so
#@my file.txtinjects a file namedmy. Use thereadtool for files with spaces. - No directories.
#@src/is left as-is. Use areadorlstool. - No globs.
#@src/*.tsis a literal path, not a pattern. It resolves only if a file named*.tsexists. - A backtick right after
#@blocks it. Inside a code span like`#@a.ts`, nothing is injected. To suppress#@anywhere, write# @with a space. - Markdown imports are relative-only. A
#@inside a.md/.markdownfile that points at an absolute or tilde path is ignored, never resolved. - Only markdown is scanned. A
#@inside an injected.ts,.json, image, or any other non-markdown file is inert — only.md/.markdownpull in further files. - Bare-
@imports stay inside markdown. Even withmarkdownBareAtImportson, a bare@pathin your prompt is never injected — the setting only changes what a delivered markdown file pulls in.#@remains the sole prompt-level trigger. - No autocomplete for in-file imports. The
#@path completer runs in the editor prompt only. Import directives live inside markdown files (written by hand), where your editor's normal file completion applies. - URLs: 20 s fetch timeout. A page that doesn't respond in 20 seconds is left as written.
- URLs: 1 MB response cap. A page whose body exceeds 1 MB is left as written (not paged).
- URLs: no caching. Every injection fetches fresh — cancelling and re-opening, forking, or re-submitting re-fetches the page.
- URLs: JS-rendered pages fall back to verbatim. Extraction works on server-delivered HTML only. A single-page app that loads its content with JavaScript usually yields too little to extract, so the
#<url>token is left as a reference (with a short notice) instead. - URLs never page. Unlike
#@file(which pages oversize files through thereadtool), an over-budget URL is left verbatim — thereadtool can't fetch a URL. - URLs need a dotted, alphabetic hostname. A
#<url>token must be anhttp(s)://orftp://URL or a bare host whose final label is 2+ letters (e.g.example.com,api.example.co.uk). This rejects#3.14,#v1.2,#fffand other token-like text. In a coding agent a bare#word.extis almost always a file reference rather than a website, so a scheme-less token with no path whose final label is a common code/file extension —#main.go,#notes.md,#config.json,#node.js, … — is treated as a file reference and left as written (no fetch, nothing injected). A slash makes it a real domain plus a path, so#example.com/img.pngis still fetched; and an explicit scheme bypasses the check entirely, so to fetch a domain whose TLD collides with a code extension, write#https://foo.shrather than#foo.sh. As a side effect, raw IP addresses andlocalhostare not detected as URLs —#127.0.0.1:8080,#localhost:3000/api, and even#http://127.0.0.1are left verbatim with no fetch and no error. To inject a local dev server, give it a resolvable hostname (an/etc/hostsalias, a*.localname, or a real domain) rather than an IP orlocalhost.ftp://is recognized but never injects: Node'sfetchcannot retrieve ftp, so the fetch is genuinely attempted, fails, and the token is left as written (the loading indicator may flash briefly).
#@ versus `@
#@fileinjects the whole file, always, everywhere.@fileis Pi's built-in autocomplete and command-line argument handling. This extension does not change it.
Use #@ when you want all of a file. Use @ or the read tool when you want to browse or search without loading the whole file.
