Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e5238877 | ||
|
|
9ebd484e60 | ||
|
|
85c0d4dccd | ||
|
|
6beb4c0d39 |
No files matched your search
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Every release, newest first, generated from the commits in each release.
|
Every release, newest first, generated from the commits in each release.
|
||||||
|
|
||||||
|
## v0.1.1 — 2026-09-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- draw the API key once, and let status answer the dimensions question
|
||||||
|
- keep local home paths and addresses out of the test fixtures
|
||||||
|
|
||||||
|
|
||||||
## v0.1.0 — 2026-09-26
|
## v0.1.0 — 2026-09-26
|
||||||
|
|
||||||
- port the settings page and shell seam to dsh 0.1.7-rc.2
|
- port the settings page and shell seam to dsh 0.1.7-rc.2
|
||||||
|
|||||||
@@ -113,6 +113,43 @@ and the tool write the same place).
|
|||||||
| `embeddingRequestDelay` | Milliseconds between embedding requests, for rate-limited providers. |
|
| `embeddingRequestDelay` | Milliseconds between embedding requests, for rate-limited providers. |
|
||||||
| `timeoutMs` | Cooperative budget for a foreground run. |
|
| `timeoutMs` | Cooperative budget for a foreground run. |
|
||||||
|
|
||||||
|
### How to find `embeddingDimensions`
|
||||||
|
|
||||||
|
It is the width of the vector the model returns, and slopo **freezes it into the
|
||||||
|
database on the first index** — get it wrong and every later run refuses with
|
||||||
|
`Configuration mismatch` until you delete the index and re-embed. So ask the
|
||||||
|
endpoint rather than guessing from a model name.
|
||||||
|
|
||||||
|
**The tool will tell you.** `status` probes the endpoint whenever a model and a
|
||||||
|
base URL are set — even when dimensions is not — and reports the width it
|
||||||
|
observed:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "action": "status" }
|
||||||
|
// → Missing: embedding_dimensions.
|
||||||
|
// The endpoint is reachable and returned 768 dimensions, so set
|
||||||
|
// embeddingDimensions to 768.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or ask it directly**, which is the same request:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The width of the vector that comes back.
|
||||||
|
curl -s http://127.0.0.1:11434/v1/embeddings \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"model":"unclemusclez/jina-embeddings-v2-base-code:latest","input":["probe"]}' |
|
||||||
|
python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
||||||
|
|
||||||
|
# On Ollama, the model's own recorded width, with no inference at all.
|
||||||
|
curl -s http://127.0.0.1:11434/api/show \
|
||||||
|
-d '{"model":"unclemusclez/jina-embeddings-v2-base-code:latest"}' |
|
||||||
|
python3 -c 'import json,sys; print(json.load(sys.stdin)["model_info"]["jina-bert-v2.embedding_length"])'
|
||||||
|
```
|
||||||
|
|
||||||
|
Both must agree. If they do not, the endpoint is truncating or padding — for
|
||||||
|
example a server honouring a `dimensions` parameter — and it is the **returned**
|
||||||
|
width that slopo needs, because that is what lands in the database.
|
||||||
|
|
||||||
### Choose the provider prefix carefully
|
### Choose the provider prefix carefully
|
||||||
|
|
||||||
The provider prefix is not cosmetic — it selects which LiteLLM validator runs.
|
The provider prefix is not cosmetic — it selects which LiteLLM validator runs.
|
||||||
|
|||||||
@@ -299,6 +299,10 @@ window.__ModuleLoader__.load({
|
|||||||
const disabled = !state.writable
|
const disabled = !state.writable
|
||||||
const children = []
|
const children = []
|
||||||
for (const field of FIELDS) {
|
for (const field of FIELDS) {
|
||||||
|
// The secret is drawn once, by hand, below. Walking it here as well gave
|
||||||
|
// it a second control — an ordinary text input, echoing the key in
|
||||||
|
// clear — sitting beside the write-only one.
|
||||||
|
if (field.kind === 'secret') continue
|
||||||
const meta = describeField(t, field)
|
const meta = describeField(t, field)
|
||||||
const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
|
const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
|
||||||
children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
|
children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
|
||||||
|
|||||||
@@ -537,21 +537,43 @@ export function apply(ctx, config) {
|
|||||||
// A direct fetch rather than the shell seam: the probe is one small
|
// A direct fetch rather than the shell seam: the probe is one small
|
||||||
// HTTP request and does not belong behind a sandbox boundary that
|
// HTTP request and does not belong behind a sandbox boundary that
|
||||||
// exists to confine filesystem writes.
|
// exists to confine filesystem writes.
|
||||||
|
//
|
||||||
|
// It runs whenever the endpoint can be NAMED — a base URL and a model
|
||||||
|
// — not only once every required setting is present. Gating it on
|
||||||
|
// completeness hid it in the one state that needs it most: nobody can
|
||||||
|
// look up how wide an arbitrary embedding model is, and the probe is
|
||||||
|
// what reports the width it observed.
|
||||||
|
const apiBase = typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : ''
|
||||||
|
const model = String(slopoConfig.embedding_model ?? '')
|
||||||
const endpoint =
|
const endpoint =
|
||||||
missing.length === 0
|
apiBase.trim() !== '' && model.trim() !== ''
|
||||||
? await probeEmbeddingEndpoint({
|
? await probeEmbeddingEndpoint({
|
||||||
apiBase: typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '',
|
apiBase,
|
||||||
model: String(slopoConfig.embedding_model ?? ''),
|
model,
|
||||||
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
|
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
|
||||||
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
|
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
|
||||||
})
|
})
|
||||||
: { checked: false, skipped: 'nothing is configured yet' }
|
: {
|
||||||
|
checked: false,
|
||||||
|
skipped: 'set embeddingModel and embeddingApiBase to probe the endpoint and read its dimensions',
|
||||||
|
}
|
||||||
|
|
||||||
let message
|
let message
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
message =
|
message =
|
||||||
`Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
|
`Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
|
||||||
'to set embeddingModel and embeddingDimensions, then "prepare".'
|
'to set embeddingModel and embeddingDimensions, then "prepare".'
|
||||||
|
// The one thing a new user cannot look up anywhere: how wide this
|
||||||
|
// model's vectors are. Say it plainly when that is what is missing.
|
||||||
|
if (
|
||||||
|
endpoint.checked === true &&
|
||||||
|
typeof endpoint.dimensions === 'number' &&
|
||||||
|
missing.includes('embedding_dimensions')
|
||||||
|
) {
|
||||||
|
message +=
|
||||||
|
` The endpoint is reachable and returned ${endpoint.dimensions} dimensions, so ` +
|
||||||
|
`set embeddingDimensions to ${endpoint.dimensions}.`
|
||||||
|
}
|
||||||
} else if (endpoint.ok === false) {
|
} else if (endpoint.ok === false) {
|
||||||
message =
|
message =
|
||||||
`Configured, but the embedding backend failed its check: ${endpoint.error}. ` +
|
`Configured, but the embedding backend failed its check: ${endpoint.error}. ` +
|
||||||
|
|||||||
+6
-1
@@ -156,7 +156,12 @@ export async function probeEmbeddingEndpoint({
|
|||||||
// is not lossless JSON and fails the tool's output validation before the
|
// is not lossless JSON and fails the tool's output validation before the
|
||||||
// result is ever materialised.
|
// result is ever materialised.
|
||||||
if (actual !== expected) {
|
if (actual !== expected) {
|
||||||
result.error = `the model returns ${actual} dimensions but embeddingDimensions is ${expected}`
|
// "not set yet" and "set wrong" are different problems with different
|
||||||
|
// remedies, and `dimensions: actual` is the answer to the first one.
|
||||||
|
result.error =
|
||||||
|
expected > 0
|
||||||
|
? `the model returns ${actual} dimensions but embeddingDimensions is ${expected}`
|
||||||
|
: `the model returns ${actual} dimensions and embeddingDimensions is not set`
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dsh-slopo",
|
"name": "dsh-slopo",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Slopo as a DeepSeek Harness bundle — embedding-based duplicate-code review, exposed as three user-triggered skills, one config-aware tool, and a settings card.",
|
"description": "Slopo as a DeepSeek Harness bundle — embedding-based duplicate-code review, exposed as three user-triggered skills, one config-aware tool, and a settings card.",
|
||||||
|
|||||||
+31
-2
@@ -31,11 +31,18 @@ import assert from 'node:assert/strict'
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
|
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
|
||||||
|
|
||||||
/** Where a global dsh installation keeps its packages, if there is one. */
|
/**
|
||||||
const DSH_PACKAGES = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai'
|
* The installed harness's packages, reached through this repository's own
|
||||||
|
* `node_modules/@deepseek-ai` symlink rather than a committed absolute path —
|
||||||
|
* the link is what makes the `@deepseek-ai/*` imports resolve during tests in
|
||||||
|
* the first place, so it is the one address that is already relative to the
|
||||||
|
* checkout.
|
||||||
|
*/
|
||||||
|
const DSH_PACKAGES = fileURLToPath(new URL('../node_modules/@deepseek-ai/', import.meta.url))
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// The two doubles
|
// The two doubles
|
||||||
@@ -625,6 +632,28 @@ test('the API key is a write-only password control that reports only whether one
|
|||||||
assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /A key is stored/)
|
assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /A key is stored/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the API key has exactly one control, and it is never a plain text field', () => {
|
||||||
|
// The regression: the render loop walked every entry in FIELDS, so the secret
|
||||||
|
// drew a second control — an ordinary text input, echoing the key in clear —
|
||||||
|
// beside the write-only password control. One key, two boxes, one of them
|
||||||
|
// visible.
|
||||||
|
const { render } = registered()
|
||||||
|
const tree = render()
|
||||||
|
const labelled = findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === 'Embedding API key')
|
||||||
|
assert.equal(labelled.length, 1, 'the key must have exactly one control')
|
||||||
|
assert.equal(labelled[0].props.type, 'password')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the card draws one control per schema field, and no more', () => {
|
||||||
|
// A count, not a list of names: the label assertions above pass just as
|
||||||
|
// happily with a duplicate row sitting beside the real one.
|
||||||
|
const { render } = registered()
|
||||||
|
const controls = findAll(render(), (node) => node.type === 'input')
|
||||||
|
assert.equal(controls.length, 15, 'the schema declares 15 fields, so each gets one control')
|
||||||
|
const labels = controls.map((node) => node.props['aria-label'])
|
||||||
|
assert.equal(new Set(labels).size, labels.length, `duplicate controls: ${labels.join(', ')}`)
|
||||||
|
})
|
||||||
|
|
||||||
test('a deployment with no key says so', () => {
|
test('a deployment with no key says so', () => {
|
||||||
const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: false }] })
|
const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: false }] })
|
||||||
const tree = render()
|
const tree = render()
|
||||||
|
|||||||
+36
-3
@@ -477,7 +477,11 @@ test(
|
|||||||
sourceDir: join(repo, 'src'),
|
sourceDir: join(repo, 'src'),
|
||||||
embeddingModel: 'ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
|
embeddingModel: 'ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
|
||||||
embeddingDimensions: 768,
|
embeddingDimensions: 768,
|
||||||
embeddingApiBase: 'http://10.50.1.2:11434',
|
// Where the real embedding backend lives is a property of the machine
|
||||||
|
// running the E2E suite, not of this repository, so it is read from the
|
||||||
|
// environment rather than committed. The default is Ollama's own
|
||||||
|
// loopback address.
|
||||||
|
embeddingApiBase: process.env.DSH_SLOPO_E2E_API_BASE ?? 'http://127.0.0.1:11434',
|
||||||
})
|
})
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
|
|
||||||
@@ -637,8 +641,37 @@ test('status returns lossless JSON when the endpoint probe SUCCEEDS', async () =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('status returns lossless JSON when the endpoint probe FAILS', async () => {
|
test('status asks the endpoint how wide the model is when embeddingDimensions is unset', async () => {
|
||||||
const { state } = harness({
|
// The question a new user cannot answer from anywhere else. The probe used to
|
||||||
|
// be gated on every required setting being present, which meant it was
|
||||||
|
// unavailable in exactly the state that needs it — so `status` said "Missing:
|
||||||
|
// embedding_dimensions" and left you to guess.
|
||||||
|
const { createServer } = await import('node:http')
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' })
|
||||||
|
response.end(JSON.stringify({ data: [{ embedding: new Array(1024).fill(0.01) }] }))
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const { port } = server.address()
|
||||||
|
try {
|
||||||
|
const { state } = harness({
|
||||||
|
embeddingModel: 'ollama/example:latest',
|
||||||
|
embeddingDimensions: 0,
|
||||||
|
embeddingApiBase: `http://127.0.0.1:${port}`,
|
||||||
|
})
|
||||||
|
const value = await state.tools.get('slopo').execute({ action: 'status' }, execContext('/tmp'))
|
||||||
|
assertLossless('status (dimensions unset)', value)
|
||||||
|
assert.deepEqual(value.details.missingSettings, ['embedding_dimensions'])
|
||||||
|
assert.equal(value.details.embeddingEndpoint.checked, true, 'the probe must still run')
|
||||||
|
assert.equal(value.details.embeddingEndpoint.dimensions, 1024, 'the observed width is the answer')
|
||||||
|
assert.match(value.message, /returned 1024 dimensions/)
|
||||||
|
assert.match(value.message, /set embeddingDimensions to 1024/)
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('status returns lossless JSON when the endpoint probe FAILS', async () => { const { state } = harness({
|
||||||
embeddingModel: 'ollama/example:latest',
|
embeddingModel: 'ollama/example:latest',
|
||||||
embeddingDimensions: 768,
|
embeddingDimensions: 768,
|
||||||
// Nothing is listening on this port, so the fetch rejects.
|
// Nothing is listening on this port, so the fetch rejects.
|
||||||
|
|||||||
@@ -73,6 +73,22 @@ test('probeEmbeddingEndpoint catches a dimension mismatch, which is what slopo f
|
|||||||
assert.match(result.error, /returns 768 dimensions but embeddingDimensions is 1024/)
|
assert.match(result.error, /returns 768 dimensions but embeddingDimensions is 1024/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('probeEmbeddingEndpoint reports the width when dimensions is not set yet', async () => {
|
||||||
|
// This is the case a new user is in, and the reason the probe runs before
|
||||||
|
// every required setting exists: nobody can look up how wide an arbitrary
|
||||||
|
// model is, so `dimensions` in the result IS the answer.
|
||||||
|
const result = await probeEmbeddingEndpoint({
|
||||||
|
apiBase: 'http://host:11434',
|
||||||
|
model: 'ollama/m:latest',
|
||||||
|
dimensions: 0,
|
||||||
|
fetchImpl: fakeFetch({ body: { data: [{ embedding: new Array(768).fill(0) }] } }),
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, false, 'unset is not a match')
|
||||||
|
assert.equal(result.dimensions, 768, 'the observed width is reported regardless')
|
||||||
|
assert.equal(result.expected, 0)
|
||||||
|
assert.match(result.error, /returns 768 dimensions and embeddingDimensions is not set/)
|
||||||
|
})
|
||||||
|
|
||||||
test('probeEmbeddingEndpoint reports an HTTP error instead of throwing', async () => {
|
test('probeEmbeddingEndpoint reports an HTTP error instead of throwing', async () => {
|
||||||
const result = await probeEmbeddingEndpoint({
|
const result = await probeEmbeddingEndpoint({
|
||||||
apiBase: 'http://host:11434',
|
apiBase: 'http://host:11434',
|
||||||
|
|||||||
+33
-24
@@ -17,7 +17,7 @@ import {
|
|||||||
slopoCandidates,
|
slopoCandidates,
|
||||||
uvInstallArgs,
|
uvInstallArgs,
|
||||||
} from '../lib/slopo.js'
|
} from '../lib/slopo.js'
|
||||||
import { managedSlopoPath, resolveDshHome, workspaceKey, workspaceStateDir } from '../lib/paths.js'
|
import { managedSlopoPath, resolveDshHome, userLocalSlopoPath, workspaceKey, workspaceStateDir } from '../lib/paths.js'
|
||||||
|
|
||||||
test('findSlopo prefers the managed install over PATH', () => {
|
test('findSlopo prefers the managed install over PATH', () => {
|
||||||
const home = '/dsh'
|
const home = '/dsh'
|
||||||
@@ -152,26 +152,35 @@ test('workspace state lives under the harness home, not next to the code', () =>
|
|||||||
assert.match(workspaceStateDir('/dsh', '/repo'), /^\/dsh\/dsh-slopo\/workspaces\/repo-[0-9a-f]{8}$/)
|
assert.match(workspaceStateDir('/dsh', '/repo'), /^\/dsh\/dsh-slopo\/workspaces\/repo-[0-9a-f]{8}$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the generated config is accepted by the real slopo', { skip: !existsSync(managedSlopoPath('/root/.dsh')) && !existsSync('/root/.local/bin/slopo') }, () => {
|
test(
|
||||||
const binary = findSlopo('/root/.dsh')
|
'the generated config is accepted by the real slopo',
|
||||||
assert.ok(binary, 'expected a slopo binary for this test')
|
{
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-slopo-conf-'))
|
// Discovery resolves from the RUNNING user's home, not from a committed
|
||||||
const configPath = join(dir, 'slopo.conf.yaml')
|
// absolute one: a hardcoded home directory only ever matched one machine,
|
||||||
writeFileSync(
|
// and it published that machine's layout to everyone else.
|
||||||
configPath,
|
skip: !existsSync(managedSlopoPath(resolveDshHome())) && !existsSync(userLocalSlopoPath()),
|
||||||
renderSlopoConfig({
|
},
|
||||||
source_dir: dir,
|
() => {
|
||||||
db_file: join(dir, 'slopo.db'),
|
const binary = findSlopo(resolveDshHome())
|
||||||
ignore_file: join(dir, 'slopo.ignore.txt'),
|
assert.ok(binary, 'expected a slopo binary for this test')
|
||||||
agent_log_file: join(dir, 'slopo.agent.log'),
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-slopo-conf-'))
|
||||||
embedding_model: 'ollama/example:latest',
|
const configPath = join(dir, 'slopo.conf.yaml')
|
||||||
embedding_dimensions: 768,
|
writeFileSync(
|
||||||
embedding_params: { api_base: 'http://127.0.0.1:11434' },
|
configPath,
|
||||||
source_dir_exclude: ['**/test/**'],
|
renderSlopoConfig({
|
||||||
}),
|
source_dir: dir,
|
||||||
)
|
db_file: join(dir, 'slopo.db'),
|
||||||
const out = execFileSync(binary, ['--config', configPath, 'show-config'], { encoding: 'utf8' })
|
ignore_file: join(dir, 'slopo.ignore.txt'),
|
||||||
assert.match(out, /embedding_model: ollama\/example:latest/)
|
agent_log_file: join(dir, 'slopo.agent.log'),
|
||||||
assert.match(out, /embedding_dimensions: 768/)
|
embedding_model: 'ollama/example:latest',
|
||||||
assert.match(out, /api_base: http:\/\/127\.0\.0\.1:11434/)
|
embedding_dimensions: 768,
|
||||||
})
|
embedding_params: { api_base: 'http://127.0.0.1:11434' },
|
||||||
|
source_dir_exclude: ['**/test/**'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const out = execFileSync(binary, ['--config', configPath, 'show-config'], { encoding: 'utf8' })
|
||||||
|
assert.match(out, /embedding_model: ollama\/example:latest/)
|
||||||
|
assert.match(out, /embedding_dimensions: 768/)
|
||||||
|
assert.match(out, /api_base: http:\/\/127\.0\.0\.1:11434/)
|
||||||
|
},
|
||||||
|
)
|
||||||
Reference in new issue
Block a user