14 Commits
Author SHA1 Message Date
gitea-actions 9eff2af742 Release v0.2.0 [skip release] [skip actions] 2026-09-27 06:17:05 +00:00
NotExpectedYet 0dc79f8e0b fix: hand the jobs registry a session id, not the agent handle
Release / release (push) Successful in 6s
JobSpec.owner is a SessionId, and the registry resolves it through
agents.get(owner) - so passing exec.agent made the DEFAULT index call fail
with 'session "[object Object]" has no live agent'. Since index defaults to
run_in_background, the action that exists to pre-index a workspace was the
one action that could not run. A handle without an id registers the job
unowned rather than throwing.

The test mounts a jobs service and pins the owner contract; no test had ever
supplied one, which is why this reached a real deployment.
2026-09-27 07:16:31 +01:00
NotExpectedYet de4d020dcb feat: index extra source roots beside the workspace, for the code you write against
The workspace index cannot answer questions about an installed framework or
SDK, and in plugin development that is where most of the reading goes: 64 of
80 tool calls in one real session went to the harness checkout under
/usr/local/lib/node_modules to look up the API being implemented.

A root is entered whatever it is called, and its dependencies are not: a
path ending in node_modules/... is the normal way to name a package tree,
while a node_modules below the root is somebody else's code. A result
carries the label that says which tree it came from and an absolute
sourcePath, the only form a follow-up read can resolve.

The tool description now also claims the move this replaces - searching
before reading files to find out what a module contains.
2026-09-27 07:16:21 +01:00
NotExpectedYet bccd112ce5 docs: require committing as you go, in coherent single-concern commits 2026-09-27 07:16:14 +01:00
gitea-actions 602c715cbf Release v0.1.5 [skip release] [skip actions] 2026-09-27 05:37:14 +00:00
NotExpectedYet 28df7a0e56 fix: put the recall chip in the header utilities, not the cell the sidebar's expand button owns
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.
2026-09-27 06:36:49 +01:00
gitea-actions 811871e978 Release v0.1.4 [skip release] [skip actions] 2026-09-26 06:50:34 +00:00
NotExpectedYet fbf1da7452 fix: register the settings card instead of throwing while building it
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.
2026-09-26 07:43:31 +01:00
gitea-actions a350178434 Release v0.1.3 [skip release] [skip actions] 2026-09-26 06:07:51 +00:00
NotExpectedYet 7f1dfddb83 Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall
Release / release (push) Successful in 6s
2026-09-26 07:07:33 +01:00
NotExpectedYet 233215e6c9 fix: draw the settings on the plugin card, not a click deeper
The page was registered into `plugins.row.config`, keyed
`<package>#<row id>`. That seat puts a page behind the ROW's title button on the
bundle's card — the right home for one row of a bundle that declares several,
and the reasoning the previous commit wrote down. What it missed is that this
bundle declares exactly one row, so its settings became reachable only by
opening the card and noticing that the row title had turned into a button.
Reported from a real install as "it shows the plugin just no settings", while
the sibling plugin's settings were visible: that one registers into
`plugins.bundle.config`.

`plugins.bundle.config` is the same document, dispatched on the card itself:
`PackageDetail` gates a section on `ledger.bundles.has(pkg.name)` and renders
`renderSlot("plugins.bundle.config", { view: "page" }, { entryKey: pkg.name })`
between the description and the rows. So the key becomes the bundle's package
name, and the form draws where someone opening "dsh-recall" is already looking.

The section gets no `form` prop at this seat, unlike the row page — which is
fine, because the card reads its own form from `configForms` rather than
depending on the one handed in.

Verified by serving the bundle from a profile that links this checkout and
reading the artifact back: the served `client.js` carries
`plugins.bundle.config` at the registration and names the retired seat only in
prose. The tests pin the new key and now fail if either the seat or the key
drifts.
2026-09-26 07:05:52 +01:00
gitea-actions df2fa0f54b Release v0.1.2 [skip release] [skip actions] 2026-09-26 05:53:08 +00:00
NotExpectedYet 1f42d2a821 Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall
Release / release (push) Successful in 6s
2026-09-26 06:52:50 +01:00
NotExpectedYet 269360a4ea docs: lead the install instructions with the published git spec
`dsh plugin --profile web add /path/to/dsh-recall` was the only form given,
which is the development one: it is what you type against a checkout, not what
somebody installing this repository types. The published spec now comes first,
exactly as the maintainer writes it, with the `#semver:^0.1.0` variant beside it
because that is what keeps `dsh plugin update` following release tags — the
Releases section explains why, and this points at it.

The directory form stays, reframed as the alternative it is: a `link:`
dependency that tracks a working tree, so an edit needs no reinstall.

The directory form is the one exercised in a profile here; the remote form
cannot be resolved from this machine, which has no credentials for that host.
2026-09-26 06:51:49 +01:00
12 changed files with 1095 additions and 88 deletions

No files matched your search

+19
View File
@@ -21,6 +21,25 @@ release notes. Write it for that reader:
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the `feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
subject line is read; the body is not. subject line is read; the body is not.
## Commit as you go
Commit each coherent change as you finish it, not once at the end of a session:
- **A commit is the unit the release is built from.** The subject becomes a line
in the release notes and the version bump for that push, so a session's work
squashed into one commit is one note that describes none of it well.
- **One change, one concern.** A fix and a feature are two commits even when they
touch the same file, because they are two different lines in the notes and two
different reasons for a reader to care.
- **Run `npm test` before each commit**, so no commit in the history is a state
the suite would have refused.
- **Do not batch unrelated work to save commits.** The cost of committing as you
go is a little more care; the cost of not doing it is a release log nobody can
read and a push that cannot be bisected.
Work still in progress is committed too — the repository is the record, and a
half-finished change kept in the working tree is a change with no history.
## The release job owns the version and the changelog ## The release job owns the version and the changelog
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived `package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
+43
View File
@@ -2,6 +2,49 @@
Every release, newest first, generated from the commits in each release. Every release, newest first, generated from the commits in each release.
## v0.2.0 — 2026-09-27
### Added
- index extra source roots beside the workspace, for the code you write against
### Fixed
- hand the jobs registry a session id, not the agent handle
### Documented
- require committing as you go, in coherent single-concern commits
## v0.1.5 — 2026-09-27
### Fixed
- put the recall chip in the header utilities, not the cell the sidebar's expand button owns
## v0.1.4 — 2026-09-26
### Fixed
- register the settings card instead of throwing while building it
## v0.1.3 — 2026-09-26
### Fixed
- draw the settings on the plugin card, not a click deeper
## v0.1.2 — 2026-09-26
### Documented
- lead the install instructions with the published git spec
## v0.1.1 — 2026-09-26 ## v0.1.1 — 2026-09-26
### Fixed ### Fixed
+132 -15
View File
@@ -49,6 +49,22 @@ about a millisecond; the only per-query network cost is one embedding call.
## Install ## Install
```sh
dsh plugin --profile web add git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall.git
```
That form installs whatever the default branch holds, pinned to a commit in the
profile's lockfile. The semver fragment installs the newest release tag instead,
and is what makes `dsh plugin update` follow releases — see
[Releases](#releases) for the tag and versioning rules:
```sh
dsh plugin --profile web add 'git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall.git#semver:^0.1.0'
```
To run your own copy instead, add the directory — that becomes a `link:`
dependency which tracks the working tree, so an edit needs no reinstall:
```sh ```sh
dsh plugin --profile web add /path/to/dsh-recall dsh plugin --profile web add /path/to/dsh-recall
``` ```
@@ -65,9 +81,11 @@ a fact:
embeddingApiBase: http://127.0.0.1:11434 # replace with your endpoint embeddingApiBase: http://127.0.0.1:11434 # replace with your endpoint
``` ```
Or edit every setting from the UI: **Plugins → dsh-recall → Configure**. The Or edit every setting from the UI: **Plugins** in the sidebar → the
plugin ships **two halves**, and the page appears only when both are present and **dsh-recall** card → its settings are on that card, below the description.
the deployment serves the namespace: (Not Settings → *Built-in plugins*: that section is a read-only inventory of
what the deployment ships.) The plugin ships **two halves**, and the section
appears only when both are present and the deployment serves the namespace:
- the **host half** (`index.js`) exports a `Config` schema whose every field is - the **host half** (`index.js`) exports a `Config` schema whose every field is
`.volatile()`. That schema *is* the settings page's description — the Harness `.volatile()`. That schema *is* the settings page's description — the Harness
@@ -76,11 +94,16 @@ the deployment serves the namespace:
a schema without those markers has no settings surface at all rather than an a schema without those markers has no settings surface at all rather than an
empty one; empty one;
- the **browser half** (`client/client.js`) renders that form into the Plugins - the **browser half** (`client/client.js`) renders that form into the Plugins
panel's per-row configuration seat, `plugins.row.config`, keyed panel's per-bundle configuration seat, `plugins.bundle.config`, keyed by the
`<bundle package>#<row id>` — here `dsh-recall#dsh-recall`. Registering it is bundle's package name. Registering it is what draws the section; without it
what adds the **Configure** control to the plugin's row. there is no settings UI however complete the schema is.
A served namespace with no page draws no control, and a page whose namespace is The sibling seat `plugins.row.config` — keyed `<package>#<row id>` — draws one
row's configuration on that row's own page. That is the right home for one row
of a bundle that declares several; this bundle declares one, so its settings
belong on the card.
A served namespace with no page draws nothing, and a page whose namespace is
not served is deliberately not registered — so both are required. A restart is not served is deliberately not registered — so both are required. A restart is
needed for a newly installed browser half to appear, because client bundle bytes needed for a newly installed browser half to appear, because client bundle bytes
are snapshotted when the profile activates. are snapshotted when the profile activates.
@@ -123,18 +146,20 @@ Parameters: `query`, `action`, `limit`, `path` (restrict to a subdirectory),
Two surfaces, both reading a host projection rather than polling: Two surfaces, both reading a host projection rather than polling:
- a **chip in the session header** — `Recall 3 · 40× · 457 KB kept out`. No price - a **chip in the session header's utilities** — `Recall 3 · 40× · 457 KB kept
on it, deliberately: at the measured rate a whole conversation's avoided out`. No price on it, deliberately: at the measured rate a whole conversation's
context is a fraction of a penny, and a chip advertising that reads as the tool avoided context is a fraction of a penny, and a chip advertising that reads as
being useless rather than as the arithmetic being honest. The tooltip carries the tool being useless rather than as the arithmetic being honest. The tooltip
the full comparison. carries the full comparison.
- a **right-sidebar Recall tab** — the same figures with room for what they are - a **right-sidebar Recall tab** — the same figures with room for what they are
not: *"An estimate, and an upper bound: it assumes a whole file would have been not: *"An estimate, and an upper bound: it assumes a whole file would have been
read each time. Token counts are bytes ÷ 4."* read each time. Token counts are bytes ÷ 4."*
Both render nothing when there is nothing to say — a conversation that never used Both render nothing when there is nothing to say — a conversation that never used
recall costs no layout at all, which matters for the header corner because the recall costs no layout at all. The chip is an entry in the header's `utilities`
framework lays it out only while its occupant renders something. list, carrying its own id. The far-right corner is deliberately left alone: it is
a single cell that the product's own right-sidebar expand control occupies, so a
registration there shadows shipped UI rather than sitting beside it.
The data path is `presentationMeta` → the `tool/result` event's `meta` → a The data path is `presentationMeta` → the `tool/result` event's `meta` → a
projection unit (`recallUsage`) with a wire view the browser reads through projection unit (`recallUsage`) with a wire view the browser reads through
@@ -143,7 +168,99 @@ machine.
The tool description steers the model on when *not* to use it: prefer `grep` when The tool description steers the model on when *not* to use it: prefer `grep` when
you already know the symbol, `read` when you need the whole file, and do not you already know the symbol, `read` when you need the whole file, and do not
follow up with a `read` of a range you were just handed. follow up with a `read` of a range you were just handed. It also claims the one
move it exists to replace: **reaching for it before reading files to find out
what a module contains**, because that is a search — and one search that warms a
bounded slice of the index costs less than the first round of reads it avoids.
## Extra source roots: indexing the code you write *against*
A workspace index answers questions about the code you are writing. It cannot
answer questions about the library that code is written against — the framework,
the SDK, the installed platform — because none of that is under the workspace.
Measured on one real plugin-development session, **64 of 80 tool calls** went to
the installed harness checkout under `/usr/local/lib/node_modules` to look up the
contract being implemented: *"Read workspace slot contract"*, *"Locate
DirectoryListing definition"*, *"Read UiWorkspace service type"*. That is the only
authoritative copy of the API, and every one of those reads went into the
conversation the expensive way.
```yaml
# your profile's cordis.patch.yml
- id: dsh-recall
name: dsh-recall
config:
sourceRoots:
- /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai
# or, with a chosen label and a workspace-relative path:
# - vendor=../packages/node_modules/@vendor
```
Registered through the settings form it is a comma-separated list, as every
list-valued setting is.
An entry is a path, optionally prefixed with `name=`:
- an **absolute** path is taken as given — what a deployment's installed tree needs;
- a **relative** path resolves against the workspace, which is what makes a
checked-in configuration portable;
- the **name** defaults to the directory's basename and becomes the first segment
of every result path from that root, so a result says which tree it came from.
### The root is entered; its dependencies are not
A root is usually *a directory of packages*, which is the one shape the workspace
walk handles worst: `node_modules` and friends are skipped at every depth, so the
interesting files are exactly the ones it refuses to enter. So the rule is
asymmetric, and the asymmetry is the feature:
- **the root itself is entered whatever it is called** — a path ending in
`node_modules/...` is the normal way to name a package-manager tree;
- **below the root** the usual skip list applies, so a `node_modules` inside one
of those packages is not indexed. A dependency is somebody else's code, and it
is the same code the root already holds.
Measured against the real harness packages (`@deepseek-ai`): **2,927 files** into
11,926 chunks, a **113 ms** walk, and **zero** indexed paths containing
`node_modules`. A query — *"how does a plugin register a settings section the
client renders"* — returned its top six hits inside `dsh-client-ui-settings*`, the
correct module, at 11 KB of excerpts against 373 KB across the 7 files they came
from.
### How a result from a root is addressed
The paths a search returns are what the caller hands to `read`, which resolves
them against the workspace. A file under an extra root is not under the workspace,
so no prefixing makes `@dsh/dsh-agent/lib/index.js` readable. Each such result
therefore carries **both**: the labelled path that says where it came from, and an
absolute `sourcePath`:
```
--- 3. @dsh/dsh-client-ui-settings/lib/types/client/contract/slots.d.ts:128-169 similarity 0.47
at /usr/local/lib/node_modules/@deepseek-ai/.../slots.d.ts
```
Reading that absolute path may still need a scope grant from the caller's own
policy — a facet guard, say. This plugin cannot grant one and does not pretend to.
An unusable entry — a typo, a path that is a file rather than a directory, two
roots claiming one name, or a name that collides with the active focus — is
**reported in the result** and skipped. One bad entry must not cost the other
roots, and it must not look like the feature doing nothing.
### Warming it before you need it
An extra root makes the index bigger, and a cold index is the one call that is
slow. `index` runs as a background job by default for exactly that reason — and
it is what makes the first search of a session cheap:
```
recall action=index
```
The job is owned by the session that started it, so its completion reaches that
conversation and its cancellation follows the session.
## Focus: letting a conversation see less ## Focus: letting a conversation see less
+68 -25
View File
@@ -4,7 +4,7 @@
* Three contributions, each into a seat the Harness 0.1.7 composition declares: * Three contributions, each into a seat the Harness 0.1.7 composition declares:
* *
* 1. the plugin's configuration page, in the **Plugins** panel; * 1. the plugin's configuration page, in the **Plugins** panel;
* 2. a one-line usage chip in a conversation's header corner; * 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. * 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 * **Where plugin settings live now.** 0.1.5 drew plugin configuration as cards
@@ -12,10 +12,11 @@
* binding `settingsScope` and registering into a `settings.plugin.item` slot. * binding `settingsScope` and registering into a `settings.plugin.item` slot.
* Neither exists in 0.1.7. The generated settings document is keyed by the * 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 * 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 * contribution to the Plugins panel — `plugins.bundle.config`, keyed by the
* `<bundle package>#<row id>`. That key is what puts a **configure** control on * bundle's package name, which the page draws as a section on the plugin's own
* the plugin's row — without a contribution there is no control and no page, * card. Without a contribution there is no section, however complete the schema
* however complete the schema is. * 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 * **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 * reproduce `PluginCard`'s chrome by hand — the `<li>` root, the disclosure
@@ -70,13 +71,17 @@ window.__ModuleLoader__.load({
/** /**
* The seat this plugin's configuration page renders in. * The seat this plugin's configuration page renders in.
* *
* Keyed `<bundle package name>#<row id>`, both as the profile's patch * `plugins.bundle.config`, keyed by the BUNDLE's package name: the Plugins
* declares them. For this bundle the two coincide, which is a coincidence * page draws that contribution as a section on the plugin's own card,
* and not a rule — the constants are kept separate so that stays visible. * 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_PACKAGE = 'dsh-recall'
const BUNDLE_ROW = 'dsh-recall' const BUNDLE_CONFIG_KEY = BUNDLE_PACKAGE
const ROW_CONFIG_KEY = `${BUNDLE_PACKAGE}#${BUNDLE_ROW}`
const TITLE = 'Recall' const TITLE = 'Recall'
const DESCRIPTION = const DESCRIPTION =
@@ -247,6 +252,16 @@ window.__ModuleLoader__.load({
label: 'Scope marker files', label: 'Scope marker files',
hint: 'Comma separated filenames that make a directory its own scope. Empty uses the built-ins.', hint: 'Comma separated filenames that make a directory its own scope. Empty uses the built-ins.',
}, },
{
key: 'sourceRoots',
kind: 'list',
group: 'Focus',
label: 'Extra source roots',
hint:
'Comma separated trees to index beside the workspace, for code that is not in it. ' +
'A path, or name=path to choose the label results carry. Relative paths resolve against the ' +
'workspace; absolute ones are taken as given, e.g. /usr/local/lib/node_modules/@deepseek-ai',
},
{ {
key: 'expansionEnabled', key: 'expansionEnabled',
@@ -380,6 +395,16 @@ window.__ModuleLoader__.load({
*/ */
constructor(ctx) { constructor(ctx) {
this.ctx = 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.form = ctx.configForms.get(SETTINGS_ENTRY)
this.model = new SettingsFormModel( this.model = new SettingsFormModel(
this.form, this.form,
@@ -394,8 +419,6 @@ window.__ModuleLoader__.load({
})), })),
) )
this.store = this.model.bind(() => this.projection()) 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. */ /** Everything the page renders, rebuilt whenever the form or a draft moves. */
@@ -486,10 +509,12 @@ window.__ModuleLoader__.load({
/** /**
* The plugin's configuration page. * The plugin's configuration page.
* *
* The Plugins page asks a configuration entry twice: once for a one-liner to * The seat asks for `page`, the form with its save control; the `summary`
* stand in for a missing description (`summary`), and once for the body of * branch answers the other shape a configuration entry can be asked for —
* the plugin's own page (`page`). Only the second draws a save control — * the one-liner standing in for a missing description — because a component
* and only one save writes, because the form discards its drafts on unmount. * 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) { function RecallSettingsCard(props) {
if (props.view === 'summary') return DESCRIPTION if (props.view === 'summary') return DESCRIPTION
@@ -605,8 +630,8 @@ window.__ModuleLoader__.load({
function RecallChip(props) { function RecallChip(props) {
const usage = useUsage(props) const usage = useUsage(props)
// The corner is laid out only while its occupant renders something, so a // The utilities list draws nothing for an entry that renders nothing, so
// conversation that never called the tool costs no layout at all. // a conversation that never called the tool costs no layout at all.
if (usage === undefined || !usage.searches) return null if (usage === undefined || !usage.searches) return null
return h( return h(
'span', 'span',
@@ -705,11 +730,11 @@ window.__ModuleLoader__.load({
scoped.effect( scoped.effect(
() => () =>
scoped.configForms.whileServed([SETTINGS_ENTRY], () => scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
scoped.slots.inject('plugins.row.config', () => scoped.slots.inject('plugins.bundle.config', () =>
scoped.slots.register( scoped.slots.register(
{ {
name: 'plugins.row.config', name: 'plugins.bundle.config',
key: ROW_CONFIG_KEY, key: BUNDLE_CONFIG_KEY,
inject: () => card.inject(), inject: () => card.inject(),
}, },
RecallSettingsCard, RecallSettingsCard,
@@ -720,10 +745,28 @@ window.__ModuleLoader__.load({
) )
}) })
// The chip: the header's far-right corner, which the framework lays out // The chip: a right-aligned session utility, next to the header's
// only while its occupant renders something. // far-right corner.
ctx.slots.inject('conversation.session.header.corner', () => //
ctx.slots.register({ name: 'conversation.session.header.corner' }, (props) => h(RecallChip, props)), // 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 // The right-sidebar tab is registered in two stages, and the second is
+211 -16
View File
@@ -41,7 +41,7 @@
import { readFile, stat } from 'node:fs/promises' import { readFile, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { basename, join, resolve } from 'node:path' import { basename, join, resolve, sep } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools' import { defineTool } from '@deepseek-ai/dsh-tools'
// A real dependency rather than a duck-typed `{ parse }`: the projection service // A real dependency rather than a duck-typed `{ parse }`: the projection service
@@ -55,6 +55,7 @@ import { EmbeddingError, createEmbedder } from './lib/embed.js'
import { DEFAULT_FOCUS_MARKERS, discoverScopes, focusKey, resolveFocus } from './lib/focus.js' import { DEFAULT_FOCUS_MARKERS, discoverScopes, focusKey, resolveFocus } from './lib/focus.js'
import { indexFile, resolveDshHome, workspaceStateDir } from './lib/paths.js' import { indexFile, resolveDshHome, workspaceStateDir } from './lib/paths.js'
import { fuseRankings, rankOrder, selectFrom } from './lib/rank.js' import { fuseRankings, rankOrder, selectFrom } from './lib/rank.js'
import { listRootFiles, resolveSourceRoots } from './lib/roots.js'
import { RecallIndex } from './lib/store.js' import { RecallIndex } from './lib/store.js'
import { createExpander } from './lib/expand.js' import { createExpander } from './lib/expand.js'
import { createReranker } from './lib/rerank.js' import { createReranker } from './lib/rerank.js'
@@ -100,6 +101,7 @@ export const SETTING_KEYS = [
'stateDir', 'stateDir',
'focus', 'focus',
'focusMarkers', 'focusMarkers',
'sourceRoots',
'expansionEnabled', 'expansionEnabled',
'expansionModel', 'expansionModel',
'expansionCount', 'expansionCount',
@@ -209,6 +211,38 @@ export const Config = Schema.object({
focus: Schema.string().default('').volatile(), focus: Schema.string().default('').volatile(),
/** Filenames that mark a directory as its own scope. Empty means the built-ins. */ /** Filenames that mark a directory as its own scope. Empty means the built-ins. */
focusMarkers: Schema.array(String).default([]).volatile(), focusMarkers: Schema.array(String).default([]).volatile(),
/**
* Extra trees to index beside the workspace, for code that is not in it.
*
* The workspace index cannot answer questions about the code you are writing
* *against* — an installed framework, a platform SDK, a sibling checkout. In
* plugin development that is where most of the reading goes: the harness's own
* packages are the only authoritative copy of the API being implemented, and
* they live outside the workspace.
*
* Each entry is a path, optionally prefixed with `name=`:
*
* - an **absolute** path is taken as given — what a deployment's installed
* tree needs;
* - a **relative** path resolves against the workspace, which is what makes a
* checked-in configuration portable;
* - the **name** defaults to the directory's basename and becomes the first
* segment of every result path from that root (`@deepseek-ai/dsh-agent/…`),
* so a result says which tree it came from. Use `name=` when the basename is
* long or ambiguous.
*
* The root's *contents* are indexed, never its dependencies: `node_modules`,
* build output and VCS directories are skipped below the root while the root
* itself is always entered — which is what lets a root be the scoped-packages
* directory of a package manager. An unusable entry is reported in the result
* rather than being fatal, so one typo cannot cost the other roots.
*
* A result from a root carries an absolute `sourcePath` for the follow-up read,
* because the labelled path is relative to the root rather than the workspace.
* Reading it may still need a scope grant from the caller's own policy; this
* plugin cannot grant one.
*/
sourceRoots: Schema.array(String).default([]).volatile(),
/** /**
* Ask the local model for alternative phrasings and fuse the rankings. * Ask the local model for alternative phrasings and fuse the rankings.
* *
@@ -575,6 +609,7 @@ export function apply(ctx, config) {
'oversized', 'oversized',
'unreadable', 'unreadable',
'truncated', 'truncated',
'sourceRoots',
] ]
for (const key of fields) if (extra[key] !== undefined) out[key] = extra[key] for (const key of fields) if (extra[key] !== undefined) out[key] = extra[key]
return out return out
@@ -655,8 +690,18 @@ export function apply(ctx, config) {
* knows exactly what is stale, so a partial index is described rather than * knows exactly what is stale, so a partial index is described rather than
* guessed at. * guessed at.
*/ */
async function syncWorkspace(input) {
const { root, prefix = '', index, settings, budgetMs, signal, onProgress } = input /**
* The candidate file list for one index: the workspace (or focus) tree plus
* every extra root.
*
* Split out from {@link syncWorkspace} because a search reports the absolute
* path of a result that came from an extra root, and the read phase is where
* that mapping is known. Both callers therefore see one list and one set of
* problems.
*/
async function walkIndex(input) {
const { root, prefix = '', settings, signal, sourceRoots = [] } = input
const walk = await listCodeFiles(root, { const walk = await listCodeFiles(root, {
...(settings.extensions?.length ? { extensions: settings.extensions } : {}), ...(settings.extensions?.length ? { extensions: settings.extensions } : {}),
skipDirs: DEFAULT_SKIP_DIRS, skipDirs: DEFAULT_SKIP_DIRS,
@@ -672,8 +717,46 @@ export function apply(ctx, config) {
for (const file of walk.files) file.path = `${prefix}/${file.path}` for (const file of walk.files) file.path = `${prefix}/${file.path}`
} }
// Extra roots are walked as their own trees and merged into the same index,
// so one search ranks workspace code and library code together. Each root
// reports its own truncation: a root too large to index must say so rather
// than answer from its first few files.
const files = [...walk.files]
const problems = [...(input.sourceRootProblems ?? [])]
const summary = []
let truncated = walk.truncated
for (const sourceRoot of sourceRoots) {
const walkOfRoot = await listRootFiles({
name: sourceRoot.name,
path: sourceRoot.path,
...(settings.extensions?.length ? { extensions: settings.extensions } : {}),
exclude: settings.exclude ?? [],
maxFiles: Number(settings.maxFiles),
signal,
})
// A root labelled with the focus path itself would index two different
// files under one key — the second write silently replacing the first.
if (prefix !== '' && (prefix === sourceRoot.name || prefix.startsWith(`${sourceRoot.name}/`))) {
problems.push(
`the source root "${sourceRoot.name}" collides with the focus path "${prefix}"; ` +
'give it a different name= or search without that focus',
)
continue
}
files.push(...walkOfRoot.files)
summary.push({ name: sourceRoot.name, path: sourceRoot.path, files: walkOfRoot.files.length })
truncated = truncated || walkOfRoot.truncated
}
return { files, truncated, problems, summary }
}
async function syncWorkspace(input) {
const { index, settings, budgetMs, signal, onProgress } = input
const { files, truncated, problems: rootProblems, summary: rootSummary } = await walkIndex(input)
const known = index.fileHashes() const known = index.fileHashes()
const present = new Set(walk.files.map((file) => file.path)) const present = new Set(files.map((file) => file.path))
const removed = index.prune([...known.keys()].filter((path) => !present.has(path))) const removed = index.prune([...known.keys()].filter((path) => !present.has(path)))
const maxBytes = Number(settings.maxFileBytes) const maxBytes = Number(settings.maxFileBytes)
@@ -681,7 +764,7 @@ export function apply(ctx, config) {
let oversized = 0 let oversized = 0
let unreadable = 0 let unreadable = 0
for (const file of walk.files) { for (const file of files) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted') if (signal?.aborted) throw signal.reason ?? new Error('aborted')
let content let content
try { try {
@@ -746,8 +829,8 @@ export function apply(ctx, config) {
const stats = index.stats() const stats = index.stats()
return { return {
...stats, ...stats,
scanned: walk.files.length, scanned: files.length,
truncated: walk.truncated, truncated,
removed, removed,
embedded, embedded,
chunksWritten, chunksWritten,
@@ -755,6 +838,13 @@ export function apply(ctx, config) {
stale: [...stale], stale: [...stale],
oversized, oversized,
unreadable, unreadable,
sourceRoots: rootSummary,
sourceRootProblems: rootProblems,
// Not part of the model-facing contract — the schema is closed — but a
// search needs it: a result from an extra root is labelled relative to
// that root, so the absolute path is the only thing `read` can use, and
// only the walk knows it.
fileAbsolutes: new Map(files.map((file) => [file.path, file.absolute])),
} }
} }
@@ -790,6 +880,13 @@ export function apply(ctx, config) {
excerpt: { type: 'string', required: true }, excerpt: { type: 'string', required: true },
truncated: { type: 'boolean', required: true }, truncated: { type: 'boolean', required: true },
stale: { type: 'boolean', required: true }, stale: { type: 'boolean', required: true },
/**
* Absolute path, present only for a result that came from an extra
* index root. The labelled `path` of such a result is relative to
* its root rather than the workspace, so it is not what `read`
* resolves — this is.
*/
sourcePath: { type: 'string' },
}, },
}, },
}, },
@@ -808,8 +905,23 @@ export function apply(ctx, config) {
truncated: { type: 'boolean' }, truncated: { type: 'boolean' },
database: { type: 'string' }, database: { type: 'string' },
stateDir: { type: 'string' }, stateDir: { type: 'string' },
/** The extra roots this index holds, and how many files each contributed. */
sourceRoots: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', required: true },
path: { type: 'string', required: true },
files: { type: 'integer', required: true },
}, },
}, },
},
},
},
/** Entries of `sourceRoots` that could not be used, one message each. */
sourceRootProblems: { type: 'array', items: { type: 'string' } },
best: { type: 'number' }, best: { type: 'number' },
endpoint: { endpoint: {
type: 'object', type: 'object',
@@ -880,12 +992,15 @@ export function apply(ctx, config) {
} }
if (value.action !== 'search') { if (value.action !== 'search') {
const { files = 0, chunks = 0, pending = 0, scanned = 0, embedded = 0 } = value.index ?? {} const { files = 0, chunks = 0, pending = 0, scanned = 0, embedded = 0 } = value.index ?? {}
const refused = (value.sourceRootProblems ?? []).length === 0
? ''
: `\n\nRefused source roots: ${value.sourceRootProblems.join('; ')}.`
return [ return [
{ {
type: 'text', type: 'text',
text: text:
`Index for ${value.workspace}: ${files} files, ${chunks} chunks. ` + `Index for ${value.workspace}: ${files} files, ${chunks} chunks. ` +
`Scanned ${scanned}, embedded ${embedded} this call, ${pending} still pending.`, `Scanned ${scanned}, embedded ${embedded} this call, ${pending} still pending.${refused}`,
}, },
] ]
} }
@@ -893,11 +1008,15 @@ export function apply(ctx, config) {
const results = value.results ?? [] const results = value.results ?? []
if (results.length === 0) { if (results.length === 0) {
const suffix = (value.index?.pending ?? 0) > 0 ? ` ${value.index.pending} file(s) are still unindexed.` : '' const suffix = (value.index?.pending ?? 0) > 0 ? ` ${value.index.pending} file(s) are still unindexed.` : ''
const refused =
(value.sourceRootProblems ?? []).length === 0
? ''
: ` Refused source roots: ${value.sourceRootProblems.join('; ')}.`
return [ return [
{ {
type: 'text', type: 'text',
text: text:
`No match for ${JSON.stringify(value.query)}.${suffix} ` + `No match for ${JSON.stringify(value.query)}.${suffix}${refused} ` +
'If you know the exact identifier, grep is the better tool for this query.', 'If you know the exact identifier, grep is the better tool for this query.',
}, },
] ]
@@ -912,9 +1031,14 @@ export function apply(ctx, config) {
const symbol = result.symbol === undefined ? '' : ` (${result.symbol})` const symbol = result.symbol === undefined ? '' : ` (${result.symbol})`
const stale = result.stale ? ' [stale — file changed since indexing]' : '' const stale = result.stale ? ' [stale — file changed since indexing]' : ''
const cut = result.truncated ? '\n… excerpt truncated; read the file for the rest' : '' const cut = result.truncated ? '\n… excerpt truncated; read the file for the rest' : ''
// A result from an extra root is labelled relative to that root, so the
// workspace-relative `path` is not something `read` can resolve. The
// absolute path is stated when it is the one that differs — a workspace
// result already has a usable path and does not need the noise.
const source = result.sourcePath === undefined ? '' : `\n at ${result.sourcePath}`
return ( return (
`--- ${at + 1}. ${result.path}:${result.startLine}-${result.endLine}${symbol} ` + `--- ${at + 1}. ${result.path}:${result.startLine}-${result.endLine}${symbol} ` +
`similarity ${result.similarity}${stale}\n${result.excerpt}${cut}` `similarity ${result.similarity}${stale}${source}\n${result.excerpt}${cut}`
) )
}) })
// A scope is a window, not a filter on relevance. When it holds nothing // A scope is a window, not a filter on relevance. When it holds nothing
@@ -938,10 +1062,22 @@ export function apply(ctx, config) {
'Search this workspace semantically with a local embedding model and return ranked code excerpts ' + 'Search this workspace semantically with a local embedding model and return ranked code excerpts ' +
'with file paths and line ranges — without reading whole files into the conversation. Use it when ' + 'with file paths and line ranges — without reading whole files into the conversation. Use it when ' +
'you do not know the exact identifier: "where is X handled", "which module does Y", "how does Z ' + 'you do not know the exact identifier: "where is X handled", "which module does Y", "how does Z ' +
'recover from failure". Prefer grep when you already know the exact symbol, and read when you need ' + 'recover from failure". ' +
'the whole file. Because excerpts are returned inline, do not follow up with a read of the same ' + // The move this exists to replace: reading a module's files one by one to
'range. Actions: "search" (default) answers a query and warms a bounded slice of the index; ' + // find out what is in it. That is a search, and this is cheaper than the
'"index" builds or refreshes the whole index, as a background job by default; "status" reports the ' + // reads it avoids — including on the very first call, because a search
// warms a bounded slice of the index as it answers.
'Reach for it before reading files to find out what a module contains or ' +
'which of several alike modules does the job: one search returns the relevant lines from wherever ' +
'they are, where reading to find them costs the whole file for each wrong guess and every one of ' +
'those lines is re-sent on every later step. ' +
'Prefer grep when you already know the exact symbol, and read when you need the whole file. ' +
'Because excerpts are returned inline, do not follow up with a read of the same ' +
'range. A result labelled with a leading name (for example "@dsh/…") came from an extra source ' +
'root rather than the workspace; such a result also carries an absolute `sourcePath`, which is the ' +
'one `read` can resolve. Actions: "search" (default) answers a query and warms a bounded slice of ' +
'the index; "index" builds or refreshes the whole index, as a background job by default, which is ' +
'what makes the first search of a session cheap; "status" reports the ' +
'index and endpoint state. The index is incremental and lives outside your repository.', 'index and endpoint state. The index is incremental and lives outside your repository.',
parameters: { parameters: {
query: { query: {
@@ -1040,6 +1176,15 @@ export function apply(ctx, config) {
}) })
const focusPath = focusKey(cwd, focus.root) const focusPath = focusKey(cwd, focus.root)
// Extra roots are resolved once per call against the live workspace, so
// an entry is judged where it is used rather than at load: a path that
// has not been installed yet is reported on the call that wanted it. A
// bad entry never aborts the call — it is reported beside the result, or
// the caller would lose the workspace index to one typo.
const resolvedRoots = resolveSourceRoots(settings.sourceRoots ?? [], { workspace: cwd })
const sourceRoots = resolvedRoots.roots
const sourceRootProblems = resolvedRoots.problems
let opened let opened
try { try {
opened = indexFor(settings, cwd, focusPath) opened = indexFor(settings, cwd, focusPath)
@@ -1094,6 +1239,7 @@ export function apply(ctx, config) {
index: indexView(base.index, stats), index: indexView(base.index, stats),
endpoint, endpoint,
scopes: scopes.map((entry) => ({ path: entry.path, marker: entry.marker })), scopes: scopes.map((entry) => ({ path: entry.path, marker: entry.marker })),
...(sourceRootProblems.length === 0 ? {} : { sourceRootProblems }),
note: note:
`Index ${file} holds ${stats.files} files and ${stats.chunks} chunks, scoped to ` + `Index ${file} holds ${stats.files} files and ${stats.chunks} chunks, scoped to ` +
`${focusPath === '.' ? 'the whole workspace' : focusPath} (${focus.source}` + `${focusPath === '.' ? 'the whole workspace' : focusPath} (${focus.source}` +
@@ -1108,6 +1254,17 @@ export function apply(ctx, config) {
.slice(0, 12) .slice(0, 12)
.map((entry) => entry.path) .map((entry) => entry.path)
.join(', ')}${scopes.length > 12 ? ` (+${scopes.length - 12} more)` : ''}. `) + .join(', ')}${scopes.length > 12 ? ` (+${scopes.length - 12} more)` : ''}. `) +
// Extra roots are named even when the index has not been rebuilt
// since they were configured, so their file counts are deliberately
// absent here — `stats` describes the index, not the configuration.
(sourceRoots.length === 0
? ''
: `Extra roots indexed beside the workspace: ${sourceRoots
.map((entry) => `${entry.name} (${entry.path})`)
.join(', ')}. `) +
(sourceRootProblems.length === 0
? ''
: `Source roots refused: ${sourceRootProblems.join('; ')}. `) +
`Embeddings come from "${settings.embeddingModel}" (${settings.embeddingDimensions} dims) at ` + `Embeddings come from "${settings.embeddingModel}" (${settings.embeddingDimensions} dims) at ` +
`${settings.embeddingApiBase} — ` + `${settings.embeddingApiBase} — ` +
(endpoint.ok === true (endpoint.ok === true
@@ -1193,14 +1350,27 @@ export function apply(ctx, config) {
settings, settings,
budgetMs: Number.POSITIVE_INFINITY, budgetMs: Number.POSITIVE_INFINITY,
signal, signal,
sourceRoots,
sourceRootProblems,
}), }),
) )
if (wantsBackground && jobs !== undefined) { if (wantsBackground && jobs !== undefined) {
// `owner` is a SESSION ID, not the Agent handle.
//
// The jobs registry resolves an owner through the agent registry —
// `agents.get(session)` — and throws `session "[object Object]" has
// no live agent` for anything else, so passing `exec.agent` here made
// the default `index` call fail outright: it is the one path this
// plugin takes that a caller cannot see fail until the job service is
// present. The agent's own `id` IS the session id (the registry keys
// by it), and a handle without one is registered unowned rather than
// refused.
const owner = exec.agent?.id
const jobId = jobs.start({ const jobId = jobs.start({
kind: 'recall-index', kind: 'recall-index',
label: `recall index ${workspace}`, label: `recall index ${workspace}`,
...(exec.agent === undefined ? {} : { owner: exec.agent }), ...(owner === undefined || owner === null || owner === '' ? {} : { owner }),
run: () => { run: () => {
const controller = new AbortController() const controller = new AbortController()
const done = (async () => { const done = (async () => {
@@ -1220,7 +1390,14 @@ export function apply(ctx, config) {
} }
const result = await runIndex(exec.signal) const result = await runIndex(exec.signal)
return { ...base, ok: true, index: indexView(base.index, result) } return {
...base,
ok: true,
index: indexView(base.index, result),
...(result.sourceRootProblems.length === 0
? {}
: { sourceRootProblems: result.sourceRootProblems }),
}
} }
// search // search
@@ -1250,6 +1427,8 @@ export function apply(ctx, config) {
settings, settings,
budgetMs: Math.max(0, Number(settings.syncBudgetMs)), budgetMs: Math.max(0, Number(settings.syncBudgetMs)),
signal: exec.signal, signal: exec.signal,
sourceRoots,
sourceRootProblems,
}), }),
) )
const matrix = index.matrix() const matrix = index.matrix()
@@ -1342,6 +1521,18 @@ export function apply(ctx, config) {
const stale = new Set(sync.stale ?? []) const stale = new Set(sync.stale ?? [])
const best = results.length === 0 ? 0 : Math.max(...results.map((result) => result.similarity)) const best = results.length === 0 ? 0 : Math.max(...results.map((result) => result.similarity))
// Where the result actually is, for the cases where the label is not a
// workspace-relative path. A workspace result keeps its plain path: the
// caller resolves it against the workspace and has no use for an
// absolute one. A result from an extra root cannot be resolved that
// way, so it carries the absolute path beside its label.
const absolutes = sync.fileAbsolutes ?? new Map()
const workspacePrefix = resolve(cwd).endsWith(sep) ? resolve(cwd) : `${resolve(cwd)}${sep}`
const sourcePathOf = (result) => {
const absolute = absolutes.get(result.path)
if (absolute === undefined) return undefined
return absolute.startsWith(workspacePrefix) ? undefined : absolute
}
// The alternative to these excerpts: the whole of each file they came // The alternative to these excerpts: the whole of each file they came
// from. Sizes come from the index, which recorded them at embed time, // from. Sizes come from the index, which recorded them at embed time,
@@ -1377,11 +1568,15 @@ export function apply(ctx, config) {
excerpt: result.excerpt, excerpt: result.excerpt,
truncated: result.truncated, truncated: result.truncated,
stale: stale.has(result.path), stale: stale.has(result.path),
...(sourcePathOf(result) === undefined ? {} : { sourcePath: sourcePathOf(result) }),
})), })),
index: indexView(base.index, sync), index: indexView(base.index, sync),
best, best,
weak: results.length === 0 || best < Number(settings.minSimilarity), weak: results.length === 0 || best < Number(settings.minSimilarity),
measure, measure,
...(sync.sourceRootProblems.length === 0
? {}
: { sourceRootProblems: sync.sourceRootProblems }),
} }
} catch (error) { } catch (error) {
const code = error instanceof EmbeddingError ? error.code : 'RECALL_FAILED' const code = error instanceof EmbeddingError ? error.code : 'RECALL_FAILED'
+254
View File
@@ -0,0 +1,254 @@
/**
* Extra index roots: code that is not in the workspace, indexed beside it.
*
* ## Why this exists
*
* A workspace index answers questions about the code you are writing. It does
* not answer questions about the code that code is written *against* — the
* library, the framework, the platform SDK. In plugin development that is not a
* side interest: measured on one real session, 64 of 80 tool calls went to the
* installed harness checkout under `/usr/local/lib/node_modules` to look up the
* contract being implemented, because that is the only authoritative copy of it.
* None of that is under the workspace, so none of it was indexed, and the agent
* read it into context the expensive way.
*
* An extra root is a second tree, indexed into the same index, whose results are
* distinguishable from the workspace's at a glance.
*
* ## The traversal rule is the whole feature
*
* An extra root is usually *a directory of packages* — the scoped-packages
* directory of a package manager, say — which is the one shape the workspace
* walk handles worst: it skips `node_modules` and friends at any depth, so the
* interesting files are exactly the ones it refuses to enter.
*
* So the rule is deliberately asymmetric, and it is worth stating precisely
* because the wrong version is silently wrong:
*
* - **Below the root**, `node_modules`, build output and VCS directories are
* skipped as usual. A dependency of a package is still somebody else's code,
* and it is the same code the root already holds.
* - **The root itself is entered whatever it is called.** A path that ends in
* `node_modules/...` is the normal way to name a package-manager tree, and a
* rule that skipped it would refuse the configuration this exists for.
*
* The cost of not applying the first half would be an index that embeds every
* dependency copy it can reach; the cost of not applying the second is that the
* feature cannot be configured at all. Only the root is exempt, never a nested
* directory that happens to share its name.
*
* ## Names, not just prefixes
*
* A result from an extra root is labelled `<name>/<path within the root>`. The
* name defaults to the root directory's basename — `@deepseek-ai` for a scope
* directory — and a deployment that wants something shorter says so with the
* `name=path` form, because `@deepseek-ai/dsh-agent/lib/index.js` is precise but
* long, and the label is on every result.
*
* ## Results from a root are not workspace-relative, and cannot pretend to be
*
* The paths a search returns are what the caller hands to `read`, which resolves
* against the workspace. A file under an extra root is not under the workspace,
* so no amount of prefixing makes `<name>/lib/index.js` readable. The honest
* answer is the absolute path, reported beside the relative label: the label
* says where the excerpt came from, the absolute path is what a follow-up read
* needs. Reading it may still need a scope grant from the caller's own policy —
* this module cannot grant it and does not pretend to.
*/
import { existsSync, statSync } from 'node:fs'
import { basename, isAbsolute, resolve } from 'node:path'
import { DEFAULT_SKIP_DIRS, listCodeFiles } from './walk.js'
/**
* Directories skipped inside an extra root.
*
* The workspace default minus `node_modules`: a root is normally a
* package-manager tree whose *top* level is the thing being indexed, while a
* `node_modules` further down is a dependency of one of those packages and is
* skipped by the depth rule in {@link listRootFiles}.
*
* `node_modules` stays IN the set: it is skipped below a root like anything else,
* and it is {@link skipsRootDir} — not a shorter list — that lets the root itself
* be one. Dropping it here as well would index every dependency of every package
* in a package-manager tree, which is the cost the depth rule exists to avoid.
*/
export const ROOT_SKIP_DIRS = Object.freeze([...DEFAULT_SKIP_DIRS])
/** Whether `path` exists and is a directory. */
function statIsDirectory(path) {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
/**
* Read one `sourceRoots` entry.
*
* Accepted forms, both orderings of the same two facts:
*
* ```
* /abs/path/to/packages # name defaults to the basename
* @dsh=/abs/path/to/packages # explicit name
* ```
*
* A relative path is resolved against the workspace, which is what makes a
* checked-in configuration portable; an absolute one is taken as given, which is
* what a deployment's own installed tree needs.
*
* @param {string} entry one configured root
* @param {string} workspace absolute workspace root
* @returns {{name: string, path: string, workspaceRelative: boolean}}
*/
export function parseSourceRoot(entry, workspace) {
const text = String(entry ?? '').trim()
if (text === '') throw new Error('an empty entry')
// `name=path` only when a name is actually present: a Windows path or a
// relative path with an `=` in it must not be split by accident.
let name = ''
let target = text
const equals = text.indexOf('=')
if (equals > 0) {
name = text.slice(0, equals).trim()
target = text.slice(equals + 1).trim()
}
if (target === '') throw new Error(`"${text}" names no path`)
if (name.includes('/') || name.includes('\\')) {
throw new Error(`"${name}" is not a usable name: it contains a path separator`)
}
const workspaceRelative = !isAbsolute(target)
const absolute = workspaceRelative ? resolve(workspace, target) : resolve(target)
const label = sanitizeRootName(name === '' ? basename(absolute) : name)
if (label === '') throw new Error(`"${text}" does not yield a usable name`)
return { name: label, path: absolute, workspaceRelative }
}
/**
* Make a name safe to use as a path prefix.
*
* The name becomes the first segment of every result path, so a name carrying a
* separator would forge a deeper path than the root holds, and a name of `.` or
* `..` would forge one outside it.
*/
export function sanitizeRootName(name) {
const cleaned = String(name ?? '')
.trim()
.replace(/[^A-Za-z0-9._@-]+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
return cleaned === '.' || cleaned === '..' ? '' : cleaned
}
/**
* Resolve every configured root, reporting each unusable one by name.
*
* A bad entry is skipped rather than fatal, and said out loud: a typo in one
* root must not cost the caller the other roots, and it must not look like the
* feature doing nothing. This mirrors how an unusable `focus` is reported.
*
* @param {readonly string[]} entries configured `sourceRoots`
* @param {object} [options]
* @param {string} options.workspace absolute workspace root
* @param {(path: string) => boolean} [options.isDirectory] injection point for tests
* @returns {{roots: {name: string, path: string}[], problems: string[]}}
*/
export function resolveSourceRoots(entries, options = {}) {
const { workspace, isDirectory = statIsDirectory } = options
const roots = []
const problems = []
const seen = new Set()
for (const entry of entries ?? []) {
let parsed
try {
parsed = parseSourceRoot(entry, workspace)
} catch (error) {
problems.push(String(error?.message ?? error))
continue
}
if (!isDirectory(parsed.path)) {
problems.push(`"${String(entry).trim()}" is not a directory: ${parsed.path}`)
continue
}
if (seen.has(parsed.name)) {
problems.push(`"${String(entry).trim()}" duplicates the name "${parsed.name}", which was already indexed`)
continue
}
seen.add(parsed.name)
roots.push({ name: parsed.name, path: parsed.path })
}
return { roots, problems }
}
/**
* Whether a directory is skipped during a *root* walk.
*
* Exported because it is the rule the feature turns on, and a test that asserts
* a nested `node_modules` is skipped is a test of this function, not of a walk.
*
* @param {number} depth level below the root; 0 is the root's own entries
* @param {string} name a directory entry's name
* @param {ReadonlySet<string>} [skip] directory names skipped below the root
*/
export function skipsRootDir(depth, name, skip = new Set(ROOT_SKIP_DIRS)) {
// The root is entered whatever it is called — that is the whole point of an
// explicit root. Everything below it obeys the normal skip list.
if (depth === 0) return false
return skip.has(name)
}
/**
* Label a file found under an extra root.
*
* @param {string} rootName the root's label
* @param {string} relPath path within the root, `/`-separated
*/
export function rootRelativePath(rootName, relPath) {
return relPath === '' ? rootName : `${rootName}/${relPath}`
}
/**
* List the indexable files under one extra root.
*
* Keyed by `ROOT_SKIP_DIRS` membership *below* the root and by nothing at all at
* it, because `listCodeFiles` reports the walk root at depth 0 and a directory it
* descends into at depth + 1. So the root's own name never decides anything,
* which is what lets a root be a `node_modules` tree, and a `node_modules`
* *inside* one of its packages is still skipped.
*
* `maxFiles` bounds each root rather than the whole index, and the result reports
* truncation, so a root too large to index says so instead of quietly answering
* from its first few files.
*
* @param {object} input
* @param {string} input.name label for the root, prefixed onto every path
* @param {string} input.path absolute directory to walk
* @param {readonly string[]} [input.extensions] allowed extensions, without dots
* @param {readonly string[]} [input.exclude] glob patterns over the root-relative path
* @param {number} [input.maxFiles] stop after this many candidates
* @param {AbortSignal} [input.signal]
* @returns {Promise<{files: {path: string, absolute: string}[], truncated: boolean, skippedFiles: number}>}
*/
export async function listRootFiles(input) {
const { name, path, extensions, exclude = [], maxFiles, signal } = input
const skip = new Set(ROOT_SKIP_DIRS)
const walk = await listCodeFiles(path, {
...(extensions === undefined ? {} : { extensions }),
exclude,
...(maxFiles === undefined ? {} : { maxFiles }),
isSkipped: (depth, dirName) => skipsRootDir(depth, dirName, skip),
signal,
})
return {
...walk,
files: walk.files.map((file) => ({ path: rootRelativePath(name, file.path), absolute: file.absolute })),
}
}
/** One entry of the `sourceRoots` field, as the plugin's own docs describe it. */
export const SOURCE_ROOT_EXAMPLE = '/usr/local/lib/node_modules/@deepseek-ai'
+13 -1
View File
@@ -142,6 +142,11 @@ export function extensionOf(path) {
* @param {readonly string[]} [options.extensions] allowed extensions, without dots * @param {readonly string[]} [options.extensions] allowed extensions, without dots
* @param {readonly string[]} [options.skipDirs] directory names skipped at any depth * @param {readonly string[]} [options.skipDirs] directory names skipped at any depth
* @param {readonly string[]} [options.exclude] glob patterns over the relative path * @param {readonly string[]} [options.exclude] glob patterns over the relative path
* @param {(depth: number, name: string, absolute: string) => boolean} [options.isSkipped]
* decides directory traversal, replacing the `skipDirs` membership test. This is
* the seam an extra index root uses: its top level is a package-manager
* `node_modules` tree, which the built-in list exists to skip, so it must opt out
* for the root and only the root. Depth 0 is the root's own entries.
* @param {number} [options.maxFiles] stop after this many candidates * @param {number} [options.maxFiles] stop after this many candidates
* @param {number} [options.maxDepth] deepest directory level to enter * @param {number} [options.maxDepth] deepest directory level to enter
* @param {AbortSignal} [options.signal] * @param {AbortSignal} [options.signal]
@@ -152,6 +157,7 @@ export async function listCodeFiles(root, options = {}) {
extensions = DEFAULT_EXTENSIONS, extensions = DEFAULT_EXTENSIONS,
skipDirs = DEFAULT_SKIP_DIRS, skipDirs = DEFAULT_SKIP_DIRS,
exclude = [], exclude = [],
isSkipped,
maxFiles = 20_000, maxFiles = 20_000,
maxDepth = 24, maxDepth = 24,
signal, signal,
@@ -159,6 +165,12 @@ export async function listCodeFiles(root, options = {}) {
const wanted = new Set(extensions.map((value) => String(value).toLowerCase())) const wanted = new Set(extensions.map((value) => String(value).toLowerCase()))
const skip = new Set(skipDirs) const skip = new Set(skipDirs)
// Materialised once per walk rather than tested with `isSkipped ?? default` at
// every directory: the hot loop should not carry a branch that cannot change.
const shouldSkip =
isSkipped === undefined
? (depth, name) => skip.has(name)
: (depth, name, absolute) => isSkipped(depth, name, absolute)
const files = [] const files = []
let truncated = false let truncated = false
let skippedFiles = 0 let skippedFiles = 0
@@ -185,7 +197,7 @@ export async function listCodeFiles(root, options = {}) {
const absolute = join(dir, entry.name) const absolute = join(dir, entry.name)
const relPath = relative(root, absolute).split(sep).join('/') const relPath = relative(root, absolute).split(sep).join('/')
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (skip.has(entry.name)) continue if (shouldSkip(depth, entry.name, absolute)) continue
if (depth >= maxDepth) continue if (depth >= maxDepth) continue
if (matchesAny(relPath, exclude)) continue if (matchesAny(relPath, exclude)) continue
stack.push({ dir: absolute, depth: depth + 1 }) stack.push({ dir: absolute, depth: depth + 1 })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-recall", "name": "dsh-recall",
"version": "0.1.1", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Local-embedding code recall for DeepSeek Harness — returns ranked excerpts with line ranges instead of whole files, so the context window stops growing with every read.", "description": "Local-embedding code recall for DeepSeek Harness — returns ranked excerpts with line ranges instead of whole files, so the context window stops growing with every read.",
+50 -25
View File
@@ -7,10 +7,13 @@
* leaving the page to be eyeballed in a running app, because the failures this * leaving the page to be eyeballed in a running app, because the failures this
* file exists to catch are structural and invisible until someone opens Plugins: * file exists to catch are structural and invisible until someone opens Plugins:
* *
* - the configuration page is registered into `plugins.row.config` under * - the configuration page is registered into `plugins.bundle.config` under
* `<bundle package>#<row id>`. The 0.1.5 seat (`settings.plugin.item`, bound * the bundle's package name, which is the seat the Plugins page draws as a
* through `settingsScope`) does not exist in 0.1.7, so a page left in it * section on the plugin's own card. The 0.1.5 seat (`settings.plugin.item`,
* registers against a slot nobody declares and simply never appears. * bound through `settingsScope`) does not exist in 0.1.7, so a page left in
* it registers against a slot nobody declares and simply never appears; the
* sibling seat `plugins.row.config` renders one click deeper instead, on a
* single row's page, which is not where someone opening the card looks.
* - the right-sidebar tab needs a TYPE registered before its body means * - the right-sidebar tab needs a TYPE registered before its body means
* anything: the keyed body slot is dispatched under the definition's `id`. * anything: the keyed body slot is dispatched under the definition's `id`.
* - the field list here and the `Config` schema in index.js must name the same * - the field list here and the `Config` schema in index.js must name the same
@@ -106,6 +109,11 @@ function makePrimitives(record) {
bind(project) { bind(project) {
this.project = project this.project = project
// Faithful to the framework: `bind` is not a subscription setter, it
// builds the snapshot store from ONE immediate projection. A stub that
// only stored the projection would let a controller read a field it has
// not assigned yet — which is exactly the bug this reproduces.
project()
return { getSnapshot: () => project(), subscribe: () => () => {} } return { getSnapshot: () => project(), subscribe: () => () => {} }
} }
@@ -322,7 +330,7 @@ function mount(options = {}) {
* does, so the component under test sees the props a real render would give it. * does, so the component under test sees the props a real render would give it.
*/ */
const card = (view) => { const card = (view) => {
const item = byName.get('plugins.row.config') const item = byName.get('plugins.bundle.config')
assert.ok(item !== undefined, 'no settings page was registered') assert.ok(item !== undefined, 'no settings page was registered')
const props = { ...item.meta.inject(), view } const props = { ...item.meta.inject(), view }
delete props.hooks delete props.hooks
@@ -364,13 +372,15 @@ test('the client module claims the slot registry in its inject list', () => {
assert.deepEqual(module.inject, ['slots']) assert.deepEqual(module.inject, ['slots'])
}) })
test('the configuration page claims the 0.1.7 seat, keyed as the patch declares it', () => { test('the configuration page claims the seat the plugin card draws', () => {
const { registered } = mount() const { registered } = mount()
const page = registered.find((item) => item.meta.name === 'plugins.row.config') const page = registered.find((item) => item.meta.name === 'plugins.bundle.config')
assert.ok(page !== undefined, 'the page must register into plugins.row.config') assert.ok(page !== undefined, 'the page must register into plugins.bundle.config')
// `<bundle package name>#<row id>`: the row id is the one this bundle's own // Keyed by the BUNDLE's package name, which is what the Plugins page
// cordis.patch.yml declares, which is what the Plugins page dispatches by. // dispatches this seat with (`entryKey: pkg.name`). The sibling seat,
assert.equal(page.meta.key, 'dsh-recall#dsh-recall') // `plugins.row.config`, would be keyed `<package>#<row id>` and would render
// one click deeper, on the row's own page.
assert.equal(page.meta.key, 'dsh-recall')
assert.equal(typeof page.meta.inject, 'function') assert.equal(typeof page.meta.inject, 'function')
}) })
@@ -385,7 +395,7 @@ test('no page is registered for a namespace the Host does not serve', () => {
// onto fields nothing would accept. // onto fields nothing would accept.
const { registered } = mount({ served: ['some-other-plugin'] }) const { registered } = mount({ served: ['some-other-plugin'] })
assert.equal( assert.equal(
registered.some((item) => item.meta.name === 'plugins.row.config'), registered.some((item) => item.meta.name === 'plugins.bundle.config'),
false, false,
) )
}) })
@@ -395,17 +405,32 @@ test('the chip and the sidebar tab survive a deployment with no settings provide
// profile without a settings provider loses the page and keeps the rest. // profile without a settings provider loses the page and keeps the rest.
const { registered, tabTypes } = mount({ withConfigForms: false }) const { registered, tabTypes } = mount({ withConfigForms: false })
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [ assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner', 'conversation.session.header.utilities',
'sidebar.right.pane.tab', 'sidebar.right.pane.tab',
]) ])
assert.equal(tabTypes.length, 1, 'the tab type is independent of settings') assert.equal(tabTypes.length, 1, 'the tab type is independent of settings')
}) })
test('the header chip is a single-seat contribution with no key', () => { test('the header chip is an additive list entry carrying its own id', () => {
// The header's far-right corner is a SINGLE cell, and the product already
// occupies it with its right-sidebar expand control — the client catalog
// reports that key as `replaceRisk: "shadows-shipped-ui"`. A second
// registration at the same priority is a hard error inside the registry,
// thrown while the OTHER plugin's `slots.inject()` callback runs: the
// renderer catches it, stops that controller for the rest of the fiber's
// life, and rethrows it uncaught. So the chip belongs in a list cell, where
// a unique id makes it additive.
const { registered } = mount() const { registered } = mount()
const chip = registered.find((item) => item.meta.name === 'conversation.session.header.corner') const chip = registered.find((item) => item.meta.name === 'conversation.session.header.utilities')
assert.ok(chip !== undefined) assert.ok(chip !== undefined)
assert.equal(chip.meta.key, undefined, 'the corner is a single seat, so it takes no key') assert.equal(chip.meta.id, 'dsh-recall', 'a list cell is addressed by its id')
assert.equal(typeof chip.meta.order, 'number', 'the order places the cell within the list')
assert.equal(chip.meta.key, undefined, 'a list cell takes an id, not a key')
assert.deepEqual(
registered.filter((item) => item.meta.name === 'conversation.session.header.corner'),
[],
'the corner is a single cell the product owns; a registration there shadows shipped UI',
)
}) })
test('the sidebar tab registers a type AND a body under the same id', () => { test('the sidebar tab registers a type AND a body under the same id', () => {
@@ -430,8 +455,8 @@ test('the sidebar tab registers a type AND a body under the same id', () => {
test('the browser half contributes three seats, none of them the retired 0.1.5 one', () => { test('the browser half contributes three seats, none of them the retired 0.1.5 one', () => {
const { registered } = mount() const { registered } = mount()
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [ assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner', 'conversation.session.header.utilities',
'plugins.row.config', 'plugins.bundle.config',
'sidebar.right.pane.tab', 'sidebar.right.pane.tab',
]) ])
}) })
@@ -580,17 +605,17 @@ function usageView(overrides = {}) {
test('the header chip renders nothing when the conversation never used it', () => { test('the header chip renders nothing when the conversation never used it', () => {
const { renderSlot } = mount() const { renderSlot } = mount()
// The corner is laid out only while its occupant renders something, so this // The utilities list draws nothing for an entry that renders nothing, so
// is the state that must cost no space at all. // this is the state that must cost no space at all.
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => undefined }), null) assert.equal(renderSlot('conversation.session.header.utilities', { useProjection: () => undefined }), null)
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null) assert.equal(renderSlot('conversation.session.header.utilities', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null)
// And a host without the projection service at all. // And a host without the projection service at all.
assert.equal(renderSlot('conversation.session.header.corner', {}), null) assert.equal(renderSlot('conversation.session.header.utilities', {}), null)
}) })
test('the header chip leads with what was kept out, not what it cost', () => { test('the header chip leads with what was kept out, not what it cost', () => {
const { renderSlot } = mount() const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() }) const chip = renderSlot('conversation.session.header.utilities', { useProjection: () => usageView() })
assert.ok(chip !== null) assert.ok(chip !== null)
const text = String(chip.props.children) const text = String(chip.props.children)
assert.match(text, /Recall 3/) assert.match(text, /Recall 3/)
@@ -603,7 +628,7 @@ test('the header chip leads with what was kept out, not what it cost', () => {
test('the chip tooltip carries the figures the chip has no room for', () => { test('the chip tooltip carries the figures the chip has no room for', () => {
const { renderSlot } = mount() const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ rereadTokens: 500_000 }) }) const chip = renderSlot('conversation.session.header.utilities', { useProjection: () => usageView({ rereadTokens: 500_000 }) })
const detail = chip.props.title const detail = chip.props.title
assert.match(detail, /3 searches/) assert.match(detail, /3 searches/)
assert.match(detail, /9 excerpts from 5 files/) assert.match(detail, /9 excerpts from 5 files/)
+8 -2
View File
@@ -102,8 +102,14 @@ export function makeContext(options = {}) {
} }
/** An `exec` argument for one tool call. */ /** An `exec` argument for one tool call. */
export function makeExec(cwd, signal) { export function makeExec(cwd, signal, sessionId = 'session-under-test') {
return { signal: signal ?? new AbortController().signal, agent: { session: { header: { cwd } } } } // `agent.id` is the SESSION id — the jobs registry resolves a background job's
// owner through `agents.get(id)` and refuses anything that is not that string.
// A fixture without it cannot catch a call that passes the whole handle.
return {
signal: signal ?? new AbortController().signal,
agent: { id: sessionId, session: { header: { cwd } } },
}
} }
/** Write a map of relative path to content into a fresh temporary directory. */ /** Write a map of relative path to content into a fresh temporary directory. */
+158 -3
View File
@@ -1,5 +1,5 @@
import { strict as assert } from 'node:assert' import { strict as assert } from 'node:assert'
import { readFile, rm, writeFile } from 'node:fs/promises' import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import { test } from 'node:test' import { test } from 'node:test'
@@ -159,6 +159,7 @@ function configFor(stateDir, extra = {}) {
// the two lists from drifting apart again. // the two lists from drifting apart again.
focus: '', focus: '',
focusMarkers: [], focusMarkers: [],
sourceRoots: [],
expansionEnabled: false, expansionEnabled: false,
expansionModel: '', expansionModel: '',
expansionCount: 4, expansionCount: 4,
@@ -173,7 +174,7 @@ function configFor(stateDir, extra = {}) {
} }
/** Apply the plugin to a fresh workspace with a stubbed embedding endpoint. */ /** Apply the plugin to a fresh workspace with a stubbed embedding endpoint. */
async function setup(t, files = FILES, extra = {}, stubOptions = {}) { async function setup(t, files = FILES, extra = {}, stubOptions = {}, services = {}) {
const workspace = await makeWorkspace(files) const workspace = await makeWorkspace(files)
const stateDir = await makeStateDir() const stateDir = await makeStateDir()
const stub = makeStubFetch(stubOptions) const stub = makeStubFetch(stubOptions)
@@ -185,7 +186,7 @@ async function setup(t, files = FILES, extra = {}, stubOptions = {}) {
await rm(stateDir, { recursive: true, force: true }) await rm(stateDir, { recursive: true, force: true })
}) })
const { ctx, tools, dispose } = makeContext() const { ctx, tools, dispose } = makeContext({ services })
t.after(() => dispose()) t.after(() => dispose())
apply(ctx, configFor(stateDir, extra)) apply(ctx, configFor(stateDir, extra))
assert.equal(tools.length, 1, 'exactly one tool should be registered') assert.equal(tools.length, 1, 'exactly one tool should be registered')
@@ -814,3 +815,157 @@ test('re-ranking degrades to the fused order when the model has no opinion', asy
) )
assert.ok(reranked.results.length > 0, 'no opinion must not mean no results') assert.ok(reranked.results.length > 0, 'no opinion must not mean no results')
}) })
test('a background index hands the jobs registry a session id, not the agent handle', async (t) => {
// The jobs registry resolves `spec.owner` through `agents.get(owner)` and
// throws `session "[object Object]" has no live agent` for anything that is not
// the session id string. This test is a stand-in for that check: the bug it
// catches was invisible in-process and made the DEFAULT `index` call fail on a
// real deployment, where a throwing starter leaves the caller no job at all.
const started = []
const services = {
jobs: {
start(spec) {
started.push(spec)
return `recall-index-${started.length}`
},
},
}
const { tool, workspace } = await setup(t, FILES, {}, {}, services)
const value = await tool.execute({ action: 'index' }, makeExec(workspace, undefined, 'session-42'))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.jobId, 'recall-index-1')
assert.equal(started.length, 1)
assert.equal(
started[0].owner,
'session-42',
'owner must be the session id: the registry refuses an Agent handle outright',
)
assert.equal(typeof started[0].run, 'function')
// And the job the registry was handed actually indexes the workspace.
const outcome = await started[0].run().done
assert.equal(outcome.status, 'completed')
assert.match(outcome.output, /Indexed/)
const search = await tool.execute({ query: 'session expiry' }, makeExec(workspace))
assert.ok(search.results.length > 0, 'the background job must leave a searchable index behind')
})
/**
* A workspace with a library tree beside it, laid out the way a real one is.
*
* The library is a *scoped packages directory* — its own path runs through
* `node_modules` — because that is the shape the workspace walk exists to skip
* and the shape this feature has to enter. `root` is the parent of both trees, so
* the configured root can be given as a relative path and stays portable.
*
* @returns {Promise<{root: string, cleanup: () => Promise<void>}>}
*/
async function makeRootPair() {
const root = await makeWorkspace({})
const workspace = join(root, 'My Project')
const library = join(root, 'packages', 'node_modules', '@vendor')
await mkdir(join(library, 'widget', 'lib'), { recursive: true })
await mkdir(join(library, 'widget', 'node_modules', 'dep'), { recursive: true })
await mkdir(workspace, { recursive: true })
await writeFile(join(workspace, 'app.ts'), 'export const app = 1\n')
await writeFile(
join(library, 'widget', 'lib', 'index.js'),
'export function mountWidget(root, options) { return { root, options } }\n',
)
await writeFile(
join(library, 'widget', 'node_modules', 'dep', 'index.js'),
'export const vendored = "should never be indexed"\n',
)
return { root, workspace, library, cleanup: () => rm(root, { recursive: true, force: true }) }
}
/** Apply the plugin to a workspace the caller made itself. */
async function setupAt(t, workspace, extra = {}) {
const stateDir = await makeStateDir()
const stub = makeStubFetch()
const originalFetch = globalThis.fetch
globalThis.fetch = stub
t.after(async () => {
globalThis.fetch = originalFetch
await rm(stateDir, { recursive: true, force: true })
})
const { ctx, tools, dispose } = makeContext()
t.after(() => dispose())
apply(ctx, configFor(stateDir, extra))
return { tool: tools[0], workspace, stub }
}
test('an extra root is indexed beside the workspace, and its dependencies are not', async (t) => {
const { root, workspace, library } = await makeRootPair()
t.after(async () => rm(root, { recursive: true, force: true }))
// Relative to the workspace, and named, because the naming is what makes a
// result say which tree it came from.
const { tool } = await setupAt(t, workspace, { sourceRoots: ['vendor=../packages/node_modules/@vendor'] })
const index = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, index), [])
assert.deepEqual(index.index.sourceRoots, [{ name: 'vendor', path: library, files: 1 }])
assert.equal(index.index.files, 2, 'the workspace file and the library file, and nothing vendored')
const search = await tool.execute({ query: 'mountWidget root options' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, search), [])
const hit = search.results.find((result) => result.path.startsWith('vendor/'))
assert.ok(hit, `no library result in ${search.results.map((result) => result.path).join(', ')}`)
// The label names the tree; the absolute path is the only thing `read` can use,
// because the label is relative to the root rather than the workspace.
assert.equal(hit.path, 'vendor/widget/lib/index.js')
assert.equal(hit.sourcePath, join(library, 'widget', 'lib', 'index.js'))
// A workspace result is unchanged: its own path already resolves.
const own = search.results.find((result) => result.path === 'app.ts')
if (own !== undefined) assert.equal(own.sourcePath, undefined)
// The nested dependency is absent from the index entirely, not merely unranked.
assert.equal(
search.results.some((result) => result.path.includes('node_modules')),
false,
)
})
test('an unusable source root is reported and never costs the workspace index', async (t) => {
const { root, workspace } = await makeRootPair()
t.after(async () => rm(root, { recursive: true, force: true }))
const { tool } = await setupAt(t, workspace, { sourceRoots: ['ghost=../nowhere', 'vendor=../packages/node_modules/@vendor'] })
const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
// The good root still indexed, and the bad one is named rather than swallowed.
assert.deepEqual(value.index.sourceRoots.map((entry) => entry.name), ['vendor'])
assert.equal(value.sourceRootProblems.length, 1)
assert.match(value.sourceRootProblems[0], /nowhere/)
assert.ok(value.index.files >= 2, 'the workspace and the surviving root must both be indexed')
const status = await tool.execute({ action: 'status' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, status), [])
assert.equal(status.sourceRootProblems.length, 1)
assert.ok(status.results === undefined, 'status returns no results')
})
test('a source root whose path is a file, not a directory, is refused', async (t) => {
const { root, workspace } = await makeRootPair()
t.after(async () => rm(root, { recursive: true, force: true }))
const { tool } = await setupAt(t, workspace, { sourceRoots: ['app=./app.ts'] })
const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.index.sourceRoots.length, 0)
assert.match(value.sourceRootProblems[0], /not a directory/)
})
test('a source root named after the focus path is refused as a collision', async (t) => {
// Two different files would be indexed under one key, the second write
// replacing the first — so this cannot be left to the walk to get right.
const root = await makeWorkspace({ 'vendor/app.ts': 'export const a = 1\n', 'lib/app.ts': 'export const b = 2\n' })
t.after(() => rm(root, { recursive: true, force: true }))
const { tool } = await setupAt(t, root, { sourceRoots: ['vendor=vendor'] })
const value = await tool.execute(
{ action: 'index', run_in_background: false, focus: 'vendor' },
makeExec(root),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.index.sourceRoots.length, 0)
assert.match(value.sourceRootProblems[0], /collides with the focus path/)
})
+138
View File
@@ -0,0 +1,138 @@
import { strict as assert } from 'node:assert'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import {
ROOT_SKIP_DIRS,
listRootFiles,
parseSourceRoot,
resolveSourceRoots,
rootRelativePath,
sanitizeRootName,
skipsRootDir,
} from '../lib/roots.js'
test('a root defaults its name to the directory basename', () => {
const parsed = parseSourceRoot('/usr/local/lib/node_modules/@deepseek-ai', '/repo')
assert.equal(parsed.name, '@deepseek-ai')
assert.equal(parsed.path, '/usr/local/lib/node_modules/@deepseek-ai')
assert.equal(parsed.workspaceRelative, false)
})
test('an explicit name is taken as written', () => {
const parsed = parseSourceRoot('dsh=/usr/local/lib/node_modules/@deepseek-ai', '/repo')
assert.equal(parsed.name, 'dsh')
assert.equal(parsed.path, '/usr/local/lib/node_modules/@deepseek-ai')
})
test('a relative root resolves against the workspace and says so', () => {
const parsed = parseSourceRoot('vendor/lib', '/repo')
assert.equal(parsed.path, '/repo/vendor/lib')
assert.equal(parsed.name, 'lib')
assert.equal(parsed.workspaceRelative, true)
})
test('a name containing a separator is refused, because it would forge a path', () => {
assert.throws(() => parseSourceRoot('a/b=/tmp/x', '/repo'), /path separator/)
})
test('an entry that names no path or yields no name is refused', () => {
assert.throws(() => parseSourceRoot('', '/repo'), /empty entry/)
assert.throws(() => parseSourceRoot('name=', '/repo'), /names no path/)
// A root that climbs to the filesystem root has no basename to be labelled
// with, and `resolve` collapsing the traversal is exactly why the check is on
// the resolved path rather than on the text of the entry.
assert.throws(() => parseSourceRoot('/../..', '/repo'), /does not yield a usable name/)
})
test('sanitizeRootName keeps the characters a path segment can hold', () => {
assert.equal(sanitizeRootName('@deepseek-ai'), '@deepseek-ai')
assert.equal(sanitizeRootName('my root'), 'my-root')
assert.equal(sanitizeRootName('..'), '')
assert.equal(sanitizeRootName('.'), '')
assert.equal(sanitizeRootName(' '), '')
})
test('rootRelativePath never emits a leading or trailing separator', () => {
assert.equal(rootRelativePath('dsh', 'lib/index.js'), 'dsh/lib/index.js')
assert.equal(rootRelativePath('dsh', ''), 'dsh')
})
test('resolveSourceRoots skips a bad entry loudly and keeps the rest', () => {
const result = resolveSourceRoots(['/good', 'missing=/nope', '/good'], {
workspace: '/repo',
isDirectory: (path) => path === '/good',
})
assert.deepEqual(result.roots, [{ name: 'good', path: '/good' }])
// A duplicate name is dropped rather than silently overwriting: two roots with
// one label would make every result from the second ambiguous.
assert.equal(result.problems.length, 2)
assert.match(result.problems[0], /not a directory/)
assert.match(result.problems[1], /duplicates the name/)
})
test('resolveSourceRoots tolerates no configuration at all', () => {
assert.deepEqual(resolveSourceRoots([], { workspace: '/repo' }), { roots: [], problems: [] })
assert.deepEqual(resolveSourceRoots(undefined, { workspace: '/repo' }), { roots: [], problems: [] })
})
test('the root itself is entered whatever it is called, and node_modules below it is not', () => {
// This asymmetry is the feature: a root is normally named `.../node_modules/...`,
// while a `node_modules` inside one of its packages is somebody else's code.
assert.equal(skipsRootDir(0, 'node_modules'), false, 'the root must not be skipped')
assert.equal(skipsRootDir(0, '.git'), false)
assert.equal(skipsRootDir(1, 'node_modules'), true, 'a nested dependency store must be skipped')
assert.equal(skipsRootDir(3, 'node_modules'), true)
assert.equal(skipsRootDir(1, 'dist'), true)
assert.equal(skipsRootDir(1, 'lib'), false)
assert.ok(ROOT_SKIP_DIRS.includes('node_modules'), 'the skip list must still name node_modules')
})
test('listRootFiles indexes a package tree and not its dependencies', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'recall-root-'))
t.after(() => rm(root, { recursive: true, force: true }))
const packages = join(root, 'node_modules', '@scope')
await mkdir(join(packages, 'alpha', 'lib'), { recursive: true })
await mkdir(join(packages, 'alpha', 'node_modules', 'dep'), { recursive: true })
await mkdir(join(packages, 'beta', 'dist'), { recursive: true })
await writeFile(join(packages, 'alpha', 'lib', 'index.js'), 'export const a = 1\n')
await writeFile(join(packages, 'alpha', 'node_modules', 'dep', 'index.js'), 'module.exports = 0\n')
await writeFile(join(packages, 'beta', 'dist', 'bundle.js'), 'var x = 1\n')
await writeFile(join(packages, 'beta', 'index.js'), 'export const b = 2\n')
await writeFile(join(packages, 'README.md'), '# not code\n')
const walk = await listRootFiles({ name: 'dsh', path: packages })
assert.deepEqual(
walk.files.map((file) => file.path),
['dsh/alpha/lib/index.js', 'dsh/beta/index.js'],
'the root is entered, a dependency below it is not, and dist is not',
)
// Every path is labelled but resolvable: `read` needs the absolute one.
for (const file of walk.files) assert.ok(file.absolute.startsWith(packages), file.absolute)
})
test('listRootFiles does not follow a symlink out of the root', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'recall-root-'))
const outside = await mkdtemp(join(tmpdir(), 'recall-outside-'))
t.after(async () => {
await rm(root, { recursive: true, force: true })
await rm(outside, { recursive: true, force: true })
})
await mkdir(join(root, 'pkg'), { recursive: true })
await writeFile(join(root, 'pkg', 'a.js'), 'export const a = 1\n')
await writeFile(join(outside, 'secret.js'), 'const secret = 1\n')
await symlink(outside, join(root, 'link'), 'dir')
const walk = await listRootFiles({ name: 'r', path: root })
assert.deepEqual(walk.files.map((file) => file.path), ['r/pkg/a.js'])
})
test('a file at the root of a root is labelled with the root name alone', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'recall-root-'))
t.after(() => rm(root, { recursive: true, force: true }))
await writeFile(join(root, 'index.js'), 'export const root = 1\n')
const walk = await listRootFiles({ name: 'solo', path: root })
assert.deepEqual(walk.files.map((file) => file.path), ['solo/index.js'])
})