Release / release (push) Successful in 7s
The card never appeared, and nothing said why: the plugin's configuration
section was missing from its card in the Plugins panel while a sibling
plugin's showed, on a deployment where the Host demonstrably served both
namespaces (`settings/describe` over the app's own RPC answered
`dsh-recall fields=32 secrets=[embeddingApiKey set]`) and the browser was
demonstrably serving this bundle.
The cause was construction order. `SettingsFormModel.bind()` is not a
subscription setter — it builds its snapshot store from one immediate
`project()` call — so the projection runs inside the constructor, and the
projection reads the describe mirror to report whether the API key is set.
`this.describe` was assigned *after* `bind`, so that first projection read
`undefined.getSnapshot()`:
TypeError: Cannot read properties of undefined (reading 'getSnapshot')
at RecallSettingsController.secretIsSet (client/client.js:428)
at RecallSettingsController.projection (client/client.js:413)
It was thrown from inside the `ctx.inject(['configForms'], …)` callback while
the card was being registered, so the registration was abandoned: no ledger
entry, no section on the card, and no error in the UI — the one failure mode
this file's own header set out to prevent.
Everything the projection reads is now assigned before anything projects. The
sibling plugin was never affected because it reads the same face lazily from a
field it assigns first.
The test stub's `bind()` only *stored* the projection, which is why the suite
was green through all of this. It now projects once, as the framework does —
and with the old ordering every mounting test fails with that TypeError, so
this cannot regress silently again.
784 lines
31 KiB
JavaScript
784 lines
31 KiB
JavaScript
/**
|
|
* 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 corner;
|
|
* 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 `<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({
|
|
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 `<package>#<row id>`), 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/<workspace>.',
|
|
},
|
|
|
|
{
|
|
key: 'focus',
|
|
kind: 'text',
|
|
group: 'Focus',
|
|
label: 'Default focus',
|
|
hint: 'A workspace-relative directory to scope every call to. Empty resolves by marker file, then the workspace root.',
|
|
},
|
|
{
|
|
key: 'focusMarkers',
|
|
kind: 'list',
|
|
group: 'Focus',
|
|
label: 'Scope marker files',
|
|
hint: 'Comma separated filenames that make a directory its own scope. Empty uses the built-ins.',
|
|
},
|
|
|
|
{
|
|
key: 'expansionEnabled',
|
|
kind: 'boolean',
|
|
group: 'Query expansion',
|
|
label: 'Ask the local model for rephrasings',
|
|
hint: 'true or false. Off by default: it spends one local generation per search on a gain that has not been measured against a real model.',
|
|
},
|
|
{
|
|
key: 'expansionModel',
|
|
kind: 'text',
|
|
group: 'Query expansion',
|
|
label: 'Expansion model',
|
|
hint: 'Empty uses the embedding model.',
|
|
},
|
|
{ key: 'expansionCount', kind: 'number', group: 'Query expansion', label: 'Most variants to ask for' },
|
|
{
|
|
key: 'expansionTimeoutMs',
|
|
kind: 'number',
|
|
group: 'Query expansion',
|
|
label: 'Expansion budget (ms)',
|
|
hint: 'Expiry just means the search runs without expansion.',
|
|
},
|
|
|
|
{
|
|
key: 'rerankEnabled',
|
|
kind: 'boolean',
|
|
group: 'Re-ranking',
|
|
label: 'Score candidates with the local model',
|
|
hint: 'true or false. Off by default: one more model call on the critical path of every search.',
|
|
},
|
|
{ key: 'rerankModel', kind: 'text', group: 'Re-ranking', label: 'Re-ranking model' },
|
|
{
|
|
key: 'rerankCandidates',
|
|
kind: 'number',
|
|
group: 'Re-ranking',
|
|
label: 'Candidates scored per search',
|
|
},
|
|
{ key: 'rerankTimeoutMs', kind: 'number', group: 'Re-ranking', label: 'Re-ranking budget (ms)' },
|
|
]
|
|
|
|
/** Fields the Host redacts, so the page never holds their real value. */
|
|
const SECRET_KEYS = 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 corner is laid out only while its occupant renders something, 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: the header's far-right corner, which the framework lays out
|
|
// only while its occupant renders something.
|
|
ctx.slots.inject('conversation.session.header.corner', () =>
|
|
ctx.slots.register({ name: 'conversation.session.header.corner' }, (props) => h(RecallChip, props)),
|
|
)
|
|
|
|
// The right-sidebar tab is registered in two stages, and the second is
|
|
// useless without the first: a tab TYPE says what a tab is (id, kind,
|
|
// title, guide entry), and the keyed slot of the same id supplies its
|
|
// body. Registering only the body leaves a type nobody declared, so no
|
|
// tab can ever open it.
|
|
ctx.inject(['sidebarRightTabs'], (scoped) => {
|
|
scoped.effect(
|
|
() =>
|
|
scoped.sidebarRightTabs.register({
|
|
id: NS,
|
|
kind: NS,
|
|
// `extension` is the band for a type from outside the product, and
|
|
// the kind is ours alone — nothing shipped claims it.
|
|
priority: 'extension',
|
|
title: () => TITLE,
|
|
guide: [
|
|
{
|
|
id: NS,
|
|
order: 60,
|
|
title: () => TITLE,
|
|
description: () => 'What recall kept out of this conversation, and what that estimate assumes.',
|
|
},
|
|
],
|
|
}),
|
|
'dsh-recall: right-sidebar tab type',
|
|
)
|
|
})
|
|
|
|
// The body, dispatched under the tab type's id. Registered through
|
|
// `slots.inject` because the seat belongs to the right sidebar's session
|
|
// entry and may not be declared yet.
|
|
ctx.slots.inject('sidebar.right.pane.tab', () =>
|
|
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: NS }, (props) => h(RecallPanel, props)),
|
|
)
|
|
}
|
|
|
|
module.exports = { name: NS, inject: ['slots'], apply }
|
|
return module.exports
|
|
},
|
|
})
|