It rendered as a flat, always-open block with no header. The framework's card
chrome is a disclosure: the tab renders every card into a <ul>, each card's root
is an <li>, its header is its own <button> that discloses the controls in place,
it starts collapsed, the header marks it when it holds unsaved edits, and a save
that lands folds it back up.
Three things were wrong and only one of them was cosmetic:
* the root was a <div> inside the tab's <ul>, so the list and card styling
never reached it
* there was no header button, so no disclosure and no aria-expanded
* nothing marked unsaved edits, and nothing collapsed after a save
`PluginCard` is not importable — it lives in that package's client bundle, and
the client purity gate forbids cross-plugin value imports — so the behaviour is
reproduced rather than borrowed, with inline styles for the same reason.
The collapse-on-save originally lived in an effect watching a `saving` flag, which
is how the shipped card does it because its form state comes from a controller.
This card awaits its own writes, so it already knows the save landed; collapsing
directly in the success path is simpler and is what the tests can actually reach.
A failed save still keeps the card open with its drafts.
Tests cover the disclosure itself: the root element, collapsed-by-default, the
header's aria-expanded and label, that staged edits survive collapsing, and that
a landed save folds the card up.
dsh-slopo
slopo — an embedding-based duplicate-code detector — as a DeepSeek Harness bundle.
Slopo finds non-obvious duplicated logic: similar implementations of the same problem that are not copy-paste, and that a human reviewer, a coding agent and a clone detector all miss. This bundle gives you three user-triggered skills and one configuration-aware tool, and a settings card. Run it against whichever workspace you are in without remembering flags.
What it adds
| Contribution | What it is |
|---|---|
/slopo-review |
Slopo reports similar code involving your Git changes, and the agent reviews them as a note-taker. |
/slopo-analyze-ignore |
Triages the whole codebase's clusters, writing the obvious false positives into the ignore file. |
/slopo-analyze-one |
Reports one cluster at a time — optionally by hash — and judges whether it is worth acting on. |
the slopo tool |
Resolves the CLI, materialises the config, runs the work, and turns slopo's terse failures into something actionable. |
the dsh-slopo settings namespace |
The embedding backend and per-workspace state, registered on the Host. |
| the settings card | A form for all of it, in Settings → Plugins → Plugin configuration. |
| All three skills are user-invocable and deliberately not model-invocable, | |
matching slopo's own agent configs. Typing /slopo-review anywhere in a message |
|
| injects that skill; the model will not reach for one on its own. Every one of | |
| these commands can spend minutes embedding, so the human decides when. |
Install
dsh plugin --profile web add git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-slopo.git
Then restart the profile. A new bundle's modules are resolved at startup, and a client half's bytes are snapshotted at activation — so the settings card only appears after that restart.
Plain ESM, no build step, and therefore no prepare script and no allowBuilds
entry to authorise. That is deliberate: dsh plugin add git+… fetches the source
tree rather than build output, so a prepare script would make every user
allowlist a build before the first install can succeed.
Settings
The plugin contributes a card to Settings → Plugins → Plugin configuration,
keyed on the dsh-slopo settings namespace the Host registers. Every setting in
the table below is editable there, overridden fields are marked, and Reset
returns a field to the deployment default rather than to empty.
The card behaves the way the other plugin cards do: it starts collapsed, its header is a button that discloses the controls in place, the header marks it when it holds unsaved edits — visible even while collapsed, since staged edits outlive being folded away — and a save that lands collapses it again.
The card is the bundle's second half — the browser half. It needs no build step
and ships as plain ESM in the module-loader wrapper, but the platform snapshots
client bundles at activation, so a client half only appears after the profile
restarts. Without it the namespace still works — it is editable in
$DSH_HOME/settings.yaml and through the tool's configure action — there is
simply no form drawn for it, because the tab renders the intersection of served
namespaces and registered cards.
Configure it
Nothing is defaulted. Slopo itself requires a source directory, an embedding model and its dimensions, and guessing an embedding backend would mean guessing where money is spent — so the plugin starts unconfigured and says so.
Ask the tool what it wants:
{ "action": "status" }
Then set it up:
{
"action": "configure",
"config": {
"embeddingModel": "ollama/unclemusclez/jina-embeddings-v2-base-code:latest",
"embeddingDimensions": 768,
"embeddingApiBase": "http://127.0.0.1:11434"
}
}
Values land in the dsh-slopo section of $DSH_HOME/settings.yaml.
| Setting | Meaning |
|---|---|
embeddingModel |
LiteLLM model string. Include the provider prefix — see below. |
embeddingDimensions |
Must match what the model actually returns. Slopo freezes it at first index. |
embeddingApiBase |
Provider base URL. Becomes slopo's embedding_params.api_base. |
embeddingApiKey |
Optional. Passed to slopo in its environment, never written to disk. |
embeddingParamsJson |
Extra LiteLLM embedding params as a JSON object string. |
sourceDir |
Code to index. Defaults to the session's working directory. |
sourceDirExclude |
gitignore-style paths to skip. |
autoInstall |
Install slopo into $DSH_HOME with uv on first use. Default true. |
binary |
An explicit slopo executable, overriding discovery. |
stateDir |
Override the per-workspace state directory. |
ignoreFile |
Override the ignore file path. |
embeddingRequestDelay |
Milliseconds between embedding requests, for rate-limited providers. |
timeoutMs |
Cooperative budget for a foreground run. |
Choose the provider prefix carefully
The provider prefix is not cosmetic — it selects which LiteLLM validator runs.
openai/<model> ✗ UnsupportedParamsError: Setting dimensions is not supported
for OpenAI `text-embedding-3` and later models
ollama/<model> ✓ works
Slopo always passes dimensions, and the openai/ prefix makes LiteLLM apply
OpenAI-specific parameter validation to any model behind it. A local
OpenAI-compatible server therefore needs its own prefix (ollama/,
hosted_vllm/), not openai/. This is the single most likely thing to trip you
up on first setup, and slopo reports it only as Error: Embedding failed. on
stdout — the real message is in the agent log, which the tool reads for you.
A local backend costs nothing
A local Ollama server is a good default: free, private, no API key and no egress. Measured throughput on a small CPU box is roughly 230 ms per unique unit (~4/second), with repeated inputs served from cache. Budget minutes, not seconds, for a first index of a real repository.
Use it
Type the skill name in a message:
/slopo-review
/slopo-review main
/slopo-analyze-one
Or drive the tool directly:
| Action | What it does |
|---|---|
status |
Everything: resolved settings, index state, the binary and its version, plus a live pre-flight against the embedding endpoint. |
configure |
Patch the settings namespace. |
prepare |
slopo index then slopo embed — the slow, possibly costly phase. |
review |
Similar code involving Git changes (base defaults to HEAD). |
analyze |
Triage the whole codebase. |
analyze-one |
One cluster, optionally by cluster hash. |
update |
Upgrade the slopo CLI. |
The long actions run as background jobs by default (pass foreground: true
to run inline). Embedding takes minutes, and slopo writes its progress to a log
file rather than to stdout, so there is nothing to stream — the job handle
carries the log path instead.
Where things live
Everything is under $DSH_HOME, and nothing is written into your repository:
$DSH_HOME/dsh-slopo/
├── uv-tools/ # the uv-managed slopo install and its shim
├── uv-python/ # the CPython 3.12 that slopo requires
└── workspaces/<name>-<hash8>/
├── slopo.conf.yaml # generated; edits here are overwritten
├── slopo.db # the index and embeddings
├── slopo.ignore.txt # dismissed cluster hashes
└── slopo.agent.log # full errors, progress
$DSH_HOME is not an arbitrary choice. In a containerised harness it is usually
the only persistent mount, while ~/.local — where uv tool install puts things
by default — lives on the container's writable layer and is lost when the
container is recreated, not merely restarted. Setting UV_TOOL_DIR,
UV_TOOL_BIN_DIR and UV_PYTHON_INSTALL_DIR into $DSH_HOME makes the install
survive that, and installation is lazy rather than on boot so a fresh container
self-heals on first use.
Point ignoreFile at e.g. <repo>/slopo.ignore.txt if you would rather those
decisions were versioned with the code — they are a judgement about your
codebase, and arguably belong in review.
What gets indexed
Slopo does not read .gitignore — it walks the tree you point it at. Pointed
at a repository root with no exclusions, it will happily parse every file under
node_modules. That is not a review of your code; it is a review of your
dependencies, and it is slow enough to matter.
Measured on one real repository:
| scope | files | code units | embedding at the reference rate |
|---|---|---|---|
| nothing excluded | ~1,300 | 113,768 | ~7.9 hours |
| default excludes | 562 | 8,607 | ~36 minutes |
| default excludes + a server-only scope | 165 | 1,519 | ~6 minutes |
So there are two independent safeguards.
Default excludes, on unless you turn them off: node_modules, .git,
dist, build, out, coverage, .venv, venv, __pycache__, target,
vendor, and minified bundles. Your own sourceDirExclude is added to
these rather than replacing them — deliberately, because a settings patch
replaces a whole value, so setting one pattern would otherwise silently
re-enable the 13x blow-up.
A budget on the backlog. maxIndexUnits (default 5,000) refuses to start an
embed that exceeds it, naming the count, the estimate and the lever:
113,768 code units still need embedding across 1,300 files. At the reference rate that is about 7.9 hours, against a budget of 5,000.
Nothing has been embedded at that point. Raise the budget, or narrow the scope —
setting maxIndexUnits to 0 removes the limit. The count comes from reading
slopo's own database, not from parsing its output, because Indexed N code units
reports only what was newly indexed: on a warm database it reads zero while the
backlog is still full, which is exactly the state a refusal leaves behind.
Every action indexes first so the gate runs before anything expensive, and
status reports both the effective excludes and the budget.
Things worth knowing
Five settings are frozen at first index. source_dir, embedding_model,
embedding_dimensions and both node-count thresholds are written into
slopo.db on the first index, and every later run refuses to mix generations:
Error: Configuration mismatch.
Changing any of them means deleting the database and re-embedding everything — real time, and real money on a hosted backend. The tool recognises this failure and says so rather than relaying the two-word stderr line.
A review cannot bootstrap the database. agent-review opens the index with a
read-only open, so on a fresh workspace it fails with No data in database. The tool wasn't initialized. Run prepare first. Indexing is pure local parsing and
free; only embedding reaches the network.
Slopo refuses more than 1000 unembedded units inside an agent call, to
protect agent latency. The tool translates that into an offer to run prepare
in the background.
Slopo's stderr is deliberately unhelpful. It prints one short non-revealing line for an agent and writes the real error to the agent log. The tool reads the log tail and maps the known failures — missing database, configuration mismatch, oversized backlog, missing or invalid config, stale index, git failure, embedding failure — onto a message and a next step.
--config-version 1 is an alarm, not boilerplate. It makes slopo fail if its
agent-facing contract has moved on, which is the signal that the skill text in
skills/ needs re-porting from upstream.
The sandbox can block slopo's state. Slopo's index, config and log live
under $DSH_HOME, which is outside the workspace that a workspace-write
sandbox permits writing to. The tool detects that and refuses before running,
naming both ways out, rather than letting a write fail halfway through an embed:
run the session under the danger-full-access preset, or set stateDir to a
directory inside the workspace.
What the tool does with the sandbox policy, and why it matters. The mounted
ctx.shell on a confined deployment is dsh-bash-sandbox, whose resolve()
stamps request.sandboxPolicy ?? ctx.sandboxPolicy.resolve(). Omitting the
policy therefore does not mean "unconfined" — it silently substitutes the
deployment default (DSH_PERMISSION_MODE ?? 'workspace-write'), which is not
the session's preset. That is what makes a danger-full-access session fail with
SandboxUnavailableError on a host with no sandbox runner. The tool resolves the
session's policy the way dsh-tool-bash does and passes it per call.
Slopo is an external process, not a library
It is installed separately, invoked through its own CLI, and never imported or modified. Slopo is AGPL-3.0-or-later and does not accept patches, so its command-line interface is the only contract worth building on — and executing an unmodified, separately installed program is a materially different proposition from linking against it.
Development
node --test test/*.test.js # unit + integration, mock harness
DSH_SLOPO_E2E=1 npm test # also drives the real CLI end to end
The integration tests load the real plugin module against a mock Cordis context
and drive the tool through a real shell into the real slopo binary, so a wrong
assumption about a dsh service surfaces as a failing test rather than as a plugin
that silently does nothing. Two real bugs were caught this way: a config
renderer that would write the API key to disk if handed one, and --config-version
placed before the subcommand, which slopo rejects with a usage error.
The browser half has no browser to run in, so its tests evaluate the bundle the
way the real module loader does — inside window.__ModuleLoader__.load — with a
fake require that answers react and throws on anything else. That last part is
deliberate: it checks the card stays inside the platform's seed table, which is
the constraint a hand-written client half is most likely to break. The card is
then rendered and its handlers driven, which is not a substitute for looking at
it but does cover the wiring it cannot work without.
node_modules/@deepseek-ai is a local symlink to the installed harness so the
@deepseek-ai/* imports resolve during tests. It is gitignored and is not a
declared dependency — an out-of-tree bundle that depended on an in-box package
would install a second copy that drifts from the host's.
Licence
AGPL-3.0-or-later, matching slopo. The three skill bodies are ports of slopo's
own agent configurations rather than new prose; NOTICE.md records exactly what
changed and why the licence follows.