26 Commits
Author SHA1 Message Date
gitea-actions 48e5238877 Release v0.1.1 [skip release] [skip actions] v0.1.1 2026-09-26 06:06:57 +00:00
dsh-slopo agent 9ebd484e60 fix: draw the API key once, and let status answer the dimensions question
Release / release (push) Successful in 6s
Two things a user setting this up hit immediately.

The card drew `embeddingApiKey` twice. The render loop walked every entry in
FIELDS, and the secret — which is drawn by hand, as a write-only password
control — was one of them, so a second control appeared beside it: an ordinary
text input, echoing the key in clear as you typed. One key, two boxes, one of
them visible. The loop skips secrets now, and the tests count controls rather
than checking that each expected label is present, because a duplicate beside
the real control passed the old assertions.

`status` could not answer the one question a new user cannot look up: how wide
is this model? The probe that reports it was gated on every required setting
being present, so in exactly the state that needs the answer — dimensions
unset — it reported nothing and said "Missing: embedding_dimensions". It now
runs whenever the endpoint can be named (a model and a base URL), and the
status message names the width it observed:

  Missing: embedding_dimensions. The endpoint is reachable and returned 768
  dimensions, so set embeddingDimensions to 768.

The probe also tells "not set yet" apart from "set wrong", because the two have
different remedies.

Also documents both ways to read the width by hand, since a wrong value is
frozen into the index at first use and costs a full re-embed to correct.
2026-09-26 07:05:36 +01:00
dsh-slopo agent 85c0d4dccd Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-slopo 2026-09-26 07:05:36 +01:00
dsh-slopo agent 6beb4c0d39 fix: keep local home paths and addresses out of the test fixtures
Three test-only spots had baked in the machine this repository was developed
on, and the repository is publicly readable, so they shipped that machine's
shape to anyone who cloned it:

  * the end-to-end embedding backend was pinned to a private LAN address;
    it is now read from DSH_SLOPO_E2E_API_BASE, defaulting to Ollama's own
    loopback address, so the machine running the suite supplies its own;
  * the real-slopo test resolved discovery from a hardcoded root home, which
    meant it could only ever run for one user — it resolves from the running
    user's home now, which is also the fix for everyone else;
  * the harness-slot contract tests read the global npm tree by absolute
    path; they go through this repository's own node_modules symlink, which
    is already relative to the checkout.

No user-facing behaviour changes: the test files are not in the published
package, and the default configuration was already free of machine-specific
values — every address, path and key defaults to empty and is resolved per
user at runtime.
2026-09-26 07:05:33 +01:00
gitea-actions 55d08ef97e Release v0.1.0 [skip release] [skip actions] v0.1.0 2026-09-26 05:39:38 +00:00
dsh-slopo agent 1b2ab3613f fix!: port the settings page and shell seam to dsh 0.1.7-rc.2
Release / release (push) Successful in 6s
0.1.7-rc.2 moved four things this bundle was built on, and every one of them
failed quietly rather than loudly, so the plugin looked installed and did
nothing:

  * `ctx.settings.installSection(...)` is gone. The call threw into a
    `try/catch` that only logged a warning, so the settings namespace was never
    registered and `configure` had nowhere to persist. An entry's exported
    Config IS the form now; the namespace is the Loader entry id the bundle's
    patch declares, and `ctx.settings.configure({ auto: false })` is what claims
    that entry's own page.

  * `settingsScope` and the `settings.plugin.item` slot are gone. The card
    registered into both, so it never rendered — the tab draws the intersection
    of served namespaces and registered cards, and that intersection was empty.
    It now registers into `plugins.bundle.config` keyed by the bundle's package
    name, over the `configForms` service, and uses the framework's own form
    chrome (`SettingsFormModel` / `SettingsForm` / `SettingsValueField` /
    `SettingsSecretField`) instead of the hand-rolled equivalent. A save is one
    revision-fenced write.

  * `ctx.shell.run(spec)` is gone. Every action that runs a command threw
    `shell.run is not a function`; the seam is now
    `await ctx.shell.execute(ctx.shell.resolve(request)).result()`.

  * A job's `owner` is a session id, not the agent object. The wrong owner
    registered a job no session could collect or cancel, and the producer's
    `done` resolved `output` where the registry reads `result`, so a finished
    job handed back nothing.

Two conformance gaps went with them: a Config field that is not marked
`volatile()` is invisible to every settings UI — the form projection keeps only
volatile fields, and an entry with no form is dropped from `settings.describe`
entirely — and `embeddingApiKey` was a plain field, so the key rode every
settings read back to the browser. It is `role('secret')` now: the browser is
told whether a key is stored and never receives the key.

Also declares the `@deepseek-ai/dsh-*` peerDependencies the compatibility gate
reads, so an incompatible runtime refuses this plugin at startup instead of
half-loading it.

Verified against a real 0.1.7-rc.2 boot: `settings.describe` serves the entry
with `autoGenerate: false`, the key redacted and reported only as `set: true`,
and no startup diagnostics. The host mock in the tests now has no `run` and no
`installSection`, so those seams are contract-tested rather than assumed, and
the browser tests read the installed harness's own slot catalog and package
exports — a rename fails a test instead of quietly emptying a page.
2026-09-26 06:38:16 +01:00
dsh-slopo agent ffefc42e01 feat: derive the release bump from conventional commit subjects
The bump was decided by `[major]` / `[minor]` markers alone, so a range of
ordinary `fix:` commits and a range of `feat:` commits released the same way,
and the version number said nothing about what changed.

Read the intent from the subject instead, keeping the explicit markers as
overrides for the cases a subject cannot carry:

  `feat` as its own word   minor   0.1.0 -> 0.2.0
  `!:` or BREAKING         major   0.1.0 -> 1.0.0
  anything else            patch   0.1.0 -> 0.1.1

`feat` has to be a word, so "Fix the feature flag leak" is a patch rather than
a minor. Only the subject line is read; the body is not.

Adds `npm run release:notes`, which prints the notes a push would publish, and
AGENTS.md, which states the rules where an agent will read them.
2026-09-26 06:38:08 +01:00
James Mackay e0813bcbea Release on every push to master, and make updating possible
A dsh plugin installed as a bare git URL is pinned to a commit in the profile
lockfile. With no tags in the repository there was nothing for an update to move
to, which is why updating appeared to do nothing and a reinstall was the only way
through.

Gitea action on push to master: work out the range since the last tag, decide
whether the push releases at all, bump package.json, prepend a CHANGELOG section,
commit, tag vX.Y.Z, and publish the release with those notes as the body.

The bump is a patch unless a commit says [minor] or [major]; [skip release]
publishes nothing. With no tags yet the declared version is what gets tagged, so
the first release matches the code. Notes come from commit subjects, grouped under
headings only when the range actually uses conventional prefixes.

The logic is a script rather than inline YAML so it runs without a runner:
npm run test:release covers 31 cases, including an empty range, a repository with
no tags, and a patch number already at 9. The empty-range guard is load-bearing:
the release commit is itself a push to master.

Two bugs the tests caught: an empty note section made '[ -n x ] && printf' return
non-zero, which under set -e aborted the whole release, and a bullet beginning
with '-' was read by grep as an option.

README documents the #semver: spec, which tracks releases rather than every
commit, and the two things that look like a failed update: an unfetchable remote,
and the profile restart client bundle bytes require.
2026-09-25 19:03:50 +01:00
dsh-slopo agent 5ff10eaefd Background only the warm-up, and use the framework's own parameter name
The tool backgrounded EVERY long action and exposed an inverted `foreground`
flag. dsh-tool-bash does neither: its parameter is `run_in_background`, it
defaults to foreground, and the model opts in. That difference is what made a
finished job go uncollected.

The evidence, from this session's own log: the only slopo job that ever ran was
slopo-1, and the agent KILLED it — `kill()` sets `reported = true`, which
suppresses the completion notice by design, because a caller who asked to stop
something does not want to be told it stopped. The agent then worked around the
tool entirely and re-ran slopo through bash, which is where the six notices in
that session came from. Nothing was broken; the tool was asking the caller to
depend on a best-effort signal.

So the query actions return their report directly. Only `prepare` — index plus
embed, the genuinely long and watchable step, and the one the user asked to be
able to watch — backgrounds by default. Any action can still be backgrounded
with `run_in_background: true`.

The render now names the collect call explicitly (`job_output` for that id, with
`wait: true` when blocked on it) instead of the vaguer "read the job output when
it settles", so a caller that is not woken still has an unambiguous next step.

I checked the waiter path while I was in there: a timed-out `wait` decrements
`waiters` in a `finally`, so it does not leak and does not suppress the notice.
That was my first hypothesis and it is wrong — worth recording, since it was the
plausible one.
2026-09-22 13:30:55 +01:00
dsh-slopo agent 2d7a7d1e73 Make the settings card behave like the other plugin cards
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.
2026-09-22 13:26:29 +01:00
dsh-slopo agent 6b9d91b436 Document what gets indexed, with the measured numbers 2026-09-22 13:23:49 +01:00
dsh-slopo agent 22be1e819a Test the scope defaults, the backlog gate and the database reader
The gate tests use a canned shell runner, and two of them build a real
slopo-shaped SQLite file to prove the warm-database hole is closed: the summary
line says zero new units while the database says 8,607 unembedded, and the gate
must believe the database.

Those fixtures insert in one transaction. Row-by-row they were 8,607 individual
fsyncs, which took two minutes and timed the suite out - worth knowing before
writing another one.
2026-09-22 13:23:49 +01:00
dsh-slopo agent e149b6a977 Surface the scope settings on the card
Extra exclude patterns, the default-excludes switch, and the backlog budget, each
with the number that motivates it.
2026-09-22 13:23:49 +01:00
dsh-slopo agent 1074b50e51 Stop indexing node_modules, and make a large scope a decision
A review on a real repository indexed 113,768 code units and began an embed
that would have run for about eight hours. Two independent causes, both mine:

1. Nothing excluded anything. Slopo does NOT read .gitignore, so a repo with
   node_modules in its ignore file still had all 21,104 files under it parsed -
   13,091 of them source. The generated config carried no source_dir_exclude at
   all, so the tool reviewed the dependencies instead of the code.

2. Nothing bounded the work. Slopo's own >1000 guard is useless here: it
   measures the PRE-EXISTING backlog before indexing, and a fresh database has
   no units at all. The guard that exists to protect an agent's latency is
   silent in the one case that most needs it.

Measured on that repository:

  no excludes                 113,768 units   ~7.9 h
  default excludes              8,607 units   ~36 min
  default + a server-only scope 1,519 units   ~6 min

So: default excludes for vendored and generated directories, ADDED to the
user's own list rather than chosen between (a settings patch replaces a whole
value, so a single sourceDirExclude pattern would otherwise re-enable the 13x
blow-up). And every action now indexes first, checks the backlog against
maxIndexUnits (default 5,000), and refuses with the count, the estimate and the
lever rather than starting the work.

The count comes from slopo's own database via node:sqlite - no new dependency,
and it degrades to the summary line on an older runtime or a locked file. That
matters because `Indexed N` 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. Caught that hole by running the thing against the
real repository rather than trusting the first version.

Also fixes `status` reporting when the endpoint probe succeeded - the fix from
the previous commit is what made this run reachable at all.
2026-09-22 13:23:49 +01:00
dsh-slopo agent 46124b11aa Fix: status could not be serialised when the probe SUCCEEDED
The probe returned `error: undefined` on a dimension match. `undefined` is not a
JSON value -- JSON.stringify silently drops it from an object -- so the shape
looked harmless and was not: the harness enforces the declared output schema
against the canonical value before materialising it, and an explicit undefined
property fails that check. A perfectly healthy status call could not be returned
at all.

Fixed at the boundary rather than at each site. Every return now funnels through
`toJsonValue`, which drops undefined properties exactly as JSON.stringify would,
maps non-finite numbers to null (same reasoning: JSON.stringify(NaN) is "null"),
turns an undefined array slot into null rather than shifting indices, and refuses
a circular value instead of recursing forever. `update` was carrying
`errorCode: undefined` and `ensureSlopo` a `detail: undefined` on the same path,
so a per-site fix would have left two more of these waiting.

The probe also now omits `error` rather than assigning undefined, since it is a
plausible thing for another consumer to read.

Four regression tests, and they were checked against a negative control: with the
sanitiser reverted and the undefined restored, exactly the "probe SUCCEEDS" test
fails with "does not survive a JSON round trip" and the others still pass, which
is what makes it a test of this bug rather than of the JSON round trip in
general.
2026-09-22 13:08:22 +01:00
dsh-slopo agent 534efa8b50 Document the settings card and the status pre-flight
Including the part that matters operationally: a client half's bytes are
snapshotted at activation, so the card only appears after the profile restarts.
Without it the namespace still works — settings.yaml and the `configure` action —
there is simply no form drawn.
2026-09-22 07:22:03 +01:00
dsh-slopo agent 0028349d53 Test the probe and the card
The probe tests use an injected fetch, so they cover the shapes that matter
without a network: a matching width, a mismatch, an HTTP error, an unreachable
host, a timeout, and the key being sent only when there is one.

The client 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 throw is the point: it is the check
that 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 by
expanding its element tree, and its handlers are driven directly to prove that a
changed field reaches `set`, a cleared field reaches `unset`, a number field is
sent as a number, a list round-trips through comma-separated text, and a
read-only namespace disables everything.

The status test moves with the behaviour: status may now run a read-only
`--version`, so the assertion is that it executes nothing else.
2026-09-22 07:22:03 +01:00
dsh-slopo agent 4bb19af2ac Add the settings card — the browser half
The settings namespace was registered but nothing drew a form for it, because
the Plugin configuration tab renders the INTERSECTION of the namespaces the Host
serves and the cards a browser half claims. A served namespace with no card
renders nothing at all. This is the missing card.

Plain ESM in the module-loader wrapper, no build step, requiring only `react` —
which is in the platform seed table. Anything outside that table would have to be
bundled into this file or declared in `dsh.client.external`, and the tests fail
loudly on any other specifier.

It stages edits and writes them on Save; a cleared field is an `unset` rather
than a write of "", so the value re-inherits the composition layer instead of
becoming empty, and the user layer's presence is what marks a field overridden.
Reset clears every override. A namespace this client cannot reach renders
nothing, so a deployment without the Host half shows no trace.

package.json gains an `exports` map (the loader reads `exports["./client"]`; a
bare `main` would not be enough) and the `dsh.client` declaration.
2026-09-22 07:22:03 +01:00
dsh-slopo agent d7eccc6366 Add a real pre-flight to status
Slopo reports a bad embedding configuration as the two words "Embedding failed."
on stdout, with the real reason in its agent log. That is a slow way to learn
that a model string has the wrong provider prefix, or that the dimensions do not
match what the model actually returns — the second of which is expensive, because
slopo freezes the dimensions into the index at first use.

`status` now does three things instead of one: it reports the resolved settings,
it runs `slopo --version` through the shell seam, and it makes one tiny embedding
request against the configured endpoint and compares the returned width with the
configured one. Verified against a real Ollama: a correct config reports ok, a
dimension mismatch names both numbers, and a wrong model returns the endpoint's
own 404 rather than a silent failure minutes into an embed.

The probe is a direct fetch rather than a shell call, because it is one small
HTTP request and does not belong behind a boundary that exists to confine
filesystem writes.

Two smaller things this needed:
- the sandbox policy is now resolved once at the top of execute, so `status` can
  use it without a second resolution
- provider-prefix stripping is an allow-list, not "drop the first segment".
  `unclemusclez/jina-embeddings-v2-base-code:latest` is a real model name with no
  prefix at all, and an unknown first segment is left alone so a wrong guess is
  visible in the reported `sentModel` rather than silent
2026-09-22 07:22:03 +01:00
dsh-slopo agent e758a723e2 Fix: the tool substituted the deployment sandbox default for the session's
dsh-bash-sandbox is the composed ctx.shell, and its 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'`) for the session's preset.
On a host with no sandbox runner that confines a danger-full-access session and
fails with SandboxUnavailableError before slopo is ever reached, which is
exactly what happened: the tool was unusable while bash worked.

Resolve the calling session's policy the way dsh-tool-bash does, and pass it on
every call. A confining executor with no ctx.sandboxPolicy is now refused
outright rather than guessed at.

That fix exposes a second problem worth naming. Slopo's state lives under
$DSH_HOME, which is outside the workspace root a workspace-write sandbox permits
writing to, so under that preset nothing would work. The tool now refuses before
running and names both ways out -- run under danger-full-access, or set stateDir
to a directory inside the workspace -- instead of letting a write fail halfway
through an embed.

Five tests pin this: the session's policy is resolved exactly once and travels
with the session, a missing policy service is refused, state outside the sandbox
is refused before any execution, state inside it proceeds, and status/configure
never touch the shell at all.
2026-09-22 07:12:03 +01:00
dsh-slopo agent a8e577ae38 Document dsh-slopo, including the traps that cost real time
The provider prefix is not cosmetic: the openai/ prefix makes LiteLLM apply
OpenAI parameter validation to any model behind it, and slopo always passes
dimensions, so a local OpenAI-compatible server needs its own prefix.

Five settings are frozen into the database at first index, so changing the
embedding model means re-embedding everything. And a review cannot bootstrap the
database at all, because agent-review opens the index read-only.
2026-09-22 06:11:17 +01:00
dsh-slopo agent 3373e0b4f6 Test the plugin against the real harness seams and the real CLI
The integration tests load the real plugin module against a thin mock Cordis
context and drive the tool through a real shell into the real slopo binary. The
mock implements only the seams the plugin touches, so a wrong assumption about a
dsh service fails a test instead of producing a plugin that silently does
nothing at runtime.

Two real bugs were caught this way and are now pinned:

  * renderSlopoConfig would write the embedding API key to disk if it were
    handed one. It cannot now.
  * --config-version was placed before the subcommand, which slopo rejects with
    exit code 2. It is declared on the command, not the callback.

The end-to-end test is opt-in (DSH_SLOPO_E2E=1) because it needs a reachable
embedding backend; it indexes a real repository, embeds it, and asserts a real
duplicate cluster comes back.
2026-09-22 06:11:17 +01:00
dsh-slopo agent 31991143e4 Add the plugin host: three skills, a settings namespace, and one tool
The three skills are ports of slopo's own agent configs, published
user-invocable and NOT model-invocable. Slopo makes that choice upstream and the
reason holds here: every one of these commands can spend minutes embedding, so a
model that decides to "check for duplication" mid-task can do it repeatedly.

settings goes through ctx.inject(['settings'], ...) rather than the top-level
inject, so a profile without a settings provider still gets the tool and the
skills and degrades to the composition config. A required dependency would have
suppressed apply entirely -- a silent whole-plugin failure for an optional
capability.

The tool resolves the CLI, materialises the config, and runs the long actions as
background jobs. Background is the right default because embedding takes minutes
and slopo writes progress to a log file rather than stdout, so there is nothing
to stream; the job handle carries the log path instead.

Nothing is defaulted. Guessing an embedding backend would mean guessing where
money is spent, so status says what is missing and configure sets it.
2026-09-22 06:11:17 +01:00
dsh-slopo agent e956a8c121 Add the slopo CLI adapter
Three things live here: where the plugin keeps its files, how it renders a
slopo config, and how it finds, installs and invokes the binary.

The adapter is driven by behaviour observed against slopo 0.8.0 rather than by
its documentation:

  * --config is a global option and must precede the subcommand
  * every path is written absolute, because slopo resolves relative paths
    against the process working directory, not against the config file
  * unknown config keys are a hard error, so the renderer writes from a closed
    allow-list
  * the embedding API key is never written at all -- slopo reads
    SLOPO_EMBEDDING_API_KEY from the environment ahead of the file, so the
    secret stays out of every file this plugin touches. Enforced in pruneConfig
    rather than at the call site, so no future caller can leak it by passing one.

explainFailure maps slopo's deliberately terse agent-facing messages onto a
message and a next step, and reads the log tail because stderr alone leaves the
agent with no move: on a real failure stderr said "Embedding failed." while the
log named the exact litellm parameter that was rejected.

installEnv redirects UV_TOOL_DIR, UV_TOOL_BIN_DIR and UV_PYTHON_INSTALL_DIR
into $DSH_HOME. Only two directories in this container are persistent mounts;
~/.local, where uv installs by default, is on the container's writable layer
and does not survive a recreation.
2026-09-22 06:11:12 +01:00
dsh-slopo agent 844f12faa3 Add path resolution and slopo config rendering
Every path the plugin owns is under $DSH_HOME. Only two directories in the
container this runs in are persistent volumes ($DSH_HOME and the workspace
root); $HOME/.local -- where 'uv tool install' puts slopo and its CPython by
default -- is on the container's writable layer and dies on recreation, not
merely on restart.

renderSlopoConfig writes from a closed allow-list because slopo treats an
unknown config key as a hard error, and quotes every scalar because a path
can contain a colon or a leading dash. Verified against slopo 0.8.0 itself:
it reads the generated file back with no complaint and round-trips all 19
keys. The embedding API key is deliberately absent -- slopo reads
SLOPO_EMBEDDING_API_KEY from the environment ahead of the YAML value, so the
secret never lands on disk.
2026-09-22 06:06:44 +01:00
dsh-slopo agent 3490e5aba0 Scaffold the dsh-slopo bundle and port slopo's three agent skills
The three SKILL.md files are ports of the configs slopo 0.8.0 ships for
Claude Code, not new prose. What changed is the mechanics:

  * name + description frontmatter, both required by dsh
  * disable-model-invocation: true kept verbatim -- dsh has the same key,
    and slopo makes that choice for a reason (every one of these commands
    can spend minutes embedding)
  * allowed-tools / argument-hint / arguments dropped, not dsh keys
  * the !`slopo ...` shell-injection directive replaced with an
    instruction to call the slopo tool plus an explicit failure check,
    because dsh skill bodies do not execute commands

The guidance prose is quoted from slopo; NOTICE.md records the mapping and
the licence obligation that follows from it (AGPL-3.0-or-later, matching
upstream and the LICENSE file fetched from it).
2026-09-22 06:05:28 +01:00