@liustack/modlens 2.7.4 → 2.7.6
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/README.md +3 -0
- package/README.zh-CN.md +3 -0
- package/dist/main.js +224 -154
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +1 -0
package/README.md
CHANGED
|
@@ -138,9 +138,12 @@ Two more subcommands: `modlens config <init|set|show>` manages providers and key
|
|
|
138
138
|
modlens recover-paste # newest pasted image, path printed as JSON
|
|
139
139
|
modlens recover-paste --count 3 # the three newest
|
|
140
140
|
modlens recover-paste --session <id> # exact session (skills pass ${CLAUDE_SESSION_ID})
|
|
141
|
+
modlens recover-paste --harness pi # force one harness's format
|
|
141
142
|
# --transcript <path> overrides everything; --cwd <dir> sets the project directory
|
|
142
143
|
```
|
|
143
144
|
|
|
145
|
+
Recovered images are written 0600 into a 0700 directory, so nobody else on a shared machine can read them. Locating a session checks the cwd recorded inside the transcript as well as the directory, because directory slugs collide (`/tmp/a.b` and `/tmp/a-b` produce the same one) and without that check you can be handed a neighbouring project's images.
|
|
146
|
+
|
|
144
147
|
## Providers and config
|
|
145
148
|
|
|
146
149
|
ModLens ships five vision providers. `antigravity-cli` stays the default: zero keys, pure free quota.
|
package/README.zh-CN.md
CHANGED
|
@@ -138,9 +138,12 @@ modlens -i <图片路径或 URL> [选项]
|
|
|
138
138
|
modlens recover-paste # 捞最新一张,路径以 JSON 打印
|
|
139
139
|
modlens recover-paste --count 3 # 捞最近三张
|
|
140
140
|
modlens recover-paste --session <id> # 精确会话(skill 会传 ${CLAUDE_SESSION_ID})
|
|
141
|
+
modlens recover-paste --harness pi # 强制按某家宿主的格式解析
|
|
141
142
|
# --transcript <path> 优先级最高,--cwd <dir> 指定项目目录
|
|
142
143
|
```
|
|
143
144
|
|
|
145
|
+
恢复出来的图片写成 0600、放进 0700 目录,共享机器上别人读不到。定位会话时除了目录,还会核对会话记录里写着的真实工作目录,因为目录 slug 会碰撞(`/tmp/a.b` 和 `/tmp/a-b` 算出同一个),不核对就可能把隔壁项目的图交给你。
|
|
146
|
+
|
|
144
147
|
## Provider 与配置
|
|
145
148
|
|
|
146
149
|
ModLens 内置五个视觉 provider,默认还是 `antigravity-cli`:零 key,纯免费额度。
|
package/dist/main.js
CHANGED
|
@@ -7,128 +7,6 @@ import { spawn } from "child_process";
|
|
|
7
7
|
import * as os from "os";
|
|
8
8
|
import * as crypto from "crypto";
|
|
9
9
|
import { createRequire } from "module";
|
|
10
|
-
const CONFIG_DIR = path.join(os.homedir(), ".modlens");
|
|
11
|
-
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
12
|
-
const ENV_BINDINGS = {
|
|
13
|
-
"gemini-api": { apiKey: "GEMINI_API_KEY" },
|
|
14
|
-
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
|
|
15
|
-
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
|
|
16
|
-
};
|
|
17
|
-
function loadConfigFile(configPath = CONFIG_PATH) {
|
|
18
|
-
let raw;
|
|
19
|
-
try {
|
|
20
|
-
raw = fs.readFileSync(configPath, "utf-8");
|
|
21
|
-
} catch {
|
|
22
|
-
return {};
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const parsed = JSON.parse(raw);
|
|
26
|
-
if (!parsed || typeof parsed !== "object") {
|
|
27
|
-
return {};
|
|
28
|
-
}
|
|
29
|
-
return parsed;
|
|
30
|
-
} catch (error) {
|
|
31
|
-
throw new Error(
|
|
32
|
-
`Failed to parse ${configPath}: ${error.message}. Fix or delete the file.`
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
function defaultProviderName(config2) {
|
|
37
|
-
return config2.provider?.trim() || "antigravity-cli";
|
|
38
|
-
}
|
|
39
|
-
const PROVIDER_ALIASES = {
|
|
40
|
-
antigravity: "antigravity-cli",
|
|
41
|
-
agy: "antigravity-cli",
|
|
42
|
-
gemini: "gemini-api",
|
|
43
|
-
claude: "claude-cli"
|
|
44
|
-
};
|
|
45
|
-
function resolveProviderSettings(providerName, config2, env = process.env) {
|
|
46
|
-
const aliasNames = Object.entries(PROVIDER_ALIASES).filter(([, canonical]) => canonical === providerName).map(([alias]) => alias);
|
|
47
|
-
const fromFile = {
|
|
48
|
-
...Object.assign({}, ...aliasNames.map((alias) => config2.providers?.[alias] ?? {})),
|
|
49
|
-
...config2.providers?.[providerName] ?? {}
|
|
50
|
-
};
|
|
51
|
-
const bindings = ENV_BINDINGS[providerName] ?? {};
|
|
52
|
-
const settings = { ...fromFile };
|
|
53
|
-
for (const [field, envName] of Object.entries(bindings)) {
|
|
54
|
-
const value = env[envName]?.trim();
|
|
55
|
-
if (value) {
|
|
56
|
-
settings[field] = value;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return settings;
|
|
60
|
-
}
|
|
61
|
-
function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
62
|
-
const config2 = loadConfigFile(configPath);
|
|
63
|
-
if (dottedKey === "provider") {
|
|
64
|
-
config2.provider = value;
|
|
65
|
-
} else {
|
|
66
|
-
const dot = dottedKey.indexOf(".");
|
|
67
|
-
if (dot <= 0 || dot === dottedKey.length - 1) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
`Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model>".`
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
const providerName = dottedKey.slice(0, dot);
|
|
73
|
-
const field = dottedKey.slice(dot + 1);
|
|
74
|
-
if (!["apiKey", "baseUrl", "model"].includes(field)) {
|
|
75
|
-
throw new Error(`Unknown config field: ${field}. Use apiKey, baseUrl, or model.`);
|
|
76
|
-
}
|
|
77
|
-
config2.providers ??= {};
|
|
78
|
-
config2.providers[providerName] ??= {};
|
|
79
|
-
config2.providers[providerName][field] = value;
|
|
80
|
-
}
|
|
81
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
82
|
-
fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
|
|
83
|
-
`, { mode: 384 });
|
|
84
|
-
try {
|
|
85
|
-
fs.chmodSync(configPath, 384);
|
|
86
|
-
} catch {
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
const CONFIG_TEMPLATE = {
|
|
90
|
-
provider: "antigravity-cli",
|
|
91
|
-
providers: {
|
|
92
|
-
"antigravity-cli": { model: "gemini-3.6-flash-low" },
|
|
93
|
-
"gemini-api": { apiKey: "", model: "gemini-3.6-flash" },
|
|
94
|
-
openai: { baseUrl: "", apiKey: "", model: "" },
|
|
95
|
-
anthropic: { apiKey: "", model: "claude-haiku-4-5-20251001" },
|
|
96
|
-
"claude-cli": { model: "haiku" }
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
function initConfigFile(configPath = CONFIG_PATH, force = false) {
|
|
100
|
-
if (!force && fs.existsSync(configPath)) {
|
|
101
|
-
throw new Error(`${configPath} already exists. Use --force to overwrite.`);
|
|
102
|
-
}
|
|
103
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
104
|
-
fs.writeFileSync(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
|
|
105
|
-
`, { mode: 384 });
|
|
106
|
-
try {
|
|
107
|
-
fs.chmodSync(configPath, 384);
|
|
108
|
-
} catch {
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
function renderConfig(config2) {
|
|
112
|
-
const masked = {
|
|
113
|
-
...config2,
|
|
114
|
-
providers: Object.fromEntries(
|
|
115
|
-
Object.entries(config2.providers ?? {}).map(([name, settings]) => [
|
|
116
|
-
name,
|
|
117
|
-
{
|
|
118
|
-
...settings,
|
|
119
|
-
...settings.apiKey ? { apiKey: maskKey(settings.apiKey) } : {}
|
|
120
|
-
}
|
|
121
|
-
])
|
|
122
|
-
)
|
|
123
|
-
};
|
|
124
|
-
return JSON.stringify(masked, null, 2);
|
|
125
|
-
}
|
|
126
|
-
function maskKey(key) {
|
|
127
|
-
if (key.length <= 8) {
|
|
128
|
-
return "****";
|
|
129
|
-
}
|
|
130
|
-
return `${key.slice(0, 6)}...${key.slice(-2)}`;
|
|
131
|
-
}
|
|
132
10
|
function buildVisionPrompt(options) {
|
|
133
11
|
const readInstruction = options.imageKind === "inline" ? "Analyze the image attached to this message." : options.imageKind === "remote" ? `Fetch the image at this URL and analyze it: ${options.imageSource}` : `Read the image file at this path and analyze it: ${options.imageSource}`;
|
|
134
12
|
const basePrompt = `${readInstruction}
|
|
@@ -370,6 +248,23 @@ function agyLogDir() {
|
|
|
370
248
|
return path.join(os.homedir(), ".gemini", "antigravity-cli", "log");
|
|
371
249
|
}
|
|
372
250
|
const LOG_FRESHNESS_MS = 2 * 60 * 1e3;
|
|
251
|
+
function parseAgyLogTime(line, now = /* @__PURE__ */ new Date()) {
|
|
252
|
+
const match = /\b[IWEF](\d{2})(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/.exec(line);
|
|
253
|
+
if (!match) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const [, month, day, hour, minute, second, fraction] = match;
|
|
257
|
+
const stamp = new Date(
|
|
258
|
+
now.getFullYear(),
|
|
259
|
+
Number(month) - 1,
|
|
260
|
+
Number(day),
|
|
261
|
+
Number(hour),
|
|
262
|
+
Number(minute),
|
|
263
|
+
Number(second),
|
|
264
|
+
fraction ? Number(fraction.slice(0, 3)) : 0
|
|
265
|
+
).getTime();
|
|
266
|
+
return stamp - now.getTime() > 24 * 60 * 60 * 1e3 ? new Date(new Date(stamp).setFullYear(now.getFullYear() - 1)).getTime() : stamp;
|
|
267
|
+
}
|
|
373
268
|
function readRecentAgyLog(since) {
|
|
374
269
|
try {
|
|
375
270
|
const dir = agyLogDir();
|
|
@@ -380,7 +275,11 @@ function readRecentAgyLog(since) {
|
|
|
380
275
|
if (!newest || newest.mtime < since) {
|
|
381
276
|
return "";
|
|
382
277
|
}
|
|
383
|
-
|
|
278
|
+
const recent = fs.readFileSync(newest.full, "utf-8").slice(-64e3).split("\n").filter((line) => {
|
|
279
|
+
const stamp = parseAgyLogTime(line);
|
|
280
|
+
return stamp !== null && stamp >= since;
|
|
281
|
+
});
|
|
282
|
+
return recent.join("\n");
|
|
384
283
|
} catch {
|
|
385
284
|
return "";
|
|
386
285
|
}
|
|
@@ -743,11 +642,8 @@ Respond with ONE JSON object only, no markdown fences, no commentary. Fill this
|
|
|
743
642
|
if (result === null) {
|
|
744
643
|
throw new Error(`OpenAI-compatible API returned non-JSON output: ${truncate(text)}`);
|
|
745
644
|
}
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
(field) => shaped[field] === void 0 || shaped[field] === null
|
|
749
|
-
);
|
|
750
|
-
if (missing.length > 0 || typeof shaped.summary !== "string" || typeof shaped.ocr !== "object" || !Array.isArray(shaped.uncertainty)) {
|
|
645
|
+
const missing = missingSchemaFields(result);
|
|
646
|
+
if (missing.length > 0) {
|
|
751
647
|
throw new Error(
|
|
752
648
|
`OpenAI-compatible API returned JSON that does not match the vision schema${missing.length > 0 ? ` (missing: ${missing.join(", ")})` : ""}. Retry, or switch to gemini-api / anthropic for enforced schemas. Got: ${truncate(text)}`
|
|
753
649
|
);
|
|
@@ -761,6 +657,28 @@ Respond with ONE JSON object only, no markdown fences, no commentary. Fill this
|
|
|
761
657
|
}
|
|
762
658
|
};
|
|
763
659
|
}
|
|
660
|
+
function missingSchemaFields(result) {
|
|
661
|
+
const missing = [];
|
|
662
|
+
const root = result ?? {};
|
|
663
|
+
const child = (key) => root[key] && typeof root[key] === "object" ? root[key] : {};
|
|
664
|
+
const expect = (path2, ok) => {
|
|
665
|
+
if (!ok) {
|
|
666
|
+
missing.push(path2);
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
expect("summary", typeof root.summary === "string");
|
|
670
|
+
expect("ocr", typeof root.ocr === "object" && root.ocr !== null);
|
|
671
|
+
expect("ocr.full_text", typeof child("ocr").full_text === "string");
|
|
672
|
+
expect("ocr.lines", Array.isArray(child("ocr").lines));
|
|
673
|
+
expect("layout", typeof root.layout === "object" && root.layout !== null);
|
|
674
|
+
expect("layout.regions", Array.isArray(child("layout").regions));
|
|
675
|
+
expect("semantics", typeof root.semantics === "object" && root.semantics !== null);
|
|
676
|
+
expect("semantics.scene", typeof child("semantics").scene === "string");
|
|
677
|
+
expect("semantics.entities", Array.isArray(child("semantics").entities));
|
|
678
|
+
expect("visual", typeof root.visual === "object" && root.visual !== null);
|
|
679
|
+
expect("uncertainty", Array.isArray(root.uncertainty));
|
|
680
|
+
return missing;
|
|
681
|
+
}
|
|
764
682
|
function toDataUrl(image) {
|
|
765
683
|
return `data:${image.mimeType};base64,${image.data}`;
|
|
766
684
|
}
|
|
@@ -795,12 +713,134 @@ function resolveProvider(providerName = "antigravity-cli") {
|
|
|
795
713
|
}
|
|
796
714
|
return provider;
|
|
797
715
|
}
|
|
716
|
+
function providerAliases() {
|
|
717
|
+
return Object.fromEntries(
|
|
718
|
+
Object.entries(PROVIDERS).map(([alias, provider]) => [alias, provider.name])
|
|
719
|
+
);
|
|
720
|
+
}
|
|
798
721
|
function listProviders() {
|
|
799
722
|
return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
|
|
800
723
|
}
|
|
724
|
+
const CONFIG_DIR = path.join(os.homedir(), ".modlens");
|
|
725
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
726
|
+
const ENV_BINDINGS = {
|
|
727
|
+
"gemini-api": { apiKey: "GEMINI_API_KEY" },
|
|
728
|
+
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
|
|
729
|
+
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
|
|
730
|
+
};
|
|
731
|
+
function loadConfigFile(configPath = CONFIG_PATH) {
|
|
732
|
+
let raw;
|
|
733
|
+
try {
|
|
734
|
+
raw = fs.readFileSync(configPath, "utf-8");
|
|
735
|
+
} catch (error) {
|
|
736
|
+
if (error.code === "ENOENT") {
|
|
737
|
+
return {};
|
|
738
|
+
}
|
|
739
|
+
throw new Error(
|
|
740
|
+
`Cannot read ${configPath}: ${error.message}. Fix the file or its permissions.`
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
const parsed = JSON.parse(raw);
|
|
745
|
+
if (!parsed || typeof parsed !== "object") {
|
|
746
|
+
return {};
|
|
747
|
+
}
|
|
748
|
+
return parsed;
|
|
749
|
+
} catch (error) {
|
|
750
|
+
throw new Error(
|
|
751
|
+
`Failed to parse ${configPath}: ${error.message}. Fix or delete the file.`
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function defaultProviderName(config2) {
|
|
756
|
+
return config2.provider?.trim() || "antigravity-cli";
|
|
757
|
+
}
|
|
758
|
+
function resolveProviderSettings(providerName, config2, env = process.env) {
|
|
759
|
+
const aliasNames = Object.entries(providerAliases()).filter(([alias, canonical]) => canonical === providerName && alias !== providerName).map(([alias]) => alias);
|
|
760
|
+
const fromFile = {
|
|
761
|
+
...Object.assign({}, ...aliasNames.map((alias) => config2.providers?.[alias] ?? {})),
|
|
762
|
+
...config2.providers?.[providerName] ?? {}
|
|
763
|
+
};
|
|
764
|
+
const bindings = ENV_BINDINGS[providerName] ?? {};
|
|
765
|
+
const settings = { ...fromFile };
|
|
766
|
+
for (const [field, envName] of Object.entries(bindings)) {
|
|
767
|
+
const value = env[envName]?.trim();
|
|
768
|
+
if (value) {
|
|
769
|
+
settings[field] = value;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return settings;
|
|
773
|
+
}
|
|
774
|
+
function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
775
|
+
const config2 = loadConfigFile(configPath);
|
|
776
|
+
if (dottedKey === "provider") {
|
|
777
|
+
config2.provider = value;
|
|
778
|
+
} else {
|
|
779
|
+
const dot = dottedKey.indexOf(".");
|
|
780
|
+
if (dot <= 0 || dot === dottedKey.length - 1) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
`Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model>".`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
const providerName = dottedKey.slice(0, dot);
|
|
786
|
+
const field = dottedKey.slice(dot + 1);
|
|
787
|
+
if (!["apiKey", "baseUrl", "model"].includes(field)) {
|
|
788
|
+
throw new Error(`Unknown config field: ${field}. Use apiKey, baseUrl, or model.`);
|
|
789
|
+
}
|
|
790
|
+
config2.providers ??= {};
|
|
791
|
+
config2.providers[providerName] ??= {};
|
|
792
|
+
config2.providers[providerName][field] = value;
|
|
793
|
+
}
|
|
794
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
795
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
|
|
796
|
+
`, { mode: 384 });
|
|
797
|
+
try {
|
|
798
|
+
fs.chmodSync(configPath, 384);
|
|
799
|
+
} catch {
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const CONFIG_TEMPLATE = {
|
|
803
|
+
// Empty means the built-in default provider.
|
|
804
|
+
provider: "",
|
|
805
|
+
providers: {}
|
|
806
|
+
};
|
|
807
|
+
function initConfigFile(configPath = CONFIG_PATH, force = false) {
|
|
808
|
+
if (!force && fs.existsSync(configPath)) {
|
|
809
|
+
throw new Error(`${configPath} already exists. Use --force to overwrite.`);
|
|
810
|
+
}
|
|
811
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
812
|
+
fs.writeFileSync(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
|
|
813
|
+
`, { mode: 384 });
|
|
814
|
+
try {
|
|
815
|
+
fs.chmodSync(configPath, 384);
|
|
816
|
+
} catch {
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function renderConfig(config2) {
|
|
820
|
+
const masked = {
|
|
821
|
+
...config2,
|
|
822
|
+
providers: Object.fromEntries(
|
|
823
|
+
Object.entries(config2.providers ?? {}).map(([name, settings]) => [
|
|
824
|
+
name,
|
|
825
|
+
{
|
|
826
|
+
...settings,
|
|
827
|
+
...settings.apiKey ? { apiKey: maskKey(settings.apiKey) } : {}
|
|
828
|
+
}
|
|
829
|
+
])
|
|
830
|
+
)
|
|
831
|
+
};
|
|
832
|
+
return JSON.stringify(masked, null, 2);
|
|
833
|
+
}
|
|
834
|
+
function maskKey(key) {
|
|
835
|
+
if (key.length <= 8) {
|
|
836
|
+
return "****";
|
|
837
|
+
}
|
|
838
|
+
return `${key.slice(0, 6)}...${key.slice(-2)}`;
|
|
839
|
+
}
|
|
801
840
|
const DEFAULT_TIMEOUT_MS = 18e4;
|
|
802
841
|
const KILL_GRACE_MS = 3e4;
|
|
803
842
|
const DRAIN_GRACE_MS = 500;
|
|
843
|
+
const SIGKILL_GRACE_MS = 2e3;
|
|
804
844
|
async function analyzeImage(options) {
|
|
805
845
|
const resolvedInput = resolveInput(options.input);
|
|
806
846
|
if (resolvedInput.kind === "local") {
|
|
@@ -893,6 +933,12 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
893
933
|
const timer = setTimeout(() => {
|
|
894
934
|
timedOut = true;
|
|
895
935
|
child.kill("SIGTERM");
|
|
936
|
+
settle(null);
|
|
937
|
+
setTimeout(() => {
|
|
938
|
+
if (!child.killed) {
|
|
939
|
+
child.kill("SIGKILL");
|
|
940
|
+
}
|
|
941
|
+
}, SIGKILL_GRACE_MS).unref();
|
|
896
942
|
}, timeoutMs);
|
|
897
943
|
const settle = (code) => {
|
|
898
944
|
if (settled) {
|
|
@@ -901,6 +947,8 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
901
947
|
settled = true;
|
|
902
948
|
clearTimeout(timer);
|
|
903
949
|
clearTimeout(drainTimer);
|
|
950
|
+
stdout += outDecoder.decode();
|
|
951
|
+
stderr += errDecoder.decode();
|
|
904
952
|
child.stdout?.destroy();
|
|
905
953
|
child.stderr?.destroy();
|
|
906
954
|
child.unref();
|
|
@@ -972,37 +1020,44 @@ const EXT_BY_MIME = {
|
|
|
972
1020
|
"image/webp": "webp",
|
|
973
1021
|
"image/gif": "gif"
|
|
974
1022
|
};
|
|
975
|
-
function transcriptBelongsTo(
|
|
1023
|
+
function transcriptBelongsTo(lines, cwd) {
|
|
976
1024
|
const wanted = path.resolve(cwd);
|
|
977
|
-
let
|
|
978
|
-
|
|
979
|
-
raw = fs.readFileSync(filePath, "utf-8");
|
|
980
|
-
} catch {
|
|
981
|
-
return false;
|
|
982
|
-
}
|
|
983
|
-
for (const line of raw.split("\n")) {
|
|
1025
|
+
let sawCwd = false;
|
|
1026
|
+
for (const line of lines) {
|
|
984
1027
|
if (!line.includes('"cwd"')) {
|
|
985
1028
|
continue;
|
|
986
1029
|
}
|
|
987
1030
|
try {
|
|
988
1031
|
const recorded = JSON.parse(line).cwd;
|
|
989
|
-
if (typeof recorded
|
|
990
|
-
|
|
991
|
-
|
|
1032
|
+
if (typeof recorded !== "string") {
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
sawCwd = true;
|
|
1036
|
+
const resolved = path.resolve(recorded);
|
|
1037
|
+
if (resolved === wanted || resolved.startsWith(`${wanted}${path.sep}`)) {
|
|
1038
|
+
return true;
|
|
992
1039
|
}
|
|
993
1040
|
} catch {
|
|
994
1041
|
}
|
|
995
1042
|
}
|
|
996
|
-
return
|
|
1043
|
+
return !sawCwd;
|
|
997
1044
|
}
|
|
998
|
-
function
|
|
999
|
-
let raw;
|
|
1045
|
+
function readLines(filePath) {
|
|
1000
1046
|
try {
|
|
1001
|
-
|
|
1047
|
+
return fs.readFileSync(filePath, "utf-8").split("\n");
|
|
1002
1048
|
} catch {
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
function forEachJsonLine(filePath, visit) {
|
|
1053
|
+
const lines = readLines(filePath);
|
|
1054
|
+
if (!lines) {
|
|
1003
1055
|
return;
|
|
1004
1056
|
}
|
|
1005
|
-
|
|
1057
|
+
forEachParsedLine(lines, visit);
|
|
1058
|
+
}
|
|
1059
|
+
function forEachParsedLine(lines, visit) {
|
|
1060
|
+
for (const line of lines) {
|
|
1006
1061
|
if (!line.includes('"image"')) {
|
|
1007
1062
|
continue;
|
|
1008
1063
|
}
|
|
@@ -1025,9 +1080,9 @@ function jsonlSource(harness, filePath, extractLine) {
|
|
|
1025
1080
|
}
|
|
1026
1081
|
};
|
|
1027
1082
|
}
|
|
1028
|
-
function newestJsonlTimestamp(
|
|
1083
|
+
function newestJsonlTimestamp(lines, extractLine) {
|
|
1029
1084
|
let latest = null;
|
|
1030
|
-
|
|
1085
|
+
forEachParsedLine(lines, (line) => {
|
|
1031
1086
|
if (extractLine(line).length === 0) {
|
|
1032
1087
|
return;
|
|
1033
1088
|
}
|
|
@@ -1054,10 +1109,11 @@ function jsonlAdapter(options) {
|
|
|
1054
1109
|
findNewest: (cwd) => {
|
|
1055
1110
|
let best = null;
|
|
1056
1111
|
for (const file of listJsonl(dirFor(cwd))) {
|
|
1057
|
-
|
|
1112
|
+
const lines = readLines(file);
|
|
1113
|
+
if (!lines || !transcriptBelongsTo(lines, cwd)) {
|
|
1058
1114
|
continue;
|
|
1059
1115
|
}
|
|
1060
|
-
const timestamp = newestJsonlTimestamp(
|
|
1116
|
+
const timestamp = newestJsonlTimestamp(lines, extractLine);
|
|
1061
1117
|
if (timestamp !== null && (!best || timestamp > best.timestamp)) {
|
|
1062
1118
|
best = { ref: jsonlSource(name, file, extractLine), timestamp };
|
|
1063
1119
|
}
|
|
@@ -1066,7 +1122,11 @@ function jsonlAdapter(options) {
|
|
|
1066
1122
|
},
|
|
1067
1123
|
findSession: (cwd, sessionId) => {
|
|
1068
1124
|
for (const file of listJsonl(dirFor(cwd))) {
|
|
1069
|
-
if (matchesSession(path.basename(file), sessionId)
|
|
1125
|
+
if (!matchesSession(path.basename(file), sessionId)) {
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
const lines = readLines(file);
|
|
1129
|
+
if (lines && transcriptBelongsTo(lines, cwd)) {
|
|
1070
1130
|
return jsonlSource(name, file, extractLine);
|
|
1071
1131
|
}
|
|
1072
1132
|
}
|
|
@@ -1240,7 +1300,7 @@ function harnessFromPsTable(psOutput, startPid) {
|
|
|
1240
1300
|
const tokens = proc.command.trim().split(/\s+/);
|
|
1241
1301
|
const candidates = [tokens[0]];
|
|
1242
1302
|
if (/^(node|bun|deno)$/.test(path.basename(tokens[0] ?? ""))) {
|
|
1243
|
-
const script = tokens.slice(1).find((token) => !token.startsWith("-"));
|
|
1303
|
+
const script = tokens.slice(1).find((token) => !token.startsWith("-") && /[/\\]|\.(m|c)?[jt]s$/.test(token));
|
|
1244
1304
|
if (script) {
|
|
1245
1305
|
candidates.push(script);
|
|
1246
1306
|
}
|
|
@@ -1348,6 +1408,12 @@ function recoverPastedImages(options = {}) {
|
|
|
1348
1408
|
"This is a Codex session: pasted images already exist as temp files, and each image tag in the message carries its path. Read the path from the tag instead of running recover-paste."
|
|
1349
1409
|
);
|
|
1350
1410
|
}
|
|
1411
|
+
const requested = options.harness?.trim();
|
|
1412
|
+
if (requested && requested !== "none" && !ADAPTERS.some((a) => a.name === requested)) {
|
|
1413
|
+
throw new Error(
|
|
1414
|
+
`Unknown harness "${requested}". Supported: ${ADAPTERS.map((a) => a.name).join(", ")} (or none to scan all).`
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1351
1417
|
const scoped = detected && detected !== "none" ? detected : null;
|
|
1352
1418
|
if (scoped && !ADAPTERS.some((adapter) => adapter.name === scoped)) {
|
|
1353
1419
|
throw new Error(
|
|
@@ -1412,7 +1478,7 @@ function recoverPastedImages(options = {}) {
|
|
|
1412
1478
|
return result;
|
|
1413
1479
|
}
|
|
1414
1480
|
const program = new Command();
|
|
1415
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.
|
|
1481
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.6");
|
|
1416
1482
|
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").action(async (options) => {
|
|
1417
1483
|
try {
|
|
1418
1484
|
const timeoutMs = Number.parseInt(options.timeout, 10);
|
|
@@ -1481,9 +1547,13 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
|
|
|
1481
1547
|
try {
|
|
1482
1548
|
initConfigFile(CONFIG_PATH, Boolean(options.force));
|
|
1483
1549
|
process.stdout.write(
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1550
|
+
[
|
|
1551
|
+
`Created ${CONFIG_PATH}`,
|
|
1552
|
+
"Everything is optional. Two things you can set:",
|
|
1553
|
+
" modlens config set provider <name> which provider analyzes images",
|
|
1554
|
+
" modlens config set <provider>.<apiKey|baseUrl|model> <value> provider credentials",
|
|
1555
|
+
""
|
|
1556
|
+
].join("\n")
|
|
1487
1557
|
);
|
|
1488
1558
|
} catch (error) {
|
|
1489
1559
|
process.stderr.write(
|
package/package.json
CHANGED
package/skills/modlens/SKILL.md
CHANGED
|
@@ -73,6 +73,7 @@ Harnesses rarely hand you a clean path. First identify which harness you are in,
|
|
|
73
73
|
- The output is JSON with real file paths, ordered oldest to newest, so the LAST path is the user's most recent paste. Analyze that one first. Entries carry `filename` (the original attachment name) when the harness stored one; if the user's message or an error mentions a filename, match on it.
|
|
74
74
|
- Run every command yourself: `recover-paste`, then `modlens -i <path>` on the recovered file, then answer from the JSON. Never ask the user to run modlens or to relay paths.
|
|
75
75
|
- The output's `detected` field names the harness scope that was applied. If it is absent, detection failed and every store was scanned by newest-image timestamp: before describing anything, check that `harness` and `filename` match what you expect, force the scope with `--harness <claude-code|pi|opencode>` if they do not, and when in doubt ask the user for the file instead of describing the wrong image.
|
|
76
|
+
- Recovery is scoped to this project: the harness's own record of its working directory is checked, not just the directory name, so images from a neighbouring project are never handed over. Recovered files are private to the user (0600).
|
|
76
77
|
- If recovery fails (session storage is each harness's internals and may change), ask the user to drag the image file into the terminal or type its path.
|
|
77
78
|
|
|
78
79
|
**Any other harness, or nothing matches** (no path tag and `recover-paste` reports no transcripts): do not guess. Ask the user for the image file path, or suggest dragging the file into the terminal.
|