fix!: restore the settings page against the harness 0.1.7 plugin contracts

The configuration page registered into seats that 0.1.7 removed, so it could
not appear at all — and the schema carried none of the metadata the new
settings document requires.

Host half. `ctx.settings.installSection` no longer exists: a plugin exports a
`Config` schema, the Harness keys one form per composed entry by that row's
`id`, and `ctx.settings.describe` skips an entry with no `volatile()` field
outright. Every field is now volatile, which is also what makes a saved edit
apply to the running instance instead of remounting it — so `apply` reads the
section through `settingsSnapshot`, which unwraps the live references a
volatile field arrives as. Writes go through `ctx.settings.update` fenced by
the revision `describe` reports. The `.min`/`.max` bounds the schema can state
are stated there, so the form refuses an impossible draft in front of the
person typing it; `validateSettings` keeps the cross-field rules no single
field can express, and now runs on the section a call is about to act on
rather than at write time, because 0.1.7 has no write-time validator hook.

Browser half. `settingsScope` and the `settings.plugin.item` slot are gone. The
page renders through `plugins.row.config`, keyed `<bundle package>#<row id>`
(`dsh-recall#dsh-recall`), which is what puts a Configure control on the
plugin's row in the Plugins panel. It uses the framework's own staged form —
`SettingsFormModel` over `ctx.configForms`, drawn by `SettingsForm` and
`SettingsValueField`/`SettingsSecretField` — instead of reproducing card chrome
by hand, so the unsaved, overridden, invalid, and save states come from the
same code as every other plugin's page. All 33 settings are editable now; the
old card exposed 23. The right-sidebar tab also needed the stage-one tab TYPE
(`ctx.sidebarRightTabs.register`) that its keyed body slot is dispatched under,
so a tab can finally be opened. `--dsh-border`, defined nowhere, becomes the
product's `--dsw-alias-border-l2`.

Both halves are now pinned by tests: every field is volatile, the field list
and the schema cannot drift, the page claims the key the patch declares, and a
blank API key is never written — the secret control reports configured-and-
absent instead.

Verified against the harness rather than only in unit tests: `--dump-config-
schema` emits the volatile and secret metadata, `apply` registers `recall` on
the real tool registry, `settings.describe({redactSecrets:true})` serves
`dsh-recall` with 33 fields and the key reported set-but-redacted, and a
scratch profile boots the web app with the plugin composed and its bundle
served byte-for-byte as this checkout has it.
This commit is contained in:
NotExpectedYet committed 2026-09-26 06:37:57 +01:00
1 parent 0964819cdc
commit 040c69b57d
7 files changed
+1337 -653

No files matched your search

+37 -19
View File
@@ -65,17 +65,29 @@ a fact:
embeddingApiBase: http://10.50.1.2:11434
```
Or edit every setting from the UI. The plugin ships **two halves**, and the settings
page draws a card only when it has both:
Or edit every setting from the UI: **Plugins → dsh-recall → Configure**. The
plugin ships **two halves**, and the page appears only when both are present and
the deployment serves the namespace:
- the **host half** (`index.js`) serves a `dsh-recall` settings namespace;
- the **browser half** (`client/client.js`) claims that same namespace with a
collapsible card under **Settings → Plugins → Plugin configuration**.
- the **host half** (`index.js`) exports a `Config` schema whose every field is
`.volatile()`. That schema *is* the settings page's description — the Harness
keys one form per profile entry by that row's `id` (`dsh-recall` in
`cordis.patch.yml`), and it skips an entry with no volatile field entirely, so
a schema without those markers has no settings surface at all rather than an
empty one;
- the **browser half** (`client/client.js`) renders that form into the Plugins
panel's per-row configuration seat, `plugins.row.config`, keyed
`<bundle package>#<row id>` — here `dsh-recall#dsh-recall`. Registering it is
what adds the **Configure** control to the plugin's row.
A served namespace with no card renders nothing at all, and a card whose namespace
is not served is never dispatched — so both are required. A restart is needed for a
newly installed browser half to appear, because client bundle bytes are snapshotted
when the profile activates.
A served namespace with no page draws no control, and a page whose namespace is
not served is deliberately not registered — so both are required. A restart is
needed for a newly installed browser half to appear, because client bundle bytes
are snapshotted when the profile activates.
Editing in the UI writes to the profile's own patch layer through the settings
document, so the value survives a restart and stays visible in
`cordis.patch.yml`.
Every value is also checkable from the tool, without touching a file:
@@ -83,9 +95,9 @@ Every value is also checkable from the tool, without touching a file:
recall action=configure config={"embeddingApiBase":"http://10.50.1.2:11434"}
```
The API key is declared `role('secret')` on the host, so it is stripped from every
settings response — the card can show that a key is set without ever receiving it,
and leaving it blank on save means "unchanged" rather than "clear".
The API key is declared `role('secret')`, so it is stripped from every settings
response: the page can show that a key is set without ever receiving it, and a
blank draft writes nothing — leaving it alone means "unchanged", never "clear".
Pull the model once:
@@ -102,7 +114,7 @@ ollama pull unclemusclez/jina-embeddings-v2-base-code
| `search` *(default)* | answers `query`, warming a bounded slice of the index first |
| `index` | builds or refreshes the whole index; **background job by default** |
| `status` | index and endpoint state, touching nothing |
| `configure` | writes settings through the settings namespace |
| `configure` | writes settings through the settings document |
Parameters: `query`, `action`, `limit`, `path` (restrict to a subdirectory),
`run_in_background`.
@@ -363,10 +375,16 @@ exactly that during development.
`test/config.test.js` covers the load-time contract. Cordis rejects a `Config`
before `apply` ever runs, so a broken schema cannot be reported from inside the
plugin — the profile simply refuses to boot. Every other test in the suite calls
`apply` directly and would not notice.
`apply` directly and would not notice. It also pins the second half of that
contract: that every field is `volatile()`, because that marker is what makes
the Harness serve a settings page at all, and its absence is invisible from
inside the process.
`test/client.test.js` runs the browser half in Node by stubbing `window` and
`require('react')`. That is worth more than it sounds: the two failure modes most
likely to reach you silently are structural — a root element that is not an `<li>`,
and a card that does not start collapsed — and both are invisible until someone
opens Settings. It also pins that an untouched API key is never written or unset.
`test/client.test.js` runs the browser half in Node by stubbing `window`,
`require('react')`, and the settings-form primitives. That is worth more than it
sounds: the failure modes most likely to reach you silently are structural — a
page registered into a slot nobody declares (which is what the 0.1.5 seat,
`settings.plugin.item`, became), a right-sidebar body with no tab type behind it,
and a field list that has drifted from the schema — and all three are invisible
until someone opens Plugins. It also pins that an untouched API key is never
written, and that the field list and `Config` name the same settings.
+461 -337
View File
@@ -1,24 +1,37 @@
/**
* dsh-recall — browser half: the card in Settings → Plugins → Plugin configuration.
* dsh-recall — browser half.
*
* The Host half registers a `dsh-recall` settings namespace. This file registers
* a card under that exact namespace key. Both are required: the tab pairs the
* namespaces the Host serves with the cards the browser claims, so a served
* namespace with no card draws nothing at all, and a card whose namespace is not
* served is never dispatched.
* Three contributions, each into a seat the Harness 0.1.7 composition declares:
*
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the tab
* renders every card into a `<ul>`), the header is its own `<button>` that
* discloses the controls in place, the card starts collapsed, it marks itself
* when it holds unsaved edits, and it collapses again after a successful save.
* None of that is imported — `PluginCard` lives in that package's client bundle
* and the client purity gate forbids cross-plugin value imports — so the
* behaviour is reproduced here rather than borrowed.
* 1. the plugin's configuration page, in the **Plugins** panel;
* 2. a one-line usage chip in a conversation's header corner;
* 3. a right-sidebar tab with the same figures and room for their caveats.
*
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
* step and no bundler. The only import is `react`, which is in the platform seed
* table. Styling is inline for the same reason: there is no stylesheet this file
* may reach.
* **Where plugin settings live now.** 0.1.5 drew plugin configuration as cards
* under Settings → Plugins → Plugin configuration, and a plugin claimed one by
* binding `settingsScope` and registering into a `settings.plugin.item` slot.
* Neither exists in 0.1.7. The generated settings document is keyed by the
* plugin's own profile entry id, and a plugin that wants a page renders it as a
* contribution to the Plugins panel: `plugins.row.config`, keyed
* `<bundle package>#<row id>`. That key is what puts a **configure** control on
* the plugin's row — without a contribution there is no control and no page,
* however complete the schema is.
*
* **The page is the framework's form, not a form of our own.** The card used to
* reproduce `PluginCard`'s chrome by hand — the `<li>` root, the disclosure
* button, the unsaved marker — because the 0.1.5 client-purity rule forbade
* importing another plugin's values. 0.1.7 ships that chrome as platform
* primitives: `SettingsFormModel` stages drafts over the settings document and
* `SettingsForm`/`SettingsValueField` render them, with the overridden badges,
* per-field reset, and revision-fenced save that a hand-rolled card had to
* guess at. `@deepseek-ai/dsh-client-ui-primitives` is a module-table seed, so
* it is importable by name and needs no build step.
*
* **The only import is a platform seed.** `react` and the primitives package
* are both in the boot seed table; everything else — the settings document, the
* tab registry, the slot ledgers, the projection the chip reads — arrives as a
* Cordis service or a slot prop. Nothing here reaches into another plugin's
* bundle, which is what keeps this file loadable without a bundler.
*/
window.__ModuleLoader__.load({
@@ -28,444 +41,513 @@ window.__ModuleLoader__.load({
var exports = module.exports
const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const {
SettingsForm,
SettingsFormModel,
SettingsSecretField,
SettingsValueField,
settingsNumberField,
settingsTextField,
} = primitives
const NS = 'dsh-recall'
const h = react.createElement
const TITLE = 'Recall'
const DESCRIPTION = 'Local embedding endpoint, what gets indexed, and how many tokens a result may cost.'
/** This plugin's identity: its settings namespace AND its tab-type id. */
const NS = 'dsh-recall'
/**
* The settings this card edits, grouped for scanning.
* The settings document is keyed by the plugin's PROFILE ENTRY id.
*
* This list mirrors the Host schema in index.js. It is duplicated rather
* than fetched because the card renders its own controls — there is no
* generic schema-to-form renderer in the framework — and a field the Host
* does not know is rejected there anyway, so drift shows up as a clear error
* rather than as silent data loss.
* That is the `id` of this bundle's row in the composed tree — `dsh-recall`
* in cordis.patch.yml. It is not registered anywhere from here; exporting
* `Config` from the host half is the registration, and this is the key the
* Harness files it under.
*/
const SETTINGS_ENTRY = 'dsh-recall'
/**
* The seat this plugin's configuration page renders in.
*
* Keyed `<bundle package name>#<row id>`, both as the profile's patch
* declares them. For this bundle the two coincide, which is a coincidence
* and not a rule — the constants are kept separate so that stays visible.
*/
const BUNDLE_PACKAGE = 'dsh-recall'
const BUNDLE_ROW = 'dsh-recall'
const ROW_CONFIG_KEY = `${BUNDLE_PACKAGE}#${BUNDLE_ROW}`
const TITLE = 'Recall'
const DESCRIPTION =
'Local embedding endpoint, what gets indexed, and how many tokens a result may cost.'
/**
* The settings this page edits, grouped for scanning.
*
* The list mirrors the host schema in index.js, deliberately: each entry
* names the field, the control that stages it, and the copy a person needs
* to choose a value. A field the host does not declare is refused by the
* document, and a declared field missing from this list is simply not
* editable here — so the two are kept in step by
* `test/client.test.js`, which fails when the schema and this list differ.
*/
const FIELDS = [
{
key: 'embeddingApiBase',
kind: 'text',
group: 'Endpoint',
label: 'Embedding endpoint',
hint: 'e.g. http://127.0.0.1:11434 for Ollama. Must be reachable from the machine running the harness.',
},
{
key: 'embeddingModel',
kind: 'text',
group: 'Endpoint',
label: 'Embedding model',
hint: 'The model id as the endpoint knows it. Changing this discards the existing index.',
},
{
key: 'embeddingDimensions',
kind: 'number',
group: 'Endpoint',
label: 'Embedding dimensions',
kind: 'number',
hint: 'Must match what the model returns. Changing this discards the existing index.',
},
{
key: 'embeddingApiKey',
kind: 'secret',
group: 'Endpoint',
label: 'Embedding API key',
kind: 'secret',
hint: 'Stored as a secret and never returned to this page. Leave blank to keep the current value; use Reset to clear it.',
hint: 'Stored as a secret and never returned to this page. Leave blank to keep the current value.',
},
{
key: 'embeddingBatchSize',
kind: 'number',
group: 'Endpoint',
label: 'Inputs per request',
},
{
key: 'embeddingConcurrency',
kind: 'number',
group: 'Endpoint',
label: 'Requests in flight',
hint: 'Keep this low on a small host. A cold index is a sustained CPU load, and too much of it can leave the endpoint unable to load the model.',
},
{
key: 'embeddingTimeoutMs',
kind: 'number',
group: 'Endpoint',
label: 'Request timeout (ms)',
hint: 'Short on purpose. A wedged endpoint accepts the connection and never answers, so a long timeout looks like a hang.',
},
{
key: 'embeddingKeepAlive',
kind: 'text',
group: 'Endpoint',
label: 'Keep-alive',
hint: 'Ollama keep_alive, e.g. 5m. Keeps the model resident for the length of an index run.',
},
{
key: 'embeddingTimeoutMs',
group: 'Endpoint',
label: 'Request timeout (ms)',
kind: 'number',
hint: 'Short on purpose. A wedged endpoint accepts the connection and never answers, so a long timeout looks like a hang.',
},
{
key: 'embeddingBatchSize',
group: 'Endpoint',
label: 'Inputs per request',
kind: 'number',
},
{
key: 'embeddingConcurrency',
group: 'Endpoint',
label: 'Requests in flight',
kind: 'number',
hint: 'Keep this low on a small host. A cold index is a sustained CPU load, and too much of it can leave the endpoint unable to load the model.',
},
{
key: 'extensions',
kind: 'list',
group: 'What gets indexed',
label: 'Extensions',
kind: 'list',
hint: 'Comma separated. Empty uses the built-in code set (no prose or markup).',
},
{
key: 'exclude',
kind: 'list',
group: 'What gets indexed',
label: 'Extra exclude patterns',
kind: 'list',
hint: 'Comma separated globs, e.g. src/generated/. Added to the built-in directory list rather than replacing it.',
},
{
key: 'maxFileBytes',
group: 'What gets indexed',
label: 'Skip files larger than (bytes)',
kind: 'number',
hint: 'Generated and minified files are usually large; they are skipped rather than indexed as noise.',
},
{
key: 'maxFiles',
kind: 'number',
group: 'What gets indexed',
label: 'Most files to consider',
},
{
key: 'maxFileBytes',
kind: 'number',
group: 'What gets indexed',
label: 'Skip files larger than (bytes)',
hint: 'Generated and minified files are usually large; they are skipped rather than indexed as noise.',
},
{ key: 'chunkLines', group: 'Chunking', label: 'Target lines per chunk', kind: 'number' },
{ key: 'maxChunkLines', group: 'Chunking', label: 'Hard cap before splitting', kind: 'number' },
{ key: 'chunkLines', kind: 'number', group: 'Chunking', label: 'Target lines per chunk' },
{ key: 'maxChunkLines', kind: 'number', group: 'Chunking', label: 'Hard cap before splitting' },
{
key: 'overlapLines',
kind: 'number',
group: 'Chunking',
label: 'Lines shared when splitting',
kind: 'number',
hint: 'Must be smaller than the hard cap, or a split never advances.',
},
{
key: 'excerptLines',
kind: 'number',
group: 'Results',
label: 'Lines per excerpt',
kind: 'number',
hint: 'A chunk is a bounded semantic unit, so this defaults to the chunk cap and results come back whole. Truncating below it is how a search turns into a follow-up read that costs more than it saved.',
},
{
key: 'totalExcerptLines',
kind: 'number',
group: 'Results',
label: 'Total lines across one result set',
kind: 'number',
hint: 'Shared budget, allocated by rank, so the best answer gets the room and the response stays bounded.',
},
{ key: 'defaultLimit', group: 'Results', label: 'Excerpts returned', kind: 'number' },
{ key: 'maxLimit', group: 'Results', label: 'Most a caller may ask for', kind: 'number' },
{ key: 'defaultLimit', kind: 'number', group: 'Results', label: 'Excerpts returned' },
{ key: 'maxLimit', kind: 'number', group: 'Results', label: 'Most a caller may ask for' },
{
key: 'perFileLimit',
kind: 'number',
group: 'Results',
label: 'Most chunks from one file',
kind: 'number',
hint: 'Stops one large file filling the whole result set.',
},
{
key: 'minSimilarity',
kind: 'number',
group: 'Results',
label: 'Weak-match threshold',
kind: 'number',
hint: 'A cosine similarity between 0 and 1. Below this best score, results are reported as a weak match and the model is told to prefer grep.',
},
{
key: 'syncBudgetMs',
kind: 'number',
group: 'Index',
label: 'Index warm-up budget per search (ms)',
kind: 'number',
hint: 'How long one search may spend embedding changed files before reporting the rest as pending.',
},
{
key: 'stateDir',
kind: 'text',
group: 'Index',
label: 'Index directory',
hint: 'Empty uses $DSH_HOME/dsh-recall/workspaces/<workspace>.',
},
{
key: 'focus',
kind: 'text',
group: 'Focus',
label: 'Default focus',
hint: 'A workspace-relative directory to scope every call to. Empty resolves by marker file, then the workspace root.',
},
{
key: 'focusMarkers',
kind: 'list',
group: 'Focus',
label: 'Scope marker files',
hint: 'Comma separated filenames that make a directory its own scope. Empty uses the built-ins.',
},
{
key: 'expansionEnabled',
kind: 'boolean',
group: 'Query expansion',
label: 'Ask the local model for rephrasings',
hint: 'true or false. Off by default: it spends one local generation per search on a gain that has not been measured against a real model.',
},
{
key: 'expansionModel',
kind: 'text',
group: 'Query expansion',
label: 'Expansion model',
hint: 'Empty uses the embedding model.',
},
{ key: 'expansionCount', kind: 'number', group: 'Query expansion', label: 'Most variants to ask for' },
{
key: 'expansionTimeoutMs',
kind: 'number',
group: 'Query expansion',
label: 'Expansion budget (ms)',
hint: 'Expiry just means the search runs without expansion.',
},
{
key: 'rerankEnabled',
kind: 'boolean',
group: 'Re-ranking',
label: 'Score candidates with the local model',
hint: 'true or false. Off by default: one more model call on the critical path of every search.',
},
{ key: 'rerankModel', kind: 'text', group: 'Re-ranking', label: 'Re-ranking model' },
{
key: 'rerankCandidates',
kind: 'number',
group: 'Re-ranking',
label: 'Candidates scored per search',
},
{ key: 'rerankTimeoutMs', kind: 'number', group: 'Re-ranking', label: 'Re-ranking budget (ms)' },
]
/** Fields the Host redacts, so the page never holds their real value. */
const SECRET_KEYS = new Set(FIELDS.filter((field) => field.kind === 'secret').map((field) => field.key))
const SECRET_KEYS = FIELDS.filter((field) => field.kind === 'secret').map((field) => field.key)
const BORDER = '1px solid var(--dsh-border, #3a3a3a)'
/** HTML ids, unique per field, so every label points at its own control. */
const controlId = (key) => `dsh-recall-${key}`
const S = {
// The tab renders into a <ul>, so the root must be an <li> or the list
// styling never reaches it.
card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
header: {
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'transparent',
color: 'inherit',
textAlign: 'left',
cursor: 'pointer',
font: 'inherit',
},
headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 },
name: { fontSize: '14px', fontWeight: 600 },
description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 },
pending: {
flex: '0 0 auto',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 6px',
borderRadius: '4px',
border: BORDER,
opacity: 0.8,
},
chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' },
chevronOpen: { transform: 'rotate(180deg)' },
body: { padding: '0 16px 16px 16px' },
readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' },
group: {
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
opacity: 0.55,
marginTop: '18px',
marginBottom: '2px',
paddingBottom: '4px',
borderBottom: BORDER,
},
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
row: { marginTop: '12px' },
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
input: {
width: '100%',
boxSizing: 'border-box',
padding: '6px 8px',
fontSize: '12px',
borderRadius: '6px',
border: BORDER,
background: 'transparent',
color: 'inherit',
},
actions: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap' },
button: {
padding: '6px 12px',
fontSize: '12px',
borderRadius: '6px',
border: BORDER,
background: 'transparent',
color: 'inherit',
cursor: 'pointer',
},
primary: { fontWeight: 600 },
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
footer: { fontSize: '11px', opacity: 0.6, marginTop: '16px', lineHeight: 1.5 },
}
/** The editable string form of one field's value. */
function toText(field, value) {
if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : ''
if (value === undefined || value === null) return ''
return String(value)
}
/** The value to write for one field, from its string form. */
function fromText(field, text) {
const trimmed = typeof text === 'string' ? text.trim() : ''
if (field.kind === 'number') return Number(trimmed)
if (field.kind === 'list') {
return trimmed === ''
? []
: trimmed
/**
* A whole-list field: comma or newline separated in the control, an array of
* non-empty strings in the document.
*
* One of three conversions the shared model asks a field for. `number` and
* `text` ship with the primitives; a list does not, because only the owning
* plugin knows whether its list is comma-separated, JSON, or one-per-line.
*/
function settingsListField(field) {
return {
field,
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
parse: (text) => {
const parts = String(text)
.split(/[,\n]/)
.map((part) => part.trim())
.filter((part) => part !== '')
// Empty means "no restriction", which for these fields is the schema
// default — so this clears the override rather than storing an empty
// list that would shadow a deployment's own default with nothing.
return parts.length === 0 ? { kind: 'clear' } : { kind: 'set', value: parts }
},
}
return text
}
/** Build a draft of every field from a resolved section. */
function toDraft(value) {
const draft = {}
for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key])
return draft
/**
* A boolean field, edited as text.
*
* The shared model stages strings, so a boolean is staged as `true`/`false`
* and the accepted spellings are broadened rather than guessed at: anything
* else leaves the draft invalid and blocks the save, which is the honest
* outcome for a value the field cannot represent.
*/
function settingsBooleanField(field) {
return {
field,
format: (value) => (value === true ? 'true' : 'false'),
parse: (text) => {
const normalised = String(text).trim().toLowerCase()
if (normalised === '') return { kind: 'clear' }
if (['true', 'on', 'yes', '1'].includes(normalised)) return { kind: 'set', value: true }
if (['false', 'off', 'no', '0'].includes(normalised)) return { kind: 'set', value: false }
return undefined
},
}
}
function Field(props) {
const { field, value, overridden, disabled, onChange } = props
const common = {
style: S.input,
value,
disabled,
'aria-label': field.label,
onChange: (event) => onChange(event.target.value),
/** The conversion spec one declared field stages through. */
function specFor(field) {
if (field.kind === 'number') return settingsNumberField(field.key)
if (field.kind === 'list') return settingsListField(field.key)
if (field.kind === 'boolean') return settingsBooleanField(field.key)
// A secret is written outside the section, so it gets a write-only spec
// below rather than a value spec that would render an empty draft as an
// instruction to clear the stored key.
return settingsTextField(field.key)
}
return h(
'div',
{ style: S.row },
h(
'label',
{ style: S.label },
field.label,
overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null,
),
h('input', {
...common,
type: field.kind === 'secret' ? 'password' : 'text',
...(field.kind === 'secret' ? { placeholder: '(unchanged)' } : {}),
}),
field.hint === undefined ? null : h('div', { style: S.hint }, field.hint),
/** Copy the shared form frame renders. */
const FORM_LABELS = {
unavailable: 'dsh-recall is not configurable in this deployment.',
readOnly: 'This deployment stores plugin settings read-only.',
saveFailed: 'The host did not accept that change.',
save: 'Save',
saving: 'Saving…',
}
const OVERRIDDEN_LABEL = 'overridden'
const RESET_LABEL = 'reset'
const INVALID_LABEL = 'Not a value this field accepts.'
/**
* Bridge the settings document onto the shared staged form.
*
* The document's form for one entry is a `ConfigForm`: a sync snapshot, a
* subscription, and one atomic revision-fenced mutation. `SettingsFormModel`
* owns the staging, validation, and save ordering; this class owns the two
* things that are true of dsh-recall in particular — which fields exist, and
* that one of them is a secret the wire never carries — and nothing else.
*/
class RecallSettingsController {
/**
* @param {object} ctx the plugin's client context, carrying `configForms`
*/
constructor(ctx) {
this.ctx = ctx
this.form = ctx.configForms.get(SETTINGS_ENTRY)
this.model = new SettingsFormModel(
this.form,
FIELDS.filter((field) => field.kind !== 'secret').map(specFor),
SECRET_KEYS.map((key) => ({
field: key,
// A blank draft writes nothing — the model only calls this when the
// user actually typed a key — so opening the page and saving can
// never delete a credential the page was never shown.
write: (text) =>
this.form.mutate([{ op: 'set', path: [key], value: text }], this.form.getSnapshot().revision),
})),
)
this.store = this.model.bind(() => this.projection())
/** The describe mirror that knows whether a secret is configured. */
this.describe = ctx.configForms.describe()
}
/** Everything the page renders, rebuilt whenever the form or a draft moves. */
projection() {
const fields = {}
for (const field of FIELDS) fields[field.key] = this.model.field(field.key)
return {
shell: this.model.shell(),
fields,
secretSet: this.secretIsSet(),
}
}
/**
* Whether the endpoint currently holds an API key.
*
* Read from the namespace view's `secrets`, which names every declared
* secret slot and whether it holds a value — the value itself never
* crosses the wire, which is the whole point of the control being
* write-only.
*
* @returns {boolean} whether a key is stored
*/
secretIsSet() {
const view = this.describe.getSnapshot().view
const namespace = view?.namespaces?.find((entry) => entry.ns === SETTINGS_ENTRY)
return (namespace?.secrets ?? []).some(
(secret) => secret.path.length === 1 && SECRET_KEYS.includes(secret.path[0]) && secret.set === true,
)
}
function Card(props) {
const scope = props.scope
const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot())
const [draft, setDraft] = react.useState(null)
const [busy, setBusy] = react.useState(false)
const [note, setNote] = react.useState('')
// Collapsed on mount, like every other card in the tab.
const [open, setOpen] = react.useState(false)
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
const status = snapshot.status
const value = snapshot.value
const baseline = react.useMemo(() => toDraft(value), [value])
const current = draft ?? baseline
const user = snapshot.user
if (status === 'unavailable') {
// A deployment without the Host half, or a remote browser: show no trace.
return null
}
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
const writable = snapshot.writable === true
const save = async () => {
setBusy(true)
setNote('')
try {
for (const field of FIELDS) {
const next = current[field.key]
if (next === baseline[field.key]) continue
const trimmed = String(next).trim()
// A redacted secret always reads as blank. Blank therefore means
// "leave it alone", not "clear it" — otherwise opening this card and
// pressing Save would silently delete the key.
if (SECRET_KEYS.has(field.key) && trimmed === '') continue
// Clearing any other field is an unset, not a write of "", so the
// value re-inherits the composition layer instead of becoming empty.
if (trimmed === '') await scope.unset(field.key)
else await scope.set(field.key, fromText(field, next))
}
setDraft(null)
setNote('Saved.')
// A landed save folds the card back up. A failed or blocked one stays
// open with the drafts, which is the whole point of staging them.
setOpen(false)
} catch (error) {
setNote(`Save failed: ${error?.message ?? error}`)
} finally {
setBusy(false)
/**
* The face the slot registration injects.
*
* `hooks` becomes a `use*` prop on the component, which is how a card
* reads its own state through a snapshot selector without importing
* anything from another plugin.
*
* @returns {object} the store and the staged-form actions
*/
inject() {
return {
hooks: { recallSettings: this.store },
...this.model.actions(),
}
}
const reset = async () => {
setBusy(true)
setNote('')
try {
for (const key of overriddenKeys) await scope.unset(key)
setDraft(null)
setNote('Reset to the deployment defaults.')
} catch (error) {
setNote(`Reset failed: ${error?.message ?? error}`)
} finally {
setBusy(false)
/** Release the form's subscription to the settings document. */
dispose() {
this.model.dispose()
}
}
/** One field's label, control, hint, and overridden marker. */
function Field(props) {
const { field, state, disabled, onEdit, onReset, secretSet } = props
if (field.kind === 'secret') {
return h(SettingsSecretField, {
id: controlId(field.key),
label: field.label,
hint: field.hint ?? '',
text: state.text,
configured: secretSet,
stateLabel: secretSet ? 'A key is stored.' : 'No key is stored.',
disabled,
onEdit,
})
}
return h(SettingsValueField, {
id: controlId(field.key),
label: field.label,
hint: field.hint,
text: state.text,
overridden: state.overridden,
invalid: state.invalid,
overriddenLabel: OVERRIDDEN_LABEL,
resetLabel: RESET_LABEL,
invalidLabel: INVALID_LABEL,
numeric: field.kind === 'number',
disabled,
onEdit,
onReset,
})
}
/**
* The plugin's configuration page.
*
* The Plugins page asks a configuration entry twice: once for a one-liner to
* stand in for a missing description (`summary`), and once for the body of
* the plugin's own page (`page`). Only the second draws a save control —
* and only one save writes, because the form discards its drafts on unmount.
*/
function RecallSettingsCard(props) {
if (props.view === 'summary') return DESCRIPTION
const state = props.useRecallSettings((snapshot) => snapshot)
const rows = []
let group
for (const field of FIELDS) {
if (field.group !== group) {
group = field.group
rows.push(h('div', { key: `group-${group}`, style: S.group }, group))
// The only chrome this file draws itself. Thirty-three fields in one
// flat list is a long scroll, and the framework's form renders no
// group headings of its own — so the headings are here, coloured from
// the product's own token rather than a hand-picked grey so they sit
// in the panel instead of beside it.
rows.push(
h(
'h4',
{
key: `group-${group}`,
style: {
margin: '20px 0 0',
fontSize: '12px',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
color: 'var(--dsw-alias-label-tertiary)',
},
},
group,
),
)
}
rows.push(
h(Field, {
key: field.key,
field,
value: current[field.key],
overridden: overriddenKeys.includes(field.key),
disabled: busy || !writable,
onChange: (next) => setDraft({ ...current, [field.key]: next }),
state: state.fields[field.key],
secretSet: state.secretSet,
disabled: !state.shell.writable || state.shell.saving,
onEdit: (text) => props.edit(field.key, text),
onReset: () => props.resetField(field.key),
}),
)
}
return h(
'li',
{ style: open ? { ...S.card, ...S.cardOpen } : S.card },
h(
'button',
SettingsForm,
{
type: 'button',
style: S.header,
'aria-expanded': open,
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
onClick: () => setOpen(!open),
labels: FORM_LABELS,
state: state.shell,
onSave: props.save,
onDiscard: props.discard,
},
h(
'span',
{ style: S.headText },
h('span', { style: S.name }, TITLE),
h('span', { style: S.description }, DESCRIPTION),
),
// Marked on the header, so unsaved edits are visible while collapsed.
dirtyKeys.length > 0 ? h('span', { style: S.pending }, 'unsaved') : null,
h('span', { style: open ? { ...S.chevron, ...S.chevronOpen } : S.chevron }, '▾'),
),
open
? h(
'div',
{ style: S.body },
writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
...rows,
h(
'div',
{ style: S.actions },
h(
'button',
{ type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
),
h(
'button',
{ type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
`Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
),
note === '' ? null : h('span', { style: S.note }, note),
),
h(
'p',
{ style: S.footer },
'Values are checked when saved, not when next used. The endpoint is not contacted here — run ' +
'recall with action "status" to probe it.',
),
)
: null,
)
}
/**
* Register the card, but only once a settings transport exists.
*
* `ctx.inject(['settingsScope'], …)` rather than a top-level `inject`,
* because a required dependency that is unsatisfied means this plugin never
* applies at all — the card would vanish along with everything else instead
* of simply not appearing.
*/
/** The projection key the host half registers. */
const USAGE_KEY = 'recallUsage'
@@ -537,17 +619,16 @@ window.__ModuleLoader__.load({
whiteSpace: 'nowrap',
padding: '2px 8px',
borderRadius: '999px',
border: '1px solid var(--dsh-border, #3a3a3a)',
// The product's own border token. This used to read `--dsh-border`,
// which is defined nowhere — so it always fell back to a hardcoded
// dark grey and drew a dark ring around the chip in a light theme.
border: '1px solid var(--dsw-alias-border-l2)',
},
},
chipText(usage),
)
}
function RecallTabTitle() {
return h('span', { style: { fontSize: '12px' } }, 'Recall')
}
/** One labelled figure in the panel. */
function Figure(props) {
const { label, value, note } = props
@@ -608,34 +689,77 @@ window.__ModuleLoader__.load({
}
function apply(ctx) {
// The chip and the panel are session-scoped, so the framework hands them
// `useProjection` as a standard prop — the same wire view the host
// projection registers. Both render nothing when there is nothing to say.
// The settings page. Reached through `ctx.inject` rather than a top-level
// `inject`, so a deployment that composed no settings provider loses the
// configuration page and keeps the chip and the tab — a required
// dependency would instead mean this plugin never applied at all.
ctx.inject(['configForms'], (scoped) => {
const card = new RecallSettingsController(scoped)
scoped.effect(() => () => {
card.dispose()
}, 'dsh-recall: settings form')
// `whileServed` keeps the registration alive exactly while the Host
// serves this entry's settings. A page for a namespace the Host does not
// serve would be a control that silently writes nowhere.
scoped.effect(
() =>
scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
scoped.slots.inject('plugins.row.config', () =>
scoped.slots.register(
{
name: 'plugins.row.config',
key: ROW_CONFIG_KEY,
inject: () => card.inject(),
},
RecallSettingsCard,
),
),
),
'dsh-recall: settings page',
)
})
// The chip: the header's far-right corner, which the framework lays out
// only while its occupant renders something.
ctx.slots.inject('conversation.session.header.corner', () =>
ctx.slots.register({ name: 'conversation.session.header.corner' }, (props) => h(RecallChip, props)),
)
// The right-sidebar tab is registered in two stages, and the second is
// useless without the first: a tab TYPE says what a tab is (id, kind,
// title, guide entry), and the keyed slot of the same id supplies its
// body. Registering only the body leaves a type nobody declared, so no
// tab can ever open it.
ctx.inject(['sidebarRightTabs'], (scoped) => {
scoped.effect(
() =>
scoped.sidebarRightTabs.register({
id: NS,
kind: NS,
// `extension` is the band for a type from outside the product, and
// the kind is ours alone — nothing shipped claims it.
priority: 'extension',
title: () => TITLE,
guide: [
{
id: NS,
order: 60,
title: () => TITLE,
description: () => 'What recall kept out of this conversation, and what that estimate assumes.',
},
],
}),
'dsh-recall: right-sidebar tab type',
)
})
// The body, dispatched under the tab type's id. Registered through
// `slots.inject` because the seat belongs to the right sidebar's session
// entry and may not be declared yet.
ctx.slots.inject('sidebar.right.pane.tab', () =>
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: NS }, (props) => h(RecallPanel, props)),
)
ctx.slots.inject('sidebar.right.pane.tab.title', () =>
ctx.slots.register({ name: 'sidebar.right.pane.tab.title', key: NS }, () => h(RecallTabTitle)),
)
ctx.inject(['settingsScope'], (scoped) => {
const binder = scoped.settingsScope
if (binder === undefined) return
const scope = binder.bind({ namespace: NS })
scoped.slots.inject('settings.plugin.item', () =>
scoped.slots.register(
{
name: 'settings.plugin.item',
key: NS,
inject: () => ({ scope }),
},
(props) => h(Card, props),
),
)
})
}
module.exports = { name: NS, inject: ['slots'], apply }
+196 -68
View File
@@ -2,8 +2,18 @@
* dsh-recall — local-embedding code recall for DeepSeek Harness.
*
* One tool (`recall`) over a per-workspace vector index built by a local
* embedding model, plus a settings namespace so the endpoint is editable at
* runtime.
* embedding model, plus a `Config` schema whose every field is live-editable —
* the endpoint is configuration, not a constant.
*
* **How the settings surface works (harness 0.1.7).** A plugin no longer
* registers a settings section. It exports a `Config` schema; the Harness keys
* a form for the profile entry by that row's `id`, and a field is offered to
* the UI only when its schema node is declared `volatile()` — the marker that
* means "editable without remounting the plugin". So this file's schema carries
* two jobs at once: validation, and being the whole of the settings page's
* description. `apply` therefore receives volatile fields as live references
* and reads them through {@link settingsSnapshot} per call, which is what makes
* a saved edit take effect without a restart.
*
* **The economics this exists for.** In a long agent session the dominant cost
* is not the model's reasoning; it is re-reading the accumulated transcript on
@@ -21,11 +31,11 @@
* fits in a time budget — and reports honestly what is still pending. A full
* warm-up is available as an explicit background action.
*
* **Why there is a settings namespace and no baked endpoint.** The Ollama
* default of `127.0.0.1:11434` is a convention, not a fact — this deployment
* runs Ollama on another host. Baking one machine's address into a bundle layer
* that other machines apply is exactly the mistake the composition docs warn
* about, so the endpoint is a schema default the profile can override.
* **Why the endpoint is configuration and not a constant.** The Ollama default
* of `127.0.0.1:11434` is a convention, not a fact — this deployment runs Ollama
* on another host. Baking one machine's address into a bundle layer that other
* machines apply is exactly the mistake the composition docs warn about, so the
* endpoint is a schema default the profile can override, at rest or live.
*/
import { readFile, stat } from 'node:fs/promises'
@@ -50,7 +60,16 @@ import { createReranker } from './lib/rerank.js'
import { deriveUsage, emptyUsage, foldUsage, usageSummary } from './lib/usage.js'
import { DEFAULT_EXTENSIONS, DEFAULT_SKIP_DIRS, listCodeFiles } from './lib/walk.js'
/** Settings namespace. Must match /^[a-z][a-z0-9-]*$/. */
/**
* This plugin's settings namespace, which in 0.1.7 IS its profile entry id.
*
* `ctx.settings.describe()` keys one form per composed entry by the `id` the
* patch layer gives that row — `dsh-recall` in cordis.patch.yml — and writes go
* back to the same key. It is not a name the plugin registers; the schema
* exported as `Config` is the registration, and this constant is the id those
* settings surfaces address it by. Renaming the row without renaming this
* would leave the `configure` action writing to a row that does not exist.
*/
const SETTINGS_NAMESPACE = 'dsh-recall'
/** Every configuration field this plugin exposes. */
@@ -90,22 +109,46 @@ export const SETTING_KEYS = [
'rerankTimeoutMs',
]
/**
* Every field is `.volatile()`.
*
* A volatile field is one the configuration document may change without
* remounting the plugin: the Loader re-parses the raw config, writes the new
* value into the running instance's existing reference, and emits
* `loader/volatile-update`. Two consequences decide the shape below:
*
* - `ctx.settings.describe` SKIPS an entry with no volatile field, so a schema
* that marks none is invisible to every settings surface — there is no page
* to draw rather than an empty one. This is why the plugin cannot expose a
* settings page at all without these markers.
* - a volatile field arrives in `apply`'s config as a reference (`{ get() }`)
* rather than a value, which is what {@link settingsSnapshot} unwraps.
*
* Every field qualifies: all of them are read afresh per tool call, so a live
* edit takes effect on the next call with nothing to re-register.
*
* The `.min`/`.max` bounds duplicate what {@link validateSettings} enforces.
* That is deliberate rather than redundant: those are the constraints a schema
* can express, and expressing them here is what makes the generated form refuse
* the invalid draft in front of the person typing it. `validateSettings` keeps
* the cross-field rules no single field's schema can state.
*/
export const Config = Schema.object({
/** Embedding endpoint, without a trailing slash. */
embeddingApiBase: Schema.string().default('http://127.0.0.1:11434'),
embeddingApiBase: Schema.string().default('http://127.0.0.1:11434').volatile(),
/** Model id as the endpoint knows it. */
embeddingModel: Schema.string().default('unclemusclez/jina-embeddings-v2-base-code:latest'),
embeddingModel: Schema.string().default('unclemusclez/jina-embeddings-v2-base-code:latest').volatile(),
/** Vector width. Must match the model; a mismatch is refused, not truncated. */
embeddingDimensions: Schema.number().default(768),
embeddingDimensions: Schema.number().min(1).default(768).volatile(),
/** Bearer token for a gateway that wants one. */
// `role('secret')` keeps this out of every settings response: the wire
// surfaces strip schema-declared secret positions, so a card can report that a
// surfaces strip schema-declared secret positions, so a page can report that a
// key is set without ever receiving it.
embeddingApiKey: Schema.string().default('').role('secret'),
embeddingApiKey: Schema.string().default('').role('secret').volatile(),
/** Inputs per request on the batch endpoint. */
embeddingBatchSize: Schema.number().default(16),
embeddingBatchSize: Schema.number().min(1).default(16).volatile(),
/** Requests in flight. */
embeddingConcurrency: Schema.number().default(2),
embeddingConcurrency: Schema.number().min(1).default(2).volatile(),
/**
* Per-request timeout.
*
@@ -115,29 +158,29 @@ export const Config = Schema.object({
* more than a batch of a few thousand characters needs on any CPU, and it
* fails in bounded time instead of leaving the caller guessing.
*/
embeddingTimeoutMs: Schema.number().default(30_000),
embeddingTimeoutMs: Schema.number().min(1).default(30_000).volatile(),
/** Ollama `keep_alive`. Keeps the model resident across an index run. */
embeddingKeepAlive: Schema.string().default('5m'),
embeddingKeepAlive: Schema.string().default('5m').volatile(),
/** Extensions to index. Empty means the built-in code set. */
extensions: Schema.array(String).default([]),
extensions: Schema.array(String).default([]).volatile(),
/** Extra glob patterns to skip, on top of the built-in directory list. */
exclude: Schema.array(String).default([]),
exclude: Schema.array(String).default([]).volatile(),
/** Most files a workspace walk will consider. */
maxFiles: Schema.number().default(20_000),
maxFiles: Schema.number().min(1).default(20_000).volatile(),
/** Files larger than this are skipped as generated. */
maxFileBytes: Schema.number().default(1_048_576),
maxFileBytes: Schema.number().min(1).default(1_048_576).volatile(),
/** Target lines per chunk. */
chunkLines: Schema.number().default(48),
chunkLines: Schema.number().min(1).default(48).volatile(),
/** Hard cap before a chunk is split. */
maxChunkLines: Schema.number().default(80),
maxChunkLines: Schema.number().min(1).default(80).volatile(),
/** Lines shared between the halves of a split chunk. */
overlapLines: Schema.number().default(8),
overlapLines: Schema.number().min(0).default(8).volatile(),
/** Results returned when the caller does not say. */
defaultLimit: Schema.number().default(8),
defaultLimit: Schema.number().min(1).default(8).volatile(),
/** Ceiling on the caller's `limit`. */
maxLimit: Schema.number().default(25),
maxLimit: Schema.number().min(1).default(25).volatile(),
/** Most chunks one file may contribute to one result set. */
perFileLimit: Schema.number().default(2),
perFileLimit: Schema.number().min(1).default(2).volatile(),
/**
* Lines shown per excerpt.
*
@@ -145,15 +188,15 @@ export const Config = Schema.object({
* is returned whole. Cutting one in half is how a search turns into a
* follow-up read that costs more than the lines it saved.
*/
excerptLines: Schema.number().default(80),
excerptLines: Schema.number().min(1).default(80).volatile(),
/** Total lines across one result set, allocated by rank. */
totalExcerptLines: Schema.number().default(400),
totalExcerptLines: Schema.number().min(1).default(400).volatile(),
/** Below this best-similarity, the result set is reported as weak. */
minSimilarity: Schema.number().default(0.2),
minSimilarity: Schema.number().min(0).max(1).default(0.2).volatile(),
/** Embedding time one `search` call may spend warming the index. */
syncBudgetMs: Schema.number().default(20_000),
syncBudgetMs: Schema.number().min(0).default(20_000).volatile(),
/** Index directory. Empty means `$DSH_HOME/dsh-recall/workspaces/<key>`. */
stateDir: Schema.string().default(''),
stateDir: Schema.string().default('').volatile(),
/**
* Narrows the index and every search to one directory.
*
@@ -162,9 +205,9 @@ export const Config = Schema.object({
* the fallback — which is the behaviour that existed before focus did, so
* adopting this cannot regress a deployment that never configures it.
*/
focus: Schema.string().default(''),
focus: Schema.string().default('').volatile(),
/** Filenames that mark a directory as its own scope. Empty means the built-ins. */
focusMarkers: Schema.array(String).default([]),
focusMarkers: Schema.array(String).default([]).volatile(),
/**
* Ask the local model for alternative phrasings and fuse the rankings.
*
@@ -172,28 +215,60 @@ export const Config = Schema.object({
* and its benefit has not been measured against a real model — a default that
* spends latency on an unquantified gain is not a default worth having.
*/
expansionEnabled: Schema.boolean().default(false),
expansionEnabled: Schema.boolean().default(false).volatile(),
/** Model that writes the phrasings. Empty uses `embeddingModel`'s endpoint, not its model. */
expansionModel: Schema.string().default(''),
expansionModel: Schema.string().default('').volatile(),
/** Most variants to ask for. */
expansionCount: Schema.number().default(4),
expansionCount: Schema.number().min(1).default(4).volatile(),
/** Budget for the whole expansion, in ms. Expiry just means search without it. */
expansionTimeoutMs: Schema.number().default(8_000),
expansionTimeoutMs: Schema.number().min(1).default(8_000).volatile(),
/**
* Have the local model score the top candidates against the query.
*
* Off by default for the same reason, and more so: it is one more model call
* on the critical path of every search.
*/
rerankEnabled: Schema.boolean().default(false),
rerankEnabled: Schema.boolean().default(false).volatile(),
/** Model that scores candidates. */
rerankModel: Schema.string().default(''),
rerankModel: Schema.string().default('').volatile(),
/** Candidates scored per search. The reranker caps this at 12. */
rerankCandidates: Schema.number().default(8),
rerankCandidates: Schema.number().min(1).default(8).volatile(),
/** Budget for the scoring call, in ms. */
rerankTimeoutMs: Schema.number().default(15_000),
rerankTimeoutMs: Schema.number().min(1).default(15_000).volatile(),
})
/**
* Read one configuration field.
*
* A volatile field is a reference in the running config, not a plain value; a
* non-volatile one (or a config object a test handed straight to `apply`) is
* the value itself. Both are accepted so the same path serves a real profile
* and a direct call.
*
* @param {unknown} value a field of the resolved config
* @returns {unknown} the current value
*/
export function configValue(value) {
return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
}
/**
* A plain snapshot of the whole settings section.
*
* Taken per tool call rather than cached, because that is exactly what a
* volatile field buys: the running instance's references are updated in place
* when the document changes, so the next read sees the new value without the
* plugin being remounted.
*
* @param {Record<string, any>} config the resolved composition config
* @returns {Record<string, any>} every declared field, unwrapped
*/
export function settingsSnapshot(config) {
const snapshot = {}
for (const key of Object.keys(config ?? {})) snapshot[key] = configValue(config[key])
return snapshot
}
/** Resolve the harness home, preferring the harness's own resolver. */
function dshHomeOf(ctx) {
try {
@@ -209,12 +284,15 @@ function dshHomeOf(ctx) {
}
/**
* Constraints the settings schema cannot express.
* Constraints a single field's schema cannot express.
*
* Run by the settings provider when a value is WRITTEN, not when it is next
* used. That distinction is the point: a bad endpoint or an impossible limit
* should fail in front of the person who typed it, rather than surfacing later
* as a failed search with no obvious cause.
* Called on the section a call is about to act on, and on the section a
* `configure` patch would leave behind. The Host validates every field against
* `Config` — including the `.min`/`.max` bounds — but the rules here relate two
* fields to each other (`defaultLimit` against `maxLimit`, `overlapLines`
* against `maxChunkLines`), and 0.1.7 has no write-time validator hook for
* those. So the check moves to the two moments that can still refuse: before a
* write this plugin performs, and before a configured value is put to work.
*
* @param {Record<string, any>} value a schema-valid settings section
* @throws {Error} when the section cannot be acted on
@@ -403,9 +481,14 @@ export function apply(ctx, config) {
openIndexes.clear()
})
// The authoritative settings source. Starts as the composition config and is
// replaced by the settings scope's thunk once a provider attaches.
let readSettings = () => config
// The authoritative settings source: a plain snapshot of the running config.
//
// No settings provider is involved in READING. The `Config` schema above
// declares every field volatile, so a document edit updates this instance's
// config references in place and the very next snapshot sees it. That is the
// 0.1.7 model — there is no `installSection` to attach to, and nothing to
// subscribe to for a value that is already live.
const readSettings = () => settingsSnapshot(config)
let writeSettings
// Per-conversation usage, folded from the tool metadata the log already
@@ -419,24 +502,20 @@ export function apply(ctx, config) {
}
})
// Writing is still the settings provider's job, because the value has to
// reach the profile's own patch layer rather than live only in this process.
//
// `ns` is the profile ENTRY id — the `id` of this plugin's row in the composed
// tree, which is `dsh-recall` in cordis.patch.yml — not a namespace this
// plugin invents. The revision is read back from `describe()` so a
// configuration dialog that saved in the meantime refuses this write instead
// of being silently overwritten.
ctx.inject(['settings'], (settingsCtx) => {
try {
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
setSource: (current) => {
readSettings = current
},
// Settings changes take effect on the next call: the embedder is built
// per call, and `recheck` re-validates the stored embedding identity
// against the new snapshot, so there are no registration-level facts to
// re-judge here.
onChange: () => {},
// Constraints the schema cannot express, refused at WRITE time rather
// than discovered on the next search.
validate: validateSettings,
})
writeSettings = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch)
} catch (error) {
logger.warn('dsh-recall: settings namespace unavailable: %s', error?.message ?? error)
writeSettings = async (patch) => {
const revision = settingsCtx.settings
.describe()
.find((descriptor) => descriptor.ns === SETTINGS_NAMESPACE)?.revision
await settingsCtx.settings.update(SETTINGS_NAMESPACE, patch, revision)
}
})
@@ -916,10 +995,34 @@ export function apply(ctx, config) {
},
isConcurrencySafe: (args) => (args.action ?? 'search') !== 'index',
async execute(args, exec) {
const settings = readSettings() ?? config
const settings = readSettings()
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
const action = args.action ?? 'search'
const workspace = basename(cwd) || cwd
// Validate the live section before using it, every call.
//
// The Host parses a write against the schema, so anything a single field
// can express is refused at the document; the cross-field rules — a cap
// below its target, an overlap that never advances a split — are not
// expressible that way, and 0.1.7 has no write-time validator hook to
// hang them on. Checking here is what keeps a bad combination from
// surfacing later as an unexplained empty search.
try {
validateSettings(settings)
} catch (error) {
return {
action,
ok: false,
workspace,
errorCode: 'INVALID_SETTINGS',
note: `The dsh-recall configuration is not usable as it stands: ${error?.message ?? error}`,
remedy:
'Fix the conflicting values in Settings → Plugins → dsh-recall, or in the dsh-recall ' +
'row of your cordis layer.',
}
}
const embedder = embedderFor(settings)
// Resolve the scope before anything else, because it decides which index
@@ -1035,6 +1138,21 @@ export function apply(ctx, config) {
remedy: `Known settings: ${SETTING_KEYS.join(', ')}.`,
}
}
// Judge the SECTION the write would leave behind, not the patch on its
// own: `maxLimit` alone is always fine, and `defaultLimit` alone is
// always fine, but raising one past the other is the mistake worth
// catching — and it is invisible to any single value's schema.
try {
validateSettings({ ...settings, ...patch })
} catch (error) {
return {
...base,
ok: false,
errorCode: 'INVALID_SETTING',
note: `Refused: ${error?.message ?? error}`,
remedy: 'Nothing was written. Send a patch whose resulting section is consistent.',
}
}
if (writeSettings === undefined) {
return {
...base,
@@ -1044,7 +1162,17 @@ export function apply(ctx, config) {
remedy: `Set these values in the dsh-recall row of your cordis layer instead: ${JSON.stringify(patch)}`,
}
}
try {
await writeSettings(patch)
} catch (error) {
return {
...base,
ok: false,
errorCode: 'SETTINGS_WRITE_FAILED',
note: `The settings document refused the write: ${error?.message ?? error}`,
remedy: 'Re-read the current values and retry; a concurrent edit is the usual cause.',
}
}
return { ...base, ok: true, note: `Updated ${Object.keys(patch).join(', ')}.` }
}
+1 -1
View File
@@ -35,8 +35,8 @@
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar-right"
]
+431 -204
View File
@@ -2,20 +2,34 @@
* The browser half, without a browser.
*
* `client/client.js` is a plain script that hands a factory to the platform's
* module loader, so it can be exercised in Node by stubbing `window` and
* `require('react')`. That is worth doing rather than leaving the card to be
* eyeballed in a running app, because the two things most likely to be wrong are
* both structural and both invisible until someone opens Settings:
* module loader, so it can be exercised in Node by stubbing `window`, `react`,
* and the one primitives package it imports. That is worth doing rather than
* leaving the page to be eyeballed in a running app, because the failures this
* file exists to catch are structural and invisible until someone opens Plugins:
*
* - the root element must be an `<li>`, because the tab renders every card
* into a `<ul>` and a `<div>` never receives the list styling;
* - the card must start collapsed and disclose on click, because that is the
* framework's card contract and the thing that was wrong the first time.
* - the configuration page is registered into `plugins.row.config` under
* `<bundle package>#<row id>`. The 0.1.5 seat (`settings.plugin.item`, bound
* through `settingsScope`) does not exist in 0.1.7, so a page left in it
* registers against a slot nobody declares and simply never appears.
* - the right-sidebar tab needs a TYPE registered before its body means
* anything: the keyed body slot is dispatched under the definition's `id`.
* - the field list here and the `Config` schema in index.js must name the same
* settings, or a setting is silently uneditable.
*
* Two things are stubbed rather than real, and the tests that depend on them say
* so. `SettingsFormModel` and the field controls come from
* `@deepseek-ai/dsh-client-ui-primitives`, whose bundle imports CSS and is
* therefore not loadable in bare Node; the stub records what this plugin hands
* the framework and answers the same questions the real one does. What the
* framework is trusted for — staging, save ordering, revision fencing, the
* rendered chrome — is covered by the harness's own suite, not here.
*/
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { SETTING_KEYS } from '../index.js'
/** A React good enough to run one component: element, state, effect, memo. */
function makeReact() {
const hooks = []
@@ -53,8 +67,8 @@ function makeReact() {
/**
* Expand a function element the way React would.
*
* The card renders each control as `h(Field, …)`, and those elements are only
* real DOM once React calls them — so a walk that stops at `type === 'input'`
* The card renders its controls as `h(Field, …)`, and those elements are only
* real nodes once React calls them — so a walk that stops at `type === 'input'`
* finds nothing without this.
*/
function expand(node) {
@@ -73,31 +87,114 @@ function find(node, predicate, found = []) {
return found
}
/** The input carrying a given accessible label. */
function inputFor(tree, label) {
return find(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
}
/**
* Stand-ins for the framework's settings-form kit.
*
* The model records the specs and secret writers this plugin hands it, and
* answers `shell`/`field`/`plan` the way the real one does; the controls render
* plain nodes carrying the props they were given.
*/
function makePrimitives(record) {
class SettingsFormModel {
constructor(scope, specs, secrets = []) {
this.scope = scope
this.specs = specs
this.secrets = secrets
this.staged = new Map()
record.model = this
}
/** The save button, whatever its current label. */
function saveButton(tree) {
return find(tree, (node) => node.type === 'button' && String(node.props.children).startsWith('Save'))[0]
}
bind(project) {
this.project = project
return { getSnapshot: () => project(), subscribe: () => () => {} }
}
/** A settings scope that records writes instead of performing them. */
function makeScope(options = {}) {
const { value = {}, user = null, status = 'ready', writable = true } = options
const calls = []
const snapshot = { status, value, user, writable }
shell() {
return {
calls,
getSnapshot: () => snapshot,
subscribe: () => () => {},
set: async (key, next) => {
calls.push({ op: 'set', key, value: next })
available: this.scope.getSnapshot().status === 'ready',
writable: this.scope.getSnapshot().writable,
dirty: this.staged.size > 0,
invalid: [...this.staged].some(([name, text]) => {
if (this.secrets.some((entry) => entry.field === name)) return false
return this.specs.find((entry) => entry.field === name).parse(text) === undefined
}),
saving: false,
failed: false,
}
}
field(name) {
if (this.secrets.some((entry) => entry.field === name)) {
return { text: this.staged.get(name) ?? '', overridden: false, invalid: false }
}
const spec = this.specs.find((entry) => entry.field === name)
const text = this.staged.has(name)
? this.staged.get(name)
: spec.format(this.scope.getSnapshot().value?.[name])
const write = spec.parse(text)
const user = this.scope.getSnapshot().user
return {
text,
overridden: this.staged.has(name) ? write?.kind === 'set' : Object.hasOwn(user ?? {}, name),
invalid: write === undefined,
}
}
actions() {
return {
edit: (name, text) => {
this.staged.set(name, text)
record.edits.push({ name, text })
},
unset: async (key) => {
calls.push({ op: 'unset', key })
resetField: (name) => this.staged.set(name, undefined),
save: () => {
record.saved = true
},
discard: () => this.staged.clear(),
}
}
dispose() {
record.disposed = true
}
/** What a save would write, built from this plugin's own spec objects. */
plan() {
const plan = []
for (const [name, text] of this.staged) {
if (this.secrets.some((entry) => entry.field === name)) {
plan.push({ field: name, run: () => this.secrets.find((s) => s.field === name).write(String(text)) })
continue
}
const write = this.specs.find((entry) => entry.field === name).parse(String(text))
if (write === undefined) plan.push({ field: name })
else if (write.kind === 'clear') plan.push({ field: name, op: { op: 'unset', path: [name] } })
else plan.push({ field: name, op: { op: 'set', path: [name], value: write.value } })
}
return plan
}
}
return {
SettingsForm: (props) => ({ type: 'form', props }),
SettingsValueField: (props) => ({ type: 'input', props }),
SettingsSecretField: (props) => ({ type: 'input', props: { ...props, type: 'password' } }),
SettingsFormModel,
settingsNumberField: (field) => ({
field,
format: (value) => (value === undefined || value === null ? '' : String(value)),
parse: (text) =>
String(text).trim() === ''
? { kind: 'clear' }
: Number.isFinite(Number(text))
? { kind: 'set', value: Number(text) }
: undefined,
}),
settingsTextField: (field) => ({
field,
format: (value) => (value === undefined || value === null ? '' : String(value)),
parse: (text) => (String(text).trim() === '' ? { kind: 'clear' } : { kind: 'set', value: text }),
}),
}
}
@@ -108,20 +205,28 @@ await import('../client/client.js')
const entry = loaded[0]
/** Instantiate the module with a stubbed `react`. */
function makeModule() {
/**
* Instantiate the bundle, mount it on a stubbed context, and expose what it
* registered.
*
* @param {object} options `services` mounts extra optional dependencies,
* `served` is what the (stubbed) describe mirror reports, and
* `withConfigForms: false` models a deployment with no settings provider.
*/
function mount(options = {}) {
const { services = {}, served = ['dsh-recall'], withConfigForms = true } = options
const record = { edits: [], saved: false, disposed: false, model: undefined }
const react = makeReact()
const primitives = makePrimitives(record)
const factory = entry.factory((name) => {
if (name === 'react') return react
if (name === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`unexpected import: ${name}`)
})
return { react, module: factory }
}
/** Register every slot the browser half contributes, and render any of them. */
function mount(scope) {
const { react, module } = makeModule()
const registered = []
const tabTypes = []
const whileServedCalls = []
const slots = {
inject: (name, fn) => fn(),
register: (meta, component) => {
@@ -129,49 +234,122 @@ function mount(scope) {
return () => {}
},
}
const form = {
calls: [],
snapshot: { status: 'ready', value: {}, base: {}, user: {}, writable: true, revision: 9 },
getSnapshot() {
return this.snapshot
},
subscribe: () => () => {},
async mutate(ops, revision) {
this.calls.push({ ops, revision })
return true
},
}
const describe = {
getSnapshot: () => ({
status: 'ready',
view: {
writable: true,
namespaces: served.map((ns) => ({ ns, secrets: [{ path: ['embeddingApiKey'], set: true }] })),
},
}),
subscribe: () => () => {},
}
const configForms = {
requested: undefined,
get(id) {
this.requested = id
return form
},
describe: () => describe,
whileServed: (namespaces, register) => {
whileServedCalls.push({ namespaces })
const live = namespaces.some((ns) => served.includes(ns))
const off = live ? register(new Set(served)) : undefined
return () => off?.()
},
}
const sidebarRightTabs =
services.sidebarRightTabs ??
{
register: (definition) => {
tabTypes.push(definition)
return () => {}
},
}
const ctx = {
slots,
inject: (deps, cb) =>
cb({
settingsScope: {
bind: (options) => {
assert.equal(options.namespace, 'dsh-recall')
return scope
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') record.disposables = [...(record.disposables ?? []), dispose]
},
inject: (deps, callback) => {
const available = {
...services,
sidebarRightTabs,
...(withConfigForms ? { configForms } : {}),
}
const missing = deps.filter((dep) => available[dep] === undefined)
if (missing.length > 0) return
callback({ ...ctx, ...Object.fromEntries(deps.map((dep) => [dep, available[dep]])) })
},
},
slots,
}),
}
module.apply(ctx)
const byName = new Map(registered.map((entry) => [entry.meta.name, entry]))
factory.apply(ctx)
const byName = new Map(registered.map((item) => [item.meta.name, item]))
/** Render one registered slot with the props its occupant would receive. */
const renderSlot = (name, props = {}) => {
const entry = byName.get(name)
assert.ok(entry !== undefined, `nothing registered for ${name}`)
const item = byName.get(name)
assert.ok(item !== undefined, `nothing registered for ${name}`)
react.__render()
const element = entry.component(props)
const element = item.component(props)
if (element === null) return null
return element.type(element.props)
}
/** Render the settings card, at its current state. */
const render = () => {
react.__render()
const element = byName.get('settings.plugin.item').component({ scope })
return element.type(element.props)
/**
* The settings page's element plus the props it would receive.
*
* `hooks` is resolved into `use<Name>` here exactly as the slot machinery
* does, so the component under test sees the props a real render would give it.
*/
const card = (view) => {
const item = byName.get('plugins.row.config')
assert.ok(item !== undefined, 'no settings page was registered')
const props = { ...item.meta.inject(), view }
delete props.hooks
props.useRecallSettings = (selector) => selector(record.model.project())
return { item, props, element: item.component(props) }
}
return { react, module, meta: byName.get('settings.plugin.item').meta, registered, render, renderSlot }
return {
react,
module: factory,
record,
form,
registered,
tabTypes,
whileServedCalls,
renderSlot,
card,
configForms,
}
}
/** Open the card, and hand back a function returning the CURRENT body. */
function openCard(render) {
render().props.children[0].props.onClick()
// Call this after any edit: React re-renders then, and the Save button the
// user actually clicks is the one created by that later render.
return () => render().props.children[1]
/** The rendered page's control nodes, keyed by their HTML id. */
function controlsOf(element) {
const form = expand(element)
const controls = new Map()
for (const node of find(form, (candidate) => candidate.type === 'input')) controls.set(node.props.id, node)
return controls
}
test('the bundle registers under the loader with the plugin id', () => {
@@ -180,146 +358,203 @@ test('the bundle registers under the loader with the plugin id', () => {
assert.equal(typeof entry.factory, 'function')
})
test('the client module claims slots and reports the host namespace', () => {
const { module } = makeModule()
test('the client module claims the slot registry in its inject list', () => {
const { module } = mount()
assert.equal(module.name, 'dsh-recall')
assert.deepEqual(module.inject, ['slots'])
})
test('the card is registered under the same key the host half serves', () => {
const { meta } = mount(makeScope())
assert.equal(meta.name, 'settings.plugin.item')
assert.equal(meta.key, 'dsh-recall', 'the host namespace and the card key must match, or nothing renders')
assert.equal(typeof meta.inject, 'function')
test('the configuration page claims the 0.1.7 seat, keyed as the patch declares it', () => {
const { registered } = mount()
const page = registered.find((item) => item.meta.name === 'plugins.row.config')
assert.ok(page !== undefined, 'the page must register into plugins.row.config')
// `<bundle package name>#<row id>`: the row id is the one this bundle's own
// cordis.patch.yml declares, which is what the Plugins page dispatches by.
assert.equal(page.meta.key, 'dsh-recall#dsh-recall')
assert.equal(typeof page.meta.inject, 'function')
})
test('the root is an <li>, because the tab renders cards into a <ul>', () => {
const { render } = mount(makeScope())
assert.equal(render().type, 'li')
test('the page edits the settings entry the Host keys this plugin under', () => {
const { configForms, whileServedCalls } = mount()
assert.equal(configForms.requested, 'dsh-recall')
assert.deepEqual(whileServedCalls, [{ namespaces: ['dsh-recall'] }])
})
test('the card starts collapsed and discloses on click', () => {
const { render } = mount(makeScope())
const closed = render()
const [closedHeader, closedBody] = closed.props.children
assert.equal(closedHeader.type, 'button')
assert.equal(closedHeader.props['aria-expanded'], false, 'the card must start collapsed')
assert.equal(closedBody, null, 'a collapsed card renders no body')
// Click the header, then render again — hook state persists across renders.
closedHeader.props.onClick()
const [openHeader, openBodyNode] = render().props.children
assert.equal(openHeader.props['aria-expanded'], true)
assert.notEqual(openBodyNode, null, 'an expanded card renders its controls')
assert.equal(openBodyNode.type, 'div')
test('no page is registered for a namespace the Host does not serve', () => {
// A control that writes nowhere is worse than no control: the page would open
// onto fields nothing would accept.
const { registered } = mount({ served: ['some-other-plugin'] })
assert.equal(
registered.some((item) => item.meta.name === 'plugins.row.config'),
false,
)
})
test('every host setting appears as a control', () => {
const { render } = mount(makeScope())
const body = openCard(render)
const labels = find(body(), (node) => node.type === 'input').map((node) => node.props['aria-label'])
for (const label of [
'Embedding endpoint',
'Embedding model',
'Embedding dimensions',
'Embedding API key',
'Lines per excerpt',
'Index warm-up budget per search (ms)',
]) {
assert.ok(labels.includes(label), `expected a control labelled ${label}, saw ${labels.join(' | ')}`)
}
})
test('an unavailable namespace renders nothing at all', () => {
const { render } = mount(makeScope({ status: 'unavailable' }))
assert.equal(render(), null)
})
test('a read-only namespace says so rather than offering a dead form', () => {
const { render } = mount(makeScope({ writable: false }))
const body = openCard(render)
assert.ok(find(body(), (node) => node.props?.role === 'status').length > 0)
})
test('a save writes only the fields the user changed', async () => {
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434', excerptLines: 40 } })
const { render } = mount(scope)
const body = openCard(render)
inputFor(body(), 'Lines per excerpt').props.onChange({ target: { value: '25' } })
const button = saveButton(body())
assert.match(String(button.props.children), /Save 1 change/)
await button.props.onClick()
assert.deepEqual(scope.calls, [{ op: 'set', key: 'excerptLines', value: 25 }])
})
test('clearing a non-secret field unsets it rather than writing an empty value', async () => {
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434' } })
const { render } = mount(scope)
const body = openCard(render)
inputFor(body(), 'Embedding endpoint').props.onChange({ target: { value: '' } })
await saveButton(body()).props.onClick()
assert.deepEqual(scope.calls, [{ op: 'unset', key: 'embeddingApiBase' }])
})
test('a list field round-trips as a comma separated string', async () => {
const scope = makeScope({ value: { exclude: ['dist/', 'vendor/'] } })
const { render } = mount(scope)
const body = openCard(render)
const input = inputFor(body(), 'Extra exclude patterns')
assert.equal(input.props.value, 'dist/, vendor/')
input.props.onChange({ target: { value: 'dist/, vendor/, tmp/' } })
await saveButton(body()).props.onClick()
assert.deepEqual(scope.calls, [{ op: 'set', key: 'exclude', value: ['dist/', 'vendor/', 'tmp/'] }])
})
test('the API key is never sent back to the page, and blank does not clear it', async () => {
// The host marks this field role('secret'), so it arrives absent. A save with
// the field untouched must not write anything for it — otherwise opening the
// card and pressing Save would delete the key.
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434' }, user: { embeddingApiKey: 'stored' } })
const { render } = mount(scope)
const body = openCard(render)
const keyInput = inputFor(body(), 'Embedding API key')
assert.ok(keyInput !== undefined)
assert.equal(keyInput.props.type, 'password')
assert.equal(keyInput.props.value, '', 'a redacted secret must render blank')
assert.equal(keyInput.props.placeholder, '(unchanged)')
// Nothing is dirty, so there is nothing to save and the button is inert.
const button = saveButton(body())
assert.equal(button.props.disabled, true)
await button.props.onClick()
assert.deepEqual(scope.calls, [], 'an untouched secret must never be written or unset')
})
test('an unsaved edit is marked on the header while collapsed', () => {
const { render } = mount(makeScope({ value: { excerptLines: 40 } }))
const body = openCard(render)
inputFor(body(), 'Lines per excerpt').props.onChange({ target: { value: '25' } })
// Collapse again and look at the header.
render().props.children[0].props.onClick()
const collapsed = render()
const marked = find(collapsed.props.children[0], (node) => node.props?.children === 'unsaved')
assert.equal(marked.length, 1, 'unsaved edits must be visible while the card is folded away')
})
test('the browser half contributes four slots, one of them the header corner', () => {
const { registered } = mount(makeScope())
const names = registered.map((entry) => entry.meta.name).sort()
assert.deepEqual(names, [
test('the chip and the sidebar tab survive a deployment with no settings provider', () => {
// The page is reached through `ctx.inject`, not a top-level `inject`, so a
// profile without a settings provider loses the page and keeps the rest.
const { registered, tabTypes } = mount({ withConfigForms: false })
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner',
'settings.plugin.item',
'sidebar.right.pane.tab',
'sidebar.right.pane.tab.title',
])
assert.equal(tabTypes.length, 1, 'the tab type is independent of settings')
})
test('the header chip is a single-seat contribution with no key', () => {
const { registered } = mount()
const chip = registered.find((item) => item.meta.name === 'conversation.session.header.corner')
assert.ok(chip !== undefined)
assert.equal(chip.meta.key, undefined, 'the corner is a single seat, so it takes no key')
})
test('the sidebar tab registers a type AND a body under the same id', () => {
// The body slot is dispatched under the id of the type in force for a tab's
// kind. A body with no type is a page nothing can open.
const { registered, tabTypes } = mount()
const body = registered.find((item) => item.meta.name === 'sidebar.right.pane.tab')
assert.ok(body !== undefined)
assert.equal(body.meta.key, 'dsh-recall')
assert.equal(tabTypes.length, 1, 'exactly one tab type')
assert.equal(tabTypes[0].id, 'dsh-recall', "the body's key must be the definition's id")
assert.equal(tabTypes[0].kind, 'dsh-recall')
assert.equal(tabTypes[0].priority, 'extension', 'a type from outside the product sits in that band')
assert.equal(typeof tabTypes[0].title, 'function')
assert.equal(tabTypes[0].title(), 'Recall')
// A page type recognises no address, so the guide entry is the only way in.
assert.equal(tabTypes[0].guide.length, 1)
assert.equal(tabTypes[0].guide[0].title(), 'Recall')
assert.match(tabTypes[0].guide[0].description(), /kept out/)
})
test('the browser half contributes three seats, none of them the retired 0.1.5 one', () => {
const { registered } = mount()
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner',
'plugins.row.config',
'sidebar.right.pane.tab',
])
})
test('the page asks for nothing to be edited before the user types', () => {
const { card } = mount()
const { element } = card('page')
const state = find(element, (node) => node.type === 'form')[0]
assert.equal(state.props.state.dirty, false)
assert.equal(state.props.state.invalid, false)
assert.equal(state.props.state.writable, true)
assert.equal(typeof state.props.onSave, 'function')
assert.equal(typeof state.props.onDiscard, 'function')
assert.equal(state.props.labels.save, 'Save')
})
test('the summary view is the one-liner the Plugins page falls back to', () => {
const { card } = mount()
// The page asks for `summary` to fill a row with no description of its own,
// so it must be text, and it must describe the plugin rather than the form.
const summary = card('summary').element
assert.equal(typeof summary, 'string')
assert.match(summary, /Local embedding endpoint/)
// And the page view is the form, not that string.
assert.notEqual(typeof card('page').element, 'string')
})
test('every declared setting renders a control, and the API key renders as a secret', () => {
const { card } = mount()
const controls = controlsOf(card('page').element)
assert.deepEqual(
[...controls.keys()].sort(),
SETTING_KEYS.map((key) => `dsh-recall-${key}`).sort(),
'every setting in the host schema must be editable here, and nothing else',
)
const secret = controls.get('dsh-recall-embeddingApiKey')
assert.equal(secret.props.type, 'password')
assert.equal(secret.props.text, '', 'a redacted secret renders blank')
// The page learns whether a key is stored, never the key itself.
assert.equal(secret.props.configured, true)
assert.match(secret.props.stateLabel, /stored/)
})
test('the field list and the schema cannot drift apart', () => {
const { record } = mount()
assert.deepEqual(
record.model.specs.map((spec) => spec.field).sort(),
SETTING_KEYS.filter((key) => key !== 'embeddingApiKey').sort(),
'every non-secret setting needs a conversion spec',
)
assert.deepEqual(record.model.secrets.map((spec) => spec.field), ['embeddingApiKey'])
})
test('a list setting round-trips as comma separated text', () => {
const { record } = mount()
const spec = record.model.specs.find((entry) => entry.field === 'exclude')
assert.equal(spec.format(['dist/', 'vendor/']), 'dist/, vendor/')
assert.deepEqual(spec.parse('dist/, vendor/, tmp/'), { kind: 'set', value: ['dist/', 'vendor/', 'tmp/'] })
assert.deepEqual(spec.parse(''), { kind: 'clear' }, 'emptying the control reverts to the schema default')
})
test('a boolean setting accepts the obvious spellings and refuses the rest', () => {
const { record } = mount()
const spec = record.model.specs.find((entry) => entry.field === 'rerankEnabled')
assert.equal(spec.format(true), 'true')
assert.equal(spec.format(false), 'false')
assert.deepEqual(spec.parse('TRUE'), { kind: 'set', value: true })
assert.deepEqual(spec.parse('off'), { kind: 'set', value: false })
assert.deepEqual(spec.parse(''), { kind: 'clear' })
// Anything else blocks the save rather than guessing which way the user meant.
assert.equal(spec.parse('maybe'), undefined)
})
test('an invalid draft is reported, so the framework can block the save', () => {
const { record } = mount()
record.model.actions().edit('embeddingConcurrency', 'lots')
assert.equal(record.model.field('embeddingConcurrency').invalid, true)
assert.equal(record.model.shell().invalid, true)
})
test('an edit is staged in the model, never written to the document on the spot', () => {
const { record, form } = mount()
record.model.actions().edit('embeddingConcurrency', '4')
assert.deepEqual(record.edits, [{ name: 'embeddingConcurrency', text: '4' }])
assert.deepEqual(form.calls, [], 'only a save writes')
})
test('the API key is written through the document, fenced by the current revision', async () => {
const { record, form } = mount()
const secret = record.model.secrets[0]
assert.equal(secret.field, 'embeddingApiKey')
await secret.write('sk-secret')
assert.deepEqual(form.calls, [
{ ops: [{ op: 'set', path: ['embeddingApiKey'], value: 'sk-secret' }], revision: 9 },
])
})
test('a blank secret is never staged, so opening the page cannot delete the key', () => {
const { record } = mount()
assert.deepEqual(record.model.plan(), [], 'nothing typed means nothing to write')
})
test('a typed secret is planned as a write outside the section, not as a section op', () => {
const { record } = mount()
record.model.actions().edit('embeddingApiKey', 'sk-secret')
const plan = record.model.plan()
assert.equal(plan.length, 1)
assert.equal(plan[0].field, 'embeddingApiKey')
assert.equal(plan[0].op, undefined, 'a secret rides its own writer, because the wire never returns it')
})
test('a later revision is carried by the next write rather than the one staged from', async () => {
const { record, form } = mount()
form.snapshot = { ...form.snapshot, revision: 12 }
await record.model.secrets[0].write('sk-newer')
assert.equal(form.calls[0].revision, 12, 'every write carries the revision it was staged against')
})
test('the form subscription is released with the plugin', () => {
const { record } = mount()
for (const dispose of record.disposables ?? []) dispose()
// The controller's disposer runs the model's, so a reload leaks no listener.
assert.equal(record.disposed, true)
})
/** A projection value shaped like the host unit's wire view. */
@@ -344,7 +579,7 @@ function usageView(overrides = {}) {
}
test('the header chip renders nothing when the conversation never used it', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
// The corner is laid out only while its occupant renders something, so this
// is the state that must cost no space at all.
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => undefined }), null)
@@ -354,7 +589,7 @@ test('the header chip renders nothing when the conversation never used it', () =
})
test('the header chip leads with what was kept out, not what it cost', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() })
assert.ok(chip !== null)
const text = String(chip.props.children)
@@ -367,7 +602,7 @@ test('the header chip leads with what was kept out, not what it cost', () => {
})
test('the chip tooltip carries the figures the chip has no room for', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ rereadTokens: 500_000 }) })
const detail = chip.props.title
assert.match(detail, /3 searches/)
@@ -377,7 +612,7 @@ test('the chip tooltip carries the figures the chip has no room for', () => {
})
test('the sidebar panel explains an unused conversation instead of showing zeroes', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
const empty = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ searches: 0, indexes: 0, excerpts: 0 }) })
const text = JSON.stringify(empty)
assert.match(text, /has not used recall yet/)
@@ -385,13 +620,13 @@ test('the sidebar panel explains an unused conversation instead of showing zeroe
})
test('the sidebar panel says so when the projection is absent', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
const missing = renderSlot('sidebar.right.pane.tab', {})
assert.match(JSON.stringify(missing), /not available for this conversation/)
})
test('the sidebar panel reports the comparison and the compounding', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
const panel = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ rereadTokens: 1_200_000 }) })
const text = JSON.stringify(panel)
assert.match(text, /Kept out of this conversation/)
@@ -400,11 +635,3 @@ test('the sidebar panel reports the comparison and the compounding', () => {
assert.match(text, /1,200,000 tokens/)
assert.match(text, /upper bound/, 'the panel must say what the figure is not')
})
test('the tab title is registered under the same key as the panel', () => {
const { registered } = mount(makeScope())
const panel = registered.find((entry) => entry.meta.name === 'sidebar.right.pane.tab')
const title = registered.find((entry) => entry.meta.name === 'sidebar.right.pane.tab.title')
assert.equal(panel.meta.key, 'dsh-recall')
assert.equal(title.meta.key, panel.meta.key, 'the title must bind to the panel it names')
})
+80 -19
View File
@@ -1,15 +1,21 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { Config, SETTING_KEYS, validateSettings } from '../index.js'
import { Config, SETTING_KEYS, configValue, settingsSnapshot, validateSettings } from '../index.js'
/**
* The load-time contract.
* The load-time contract, and the shape settings arrive in.
*
* `apply` never runs when Cordis rejects a plugin's `Config`, so these are the
* only failures a plugin cannot report from inside itself — the profile simply
* refuses to boot. The rest of the suite calls `apply` directly and would never
* notice, which is exactly why this file exists separately.
*
* There are two contracts here, not one. The first is validation. The second is
* that a field marked `volatile()` arrives as a live reference rather than a
* value — so a schema edit changes both what the plugin validates against and
* how it must be read. Both are asserted, because getting the second wrong
* looks like a config of `undefined`s rather than like an error.
*/
test('Config is a Standard Schema, which is what Cordis requires', () => {
@@ -17,29 +23,35 @@ test('Config is a Standard Schema, which is what Cordis requires', () => {
assert.ok(Config['~standard'] !== undefined, 'Config must implement the Standard Schema interface')
})
/** The section as the plugin reads it: schema-resolved, then unwrapped. */
function resolve(patch = {}) {
return settingsSnapshot(Config(patch))
}
test('Config fills every field from an empty input, so apply can rely on defaults', () => {
const filled = Config({})
for (const key of SETTING_KEYS) {
assert.ok(key in filled, `${key} has no default and would be undefined in apply`)
}
assert.equal(filled.embeddingApiBase, 'http://127.0.0.1:11434')
assert.equal(filled.embeddingDimensions, 768)
assert.equal(filled.syncBudgetMs, 20_000)
assert.deepEqual(filled.exclude, [])
assert.deepEqual(filled.extensions, [])
const resolved = settingsSnapshot(filled)
assert.equal(resolved.embeddingApiBase, 'http://127.0.0.1:11434')
assert.equal(resolved.embeddingDimensions, 768)
assert.equal(resolved.syncBudgetMs, 20_000)
assert.deepEqual(resolved.exclude, [])
assert.deepEqual(resolved.extensions, [])
})
test('Config keeps an explicit value and fills the rest', () => {
const filled = Config({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
assert.equal(filled.embeddingApiBase, 'http://10.50.1.2:11434')
assert.equal(filled.embeddingConcurrency, 8)
assert.equal(filled.embeddingBatchSize, 16)
assert.equal(filled.embeddingTimeoutMs, 30_000)
assert.equal(filled.embeddingKeepAlive, '5m')
const resolved = resolve({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
assert.equal(resolved.embeddingApiBase, 'http://10.50.1.2:11434')
assert.equal(resolved.embeddingConcurrency, 8)
assert.equal(resolved.embeddingBatchSize, 16)
assert.equal(resolved.embeddingTimeoutMs, 30_000)
assert.equal(resolved.embeddingKeepAlive, '5m')
// The per-excerpt cap equals the chunk cap, so chunks arrive whole.
assert.equal(filled.excerptLines, 80)
assert.equal(filled.totalExcerptLines, 400)
assert.equal(filled.maxChunkLines, 80)
assert.equal(resolved.excerptLines, 80)
assert.equal(resolved.totalExcerptLines, 400)
assert.equal(resolved.maxChunkLines, 80)
})
test('a wrongly typed value fails loudly at load rather than silently in apply', () => {
@@ -48,6 +60,48 @@ test('a wrongly typed value fails loudly at load rather than silently in apply',
assert.throws(() => Config({ embeddingApiBase: { host: 'localhost' } }))
})
test('a value outside a field range is refused by the schema, not just by validateSettings', () => {
// These bounds are duplicated in validateSettings on purpose, but the copy
// that matters most is this one: it is what the generated settings form
// validates a draft against before the Host is ever asked to store it.
assert.throws(() => Config({ minSimilarity: 1.5 }), /<= 1/)
assert.throws(() => Config({ chunkLines: 0 }), />= 1/)
assert.throws(() => Config({ overlapLines: -1 }), />= 0/)
assert.throws(() => Config({ embeddingDimensions: -1 }), />= 1/)
})
test('every declared field is volatile, or the settings surface is not served', () => {
// `ctx.settings.describe` skips an entry with no volatile field, so this is
// the difference between a configuration page and no configuration page —
// not between a full page and an empty one.
const envelope = JSON.parse(JSON.stringify(Config.toJSON()))
const root = envelope.refs[String(envelope.uid)]
const notVolatile = Object.entries(root.dict)
.filter(([, uid]) => envelope.refs[String(uid)].meta?.volatile !== true)
.map(([key]) => key)
assert.deepEqual(notVolatile, [], 'every field must be volatile')
})
test('a volatile field reads through configValue; a plain one is handed back', () => {
const filled = Config({ embeddingApiBase: 'http://stub' })
assert.equal(typeof filled.embeddingApiBase, 'object', 'a volatile field arrives as a reference')
assert.equal(configValue(filled.embeddingApiBase), 'http://stub')
// A test (or any caller) that hands `apply` a plain resolved object must keep
// working: the same reader serves both shapes.
assert.equal(configValue('http://plain'), 'http://plain')
assert.equal(configValue(undefined), undefined)
assert.equal(configValue(0), 0)
})
test('a snapshot tracks a live edit, because volatile references update in place', () => {
const filled = Config({ embeddingConcurrency: 2 })
assert.equal(settingsSnapshot(filled).embeddingConcurrency, 2)
// This is what the Loader does on a volatile-only config change: write the new
// value into the reference the running instance already holds.
filled.embeddingConcurrency[Object.getOwnPropertySymbols(filled.embeddingConcurrency)[0]](8)
assert.equal(settingsSnapshot(filled).embeddingConcurrency, 8)
})
test('every declared setting is a real schema key, so none is dead config', () => {
const filled = Config({})
for (const key of SETTING_KEYS) {
@@ -60,13 +114,20 @@ test('every declared setting is a real schema key, so none is dead config', () =
}
})
/** The defaults plus an override, as the settings provider would resolve them. */
/**
* A plain section with an override laid over the resolved defaults.
*
* Deliberately built WITHOUT going back through `Config`: these tests are about
* the rules `validateSettings` owns, and half of them are also schema bounds —
* so parsing them would fail at the schema and never reach the rule under test.
* The schema's own refusal of the same values is asserted separately above.
*/
function settingsWith(patch) {
return { ...Config({}), ...patch }
return { ...settingsSnapshot(Config({})), ...patch }
}
test('validateSettings accepts the defaults', () => {
assert.doesNotThrow(() => validateSettings(Config({})))
assert.doesNotThrow(() => validateSettings(resolve()))
})
test('validateSettings rejects an endpoint that is not a URL', () => {
+131 -5
View File
@@ -3,7 +3,7 @@ import { readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { test } from 'node:test'
import { apply } from '../index.js'
import { apply, Config, SETTING_KEYS, settingsSnapshot } from '../index.js'
import { makeContext, makeExec, makeStateDir, makeStubFetch, makeWorkspace } from './helpers.js'
/**
@@ -135,6 +135,7 @@ function configFor(stateDir, extra = {}) {
embeddingBatchSize: 32,
embeddingConcurrency: 2,
embeddingTimeoutMs: 5_000,
embeddingKeepAlive: '5m',
extensions: [],
exclude: [],
maxFiles: 500,
@@ -146,12 +147,16 @@ function configFor(stateDir, extra = {}) {
maxLimit: 25,
perFileLimit: 2,
excerptLines: 40,
totalExcerptLines: 400,
minSimilarity: 0.2,
syncBudgetMs: 20_000,
// These mirror the Config schema exactly. Cordis fills schema defaults
// before `apply`, and this object stands in for that validated config — so
// any key the code reads and this object omits arrives as `undefined`, which
// is how `AbortSignal.timeout(NaN)` came to throw inside the expander.
// This stands in for the config Cordis hands `apply` after schema
// resolution: every declared field present, defaults filled. A key this
// object omits arrives as `undefined` in the plugin — which is how
// `AbortSignal.timeout(NaN)` once came to throw inside the expander, and how
// the use-time `validateSettings` guard below came to reject a whole call.
// `configFor covers exactly the declared settings` in plugin.test.js keeps
// the two lists from drifting apart again.
focus: '',
focusMarkers: [],
expansionEnabled: false,
@@ -199,6 +204,39 @@ test('the plugin registers one tool whose declared schema is closed', async (t)
assert.equal(tool.isConcurrencySafe({ action: 'index' }), false)
})
test('configFor covers exactly the declared settings', () => {
// `configFor` stands in for the schema-resolved config Cordis hands `apply`.
// If it drifts from the schema, the plugin reads `undefined` where a default
// belongs — and `validateSettings`, which now runs on every call, turns that
// into a refused call rather than a confusing one. Deriving the list from the
// real schema is what makes that impossible to miss.
const resolved = settingsSnapshot(Config({}))
const fixture = configFor('/tmp/state')
assert.deepEqual(
Object.keys(fixture).sort(),
SETTING_KEYS.slice().sort(),
'configFor must name every declared setting and nothing else',
)
assert.deepEqual(
Object.keys(resolved).sort(),
SETTING_KEYS.slice().sort(),
'SETTING_KEYS must name every field the schema declares',
)
})
test('a configured section that cannot be acted on is refused with a remedy', async (t) => {
// The Host validates each field against Config; the cross-field rules have no
// schema to live in, so `execute` checks them before putting the section to
// work. The alternative — acting on it — is an unexplained empty search.
const { tool, workspace } = await setup(t, FILES, { defaultLimit: 50, maxLimit: 10 })
const value = await tool.execute({ query: 'session' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'INVALID_SETTINGS')
assert.match(value.note, /defaultLimit \(50\) cannot exceed maxLimit \(10\)/)
assert.match(value.remedy, /Settings/)
})
test('a search returns the right file, as an excerpt rather than a whole file', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute(
@@ -358,6 +396,94 @@ test('configure rejects an unknown setting by name', async (t) => {
assert.match(value.note, /nonsense/)
})
/**
* Mount the plugin with a settings provider, and record what it is asked to do.
*
* In 0.1.7 the namespace is the profile ENTRY id, and the revision the write is
* fenced with comes from `describe()` — so both are asserted rather than
* assumed, because getting either wrong writes to a row that does not exist or
* silently overwrites a concurrent edit.
*/
async function setupWithSettings(t, extra = {}, behaviour = {}) {
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()
const calls = { update: [], describe: 0 }
globalThis.fetch = makeStubFetch({})
t.after(async () => {
await rm(workspace, { recursive: true, force: true })
await rm(stateDir, { recursive: true, force: true })
})
const settings = {
describe: () => {
calls.describe += 1
return [
{ ns: 'some-other-plugin', revision: 3 },
{ ns: 'dsh-recall', revision: 7 },
]
},
update: async (ns, patch, revision) => {
calls.update.push({ ns, patch, revision })
if (behaviour.reject === true) throw new Error('settings/rejected')
},
}
const { ctx, tools, dispose } = makeContext({ services: { settings } })
t.after(() => dispose())
apply(ctx, configFor(stateDir, extra))
return { tool: tools[0], workspace, calls }
}
test('configure writes to the profile entry id, fenced by the revision describe reported', async (t) => {
const { tool, workspace, calls } = await setupWithSettings(t)
const value = await tool.execute(
{ action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, true)
assert.match(value.note, /embeddingApiBase/)
assert.deepEqual(calls.update, [
{ ns: 'dsh-recall', patch: { embeddingApiBase: 'http://elsewhere:11434' }, revision: 7 },
])
})
test('configure judges the section a patch would leave, and writes nothing it refuses', async (t) => {
// Raising `defaultLimit` alone is valid; raising it past `maxLimit` is not,
// and the contradiction is invisible to either field's schema.
const { tool, workspace, calls } = await setupWithSettings(t)
const value = await tool.execute(
{ action: 'configure', config: { defaultLimit: 99 } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'INVALID_SETTING')
assert.match(value.note, /defaultLimit \(99\) cannot exceed maxLimit \(25\)/)
assert.deepEqual(calls.update, [], 'a refused patch must not reach the document')
})
test('a settings write the document refuses is reported, not thrown', async (t) => {
const { tool, workspace } = await setupWithSettings(t, {}, { reject: true })
const value = await tool.execute(
{ action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'SETTINGS_WRITE_FAILED')
assert.match(value.remedy, /concurrent/)
})
test('reading settings does not need a settings provider, because volatile fields are live', async (t) => {
// The 0.1.5 shape attached a reader to the settings provider; 0.1.7 does not,
// so `apply` must not discover configured values only when one is mounted.
const { tool, workspace } = await setupWithSettings(t, { defaultLimit: 3 })
const value = await tool.execute({ query: 'session token' }, makeExec(workspace))
assert.equal(value.ok, true)
assert.ok(value.results.length <= 3, 'the configured limit must be honoured without a provider')
})
test('an unreachable endpoint fails with a remedy, not a stack trace', async (t) => {
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()