Release / release (push) Successful in 6s
0.1.7-rc.2 moved four things this bundle was built on, and every one of them
failed quietly rather than loudly, so the plugin looked installed and did
nothing:
* `ctx.settings.installSection(...)` is gone. The call threw into a
`try/catch` that only logged a warning, so the settings namespace was never
registered and `configure` had nowhere to persist. An entry's exported
Config IS the form now; the namespace is the Loader entry id the bundle's
patch declares, and `ctx.settings.configure({ auto: false })` is what claims
that entry's own page.
* `settingsScope` and the `settings.plugin.item` slot are gone. The card
registered into both, so it never rendered — the tab draws the intersection
of served namespaces and registered cards, and that intersection was empty.
It now registers into `plugins.bundle.config` keyed by the bundle's package
name, over the `configForms` service, and uses the framework's own form
chrome (`SettingsFormModel` / `SettingsForm` / `SettingsValueField` /
`SettingsSecretField`) instead of the hand-rolled equivalent. A save is one
revision-fenced write.
* `ctx.shell.run(spec)` is gone. Every action that runs a command threw
`shell.run is not a function`; the seam is now
`await ctx.shell.execute(ctx.shell.resolve(request)).result()`.
* A job's `owner` is a session id, not the agent object. The wrong owner
registered a job no session could collect or cancel, and the producer's
`done` resolved `output` where the registry reads `result`, so a finished
job handed back nothing.
Two conformance gaps went with them: a Config field that is not marked
`volatile()` is invisible to every settings UI — the form projection keeps only
volatile fields, and an entry with no form is dropped from `settings.describe`
entirely — and `embeddingApiKey` was a plain field, so the key rode every
settings read back to the browser. It is `role('secret')` now: the browser is
told whether a key is stored and never receives the key.
Also declares the `@deepseek-ai/dsh-*` peerDependencies the compatibility gate
reads, so an incompatible runtime refuses this plugin at startup instead of
half-loading it.
Verified against a real 0.1.7-rc.2 boot: `settings.describe` serves the entry
with `autoGenerate: false`, the key redacted and reported only as `set: true`,
and no startup diagnostics. The host mock in the tests now has no `run` and no
`installSection`, so those seams are contract-tested rather than assumed, and
the browser tests read the installed harness's own slot catalog and package
exports — a rename fails a test instead of quietly emptying a page.
446 lines
18 KiB
JavaScript
446 lines
18 KiB
JavaScript
/**
|
|
* dsh-slopo — browser half: the bundle's configuration page on the Plugins page.
|
|
*
|
|
* Where the card lives, and why it moved. Up to dsh 0.1.6 the settings surfaces
|
|
* were: a settings namespace a plugin registered for itself, plus a
|
|
* `settings.plugin.item` card the plugin's client half registered under that
|
|
* namespace key, rendered inside Settings → Plugins → Plugin configuration.
|
|
* 0.1.7-rc.2 removed both halves of that contract:
|
|
*
|
|
* * `ctx.settings.installSection(...)` no longer exists, and a namespace is no
|
|
* longer chosen by the plugin — it IS the Loader profile entry id, which the
|
|
* bundle's own patch declares (`dsh-slopo`).
|
|
* * the `settings.plugin.item` slot and the `settingsScope` service no longer
|
|
* exist either. Configuration now lives on the Plugins page, and a bundle's
|
|
* own settings register into `plugins.bundle.config` keyed by the bundle's
|
|
* PACKAGE NAME, which the page only dispatches for a package it lists.
|
|
*
|
|
* The service that replaced `settingsScope` is `configForms`, and the object it
|
|
* hands out for one entry (`configForms.get(id)`) is API-compatible with what
|
|
* this card used before: `getSnapshot()`, `subscribe()`, `mutate(ops, revision)`.
|
|
* What actually changed is the chrome. The framework now ships the form
|
|
* vocabulary this card hand-rolled — `SettingsFormModel` stages the edits and
|
|
* fences the save with the revision the drafts were read at, `SettingsForm`
|
|
* draws the frame and the single Save, and the field controls carry the
|
|
* overridden badge, the reset and the invalid-edit block. Reproducing that by
|
|
* hand is what the file used to do; using it is the point.
|
|
*
|
|
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
|
|
* step and no bundler. `dsh plugin add git+…` fetches sources rather than build
|
|
* output, so a `prepare` script would make every user allowlist a build before
|
|
* the first install succeeds. The imports are `react` and
|
|
* `@deepseek-ai/dsh-client-ui-primitives`, both platform seed words; anything
|
|
* outside that table would have to be bundled into this file or declared in
|
|
* `dsh.client.external`.
|
|
*
|
|
* Styling is inline on purpose, for the same reason: there is no stylesheet this
|
|
* file may reach.
|
|
*/
|
|
window.__ModuleLoader__.load({
|
|
id: 'dsh-slopo',
|
|
factory: (require) => {
|
|
var module = { exports: {} }
|
|
var exports = module.exports
|
|
|
|
const react = require('react')
|
|
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
|
|
const h = react.createElement
|
|
const { Checkbox, SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField } = primitives
|
|
const { settingsNumberField, settingsTextField } = primitives
|
|
|
|
/**
|
|
* The bundle's package name.
|
|
*
|
|
* One string plays three roles and they must agree: it is the npm package
|
|
* the Plugins page lists, the Loader entry id inside that bundle's patch
|
|
* (and therefore the settings namespace `configForms` addresses), and the
|
|
* `plugins.bundle.config` key this card registers under.
|
|
*/
|
|
const BUNDLE = 'dsh-slopo'
|
|
|
|
/** Dictionary namespace owned by this plugin. */
|
|
const NS = 'dsh-slopo'
|
|
|
|
/** The one write-only field: a credential the wire never carries back. */
|
|
const API_KEY_FIELD = 'embeddingApiKey'
|
|
|
|
const DESCRIPTION =
|
|
'Embedding backend, index scope and the budget that stops a large embed.'
|
|
|
|
/** The card's own copy. */
|
|
const en = {
|
|
description: DESCRIPTION,
|
|
overridden: 'Overridden',
|
|
reset: 'Reset to default',
|
|
readOnly: 'This deployment stores settings read-only.',
|
|
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
|
|
save: 'Save',
|
|
saving: 'Saving…',
|
|
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
|
invalidNumber: 'Enter a number, or leave blank to use the default.',
|
|
apiKeySet: 'A key is stored.',
|
|
apiKeyUnset: 'No key is stored.',
|
|
listHint: 'Comma separated.',
|
|
'binary.label': 'slopo executable',
|
|
'binary.hint': 'Empty discovers it: managed install, then PATH, then ~/.local/bin.',
|
|
'autoInstall.label': 'Install slopo with uv when missing',
|
|
'autoInstall.hint': 'Installs into $DSH_HOME with uv, so the executable survives a container recreation.',
|
|
'embeddingModel.label': 'Embedding model',
|
|
'embeddingModel.hint':
|
|
'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
|
|
'embeddingDimensions.label': 'Embedding dimensions',
|
|
'embeddingDimensions.hint': 'Must match what the model returns. Frozen into the index at first use.',
|
|
'embeddingApiBase.label': 'Embedding API base',
|
|
'embeddingApiBase.hint': 'e.g. http://127.0.0.1:11434',
|
|
'embeddingApiKey.label': 'Embedding API key',
|
|
'embeddingApiKey.hint':
|
|
'Passed to slopo in its environment and never written into the slopo config file. It is write-only here: the deployment reports whether one is stored, never the key.',
|
|
'embeddingParamsJson.label': 'Extra embedding parameters',
|
|
'embeddingParamsJson.hint': 'A JSON object, merged into embedding_params.',
|
|
'sourceDir.label': 'Source directory',
|
|
'sourceDir.hint': "Empty uses the session's working directory.",
|
|
'sourceDirExclude.label': 'Extra exclude patterns',
|
|
'sourceDirExclude.hint': 'Gitignore style, comma separated. Added to the defaults rather than replacing them.',
|
|
'useDefaultExcludes.label': 'Exclude node_modules and build output',
|
|
'useDefaultExcludes.hint':
|
|
'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.',
|
|
'maxIndexUnits.label': 'Refuse to embed more than this many units',
|
|
'maxIndexUnits.hint': '0 removes the limit. Makes a large scope a question rather than an hours-long embed.',
|
|
'ignoreFile.label': 'Ignore file',
|
|
'ignoreFile.hint': 'Point this at the repository to version the dismissed clusters.',
|
|
'stateDir.label': 'State directory',
|
|
'stateDir.hint': 'Empty uses $DSH_HOME/dsh-slopo/workspaces/<workspace>.',
|
|
'embeddingRequestDelay.label': 'Delay between embedding requests (ms)',
|
|
'embeddingRequestDelay.hint': 'Rate limit for a provider that rejects bursts. 0 sends as fast as it can.',
|
|
'timeoutMs.label': 'Foreground timeout (ms)',
|
|
'timeoutMs.hint': 'How long one foreground slopo run may take before it is killed.',
|
|
}
|
|
|
|
/**
|
|
* The fields this card edits, in display order.
|
|
*
|
|
* This list mirrors the Host schema in index.js, including `kind`. It is
|
|
* duplicated rather than fetched because the card renders its own controls —
|
|
* there is no schema-to-form renderer in the framework — and a field the
|
|
* Host does not know is rejected there anyway, so a drift shows up as a
|
|
* clear error rather than silent data loss. Every key must be a `volatile()`
|
|
* field of that schema or the write is refused.
|
|
*/
|
|
const FIELDS = [
|
|
{ key: 'embeddingModel', kind: 'text' },
|
|
{ key: 'embeddingDimensions', kind: 'number' },
|
|
{ key: 'embeddingApiBase', kind: 'text' },
|
|
{ key: 'embeddingApiKey', kind: 'secret' },
|
|
{ key: 'embeddingParamsJson', kind: 'text' },
|
|
{ key: 'binary', kind: 'text' },
|
|
{ key: 'autoInstall', kind: 'boolean' },
|
|
{ key: 'sourceDir', kind: 'text' },
|
|
{ key: 'sourceDirExclude', kind: 'list' },
|
|
{ key: 'useDefaultExcludes', kind: 'boolean' },
|
|
{ key: 'maxIndexUnits', kind: 'number' },
|
|
{ key: 'ignoreFile', kind: 'text' },
|
|
{ key: 'stateDir', kind: 'text' },
|
|
{ key: 'embeddingRequestDelay', kind: 'number' },
|
|
{ key: 'timeoutMs', kind: 'number' },
|
|
]
|
|
|
|
/** A label or hint from this card's dictionary. */
|
|
const copy = (t, key, part) => t(`${key}.${part}`)
|
|
|
|
/** Every field's label and hint, resolving against the active locale. */
|
|
const describeField = (t, field) => ({
|
|
key: field.key,
|
|
kind: field.kind,
|
|
label: copy(t, field.key, 'label'),
|
|
hint: copy(t, field.key, 'hint'),
|
|
id: `plugin-config-${BUNDLE}-${field.key}`,
|
|
})
|
|
|
|
/**
|
|
* A boolean field's draft conversion.
|
|
*
|
|
* The shared kit exports number and text specs only; a switch is a set of
|
|
* `true`/`false`, so the control has nothing to parse but the two literals
|
|
* it renders. Anything else is an invalid draft, which blocks the save
|
|
* rather than silently writing `false`.
|
|
*/
|
|
const settingsBooleanField = (field) => ({
|
|
field,
|
|
format: (value) => (value === true ? 'true' : 'false'),
|
|
parse: (text) => {
|
|
const trimmed = text.trim()
|
|
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
return undefined
|
|
},
|
|
})
|
|
|
|
/**
|
|
* A string-array field's draft conversion: a comma-separated list.
|
|
*
|
|
* An empty draft is an empty list rather than a clear: the schema declares
|
|
* this field's default as `[]`, so "no extra patterns" and "fall back to
|
|
* the composition layer" are the same answer.
|
|
*/
|
|
const settingsListField = (field) => ({
|
|
field,
|
|
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
|
|
parse: (text) => ({
|
|
kind: 'set',
|
|
value: text
|
|
.split(/[,\n]/)
|
|
.map((part) => part.trim())
|
|
.filter((part) => part !== ''),
|
|
}),
|
|
})
|
|
|
|
/** The spec one field's drafts are converted with. */
|
|
function specFor(field) {
|
|
if (field.kind === 'number') return settingsNumberField(field.key)
|
|
if (field.kind === 'boolean') return settingsBooleanField(field.key)
|
|
if (field.kind === 'list') return settingsListField(field.key)
|
|
return settingsTextField(field.key)
|
|
}
|
|
|
|
/** The form frame's copy, read from this card's dictionary. */
|
|
const formLabels = (t) => ({
|
|
unavailable: t('unavailable'),
|
|
readOnly: t('readOnly'),
|
|
saveFailed: t('saveFailed'),
|
|
save: t('save'),
|
|
saving: t('saving'),
|
|
})
|
|
|
|
/** The field-control copy shared by every row. */
|
|
const fieldLabels = (t) => ({
|
|
overriddenLabel: t('overridden'),
|
|
resetLabel: t('reset'),
|
|
invalidLabel: t('invalidNumber'),
|
|
})
|
|
|
|
/** One staged text row. */
|
|
function Row(props) {
|
|
const { t, state, disabled, edit, resetField, meta } = props
|
|
return h(SettingsValueField, {
|
|
id: meta.id,
|
|
label: meta.label,
|
|
hint: meta.hint,
|
|
disabled,
|
|
numeric: meta.kind === 'number',
|
|
...fieldLabels(t),
|
|
...state,
|
|
onEdit: (text) => edit(meta.key, text),
|
|
onReset: () => resetField(meta.key),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* One staged switch row.
|
|
*
|
|
* `Checkbox` owns native keyboard and form semantics but no override
|
|
* affordance, so the badge and the reset sit beside it — the same pair a
|
|
* `SettingsValueField` draws for a text row.
|
|
*/
|
|
function SwitchRow(props) {
|
|
const { t, state, disabled, edit, resetField, meta } = props
|
|
const checked = state.text === 'true'
|
|
return h(
|
|
'div',
|
|
{ style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
h(
|
|
'div',
|
|
{ style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
|
|
h(Checkbox, {
|
|
label: meta.label,
|
|
checked,
|
|
disabled,
|
|
onChange: (next) => edit(meta.key, next ? 'true' : 'false'),
|
|
}),
|
|
state.overridden
|
|
? h(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
disabled,
|
|
onClick: () => resetField(meta.key),
|
|
style: {
|
|
font: 'inherit',
|
|
fontSize: '11px',
|
|
cursor: 'pointer',
|
|
background: 'transparent',
|
|
color: 'inherit',
|
|
opacity: 0.75,
|
|
border: 'none',
|
|
padding: 0,
|
|
},
|
|
},
|
|
`${t('overridden')} · ${t('reset')}`,
|
|
)
|
|
: null,
|
|
),
|
|
h('div', { style: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 } }, meta.hint),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Render the bundle's page: the form, or nothing while the deployment does
|
|
* not serve the entry.
|
|
*
|
|
* @param props - the card's state snapshot, the form actions, and locale copy.
|
|
* @returns the settings form.
|
|
*/
|
|
function SlopoCard(props) {
|
|
const { t } = props
|
|
const state = props.useSlopoCard((snapshot) => snapshot)
|
|
// `view` is `page` for a bundle's configuration slot; the bundle's
|
|
// one-liner is the package's own description, so there is no summary view
|
|
// to answer here.
|
|
const disabled = !state.writable
|
|
const children = []
|
|
for (const field of FIELDS) {
|
|
const meta = describeField(t, field)
|
|
const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
|
|
children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
|
|
}
|
|
return h(
|
|
SettingsForm,
|
|
{ labels: formLabels(t), state, onSave: props.save, onDiscard: props.discard },
|
|
h(
|
|
'div',
|
|
{ style: { display: 'flex', flexDirection: 'column', gap: '14px' } },
|
|
// The key is write-only, so it is not one of the rows above.
|
|
h(SettingsSecretField, {
|
|
id: `plugin-config-${BUNDLE}-${API_KEY_FIELD}`,
|
|
label: copy(t, API_KEY_FIELD, 'label'),
|
|
hint: copy(t, API_KEY_FIELD, 'hint'),
|
|
disabled,
|
|
text: state.apiKey.text,
|
|
configured: state.apiKeyConfigured,
|
|
stateLabel: state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset'),
|
|
onEdit: (text) => props.edit(API_KEY_FIELD, text),
|
|
}),
|
|
...children,
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The card's staged form over the `dsh-slopo` profile entry.
|
|
*
|
|
* `SettingsFormModel` is what makes Save the only write: a draft is staged
|
|
* against the revision it was read at, an invalid draft blocks the save
|
|
* instead of being dropped, and a refused save keeps its drafts so the user
|
|
* can correct them. The model subscribes to the scope itself and republishes
|
|
* through {@link SettingsFormModel.bind}, so this class only has to describe
|
|
* what one snapshot contains.
|
|
*/
|
|
class SlopoCardController {
|
|
/**
|
|
* @param ctx - the browser plugin context, whose `configForms` reports the entry's secret slots.
|
|
* @param scope - the shared configuration form for the `dsh-slopo` entry.
|
|
*/
|
|
constructor(ctx, scope) {
|
|
const specs = FIELDS.filter((field) => field.kind !== 'secret').map(specFor)
|
|
const secrets = [
|
|
{
|
|
field: API_KEY_FIELD,
|
|
// The credential is a settings field, not a separate credential
|
|
// reference, so its one-way write is the same fenced mutation the
|
|
// ordinary rows use — with the value never coming back.
|
|
write: (text) => scope.mutate([{ op: 'set', path: [API_KEY_FIELD], value: text }]),
|
|
},
|
|
]
|
|
this.ctx = ctx
|
|
this.form = new SettingsFormModel(scope, specs, secrets)
|
|
this.store = this.form.bind(() => this.projection())
|
|
}
|
|
|
|
/** Whether the deployment reports a stored key; the value itself never rides. */
|
|
readApiKeyConfigured() {
|
|
const view = this.ctx.configForms.describe().getSnapshot().view
|
|
const entry = view?.namespaces?.find((row) => row.ns === BUNDLE)
|
|
const slot = entry?.secrets?.find(
|
|
(secret) => Array.isArray(secret.path) && secret.path.length === 1 && secret.path[0] === API_KEY_FIELD,
|
|
)
|
|
return slot?.set === true
|
|
}
|
|
|
|
/** Build the snapshot the card renders from the form's current reads. */
|
|
projection() {
|
|
const state = {
|
|
...this.form.shell(),
|
|
apiKey: this.form.field(API_KEY_FIELD),
|
|
apiKeyConfigured: this.readApiKeyConfigured(),
|
|
}
|
|
for (const field of FIELDS) {
|
|
if (field.kind !== 'secret') state[field.key] = this.form.field(field.key)
|
|
}
|
|
return state
|
|
}
|
|
|
|
/** Build the face the slot registration injects: the card's state and its actions. */
|
|
inject() {
|
|
return { hooks: { slopoCard: this.store }, ...this.form.actions() }
|
|
}
|
|
|
|
/** Release the form's accepted-value subscription. */
|
|
dispose() {
|
|
this.form.dispose()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register the card, but only once the settings transport exists.
|
|
*
|
|
* `ctx.inject(['configForms'], …)` rather than a top-level `inject`: a
|
|
* required dependency that is unsatisfied means this plugin never applies at
|
|
* all, and the card would simply not exist on a deployment that composes no
|
|
* settings surface — which is the correct outcome, not a load failure.
|
|
*
|
|
* `whileServed` is the second gate. The registration only stands while the
|
|
* deployment actually serves the `dsh-slopo` namespace, so an uninstalled or
|
|
* disabled bundle leaves no card behind, and the Plugins page's own
|
|
* `plugins.bundle.config` ledger stays in step with what the Host serves.
|
|
*/
|
|
function apply(ctx) {
|
|
ctx.effect(
|
|
() =>
|
|
ctx.locale.register(NS, {
|
|
en,
|
|
}),
|
|
'dsh-slopo: card dictionary',
|
|
)
|
|
ctx.inject(['configForms'], (child) => {
|
|
const forms = child.configForms
|
|
if (forms === undefined) return
|
|
const controller = new SlopoCardController(child, forms.get(BUNDLE))
|
|
child.effect(
|
|
() => () => controller.dispose(),
|
|
'dsh-slopo: form subscription',
|
|
)
|
|
child.effect(
|
|
() =>
|
|
forms.whileServed([BUNDLE], () =>
|
|
child.slots.inject('plugins.bundle.config', () =>
|
|
child.slots.register(
|
|
{
|
|
name: 'plugins.bundle.config',
|
|
key: BUNDLE,
|
|
locale: NS,
|
|
inject: () => controller.inject(),
|
|
},
|
|
SlopoCard,
|
|
),
|
|
),
|
|
),
|
|
'dsh-slopo: Plugins page card',
|
|
)
|
|
})
|
|
}
|
|
|
|
module.exports = { name: BUNDLE, inject: ['slots', 'locale'], apply }
|
|
return module.exports
|
|
},
|
|
})
|