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
@@ -2,8 +2,18 @@
|
||||
* dsh-recall — local-embedding code recall for DeepSeek Harness.
|
||||
*
|
||||
* One tool (`recall`) over a per-workspace vector index built by a local
|
||||
* embedding model, plus a settings namespace so the endpoint is editable at
|
||||
* runtime.
|
||||
* embedding model, plus a `Config` schema whose every field is live-editable —
|
||||
* the endpoint is configuration, not a constant.
|
||||
*
|
||||
* **How the settings surface works (harness 0.1.7).** A plugin no longer
|
||||
* registers a settings section. It exports a `Config` schema; the Harness keys
|
||||
* a form for the profile entry by that row's `id`, and a field is offered to
|
||||
* the UI only when its schema node is declared `volatile()` — the marker that
|
||||
* means "editable without remounting the plugin". So this file's schema carries
|
||||
* two jobs at once: validation, and being the whole of the settings page's
|
||||
* description. `apply` therefore receives volatile fields as live references
|
||||
* and reads them through {@link settingsSnapshot} per call, which is what makes
|
||||
* a saved edit take effect without a restart.
|
||||
*
|
||||
* **The economics this exists for.** In a long agent session the dominant cost
|
||||
* is not the model's reasoning; it is re-reading the accumulated transcript on
|
||||
@@ -21,11 +31,11 @@
|
||||
* fits in a time budget — and reports honestly what is still pending. A full
|
||||
* warm-up is available as an explicit background action.
|
||||
*
|
||||
* **Why there is a settings namespace and no baked endpoint.** The Ollama
|
||||
* default of `127.0.0.1:11434` is a convention, not a fact — this deployment
|
||||
* runs Ollama on another host. Baking one machine's address into a bundle layer
|
||||
* that other machines apply is exactly the mistake the composition docs warn
|
||||
* about, so the endpoint is a schema default the profile can override.
|
||||
* **Why the endpoint is configuration and not a constant.** The Ollama default
|
||||
* of `127.0.0.1:11434` is a convention, not a fact — this deployment runs Ollama
|
||||
* on another host. Baking one machine's address into a bundle layer that other
|
||||
* machines apply is exactly the mistake the composition docs warn about, so the
|
||||
* endpoint is a schema default the profile can override, at rest or live.
|
||||
*/
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
@@ -50,7 +60,16 @@ import { createReranker } from './lib/rerank.js'
|
||||
import { deriveUsage, emptyUsage, foldUsage, usageSummary } from './lib/usage.js'
|
||||
import { DEFAULT_EXTENSIONS, DEFAULT_SKIP_DIRS, listCodeFiles } from './lib/walk.js'
|
||||
|
||||
/** Settings namespace. Must match /^[a-z][a-z0-9-]*$/. */
|
||||
/**
|
||||
* This plugin's settings namespace, which in 0.1.7 IS its profile entry id.
|
||||
*
|
||||
* `ctx.settings.describe()` keys one form per composed entry by the `id` the
|
||||
* patch layer gives that row — `dsh-recall` in cordis.patch.yml — and writes go
|
||||
* back to the same key. It is not a name the plugin registers; the schema
|
||||
* exported as `Config` is the registration, and this constant is the id those
|
||||
* settings surfaces address it by. Renaming the row without renaming this
|
||||
* would leave the `configure` action writing to a row that does not exist.
|
||||
*/
|
||||
const SETTINGS_NAMESPACE = 'dsh-recall'
|
||||
|
||||
/** Every configuration field this plugin exposes. */
|
||||
@@ -90,22 +109,46 @@ export const SETTING_KEYS = [
|
||||
'rerankTimeoutMs',
|
||||
]
|
||||
|
||||
/**
|
||||
* Every field is `.volatile()`.
|
||||
*
|
||||
* A volatile field is one the configuration document may change without
|
||||
* remounting the plugin: the Loader re-parses the raw config, writes the new
|
||||
* value into the running instance's existing reference, and emits
|
||||
* `loader/volatile-update`. Two consequences decide the shape below:
|
||||
*
|
||||
* - `ctx.settings.describe` SKIPS an entry with no volatile field, so a schema
|
||||
* that marks none is invisible to every settings surface — there is no page
|
||||
* to draw rather than an empty one. This is why the plugin cannot expose a
|
||||
* settings page at all without these markers.
|
||||
* - a volatile field arrives in `apply`'s config as a reference (`{ get() }`)
|
||||
* rather than a value, which is what {@link settingsSnapshot} unwraps.
|
||||
*
|
||||
* Every field qualifies: all of them are read afresh per tool call, so a live
|
||||
* edit takes effect on the next call with nothing to re-register.
|
||||
*
|
||||
* The `.min`/`.max` bounds duplicate what {@link validateSettings} enforces.
|
||||
* That is deliberate rather than redundant: those are the constraints a schema
|
||||
* can express, and expressing them here is what makes the generated form refuse
|
||||
* the invalid draft in front of the person typing it. `validateSettings` keeps
|
||||
* the cross-field rules no single field's schema can state.
|
||||
*/
|
||||
export const Config = Schema.object({
|
||||
/** Embedding endpoint, without a trailing slash. */
|
||||
embeddingApiBase: Schema.string().default('http://127.0.0.1:11434'),
|
||||
embeddingApiBase: Schema.string().default('http://127.0.0.1:11434').volatile(),
|
||||
/** Model id as the endpoint knows it. */
|
||||
embeddingModel: Schema.string().default('unclemusclez/jina-embeddings-v2-base-code:latest'),
|
||||
embeddingModel: Schema.string().default('unclemusclez/jina-embeddings-v2-base-code:latest').volatile(),
|
||||
/** Vector width. Must match the model; a mismatch is refused, not truncated. */
|
||||
embeddingDimensions: Schema.number().default(768),
|
||||
embeddingDimensions: Schema.number().min(1).default(768).volatile(),
|
||||
/** Bearer token for a gateway that wants one. */
|
||||
// `role('secret')` keeps this out of every settings response: the wire
|
||||
// surfaces strip schema-declared secret positions, so a card can report that a
|
||||
// surfaces strip schema-declared secret positions, so a page can report that a
|
||||
// key is set without ever receiving it.
|
||||
embeddingApiKey: Schema.string().default('').role('secret'),
|
||||
embeddingApiKey: Schema.string().default('').role('secret').volatile(),
|
||||
/** Inputs per request on the batch endpoint. */
|
||||
embeddingBatchSize: Schema.number().default(16),
|
||||
embeddingBatchSize: Schema.number().min(1).default(16).volatile(),
|
||||
/** Requests in flight. */
|
||||
embeddingConcurrency: Schema.number().default(2),
|
||||
embeddingConcurrency: Schema.number().min(1).default(2).volatile(),
|
||||
/**
|
||||
* Per-request timeout.
|
||||
*
|
||||
@@ -115,29 +158,29 @@ export const Config = Schema.object({
|
||||
* more than a batch of a few thousand characters needs on any CPU, and it
|
||||
* fails in bounded time instead of leaving the caller guessing.
|
||||
*/
|
||||
embeddingTimeoutMs: Schema.number().default(30_000),
|
||||
embeddingTimeoutMs: Schema.number().min(1).default(30_000).volatile(),
|
||||
/** Ollama `keep_alive`. Keeps the model resident across an index run. */
|
||||
embeddingKeepAlive: Schema.string().default('5m'),
|
||||
embeddingKeepAlive: Schema.string().default('5m').volatile(),
|
||||
/** Extensions to index. Empty means the built-in code set. */
|
||||
extensions: Schema.array(String).default([]),
|
||||
extensions: Schema.array(String).default([]).volatile(),
|
||||
/** Extra glob patterns to skip, on top of the built-in directory list. */
|
||||
exclude: Schema.array(String).default([]),
|
||||
exclude: Schema.array(String).default([]).volatile(),
|
||||
/** Most files a workspace walk will consider. */
|
||||
maxFiles: Schema.number().default(20_000),
|
||||
maxFiles: Schema.number().min(1).default(20_000).volatile(),
|
||||
/** Files larger than this are skipped as generated. */
|
||||
maxFileBytes: Schema.number().default(1_048_576),
|
||||
maxFileBytes: Schema.number().min(1).default(1_048_576).volatile(),
|
||||
/** Target lines per chunk. */
|
||||
chunkLines: Schema.number().default(48),
|
||||
chunkLines: Schema.number().min(1).default(48).volatile(),
|
||||
/** Hard cap before a chunk is split. */
|
||||
maxChunkLines: Schema.number().default(80),
|
||||
maxChunkLines: Schema.number().min(1).default(80).volatile(),
|
||||
/** Lines shared between the halves of a split chunk. */
|
||||
overlapLines: Schema.number().default(8),
|
||||
overlapLines: Schema.number().min(0).default(8).volatile(),
|
||||
/** Results returned when the caller does not say. */
|
||||
defaultLimit: Schema.number().default(8),
|
||||
defaultLimit: Schema.number().min(1).default(8).volatile(),
|
||||
/** Ceiling on the caller's `limit`. */
|
||||
maxLimit: Schema.number().default(25),
|
||||
maxLimit: Schema.number().min(1).default(25).volatile(),
|
||||
/** Most chunks one file may contribute to one result set. */
|
||||
perFileLimit: Schema.number().default(2),
|
||||
perFileLimit: Schema.number().min(1).default(2).volatile(),
|
||||
/**
|
||||
* Lines shown per excerpt.
|
||||
*
|
||||
@@ -145,15 +188,15 @@ export const Config = Schema.object({
|
||||
* is returned whole. Cutting one in half is how a search turns into a
|
||||
* follow-up read that costs more than the lines it saved.
|
||||
*/
|
||||
excerptLines: Schema.number().default(80),
|
||||
excerptLines: Schema.number().min(1).default(80).volatile(),
|
||||
/** Total lines across one result set, allocated by rank. */
|
||||
totalExcerptLines: Schema.number().default(400),
|
||||
totalExcerptLines: Schema.number().min(1).default(400).volatile(),
|
||||
/** Below this best-similarity, the result set is reported as weak. */
|
||||
minSimilarity: Schema.number().default(0.2),
|
||||
minSimilarity: Schema.number().min(0).max(1).default(0.2).volatile(),
|
||||
/** Embedding time one `search` call may spend warming the index. */
|
||||
syncBudgetMs: Schema.number().default(20_000),
|
||||
syncBudgetMs: Schema.number().min(0).default(20_000).volatile(),
|
||||
/** Index directory. Empty means `$DSH_HOME/dsh-recall/workspaces/<key>`. */
|
||||
stateDir: Schema.string().default(''),
|
||||
stateDir: Schema.string().default('').volatile(),
|
||||
/**
|
||||
* Narrows the index and every search to one directory.
|
||||
*
|
||||
@@ -162,9 +205,9 @@ export const Config = Schema.object({
|
||||
* the fallback — which is the behaviour that existed before focus did, so
|
||||
* adopting this cannot regress a deployment that never configures it.
|
||||
*/
|
||||
focus: Schema.string().default(''),
|
||||
focus: Schema.string().default('').volatile(),
|
||||
/** Filenames that mark a directory as its own scope. Empty means the built-ins. */
|
||||
focusMarkers: Schema.array(String).default([]),
|
||||
focusMarkers: Schema.array(String).default([]).volatile(),
|
||||
/**
|
||||
* Ask the local model for alternative phrasings and fuse the rankings.
|
||||
*
|
||||
@@ -172,28 +215,60 @@ export const Config = Schema.object({
|
||||
* and its benefit has not been measured against a real model — a default that
|
||||
* spends latency on an unquantified gain is not a default worth having.
|
||||
*/
|
||||
expansionEnabled: Schema.boolean().default(false),
|
||||
expansionEnabled: Schema.boolean().default(false).volatile(),
|
||||
/** Model that writes the phrasings. Empty uses `embeddingModel`'s endpoint, not its model. */
|
||||
expansionModel: Schema.string().default(''),
|
||||
expansionModel: Schema.string().default('').volatile(),
|
||||
/** Most variants to ask for. */
|
||||
expansionCount: Schema.number().default(4),
|
||||
expansionCount: Schema.number().min(1).default(4).volatile(),
|
||||
/** Budget for the whole expansion, in ms. Expiry just means search without it. */
|
||||
expansionTimeoutMs: Schema.number().default(8_000),
|
||||
expansionTimeoutMs: Schema.number().min(1).default(8_000).volatile(),
|
||||
/**
|
||||
* Have the local model score the top candidates against the query.
|
||||
*
|
||||
* Off by default for the same reason, and more so: it is one more model call
|
||||
* on the critical path of every search.
|
||||
*/
|
||||
rerankEnabled: Schema.boolean().default(false),
|
||||
rerankEnabled: Schema.boolean().default(false).volatile(),
|
||||
/** Model that scores candidates. */
|
||||
rerankModel: Schema.string().default(''),
|
||||
rerankModel: Schema.string().default('').volatile(),
|
||||
/** Candidates scored per search. The reranker caps this at 12. */
|
||||
rerankCandidates: Schema.number().default(8),
|
||||
rerankCandidates: Schema.number().min(1).default(8).volatile(),
|
||||
/** Budget for the scoring call, in ms. */
|
||||
rerankTimeoutMs: Schema.number().default(15_000),
|
||||
rerankTimeoutMs: Schema.number().min(1).default(15_000).volatile(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Read one configuration field.
|
||||
*
|
||||
* A volatile field is a reference in the running config, not a plain value; a
|
||||
* non-volatile one (or a config object a test handed straight to `apply`) is
|
||||
* the value itself. Both are accepted so the same path serves a real profile
|
||||
* and a direct call.
|
||||
*
|
||||
* @param {unknown} value a field of the resolved config
|
||||
* @returns {unknown} the current value
|
||||
*/
|
||||
export function configValue(value) {
|
||||
return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain snapshot of the whole settings section.
|
||||
*
|
||||
* Taken per tool call rather than cached, because that is exactly what a
|
||||
* volatile field buys: the running instance's references are updated in place
|
||||
* when the document changes, so the next read sees the new value without the
|
||||
* plugin being remounted.
|
||||
*
|
||||
* @param {Record<string, any>} config the resolved composition config
|
||||
* @returns {Record<string, any>} every declared field, unwrapped
|
||||
*/
|
||||
export function settingsSnapshot(config) {
|
||||
const snapshot = {}
|
||||
for (const key of Object.keys(config ?? {})) snapshot[key] = configValue(config[key])
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** Resolve the harness home, preferring the harness's own resolver. */
|
||||
function dshHomeOf(ctx) {
|
||||
try {
|
||||
@@ -209,12 +284,15 @@ function dshHomeOf(ctx) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Constraints the settings schema cannot express.
|
||||
* Constraints a single field's schema cannot express.
|
||||
*
|
||||
* Run by the settings provider when a value is WRITTEN, not when it is next
|
||||
* used. That distinction is the point: a bad endpoint or an impossible limit
|
||||
* should fail in front of the person who typed it, rather than surfacing later
|
||||
* as a failed search with no obvious cause.
|
||||
* Called on the section a call is about to act on, and on the section a
|
||||
* `configure` patch would leave behind. The Host validates every field against
|
||||
* `Config` — including the `.min`/`.max` bounds — but the rules here relate two
|
||||
* fields to each other (`defaultLimit` against `maxLimit`, `overlapLines`
|
||||
* against `maxChunkLines`), and 0.1.7 has no write-time validator hook for
|
||||
* those. So the check moves to the two moments that can still refuse: before a
|
||||
* write this plugin performs, and before a configured value is put to work.
|
||||
*
|
||||
* @param {Record<string, any>} value a schema-valid settings section
|
||||
* @throws {Error} when the section cannot be acted on
|
||||
@@ -403,9 +481,14 @@ export function apply(ctx, config) {
|
||||
openIndexes.clear()
|
||||
})
|
||||
|
||||
// The authoritative settings source. Starts as the composition config and is
|
||||
// replaced by the settings scope's thunk once a provider attaches.
|
||||
let readSettings = () => config
|
||||
// The authoritative settings source: a plain snapshot of the running config.
|
||||
//
|
||||
// No settings provider is involved in READING. The `Config` schema above
|
||||
// declares every field volatile, so a document edit updates this instance's
|
||||
// config references in place and the very next snapshot sees it. That is the
|
||||
// 0.1.7 model — there is no `installSection` to attach to, and nothing to
|
||||
// subscribe to for a value that is already live.
|
||||
const readSettings = () => settingsSnapshot(config)
|
||||
let writeSettings
|
||||
|
||||
// Per-conversation usage, folded from the tool metadata the log already
|
||||
@@ -419,24 +502,20 @@ export function apply(ctx, config) {
|
||||
}
|
||||
})
|
||||
|
||||
// Writing is still the settings provider's job, because the value has to
|
||||
// reach the profile's own patch layer rather than live only in this process.
|
||||
//
|
||||
// `ns` is the profile ENTRY id — the `id` of this plugin's row in the composed
|
||||
// tree, which is `dsh-recall` in cordis.patch.yml — not a namespace this
|
||||
// plugin invents. The revision is read back from `describe()` so a
|
||||
// configuration dialog that saved in the meantime refuses this write instead
|
||||
// of being silently overwritten.
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
try {
|
||||
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
||||
setSource: (current) => {
|
||||
readSettings = current
|
||||
},
|
||||
// Settings changes take effect on the next call: the embedder is built
|
||||
// per call, and `recheck` re-validates the stored embedding identity
|
||||
// against the new snapshot, so there are no registration-level facts to
|
||||
// re-judge here.
|
||||
onChange: () => {},
|
||||
// Constraints the schema cannot express, refused at WRITE time rather
|
||||
// than discovered on the next search.
|
||||
validate: validateSettings,
|
||||
})
|
||||
writeSettings = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch)
|
||||
} catch (error) {
|
||||
logger.warn('dsh-recall: settings namespace unavailable: %s', error?.message ?? error)
|
||||
writeSettings = async (patch) => {
|
||||
const revision = settingsCtx.settings
|
||||
.describe()
|
||||
.find((descriptor) => descriptor.ns === SETTINGS_NAMESPACE)?.revision
|
||||
await settingsCtx.settings.update(SETTINGS_NAMESPACE, patch, revision)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -916,10 +995,34 @@ export function apply(ctx, config) {
|
||||
},
|
||||
isConcurrencySafe: (args) => (args.action ?? 'search') !== 'index',
|
||||
async execute(args, exec) {
|
||||
const settings = readSettings() ?? config
|
||||
const settings = readSettings()
|
||||
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
|
||||
const action = args.action ?? 'search'
|
||||
const workspace = basename(cwd) || cwd
|
||||
|
||||
// Validate the live section before using it, every call.
|
||||
//
|
||||
// The Host parses a write against the schema, so anything a single field
|
||||
// can express is refused at the document; the cross-field rules — a cap
|
||||
// below its target, an overlap that never advances a split — are not
|
||||
// expressible that way, and 0.1.7 has no write-time validator hook to
|
||||
// hang them on. Checking here is what keeps a bad combination from
|
||||
// surfacing later as an unexplained empty search.
|
||||
try {
|
||||
validateSettings(settings)
|
||||
} catch (error) {
|
||||
return {
|
||||
action,
|
||||
ok: false,
|
||||
workspace,
|
||||
errorCode: 'INVALID_SETTINGS',
|
||||
note: `The dsh-recall configuration is not usable as it stands: ${error?.message ?? error}`,
|
||||
remedy:
|
||||
'Fix the conflicting values in Settings → Plugins → dsh-recall, or in the dsh-recall ' +
|
||||
'row of your cordis layer.',
|
||||
}
|
||||
}
|
||||
|
||||
const embedder = embedderFor(settings)
|
||||
|
||||
// Resolve the scope before anything else, because it decides which index
|
||||
@@ -1035,6 +1138,21 @@ export function apply(ctx, config) {
|
||||
remedy: `Known settings: ${SETTING_KEYS.join(', ')}.`,
|
||||
}
|
||||
}
|
||||
// Judge the SECTION the write would leave behind, not the patch on its
|
||||
// own: `maxLimit` alone is always fine, and `defaultLimit` alone is
|
||||
// always fine, but raising one past the other is the mistake worth
|
||||
// catching — and it is invisible to any single value's schema.
|
||||
try {
|
||||
validateSettings({ ...settings, ...patch })
|
||||
} catch (error) {
|
||||
return {
|
||||
...base,
|
||||
ok: false,
|
||||
errorCode: 'INVALID_SETTING',
|
||||
note: `Refused: ${error?.message ?? error}`,
|
||||
remedy: 'Nothing was written. Send a patch whose resulting section is consistent.',
|
||||
}
|
||||
}
|
||||
if (writeSettings === undefined) {
|
||||
return {
|
||||
...base,
|
||||
@@ -1044,7 +1162,17 @@ export function apply(ctx, config) {
|
||||
remedy: `Set these values in the dsh-recall row of your cordis layer instead: ${JSON.stringify(patch)}`,
|
||||
}
|
||||
}
|
||||
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(', ')}.` }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user