/** * dsh-recall — browser half. * * Three contributions, each into a seat the Harness 0.1.7 composition declares: * * 1. the plugin's configuration page, in the **Plugins** panel; * 2. a one-line usage chip in a conversation's header utilities; * 3. a right-sidebar tab with the same figures and room for their caveats. * * **Where plugin settings live now.** 0.1.5 drew plugin configuration as cards * under Settings → Plugins → Plugin configuration, and a plugin claimed one by * binding `settingsScope` and registering into a `settings.plugin.item` slot. * Neither exists in 0.1.7. The generated settings document is keyed by the * plugin's own profile entry id, and a plugin that wants a page renders it as a * contribution to the Plugins panel — `plugins.bundle.config`, keyed by the * bundle's package name, which the page draws as a section on the plugin's own * card. Without a contribution there is no section, however complete the schema * is; the sibling seat `plugins.row.config` would instead put it one click * deeper, on a single row's page. * * **The page is the framework's form, not a form of our own.** The card used to * reproduce `PluginCard`'s chrome by hand — the `
  • ` 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({ id: 'dsh-recall', factory: (require) => { var module = { exports: {} } var exports = module.exports const react = require('react') const primitives = require('@deepseek-ai/dsh-client-ui-primitives') const { SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField, settingsNumberField, settingsTextField, } = primitives const h = react.createElement /** This plugin's identity: its settings namespace AND its tab-type id. */ const NS = 'dsh-recall' /** * The settings document is keyed by the plugin's PROFILE ENTRY id. * * That is the `id` of this bundle's row in the composed tree — `dsh-recall` * in cordis.patch.yml. It is not registered anywhere from here; exporting * `Config` from the host half is the registration, and this is the key the * Harness files it under. */ const SETTINGS_ENTRY = 'dsh-recall' /** * The seat this plugin's configuration page renders in. * * `plugins.bundle.config`, keyed by the BUNDLE's package name: the Plugins * page draws that contribution as a section on the plugin's own card, * between its description and its rows. That is where someone opening * "dsh-recall" expects its settings — the alternative seat, * `plugins.row.config` (keyed `#`), draws them on a page * one click deeper, behind the row's title button, which is the right home * for one row of a bundle that declares several. This bundle declares one * row, so the card is the honest place. */ const BUNDLE_PACKAGE = 'dsh-recall' const BUNDLE_CONFIG_KEY = BUNDLE_PACKAGE const TITLE = 'Recall' const DESCRIPTION = 'Local embedding endpoint, what gets indexed, and how many tokens a result may cost.' /** * The settings this page edits, grouped for scanning. * * The list mirrors the host schema in index.js, deliberately: each entry * names the field, the control that stages it, and the copy a person needs * to choose a value. A field the host does not declare is refused by the * document, and a declared field missing from this list is simply not * editable here — so the two are kept in step by * `test/client.test.js`, which fails when the schema and this list differ. */ const FIELDS = [ { key: 'embeddingApiBase', kind: 'text', group: 'Endpoint', label: 'Embedding endpoint', hint: 'e.g. http://127.0.0.1:11434 for Ollama. Must be reachable from the machine running the harness.', }, { key: 'embeddingModel', kind: 'text', group: 'Endpoint', label: 'Embedding model', hint: 'The model id as the endpoint knows it. Changing this discards the existing index.', }, { key: 'embeddingDimensions', kind: 'number', group: 'Endpoint', label: 'Embedding dimensions', hint: 'Must match what the model returns. Changing this discards the existing index.', }, { key: 'embeddingApiKey', kind: 'secret', group: 'Endpoint', label: 'Embedding API key', hint: 'Stored as a secret and never returned to this page. Leave blank to keep the current value.', }, { key: 'embeddingBatchSize', kind: 'number', group: 'Endpoint', label: 'Inputs per request', }, { key: 'embeddingConcurrency', kind: 'number', group: 'Endpoint', label: 'Requests in flight', hint: 'Keep this low on a small host. A cold index is a sustained CPU load, and too much of it can leave the endpoint unable to load the model.', }, { key: 'embeddingTimeoutMs', kind: 'number', group: 'Endpoint', label: 'Request timeout (ms)', hint: 'Short on purpose. A wedged endpoint accepts the connection and never answers, so a long timeout looks like a hang.', }, { key: 'embeddingKeepAlive', kind: 'text', group: 'Endpoint', label: 'Keep-alive', hint: 'Ollama keep_alive, e.g. 5m. Keeps the model resident for the length of an index run.', }, { key: 'extensions', kind: 'list', group: 'What gets indexed', label: 'Extensions', hint: 'Comma separated. Empty uses the built-in code set (no prose or markup).', }, { key: 'exclude', kind: 'list', group: 'What gets indexed', label: 'Extra exclude patterns', hint: 'Comma separated globs, e.g. src/generated/. Added to the built-in directory list rather than replacing it.', }, { key: 'maxFiles', kind: 'number', group: 'What gets indexed', label: 'Most files to consider', }, { key: 'maxFileBytes', kind: 'number', group: 'What gets indexed', label: 'Skip files larger than (bytes)', hint: 'Generated and minified files are usually large; they are skipped rather than indexed as noise.', }, { key: 'chunkLines', kind: 'number', group: 'Chunking', label: 'Target lines per chunk' }, { key: 'maxChunkLines', kind: 'number', group: 'Chunking', label: 'Hard cap before splitting' }, { key: 'overlapLines', kind: 'number', group: 'Chunking', label: 'Lines shared when splitting', hint: 'Must be smaller than the hard cap, or a split never advances.', }, { key: 'excerptLines', kind: 'number', group: 'Results', label: 'Lines per excerpt', hint: 'A chunk is a bounded semantic unit, so this defaults to the chunk cap and results come back whole. Truncating below it is how a search turns into a follow-up read that costs more than it saved.', }, { key: 'totalExcerptLines', kind: 'number', group: 'Results', label: 'Total lines across one result set', hint: 'Shared budget, allocated by rank, so the best answer gets the room and the response stays bounded.', }, { key: 'defaultLimit', kind: 'number', group: 'Results', label: 'Excerpts returned' }, { key: 'maxLimit', kind: 'number', group: 'Results', label: 'Most a caller may ask for' }, { key: 'perFileLimit', kind: 'number', group: 'Results', label: 'Most chunks from one file', hint: 'Stops one large file filling the whole result set.', }, { key: 'minSimilarity', kind: 'number', group: 'Results', label: 'Weak-match threshold', hint: 'A cosine similarity between 0 and 1. Below this best score, results are reported as a weak match and the model is told to prefer grep.', }, { key: 'syncBudgetMs', kind: 'number', group: 'Index', label: 'Index warm-up budget per search (ms)', hint: 'How long one search may spend embedding changed files before reporting the rest as pending.', }, { key: 'stateDir', kind: 'text', group: 'Index', label: 'Index directory', hint: 'Empty uses $DSH_HOME/dsh-recall/workspaces/.', }, { key: 'focus', kind: 'text', group: 'Focus', label: 'Default focus', hint: 'A workspace-relative directory to scope every call to. Empty resolves by marker file, then the workspace root.', }, { key: 'focusMarkers', kind: 'list', group: 'Focus', label: 'Scope marker files', hint: 'Comma separated filenames that make a directory its own scope. Empty uses the built-ins.', }, { key: 'expansionEnabled', kind: 'boolean', group: 'Query expansion', label: 'Ask the local model for rephrasings', hint: 'true or false. Off by default: it spends one local generation per search on a gain that has not been measured against a real model.', }, { key: 'expansionModel', kind: 'text', group: 'Query expansion', label: 'Expansion model', hint: 'Empty uses the embedding model.', }, { key: 'expansionCount', kind: 'number', group: 'Query expansion', label: 'Most variants to ask for' }, { key: 'expansionTimeoutMs', kind: 'number', group: 'Query expansion', label: 'Expansion budget (ms)', hint: 'Expiry just means the search runs without expansion.', }, { key: 'rerankEnabled', kind: 'boolean', group: 'Re-ranking', label: 'Score candidates with the local model', hint: 'true or false. Off by default: one more model call on the critical path of every search.', }, { key: 'rerankModel', kind: 'text', group: 'Re-ranking', label: 'Re-ranking model' }, { key: 'rerankCandidates', kind: 'number', group: 'Re-ranking', label: 'Candidates scored per search', }, { key: 'rerankTimeoutMs', kind: 'number', group: 'Re-ranking', label: 'Re-ranking budget (ms)' }, ] /** Fields the Host redacts, so the page never holds their real value. */ const SECRET_KEYS = FIELDS.filter((field) => field.kind === 'secret').map((field) => field.key) /** HTML ids, unique per field, so every label points at its own control. */ const controlId = (key) => `dsh-recall-${key}` /** * A whole-list field: comma or newline separated in the control, an array of * non-empty strings in the document. * * One of three conversions the shared model asks a field for. `number` and * `text` ship with the primitives; a list does not, because only the owning * plugin knows whether its list is comma-separated, JSON, or one-per-line. */ function settingsListField(field) { return { field, format: (value) => (Array.isArray(value) ? value.join(', ') : ''), parse: (text) => { const parts = String(text) .split(/[,\n]/) .map((part) => part.trim()) .filter((part) => part !== '') // Empty means "no restriction", which for these fields is the schema // default — so this clears the override rather than storing an empty // list that would shadow a deployment's own default with nothing. return parts.length === 0 ? { kind: 'clear' } : { kind: 'set', value: parts } }, } } /** * A boolean field, edited as text. * * The shared model stages strings, so a boolean is staged as `true`/`false` * and the accepted spellings are broadened rather than guessed at: anything * else leaves the draft invalid and blocks the save, which is the honest * outcome for a value the field cannot represent. */ function settingsBooleanField(field) { return { field, format: (value) => (value === true ? 'true' : 'false'), parse: (text) => { const normalised = String(text).trim().toLowerCase() if (normalised === '') return { kind: 'clear' } if (['true', 'on', 'yes', '1'].includes(normalised)) return { kind: 'set', value: true } if (['false', 'off', 'no', '0'].includes(normalised)) return { kind: 'set', value: false } return undefined }, } } /** 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 // Every field the projection reads must exist BEFORE anything projects. // // `SettingsFormModel.bind()` is not a subscription setter: it builds the // snapshot store from one immediate `project()` call, so the projection // runs once, right here, inside this constructor. Assigning `describe` // after `bind` made that first projection read `undefined.getSnapshot()` // — a TypeError thrown while the inject callback was registering the // card, which aborted the registration and left the plugin with no // settings section at all and nothing in the UI to say so. this.describe = ctx.configForms.describe() 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()) } /** 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) { const { field, state, disabled, onEdit, onReset, secretSet } = props if (field.kind === 'secret') { return h(SettingsSecretField, { id: controlId(field.key), label: field.label, hint: field.hint ?? '', text: state.text, configured: secretSet, stateLabel: secretSet ? 'A key is stored.' : 'No key is stored.', disabled, onEdit, }) } return h(SettingsValueField, { id: controlId(field.key), label: field.label, hint: field.hint, text: state.text, overridden: state.overridden, invalid: state.invalid, overriddenLabel: OVERRIDDEN_LABEL, resetLabel: RESET_LABEL, invalidLabel: INVALID_LABEL, numeric: field.kind === 'number', disabled, onEdit, onReset, }) } /** * The plugin's configuration page. * * The seat asks for `page`, the form with its save control; the `summary` * branch answers the other shape a configuration entry can be asked for — * the one-liner standing in for a missing description — because a component * registered under a seat that only ever asks for `page` is also the natural * thing to reuse where that shape arrives. Only a save writes, and the form * discards its drafts on unmount. */ function RecallSettingsCard(props) { if (props.view === 'summary') return DESCRIPTION const state = props.useRecallSettings((snapshot) => snapshot) const rows = [] let group for (const field of FIELDS) { if (field.group !== group) { group = field.group // The only chrome this file draws itself. Thirty-three fields in one // flat list is a long scroll, and the framework's form renders no // group headings of its own — so the headings are here, coloured from // the product's own token rather than a hand-picked grey so they sit // in the panel instead of beside it. rows.push( h( 'h4', { key: `group-${group}`, style: { margin: '20px 0 0', fontSize: '12px', fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase', color: 'var(--dsw-alias-label-tertiary)', }, }, group, ), ) } rows.push( h(Field, { key: field.key, field, state: state.fields[field.key], secretSet: state.secretSet, disabled: !state.shell.writable || state.shell.saving, onEdit: (text) => props.edit(field.key, text), onReset: () => props.resetField(field.key), }), ) } return h( SettingsForm, { labels: FORM_LABELS, state: state.shell, onSave: props.save, onDiscard: props.discard, }, ...rows, ) } /** The projection key the host half registers. */ const USAGE_KEY = 'recallUsage' /** Byte counts a person reads at a glance. */ function humanBytes(bytes) { if (!Number.isFinite(bytes) || bytes <= 0) return '0' if (bytes < 1024) return bytes + ' B' if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB' return (bytes / (1024 * 1024)).toFixed(1) + ' MB' } /** * Read the usage projection, whatever the slot can offer. * * Called unconditionally and before any early return, because it is a hook: * a conditional call would break the moment the chip had nothing to show, * which is its normal state in a conversation that has not used the tool. */ function useUsage(props) { if (typeof props.useProjection !== 'function') return undefined return props.useProjection(USAGE_KEY) } /** * The one-line summary: what it kept out, not what it cost. * * The dollar figure is deliberately absent here. At the measured cache-read * rate a whole conversation's avoided context is worth a fraction of a * penny, and a chip advertising that would be read as the tool being * useless rather than as the arithmetic being honest. The detail lives in * the tab and in the tooltip. */ function chipText(usage) { const parts = ['Recall ' + usage.searches] if (usage.compression > 1) parts.push(usage.compression + '\u00d7') if (usage.avoidedBytes > 0) parts.push(humanBytes(usage.avoidedBytes) + ' kept out') return parts.join(' \u00b7 ') } /** Everything a hover can hold, including what is not worth a chip. */ function chipDetail(usage) { const lines = [ usage.searches + ' search' + (usage.searches === 1 ? '' : 'es') + (usage.indexes ? ', ' + usage.indexes + ' index' : ''), usage.excerpts + ' excerpt' + (usage.excerpts === 1 ? '' : 's') + ' from ' + usage.files + ' file' + (usage.files === 1 ? '' : 's'), humanBytes(usage.returnedBytes) + ' returned where ' + humanBytes(usage.sourceBytes) + ' would have been read', ] if (usage.rereadTokens > 0) { lines.push( 'about ' + usage.rereadTokens.toLocaleString() + ' tokens re-read since, if those files had been read in full', ) } lines.push('estimate: reading every file the excerpts came from, not a measurement of what you would have done') return lines.join('\n') } function RecallChip(props) { const usage = useUsage(props) // The utilities list draws nothing for an entry that renders nothing, so // a conversation that never called the tool costs no layout at all. if (usage === undefined || !usage.searches) return null return h( 'span', { title: chipDetail(usage), 'aria-label': chipText(usage) + ' \u2014 recall usage for this conversation', style: { fontSize: '11px', opacity: 0.75, whiteSpace: 'nowrap', padding: '2px 8px', borderRadius: '999px', // 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), ) } /** One labelled figure in the panel. */ function Figure(props) { const { label, value, note } = props return h( 'div', { style: { marginBottom: '10px' } }, h('div', { style: { fontSize: '11px', opacity: 0.6 } }, label), h('div', { style: { fontSize: '15px', fontWeight: 600 } }, value), note === undefined ? null : h('div', { style: { fontSize: '11px', opacity: 0.6, lineHeight: 1.4 } }, note), ) } function RecallPanel(props) { const usage = useUsage(props) if (usage === undefined) { // A host without the projection, or a session that predates it. Say so // rather than showing zeroes, which would read as "this did nothing". return h('div', { style: { padding: '16px', fontSize: '12px', opacity: 0.7 } }, 'Recall usage is not available for this conversation.') } if (!usage.searches && !usage.indexes) { return h( 'div', { style: { padding: '16px', fontSize: '12px', opacity: 0.7, lineHeight: 1.5 } }, 'This conversation has not used recall yet. It returns ranked excerpts instead of whole files, so context it keeps out is never re-sent on a later step.', ) } const head = usage.compression > 1 ? usage.compression + '\u00d7' : '\u2014' return h( 'div', { style: { padding: '16px', fontSize: '12px' } }, h(Figure, { label: 'Kept out of this conversation', value: humanBytes(usage.avoidedBytes), note: humanBytes(usage.returnedBytes) + ' returned, where reading those files would have been ' + humanBytes(usage.sourceBytes), }), h(Figure, { label: 'Smaller than reading the files', value: head, note: 'The excerpt is a slice; the comparison is against the whole of each file it came from.', }), h(Figure, { label: 'Re-read since', value: usage.rereadTokens > 0 ? '~' + usage.rereadTokens.toLocaleString() + ' tokens' : 'not yet', note: 'Context a conversation carries is re-sent on every later step. Avoided context is avoided every time.', }), h(Figure, { label: 'Searches', value: String(usage.searches) + (usage.indexes ? ' + ' + usage.indexes + ' indexed' : ''), note: usage.excerpts + ' excerpts from ' + usage.files + ' files' + (usage.weak ? '; ' + usage.weak + ' too weak to use' : ''), }), h( 'p', { style: { fontSize: '11px', opacity: 0.6, lineHeight: 1.5, marginTop: '4px' } }, 'An estimate, and an upper bound: it assumes a whole file would have been read each time. Token counts are bytes \u00f7 4.', ), ) } function apply(ctx) { // The settings page. Reached through `ctx.inject` rather than a top-level // `inject`, so a deployment that composed no settings provider loses the // configuration page and keeps the chip and the tab — a required // dependency would instead mean this plugin never applied at all. ctx.inject(['configForms'], (scoped) => { const card = new RecallSettingsController(scoped) scoped.effect(() => () => { card.dispose() }, 'dsh-recall: settings form') // `whileServed` keeps the registration alive exactly while the Host // serves this entry's settings. A page for a namespace the Host does not // serve would be a control that silently writes nowhere. scoped.effect( () => scoped.configForms.whileServed([SETTINGS_ENTRY], () => scoped.slots.inject('plugins.bundle.config', () => scoped.slots.register( { name: 'plugins.bundle.config', key: BUNDLE_CONFIG_KEY, inject: () => card.inject(), }, RecallSettingsCard, ), ), ), 'dsh-recall: settings page', ) }) // The chip: a right-aligned session utility, next to the header's // far-right corner. // // It is in the `utilities` LIST, not the corner, and that is not a // cosmetic choice. The corner is a `single` cell whose occupant the // product already declares — `client-ui-sidebar-right ExpandButton`, the // right sidebar's expand control — and the client catalog reports it as // `replaceRisk: "shadows-shipped-ui"`. A second registration at the same // priority is a hard error inside the slot registry, thrown while the // other plugin's `slots.inject()` callback runs: the renderer catches it, // STOPS that inject controller for the rest of the fiber's life, and // rethrows it uncaught. So squatting there did not merely race — it took // the shipped expand control down and logged an uncaught error. A list // entry carrying its own id cannot collide with anyone, and the framework // draws nothing for an entry that renders nothing. ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register( // `order` ascends left to right, so a high value keeps the chip at // the right-hand end of the utilities, where it was before. { name: 'conversation.session.header.utilities', id: 'dsh-recall', order: 20 }, (props) => h(RecallChip, props), ), ) // The right-sidebar tab is registered in two stages, and the second is // useless without the first: a tab TYPE says what a tab is (id, kind, // title, guide entry), and the keyed slot of the same id supplies its // body. Registering only the body leaves a type nobody declared, so no // tab can ever open it. ctx.inject(['sidebarRightTabs'], (scoped) => { scoped.effect( () => scoped.sidebarRightTabs.register({ id: NS, kind: NS, // `extension` is the band for a type from outside the product, and // the kind is ours alone — nothing shipped claims it. priority: 'extension', title: () => TITLE, guide: [ { id: NS, order: 60, title: () => TITLE, description: () => 'What recall kept out of this conversation, and what that estimate assumes.', }, ], }), 'dsh-recall: right-sidebar tab type', ) }) // The body, dispatched under the tab type's id. Registered through // `slots.inject` because the seat belongs to the right sidebar's session // entry and may not be declared yet. ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: NS }, (props) => h(RecallPanel, props)), ) } module.exports = { name: NS, inject: ['slots'], apply } return module.exports }, })