@liustack/modlens 3.18.6 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/dist/main.js +61 -25
- package/docs/harness-setup.md +2 -2
- package/docs/harness-setup.zh-CN.md +2 -2
- package/docs/troubleshooting.md +2 -2
- package/docs/troubleshooting.zh-CN.md +2 -2
- package/dsh/index.js +71 -11
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +4 -4
- package/skills/modlens/references/runtime.md +1 -1
- package/skills/modlens/scripts/run.ps1 +1 -1
- package/skills/modlens/scripts/run.sh +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.19.0 - 2026-08-18
|
|
4
|
+
|
|
5
|
+
An adversarial review swept everything written since 3.17.0, three reviewers by area plus release-artifact checks. One crash, a config trap, and a set of lifecycle and Windows-lookup defects came out of it, all fixed here with tests that failed before the fix. One behaviour change is deliberate and marked breaking below.
|
|
6
|
+
|
|
7
|
+
- **openai: a `finish_reason: null` no longer crashes the diagnosis it was part of.** null is the streaming-chunk spelling of the field, and compat gateways that reuse one response model send it in non-streaming answers too. It fell past the length and stop checks into the quoted-reason branch, where redaction called `.split` on it, so both unusable-output paths surfaced a TypeError in place of the advice they exist to give. Anything that is not a string now reads as the field being absent. The non-JSON branch had carried this since 3.17.1.
|
|
8
|
+
- **config: `config set` folds and validates the provider name.** `config set OpenAI.apiKey` was saved verbatim, reported as saved, and never read, since reads look sections up by exact lowercase key; the environment quietly kept answering for the provider the user thought they had just configured, and the effective view showed two rows for one provider. The name is now folded the way `-p` folds it, aliases stay the storage key they always were, and a name no provider answers to is refused with the valid ones listed.
|
|
9
|
+
- **analyzer: an isolation that fails to set up removes its directory.** A copy failure between creating the throwaway workdir and returning it leaked one directory per failover attempt, since the run's cleanup only knows directories that were returned.
|
|
10
|
+
- **dsh: the pinned-upstream lifecycle gains the protections the discovery sweep already had.** A duplicate registration retried and logged on every topology event instead of being remembered as another holder's claim, and is now retried only once their route disappears. The wrapper's own drop emits a topology event mid-reconcile, which the re-entrancy guard used to swallow, so an upstream that unmounted and remounted in one breath stayed unregistered until an unrelated event; the guard now queues a rerun. A refresh whose replace throws after the host already committed kept disposing a healthy registration; the catch now asks the registry which side of the commit the failure landed on. And the sweep's availability set reads bare-string provider listings the way the pinned path always did.
|
|
11
|
+
- **Windows: the bare-node lookup is as picky as cmd, and shim-supplied targets stay on this machine.** A directory that happened to carry the name counted as the found executable, so spawn was handed a directory while cmd skipped it and ran the node one entry later. Quoted PATH entries were probed with their quotes on, which deleted them from the search: a different node ran, silently, or the shim was declined where cmd works. Both now read the way the Windows search reads them. A shim whose text supplies a plain-absolute target may no longer name UNC or device paths, and an execution line glued to `%*` is declined, since cmd concatenates there and a split argv would not be that spawn.
|
|
12
|
+
- **dsh (BREAKING): a pinned upstream's default provider id now encodes the upstream, `modlens-<upstream>`.** The flat default, `deepseek-modlens` whatever the upstream, was also the id auto-discovery mints for `deepseek-official`, so history recorded under a pinned foreign upstream was indistinguishable from DeepSeek history, and a later switch to auto-discovery could hand that route's private replay state to the DeepSeek adapter, the exact transfer the 3.17.2 boundary exists to prevent. It also meant pinned foreign setups never got that boundary's relabelling at all, so their reasoning continuity was silently dropped every turn. A pinned `deepseek-official` keeps its name, and an explicit `providerId` is honoured as before. If a session or default-model setting on a pinned foreign setup named `deepseek-modlens`, reselect the model once; those routes gain the reasoning continuity they were losing.
|
|
13
|
+
|
|
14
|
+
Known and recorded rather than fixed here: default-configuration pnpm shims carry a NODE_PATH preamble the recogniser does not know yet, so they still land on the spawn error 3.18.0 removed for npm; recognition for that template is the next piece of work.
|
|
15
|
+
|
|
3
16
|
## 3.18.6 - 2026-08-18
|
|
4
17
|
|
|
5
18
|
- **dsh: startup no longer logs a scary failure while waiting for a late upstream ([#66](https://github.com/liustack/modlens/issues/66)).** A pinned upstream that mounts after this plugin is ordinary startup order: llm-pi-ai, for one, mounts its providers once settings load. But the wrapper registered against the absence anyway, and that attempt cannot succeed, because dsh snapshots the retry policy synchronously during registration and the upstream lookup throws `NO_ADAPTER`. Every reconcile before the mount burned one doomed attempt and logged `vision provider registration skipped ... LlmError`, which reads fatal while the next topology event quietly healed it, so the plugin looked broken in exactly the logs people check when it matters. The reconcile now waits: nothing is attempted until the upstream is actually mounted, the first real registration snapshots the upstream's own retry policy rather than burning a placeholder round, and while waiting a single calm line names the upstream being waited on, so a mistyped upstream that never arrives still leaves a breadcrumb saying exactly what was expected. Thanks to @abyss-stars, whose report named the race, the synchronous snapshot inside registration, and the self-heal, and drew the boundary against #57 and #29 before anyone had to ask.
|
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
|
|
|
34
34
|
|
|
35
35
|
## Highlights
|
|
36
36
|
|
|
37
|
-
**🥇 The most capable vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.
|
|
37
|
+
**🥇 The most capable vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.19.0`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
|
|
38
38
|
|
|
39
39
|
Pasting an image works two ways. **① Just paste.** On a text-only model the pasted image lands as a private temp file and its path enters the composer — the same interaction OpenCode and Pi ship — and the `modlens_read_image` tool takes it from there. **② Pick a `(modlens vision)` entry** in the model selector (it remembers your choice, so once is enough), then paste: the thumbnail stays visible in your message, closer to the Codex app feel, and the image is converted to structured evidence at request time, answered by the same underlying route. The plugin auto-discovers every provider route carrying text-only DeepSeek or GLM models and adds a wrapped entry per route (a stock install gets **`DeepSeek-V4-Flash (modlens vision)`** and **`DeepSeek-V4-Pro (modlens vision)`**; extra routes like opencode-go or zai get their own); the two families' own vision models are excluded automatically. Which paste route applies is the host's per-model call: only a model its metadata positively confirms text-only is taken over, anything unconfirmed is left alone, so vision models keep their native paste ([details](docs/harness-setup.md)).
|
|
40
40
|
|
package/README.zh-CN.md
CHANGED
|
@@ -34,7 +34,7 @@ DeepSeek 和 GLM 的主力对话模型是纯文本的,无法进行图片识别
|
|
|
34
34
|
|
|
35
35
|
## 亮点
|
|
36
36
|
|
|
37
|
-
**🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.
|
|
37
|
+
**🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.19.0`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
|
|
38
38
|
|
|
39
39
|
DeepSeek Harness 粘贴识图有两种玩法。
|
|
40
40
|
|
|
@@ -69,7 +69,7 @@ agy # 浏览器完成
|
|
|
69
69
|
**DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
|
|
70
70
|
|
|
71
71
|
```sh
|
|
72
|
-
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.
|
|
72
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.19.0
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
|
package/dist/main.js
CHANGED
|
@@ -1432,6 +1432,9 @@ function requestShapeAdvice(settings) {
|
|
|
1432
1432
|
}
|
|
1433
1433
|
return "The gateway was asked to enforce the shape and answered with this anyway. Retry, or switch to gemini-api / anthropic for enforced schemas.";
|
|
1434
1434
|
}
|
|
1435
|
+
function stringOrAbsent(value) {
|
|
1436
|
+
return typeof value === "string" ? value : void 0;
|
|
1437
|
+
}
|
|
1435
1438
|
function unusableOutputAdvice(finishReason, settings, quoteReason, whenFinished) {
|
|
1436
1439
|
if (finishReason === "length") {
|
|
1437
1440
|
return `The answer was cut off (finish_reason=length), so this is a length limit rather than a shape problem. Raise it, e.g. modlens config set openai.extraBody '{"max_tokens":4096}'.`;
|
|
@@ -1511,7 +1514,7 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
|
|
|
1511
1514
|
const rawResult = extractJson(text);
|
|
1512
1515
|
if (rawResult === null) {
|
|
1513
1516
|
const advice = unusableOutputAdvice(
|
|
1514
|
-
payload.choices?.[0]?.finish_reason,
|
|
1517
|
+
stringOrAbsent(payload.choices?.[0]?.finish_reason),
|
|
1515
1518
|
options.settings,
|
|
1516
1519
|
(reason) => quote(reason, (clipped) => truncate(clipped, 80)),
|
|
1517
1520
|
"The answer ended normally but no complete JSON object could be read from it."
|
|
@@ -1524,7 +1527,7 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
|
|
|
1524
1527
|
const missing = missingSchemaFields(result);
|
|
1525
1528
|
if (missing.length > 0) {
|
|
1526
1529
|
const advice = unusableOutputAdvice(
|
|
1527
|
-
payload.choices?.[0]?.finish_reason,
|
|
1530
|
+
stringOrAbsent(payload.choices?.[0]?.finish_reason),
|
|
1528
1531
|
options.settings,
|
|
1529
1532
|
(reason) => quote(reason, (clipped) => truncate(clipped, 80)),
|
|
1530
1533
|
"The answer ended normally and parsed, but not into the contract."
|
|
@@ -1715,8 +1718,18 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
1715
1718
|
`Invalid config key: ${dottedKey}. Use "provider", "proxy", "reuse.<claude|codex|opencode|pi|grok>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|proxy|extraBody|structuredOutput>".`
|
|
1716
1719
|
);
|
|
1717
1720
|
}
|
|
1718
|
-
const
|
|
1721
|
+
const typedName = dottedKey.slice(0, dot);
|
|
1719
1722
|
const field = dottedKey.slice(dot + 1);
|
|
1723
|
+
const providerName = typedName.trim().toLowerCase();
|
|
1724
|
+
try {
|
|
1725
|
+
resolveProvider(providerName);
|
|
1726
|
+
} catch {
|
|
1727
|
+
throw new Error(
|
|
1728
|
+
`Unknown provider: ${typedName}. Use one of ${listProviders().join(", ")} (aliases like ${Object.keys(
|
|
1729
|
+
providerAliases()
|
|
1730
|
+
).filter((alias) => providerAliases()[alias] !== alias).slice(0, 4).join(", ")} work too).`
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1720
1733
|
if (field === "structuredOutput") {
|
|
1721
1734
|
if ((providerAliases()[providerName] ?? providerName) !== "openai") {
|
|
1722
1735
|
throw new Error(
|
|
@@ -2057,12 +2070,30 @@ function execFileSyncHidden(file, args, options) {
|
|
|
2057
2070
|
}
|
|
2058
2071
|
function existenceOf(target) {
|
|
2059
2072
|
try {
|
|
2060
|
-
fs.statSync(target);
|
|
2061
|
-
return "present";
|
|
2073
|
+
return fs.statSync(target).isDirectory() ? "directory" : "present";
|
|
2062
2074
|
} catch (error) {
|
|
2063
2075
|
return error.code === "ENOENT" ? "absent" : "unknown";
|
|
2064
2076
|
}
|
|
2065
2077
|
}
|
|
2078
|
+
function splitWindowsPath(pathValue) {
|
|
2079
|
+
const entries = [];
|
|
2080
|
+
let current = "";
|
|
2081
|
+
let inQuotes = false;
|
|
2082
|
+
for (const char of pathValue) {
|
|
2083
|
+
if (char === '"') {
|
|
2084
|
+
inQuotes = !inQuotes;
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
if (char === ";" && !inQuotes) {
|
|
2088
|
+
if (current) entries.push(current);
|
|
2089
|
+
current = "";
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
2092
|
+
current += char;
|
|
2093
|
+
}
|
|
2094
|
+
if (current) entries.push(current);
|
|
2095
|
+
return entries;
|
|
2096
|
+
}
|
|
2066
2097
|
const REAL_DEPS = {
|
|
2067
2098
|
platform: process.platform,
|
|
2068
2099
|
readFileSync: (p) => fs.readFileSync(p, "utf-8"),
|
|
@@ -2092,7 +2123,7 @@ function resolveLikeCmd(name, env, cwd, deps) {
|
|
|
2092
2123
|
const effectiveCwd = cwd ?? process.cwd();
|
|
2093
2124
|
const dirs = [
|
|
2094
2125
|
...skipCwd ? [] : [effectiveCwd],
|
|
2095
|
-
...pathValue
|
|
2126
|
+
...splitWindowsPath(pathValue).map((dir) => path.win32.resolve(effectiveCwd, dir))
|
|
2096
2127
|
];
|
|
2097
2128
|
for (const dir of dirs) {
|
|
2098
2129
|
for (const suffix of [...exts, ""]) {
|
|
@@ -2111,7 +2142,7 @@ function resolveLikeCmd(name, env, cwd, deps) {
|
|
|
2111
2142
|
function templatePath(text, shimDir) {
|
|
2112
2143
|
const rooted = /^%(?:dp0%|~dp0)\\?(.*)$/i.exec(text);
|
|
2113
2144
|
if (!rooted) {
|
|
2114
|
-
return
|
|
2145
|
+
return isFullyQualifiedLocalPath(text) && !text.includes("%") ? text : null;
|
|
2115
2146
|
}
|
|
2116
2147
|
const rest = rooted[1];
|
|
2117
2148
|
if (rest.includes("%") || rest === "") {
|
|
@@ -2145,7 +2176,7 @@ function nodeRecipe(shimDir, tail2, effective, env, cwd, deps) {
|
|
|
2145
2176
|
if (found === "unknown") {
|
|
2146
2177
|
return null;
|
|
2147
2178
|
}
|
|
2148
|
-
if (found === "present") {
|
|
2179
|
+
if (found === "present" || found === "directory") {
|
|
2149
2180
|
return {
|
|
2150
2181
|
command: local,
|
|
2151
2182
|
args: tail2,
|
|
@@ -2170,13 +2201,13 @@ const NPM_PROLOGUE = [
|
|
|
2170
2201
|
"CALL :find_dp0"
|
|
2171
2202
|
];
|
|
2172
2203
|
const NPM_PREFIX = "endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & ";
|
|
2173
|
-
const NPM_EXEC_LEGACY = /^"%_prog%"(.*)%\*$/;
|
|
2174
|
-
const NPM_EXEC_CURRENT = /^set PATHEXT=%PATHEXT:;\.([A-Z]+);=;% & "%_prog%"(.*)%\*$/;
|
|
2204
|
+
const NPM_EXEC_LEGACY = /^"%_prog%"(.*)\s%\*$/;
|
|
2205
|
+
const NPM_EXEC_CURRENT = /^set PATHEXT=%PATHEXT:;\.([A-Z]+);=;% & "%_prog%"(.*)\s%\*$/;
|
|
2175
2206
|
const NPM_NATIVE_EXEC = /^"([^"]*)"\s+%\*$/;
|
|
2176
2207
|
const PNPM_IF = /^@IF EXIST "([^"]*)" \($/;
|
|
2177
|
-
const PNPM_ARM = /^"([^"]*)"(.*)%\*$/;
|
|
2178
|
-
const PNPM_BARE_ARM = /^node(.*)%\*$/;
|
|
2179
|
-
const PNPM_ONELINE = /^@"([^"]*)"(.*)%\*$/;
|
|
2208
|
+
const PNPM_ARM = /^"([^"]*)"(.*)\s%\*$/;
|
|
2209
|
+
const PNPM_BARE_ARM = /^node(.*)\s%\*$/;
|
|
2210
|
+
const PNPM_ONELINE = /^@"([^"]*)"(.*)\s%\*$/;
|
|
2180
2211
|
function normalizeLines(content) {
|
|
2181
2212
|
const lines = content.split(/\r?\n/).map((line) => line.trim());
|
|
2182
2213
|
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
@@ -3204,7 +3235,7 @@ async function runProvider(provider, model, options, resolvedInput, timeoutMs, c
|
|
|
3204
3235
|
} else if (provider.buildInvocation && provider.parseOutput) {
|
|
3205
3236
|
const buildInvocation = provider.buildInvocation;
|
|
3206
3237
|
const parseOutput = provider.parseOutput;
|
|
3207
|
-
const isolation = !options.workdir && provider.isolateWorkdir ? resolvedInput.kind === "local" ? isolateImage(resolvedInput.source) : emptyWorkdir() : null;
|
|
3238
|
+
const isolation = !options.workdir && provider.isolateWorkdir ? resolvedInput.kind === "local" ? await isolateImage(resolvedInput.source) : emptyWorkdir() : null;
|
|
3208
3239
|
try {
|
|
3209
3240
|
const invocation = buildInvocation({
|
|
3210
3241
|
...providerOptions,
|
|
@@ -3272,16 +3303,21 @@ async function removeWorkdir(workdir) {
|
|
|
3272
3303
|
} catch {
|
|
3273
3304
|
}
|
|
3274
3305
|
}
|
|
3275
|
-
function isolateImage(source) {
|
|
3306
|
+
async function isolateImage(source) {
|
|
3276
3307
|
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3308
|
+
try {
|
|
3309
|
+
const imageSource = path.join(workdir, path.basename(source));
|
|
3310
|
+
fs.copyFileSync(source, imageSource);
|
|
3311
|
+
fs.chmodSync(imageSource, 384);
|
|
3312
|
+
return {
|
|
3313
|
+
imageSource,
|
|
3314
|
+
workdir,
|
|
3315
|
+
cleanup: () => removeWorkdir(workdir)
|
|
3316
|
+
};
|
|
3317
|
+
} catch (error) {
|
|
3318
|
+
await removeWorkdir(workdir);
|
|
3319
|
+
throw error;
|
|
3320
|
+
}
|
|
3285
3321
|
}
|
|
3286
3322
|
function emptyWorkdir() {
|
|
3287
3323
|
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
|
|
@@ -4598,7 +4634,7 @@ function parsePositiveInt(raw, flag) {
|
|
|
4598
4634
|
}
|
|
4599
4635
|
return Number.parseInt(raw, 10);
|
|
4600
4636
|
}
|
|
4601
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.
|
|
4637
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.19.0");
|
|
4602
4638
|
program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").option(
|
|
4603
4639
|
"--extra-body <json>",
|
|
4604
4640
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|
|
@@ -4708,7 +4744,7 @@ program.command("doctor").description(
|
|
|
4708
4744
|
configPath: CONFIG_PATH,
|
|
4709
4745
|
// Lets doctor name an installed skill copy that is older than
|
|
4710
4746
|
// the CLI reporting on it (issue #33).
|
|
4711
|
-
version: "3.
|
|
4747
|
+
version: "3.19.0"
|
|
4712
4748
|
});
|
|
4713
4749
|
const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
|
|
4714
4750
|
process.stdout.write(`${output}
|
package/docs/harness-setup.md
CHANGED
|
@@ -55,7 +55,7 @@ OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (
|
|
|
55
55
|
dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
|
|
56
56
|
|
|
57
57
|
```sh
|
|
58
|
-
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.
|
|
58
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.19.0
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
This registers a `modlens_read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves.
|
|
@@ -81,7 +81,7 @@ modlens ships often, and both install shapes freeze at whatever version they
|
|
|
81
81
|
got. On dsh, re-run the install with the version named:
|
|
82
82
|
|
|
83
83
|
```sh
|
|
84
|
-
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.
|
|
84
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.19.0
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
`npm view @liustack/modlens version` prints the current one, and this page is
|
|
@@ -55,7 +55,7 @@ OpenCode 接 DeepSeek:执行 `opencode auth login`,选择 DeepSeek 并粘贴
|
|
|
55
55
|
dsh 与其他 harness 不同:modlens 以原生工具的形式接入,而不是靠提示词触发的 skill。本包自身就是一个 dsh bundle,一条命令即可装进某个 profile:
|
|
56
56
|
|
|
57
57
|
```sh
|
|
58
|
-
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.
|
|
58
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.19.0
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
|
|
@@ -71,7 +71,7 @@ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在*
|
|
|
71
71
|
modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
|
|
72
72
|
|
|
73
73
|
```sh
|
|
74
|
-
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.
|
|
74
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.19.0
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
`npm view @liustack/modlens version` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
|
package/docs/troubleshooting.md
CHANGED
|
@@ -163,7 +163,7 @@ simply lands on an older one. Name the exact version instead, which pnpm treats
|
|
|
163
163
|
as a deliberate request rather than a resolution:
|
|
164
164
|
|
|
165
165
|
```sh
|
|
166
|
-
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.
|
|
166
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.19.0
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
`npm view @liustack/modlens version` prints the current one. pnpm 11 installs a named
|
|
@@ -178,7 +178,7 @@ file:
|
|
|
178
178
|
|
|
179
179
|
```yaml
|
|
180
180
|
minimumReleaseAgeExclude:
|
|
181
|
-
- '@liustack/modlens@3.
|
|
181
|
+
- '@liustack/modlens@3.19.0'
|
|
182
182
|
```
|
|
183
183
|
|
|
184
184
|
Or lift the gate for a single command, which lifts it for everything that
|
|
@@ -144,7 +144,7 @@ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存
|
|
|
144
144
|
`@latest` 绕不开这一层,本页早先的说法是错的。冷静期先把候选版本过滤掉,dist-tag 才在剩下的里面解析,于是它直接落到了更旧的那个上。改成写死精确版本号,pnpm 会把它当作一次明确的指定,而不是一次解析:
|
|
145
145
|
|
|
146
146
|
```sh
|
|
147
|
-
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.
|
|
147
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.19.0
|
|
148
148
|
```
|
|
149
149
|
|
|
150
150
|
`npm view @liustack/modlens version` 可以查到当前版本号。pnpm 11 会装上被点名的版本,11.1.3 起还会把它作为一条已批准的例外写进该 profile 的 `pnpm-workspace.yaml`,其余所有包和 modlens 以后的版本仍然留在窗口后面。
|
|
@@ -153,7 +153,7 @@ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.6
|
|
|
153
153
|
|
|
154
154
|
```yaml
|
|
155
155
|
minimumReleaseAgeExclude:
|
|
156
|
-
- '@liustack/modlens@3.
|
|
156
|
+
- '@liustack/modlens@3.19.0'
|
|
157
157
|
```
|
|
158
158
|
|
|
159
159
|
或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
|
package/dsh/index.js
CHANGED
|
@@ -674,11 +674,25 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
674
674
|
if (typeof current.registration === 'function') current.registration()
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
677
|
+
// Whether our synthetic route is currently in the registry, spelled the
|
|
678
|
+
// way reconcile spells its availability check. Used where an operation
|
|
679
|
+
// failed and what to do next depends on whether it failed before or after
|
|
680
|
+
// the host committed.
|
|
681
|
+
const routed = (providerId) => {
|
|
682
|
+
if (typeof ctx.llm.listProviders !== 'function') return false
|
|
683
|
+
try {
|
|
684
|
+
return ctx.llm.listProviders().some((info) => (typeof info === 'string' ? info : info?.id) === providerId)
|
|
685
|
+
} catch {
|
|
686
|
+
return false
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// commitRoutes mutates the registry and only then emits, so a listener
|
|
691
|
+
// throwing during that emit means the replace already SUCCEEDED, and
|
|
692
|
+
// treating the throw as failure would drop a healthy registration. dsh
|
|
693
|
+
// even ships such a listener (its llm invariant re-reads every policy on
|
|
694
|
+
// each update and fails loud), so the catch below asks the registry which
|
|
695
|
+
// side of the commit the failure landed on before deciding.
|
|
682
696
|
const refreshWrapper = (upstream, displayName) => {
|
|
683
697
|
const current = registrations.get(upstream)
|
|
684
698
|
if (!current || typeof current.registration?.replace !== 'function') return
|
|
@@ -700,6 +714,15 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
700
714
|
current.registration.replace([current.providerId])
|
|
701
715
|
} catch (error) {
|
|
702
716
|
current.state.displayName = previousName
|
|
717
|
+
if (routed(current.providerId)) {
|
|
718
|
+
// The route survived, so the throw came from after the commit (a
|
|
719
|
+
// listener), or the host kept the old snapshot. Either converges on
|
|
720
|
+
// the next refresh; disposing would not.
|
|
721
|
+
console.error(
|
|
722
|
+
`[modlens] vision provider refresh failed (${current.providerId}), keeping the existing registration: ${error}`,
|
|
723
|
+
)
|
|
724
|
+
return
|
|
725
|
+
}
|
|
703
726
|
dropWrapper(upstream, current)
|
|
704
727
|
console.error(`[modlens] vision provider refresh failed (${current.providerId}): ${error}`)
|
|
705
728
|
}
|
|
@@ -707,7 +730,17 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
707
730
|
|
|
708
731
|
if (config.upstream) {
|
|
709
732
|
const upstream = config.upstream
|
|
710
|
-
|
|
733
|
+
// The default id encodes its upstream, the same minting rule the sweep
|
|
734
|
+
// uses, because #49's relabelling trusts only ids that prove their
|
|
735
|
+
// upstream. The old flat default, deepseek-modlens for every pinned
|
|
736
|
+
// upstream, was also the id auto-discovery mints for deepseek-official,
|
|
737
|
+
// so history recorded under a pinned foreign upstream became
|
|
738
|
+
// indistinguishable from DeepSeek history, and switching to
|
|
739
|
+
// auto-discovery could hand that foreign replay state to the DeepSeek
|
|
740
|
+
// adapter. An explicit config.providerId is honoured as before, and a
|
|
741
|
+
// pinned deepseek-official keeps the name existing setups know.
|
|
742
|
+
const providerId =
|
|
743
|
+
config.providerId || (upstream === 'deepseek-official' ? 'deepseek-modlens' : `modlens-${upstream}`)
|
|
711
744
|
// Named after the route it actually wraps. This used to say DeepSeek
|
|
712
745
|
// whatever `upstream` was, so anyone pointing it at another route got a
|
|
713
746
|
// model group labelled for a provider they were not using. The refresh
|
|
@@ -723,9 +756,21 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
723
756
|
}
|
|
724
757
|
}
|
|
725
758
|
let reconciling = false
|
|
759
|
+
let rerunQueued = false
|
|
726
760
|
let waitingLogged = false
|
|
761
|
+
// The id is held by someone else (a second modlens install, most
|
|
762
|
+
// likely). Their registration answers the routing, so retrying ours on
|
|
763
|
+
// every topology event would only repeat the same log line; the claim is
|
|
764
|
+
// re-examined when the holder's route disappears.
|
|
765
|
+
let claimedElsewhere = false
|
|
727
766
|
const reconcile = () => {
|
|
728
|
-
if (reconciling)
|
|
767
|
+
if (reconciling) {
|
|
768
|
+
// dropWrapper's disposer makes the host emit adapters-updated while
|
|
769
|
+
// this very run is on the stack, and whatever that event announced
|
|
770
|
+
// (a quick remount, say) must not wait for an unrelated next event.
|
|
771
|
+
rerunQueued = true
|
|
772
|
+
return
|
|
773
|
+
}
|
|
729
774
|
reconciling = true
|
|
730
775
|
try {
|
|
731
776
|
const current = registrations.get(upstream)
|
|
@@ -733,6 +778,11 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
733
778
|
typeof ctx.llm.listProviders !== 'function' ||
|
|
734
779
|
ctx.llm.listProviders().some((info) => (typeof info === 'string' ? info : info?.id) === upstream)
|
|
735
780
|
if (!current) {
|
|
781
|
+
if (claimedElsewhere) {
|
|
782
|
+
if (routed(providerId)) return
|
|
783
|
+
// The holder released the id: it is ours to try again.
|
|
784
|
+
claimedElsewhere = false
|
|
785
|
+
}
|
|
736
786
|
if (!available) {
|
|
737
787
|
// A pinned upstream that mounts after this plugin is ordinary
|
|
738
788
|
// startup order (llm-pi-ai mounts its providers once settings
|
|
@@ -751,7 +801,11 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
751
801
|
return
|
|
752
802
|
}
|
|
753
803
|
waitingLogged = false
|
|
754
|
-
registerWrapper(upstream, providerId, `${upstreamName()} (modlens vision)`)
|
|
804
|
+
if (registerWrapper(upstream, providerId, `${upstreamName()} (modlens vision)`)) {
|
|
805
|
+
// True with nothing recorded is the duplicate branch: another
|
|
806
|
+
// holder already answers for this id.
|
|
807
|
+
claimedElsewhere = !registrations.has(upstream)
|
|
808
|
+
}
|
|
755
809
|
return
|
|
756
810
|
}
|
|
757
811
|
if (!available) {
|
|
@@ -761,6 +815,10 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
761
815
|
refreshWrapper(upstream, `${upstreamName()} (modlens vision)`)
|
|
762
816
|
} finally {
|
|
763
817
|
reconciling = false
|
|
818
|
+
if (rerunQueued) {
|
|
819
|
+
rerunQueued = false
|
|
820
|
+
reconcile()
|
|
821
|
+
}
|
|
764
822
|
}
|
|
765
823
|
}
|
|
766
824
|
reconcile()
|
|
@@ -795,16 +853,18 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
795
853
|
return
|
|
796
854
|
}
|
|
797
855
|
const providers = ctx.llm.listProviders()
|
|
798
|
-
|
|
856
|
+
// Same tolerance as the pinned path: an entry may be a bare id string.
|
|
857
|
+
const idOf = (info) => (typeof info === 'string' ? info : info?.id)
|
|
858
|
+
const available = new Set(providers.map(idOf).filter(Boolean))
|
|
799
859
|
for (const [upstream, current] of registrations) {
|
|
800
860
|
if (available.has(upstream)) continue
|
|
801
861
|
dropWrapper(upstream, current)
|
|
802
862
|
}
|
|
803
863
|
for (const info of providers) {
|
|
804
|
-
const id = info
|
|
864
|
+
const id = idOf(info)
|
|
805
865
|
if (!id || String(id).startsWith('modlens-')) continue
|
|
806
866
|
if (discover && !discover.has(id)) continue
|
|
807
|
-
const base = info.name ?? id
|
|
867
|
+
const base = (typeof info === 'string' ? undefined : info.name) ?? id
|
|
808
868
|
if (registrations.has(id)) {
|
|
809
869
|
refreshWrapper(id, `${base} (modlens vision)`)
|
|
810
870
|
continue
|
package/package.json
CHANGED
package/skills/modlens/SKILL.md
CHANGED
|
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
|
|
|
20
20
|
|
|
21
21
|
It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
|
|
22
22
|
|
|
23
|
-
If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.
|
|
23
|
+
If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.19.0):
|
|
24
24
|
|
|
25
|
-
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.
|
|
26
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.
|
|
27
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.
|
|
25
|
+
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.19.0: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.19.0 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.19.0 <args>`.
|
|
28
28
|
4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.19+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
|
|
29
29
|
|
|
30
30
|
`references/runtime.md` documents the pin and the diagnostic fields.
|
|
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
|
|
|
24
24
|
# package.json version, and the release script rewrites it on every bump.
|
|
25
25
|
$Package = '@liustack/modlens'
|
|
26
26
|
$Bin = 'modlens'
|
|
27
|
-
$Pinned = '3.
|
|
27
|
+
$Pinned = '3.19.0'
|
|
28
28
|
# -------------------------------------------------------------------------------
|
|
29
29
|
|
|
30
30
|
$NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
|
|
@@ -22,7 +22,7 @@ set -eu
|
|
|
22
22
|
# package.json version, and the release script rewrites it on every bump.
|
|
23
23
|
PKG="@liustack/modlens"
|
|
24
24
|
BIN="modlens"
|
|
25
|
-
PINNED="3.
|
|
25
|
+
PINNED="3.19.0"
|
|
26
26
|
# -------------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
|