@liustack/modlens 3.16.1 → 3.16.3
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 +11 -0
- package/README.md +4 -4
- package/README.zh-CN.md +3 -3
- package/dist/main.js +304 -26
- package/docs/harness-setup.md +2 -2
- package/docs/harness-setup.zh-CN.md +2 -2
- package/docs/output-schema.md +4 -2
- package/docs/output-schema.zh-CN.md +4 -2
- package/docs/troubleshooting.md +25 -2
- package/docs/troubleshooting.zh-CN.md +14 -2
- package/dsh/client.js +1 -1
- package/dsh/index.js +31 -28
- package/dsh/vision-schema.json +1 -1
- 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,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.16.3 - 2026-08-15
|
|
4
|
+
|
|
5
|
+
- **The dsh tool is registered under a name of its own, so the model can actually see it ([#34](https://github.com/liustack/modlens/issues/34)).** A host with a durable attachment store mounts its own `read_image`, and 3.13.0 handled that by catching the duplicate-registration error and renaming. That catch never fired. dsh's tool registry is layered and a scoped tool shadows a global one, so a host `read_image` in the agent-preset scope and ours registered globally are not a duplicate at all: the registration succeeded, nothing was logged, and the model kept resolving the host tool, which refuses a text-only model outright. Nor is the collision cheap to detect, so the tool now takes the name `modlens_read_image` instead of competing for `read_image`, and the model finds it through the schema that reaches it every request regardless of the name. `toolName` still pins whatever a host prefers. Thanks to @ingleav626-art, who instrumented the registry and brought back the layer that made the old fix dead code.
|
|
6
|
+
- **A region kind outside the common list no longer fails the read ([#34](https://github.com/liustack/modlens/issues/34)).** `layout.regions[].type` was a closed enum, so a model answering `link` on any web screenshot or `search` on a portal produced a result the schema rejected, losing the entire read over a descriptive label. Region kinds are an open set: the same reporter found that extending the list to 13 values still was not enough. The field is a free string now, and the common vocabulary moved into its schema description, where it guides every provider that enforces the schema server-side without constraining any of them.
|
|
7
|
+
|
|
8
|
+
## 3.16.2 - 2026-08-14
|
|
9
|
+
|
|
10
|
+
- **The claude-cli provider starts on Windows ([#31](https://github.com/liustack/modlens/issues/31)).** npm installs every JS CLI as a trio, and both ways of reaching it failed: the bare name found none of them (ENOENT, reported as "not installed" next to a `claude --version` that worked fine), and handing spawn the `.cmd` hit Node's post-CVE refusal to run batch files without a shell (EINVAL). Wrapping it in `cmd.exe` turned out to be a trap, since a cmd command line cannot carry a raw newline and our provider arguments are whole multi-line vision prompts, so a wrapped prompt truncates at its first line break and the rest is read as a second command. modlens instead reads the shim, takes the Node entry it points at, and spawns Node on it directly: no shell, no escaping, and the child is the real provider, so a timeout's SIGTERM lands on it rather than an intermediate. The reading is conservative by construction. Every line of the shim must be accounted for, the interpreter is read rather than inferred (cmd-shim will happily generate a python shim for a file named `.js`), and a shim carrying anything this cannot reproduce faithfully, an environment assignment, cmd control syntax, an argument whose quoting it cannot prove, is declined and left to a spawn error that names the provider, the command, and the real error code. Nine rounds of independent review shaped that boundary, each round adding a shape it had to refuse rather than guess at. Thanks to @zhang66633 for a report that arrived with both failure modes already isolated.
|
|
11
|
+
- **The install procedure knows about dsh ([#32](https://github.com/liustack/modlens/issues/32)).** INSTALL.md, the file an agent is pointed at, never mentioned dsh once, so an agent told to install modlens followed the skill procedure to its end and left the user without the `read_image` tool and without the `(modlens vision)` entries they were looking for. It now opens with a dsh branch: install the plugin, skip the skill copy, keep the same engine configuration.
|
|
12
|
+
- **`doctor` says when an installed skill copy has fallen behind ([#33](https://github.com/liustack/modlens/issues/33)).** A skill is installed by copying it, and a copy keeps the version it was stamped with, so one machine ran 3.8.0 for eight releases and hit bugs that were long fixed. doctor now reads the pin out of every installed copy it can find and compares it against the version of the CLI reporting, which differ exactly when it matters, since the dsh plugin and an `@latest` run are both current. Still offline: two local file reads, no registry call. Thanks to @Ztyss for a report that had already verified the release stamping, leaving the update story as the real gap.
|
|
13
|
+
|
|
3
14
|
## 3.16.1 - 2026-08-14
|
|
4
15
|
|
|
5
16
|
- **OpenChamber (OpenCode's desktop UI) is detected, and Windows detection got quieter and sharper ([#30](https://github.com/liustack/modlens/issues/30)).** Three stacked Windows gaps from one runtime-confirmed report. The env-fingerprint fallback never checked the markers opencode servers inject (`OPENCODE`, `OPENCODE_PID`, `OPENCODE_BINARY`), so OpenChamber read as "none detected" and `recover-paste` never auto-ran, while the pasted bytes sat recoverable in the opencode database the whole time; the fingerprint now resolves to `opencode`, placed before Claude Code's so nested setups pick the innermost input box. The `ps` ancestry probe now runs only off Windows: MSYS machines carry a `ps` that exists but rejects `-Ao`, and a failed child's stderr printed into every `doctor` run (the docs always said Windows skips ancestry, now the code agrees). And `findOnPath` tries the PATHEXT extensions before the bare name, so the POSIX `sh` shim npm installs next to `opencode.cmd` no longer shadows the executable into a `spawnSync ENOENT`. Thanks to @IA20201 for a report with the evidence already attached: observed env markers, doctor output, and the exact database row.
|
package/README.md
CHANGED
|
@@ -34,9 +34,9 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
|
|
|
34
34
|
|
|
35
35
|
## Highlights
|
|
36
36
|
|
|
37
|
-
**🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest`, and the text-only DeepSeek model behind dsh reads images through a native `
|
|
37
|
+
**🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. If dsh warns `declares no dsh.bundle`, pnpm's release-age gate installed an old version: see [troubleshooting](docs/troubleshooting.md#dsh-says-declares-no-dshbundle--installed-as-a-plain-dependency) for the one-line fix.
|
|
38
38
|
|
|
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 `
|
|
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
|
|
|
41
41
|
**Paste an image and it reads it.** No saving to a file and passing a path first.
|
|
42
42
|
|
|
@@ -159,10 +159,10 @@ ModLens does not accept pull requests. The project is maintained by a single aut
|
|
|
159
159
|
This project runs on LIUSTACK Skills: `shaping` before you build, `coding` while you build, `dig` when it breaks, `snapshot` when you hand off. Lighter than Superpowers, and stronger.
|
|
160
160
|
|
|
161
161
|
```bash
|
|
162
|
-
npx -y skills add liustack/
|
|
162
|
+
npx -y skills add liustack/vibemaster -g
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
⭐ If it helps, star [ModLens](https://github.com/liustack/modlens) and [
|
|
165
|
+
⭐ If it helps, star [ModLens](https://github.com/liustack/modlens) and [VibeMaster](https://github.com/liustack/vibemaster). Stars are how the next developer finds them.
|
|
166
166
|
|
|
167
167
|
## Star History
|
|
168
168
|
|
package/README.zh-CN.md
CHANGED
|
@@ -34,11 +34,11 @@ DeepSeek 和 GLM 的主力对话模型是纯文本的,无法进行图片识别
|
|
|
34
34
|
|
|
35
35
|
## 亮点
|
|
36
36
|
|
|
37
|
-
**🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `
|
|
37
|
+
**🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。如果 dsh 提示 `declares no dsh.bundle`,是 pnpm 的发布冷静期装了旧版,一行命令可解,见[故障排查](docs/troubleshooting.zh-CN.md#dsh-提示-declares-no-dshbundle--installed-as-a-plain-dependency)。
|
|
38
38
|
|
|
39
39
|
DeepSeek Harness 粘贴识图有两种玩法。
|
|
40
40
|
|
|
41
|
-
**① 直接粘贴** 贴进来的图片自主转换成文件路径进输入框(与 OpenCode、Pi 同款交互),`
|
|
41
|
+
**① 直接粘贴** 贴进来的图片自主转换成文件路径进输入框(与 OpenCode、Pi 同款交互),`modlens_read_image` 工具接手读图。
|
|
42
42
|
|
|
43
43
|
**② 切到带 `(modlens vision)` 后缀的模型变体**(选择器有记忆,选一次就行)再粘贴:缩略图直接可见、所见即所得,体验更接近 Codex App。变体由插件自动发现生成:每条承载纯文本 DeepSeek 或 GLM 模型的 provider 路由各得一组包装条目(默认安装下就是 **`DeepSeek-V4-Flash (modlens vision)`** 和 **`DeepSeek-V4-Pro (modlens vision)`**,装了 opencode-go、zai 等额外路由的机器会各自多出一组),两家自己的视觉型号自动排除。走哪条通路由 host 依据真实模型元数据逐个裁决:只有被元数据确认纯文本的模型才会被接管,确认不了的一律不动,视觉模型因此保留原生贴图([细节](docs/harness-setup.zh-CN.md))。
|
|
44
44
|
|
|
@@ -72,7 +72,7 @@ agy # 浏览器完成
|
|
|
72
72
|
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
装完即有 `
|
|
75
|
+
装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
|
|
76
76
|
|
|
77
77
|
## 用法
|
|
78
78
|
|
package/dist/main.js
CHANGED
|
@@ -501,7 +501,7 @@ async function apiFetch(url, init, proxy, env = process.env) {
|
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
503
|
const JSON_TEMPLATE_INSTRUCTION = `Respond with ONE JSON object only, no markdown fences, no commentary. Fill this exact structure with your findings from the image (do not repeat this template literally, replace every value):
|
|
504
|
-
{"summary":"one paragraph describing the image","ocr":{"full_text":"all visible text","lines":[{"text":"one line","language":"en"}]},"layout":{"regions":[{"type":"title
|
|
504
|
+
{"summary":"one paragraph describing the image","ocr":{"full_text":"all visible text","lines":[{"text":"one line","language":"en"}]},"layout":{"regions":[{"type":"a short kind, e.g. title, heading, paragraph, list, table, chart, form, code, image, icon, link, nav, button, search, or any other short label that fits better","reading_order":1,"text":"region text"}]},"semantics":{"scene":"what kind of scene","intent":"what the image is for","entities":[{"name":"entity","type":"kind","evidence":"where seen"}],"relations":[{"subject":"a","predicate":"relates to","object":"b"}]},"visual":{"dominant_colors":["color"],"style":"visual style","notes":["notable visual detail"]},"uncertainty":["anything unreadable or ambiguous"]}`;
|
|
505
505
|
function buildVisionPrompt(options) {
|
|
506
506
|
const readInstruction = options.imageKind === "inline" ? "Analyze the image attached to this message." : options.imageKind === "remote" ? `Fetch the image at this URL and analyze it: ${options.imageSource}` : `Read the image file at this path and analyze it: ${options.imageSource}`;
|
|
507
507
|
const basePrompt = `${readInstruction}
|
|
@@ -553,21 +553,18 @@ const VISION_RESULT_SCHEMA = {
|
|
|
553
553
|
items: {
|
|
554
554
|
type: "object",
|
|
555
555
|
properties: {
|
|
556
|
+
// Deliberately not an enum. Region kinds are an
|
|
557
|
+
// open set: a closed list rejected `link` on any
|
|
558
|
+
// web screenshot and `search` on a portal, and a
|
|
559
|
+
// rejected result fails the whole read over a
|
|
560
|
+
// descriptive label (issue #34). The common
|
|
561
|
+
// vocabulary moves into the description, which
|
|
562
|
+
// guides without constraining and rides along to
|
|
563
|
+
// every provider that enforces this schema
|
|
564
|
+
// server-side.
|
|
556
565
|
type: {
|
|
557
566
|
type: "string",
|
|
558
|
-
|
|
559
|
-
"title",
|
|
560
|
-
"subtitle",
|
|
561
|
-
"paragraph",
|
|
562
|
-
"list",
|
|
563
|
-
"table",
|
|
564
|
-
"chart",
|
|
565
|
-
"form",
|
|
566
|
-
"code",
|
|
567
|
-
"image",
|
|
568
|
-
"icon",
|
|
569
|
-
"other"
|
|
570
|
-
]
|
|
567
|
+
description: "A short kind for this region. Prefer a common one where it fits: title, heading, paragraph, list, table, chart, form, code, image, icon, link, nav, button, search. Any other short label is fine when none of those describe it."
|
|
571
568
|
},
|
|
572
569
|
reading_order: { type: "number" },
|
|
573
570
|
text: { type: "string" }
|
|
@@ -1687,6 +1684,215 @@ function providerChain(kind, config2, env = process.env) {
|
|
|
1687
1684
|
}
|
|
1688
1685
|
return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
|
|
1689
1686
|
}
|
|
1687
|
+
function tokenizeCmdLine(line) {
|
|
1688
|
+
const pattern = /(?:"[^"]*"|[^\s"])+/g;
|
|
1689
|
+
const args = line.match(pattern) ?? [];
|
|
1690
|
+
if (line.replace(pattern, "").trim() !== "") {
|
|
1691
|
+
return null;
|
|
1692
|
+
}
|
|
1693
|
+
return args.map((raw, index) => {
|
|
1694
|
+
const text = index === 0 ? raw.replace(/^@/, "") : raw;
|
|
1695
|
+
const whole = /^"([^"]*)"$/.exec(text);
|
|
1696
|
+
return whole ? { value: whole[1], quoted: true } : { value: text, quoted: false };
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
function expandShimPath(token, shimDir) {
|
|
1700
|
+
const relative = /^%~?dp0%?\\?(.*)$/i.exec(token);
|
|
1701
|
+
if (relative) {
|
|
1702
|
+
return path.win32.join(shimDir, relative[1]);
|
|
1703
|
+
}
|
|
1704
|
+
if (path.win32.isAbsolute(token)) {
|
|
1705
|
+
return token;
|
|
1706
|
+
}
|
|
1707
|
+
return null;
|
|
1708
|
+
}
|
|
1709
|
+
const CMD_SYNTAX = /"|%~?\d/;
|
|
1710
|
+
const CMD_CONTROL = /[\^&|<>()]/;
|
|
1711
|
+
function literalToken(token, shimDir) {
|
|
1712
|
+
const text = token.value;
|
|
1713
|
+
if (CMD_SYNTAX.test(text) || !token.quoted && CMD_CONTROL.test(text)) {
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
const substituted = text.replace(/%dp0%|%~dp0/gi, `${shimDir}\\`);
|
|
1717
|
+
if (substituted.includes("%")) {
|
|
1718
|
+
return null;
|
|
1719
|
+
}
|
|
1720
|
+
return /^(%dp0%|%~dp0)/i.test(text) ? path.win32.normalize(substituted) : substituted;
|
|
1721
|
+
}
|
|
1722
|
+
function isNodeInterpreter(token) {
|
|
1723
|
+
return /^node(\.exe)?$/i.test(path.win32.basename(token));
|
|
1724
|
+
}
|
|
1725
|
+
function carriesForeignEnv(content) {
|
|
1726
|
+
const setRe = /^\s*@?SET\s+"?([A-Za-z_][A-Za-z0-9_]*)=/gim;
|
|
1727
|
+
let match;
|
|
1728
|
+
while ((match = setRe.exec(content)) !== null) {
|
|
1729
|
+
const name = match[1].toLowerCase();
|
|
1730
|
+
if (name !== "dp0" && name !== "_prog" && name !== "pathext") {
|
|
1731
|
+
return true;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
function progIsNode(content, shimDir) {
|
|
1737
|
+
const progRe = /^\s*@?SET\s+"?_prog=([^"\r\n]*)"?/gim;
|
|
1738
|
+
const values = [];
|
|
1739
|
+
let match;
|
|
1740
|
+
while ((match = progRe.exec(content)) !== null) {
|
|
1741
|
+
values.push(match[1].trim());
|
|
1742
|
+
}
|
|
1743
|
+
if (values.length === 0) {
|
|
1744
|
+
return { ok: false };
|
|
1745
|
+
}
|
|
1746
|
+
let absolute;
|
|
1747
|
+
for (const value of values) {
|
|
1748
|
+
const expanded = expandShimPath(value, shimDir) ?? value;
|
|
1749
|
+
if (!isNodeInterpreter(expanded)) {
|
|
1750
|
+
return { ok: false };
|
|
1751
|
+
}
|
|
1752
|
+
if (path.win32.isAbsolute(value)) {
|
|
1753
|
+
absolute = value;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return { ok: true, ...absolute ? { absolute } : {} };
|
|
1757
|
+
}
|
|
1758
|
+
const STRUCTURAL_LINE = [
|
|
1759
|
+
/^\s*$/,
|
|
1760
|
+
/^\s*@?ECHO\s+off\s*$/i,
|
|
1761
|
+
/^\s*@?SETLOCAL\s*$/i,
|
|
1762
|
+
/^\s*@?ENDLOCAL\s*$/i,
|
|
1763
|
+
/^\s*GOTO\s+\S+\s*$/i,
|
|
1764
|
+
/^\s*:\S+\s*$/,
|
|
1765
|
+
/^\s*EXIT\s+\/b\s*$/i,
|
|
1766
|
+
/^\s*CALL\s+:\S+\s*$/i,
|
|
1767
|
+
/^\s*@?SET\s+dp0=%~dp0\s*$/i,
|
|
1768
|
+
/^\s*@?SET\s+"?_prog=[^"\r\n]*"?\s*$/i,
|
|
1769
|
+
/^\s*@?SET\s+PATHEXT=%PATHEXT:[^%]*%\s*$/i,
|
|
1770
|
+
/^\s*@?IF\s+EXIST\s+"[^"]*"\s*\(\s*$/i,
|
|
1771
|
+
/^\s*\)\s*ELSE\s*\(\s*$/i,
|
|
1772
|
+
/^\s*\)\s*$/
|
|
1773
|
+
];
|
|
1774
|
+
function parseCmdShimTarget(cmdPath, content) {
|
|
1775
|
+
const shimDir = path.win32.dirname(cmdPath);
|
|
1776
|
+
if (carriesForeignEnv(content)) {
|
|
1777
|
+
return null;
|
|
1778
|
+
}
|
|
1779
|
+
const lines = content.split(/\r?\n/);
|
|
1780
|
+
const executionLines = [];
|
|
1781
|
+
for (const line of lines) {
|
|
1782
|
+
if (STRUCTURAL_LINE.some((pattern) => pattern.test(line))) {
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if (!line.includes("%*")) {
|
|
1786
|
+
return null;
|
|
1787
|
+
}
|
|
1788
|
+
executionLines.push(line);
|
|
1789
|
+
}
|
|
1790
|
+
if (executionLines.length === 0) {
|
|
1791
|
+
return null;
|
|
1792
|
+
}
|
|
1793
|
+
let agreed = null;
|
|
1794
|
+
for (const line of executionLines) {
|
|
1795
|
+
const parsed = parseExecutionLine(line, content, shimDir);
|
|
1796
|
+
if (!parsed) {
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1799
|
+
if (agreed && !sameTarget(agreed, parsed)) {
|
|
1800
|
+
return null;
|
|
1801
|
+
}
|
|
1802
|
+
agreed ??= parsed;
|
|
1803
|
+
}
|
|
1804
|
+
return agreed;
|
|
1805
|
+
}
|
|
1806
|
+
function sameTarget(a, b) {
|
|
1807
|
+
return a.nodeExec === b.nodeExec && a.args.length === b.args.length && a.args.every((value, index) => value === b.args[index]);
|
|
1808
|
+
}
|
|
1809
|
+
function parseExecutionLine(line, content, shimDir) {
|
|
1810
|
+
{
|
|
1811
|
+
const tokens = tokenizeCmdLine(line);
|
|
1812
|
+
if (!tokens) {
|
|
1813
|
+
return null;
|
|
1814
|
+
}
|
|
1815
|
+
const forwardIndex = tokens.findIndex((token) => !token.quoted && token.value === "%*");
|
|
1816
|
+
if (forwardIndex < 2 || forwardIndex !== tokens.length - 1) {
|
|
1817
|
+
return null;
|
|
1818
|
+
}
|
|
1819
|
+
const runTokens = tokens.slice(0, forwardIndex);
|
|
1820
|
+
const lastAmp = runTokens.reduce(
|
|
1821
|
+
(found, token, index) => !token.quoted && token.value === "&" ? index : found,
|
|
1822
|
+
-1
|
|
1823
|
+
);
|
|
1824
|
+
let words = lastAmp >= 0 ? runTokens.slice(lastAmp + 1) : runTokens;
|
|
1825
|
+
if (words.length < 2) {
|
|
1826
|
+
return null;
|
|
1827
|
+
}
|
|
1828
|
+
let nodeExec;
|
|
1829
|
+
if (/^-S(\.exe)?$/i.test(path.win32.basename(words[0].value))) {
|
|
1830
|
+
words = words.slice(1);
|
|
1831
|
+
}
|
|
1832
|
+
const interpreter = words[0].value;
|
|
1833
|
+
if (interpreter === "%_prog%") {
|
|
1834
|
+
const prog = progIsNode(content, shimDir);
|
|
1835
|
+
if (!prog.ok) {
|
|
1836
|
+
return null;
|
|
1837
|
+
}
|
|
1838
|
+
nodeExec = prog.absolute;
|
|
1839
|
+
} else {
|
|
1840
|
+
const expanded = expandShimPath(interpreter, shimDir) ?? interpreter;
|
|
1841
|
+
if (!isNodeInterpreter(expanded)) {
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
if (path.win32.isAbsolute(interpreter)) {
|
|
1845
|
+
nodeExec = interpreter;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
const args = [];
|
|
1849
|
+
let expandable = true;
|
|
1850
|
+
for (const word of words.slice(1)) {
|
|
1851
|
+
const expanded = literalToken(word, shimDir);
|
|
1852
|
+
if (expanded === null) {
|
|
1853
|
+
expandable = false;
|
|
1854
|
+
break;
|
|
1855
|
+
}
|
|
1856
|
+
args.push(expanded);
|
|
1857
|
+
}
|
|
1858
|
+
if (!expandable || args.length === 0) {
|
|
1859
|
+
return null;
|
|
1860
|
+
}
|
|
1861
|
+
return { args, ...nodeExec ? { nodeExec } : {} };
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
const REAL_DEPS = {
|
|
1865
|
+
platform: process.platform,
|
|
1866
|
+
readFileSync: (p) => fs.readFileSync(p, "utf-8"),
|
|
1867
|
+
resolveOnPath: findOnPath,
|
|
1868
|
+
execPath: process.execPath
|
|
1869
|
+
};
|
|
1870
|
+
function resolveSpawnPlan(command, args, env = process.env, deps = REAL_DEPS) {
|
|
1871
|
+
if (deps.platform !== "win32") {
|
|
1872
|
+
return { command, args };
|
|
1873
|
+
}
|
|
1874
|
+
let resolved = command;
|
|
1875
|
+
if (!command.includes("/") && !command.includes("\\")) {
|
|
1876
|
+
resolved = deps.resolveOnPath(command, env) ?? command;
|
|
1877
|
+
}
|
|
1878
|
+
if (!/\.(cmd|bat)$/i.test(path.win32.basename(resolved))) {
|
|
1879
|
+
return { command: resolved, args };
|
|
1880
|
+
}
|
|
1881
|
+
let content;
|
|
1882
|
+
try {
|
|
1883
|
+
content = deps.readFileSync(resolved);
|
|
1884
|
+
} catch {
|
|
1885
|
+
return { command: resolved, args };
|
|
1886
|
+
}
|
|
1887
|
+
const target = parseCmdShimTarget(resolved, content);
|
|
1888
|
+
if (!target) {
|
|
1889
|
+
return { command: resolved, args };
|
|
1890
|
+
}
|
|
1891
|
+
return {
|
|
1892
|
+
command: target.nodeExec ?? deps.execPath,
|
|
1893
|
+
args: [...target.args, ...args]
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1690
1896
|
const VISION_MODEL_PATTERNS = [
|
|
1691
1897
|
"claude-*",
|
|
1692
1898
|
"gpt-4o*",
|
|
@@ -1723,7 +1929,12 @@ function isVisionModel(modelId) {
|
|
|
1723
1929
|
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
1724
1930
|
const CLI_TIMEOUT_MS = 1e4;
|
|
1725
1931
|
function defaultRunCli(bin, args, timeoutMs) {
|
|
1726
|
-
|
|
1932
|
+
const plan = resolveSpawnPlan(bin, args);
|
|
1933
|
+
return execFileSync(plan.command, plan.args, {
|
|
1934
|
+
encoding: "utf-8",
|
|
1935
|
+
timeout: timeoutMs,
|
|
1936
|
+
stdio: "pipe"
|
|
1937
|
+
});
|
|
1727
1938
|
}
|
|
1728
1939
|
function timed(run) {
|
|
1729
1940
|
const start = Date.now();
|
|
@@ -2295,11 +2506,19 @@ function piRoutes(home, env, targets = DEFAULT_TARGETS) {
|
|
|
2295
2506
|
}
|
|
2296
2507
|
function fetchPiKey(piPath, modelId, provider, timeoutMs) {
|
|
2297
2508
|
try {
|
|
2298
|
-
const
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2509
|
+
const plan = resolveSpawnPlan(piPath, [
|
|
2510
|
+
"auth",
|
|
2511
|
+
"print-api-key",
|
|
2512
|
+
"--model",
|
|
2513
|
+
modelId,
|
|
2514
|
+
"--provider",
|
|
2515
|
+
provider
|
|
2516
|
+
]);
|
|
2517
|
+
const key = execFileSync(plan.command, plan.args, {
|
|
2518
|
+
encoding: "utf-8",
|
|
2519
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2520
|
+
timeout: timeoutMs
|
|
2521
|
+
}).trim();
|
|
2303
2522
|
if (!key) {
|
|
2304
2523
|
throw new Error("empty");
|
|
2305
2524
|
}
|
|
@@ -2605,7 +2824,8 @@ function emptyWorkdir() {
|
|
|
2605
2824
|
function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
2606
2825
|
const runStartedAt = Date.now();
|
|
2607
2826
|
return new Promise((resolve, reject) => {
|
|
2608
|
-
const
|
|
2827
|
+
const plan = resolveSpawnPlan(invocation.command, invocation.args);
|
|
2828
|
+
const child = spawn(plan.command, plan.args, {
|
|
2609
2829
|
cwd: invocation.cwd,
|
|
2610
2830
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2611
2831
|
});
|
|
@@ -2681,16 +2901,21 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
2681
2901
|
clearTimeout(timer);
|
|
2682
2902
|
clearTimeout(drainTimer);
|
|
2683
2903
|
clearTimeout(killTimer);
|
|
2684
|
-
|
|
2904
|
+
const code = error.code;
|
|
2905
|
+
if (code === "ENOENT") {
|
|
2685
2906
|
const missingCwd = !fs.existsSync(invocation.cwd);
|
|
2686
2907
|
reject(
|
|
2687
2908
|
new Error(
|
|
2688
|
-
missingCwd ? `Working directory does not exist: ${invocation.cwd}` : `Provider CLI not found: ${invocation.command}. Install it and sign in first.`
|
|
2909
|
+
missingCwd ? `Working directory does not exist: ${invocation.cwd}` : `Provider CLI not found: ${invocation.command} (spawn ENOENT). Install it and sign in first.`
|
|
2689
2910
|
)
|
|
2690
2911
|
);
|
|
2691
2912
|
return;
|
|
2692
2913
|
}
|
|
2693
|
-
reject(
|
|
2914
|
+
reject(
|
|
2915
|
+
new Error(
|
|
2916
|
+
`${providerName} provider could not start \`${invocation.command}\`: ${error.message}`
|
|
2917
|
+
)
|
|
2918
|
+
);
|
|
2694
2919
|
});
|
|
2695
2920
|
child.on("exit", (code) => {
|
|
2696
2921
|
exitCode = code;
|
|
@@ -3351,6 +3576,43 @@ function runGuard(guards, options) {
|
|
|
3351
3576
|
}
|
|
3352
3577
|
return evaluateGuard(guards, detectActiveModel(options));
|
|
3353
3578
|
}
|
|
3579
|
+
const SKILL_DIRS = [
|
|
3580
|
+
["claude-code", ".claude/skills"],
|
|
3581
|
+
["codex", ".codex/skills"],
|
|
3582
|
+
["pi/opencode", ".agents/skills"],
|
|
3583
|
+
["dsh", ".dsh/skills"]
|
|
3584
|
+
];
|
|
3585
|
+
function isOlder(pinned, current) {
|
|
3586
|
+
const parse = (v) => v.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
3587
|
+
const [pa, pb, pc] = parse(pinned);
|
|
3588
|
+
const [ca, cb, cc] = parse(current);
|
|
3589
|
+
if (pa !== ca) return pa < ca;
|
|
3590
|
+
if (pb !== cb) return pb < cb;
|
|
3591
|
+
return pc < cc;
|
|
3592
|
+
}
|
|
3593
|
+
function readPinnedVersion(launcher) {
|
|
3594
|
+
return /^PINNED="([^"]+)"/m.exec(launcher)?.[1] ?? null;
|
|
3595
|
+
}
|
|
3596
|
+
function findSkillInstalls(currentVersion, home = os.homedir(), skillName = "modlens") {
|
|
3597
|
+
const installs = [];
|
|
3598
|
+
for (const [harness, relative] of SKILL_DIRS) {
|
|
3599
|
+
const launcher = path.join(home, relative, skillName, "scripts", "run.sh");
|
|
3600
|
+
let text;
|
|
3601
|
+
try {
|
|
3602
|
+
text = fs.readFileSync(launcher, "utf-8");
|
|
3603
|
+
} catch {
|
|
3604
|
+
continue;
|
|
3605
|
+
}
|
|
3606
|
+
const pinned = readPinnedVersion(text);
|
|
3607
|
+
installs.push({
|
|
3608
|
+
harness,
|
|
3609
|
+
path: launcher,
|
|
3610
|
+
pinned,
|
|
3611
|
+
outdated: pinned !== null && isOlder(pinned, currentVersion)
|
|
3612
|
+
});
|
|
3613
|
+
}
|
|
3614
|
+
return installs;
|
|
3615
|
+
}
|
|
3354
3616
|
const MIN_NODE = "22.19";
|
|
3355
3617
|
function versionParts(version) {
|
|
3356
3618
|
const match = /(\d+)\.(\d+)/.exec(version.replace(/^v/, ""));
|
|
@@ -3499,6 +3761,7 @@ function buildDoctorReport(input) {
|
|
|
3499
3761
|
remote: composeChain("remote", input.config, reuseOptions).map(chainEntryName)
|
|
3500
3762
|
},
|
|
3501
3763
|
harness: { detected: harnessDetection.harness, source: harnessDetection.source },
|
|
3764
|
+
skillInstalls: input.version ? findSkillInstalls(input.version, input.home) : [],
|
|
3502
3765
|
guard: {
|
|
3503
3766
|
rules: denyPatterns(input.config.guards).length,
|
|
3504
3767
|
allowRules: allowPatterns(input.config.guards).length,
|
|
@@ -3563,6 +3826,18 @@ function renderDoctorReport(report) {
|
|
|
3563
3826
|
report.harness.detected ? ` ${report.harness.detected} (via ${report.harness.source})` : ` none detected (${report.harness.source})`
|
|
3564
3827
|
);
|
|
3565
3828
|
lines.push("");
|
|
3829
|
+
if (report.skillInstalls.length > 0) {
|
|
3830
|
+
lines.push("Installed skill copies (a copy keeps its install-time version)");
|
|
3831
|
+
for (const install of report.skillInstalls) {
|
|
3832
|
+
const state = install.pinned === null ? "no pin found" : `pins ${install.pinned}`;
|
|
3833
|
+
lines.push(` ${install.harness}: ${state}${install.outdated ? " [outdated]" : ""}`);
|
|
3834
|
+
}
|
|
3835
|
+
if (report.skillInstalls.some((install) => install.outdated)) {
|
|
3836
|
+
lines.push(" Refresh an outdated copy by re-running the install: it overwrites in");
|
|
3837
|
+
lines.push(" place. See https://github.com/liustack/modlens/blob/main/INSTALL.md");
|
|
3838
|
+
}
|
|
3839
|
+
lines.push("");
|
|
3840
|
+
}
|
|
3566
3841
|
lines.push("Guard (should the vision engine run for the active model?)");
|
|
3567
3842
|
lines.push(
|
|
3568
3843
|
` rules: ${report.guard.rules} deny pattern(s), ${report.guard.allowRules} allow pattern(s)${report.guard.allowRules > 0 ? " (allowlist mode)" : ""}, denyWhenUnknown: ${report.guard.denyWhenUnknown}`
|
|
@@ -3794,7 +4069,7 @@ function parsePositiveInt(raw, flag) {
|
|
|
3794
4069
|
}
|
|
3795
4070
|
return Number.parseInt(raw, 10);
|
|
3796
4071
|
}
|
|
3797
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.16.
|
|
4072
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.16.3");
|
|
3798
4073
|
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(
|
|
3799
4074
|
"--extra-body <json>",
|
|
3800
4075
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|
|
@@ -3901,7 +4176,10 @@ program.command("doctor").description(
|
|
|
3901
4176
|
config: loadConfigFile(),
|
|
3902
4177
|
env: process.env,
|
|
3903
4178
|
providerFlag: options.provider,
|
|
3904
|
-
configPath: CONFIG_PATH
|
|
4179
|
+
configPath: CONFIG_PATH,
|
|
4180
|
+
// Lets doctor name an installed skill copy that is older than
|
|
4181
|
+
// the CLI reporting on it (issue #33).
|
|
4182
|
+
version: "3.16.3"
|
|
3905
4183
|
});
|
|
3906
4184
|
const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
|
|
3907
4185
|
process.stdout.write(`${output}
|
package/docs/harness-setup.md
CHANGED
|
@@ -58,7 +58,7 @@ dsh is different from the other harnesses: modlens plugs in as a native tool, no
|
|
|
58
58
|
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
This registers a `
|
|
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.
|
|
62
62
|
|
|
63
63
|
### Paste-to-path (web profile)
|
|
64
64
|
|
|
@@ -69,7 +69,7 @@ the image bytes go to the plugin's `/modlens/paste` route on the dsh web
|
|
|
69
69
|
server (loopback, magic-byte checked, 25 MB cap), land as a private temp file,
|
|
70
70
|
and the composer receives the file path as plain text — the same shape Pi,
|
|
71
71
|
OpenCode, and Claude Code hand their models, and the modlens skill's and
|
|
72
|
-
`
|
|
72
|
+
`modlens_read_image` tool's primary trigger. Admission never fires because the message
|
|
73
73
|
carries no image attachment.
|
|
74
74
|
|
|
75
75
|
The takeover is conditional, and the decision is the host's: the browser half
|
|
@@ -58,10 +58,10 @@ dsh 与其他 harness 不同:modlens 以原生工具的形式接入,而不
|
|
|
58
58
|
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
这会注册一个 `
|
|
61
|
+
这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
|
|
62
62
|
|
|
63
63
|
### 粘贴转路径(paste-to-path,web profile)
|
|
64
64
|
|
|
65
|
-
过去在 dsh Web UI 里,**纯文本模型**下粘贴图片会死在图片准入检查这一步。插件现在带了一个浏览器端半边(由 dsh 的客户端插件系统自动加载),恰好在这种情况下接管粘贴:图片字节发到插件在 dsh web 服务器上的 `/modlens/paste` 路由(仅回环地址,校验 magic byte,上限 25 MB),落成一个私有临时文件,输入框收到的则是纯文本的文件路径。这与 Pi、OpenCode、Claude Code 递给模型的形态一致,也正是 modlens skill 和 `
|
|
65
|
+
过去在 dsh Web UI 里,**纯文本模型**下粘贴图片会死在图片准入检查这一步。插件现在带了一个浏览器端半边(由 dsh 的客户端插件系统自动加载),恰好在这种情况下接管粘贴:图片字节发到插件在 dsh web 服务器上的 `/modlens/paste` 路由(仅回环地址,校验 magic byte,上限 25 MB),落成一个私有临时文件,输入框收到的则是纯文本的文件路径。这与 Pi、OpenCode、Claude Code 递给模型的形态一致,也正是 modlens skill 和 `modlens_read_image` 工具的首要触发条件。消息里不带图片附件,准入检查根本不会触发。
|
|
66
66
|
|
|
67
67
|
接管是有条件的,且裁决权在 host 一侧:浏览器半边先向插件路由询问当前选中的模型是否纯文本,host 用 provider 注册表里声明的模型元数据(`inputModalities`)回答,而不是靠名称猜。`(modlens vision)` 变体和任何声明支持图片输入的模型都保留原生粘贴流程(变体在发请求时转换且保留缩略图,视觉模型自己读图),host 认不出的模型同样不接管:在 host 确认该接管之前,粘贴一律走原生路径。模型元数据里没有声明输入模态的,一律算认不出:元数据缺失绝不当成「已确认纯文本」。裁决还有 60 秒时效,模型中途变了会重新问询,不会永远信旧答案。在插件配置行里设 `pasteToPath: false` 可整体关掉这个功能:策略端点 404 时浏览器半边彻底停手。若路由在裁决确认后中途消失,失败结果返回前那个短暂窗口(一次本地往返)内发生的粘贴会丢失,之后客户端清空全部裁决,后续粘贴一律走原生路径。
|
package/docs/output-schema.md
CHANGED
|
@@ -44,7 +44,7 @@ The CLI prints one JSON object to stdout:
|
|
|
44
44
|
"layout": {
|
|
45
45
|
"regions": [
|
|
46
46
|
{
|
|
47
|
-
"type": "title
|
|
47
|
+
"type": "string (a short kind: title, paragraph, list, table, chart, form, code, image, icon, link, nav, ...)",
|
|
48
48
|
"reading_order": 1,
|
|
49
49
|
"text": "string"
|
|
50
50
|
}
|
|
@@ -71,4 +71,6 @@ The CLI prints one JSON object to stdout:
|
|
|
71
71
|
|
|
72
72
|
Required fields: `summary`, `ocr`, `layout`, `semantics`, `visual`, `uncertainty` — every top-level field, `visual` included. (Earlier docs called `visual` optional; the enforced schema has always required it, so build to the schema.)
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
`layout.regions[].type` is a free string, not a closed list. Region kinds are an open set: a fixed enum rejected `link` on any web screenshot and `search` on a portal, and a rejected result fails the whole read over a descriptive label. The field's schema `description` names the common vocabulary as guidance, which reaches every provider that enforces this schema server-side, so an unlisted kind costs nothing.
|
|
75
|
+
|
|
76
|
+
Changes from v1: pixel `bbox` coordinates and numeric `confidence` scores were removed. Vision models fabricate both, so v2 stops pretending to provide them.
|
|
@@ -44,7 +44,7 @@ CLI 向 stdout 打印一个 JSON 对象:
|
|
|
44
44
|
"layout": {
|
|
45
45
|
"regions": [
|
|
46
46
|
{
|
|
47
|
-
"type": "title
|
|
47
|
+
"type": "string (a short kind: title, paragraph, list, table, chart, form, code, image, icon, link, nav, ...)",
|
|
48
48
|
"reading_order": 1,
|
|
49
49
|
"text": "string"
|
|
50
50
|
}
|
|
@@ -71,4 +71,6 @@ CLI 向 stdout 打印一个 JSON 对象:
|
|
|
71
71
|
|
|
72
72
|
必填字段:`summary`、`ocr`、`layout`、`semantics`、`visual`、`uncertainty`,也就是每一个顶层字段,`visual` 也不例外。(早期文档把 `visual` 写成可选,但强制执行的 schema 一直要求它,请以 schema 为准。)
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
`layout.regions[].type` 是自由字符串,不是封闭列表。区域类型本质是开放集合:固定枚举会让任何网页截图里的 `link`、门户页里的 `search` 直接落选,而一次落选就为了一个描述性标签废掉整次识别。常用词表写在该字段的 schema `description` 里作为指引,凡是在服务端强制执行这份 schema 的 provider 都会收到,没列到的类型不会有任何代价。
|
|
75
|
+
|
|
76
|
+
相对 v1 的变化:删掉了像素级 `bbox` 坐标和数值型 `confidence` 分数。视觉模型会凭空编造这两样,v2 不再假装提供。
|
package/docs/troubleshooting.md
CHANGED
|
@@ -47,10 +47,10 @@ Wait for the reset, or move to `gemini-api`, which has its own budget. Parallel
|
|
|
47
47
|
## Provider CLI not found
|
|
48
48
|
|
|
49
49
|
```
|
|
50
|
-
Provider CLI not found: agy. Install it and sign in first.
|
|
50
|
+
Provider CLI not found: agy (spawn ENOENT). Install it and sign in first.
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
The binary is not on PATH, or `--provider-bin` points somewhere wrong.
|
|
53
|
+
The binary is not on PATH, or `--provider-bin` points somewhere wrong. A different spawn-level failure (`... could not start \`claude\`: spawn EACCES`) keeps its real error code so the cause is nameable. On Windows the npm-installed CLIs are `.cmd` shims; modlens resolves them through PATHEXT and runs their real Node entry directly, so neither the bare name (ENOENT) nor the `.cmd` (EINVAL) trips it up.
|
|
54
54
|
|
|
55
55
|
```
|
|
56
56
|
Working directory does not exist: /some/path
|
|
@@ -156,6 +156,29 @@ The trade-off is honest either way: an explicit `@latest` (or the exclusion)
|
|
|
156
156
|
opts modlens out of pnpm's supply-chain cooling-off window, so new releases
|
|
157
157
|
install immediately.
|
|
158
158
|
|
|
159
|
+
## dsh: the model cannot see the read_image tool
|
|
160
|
+
|
|
161
|
+
The plugin registers its tool as `modlens_read_image`, not `read_image`. dsh's
|
|
162
|
+
tool registry is layered, and a scoped tool shadows a global one: a host
|
|
163
|
+
`read_image` mounted in the agent-preset scope and a plugin's registered
|
|
164
|
+
globally are not a duplicate at all, so the registration succeeds silently and
|
|
165
|
+
the model still resolves the host's, which refuses a text-only model outright
|
|
166
|
+
([#34](https://github.com/liustack/modlens/issues/34)). Under our own name
|
|
167
|
+
there is nothing to shadow it, and the model finds the tool through its
|
|
168
|
+
schema, which reaches it on every request regardless of the name.
|
|
169
|
+
|
|
170
|
+
If the model still cannot see it, check the harness log for
|
|
171
|
+
`[modlens] ... registration skipped`. `toolName` in the plugin row pins a
|
|
172
|
+
different name, but pick one nothing else uses: any name a scoped tool already
|
|
173
|
+
holds will be shadowed exactly the way `read_image` is, which puts you back in
|
|
174
|
+
this section.
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
- id: modlens
|
|
178
|
+
config:
|
|
179
|
+
toolName: vision_read_image
|
|
180
|
+
```
|
|
181
|
+
|
|
159
182
|
## fetch failed, or could not connect
|
|
160
183
|
|
|
161
184
|
```
|
|
@@ -47,10 +47,10 @@ agy's free tier is one weekly bucket shared by the desktop app, the CLI, and the
|
|
|
47
47
|
## 找不到 provider CLI
|
|
48
48
|
|
|
49
49
|
```
|
|
50
|
-
Provider CLI not found: agy. Install it and sign in first.
|
|
50
|
+
Provider CLI not found: agy (spawn ENOENT). Install it and sign in first.
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
二进制不在 PATH 上,或者 `--provider-bin`
|
|
53
|
+
二进制不在 PATH 上,或者 `--provider-bin` 指错了地方。其他 spawn 级失败(`... could not start \`claude\`: spawn EACCES`)会保留真实错误码,方便定位。Windows 上 npm 装的 CLI 是 `.cmd` shim,modlens 通过 PATHEXT 解析并直接运行它背后的 Node 入口,所以裸名(ENOENT)和 `.cmd`(EINVAL)都不会卡住它。
|
|
54
54
|
|
|
55
55
|
```
|
|
56
56
|
Working directory does not exist: /some/path
|
|
@@ -142,6 +142,18 @@ minimumReleaseAgeExclude:
|
|
|
142
142
|
|
|
143
143
|
然后执行 `npx -y @deepseek-ai/dsh plugin --profile <name> update @liustack/modlens`。两种方式的代价都摆在明面上:显式 `@latest`(或这条排除)让 modlens 退出 pnpm 的供应链冷静期,新版本会立即装上。
|
|
144
144
|
|
|
145
|
+
## dsh:模型看不到 read_image 工具
|
|
146
|
+
|
|
147
|
+
插件注册的工具名是 `modlens_read_image`,不是 `read_image`。dsh 的工具注册表是分层的,scoped 层会遮蔽全局层:宿主的 `read_image` 挂在 agent preset 作用域、插件注册在全局层,两者根本不算重名,于是注册静默成功,模型解析到的仍是宿主那个,而它对纯文本模型直接拒绝([#34](https://github.com/liustack/modlens/issues/34))。用自己的名字就没有东西会遮蔽它,模型是通过工具 schema 找到它的,而 schema 每次请求都会送达,与叫什么名字无关。
|
|
148
|
+
|
|
149
|
+
如果模型仍然看不到,去 harness 日志里搜 `[modlens] ... registration skipped`。想改名就在插件配置行里设 `toolName`,但要挑一个别人没用的:任何已被某个 scoped 工具占用的名字,都会像 `read_image` 一样被遮蔽,那就又绕回本节了。
|
|
150
|
+
|
|
151
|
+
```yaml
|
|
152
|
+
- id: modlens
|
|
153
|
+
config:
|
|
154
|
+
toolName: vision_read_image
|
|
155
|
+
```
|
|
156
|
+
|
|
145
157
|
## fetch failed 或连接失败
|
|
146
158
|
|
|
147
159
|
```
|
package/dsh/client.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// (POST /modlens/paste), land as a private temp file, and the returned path
|
|
8
8
|
// is inserted into the composer as plain text. A text-only model then sees
|
|
9
9
|
// exactly what Pi, OpenCode, and Claude Code hand their models: a file path,
|
|
10
|
-
// which is also the modlens skill's and
|
|
10
|
+
// which is also the modlens skill's and the read tool's primary trigger.
|
|
11
11
|
//
|
|
12
12
|
// Hand-written in the lazy-CJS bundle protocol (window.__ModuleLoader__.load
|
|
13
13
|
// with a factory returning cordis-plugin exports), so no build step and no
|
package/dsh/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// DeepSeek Harness (dsh) plugin: registers a
|
|
2
|
-
// modlens CLI that ships in this very package. dsh models are
|
|
3
|
-
// the tool is the vision bridge; unlike prompt-triggered
|
|
4
|
-
// tool schema reaches the model on every request, so
|
|
5
|
-
// gamble. The
|
|
1
|
+
// DeepSeek Harness (dsh) plugin: registers a modlens_read_image tool backed
|
|
2
|
+
// by the modlens CLI that ships in this very package. dsh models are
|
|
3
|
+
// text-only, so the tool is the vision bridge; unlike prompt-triggered
|
|
4
|
+
// skills, a registered tool schema reaches the model on every request, so
|
|
5
|
+
// there is no trigger gamble. The name is ours rather than the host's
|
|
6
|
+
// `read_image` (see the registration in apply, and issue #34).
|
|
7
|
+
// The engine is spawned from ../dist/main.js inside this package:
|
|
6
8
|
// no PATH lookup, no npx, the plugin and its engine version-lock together.
|
|
7
9
|
//
|
|
8
10
|
// Loaded via the cordis.patch.yml row `@liustack/modlens/dsh` (see the
|
|
@@ -66,13 +68,11 @@ export function apply(ctx, config = {}) {
|
|
|
66
68
|
// of @deepseek-ai/dsh-tools is not yet reliable), so this plugin owns its
|
|
67
69
|
// own argument validation inside execute.
|
|
68
70
|
//
|
|
69
|
-
// The name
|
|
70
|
-
// own native read_image (dsh-tool-fs),
|
|
71
|
-
// which
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// text-only models — and any other registration error degrades loudly
|
|
75
|
-
// instead of taking the vision wrapper down with it.
|
|
71
|
+
// The name is ours by default (see the registration below): hosts with a
|
|
72
|
+
// durable attachment store mount their own native read_image (dsh-tool-fs),
|
|
73
|
+
// which is gated on the model declaring image input and so refuses the
|
|
74
|
+
// text-only models this plugin exists for. Any registration error degrades
|
|
75
|
+
// loudly instead of taking the vision wrapper down with it (issue #21).
|
|
76
76
|
const readImageTool = (toolName) => ({
|
|
77
77
|
name: toolName,
|
|
78
78
|
description:
|
|
@@ -100,7 +100,7 @@ export function apply(ctx, config = {}) {
|
|
|
100
100
|
isConcurrencySafe: () => true,
|
|
101
101
|
presentCall: (args) => ({
|
|
102
102
|
card: 'generic',
|
|
103
|
-
title:
|
|
103
|
+
title: toolName,
|
|
104
104
|
kind: 'read',
|
|
105
105
|
rawInput: args,
|
|
106
106
|
...(typeof args?.path === 'string' && !/^https?:\/\//i.test(args.path)
|
|
@@ -109,7 +109,7 @@ export function apply(ctx, config = {}) {
|
|
|
109
109
|
}),
|
|
110
110
|
async execute(args, exec) {
|
|
111
111
|
if (typeof args?.path !== 'string' || args.path.trim() === '') {
|
|
112
|
-
throw new Error(
|
|
112
|
+
throw new Error(`${toolName} needs a non-empty string "path".`)
|
|
113
113
|
}
|
|
114
114
|
const cliArgs = [CLI_PATH, '-i', args.path, '--timeout', String(CLI_TIMEOUT_MS)]
|
|
115
115
|
if (args.prompt) {
|
|
@@ -130,21 +130,24 @@ export function apply(ctx, config = {}) {
|
|
|
130
130
|
return parsed.result
|
|
131
131
|
},
|
|
132
132
|
})
|
|
133
|
-
|
|
133
|
+
// A name of our own rather than the host's. dsh's registry is layered and
|
|
134
|
+
// a scoped tool shadows a global one, so a host `read_image` mounted in the
|
|
135
|
+
// agent-preset scope and ours registered globally are not a duplicate at
|
|
136
|
+
// all: the registration succeeds, nothing throws, and the model still
|
|
137
|
+
// resolves the host's (issue #34). Detecting that from here would mean
|
|
138
|
+
// walking every agent (`agents.list()` plus `agent/created`) and asking
|
|
139
|
+
// `tools.get(name, agent)` per scope, then mutating a global catalog per
|
|
140
|
+
// agent. Not entering the collision is cheaper: no host tool is known to
|
|
141
|
+
// use this name, and the model finds ours through its schema, which reaches
|
|
142
|
+
// it on every request regardless of what the tool is called. `toolName`
|
|
143
|
+
// still pins whatever a host prefers.
|
|
144
|
+
const preferred = config.toolName || 'modlens_read_image'
|
|
134
145
|
try {
|
|
135
146
|
ctx.tools.register(readImageTool(preferred))
|
|
136
147
|
} catch (error) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
ctx.tools.register(readImageTool(fallback))
|
|
141
|
-
console.error(`[modlens] tool name "${preferred}" is taken by the host; registered as "${fallback}" instead`)
|
|
142
|
-
} catch (retryError) {
|
|
143
|
-
console.error(`[modlens] read_image registration skipped: ${retryError}`)
|
|
144
|
-
}
|
|
145
|
-
} else {
|
|
146
|
-
console.error(`[modlens] read_image registration skipped: ${error}`)
|
|
147
|
-
}
|
|
148
|
+
// Same-layer duplicate of the chosen name, or a preview-era surface
|
|
149
|
+
// change: degrade loudly instead of taking the whole plugin down.
|
|
150
|
+
console.error(`[modlens] ${preferred} registration skipped: ${error}`)
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -381,7 +384,7 @@ function registerPasteRoute(ctx, host) {
|
|
|
381
384
|
* turns the image into evidence text before the delegated request goes out;
|
|
382
385
|
* the upstream serializer's own image rejection stays as the fail-closed
|
|
383
386
|
* backstop. Guarded feature-detection: if the llm registration surface moved
|
|
384
|
-
* (developer preview), the plugin quietly stays a
|
|
387
|
+
* (developer preview), the plugin quietly stays a read-only-tool plugin.
|
|
385
388
|
*
|
|
386
389
|
* Two modes (issue #29, design contributed by @zlycode01):
|
|
387
390
|
* - `config.upstream` set: wrap exactly that one route, legacy behavior.
|
|
@@ -469,7 +472,7 @@ function registerVisionProvider(ctx, config) {
|
|
|
469
472
|
console.error(`[modlens] vision provider ${providerId} already registered, keeping the existing one`)
|
|
470
473
|
return true
|
|
471
474
|
}
|
|
472
|
-
// A preview-era surface change: degrade to the
|
|
475
|
+
// A preview-era surface change: degrade to the tool-only plugin,
|
|
473
476
|
// but say so in the harness log instead of vanishing (a swallowed
|
|
474
477
|
// TypeError here once hid a missing base method).
|
|
475
478
|
console.error(`[modlens] vision provider registration skipped (${providerId}): ${error}`)
|
package/dsh/vision-schema.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"type":"object","properties":{"summary":{"type":"string"},"ocr":{"type":"object","properties":{"full_text":{"type":"string"},"lines":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string"},"language":{"type":"string"}},"required":["text"]}}},"required":["full_text","lines"]},"layout":{"type":"object","properties":{"regions":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","
|
|
1
|
+
{"type":"object","properties":{"summary":{"type":"string"},"ocr":{"type":"object","properties":{"full_text":{"type":"string"},"lines":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string"},"language":{"type":"string"}},"required":["text"]}}},"required":["full_text","lines"]},"layout":{"type":"object","properties":{"regions":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"A short kind for this region. Prefer a common one where it fits: title, heading, paragraph, list, table, chart, form, code, image, icon, link, nav, button, search. Any other short label is fine when none of those describe it."},"reading_order":{"type":"number"},"text":{"type":"string"}},"required":["type","reading_order","text"]}}},"required":["regions"]},"semantics":{"type":"object","properties":{"scene":{"type":"string"},"intent":{"type":"string"},"entities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string"},"evidence":{"type":"string"}},"required":["name","type"]}},"relations":{"type":"array","items":{"type":"object","properties":{"subject":{"type":"string"},"predicate":{"type":"string"},"object":{"type":"string"}},"required":["subject","predicate","object"]}}},"required":["scene","entities"]},"visual":{"type":"object","properties":{"dominant_colors":{"type":"array","items":{"type":"string"}},"style":{"type":"string"},"notes":{"type":"array","items":{"type":"string"}}}},"uncertainty":{"type":"array","items":{"type":"string"}}},"required":["summary","ocr","layout","semantics","visual","uncertainty"]}
|
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.16.
|
|
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.16.3):
|
|
24
24
|
|
|
25
|
-
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.16.
|
|
26
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.
|
|
27
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.
|
|
25
|
+
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.16.3: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.3 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.3 <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.16.
|
|
27
|
+
$Pinned = '3.16.3'
|
|
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.16.
|
|
25
|
+
PINNED="3.16.3"
|
|
26
26
|
# -------------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
|