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:
1 parent
0964819cdc
commit
040c69b57d
7 files changed
+1352
-668
No files matched your search
@@ -65,17 +65,29 @@ a fact:
|
|||||||
embeddingApiBase: http://10.50.1.2:11434
|
embeddingApiBase: http://10.50.1.2:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
Or edit every setting from the UI. The plugin ships **two halves**, and the settings
|
Or edit every setting from the UI: **Plugins → dsh-recall → Configure**. The
|
||||||
page draws a card only when it has both:
|
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 **host half** (`index.js`) exports a `Config` schema whose every field is
|
||||||
- the **browser half** (`client/client.js`) claims that same namespace with a
|
`.volatile()`. That schema *is* the settings page's description — the Harness
|
||||||
collapsible card under **Settings → Plugins → Plugin configuration**.
|
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
|
A served namespace with no page draws no control, and a page whose namespace is
|
||||||
is not served is never dispatched — so both are required. A restart is needed for a
|
not served is deliberately not registered — so both are required. A restart is
|
||||||
newly installed browser half to appear, because client bundle bytes are snapshotted
|
needed for a newly installed browser half to appear, because client bundle bytes
|
||||||
when the profile activates.
|
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:
|
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"}
|
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
|
The API key is declared `role('secret')`, so it is stripped from every settings
|
||||||
settings response — the card can show that a key is set without ever receiving it,
|
response: the page can show that a key is set without ever receiving it, and a
|
||||||
and leaving it blank on save means "unchanged" rather than "clear".
|
blank draft writes nothing — leaving it alone means "unchanged", never "clear".
|
||||||
|
|
||||||
Pull the model once:
|
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 |
|
| `search` *(default)* | answers `query`, warming a bounded slice of the index first |
|
||||||
| `index` | builds or refreshes the whole index; **background job by default** |
|
| `index` | builds or refreshes the whole index; **background job by default** |
|
||||||
| `status` | index and endpoint state, touching nothing |
|
| `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),
|
Parameters: `query`, `action`, `limit`, `path` (restrict to a subdirectory),
|
||||||
`run_in_background`.
|
`run_in_background`.
|
||||||
@@ -363,10 +375,16 @@ exactly that during development.
|
|||||||
`test/config.test.js` covers the load-time contract. Cordis rejects a `Config`
|
`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
|
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
|
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
|
`test/client.test.js` runs the browser half in Node by stubbing `window`,
|
||||||
`require('react')`. That is worth more than it sounds: the two failure modes most
|
`require('react')`, and the settings-form primitives. That is worth more than it
|
||||||
likely to reach you silently are structural — a root element that is not an `<li>`,
|
sounds: the failure modes most likely to reach you silently are structural — a
|
||||||
and a card that does not start collapsed — and both are invisible until someone
|
page registered into a slot nobody declares (which is what the 0.1.5 seat,
|
||||||
opens Settings. It also pins that an untouched API key is never written or unset.
|
`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.
|
||||||
+472
-348
@@ -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
|
* Three contributions, each into a seat the Harness 0.1.7 composition declares:
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the tab
|
* 1. the plugin's configuration page, in the **Plugins** panel;
|
||||||
* renders every card into a `<ul>`), the header is its own `<button>` that
|
* 2. a one-line usage chip in a conversation's header corner;
|
||||||
* discloses the controls in place, the card starts collapsed, it marks itself
|
* 3. a right-sidebar tab with the same figures and room for their caveats.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
|
* **Where plugin settings live now.** 0.1.5 drew plugin configuration as cards
|
||||||
* step and no bundler. The only import is `react`, which is in the platform seed
|
* under Settings → Plugins → Plugin configuration, and a plugin claimed one by
|
||||||
* table. Styling is inline for the same reason: there is no stylesheet this file
|
* binding `settingsScope` and registering into a `settings.plugin.item` slot.
|
||||||
* may reach.
|
* 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({
|
window.__ModuleLoader__.load({
|
||||||
@@ -28,444 +41,513 @@ window.__ModuleLoader__.load({
|
|||||||
var exports = module.exports
|
var exports = module.exports
|
||||||
|
|
||||||
const react = require('react')
|
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 h = react.createElement
|
||||||
|
|
||||||
const TITLE = 'Recall'
|
/** This plugin's identity: its settings namespace AND its tab-type id. */
|
||||||
const DESCRIPTION = 'Local embedding endpoint, what gets indexed, and how many tokens a result may cost.'
|
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
|
* That is the `id` of this bundle's row in the composed tree — `dsh-recall`
|
||||||
* than fetched because the card renders its own controls — there is no
|
* in cordis.patch.yml. It is not registered anywhere from here; exporting
|
||||||
* generic schema-to-form renderer in the framework — and a field the Host
|
* `Config` from the host half is the registration, and this is the key the
|
||||||
* does not know is rejected there anyway, so drift shows up as a clear error
|
* Harness files it under.
|
||||||
* rather than as silent data loss.
|
*/
|
||||||
|
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 = [
|
const FIELDS = [
|
||||||
{
|
{
|
||||||
key: 'embeddingApiBase',
|
key: 'embeddingApiBase',
|
||||||
|
kind: 'text',
|
||||||
group: 'Endpoint',
|
group: 'Endpoint',
|
||||||
label: 'Embedding endpoint',
|
label: 'Embedding endpoint',
|
||||||
hint: 'e.g. http://127.0.0.1:11434 for Ollama. Must be reachable from the machine running the harness.',
|
hint: 'e.g. http://127.0.0.1:11434 for Ollama. Must be reachable from the machine running the harness.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'embeddingModel',
|
key: 'embeddingModel',
|
||||||
|
kind: 'text',
|
||||||
group: 'Endpoint',
|
group: 'Endpoint',
|
||||||
label: 'Embedding model',
|
label: 'Embedding model',
|
||||||
hint: 'The model id as the endpoint knows it. Changing this discards the existing index.',
|
hint: 'The model id as the endpoint knows it. Changing this discards the existing index.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'embeddingDimensions',
|
key: 'embeddingDimensions',
|
||||||
|
kind: 'number',
|
||||||
group: 'Endpoint',
|
group: 'Endpoint',
|
||||||
label: 'Embedding dimensions',
|
label: 'Embedding dimensions',
|
||||||
kind: 'number',
|
|
||||||
hint: 'Must match what the model returns. Changing this discards the existing index.',
|
hint: 'Must match what the model returns. Changing this discards the existing index.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'embeddingApiKey',
|
key: 'embeddingApiKey',
|
||||||
|
kind: 'secret',
|
||||||
group: 'Endpoint',
|
group: 'Endpoint',
|
||||||
label: 'Embedding API key',
|
label: 'Embedding API key',
|
||||||
kind: 'secret',
|
hint: 'Stored as a secret and never returned to this page. Leave blank to keep the current value.',
|
||||||
hint: 'Stored as a secret and never returned to this page. Leave blank to keep the current value; use Reset to clear it.',
|
},
|
||||||
|
{
|
||||||
|
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',
|
key: 'embeddingKeepAlive',
|
||||||
|
kind: 'text',
|
||||||
group: 'Endpoint',
|
group: 'Endpoint',
|
||||||
label: 'Keep-alive',
|
label: 'Keep-alive',
|
||||||
hint: 'Ollama keep_alive, e.g. 5m. Keeps the model resident for the length of an index run.',
|
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',
|
key: 'extensions',
|
||||||
|
kind: 'list',
|
||||||
group: 'What gets indexed',
|
group: 'What gets indexed',
|
||||||
label: 'Extensions',
|
label: 'Extensions',
|
||||||
kind: 'list',
|
|
||||||
hint: 'Comma separated. Empty uses the built-in code set (no prose or markup).',
|
hint: 'Comma separated. Empty uses the built-in code set (no prose or markup).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'exclude',
|
key: 'exclude',
|
||||||
|
kind: 'list',
|
||||||
group: 'What gets indexed',
|
group: 'What gets indexed',
|
||||||
label: 'Extra exclude patterns',
|
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.',
|
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',
|
key: 'maxFiles',
|
||||||
|
kind: 'number',
|
||||||
group: 'What gets indexed',
|
group: 'What gets indexed',
|
||||||
label: 'Most files to consider',
|
label: 'Most files to consider',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxFileBytes',
|
||||||
kind: 'number',
|
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: 'chunkLines', kind: 'number', group: 'Chunking', label: 'Target lines per chunk' },
|
||||||
{ key: 'maxChunkLines', group: 'Chunking', label: 'Hard cap before splitting', kind: 'number' },
|
{ key: 'maxChunkLines', kind: 'number', group: 'Chunking', label: 'Hard cap before splitting' },
|
||||||
{
|
{
|
||||||
key: 'overlapLines',
|
key: 'overlapLines',
|
||||||
|
kind: 'number',
|
||||||
group: 'Chunking',
|
group: 'Chunking',
|
||||||
label: 'Lines shared when splitting',
|
label: 'Lines shared when splitting',
|
||||||
kind: 'number',
|
|
||||||
hint: 'Must be smaller than the hard cap, or a split never advances.',
|
hint: 'Must be smaller than the hard cap, or a split never advances.',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'excerptLines',
|
key: 'excerptLines',
|
||||||
|
kind: 'number',
|
||||||
group: 'Results',
|
group: 'Results',
|
||||||
label: 'Lines per excerpt',
|
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.',
|
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',
|
key: 'totalExcerptLines',
|
||||||
|
kind: 'number',
|
||||||
group: 'Results',
|
group: 'Results',
|
||||||
label: 'Total lines across one result set',
|
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.',
|
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: 'defaultLimit', kind: 'number', group: 'Results', label: 'Excerpts returned' },
|
||||||
{ key: 'maxLimit', group: 'Results', label: 'Most a caller may ask for', kind: 'number' },
|
{ key: 'maxLimit', kind: 'number', group: 'Results', label: 'Most a caller may ask for' },
|
||||||
{
|
{
|
||||||
key: 'perFileLimit',
|
key: 'perFileLimit',
|
||||||
|
kind: 'number',
|
||||||
group: 'Results',
|
group: 'Results',
|
||||||
label: 'Most chunks from one file',
|
label: 'Most chunks from one file',
|
||||||
kind: 'number',
|
|
||||||
hint: 'Stops one large file filling the whole result set.',
|
hint: 'Stops one large file filling the whole result set.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'minSimilarity',
|
key: 'minSimilarity',
|
||||||
|
kind: 'number',
|
||||||
group: 'Results',
|
group: 'Results',
|
||||||
label: 'Weak-match threshold',
|
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.',
|
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',
|
key: 'syncBudgetMs',
|
||||||
|
kind: 'number',
|
||||||
group: 'Index',
|
group: 'Index',
|
||||||
label: 'Index warm-up budget per search (ms)',
|
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.',
|
hint: 'How long one search may spend embedding changed files before reporting the rest as pending.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'stateDir',
|
key: 'stateDir',
|
||||||
|
kind: 'text',
|
||||||
group: 'Index',
|
group: 'Index',
|
||||||
label: 'Index directory',
|
label: 'Index directory',
|
||||||
hint: 'Empty uses $DSH_HOME/dsh-recall/workspaces/<workspace>.',
|
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. */
|
/** 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
|
* A whole-list field: comma or newline separated in the control, an array of
|
||||||
// styling never reaches it.
|
* non-empty strings in the document.
|
||||||
card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
|
*
|
||||||
cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
|
* One of three conversions the shared model asks a field for. `number` and
|
||||||
header: {
|
* `text` ship with the primitives; a list does not, because only the owning
|
||||||
display: 'flex',
|
* plugin knows whether its list is comma-separated, JSON, or one-per-line.
|
||||||
alignItems: 'center',
|
*/
|
||||||
gap: '12px',
|
function settingsListField(field) {
|
||||||
width: '100%',
|
return {
|
||||||
padding: '12px 16px',
|
field,
|
||||||
border: 'none',
|
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
|
||||||
background: 'transparent',
|
parse: (text) => {
|
||||||
color: 'inherit',
|
const parts = String(text)
|
||||||
textAlign: 'left',
|
.split(/[,\n]/)
|
||||||
cursor: 'pointer',
|
.map((part) => part.trim())
|
||||||
font: 'inherit',
|
.filter((part) => part !== '')
|
||||||
},
|
// Empty means "no restriction", which for these fields is the schema
|
||||||
headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 },
|
// default — so this clears the override rather than storing an empty
|
||||||
name: { fontSize: '14px', fontWeight: 600 },
|
// list that would shadow a deployment's own default with nothing.
|
||||||
description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 },
|
return parts.length === 0 ? { kind: 'clear' } : { kind: 'set', value: parts }
|
||||||
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
|
|
||||||
.split(/[,\n]/)
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter((part) => part !== '')
|
|
||||||
}
|
}
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a draft of every field from a resolved section. */
|
/**
|
||||||
function toDraft(value) {
|
* A boolean field, edited as text.
|
||||||
const draft = {}
|
*
|
||||||
for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key])
|
* The shared model stages strings, so a boolean is staged as `true`/`false`
|
||||||
return draft
|
* 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
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
function Field(props) {
|
||||||
const { field, value, overridden, disabled, onChange } = props
|
const { field, state, disabled, onEdit, onReset, secretSet } = props
|
||||||
const common = {
|
|
||||||
style: S.input,
|
if (field.kind === 'secret') {
|
||||||
value,
|
return h(SettingsSecretField, {
|
||||||
disabled,
|
id: controlId(field.key),
|
||||||
'aria-label': field.label,
|
label: field.label,
|
||||||
onChange: (event) => onChange(event.target.value),
|
hint: field.hint ?? '',
|
||||||
|
text: state.text,
|
||||||
|
configured: secretSet,
|
||||||
|
stateLabel: secretSet ? 'A key is stored.' : 'No key is stored.',
|
||||||
|
disabled,
|
||||||
|
onEdit,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return h(
|
|
||||||
'div',
|
return h(SettingsValueField, {
|
||||||
{ style: S.row },
|
id: controlId(field.key),
|
||||||
h(
|
label: field.label,
|
||||||
'label',
|
hint: field.hint,
|
||||||
{ style: S.label },
|
text: state.text,
|
||||||
field.label,
|
overridden: state.overridden,
|
||||||
overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null,
|
invalid: state.invalid,
|
||||||
),
|
overriddenLabel: OVERRIDDEN_LABEL,
|
||||||
h('input', {
|
resetLabel: RESET_LABEL,
|
||||||
...common,
|
invalidLabel: INVALID_LABEL,
|
||||||
type: field.kind === 'secret' ? 'password' : 'text',
|
numeric: field.kind === 'number',
|
||||||
...(field.kind === 'secret' ? { placeholder: '(unchanged)' } : {}),
|
disabled,
|
||||||
}),
|
onEdit,
|
||||||
field.hint === undefined ? null : h('div', { style: S.hint }, field.hint),
|
onReset,
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function Card(props) {
|
/**
|
||||||
const scope = props.scope
|
* The plugin's configuration page.
|
||||||
const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot())
|
*
|
||||||
const [draft, setDraft] = react.useState(null)
|
* The Plugins page asks a configuration entry twice: once for a one-liner to
|
||||||
const [busy, setBusy] = react.useState(false)
|
* stand in for a missing description (`summary`), and once for the body of
|
||||||
const [note, setNote] = react.useState('')
|
* the plugin's own page (`page`). Only the second draws a save control —
|
||||||
// Collapsed on mount, like every other card in the tab.
|
* and only one save writes, because the form discards its drafts on unmount.
|
||||||
const [open, setOpen] = react.useState(false)
|
*/
|
||||||
|
function RecallSettingsCard(props) {
|
||||||
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
|
if (props.view === 'summary') return DESCRIPTION
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const state = props.useRecallSettings((snapshot) => snapshot)
|
||||||
const rows = []
|
const rows = []
|
||||||
let group
|
let group
|
||||||
for (const field of FIELDS) {
|
for (const field of FIELDS) {
|
||||||
if (field.group !== group) {
|
if (field.group !== group) {
|
||||||
group = field.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(
|
rows.push(
|
||||||
h(Field, {
|
h(Field, {
|
||||||
key: field.key,
|
key: field.key,
|
||||||
field,
|
field,
|
||||||
value: current[field.key],
|
state: state.fields[field.key],
|
||||||
overridden: overriddenKeys.includes(field.key),
|
secretSet: state.secretSet,
|
||||||
disabled: busy || !writable,
|
disabled: !state.shell.writable || state.shell.saving,
|
||||||
onChange: (next) => setDraft({ ...current, [field.key]: next }),
|
onEdit: (text) => props.edit(field.key, text),
|
||||||
|
onReset: () => props.resetField(field.key),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
'li',
|
SettingsForm,
|
||||||
{ style: open ? { ...S.card, ...S.cardOpen } : S.card },
|
{
|
||||||
h(
|
labels: FORM_LABELS,
|
||||||
'button',
|
state: state.shell,
|
||||||
{
|
onSave: props.save,
|
||||||
type: 'button',
|
onDiscard: props.discard,
|
||||||
style: S.header,
|
},
|
||||||
'aria-expanded': open,
|
...rows,
|
||||||
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
|
|
||||||
onClick: () => setOpen(!open),
|
|
||||||
},
|
|
||||||
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. */
|
/** The projection key the host half registers. */
|
||||||
const USAGE_KEY = 'recallUsage'
|
const USAGE_KEY = 'recallUsage'
|
||||||
|
|
||||||
@@ -537,17 +619,16 @@ window.__ModuleLoader__.load({
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '999px',
|
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),
|
chipText(usage),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecallTabTitle() {
|
|
||||||
return h('span', { style: { fontSize: '12px' } }, 'Recall')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One labelled figure in the panel. */
|
/** One labelled figure in the panel. */
|
||||||
function Figure(props) {
|
function Figure(props) {
|
||||||
const { label, value, note } = props
|
const { label, value, note } = props
|
||||||
@@ -608,34 +689,77 @@ window.__ModuleLoader__.load({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function apply(ctx) {
|
function apply(ctx) {
|
||||||
// The chip and the panel are session-scoped, so the framework hands them
|
// The settings page. Reached through `ctx.inject` rather than a top-level
|
||||||
// `useProjection` as a standard prop — the same wire view the host
|
// `inject`, so a deployment that composed no settings provider loses the
|
||||||
// projection registers. Both render nothing when there is nothing to say.
|
// 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.inject('conversation.session.header.corner', () =>
|
||||||
ctx.slots.register({ name: 'conversation.session.header.corner' }, (props) => h(RecallChip, props)),
|
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.inject('sidebar.right.pane.tab', () =>
|
||||||
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: NS }, (props) => h(RecallPanel, props)),
|
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 }
|
module.exports = { name: NS, inject: ['slots'], apply }
|
||||||
|
|||||||
@@ -2,8 +2,18 @@
|
|||||||
* dsh-recall — local-embedding code recall for DeepSeek Harness.
|
* dsh-recall — local-embedding code recall for DeepSeek Harness.
|
||||||
*
|
*
|
||||||
* One tool (`recall`) over a per-workspace vector index built by a local
|
* 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
|
* embedding model, plus a `Config` schema whose every field is live-editable —
|
||||||
* runtime.
|
* 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
|
* **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
|
* 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
|
* fits in a time budget — and reports honestly what is still pending. A full
|
||||||
* warm-up is available as an explicit background action.
|
* warm-up is available as an explicit background action.
|
||||||
*
|
*
|
||||||
* **Why there is a settings namespace and no baked endpoint.** The Ollama
|
* **Why the endpoint is configuration and not a constant.** The Ollama default
|
||||||
* default of `127.0.0.1:11434` is a convention, not a fact — this deployment
|
* of `127.0.0.1:11434` is a convention, not a fact — this deployment runs Ollama
|
||||||
* runs Ollama on another host. Baking one machine's address into a bundle layer
|
* on another host. Baking one machine's address into a bundle layer that other
|
||||||
* that other machines apply is exactly the mistake the composition docs warn
|
* machines apply is exactly the mistake the composition docs warn about, so the
|
||||||
* about, so the endpoint is a schema default the profile can override.
|
* endpoint is a schema default the profile can override, at rest or live.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFile, stat } from 'node:fs/promises'
|
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 { deriveUsage, emptyUsage, foldUsage, usageSummary } from './lib/usage.js'
|
||||||
import { DEFAULT_EXTENSIONS, DEFAULT_SKIP_DIRS, listCodeFiles } from './lib/walk.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'
|
const SETTINGS_NAMESPACE = 'dsh-recall'
|
||||||
|
|
||||||
/** Every configuration field this plugin exposes. */
|
/** Every configuration field this plugin exposes. */
|
||||||
@@ -90,22 +109,46 @@ export const SETTING_KEYS = [
|
|||||||
'rerankTimeoutMs',
|
'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({
|
export const Config = Schema.object({
|
||||||
/** Embedding endpoint, without a trailing slash. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** Bearer token for a gateway that wants one. */
|
||||||
// `role('secret')` keeps this out of every settings response: the wire
|
// `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.
|
// 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. */
|
/** Inputs per request on the batch endpoint. */
|
||||||
embeddingBatchSize: Schema.number().default(16),
|
embeddingBatchSize: Schema.number().min(1).default(16).volatile(),
|
||||||
/** Requests in flight. */
|
/** Requests in flight. */
|
||||||
embeddingConcurrency: Schema.number().default(2),
|
embeddingConcurrency: Schema.number().min(1).default(2).volatile(),
|
||||||
/**
|
/**
|
||||||
* Per-request timeout.
|
* 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
|
* 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.
|
* 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. */
|
/** 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 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. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** Target lines per chunk. */
|
||||||
chunkLines: Schema.number().default(48),
|
chunkLines: Schema.number().min(1).default(48).volatile(),
|
||||||
/** Hard cap before a chunk is split. */
|
/** 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. */
|
/** 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. */
|
/** 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`. */
|
/** 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. */
|
/** 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.
|
* 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
|
* 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.
|
* 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. */
|
/** 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. */
|
/** 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. */
|
/** 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>`. */
|
/** 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.
|
* 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
|
* the fallback — which is the behaviour that existed before focus did, so
|
||||||
* adopting this cannot regress a deployment that never configures it.
|
* 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. */
|
/** 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.
|
* 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
|
* 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.
|
* 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. */
|
/** 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. */
|
/** 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. */
|
/** 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.
|
* 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
|
* Off by default for the same reason, and more so: it is one more model call
|
||||||
* on the critical path of every search.
|
* on the critical path of every search.
|
||||||
*/
|
*/
|
||||||
rerankEnabled: Schema.boolean().default(false),
|
rerankEnabled: Schema.boolean().default(false).volatile(),
|
||||||
/** Model that scores candidates. */
|
/** Model that scores candidates. */
|
||||||
rerankModel: Schema.string().default(''),
|
rerankModel: Schema.string().default('').volatile(),
|
||||||
/** Candidates scored per search. The reranker caps this at 12. */
|
/** 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. */
|
/** 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. */
|
/** Resolve the harness home, preferring the harness's own resolver. */
|
||||||
function dshHomeOf(ctx) {
|
function dshHomeOf(ctx) {
|
||||||
try {
|
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
|
* Called on the section a call is about to act on, and on the section a
|
||||||
* used. That distinction is the point: a bad endpoint or an impossible limit
|
* `configure` patch would leave behind. The Host validates every field against
|
||||||
* should fail in front of the person who typed it, rather than surfacing later
|
* `Config` — including the `.min`/`.max` bounds — but the rules here relate two
|
||||||
* as a failed search with no obvious cause.
|
* 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
|
* @param {Record<string, any>} value a schema-valid settings section
|
||||||
* @throws {Error} when the section cannot be acted on
|
* @throws {Error} when the section cannot be acted on
|
||||||
@@ -403,9 +481,14 @@ export function apply(ctx, config) {
|
|||||||
openIndexes.clear()
|
openIndexes.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
// The authoritative settings source. Starts as the composition config and is
|
// The authoritative settings source: a plain snapshot of the running config.
|
||||||
// replaced by the settings scope's thunk once a provider attaches.
|
//
|
||||||
let readSettings = () => 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
|
let writeSettings
|
||||||
|
|
||||||
// Per-conversation usage, folded from the tool metadata the log already
|
// 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) => {
|
ctx.inject(['settings'], (settingsCtx) => {
|
||||||
try {
|
writeSettings = async (patch) => {
|
||||||
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
const revision = settingsCtx.settings
|
||||||
setSource: (current) => {
|
.describe()
|
||||||
readSettings = current
|
.find((descriptor) => descriptor.ns === SETTINGS_NAMESPACE)?.revision
|
||||||
},
|
await settingsCtx.settings.update(SETTINGS_NAMESPACE, patch, revision)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -916,10 +995,34 @@ export function apply(ctx, config) {
|
|||||||
},
|
},
|
||||||
isConcurrencySafe: (args) => (args.action ?? 'search') !== 'index',
|
isConcurrencySafe: (args) => (args.action ?? 'search') !== 'index',
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
const settings = readSettings() ?? config
|
const settings = readSettings()
|
||||||
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
|
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
|
||||||
const action = args.action ?? 'search'
|
const action = args.action ?? 'search'
|
||||||
const workspace = basename(cwd) || cwd
|
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)
|
const embedder = embedderFor(settings)
|
||||||
|
|
||||||
// Resolve the scope before anything else, because it decides which index
|
// 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(', ')}.`,
|
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) {
|
if (writeSettings === undefined) {
|
||||||
return {
|
return {
|
||||||
...base,
|
...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)}`,
|
remedy: `Set these values in the dsh-recall row of your cordis layer instead: ${JSON.stringify(patch)}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await writeSettings(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(', ')}.` }
|
return { ...base, ok: true, note: `Updated ${Object.keys(patch).join(', ')}.` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -35,8 +35,8 @@
|
|||||||
"client": {
|
"client": {
|
||||||
"platform": "web",
|
"platform": "web",
|
||||||
"inject": [
|
"inject": [
|
||||||
"@deepseek-ai/dsh-client-ui-slots",
|
|
||||||
"@deepseek-ai/dsh-client-ui-settings",
|
"@deepseek-ai/dsh-client-ui-settings",
|
||||||
|
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
||||||
"@deepseek-ai/dsh-client-ui-conversation",
|
"@deepseek-ai/dsh-client-ui-conversation",
|
||||||
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
||||||
]
|
]
|
||||||
|
|||||||
+434
-207
@@ -2,20 +2,34 @@
|
|||||||
* The browser half, without a browser.
|
* The browser half, without a browser.
|
||||||
*
|
*
|
||||||
* `client/client.js` is a plain script that hands a factory to the platform's
|
* `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
|
* module loader, so it can be exercised in Node by stubbing `window`, `react`,
|
||||||
* `require('react')`. That is worth doing rather than leaving the card to be
|
* and the one primitives package it imports. That is worth doing rather than
|
||||||
* eyeballed in a running app, because the two things most likely to be wrong are
|
* leaving the page to be eyeballed in a running app, because the failures this
|
||||||
* both structural and both invisible until someone opens Settings:
|
* 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
|
* - the configuration page is registered into `plugins.row.config` under
|
||||||
* into a `<ul>` and a `<div>` never receives the list styling;
|
* `<bundle package>#<row id>`. The 0.1.5 seat (`settings.plugin.item`, bound
|
||||||
* - the card must start collapsed and disclose on click, because that is the
|
* through `settingsScope`) does not exist in 0.1.7, so a page left in it
|
||||||
* framework's card contract and the thing that was wrong the first time.
|
* 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 { strict as assert } from 'node:assert'
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
|
|
||||||
|
import { SETTING_KEYS } from '../index.js'
|
||||||
|
|
||||||
/** A React good enough to run one component: element, state, effect, memo. */
|
/** A React good enough to run one component: element, state, effect, memo. */
|
||||||
function makeReact() {
|
function makeReact() {
|
||||||
const hooks = []
|
const hooks = []
|
||||||
@@ -53,8 +67,8 @@ function makeReact() {
|
|||||||
/**
|
/**
|
||||||
* Expand a function element the way React would.
|
* Expand a function element the way React would.
|
||||||
*
|
*
|
||||||
* The card renders each control as `h(Field, …)`, and those elements are only
|
* The card renders its controls as `h(Field, …)`, and those elements are only
|
||||||
* real DOM once React calls them — so a walk that stops at `type === 'input'`
|
* real nodes once React calls them — so a walk that stops at `type === 'input'`
|
||||||
* finds nothing without this.
|
* finds nothing without this.
|
||||||
*/
|
*/
|
||||||
function expand(node) {
|
function expand(node) {
|
||||||
@@ -73,31 +87,114 @@ function find(node, predicate, found = []) {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The input carrying a given accessible label. */
|
/**
|
||||||
function inputFor(tree, label) {
|
* Stand-ins for the framework's settings-form kit.
|
||||||
return find(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
|
*
|
||||||
}
|
* 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. */
|
bind(project) {
|
||||||
function saveButton(tree) {
|
this.project = project
|
||||||
return find(tree, (node) => node.type === 'button' && String(node.props.children).startsWith('Save'))[0]
|
return { getSnapshot: () => project(), subscribe: () => () => {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shell() {
|
||||||
|
return {
|
||||||
|
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 })
|
||||||
|
},
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 }
|
|
||||||
return {
|
return {
|
||||||
calls,
|
SettingsForm: (props) => ({ type: 'form', props }),
|
||||||
getSnapshot: () => snapshot,
|
SettingsValueField: (props) => ({ type: 'input', props }),
|
||||||
subscribe: () => () => {},
|
SettingsSecretField: (props) => ({ type: 'input', props: { ...props, type: 'password' } }),
|
||||||
set: async (key, next) => {
|
SettingsFormModel,
|
||||||
calls.push({ op: 'set', key, value: next })
|
settingsNumberField: (field) => ({
|
||||||
},
|
field,
|
||||||
unset: async (key) => {
|
format: (value) => (value === undefined || value === null ? '' : String(value)),
|
||||||
calls.push({ op: 'unset', key })
|
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]
|
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 react = makeReact()
|
||||||
|
const primitives = makePrimitives(record)
|
||||||
const factory = entry.factory((name) => {
|
const factory = entry.factory((name) => {
|
||||||
if (name === 'react') return react
|
if (name === 'react') return react
|
||||||
|
if (name === '@deepseek-ai/dsh-client-ui-primitives') return primitives
|
||||||
throw new Error(`unexpected import: ${name}`)
|
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 registered = []
|
||||||
|
const tabTypes = []
|
||||||
|
const whileServedCalls = []
|
||||||
const slots = {
|
const slots = {
|
||||||
inject: (name, fn) => fn(),
|
inject: (name, fn) => fn(),
|
||||||
register: (meta, component) => {
|
register: (meta, component) => {
|
||||||
@@ -129,49 +234,122 @@ function mount(scope) {
|
|||||||
return () => {}
|
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 = {
|
const ctx = {
|
||||||
slots,
|
slots,
|
||||||
inject: (deps, cb) =>
|
effect: (fn) => {
|
||||||
cb({
|
const dispose = fn()
|
||||||
settingsScope: {
|
if (typeof dispose === 'function') record.disposables = [...(record.disposables ?? []), dispose]
|
||||||
bind: (options) => {
|
},
|
||||||
assert.equal(options.namespace, 'dsh-recall')
|
inject: (deps, callback) => {
|
||||||
return scope
|
const available = {
|
||||||
},
|
...services,
|
||||||
},
|
sidebarRightTabs,
|
||||||
slots,
|
...(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]])) })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
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. */
|
/** Render one registered slot with the props its occupant would receive. */
|
||||||
const renderSlot = (name, props = {}) => {
|
const renderSlot = (name, props = {}) => {
|
||||||
const entry = byName.get(name)
|
const item = byName.get(name)
|
||||||
assert.ok(entry !== undefined, `nothing registered for ${name}`)
|
assert.ok(item !== undefined, `nothing registered for ${name}`)
|
||||||
react.__render()
|
react.__render()
|
||||||
const element = entry.component(props)
|
const element = item.component(props)
|
||||||
if (element === null) return null
|
if (element === null) return null
|
||||||
return element.type(element.props)
|
return element.type(element.props)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the settings card, at its current state. */
|
/**
|
||||||
const render = () => {
|
* The settings page's element plus the props it would receive.
|
||||||
react.__render()
|
*
|
||||||
const element = byName.get('settings.plugin.item').component({ scope })
|
* `hooks` is resolved into `use<Name>` here exactly as the slot machinery
|
||||||
return element.type(element.props)
|
* 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. */
|
/** The rendered page's control nodes, keyed by their HTML id. */
|
||||||
function openCard(render) {
|
function controlsOf(element) {
|
||||||
render().props.children[0].props.onClick()
|
const form = expand(element)
|
||||||
// Call this after any edit: React re-renders then, and the Save button the
|
const controls = new Map()
|
||||||
// user actually clicks is the one created by that later render.
|
for (const node of find(form, (candidate) => candidate.type === 'input')) controls.set(node.props.id, node)
|
||||||
return () => render().props.children[1]
|
return controls
|
||||||
}
|
}
|
||||||
|
|
||||||
test('the bundle registers under the loader with the plugin id', () => {
|
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')
|
assert.equal(typeof entry.factory, 'function')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the client module claims slots and reports the host namespace', () => {
|
test('the client module claims the slot registry in its inject list', () => {
|
||||||
const { module } = makeModule()
|
const { module } = mount()
|
||||||
assert.equal(module.name, 'dsh-recall')
|
assert.equal(module.name, 'dsh-recall')
|
||||||
assert.deepEqual(module.inject, ['slots'])
|
assert.deepEqual(module.inject, ['slots'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the card is registered under the same key the host half serves', () => {
|
test('the configuration page claims the 0.1.7 seat, keyed as the patch declares it', () => {
|
||||||
const { meta } = mount(makeScope())
|
const { registered } = mount()
|
||||||
assert.equal(meta.name, 'settings.plugin.item')
|
const page = registered.find((item) => item.meta.name === 'plugins.row.config')
|
||||||
assert.equal(meta.key, 'dsh-recall', 'the host namespace and the card key must match, or nothing renders')
|
assert.ok(page !== undefined, 'the page must register into plugins.row.config')
|
||||||
assert.equal(typeof meta.inject, 'function')
|
// `<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>', () => {
|
test('the page edits the settings entry the Host keys this plugin under', () => {
|
||||||
const { render } = mount(makeScope())
|
const { configForms, whileServedCalls } = mount()
|
||||||
assert.equal(render().type, 'li')
|
assert.equal(configForms.requested, 'dsh-recall')
|
||||||
|
assert.deepEqual(whileServedCalls, [{ namespaces: ['dsh-recall'] }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the card starts collapsed and discloses on click', () => {
|
test('no page is registered for a namespace the Host does not serve', () => {
|
||||||
const { render } = mount(makeScope())
|
// A control that writes nowhere is worse than no control: the page would open
|
||||||
const closed = render()
|
// onto fields nothing would accept.
|
||||||
const [closedHeader, closedBody] = closed.props.children
|
const { registered } = mount({ served: ['some-other-plugin'] })
|
||||||
assert.equal(closedHeader.type, 'button')
|
assert.equal(
|
||||||
assert.equal(closedHeader.props['aria-expanded'], false, 'the card must start collapsed')
|
registered.some((item) => item.meta.name === 'plugins.row.config'),
|
||||||
assert.equal(closedBody, null, 'a collapsed card renders no body')
|
false,
|
||||||
|
)
|
||||||
// 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('every host setting appears as a control', () => {
|
test('the chip and the sidebar tab survive a deployment with no settings provider', () => {
|
||||||
const { render } = mount(makeScope())
|
// The page is reached through `ctx.inject`, not a top-level `inject`, so a
|
||||||
const body = openCard(render)
|
// profile without a settings provider loses the page and keeps the rest.
|
||||||
const labels = find(body(), (node) => node.type === 'input').map((node) => node.props['aria-label'])
|
const { registered, tabTypes } = mount({ withConfigForms: false })
|
||||||
for (const label of [
|
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
|
||||||
'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, [
|
|
||||||
'conversation.session.header.corner',
|
'conversation.session.header.corner',
|
||||||
'settings.plugin.item',
|
|
||||||
'sidebar.right.pane.tab',
|
'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. */
|
/** 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', () => {
|
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
|
// The corner is laid out only while its occupant renders something, so this
|
||||||
// is the state that must cost no space at all.
|
// is the state that must cost no space at all.
|
||||||
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => undefined }), null)
|
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', () => {
|
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() })
|
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() })
|
||||||
assert.ok(chip !== null)
|
assert.ok(chip !== null)
|
||||||
const text = String(chip.props.children)
|
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', () => {
|
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 chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ rereadTokens: 500_000 }) })
|
||||||
const detail = chip.props.title
|
const detail = chip.props.title
|
||||||
assert.match(detail, /3 searches/)
|
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', () => {
|
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 empty = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ searches: 0, indexes: 0, excerpts: 0 }) })
|
||||||
const text = JSON.stringify(empty)
|
const text = JSON.stringify(empty)
|
||||||
assert.match(text, /has not used recall yet/)
|
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', () => {
|
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', {})
|
const missing = renderSlot('sidebar.right.pane.tab', {})
|
||||||
assert.match(JSON.stringify(missing), /not available for this conversation/)
|
assert.match(JSON.stringify(missing), /not available for this conversation/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the sidebar panel reports the comparison and the compounding', () => {
|
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 panel = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ rereadTokens: 1_200_000 }) })
|
||||||
const text = JSON.stringify(panel)
|
const text = JSON.stringify(panel)
|
||||||
assert.match(text, /Kept out of this conversation/)
|
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, /1,200,000 tokens/)
|
||||||
assert.match(text, /upper bound/, 'the panel must say what the figure is not')
|
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
@@ -1,15 +1,21 @@
|
|||||||
import { strict as assert } from 'node:assert'
|
import { strict as assert } from 'node:assert'
|
||||||
import { test } from 'node:test'
|
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
|
* `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
|
* 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
|
* refuses to boot. The rest of the suite calls `apply` directly and would never
|
||||||
* notice, which is exactly why this file exists separately.
|
* 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', () => {
|
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')
|
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', () => {
|
test('Config fills every field from an empty input, so apply can rely on defaults', () => {
|
||||||
const filled = Config({})
|
const filled = Config({})
|
||||||
for (const key of SETTING_KEYS) {
|
for (const key of SETTING_KEYS) {
|
||||||
assert.ok(key in filled, `${key} has no default and would be undefined in apply`)
|
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')
|
const resolved = settingsSnapshot(filled)
|
||||||
assert.equal(filled.embeddingDimensions, 768)
|
assert.equal(resolved.embeddingApiBase, 'http://127.0.0.1:11434')
|
||||||
assert.equal(filled.syncBudgetMs, 20_000)
|
assert.equal(resolved.embeddingDimensions, 768)
|
||||||
assert.deepEqual(filled.exclude, [])
|
assert.equal(resolved.syncBudgetMs, 20_000)
|
||||||
assert.deepEqual(filled.extensions, [])
|
assert.deepEqual(resolved.exclude, [])
|
||||||
|
assert.deepEqual(resolved.extensions, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Config keeps an explicit value and fills the rest', () => {
|
test('Config keeps an explicit value and fills the rest', () => {
|
||||||
const filled = Config({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
|
const resolved = resolve({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
|
||||||
assert.equal(filled.embeddingApiBase, 'http://10.50.1.2:11434')
|
assert.equal(resolved.embeddingApiBase, 'http://10.50.1.2:11434')
|
||||||
assert.equal(filled.embeddingConcurrency, 8)
|
assert.equal(resolved.embeddingConcurrency, 8)
|
||||||
assert.equal(filled.embeddingBatchSize, 16)
|
assert.equal(resolved.embeddingBatchSize, 16)
|
||||||
assert.equal(filled.embeddingTimeoutMs, 30_000)
|
assert.equal(resolved.embeddingTimeoutMs, 30_000)
|
||||||
assert.equal(filled.embeddingKeepAlive, '5m')
|
assert.equal(resolved.embeddingKeepAlive, '5m')
|
||||||
// The per-excerpt cap equals the chunk cap, so chunks arrive whole.
|
// The per-excerpt cap equals the chunk cap, so chunks arrive whole.
|
||||||
assert.equal(filled.excerptLines, 80)
|
assert.equal(resolved.excerptLines, 80)
|
||||||
assert.equal(filled.totalExcerptLines, 400)
|
assert.equal(resolved.totalExcerptLines, 400)
|
||||||
assert.equal(filled.maxChunkLines, 80)
|
assert.equal(resolved.maxChunkLines, 80)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a wrongly typed value fails loudly at load rather than silently in apply', () => {
|
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' } }))
|
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', () => {
|
test('every declared setting is a real schema key, so none is dead config', () => {
|
||||||
const filled = Config({})
|
const filled = Config({})
|
||||||
for (const key of SETTING_KEYS) {
|
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) {
|
function settingsWith(patch) {
|
||||||
return { ...Config({}), ...patch }
|
return { ...settingsSnapshot(Config({})), ...patch }
|
||||||
}
|
}
|
||||||
|
|
||||||
test('validateSettings accepts the defaults', () => {
|
test('validateSettings accepts the defaults', () => {
|
||||||
assert.doesNotThrow(() => validateSettings(Config({})))
|
assert.doesNotThrow(() => validateSettings(resolve()))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateSettings rejects an endpoint that is not a URL', () => {
|
test('validateSettings rejects an endpoint that is not a URL', () => {
|
||||||
|
|||||||
+131
-5
@@ -3,7 +3,7 @@ import { readFile, rm, writeFile } from 'node:fs/promises'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { test } from 'node:test'
|
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'
|
import { makeContext, makeExec, makeStateDir, makeStubFetch, makeWorkspace } from './helpers.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,6 +135,7 @@ function configFor(stateDir, extra = {}) {
|
|||||||
embeddingBatchSize: 32,
|
embeddingBatchSize: 32,
|
||||||
embeddingConcurrency: 2,
|
embeddingConcurrency: 2,
|
||||||
embeddingTimeoutMs: 5_000,
|
embeddingTimeoutMs: 5_000,
|
||||||
|
embeddingKeepAlive: '5m',
|
||||||
extensions: [],
|
extensions: [],
|
||||||
exclude: [],
|
exclude: [],
|
||||||
maxFiles: 500,
|
maxFiles: 500,
|
||||||
@@ -146,12 +147,16 @@ function configFor(stateDir, extra = {}) {
|
|||||||
maxLimit: 25,
|
maxLimit: 25,
|
||||||
perFileLimit: 2,
|
perFileLimit: 2,
|
||||||
excerptLines: 40,
|
excerptLines: 40,
|
||||||
|
totalExcerptLines: 400,
|
||||||
minSimilarity: 0.2,
|
minSimilarity: 0.2,
|
||||||
syncBudgetMs: 20_000,
|
syncBudgetMs: 20_000,
|
||||||
// These mirror the Config schema exactly. Cordis fills schema defaults
|
// This stands in for the config Cordis hands `apply` after schema
|
||||||
// before `apply`, and this object stands in for that validated config — so
|
// resolution: every declared field present, defaults filled. A key this
|
||||||
// any key the code reads and this object omits arrives as `undefined`, which
|
// object omits arrives as `undefined` in the plugin — which is how
|
||||||
// is how `AbortSignal.timeout(NaN)` came to throw inside the expander.
|
// `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: '',
|
focus: '',
|
||||||
focusMarkers: [],
|
focusMarkers: [],
|
||||||
expansionEnabled: false,
|
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)
|
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) => {
|
test('a search returns the right file, as an excerpt rather than a whole file', async (t) => {
|
||||||
const { tool, workspace } = await setup(t)
|
const { tool, workspace } = await setup(t)
|
||||||
const value = await tool.execute(
|
const value = await tool.execute(
|
||||||
@@ -358,6 +396,94 @@ test('configure rejects an unknown setting by name', async (t) => {
|
|||||||
assert.match(value.note, /nonsense/)
|
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) => {
|
test('an unreachable endpoint fails with a remedy, not a stack trace', async (t) => {
|
||||||
const workspace = await makeWorkspace(FILES)
|
const workspace = await makeWorkspace(FILES)
|
||||||
const stateDir = await makeStateDir()
|
const stateDir = await makeStateDir()
|
||||||
|
|||||||
Reference in new issue
Block a user