/** * dsh-facet — the host half. * * A facet session is confined to one module of a multi-faceted system: the * folder the conversation was started in, plus whatever the user has explicitly * granted. This row owns the *policy* — the enforcement mode, the standing * grants, and the live per-session grants — and publishes it as `ctx.facetPolicy`. * * **Why the policy is a host row and the enforcement is not.** The rule that * confines an agent has to hold per agent, so it is mounted inside the `facet` * agent preset; a guard registered on the host plane would confine every session * in the profile, including the ones deliberately working across the whole * system. The *configuration*, though, is a fact about this deployment — one * mode, one set of standing grants — and duplicating it into the preset would * mean two places to edit and two answers to the same question. So the policy is * published once here and consumed there, which is also why the preset can be * swapped without touching any of this. * * There is deliberately no `settings` *namespace* of our own: a facet grant is a * statement about one conversation, so the only thing worth persisting is the * deployment's standing grants, and those belong in the profile patch — the * layer where this machine's facts already live. What the framework projects * onto this row is the {@link Config} schema below, which is the same patch * layer reached through the Web UI rather than a second place to store things. */ import Schema from '@deepseek-ai/schemastery' import { createFacetPolicy } from './lib/policy.js' export const name = 'dsh-facet' /** * Every configuration field this plugin exposes. * * `mode`, `extraRoots` and `scopes` are marked `volatile()`, and that marker is * doing two jobs rather than one. It is what puts them on the settings page at * all — the framework's form projection keeps only fields under a volatile * ancestor, and drops an entry with no such field from `settings.describe` * entirely, so without it this row has no card. It is also what makes an edit * *live*: a volatile field is handed to {@link apply} as a reference rather than * a value, and the framework commits an edit into that reference without * re-applying the plugin. * * The second job is why every reader of these fields goes through * `configValue()` in `./lib/policy.js` and reads at the point of use. A plugin * that takes the reference for a string gets a settings card that appears to * work and changes nothing. * * `scopes` is volatile for a third reason: it is the only field the browser * context panel writes, and `settings.mutate` refuses a path that is not under a * volatile node. That is the whole mechanism by which the panel — and not the * agent — decides what one conversation may see. * * `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place * the rule and the binding among the other prompt sections, which is settled * once at load; making them volatile would put two knobs nobody should touch on * the card, and a section cannot be re-ordered without a remount anyway. */ export const Config = Schema.object({ /** * How a call outside the facet is handled. `ask` questions the user, `deny` * refuses outright, `off` disables enforcement while leaving the preset in * place — useful for telling a scoping problem from an unrelated one. */ mode: Schema.union(['ask', 'deny', 'off']).default('ask').volatile(), /** * A deployment-wide seed of paths always in scope, for code a module * legitimately depends on — shared types, a common test harness. * * Deliberately only a seed. Before per-session scope existed this was the only * way to widen anything, which meant "this one conversation needs the entity * folder" had to be written as a fact about every conversation in every * workspace. Per-session scope belongs in {@link Config.scopes}; this is for * the paths a deployment genuinely always wants, and it is empty by default. * * Relative paths resolve against each session's own directory, so the same * list is meaningful in every workspace. */ extraRoots: Schema.array(String).default([]).volatile(), /** * Per-session scope — the user's declaration for one conversation, keyed by * session id. * * Written only by the browser context panel, through the settings service, and * never by the agent: this plugin exposes no write path to it, and a facet * session carries no settings tool. `focus` narrows the session to a folder; * `extras` are paths the conversation may read outside it. Both are relative * to the session's own directory unless absolute, so a declaration keeps its * meaning wherever the workspace lives. * * Entries outlive their sessions, which is deliberate — reopening a chat * should give back the scope it had — and costs a little growth in the * profile patch that the panel can tidy. */ scopes: Schema.dict( Schema.object({ /** The session's facet root, relative to the session directory or absolute. Empty means the session directory itself. */ focus: Schema.string(), /** Paths this session may also read, outside the root. */ extras: Schema.array(String).default([]), }), ) .default({}) .volatile(), /** Placement of the invariant rule in the system prompt. */ sectionOrder: Schema.number().default(100), /** Placement of the per-session binding in the runtime-context snapshot. */ contextOrder: Schema.number().default(105), }) /** * Publish the deployment's facet policy. * * @param {import('@deepseek-ai/cordis').Context} ctx - host context. * @param {object} config - validated {@link Config}. */ export function apply(ctx, config) { const policy = createFacetPolicy(config) // A grant belongs to the conversation that asked for it. Without this the map // would grow for the life of the process, and a resumed session id would // inherit a grant nobody remembers making. ctx.on('session/disposed', (session) => { policy.forget(session.id) }) ctx.provide('facetPolicy', policy) }