Release / release (push) Successful in 6s
The per-session scope the host half now reads had no writer. This is it: a crosshair on the composer tool row, present only in sessions running the facet preset, opening a panel that sets the focus and the paths allowed outside it. The gate is the session's own `agentPreset` projection rather than anything inferred from the URL or from the plugin being installed, so a control for a plugin that is not running never appears. It writes through the settings document — the same one the plugin card edits — because `settings.mutate` accepts a path under a volatile node and refuses anything else. That is what makes the split real rather than aspirational: the agent has no write path to `scopes`, and the panel's write is the only one that can widen a session. The panel also carries the mode details and the usage notes, which the preset picker's guide dialog cannot show for a preset no core package ships — its guide table holds four shipped ids and is looked up only for built-ins. Also adds `@deepseek-ai/dsh-client-ui-conversation` to the client half's inject list: registering into `conversation.input.left` is a dependency on the package that declares it, and `slots.inject` waits for a slot rather than creating one, so naming another package's slot without loading it first registers nothing — silently, because nothing errors.
334 lines
16 KiB
Markdown
334 lines
16 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: [] # optional: a seed of paths always in scope, every session
|
|
```
|
|
|
|
Both 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.
|
|
|
|
### Per-session scope, and who gets to decide it
|
|
|
|
Scope has two layers, and the split is the point:
|
|
|
|
| layer | what it is | who can write it |
|
|
|---|---|---|
|
|
| **the user's declaration** | `scopes[<session id>]` in this row's config — a `focus` and a list of `extras` | only the context panel, through the settings service |
|
|
| **the agent's** | a narrowing `focus` and any session grants, held in memory | the agent, and only ever to *reduce* |
|
|
|
|
The effective root is the agent's narrowing while it still sits inside your root,
|
|
and your root otherwise. So an agent that goes wrong cannot widen its own scope,
|
|
and `facet action=reset` returns it to what you declared rather than to the
|
|
session directory — landing on the directory would have made `reset` a widening.
|
|
|
|
You set the declaration from the **crosshair button on the composer tool row**,
|
|
which appears only in sessions running the `facet` preset. It shows the focus and
|
|
the allowed paths, adds and removes them, and carries the mode details and usage
|
|
notes that the preset picker's guide dialog cannot show for a preset no core
|
|
package ships. Writes go to the profile patch, so reopening a chat gives back the
|
|
scope it had.
|
|
|
|
Because the panel owns widening, `facet action=add` is the *other* path and the
|
|
narrower one: it is gated on an approval channel, and a deployment with none — the
|
|
`danger-full-access` permission preset sets `approval: never` — refuses it before
|
|
anyone is asked. In that deployment the panel is the only way to widen, which is
|
|
exactly what the binding tells the agent.
|
|
|
|
`extraRoots` is only a seed now. Before per-session scope it was the *only* way to
|
|
widen anything, which meant "this conversation needs the entity folder" had to be
|
|
written as a fact about every conversation in every workspace. It is for paths a
|
|
deployment genuinely always wants, and it is empty by default.
|
|
|
|
## 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:
|
|
|
|
- 73 tests (`npm test`) over the scope algebra, the policy state, the guard, and
|
|
the browser half — 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, that a live `mode` edit decides the next call
|
|
without the guard being remounted, that an agent narrowing the user has since
|
|
narrowed past is dropped rather than honoured, that `reset` lands on the
|
|
declared scope rather than the session directory, 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
|
|
client/client.js browser half: the settings card, and the context panel
|
|
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/ 73 tests
|
|
```
|
|
|
|
`client/client.js` is a browser half rather than a module the host imports, and
|
|
it is not optional decoration. The Plugins page does not build a card from a
|
|
`Config` schema — it draws only what browser halves register into the slots it
|
|
declares. `volatile()` on the host schema makes the namespace *servable*
|
|
(`settings.describe()` drops an entry with none, and the card registers only
|
|
while the namespace is served), but a schema alone puts nothing on the page.
|
|
|
|
The two keys in that file are deliberately different strings, and that is the
|
|
easy mistake to make here: the settings **document** is filed under the profile
|
|
entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the
|
|
**card** is keyed by the package name (`dsh-facet`). Every sibling plugin
|
|
happens to name them identically, so binding the form to the wrong one produces
|
|
a card that renders, saves, and writes nowhere. `test/client.test.js` pins both.
|
|
|
|
The same file carries the context panel, registered into
|
|
`conversation.input.left` — the composer tool row — and gated on the session's own
|
|
`agentPreset` projection, so it appears only where the facet is actually in force.
|
|
That gate is why `scopes` is `volatile()` in the host schema: `settings.mutate`
|
|
refuses a path that is not under a volatile node, and refusing is exactly right —
|
|
the panel is the only writer, and a plugin that could not serve the namespace
|
|
would be a control that silently stored nothing.
|
|
|
|
## 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.
|