Release / release (push) Successful in 6s
The extras control accepts a file — the field and its label said so — and the listing under it could not show one, because the Host primitive behind the picker browses folders and nothing else. So a grant for a single file meant typing its name exactly, which is the one thing a listing exists to avoid. The Host has a second lister and it does return files: `remote.workspaceFiles.list`, the same listing behind the sidebar's own file tree, confined to the session's workspace. The extras picker now takes that one while the level is inside the session folder — which is where an extra usually is — and falls back to the folder lister above it, saying so in the dialog rather than letting file rows vanish. The focus picker never offers files: a focus is a folder, so a file row there would offer something that cannot be chosen. The two listings normalise into one level shape, so the picker does not care which answered, and a row now carries its type: a folder is a way down, a file is a thing to take. Clicking a file fills the path field and marks the row, and the footer adds it in declared form. Pressing Enter on a name the level already lists is no longer reported as a failure — the path is exactly what the dialog is for. Everything stays additive: with no workspace file service, or above the session folder, the picker lists folders exactly as before.
413 lines
22 KiB
Markdown
413 lines
22 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.
|
|
|
|
**A tool that names no path is judged on the path it would use.** `grep`, `glob`
|
|
and `recall` all default to a directory when the model omits one — the session
|
|
directory for the first two, a marker-chosen one for the third — and on a session
|
|
whose facet is narrower than its directory, that default reads outside the facet.
|
|
An absent argument is not an absent path, so those three are judged against the
|
|
default they would have searched, and the refusal says which argument to add.
|
|
`lib/scope.js` holds the list; a tool with no path argument at all is still left
|
|
alone, because it has no default that could leave.
|
|
|
|
### 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.
|
|
|
|
**A session spawned or forked from a scoped one inherits its root.** Your
|
|
declaration is keyed by session id, and a subagent or a fork has none of its own,
|
|
so without this its facet would be its own working directory — one level up, and
|
|
a way to delegate around the scope rather than work inside it. The inherited root
|
|
already includes the parent's own narrowing, so delegating is not a way back out
|
|
of a focus the agent set for itself either, and it is recorded per level, so it
|
|
holds at any depth of delegation. It applies only where you made no statement:
|
|
a declaration for the child session is the more specific answer and stands.
|
|
|
|
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.
|
|
|
|
Both controls are a **directory listing**, not a text box: the panel's vocabulary
|
|
is paths relative to the session folder, and "which folder is `../shared`" is the
|
|
one question free text cannot answer. Pressing it opens a second dialog over the
|
|
panel, shaped like the host's own workspace browser — one level of children, a
|
|
breadcrumb trail, a hidden-entries toggle, and a file-type glyph per row. A pick
|
|
is stored in the form that keeps its meaning: `/work/root/src/shared` becomes
|
|
`src/shared` when the session started in `/work/root`.
|
|
|
|
The listing is driven by **two Host listers**, because they answer different
|
|
questions and the extras need both:
|
|
|
|
| lister | returns | reach |
|
|
|---|---|---|
|
|
| `uiWorkspace.listDirectory` | folders | anywhere on the machine |
|
|
| `remote.workspaceFiles.list` | files and folders | the session's workspace only |
|
|
|
|
The extras picker takes the file listing while the level is inside the session
|
|
folder, which is how a file is chosen by clicking its name instead of typing it —
|
|
the same listing behind the sidebar's own file tree, applied one level at a time.
|
|
Above the session folder the Host confines it, and the level falls back to folders
|
|
and says so in the dialog: a file outside the workspace is still added by typing
|
|
its path, which is why the action reads **Add path or file** and its field **Path
|
|
or file**. The focus picker never offers files at all — a focus is a folder, so a
|
|
file row there would offer something that cannot be chosen.
|
|
|
|
A folder row is a way down; a file row is a thing to take. Clicking a file fills
|
|
the path field and marks the row, and the footer's action then adds it in declared
|
|
form; pressing Enter on a name the level already lists is not an error, because
|
|
the path is exactly what this dialog is for.
|
|
|
|
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. It is not a theoretical hole: the first deployment to run this had
|
|
an agent scoped to one module use a shell command to parse a config file
|
|
outside its facet, having been told in the same prompt not to. A prompt is not
|
|
a guard, which is what the rest of this plugin is for.
|
|
- **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:
|
|
|
|
- 93 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, that a search naming no path
|
|
is refused when the directory it defaults to is outside the facet, that a
|
|
delegated session inherits its parent's root at any depth and is not a way back
|
|
out of a self-narrowing, that both listings normalise into one picker level and
|
|
that a file listing the Host confines still leaves a browsable one, 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.
|
|
|
|
**Verified live.** The bundle is installed in a `web` profile and composing
|
|
sessions: the runtime binding is in the running session's context, the crosshair
|
|
panel writes the declaration, and the guard decides the calls it names. That is
|
|
also where the `bash` boundary above was confirmed the hard way. The first thing
|
|
to check after any change is still 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/ 93 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.
|
|
|
|
The listings reach the panel through `ctx.get` rather than through an injection —
|
|
`uiWorkspace` for folders and `remote.workspaceFiles` for files — and each
|
|
`available()` is asked on every render. Both are deliberate: `ctx.inject` would
|
|
make the whole context button conditional on services the plugin does not own, and
|
|
the panel is still the only way to declare scope in a deployment with no directory
|
|
browser — it would simply have no listing to offer, which is what it says instead.
|
|
The services are resolved at the call because the renderer memoizes a
|
|
registration's inject result, so a boolean read there would freeze at whatever the
|
|
first render saw. Both packages are named in this bundle's `dsh.client.inject`, so
|
|
a profile that installs this one gets the services its dialog asks for.
|
|
|
|
The two dialogs are wider than the primitive's 380px card, which is fixed in the
|
|
primitives' own stylesheet. `client/client.js` installs one small stylesheet of
|
|
its own — the widths and a row hover — and passes each card a class through
|
|
`Modal`'s `className`. Every selector in it doubles the class it names, so it
|
|
outranks the single hashed class the primitive applies whatever order the two
|
|
stylesheets land in; there is no `!important` and nothing reaching into a class
|
|
name this bundle does not own. The panel's body is a two-column grid that
|
|
`auto-fit` collapses to one on a narrow card, and each dialog scrolls a bounded
|
|
body rather than growing past the viewport — the panel used to be a tall single
|
|
column of prose on a 380px card, which is what it looked like.
|
|
|
|
## 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.
|