# dsh-prompt-file-injector project status ## Introduction A DSH prompt-file injection plugin: maintain a list of paths on the Plugins page and inject each file into the model context according to the modes chosen for that path. ## Overview The plugin has a host half and a client half. The host half's configuration is the entry's own `Config`, whose four fields are all declared volatile and projected by dsh's settings contract into the configuration form on the Plugins page; it also reads files against each session's working directory on session creation and registers its injection contributions. The client half registers that configuration area on this package's own card on the Plugins page (`plugins.bundle.config`), which edits paths and modes. Three modes: - System prompt: joins every system-prompt file of the session into one block and registers it as a fixed system prompt section. - Every turn: on each turn's first step, appends the full text of the current configuration and files near the end of the conversation. - AGENTS: merges the file names into the candidate list of the built-in `agent-instructions`, which then loads them the way it loads `AGENTS.md`. Configuration uses the two layers dsh settings provides: the static definition (the plugin's composition entry config, used as the base layer) and Web edits (the user layer written from the configuration area). The host reads the entry's volatile fields on demand, so a change takes effect for the next operation without remounting the plugin. ## todo ## Features ### Configuration area - Description: adds a configuration area to this plugin's own card on the Plugins page, holding the path list with the three modes per path, plus a reminder frequency (off / every turn / every step), an "Also inject every-turn content in minimal mode" toggle, and the per-file injection cap. - Implementation: [src/client/settings/index.ts](../../src/client/settings/index.ts), [src/client/settings/SettingsPage.tsx](../../src/client/settings/SettingsPage.tsx) - Solution: the client binds the entry configuration's form through `ctx.configForms.get('prompt-file-injector')` (the key is the profile entry id); page state arrives from the injected `hooks.pageState`, and writes go through the form's `set`, replacing `entries` wholesale or writing `reminder`, `everyRoundInMinimal`, and `maxFileBytes`. It registers through `ctx.slots.inject('plugins.bundle.config', ...)`, whose `key` is the package name `dsh-prompt-file-injector` — the Host dispatches on `pkg.name`. - Approach: the page does not explain the modes itself; the question mark beside each item carries what the mode does, why it exists, and what it costs. The three-way reminder uses a radio group, while the two-state minimal toggle uses `ui-primitives`' `Switch` — the same control the neighboring `ui-settings-plugins` uses. The three settings rows share one shape and one column: a hairline divider separates them (`0.5px` with `border-l2`, matching dsh settings' "Setting-Cell"), and the last row draws none. Two keys belong to two identity systems: the configuration area dispatches on `key` and takes the **package name**, while the form is fetched by namespace and takes the **entry id** (see "Entry id"). The `view === 'summary'` branch must exist to type-check even though this position only ever renders `page`. ### Input write timing - Description: text inputs (each path, and the per-file cap) write on blur or Enter and never on the keystroke; mode radios, the workspace choice, and add/remove write as soon as they are clicked. - Implementation: [src/client/settings/SettingsPage.tsx](../../src/client/settings/SettingsPage.tsx), [src/client/settings/index.ts](../../src/client/settings/index.ts) - Solution: the path list and the cap each hold a local draft, and a commit compares by value so an unchanged value writes nothing. The reminder frequency and the minimal toggle also land locally first, and their controls render from the local value. Every commit records one in-flight write and clears it on settlement; the Host's value is adopted only when no write is in flight and it differs from this side's baseline. Each row carries a locally minted identity used as React's `key` and as the radio group's `name`. - Approach: every accepted write makes the Host validate the whole Config and rewrite the profile's cordis patch on disk, and the write queue only preserves order without coalescing (`config-form.ts`), so a commit per keystroke magnifies one edit into dozens of serial requests and disk writes and, through the resulting snapshot updates, as many re-renders; a text input has nothing committable mid-typing, so waiting for blur drops those writes without losing meaning. A discrete click has no "typing" phase — one click is one definite change of intent — and so commits immediately. The comparison is by value rather than by reference because after a successful commit the Host's snapshot equals the draft, and rebuilding the draft there would overwrite what is being typed. A controlled control bound straight to the Host value renders the old value back first and shows the new one only once the write settles, so the discrete fields keep a local value too. Adopting the Host's value waits for the in-flight count to reach zero because one click often blurs the input first and then fires the click, queueing two writes where the earlier one to settle carries only the previous step; following it there would drop the UI back to the old value and jump forward again when the later write settles. The baseline is "the last write sent, or the last Host value adopted" rather than the current Host value, because while a write is in flight the Host value still sits at an earlier state and comparing against it would miss a "change back" edit. Row identities are minted locally and stay out of the configuration: adding one to the `entries` schema would drag in a migration for existing configurations, while the identity serves React's node reuse alone; a counter rather than a random uuid because the repository forbids calling `crypto.randomUUID` directly, and adding a dependency for a render key is not worth it. An index as `key` shifts wholesale when a middle row is deleted, and React then reuses DOM nodes by position, misaligning the next row's path and mode onto the row above. ### Entry id - Description: this plugin's profile entry id is `prompt-file-injector`, which is also the settings contract's namespace. - Implementation: [src/mode-rules.ts](../../src/mode-rules.ts), [src/index.ts](../../src/index.ts), [src/client/settings/index.ts](../../src/client/settings/index.ts), [cordis.patch.yml](../../cordis.patch.yml) - Solution: `PLUGIN_ENTRY_ID` is the single source for the constant; the `id` on that line of `cordis.patch.yml` is character-for-character the same, the host filters `settings/document-updated` by it, and the client fetches its form with it. - Approach: the settings form projects volatile fields keyed by the **entry id**, so the `id` on that profile row decides which namespace the configuration lives in and the plugin cannot open one of its own. The value sits in the Host/Client-shared `mode-rules.ts` because the configuration area needs it as the form key, and importing it from the host entry would drag `node:fs` into the browser artifact. ### Workspace scope - Description: in the System prompt and Every turn modes each path can name one workspace to limit where it applies, and clearing the choice removes the limit so every workspace injects it. The AGENTS mode cannot be limited — its file names go into the kernel's candidate list, which is one per preset for the whole process and has no per-session entry point — so in that mode the picker is disabled and shows "Any workspace". - Implementation: [src/types.ts](../../src/types.ts), [src/mode-rules.ts](../../src/mode-rules.ts), [src/index.ts](../../src/index.ts), [src/client/settings/SettingsPage.tsx](../../src/client/settings/SettingsPage.tsx) - Solution: an optional `workspaceId` on the entry carries the restriction; on the host, `workspaceIdForCwd` looks the id up in the workspace registry by matching a session's working directory against each workspace's path, and `entriesFor` filters on it (only the System prompt and Every turn modes reach it), with an unrestricted entry applying in every workspace. In the configuration area each entry's `WorkspacePicker` lists the workspaces through `ui-primitives`' `Menu` on the mode row beside the modes, left-aligned, where choosing "Any workspace" clears the key. The list's duplicate check reads `scopedWorkspaceId` from `mode-rules.ts` (always unrestricted for AGENTS), so two same-name AGENTS entries count as duplicates no matter what each stores. - Approach: the configuration stores the workspace id rather than the path — paths are rewritten by normalization while the id is a generated uuid that never changes. The lookup compares paths instead of calling `resolveByPath`: when a workspace initiates a session its cwd is that workspace's path and both are canonical, so a synchronous comparison suffices, whereas `resolveByPath` performs a `realpath` internally and is asynchronous, which would turn the whole synchronous `buildSystemText` chain asynchronous for no gain. The workspace list comes from `useWorkspaces`, a global seat that `plugins.bundle.config`'s root-scope props already include, so the page needs no data source of its own. The duplicate check accounts for scope: an unrestricted entry counts as a duplicate of any same-path, same-mode restricted entry, while two entries restricted to different workspaces do not overlap, comparing through `scopedWorkspaceId`'s effective value. A mode that cannot be limited disables the control rather than hiding it, so the row never gains and loses a control as the mode changes; switching modes writes back the effective scope, so switching to AGENTS clears the restriction and switching back cannot revive a value the page never displayed. ### System prompt mode - Description: joins the session's active files into one block injected as a fixed system prompt section that stays present. - Implementation: [src/index.ts](../../src/index.ts) - Solution: on `agent/created`, registers one `systemPrompt.section` through that agent's scoped context, with the text read once at registration time. A pair of `` tags wraps the block, which states its origin on the line below the opening tag and then lists "absolute path + full text" per file; both the opening and closing tag of this frame are escaped inside file content. The section is not registered when no file is active. Configuration changes and file edits do not affect sessions already established. - Approach: changing the system prompt mid-conversation invalidates the whole prefix cache after it, so the section is fixed only when a new session is created. The tag identifiers are fixed random strings rather than semantic names, so neither tag is a prefix of the other and the model cannot guess their meaning from the name — it has to read the origin line. ### Every turn mode - Description: puts file content back into the conversation near the end on every turn. - Implementation: [src/index.ts](../../src/index.ts) - Solution: the `agent/pre-step` listener appends one `snapshot`-sourced user message when `step === 1`, with its text rebuilt by `buildEveryRoundText` from the current configuration and files each time and wrapped in the every-turn's own `` tag. Nothing is appended when no file is active. When a persona owns the system prompt outright (minimal mode) nothing is appended by default, and the `everyRoundInMinimal` setting decides whether to append as usual. - Approach: a pre-step append enters the turn unconditionally, so the every-turn text reaches the end of the conversation through it; `step === 1` is agent-loop's first step after its per-turn reset. The every-turn tag and the system prompt section's are not prefixes of one another; the header declares only which of this block's copies is newest and does not restate the tag — a second opening tag inside the block would create an unpaired scope — and it avoids the word "supersede", which generalizes too easily into "other injections were superseded too". The header says nothing about what the system prompt mode injected: that belongs to the reminder setting, and the section is not registered at all when no system entry is active. Minimal mode's barrier is judged separately: there the prompt is owned by a `complete` persona while an every-turn injection travels through a pre-step append, never through prompt assembly, so `complete` cannot stop it; see "Prompt-ownership probe" for the decision. ### System prompt reminder - Description: adds one constant line near the end of the conversation that points attention back to what was injected into the system prompt; the frequency is one of off, every turn, or every step. - Implementation: [src/index.ts](../../src/index.ts), [src/client/settings/SettingsPage.tsx](../../src/client/settings/SettingsPage.tsx) - Solution: the `reminder` setting (`'off' | 'turn' | 'step'`, default `'turn'`). The reminder travels through an `agent/pre-step` append: `step` appends on every firing, `turn` appends only on each turn's first step (`step === 1`), and `off` appends nothing. It reminds only when the system prompt section actually appears in the final assembly, decided by the "Prompt-ownership probe". The line reads `Remember the block at the top before replying.` - Approach: `step === 1` is each turn's first step, and later steps continue the same turn after a tool call, so one injection point decides all three frequencies. The reminder only points and recalls and makes no claim about validity or scope: saying "still in effect" invites the model, when taking stock of the current rules, to ask why only this one is named and to treat everything unnamed as void — which is how it dropped both the system prompt section and the workspace instructions the kernel sends on change. It also does not name the plugin's scope: the tag is already unique and each block states its own origin on its first line. Whether to remind follows the assembly's actual result rather than the registration: a `complete` section (the `minimal` preset's persona) makes assembly replace the sections with itself, discarding this one. Without this check, minimal mode reminds about a block that does not exist. ### Prompt-ownership probe - Description: decides whether this session's system prompt is owned outright by another section, and whether this plugin's system prompt section actually reaches the model. - Implementation: [src/index.ts](../../src/index.ts) - Solution: `registerForAgent` always registers a probe section named `prompt-file-injector:probe` whose text is empty (empty text is filtered out by `renderPrompt`, so the model never sees it); `probePrompt` calls `agent.ctx.systemPrompt.assemble()` once and reads two facts from the returned sections: the probe's absence means the prompt is owned, and the system prompt section's presence means it truly reached the model. The result is cached per session. - Approach: the decision keys on the mechanism — ownership — rather than on a preset name, so every preset using a `complete` persona is covered. The `complete` replacement happens after the `system-prompt/assemble` waterfall, so a self-registered listener only sees the pre-replacement state and only `assemble()`'s return value is post-replacement. Both facts come from one assembly, so no second probe is needed. ### AGENTS mode - Description: adds the bare file names the user entered to the candidate list of the built-in `agent-instructions`. - Implementation: [src/agent-instructions.ts](../../src/agent-instructions.ts) - Solution: target entries are looked up in the host layer and in every agent preset subtree; the candidate list first seen for an entry becomes a union baseline, and the write is "baseline ∪ current user names" followed by `entry.update`. Repeated calls are idempotent. The plugin applies this on its own apply, on `agent/created`, on `agent-preset/selected`, and on the host's `settings/document-updated` filtered by this entry id. - Approach: the package does not export the default candidate names, so they are restated locally to avoid dropping the built-in defaults when the list is written explicitly. The triggers must cover both preset mount timings: a default preset is in place with the agent when the session is established, so `agent/created` sees it; a preset switch mounts the subtree after the session is established and records the choice afterwards, so the scan must run again on `agent-preset/selected` or the switched-to subtree never receives the candidate list. ### Per-file injection cap - Description: the configuration area accepts a byte cap per file (entered as KB); a larger file is skipped. - Implementation: [src/index.ts](../../src/index.ts), [src/client/settings/SettingsPage.tsx](../../src/client/settings/SettingsPage.tsx), [src/mode-rules.ts](../../src/mode-rules.ts) - Solution: the default is `65536` (64 KiB), aligned with the composition's `agent-instructions` `maxBytes`; the configuration still stores bytes while the interface presents and accepts KB. A read short-circuits on `statSync`, then reads in chunks through `openSync`/`readSync` and stops as soon as the running total passes the cap. - Approach: an injected file stays in the conversation for the whole session, so the cap exists to keep one file from crowding out the task; its value therefore matches what dsh already allows for all workspace instructions. ### Unreadable paths - Description: skips an entry whose path cannot be read. - Implementation: [src/index.ts](../../src/index.ts), [src/mode-rules.ts](../../src/mode-rules.ts) - Solution: a failed read of a resolved path returns undefined, injecting nothing and warning nobody; a relative path is likewise skipped when the session has no working directory, with no fallback to the process working directory. - Approach: one global configuration is reused across workspaces, and a missing file in one of them is ordinary. ### Configuration validation - Description: bad configuration fails loudly at the write, rather than being quietly accepted. - Implementation: [src/index.ts](../../src/index.ts) - Solution: the `maxFileBytes` schema is `z.number().step(1).min(1)`, constraining it to a whole number of at least 1. Every element field of `entries` is optional — that is the ordinary mid-edit state, and `usableEntry` skips such entries when reading. - Approach: a settings write always goes through schema resolution, so putting the constraint in the schema stops a bad value at the write itself rather than needing a separate runtime check in the plugin. Keeping `entries` element fields optional is deliberate: rejecting at write time would make the intermediate states of "add a path" and "pick a mode" unsavable, which shows up as a control that does nothing when clicked.