The configuration page registered into seats that 0.1.7 removed, so it could
not appear at all — and the schema carried none of the metadata the new
settings document requires.
Host half. `ctx.settings.installSection` no longer exists: a plugin exports a
`Config` schema, the Harness keys one form per composed entry by that row's
`id`, and `ctx.settings.describe` skips an entry with no `volatile()` field
outright. Every field is now volatile, which is also what makes a saved edit
apply to the running instance instead of remounting it — so `apply` reads the
section through `settingsSnapshot`, which unwraps the live references a
volatile field arrives as. Writes go through `ctx.settings.update` fenced by
the revision `describe` reports. The `.min`/`.max` bounds the schema can state
are stated there, so the form refuses an impossible draft in front of the
person typing it; `validateSettings` keeps the cross-field rules no single
field can express, and now runs on the section a call is about to act on
rather than at write time, because 0.1.7 has no write-time validator hook.
Browser half. `settingsScope` and the `settings.plugin.item` slot are gone. The
page renders through `plugins.row.config`, keyed `<bundle package>#<row id>`
(`dsh-recall#dsh-recall`), which is what puts a Configure control on the
plugin's row in the Plugins panel. It uses the framework's own staged form —
`SettingsFormModel` over `ctx.configForms`, drawn by `SettingsForm` and
`SettingsValueField`/`SettingsSecretField` — instead of reproducing card chrome
by hand, so the unsaved, overridden, invalid, and save states come from the
same code as every other plugin's page. All 33 settings are editable now; the
old card exposed 23. The right-sidebar tab also needed the stage-one tab TYPE
(`ctx.sidebarRightTabs.register`) that its keyed body slot is dispatched under,
so a tab can finally be opened. `--dsh-border`, defined nowhere, becomes the
product's `--dsw-alias-border-l2`.
Both halves are now pinned by tests: every field is volatile, the field list
and the schema cannot drift, the page claims the key the patch declares, and a
blank API key is never written — the secret control reports configured-and-
absent instead.
Verified against the harness rather than only in unit tests: `--dump-config-
schema` emits the volatile and secret metadata, `apply` registers `recall` on
the real tool registry, `settings.describe({redactSecrets:true})` serves
`dsh-recall` with 33 fields and the key reported set-but-redacted, and a
scratch profile boots the web app with the plugin composed and its bundle
served byte-for-byte as this checkout has it.
769 lines
30 KiB
JavaScript
769 lines
30 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.row.config`, keyed
|
|
* `<bundle package>#<row id>`. That key is what puts a **configure** control on
|
|
* the plugin's row — without a contribution there is no control and no page,
|
|
* however complete the schema is.
|
|
*
|
|
* **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.
|
|
*
|
|
* Keyed `<bundle package name>#<row id>`, both as the profile's patch
|
|
* declares them. For this bundle the two coincide, which is a coincidence
|
|
* and not a rule — the constants are kept separate so that stays visible.
|
|
*/
|
|
const BUNDLE_PACKAGE = 'dsh-recall'
|
|
const BUNDLE_ROW = 'dsh-recall'
|
|
const ROW_CONFIG_KEY = `${BUNDLE_PACKAGE}#${BUNDLE_ROW}`
|
|
|
|
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
|
|
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())
|
|
/** The describe mirror that knows whether a secret is configured. */
|
|
this.describe = ctx.configForms.describe()
|
|
}
|
|
|
|
/** 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 Plugins page asks a configuration entry twice: once for a one-liner to
|
|
* stand in for a missing description (`summary`), and once for the body of
|
|
* the plugin's own page (`page`). Only the second draws a save control —
|
|
* and only one save writes, because 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.row.config', () =>
|
|
scoped.slots.register(
|
|
{
|
|
name: 'plugins.row.config',
|
|
key: ROW_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
|
|
},
|
|
})
|