`mode` and `extraRoots` carried no `volatile()` marker, so the framework's
form projection dropped the whole entry from `settings.describe` and the plugin
had no card under Plugins at all. The marker is what creates the card.
It is also what turns each field into a reference rather than a value: the
resolved config hands `apply` a `{get(), [write]}` per volatile field, which is
how a settings write lands in a running plugin without re-applying it. So the
policy and the guard now read both fields at the point of use instead of
snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked
because a prompt section cannot be re-ordered without a remount.
A section is also registered once as a plain string, so the rule could not
follow a live `mode` without freezing at load. The mode-specific sentence moved
into the binding, which is a callback.
283 lines
13 KiB
Markdown
283 lines
13 KiB
Markdown
# dsh-facet
|
|
|
|
Scope an agent session to **one facet of a system** — the folder the
|
|
conversation was started in, plus whatever you explicitly grant it.
|
|
|
|
A DeepSeek Harness bundle: one host row (the policy) and one agent preset (the
|
|
enforcement).
|
|
|
|
---
|
|
|
|
## Why this exists
|
|
|
|
A multi-faceted system is built from modules that look alike. In the project this
|
|
was written against there are fourteen controllers — the same idioms, the same
|
|
naming, the same shape — and a conversation about one of them that can see all
|
|
fourteen spends its first several steps working out which one it is looking at.
|
|
That is a quality problem before it is a cost problem, and the cost is real too:
|
|
in a long session the dominant expense is re-reading accumulated history, and
|
|
most of that history is file content the agent did not need.
|
|
|
|
So this narrows the world to one module rather than trying to steer through all
|
|
of them. It is the same idea as the `focus` argument in
|
|
[`dsh-recall`](https://github.com/green-dalii/dsh-recall) — which confines a
|
|
*search* to one module — applied to the whole agent: what it may read, where it
|
|
may search, and what its prompt says it is.
|
|
|
|
## What it does
|
|
|
|
Three things, and they are deliberately in three different places:
|
|
|
|
| | where | why there |
|
|
|---|---|---|
|
|
| **the rule** ("work inside the facet; ask to go outside") | system prompt | invariant for the session, so it does not invalidate the prompt cache |
|
|
| **the binding** (which folder, what is granted) | runtime-context snapshot | it changes when you grant something, and a changed system prompt costs a full-context cache miss |
|
|
| **the guard** | `tools/pre-execute` | the part that does not depend on the model agreeing |
|
|
|
|
**The scope is not configured — it is where you already are.** A session's
|
|
working directory is recorded immutably when the session is created, so starting
|
|
a conversation in `src/controllers/room/occupancy` makes that controller the
|
|
facet, and starting one at the project root simply leaves the session unscoped
|
|
(which is the honest answer for a conversation that is genuinely about
|
|
everything).
|
|
|
|
**Going outside is a question, not a refusal.** In that same project 41.6% of the
|
|
code — shared types, services, infrastructure — sits outside every controller. A
|
|
wall that cannot be opened would not confine the agent, it would only make it
|
|
wrong, and an agent that cannot reach a shared type does not stop needing it.
|
|
So an out-of-scope file call is put to you, and saying yes records the grant for
|
|
the rest of the conversation.
|
|
|
|
### The `facet` tool
|
|
|
|
| action | effect |
|
|
|---|---|
|
|
| `status` | the root, the mode, the standing grants and this session's grants |
|
|
| `add` | asks you to approve an extra path, then records it for this session |
|
|
| `remove` | drops a session grant |
|
|
| `reset` | returns to the session's own folder |
|
|
|
|
`add` is the only one that needs approval. It is also asked in `deny` mode,
|
|
because otherwise there would be no way to grant anything at all.
|
|
|
|
## Install
|
|
|
|
From the repository, following releases:
|
|
|
|
```sh
|
|
dsh plugin --profile web add "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet.git#semver:^0.1.0"
|
|
dsh --profile web --dump-config | grep preset-facet # expect the row
|
|
```
|
|
|
|
Or from a checkout, while working on the plugin itself:
|
|
|
|
```sh
|
|
dsh plugin --profile web add "/path/to/dsh-facet"
|
|
```
|
|
|
|
The `#semver:^0.1.0` fragment is the difference between an install that can be
|
|
updated and one that cannot. Leave it off and pnpm resolves the default branch to
|
|
a **commit** and records that commit in `pnpm-lock.yaml` — `dsh plugin update`
|
|
then has no version to move to, because there is nothing to move between. With
|
|
the fragment, pnpm resolves against the repository's tags, which the release
|
|
workflow creates on every push to `master`. The fragment needs a tag to resolve
|
|
against, so it only works once the first release has been published.
|
|
|
|
Either way the command is `pnpm add` run inside the profile, and it registers the
|
|
bundle in `dsh.profile.bundles` for you. A git-hosted plugin that builds on
|
|
install can be blocked by pnpm's build policy; add the key pnpm asks for under
|
|
`allowBuilds` in the profile's `pnpm-workspace.yaml`. This bundle ships sources
|
|
with no build step, so it does not trip that.
|
|
|
|
The bundle contributes two layers:
|
|
|
|
- **`cordis.patch.yml`** — the host row `facet-policy`, which owns the mode and
|
|
the standing grants and publishes them as `ctx.facetPolicy`.
|
|
- **`presets/facet.patch.yml`** — the agent preset `facet`, which is the shipped
|
|
`standard` composition plus the guard and a configured compaction row.
|
|
|
|
Then pick **Facet** as the preset for a session — in the Web UI that is the
|
|
preset picker in the new-session surface, or *Settings → General → Agent preset*
|
|
to make it the default for new sessions. The preset is fixed once a session's
|
|
first turn has run, so choose it when you start the conversation.
|
|
|
|
### Configure it in your profile, not in the package
|
|
|
|
The host row's config is where this deployment's facts belong — the profile's own
|
|
`cordis.patch.yml`:
|
|
|
|
```yaml
|
|
- id: facet-policy
|
|
name: dsh-facet
|
|
config:
|
|
mode: ask # ask | deny | off
|
|
extraRoots: # always in scope, in every session
|
|
- ../../shared # relative to each session's own directory
|
|
```
|
|
|
|
Both fields are also on the plugin's card under **Plugins** in the Web UI, which
|
|
is the same layer reached a different way: a settings write rewrites this entry in
|
|
the profile patch rather than storing anything new. An edit there is live — the
|
|
guard reads `mode` and `extraRoots` on every call, so a change decides the next
|
|
file call without a restart.
|
|
|
|
The one exception is `off`. Choosing it while a session is running stops the guard
|
|
immediately and empties the binding on the next request, because the binding is a
|
|
callback. The rule is not: it was registered at load as a plain string, and
|
|
nothing unregisters it, so the prompt would go on stating a rule that nothing
|
|
enforces. An `off` *deployment* is therefore settled at load — set it in the
|
|
patch. `ask` and `deny` swap freely in both directions.
|
|
|
|
`sectionOrder` and `contextOrder` are deliberately absent from the card. They
|
|
place the rule and the binding among the other prompt sections, which is decided
|
|
once at load, and no section is re-ordered without a remount.
|
|
|
|
`extraRoots` is for code a module legitimately always depends on. Anything you
|
|
grant by hand is per-conversation and is never persisted — "this session may
|
|
also see `src/shared`" is a statement about one conversation, and writing it down
|
|
would quietly widen every future one, which is the failure this plugin exists to
|
|
prevent.
|
|
|
|
## The compaction row
|
|
|
|
`presets/facet.patch.yml` also configures `compaction-basic`, because a scoped
|
|
session and a budgeted context are two halves of one idea — scoping reduces
|
|
context at the source, compaction is the ceiling:
|
|
|
|
```yaml
|
|
thresholdRatio: 0.35 # a 350k ceiling, not the shipped 800k
|
|
retainTokens: 80000 # an absolute working set, not 16% of a 1M window
|
|
maxTokens: 16384 # the summarisation cap, stated rather than inherited
|
|
```
|
|
|
|
The shipped ratio is `0.80` of a 1,000,000-token window, i.e. 800k. Measured
|
|
across this deployment's 40 recorded sessions, the deepest ever reached 787,668
|
|
pressure tokens — **the trigger had never once fired**, so every token below it
|
|
was re-read on every subsequent step. `maxTokens` is worth stating explicitly:
|
|
its default is not 8192 but `headroomTokens`, 65536.
|
|
|
|
If you want that budget on *every* session rather than only scoped ones,
|
|
`presets/standard-compaction.patch.yml` is an opt-in overlay that applies the
|
|
same row to the shipped `standard` preset. It is not in the bundle's default
|
|
patch list, and the header of that file explains why: a preset declaration
|
|
replaces the whole plugin list rather than merging into it, so the file is a
|
|
frozen copy of `standard` that will not follow the harness when `standard`
|
|
changes.
|
|
|
|
## What it does not cover
|
|
|
|
Honest boundaries, because each one is a decision rather than an oversight:
|
|
|
|
- **`bash` is not guarded.** A shell command cannot be judged reliably from
|
|
argv, so the facet is enforced on the file tools and `bash` remains a
|
|
legitimate way out. The prompt tells the model this rather than pretending
|
|
otherwise.
|
|
- **Tools that name a path in a shape the table cannot read are not guessed at** —
|
|
including MCP tools. See `PATH_ARGUMENTS` in `lib/scope.js`; it is a list of
|
|
tools whose path argument is a plain top-level string.
|
|
- **This guards the tool pipeline, not the filesystem.** There is no read seam in
|
|
the harness — `dsh-fs-sandbox` states plainly that "reads pass through
|
|
untouched: every mode permits reading" — so anything that reads through
|
|
`ctx.fs` without going through tool dispatch (another plugin, the HTTP
|
|
workspace-files endpoint) is unaffected. Fencing *all* readers would mean
|
|
replacing `ctx.fs` with a stricter `FileSystem` subclass.
|
|
- **A grant is not persisted**, so it does not survive a process restart.
|
|
- **Sessions at the project root are not scoped at all**, which is deliberate.
|
|
|
|
## What has been verified, and what has not
|
|
|
|
Verified:
|
|
|
|
- 45 tests (`npm test`) over the scope algebra, the policy state, and the guard —
|
|
including that containment is decided on canonical targets (a symlink inside
|
|
the facet pointing outside it is refused), that a prefix is not a parent
|
|
(`/a/room` does not contain `/a/roommate`), that a malformed tool call never
|
|
makes the guard throw, and that every `facet` result satisfies the tool's own
|
|
declared output schema.
|
|
- The bundle's patch layers parse, merge and land correctly against the real
|
|
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
|
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
|
the `facet-guard` row and the compaction config, with `preset-standard`
|
|
untouched. The optional `standard-compaction` overlay was checked the same way.
|
|
|
|
**Not yet verified: a live session.** The plugin has not been loaded by a running
|
|
harness, because that needs the bundle installed. The first thing to check after
|
|
installing is that `preset-facet` is not `broken` — a preset whose child row
|
|
fails to resolve still appears on the roster, with its diagnostic, and cannot
|
|
compose a session:
|
|
|
|
```sh
|
|
dsh --profile web --dump-config | grep -A 3 preset-facet
|
|
```
|
|
|
|
## Releases
|
|
|
|
`.gitea/workflows/release.yml` runs on every push to `master`: it works out the
|
|
range since the last tag, bumps `package.json`, prepends a `CHANGELOG.md`
|
|
section, commits and tags `vX.Y.Z`, then creates the Gitea release with the
|
|
commit subjects as the body.
|
|
|
|
The bug it fixes is not tidiness. Installed as a bare `git+https://…/repo.git`,
|
|
a plugin is pinned to a **commit** in the profile's lockfile, so with no tags
|
|
there is nothing for `dsh plugin update` to move to. Point the profile at
|
|
`#semver:^0.1.0` and the updates follow releases instead:
|
|
|
|
```jsonc
|
|
"dsh-facet": "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet.git#semver:^0.1.0"
|
|
```
|
|
|
|
The bump is read from the commit subjects being released:
|
|
|
|
| in a commit subject | effect |
|
|
|---|---|
|
|
| `feat`, as its own word (`feat:`, `feat(x):`) | minor — `0.1.0` → `0.2.0` |
|
|
| `feat!:` / `fix(x)!:` / `BREAKING` | major — `0.1.0` → `1.0.0` |
|
|
| `fix:`, `docs:`, prose, anything else | patch — `0.1.0` → `0.1.1` |
|
|
| `[minor]` / `[major]` | force the bump when the subject cannot carry it |
|
|
| `[skip release]` | this push publishes nothing |
|
|
|
|
Only the **subject** line is read, and `feat` has to be a word — `Fix the feature
|
|
flag leak` is a patch, not a minor. With no tags yet, the version already in
|
|
`package.json` is what gets tagged, so the first release matches the code.
|
|
|
|
The logic lives in `.gitea/scripts/release.sh` so it runs without a runner:
|
|
`npm run test:release` exercises it — an empty range, a repository with no tags
|
|
yet, a `feat` that is only a word — `npm run release:plan` says whether the
|
|
current HEAD would release, and `npm run release:notes` prints the notes it
|
|
would publish.
|
|
|
|
## Development
|
|
|
|
```sh
|
|
node scripts/dev-links.mjs # link the harness's cordis/schemastery/dsh-tools
|
|
npm test
|
|
```
|
|
|
|
`dev-links.mjs` links rather than installs those three packages, and for
|
|
`@deepseek-ai/cordis` that is a correctness requirement rather than tidiness: a
|
|
second copy of Cordis means a second `Context` class, two service registries in
|
|
one process, and a plugin whose services are invisible to the harness that loaded
|
|
it.
|
|
|
|
## Layout
|
|
|
|
```
|
|
index.js host row: the policy, published as ctx.facetPolicy
|
|
lib/policy.js the stateful half — scope resolution, grants
|
|
lib/scope.js the pure half — argument extraction, text, reasons
|
|
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
|
presets/facet.patch.yml the `facet` agent preset
|
|
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
|
test/ 45 tests
|
|
```
|
|
|
|
## Also in this repository
|
|
|
|
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
|
including the harness mechanisms it rests on.
|
|
- [`compaction-tuning.md`](../compaction-tuning.md) — the investigation into why
|
|
compaction had never fired. Its measurements reproduce exactly; note that its
|
|
"The change" section prescribes `$DSH_HOME/.agent-presets/`, a directory
|
|
**nothing reads any more** on the installed harness. Presets are bundle patch
|
|
declarations now, which is what `presets/facet.patch.yml` is.
|