Release / release (push) Successful in 6s
The chip registered into `conversation.session.header.corner`, which is a
SINGLE cell the product already occupies: the client catalog lists its
occupants as `client-ui-sidebar-right ExpandButton` and its replaceRisk as
`shadows-shipped-ui`. Two registrations at priority 0 in a single cell are a
hard error inside the slot registry, and the error lands in the OTHER
plugin's `slots.inject()` callback: the renderer's `changed` handler catches
it, calls `stop()` on that inject controller — killing that contribution for
the rest of the fiber's life — and rethrows it from a microtask, which is the
uncaught `single slot ... already has a registration at priority 0 (registered
by dsh-recall)` in the console. So the chip was not merely racing the shipped
right-sidebar expand control; it was taking it down.
It now registers into `conversation.session.header.utilities`, a list cell
(`replaceRisk: none`, right-aligned, same Session scope and therefore the same
`useProjection` prop) as `{ id: 'dsh-recall', order: 20 }`, so it stays at the
right-hand end without competing for a seat.
The suite could not have caught this: the slot stub accepts any registration
and knows nothing about cardinality, keys or collisions. It now asserts the
chip carries a list id and that this plugin registers nothing into the corner.
802 lines
32 KiB
JavaScript
802 lines
32 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 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 `<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 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
|
|
},
|
|
})
|