@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 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.0`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
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.0`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
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.0
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
- const aliases = providerAliases();
1601
- return Object.keys(config2.providers ?? {}).filter(
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({}, ...ordered.map((key) => config2.providers?.[key] ?? {}));
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?.trim()) {
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 ((providerAliases()[providerName] ?? providerName) !== "openai") {
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 = providerAliases()[folded] ?? folded;
1949
+ const canonical = foldProviderName(folded);
1805
1950
  if (canonical !== "openai") {
1806
1951
  throw new Error(
1807
- `Only the openai slot has saved copies; "${slot}" does not. It is the one slot users point at many different gateways.`
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
- const bundle = config2.saved?.[canonical]?.[label];
1835
- if (bundle === void 0) {
1836
- const known = Object.keys(config2.saved?.[canonical] ?? {}).sort();
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(config2.saved?.[canonical] ?? {}).some(
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(`${configPath} already exists. Use --force to overwrite.`);
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 aliases = providerAliases();
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(config2.providers ?? {}).map((key) => aliases[key] ?? key)
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 !== void 0) {
1928
- const shown = field === "apiKey" ? maskKey(value) : field === "proxy" ? maskUrlCredentials(value) : value;
1929
- fields[field] = `${shown} (${source})`;
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 savedView = {};
1946
- for (const [slot, bundles] of Object.entries(config2.saved ?? {})) {
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 !== void 0 ? `key ${maskKey(bundle.apiKey)}` : "no key"
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
- savedView[slot] ??= {};
1955
- savedView[slot][label] = parts.join(" @ ");
2178
+ savedRows.push(`${slot}/${label}: ${parts.join(" @ ")}`);
1956
2179
  }
1957
2180
  }
1958
- if (Object.keys(savedView).length > 0) {
1959
- effective.saved = savedView;
2181
+ if (savedRows.length > 0) {
2182
+ effective.saved = savedRows;
1960
2183
  }
1961
- if (config2.provider?.trim()) {
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?.trim()) {
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 && Object.keys(config2.reuse).length > 0) {
1986
- effective.reuse = Object.fromEntries(
1987
- Object.entries(config2.reuse).map(([harness, granted]) => [
1988
- harness,
1989
- `${granted} (file)`
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.0");
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.0"
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(`Saved the ${slot} slot as "${label}" in ${CONFIG_PATH}
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)}
@@ -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.0
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.0
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.0
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.0
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` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
@@ -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.0
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.0'
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.0
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.0'
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: new Map(),
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, but the
912
- // cache must never make a failure permanent or run the engine twice for
913
- // concurrent steps. So it stores promises (concurrent readers join the first
914
- // run), evicts failed reads on settle (a fixed config gets a fresh chance),
915
- // and caps itself LRU-style so a long-lived Web profile cannot hoard
916
- // evidence text forever.
917
- const EVIDENCE_CACHE_LIMIT = 64
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
- function cachedEvidence(ctx, adapter, block) {
920
- const key = JSON.stringify(block.attachment ?? block)
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
- // Refresh recency: Map iteration order is insertion order.
924
- adapter.evidenceCache.delete(key)
925
- adapter.evidenceCache.set(key, hit)
926
- return hit
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 evict our own entry: this promise may have been LRU-evicted and
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.delete(key)
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
- (error) => {
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
- if (adapter.evidenceCache.get(key) === pending) {
945
- adapter.evidenceCache.delete(key)
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: `[A pasted image could not be read by modlens: ${
950
- error instanceof Error ? error.message.slice(0, 300) : String(error)
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
- while (adapter.evidenceCache.size > EVIDENCE_CACHE_LIMIT) {
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
- for (const message of messages) {
1021
- if (!contentHasImage(message.content)) {
1022
- out.push(message)
1023
- continue
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
- const content = await convertBlocks(message.content, (block) =>
1026
- abortableWait(cachedEvidence(ctx, adapter, block), signal),
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
- for (const message of decision.messages) {
1052
- if (!contentHasImage(message.content)) {
1053
- messages.push(message)
1054
- continue
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
- const content = await convertBlocks(
1057
- message.content,
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 refuses
1070
- * to memoize it).
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
- block: {
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: `[A pasted image could not be read by modlens: ${
1119
- error instanceof Error ? error.message.slice(0, 300) : String(error)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.21.0",
3
+ "version": "3.21.1",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.0):
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.0: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.21.0 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.21.0 <args>`.
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 five 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.
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
- 所有内容都在五个顶层键之下,全部可选。下面的示例一次性展示了所有支持的键和字段(真实文件只需要写你用到的部分)。文件不存在就全用默认值。provider 的设置放在 `providers.<name>` 下面,不在顶层,手工编辑最常犯的就是这个错。
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*`),这样厂商下一代多模态型号会自动掉出名单,等你检查过再上场。
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.21.0
11
+ - Pinned CLI version: 3.21.1
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -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.0'
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.0"
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"