glotfile 1.0.1 → 1.1.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.
@@ -2,7 +2,7 @@
2
2
  import { Hono as Hono2 } from "hono";
3
3
  import { serve } from "@hono/node-server";
4
4
  import { fileURLToPath } from "url";
5
- import { dirname as dirname4, join as join19, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
5
+ import { dirname as dirname4, join as join21, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
6
6
  import { readFile, stat } from "fs/promises";
7
7
  import { createServer } from "net";
8
8
  import open from "open";
@@ -270,7 +270,8 @@ function validate(raw) {
270
270
  }
271
271
  }
272
272
  if (raw.glossary !== void 0 && !Array.isArray(raw.glossary)) fail("glossary must be an array");
273
- const state = { glossary: [], ...raw };
273
+ if (raw.glossarySuggestions !== void 0 && !Array.isArray(raw.glossarySuggestions)) fail("glossarySuggestions must be an array");
274
+ const state = { glossary: [], glossarySuggestions: [], ...raw };
274
275
  return state;
275
276
  }
276
277
  function defaultState() {
@@ -289,6 +290,7 @@ function defaultState() {
289
290
  autoExport: true
290
291
  },
291
292
  glossary: [],
293
+ glossarySuggestions: [],
292
294
  keys: {}
293
295
  };
294
296
  }
@@ -756,6 +758,39 @@ function upsertGlossaryEntry(state, entry) {
756
758
  function deleteGlossaryEntry(state, term) {
757
759
  state.glossary = state.glossary.filter((e) => e.term !== term);
758
760
  }
761
+ function normGlossaryTerm(term) {
762
+ return term.trim().toLowerCase();
763
+ }
764
+ function mergeGlossarySuggestions(state, found) {
765
+ const known = /* @__PURE__ */ new Set();
766
+ for (const g of state.glossary) known.add(normGlossaryTerm(g.term));
767
+ for (const s of state.glossarySuggestions) known.add(normGlossaryTerm(s.term));
768
+ const added = [];
769
+ for (const f of found) {
770
+ const term = f.term.trim();
771
+ if (!term) continue;
772
+ const key = normGlossaryTerm(term);
773
+ if (known.has(key)) continue;
774
+ known.add(key);
775
+ const sug = { term, status: "pending" };
776
+ if (f.note?.trim()) sug.note = f.note.trim();
777
+ if (f.doNotTranslate) sug.doNotTranslate = true;
778
+ if (f.caseSensitive) sug.caseSensitive = true;
779
+ if (f.wholeWord === false) sug.wholeWord = false;
780
+ state.glossarySuggestions.push(sug);
781
+ added.push(sug);
782
+ }
783
+ return added;
784
+ }
785
+ function dismissGlossarySuggestion(state, term) {
786
+ const key = normGlossaryTerm(term);
787
+ const s = state.glossarySuggestions.find((x) => normGlossaryTerm(x.term) === key);
788
+ if (s) s.status = "dismissed";
789
+ }
790
+ function removeGlossarySuggestion(state, term) {
791
+ const key = normGlossaryTerm(term);
792
+ state.glossarySuggestions = state.glossarySuggestions.filter((x) => normGlossaryTerm(x.term) !== key);
793
+ }
759
794
  function addCustomWord(state, word) {
760
795
  const w = word.trim();
761
796
  if (!w) return;
@@ -786,6 +821,158 @@ function applyMachineTranslationForms(state, key, locale, forms, clock = systemC
786
821
  return true;
787
822
  }
788
823
 
824
+ // src/server/ai/glossary-suggest.ts
825
+ function globToRegExp(glob) {
826
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
827
+ return new RegExp(`^${escaped}$`);
828
+ }
829
+ function selectGlossarySources(state, opts) {
830
+ const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
831
+ let rows = [];
832
+ for (const key of Object.keys(state.keys)) {
833
+ if (keyRe && !keyRe.test(key)) continue;
834
+ const entry = state.keys[key];
835
+ if (opts.since) {
836
+ if (!entry.createdAt || entry.createdAt < opts.since) continue;
837
+ }
838
+ const lv = entry.values[state.config.sourceLocale];
839
+ const source = (lv?.value ?? lv?.forms?.other ?? "").trim();
840
+ if (!source) continue;
841
+ rows.push({ key, source });
842
+ }
843
+ rows.sort((a, b) => {
844
+ const ta = state.keys[a.key].createdAt ?? "";
845
+ const tb = state.keys[b.key].createdAt ?? "";
846
+ return tb.localeCompare(ta) || a.key.localeCompare(b.key);
847
+ });
848
+ if (opts.limit !== void 0) rows = rows.slice(0, opts.limit);
849
+ return rows;
850
+ }
851
+ function knownTermList(state) {
852
+ const out = /* @__PURE__ */ new Set();
853
+ for (const g of state.glossary) out.add(g.term);
854
+ for (const s of state.glossarySuggestions) out.add(s.term);
855
+ return [...out];
856
+ }
857
+ function buildGlossarySuggestSystemPrompt() {
858
+ return [
859
+ "You identify GLOSSARY-CANDIDATE terms in a UI string catalog so they translate consistently.",
860
+ "A glossary term is a brand or product name, a feature or module name, an acronym, a piece of domain/industry jargon, or any noun phrase that should translate the SAME way everywhere (or stay verbatim).",
861
+ "You are given source strings (the app's original language). Return the candidate terms you find.",
862
+ "Rules:",
863
+ "- Only surface terms a translator would benefit from pinning. IGNORE ordinary words, verbs, and generic UI labels (e.g. 'Save', 'Cancel', 'Welcome').",
864
+ "- Prefer terms that recur or are clearly proper nouns / product names / acronyms.",
865
+ "- Set doNotTranslate: true for brand/product names, code identifiers, and acronyms that must stay verbatim in every language.",
866
+ "- Set caseSensitive: true only when casing is meaningful (e.g. an all-caps acronym that must not match a lowercase common word).",
867
+ "- Set wholeWord: false ONLY if the term should also match inside larger words; otherwise omit it (whole-word is the default).",
868
+ "- note: one short phrase on why it's a term (e.g. 'product name', 'industry acronym', 'recurring UI concept'). Keep it under 80 characters.",
869
+ "- Do NOT return any term in the provided 'Already known' list.",
870
+ "- Return the term exactly as it appears in the source (preserve casing)."
871
+ ].join("\n");
872
+ }
873
+ function buildGlossarySuggestBatchPrompt(sources, knownTerms) {
874
+ const known = knownTerms.length ? knownTerms.join(", ") : "(none yet)";
875
+ const lines = sources.map((s) => `- [${s.key}] ${s.source}`).join("\n");
876
+ return [
877
+ `Already known (do NOT return these): ${known}`,
878
+ "",
879
+ "Source strings:",
880
+ lines,
881
+ "",
882
+ 'Return JSON {"terms":[{"term","note?","doNotTranslate?","caseSensitive?","wholeWord?"}]}. Return an empty array if you find no good candidates.'
883
+ ].join("\n");
884
+ }
885
+ var GLOSSARY_SUGGEST_SCHEMA = {
886
+ type: "object",
887
+ properties: {
888
+ terms: {
889
+ type: "array",
890
+ items: {
891
+ type: "object",
892
+ properties: {
893
+ term: { type: "string" },
894
+ note: { type: "string" },
895
+ doNotTranslate: { type: "boolean" },
896
+ caseSensitive: { type: "boolean" },
897
+ wholeWord: { type: "boolean" }
898
+ },
899
+ required: ["term"],
900
+ additionalProperties: false
901
+ }
902
+ }
903
+ },
904
+ required: ["terms"],
905
+ additionalProperties: false
906
+ };
907
+ function dedupeTerms(terms) {
908
+ const seen = /* @__PURE__ */ new Set();
909
+ const out = [];
910
+ for (const t of terms) {
911
+ const term = t.term?.trim();
912
+ if (!term) continue;
913
+ const key = term.toLowerCase();
914
+ if (seen.has(key)) continue;
915
+ seen.add(key);
916
+ out.push({ ...t, term });
917
+ }
918
+ return out;
919
+ }
920
+
921
+ // src/server/glossary.ts
922
+ function escapeRegExp(s) {
923
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
924
+ }
925
+ function contains(haystack, needle, caseSensitive, wholeWord) {
926
+ if (!wholeWord) {
927
+ return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
928
+ }
929
+ const re = new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(needle)}(?![\\p{L}\\p{N}])`, caseSensitive ? "u" : "iu");
930
+ return re.test(haystack);
931
+ }
932
+ function termInSource(source, entry) {
933
+ return contains(source, entry.term, entry.caseSensitive, entry.wholeWord ?? true);
934
+ }
935
+ function relevantGlossary(source, targetLocale, glossary) {
936
+ const hints = [];
937
+ for (const entry of glossary) {
938
+ if (!termInSource(source, entry)) continue;
939
+ hints.push({
940
+ term: entry.term,
941
+ doNotTranslate: entry.doNotTranslate,
942
+ forced: entry.translations?.[targetLocale],
943
+ notes: entry.notes
944
+ });
945
+ }
946
+ return hints;
947
+ }
948
+ function sourceKeysForTerm(state, term, opts = {}) {
949
+ const pseudo = { term, caseSensitive: opts.caseSensitive, wholeWord: opts.wholeWord };
950
+ const out = [];
951
+ for (const [key, entry] of Object.entries(state.keys)) {
952
+ const lv = entry.values[state.config.sourceLocale];
953
+ const text = lv?.value ?? lv?.forms?.other ?? "";
954
+ if (text && termInSource(text, pseudo)) out.push(key);
955
+ }
956
+ return out;
957
+ }
958
+ function glossaryViolations(source, value, targetLocale, glossary) {
959
+ const out = [];
960
+ for (const entry of glossary) {
961
+ if (!termInSource(source, entry)) continue;
962
+ if (entry.doNotTranslate) {
963
+ if (!contains(value, entry.term, entry.caseSensitive)) {
964
+ out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
965
+ }
966
+ continue;
967
+ }
968
+ const forced = entry.translations?.[targetLocale];
969
+ if (forced && !contains(value, forced, entry.caseSensitive)) {
970
+ out.push({ term: entry.term, expected: forced, kind: "forced" });
971
+ }
972
+ }
973
+ return out;
974
+ }
975
+
789
976
  // src/server/lint/accept.ts
790
977
  function acceptFindings(state, findings, opts = {}, clock = systemClock) {
791
978
  const byRule = {};
@@ -824,7 +1011,7 @@ function ensureGlotfileDir(projectRoot) {
824
1011
  }
825
1012
 
826
1013
  // src/server/glob.ts
827
- function globToRegExp(glob) {
1014
+ function globToRegExp2(glob) {
828
1015
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
829
1016
  return new RegExp(`^${escaped}$`);
830
1017
  }
@@ -875,7 +1062,7 @@ function computeUsedKeys(state, cache2) {
875
1062
  matchers.push(literalMatcher(l.literal));
876
1063
  }
877
1064
  }
878
- const keep = (state.config.scan?.keep ?? []).map(globToRegExp);
1065
+ const keep = (state.config.scan?.keep ?? []).map(globToRegExp2);
879
1066
  return Object.keys(state.keys).filter((key) => exact.has(key) || prefixes.some((p) => key.startsWith(p)) || matchers.some((matches) => matches(key)) || keep.some((re) => re.test(key))).sort();
880
1067
  }
881
1068
  function escapeRe(s) {
@@ -1403,7 +1590,7 @@ var MAX_CONTEXT_LENGTH = 500;
1403
1590
  var SNIPPET_WINDOW = 15;
1404
1591
  var MAX_SNIPPETS = 3;
1405
1592
  var EXCLUDED_DIRS = ["node_modules/", "vendor/", "dist/", ".git/", ".glotfile/"];
1406
- function globToRegExp2(glob) {
1593
+ function globToRegExp3(glob) {
1407
1594
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1408
1595
  return new RegExp(`^${escaped}$`);
1409
1596
  }
@@ -1433,6 +1620,21 @@ function extractSnippets(refs, projectRoot, fileCache) {
1433
1620
  }
1434
1621
  return snippets;
1435
1622
  }
1623
+ function attachUsageSnippets(targets, cache2, projectRoot) {
1624
+ const fileCache = /* @__PURE__ */ new Map();
1625
+ for (const target of targets) {
1626
+ const allRefs = Object.entries(cache2.files).flatMap(
1627
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
1628
+ key: r.key,
1629
+ file,
1630
+ line: r.line,
1631
+ col: r.col,
1632
+ scanner: r.scanner
1633
+ }))
1634
+ );
1635
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
1636
+ }
1637
+ }
1436
1638
  function buildUsageIndex(cache2) {
1437
1639
  const index = /* @__PURE__ */ new Map();
1438
1640
  for (const [file, entry] of Object.entries(cache2.files)) {
@@ -1446,7 +1648,7 @@ function buildUsageIndex(cache2) {
1446
1648
  }
1447
1649
  function selectContextTargets(state, opts, cache2, lastRunAt) {
1448
1650
  const cutoff = opts.all ? void 0 : opts.since ?? lastRunAt;
1449
- const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
1651
+ const keyRe = opts.keyGlob ? globToRegExp3(opts.keyGlob) : null;
1450
1652
  const keySet = opts.keys ? new Set(opts.keys) : null;
1451
1653
  const usageIndex = buildUsageIndex(cache2);
1452
1654
  let candidates = [];
@@ -1668,41 +1870,6 @@ function computeStats(state) {
1668
1870
  };
1669
1871
  }
1670
1872
 
1671
- // src/server/glossary.ts
1672
- function contains(haystack, needle, caseSensitive) {
1673
- return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
1674
- }
1675
- function relevantGlossary(source, targetLocale, glossary) {
1676
- const hints = [];
1677
- for (const entry of glossary) {
1678
- if (!contains(source, entry.term, entry.caseSensitive)) continue;
1679
- hints.push({
1680
- term: entry.term,
1681
- doNotTranslate: entry.doNotTranslate,
1682
- forced: entry.translations?.[targetLocale],
1683
- notes: entry.notes
1684
- });
1685
- }
1686
- return hints;
1687
- }
1688
- function glossaryViolations(source, value, targetLocale, glossary) {
1689
- const out = [];
1690
- for (const entry of glossary) {
1691
- if (!contains(source, entry.term, entry.caseSensitive)) continue;
1692
- if (entry.doNotTranslate) {
1693
- if (!contains(value, entry.term, entry.caseSensitive)) {
1694
- out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
1695
- }
1696
- continue;
1697
- }
1698
- const forced = entry.translations?.[targetLocale];
1699
- if (forced && !contains(value, forced, entry.caseSensitive)) {
1700
- out.push({ term: entry.term, expected: forced, kind: "forced" });
1701
- }
1702
- }
1703
- return out;
1704
- }
1705
-
1706
1873
  // src/server/spell.ts
1707
1874
  var instances = /* @__PURE__ */ new Map();
1708
1875
  var loading = /* @__PURE__ */ new Set();
@@ -2134,7 +2301,7 @@ async function runLint(state, options = {}) {
2134
2301
  spellers,
2135
2302
  allowWords
2136
2303
  };
2137
- const ignoreRes = (config.ignore ?? []).map(globToRegExp);
2304
+ const ignoreRes = (config.ignore ?? []).map(globToRegExp2);
2138
2305
  const localeFilter = options.locales ? new Set(options.locales) : null;
2139
2306
  const findings = [];
2140
2307
  let suppressed = 0;
@@ -3084,7 +3251,7 @@ function checkOutputs(state, root) {
3084
3251
  }
3085
3252
 
3086
3253
  // src/server/api.ts
3087
- import { readFileSync as readFileSync24, existsSync as existsSync13, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync6 } from "fs";
3254
+ import { readFileSync as readFileSync26, existsSync as existsSync14, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync7 } from "fs";
3088
3255
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
3089
3256
 
3090
3257
  // src/server/ai/anthropic.ts
@@ -3330,6 +3497,48 @@ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, o
3330
3497
  return results;
3331
3498
  }
3332
3499
 
3500
+ // src/server/ai/price-cache.ts
3501
+ import { readFileSync as readFileSync7 } from "fs";
3502
+ import { homedir } from "os";
3503
+ import { join as join4 } from "path";
3504
+ var defaultPriceCachePath = () => process.env.GLOTFILE_PRICES_PATH || join4(homedir(), ".glotfile", "model-prices.json");
3505
+ function isModelPrice(v) {
3506
+ if (!v || typeof v !== "object") return false;
3507
+ const p = v;
3508
+ return typeof p.inputPerMTok === "number" && typeof p.outputPerMTok === "number";
3509
+ }
3510
+ function loadPriceCache(path = defaultPriceCachePath()) {
3511
+ let parsed;
3512
+ try {
3513
+ parsed = JSON.parse(readFileSync7(path, "utf8"));
3514
+ } catch {
3515
+ return null;
3516
+ }
3517
+ if (!parsed || typeof parsed !== "object") return null;
3518
+ const raw = parsed;
3519
+ if (!raw.models || typeof raw.models !== "object") return null;
3520
+ const models = {};
3521
+ for (const [id, price] of Object.entries(raw.models)) {
3522
+ if (isModelPrice(price)) models[id] = price;
3523
+ }
3524
+ return {
3525
+ source: typeof raw.source === "string" ? raw.source : "unknown",
3526
+ fetchedAt: typeof raw.fetchedAt === "string" ? raw.fetchedAt : "",
3527
+ models
3528
+ };
3529
+ }
3530
+ function savePriceCache(cache2, path = defaultPriceCachePath()) {
3531
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
3532
+ }
3533
+ var memo;
3534
+ function getPriceCache() {
3535
+ if (memo === void 0) memo = loadPriceCache();
3536
+ return memo;
3537
+ }
3538
+ function invalidatePriceCache() {
3539
+ memo = void 0;
3540
+ }
3541
+
3333
3542
  // src/server/ai/pricing.ts
3334
3543
  function addUsage(into, add) {
3335
3544
  into.inputTokens += add.inputTokens;
@@ -3346,7 +3555,9 @@ function usageCostUsd(usage, ai, multiplier = 1) {
3346
3555
  return pricing ? estimateUsageCostUsd(usage, pricing, multiplier) : void 0;
3347
3556
  }
3348
3557
  function estimateUsageCostUsd(usage, pricing, multiplier = 1) {
3349
- const inputCost = (usage.inputTokens + usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER + usage.cacheReadInputTokens * CACHE_READ_MULTIPLIER) * pricing.inputPerMTok;
3558
+ const writeRate = pricing.cacheWritePerMTok ?? pricing.inputPerMTok * CACHE_WRITE_MULTIPLIER;
3559
+ const readRate = pricing.cacheReadPerMTok ?? pricing.inputPerMTok * CACHE_READ_MULTIPLIER;
3560
+ const inputCost = usage.inputTokens * pricing.inputPerMTok + usage.cacheCreationInputTokens * writeRate + usage.cacheReadInputTokens * readRate;
3350
3561
  return (inputCost + usage.outputTokens * pricing.outputPerMTok) / 1e6 * multiplier;
3351
3562
  }
3352
3563
  var PRICE_TABLE = [
@@ -3378,12 +3589,27 @@ function bareModelId(model) {
3378
3589
  if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3379
3590
  return id;
3380
3591
  }
3381
- function resolvePricing(ai) {
3592
+ function lookupCachePrice(cache2, id) {
3593
+ const exact = cache2.models[id];
3594
+ if (exact) return { source: "cache", ...exact };
3595
+ let best;
3596
+ for (const [cid, price] of Object.entries(cache2.models)) {
3597
+ if (id.startsWith(cid) && (!best || cid.length > best.id.length)) {
3598
+ best = { id: cid, price: { source: "cache", ...price } };
3599
+ }
3600
+ }
3601
+ return best ? best.price : null;
3602
+ }
3603
+ function resolvePricing(ai, cache2 = getPriceCache()) {
3382
3604
  if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3383
3605
  return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3384
3606
  }
3385
3607
  if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3386
3608
  const id = bareModelId(ai.model);
3609
+ if (cache2) {
3610
+ const cached = lookupCachePrice(cache2, id);
3611
+ if (cached) return cached;
3612
+ }
3387
3613
  let best;
3388
3614
  for (const row of PRICE_TABLE) {
3389
3615
  if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
@@ -3931,7 +4157,7 @@ function makeProvider(ai) {
3931
4157
  }
3932
4158
 
3933
4159
  // src/server/ai/run.ts
3934
- import { readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
4160
+ import { readFileSync as readFileSync8, existsSync as existsSync7 } from "fs";
3935
4161
  import { resolve as resolve5, extname as extname2 } from "path";
3936
4162
 
3937
4163
  // src/server/cell-state.ts
@@ -3950,7 +4176,7 @@ function cellState(entry, locale, sourceLocale) {
3950
4176
  // src/server/ai/run.ts
3951
4177
  function selectRequests(state, opts) {
3952
4178
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
3953
- const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
4179
+ const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
3954
4180
  const keySet = opts.keys ? new Set(opts.keys) : null;
3955
4181
  const stateSet = opts.states ? new Set(opts.states) : null;
3956
4182
  const skip = (st) => stateSet ? !stateSet.has(st) : !!opts.onlyMissing && st !== "missing";
@@ -4028,7 +4254,7 @@ function attachScreenshots(reqs, state, projectRoot) {
4028
4254
  if (!existsSync7(abs)) {
4029
4255
  cache2.set(screenshot, null);
4030
4256
  } else {
4031
- const buf = readFileSync7(abs);
4257
+ const buf = readFileSync8(abs);
4032
4258
  cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
4033
4259
  }
4034
4260
  }
@@ -4152,17 +4378,61 @@ function applyResults(state, reqs, results, clock = systemClock, force = false)
4152
4378
  return { written, errors };
4153
4379
  }
4154
4380
 
4381
+ // src/server/ai/explain-error.ts
4382
+ var KEY_ENV = {
4383
+ anthropic: "ANTHROPIC_API_KEY",
4384
+ openai: "OPENAI_API_KEY",
4385
+ openrouter: "OPENROUTER_API_KEY"
4386
+ };
4387
+ function rawMessage(err) {
4388
+ if (err instanceof Error && err.message) return err.message;
4389
+ if (typeof err === "string") return err;
4390
+ if (err && typeof err === "object" && "message" in err && typeof err.message === "string") {
4391
+ return err.message;
4392
+ }
4393
+ return String(err ?? "Unknown error");
4394
+ }
4395
+ function explainProviderError(provider, err) {
4396
+ const raw = rawMessage(err);
4397
+ const m = raw.toLowerCase();
4398
+ if (provider === "bedrock") {
4399
+ if (/could not load credentials|unable to locate credentials|credentialsprovider|credentials from any providers/.test(m)) {
4400
+ return "No AWS credentials found. Set AWS_PROFILE (or AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) in your shell or a .env file in the directory you started glotfile from, or use an SSO / instance role. If you just edited .env, restart glotfile so it reloads. For SSO, run `aws sso login`.";
4401
+ }
4402
+ if (/on-demand throughput isn.?t supported/.test(m) || /inference profile/.test(m)) {
4403
+ return 'This Bedrock model needs an inference profile for on-demand use. Prefix the model id with your region group \u2014 e.g. "eu.anthropic.claude-3-5-sonnet-20241022-v2:0" (or "us." / "apac." for your region).';
4404
+ }
4405
+ if (/not authorized to perform/.test(m) || /no identity-based policy/.test(m) || /bedrock:invoke/.test(m)) {
4406
+ return "Your AWS credentials authenticated, but their IAM policy doesn't allow this action. Add bedrock:InvokeModel (and bedrock:InvokeModelWithResponseStream) for this model to the IAM policy on this user/role.";
4407
+ }
4408
+ if (/access to the model|don.?t have access to the model/.test(m)) {
4409
+ return "Your account doesn't have access to this model in this region. Enable it in the Bedrock console under Model access, for the region you configured.";
4410
+ }
4411
+ if (/access ?denied/.test(m)) {
4412
+ return "Bedrock denied access. Either the model isn't enabled for your account/region (enable it in the Bedrock console under Model access) or your IAM policy is missing bedrock:InvokeModel for this model.";
4413
+ }
4414
+ if (/region/.test(m)) {
4415
+ return "No AWS region set for Bedrock. Set the Region in AI settings, or AWS_REGION in your environment.";
4416
+ }
4417
+ }
4418
+ const keyEnv = KEY_ENV[provider];
4419
+ if (keyEnv && /api key|unauthorized|\b401\b|authentication|incorrect api key|invalid x-api-key/.test(m)) {
4420
+ return `${provider} rejected the request \u2014 check ${keyEnv}. Set it in your environment or a .env file in the directory you started glotfile from.`;
4421
+ }
4422
+ return raw;
4423
+ }
4424
+
4155
4425
  // src/server/ai/pending-batch.ts
4156
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
4157
- import { join as join4 } from "path";
4426
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
4427
+ import { join as join5 } from "path";
4158
4428
  function pendingBatchPath(projectRoot) {
4159
- return join4(projectRoot, ".glotfile", "batch.json");
4429
+ return join5(projectRoot, ".glotfile", "batch.json");
4160
4430
  }
4161
4431
  function loadPendingBatch(projectRoot) {
4162
4432
  const path = pendingBatchPath(projectRoot);
4163
4433
  if (!existsSync8(path)) return void 0;
4164
4434
  try {
4165
- const parsed = JSON.parse(readFileSync8(path, "utf8"));
4435
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
4166
4436
  if (parsed?.version !== 1) return void 0;
4167
4437
  return parsed;
4168
4438
  } catch {
@@ -4170,9 +4440,9 @@ function loadPendingBatch(projectRoot) {
4170
4440
  }
4171
4441
  }
4172
4442
  function savePendingBatch(projectRoot, pending) {
4173
- const dir = join4(projectRoot, ".glotfile");
4443
+ const dir = join5(projectRoot, ".glotfile");
4174
4444
  mkdirSync4(dir, { recursive: true });
4175
- const gitignore = join4(dir, ".gitignore");
4445
+ const gitignore = join5(dir, ".gitignore");
4176
4446
  if (!existsSync8(gitignore)) writeFileSync3(gitignore, "*\n");
4177
4447
  writeFileSync3(pendingBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4178
4448
  }
@@ -4181,7 +4451,7 @@ function clearPendingBatch(projectRoot) {
4181
4451
  }
4182
4452
 
4183
4453
  // src/server/log.ts
4184
- import { appendFileSync, existsSync as existsSync9, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync9 } from "fs";
4454
+ import { appendFileSync, existsSync as existsSync9, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync10 } from "fs";
4185
4455
  import { resolve as resolve6 } from "path";
4186
4456
  function logPath(projectRoot) {
4187
4457
  return resolve6(projectRoot, ".glotfile", "log.jsonl");
@@ -4196,7 +4466,7 @@ function appendLog(projectRoot, entry) {
4196
4466
  }
4197
4467
  function trimLog(path, maxBytes = MAX_LOG_BYTES, targetBytes = TRIM_LOG_TO_BYTES) {
4198
4468
  if (!existsSync9(path) || statSync2(path).size <= maxBytes) return;
4199
- const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
4469
+ const lines = readFileSync10(path, "utf8").split("\n").filter((l) => l.trim() !== "");
4200
4470
  const kept = [];
4201
4471
  let bytes = 0;
4202
4472
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -4374,16 +4644,16 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
4374
4644
  }
4375
4645
 
4376
4646
  // src/server/ai/pending-context-batch.ts
4377
- import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
4378
- import { join as join5 } from "path";
4647
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
4648
+ import { join as join6 } from "path";
4379
4649
  function pendingContextBatchPath(projectRoot) {
4380
- return join5(projectRoot, ".glotfile", "context-batch.json");
4650
+ return join6(projectRoot, ".glotfile", "context-batch.json");
4381
4651
  }
4382
4652
  function loadPendingContextBatch(projectRoot) {
4383
4653
  const path = pendingContextBatchPath(projectRoot);
4384
4654
  if (!existsSync10(path)) return void 0;
4385
4655
  try {
4386
- const parsed = JSON.parse(readFileSync10(path, "utf8"));
4656
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
4387
4657
  if (parsed?.version !== 1) return void 0;
4388
4658
  return parsed;
4389
4659
  } catch {
@@ -4391,9 +4661,9 @@ function loadPendingContextBatch(projectRoot) {
4391
4661
  }
4392
4662
  }
4393
4663
  function savePendingContextBatch(projectRoot, pending) {
4394
- const dir = join5(projectRoot, ".glotfile");
4664
+ const dir = join6(projectRoot, ".glotfile");
4395
4665
  mkdirSync5(dir, { recursive: true });
4396
- const gitignore = join5(dir, ".gitignore");
4666
+ const gitignore = join6(dir, ".gitignore");
4397
4667
  if (!existsSync10(gitignore)) writeFileSync4(gitignore, "*\n");
4398
4668
  writeFileSync4(pendingContextBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4399
4669
  }
@@ -4500,106 +4770,319 @@ async function applyContextBatchResults(load, persist, provider, pending, projec
4500
4770
  return { written, errors, retried: retryChunks.length };
4501
4771
  }
4502
4772
 
4503
- // src/server/ai/estimate.ts
4504
- var CJK_RE = /[ -鿿가-힯豈-﫿]/g;
4505
- function estimateTokens(text) {
4506
- const cjk = text.match(CJK_RE)?.length ?? 0;
4507
- return Math.ceil((text.length - cjk) / 4 + cjk / 2);
4508
- }
4509
- var EXPANSION = 1.2;
4510
- var ITEM_REPLY_OVERHEAD = 16;
4511
- var FORM_REPLY_OVERHEAD = 8;
4512
- function estimateOutputTokens(req) {
4513
- const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
4514
- if (req.plural) {
4515
- return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
4516
- }
4517
- return ITEM_REPLY_OVERHEAD + translated;
4518
- }
4519
- function estimateTranslation(state, ai, opts) {
4520
- const reqs = selectRequests(state, opts);
4521
- const byLocale = /* @__PURE__ */ new Map();
4522
- for (const r of reqs) {
4523
- let group = byLocale.get(r.targetLocale);
4524
- if (!group) {
4525
- group = [];
4526
- byLocale.set(r.targetLocale, group);
4527
- }
4528
- group.push(r);
4529
- }
4530
- const perLocale = [];
4531
- for (const [locale, group] of byLocale) {
4532
- let inputTokens2 = 0;
4533
- let outputTokens2 = 0;
4534
- const batches = chunk(group, Math.max(1, ai.batchSize));
4535
- for (const batch of batches) {
4536
- const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
4537
- inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
4538
- for (const r of batch) outputTokens2 += estimateOutputTokens(r);
4539
- }
4540
- perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
4541
- }
4542
- const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
4543
- const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
4544
- const pricing = resolvePricing(ai);
4545
- return {
4546
- requests: reqs.length,
4547
- batches: perLocale.reduce((n, l) => n + l.batches, 0),
4548
- perLocale,
4549
- inputTokens,
4550
- outputTokens,
4551
- pricing,
4552
- estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4553
- };
4773
+ // src/server/ai/pending-glossary-batch.ts
4774
+ import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync12, writeFileSync as writeFileSync5, rmSync as rmSync6 } from "fs";
4775
+ import { join as join7 } from "path";
4776
+ function pendingGlossaryBatchPath(projectRoot) {
4777
+ return join7(projectRoot, ".glotfile", "glossary-suggest-batch.json");
4554
4778
  }
4555
-
4556
- // src/server/import/run.ts
4557
- import { relative as relative3 } from "path";
4558
-
4559
- // src/server/import/detect.ts
4560
- import { existsSync as existsSync11, readdirSync as readdirSync3, readFileSync as readFileSync11, statSync as statSync3 } from "fs";
4561
- import { join as join6 } from "path";
4562
- var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4563
- var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
4564
- function safeIsDir(p) {
4779
+ function loadPendingGlossaryBatch(projectRoot) {
4780
+ const path = pendingGlossaryBatchPath(projectRoot);
4781
+ if (!existsSync11(path)) return void 0;
4565
4782
  try {
4566
- return statSync3(p).isDirectory();
4783
+ const parsed = JSON.parse(readFileSync12(path, "utf8"));
4784
+ if (parsed?.version !== 1) return void 0;
4785
+ return parsed;
4567
4786
  } catch {
4568
- return false;
4787
+ return void 0;
4569
4788
  }
4570
4789
  }
4571
- function listDirs(dir) {
4572
- return readdirSync3(dir).filter((e) => safeIsDir(join6(dir, e)));
4790
+ function savePendingGlossaryBatch(projectRoot, pending) {
4791
+ const dir = join7(projectRoot, ".glotfile");
4792
+ mkdirSync6(dir, { recursive: true });
4793
+ const gitignore = join7(dir, ".gitignore");
4794
+ if (!existsSync11(gitignore)) writeFileSync5(gitignore, "*\n");
4795
+ writeFileSync5(pendingGlossaryBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4573
4796
  }
4574
- function fileCount(dir) {
4575
- try {
4576
- return readdirSync3(dir).length;
4577
- } catch {
4578
- return 0;
4579
- }
4797
+ function clearPendingGlossaryBatch(projectRoot) {
4798
+ rmSync6(pendingGlossaryBatchPath(projectRoot), { force: true });
4580
4799
  }
4581
- function pickSource(locales, sizeOf) {
4582
- if (locales.includes("en")) return "en";
4583
- return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4800
+
4801
+ // src/server/ai/glossary-batch-run.ts
4802
+ function completionRequestFor2(chunk2, knownTerms) {
4803
+ return {
4804
+ system: buildGlossarySuggestSystemPrompt(),
4805
+ content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunk2, knownTerms) }],
4806
+ schema: GLOSSARY_SUGGEST_SCHEMA
4807
+ };
4808
+ }
4809
+ async function submitGlossarySuggestBatch(provider, sources, knownTerms, batchSize, model, projectRoot) {
4810
+ if (loadPendingGlossaryBatch(projectRoot)) {
4811
+ throw new Error("A glossary suggestion batch is already pending. Apply or cancel it first.");
4812
+ }
4813
+ const chunks = [];
4814
+ const size = Math.max(1, batchSize);
4815
+ for (let i = 0; i < sources.length; i += size) chunks.push(sources.slice(i, i + size));
4816
+ const jobs = chunks.map((chunk2, i) => ({ customId: `gloss_${i}`, chunk: chunk2 }));
4817
+ const batchId = await provider.submitCompletionBatch(
4818
+ jobs.map((j) => ({ customId: j.customId, request: completionRequestFor2(j.chunk, knownTerms) }))
4819
+ );
4820
+ const pending = {
4821
+ version: 1,
4822
+ // Only Anthropic implements completion batches today.
4823
+ provider: "anthropic",
4824
+ model,
4825
+ batchId,
4826
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4827
+ total: sources.length,
4828
+ knownTerms,
4829
+ jobs: jobs.map((j) => ({
4830
+ customId: j.customId,
4831
+ requests: j.chunk
4832
+ }))
4833
+ };
4834
+ savePendingGlossaryBatch(projectRoot, pending);
4835
+ return pending;
4836
+ }
4837
+ async function applyGlossarySuggestBatchResults(load, persist, provider, pending, projectRoot, ai) {
4838
+ provider.takeUsage?.();
4839
+ const outcomes = await provider.completionBatchResults(pending.batchId);
4840
+ const batchUsage = provider.takeUsage?.();
4841
+ const allTerms = [];
4842
+ const errors = [];
4843
+ const jobFailures = [];
4844
+ const retryChunks = [];
4845
+ for (const job of pending.jobs) {
4846
+ const outcome = outcomes.get(job.customId);
4847
+ if (outcome?.type === "json") {
4848
+ const batch = outcome.value;
4849
+ allTerms.push(...batch.terms ?? []);
4850
+ continue;
4851
+ }
4852
+ if (!outcome) jobFailures.push({ customId: job.customId, locale: "", type: "missing" });
4853
+ else if (outcome.type === "malformed") jobFailures.push({ customId: job.customId, locale: "", type: "malformed", raw: outcome.raw });
4854
+ else jobFailures.push({ customId: job.customId, locale: "", type: "failed", error: outcome.error });
4855
+ retryChunks.push(job.requests);
4856
+ }
4857
+ for (const chunk2 of retryChunks) {
4858
+ try {
4859
+ const raw = await provider.complete(completionRequestFor2(chunk2, pending.knownTerms));
4860
+ const batch = raw;
4861
+ allTerms.push(...batch.terms ?? []);
4862
+ } catch (e) {
4863
+ errors.push({ error: e.message });
4864
+ }
4865
+ }
4866
+ const retryUsage = provider.takeUsage?.();
4867
+ const pricing = resolvePricing({ ...ai, model: pending.model });
4868
+ let estimatedCostUsd;
4869
+ if (pricing && (batchUsage || retryUsage)) {
4870
+ estimatedCostUsd = (batchUsage ? estimateUsageCostUsd(batchUsage, pricing, BATCH_PRICE_MULTIPLIER) : 0) + (retryUsage ? estimateUsageCostUsd(retryUsage, pricing) : 0);
4871
+ }
4872
+ let usage;
4873
+ if (batchUsage || retryUsage) {
4874
+ usage = batchUsage ?? { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
4875
+ if (retryUsage) addUsage(usage, retryUsage);
4876
+ }
4877
+ const fresh = load();
4878
+ const added = mergeGlossarySuggestions(fresh, dedupeTerms(allTerms));
4879
+ persist(fresh);
4880
+ clearPendingGlossaryBatch(projectRoot);
4881
+ const costSuffix = estimatedCostUsd !== void 0 ? ` (~$${estimatedCostUsd.toFixed(2)})` : "";
4882
+ appendLog(projectRoot, {
4883
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4884
+ kind: "glossary",
4885
+ summary: `Applied glossary suggestion batch ${pending.batchId}: ${added.length} new term(s), ${errors.length} error(s), ${retryChunks.length} job(s) retried${costSuffix}`,
4886
+ model: pending.model,
4887
+ jobFailures: jobFailures.length ? jobFailures : void 0,
4888
+ usage,
4889
+ estimatedCostUsd
4890
+ });
4891
+ return { added: added.length, errors, retried: retryChunks.length };
4892
+ }
4893
+
4894
+ // src/server/ai/estimate.ts
4895
+ var CJK_RE = /[ -鿿가-힯豈-﫿]/g;
4896
+ function estimateTokens(text) {
4897
+ const cjk = text.match(CJK_RE)?.length ?? 0;
4898
+ return Math.ceil((text.length - cjk) / 4 + cjk / 2);
4899
+ }
4900
+ var EXPANSION = 1.2;
4901
+ var ITEM_REPLY_OVERHEAD = 16;
4902
+ var FORM_REPLY_OVERHEAD = 8;
4903
+ function estimateOutputTokens(req) {
4904
+ const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
4905
+ if (req.plural) {
4906
+ return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
4907
+ }
4908
+ return ITEM_REPLY_OVERHEAD + translated;
4909
+ }
4910
+ function estimateTranslation(state, ai, opts) {
4911
+ const reqs = selectRequests(state, opts);
4912
+ const byLocale = /* @__PURE__ */ new Map();
4913
+ for (const r of reqs) {
4914
+ let group = byLocale.get(r.targetLocale);
4915
+ if (!group) {
4916
+ group = [];
4917
+ byLocale.set(r.targetLocale, group);
4918
+ }
4919
+ group.push(r);
4920
+ }
4921
+ const perLocale = [];
4922
+ for (const [locale, group] of byLocale) {
4923
+ let inputTokens2 = 0;
4924
+ let outputTokens2 = 0;
4925
+ const batches = chunk(group, Math.max(1, ai.batchSize));
4926
+ for (const batch of batches) {
4927
+ const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
4928
+ inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
4929
+ for (const r of batch) outputTokens2 += estimateOutputTokens(r);
4930
+ }
4931
+ perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
4932
+ }
4933
+ const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
4934
+ const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
4935
+ const pricing = resolvePricing(ai);
4936
+ return {
4937
+ requests: reqs.length,
4938
+ batches: perLocale.reduce((n, l) => n + l.batches, 0),
4939
+ perLocale,
4940
+ inputTokens,
4941
+ outputTokens,
4942
+ pricing,
4943
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4944
+ };
4945
+ }
4946
+ var CONTEXT_REPLY_OVERHEAD = 16;
4947
+ var TYPICAL_CONTEXT_TOKENS = 35;
4948
+ function estimateContext(targets, ai) {
4949
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4950
+ const batches = chunk(targets, batchSize);
4951
+ const system = buildContextSystemPrompt();
4952
+ let inputTokens = 0;
4953
+ let outputTokens = 0;
4954
+ for (const batch of batches) {
4955
+ inputTokens += estimateTokens(system) + estimateTokens(buildContextBatchPrompt(batch));
4956
+ outputTokens += batch.length * (CONTEXT_REPLY_OVERHEAD + TYPICAL_CONTEXT_TOKENS);
4957
+ }
4958
+ const pricing = resolvePricing(ai);
4959
+ return {
4960
+ keys: targets.length,
4961
+ batches: batches.length,
4962
+ inputTokens,
4963
+ outputTokens,
4964
+ pricing,
4965
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4966
+ };
4967
+ }
4968
+ var TERM_REPLY_TOKENS = 24;
4969
+ var TERM_YIELD = 0.15;
4970
+ function estimateGlossarySuggest(sources, knownTerms, ai) {
4971
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4972
+ const batches = chunk(sources, batchSize);
4973
+ const system = buildGlossarySuggestSystemPrompt();
4974
+ let inputTokens = 0;
4975
+ let outputTokens = 0;
4976
+ for (const batch of batches) {
4977
+ inputTokens += estimateTokens(system) + estimateTokens(buildGlossarySuggestBatchPrompt(batch, knownTerms));
4978
+ outputTokens += Math.ceil(batch.length * TERM_YIELD) * TERM_REPLY_TOKENS;
4979
+ }
4980
+ const pricing = resolvePricing(ai);
4981
+ return {
4982
+ sources: sources.length,
4983
+ batches: batches.length,
4984
+ inputTokens,
4985
+ outputTokens,
4986
+ pricing,
4987
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4988
+ };
4989
+ }
4990
+
4991
+ // src/server/ai/price-fetch.ts
4992
+ var MODELS_DEV_URL = "https://models.dev/api.json";
4993
+ var priceUrl = () => process.env.GLOTFILE_PRICES_URL || MODELS_DEV_URL;
4994
+ var PROVIDER_PREFERENCE = ["anthropic", "openai", "google", "meta-llama", "mistral", "deepseek", "xai", "groq"];
4995
+ var providerRank = (provId) => {
4996
+ const i = PROVIDER_PREFERENCE.indexOf(provId);
4997
+ return i === -1 ? PROVIDER_PREFERENCE.length : i;
4998
+ };
4999
+ function normalizeModelsDevPrices(api) {
5000
+ const out = {};
5001
+ const ranks = {};
5002
+ if (!api || typeof api !== "object") return out;
5003
+ for (const [provId, prov] of Object.entries(api)) {
5004
+ const models = prov?.models;
5005
+ if (!models || typeof models !== "object") continue;
5006
+ const rank = providerRank(provId);
5007
+ for (const [modelKey, model] of Object.entries(models)) {
5008
+ const cost = model?.cost;
5009
+ if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number") continue;
5010
+ const bareId = bareModelId(modelKey);
5011
+ if (!bareId) continue;
5012
+ const existingRank = ranks[bareId];
5013
+ if (existingRank !== void 0 && existingRank <= rank) continue;
5014
+ const price = { inputPerMTok: cost.input, outputPerMTok: cost.output };
5015
+ if (typeof cost.cache_read === "number") price.cacheReadPerMTok = cost.cache_read;
5016
+ if (typeof cost.cache_write === "number") price.cacheWritePerMTok = cost.cache_write;
5017
+ out[bareId] = price;
5018
+ ranks[bareId] = rank;
5019
+ }
5020
+ }
5021
+ return out;
5022
+ }
5023
+ var defaultNow = () => (/* @__PURE__ */ new Date()).toISOString();
5024
+ async function refreshPrices(opts = {}) {
5025
+ const url = opts.url ?? priceUrl();
5026
+ const doFetch = opts.fetchImpl ?? fetch;
5027
+ const res = await doFetch(url);
5028
+ if (!res.ok) throw new Error(`Failed to fetch prices from ${url}: HTTP ${res.status}`);
5029
+ const api = await res.json();
5030
+ const models = normalizeModelsDevPrices(api);
5031
+ const modelCount = Object.keys(models).length;
5032
+ if (modelCount === 0) throw new Error(`No model prices found in response from ${url}`);
5033
+ const cache2 = { source: "models.dev", fetchedAt: (opts.now ?? defaultNow)(), models };
5034
+ const path = opts.path ?? defaultPriceCachePath();
5035
+ savePriceCache(cache2, path);
5036
+ return { source: cache2.source, fetchedAt: cache2.fetchedAt, modelCount, path };
5037
+ }
5038
+
5039
+ // src/server/import/run.ts
5040
+ import { relative as relative3 } from "path";
5041
+
5042
+ // src/server/import/detect.ts
5043
+ import { existsSync as existsSync12, readdirSync as readdirSync3, readFileSync as readFileSync13, statSync as statSync3 } from "fs";
5044
+ import { join as join8 } from "path";
5045
+ var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5046
+ var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
5047
+ function safeIsDir(p) {
5048
+ try {
5049
+ return statSync3(p).isDirectory();
5050
+ } catch {
5051
+ return false;
5052
+ }
5053
+ }
5054
+ function listDirs(dir) {
5055
+ return readdirSync3(dir).filter((e) => safeIsDir(join8(dir, e)));
5056
+ }
5057
+ function fileCount(dir) {
5058
+ try {
5059
+ return readdirSync3(dir).length;
5060
+ } catch {
5061
+ return 0;
5062
+ }
5063
+ }
5064
+ function pickSource(locales, sizeOf) {
5065
+ if (locales.includes("en")) return "en";
5066
+ return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4584
5067
  }
4585
5068
  function detectLaravel(root) {
4586
- const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
5069
+ const localeRoot = [join8(root, "resources", "lang"), join8(root, "lang")].find(safeIsDir);
4587
5070
  if (!localeRoot) return null;
4588
5071
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4589
5072
  if (locales.length === 0) return null;
4590
- const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
5073
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join8(localeRoot, loc)));
4591
5074
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4592
5075
  }
4593
5076
  function detectVue(root, forced = false) {
4594
5077
  for (const rel of VUE_DIR_CANDIDATES) {
4595
- const localeRoot = join6(root, rel);
5078
+ const localeRoot = join8(root, rel);
4596
5079
  if (!safeIsDir(localeRoot)) continue;
4597
5080
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4598
5081
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4599
5082
  if (enough) {
4600
5083
  const sourceLocale = pickSource(locales, (loc) => {
4601
5084
  try {
4602
- return statSync3(join6(localeRoot, `${loc}.json`)).size;
5085
+ return statSync3(join8(localeRoot, `${loc}.json`)).size;
4603
5086
  } catch {
4604
5087
  return 0;
4605
5088
  }
@@ -4613,9 +5096,9 @@ var NEXT_INTL_CONFIG_CANDIDATES = ["src/i18n/request.ts", "i18n/request.ts", "sr
4613
5096
  var NEXT_INTL_ROUTING_CANDIDATES = ["src/i18n/routing.ts", "i18n/routing.ts", "src/i18n/routing.js", "i18n/routing.js"];
4614
5097
  var NEXT_INTL_DIR_CANDIDATES = ["messages", "src/messages", "locales", "src/locales", "src/i18n/messages"];
4615
5098
  function hasNextIntlSignal(root) {
4616
- if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync11(join6(root, rel)))) return true;
5099
+ if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync12(join8(root, rel)))) return true;
4617
5100
  try {
4618
- const pkg = JSON.parse(readFileSync11(join6(root, "package.json"), "utf8"));
5101
+ const pkg = JSON.parse(readFileSync13(join8(root, "package.json"), "utf8"));
4619
5102
  if (pkg.dependencies?.["next-intl"] || pkg.devDependencies?.["next-intl"]) return true;
4620
5103
  } catch {
4621
5104
  }
@@ -4624,7 +5107,7 @@ function hasNextIntlSignal(root) {
4624
5107
  function nextIntlDefaultLocale(root) {
4625
5108
  for (const rel of NEXT_INTL_ROUTING_CANDIDATES) {
4626
5109
  try {
4627
- const m = readFileSync11(join6(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
5110
+ const m = readFileSync13(join8(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
4628
5111
  if (m) return m[1];
4629
5112
  } catch {
4630
5113
  }
@@ -4634,14 +5117,14 @@ function nextIntlDefaultLocale(root) {
4634
5117
  function detectNextIntl(root, forced = false) {
4635
5118
  if (!forced && !hasNextIntlSignal(root)) return null;
4636
5119
  for (const rel of NEXT_INTL_DIR_CANDIDATES) {
4637
- const localeRoot = join6(root, rel);
5120
+ const localeRoot = join8(root, rel);
4638
5121
  if (!safeIsDir(localeRoot)) continue;
4639
5122
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4640
5123
  if (locales.length === 0) continue;
4641
5124
  const def = nextIntlDefaultLocale(root);
4642
5125
  const sourceLocale = def && locales.includes(def) ? def : pickSource(locales, (loc) => {
4643
5126
  try {
4644
- return statSync3(join6(localeRoot, `${loc}.json`)).size;
5127
+ return statSync3(join8(localeRoot, `${loc}.json`)).size;
4645
5128
  } catch {
4646
5129
  return 0;
4647
5130
  }
@@ -4652,7 +5135,7 @@ function detectNextIntl(root, forced = false) {
4652
5135
  }
4653
5136
  function detectArb(root) {
4654
5137
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4655
- const localeRoot = join6(root, rel);
5138
+ const localeRoot = join8(root, rel);
4656
5139
  if (!safeIsDir(localeRoot)) continue;
4657
5140
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4658
5141
  if (locales.length >= 1) {
@@ -4662,10 +5145,10 @@ function detectArb(root) {
4662
5145
  return null;
4663
5146
  }
4664
5147
  function lprojLocales(dir) {
4665
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.strings")));
5148
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join8(dir, `${l}.lproj`, "Localizable.strings")));
4666
5149
  }
4667
5150
  function detectApple(root) {
4668
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5151
+ const candidates = [root, ...listDirs(root).map((d) => join8(root, d))];
4669
5152
  let best = null;
4670
5153
  for (const dir of candidates) {
4671
5154
  const locales = lprojLocales(dir);
@@ -4677,7 +5160,7 @@ function detectApple(root) {
4677
5160
  locales,
4678
5161
  sourceLocale: pickSource(locales, (loc) => {
4679
5162
  try {
4680
- return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
5163
+ return statSync3(join8(dir, `${loc}.lproj`, "Localizable.strings")).size;
4681
5164
  } catch {
4682
5165
  return 0;
4683
5166
  }
@@ -4690,7 +5173,7 @@ function detectApple(root) {
4690
5173
  var ANGULAR_DIR_CANDIDATES = [".", "src/locale", "src/locales", "src/i18n", "locale", "locales", "i18n", "translations"];
4691
5174
  function detectAngularXliff(root) {
4692
5175
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4693
- const localeRoot = rel === "." ? root : join6(root, rel);
5176
+ const localeRoot = rel === "." ? root : join8(root, rel);
4694
5177
  if (!safeIsDir(localeRoot)) continue;
4695
5178
  const files = readdirSync3(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4696
5179
  if (files.length === 0) continue;
@@ -4698,7 +5181,7 @@ function detectAngularXliff(root) {
4698
5181
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4699
5182
  let sourceLocale;
4700
5183
  try {
4701
- sourceLocale = readFileSync11(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
5184
+ sourceLocale = readFileSync13(join8(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4702
5185
  } catch {
4703
5186
  }
4704
5187
  if (!sourceLocale && locales.length === 0) continue;
@@ -4709,14 +5192,14 @@ function detectAngularXliff(root) {
4709
5192
  return null;
4710
5193
  }
4711
5194
  function detectRails(root) {
4712
- const localeRoot = join6(root, "config", "locales");
5195
+ const localeRoot = join8(root, "config", "locales");
4713
5196
  if (!safeIsDir(localeRoot)) return null;
4714
5197
  const locales = [];
4715
5198
  for (const file of readdirSync3(localeRoot).sort()) {
4716
5199
  if (!/\.ya?ml$/.test(file)) continue;
4717
5200
  let text;
4718
5201
  try {
4719
- text = readFileSync11(join6(localeRoot, file), "utf8");
5202
+ text = readFileSync13(join8(localeRoot, file), "utf8");
4720
5203
  } catch {
4721
5204
  continue;
4722
5205
  }
@@ -4731,15 +5214,15 @@ function detectRails(root) {
4731
5214
  var I18NEXT_DIR_CANDIDATES = ["public/locales", "static/locales", "locales", "src/locales", "src/i18n/locales"];
4732
5215
  function detectI18next(root) {
4733
5216
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4734
- const localeRoot = join6(root, rel);
5217
+ const localeRoot = join8(root, rel);
4735
5218
  if (!safeIsDir(localeRoot)) continue;
4736
5219
  const locales = listDirs(localeRoot).filter(
4737
- (d) => LOCALE_RE.test(d) && readdirSync3(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
5220
+ (d) => LOCALE_RE.test(d) && readdirSync3(join8(localeRoot, d)).some((f) => f.endsWith(".json"))
4738
5221
  );
4739
5222
  if (locales.length === 0) continue;
4740
5223
  const sourceLocale = pickSource(locales, (loc) => {
4741
5224
  try {
4742
- return readdirSync3(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join6(localeRoot, loc, f)).size, 0);
5225
+ return readdirSync3(join8(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join8(localeRoot, loc, f)).size, 0);
4743
5226
  } catch {
4744
5227
  return 0;
4745
5228
  }
@@ -4756,8 +5239,8 @@ function gettextLocales(dir) {
4756
5239
  if (!locales.includes(flat)) locales.push(flat);
4757
5240
  continue;
4758
5241
  }
4759
- if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4760
- const sub = join6(dir, entry);
5242
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join8(dir, entry))) continue;
5243
+ const sub = join8(dir, entry);
4761
5244
  const hasPo = (d) => {
4762
5245
  try {
4763
5246
  return readdirSync3(d).some((f) => f.endsWith(".po"));
@@ -4765,7 +5248,7 @@ function gettextLocales(dir) {
4765
5248
  return false;
4766
5249
  }
4767
5250
  };
4768
- if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
5251
+ if (hasPo(join8(sub, "LC_MESSAGES")) || hasPo(sub)) {
4769
5252
  if (!locales.includes(entry)) locales.push(entry);
4770
5253
  }
4771
5254
  }
@@ -4774,7 +5257,7 @@ function gettextLocales(dir) {
4774
5257
  var GETTEXT_DIR_CANDIDATES = ["locale", "locales", "po", "translations"];
4775
5258
  function detectGettext(root) {
4776
5259
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4777
- const localeRoot = join6(root, rel);
5260
+ const localeRoot = join8(root, rel);
4778
5261
  if (!safeIsDir(localeRoot)) continue;
4779
5262
  const locales = gettextLocales(localeRoot);
4780
5263
  if (locales.length === 0) continue;
@@ -4783,10 +5266,10 @@ function detectGettext(root) {
4783
5266
  return null;
4784
5267
  }
4785
5268
  function detectAppleStringsdict(root) {
4786
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5269
+ const candidates = [root, ...listDirs(root).map((d) => join8(root, d))];
4787
5270
  let best = null;
4788
5271
  for (const dir of candidates) {
4789
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.stringsdict")));
5272
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join8(dir, `${l}.lproj`, "Localizable.stringsdict")));
4790
5273
  if (locales.length === 0) continue;
4791
5274
  if (!best || locales.length > best.locales.length) {
4792
5275
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4819,7 +5302,7 @@ var BY_FORMAT = {
4819
5302
  "apple-stringsdict": detectAppleStringsdict
4820
5303
  };
4821
5304
  function detect(root, formatOverride) {
4822
- if (!existsSync11(root)) return null;
5305
+ if (!existsSync12(root)) return null;
4823
5306
  if (formatOverride) {
4824
5307
  const fn = BY_FORMAT[formatOverride];
4825
5308
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -4833,8 +5316,8 @@ function detect(root, formatOverride) {
4833
5316
  }
4834
5317
 
4835
5318
  // src/server/import/parsers/vue-i18n-json.ts
4836
- import { readdirSync as readdirSync4, readFileSync as readFileSync12 } from "fs";
4837
- import { join as join7 } from "path";
5319
+ import { readdirSync as readdirSync4, readFileSync as readFileSync14 } from "fs";
5320
+ import { join as join9 } from "path";
4838
5321
 
4839
5322
  // src/server/import/flatten.ts
4840
5323
  function flattenObject(value, prefix, warnings) {
@@ -4875,7 +5358,7 @@ var vueI18nJson2 = {
4875
5358
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4876
5359
  let data;
4877
5360
  try {
4878
- data = JSON.parse(readFileSync12(join7(localeRoot, file), "utf8"));
5361
+ data = JSON.parse(readFileSync14(join9(localeRoot, file), "utf8"));
4879
5362
  } catch (e) {
4880
5363
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4881
5364
  continue;
@@ -4890,8 +5373,8 @@ var vueI18nJson2 = {
4890
5373
  };
4891
5374
 
4892
5375
  // src/server/import/parsers/next-intl-json.ts
4893
- import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4894
- import { join as join8 } from "path";
5376
+ import { readdirSync as readdirSync5, readFileSync as readFileSync15 } from "fs";
5377
+ import { join as join10 } from "path";
4895
5378
  var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4896
5379
  var nextIntlJson2 = {
4897
5380
  name: "next-intl-json",
@@ -4906,7 +5389,7 @@ var nextIntlJson2 = {
4906
5389
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4907
5390
  let data;
4908
5391
  try {
4909
- data = JSON.parse(readFileSync13(join8(localeRoot, file), "utf8"));
5392
+ data = JSON.parse(readFileSync15(join10(localeRoot, file), "utf8"));
4910
5393
  } catch (e) {
4911
5394
  warnings.push(`next-intl-json: failed to parse ${file}: ${e.message}`);
4912
5395
  continue;
@@ -4922,7 +5405,7 @@ var nextIntlJson2 = {
4922
5405
 
4923
5406
  // src/server/import/parsers/laravel-php.ts
4924
5407
  import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
4925
- import { join as join9, relative as relative2 } from "path";
5408
+ import { join as join11, relative as relative2 } from "path";
4926
5409
  import { execFileSync } from "child_process";
4927
5410
 
4928
5411
  // src/server/import/placeholders.ts
@@ -4938,13 +5421,13 @@ function railsToCanonical(value) {
4938
5421
 
4939
5422
  // src/server/import/parsers/laravel-php.ts
4940
5423
  function listDirs2(dir) {
4941
- return readdirSync6(dir).filter((e) => statSync4(join9(dir, e)).isDirectory());
5424
+ return readdirSync6(dir).filter((e) => statSync4(join11(dir, e)).isDirectory());
4942
5425
  }
4943
5426
  function listPhpFiles(dir) {
4944
5427
  const out = [];
4945
5428
  const walk = (d) => {
4946
5429
  for (const e of readdirSync6(d)) {
4947
- const full = join9(d, e);
5430
+ const full = join11(d, e);
4948
5431
  if (statSync4(full).isDirectory()) walk(full);
4949
5432
  else if (e.endsWith(".php")) out.push(full);
4950
5433
  }
@@ -4981,7 +5464,7 @@ var laravelPhp2 = {
4981
5464
  for (const locale of listDirs2(localeRoot).sort()) {
4982
5465
  if (locale === "vendor") continue;
4983
5466
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4984
- const localeDir = join9(localeRoot, locale);
5467
+ const localeDir = join11(localeRoot, locale);
4985
5468
  locales.push(locale);
4986
5469
  for (const file of listPhpFiles(localeDir)) {
4987
5470
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -5004,8 +5487,8 @@ var laravelPhp2 = {
5004
5487
  };
5005
5488
 
5006
5489
  // src/server/import/parsers/flutter-arb.ts
5007
- import { readdirSync as readdirSync7, readFileSync as readFileSync14 } from "fs";
5008
- import { join as join10 } from "path";
5490
+ import { readdirSync as readdirSync7, readFileSync as readFileSync16 } from "fs";
5491
+ import { join as join12 } from "path";
5009
5492
  var LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5010
5493
  function localeFromArbName(file) {
5011
5494
  const m = file.match(/^(.+)\.arb$/);
@@ -5041,7 +5524,7 @@ var flutterArb2 = {
5041
5524
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5042
5525
  let data;
5043
5526
  try {
5044
- data = JSON.parse(readFileSync14(join10(localeRoot, file), "utf8"));
5527
+ data = JSON.parse(readFileSync16(join12(localeRoot, file), "utf8"));
5045
5528
  } catch (e) {
5046
5529
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
5047
5530
  continue;
@@ -5066,8 +5549,8 @@ var flutterArb2 = {
5066
5549
  };
5067
5550
 
5068
5551
  // src/server/import/parsers/apple-strings.ts
5069
- import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
5070
- import { join as join11 } from "path";
5552
+ import { readdirSync as readdirSync8, readFileSync as readFileSync17, statSync as statSync5 } from "fs";
5553
+ import { join as join13 } from "path";
5071
5554
  var LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5072
5555
  var TABLE = "Localizable.strings";
5073
5556
  function localeFromLproj(dir) {
@@ -5183,16 +5666,16 @@ var appleStrings2 = {
5183
5666
  const locale = localeFromLproj(dir);
5184
5667
  if (!locale) continue;
5185
5668
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5186
- const file = join11(localeRoot, dir, TABLE);
5669
+ const file = join13(localeRoot, dir, TABLE);
5187
5670
  let text;
5188
5671
  try {
5189
5672
  if (!statSync5(file).isFile()) continue;
5190
- text = readFileSync15(file, "utf8");
5673
+ text = readFileSync17(file, "utf8");
5191
5674
  } catch {
5192
5675
  continue;
5193
5676
  }
5194
5677
  locales.push(locale);
5195
- const others = readdirSync8(join11(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5678
+ const others = readdirSync8(join13(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5196
5679
  if (others.length) {
5197
5680
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
5198
5681
  }
@@ -5205,8 +5688,8 @@ var appleStrings2 = {
5205
5688
  };
5206
5689
 
5207
5690
  // src/server/import/parsers/angular-xliff.ts
5208
- import { readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
5209
- import { join as join12 } from "path";
5691
+ import { readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
5692
+ import { join as join14 } from "path";
5210
5693
  var LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5211
5694
  var FILE_RE = /^messages(?:\.(.+))?\.xlf$/;
5212
5695
  function decodeEntities(s) {
@@ -5273,7 +5756,7 @@ var angularXliff2 = {
5273
5756
  if (fnameLocale !== void 0 && !LOCALE_RE6.test(fnameLocale)) continue;
5274
5757
  let xml;
5275
5758
  try {
5276
- xml = readFileSync16(join12(localeRoot, file), "utf8");
5759
+ xml = readFileSync18(join14(localeRoot, file), "utf8");
5277
5760
  } catch (e) {
5278
5761
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
5279
5762
  continue;
@@ -5318,8 +5801,8 @@ var angularXliff2 = {
5318
5801
  };
5319
5802
 
5320
5803
  // src/server/import/parsers/gettext-po.ts
5321
- import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
5322
- import { join as join13 } from "path";
5804
+ import { readdirSync as readdirSync10, readFileSync as readFileSync19 } from "fs";
5805
+ import { join as join15 } from "path";
5323
5806
  var LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5324
5807
  var DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
5325
5808
  var CONT_RE = /^[ \t]*"(.*)"\s*$/;
@@ -5411,17 +5894,17 @@ function discoverPoFiles(root) {
5411
5894
  for (const e of entries) {
5412
5895
  if (e.isFile() && e.name.endsWith(".po")) {
5413
5896
  const base = e.name.slice(0, -3);
5414
- found.push({ path: join13(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5897
+ found.push({ path: join15(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5415
5898
  } else if (e.isDirectory() && LOCALE_RE7.test(e.name)) {
5416
- for (const sub of [join13(e.name, "LC_MESSAGES"), e.name]) {
5899
+ for (const sub of [join15(e.name, "LC_MESSAGES"), e.name]) {
5417
5900
  let names;
5418
5901
  try {
5419
- names = readdirSync10(join13(root, sub)).sort();
5902
+ names = readdirSync10(join15(root, sub)).sort();
5420
5903
  } catch {
5421
5904
  continue;
5422
5905
  }
5423
5906
  for (const f of names) {
5424
- if (f.endsWith(".po")) found.push({ path: join13(root, sub, f), rel: join13(sub, f), locale: e.name });
5907
+ if (f.endsWith(".po")) found.push({ path: join15(root, sub, f), rel: join15(sub, f), locale: e.name });
5425
5908
  }
5426
5909
  }
5427
5910
  }
@@ -5437,7 +5920,7 @@ var gettextPo2 = {
5437
5920
  for (const file of discoverPoFiles(localeRoot)) {
5438
5921
  let entries;
5439
5922
  try {
5440
- entries = parseEntries(readFileSync17(file.path, "utf8"));
5923
+ entries = parseEntries(readFileSync19(file.path, "utf8"));
5441
5924
  } catch (e) {
5442
5925
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5443
5926
  continue;
@@ -5482,8 +5965,8 @@ var gettextPo2 = {
5482
5965
  };
5483
5966
 
5484
5967
  // src/server/import/parsers/i18next-json.ts
5485
- import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5486
- import { join as join14 } from "path";
5968
+ import { readdirSync as readdirSync11, readFileSync as readFileSync20, statSync as statSync6 } from "fs";
5969
+ import { join as join16 } from "path";
5487
5970
  var LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5488
5971
  var PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
5489
5972
  var PLURAL_ARG = "count";
@@ -5502,7 +5985,7 @@ function fromI18next(value) {
5502
5985
  function ingestFile(path, label, prefix, locale, keys, warnings) {
5503
5986
  let data;
5504
5987
  try {
5505
- data = JSON.parse(readFileSync18(path, "utf8"));
5988
+ data = JSON.parse(readFileSync20(path, "utf8"));
5506
5989
  } catch (e) {
5507
5990
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5508
5991
  return false;
@@ -5544,7 +6027,7 @@ var i18nextJson2 = {
5544
6027
  const keys = {};
5545
6028
  const locales = [];
5546
6029
  for (const entry of readdirSync11(localeRoot).sort()) {
5547
- const full = join14(localeRoot, entry);
6030
+ const full = join16(localeRoot, entry);
5548
6031
  if (safeIsDir2(full)) {
5549
6032
  if (!LOCALE_RE8.test(entry)) continue;
5550
6033
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -5553,7 +6036,7 @@ var i18nextJson2 = {
5553
6036
  if (!file.endsWith(".json")) continue;
5554
6037
  const ns = file.slice(0, -".json".length);
5555
6038
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5556
- if (ingestFile(join14(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
6039
+ if (ingestFile(join16(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5557
6040
  }
5558
6041
  if (any && !locales.includes(entry)) locales.push(entry);
5559
6042
  } else if (entry.endsWith(".json")) {
@@ -5570,8 +6053,8 @@ var i18nextJson2 = {
5570
6053
  };
5571
6054
 
5572
6055
  // src/server/import/parsers/rails-yaml.ts
5573
- import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5574
- import { join as join15 } from "path";
6056
+ import { readdirSync as readdirSync12, readFileSync as readFileSync21 } from "fs";
6057
+ import { join as join17 } from "path";
5575
6058
  var LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5576
6059
  var CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5577
6060
  function makeNode() {
@@ -5789,7 +6272,7 @@ var railsYaml2 = {
5789
6272
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5790
6273
  let text;
5791
6274
  try {
5792
- text = readFileSync19(join15(localeRoot, file), "utf8");
6275
+ text = readFileSync21(join17(localeRoot, file), "utf8");
5793
6276
  } catch (e) {
5794
6277
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5795
6278
  continue;
@@ -5810,8 +6293,8 @@ var railsYaml2 = {
5810
6293
  };
5811
6294
 
5812
6295
  // src/server/import/parsers/apple-stringsdict.ts
5813
- import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5814
- import { join as join16 } from "path";
6296
+ import { readdirSync as readdirSync13, readFileSync as readFileSync22, statSync as statSync7 } from "fs";
6297
+ import { join as join18 } from "path";
5815
6298
  var LOCALE_RE10 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5816
6299
  var TABLE2 = "Localizable.stringsdict";
5817
6300
  function localeFromLproj2(dir) {
@@ -5965,16 +6448,16 @@ var appleStringsdict2 = {
5965
6448
  const locale = localeFromLproj2(dir);
5966
6449
  if (!locale) continue;
5967
6450
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5968
- const file = join16(localeRoot, dir, TABLE2);
6451
+ const file = join18(localeRoot, dir, TABLE2);
5969
6452
  let text;
5970
6453
  try {
5971
6454
  if (!statSync7(file).isFile()) continue;
5972
- text = readFileSync20(file, "utf8");
6455
+ text = readFileSync22(file, "utf8");
5973
6456
  } catch {
5974
6457
  continue;
5975
6458
  }
5976
6459
  locales.push(locale);
5977
- const others = readdirSync13(join16(localeRoot, dir)).filter(
6460
+ const others = readdirSync13(join18(localeRoot, dir)).filter(
5978
6461
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
5979
6462
  );
5980
6463
  if (others.length) {
@@ -6098,6 +6581,7 @@ function assemble2(parsed, opts) {
6098
6581
  spelling: { customWords: [] }
6099
6582
  },
6100
6583
  glossary: [],
6584
+ glossarySuggestions: [],
6101
6585
  keys,
6102
6586
  warnings
6103
6587
  };
@@ -6294,7 +6778,7 @@ function refreshLocationUsage(projectRoot, format) {
6294
6778
  }
6295
6779
 
6296
6780
  // src/server/export-run.ts
6297
- import { existsSync as existsSync12, readFileSync as readFileSync21, readdirSync as readdirSync14, rmdirSync, statSync as statSync8, unlinkSync } from "fs";
6781
+ import { existsSync as existsSync13, readFileSync as readFileSync23, readdirSync as readdirSync14, rmdirSync, statSync as statSync8, unlinkSync } from "fs";
6298
6782
  import { dirname as dirname2, resolve as resolve7, sep } from "path";
6299
6783
  function effectiveLocales(config) {
6300
6784
  const limit = config.exportLocales;
@@ -6307,11 +6791,11 @@ function narrowForExport(state) {
6307
6791
  return { ...state, config: { ...state.config, locales } };
6308
6792
  }
6309
6793
  var LOCALE_TOKEN = /^[a-z]{2,3}([_-][a-z0-9]+)*$/i;
6310
- function escapeRegExp(s) {
6794
+ function escapeRegExp2(s) {
6311
6795
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6312
6796
  }
6313
6797
  function segmentRegExp(segment) {
6314
- const pattern = escapeRegExp(segment).replaceAll("\\{locale\\}", "(?<locale>[A-Za-z0-9_-]+)").replaceAll("\\{namespace\\}", "[^/]*");
6798
+ const pattern = escapeRegExp2(segment).replaceAll("\\{locale\\}", "(?<locale>[A-Za-z0-9_-]+)").replaceAll("\\{namespace\\}", "[^/]*");
6315
6799
  return new RegExp(`^${pattern}$`);
6316
6800
  }
6317
6801
  function removeEmptyDirs(dir, stopAt) {
@@ -6337,7 +6821,7 @@ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
6337
6821
  if (!segment.includes("{locale}") && !segment.includes("{namespace}")) {
6338
6822
  const next = resolve7(dir, segment);
6339
6823
  if (isLast) {
6340
- if (stale(locale) && existsSync12(next) && statSync8(next).isFile()) {
6824
+ if (stale(locale) && existsSync13(next) && statSync8(next).isFile()) {
6341
6825
  unlinkSync(next);
6342
6826
  deleted++;
6343
6827
  removeEmptyDirs(dir, root);
@@ -6393,7 +6877,7 @@ function exportToDisk(state, projectRoot, opts) {
6393
6877
  writtenPaths.add(abs);
6394
6878
  let current = null;
6395
6879
  try {
6396
- current = readFileSync21(abs, "utf8");
6880
+ current = readFileSync23(abs, "utf8");
6397
6881
  } catch {
6398
6882
  }
6399
6883
  if (current === f.contents) {
@@ -6410,17 +6894,17 @@ function exportToDisk(state, projectRoot, opts) {
6410
6894
  }
6411
6895
 
6412
6896
  // src/server/ui-prefs.ts
6413
- import { readFileSync as readFileSync22 } from "fs";
6414
- import { homedir } from "os";
6415
- import { join as join17 } from "path";
6897
+ import { readFileSync as readFileSync24 } from "fs";
6898
+ import { homedir as homedir2 } from "os";
6899
+ import { join as join19 } from "path";
6416
6900
  var THEMES = ["system", "light", "dark"];
6417
6901
  var isThemeMode = (v) => THEMES.includes(v);
6418
6902
  var isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
6419
- var defaultUiPrefsPath = () => join17(homedir(), ".glotfile", "ui.json");
6903
+ var defaultUiPrefsPath = () => join19(homedir2(), ".glotfile", "ui.json");
6420
6904
  var DEFAULTS = { theme: "system" };
6421
6905
  function readJson(path) {
6422
6906
  try {
6423
- const parsed = JSON.parse(readFileSync22(path, "utf8"));
6907
+ const parsed = JSON.parse(readFileSync24(path, "utf8"));
6424
6908
  return parsed && typeof parsed === "object" ? parsed : {};
6425
6909
  } catch {
6426
6910
  return {};
@@ -6439,7 +6923,7 @@ function saveUiPrefs(path, prefs) {
6439
6923
  }
6440
6924
 
6441
6925
  // src/server/local-settings.ts
6442
- import { readFileSync as readFileSync23 } from "fs";
6926
+ import { readFileSync as readFileSync25 } from "fs";
6443
6927
  import { resolve as resolve8 } from "path";
6444
6928
  var EDITOR_IDS = ["vscode", "zed", "phpstorm"];
6445
6929
  var isEditorId = (v) => EDITOR_IDS.includes(v);
@@ -6454,7 +6938,7 @@ var DEFAULT_EDITOR = "vscode";
6454
6938
  var settingsPath = (projectRoot) => resolve8(projectRoot, ".glotfile", "settings.json");
6455
6939
  function readJson2(path) {
6456
6940
  try {
6457
- const parsed = JSON.parse(readFileSync23(path, "utf8"));
6941
+ const parsed = JSON.parse(readFileSync25(path, "utf8"));
6458
6942
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
6459
6943
  } catch {
6460
6944
  return {};
@@ -6544,7 +7028,7 @@ function createEventHub() {
6544
7028
 
6545
7029
  // src/server/watch.ts
6546
7030
  import { statSync as statSync9, readdirSync as readdirSync15 } from "fs";
6547
- import { join as join18 } from "path";
7031
+ import { join as join20 } from "path";
6548
7032
  import { createHash as createHash2 } from "crypto";
6549
7033
  function hashState(state) {
6550
7034
  return createHash2("sha1").update(serializeJson(state, state.config.format)).digest("hex");
@@ -6560,15 +7044,15 @@ function signature(statePath) {
6560
7044
  const parts = [];
6561
7045
  for (const rel of ["config.json", "keys.json"]) {
6562
7046
  try {
6563
- const s = statSync9(join18(dir, rel));
7047
+ const s = statSync9(join20(dir, rel));
6564
7048
  parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
6565
7049
  } catch {
6566
7050
  }
6567
7051
  }
6568
7052
  try {
6569
- for (const name of readdirSync15(join18(dir, "locales")).sort()) {
7053
+ for (const name of readdirSync15(join20(dir, "locales")).sort()) {
6570
7054
  if (!name.endsWith(".json")) continue;
6571
- const s = statSync9(join18(dir, "locales", name));
7055
+ const s = statSync9(join20(dir, "locales", name));
6572
7056
  parts.push(`${name}:${s.size}:${s.mtimeMs}`);
6573
7057
  }
6574
7058
  } catch {
@@ -6641,30 +7125,15 @@ var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
6641
7125
  var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
6642
7126
  function projectName(root) {
6643
7127
  const nameFile = resolve9(root, ".idea", ".name");
6644
- if (existsSync13(nameFile)) {
7128
+ if (existsSync14(nameFile)) {
6645
7129
  try {
6646
- const name = readFileSync24(nameFile, "utf8").trim();
7130
+ const name = readFileSync26(nameFile, "utf8").trim();
6647
7131
  if (name) return name;
6648
7132
  } catch {
6649
7133
  }
6650
7134
  }
6651
7135
  return basename(root);
6652
7136
  }
6653
- function attachUsageSnippets(targets, cache2, projectRoot) {
6654
- const fileCache = /* @__PURE__ */ new Map();
6655
- for (const target of targets) {
6656
- const allRefs = Object.entries(cache2.files).flatMap(
6657
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
6658
- key: r.key,
6659
- file,
6660
- line: r.line,
6661
- col: r.col,
6662
- scanner: r.scanner
6663
- }))
6664
- );
6665
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
6666
- }
6667
- }
6668
7137
  function createApi(deps) {
6669
7138
  const app = new Hono();
6670
7139
  const load = () => loadState(deps.statePath);
@@ -6796,6 +7265,61 @@ function createApi(deps) {
6796
7265
  }
6797
7266
  return c.json({ ok: true });
6798
7267
  });
7268
+ app.post("/ai-test", async (c) => {
7269
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7270
+ const meta = { provider: aiCfg.provider, model: aiCfg.model };
7271
+ let provider;
7272
+ try {
7273
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7274
+ } catch (e) {
7275
+ return c.json({ ok: false, ...meta, error: explainProviderError(aiCfg.provider, e) });
7276
+ }
7277
+ const controller = new AbortController();
7278
+ const timer = setTimeout(() => controller.abort(), 3e4);
7279
+ try {
7280
+ const probe = {
7281
+ id: "probe",
7282
+ key: "glotfile.connection-test",
7283
+ source: "Hello",
7284
+ sourceLocale: "en",
7285
+ targetLocale: "es",
7286
+ placeholders: []
7287
+ };
7288
+ await provider.translate([probe], void 0, controller.signal);
7289
+ return c.json({ ok: true, ...meta });
7290
+ } catch (e) {
7291
+ const error = controller.signal.aborted ? "Connection test timed out after 30s \u2014 the provider didn't respond." : explainProviderError(aiCfg.provider, e);
7292
+ return c.json({ ok: false, ...meta, error });
7293
+ } finally {
7294
+ clearTimeout(timer);
7295
+ }
7296
+ });
7297
+ app.get("/prices", (c) => {
7298
+ const cache2 = loadPriceCache();
7299
+ const ai = loadLocalSettings(projectRoot).ai;
7300
+ const pricing = resolvePricing(ai, cache2);
7301
+ return c.json({
7302
+ source: cache2?.source ?? null,
7303
+ fetchedAt: cache2?.fetchedAt ?? null,
7304
+ modelCount: cache2 ? Object.keys(cache2.models).length : 0,
7305
+ path: defaultPriceCachePath(),
7306
+ resolved: pricing ? { provider: ai.provider, model: ai.model, ...pricing } : null
7307
+ });
7308
+ });
7309
+ app.get("/prices/list", (c) => {
7310
+ const cache2 = loadPriceCache();
7311
+ const models = cache2 ? Object.entries(cache2.models).map(([id, p]) => ({ id, ...p })).sort((a, b) => a.id.localeCompare(b.id)) : [];
7312
+ return c.json({ source: cache2?.source ?? null, fetchedAt: cache2?.fetchedAt ?? null, models });
7313
+ });
7314
+ app.post("/prices/refresh", async (c) => {
7315
+ try {
7316
+ const res = await refreshPrices();
7317
+ invalidatePriceCache();
7318
+ return c.json({ ok: true, ...res });
7319
+ } catch (e) {
7320
+ return c.json({ error: e.message }, 502);
7321
+ }
7322
+ });
6799
7323
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
6800
7324
  app.get("/files", (c) => {
6801
7325
  const found = /* @__PURE__ */ new Map();
@@ -6817,7 +7341,7 @@ function createApi(deps) {
6817
7341
  if (name.startsWith(".") || name === "node_modules") continue;
6818
7342
  const abs = resolve9(dir, name);
6819
7343
  let filePath = null;
6820
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync13(resolve9(abs, "config.json"))) {
7344
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync14(resolve9(abs, "config.json"))) {
6821
7345
  filePath = resolve9(dir, `${name}.json`);
6822
7346
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
6823
7347
  filePath = abs;
@@ -6851,7 +7375,7 @@ function createApi(deps) {
6851
7375
  const resolved = resolve9(projectRoot, path);
6852
7376
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
6853
7377
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
6854
- if (!existsSync13(resolved)) return c.json({ error: "file not found" }, 400);
7378
+ if (!existsSync14(resolved)) return c.json({ error: "file not found" }, 400);
6855
7379
  loadState(resolved);
6856
7380
  deps.statePath = resolved;
6857
7381
  watcher.retarget(resolved);
@@ -6913,9 +7437,9 @@ function createApi(deps) {
6913
7437
  const abs = resolve9(root, screenshot);
6914
7438
  const rel = relative4(root, abs);
6915
7439
  const seg0 = rel.split(sep2)[0] ?? "";
6916
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync13(abs)) {
7440
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync14(abs)) {
6917
7441
  try {
6918
- rmSync6(abs);
7442
+ rmSync7(abs);
6919
7443
  } catch {
6920
7444
  }
6921
7445
  }
@@ -7160,6 +7684,177 @@ function createApi(deps) {
7160
7684
  logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
7161
7685
  return c.json({ ok: true });
7162
7686
  });
7687
+ app.get("/glossary/suggestions", (c) => {
7688
+ const s = load();
7689
+ const pending = s.glossarySuggestions.filter((x) => x.status === "pending");
7690
+ return c.json(pending.map((x) => ({
7691
+ ...x,
7692
+ occurrences: sourceKeysForTerm(s, x.term, { caseSensitive: x.caseSensitive, wholeWord: x.wholeWord }).length
7693
+ })));
7694
+ });
7695
+ app.post("/glossary/suggestions/dismiss", async (c) => {
7696
+ const { term } = await c.req.json();
7697
+ if (typeof term !== "string") return c.json({ error: "term must be a string" }, 400);
7698
+ const s = load();
7699
+ dismissGlossarySuggestion(s, term);
7700
+ persist(s);
7701
+ logChange({ kind: "glossary", summary: `Dismissed suggested term "${term}"` });
7702
+ return c.json({ ok: true });
7703
+ });
7704
+ app.delete("/glossary/suggestions/:term", (c) => {
7705
+ const s = load();
7706
+ const term = decodeURIComponent(c.req.param("term"));
7707
+ removeGlossarySuggestion(s, term);
7708
+ persist(s);
7709
+ return c.json({ ok: true });
7710
+ });
7711
+ app.post("/glossary/suggest", async (c) => {
7712
+ const signal = c.req.raw.signal;
7713
+ const body = await c.req.json().catch(() => ({}));
7714
+ return streamSSE(c, async (stream) => {
7715
+ const s0 = load();
7716
+ const sources = selectGlossarySources(s0, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
7717
+ if (!sources.length) {
7718
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: 0, terms: [] }) });
7719
+ return;
7720
+ }
7721
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7722
+ let provider;
7723
+ try {
7724
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7725
+ } catch (e) {
7726
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
7727
+ return;
7728
+ }
7729
+ const known = knownTermList(s0);
7730
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: sources.length }) });
7731
+ const system = buildGlossarySuggestSystemPrompt();
7732
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
7733
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
7734
+ const chunks = [];
7735
+ for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
7736
+ const all = [];
7737
+ let done = 0;
7738
+ let next = 0;
7739
+ async function worker() {
7740
+ while (next < chunks.length) {
7741
+ if (signal?.aborted) break;
7742
+ const chunkRows = chunks[next++];
7743
+ try {
7744
+ const raw = await provider.complete({ system, content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunkRows, known) }], schema: GLOSSARY_SUGGEST_SCHEMA });
7745
+ all.push(...raw.terms ?? []);
7746
+ } catch (e) {
7747
+ void stream.writeSSE({ event: "warn", data: JSON.stringify({ error: e.message }) });
7748
+ }
7749
+ done += chunkRows.length;
7750
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done, total: sources.length }) });
7751
+ }
7752
+ }
7753
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
7754
+ if (signal?.aborted) return;
7755
+ const fresh = load();
7756
+ const added = mergeGlossarySuggestions(fresh, dedupeTerms(all));
7757
+ const usage = provider.takeUsage?.();
7758
+ persist(fresh);
7759
+ appendLog(projectRoot, {
7760
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7761
+ kind: "glossary",
7762
+ summary: `Suggested ${added.length} glossary term(s)`,
7763
+ model: aiCfg.model,
7764
+ system,
7765
+ usage,
7766
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
7767
+ });
7768
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: added.length, terms: added }) });
7769
+ });
7770
+ });
7771
+ app.post("/glossary/suggest/estimate", async (c) => {
7772
+ const body = await c.req.json().catch(() => ({}));
7773
+ const s = load();
7774
+ const sources = selectGlossarySources(s, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
7775
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7776
+ return c.json(estimateGlossarySuggest(sources, knownTermList(s), aiCfg));
7777
+ });
7778
+ app.get("/glossary/suggest/batch/status", async (c) => {
7779
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7780
+ let supported = false;
7781
+ let provider;
7782
+ try {
7783
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7784
+ supported = supportsBatchComplete(provider);
7785
+ } catch {
7786
+ }
7787
+ const pending = loadPendingGlossaryBatch(projectRoot);
7788
+ if (!pending) return c.json({ supported, pending: null });
7789
+ const base = { batchId: pending.batchId, createdAt: pending.createdAt, model: pending.model, total: pending.total };
7790
+ if (!provider || !supportsBatchComplete(provider)) {
7791
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null } });
7792
+ }
7793
+ try {
7794
+ const status = await provider.translationBatchStatus(pending.batchId);
7795
+ return c.json({ supported, pending: { ...base, status: status.status, counts: status.counts } });
7796
+ } catch (e) {
7797
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null, error: e.message } });
7798
+ }
7799
+ });
7800
+ app.post("/glossary/suggest/batch", (c) => withTranslateLock(async () => {
7801
+ const body = await c.req.json().catch(() => ({}));
7802
+ const s = load();
7803
+ const sources = selectGlossarySources(s, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
7804
+ if (!sources.length) return c.json({ error: "No source strings to scan." }, 400);
7805
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7806
+ let provider;
7807
+ try {
7808
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7809
+ } catch (e) {
7810
+ return c.json({ error: e.message }, 400);
7811
+ }
7812
+ if (!supportsBatchComplete(provider)) {
7813
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
7814
+ }
7815
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
7816
+ let pending;
7817
+ try {
7818
+ pending = await submitGlossarySuggestBatch(provider, sources, knownTermList(s), batchSize, aiCfg.model, projectRoot);
7819
+ } catch (e) {
7820
+ return c.json({ error: e.message }, 409);
7821
+ }
7822
+ appendLog(projectRoot, {
7823
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7824
+ kind: "glossary",
7825
+ summary: `Submitted glossary suggestion batch ${pending.batchId} (${pending.total} sources)`,
7826
+ model: aiCfg.model
7827
+ });
7828
+ return c.json({ batchId: pending.batchId, total: pending.total });
7829
+ }));
7830
+ app.post("/glossary/suggest/batch/apply", (c) => withTranslateLock(async () => {
7831
+ const pending = loadPendingGlossaryBatch(projectRoot);
7832
+ if (!pending) return c.json({ error: "No pending glossary suggestion batch." }, 404);
7833
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7834
+ let provider;
7835
+ try {
7836
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7837
+ } catch (e) {
7838
+ return c.json({ error: e.message }, 400);
7839
+ }
7840
+ if (!supportsBatchComplete(provider)) {
7841
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
7842
+ }
7843
+ const outcome = await applyGlossarySuggestBatchResults(load, persist, provider, pending, projectRoot, aiCfg);
7844
+ return c.json(outcome);
7845
+ }));
7846
+ app.post("/glossary/suggest/batch/cancel", async (c) => {
7847
+ const pending = loadPendingGlossaryBatch(projectRoot);
7848
+ if (!pending) return c.json({ error: "No pending glossary suggestion batch." }, 404);
7849
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7850
+ try {
7851
+ const provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7852
+ if (supportsBatchComplete(provider)) await provider.cancelTranslationBatch(pending.batchId);
7853
+ } catch {
7854
+ }
7855
+ clearPendingGlossaryBatch(projectRoot);
7856
+ return c.json({ canceled: pending.batchId });
7857
+ });
7163
7858
  app.post("/keys/:key/screenshot", async (c) => {
7164
7859
  const key = c.req.param("key");
7165
7860
  const body = await c.req.parseBody();
@@ -7345,7 +8040,7 @@ function createApi(deps) {
7345
8040
  try {
7346
8041
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7347
8042
  } catch (e) {
7348
- await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
8043
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
7349
8044
  return;
7350
8045
  }
7351
8046
  const { skipped } = attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
@@ -7362,58 +8057,65 @@ function createApi(deps) {
7362
8057
  event: "start",
7363
8058
  data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
7364
8059
  });
7365
- await runLocaleParallel(reqs, provider, {
7366
- // Announce a language the moment a worker picks it up — this is the
7367
- // signal that "something is happening" during the long first LLM call.
7368
- onLocaleStart: (locale) => {
7369
- void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
7370
- },
7371
- onBatchComplete: (done, total, batchResults, locale) => {
7372
- const fresh = load();
7373
- const { written, errors } = applyResults(fresh, reqs, batchResults);
7374
- persist(fresh);
7375
- totalWritten += written;
7376
- allErrors.push(...errors);
7377
- const usage = provider.takeUsage?.();
7378
- appendLog(projectRoot, {
7379
- at: (/* @__PURE__ */ new Date()).toISOString(),
7380
- kind: "translate",
7381
- summary: `Translated ${batchResults.length} item(s)`,
7382
- model: aiCfg.model,
7383
- system,
7384
- items: batchResults.map((r) => {
7385
- const req = reqById.get(r.id);
7386
- return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? fresh.keys[req.key]?.screenshot : void 0 };
7387
- }),
7388
- results: batchResults,
7389
- usage,
7390
- estimatedCostUsd: usageCostUsd(usage, aiCfg)
7391
- });
7392
- const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
7393
- localeDone.set(locale, ld);
7394
- console.log(`[translate] ${done}/${total}`);
7395
- void stream.writeSSE({
7396
- event: "progress",
7397
- data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
7398
- });
7399
- },
7400
- onLocaleDone: (locale) => {
7401
- void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
7402
- },
7403
- // Record the raw reply so an unparseable model response is diagnosable
7404
- // from the activity log instead of vanishing into per-item errors.
7405
- onMalformedReply: (raw, batchSize, locale) => {
7406
- console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
7407
- appendLog(projectRoot, {
7408
- at: (/* @__PURE__ */ new Date()).toISOString(),
7409
- kind: "translate",
7410
- summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
7411
- model: aiCfg.model,
7412
- locale,
7413
- raw
7414
- });
8060
+ try {
8061
+ await runLocaleParallel(reqs, provider, {
8062
+ // Announce a language the moment a worker picks it up — this is the
8063
+ // signal that "something is happening" during the long first LLM call.
8064
+ onLocaleStart: (locale) => {
8065
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
8066
+ },
8067
+ onBatchComplete: (done, total, batchResults, locale) => {
8068
+ const fresh = load();
8069
+ const { written, errors } = applyResults(fresh, reqs, batchResults);
8070
+ persist(fresh);
8071
+ totalWritten += written;
8072
+ allErrors.push(...errors);
8073
+ const usage = provider.takeUsage?.();
8074
+ appendLog(projectRoot, {
8075
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8076
+ kind: "translate",
8077
+ summary: `Translated ${batchResults.length} item(s)`,
8078
+ model: aiCfg.model,
8079
+ system,
8080
+ items: batchResults.map((r) => {
8081
+ const req = reqById.get(r.id);
8082
+ return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? fresh.keys[req.key]?.screenshot : void 0 };
8083
+ }),
8084
+ results: batchResults,
8085
+ usage,
8086
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
8087
+ });
8088
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
8089
+ localeDone.set(locale, ld);
8090
+ console.log(`[translate] ${done}/${total}`);
8091
+ void stream.writeSSE({
8092
+ event: "progress",
8093
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
8094
+ });
8095
+ },
8096
+ onLocaleDone: (locale) => {
8097
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
8098
+ },
8099
+ // Record the raw reply so an unparseable model response is diagnosable
8100
+ // from the activity log instead of vanishing into per-item errors.
8101
+ onMalformedReply: (raw, batchSize, locale) => {
8102
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8103
+ appendLog(projectRoot, {
8104
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8105
+ kind: "translate",
8106
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8107
+ model: aiCfg.model,
8108
+ locale,
8109
+ raw
8110
+ });
8111
+ }
8112
+ }, aiCfg.concurrency, signal, aiCfg.batchSize);
8113
+ } catch (e) {
8114
+ if (!signal?.aborted) {
8115
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
7415
8116
  }
7416
- }, aiCfg.concurrency, signal, aiCfg.batchSize);
8117
+ return;
8118
+ }
7417
8119
  if (!signal?.aborted) {
7418
8120
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
7419
8121
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -7440,23 +8142,28 @@ function createApi(deps) {
7440
8142
  try {
7441
8143
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7442
8144
  } catch (e) {
7443
- return c.json({ error: e.message }, 400);
8145
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 400);
7444
8146
  }
7445
8147
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
7446
8148
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
7447
- const results = await runLocaleParallel(toTranslate, provider, {
7448
- onMalformedReply: (raw, batchSize, locale) => {
7449
- console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
7450
- appendLog(projectRoot, {
7451
- at: (/* @__PURE__ */ new Date()).toISOString(),
7452
- kind: "translate",
7453
- summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
7454
- model: aiCfg.model,
7455
- locale,
7456
- raw
7457
- });
7458
- }
7459
- }, aiCfg.concurrency, void 0, aiCfg.batchSize);
8149
+ let results;
8150
+ try {
8151
+ results = await runLocaleParallel(toTranslate, provider, {
8152
+ onMalformedReply: (raw, batchSize, locale) => {
8153
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8154
+ appendLog(projectRoot, {
8155
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8156
+ kind: "translate",
8157
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8158
+ model: aiCfg.model,
8159
+ locale,
8160
+ raw
8161
+ });
8162
+ }
8163
+ }, aiCfg.concurrency, void 0, aiCfg.batchSize);
8164
+ } catch (e) {
8165
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 502);
8166
+ }
7460
8167
  const latest = load();
7461
8168
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
7462
8169
  const usage = provider.takeUsage?.();
@@ -7727,6 +8434,22 @@ function createApi(deps) {
7727
8434
  await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
7728
8435
  });
7729
8436
  });
8437
+ app.post("/context/estimate", async (c) => {
8438
+ const body = await c.req.json().catch(() => ({}));
8439
+ const cache2 = loadUsageCache(projectRoot);
8440
+ if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
8441
+ const targets = selectContextTargets(load(), {
8442
+ all: body.all,
8443
+ keyGlob: body.keyGlob,
8444
+ limit: body.limit,
8445
+ since: body.since,
8446
+ keys: body.keys,
8447
+ force: body.force
8448
+ }, cache2, body.lastRunAt);
8449
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8450
+ attachUsageSnippets(targets, cache2, projectRoot);
8451
+ return c.json(estimateContext(targets, aiCfg));
8452
+ });
7730
8453
  app.get("/context/batch/status", async (c) => {
7731
8454
  const aiCfg = loadLocalSettings(projectRoot).ai;
7732
8455
  let supported = false;
@@ -7829,7 +8552,7 @@ function createApi(deps) {
7829
8552
 
7830
8553
  // src/server/server.ts
7831
8554
  var here = dirname4(fileURLToPath(import.meta.url));
7832
- var DEFAULT_UI_DIR = join19(here, "..", "ui");
8555
+ var DEFAULT_UI_DIR = join21(here, "..", "ui");
7833
8556
  var MIME = {
7834
8557
  ".html": "text/html; charset=utf-8",
7835
8558
  ".js": "text/javascript; charset=utf-8",
@@ -7896,7 +8619,7 @@ function buildApp(opts) {
7896
8619
  const file = await readFileResponse(target);
7897
8620
  if (file) return file;
7898
8621
  }
7899
- const index = await readFileResponse(join19(root, "index.html"));
8622
+ const index = await readFileResponse(join21(root, "index.html"));
7900
8623
  if (index) return index;
7901
8624
  return c.notFound();
7902
8625
  });