@liustack/modlens 3.21.0 → 3.21.1
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 +5 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/dist/main.js +304 -52
- 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 +232 -59
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +4 -4
- package/skills/modlens/references/configure.md +6 -1
- package/skills/modlens/references/configure.zh-CN.md +7 -1
- 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,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.21.1 - 2026-08-19
|
|
4
|
+
|
|
5
|
+
- **`config use openai constructor` can no longer destroy a key, and hostile hand edits meet one discipline everywhere ([3.21.0 acceptance]).** An independent acceptance run against 3.21.0 found real data loss: the label `constructor` walked the prototype chain, came back as Object's constructor, and emptied the active slot under a success message, destroying an unsaved key with `--discard`. Seven adversarial review rounds then hardened every surface the same hostile input can reach: bundle labels, provider names (`config set constructor.apiKey` used to write the key onto a global object and print success), and the registry all read own properties only; malformed roots, entries, bundles, and field types fail with a fix-or-remove sentence naming the path, at `set`, `save`, `use`, `guards.*`, and a new execute boundary in reads and doctor; and `config show`, built to be pasted into issues, now runs every value through redaction against every known key with no length assumption, while no property name in the view ever carries user data, the structural guarantee a dedicated regression walks and asserts. Two display designs that keyed rows by user data each grew a credential leak during review; the shipped view keeps labels, slot names, and unknown spellings inside redacted values, where they cannot leak or collide.
|
|
6
|
+
- **dsh: a failed image read no longer bills the whole session, and never rewrites history while the outcome stands ([#68](https://github.com/liustack/modlens/issues/68)).** A session that pasted an image the engine happened to fail on paid full price for every request after it: the failure placeholder embedded that attempt's own error text, the cache evicted failures on settle so every step re-ran the engine and rewrote the same message, and the provider's prefix cache missed from that message on. Failure placeholders are now constants per stage, with the attempt's detail in the harness log and no attacker-shaped media type on the wire; failures hold for a 60-second cooldown on the monotonic clock, so a broken engine is probed once per cooldown per attachment and a recovered one moves the text exactly once; the cache key ignores attachment key order; cached blocks are frozen; and one evidence cache serves every wrapper route and auto-read, which used to bypass caching entirely. Eviction went through three designs under adversarial review, each broken by a measured reproduction, and what ships pins every open walk's keys as exact refcounts: within the cap, wire bytes move only when an outcome changes. Thanks to @nanami-0713 for a report that named the mechanism, the reproduction, and the cost curve, and for respecting the no-PR policy while still making the fix easy to verify.
|
|
7
|
+
|
|
3
8
|
## 3.21.0 - 2026-08-18
|
|
4
9
|
|
|
5
10
|
- **Switching OpenAI-compatible gateways no longer costs a key ([#67](https://github.com/liustack/modlens/issues/67)).** The openai slot is the one slot users point at many different gateways, and switching meant overwriting `providers.openai` field by field, with the previous endpoint's key lost unless backed up by hand. `modlens config save openai <label>` snapshots the slot whole under a name; `modlens config use openai <label>` swaps a saved copy back in whole, never merged. A slot that no label holds refuses to be overwritten without `--discard`, so the silent key loss this exists to remove now takes saying so in as many words. The saved section is inert data: resolution, guards, the failover chain, and the environment bindings never read it, the effective view lists labels with keys masked, and older CLI versions carry it through their writes untouched. The issue's own proposal, named provider slots with a type field, was declined with reasons on the thread: it opens the closed provider registry every downstream consumer assumes, and reopens the mistyped-name trap 3.19.0 closed. Per-call routing and a dsh passthrough are likewise declined; endpoint choice stays user-owned configuration.
|
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.21.
|
|
37
|
+
**🥇 The most capable vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.1`, 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.21.
|
|
37
|
+
**🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.1`,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.21.
|
|
72
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.1
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
|
package/dist/main.js
CHANGED
|
@@ -830,7 +830,7 @@ function tail(text, max = 300) {
|
|
|
830
830
|
}
|
|
831
831
|
function parseExtraBody(raw, origin) {
|
|
832
832
|
const parsed = parseJsonOrExplain(raw, origin);
|
|
833
|
-
if (!isPlainObject(parsed)) {
|
|
833
|
+
if (!isPlainObject$1(parsed)) {
|
|
834
834
|
throw new Error(
|
|
835
835
|
`${origin} must be a JSON object, for example {"thinking":{"type":"disabled"}}`
|
|
836
836
|
);
|
|
@@ -854,21 +854,21 @@ function deepMerge(base, overlay) {
|
|
|
854
854
|
const merged = { ...base };
|
|
855
855
|
for (const [key, value] of Object.entries(overlay)) {
|
|
856
856
|
const current = merged[key];
|
|
857
|
-
merged[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
|
|
857
|
+
merged[key] = isPlainObject$1(current) && isPlainObject$1(value) ? deepMerge(current, value) : value;
|
|
858
858
|
}
|
|
859
859
|
return merged;
|
|
860
860
|
}
|
|
861
861
|
function hasPath(value, dottedPath) {
|
|
862
862
|
let cursor = value;
|
|
863
863
|
for (const segment of dottedPath.split(".")) {
|
|
864
|
-
if (!isPlainObject(cursor) || !Object.hasOwn(cursor, segment)) {
|
|
864
|
+
if (!isPlainObject$1(cursor) || !Object.hasOwn(cursor, segment)) {
|
|
865
865
|
return false;
|
|
866
866
|
}
|
|
867
867
|
cursor = cursor[segment];
|
|
868
868
|
}
|
|
869
869
|
return true;
|
|
870
870
|
}
|
|
871
|
-
function isPlainObject(value) {
|
|
871
|
+
function isPlainObject$1(value) {
|
|
872
872
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
873
873
|
}
|
|
874
874
|
const TOKEN_SHAPES = [
|
|
@@ -1571,7 +1571,7 @@ const PROVIDERS = {
|
|
|
1571
1571
|
};
|
|
1572
1572
|
function resolveProvider(providerName = "antigravity-cli") {
|
|
1573
1573
|
const normalized = providerName.trim().toLowerCase();
|
|
1574
|
-
const provider = PROVIDERS[normalized];
|
|
1574
|
+
const provider = Object.hasOwn(PROVIDERS, normalized) ? PROVIDERS[normalized] : void 0;
|
|
1575
1575
|
if (!provider) {
|
|
1576
1576
|
throw new Error(
|
|
1577
1577
|
`Unsupported provider: ${providerName}. Available: ${listProviders().join(", ")}`
|
|
@@ -1597,9 +1597,8 @@ const ENV_BINDINGS = {
|
|
|
1597
1597
|
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
|
|
1598
1598
|
};
|
|
1599
1599
|
function fileKeysFor(providerName, config2) {
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
(key) => (aliases[key] ?? key) === providerName
|
|
1600
|
+
return Object.keys(isPlainObject(config2.providers) ? config2.providers : {}).filter(
|
|
1601
|
+
(key) => foldProviderName(key) === providerName
|
|
1603
1602
|
);
|
|
1604
1603
|
}
|
|
1605
1604
|
function fileSettingsFor(providerName, config2) {
|
|
@@ -1608,7 +1607,13 @@ function fileSettingsFor(providerName, config2) {
|
|
|
1608
1607
|
...keys.filter((key) => key !== providerName),
|
|
1609
1608
|
...keys.filter((key) => key === providerName)
|
|
1610
1609
|
];
|
|
1611
|
-
return Object.assign(
|
|
1610
|
+
return Object.assign(
|
|
1611
|
+
{},
|
|
1612
|
+
...ordered.map((key) => {
|
|
1613
|
+
const entry = config2.providers?.[key];
|
|
1614
|
+
return isPlainObject(entry) ? entry : {};
|
|
1615
|
+
})
|
|
1616
|
+
);
|
|
1612
1617
|
}
|
|
1613
1618
|
function envSettingsFor(providerName, env) {
|
|
1614
1619
|
const settings = {};
|
|
@@ -1674,7 +1679,7 @@ function providerConfiguredInFile(providerName, config2) {
|
|
|
1674
1679
|
function resolveProviderSettings(providerName, config2, env = process.env) {
|
|
1675
1680
|
const mentioned = providerConfiguredInFile(providerName, config2);
|
|
1676
1681
|
const settings = mentioned ? { ...fileSettingsFor(providerName, config2) } : envSettingsFor(providerName, env);
|
|
1677
|
-
if (!settings.proxy && config2.proxy
|
|
1682
|
+
if (!settings.proxy && typeof config2.proxy === "string" && config2.proxy.trim()) {
|
|
1678
1683
|
settings.proxy = config2.proxy.trim();
|
|
1679
1684
|
}
|
|
1680
1685
|
return settings;
|
|
@@ -1721,6 +1726,19 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
1721
1726
|
const typedName = dottedKey.slice(0, dot);
|
|
1722
1727
|
const field = dottedKey.slice(dot + 1);
|
|
1723
1728
|
const providerName = typedName.trim().toLowerCase();
|
|
1729
|
+
if (config2.providers !== void 0 && !isPlainObject(config2.providers)) {
|
|
1730
|
+
throw new Error(
|
|
1731
|
+
`The "providers" section in ${configPath} is not an object. Fix or remove it, then try again.`
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
for (const spelling of fileKeysFor(foldProviderName(providerName), config2)) {
|
|
1735
|
+
const entry = config2.providers?.[spelling];
|
|
1736
|
+
if (entry !== void 0 && !isPlainObject(entry)) {
|
|
1737
|
+
throw new Error(
|
|
1738
|
+
`"providers.${spelling}" in ${configPath} is not an object. Fix or remove it, then try again.`
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1724
1742
|
try {
|
|
1725
1743
|
resolveProvider(providerName);
|
|
1726
1744
|
} catch {
|
|
@@ -1731,7 +1749,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
1731
1749
|
);
|
|
1732
1750
|
}
|
|
1733
1751
|
if (field === "structuredOutput") {
|
|
1734
|
-
if ((
|
|
1752
|
+
if (foldProviderName(providerName) !== "openai") {
|
|
1735
1753
|
throw new Error(
|
|
1736
1754
|
`structuredOutput applies to the openai provider only, not ${providerName}.`
|
|
1737
1755
|
);
|
|
@@ -1781,7 +1799,134 @@ function persistConfig(config2, configPath) {
|
|
|
1781
1799
|
} catch {
|
|
1782
1800
|
}
|
|
1783
1801
|
}
|
|
1802
|
+
function assertReadableConfig(config2, configPath = CONFIG_PATH) {
|
|
1803
|
+
const sentence = (path2, what) => new Error(`${path2} in ${configPath} ${what}. Fix or remove it, then try again.`);
|
|
1804
|
+
if (config2.provider !== void 0 && typeof config2.provider !== "string") {
|
|
1805
|
+
throw sentence('"provider"', "is not a string");
|
|
1806
|
+
}
|
|
1807
|
+
if (config2.proxy !== void 0 && typeof config2.proxy !== "string") {
|
|
1808
|
+
throw sentence('"proxy"', "is not a string");
|
|
1809
|
+
}
|
|
1810
|
+
if (config2.reuse !== void 0 && !isPlainObject(config2.reuse)) {
|
|
1811
|
+
throw sentence('"reuse"', "is not an object");
|
|
1812
|
+
}
|
|
1813
|
+
if (config2.providers !== void 0 && !isPlainObject(config2.providers)) {
|
|
1814
|
+
throw sentence('"providers"', "is not an object");
|
|
1815
|
+
}
|
|
1816
|
+
for (const [name, entry] of Object.entries(
|
|
1817
|
+
isPlainObject(config2.providers) ? config2.providers : {}
|
|
1818
|
+
)) {
|
|
1819
|
+
if (!isPlainObject(entry)) {
|
|
1820
|
+
throw sentence(`"providers.${name}"`, "is not an object");
|
|
1821
|
+
}
|
|
1822
|
+
const offence = entryFieldOffence(entry);
|
|
1823
|
+
if (offence !== null) {
|
|
1824
|
+
throw sentence(`"providers.${name}"`, offence);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
if (config2.saved !== void 0 && !isPlainObject(config2.saved)) {
|
|
1828
|
+
throw sentence('"saved"', "is not an object");
|
|
1829
|
+
}
|
|
1830
|
+
if (config2.guards !== void 0) {
|
|
1831
|
+
if (!isPlainObject(config2.guards)) {
|
|
1832
|
+
throw sentence('"guards"', "is not an object");
|
|
1833
|
+
}
|
|
1834
|
+
for (const field of ["denyModels", "allowModels"]) {
|
|
1835
|
+
const value = config2.guards[field];
|
|
1836
|
+
if (value !== void 0 && !Array.isArray(value)) {
|
|
1837
|
+
throw sentence(`"guards.${field}"`, "is not an array");
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
const flag = config2.guards.denyWhenUnknown;
|
|
1841
|
+
if (flag !== void 0 && typeof flag !== "boolean") {
|
|
1842
|
+
throw sentence('"guards.denyWhenUnknown"', "is not true or false");
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
function entryFieldOffence(entry) {
|
|
1847
|
+
for (const field of ["apiKey", "baseUrl", "model", "proxy"]) {
|
|
1848
|
+
if (entry[field] !== void 0 && typeof entry[field] !== "string") {
|
|
1849
|
+
return `has a non-string ${field}`;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (entry.extraBody !== void 0 && !isPlainObject(entry.extraBody)) {
|
|
1853
|
+
return "has an extraBody that is not an object";
|
|
1854
|
+
}
|
|
1855
|
+
if (entry.structuredOutput !== void 0 && typeof entry.structuredOutput !== "boolean") {
|
|
1856
|
+
return "has a structuredOutput that is not true or false";
|
|
1857
|
+
}
|
|
1858
|
+
return null;
|
|
1859
|
+
}
|
|
1860
|
+
function redactValues(value, keys) {
|
|
1861
|
+
const ordered = [...keys].sort((a, b) => b.length - a.length);
|
|
1862
|
+
const scrub = (text) => {
|
|
1863
|
+
let out = text;
|
|
1864
|
+
for (const key of ordered) {
|
|
1865
|
+
if (key.length > 0) {
|
|
1866
|
+
out = out.split(key).join("[redacted]");
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
return out;
|
|
1870
|
+
};
|
|
1871
|
+
const walk = (node) => {
|
|
1872
|
+
if (typeof node === "string") return scrub(node);
|
|
1873
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
1874
|
+
if (node && typeof node === "object") {
|
|
1875
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
1876
|
+
for (const [key, entry] of Object.entries(node)) {
|
|
1877
|
+
out[key] = walk(entry);
|
|
1878
|
+
}
|
|
1879
|
+
return out;
|
|
1880
|
+
}
|
|
1881
|
+
return node;
|
|
1882
|
+
};
|
|
1883
|
+
return walk(value);
|
|
1884
|
+
}
|
|
1885
|
+
function knownApiKeys(config2, env = process.env) {
|
|
1886
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1887
|
+
const providersRoot = isPlainObject(config2.providers) ? config2.providers : {};
|
|
1888
|
+
for (const entry of Object.values(providersRoot)) {
|
|
1889
|
+
if (isPlainObject(entry) && typeof entry.apiKey === "string") {
|
|
1890
|
+
keys.add(entry.apiKey);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
for (const bindings of Object.values(ENV_BINDINGS)) {
|
|
1894
|
+
const variable = bindings.apiKey;
|
|
1895
|
+
const value = variable ? env[variable]?.trim() : void 0;
|
|
1896
|
+
if (value) {
|
|
1897
|
+
keys.add(value);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
const savedRoot = isPlainObject(config2.saved) ? config2.saved : {};
|
|
1901
|
+
for (const bundles of Object.values(savedRoot)) {
|
|
1902
|
+
if (!isPlainObject(bundles)) continue;
|
|
1903
|
+
for (const bundle of Object.values(bundles)) {
|
|
1904
|
+
if (isPlainObject(bundle) && typeof bundle.apiKey === "string") {
|
|
1905
|
+
keys.add(bundle.apiKey);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
return [...keys];
|
|
1910
|
+
}
|
|
1911
|
+
function bundleFieldOffence(bundle) {
|
|
1912
|
+
const offence = entryFieldOffence(bundle);
|
|
1913
|
+
if (offence !== null) {
|
|
1914
|
+
return offence;
|
|
1915
|
+
}
|
|
1916
|
+
const KNOWN = ["apiKey", "baseUrl", "model", "proxy", "extraBody", "structuredOutput"];
|
|
1917
|
+
if (!KNOWN.some((field) => bundle[field] !== void 0)) {
|
|
1918
|
+
return "holds none of the openai fields, so using it would empty the slot";
|
|
1919
|
+
}
|
|
1920
|
+
return null;
|
|
1921
|
+
}
|
|
1922
|
+
function foldProviderName(name) {
|
|
1923
|
+
const aliases = providerAliases();
|
|
1924
|
+
return Object.hasOwn(aliases, name) ? aliases[name] : name;
|
|
1925
|
+
}
|
|
1784
1926
|
const SAVED_LABEL = /^[a-z][a-z0-9-]*$/;
|
|
1927
|
+
function isPlainObject(value) {
|
|
1928
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1929
|
+
}
|
|
1785
1930
|
function deepEqualJson(a, b) {
|
|
1786
1931
|
if (a === b) return true;
|
|
1787
1932
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
@@ -1801,10 +1946,10 @@ function deepEqualJson(a, b) {
|
|
|
1801
1946
|
}
|
|
1802
1947
|
function savedSlotFor(slot) {
|
|
1803
1948
|
const folded = slot.trim().toLowerCase();
|
|
1804
|
-
const canonical =
|
|
1949
|
+
const canonical = foldProviderName(folded);
|
|
1805
1950
|
if (canonical !== "openai") {
|
|
1806
1951
|
throw new Error(
|
|
1807
|
-
`
|
|
1952
|
+
`Saved copies exist only for the openai slot, the one slot users point at many different gateways. "${slot}" has none.`
|
|
1808
1953
|
);
|
|
1809
1954
|
}
|
|
1810
1955
|
return canonical;
|
|
@@ -1823,25 +1968,62 @@ function saveProviderBundle(slot, label, configPath = CONFIG_PATH) {
|
|
|
1823
1968
|
`Nothing to save: the ${canonical} slot is empty in ${configPath}. Configure it first (modlens config set openai.baseUrl <url>).`
|
|
1824
1969
|
);
|
|
1825
1970
|
}
|
|
1971
|
+
if (config2.saved !== void 0 && !isPlainObject(config2.saved)) {
|
|
1972
|
+
throw new Error(
|
|
1973
|
+
`The "saved" section in ${configPath} is not an object. Fix or remove it, then save again.`
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1826
1976
|
config2.saved ??= {};
|
|
1977
|
+
if (config2.saved[canonical] !== void 0 && !isPlainObject(config2.saved[canonical])) {
|
|
1978
|
+
throw new Error(
|
|
1979
|
+
`"saved.${canonical}" in ${configPath} is not an object. Fix or remove it, then save again.`
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1827
1982
|
config2.saved[canonical] ??= {};
|
|
1983
|
+
const replaced = Object.hasOwn(config2.saved[canonical], label);
|
|
1828
1984
|
config2.saved[canonical][label] = snapshot;
|
|
1829
1985
|
persistConfig(config2, configPath);
|
|
1986
|
+
return replaced;
|
|
1830
1987
|
}
|
|
1831
1988
|
function useProviderBundle(slot, label, discard = false, configPath = CONFIG_PATH) {
|
|
1832
1989
|
const canonical = savedSlotFor(slot);
|
|
1833
1990
|
const config2 = loadConfigFile(configPath);
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1991
|
+
if (config2.providers !== void 0 && !isPlainObject(config2.providers)) {
|
|
1992
|
+
throw new Error(
|
|
1993
|
+
`The "providers" section in ${configPath} is not an object. Fix or remove it, then try again.`
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
if (config2.saved !== void 0 && !isPlainObject(config2.saved)) {
|
|
1997
|
+
throw new Error(
|
|
1998
|
+
`The "saved" section in ${configPath} is not an object. Fix or remove it, then try again.`
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
if (config2.saved?.[canonical] !== void 0 && !isPlainObject(config2.saved[canonical])) {
|
|
2002
|
+
throw new Error(
|
|
2003
|
+
`"saved.${canonical}" in ${configPath} is not an object. Fix or remove it, then try again.`
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
const bundles = config2.saved?.[canonical] ?? {};
|
|
2007
|
+
const known = Object.keys(bundles).sort();
|
|
2008
|
+
if (!Object.hasOwn(bundles, label)) {
|
|
1837
2009
|
throw new Error(
|
|
1838
2010
|
known.length === 0 ? `No saved copies exist for ${canonical} yet. Save the current one first: modlens config save openai <label>.` : `No saved copy named "${label}". Saved: ${known.join(", ")}.`
|
|
1839
2011
|
);
|
|
1840
2012
|
}
|
|
2013
|
+
const bundle = bundles[label];
|
|
2014
|
+
if (!isPlainObject(bundle)) {
|
|
2015
|
+
throw new Error(
|
|
2016
|
+
`The saved copy "${label}" in ${configPath} is not an object (found ${bundle === null ? "null" : Array.isArray(bundle) ? "an array" : typeof bundle}). Fix or remove it under "saved.${canonical}.${label}", then try again.`
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
const offence = bundleFieldOffence(bundle);
|
|
2020
|
+
if (offence !== null) {
|
|
2021
|
+
throw new Error(
|
|
2022
|
+
`The saved copy "${label}" in ${configPath} ${offence}. Fix it under "saved.${canonical}.${label}", then try again.`
|
|
2023
|
+
);
|
|
2024
|
+
}
|
|
1841
2025
|
const current = fileSettingsFor(canonical, config2);
|
|
1842
|
-
const currentSaved = Object.keys(current).length === 0 || Object.values(
|
|
1843
|
-
(entry) => deepEqualJson(entry, current)
|
|
1844
|
-
);
|
|
2026
|
+
const currentSaved = Object.keys(current).length === 0 || Object.values(bundles).some((entry) => deepEqualJson(entry, current));
|
|
1845
2027
|
if (!currentSaved && !discard) {
|
|
1846
2028
|
throw new Error(
|
|
1847
2029
|
`The current ${canonical} settings are not saved under any label and would be lost. Save them first (modlens config save openai <label>) or pass --discard.`
|
|
@@ -1855,6 +2037,11 @@ function useProviderBundle(slot, label, discard = false, configPath = CONFIG_PAT
|
|
|
1855
2037
|
persistConfig(config2, configPath);
|
|
1856
2038
|
}
|
|
1857
2039
|
function setGuardsValue(config2, field, value) {
|
|
2040
|
+
if (config2.guards !== void 0 && !isPlainObject(config2.guards)) {
|
|
2041
|
+
throw new Error(
|
|
2042
|
+
'The "guards" section in the config file is not an object. Fix or remove it, then try again.'
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
1858
2045
|
if (field === "denyModels" || field === "allowModels") {
|
|
1859
2046
|
if (value.trim() === "") {
|
|
1860
2047
|
delete config2.guards?.[field];
|
|
@@ -1895,7 +2082,9 @@ const CONFIG_TEMPLATE = {
|
|
|
1895
2082
|
};
|
|
1896
2083
|
function initConfigFile(configPath = CONFIG_PATH, force = false) {
|
|
1897
2084
|
if (!force && fs.existsSync(configPath)) {
|
|
1898
|
-
throw new Error(
|
|
2085
|
+
throw new Error(
|
|
2086
|
+
`${configPath} already exists. Use --force to overwrite (that also deletes every saved gateway copy under "saved").`
|
|
2087
|
+
);
|
|
1899
2088
|
}
|
|
1900
2089
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1901
2090
|
fs.writeFileSync(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
|
|
@@ -1906,34 +2095,57 @@ function initConfigFile(configPath = CONFIG_PATH, force = false) {
|
|
|
1906
2095
|
}
|
|
1907
2096
|
}
|
|
1908
2097
|
function renderEffectiveConfig(config2, env = process.env) {
|
|
1909
|
-
const
|
|
2098
|
+
const providersRoot = isPlainObject(config2.providers) ? config2.providers : void 0;
|
|
2099
|
+
const canonicalNames = new Set(listProviders());
|
|
1910
2100
|
const providerNames = new Set(
|
|
1911
|
-
Object.keys(
|
|
2101
|
+
Object.keys(providersRoot ?? {}).map((key) => foldProviderName(key)).filter((name) => canonicalNames.has(name))
|
|
1912
2102
|
);
|
|
1913
2103
|
for (const [providerName, bindings] of Object.entries(ENV_BINDINGS)) {
|
|
1914
2104
|
if (Object.values(bindings).some((variable) => env[variable]?.trim())) {
|
|
1915
2105
|
providerNames.add(providerName);
|
|
1916
2106
|
}
|
|
1917
2107
|
}
|
|
1918
|
-
const providers =
|
|
2108
|
+
const providers = /* @__PURE__ */ Object.create(null);
|
|
2109
|
+
const notes = [];
|
|
2110
|
+
for (const [rawName, entry] of Object.entries(providersRoot ?? {})) {
|
|
2111
|
+
if (entry !== void 0 && !isPlainObject(entry)) {
|
|
2112
|
+
notes.push(`providers.${rawName} is not an object; fix or remove it`);
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
if (!canonicalNames.has(foldProviderName(rawName))) {
|
|
2116
|
+
notes.push(`providers.${rawName} is not a known provider; runs ignore it`);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
if (config2.providers !== void 0 && providersRoot === void 0) {
|
|
2120
|
+
providers["(malformed)"] = {
|
|
2121
|
+
providers: 'the "providers" section is not an object; fix or remove it'
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
1919
2124
|
for (const name of [...providerNames].sort()) {
|
|
1920
2125
|
const fileSettings = fileSettingsFor(name, config2);
|
|
1921
2126
|
const mentioned = providerConfiguredInFile(name, config2);
|
|
1922
2127
|
const effective2 = mentioned ? fileSettings : envSettingsFor(name, env);
|
|
1923
2128
|
const source = mentioned ? "file" : "env";
|
|
1924
2129
|
const fields = {};
|
|
2130
|
+
const entryKey = typeof effective2.apiKey === "string" ? effective2.apiKey : void 0;
|
|
2131
|
+
const guard = (shown) => redactSecrets(shown, [entryKey]);
|
|
1925
2132
|
for (const field of STRING_FIELDS) {
|
|
1926
2133
|
const value = effective2[field];
|
|
1927
|
-
if (value
|
|
1928
|
-
|
|
1929
|
-
|
|
2134
|
+
if (value === void 0) {
|
|
2135
|
+
continue;
|
|
2136
|
+
}
|
|
2137
|
+
if (typeof value !== "string") {
|
|
2138
|
+
fields[field] = `(malformed: not a string) (${source})`;
|
|
2139
|
+
continue;
|
|
1930
2140
|
}
|
|
2141
|
+
const shown = field === "apiKey" ? maskKey(value) : field === "proxy" ? maskUrlCredentials(guard(value)) : guard(value);
|
|
2142
|
+
fields[field] = `${shown} (${source})`;
|
|
1931
2143
|
}
|
|
1932
2144
|
if (fileSettings.structuredOutput !== void 0) {
|
|
1933
2145
|
fields.structuredOutput = `${fileSettings.structuredOutput} (file)`;
|
|
1934
2146
|
}
|
|
1935
2147
|
if (fileSettings.extraBody !== void 0) {
|
|
1936
|
-
fields.extraBody = `${JSON.stringify(fileSettings.extraBody)} (file)`;
|
|
2148
|
+
fields.extraBody = `${guard(JSON.stringify(fileSettings.extraBody))} (file)`;
|
|
1937
2149
|
}
|
|
1938
2150
|
if (Object.keys(fields).length > 0 || mentioned) {
|
|
1939
2151
|
providers[name] = fields;
|
|
@@ -1942,32 +2154,51 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
1942
2154
|
const effective = {
|
|
1943
2155
|
providers
|
|
1944
2156
|
};
|
|
1945
|
-
const
|
|
1946
|
-
|
|
2157
|
+
const savedRows = [];
|
|
2158
|
+
const savedRoot = config2.saved;
|
|
2159
|
+
if (savedRoot !== void 0 && !isPlainObject(savedRoot)) {
|
|
2160
|
+
savedRows.push('the "saved" section is not an object; fix or remove it');
|
|
2161
|
+
}
|
|
2162
|
+
for (const [slot, bundles] of Object.entries(isPlainObject(savedRoot) ? savedRoot : {})) {
|
|
2163
|
+
if (!isPlainObject(bundles)) {
|
|
2164
|
+
savedRows.push(`saved.${slot} is not an object; fix or remove it`);
|
|
2165
|
+
continue;
|
|
2166
|
+
}
|
|
1947
2167
|
for (const label of Object.keys(bundles).sort()) {
|
|
1948
2168
|
const bundle = bundles[label];
|
|
2169
|
+
if (!isPlainObject(bundle)) {
|
|
2170
|
+
savedRows.push(`${slot}/${label}: (malformed: not an object; fix or remove it)`);
|
|
2171
|
+
continue;
|
|
2172
|
+
}
|
|
1949
2173
|
const parts = [
|
|
1950
|
-
bundle.model,
|
|
1951
|
-
bundle.baseUrl,
|
|
1952
|
-
bundle.apiKey
|
|
2174
|
+
typeof bundle.model === "string" ? bundle.model : void 0,
|
|
2175
|
+
typeof bundle.baseUrl === "string" ? bundle.baseUrl : void 0,
|
|
2176
|
+
bundle.apiKey === void 0 ? "no key" : typeof bundle.apiKey === "string" ? `key ${maskKey(bundle.apiKey)}` : "key (malformed: not a string)"
|
|
1953
2177
|
].filter(Boolean);
|
|
1954
|
-
|
|
1955
|
-
savedView[slot][label] = parts.join(" @ ");
|
|
2178
|
+
savedRows.push(`${slot}/${label}: ${parts.join(" @ ")}`);
|
|
1956
2179
|
}
|
|
1957
2180
|
}
|
|
1958
|
-
if (
|
|
1959
|
-
effective.saved =
|
|
2181
|
+
if (savedRows.length > 0) {
|
|
2182
|
+
effective.saved = savedRows;
|
|
1960
2183
|
}
|
|
1961
|
-
if (config2.provider
|
|
2184
|
+
if (typeof config2.provider === "string" && config2.provider.trim()) {
|
|
1962
2185
|
effective.provider = config2.provider.trim();
|
|
2186
|
+
} else if (config2.provider !== void 0 && typeof config2.provider !== "string") {
|
|
2187
|
+
effective.provider = "(malformed: not a string)";
|
|
1963
2188
|
}
|
|
1964
|
-
if (config2.proxy
|
|
2189
|
+
if (config2.proxy !== void 0 && typeof config2.proxy !== "string") {
|
|
2190
|
+
effective.proxy = "(malformed: not a string)";
|
|
2191
|
+
} else if (config2.proxy?.trim()) {
|
|
1965
2192
|
effective.proxy = `${maskUrlCredentials(config2.proxy.trim())} (file)`;
|
|
1966
2193
|
} else if (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy) {
|
|
1967
2194
|
const raw = env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy;
|
|
1968
2195
|
effective.proxy = `${maskUrlCredentials(raw)} (env)`;
|
|
1969
2196
|
}
|
|
1970
|
-
if (config2.guards) {
|
|
2197
|
+
if (config2.guards !== void 0 && !isPlainObject(config2.guards)) {
|
|
2198
|
+
effective.guards = {
|
|
2199
|
+
"(malformed)": 'the "guards" section is not an object; fix or remove it'
|
|
2200
|
+
};
|
|
2201
|
+
} else if (config2.guards) {
|
|
1971
2202
|
const guards = {};
|
|
1972
2203
|
if (config2.guards.denyModels !== void 0) {
|
|
1973
2204
|
guards.denyModels = `${JSON.stringify(config2.guards.denyModels)} (file)`;
|
|
@@ -1982,15 +2213,31 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
1982
2213
|
effective.guards = guards;
|
|
1983
2214
|
}
|
|
1984
2215
|
}
|
|
1985
|
-
if (config2.reuse &&
|
|
1986
|
-
effective.reuse =
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
)
|
|
2216
|
+
if (config2.reuse !== void 0 && !isPlainObject(config2.reuse)) {
|
|
2217
|
+
effective.reuse = {
|
|
2218
|
+
"(malformed)": 'the "reuse" section is not an object; fix or remove it'
|
|
2219
|
+
};
|
|
2220
|
+
} else if (config2.reuse && Object.keys(config2.reuse).length > 0) {
|
|
2221
|
+
const reuse = {};
|
|
2222
|
+
for (const harness of REUSE_HARNESSES) {
|
|
2223
|
+
const granted = config2.reuse[harness];
|
|
2224
|
+
if (granted !== void 0) {
|
|
2225
|
+
reuse[harness] = `${granted} (file)`;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
for (const stranger of Object.keys(config2.reuse).filter(
|
|
2229
|
+
(key) => !REUSE_HARNESSES.includes(key)
|
|
2230
|
+
)) {
|
|
2231
|
+
notes.push(`reuse.${stranger} is not a known harness; runs ignore it`);
|
|
2232
|
+
}
|
|
2233
|
+
if (Object.keys(reuse).length > 0) {
|
|
2234
|
+
effective.reuse = reuse;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
if (notes.length > 0) {
|
|
2238
|
+
effective.notes = notes;
|
|
1992
2239
|
}
|
|
1993
|
-
return JSON.stringify(effective, null, 2);
|
|
2240
|
+
return JSON.stringify(redactValues(effective, knownApiKeys(config2, env)), null, 2);
|
|
1994
2241
|
}
|
|
1995
2242
|
function maskKey(key) {
|
|
1996
2243
|
if (key.length <= 8) {
|
|
@@ -3163,6 +3410,7 @@ async function analyzeImage(options) {
|
|
|
3163
3410
|
validateInputFile(resolvedInput.source);
|
|
3164
3411
|
}
|
|
3165
3412
|
const config2 = options.config ?? loadConfigFile();
|
|
3413
|
+
assertReadableConfig(config2);
|
|
3166
3414
|
const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : composeChain(resolvedInput.kind, config2, options.autoOptions);
|
|
3167
3415
|
const named = options.provider ?? config2.provider?.trim();
|
|
3168
3416
|
if (named) {
|
|
@@ -4365,6 +4613,7 @@ function inspectConfigFile(configPath) {
|
|
|
4365
4613
|
function buildDoctorReport(input) {
|
|
4366
4614
|
const env = input.env ?? process.env;
|
|
4367
4615
|
const configPath = input.configPath ?? CONFIG_PATH;
|
|
4616
|
+
assertReadableConfig(input.config, configPath);
|
|
4368
4617
|
const harnessDetection = detectHarnessDetailed();
|
|
4369
4618
|
const guardDetection = detectActiveModel({
|
|
4370
4619
|
cwd: process.cwd(),
|
|
@@ -4746,7 +4995,7 @@ function parsePositiveInt(raw, flag) {
|
|
|
4746
4995
|
}
|
|
4747
4996
|
return Number.parseInt(raw, 10);
|
|
4748
4997
|
}
|
|
4749
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.21.
|
|
4998
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.21.1");
|
|
4750
4999
|
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(
|
|
4751
5000
|
"--extra-body <json>",
|
|
4752
5001
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|
|
@@ -4856,7 +5105,7 @@ program.command("doctor").description(
|
|
|
4856
5105
|
configPath: CONFIG_PATH,
|
|
4857
5106
|
// Lets doctor name an installed skill copy that is older than
|
|
4858
5107
|
// the CLI reporting on it (issue #33).
|
|
4859
|
-
version: "3.21.
|
|
5108
|
+
version: "3.21.1"
|
|
4860
5109
|
});
|
|
4861
5110
|
const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
|
|
4862
5111
|
process.stdout.write(`${output}
|
|
@@ -4895,9 +5144,12 @@ config.command("save <slot> <label>").description(
|
|
|
4895
5144
|
"Snapshot a provider slot under a label (openai only), so switching gateways never loses a key"
|
|
4896
5145
|
).action((slot, label) => {
|
|
4897
5146
|
try {
|
|
4898
|
-
saveProviderBundle(slot, label);
|
|
4899
|
-
process.stdout.write(
|
|
4900
|
-
`)
|
|
5147
|
+
const replaced = saveProviderBundle(slot, label);
|
|
5148
|
+
process.stdout.write(
|
|
5149
|
+
replaced ? `Saved the ${slot} slot as "${label}", replacing the previous snapshot (${CONFIG_PATH})
|
|
5150
|
+
` : `Saved the ${slot} slot as "${label}" in ${CONFIG_PATH}
|
|
5151
|
+
`
|
|
5152
|
+
);
|
|
4901
5153
|
} catch (error) {
|
|
4902
5154
|
process.stderr.write(
|
|
4903
5155
|
`Error: ${error instanceof Error ? error.message : String(error)}
|
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.21.
|
|
58
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.1
|
|
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.21.
|
|
84
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.21.1
|
|
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.21.
|
|
58
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.21.1
|
|
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.21.
|
|
74
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.21.1
|
|
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.21.
|
|
166
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.21.1
|
|
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.21.
|
|
181
|
+
- '@liustack/modlens@3.21.1'
|
|
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.21.
|
|
147
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.21.1
|
|
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.21.0
|
|
|
153
153
|
|
|
154
154
|
```yaml
|
|
155
155
|
minimumReleaseAgeExclude:
|
|
156
|
-
- '@liustack/modlens@3.21.
|
|
156
|
+
- '@liustack/modlens@3.21.1'
|
|
157
157
|
```
|
|
158
158
|
|
|
159
159
|
或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
|
package/dsh/index.js
CHANGED
|
@@ -36,11 +36,16 @@ export const MEDIA_EXT = {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function apply(ctx, config = {}) {
|
|
39
|
+
// One evidence cache for the whole plugin: every wrapper route and the
|
|
40
|
+
// auto-read path share it, so the same pasted attachment is read once,
|
|
41
|
+
// whichever surface asks first (issue #68; auto-read used to bypass
|
|
42
|
+
// caching entirely and re-read every image on every step).
|
|
43
|
+
const evidenceCache = new Map()
|
|
39
44
|
// Off by default since the vision provider converts at request time and
|
|
40
45
|
// keeps the durable log (and the UI thumbnail) intact; turn it on only for
|
|
41
46
|
// setups where images enter through a provider this plugin does not wrap.
|
|
42
47
|
if (config.autoRead === true) {
|
|
43
|
-
registerAutoRead(ctx)
|
|
48
|
+
registerAutoRead(ctx, evidenceCache)
|
|
44
49
|
}
|
|
45
50
|
// The provider ids this plugin registered itself. The takeover verdict has
|
|
46
51
|
// to skip them: our wrapper models are synthetic twins of upstream ones,
|
|
@@ -50,7 +55,7 @@ export function apply(ctx, config = {}) {
|
|
|
50
55
|
// wrappers land, including the later sweeps, and read by the verdict.
|
|
51
56
|
const ownProviders = new Set()
|
|
52
57
|
if (config.visionProvider !== false) {
|
|
53
|
-
registerVisionProvider(ctx, config, ownProviders)
|
|
58
|
+
registerVisionProvider(ctx, config, ownProviders, evidenceCache)
|
|
54
59
|
}
|
|
55
60
|
// Paste-to-path: the browser half (dsh/client.js) intercepts image pastes
|
|
56
61
|
// and POSTs the bytes here; the file lands in a private temp dir and the
|
|
@@ -541,7 +546,7 @@ function restoreUpstreamSource(messages, wrapperId, upstream) {
|
|
|
541
546
|
return changed ? out : messages
|
|
542
547
|
}
|
|
543
548
|
|
|
544
|
-
function registerVisionProvider(ctx, config, ownProviders) {
|
|
549
|
+
function registerVisionProvider(ctx, config, ownProviders, evidenceCache) {
|
|
545
550
|
// Wrap only the text-only members of these families. Their own vision
|
|
546
551
|
// models (present or future: deepseek-vl/ocr/janus, glm-4.5v, glm-5v-...)
|
|
547
552
|
// need no bridge and are excluded by name and by declared modality.
|
|
@@ -640,7 +645,7 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
640
645
|
yield* ctx.llm.stream({ ...options, provider: upstream, messages })
|
|
641
646
|
})()
|
|
642
647
|
},
|
|
643
|
-
evidenceCache
|
|
648
|
+
evidenceCache,
|
|
644
649
|
})
|
|
645
650
|
registrations.set(upstream, { providerId, registration, state })
|
|
646
651
|
// Trusted as ours only on a registration this call actually made. A
|
|
@@ -908,57 +913,182 @@ function registerVisionProvider(ctx, config, ownProviders) {
|
|
|
908
913
|
}
|
|
909
914
|
}
|
|
910
915
|
|
|
911
|
-
// The same pasted attachment rides every later step of its session,
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
|
|
916
|
+
// The same pasted attachment rides every later step of its session, and the
|
|
917
|
+
// provider caches by prefix, so what this cache protects is not just the
|
|
918
|
+
// engine bill but the BYTES of the rewritten history: a message whose text
|
|
919
|
+
// changes between steps busts the provider's context cache for everything
|
|
920
|
+
// after it (issue #68). So it stores promises (concurrent readers join the
|
|
921
|
+
// first run), keeps successes for good, and holds failures for a cooldown
|
|
922
|
+
// instead of evicting them on settle: a broken engine is probed once per
|
|
923
|
+
// cooldown per attachment rather than once per step, the placeholder text is
|
|
924
|
+
// byte-stable while the outcome is unchanged, and the first step after the
|
|
925
|
+
// cooldown retries, so a fixed engine is picked up without a restart. It
|
|
926
|
+
// caps itself LRU-style so a long-lived Web profile cannot hoard evidence
|
|
927
|
+
// text forever.
|
|
928
|
+
const EVIDENCE_CACHE_LIMIT = 256
|
|
929
|
+
const EVIDENCE_FAILURE_COOLDOWN_MS = 60_000
|
|
930
|
+
// Monotonic, so an NTP step backwards cannot freeze a cooling failure for
|
|
931
|
+
// the size of the jump: a recovered engine is re-probed one cooldown after
|
|
932
|
+
// the failure, whatever the wall clock did in between.
|
|
933
|
+
const monotonicNow = () => performance.now()
|
|
918
934
|
|
|
919
|
-
|
|
920
|
-
|
|
935
|
+
/**
|
|
936
|
+
* A cache key that survives replay: the same attachment serialized with a
|
|
937
|
+
* different key order used to miss its own entry, re-run the engine, and
|
|
938
|
+
* rewrite the history with a fresh reading (issue #68).
|
|
939
|
+
*/
|
|
940
|
+
function evidenceKey(value) {
|
|
941
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
942
|
+
if (Array.isArray(value)) return `[${value.map(evidenceKey).join(',')}]`
|
|
943
|
+
const keys = Object.keys(value).sort()
|
|
944
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${evidenceKey(value[key])}`).join(',')}}`
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function cachedEvidence(ctx, adapter, block, walk) {
|
|
948
|
+
const key = evidenceKey(block.attachment ?? block)
|
|
949
|
+
// Everything the current walk touches is pinned for the walk's duration:
|
|
950
|
+
// eviction picks victims outside the union of every open walk, or nothing.
|
|
951
|
+
walk?.pin(key)
|
|
921
952
|
const hit = adapter.evidenceCache.get(key)
|
|
922
953
|
if (hit !== undefined) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
954
|
+
const cooling = typeof hit === 'object' && hit !== null && 'retryAfter' in hit
|
|
955
|
+
if (!cooling || monotonicNow() < hit.retryAfter) {
|
|
956
|
+
// Refresh recency: Map iteration order is insertion order.
|
|
957
|
+
adapter.evidenceCache.delete(key)
|
|
958
|
+
adapter.evidenceCache.set(key, hit)
|
|
959
|
+
return cooling ? Promise.resolve(hit.block) : hit
|
|
960
|
+
}
|
|
961
|
+
// Cooldown over: fall through into one fresh probe.
|
|
927
962
|
}
|
|
928
963
|
// Deliberately no caller signal: a shared entry must not die with its first
|
|
929
964
|
// caller (their abort used to cancel every concurrent joiner). A cancelled
|
|
930
965
|
// caller simply stops awaiting; the read finishes and the cache keeps it.
|
|
931
966
|
const pending = readImageBlock(ctx, block, undefined).then(
|
|
932
967
|
(evidence) => {
|
|
933
|
-
// Only
|
|
934
|
-
// the key re-populated by a newer read meanwhile.
|
|
968
|
+
// Only replace our own entry: this promise may have been LRU-evicted
|
|
969
|
+
// and the key re-populated by a newer read meanwhile.
|
|
935
970
|
if (!evidence.ok && adapter.evidenceCache.get(key) === pending) {
|
|
936
|
-
adapter.evidenceCache.
|
|
971
|
+
adapter.evidenceCache.set(key, {
|
|
972
|
+
retryAfter: monotonicNow() + EVIDENCE_FAILURE_COOLDOWN_MS,
|
|
973
|
+
block: evidence.block,
|
|
974
|
+
})
|
|
937
975
|
}
|
|
938
976
|
return evidence.block
|
|
939
977
|
},
|
|
940
|
-
(
|
|
978
|
+
() => {
|
|
941
979
|
// readImageBlock never rejects by contract; this is the belt for a
|
|
942
980
|
// future refactor breaking that, so a rejected promise cannot lodge in
|
|
943
|
-
// the cache forever.
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
947
|
-
return {
|
|
981
|
+
// the cache forever. Same stable text as the engine stage: the detail
|
|
982
|
+
// belongs in the harness log, never in the wire history.
|
|
983
|
+
const block = Object.freeze({
|
|
948
984
|
type: 'text',
|
|
949
|
-
text:
|
|
950
|
-
|
|
951
|
-
|
|
985
|
+
text: FAILURE_TEXTS.engine,
|
|
986
|
+
})
|
|
987
|
+
if (adapter.evidenceCache.get(key) === pending) {
|
|
988
|
+
adapter.evidenceCache.set(key, {
|
|
989
|
+
retryAfter: monotonicNow() + EVIDENCE_FAILURE_COOLDOWN_MS,
|
|
990
|
+
block,
|
|
991
|
+
})
|
|
952
992
|
}
|
|
993
|
+
return block
|
|
953
994
|
},
|
|
954
995
|
)
|
|
955
996
|
adapter.evidenceCache.set(key, pending)
|
|
956
|
-
|
|
957
|
-
adapter.evidenceCache.delete(adapter.evidenceCache.keys().next().value)
|
|
958
|
-
}
|
|
997
|
+
trimEvidenceCache(adapter.evidenceCache)
|
|
959
998
|
return pending
|
|
960
999
|
}
|
|
961
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Overflow evicts the least-recently-used entry OUTSIDE the current walk's
|
|
1003
|
+
* working set, and nothing when the whole cache IS the working set.
|
|
1004
|
+
*
|
|
1005
|
+
* Both naive policies fail a real session shape. Oldest-first thrashes a
|
|
1006
|
+
* front-to-back history walk once it passes the cap (the 257th attachment
|
|
1007
|
+
* evicts the 1st, whose miss next step evicts the 2nd, and so on: the #68
|
|
1008
|
+
* retry storm wearing a cap). Newest-first survives that but starves a NEW
|
|
1009
|
+
* session on a long-lived profile: the cache sits full of a previous
|
|
1010
|
+
* session's entries, every new attachment evicts the previous new one, and
|
|
1011
|
+
* the storm returns with the old entries pinned in place forever.
|
|
1012
|
+
*
|
|
1013
|
+
* Pinning the walk resolves both. A new session's working set evicts stale
|
|
1014
|
+
* entries one by one, in LRU order, and resides in full; a single history
|
|
1015
|
+
* larger than the cap keeps everything it is walking (the cap goes soft for
|
|
1016
|
+
* the walk's duration, bounded by that history's own length) and is stable
|
|
1017
|
+
* again on the next step. The remaining honesty: an entry genuinely evicted
|
|
1018
|
+
* and later re-read by a healthy engine may be worded differently, so bytes
|
|
1019
|
+
* can move with the outcome unchanged; the cap exists so a long-lived Web
|
|
1020
|
+
* profile cannot hoard evidence forever.
|
|
1021
|
+
*/
|
|
1022
|
+
// Active pins per cache, as exact refcounts: victim selection must see EVERY
|
|
1023
|
+
// walk's pins, or two interleaved sessions on the shared Map evict each
|
|
1024
|
+
// other's in-flight entries and the full-thrash storm returns with company.
|
|
1025
|
+
// Refcounts rather than a set of sets, so the union is exact (two walks
|
|
1026
|
+
// pinning the same keys is one union, not a doubled sum) and every check is
|
|
1027
|
+
// O(1). WeakMap so a cache that dies takes its registry with it.
|
|
1028
|
+
const ACTIVE_PINS = new WeakMap()
|
|
1029
|
+
|
|
1030
|
+
function activePinsFor(cache) {
|
|
1031
|
+
let counts = ACTIVE_PINS.get(cache)
|
|
1032
|
+
if (!counts) {
|
|
1033
|
+
counts = new Map()
|
|
1034
|
+
ACTIVE_PINS.set(cache, counts)
|
|
1035
|
+
}
|
|
1036
|
+
return counts
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Open one walk over a cache: everything the walk pins stays unevictable
|
|
1041
|
+
* until end() runs, whichever walk a trim happens under. Ending the walk
|
|
1042
|
+
* releases its share of the pins; entries above the cap then linger until
|
|
1043
|
+
* the next miss trims them, which is the stability-over-punctuality trade
|
|
1044
|
+
* the cap makes on purpose.
|
|
1045
|
+
*/
|
|
1046
|
+
export function beginEvidenceWalk(cache) {
|
|
1047
|
+
const counts = activePinsFor(cache)
|
|
1048
|
+
const mine = new Set()
|
|
1049
|
+
return {
|
|
1050
|
+
pin: (key) => {
|
|
1051
|
+
if (mine.has(key)) return
|
|
1052
|
+
mine.add(key)
|
|
1053
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
1054
|
+
},
|
|
1055
|
+
end: () => {
|
|
1056
|
+
for (const key of mine) {
|
|
1057
|
+
const left = (counts.get(key) ?? 1) - 1
|
|
1058
|
+
if (left <= 0) {
|
|
1059
|
+
counts.delete(key)
|
|
1060
|
+
} else {
|
|
1061
|
+
counts.set(key, left)
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
mine.clear()
|
|
1065
|
+
},
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export function trimEvidenceCache(cache) {
|
|
1070
|
+
const counts = ACTIVE_PINS.get(cache)
|
|
1071
|
+
while (cache.size > EVIDENCE_CACHE_LIMIT) {
|
|
1072
|
+
// Exact union early-out: when every key in the cache is pinned by some
|
|
1073
|
+
// open walk, a scan would find nothing; counts.size is the union's true
|
|
1074
|
+
// cardinality, so overlapping walks cannot inflate it.
|
|
1075
|
+
if (counts !== undefined && counts.size >= cache.size) {
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
let victim
|
|
1079
|
+
for (const key of cache.keys()) {
|
|
1080
|
+
if (counts === undefined || !counts.has(key)) {
|
|
1081
|
+
victim = key
|
|
1082
|
+
break
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (victim === undefined) {
|
|
1086
|
+
return
|
|
1087
|
+
}
|
|
1088
|
+
cache.delete(victim)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
962
1092
|
/**
|
|
963
1093
|
* Wait on a shared promise without inheriting its lifetime: the caller's
|
|
964
1094
|
* abort rejects THIS wait immediately, while the underlying read keeps
|
|
@@ -1017,15 +1147,23 @@ async function convertBlocks(blocks, convertOne) {
|
|
|
1017
1147
|
|
|
1018
1148
|
async function convertImagesToEvidence(ctx, messages, signal, adapter) {
|
|
1019
1149
|
const out = []
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1150
|
+
// One walk per conversion: its pins are visible to every trim on the
|
|
1151
|
+
// shared cache until end(), so concurrent sessions cannot evict each
|
|
1152
|
+
// other's in-flight work.
|
|
1153
|
+
const walk = beginEvidenceWalk(adapter.evidenceCache)
|
|
1154
|
+
try {
|
|
1155
|
+
for (const message of messages) {
|
|
1156
|
+
if (!contentHasImage(message.content)) {
|
|
1157
|
+
out.push(message)
|
|
1158
|
+
continue
|
|
1159
|
+
}
|
|
1160
|
+
const content = await convertBlocks(message.content, (block) =>
|
|
1161
|
+
abortableWait(cachedEvidence(ctx, adapter, block, walk), signal),
|
|
1162
|
+
)
|
|
1163
|
+
out.push({ ...message, content })
|
|
1024
1164
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
)
|
|
1028
|
-
out.push({ ...message, content })
|
|
1165
|
+
} finally {
|
|
1166
|
+
walk.end()
|
|
1029
1167
|
}
|
|
1030
1168
|
return out
|
|
1031
1169
|
}
|
|
@@ -1038,7 +1176,7 @@ async function convertImagesToEvidence(ctx, messages, signal, adapter) {
|
|
|
1038
1176
|
* injectors) see and shape the same final message set; a failed read degrades
|
|
1039
1177
|
* to an explanatory text block instead of rejecting the step.
|
|
1040
1178
|
*/
|
|
1041
|
-
function registerAutoRead(ctx) {
|
|
1179
|
+
function registerAutoRead(ctx, evidenceCache) {
|
|
1042
1180
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
1043
1181
|
const decision = await next()
|
|
1044
1182
|
if (decision.kind !== 'enter') {
|
|
@@ -1048,32 +1186,63 @@ function registerAutoRead(ctx) {
|
|
|
1048
1186
|
return decision
|
|
1049
1187
|
}
|
|
1050
1188
|
const messages = []
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1189
|
+
// One walk per pre-step, spanning every message in it; pins are visible
|
|
1190
|
+
// to every trim on the shared cache until the walk ends.
|
|
1191
|
+
const walk = beginEvidenceWalk(evidenceCache)
|
|
1192
|
+
try {
|
|
1193
|
+
for (const message of decision.messages) {
|
|
1194
|
+
if (!contentHasImage(message.content)) {
|
|
1195
|
+
messages.push(message)
|
|
1196
|
+
continue
|
|
1197
|
+
}
|
|
1198
|
+
const content = await convertBlocks(message.content, (block) =>
|
|
1199
|
+
// The same cache the wrapper routes use: auto-read used to re-read
|
|
1200
|
+
// every image on every step, healthy engine or not (issue #68).
|
|
1201
|
+
abortableWait(cachedEvidence(ctx, { evidenceCache }, block, walk), payload.signal),
|
|
1202
|
+
)
|
|
1203
|
+
messages.push({ ...message, content })
|
|
1055
1204
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
async (block) => (await readImageBlock(ctx, block, payload.signal)).block,
|
|
1059
|
-
)
|
|
1060
|
-
messages.push({ ...message, content })
|
|
1205
|
+
} finally {
|
|
1206
|
+
walk.end()
|
|
1061
1207
|
}
|
|
1062
1208
|
return { kind: 'enter', messages }
|
|
1063
1209
|
})
|
|
1064
1210
|
}
|
|
1065
1211
|
|
|
1212
|
+
/**
|
|
1213
|
+
* The wire history must not change bytes unless the outcome changed, so a
|
|
1214
|
+
* failure's placeholder is a constant per failure stage, never the attempt's
|
|
1215
|
+
* own error text: the same broken engine words its failures differently on
|
|
1216
|
+
* every try, and each wording rewrote the history and busted the provider's
|
|
1217
|
+
* prefix cache for the rest of the session (issue #68). The attempt's detail
|
|
1218
|
+
* goes to the harness log, which never rides a request.
|
|
1219
|
+
*/
|
|
1220
|
+
const FAILURE_TEXTS = {
|
|
1221
|
+
store:
|
|
1222
|
+
'[A pasted image could not be read: the attachment store did not return it. Tell the user, and suggest running `npx @liustack/modlens doctor`.]',
|
|
1223
|
+
media:
|
|
1224
|
+
'[A pasted image could not be read: its media type is not supported. Tell the user, and suggest running `npx @liustack/modlens doctor`.]',
|
|
1225
|
+
engine:
|
|
1226
|
+
'[A pasted image could not be read: the vision engine failed. Tell the user, and suggest running `npx @liustack/modlens doctor`.]',
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1066
1229
|
/**
|
|
1067
1230
|
* Read one image block into an evidence text block. Never throws: failures
|
|
1068
1231
|
* degrade to an explanatory block with `ok: false`, so callers can decide
|
|
1069
|
-
* what a failure means (the pre-step keeps the step going, the cache
|
|
1070
|
-
*
|
|
1232
|
+
* what a failure means (the pre-step keeps the step going, the cache holds
|
|
1233
|
+
* it only for a cooldown).
|
|
1071
1234
|
*/
|
|
1072
1235
|
async function readImageBlock(ctx, block, signal) {
|
|
1073
1236
|
const { mkdtemp, rm, writeFile } = await import('node:fs/promises')
|
|
1074
1237
|
const { tmpdir } = await import('node:os')
|
|
1075
1238
|
const { join } = await import('node:path')
|
|
1076
1239
|
let dir
|
|
1240
|
+
// Which stage failed decides the (constant) placeholder text below. Even
|
|
1241
|
+
// the media stage carries no variable part: the type string arrives from
|
|
1242
|
+
// paste metadata and replayed content, so it is attacker-shaped, and an
|
|
1243
|
+
// unbounded newline-carrying value on the wire is a fake turn boundary.
|
|
1244
|
+
// The concrete type goes to the harness log with the rest of the detail.
|
|
1245
|
+
let stage = 'store'
|
|
1077
1246
|
try {
|
|
1078
1247
|
// StoredImageAttachment carries { ref, data: Uint8Array }; the media type
|
|
1079
1248
|
// rides the reference (verified against dsh attachment/src/types.ts).
|
|
@@ -1088,8 +1257,10 @@ async function readImageBlock(ctx, block, signal) {
|
|
|
1088
1257
|
if (!ext) {
|
|
1089
1258
|
// Refusing beats disguising: a fake .png suffix would make the CLI (and
|
|
1090
1259
|
// the provider behind it) judge mislabelled bytes.
|
|
1260
|
+
stage = 'media'
|
|
1091
1261
|
throw new Error(`unsupported pasted media type ${mediaType ?? '(none declared)'}`)
|
|
1092
1262
|
}
|
|
1263
|
+
stage = 'engine'
|
|
1093
1264
|
dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-'))
|
|
1094
1265
|
const file = join(dir, `paste${ext}`)
|
|
1095
1266
|
await writeFile(file, Buffer.from(stored.data), { mode: 0o600 })
|
|
@@ -1105,20 +1276,22 @@ async function readImageBlock(ctx, block, signal) {
|
|
|
1105
1276
|
const parsed = JSON.parse(stdout)
|
|
1106
1277
|
return {
|
|
1107
1278
|
ok: true,
|
|
1108
|
-
|
|
1279
|
+
// Frozen: the same object rides every later step from the cache, and a
|
|
1280
|
+
// downstream listener mutating it would silently rewrite history.
|
|
1281
|
+
block: Object.freeze({
|
|
1109
1282
|
type: 'text',
|
|
1110
1283
|
text: `[Pasted image, read by the modlens vision bridge]\n${renderEvidence(parsed.result)}`,
|
|
1111
|
-
},
|
|
1284
|
+
}),
|
|
1112
1285
|
}
|
|
1113
1286
|
} catch (error) {
|
|
1287
|
+
const detail = error instanceof Error ? error.message.slice(0, 300) : String(error)
|
|
1288
|
+
console.error(`[modlens] image read failed (${stage}): ${detail}`)
|
|
1114
1289
|
return {
|
|
1115
1290
|
ok: false,
|
|
1116
|
-
block: {
|
|
1291
|
+
block: Object.freeze({
|
|
1117
1292
|
type: 'text',
|
|
1118
|
-
text:
|
|
1119
|
-
|
|
1120
|
-
}. Tell the user, and suggest running \`npx @liustack/modlens doctor\`.]`,
|
|
1121
|
-
},
|
|
1293
|
+
text: FAILURE_TEXTS[stage],
|
|
1294
|
+
}),
|
|
1122
1295
|
}
|
|
1123
1296
|
} finally {
|
|
1124
1297
|
if (dir) {
|
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.21.
|
|
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.21.1):
|
|
24
24
|
|
|
25
|
-
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.21.
|
|
26
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.21.
|
|
27
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.21.
|
|
25
|
+
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.21.1: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.21.1 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.21.1 <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.
|
|
@@ -19,13 +19,18 @@ modlens config set <provider>.<field> <value> # fields: apiKey, baseUrl, model
|
|
|
19
19
|
|
|
20
20
|
## The file's exact shape
|
|
21
21
|
|
|
22
|
-
Everything lives under
|
|
22
|
+
Everything lives under six top-level keys, all optional. This example shows every supported key and field at once (a real file only needs what you use). A missing file means all defaults. Provider settings sit under `providers.<name>`, not at the top level, which is the mistake hand-editors make most.
|
|
23
23
|
|
|
24
24
|
```json
|
|
25
25
|
{
|
|
26
26
|
"provider": "gemini-api",
|
|
27
27
|
"proxy": "http://127.0.0.1:7890",
|
|
28
28
|
"reuse": { "claude": true, "codex": true, "opencode": false, "pi": true, "grok": true },
|
|
29
|
+
"saved": {
|
|
30
|
+
"openai": {
|
|
31
|
+
"dashscope": { "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", "apiKey": "sk-...", "model": "qwen3-vl-plus" }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
29
34
|
"guards": {
|
|
30
35
|
"allowModels": ["deepseek-v4-*", "glm-5.*", "minimax-m2.5*", "qwen3-coder*"],
|
|
31
36
|
"denyModels": ["glm-*v*", "deepseek-vl*"],
|
|
@@ -19,13 +19,18 @@ modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、mo
|
|
|
19
19
|
|
|
20
20
|
## 配置文件的完整形状
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
所有内容都在六个顶层键之下,全部可选。下面的示例一次性展示了所有支持的键和字段(真实文件只需要写你用到的部分)。文件不存在就全用默认值。provider 的设置放在 `providers.<name>` 下面,不在顶层,手工编辑最常犯的就是这个错。
|
|
23
23
|
|
|
24
24
|
```json
|
|
25
25
|
{
|
|
26
26
|
"provider": "gemini-api",
|
|
27
27
|
"proxy": "http://127.0.0.1:7890",
|
|
28
28
|
"reuse": { "claude": true, "codex": true, "opencode": false, "pi": true, "grok": true },
|
|
29
|
+
"saved": {
|
|
30
|
+
"openai": {
|
|
31
|
+
"dashscope": { "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", "apiKey": "sk-...", "model": "qwen3-vl-plus" }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
29
34
|
"guards": {
|
|
30
35
|
"allowModels": ["deepseek-v4-*", "glm-5.*", "minimax-m2.5*", "qwen3-coder*"],
|
|
31
36
|
"denyModels": ["glm-*v*", "deepseek-vl*"],
|
|
@@ -62,6 +67,7 @@ modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、mo
|
|
|
62
67
|
- `providers.<name>.<field>`:共六个字段,`apiKey`、`baseUrl`、`model`、`proxy`、`extraBody`、`structuredOutput`(仅 openai 路线)。每个 provider 条目都可选,条目里的每个字段也都可选。别名键同样会被读取(存在 `gemini` 下的设置在解析到 `gemini-api` 时也能找到),冲突时标准键胜出。
|
|
63
68
|
- `providers.<name>.extraBody`:一个 JSON 对象,合并进 API provider(`gemini-api`、`openai`、`anthropic`)的请求体,用来传厂商有而 modlens 没有对应参数的开关。最常见的用途是关掉思考,见下文小节。嵌套对象逐键合并,所以加一个开关不会动到该块里的其他内容。承载图片、提示词和各路线自身强制机制的字段会被拒绝,报错会点名该字段。`openai` 路线上的 `response_format` 不在此列:在那里设置它就是有意替换掉 modlens 本来会发的那份 schema。三个 CLI provider 不发请求体,所以在 `antigravity-cli`、`claude-cli` 或 `kimi-cli` 上运行时它会被忽略,并在 `meta.warnings` 里说明。
|
|
64
69
|
- `providers.openai.structuredOutput`:设为 `true` 时,让 OpenAI 兼容网关自己强制执行视觉契约,以 `response_format: json_schema` 的严格形式发出。默认关闭,因为不支持结构化输出的网关会对这个字段返回 400。你在 `extraBody` 里设的 `response_format` 优先级更高。
|
|
70
|
+
- `saved.openai.<标签>`:openai 槽的命名存档,只有 `modlens config save openai <标签>` 写入、`modlens config use openai <标签>` 整包换入。切换网关不再丢上一个端点的 key:`use` 拒绝覆盖没有任何标签保存过的活跃槽(`--discard` 表示明确放弃)。解析、guard、failover、环境变量规则都不读这个区,活跃槽始终是唯一生效的 openai 路由。
|
|
65
71
|
- `guards`:调用 guard,给在同一个客户端里既跑纯文本模型又跑视觉模型的人用。两个列表都放 glob 模式(支持 `*` 和 `?`,不区分大小写,同时匹配模型名和 `provider/model`),用 `modlens config set guards.denyModels '["gemini-3*"]'` 或 `guards.allowModels` 设置(JSON 数组或逗号分隔的列表都行,传空则清除)。两种写法表达同一个意图,选列表更短的那种:
|
|
66
72
|
- 只用 `denyModels`:除了列出的视觉模型,其余全部运行引擎。适合你接入的模型大多是纯文本的情况。
|
|
67
73
|
- `allowModels` 非空(白名单模式):只有列出的模型运行引擎,其他所有已识别的模型一律拒绝。适合 2026 年的实际格局,纯文本模型才是那份短名单。deny 模式仍然优先于 allow 匹配,所以宽泛的 allow 可以把视觉变体剔出去,正如上面的示例:`glm-5.*` 放行文本系列,`glm-*v*` 抓住 `glm-5v-turbo`。allow 模式要锚定得紧一些(写 `deepseek-v4-*` 而不是 `deepseek*`),这样厂商下一代多模态型号会自动掉出名单,等你检查过再上场。
|
|
@@ -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.21.
|
|
27
|
+
$Pinned = '3.21.1'
|
|
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.21.
|
|
25
|
+
PINNED="3.21.1"
|
|
26
26
|
# -------------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
|