glotfile 1.0.1 → 1.1.0

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.
@@ -58,6 +58,14 @@ var init_atomic_write = __esm({
58
58
  });
59
59
 
60
60
  // src/server/lint/registry.ts
61
+ function unknownRuleIds(ids) {
62
+ const valid = new Set(RULE_IDS);
63
+ return ids.filter((id) => !valid.has(id));
64
+ }
65
+ function suggestRuleId(unknown) {
66
+ const lower = unknown.toLowerCase();
67
+ return RULE_IDS.find((id) => id.includes(lower) || lower.includes(id));
68
+ }
61
69
  var RULE_IDS, DEFAULT_SEVERITY;
62
70
  var init_registry = __esm({
63
71
  "src/server/lint/registry.ts"() {
@@ -271,7 +279,8 @@ function validate(raw) {
271
279
  }
272
280
  }
273
281
  if (raw.glossary !== void 0 && !Array.isArray(raw.glossary)) fail("glossary must be an array");
274
- const state = { glossary: [], ...raw };
282
+ if (raw.glossarySuggestions !== void 0 && !Array.isArray(raw.glossarySuggestions)) fail("glossarySuggestions must be an array");
283
+ const state = { glossary: [], glossarySuggestions: [], ...raw };
275
284
  return state;
276
285
  }
277
286
  function defaultState() {
@@ -290,6 +299,7 @@ function defaultState() {
290
299
  autoExport: true
291
300
  },
292
301
  glossary: [],
302
+ glossarySuggestions: [],
293
303
  keys: {}
294
304
  };
295
305
  }
@@ -807,6 +817,39 @@ function upsertGlossaryEntry(state, entry) {
807
817
  function deleteGlossaryEntry(state, term) {
808
818
  state.glossary = state.glossary.filter((e) => e.term !== term);
809
819
  }
820
+ function normGlossaryTerm(term) {
821
+ return term.trim().toLowerCase();
822
+ }
823
+ function mergeGlossarySuggestions(state, found) {
824
+ const known = /* @__PURE__ */ new Set();
825
+ for (const g of state.glossary) known.add(normGlossaryTerm(g.term));
826
+ for (const s of state.glossarySuggestions) known.add(normGlossaryTerm(s.term));
827
+ const added = [];
828
+ for (const f of found) {
829
+ const term = f.term.trim();
830
+ if (!term) continue;
831
+ const key = normGlossaryTerm(term);
832
+ if (known.has(key)) continue;
833
+ known.add(key);
834
+ const sug = { term, status: "pending" };
835
+ if (f.note?.trim()) sug.note = f.note.trim();
836
+ if (f.doNotTranslate) sug.doNotTranslate = true;
837
+ if (f.caseSensitive) sug.caseSensitive = true;
838
+ if (f.wholeWord === false) sug.wholeWord = false;
839
+ state.glossarySuggestions.push(sug);
840
+ added.push(sug);
841
+ }
842
+ return added;
843
+ }
844
+ function dismissGlossarySuggestion(state, term) {
845
+ const key = normGlossaryTerm(term);
846
+ const s = state.glossarySuggestions.find((x) => normGlossaryTerm(x.term) === key);
847
+ if (s) s.status = "dismissed";
848
+ }
849
+ function removeGlossarySuggestion(state, term) {
850
+ const key = normGlossaryTerm(term);
851
+ state.glossarySuggestions = state.glossarySuggestions.filter((x) => normGlossaryTerm(x.term) !== key);
852
+ }
810
853
  function addCustomWord(state, word) {
811
854
  const w = word.trim();
812
855
  if (!w) return;
@@ -2528,6 +2571,54 @@ var init_batch = __esm({
2528
2571
  }
2529
2572
  });
2530
2573
 
2574
+ // src/server/ai/price-cache.ts
2575
+ import { readFileSync as readFileSync4 } from "fs";
2576
+ import { homedir } from "os";
2577
+ import { join as join3 } from "path";
2578
+ function isModelPrice(v) {
2579
+ if (!v || typeof v !== "object") return false;
2580
+ const p = v;
2581
+ return typeof p.inputPerMTok === "number" && typeof p.outputPerMTok === "number";
2582
+ }
2583
+ function loadPriceCache(path = defaultPriceCachePath()) {
2584
+ let parsed;
2585
+ try {
2586
+ parsed = JSON.parse(readFileSync4(path, "utf8"));
2587
+ } catch {
2588
+ return null;
2589
+ }
2590
+ if (!parsed || typeof parsed !== "object") return null;
2591
+ const raw = parsed;
2592
+ if (!raw.models || typeof raw.models !== "object") return null;
2593
+ const models = {};
2594
+ for (const [id, price] of Object.entries(raw.models)) {
2595
+ if (isModelPrice(price)) models[id] = price;
2596
+ }
2597
+ return {
2598
+ source: typeof raw.source === "string" ? raw.source : "unknown",
2599
+ fetchedAt: typeof raw.fetchedAt === "string" ? raw.fetchedAt : "",
2600
+ models
2601
+ };
2602
+ }
2603
+ function savePriceCache(cache2, path = defaultPriceCachePath()) {
2604
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
2605
+ }
2606
+ function getPriceCache() {
2607
+ if (memo === void 0) memo = loadPriceCache();
2608
+ return memo;
2609
+ }
2610
+ function invalidatePriceCache() {
2611
+ memo = void 0;
2612
+ }
2613
+ var defaultPriceCachePath, memo;
2614
+ var init_price_cache = __esm({
2615
+ "src/server/ai/price-cache.ts"() {
2616
+ "use strict";
2617
+ init_atomic_write();
2618
+ defaultPriceCachePath = () => process.env.GLOTFILE_PRICES_PATH || join3(homedir(), ".glotfile", "model-prices.json");
2619
+ }
2620
+ });
2621
+
2531
2622
  // src/server/ai/pricing.ts
2532
2623
  function addUsage(into, add) {
2533
2624
  into.inputTokens += add.inputTokens;
@@ -2541,7 +2632,9 @@ function usageCostUsd(usage, ai, multiplier = 1) {
2541
2632
  return pricing ? estimateUsageCostUsd(usage, pricing, multiplier) : void 0;
2542
2633
  }
2543
2634
  function estimateUsageCostUsd(usage, pricing, multiplier = 1) {
2544
- const inputCost = (usage.inputTokens + usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER + usage.cacheReadInputTokens * CACHE_READ_MULTIPLIER) * pricing.inputPerMTok;
2635
+ const writeRate = pricing.cacheWritePerMTok ?? pricing.inputPerMTok * CACHE_WRITE_MULTIPLIER;
2636
+ const readRate = pricing.cacheReadPerMTok ?? pricing.inputPerMTok * CACHE_READ_MULTIPLIER;
2637
+ const inputCost = usage.inputTokens * pricing.inputPerMTok + usage.cacheCreationInputTokens * writeRate + usage.cacheReadInputTokens * readRate;
2545
2638
  return (inputCost + usage.outputTokens * pricing.outputPerMTok) / 1e6 * multiplier;
2546
2639
  }
2547
2640
  function bareModelId(model) {
@@ -2552,12 +2645,27 @@ function bareModelId(model) {
2552
2645
  if (anth !== -1) id = id.slice(anth + "anthropic.".length);
2553
2646
  return id;
2554
2647
  }
2555
- function resolvePricing(ai) {
2648
+ function lookupCachePrice(cache2, id) {
2649
+ const exact = cache2.models[id];
2650
+ if (exact) return { source: "cache", ...exact };
2651
+ let best;
2652
+ for (const [cid, price] of Object.entries(cache2.models)) {
2653
+ if (id.startsWith(cid) && (!best || cid.length > best.id.length)) {
2654
+ best = { id: cid, price: { source: "cache", ...price } };
2655
+ }
2656
+ }
2657
+ return best ? best.price : null;
2658
+ }
2659
+ function resolvePricing(ai, cache2 = getPriceCache()) {
2556
2660
  if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
2557
2661
  return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
2558
2662
  }
2559
2663
  if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
2560
2664
  const id = bareModelId(ai.model);
2665
+ if (cache2) {
2666
+ const cached = lookupCachePrice(cache2, id);
2667
+ if (cached) return cached;
2668
+ }
2561
2669
  let best;
2562
2670
  for (const row of PRICE_TABLE) {
2563
2671
  if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
@@ -2568,6 +2676,7 @@ var BATCH_PRICE_MULTIPLIER, CACHE_WRITE_MULTIPLIER, CACHE_READ_MULTIPLIER, PRICE
2568
2676
  var init_pricing = __esm({
2569
2677
  "src/server/ai/pricing.ts"() {
2570
2678
  "use strict";
2679
+ init_price_cache();
2571
2680
  BATCH_PRICE_MULTIPLIER = 0.5;
2572
2681
  CACHE_WRITE_MULTIPLIER = 1.25;
2573
2682
  CACHE_READ_MULTIPLIER = 0.1;
@@ -3216,11 +3325,11 @@ var init_glotfile_dir = __esm({
3216
3325
  });
3217
3326
 
3218
3327
  // src/server/local-settings.ts
3219
- import { readFileSync as readFileSync4 } from "fs";
3328
+ import { readFileSync as readFileSync5 } from "fs";
3220
3329
  import { resolve as resolve3 } from "path";
3221
3330
  function readJson(path) {
3222
3331
  try {
3223
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
3332
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
3224
3333
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3225
3334
  } catch {
3226
3335
  return {};
@@ -3307,13 +3416,23 @@ var init_local_settings = __esm({
3307
3416
  });
3308
3417
 
3309
3418
  // src/server/glossary.ts
3310
- function contains(haystack, needle, caseSensitive) {
3311
- return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
3419
+ function escapeRegExp2(s) {
3420
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3421
+ }
3422
+ function contains(haystack, needle, caseSensitive, wholeWord) {
3423
+ if (!wholeWord) {
3424
+ return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
3425
+ }
3426
+ const re = new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp2(needle)}(?![\\p{L}\\p{N}])`, caseSensitive ? "u" : "iu");
3427
+ return re.test(haystack);
3428
+ }
3429
+ function termInSource(source, entry) {
3430
+ return contains(source, entry.term, entry.caseSensitive, entry.wholeWord ?? true);
3312
3431
  }
3313
3432
  function relevantGlossary(source, targetLocale, glossary) {
3314
3433
  const hints = [];
3315
3434
  for (const entry of glossary) {
3316
- if (!contains(source, entry.term, entry.caseSensitive)) continue;
3435
+ if (!termInSource(source, entry)) continue;
3317
3436
  hints.push({
3318
3437
  term: entry.term,
3319
3438
  doNotTranslate: entry.doNotTranslate,
@@ -3323,10 +3442,20 @@ function relevantGlossary(source, targetLocale, glossary) {
3323
3442
  }
3324
3443
  return hints;
3325
3444
  }
3445
+ function sourceKeysForTerm(state, term, opts = {}) {
3446
+ const pseudo = { term, caseSensitive: opts.caseSensitive, wholeWord: opts.wholeWord };
3447
+ const out = [];
3448
+ for (const [key, entry] of Object.entries(state.keys)) {
3449
+ const lv = entry.values[state.config.sourceLocale];
3450
+ const text = lv?.value ?? lv?.forms?.other ?? "";
3451
+ if (text && termInSource(text, pseudo)) out.push(key);
3452
+ }
3453
+ return out;
3454
+ }
3326
3455
  function glossaryViolations(source, value, targetLocale, glossary) {
3327
3456
  const out = [];
3328
3457
  for (const entry of glossary) {
3329
- if (!contains(source, entry.term, entry.caseSensitive)) continue;
3458
+ if (!termInSource(source, entry)) continue;
3330
3459
  if (entry.doNotTranslate) {
3331
3460
  if (!contains(value, entry.term, entry.caseSensitive)) {
3332
3461
  out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
@@ -3347,7 +3476,7 @@ var init_glossary = __esm({
3347
3476
  });
3348
3477
 
3349
3478
  // src/server/ai/run.ts
3350
- import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
3479
+ import { readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
3351
3480
  import { resolve as resolve4, extname } from "path";
3352
3481
  function selectRequests(state, opts) {
3353
3482
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
@@ -3421,7 +3550,7 @@ function attachScreenshots(reqs, state, projectRoot) {
3421
3550
  if (!existsSync5(abs)) {
3422
3551
  cache2.set(screenshot, null);
3423
3552
  } else {
3424
- const buf = readFileSync5(abs);
3553
+ const buf = readFileSync6(abs);
3425
3554
  cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
3426
3555
  }
3427
3556
  }
@@ -3567,16 +3696,16 @@ var init_run = __esm({
3567
3696
  });
3568
3697
 
3569
3698
  // src/server/ai/pending-batch.ts
3570
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
3571
- import { join as join3 } from "path";
3699
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
3700
+ import { join as join4 } from "path";
3572
3701
  function pendingBatchPath(projectRoot) {
3573
- return join3(projectRoot, ".glotfile", "batch.json");
3702
+ return join4(projectRoot, ".glotfile", "batch.json");
3574
3703
  }
3575
3704
  function loadPendingBatch(projectRoot) {
3576
3705
  const path = pendingBatchPath(projectRoot);
3577
3706
  if (!existsSync6(path)) return void 0;
3578
3707
  try {
3579
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
3708
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
3580
3709
  if (parsed?.version !== 1) return void 0;
3581
3710
  return parsed;
3582
3711
  } catch {
@@ -3584,9 +3713,9 @@ function loadPendingBatch(projectRoot) {
3584
3713
  }
3585
3714
  }
3586
3715
  function savePendingBatch(projectRoot, pending) {
3587
- const dir = join3(projectRoot, ".glotfile");
3716
+ const dir = join4(projectRoot, ".glotfile");
3588
3717
  mkdirSync4(dir, { recursive: true });
3589
- const gitignore = join3(dir, ".gitignore");
3718
+ const gitignore = join4(dir, ".gitignore");
3590
3719
  if (!existsSync6(gitignore)) writeFileSync3(gitignore, "*\n");
3591
3720
  writeFileSync3(pendingBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
3592
3721
  }
@@ -3600,7 +3729,7 @@ var init_pending_batch = __esm({
3600
3729
  });
3601
3730
 
3602
3731
  // src/server/log.ts
3603
- import { appendFileSync, existsSync as existsSync7, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync7 } from "fs";
3732
+ import { appendFileSync, existsSync as existsSync7, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync8 } from "fs";
3604
3733
  import { resolve as resolve5 } from "path";
3605
3734
  function logPath(projectRoot) {
3606
3735
  return resolve5(projectRoot, ".glotfile", "log.jsonl");
@@ -3613,7 +3742,7 @@ function appendLog(projectRoot, entry) {
3613
3742
  }
3614
3743
  function trimLog(path, maxBytes = MAX_LOG_BYTES, targetBytes = TRIM_LOG_TO_BYTES) {
3615
3744
  if (!existsSync7(path) || statSync2(path).size <= maxBytes) return;
3616
- const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3745
+ const lines = readFileSync8(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3617
3746
  const kept = [];
3618
3747
  let bytes = 0;
3619
3748
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -3812,7 +3941,7 @@ var init_batch_run = __esm({
3812
3941
  });
3813
3942
 
3814
3943
  // src/server/ai/context.ts
3815
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
3944
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
3816
3945
  import { resolve as resolve6 } from "path";
3817
3946
  function globToRegExp2(glob) {
3818
3947
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
@@ -3828,7 +3957,7 @@ function extractSnippets(refs, projectRoot, fileCache) {
3828
3957
  const absPath = resolve6(projectRoot, ref.file);
3829
3958
  if (!fileCache.has(ref.file)) {
3830
3959
  if (!existsSync8(absPath)) continue;
3831
- const content = readFileSync8(absPath, "utf8");
3960
+ const content = readFileSync9(absPath, "utf8");
3832
3961
  fileCache.set(ref.file, content.split("\n"));
3833
3962
  }
3834
3963
  const lines = fileCache.get(ref.file);
@@ -3844,6 +3973,21 @@ function extractSnippets(refs, projectRoot, fileCache) {
3844
3973
  }
3845
3974
  return snippets;
3846
3975
  }
3976
+ function attachUsageSnippets(targets, cache2, projectRoot) {
3977
+ const fileCache = /* @__PURE__ */ new Map();
3978
+ for (const target of targets) {
3979
+ const allRefs = Object.entries(cache2.files).flatMap(
3980
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
3981
+ key: r.key,
3982
+ file,
3983
+ line: r.line,
3984
+ col: r.col,
3985
+ scanner: r.scanner
3986
+ }))
3987
+ );
3988
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
3989
+ }
3990
+ }
3847
3991
  function buildUsageIndex(cache2) {
3848
3992
  const index = /* @__PURE__ */ new Map();
3849
3993
  for (const [file, entry] of Object.entries(cache2.files)) {
@@ -3982,16 +4126,16 @@ var init_context = __esm({
3982
4126
  });
3983
4127
 
3984
4128
  // src/server/ai/pending-context-batch.ts
3985
- import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
3986
- import { join as join4 } from "path";
4129
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
4130
+ import { join as join5 } from "path";
3987
4131
  function pendingContextBatchPath(projectRoot) {
3988
- return join4(projectRoot, ".glotfile", "context-batch.json");
4132
+ return join5(projectRoot, ".glotfile", "context-batch.json");
3989
4133
  }
3990
4134
  function loadPendingContextBatch(projectRoot) {
3991
4135
  const path = pendingContextBatchPath(projectRoot);
3992
4136
  if (!existsSync9(path)) return void 0;
3993
4137
  try {
3994
- const parsed = JSON.parse(readFileSync9(path, "utf8"));
4138
+ const parsed = JSON.parse(readFileSync10(path, "utf8"));
3995
4139
  if (parsed?.version !== 1) return void 0;
3996
4140
  return parsed;
3997
4141
  } catch {
@@ -3999,9 +4143,9 @@ function loadPendingContextBatch(projectRoot) {
3999
4143
  }
4000
4144
  }
4001
4145
  function savePendingContextBatch(projectRoot, pending) {
4002
- const dir = join4(projectRoot, ".glotfile");
4146
+ const dir = join5(projectRoot, ".glotfile");
4003
4147
  mkdirSync5(dir, { recursive: true });
4004
- const gitignore = join4(dir, ".gitignore");
4148
+ const gitignore = join5(dir, ".gitignore");
4005
4149
  if (!existsSync9(gitignore)) writeFileSync4(gitignore, "*\n");
4006
4150
  writeFileSync4(pendingContextBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4007
4151
  }
@@ -4122,6 +4266,109 @@ var init_context_batch_run = __esm({
4122
4266
  }
4123
4267
  });
4124
4268
 
4269
+ // src/server/ai/glossary-suggest.ts
4270
+ function globToRegExp3(glob) {
4271
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
4272
+ return new RegExp(`^${escaped}$`);
4273
+ }
4274
+ function selectGlossarySources(state, opts) {
4275
+ const keyRe = opts.keyGlob ? globToRegExp3(opts.keyGlob) : null;
4276
+ let rows = [];
4277
+ for (const key of Object.keys(state.keys)) {
4278
+ if (keyRe && !keyRe.test(key)) continue;
4279
+ const entry = state.keys[key];
4280
+ if (opts.since) {
4281
+ if (!entry.createdAt || entry.createdAt < opts.since) continue;
4282
+ }
4283
+ const lv = entry.values[state.config.sourceLocale];
4284
+ const source = (lv?.value ?? lv?.forms?.other ?? "").trim();
4285
+ if (!source) continue;
4286
+ rows.push({ key, source });
4287
+ }
4288
+ rows.sort((a, b) => {
4289
+ const ta = state.keys[a.key].createdAt ?? "";
4290
+ const tb = state.keys[b.key].createdAt ?? "";
4291
+ return tb.localeCompare(ta) || a.key.localeCompare(b.key);
4292
+ });
4293
+ if (opts.limit !== void 0) rows = rows.slice(0, opts.limit);
4294
+ return rows;
4295
+ }
4296
+ function knownTermList(state) {
4297
+ const out = /* @__PURE__ */ new Set();
4298
+ for (const g of state.glossary) out.add(g.term);
4299
+ for (const s of state.glossarySuggestions) out.add(s.term);
4300
+ return [...out];
4301
+ }
4302
+ function buildGlossarySuggestSystemPrompt() {
4303
+ return [
4304
+ "You identify GLOSSARY-CANDIDATE terms in a UI string catalog so they translate consistently.",
4305
+ "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).",
4306
+ "You are given source strings (the app's original language). Return the candidate terms you find.",
4307
+ "Rules:",
4308
+ "- Only surface terms a translator would benefit from pinning. IGNORE ordinary words, verbs, and generic UI labels (e.g. 'Save', 'Cancel', 'Welcome').",
4309
+ "- Prefer terms that recur or are clearly proper nouns / product names / acronyms.",
4310
+ "- Set doNotTranslate: true for brand/product names, code identifiers, and acronyms that must stay verbatim in every language.",
4311
+ "- Set caseSensitive: true only when casing is meaningful (e.g. an all-caps acronym that must not match a lowercase common word).",
4312
+ "- Set wholeWord: false ONLY if the term should also match inside larger words; otherwise omit it (whole-word is the default).",
4313
+ "- note: one short phrase on why it's a term (e.g. 'product name', 'industry acronym', 'recurring UI concept'). Keep it under 80 characters.",
4314
+ "- Do NOT return any term in the provided 'Already known' list.",
4315
+ "- Return the term exactly as it appears in the source (preserve casing)."
4316
+ ].join("\n");
4317
+ }
4318
+ function buildGlossarySuggestBatchPrompt(sources, knownTerms) {
4319
+ const known = knownTerms.length ? knownTerms.join(", ") : "(none yet)";
4320
+ const lines = sources.map((s) => `- [${s.key}] ${s.source}`).join("\n");
4321
+ return [
4322
+ `Already known (do NOT return these): ${known}`,
4323
+ "",
4324
+ "Source strings:",
4325
+ lines,
4326
+ "",
4327
+ 'Return JSON {"terms":[{"term","note?","doNotTranslate?","caseSensitive?","wholeWord?"}]}. Return an empty array if you find no good candidates.'
4328
+ ].join("\n");
4329
+ }
4330
+ function dedupeTerms(terms) {
4331
+ const seen = /* @__PURE__ */ new Set();
4332
+ const out = [];
4333
+ for (const t of terms) {
4334
+ const term = t.term?.trim();
4335
+ if (!term) continue;
4336
+ const key = term.toLowerCase();
4337
+ if (seen.has(key)) continue;
4338
+ seen.add(key);
4339
+ out.push({ ...t, term });
4340
+ }
4341
+ return out;
4342
+ }
4343
+ var GLOSSARY_SUGGEST_SCHEMA;
4344
+ var init_glossary_suggest = __esm({
4345
+ "src/server/ai/glossary-suggest.ts"() {
4346
+ "use strict";
4347
+ GLOSSARY_SUGGEST_SCHEMA = {
4348
+ type: "object",
4349
+ properties: {
4350
+ terms: {
4351
+ type: "array",
4352
+ items: {
4353
+ type: "object",
4354
+ properties: {
4355
+ term: { type: "string" },
4356
+ note: { type: "string" },
4357
+ doNotTranslate: { type: "boolean" },
4358
+ caseSensitive: { type: "boolean" },
4359
+ wholeWord: { type: "boolean" }
4360
+ },
4361
+ required: ["term"],
4362
+ additionalProperties: false
4363
+ }
4364
+ }
4365
+ },
4366
+ required: ["terms"],
4367
+ additionalProperties: false
4368
+ };
4369
+ }
4370
+ });
4371
+
4125
4372
  // src/server/ai/estimate.ts
4126
4373
  function estimateTokens(text) {
4127
4374
  const cjk = text.match(CJK_RE)?.length ?? 0;
@@ -4170,29 +4417,131 @@ function estimateTranslation(state, ai, opts) {
4170
4417
  estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4171
4418
  };
4172
4419
  }
4173
- var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
4420
+ function estimateContext(targets, ai) {
4421
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4422
+ const batches = chunk(targets, batchSize);
4423
+ const system = buildContextSystemPrompt();
4424
+ let inputTokens = 0;
4425
+ let outputTokens = 0;
4426
+ for (const batch of batches) {
4427
+ inputTokens += estimateTokens(system) + estimateTokens(buildContextBatchPrompt(batch));
4428
+ outputTokens += batch.length * (CONTEXT_REPLY_OVERHEAD + TYPICAL_CONTEXT_TOKENS);
4429
+ }
4430
+ const pricing = resolvePricing(ai);
4431
+ return {
4432
+ keys: targets.length,
4433
+ batches: batches.length,
4434
+ inputTokens,
4435
+ outputTokens,
4436
+ pricing,
4437
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4438
+ };
4439
+ }
4440
+ function estimateGlossarySuggest(sources, knownTerms, ai) {
4441
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4442
+ const batches = chunk(sources, batchSize);
4443
+ const system = buildGlossarySuggestSystemPrompt();
4444
+ let inputTokens = 0;
4445
+ let outputTokens = 0;
4446
+ for (const batch of batches) {
4447
+ inputTokens += estimateTokens(system) + estimateTokens(buildGlossarySuggestBatchPrompt(batch, knownTerms));
4448
+ outputTokens += Math.ceil(batch.length * TERM_YIELD) * TERM_REPLY_TOKENS;
4449
+ }
4450
+ const pricing = resolvePricing(ai);
4451
+ return {
4452
+ sources: sources.length,
4453
+ batches: batches.length,
4454
+ inputTokens,
4455
+ outputTokens,
4456
+ pricing,
4457
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4458
+ };
4459
+ }
4460
+ var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD, CONTEXT_REPLY_OVERHEAD, TYPICAL_CONTEXT_TOKENS, TERM_REPLY_TOKENS, TERM_YIELD;
4174
4461
  var init_estimate = __esm({
4175
4462
  "src/server/ai/estimate.ts"() {
4176
4463
  "use strict";
4177
4464
  init_run();
4178
4465
  init_provider();
4466
+ init_context();
4467
+ init_glossary_suggest();
4179
4468
  init_batch();
4180
4469
  init_pricing();
4181
4470
  CJK_RE = /[ -鿿가-힯豈-﫿]/g;
4182
4471
  EXPANSION = 1.2;
4183
4472
  ITEM_REPLY_OVERHEAD = 16;
4184
4473
  FORM_REPLY_OVERHEAD = 8;
4474
+ CONTEXT_REPLY_OVERHEAD = 16;
4475
+ TYPICAL_CONTEXT_TOKENS = 35;
4476
+ TERM_REPLY_TOKENS = 24;
4477
+ TERM_YIELD = 0.15;
4478
+ }
4479
+ });
4480
+
4481
+ // src/server/ai/price-fetch.ts
4482
+ function normalizeModelsDevPrices(api) {
4483
+ const out = {};
4484
+ const ranks = {};
4485
+ if (!api || typeof api !== "object") return out;
4486
+ for (const [provId, prov] of Object.entries(api)) {
4487
+ const models = prov?.models;
4488
+ if (!models || typeof models !== "object") continue;
4489
+ const rank = providerRank(provId);
4490
+ for (const [modelKey, model] of Object.entries(models)) {
4491
+ const cost = model?.cost;
4492
+ if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number") continue;
4493
+ const bareId = bareModelId(modelKey);
4494
+ if (!bareId) continue;
4495
+ const existingRank = ranks[bareId];
4496
+ if (existingRank !== void 0 && existingRank <= rank) continue;
4497
+ const price = { inputPerMTok: cost.input, outputPerMTok: cost.output };
4498
+ if (typeof cost.cache_read === "number") price.cacheReadPerMTok = cost.cache_read;
4499
+ if (typeof cost.cache_write === "number") price.cacheWritePerMTok = cost.cache_write;
4500
+ out[bareId] = price;
4501
+ ranks[bareId] = rank;
4502
+ }
4503
+ }
4504
+ return out;
4505
+ }
4506
+ async function refreshPrices(opts = {}) {
4507
+ const url = opts.url ?? priceUrl();
4508
+ const doFetch = opts.fetchImpl ?? fetch;
4509
+ const res = await doFetch(url);
4510
+ if (!res.ok) throw new Error(`Failed to fetch prices from ${url}: HTTP ${res.status}`);
4511
+ const api = await res.json();
4512
+ const models = normalizeModelsDevPrices(api);
4513
+ const modelCount = Object.keys(models).length;
4514
+ if (modelCount === 0) throw new Error(`No model prices found in response from ${url}`);
4515
+ const cache2 = { source: "models.dev", fetchedAt: (opts.now ?? defaultNow)(), models };
4516
+ const path = opts.path ?? defaultPriceCachePath();
4517
+ savePriceCache(cache2, path);
4518
+ return { source: cache2.source, fetchedAt: cache2.fetchedAt, modelCount, path };
4519
+ }
4520
+ var MODELS_DEV_URL, priceUrl, PROVIDER_PREFERENCE, providerRank, defaultNow;
4521
+ var init_price_fetch = __esm({
4522
+ "src/server/ai/price-fetch.ts"() {
4523
+ "use strict";
4524
+ init_pricing();
4525
+ init_price_cache();
4526
+ MODELS_DEV_URL = "https://models.dev/api.json";
4527
+ priceUrl = () => process.env.GLOTFILE_PRICES_URL || MODELS_DEV_URL;
4528
+ PROVIDER_PREFERENCE = ["anthropic", "openai", "google", "meta-llama", "mistral", "deepseek", "xai", "groq"];
4529
+ providerRank = (provId) => {
4530
+ const i = PROVIDER_PREFERENCE.indexOf(provId);
4531
+ return i === -1 ? PROVIDER_PREFERENCE.length : i;
4532
+ };
4533
+ defaultNow = () => (/* @__PURE__ */ new Date()).toISOString();
4185
4534
  }
4186
4535
  });
4187
4536
 
4188
4537
  // src/server/scan.ts
4189
- import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
4538
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
4190
4539
  import { resolve as resolve7 } from "path";
4191
4540
  function loadUsageCache(projectRoot) {
4192
4541
  const path = resolve7(projectRoot, ".glotfile", "usage.json");
4193
4542
  if (!existsSync10(path)) return null;
4194
4543
  try {
4195
- return JSON.parse(readFileSync10(path, "utf8"));
4544
+ return JSON.parse(readFileSync11(path, "utf8"));
4196
4545
  } catch {
4197
4546
  return null;
4198
4547
  }
@@ -4257,8 +4606,8 @@ var init_scan = __esm({
4257
4606
  });
4258
4607
 
4259
4608
  // src/server/scanner.ts
4260
- import { readdirSync as readdirSync3, statSync as statSync3, readFileSync as readFileSync11 } from "fs";
4261
- import { join as join5, extname as extname2, relative } from "path";
4609
+ import { readdirSync as readdirSync3, statSync as statSync3, readFileSync as readFileSync12 } from "fs";
4610
+ import { join as join6, extname as extname2, relative } from "path";
4262
4611
  function scannerForExt(ext) {
4263
4612
  return EXT_SCANNER[ext] ?? null;
4264
4613
  }
@@ -4480,7 +4829,7 @@ function* walkFiles(dir, root, exclude) {
4480
4829
  }
4481
4830
  for (const name of entries) {
4482
4831
  if (ALWAYS_EXCLUDE.has(name)) continue;
4483
- const abs = join5(dir, name);
4832
+ const abs = join6(dir, name);
4484
4833
  const rel = relative(root, abs);
4485
4834
  let st;
4486
4835
  try {
@@ -4510,7 +4859,7 @@ function runScan(projectRoot, opts, existing) {
4510
4859
  const ext = extname2(relPath);
4511
4860
  const scanner = scannerForExt(ext);
4512
4861
  if (!scanner) continue;
4513
- const abs = join5(projectRoot, relPath);
4862
+ const abs = join6(projectRoot, relPath);
4514
4863
  let st;
4515
4864
  try {
4516
4865
  st = statSync3(abs);
@@ -4526,7 +4875,7 @@ function runScan(projectRoot, opts, existing) {
4526
4875
  }
4527
4876
  let content;
4528
4877
  try {
4529
- content = readFileSync11(abs, "utf8");
4878
+ content = readFileSync12(abs, "utf8");
4530
4879
  } catch {
4531
4880
  continue;
4532
4881
  }
@@ -4660,8 +5009,8 @@ var init_scanner = __esm({
4660
5009
  });
4661
5010
 
4662
5011
  // src/server/import/detect.ts
4663
- import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync12, statSync as statSync4 } from "fs";
4664
- import { join as join6 } from "path";
5012
+ import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync13, statSync as statSync4 } from "fs";
5013
+ import { join as join7 } from "path";
4665
5014
  function safeIsDir(p) {
4666
5015
  try {
4667
5016
  return statSync4(p).isDirectory();
@@ -4670,7 +5019,7 @@ function safeIsDir(p) {
4670
5019
  }
4671
5020
  }
4672
5021
  function listDirs(dir) {
4673
- return readdirSync4(dir).filter((e) => safeIsDir(join6(dir, e)));
5022
+ return readdirSync4(dir).filter((e) => safeIsDir(join7(dir, e)));
4674
5023
  }
4675
5024
  function fileCount(dir) {
4676
5025
  try {
@@ -4684,23 +5033,23 @@ function pickSource(locales, sizeOf) {
4684
5033
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4685
5034
  }
4686
5035
  function detectLaravel(root) {
4687
- const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
5036
+ const localeRoot = [join7(root, "resources", "lang"), join7(root, "lang")].find(safeIsDir);
4688
5037
  if (!localeRoot) return null;
4689
5038
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4690
5039
  if (locales.length === 0) return null;
4691
- const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
5040
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join7(localeRoot, loc)));
4692
5041
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4693
5042
  }
4694
5043
  function detectVue(root, forced = false) {
4695
5044
  for (const rel of VUE_DIR_CANDIDATES) {
4696
- const localeRoot = join6(root, rel);
5045
+ const localeRoot = join7(root, rel);
4697
5046
  if (!safeIsDir(localeRoot)) continue;
4698
5047
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4699
5048
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4700
5049
  if (enough) {
4701
5050
  const sourceLocale = pickSource(locales, (loc) => {
4702
5051
  try {
4703
- return statSync4(join6(localeRoot, `${loc}.json`)).size;
5052
+ return statSync4(join7(localeRoot, `${loc}.json`)).size;
4704
5053
  } catch {
4705
5054
  return 0;
4706
5055
  }
@@ -4711,9 +5060,9 @@ function detectVue(root, forced = false) {
4711
5060
  return null;
4712
5061
  }
4713
5062
  function hasNextIntlSignal(root) {
4714
- if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync11(join6(root, rel)))) return true;
5063
+ if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync11(join7(root, rel)))) return true;
4715
5064
  try {
4716
- const pkg = JSON.parse(readFileSync12(join6(root, "package.json"), "utf8"));
5065
+ const pkg = JSON.parse(readFileSync13(join7(root, "package.json"), "utf8"));
4717
5066
  if (pkg.dependencies?.["next-intl"] || pkg.devDependencies?.["next-intl"]) return true;
4718
5067
  } catch {
4719
5068
  }
@@ -4722,7 +5071,7 @@ function hasNextIntlSignal(root) {
4722
5071
  function nextIntlDefaultLocale(root) {
4723
5072
  for (const rel of NEXT_INTL_ROUTING_CANDIDATES) {
4724
5073
  try {
4725
- const m = readFileSync12(join6(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
5074
+ const m = readFileSync13(join7(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
4726
5075
  if (m) return m[1];
4727
5076
  } catch {
4728
5077
  }
@@ -4732,14 +5081,14 @@ function nextIntlDefaultLocale(root) {
4732
5081
  function detectNextIntl(root, forced = false) {
4733
5082
  if (!forced && !hasNextIntlSignal(root)) return null;
4734
5083
  for (const rel of NEXT_INTL_DIR_CANDIDATES) {
4735
- const localeRoot = join6(root, rel);
5084
+ const localeRoot = join7(root, rel);
4736
5085
  if (!safeIsDir(localeRoot)) continue;
4737
5086
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4738
5087
  if (locales.length === 0) continue;
4739
5088
  const def = nextIntlDefaultLocale(root);
4740
5089
  const sourceLocale = def && locales.includes(def) ? def : pickSource(locales, (loc) => {
4741
5090
  try {
4742
- return statSync4(join6(localeRoot, `${loc}.json`)).size;
5091
+ return statSync4(join7(localeRoot, `${loc}.json`)).size;
4743
5092
  } catch {
4744
5093
  return 0;
4745
5094
  }
@@ -4750,7 +5099,7 @@ function detectNextIntl(root, forced = false) {
4750
5099
  }
4751
5100
  function detectArb(root) {
4752
5101
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4753
- const localeRoot = join6(root, rel);
5102
+ const localeRoot = join7(root, rel);
4754
5103
  if (!safeIsDir(localeRoot)) continue;
4755
5104
  const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4756
5105
  if (locales.length >= 1) {
@@ -4760,10 +5109,10 @@ function detectArb(root) {
4760
5109
  return null;
4761
5110
  }
4762
5111
  function lprojLocales(dir) {
4763
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.strings")));
5112
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join7(dir, `${l}.lproj`, "Localizable.strings")));
4764
5113
  }
4765
5114
  function detectApple(root) {
4766
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5115
+ const candidates = [root, ...listDirs(root).map((d) => join7(root, d))];
4767
5116
  let best = null;
4768
5117
  for (const dir of candidates) {
4769
5118
  const locales = lprojLocales(dir);
@@ -4775,7 +5124,7 @@ function detectApple(root) {
4775
5124
  locales,
4776
5125
  sourceLocale: pickSource(locales, (loc) => {
4777
5126
  try {
4778
- return statSync4(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
5127
+ return statSync4(join7(dir, `${loc}.lproj`, "Localizable.strings")).size;
4779
5128
  } catch {
4780
5129
  return 0;
4781
5130
  }
@@ -4787,7 +5136,7 @@ function detectApple(root) {
4787
5136
  }
4788
5137
  function detectAngularXliff(root) {
4789
5138
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4790
- const localeRoot = rel === "." ? root : join6(root, rel);
5139
+ const localeRoot = rel === "." ? root : join7(root, rel);
4791
5140
  if (!safeIsDir(localeRoot)) continue;
4792
5141
  const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4793
5142
  if (files.length === 0) continue;
@@ -4795,7 +5144,7 @@ function detectAngularXliff(root) {
4795
5144
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4796
5145
  let sourceLocale;
4797
5146
  try {
4798
- sourceLocale = readFileSync12(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
5147
+ sourceLocale = readFileSync13(join7(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4799
5148
  } catch {
4800
5149
  }
4801
5150
  if (!sourceLocale && locales.length === 0) continue;
@@ -4806,14 +5155,14 @@ function detectAngularXliff(root) {
4806
5155
  return null;
4807
5156
  }
4808
5157
  function detectRails(root) {
4809
- const localeRoot = join6(root, "config", "locales");
5158
+ const localeRoot = join7(root, "config", "locales");
4810
5159
  if (!safeIsDir(localeRoot)) return null;
4811
5160
  const locales = [];
4812
5161
  for (const file of readdirSync4(localeRoot).sort()) {
4813
5162
  if (!/\.ya?ml$/.test(file)) continue;
4814
5163
  let text;
4815
5164
  try {
4816
- text = readFileSync12(join6(localeRoot, file), "utf8");
5165
+ text = readFileSync13(join7(localeRoot, file), "utf8");
4817
5166
  } catch {
4818
5167
  continue;
4819
5168
  }
@@ -4827,15 +5176,15 @@ function detectRails(root) {
4827
5176
  }
4828
5177
  function detectI18next(root) {
4829
5178
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4830
- const localeRoot = join6(root, rel);
5179
+ const localeRoot = join7(root, rel);
4831
5180
  if (!safeIsDir(localeRoot)) continue;
4832
5181
  const locales = listDirs(localeRoot).filter(
4833
- (d) => LOCALE_RE.test(d) && readdirSync4(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
5182
+ (d) => LOCALE_RE.test(d) && readdirSync4(join7(localeRoot, d)).some((f) => f.endsWith(".json"))
4834
5183
  );
4835
5184
  if (locales.length === 0) continue;
4836
5185
  const sourceLocale = pickSource(locales, (loc) => {
4837
5186
  try {
4838
- return readdirSync4(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join6(localeRoot, loc, f)).size, 0);
5187
+ return readdirSync4(join7(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join7(localeRoot, loc, f)).size, 0);
4839
5188
  } catch {
4840
5189
  return 0;
4841
5190
  }
@@ -4852,8 +5201,8 @@ function gettextLocales(dir) {
4852
5201
  if (!locales.includes(flat)) locales.push(flat);
4853
5202
  continue;
4854
5203
  }
4855
- if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4856
- const sub = join6(dir, entry);
5204
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join7(dir, entry))) continue;
5205
+ const sub = join7(dir, entry);
4857
5206
  const hasPo = (d) => {
4858
5207
  try {
4859
5208
  return readdirSync4(d).some((f) => f.endsWith(".po"));
@@ -4861,7 +5210,7 @@ function gettextLocales(dir) {
4861
5210
  return false;
4862
5211
  }
4863
5212
  };
4864
- if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
5213
+ if (hasPo(join7(sub, "LC_MESSAGES")) || hasPo(sub)) {
4865
5214
  if (!locales.includes(entry)) locales.push(entry);
4866
5215
  }
4867
5216
  }
@@ -4869,7 +5218,7 @@ function gettextLocales(dir) {
4869
5218
  }
4870
5219
  function detectGettext(root) {
4871
5220
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4872
- const localeRoot = join6(root, rel);
5221
+ const localeRoot = join7(root, rel);
4873
5222
  if (!safeIsDir(localeRoot)) continue;
4874
5223
  const locales = gettextLocales(localeRoot);
4875
5224
  if (locales.length === 0) continue;
@@ -4878,10 +5227,10 @@ function detectGettext(root) {
4878
5227
  return null;
4879
5228
  }
4880
5229
  function detectAppleStringsdict(root) {
4881
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5230
+ const candidates = [root, ...listDirs(root).map((d) => join7(root, d))];
4882
5231
  let best = null;
4883
5232
  for (const dir of candidates) {
4884
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.stringsdict")));
5233
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join7(dir, `${l}.lproj`, "Localizable.stringsdict")));
4885
5234
  if (locales.length === 0) continue;
4886
5235
  if (!best || locales.length > best.locales.length) {
4887
5236
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4968,8 +5317,8 @@ var init_flatten = __esm({
4968
5317
  });
4969
5318
 
4970
5319
  // src/server/import/parsers/vue-i18n-json.ts
4971
- import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4972
- import { join as join7 } from "path";
5320
+ import { readdirSync as readdirSync5, readFileSync as readFileSync14 } from "fs";
5321
+ import { join as join8 } from "path";
4973
5322
  function fromVueI18n(value) {
4974
5323
  return value.replace(/\{'([^']*)'\}/g, "'$1'");
4975
5324
  }
@@ -4992,7 +5341,7 @@ var init_vue_i18n_json2 = __esm({
4992
5341
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4993
5342
  let data;
4994
5343
  try {
4995
- data = JSON.parse(readFileSync13(join7(localeRoot, file), "utf8"));
5344
+ data = JSON.parse(readFileSync14(join8(localeRoot, file), "utf8"));
4996
5345
  } catch (e) {
4997
5346
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4998
5347
  continue;
@@ -5009,8 +5358,8 @@ var init_vue_i18n_json2 = __esm({
5009
5358
  });
5010
5359
 
5011
5360
  // src/server/import/parsers/next-intl-json.ts
5012
- import { readdirSync as readdirSync6, readFileSync as readFileSync14 } from "fs";
5013
- import { join as join8 } from "path";
5361
+ import { readdirSync as readdirSync6, readFileSync as readFileSync15 } from "fs";
5362
+ import { join as join9 } from "path";
5014
5363
  var LOCALE_RE3, nextIntlJson2;
5015
5364
  var init_next_intl_json2 = __esm({
5016
5365
  "src/server/import/parsers/next-intl-json.ts"() {
@@ -5030,7 +5379,7 @@ var init_next_intl_json2 = __esm({
5030
5379
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5031
5380
  let data;
5032
5381
  try {
5033
- data = JSON.parse(readFileSync14(join8(localeRoot, file), "utf8"));
5382
+ data = JSON.parse(readFileSync15(join9(localeRoot, file), "utf8"));
5034
5383
  } catch (e) {
5035
5384
  warnings.push(`next-intl-json: failed to parse ${file}: ${e.message}`);
5036
5385
  continue;
@@ -5064,16 +5413,16 @@ var init_placeholders2 = __esm({
5064
5413
 
5065
5414
  // src/server/import/parsers/laravel-php.ts
5066
5415
  import { readdirSync as readdirSync7, statSync as statSync5 } from "fs";
5067
- import { join as join9, relative as relative2 } from "path";
5416
+ import { join as join10, relative as relative2 } from "path";
5068
5417
  import { execFileSync } from "child_process";
5069
5418
  function listDirs2(dir) {
5070
- return readdirSync7(dir).filter((e) => statSync5(join9(dir, e)).isDirectory());
5419
+ return readdirSync7(dir).filter((e) => statSync5(join10(dir, e)).isDirectory());
5071
5420
  }
5072
5421
  function listPhpFiles(dir) {
5073
5422
  const out = [];
5074
5423
  const walk = (d) => {
5075
5424
  for (const e of readdirSync7(d)) {
5076
- const full = join9(d, e);
5425
+ const full = join10(d, e);
5077
5426
  if (statSync5(full).isDirectory()) walk(full);
5078
5427
  else if (e.endsWith(".php")) out.push(full);
5079
5428
  }
@@ -5116,7 +5465,7 @@ var init_laravel_php2 = __esm({
5116
5465
  for (const locale of listDirs2(localeRoot).sort()) {
5117
5466
  if (locale === "vendor") continue;
5118
5467
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5119
- const localeDir = join9(localeRoot, locale);
5468
+ const localeDir = join10(localeRoot, locale);
5120
5469
  locales.push(locale);
5121
5470
  for (const file of listPhpFiles(localeDir)) {
5122
5471
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -5141,8 +5490,8 @@ var init_laravel_php2 = __esm({
5141
5490
  });
5142
5491
 
5143
5492
  // src/server/import/parsers/flutter-arb.ts
5144
- import { readdirSync as readdirSync8, readFileSync as readFileSync15 } from "fs";
5145
- import { join as join10 } from "path";
5493
+ import { readdirSync as readdirSync8, readFileSync as readFileSync16 } from "fs";
5494
+ import { join as join11 } from "path";
5146
5495
  function localeFromArbName(file) {
5147
5496
  const m = file.match(/^(.+)\.arb$/);
5148
5497
  if (!m) return null;
@@ -5182,7 +5531,7 @@ var init_flutter_arb2 = __esm({
5182
5531
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5183
5532
  let data;
5184
5533
  try {
5185
- data = JSON.parse(readFileSync15(join10(localeRoot, file), "utf8"));
5534
+ data = JSON.parse(readFileSync16(join11(localeRoot, file), "utf8"));
5186
5535
  } catch (e) {
5187
5536
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
5188
5537
  continue;
@@ -5209,8 +5558,8 @@ var init_flutter_arb2 = __esm({
5209
5558
  });
5210
5559
 
5211
5560
  // src/server/import/parsers/apple-strings.ts
5212
- import { readdirSync as readdirSync9, readFileSync as readFileSync16, statSync as statSync6 } from "fs";
5213
- import { join as join11 } from "path";
5561
+ import { readdirSync as readdirSync9, readFileSync as readFileSync17, statSync as statSync6 } from "fs";
5562
+ import { join as join12 } from "path";
5214
5563
  function localeFromLproj(dir) {
5215
5564
  const m = dir.match(/^(.+)\.lproj$/);
5216
5565
  if (!m) return null;
@@ -5330,16 +5679,16 @@ var init_apple_strings2 = __esm({
5330
5679
  const locale = localeFromLproj(dir);
5331
5680
  if (!locale) continue;
5332
5681
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5333
- const file = join11(localeRoot, dir, TABLE);
5682
+ const file = join12(localeRoot, dir, TABLE);
5334
5683
  let text;
5335
5684
  try {
5336
5685
  if (!statSync6(file).isFile()) continue;
5337
- text = readFileSync16(file, "utf8");
5686
+ text = readFileSync17(file, "utf8");
5338
5687
  } catch {
5339
5688
  continue;
5340
5689
  }
5341
5690
  locales.push(locale);
5342
- const others = readdirSync9(join11(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5691
+ const others = readdirSync9(join12(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5343
5692
  if (others.length) {
5344
5693
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
5345
5694
  }
@@ -5354,8 +5703,8 @@ var init_apple_strings2 = __esm({
5354
5703
  });
5355
5704
 
5356
5705
  // src/server/import/parsers/angular-xliff.ts
5357
- import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
5358
- import { join as join12 } from "path";
5706
+ import { readdirSync as readdirSync10, readFileSync as readFileSync18 } from "fs";
5707
+ import { join as join13 } from "path";
5359
5708
  function decodeEntities(s) {
5360
5709
  return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
5361
5710
  }
@@ -5426,7 +5775,7 @@ var init_angular_xliff2 = __esm({
5426
5775
  if (fnameLocale !== void 0 && !LOCALE_RE6.test(fnameLocale)) continue;
5427
5776
  let xml;
5428
5777
  try {
5429
- xml = readFileSync17(join12(localeRoot, file), "utf8");
5778
+ xml = readFileSync18(join13(localeRoot, file), "utf8");
5430
5779
  } catch (e) {
5431
5780
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
5432
5781
  continue;
@@ -5473,8 +5822,8 @@ var init_angular_xliff2 = __esm({
5473
5822
  });
5474
5823
 
5475
5824
  // src/server/import/parsers/gettext-po.ts
5476
- import { readdirSync as readdirSync11, readFileSync as readFileSync18 } from "fs";
5477
- import { join as join13 } from "path";
5825
+ import { readdirSync as readdirSync11, readFileSync as readFileSync19 } from "fs";
5826
+ import { join as join14 } from "path";
5478
5827
  function unescapePo(s) {
5479
5828
  return s.replace(
5480
5829
  /\\([\\"ntr])/g,
@@ -5563,17 +5912,17 @@ function discoverPoFiles(root) {
5563
5912
  for (const e of entries) {
5564
5913
  if (e.isFile() && e.name.endsWith(".po")) {
5565
5914
  const base = e.name.slice(0, -3);
5566
- found.push({ path: join13(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5915
+ found.push({ path: join14(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5567
5916
  } else if (e.isDirectory() && LOCALE_RE7.test(e.name)) {
5568
- for (const sub of [join13(e.name, "LC_MESSAGES"), e.name]) {
5917
+ for (const sub of [join14(e.name, "LC_MESSAGES"), e.name]) {
5569
5918
  let names;
5570
5919
  try {
5571
- names = readdirSync11(join13(root, sub)).sort();
5920
+ names = readdirSync11(join14(root, sub)).sort();
5572
5921
  } catch {
5573
5922
  continue;
5574
5923
  }
5575
5924
  for (const f of names) {
5576
- if (f.endsWith(".po")) found.push({ path: join13(root, sub, f), rel: join13(sub, f), locale: e.name });
5925
+ if (f.endsWith(".po")) found.push({ path: join14(root, sub, f), rel: join14(sub, f), locale: e.name });
5577
5926
  }
5578
5927
  }
5579
5928
  }
@@ -5597,7 +5946,7 @@ var init_gettext_po2 = __esm({
5597
5946
  for (const file of discoverPoFiles(localeRoot)) {
5598
5947
  let entries;
5599
5948
  try {
5600
- entries = parseEntries(readFileSync18(file.path, "utf8"));
5949
+ entries = parseEntries(readFileSync19(file.path, "utf8"));
5601
5950
  } catch (e) {
5602
5951
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5603
5952
  continue;
@@ -5644,8 +5993,8 @@ var init_gettext_po2 = __esm({
5644
5993
  });
5645
5994
 
5646
5995
  // src/server/import/parsers/i18next-json.ts
5647
- import { readdirSync as readdirSync12, readFileSync as readFileSync19, statSync as statSync7 } from "fs";
5648
- import { join as join14 } from "path";
5996
+ import { readdirSync as readdirSync12, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5997
+ import { join as join15 } from "path";
5649
5998
  function safeIsDir2(p) {
5650
5999
  try {
5651
6000
  return statSync7(p).isDirectory();
@@ -5660,7 +6009,7 @@ function fromI18next(value) {
5660
6009
  function ingestFile(path, label, prefix, locale, keys, warnings) {
5661
6010
  let data;
5662
6011
  try {
5663
- data = JSON.parse(readFileSync19(path, "utf8"));
6012
+ data = JSON.parse(readFileSync20(path, "utf8"));
5664
6013
  } catch (e) {
5665
6014
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5666
6015
  return false;
@@ -5713,7 +6062,7 @@ var init_i18next_json2 = __esm({
5713
6062
  const keys = {};
5714
6063
  const locales = [];
5715
6064
  for (const entry of readdirSync12(localeRoot).sort()) {
5716
- const full = join14(localeRoot, entry);
6065
+ const full = join15(localeRoot, entry);
5717
6066
  if (safeIsDir2(full)) {
5718
6067
  if (!LOCALE_RE8.test(entry)) continue;
5719
6068
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -5722,7 +6071,7 @@ var init_i18next_json2 = __esm({
5722
6071
  if (!file.endsWith(".json")) continue;
5723
6072
  const ns = file.slice(0, -".json".length);
5724
6073
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5725
- if (ingestFile(join14(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
6074
+ if (ingestFile(join15(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5726
6075
  }
5727
6076
  if (any && !locales.includes(entry)) locales.push(entry);
5728
6077
  } else if (entry.endsWith(".json")) {
@@ -5741,8 +6090,8 @@ var init_i18next_json2 = __esm({
5741
6090
  });
5742
6091
 
5743
6092
  // src/server/import/parsers/rails-yaml.ts
5744
- import { readdirSync as readdirSync13, readFileSync as readFileSync20 } from "fs";
5745
- import { join as join15 } from "path";
6093
+ import { readdirSync as readdirSync13, readFileSync as readFileSync21 } from "fs";
6094
+ import { join as join16 } from "path";
5746
6095
  function makeNode() {
5747
6096
  return /* @__PURE__ */ Object.create(null);
5748
6097
  }
@@ -5966,7 +6315,7 @@ var init_rails_yaml2 = __esm({
5966
6315
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5967
6316
  let text;
5968
6317
  try {
5969
- text = readFileSync20(join15(localeRoot, file), "utf8");
6318
+ text = readFileSync21(join16(localeRoot, file), "utf8");
5970
6319
  } catch (e) {
5971
6320
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5972
6321
  continue;
@@ -5989,8 +6338,8 @@ var init_rails_yaml2 = __esm({
5989
6338
  });
5990
6339
 
5991
6340
  // src/server/import/parsers/apple-stringsdict.ts
5992
- import { readdirSync as readdirSync14, readFileSync as readFileSync21, statSync as statSync8 } from "fs";
5993
- import { join as join16 } from "path";
6341
+ import { readdirSync as readdirSync14, readFileSync as readFileSync22, statSync as statSync8 } from "fs";
6342
+ import { join as join17 } from "path";
5994
6343
  function localeFromLproj2(dir) {
5995
6344
  const m = dir.match(/^(.+)\.lproj$/);
5996
6345
  if (!m) return null;
@@ -6150,16 +6499,16 @@ var init_apple_stringsdict2 = __esm({
6150
6499
  const locale = localeFromLproj2(dir);
6151
6500
  if (!locale) continue;
6152
6501
  if (opts?.locales && !opts.locales.includes(locale)) continue;
6153
- const file = join16(localeRoot, dir, TABLE2);
6502
+ const file = join17(localeRoot, dir, TABLE2);
6154
6503
  let text;
6155
6504
  try {
6156
6505
  if (!statSync8(file).isFile()) continue;
6157
- text = readFileSync21(file, "utf8");
6506
+ text = readFileSync22(file, "utf8");
6158
6507
  } catch {
6159
6508
  continue;
6160
6509
  }
6161
6510
  locales.push(locale);
6162
- const others = readdirSync14(join16(localeRoot, dir)).filter(
6511
+ const others = readdirSync14(join17(localeRoot, dir)).filter(
6163
6512
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
6164
6513
  );
6165
6514
  if (others.length) {
@@ -6633,7 +6982,7 @@ var init_run2 = __esm({
6633
6982
  });
6634
6983
 
6635
6984
  // src/server/lint/outputs.ts
6636
- import { readFileSync as readFileSync22, existsSync as existsSync12 } from "fs";
6985
+ import { readFileSync as readFileSync23, existsSync as existsSync12 } from "fs";
6637
6986
  import { resolve as resolve8 } from "path";
6638
6987
  function checkOutputs(state, root) {
6639
6988
  const out = [];
@@ -6641,7 +6990,7 @@ function checkOutputs(state, root) {
6641
6990
  const result = getAdapter(output.adapter).export(state, output);
6642
6991
  for (const file of result.files) {
6643
6992
  const abs = resolve8(root, file.path);
6644
- const current = existsSync12(abs) ? readFileSync22(abs, "utf8") : null;
6993
+ const current = existsSync12(abs) ? readFileSync23(abs, "utf8") : null;
6645
6994
  if (current === null) {
6646
6995
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
6647
6996
  } else if (current !== file.contents) {
@@ -6749,6 +7098,7 @@ function assemble2(parsed, opts) {
6749
7098
  spelling: { customWords: [] }
6750
7099
  },
6751
7100
  glossary: [],
7101
+ glossarySuggestions: [],
6752
7102
  keys,
6753
7103
  warnings
6754
7104
  };
@@ -7092,13 +7442,63 @@ var init_checks = __esm({
7092
7442
  }
7093
7443
  });
7094
7444
 
7445
+ // src/server/ai/explain-error.ts
7446
+ function rawMessage(err) {
7447
+ if (err instanceof Error && err.message) return err.message;
7448
+ if (typeof err === "string") return err;
7449
+ if (err && typeof err === "object" && "message" in err && typeof err.message === "string") {
7450
+ return err.message;
7451
+ }
7452
+ return String(err ?? "Unknown error");
7453
+ }
7454
+ function explainProviderError(provider, err) {
7455
+ const raw = rawMessage(err);
7456
+ const m = raw.toLowerCase();
7457
+ if (provider === "bedrock") {
7458
+ if (/could not load credentials|unable to locate credentials|credentialsprovider|credentials from any providers/.test(m)) {
7459
+ 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`.";
7460
+ }
7461
+ if (/on-demand throughput isn.?t supported/.test(m) || /inference profile/.test(m)) {
7462
+ 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).';
7463
+ }
7464
+ if (/not authorized to perform/.test(m) || /no identity-based policy/.test(m) || /bedrock:invoke/.test(m)) {
7465
+ 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.";
7466
+ }
7467
+ if (/access to the model|don.?t have access to the model/.test(m)) {
7468
+ 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.";
7469
+ }
7470
+ if (/access ?denied/.test(m)) {
7471
+ 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.";
7472
+ }
7473
+ if (/region/.test(m)) {
7474
+ return "No AWS region set for Bedrock. Set the Region in AI settings, or AWS_REGION in your environment.";
7475
+ }
7476
+ }
7477
+ const keyEnv = KEY_ENV[provider];
7478
+ if (keyEnv && /api key|unauthorized|\b401\b|authentication|incorrect api key|invalid x-api-key/.test(m)) {
7479
+ return `${provider} rejected the request \u2014 check ${keyEnv}. Set it in your environment or a .env file in the directory you started glotfile from.`;
7480
+ }
7481
+ return raw;
7482
+ }
7483
+ var KEY_ENV;
7484
+ var init_explain_error = __esm({
7485
+ "src/server/ai/explain-error.ts"() {
7486
+ "use strict";
7487
+ KEY_ENV = {
7488
+ anthropic: "ANTHROPIC_API_KEY",
7489
+ openai: "OPENAI_API_KEY",
7490
+ openrouter: "OPENROUTER_API_KEY"
7491
+ };
7492
+ }
7493
+ });
7494
+
7095
7495
  // src/server/ui-prefs.ts
7096
- import { readFileSync as readFileSync23 } from "fs";
7097
- import { homedir } from "os";
7098
- import { join as join17 } from "path";
7496
+ import { readFileSync as readFileSync24 } from "fs";
7497
+ import { homedir as homedir2 } from "os";
7498
+ import { join as join18 } from "path";
7099
7499
  function readJson2(path) {
7100
7500
  try {
7101
- const parsed = JSON.parse(readFileSync23(path, "utf8"));
7501
+ const parsed = JSON.parse(readFileSync24(path, "utf8"));
7102
7502
  return parsed && typeof parsed === "object" ? parsed : {};
7103
7503
  } catch {
7104
7504
  return {};
@@ -7123,7 +7523,7 @@ var init_ui_prefs = __esm({
7123
7523
  THEMES = ["system", "light", "dark"];
7124
7524
  isThemeMode = (v) => THEMES.includes(v);
7125
7525
  isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
7126
- defaultUiPrefsPath = () => join17(homedir(), ".glotfile", "ui.json");
7526
+ defaultUiPrefsPath = () => join18(homedir2(), ".glotfile", "ui.json");
7127
7527
  DEFAULTS = { theme: "system" };
7128
7528
  }
7129
7529
  });
@@ -7157,7 +7557,7 @@ var init_events = __esm({
7157
7557
 
7158
7558
  // src/server/watch.ts
7159
7559
  import { statSync as statSync9, readdirSync as readdirSync15 } from "fs";
7160
- import { join as join18 } from "path";
7560
+ import { join as join19 } from "path";
7161
7561
  import { createHash as createHash2 } from "crypto";
7162
7562
  function hashState(state) {
7163
7563
  return createHash2("sha1").update(serializeJson(state, state.config.format)).digest("hex");
@@ -7173,15 +7573,15 @@ function signature(statePath) {
7173
7573
  const parts = [];
7174
7574
  for (const rel of ["config.json", "keys.json"]) {
7175
7575
  try {
7176
- const s = statSync9(join18(dir, rel));
7576
+ const s = statSync9(join19(dir, rel));
7177
7577
  parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
7178
7578
  } catch {
7179
7579
  }
7180
7580
  }
7181
7581
  try {
7182
- for (const name of readdirSync15(join18(dir, "locales")).sort()) {
7582
+ for (const name of readdirSync15(join19(dir, "locales")).sort()) {
7183
7583
  if (!name.endsWith(".json")) continue;
7184
- const s = statSync9(join18(dir, "locales", name));
7584
+ const s = statSync9(join19(dir, "locales", name));
7185
7585
  parts.push(`${name}:${s.size}:${s.mtimeMs}`);
7186
7586
  }
7187
7587
  } catch {
@@ -7260,34 +7660,19 @@ var init_watch = __esm({
7260
7660
  // src/server/api.ts
7261
7661
  import { Hono } from "hono";
7262
7662
  import { streamSSE } from "hono/streaming";
7263
- import { readFileSync as readFileSync24, existsSync as existsSync13, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync6 } from "fs";
7663
+ import { readFileSync as readFileSync25, existsSync as existsSync13, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync6 } from "fs";
7264
7664
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
7265
7665
  function projectName(root) {
7266
7666
  const nameFile = resolve9(root, ".idea", ".name");
7267
7667
  if (existsSync13(nameFile)) {
7268
7668
  try {
7269
- const name = readFileSync24(nameFile, "utf8").trim();
7669
+ const name = readFileSync25(nameFile, "utf8").trim();
7270
7670
  if (name) return name;
7271
7671
  } catch {
7272
7672
  }
7273
7673
  }
7274
7674
  return basename(root);
7275
7675
  }
7276
- function attachUsageSnippets(targets, cache2, projectRoot) {
7277
- const fileCache = /* @__PURE__ */ new Map();
7278
- for (const target of targets) {
7279
- const allRefs = Object.entries(cache2.files).flatMap(
7280
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
7281
- key: r.key,
7282
- file,
7283
- line: r.line,
7284
- col: r.col,
7285
- scanner: r.scanner
7286
- }))
7287
- );
7288
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
7289
- }
7290
- }
7291
7676
  function createApi(deps) {
7292
7677
  const app = new Hono();
7293
7678
  const load = () => loadState(deps.statePath);
@@ -7419,6 +7804,61 @@ function createApi(deps) {
7419
7804
  }
7420
7805
  return c.json({ ok: true });
7421
7806
  });
7807
+ app.post("/ai-test", async (c) => {
7808
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7809
+ const meta = { provider: aiCfg.provider, model: aiCfg.model };
7810
+ let provider;
7811
+ try {
7812
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7813
+ } catch (e) {
7814
+ return c.json({ ok: false, ...meta, error: explainProviderError(aiCfg.provider, e) });
7815
+ }
7816
+ const controller = new AbortController();
7817
+ const timer = setTimeout(() => controller.abort(), 3e4);
7818
+ try {
7819
+ const probe = {
7820
+ id: "probe",
7821
+ key: "glotfile.connection-test",
7822
+ source: "Hello",
7823
+ sourceLocale: "en",
7824
+ targetLocale: "es",
7825
+ placeholders: []
7826
+ };
7827
+ await provider.translate([probe], void 0, controller.signal);
7828
+ return c.json({ ok: true, ...meta });
7829
+ } catch (e) {
7830
+ const error = controller.signal.aborted ? "Connection test timed out after 30s \u2014 the provider didn't respond." : explainProviderError(aiCfg.provider, e);
7831
+ return c.json({ ok: false, ...meta, error });
7832
+ } finally {
7833
+ clearTimeout(timer);
7834
+ }
7835
+ });
7836
+ app.get("/prices", (c) => {
7837
+ const cache2 = loadPriceCache();
7838
+ const ai = loadLocalSettings(projectRoot).ai;
7839
+ const pricing = resolvePricing(ai, cache2);
7840
+ return c.json({
7841
+ source: cache2?.source ?? null,
7842
+ fetchedAt: cache2?.fetchedAt ?? null,
7843
+ modelCount: cache2 ? Object.keys(cache2.models).length : 0,
7844
+ path: defaultPriceCachePath(),
7845
+ resolved: pricing ? { provider: ai.provider, model: ai.model, ...pricing } : null
7846
+ });
7847
+ });
7848
+ app.get("/prices/list", (c) => {
7849
+ const cache2 = loadPriceCache();
7850
+ const models = cache2 ? Object.entries(cache2.models).map(([id, p]) => ({ id, ...p })).sort((a, b) => a.id.localeCompare(b.id)) : [];
7851
+ return c.json({ source: cache2?.source ?? null, fetchedAt: cache2?.fetchedAt ?? null, models });
7852
+ });
7853
+ app.post("/prices/refresh", async (c) => {
7854
+ try {
7855
+ const res = await refreshPrices();
7856
+ invalidatePriceCache();
7857
+ return c.json({ ok: true, ...res });
7858
+ } catch (e) {
7859
+ return c.json({ error: e.message }, 502);
7860
+ }
7861
+ });
7422
7862
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
7423
7863
  app.get("/files", (c) => {
7424
7864
  const found = /* @__PURE__ */ new Map();
@@ -7783,6 +8223,90 @@ function createApi(deps) {
7783
8223
  logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
7784
8224
  return c.json({ ok: true });
7785
8225
  });
8226
+ app.get("/glossary/suggestions", (c) => {
8227
+ const s = load();
8228
+ const pending = s.glossarySuggestions.filter((x) => x.status === "pending");
8229
+ return c.json(pending.map((x) => ({
8230
+ ...x,
8231
+ occurrences: sourceKeysForTerm(s, x.term, { caseSensitive: x.caseSensitive, wholeWord: x.wholeWord }).length
8232
+ })));
8233
+ });
8234
+ app.post("/glossary/suggestions/dismiss", async (c) => {
8235
+ const { term } = await c.req.json();
8236
+ if (typeof term !== "string") return c.json({ error: "term must be a string" }, 400);
8237
+ const s = load();
8238
+ dismissGlossarySuggestion(s, term);
8239
+ persist(s);
8240
+ logChange({ kind: "glossary", summary: `Dismissed suggested term "${term}"` });
8241
+ return c.json({ ok: true });
8242
+ });
8243
+ app.delete("/glossary/suggestions/:term", (c) => {
8244
+ const s = load();
8245
+ const term = decodeURIComponent(c.req.param("term"));
8246
+ removeGlossarySuggestion(s, term);
8247
+ persist(s);
8248
+ return c.json({ ok: true });
8249
+ });
8250
+ app.post("/glossary/suggest", async (c) => {
8251
+ const signal = c.req.raw.signal;
8252
+ const body = await c.req.json().catch(() => ({}));
8253
+ return streamSSE(c, async (stream) => {
8254
+ const s0 = load();
8255
+ const sources = selectGlossarySources(s0, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
8256
+ if (!sources.length) {
8257
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: 0, terms: [] }) });
8258
+ return;
8259
+ }
8260
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8261
+ let provider;
8262
+ try {
8263
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8264
+ } catch (e) {
8265
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
8266
+ return;
8267
+ }
8268
+ const known = knownTermList(s0);
8269
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: sources.length }) });
8270
+ const system = buildGlossarySuggestSystemPrompt();
8271
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
8272
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
8273
+ const chunks = [];
8274
+ for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
8275
+ const all = [];
8276
+ let done = 0;
8277
+ let next = 0;
8278
+ async function worker() {
8279
+ while (next < chunks.length) {
8280
+ if (signal?.aborted) break;
8281
+ const chunkRows = chunks[next++];
8282
+ try {
8283
+ const raw = await provider.complete({ system, content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunkRows, known) }], schema: GLOSSARY_SUGGEST_SCHEMA });
8284
+ all.push(...raw.terms ?? []);
8285
+ } catch (e) {
8286
+ void stream.writeSSE({ event: "warn", data: JSON.stringify({ error: e.message }) });
8287
+ }
8288
+ done += chunkRows.length;
8289
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done, total: sources.length }) });
8290
+ }
8291
+ }
8292
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
8293
+ if (signal?.aborted) return;
8294
+ const fresh = load();
8295
+ const added = mergeGlossarySuggestions(fresh, dedupeTerms(all));
8296
+ const usage = provider.takeUsage?.();
8297
+ persist(fresh);
8298
+ appendLog(projectRoot, {
8299
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8300
+ kind: "glossary",
8301
+ summary: `Suggested ${added.length} glossary term(s)`,
8302
+ model: aiCfg.model,
8303
+ system,
8304
+ usage,
8305
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
8306
+ });
8307
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: added.length, terms: added }) });
8308
+ });
8309
+ });
7786
8310
  app.post("/keys/:key/screenshot", async (c) => {
7787
8311
  const key = c.req.param("key");
7788
8312
  const body = await c.req.parseBody();
@@ -7968,7 +8492,7 @@ function createApi(deps) {
7968
8492
  try {
7969
8493
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7970
8494
  } catch (e) {
7971
- await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
8495
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
7972
8496
  return;
7973
8497
  }
7974
8498
  const { skipped } = attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
@@ -7985,58 +8509,65 @@ function createApi(deps) {
7985
8509
  event: "start",
7986
8510
  data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
7987
8511
  });
7988
- await runLocaleParallel(reqs, provider, {
7989
- // Announce a language the moment a worker picks it up — this is the
7990
- // signal that "something is happening" during the long first LLM call.
7991
- onLocaleStart: (locale) => {
7992
- void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
7993
- },
7994
- onBatchComplete: (done, total, batchResults, locale) => {
7995
- const fresh = load();
7996
- const { written, errors } = applyResults(fresh, reqs, batchResults);
7997
- persist(fresh);
7998
- totalWritten += written;
7999
- allErrors.push(...errors);
8000
- const usage = provider.takeUsage?.();
8001
- appendLog(projectRoot, {
8002
- at: (/* @__PURE__ */ new Date()).toISOString(),
8003
- kind: "translate",
8004
- summary: `Translated ${batchResults.length} item(s)`,
8005
- model: aiCfg.model,
8006
- system,
8007
- items: batchResults.map((r) => {
8008
- const req = reqById.get(r.id);
8009
- 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 };
8010
- }),
8011
- results: batchResults,
8012
- usage,
8013
- estimatedCostUsd: usageCostUsd(usage, aiCfg)
8014
- });
8015
- const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
8016
- localeDone.set(locale, ld);
8017
- console.log(`[translate] ${done}/${total}`);
8018
- void stream.writeSSE({
8019
- event: "progress",
8020
- data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
8021
- });
8022
- },
8023
- onLocaleDone: (locale) => {
8024
- void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
8025
- },
8026
- // Record the raw reply so an unparseable model response is diagnosable
8027
- // from the activity log instead of vanishing into per-item errors.
8028
- onMalformedReply: (raw, batchSize, locale) => {
8029
- console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8030
- appendLog(projectRoot, {
8031
- at: (/* @__PURE__ */ new Date()).toISOString(),
8032
- kind: "translate",
8033
- summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8034
- model: aiCfg.model,
8035
- locale,
8036
- raw
8037
- });
8512
+ try {
8513
+ await runLocaleParallel(reqs, provider, {
8514
+ // Announce a language the moment a worker picks it up — this is the
8515
+ // signal that "something is happening" during the long first LLM call.
8516
+ onLocaleStart: (locale) => {
8517
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
8518
+ },
8519
+ onBatchComplete: (done, total, batchResults, locale) => {
8520
+ const fresh = load();
8521
+ const { written, errors } = applyResults(fresh, reqs, batchResults);
8522
+ persist(fresh);
8523
+ totalWritten += written;
8524
+ allErrors.push(...errors);
8525
+ const usage = provider.takeUsage?.();
8526
+ appendLog(projectRoot, {
8527
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8528
+ kind: "translate",
8529
+ summary: `Translated ${batchResults.length} item(s)`,
8530
+ model: aiCfg.model,
8531
+ system,
8532
+ items: batchResults.map((r) => {
8533
+ const req = reqById.get(r.id);
8534
+ 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 };
8535
+ }),
8536
+ results: batchResults,
8537
+ usage,
8538
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
8539
+ });
8540
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
8541
+ localeDone.set(locale, ld);
8542
+ console.log(`[translate] ${done}/${total}`);
8543
+ void stream.writeSSE({
8544
+ event: "progress",
8545
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
8546
+ });
8547
+ },
8548
+ onLocaleDone: (locale) => {
8549
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
8550
+ },
8551
+ // Record the raw reply so an unparseable model response is diagnosable
8552
+ // from the activity log instead of vanishing into per-item errors.
8553
+ onMalformedReply: (raw, batchSize, locale) => {
8554
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8555
+ appendLog(projectRoot, {
8556
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8557
+ kind: "translate",
8558
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8559
+ model: aiCfg.model,
8560
+ locale,
8561
+ raw
8562
+ });
8563
+ }
8564
+ }, aiCfg.concurrency, signal, aiCfg.batchSize);
8565
+ } catch (e) {
8566
+ if (!signal?.aborted) {
8567
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
8038
8568
  }
8039
- }, aiCfg.concurrency, signal, aiCfg.batchSize);
8569
+ return;
8570
+ }
8040
8571
  if (!signal?.aborted) {
8041
8572
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
8042
8573
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -8063,23 +8594,28 @@ function createApi(deps) {
8063
8594
  try {
8064
8595
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8065
8596
  } catch (e) {
8066
- return c.json({ error: e.message }, 400);
8597
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 400);
8067
8598
  }
8068
8599
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
8069
8600
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
8070
- const results = await runLocaleParallel(toTranslate, provider, {
8071
- onMalformedReply: (raw, batchSize, locale) => {
8072
- console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8073
- appendLog(projectRoot, {
8074
- at: (/* @__PURE__ */ new Date()).toISOString(),
8075
- kind: "translate",
8076
- summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8077
- model: aiCfg.model,
8078
- locale,
8079
- raw
8080
- });
8081
- }
8082
- }, aiCfg.concurrency, void 0, aiCfg.batchSize);
8601
+ let results;
8602
+ try {
8603
+ results = await runLocaleParallel(toTranslate, provider, {
8604
+ onMalformedReply: (raw, batchSize, locale) => {
8605
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8606
+ appendLog(projectRoot, {
8607
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8608
+ kind: "translate",
8609
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8610
+ model: aiCfg.model,
8611
+ locale,
8612
+ raw
8613
+ });
8614
+ }
8615
+ }, aiCfg.concurrency, void 0, aiCfg.batchSize);
8616
+ } catch (e) {
8617
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 502);
8618
+ }
8083
8619
  const latest = load();
8084
8620
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
8085
8621
  const usage = provider.takeUsage?.();
@@ -8350,6 +8886,22 @@ function createApi(deps) {
8350
8886
  await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
8351
8887
  });
8352
8888
  });
8889
+ app.post("/context/estimate", async (c) => {
8890
+ const body = await c.req.json().catch(() => ({}));
8891
+ const cache2 = loadUsageCache(projectRoot);
8892
+ if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
8893
+ const targets = selectContextTargets(load(), {
8894
+ all: body.all,
8895
+ keyGlob: body.keyGlob,
8896
+ limit: body.limit,
8897
+ since: body.since,
8898
+ keys: body.keys,
8899
+ force: body.force
8900
+ }, cache2, body.lastRunAt);
8901
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8902
+ attachUsageSnippets(targets, cache2, projectRoot);
8903
+ return c.json(estimateContext(targets, aiCfg));
8904
+ });
8353
8905
  app.get("/context/batch/status", async (c) => {
8354
8906
  const aiCfg = loadLocalSettings(projectRoot).ai;
8355
8907
  let supported = false;
@@ -8454,6 +9006,8 @@ var init_api = __esm({
8454
9006
  "src/server/api.ts"() {
8455
9007
  "use strict";
8456
9008
  init_state();
9009
+ init_glossary_suggest();
9010
+ init_glossary();
8457
9011
  init_accept();
8458
9012
  init_scan();
8459
9013
  init_scanner();
@@ -8467,12 +9021,15 @@ var init_api = __esm({
8467
9021
  init_ai();
8468
9022
  init_run();
8469
9023
  init_provider();
9024
+ init_explain_error();
8470
9025
  init_batch_run();
8471
9026
  init_pending_batch();
8472
9027
  init_context_batch_run();
8473
9028
  init_pending_context_batch();
8474
9029
  init_estimate();
8475
9030
  init_pricing();
9031
+ init_price_fetch();
9032
+ init_price_cache();
8476
9033
  init_log();
8477
9034
  init_schema();
8478
9035
  init_run3();
@@ -8497,7 +9054,7 @@ __export(server_exports, {
8497
9054
  import { Hono as Hono2 } from "hono";
8498
9055
  import { serve } from "@hono/node-server";
8499
9056
  import { fileURLToPath } from "url";
8500
- import { dirname as dirname4, join as join19, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
9057
+ import { dirname as dirname4, join as join20, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
8501
9058
  import { readFile, stat } from "fs/promises";
8502
9059
  import { createServer } from "net";
8503
9060
  import open from "open";
@@ -8553,7 +9110,7 @@ function buildApp(opts) {
8553
9110
  const file = await readFileResponse(target);
8554
9111
  if (file) return file;
8555
9112
  }
8556
- const index = await readFileResponse(join19(root, "index.html"));
9113
+ const index = await readFileResponse(join20(root, "index.html"));
8557
9114
  if (index) return index;
8558
9115
  return c.notFound();
8559
9116
  });
@@ -8623,7 +9180,7 @@ var init_server = __esm({
8623
9180
  init_scanner();
8624
9181
  init_usage();
8625
9182
  here = dirname4(fileURLToPath(import.meta.url));
8626
- DEFAULT_UI_DIR = join19(here, "..", "ui");
9183
+ DEFAULT_UI_DIR = join20(here, "..", "ui");
8627
9184
  MIME = {
8628
9185
  ".html": "text/html; charset=utf-8",
8629
9186
  ".js": "text/javascript; charset=utf-8",
@@ -8655,8 +9212,8 @@ var init_server = __esm({
8655
9212
  // src/server/cli.ts
8656
9213
  init_state();
8657
9214
  init_stats();
8658
- import { resolve as resolve11, dirname as dirname5, join as join20, basename as basename2 } from "path";
8659
- import { readFileSync as readFileSync25, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
9215
+ import { resolve as resolve11, dirname as dirname5, join as join21, basename as basename2 } from "path";
9216
+ import { readFileSync as readFileSync26, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
8660
9217
  import { fileURLToPath as fileURLToPath2 } from "url";
8661
9218
 
8662
9219
  // src/server/agent-cli.ts
@@ -8788,13 +9345,17 @@ init_pending_batch();
8788
9345
  init_context_batch_run();
8789
9346
  init_pending_context_batch();
8790
9347
  init_estimate();
9348
+ init_glossary_suggest();
8791
9349
  init_pricing();
9350
+ init_price_fetch();
9351
+ init_price_cache();
8792
9352
  init_log();
8793
9353
  init_scan();
8794
9354
  init_scanner();
8795
9355
  init_usage();
8796
9356
  init_context();
8797
9357
  init_run2();
9358
+ init_registry();
8798
9359
  init_outputs();
8799
9360
 
8800
9361
  // src/server/lint/locate.ts
@@ -8871,7 +9432,7 @@ function formatSarif(report, ctx) {
8871
9432
  }
8872
9433
 
8873
9434
  // src/server/cli.ts
8874
- var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "sync", "build-context", "scan", "prune", "split", "skill", "batch", "get", "stats", "set", "set-state", "clear", "apply"];
9435
+ var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "sync", "build-context", "suggest-glossary", "scan", "prune", "split", "skill", "batch", "prices", "get", "stats", "set", "set-state", "clear", "apply"];
8875
9436
  var isCommand = (s) => s != null && COMMANDS.includes(s);
8876
9437
  function parseArgs(argv) {
8877
9438
  const statePath = resolve11(process.cwd(), "glotfile.json");
@@ -8947,6 +9508,7 @@ function parseArgs(argv) {
8947
9508
  else if (flag === "--batch") args.batch = true;
8948
9509
  else if (flag === "--wait") args.wait = true;
8949
9510
  else if (flag === "--print") args.print = true;
9511
+ else if (flag === "--refresh") args.refresh = true;
8950
9512
  else if (flag === "--state" && next) {
8951
9513
  args.states = next.split(",");
8952
9514
  i++;
@@ -9042,7 +9604,7 @@ function translateSelection(args) {
9042
9604
  }
9043
9605
  function readStdin() {
9044
9606
  try {
9045
- return readFileSync25(0, "utf8");
9607
+ return readFileSync26(0, "utf8");
9046
9608
  } catch {
9047
9609
  return "";
9048
9610
  }
@@ -9310,15 +9872,15 @@ async function runContextBatchAction(args, pending, action, projectRoot) {
9310
9872
  function sarifContextFor(statePath) {
9311
9873
  if (detectFormat(statePath) === "split") {
9312
9874
  const dir = splitDirFor(statePath);
9313
- const keysPath = join20(dir, "keys.json");
9875
+ const keysPath = join21(dir, "keys.json");
9314
9876
  return {
9315
9877
  keysUri: `${basename2(dir)}/keys.json`,
9316
- keysRawText: existsSync14(keysPath) ? readFileSync25(keysPath, "utf8") : ""
9878
+ keysRawText: existsSync14(keysPath) ? readFileSync26(keysPath, "utf8") : ""
9317
9879
  };
9318
9880
  }
9319
9881
  return {
9320
9882
  keysUri: basename2(statePath),
9321
- keysRawText: existsSync14(statePath) ? readFileSync25(statePath, "utf8") : ""
9883
+ keysRawText: existsSync14(statePath) ? readFileSync26(statePath, "utf8") : ""
9322
9884
  };
9323
9885
  }
9324
9886
  function printReport(report, format, statePath) {
@@ -9327,6 +9889,18 @@ function printReport(report, format, statePath) {
9327
9889
  else console.log(formatText(report).trimEnd());
9328
9890
  }
9329
9891
  async function runLintCmd(args) {
9892
+ if (args.ruleIds) {
9893
+ const unknown = unknownRuleIds(args.ruleIds);
9894
+ if (unknown.length > 0) {
9895
+ for (const id of unknown) {
9896
+ const hint = suggestRuleId(id);
9897
+ console.error(`Unknown --rule '${id}'.${hint ? ` Did you mean '${hint}'?` : ""}`);
9898
+ }
9899
+ console.error(`Valid rules: ${RULE_IDS.join(", ")}.`);
9900
+ process.exitCode = 1;
9901
+ return;
9902
+ }
9903
+ }
9330
9904
  const state = loadState(args.statePath);
9331
9905
  if (args.accept) {
9332
9906
  const { acceptFindings: acceptFindings2 } = await Promise.resolve().then(() => (init_accept(), accept_exports));
@@ -9468,29 +10042,30 @@ async function runBuildContext(args) {
9468
10042
  console.log("No keys need context.");
9469
10043
  return;
9470
10044
  }
10045
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10046
+ attachUsageSnippets(targets, cache2, projectRoot);
10047
+ if (args.estimate) {
10048
+ const est = estimateContext(targets, aiCfg);
10049
+ const fmt = (n) => n.toLocaleString("en-US");
10050
+ console.log(`Estimate for ${fmt(est.keys)} key(s) in ${fmt(est.batches)} batch(es) \u2014 ${aiCfg.provider} \xB7 ${aiCfg.model}`);
10051
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
10052
+ if (est.pricing) {
10053
+ const cost = est.estimatedCost;
10054
+ console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
10055
+ } else {
10056
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
10057
+ }
10058
+ return;
10059
+ }
9471
10060
  let provider;
9472
10061
  try {
9473
- provider = makeProvider(loadLocalSettings(projectRoot).ai);
10062
+ provider = makeProvider(aiCfg);
9474
10063
  } catch (e) {
9475
10064
  console.error(e.message);
9476
10065
  process.exitCode = 1;
9477
10066
  return;
9478
10067
  }
9479
- const fileCache = /* @__PURE__ */ new Map();
9480
- for (const target of targets) {
9481
- const refs = Object.values(cache2.files).flatMap(
9482
- (f) => f.refs.filter((r) => r.key === target.key).map((r) => ({
9483
- key: r.key,
9484
- file: Object.keys(cache2.files).find((path) => cache2.files[path]?.refs.includes(r)) ?? "",
9485
- line: r.line,
9486
- col: r.col,
9487
- scanner: r.scanner
9488
- }))
9489
- );
9490
- target.usageSnippets = extractSnippets(refs, projectRoot, fileCache);
9491
- }
9492
10068
  const system = buildContextSystemPrompt();
9493
- const aiCfg = loadLocalSettings(projectRoot).ai;
9494
10069
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
9495
10070
  const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
9496
10071
  if (args.batch) {
@@ -9546,6 +10121,71 @@ async function runBuildContext(args) {
9546
10121
  console.log(`Wrote context for ${written} key(s).`);
9547
10122
  for (const e of errors) console.warn(`skip ${e.key}: ${e.error}`);
9548
10123
  }
10124
+ async function runSuggestGlossary(args) {
10125
+ const state = loadState(args.statePath);
10126
+ const projectRoot = dirname5(resolve11(args.statePath));
10127
+ const sources = selectGlossarySources(state, { keyGlob: args.keyGlob, limit: args.limit, since: args.since });
10128
+ if (!sources.length) {
10129
+ console.log("No source strings to scan.");
10130
+ return;
10131
+ }
10132
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10133
+ const known = knownTermList(state);
10134
+ if (args.estimate) {
10135
+ const est = estimateGlossarySuggest(sources, known, aiCfg);
10136
+ const fmt = (n) => n.toLocaleString("en-US");
10137
+ console.log(`Estimate for ${fmt(est.sources)} source string(s) in ${fmt(est.batches)} batch(es) \u2014 ${aiCfg.provider} \xB7 ${aiCfg.model}`);
10138
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
10139
+ if (est.pricing) {
10140
+ const cost = est.estimatedCost;
10141
+ console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
10142
+ } else {
10143
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
10144
+ }
10145
+ return;
10146
+ }
10147
+ let provider;
10148
+ try {
10149
+ provider = makeProvider(aiCfg);
10150
+ } catch (e) {
10151
+ console.error(e.message);
10152
+ process.exitCode = 1;
10153
+ return;
10154
+ }
10155
+ const system = buildGlossarySuggestSystemPrompt();
10156
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
10157
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
10158
+ const chunks = [];
10159
+ for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
10160
+ const all = [];
10161
+ let done = 0;
10162
+ let next = 0;
10163
+ async function worker() {
10164
+ while (next < chunks.length) {
10165
+ const chunkRows = chunks[next++];
10166
+ try {
10167
+ const raw = await provider.complete({ system, content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunkRows, known) }], schema: GLOSSARY_SUGGEST_SCHEMA });
10168
+ const batch = raw;
10169
+ all.push(...batch.terms ?? []);
10170
+ } catch (e) {
10171
+ console.warn(`batch failed: ${e.message}`);
10172
+ }
10173
+ done += chunkRows.length;
10174
+ console.log(`[${done}/${sources.length}] scanned`);
10175
+ }
10176
+ }
10177
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
10178
+ const added = mergeGlossarySuggestions(state, dedupeTerms(all));
10179
+ saveState(args.statePath, state);
10180
+ appendLog(projectRoot, {
10181
+ at: (/* @__PURE__ */ new Date()).toISOString(),
10182
+ kind: "glossary",
10183
+ summary: `Suggested ${added.length} glossary term(s)`,
10184
+ model: aiCfg.model
10185
+ });
10186
+ console.log(`Found ${added.length} new candidate term(s). Review them in the glossary UI.`);
10187
+ for (const s of added) console.log(` \u2022 ${s.term}${s.note ? ` \u2014 ${s.note}` : ""}`);
10188
+ }
9549
10189
  async function runScanCmd(args) {
9550
10190
  const state = loadState(args.statePath);
9551
10191
  const projectRoot = dirname5(resolve11(args.statePath));
@@ -9619,10 +10259,10 @@ function runSplit(args) {
9619
10259
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
9620
10260
  );
9621
10261
  }
9622
- var SKILL_SRC = join20(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
10262
+ var SKILL_SRC = join21(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
9623
10263
  function runSkill(args) {
9624
10264
  if (args.print) {
9625
- console.log(readFileSync25(join20(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
10265
+ console.log(readFileSync26(join21(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
9626
10266
  return;
9627
10267
  }
9628
10268
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
@@ -9801,6 +10441,39 @@ function runApply(args) {
9801
10441
  console.log(JSON.stringify({ applied: r.applied, keysTouched: r.keysTouched, saved, dryRun: !!args.dryRun, errors: r.errors }, null, 2));
9802
10442
  if (r.errors.length) process.exitCode = 1;
9803
10443
  }
10444
+ async function runPrices(args) {
10445
+ const projectRoot = dirname5(resolve11(args.statePath));
10446
+ if (args.refresh) {
10447
+ try {
10448
+ const res = await refreshPrices();
10449
+ invalidatePriceCache();
10450
+ console.log(`Updated ${res.modelCount} model price(s) from ${res.source}.`);
10451
+ console.log(`Fetched ${new Date(res.fetchedAt).toLocaleString()} \u2192 ${res.path}`);
10452
+ } catch (e) {
10453
+ console.error(`Could not refresh prices: ${e.message}`);
10454
+ console.error("Existing cached prices (if any) are unchanged.");
10455
+ process.exitCode = 1;
10456
+ }
10457
+ return;
10458
+ }
10459
+ const cache2 = loadPriceCache();
10460
+ if (cache2) {
10461
+ const when = cache2.fetchedAt ? new Date(cache2.fetchedAt).toLocaleString() : "unknown time";
10462
+ console.log(`Price cache: ${Object.keys(cache2.models).length} model(s) from ${cache2.source}, fetched ${when}.`);
10463
+ console.log(`Location: ${defaultPriceCachePath()}`);
10464
+ } else {
10465
+ console.log("No price cache yet. Run `glotfile prices --refresh` to fetch the latest from models.dev.");
10466
+ }
10467
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10468
+ const pricing = resolvePricing(aiCfg, cache2);
10469
+ if (pricing) {
10470
+ console.log(`
10471
+ ${aiCfg.provider} \xB7 ${aiCfg.model}: $${pricing.inputPerMTok}/$${pricing.outputPerMTok} per MTok (${pricing.source}).`);
10472
+ } else {
10473
+ console.log(`
10474
+ No price known for ${aiCfg.provider} \xB7 ${aiCfg.model}. Set inputPricePerMTok/outputPricePerMTok in AI settings, or refresh.`);
10475
+ }
10476
+ }
9804
10477
  var GLOBAL_OPTS = [
9805
10478
  ["-f, --file <path>", "State file to use (default: ./glotfile.json)"],
9806
10479
  ["-h, --help", "Show this help"]
@@ -9879,12 +10552,24 @@ var COMMAND_HELP = {
9879
10552
  },
9880
10553
  "build-context": {
9881
10554
  summary: "AI-generate per-key context to improve translation (requires a prior scan).",
9882
- usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>] [--batch]",
10555
+ usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>] [--estimate] [--batch]",
9883
10556
  options: [
9884
10557
  ["--all", "(Re)build context for every key, not just those missing it"],
9885
10558
  ["--key <glob>", "Only keys matching this glob"],
9886
10559
  ["--limit <n>", "Process at most n keys"],
9887
- ["--since <date>", "Only keys added or changed since this date"]
10560
+ ["--since <date>", "Only keys added or changed since this date"],
10561
+ ["--estimate", "Print batches, tokens and estimated cost without building"],
10562
+ ["--batch", "Submit via the provider's batch API (50% cost, async; anthropic only)"]
10563
+ ]
10564
+ },
10565
+ "suggest-glossary": {
10566
+ summary: "AI-scan source strings for candidate glossary terms (adds a review queue; existing terms are skipped).",
10567
+ usage: "glotfile suggest-glossary [--key <glob>] [--limit <n>] [--since <date>] [--estimate]",
10568
+ options: [
10569
+ ["--key <glob>", "Only scan keys matching this glob"],
10570
+ ["--limit <n>", "Scan at most n source strings"],
10571
+ ["--since <date>", "Only keys added since this date"],
10572
+ ["--estimate", "Print batches, tokens and estimated cost without scanning"]
9888
10573
  ]
9889
10574
  },
9890
10575
  scan: {
@@ -9923,6 +10608,13 @@ var COMMAND_HELP = {
9923
10608
  ["cancel", "Cancel the pending batch and discard the handle"]
9924
10609
  ]
9925
10610
  },
10611
+ prices: {
10612
+ summary: "Show or refresh the model price cache used for cost estimates (models.dev).",
10613
+ usage: "glotfile prices [--refresh]",
10614
+ options: [
10615
+ ["--refresh", "Fetch the latest prices from models.dev into the cache (the only command that hits the network)"]
10616
+ ]
10617
+ },
9926
10618
  get: {
9927
10619
  summary: "Extract values from the catalog (filtered) without loading the whole file. Prints JSON.",
9928
10620
  usage: "glotfile get [<key-glob>\u2026] [--key <glob>] [--locale <list>] [--state <list>] [--fields <list>] [--keys-only] [--format json|ndjson]",
@@ -10012,8 +10704,8 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
10012
10704
  );
10013
10705
  }
10014
10706
  function printVersion() {
10015
- const pkgPath = join20(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
10016
- console.log(JSON.parse(readFileSync25(pkgPath, "utf8")).version);
10707
+ const pkgPath = join21(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
10708
+ console.log(JSON.parse(readFileSync26(pkgPath, "utf8")).version);
10017
10709
  }
10018
10710
  async function main(argv) {
10019
10711
  const args = parseArgs(argv);
@@ -10033,11 +10725,13 @@ async function main(argv) {
10033
10725
  if (args.command === "import") return runImportCmd(args);
10034
10726
  if (args.command === "sync") return runSyncCmd(args);
10035
10727
  if (args.command === "build-context") return runBuildContext(args);
10728
+ if (args.command === "suggest-glossary") return runSuggestGlossary(args);
10036
10729
  if (args.command === "scan") return runScanCmd(args);
10037
10730
  if (args.command === "prune") return runPrune(args);
10038
10731
  if (args.command === "split") return runSplit(args);
10039
10732
  if (args.command === "skill") return runSkill(args);
10040
10733
  if (args.command === "batch") return runBatch(args);
10734
+ if (args.command === "prices") return runPrices(args);
10041
10735
  if (args.command === "get") return runGetCmd(args);
10042
10736
  if (args.command === "stats") return runStatsCmd(args);
10043
10737
  if (args.command === "set") return runSet(args);