@liustack/modlens 2.7.4 → 2.7.5

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.
Files changed (2) hide show
  1. package/dist/main.js +213 -151
  2. package/package.json +1 -1
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
- return fs.readFileSync(newest.full, "utf-8").slice(-8e3);
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 shaped = result;
747
- const missing = ["summary", "ocr", "layout", "semantics", "visual", "uncertainty"].filter(
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,9 +713,135 @@ 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
+ provider: "antigravity-cli",
804
+ providers: {
805
+ "antigravity-cli": { model: "gemini-3.6-flash-low" },
806
+ "gemini-api": { apiKey: "", model: "gemini-3.6-flash" },
807
+ openai: { baseUrl: "", apiKey: "", model: "" },
808
+ anthropic: { apiKey: "", model: "claude-haiku-4-5-20251001" },
809
+ "claude-cli": { model: "haiku" }
810
+ }
811
+ };
812
+ function initConfigFile(configPath = CONFIG_PATH, force = false) {
813
+ if (!force && fs.existsSync(configPath)) {
814
+ throw new Error(`${configPath} already exists. Use --force to overwrite.`);
815
+ }
816
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
817
+ fs.writeFileSync(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
818
+ `, { mode: 384 });
819
+ try {
820
+ fs.chmodSync(configPath, 384);
821
+ } catch {
822
+ }
823
+ }
824
+ function renderConfig(config2) {
825
+ const masked = {
826
+ ...config2,
827
+ providers: Object.fromEntries(
828
+ Object.entries(config2.providers ?? {}).map(([name, settings]) => [
829
+ name,
830
+ {
831
+ ...settings,
832
+ ...settings.apiKey ? { apiKey: maskKey(settings.apiKey) } : {}
833
+ }
834
+ ])
835
+ )
836
+ };
837
+ return JSON.stringify(masked, null, 2);
838
+ }
839
+ function maskKey(key) {
840
+ if (key.length <= 8) {
841
+ return "****";
842
+ }
843
+ return `${key.slice(0, 6)}...${key.slice(-2)}`;
844
+ }
801
845
  const DEFAULT_TIMEOUT_MS = 18e4;
802
846
  const KILL_GRACE_MS = 3e4;
803
847
  const DRAIN_GRACE_MS = 500;
@@ -972,37 +1016,44 @@ const EXT_BY_MIME = {
972
1016
  "image/webp": "webp",
973
1017
  "image/gif": "gif"
974
1018
  };
975
- function transcriptBelongsTo(filePath, cwd) {
1019
+ function transcriptBelongsTo(lines, cwd) {
976
1020
  const wanted = path.resolve(cwd);
977
- let raw;
978
- try {
979
- raw = fs.readFileSync(filePath, "utf-8");
980
- } catch {
981
- return false;
982
- }
983
- for (const line of raw.split("\n")) {
1021
+ let sawCwd = false;
1022
+ for (const line of lines) {
984
1023
  if (!line.includes('"cwd"')) {
985
1024
  continue;
986
1025
  }
987
1026
  try {
988
1027
  const recorded = JSON.parse(line).cwd;
989
- if (typeof recorded === "string") {
990
- const resolved = path.resolve(recorded);
991
- return resolved === wanted || resolved.startsWith(`${wanted}${path.sep}`);
1028
+ if (typeof recorded !== "string") {
1029
+ continue;
1030
+ }
1031
+ sawCwd = true;
1032
+ const resolved = path.resolve(recorded);
1033
+ if (resolved === wanted || resolved.startsWith(`${wanted}${path.sep}`)) {
1034
+ return true;
992
1035
  }
993
1036
  } catch {
994
1037
  }
995
1038
  }
996
- return true;
1039
+ return !sawCwd;
997
1040
  }
998
- function forEachJsonLine(filePath, visit) {
999
- let raw;
1041
+ function readLines(filePath) {
1000
1042
  try {
1001
- raw = fs.readFileSync(filePath, "utf-8");
1043
+ return fs.readFileSync(filePath, "utf-8").split("\n");
1002
1044
  } catch {
1045
+ return null;
1046
+ }
1047
+ }
1048
+ function forEachJsonLine(filePath, visit) {
1049
+ const lines = readLines(filePath);
1050
+ if (!lines) {
1003
1051
  return;
1004
1052
  }
1005
- for (const line of raw.split("\n")) {
1053
+ forEachParsedLine(lines, visit);
1054
+ }
1055
+ function forEachParsedLine(lines, visit) {
1056
+ for (const line of lines) {
1006
1057
  if (!line.includes('"image"')) {
1007
1058
  continue;
1008
1059
  }
@@ -1025,9 +1076,9 @@ function jsonlSource(harness, filePath, extractLine) {
1025
1076
  }
1026
1077
  };
1027
1078
  }
1028
- function newestJsonlTimestamp(filePath, extractLine) {
1079
+ function newestJsonlTimestamp(lines, extractLine) {
1029
1080
  let latest = null;
1030
- forEachJsonLine(filePath, (line) => {
1081
+ forEachParsedLine(lines, (line) => {
1031
1082
  if (extractLine(line).length === 0) {
1032
1083
  return;
1033
1084
  }
@@ -1054,10 +1105,11 @@ function jsonlAdapter(options) {
1054
1105
  findNewest: (cwd) => {
1055
1106
  let best = null;
1056
1107
  for (const file of listJsonl(dirFor(cwd))) {
1057
- if (!transcriptBelongsTo(file, cwd)) {
1108
+ const lines = readLines(file);
1109
+ if (!lines || !transcriptBelongsTo(lines, cwd)) {
1058
1110
  continue;
1059
1111
  }
1060
- const timestamp = newestJsonlTimestamp(file, extractLine);
1112
+ const timestamp = newestJsonlTimestamp(lines, extractLine);
1061
1113
  if (timestamp !== null && (!best || timestamp > best.timestamp)) {
1062
1114
  best = { ref: jsonlSource(name, file, extractLine), timestamp };
1063
1115
  }
@@ -1066,7 +1118,11 @@ function jsonlAdapter(options) {
1066
1118
  },
1067
1119
  findSession: (cwd, sessionId) => {
1068
1120
  for (const file of listJsonl(dirFor(cwd))) {
1069
- if (matchesSession(path.basename(file), sessionId) && transcriptBelongsTo(file, cwd)) {
1121
+ if (!matchesSession(path.basename(file), sessionId)) {
1122
+ continue;
1123
+ }
1124
+ const lines = readLines(file);
1125
+ if (lines && transcriptBelongsTo(lines, cwd)) {
1070
1126
  return jsonlSource(name, file, extractLine);
1071
1127
  }
1072
1128
  }
@@ -1240,7 +1296,7 @@ function harnessFromPsTable(psOutput, startPid) {
1240
1296
  const tokens = proc.command.trim().split(/\s+/);
1241
1297
  const candidates = [tokens[0]];
1242
1298
  if (/^(node|bun|deno)$/.test(path.basename(tokens[0] ?? ""))) {
1243
- const script = tokens.slice(1).find((token) => !token.startsWith("-"));
1299
+ const script = tokens.slice(1).find((token) => !token.startsWith("-") && /[/\\]|\.(m|c)?[jt]s$/.test(token));
1244
1300
  if (script) {
1245
1301
  candidates.push(script);
1246
1302
  }
@@ -1348,6 +1404,12 @@ function recoverPastedImages(options = {}) {
1348
1404
  "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
1405
  );
1350
1406
  }
1407
+ const requested = options.harness?.trim();
1408
+ if (requested && requested !== "none" && !ADAPTERS.some((a) => a.name === requested)) {
1409
+ throw new Error(
1410
+ `Unknown harness "${requested}". Supported: ${ADAPTERS.map((a) => a.name).join(", ")} (or none to scan all).`
1411
+ );
1412
+ }
1351
1413
  const scoped = detected && detected !== "none" ? detected : null;
1352
1414
  if (scoped && !ADAPTERS.some((adapter) => adapter.name === scoped)) {
1353
1415
  throw new Error(
@@ -1412,7 +1474,7 @@ function recoverPastedImages(options = {}) {
1412
1474
  return result;
1413
1475
  }
1414
1476
  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.4");
1477
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.5");
1416
1478
  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
1479
  try {
1418
1480
  const timeoutMs = Number.parseInt(options.timeout, 10);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "2.7.4",
3
+ "version": "2.7.5",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {