glotfile 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,245 @@ 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
+
4372
+ // src/server/ai/pending-glossary-batch.ts
4373
+ import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync5, rmSync as rmSync6 } from "fs";
4374
+ import { join as join6 } from "path";
4375
+ function pendingGlossaryBatchPath(projectRoot) {
4376
+ return join6(projectRoot, ".glotfile", "glossary-suggest-batch.json");
4377
+ }
4378
+ function loadPendingGlossaryBatch(projectRoot) {
4379
+ const path = pendingGlossaryBatchPath(projectRoot);
4380
+ if (!existsSync10(path)) return void 0;
4381
+ try {
4382
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
4383
+ if (parsed?.version !== 1) return void 0;
4384
+ return parsed;
4385
+ } catch {
4386
+ return void 0;
4387
+ }
4388
+ }
4389
+ function savePendingGlossaryBatch(projectRoot, pending) {
4390
+ const dir = join6(projectRoot, ".glotfile");
4391
+ mkdirSync6(dir, { recursive: true });
4392
+ const gitignore = join6(dir, ".gitignore");
4393
+ if (!existsSync10(gitignore)) writeFileSync5(gitignore, "*\n");
4394
+ writeFileSync5(pendingGlossaryBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4395
+ }
4396
+ function clearPendingGlossaryBatch(projectRoot) {
4397
+ rmSync6(pendingGlossaryBatchPath(projectRoot), { force: true });
4398
+ }
4399
+ var init_pending_glossary_batch = __esm({
4400
+ "src/server/ai/pending-glossary-batch.ts"() {
4401
+ "use strict";
4402
+ }
4403
+ });
4404
+
4405
+ // src/server/ai/glossary-batch-run.ts
4406
+ function completionRequestFor2(chunk2, knownTerms) {
4407
+ return {
4408
+ system: buildGlossarySuggestSystemPrompt(),
4409
+ content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunk2, knownTerms) }],
4410
+ schema: GLOSSARY_SUGGEST_SCHEMA
4411
+ };
4412
+ }
4413
+ async function submitGlossarySuggestBatch(provider, sources, knownTerms, batchSize, model, projectRoot) {
4414
+ if (loadPendingGlossaryBatch(projectRoot)) {
4415
+ throw new Error("A glossary suggestion batch is already pending. Apply or cancel it first.");
4416
+ }
4417
+ const chunks = [];
4418
+ const size = Math.max(1, batchSize);
4419
+ for (let i = 0; i < sources.length; i += size) chunks.push(sources.slice(i, i + size));
4420
+ const jobs = chunks.map((chunk2, i) => ({ customId: `gloss_${i}`, chunk: chunk2 }));
4421
+ const batchId = await provider.submitCompletionBatch(
4422
+ jobs.map((j) => ({ customId: j.customId, request: completionRequestFor2(j.chunk, knownTerms) }))
4423
+ );
4424
+ const pending = {
4425
+ version: 1,
4426
+ // Only Anthropic implements completion batches today.
4427
+ provider: "anthropic",
4428
+ model,
4429
+ batchId,
4430
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4431
+ total: sources.length,
4432
+ knownTerms,
4433
+ jobs: jobs.map((j) => ({
4434
+ customId: j.customId,
4435
+ requests: j.chunk
4436
+ }))
4437
+ };
4438
+ savePendingGlossaryBatch(projectRoot, pending);
4439
+ return pending;
4440
+ }
4441
+ async function applyGlossarySuggestBatchResults(load, persist, provider, pending, projectRoot, ai) {
4442
+ provider.takeUsage?.();
4443
+ const outcomes = await provider.completionBatchResults(pending.batchId);
4444
+ const batchUsage = provider.takeUsage?.();
4445
+ const allTerms = [];
4446
+ const errors = [];
4447
+ const jobFailures = [];
4448
+ const retryChunks = [];
4449
+ for (const job of pending.jobs) {
4450
+ const outcome = outcomes.get(job.customId);
4451
+ if (outcome?.type === "json") {
4452
+ const batch = outcome.value;
4453
+ allTerms.push(...batch.terms ?? []);
4454
+ continue;
4455
+ }
4456
+ if (!outcome) jobFailures.push({ customId: job.customId, locale: "", type: "missing" });
4457
+ else if (outcome.type === "malformed") jobFailures.push({ customId: job.customId, locale: "", type: "malformed", raw: outcome.raw });
4458
+ else jobFailures.push({ customId: job.customId, locale: "", type: "failed", error: outcome.error });
4459
+ retryChunks.push(job.requests);
4460
+ }
4461
+ for (const chunk2 of retryChunks) {
4462
+ try {
4463
+ const raw = await provider.complete(completionRequestFor2(chunk2, pending.knownTerms));
4464
+ const batch = raw;
4465
+ allTerms.push(...batch.terms ?? []);
4466
+ } catch (e) {
4467
+ errors.push({ error: e.message });
4468
+ }
4469
+ }
4470
+ const retryUsage = provider.takeUsage?.();
4471
+ const pricing = resolvePricing({ ...ai, model: pending.model });
4472
+ let estimatedCostUsd;
4473
+ if (pricing && (batchUsage || retryUsage)) {
4474
+ estimatedCostUsd = (batchUsage ? estimateUsageCostUsd(batchUsage, pricing, BATCH_PRICE_MULTIPLIER) : 0) + (retryUsage ? estimateUsageCostUsd(retryUsage, pricing) : 0);
4475
+ }
4476
+ let usage;
4477
+ if (batchUsage || retryUsage) {
4478
+ usage = batchUsage ?? { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
4479
+ if (retryUsage) addUsage(usage, retryUsage);
4480
+ }
4481
+ const fresh = load();
4482
+ const added = mergeGlossarySuggestions(fresh, dedupeTerms(allTerms));
4483
+ persist(fresh);
4484
+ clearPendingGlossaryBatch(projectRoot);
4485
+ const costSuffix = estimatedCostUsd !== void 0 ? ` (~$${estimatedCostUsd.toFixed(2)})` : "";
4486
+ appendLog(projectRoot, {
4487
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4488
+ kind: "glossary",
4489
+ summary: `Applied glossary suggestion batch ${pending.batchId}: ${added.length} new term(s), ${errors.length} error(s), ${retryChunks.length} job(s) retried${costSuffix}`,
4490
+ model: pending.model,
4491
+ jobFailures: jobFailures.length ? jobFailures : void 0,
4492
+ usage,
4493
+ estimatedCostUsd
4494
+ });
4495
+ return { added: added.length, errors, retried: retryChunks.length };
4496
+ }
4497
+ var init_glossary_batch_run = __esm({
4498
+ "src/server/ai/glossary-batch-run.ts"() {
4499
+ "use strict";
4500
+ init_glossary_suggest();
4501
+ init_pending_glossary_batch();
4502
+ init_state();
4503
+ init_log();
4504
+ init_pricing();
4505
+ }
4506
+ });
4507
+
4125
4508
  // src/server/ai/estimate.ts
4126
4509
  function estimateTokens(text) {
4127
4510
  const cjk = text.match(CJK_RE)?.length ?? 0;
@@ -4170,29 +4553,131 @@ function estimateTranslation(state, ai, opts) {
4170
4553
  estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4171
4554
  };
4172
4555
  }
4173
- var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
4556
+ function estimateContext(targets, ai) {
4557
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4558
+ const batches = chunk(targets, batchSize);
4559
+ const system = buildContextSystemPrompt();
4560
+ let inputTokens = 0;
4561
+ let outputTokens = 0;
4562
+ for (const batch of batches) {
4563
+ inputTokens += estimateTokens(system) + estimateTokens(buildContextBatchPrompt(batch));
4564
+ outputTokens += batch.length * (CONTEXT_REPLY_OVERHEAD + TYPICAL_CONTEXT_TOKENS);
4565
+ }
4566
+ const pricing = resolvePricing(ai);
4567
+ return {
4568
+ keys: targets.length,
4569
+ batches: batches.length,
4570
+ inputTokens,
4571
+ outputTokens,
4572
+ pricing,
4573
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4574
+ };
4575
+ }
4576
+ function estimateGlossarySuggest(sources, knownTerms, ai) {
4577
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4578
+ const batches = chunk(sources, batchSize);
4579
+ const system = buildGlossarySuggestSystemPrompt();
4580
+ let inputTokens = 0;
4581
+ let outputTokens = 0;
4582
+ for (const batch of batches) {
4583
+ inputTokens += estimateTokens(system) + estimateTokens(buildGlossarySuggestBatchPrompt(batch, knownTerms));
4584
+ outputTokens += Math.ceil(batch.length * TERM_YIELD) * TERM_REPLY_TOKENS;
4585
+ }
4586
+ const pricing = resolvePricing(ai);
4587
+ return {
4588
+ sources: sources.length,
4589
+ batches: batches.length,
4590
+ inputTokens,
4591
+ outputTokens,
4592
+ pricing,
4593
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4594
+ };
4595
+ }
4596
+ var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD, CONTEXT_REPLY_OVERHEAD, TYPICAL_CONTEXT_TOKENS, TERM_REPLY_TOKENS, TERM_YIELD;
4174
4597
  var init_estimate = __esm({
4175
4598
  "src/server/ai/estimate.ts"() {
4176
4599
  "use strict";
4177
4600
  init_run();
4178
4601
  init_provider();
4602
+ init_context();
4603
+ init_glossary_suggest();
4179
4604
  init_batch();
4180
4605
  init_pricing();
4181
4606
  CJK_RE = /[ -鿿가-힯豈-﫿]/g;
4182
4607
  EXPANSION = 1.2;
4183
4608
  ITEM_REPLY_OVERHEAD = 16;
4184
4609
  FORM_REPLY_OVERHEAD = 8;
4610
+ CONTEXT_REPLY_OVERHEAD = 16;
4611
+ TYPICAL_CONTEXT_TOKENS = 35;
4612
+ TERM_REPLY_TOKENS = 24;
4613
+ TERM_YIELD = 0.15;
4614
+ }
4615
+ });
4616
+
4617
+ // src/server/ai/price-fetch.ts
4618
+ function normalizeModelsDevPrices(api) {
4619
+ const out = {};
4620
+ const ranks = {};
4621
+ if (!api || typeof api !== "object") return out;
4622
+ for (const [provId, prov] of Object.entries(api)) {
4623
+ const models = prov?.models;
4624
+ if (!models || typeof models !== "object") continue;
4625
+ const rank = providerRank(provId);
4626
+ for (const [modelKey, model] of Object.entries(models)) {
4627
+ const cost = model?.cost;
4628
+ if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number") continue;
4629
+ const bareId = bareModelId(modelKey);
4630
+ if (!bareId) continue;
4631
+ const existingRank = ranks[bareId];
4632
+ if (existingRank !== void 0 && existingRank <= rank) continue;
4633
+ const price = { inputPerMTok: cost.input, outputPerMTok: cost.output };
4634
+ if (typeof cost.cache_read === "number") price.cacheReadPerMTok = cost.cache_read;
4635
+ if (typeof cost.cache_write === "number") price.cacheWritePerMTok = cost.cache_write;
4636
+ out[bareId] = price;
4637
+ ranks[bareId] = rank;
4638
+ }
4639
+ }
4640
+ return out;
4641
+ }
4642
+ async function refreshPrices(opts = {}) {
4643
+ const url = opts.url ?? priceUrl();
4644
+ const doFetch = opts.fetchImpl ?? fetch;
4645
+ const res = await doFetch(url);
4646
+ if (!res.ok) throw new Error(`Failed to fetch prices from ${url}: HTTP ${res.status}`);
4647
+ const api = await res.json();
4648
+ const models = normalizeModelsDevPrices(api);
4649
+ const modelCount = Object.keys(models).length;
4650
+ if (modelCount === 0) throw new Error(`No model prices found in response from ${url}`);
4651
+ const cache2 = { source: "models.dev", fetchedAt: (opts.now ?? defaultNow)(), models };
4652
+ const path = opts.path ?? defaultPriceCachePath();
4653
+ savePriceCache(cache2, path);
4654
+ return { source: cache2.source, fetchedAt: cache2.fetchedAt, modelCount, path };
4655
+ }
4656
+ var MODELS_DEV_URL, priceUrl, PROVIDER_PREFERENCE, providerRank, defaultNow;
4657
+ var init_price_fetch = __esm({
4658
+ "src/server/ai/price-fetch.ts"() {
4659
+ "use strict";
4660
+ init_pricing();
4661
+ init_price_cache();
4662
+ MODELS_DEV_URL = "https://models.dev/api.json";
4663
+ priceUrl = () => process.env.GLOTFILE_PRICES_URL || MODELS_DEV_URL;
4664
+ PROVIDER_PREFERENCE = ["anthropic", "openai", "google", "meta-llama", "mistral", "deepseek", "xai", "groq"];
4665
+ providerRank = (provId) => {
4666
+ const i = PROVIDER_PREFERENCE.indexOf(provId);
4667
+ return i === -1 ? PROVIDER_PREFERENCE.length : i;
4668
+ };
4669
+ defaultNow = () => (/* @__PURE__ */ new Date()).toISOString();
4185
4670
  }
4186
4671
  });
4187
4672
 
4188
4673
  // src/server/scan.ts
4189
- import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
4674
+ import { existsSync as existsSync11, readFileSync as readFileSync12 } from "fs";
4190
4675
  import { resolve as resolve7 } from "path";
4191
4676
  function loadUsageCache(projectRoot) {
4192
4677
  const path = resolve7(projectRoot, ".glotfile", "usage.json");
4193
- if (!existsSync10(path)) return null;
4678
+ if (!existsSync11(path)) return null;
4194
4679
  try {
4195
- return JSON.parse(readFileSync10(path, "utf8"));
4680
+ return JSON.parse(readFileSync12(path, "utf8"));
4196
4681
  } catch {
4197
4682
  return null;
4198
4683
  }
@@ -4257,8 +4742,8 @@ var init_scan = __esm({
4257
4742
  });
4258
4743
 
4259
4744
  // 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";
4745
+ import { readdirSync as readdirSync3, statSync as statSync3, readFileSync as readFileSync13 } from "fs";
4746
+ import { join as join7, extname as extname2, relative } from "path";
4262
4747
  function scannerForExt(ext) {
4263
4748
  return EXT_SCANNER[ext] ?? null;
4264
4749
  }
@@ -4480,7 +4965,7 @@ function* walkFiles(dir, root, exclude) {
4480
4965
  }
4481
4966
  for (const name of entries) {
4482
4967
  if (ALWAYS_EXCLUDE.has(name)) continue;
4483
- const abs = join5(dir, name);
4968
+ const abs = join7(dir, name);
4484
4969
  const rel = relative(root, abs);
4485
4970
  let st;
4486
4971
  try {
@@ -4510,7 +4995,7 @@ function runScan(projectRoot, opts, existing) {
4510
4995
  const ext = extname2(relPath);
4511
4996
  const scanner = scannerForExt(ext);
4512
4997
  if (!scanner) continue;
4513
- const abs = join5(projectRoot, relPath);
4998
+ const abs = join7(projectRoot, relPath);
4514
4999
  let st;
4515
5000
  try {
4516
5001
  st = statSync3(abs);
@@ -4526,7 +5011,7 @@ function runScan(projectRoot, opts, existing) {
4526
5011
  }
4527
5012
  let content;
4528
5013
  try {
4529
- content = readFileSync11(abs, "utf8");
5014
+ content = readFileSync13(abs, "utf8");
4530
5015
  } catch {
4531
5016
  continue;
4532
5017
  }
@@ -4660,8 +5145,8 @@ var init_scanner = __esm({
4660
5145
  });
4661
5146
 
4662
5147
  // 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";
5148
+ import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync14, statSync as statSync4 } from "fs";
5149
+ import { join as join8 } from "path";
4665
5150
  function safeIsDir(p) {
4666
5151
  try {
4667
5152
  return statSync4(p).isDirectory();
@@ -4670,7 +5155,7 @@ function safeIsDir(p) {
4670
5155
  }
4671
5156
  }
4672
5157
  function listDirs(dir) {
4673
- return readdirSync4(dir).filter((e) => safeIsDir(join6(dir, e)));
5158
+ return readdirSync4(dir).filter((e) => safeIsDir(join8(dir, e)));
4674
5159
  }
4675
5160
  function fileCount(dir) {
4676
5161
  try {
@@ -4684,23 +5169,23 @@ function pickSource(locales, sizeOf) {
4684
5169
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4685
5170
  }
4686
5171
  function detectLaravel(root) {
4687
- const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
5172
+ const localeRoot = [join8(root, "resources", "lang"), join8(root, "lang")].find(safeIsDir);
4688
5173
  if (!localeRoot) return null;
4689
5174
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4690
5175
  if (locales.length === 0) return null;
4691
- const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
5176
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join8(localeRoot, loc)));
4692
5177
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4693
5178
  }
4694
5179
  function detectVue(root, forced = false) {
4695
5180
  for (const rel of VUE_DIR_CANDIDATES) {
4696
- const localeRoot = join6(root, rel);
5181
+ const localeRoot = join8(root, rel);
4697
5182
  if (!safeIsDir(localeRoot)) continue;
4698
5183
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4699
5184
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4700
5185
  if (enough) {
4701
5186
  const sourceLocale = pickSource(locales, (loc) => {
4702
5187
  try {
4703
- return statSync4(join6(localeRoot, `${loc}.json`)).size;
5188
+ return statSync4(join8(localeRoot, `${loc}.json`)).size;
4704
5189
  } catch {
4705
5190
  return 0;
4706
5191
  }
@@ -4711,9 +5196,9 @@ function detectVue(root, forced = false) {
4711
5196
  return null;
4712
5197
  }
4713
5198
  function hasNextIntlSignal(root) {
4714
- if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync11(join6(root, rel)))) return true;
5199
+ if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync12(join8(root, rel)))) return true;
4715
5200
  try {
4716
- const pkg = JSON.parse(readFileSync12(join6(root, "package.json"), "utf8"));
5201
+ const pkg = JSON.parse(readFileSync14(join8(root, "package.json"), "utf8"));
4717
5202
  if (pkg.dependencies?.["next-intl"] || pkg.devDependencies?.["next-intl"]) return true;
4718
5203
  } catch {
4719
5204
  }
@@ -4722,7 +5207,7 @@ function hasNextIntlSignal(root) {
4722
5207
  function nextIntlDefaultLocale(root) {
4723
5208
  for (const rel of NEXT_INTL_ROUTING_CANDIDATES) {
4724
5209
  try {
4725
- const m = readFileSync12(join6(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
5210
+ const m = readFileSync14(join8(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
4726
5211
  if (m) return m[1];
4727
5212
  } catch {
4728
5213
  }
@@ -4732,14 +5217,14 @@ function nextIntlDefaultLocale(root) {
4732
5217
  function detectNextIntl(root, forced = false) {
4733
5218
  if (!forced && !hasNextIntlSignal(root)) return null;
4734
5219
  for (const rel of NEXT_INTL_DIR_CANDIDATES) {
4735
- const localeRoot = join6(root, rel);
5220
+ const localeRoot = join8(root, rel);
4736
5221
  if (!safeIsDir(localeRoot)) continue;
4737
5222
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4738
5223
  if (locales.length === 0) continue;
4739
5224
  const def = nextIntlDefaultLocale(root);
4740
5225
  const sourceLocale = def && locales.includes(def) ? def : pickSource(locales, (loc) => {
4741
5226
  try {
4742
- return statSync4(join6(localeRoot, `${loc}.json`)).size;
5227
+ return statSync4(join8(localeRoot, `${loc}.json`)).size;
4743
5228
  } catch {
4744
5229
  return 0;
4745
5230
  }
@@ -4750,7 +5235,7 @@ function detectNextIntl(root, forced = false) {
4750
5235
  }
4751
5236
  function detectArb(root) {
4752
5237
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4753
- const localeRoot = join6(root, rel);
5238
+ const localeRoot = join8(root, rel);
4754
5239
  if (!safeIsDir(localeRoot)) continue;
4755
5240
  const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4756
5241
  if (locales.length >= 1) {
@@ -4760,10 +5245,10 @@ function detectArb(root) {
4760
5245
  return null;
4761
5246
  }
4762
5247
  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")));
5248
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join8(dir, `${l}.lproj`, "Localizable.strings")));
4764
5249
  }
4765
5250
  function detectApple(root) {
4766
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5251
+ const candidates = [root, ...listDirs(root).map((d) => join8(root, d))];
4767
5252
  let best = null;
4768
5253
  for (const dir of candidates) {
4769
5254
  const locales = lprojLocales(dir);
@@ -4775,7 +5260,7 @@ function detectApple(root) {
4775
5260
  locales,
4776
5261
  sourceLocale: pickSource(locales, (loc) => {
4777
5262
  try {
4778
- return statSync4(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
5263
+ return statSync4(join8(dir, `${loc}.lproj`, "Localizable.strings")).size;
4779
5264
  } catch {
4780
5265
  return 0;
4781
5266
  }
@@ -4787,7 +5272,7 @@ function detectApple(root) {
4787
5272
  }
4788
5273
  function detectAngularXliff(root) {
4789
5274
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4790
- const localeRoot = rel === "." ? root : join6(root, rel);
5275
+ const localeRoot = rel === "." ? root : join8(root, rel);
4791
5276
  if (!safeIsDir(localeRoot)) continue;
4792
5277
  const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4793
5278
  if (files.length === 0) continue;
@@ -4795,7 +5280,7 @@ function detectAngularXliff(root) {
4795
5280
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4796
5281
  let sourceLocale;
4797
5282
  try {
4798
- sourceLocale = readFileSync12(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
5283
+ sourceLocale = readFileSync14(join8(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4799
5284
  } catch {
4800
5285
  }
4801
5286
  if (!sourceLocale && locales.length === 0) continue;
@@ -4806,14 +5291,14 @@ function detectAngularXliff(root) {
4806
5291
  return null;
4807
5292
  }
4808
5293
  function detectRails(root) {
4809
- const localeRoot = join6(root, "config", "locales");
5294
+ const localeRoot = join8(root, "config", "locales");
4810
5295
  if (!safeIsDir(localeRoot)) return null;
4811
5296
  const locales = [];
4812
5297
  for (const file of readdirSync4(localeRoot).sort()) {
4813
5298
  if (!/\.ya?ml$/.test(file)) continue;
4814
5299
  let text;
4815
5300
  try {
4816
- text = readFileSync12(join6(localeRoot, file), "utf8");
5301
+ text = readFileSync14(join8(localeRoot, file), "utf8");
4817
5302
  } catch {
4818
5303
  continue;
4819
5304
  }
@@ -4827,15 +5312,15 @@ function detectRails(root) {
4827
5312
  }
4828
5313
  function detectI18next(root) {
4829
5314
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4830
- const localeRoot = join6(root, rel);
5315
+ const localeRoot = join8(root, rel);
4831
5316
  if (!safeIsDir(localeRoot)) continue;
4832
5317
  const locales = listDirs(localeRoot).filter(
4833
- (d) => LOCALE_RE.test(d) && readdirSync4(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
5318
+ (d) => LOCALE_RE.test(d) && readdirSync4(join8(localeRoot, d)).some((f) => f.endsWith(".json"))
4834
5319
  );
4835
5320
  if (locales.length === 0) continue;
4836
5321
  const sourceLocale = pickSource(locales, (loc) => {
4837
5322
  try {
4838
- return readdirSync4(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join6(localeRoot, loc, f)).size, 0);
5323
+ return readdirSync4(join8(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join8(localeRoot, loc, f)).size, 0);
4839
5324
  } catch {
4840
5325
  return 0;
4841
5326
  }
@@ -4852,8 +5337,8 @@ function gettextLocales(dir) {
4852
5337
  if (!locales.includes(flat)) locales.push(flat);
4853
5338
  continue;
4854
5339
  }
4855
- if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4856
- const sub = join6(dir, entry);
5340
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join8(dir, entry))) continue;
5341
+ const sub = join8(dir, entry);
4857
5342
  const hasPo = (d) => {
4858
5343
  try {
4859
5344
  return readdirSync4(d).some((f) => f.endsWith(".po"));
@@ -4861,7 +5346,7 @@ function gettextLocales(dir) {
4861
5346
  return false;
4862
5347
  }
4863
5348
  };
4864
- if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
5349
+ if (hasPo(join8(sub, "LC_MESSAGES")) || hasPo(sub)) {
4865
5350
  if (!locales.includes(entry)) locales.push(entry);
4866
5351
  }
4867
5352
  }
@@ -4869,7 +5354,7 @@ function gettextLocales(dir) {
4869
5354
  }
4870
5355
  function detectGettext(root) {
4871
5356
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4872
- const localeRoot = join6(root, rel);
5357
+ const localeRoot = join8(root, rel);
4873
5358
  if (!safeIsDir(localeRoot)) continue;
4874
5359
  const locales = gettextLocales(localeRoot);
4875
5360
  if (locales.length === 0) continue;
@@ -4878,10 +5363,10 @@ function detectGettext(root) {
4878
5363
  return null;
4879
5364
  }
4880
5365
  function detectAppleStringsdict(root) {
4881
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5366
+ const candidates = [root, ...listDirs(root).map((d) => join8(root, d))];
4882
5367
  let best = null;
4883
5368
  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")));
5369
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join8(dir, `${l}.lproj`, "Localizable.stringsdict")));
4885
5370
  if (locales.length === 0) continue;
4886
5371
  if (!best || locales.length > best.locales.length) {
4887
5372
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4890,7 +5375,7 @@ function detectAppleStringsdict(root) {
4890
5375
  return best;
4891
5376
  }
4892
5377
  function detect(root, formatOverride) {
4893
- if (!existsSync11(root)) return null;
5378
+ if (!existsSync12(root)) return null;
4894
5379
  if (formatOverride) {
4895
5380
  const fn = BY_FORMAT[formatOverride];
4896
5381
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -4968,8 +5453,8 @@ var init_flatten = __esm({
4968
5453
  });
4969
5454
 
4970
5455
  // 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";
5456
+ import { readdirSync as readdirSync5, readFileSync as readFileSync15 } from "fs";
5457
+ import { join as join9 } from "path";
4973
5458
  function fromVueI18n(value) {
4974
5459
  return value.replace(/\{'([^']*)'\}/g, "'$1'");
4975
5460
  }
@@ -4992,7 +5477,7 @@ var init_vue_i18n_json2 = __esm({
4992
5477
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4993
5478
  let data;
4994
5479
  try {
4995
- data = JSON.parse(readFileSync13(join7(localeRoot, file), "utf8"));
5480
+ data = JSON.parse(readFileSync15(join9(localeRoot, file), "utf8"));
4996
5481
  } catch (e) {
4997
5482
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4998
5483
  continue;
@@ -5009,8 +5494,8 @@ var init_vue_i18n_json2 = __esm({
5009
5494
  });
5010
5495
 
5011
5496
  // 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";
5497
+ import { readdirSync as readdirSync6, readFileSync as readFileSync16 } from "fs";
5498
+ import { join as join10 } from "path";
5014
5499
  var LOCALE_RE3, nextIntlJson2;
5015
5500
  var init_next_intl_json2 = __esm({
5016
5501
  "src/server/import/parsers/next-intl-json.ts"() {
@@ -5030,7 +5515,7 @@ var init_next_intl_json2 = __esm({
5030
5515
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5031
5516
  let data;
5032
5517
  try {
5033
- data = JSON.parse(readFileSync14(join8(localeRoot, file), "utf8"));
5518
+ data = JSON.parse(readFileSync16(join10(localeRoot, file), "utf8"));
5034
5519
  } catch (e) {
5035
5520
  warnings.push(`next-intl-json: failed to parse ${file}: ${e.message}`);
5036
5521
  continue;
@@ -5064,16 +5549,16 @@ var init_placeholders2 = __esm({
5064
5549
 
5065
5550
  // src/server/import/parsers/laravel-php.ts
5066
5551
  import { readdirSync as readdirSync7, statSync as statSync5 } from "fs";
5067
- import { join as join9, relative as relative2 } from "path";
5552
+ import { join as join11, relative as relative2 } from "path";
5068
5553
  import { execFileSync } from "child_process";
5069
5554
  function listDirs2(dir) {
5070
- return readdirSync7(dir).filter((e) => statSync5(join9(dir, e)).isDirectory());
5555
+ return readdirSync7(dir).filter((e) => statSync5(join11(dir, e)).isDirectory());
5071
5556
  }
5072
5557
  function listPhpFiles(dir) {
5073
5558
  const out = [];
5074
5559
  const walk = (d) => {
5075
5560
  for (const e of readdirSync7(d)) {
5076
- const full = join9(d, e);
5561
+ const full = join11(d, e);
5077
5562
  if (statSync5(full).isDirectory()) walk(full);
5078
5563
  else if (e.endsWith(".php")) out.push(full);
5079
5564
  }
@@ -5116,7 +5601,7 @@ var init_laravel_php2 = __esm({
5116
5601
  for (const locale of listDirs2(localeRoot).sort()) {
5117
5602
  if (locale === "vendor") continue;
5118
5603
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5119
- const localeDir = join9(localeRoot, locale);
5604
+ const localeDir = join11(localeRoot, locale);
5120
5605
  locales.push(locale);
5121
5606
  for (const file of listPhpFiles(localeDir)) {
5122
5607
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -5141,8 +5626,8 @@ var init_laravel_php2 = __esm({
5141
5626
  });
5142
5627
 
5143
5628
  // src/server/import/parsers/flutter-arb.ts
5144
- import { readdirSync as readdirSync8, readFileSync as readFileSync15 } from "fs";
5145
- import { join as join10 } from "path";
5629
+ import { readdirSync as readdirSync8, readFileSync as readFileSync17 } from "fs";
5630
+ import { join as join12 } from "path";
5146
5631
  function localeFromArbName(file) {
5147
5632
  const m = file.match(/^(.+)\.arb$/);
5148
5633
  if (!m) return null;
@@ -5182,7 +5667,7 @@ var init_flutter_arb2 = __esm({
5182
5667
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5183
5668
  let data;
5184
5669
  try {
5185
- data = JSON.parse(readFileSync15(join10(localeRoot, file), "utf8"));
5670
+ data = JSON.parse(readFileSync17(join12(localeRoot, file), "utf8"));
5186
5671
  } catch (e) {
5187
5672
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
5188
5673
  continue;
@@ -5209,8 +5694,8 @@ var init_flutter_arb2 = __esm({
5209
5694
  });
5210
5695
 
5211
5696
  // 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";
5697
+ import { readdirSync as readdirSync9, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5698
+ import { join as join13 } from "path";
5214
5699
  function localeFromLproj(dir) {
5215
5700
  const m = dir.match(/^(.+)\.lproj$/);
5216
5701
  if (!m) return null;
@@ -5330,16 +5815,16 @@ var init_apple_strings2 = __esm({
5330
5815
  const locale = localeFromLproj(dir);
5331
5816
  if (!locale) continue;
5332
5817
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5333
- const file = join11(localeRoot, dir, TABLE);
5818
+ const file = join13(localeRoot, dir, TABLE);
5334
5819
  let text;
5335
5820
  try {
5336
5821
  if (!statSync6(file).isFile()) continue;
5337
- text = readFileSync16(file, "utf8");
5822
+ text = readFileSync18(file, "utf8");
5338
5823
  } catch {
5339
5824
  continue;
5340
5825
  }
5341
5826
  locales.push(locale);
5342
- const others = readdirSync9(join11(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5827
+ const others = readdirSync9(join13(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5343
5828
  if (others.length) {
5344
5829
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
5345
5830
  }
@@ -5354,8 +5839,8 @@ var init_apple_strings2 = __esm({
5354
5839
  });
5355
5840
 
5356
5841
  // src/server/import/parsers/angular-xliff.ts
5357
- import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
5358
- import { join as join12 } from "path";
5842
+ import { readdirSync as readdirSync10, readFileSync as readFileSync19 } from "fs";
5843
+ import { join as join14 } from "path";
5359
5844
  function decodeEntities(s) {
5360
5845
  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
5846
  }
@@ -5426,7 +5911,7 @@ var init_angular_xliff2 = __esm({
5426
5911
  if (fnameLocale !== void 0 && !LOCALE_RE6.test(fnameLocale)) continue;
5427
5912
  let xml;
5428
5913
  try {
5429
- xml = readFileSync17(join12(localeRoot, file), "utf8");
5914
+ xml = readFileSync19(join14(localeRoot, file), "utf8");
5430
5915
  } catch (e) {
5431
5916
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
5432
5917
  continue;
@@ -5473,8 +5958,8 @@ var init_angular_xliff2 = __esm({
5473
5958
  });
5474
5959
 
5475
5960
  // src/server/import/parsers/gettext-po.ts
5476
- import { readdirSync as readdirSync11, readFileSync as readFileSync18 } from "fs";
5477
- import { join as join13 } from "path";
5961
+ import { readdirSync as readdirSync11, readFileSync as readFileSync20 } from "fs";
5962
+ import { join as join15 } from "path";
5478
5963
  function unescapePo(s) {
5479
5964
  return s.replace(
5480
5965
  /\\([\\"ntr])/g,
@@ -5563,17 +6048,17 @@ function discoverPoFiles(root) {
5563
6048
  for (const e of entries) {
5564
6049
  if (e.isFile() && e.name.endsWith(".po")) {
5565
6050
  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 });
6051
+ found.push({ path: join15(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5567
6052
  } else if (e.isDirectory() && LOCALE_RE7.test(e.name)) {
5568
- for (const sub of [join13(e.name, "LC_MESSAGES"), e.name]) {
6053
+ for (const sub of [join15(e.name, "LC_MESSAGES"), e.name]) {
5569
6054
  let names;
5570
6055
  try {
5571
- names = readdirSync11(join13(root, sub)).sort();
6056
+ names = readdirSync11(join15(root, sub)).sort();
5572
6057
  } catch {
5573
6058
  continue;
5574
6059
  }
5575
6060
  for (const f of names) {
5576
- if (f.endsWith(".po")) found.push({ path: join13(root, sub, f), rel: join13(sub, f), locale: e.name });
6061
+ if (f.endsWith(".po")) found.push({ path: join15(root, sub, f), rel: join15(sub, f), locale: e.name });
5577
6062
  }
5578
6063
  }
5579
6064
  }
@@ -5597,7 +6082,7 @@ var init_gettext_po2 = __esm({
5597
6082
  for (const file of discoverPoFiles(localeRoot)) {
5598
6083
  let entries;
5599
6084
  try {
5600
- entries = parseEntries(readFileSync18(file.path, "utf8"));
6085
+ entries = parseEntries(readFileSync20(file.path, "utf8"));
5601
6086
  } catch (e) {
5602
6087
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5603
6088
  continue;
@@ -5644,8 +6129,8 @@ var init_gettext_po2 = __esm({
5644
6129
  });
5645
6130
 
5646
6131
  // 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";
6132
+ import { readdirSync as readdirSync12, readFileSync as readFileSync21, statSync as statSync7 } from "fs";
6133
+ import { join as join16 } from "path";
5649
6134
  function safeIsDir2(p) {
5650
6135
  try {
5651
6136
  return statSync7(p).isDirectory();
@@ -5660,7 +6145,7 @@ function fromI18next(value) {
5660
6145
  function ingestFile(path, label, prefix, locale, keys, warnings) {
5661
6146
  let data;
5662
6147
  try {
5663
- data = JSON.parse(readFileSync19(path, "utf8"));
6148
+ data = JSON.parse(readFileSync21(path, "utf8"));
5664
6149
  } catch (e) {
5665
6150
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5666
6151
  return false;
@@ -5713,7 +6198,7 @@ var init_i18next_json2 = __esm({
5713
6198
  const keys = {};
5714
6199
  const locales = [];
5715
6200
  for (const entry of readdirSync12(localeRoot).sort()) {
5716
- const full = join14(localeRoot, entry);
6201
+ const full = join16(localeRoot, entry);
5717
6202
  if (safeIsDir2(full)) {
5718
6203
  if (!LOCALE_RE8.test(entry)) continue;
5719
6204
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -5722,7 +6207,7 @@ var init_i18next_json2 = __esm({
5722
6207
  if (!file.endsWith(".json")) continue;
5723
6208
  const ns = file.slice(0, -".json".length);
5724
6209
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5725
- if (ingestFile(join14(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
6210
+ if (ingestFile(join16(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5726
6211
  }
5727
6212
  if (any && !locales.includes(entry)) locales.push(entry);
5728
6213
  } else if (entry.endsWith(".json")) {
@@ -5741,8 +6226,8 @@ var init_i18next_json2 = __esm({
5741
6226
  });
5742
6227
 
5743
6228
  // src/server/import/parsers/rails-yaml.ts
5744
- import { readdirSync as readdirSync13, readFileSync as readFileSync20 } from "fs";
5745
- import { join as join15 } from "path";
6229
+ import { readdirSync as readdirSync13, readFileSync as readFileSync22 } from "fs";
6230
+ import { join as join17 } from "path";
5746
6231
  function makeNode() {
5747
6232
  return /* @__PURE__ */ Object.create(null);
5748
6233
  }
@@ -5966,7 +6451,7 @@ var init_rails_yaml2 = __esm({
5966
6451
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5967
6452
  let text;
5968
6453
  try {
5969
- text = readFileSync20(join15(localeRoot, file), "utf8");
6454
+ text = readFileSync22(join17(localeRoot, file), "utf8");
5970
6455
  } catch (e) {
5971
6456
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5972
6457
  continue;
@@ -5989,8 +6474,8 @@ var init_rails_yaml2 = __esm({
5989
6474
  });
5990
6475
 
5991
6476
  // 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";
6477
+ import { readdirSync as readdirSync14, readFileSync as readFileSync23, statSync as statSync8 } from "fs";
6478
+ import { join as join18 } from "path";
5994
6479
  function localeFromLproj2(dir) {
5995
6480
  const m = dir.match(/^(.+)\.lproj$/);
5996
6481
  if (!m) return null;
@@ -6150,16 +6635,16 @@ var init_apple_stringsdict2 = __esm({
6150
6635
  const locale = localeFromLproj2(dir);
6151
6636
  if (!locale) continue;
6152
6637
  if (opts?.locales && !opts.locales.includes(locale)) continue;
6153
- const file = join16(localeRoot, dir, TABLE2);
6638
+ const file = join18(localeRoot, dir, TABLE2);
6154
6639
  let text;
6155
6640
  try {
6156
6641
  if (!statSync8(file).isFile()) continue;
6157
- text = readFileSync21(file, "utf8");
6642
+ text = readFileSync23(file, "utf8");
6158
6643
  } catch {
6159
6644
  continue;
6160
6645
  }
6161
6646
  locales.push(locale);
6162
- const others = readdirSync14(join16(localeRoot, dir)).filter(
6647
+ const others = readdirSync14(join18(localeRoot, dir)).filter(
6163
6648
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
6164
6649
  );
6165
6650
  if (others.length) {
@@ -6633,7 +7118,7 @@ var init_run2 = __esm({
6633
7118
  });
6634
7119
 
6635
7120
  // src/server/lint/outputs.ts
6636
- import { readFileSync as readFileSync22, existsSync as existsSync12 } from "fs";
7121
+ import { readFileSync as readFileSync24, existsSync as existsSync13 } from "fs";
6637
7122
  import { resolve as resolve8 } from "path";
6638
7123
  function checkOutputs(state, root) {
6639
7124
  const out = [];
@@ -6641,7 +7126,7 @@ function checkOutputs(state, root) {
6641
7126
  const result = getAdapter(output.adapter).export(state, output);
6642
7127
  for (const file of result.files) {
6643
7128
  const abs = resolve8(root, file.path);
6644
- const current = existsSync12(abs) ? readFileSync22(abs, "utf8") : null;
7129
+ const current = existsSync13(abs) ? readFileSync24(abs, "utf8") : null;
6645
7130
  if (current === null) {
6646
7131
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
6647
7132
  } else if (current !== file.contents) {
@@ -6749,6 +7234,7 @@ function assemble2(parsed, opts) {
6749
7234
  spelling: { customWords: [] }
6750
7235
  },
6751
7236
  glossary: [],
7237
+ glossarySuggestions: [],
6752
7238
  keys,
6753
7239
  warnings
6754
7240
  };
@@ -7092,24 +7578,74 @@ var init_checks = __esm({
7092
7578
  }
7093
7579
  });
7094
7580
 
7095
- // src/server/ui-prefs.ts
7096
- import { readFileSync as readFileSync23 } from "fs";
7097
- import { homedir } from "os";
7098
- import { join as join17 } from "path";
7099
- function readJson2(path) {
7100
- try {
7101
- const parsed = JSON.parse(readFileSync23(path, "utf8"));
7102
- return parsed && typeof parsed === "object" ? parsed : {};
7103
- } catch {
7104
- return {};
7581
+ // src/server/ai/explain-error.ts
7582
+ function rawMessage(err) {
7583
+ if (err instanceof Error && err.message) return err.message;
7584
+ if (typeof err === "string") return err;
7585
+ if (err && typeof err === "object" && "message" in err && typeof err.message === "string") {
7586
+ return err.message;
7105
7587
  }
7588
+ return String(err ?? "Unknown error");
7106
7589
  }
7107
- function loadUiPrefs(path) {
7108
- const raw = readJson2(path);
7109
- const prefs = { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
7110
- if (isPanelWidth(raw.keyColumnWidth)) prefs.keyColumnWidth = Math.round(raw.keyColumnWidth);
7111
- if (isPanelWidth(raw.detailPanelWidth)) prefs.detailPanelWidth = Math.round(raw.detailPanelWidth);
7112
- return prefs;
7590
+ function explainProviderError(provider, err) {
7591
+ const raw = rawMessage(err);
7592
+ const m = raw.toLowerCase();
7593
+ if (provider === "bedrock") {
7594
+ if (/could not load credentials|unable to locate credentials|credentialsprovider|credentials from any providers/.test(m)) {
7595
+ 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`.";
7596
+ }
7597
+ if (/on-demand throughput isn.?t supported/.test(m) || /inference profile/.test(m)) {
7598
+ 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).';
7599
+ }
7600
+ if (/not authorized to perform/.test(m) || /no identity-based policy/.test(m) || /bedrock:invoke/.test(m)) {
7601
+ 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.";
7602
+ }
7603
+ if (/access to the model|don.?t have access to the model/.test(m)) {
7604
+ 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.";
7605
+ }
7606
+ if (/access ?denied/.test(m)) {
7607
+ 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.";
7608
+ }
7609
+ if (/region/.test(m)) {
7610
+ return "No AWS region set for Bedrock. Set the Region in AI settings, or AWS_REGION in your environment.";
7611
+ }
7612
+ }
7613
+ const keyEnv = KEY_ENV[provider];
7614
+ if (keyEnv && /api key|unauthorized|\b401\b|authentication|incorrect api key|invalid x-api-key/.test(m)) {
7615
+ return `${provider} rejected the request \u2014 check ${keyEnv}. Set it in your environment or a .env file in the directory you started glotfile from.`;
7616
+ }
7617
+ return raw;
7618
+ }
7619
+ var KEY_ENV;
7620
+ var init_explain_error = __esm({
7621
+ "src/server/ai/explain-error.ts"() {
7622
+ "use strict";
7623
+ KEY_ENV = {
7624
+ anthropic: "ANTHROPIC_API_KEY",
7625
+ openai: "OPENAI_API_KEY",
7626
+ openrouter: "OPENROUTER_API_KEY"
7627
+ };
7628
+ }
7629
+ });
7630
+
7631
+ // src/server/ui-prefs.ts
7632
+ import { readFileSync as readFileSync25 } from "fs";
7633
+ import { homedir as homedir2 } from "os";
7634
+ import { join as join19 } from "path";
7635
+ function readJson2(path) {
7636
+ try {
7637
+ const parsed = JSON.parse(readFileSync25(path, "utf8"));
7638
+ return parsed && typeof parsed === "object" ? parsed : {};
7639
+ } catch {
7640
+ return {};
7641
+ }
7642
+ }
7643
+ function loadUiPrefs(path) {
7644
+ const raw = readJson2(path);
7645
+ const prefs = { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
7646
+ if (isPanelWidth(raw.keyColumnWidth)) prefs.keyColumnWidth = Math.round(raw.keyColumnWidth);
7647
+ if (isPanelWidth(raw.detailPanelWidth)) prefs.detailPanelWidth = Math.round(raw.detailPanelWidth);
7648
+ return prefs;
7113
7649
  }
7114
7650
  function saveUiPrefs(path, prefs) {
7115
7651
  const merged = { ...readJson2(path), ...prefs };
@@ -7123,7 +7659,7 @@ var init_ui_prefs = __esm({
7123
7659
  THEMES = ["system", "light", "dark"];
7124
7660
  isThemeMode = (v) => THEMES.includes(v);
7125
7661
  isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
7126
- defaultUiPrefsPath = () => join17(homedir(), ".glotfile", "ui.json");
7662
+ defaultUiPrefsPath = () => join19(homedir2(), ".glotfile", "ui.json");
7127
7663
  DEFAULTS = { theme: "system" };
7128
7664
  }
7129
7665
  });
@@ -7157,7 +7693,7 @@ var init_events = __esm({
7157
7693
 
7158
7694
  // src/server/watch.ts
7159
7695
  import { statSync as statSync9, readdirSync as readdirSync15 } from "fs";
7160
- import { join as join18 } from "path";
7696
+ import { join as join20 } from "path";
7161
7697
  import { createHash as createHash2 } from "crypto";
7162
7698
  function hashState(state) {
7163
7699
  return createHash2("sha1").update(serializeJson(state, state.config.format)).digest("hex");
@@ -7173,15 +7709,15 @@ function signature(statePath) {
7173
7709
  const parts = [];
7174
7710
  for (const rel of ["config.json", "keys.json"]) {
7175
7711
  try {
7176
- const s = statSync9(join18(dir, rel));
7712
+ const s = statSync9(join20(dir, rel));
7177
7713
  parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
7178
7714
  } catch {
7179
7715
  }
7180
7716
  }
7181
7717
  try {
7182
- for (const name of readdirSync15(join18(dir, "locales")).sort()) {
7718
+ for (const name of readdirSync15(join20(dir, "locales")).sort()) {
7183
7719
  if (!name.endsWith(".json")) continue;
7184
- const s = statSync9(join18(dir, "locales", name));
7720
+ const s = statSync9(join20(dir, "locales", name));
7185
7721
  parts.push(`${name}:${s.size}:${s.mtimeMs}`);
7186
7722
  }
7187
7723
  } catch {
@@ -7260,34 +7796,19 @@ var init_watch = __esm({
7260
7796
  // src/server/api.ts
7261
7797
  import { Hono } from "hono";
7262
7798
  import { streamSSE } from "hono/streaming";
7263
- import { readFileSync as readFileSync24, existsSync as existsSync13, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync6 } from "fs";
7799
+ import { readFileSync as readFileSync26, existsSync as existsSync14, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync7 } from "fs";
7264
7800
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
7265
7801
  function projectName(root) {
7266
7802
  const nameFile = resolve9(root, ".idea", ".name");
7267
- if (existsSync13(nameFile)) {
7803
+ if (existsSync14(nameFile)) {
7268
7804
  try {
7269
- const name = readFileSync24(nameFile, "utf8").trim();
7805
+ const name = readFileSync26(nameFile, "utf8").trim();
7270
7806
  if (name) return name;
7271
7807
  } catch {
7272
7808
  }
7273
7809
  }
7274
7810
  return basename(root);
7275
7811
  }
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
7812
  function createApi(deps) {
7292
7813
  const app = new Hono();
7293
7814
  const load = () => loadState(deps.statePath);
@@ -7419,6 +7940,61 @@ function createApi(deps) {
7419
7940
  }
7420
7941
  return c.json({ ok: true });
7421
7942
  });
7943
+ app.post("/ai-test", async (c) => {
7944
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7945
+ const meta = { provider: aiCfg.provider, model: aiCfg.model };
7946
+ let provider;
7947
+ try {
7948
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7949
+ } catch (e) {
7950
+ return c.json({ ok: false, ...meta, error: explainProviderError(aiCfg.provider, e) });
7951
+ }
7952
+ const controller = new AbortController();
7953
+ const timer = setTimeout(() => controller.abort(), 3e4);
7954
+ try {
7955
+ const probe = {
7956
+ id: "probe",
7957
+ key: "glotfile.connection-test",
7958
+ source: "Hello",
7959
+ sourceLocale: "en",
7960
+ targetLocale: "es",
7961
+ placeholders: []
7962
+ };
7963
+ await provider.translate([probe], void 0, controller.signal);
7964
+ return c.json({ ok: true, ...meta });
7965
+ } catch (e) {
7966
+ const error = controller.signal.aborted ? "Connection test timed out after 30s \u2014 the provider didn't respond." : explainProviderError(aiCfg.provider, e);
7967
+ return c.json({ ok: false, ...meta, error });
7968
+ } finally {
7969
+ clearTimeout(timer);
7970
+ }
7971
+ });
7972
+ app.get("/prices", (c) => {
7973
+ const cache2 = loadPriceCache();
7974
+ const ai = loadLocalSettings(projectRoot).ai;
7975
+ const pricing = resolvePricing(ai, cache2);
7976
+ return c.json({
7977
+ source: cache2?.source ?? null,
7978
+ fetchedAt: cache2?.fetchedAt ?? null,
7979
+ modelCount: cache2 ? Object.keys(cache2.models).length : 0,
7980
+ path: defaultPriceCachePath(),
7981
+ resolved: pricing ? { provider: ai.provider, model: ai.model, ...pricing } : null
7982
+ });
7983
+ });
7984
+ app.get("/prices/list", (c) => {
7985
+ const cache2 = loadPriceCache();
7986
+ const models = cache2 ? Object.entries(cache2.models).map(([id, p]) => ({ id, ...p })).sort((a, b) => a.id.localeCompare(b.id)) : [];
7987
+ return c.json({ source: cache2?.source ?? null, fetchedAt: cache2?.fetchedAt ?? null, models });
7988
+ });
7989
+ app.post("/prices/refresh", async (c) => {
7990
+ try {
7991
+ const res = await refreshPrices();
7992
+ invalidatePriceCache();
7993
+ return c.json({ ok: true, ...res });
7994
+ } catch (e) {
7995
+ return c.json({ error: e.message }, 502);
7996
+ }
7997
+ });
7422
7998
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
7423
7999
  app.get("/files", (c) => {
7424
8000
  const found = /* @__PURE__ */ new Map();
@@ -7440,7 +8016,7 @@ function createApi(deps) {
7440
8016
  if (name.startsWith(".") || name === "node_modules") continue;
7441
8017
  const abs = resolve9(dir, name);
7442
8018
  let filePath = null;
7443
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync13(resolve9(abs, "config.json"))) {
8019
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync14(resolve9(abs, "config.json"))) {
7444
8020
  filePath = resolve9(dir, `${name}.json`);
7445
8021
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
7446
8022
  filePath = abs;
@@ -7474,7 +8050,7 @@ function createApi(deps) {
7474
8050
  const resolved = resolve9(projectRoot, path);
7475
8051
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
7476
8052
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
7477
- if (!existsSync13(resolved)) return c.json({ error: "file not found" }, 400);
8053
+ if (!existsSync14(resolved)) return c.json({ error: "file not found" }, 400);
7478
8054
  loadState(resolved);
7479
8055
  deps.statePath = resolved;
7480
8056
  watcher.retarget(resolved);
@@ -7536,9 +8112,9 @@ function createApi(deps) {
7536
8112
  const abs = resolve9(root, screenshot);
7537
8113
  const rel = relative4(root, abs);
7538
8114
  const seg0 = rel.split(sep2)[0] ?? "";
7539
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync13(abs)) {
8115
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync14(abs)) {
7540
8116
  try {
7541
- rmSync6(abs);
8117
+ rmSync7(abs);
7542
8118
  } catch {
7543
8119
  }
7544
8120
  }
@@ -7783,6 +8359,177 @@ function createApi(deps) {
7783
8359
  logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
7784
8360
  return c.json({ ok: true });
7785
8361
  });
8362
+ app.get("/glossary/suggestions", (c) => {
8363
+ const s = load();
8364
+ const pending = s.glossarySuggestions.filter((x) => x.status === "pending");
8365
+ return c.json(pending.map((x) => ({
8366
+ ...x,
8367
+ occurrences: sourceKeysForTerm(s, x.term, { caseSensitive: x.caseSensitive, wholeWord: x.wholeWord }).length
8368
+ })));
8369
+ });
8370
+ app.post("/glossary/suggestions/dismiss", async (c) => {
8371
+ const { term } = await c.req.json();
8372
+ if (typeof term !== "string") return c.json({ error: "term must be a string" }, 400);
8373
+ const s = load();
8374
+ dismissGlossarySuggestion(s, term);
8375
+ persist(s);
8376
+ logChange({ kind: "glossary", summary: `Dismissed suggested term "${term}"` });
8377
+ return c.json({ ok: true });
8378
+ });
8379
+ app.delete("/glossary/suggestions/:term", (c) => {
8380
+ const s = load();
8381
+ const term = decodeURIComponent(c.req.param("term"));
8382
+ removeGlossarySuggestion(s, term);
8383
+ persist(s);
8384
+ return c.json({ ok: true });
8385
+ });
8386
+ app.post("/glossary/suggest", async (c) => {
8387
+ const signal = c.req.raw.signal;
8388
+ const body = await c.req.json().catch(() => ({}));
8389
+ return streamSSE(c, async (stream) => {
8390
+ const s0 = load();
8391
+ const sources = selectGlossarySources(s0, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
8392
+ if (!sources.length) {
8393
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: 0, terms: [] }) });
8394
+ return;
8395
+ }
8396
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8397
+ let provider;
8398
+ try {
8399
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8400
+ } catch (e) {
8401
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
8402
+ return;
8403
+ }
8404
+ const known = knownTermList(s0);
8405
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: sources.length }) });
8406
+ const system = buildGlossarySuggestSystemPrompt();
8407
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
8408
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
8409
+ const chunks = [];
8410
+ for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
8411
+ const all = [];
8412
+ let done = 0;
8413
+ let next = 0;
8414
+ async function worker() {
8415
+ while (next < chunks.length) {
8416
+ if (signal?.aborted) break;
8417
+ const chunkRows = chunks[next++];
8418
+ try {
8419
+ const raw = await provider.complete({ system, content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunkRows, known) }], schema: GLOSSARY_SUGGEST_SCHEMA });
8420
+ all.push(...raw.terms ?? []);
8421
+ } catch (e) {
8422
+ void stream.writeSSE({ event: "warn", data: JSON.stringify({ error: e.message }) });
8423
+ }
8424
+ done += chunkRows.length;
8425
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done, total: sources.length }) });
8426
+ }
8427
+ }
8428
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
8429
+ if (signal?.aborted) return;
8430
+ const fresh = load();
8431
+ const added = mergeGlossarySuggestions(fresh, dedupeTerms(all));
8432
+ const usage = provider.takeUsage?.();
8433
+ persist(fresh);
8434
+ appendLog(projectRoot, {
8435
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8436
+ kind: "glossary",
8437
+ summary: `Suggested ${added.length} glossary term(s)`,
8438
+ model: aiCfg.model,
8439
+ system,
8440
+ usage,
8441
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
8442
+ });
8443
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: added.length, terms: added }) });
8444
+ });
8445
+ });
8446
+ app.post("/glossary/suggest/estimate", async (c) => {
8447
+ const body = await c.req.json().catch(() => ({}));
8448
+ const s = load();
8449
+ const sources = selectGlossarySources(s, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
8450
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8451
+ return c.json(estimateGlossarySuggest(sources, knownTermList(s), aiCfg));
8452
+ });
8453
+ app.get("/glossary/suggest/batch/status", async (c) => {
8454
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8455
+ let supported = false;
8456
+ let provider;
8457
+ try {
8458
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8459
+ supported = supportsBatchComplete(provider);
8460
+ } catch {
8461
+ }
8462
+ const pending = loadPendingGlossaryBatch(projectRoot);
8463
+ if (!pending) return c.json({ supported, pending: null });
8464
+ const base = { batchId: pending.batchId, createdAt: pending.createdAt, model: pending.model, total: pending.total };
8465
+ if (!provider || !supportsBatchComplete(provider)) {
8466
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null } });
8467
+ }
8468
+ try {
8469
+ const status = await provider.translationBatchStatus(pending.batchId);
8470
+ return c.json({ supported, pending: { ...base, status: status.status, counts: status.counts } });
8471
+ } catch (e) {
8472
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null, error: e.message } });
8473
+ }
8474
+ });
8475
+ app.post("/glossary/suggest/batch", (c) => withTranslateLock(async () => {
8476
+ const body = await c.req.json().catch(() => ({}));
8477
+ const s = load();
8478
+ const sources = selectGlossarySources(s, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
8479
+ if (!sources.length) return c.json({ error: "No source strings to scan." }, 400);
8480
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8481
+ let provider;
8482
+ try {
8483
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8484
+ } catch (e) {
8485
+ return c.json({ error: e.message }, 400);
8486
+ }
8487
+ if (!supportsBatchComplete(provider)) {
8488
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
8489
+ }
8490
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
8491
+ let pending;
8492
+ try {
8493
+ pending = await submitGlossarySuggestBatch(provider, sources, knownTermList(s), batchSize, aiCfg.model, projectRoot);
8494
+ } catch (e) {
8495
+ return c.json({ error: e.message }, 409);
8496
+ }
8497
+ appendLog(projectRoot, {
8498
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8499
+ kind: "glossary",
8500
+ summary: `Submitted glossary suggestion batch ${pending.batchId} (${pending.total} sources)`,
8501
+ model: aiCfg.model
8502
+ });
8503
+ return c.json({ batchId: pending.batchId, total: pending.total });
8504
+ }));
8505
+ app.post("/glossary/suggest/batch/apply", (c) => withTranslateLock(async () => {
8506
+ const pending = loadPendingGlossaryBatch(projectRoot);
8507
+ if (!pending) return c.json({ error: "No pending glossary suggestion batch." }, 404);
8508
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8509
+ let provider;
8510
+ try {
8511
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8512
+ } catch (e) {
8513
+ return c.json({ error: e.message }, 400);
8514
+ }
8515
+ if (!supportsBatchComplete(provider)) {
8516
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
8517
+ }
8518
+ const outcome = await applyGlossarySuggestBatchResults(load, persist, provider, pending, projectRoot, aiCfg);
8519
+ return c.json(outcome);
8520
+ }));
8521
+ app.post("/glossary/suggest/batch/cancel", async (c) => {
8522
+ const pending = loadPendingGlossaryBatch(projectRoot);
8523
+ if (!pending) return c.json({ error: "No pending glossary suggestion batch." }, 404);
8524
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8525
+ try {
8526
+ const provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8527
+ if (supportsBatchComplete(provider)) await provider.cancelTranslationBatch(pending.batchId);
8528
+ } catch {
8529
+ }
8530
+ clearPendingGlossaryBatch(projectRoot);
8531
+ return c.json({ canceled: pending.batchId });
8532
+ });
7786
8533
  app.post("/keys/:key/screenshot", async (c) => {
7787
8534
  const key = c.req.param("key");
7788
8535
  const body = await c.req.parseBody();
@@ -7968,7 +8715,7 @@ function createApi(deps) {
7968
8715
  try {
7969
8716
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7970
8717
  } catch (e) {
7971
- await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
8718
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
7972
8719
  return;
7973
8720
  }
7974
8721
  const { skipped } = attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
@@ -7985,58 +8732,65 @@ function createApi(deps) {
7985
8732
  event: "start",
7986
8733
  data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
7987
8734
  });
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
- });
8735
+ try {
8736
+ await runLocaleParallel(reqs, provider, {
8737
+ // Announce a language the moment a worker picks it up — this is the
8738
+ // signal that "something is happening" during the long first LLM call.
8739
+ onLocaleStart: (locale) => {
8740
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
8741
+ },
8742
+ onBatchComplete: (done, total, batchResults, locale) => {
8743
+ const fresh = load();
8744
+ const { written, errors } = applyResults(fresh, reqs, batchResults);
8745
+ persist(fresh);
8746
+ totalWritten += written;
8747
+ allErrors.push(...errors);
8748
+ const usage = provider.takeUsage?.();
8749
+ appendLog(projectRoot, {
8750
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8751
+ kind: "translate",
8752
+ summary: `Translated ${batchResults.length} item(s)`,
8753
+ model: aiCfg.model,
8754
+ system,
8755
+ items: batchResults.map((r) => {
8756
+ const req = reqById.get(r.id);
8757
+ 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 };
8758
+ }),
8759
+ results: batchResults,
8760
+ usage,
8761
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
8762
+ });
8763
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
8764
+ localeDone.set(locale, ld);
8765
+ console.log(`[translate] ${done}/${total}`);
8766
+ void stream.writeSSE({
8767
+ event: "progress",
8768
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
8769
+ });
8770
+ },
8771
+ onLocaleDone: (locale) => {
8772
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
8773
+ },
8774
+ // Record the raw reply so an unparseable model response is diagnosable
8775
+ // from the activity log instead of vanishing into per-item errors.
8776
+ onMalformedReply: (raw, batchSize, locale) => {
8777
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8778
+ appendLog(projectRoot, {
8779
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8780
+ kind: "translate",
8781
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8782
+ model: aiCfg.model,
8783
+ locale,
8784
+ raw
8785
+ });
8786
+ }
8787
+ }, aiCfg.concurrency, signal, aiCfg.batchSize);
8788
+ } catch (e) {
8789
+ if (!signal?.aborted) {
8790
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
8038
8791
  }
8039
- }, aiCfg.concurrency, signal, aiCfg.batchSize);
8792
+ return;
8793
+ }
8040
8794
  if (!signal?.aborted) {
8041
8795
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
8042
8796
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -8063,23 +8817,28 @@ function createApi(deps) {
8063
8817
  try {
8064
8818
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8065
8819
  } catch (e) {
8066
- return c.json({ error: e.message }, 400);
8820
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 400);
8067
8821
  }
8068
8822
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
8069
8823
  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);
8824
+ let results;
8825
+ try {
8826
+ results = await runLocaleParallel(toTranslate, provider, {
8827
+ onMalformedReply: (raw, batchSize, locale) => {
8828
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
8829
+ appendLog(projectRoot, {
8830
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8831
+ kind: "translate",
8832
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
8833
+ model: aiCfg.model,
8834
+ locale,
8835
+ raw
8836
+ });
8837
+ }
8838
+ }, aiCfg.concurrency, void 0, aiCfg.batchSize);
8839
+ } catch (e) {
8840
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 502);
8841
+ }
8083
8842
  const latest = load();
8084
8843
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
8085
8844
  const usage = provider.takeUsage?.();
@@ -8350,6 +9109,22 @@ function createApi(deps) {
8350
9109
  await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
8351
9110
  });
8352
9111
  });
9112
+ app.post("/context/estimate", async (c) => {
9113
+ const body = await c.req.json().catch(() => ({}));
9114
+ const cache2 = loadUsageCache(projectRoot);
9115
+ if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
9116
+ const targets = selectContextTargets(load(), {
9117
+ all: body.all,
9118
+ keyGlob: body.keyGlob,
9119
+ limit: body.limit,
9120
+ since: body.since,
9121
+ keys: body.keys,
9122
+ force: body.force
9123
+ }, cache2, body.lastRunAt);
9124
+ const aiCfg = loadLocalSettings(projectRoot).ai;
9125
+ attachUsageSnippets(targets, cache2, projectRoot);
9126
+ return c.json(estimateContext(targets, aiCfg));
9127
+ });
8353
9128
  app.get("/context/batch/status", async (c) => {
8354
9129
  const aiCfg = loadLocalSettings(projectRoot).ai;
8355
9130
  let supported = false;
@@ -8454,6 +9229,8 @@ var init_api = __esm({
8454
9229
  "src/server/api.ts"() {
8455
9230
  "use strict";
8456
9231
  init_state();
9232
+ init_glossary_suggest();
9233
+ init_glossary();
8457
9234
  init_accept();
8458
9235
  init_scan();
8459
9236
  init_scanner();
@@ -8467,12 +9244,17 @@ var init_api = __esm({
8467
9244
  init_ai();
8468
9245
  init_run();
8469
9246
  init_provider();
9247
+ init_explain_error();
8470
9248
  init_batch_run();
8471
9249
  init_pending_batch();
8472
9250
  init_context_batch_run();
8473
9251
  init_pending_context_batch();
9252
+ init_glossary_batch_run();
9253
+ init_pending_glossary_batch();
8474
9254
  init_estimate();
8475
9255
  init_pricing();
9256
+ init_price_fetch();
9257
+ init_price_cache();
8476
9258
  init_log();
8477
9259
  init_schema();
8478
9260
  init_run3();
@@ -8497,7 +9279,7 @@ __export(server_exports, {
8497
9279
  import { Hono as Hono2 } from "hono";
8498
9280
  import { serve } from "@hono/node-server";
8499
9281
  import { fileURLToPath } from "url";
8500
- import { dirname as dirname4, join as join19, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
9282
+ import { dirname as dirname4, join as join21, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
8501
9283
  import { readFile, stat } from "fs/promises";
8502
9284
  import { createServer } from "net";
8503
9285
  import open from "open";
@@ -8553,7 +9335,7 @@ function buildApp(opts) {
8553
9335
  const file = await readFileResponse(target);
8554
9336
  if (file) return file;
8555
9337
  }
8556
- const index = await readFileResponse(join19(root, "index.html"));
9338
+ const index = await readFileResponse(join21(root, "index.html"));
8557
9339
  if (index) return index;
8558
9340
  return c.notFound();
8559
9341
  });
@@ -8623,7 +9405,7 @@ var init_server = __esm({
8623
9405
  init_scanner();
8624
9406
  init_usage();
8625
9407
  here = dirname4(fileURLToPath(import.meta.url));
8626
- DEFAULT_UI_DIR = join19(here, "..", "ui");
9408
+ DEFAULT_UI_DIR = join21(here, "..", "ui");
8627
9409
  MIME = {
8628
9410
  ".html": "text/html; charset=utf-8",
8629
9411
  ".js": "text/javascript; charset=utf-8",
@@ -8655,8 +9437,8 @@ var init_server = __esm({
8655
9437
  // src/server/cli.ts
8656
9438
  init_state();
8657
9439
  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";
9440
+ import { resolve as resolve11, dirname as dirname5, join as join22, basename as basename2 } from "path";
9441
+ import { readFileSync as readFileSync27, existsSync as existsSync15, mkdirSync as mkdirSync7, cpSync } from "fs";
8660
9442
  import { fileURLToPath as fileURLToPath2 } from "url";
8661
9443
 
8662
9444
  // src/server/agent-cli.ts
@@ -8787,14 +9569,20 @@ init_batch_run();
8787
9569
  init_pending_batch();
8788
9570
  init_context_batch_run();
8789
9571
  init_pending_context_batch();
9572
+ init_glossary_batch_run();
9573
+ init_pending_glossary_batch();
8790
9574
  init_estimate();
9575
+ init_glossary_suggest();
8791
9576
  init_pricing();
9577
+ init_price_fetch();
9578
+ init_price_cache();
8792
9579
  init_log();
8793
9580
  init_scan();
8794
9581
  init_scanner();
8795
9582
  init_usage();
8796
9583
  init_context();
8797
9584
  init_run2();
9585
+ init_registry();
8798
9586
  init_outputs();
8799
9587
 
8800
9588
  // src/server/lint/locate.ts
@@ -8871,7 +9659,7 @@ function formatSarif(report, ctx) {
8871
9659
  }
8872
9660
 
8873
9661
  // 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"];
9662
+ 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
9663
  var isCommand = (s) => s != null && COMMANDS.includes(s);
8876
9664
  function parseArgs(argv) {
8877
9665
  const statePath = resolve11(process.cwd(), "glotfile.json");
@@ -8947,6 +9735,7 @@ function parseArgs(argv) {
8947
9735
  else if (flag === "--batch") args.batch = true;
8948
9736
  else if (flag === "--wait") args.wait = true;
8949
9737
  else if (flag === "--print") args.print = true;
9738
+ else if (flag === "--refresh") args.refresh = true;
8950
9739
  else if (flag === "--state" && next) {
8951
9740
  args.states = next.split(",");
8952
9741
  i++;
@@ -9042,7 +9831,7 @@ function translateSelection(args) {
9042
9831
  }
9043
9832
  function readStdin() {
9044
9833
  try {
9045
- return readFileSync25(0, "utf8");
9834
+ return readFileSync27(0, "utf8");
9046
9835
  } catch {
9047
9836
  return "";
9048
9837
  }
@@ -9215,13 +10004,15 @@ async function runBatch(args) {
9215
10004
  const projectRoot = dirname5(resolve11(args.statePath));
9216
10005
  const pending = loadPendingBatch(projectRoot);
9217
10006
  const ctxPending = loadPendingContextBatch(projectRoot);
9218
- if (!pending && !ctxPending) {
9219
- console.log("No pending batch. Start one with `glotfile translate --batch` or `glotfile build-context --batch`.");
10007
+ const glossPending = loadPendingGlossaryBatch(projectRoot);
10008
+ if (!pending && !ctxPending && !glossPending) {
10009
+ console.log("No pending batch. Start one with `glotfile translate --batch`, `glotfile build-context --batch`, or `glotfile suggest-glossary --batch`.");
9220
10010
  return;
9221
10011
  }
9222
10012
  const action = args.batchAction ?? "status";
9223
10013
  if (pending) await runTranslationBatchAction(args, pending, action, projectRoot);
9224
10014
  if (ctxPending) await runContextBatchAction(args, ctxPending, action, projectRoot);
10015
+ if (glossPending) await runGlossaryBatchAction(args, glossPending, action, projectRoot);
9225
10016
  }
9226
10017
  async function runTranslationBatchAction(args, pending, action, projectRoot) {
9227
10018
  if (action === "cancel") {
@@ -9307,18 +10098,65 @@ async function runContextBatchAction(args, pending, action, projectRoot) {
9307
10098
  if (outcome.retried) console.log(`${outcome.retried} job(s) re-run synchronously (batch entries failed or were malformed).`);
9308
10099
  for (const e of outcome.errors) console.warn(`skip ${e.key}: ${e.error}`);
9309
10100
  }
10101
+ async function runGlossaryBatchAction(args, pending, action, projectRoot) {
10102
+ if (action === "cancel") {
10103
+ let remoteFailed = false;
10104
+ try {
10105
+ const ai2 = loadLocalSettings(projectRoot).ai;
10106
+ const provider2 = makeProvider(ai2);
10107
+ if (supportsBatchComplete(provider2)) {
10108
+ await provider2.cancelTranslationBatch(pending.batchId);
10109
+ } else {
10110
+ remoteFailed = true;
10111
+ }
10112
+ } catch {
10113
+ remoteFailed = true;
10114
+ }
10115
+ clearPendingGlossaryBatch(projectRoot);
10116
+ const suffix = remoteFailed ? " (remote cancel failed \u2014 it will expire server-side)" : "";
10117
+ console.log(`Canceled glossary suggestion batch ${pending.batchId}.${suffix}`);
10118
+ return;
10119
+ }
10120
+ const ai = loadLocalSettings(projectRoot).ai;
10121
+ const provider = makeProviderOrExit(ai);
10122
+ if (!provider) return;
10123
+ if (!supportsBatchComplete(provider)) {
10124
+ console.error(`Pending glossary batch was submitted via anthropic, but the configured provider "${ai.provider}" has no batch support.`);
10125
+ process.exitCode = 1;
10126
+ return;
10127
+ }
10128
+ const status = await provider.translationBatchStatus(pending.batchId);
10129
+ const c = status.counts;
10130
+ console.log(`Glossary suggestion batch ${pending.batchId} (${pending.total} source(s), submitted ${pending.createdAt})`);
10131
+ console.log(` ${status.status} \u2014 ${c.succeeded} succeeded, ${c.processing} processing, ${c.errored} errored, ${c.expired} expired, ${c.canceled} canceled`);
10132
+ if (status.status !== "ended") {
10133
+ if (action === "apply") console.log("Not finished yet \u2014 try again later.");
10134
+ return;
10135
+ }
10136
+ const outcome = await applyGlossarySuggestBatchResults(
10137
+ () => loadState(args.statePath),
10138
+ (s) => saveState(args.statePath, s),
10139
+ provider,
10140
+ pending,
10141
+ projectRoot,
10142
+ ai
10143
+ );
10144
+ console.log(`Found ${outcome.added} new candidate term(s).`);
10145
+ if (outcome.retried) console.log(`${outcome.retried} job(s) re-run synchronously (batch entries failed or were malformed).`);
10146
+ for (const e of outcome.errors) console.warn(`batch job failed: ${e.error}`);
10147
+ }
9310
10148
  function sarifContextFor(statePath) {
9311
10149
  if (detectFormat(statePath) === "split") {
9312
10150
  const dir = splitDirFor(statePath);
9313
- const keysPath = join20(dir, "keys.json");
10151
+ const keysPath = join22(dir, "keys.json");
9314
10152
  return {
9315
10153
  keysUri: `${basename2(dir)}/keys.json`,
9316
- keysRawText: existsSync14(keysPath) ? readFileSync25(keysPath, "utf8") : ""
10154
+ keysRawText: existsSync15(keysPath) ? readFileSync27(keysPath, "utf8") : ""
9317
10155
  };
9318
10156
  }
9319
10157
  return {
9320
10158
  keysUri: basename2(statePath),
9321
- keysRawText: existsSync14(statePath) ? readFileSync25(statePath, "utf8") : ""
10159
+ keysRawText: existsSync15(statePath) ? readFileSync27(statePath, "utf8") : ""
9322
10160
  };
9323
10161
  }
9324
10162
  function printReport(report, format, statePath) {
@@ -9327,6 +10165,18 @@ function printReport(report, format, statePath) {
9327
10165
  else console.log(formatText(report).trimEnd());
9328
10166
  }
9329
10167
  async function runLintCmd(args) {
10168
+ if (args.ruleIds) {
10169
+ const unknown = unknownRuleIds(args.ruleIds);
10170
+ if (unknown.length > 0) {
10171
+ for (const id of unknown) {
10172
+ const hint = suggestRuleId(id);
10173
+ console.error(`Unknown --rule '${id}'.${hint ? ` Did you mean '${hint}'?` : ""}`);
10174
+ }
10175
+ console.error(`Valid rules: ${RULE_IDS.join(", ")}.`);
10176
+ process.exitCode = 1;
10177
+ return;
10178
+ }
10179
+ }
9330
10180
  const state = loadState(args.statePath);
9331
10181
  if (args.accept) {
9332
10182
  const { acceptFindings: acceptFindings2 } = await Promise.resolve().then(() => (init_accept(), accept_exports));
@@ -9375,7 +10225,7 @@ async function runImportCmd(args) {
9375
10225
  const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
9376
10226
  const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
9377
10227
  const out = resolve11(projectRoot, "glotfile.json");
9378
- if (existsSync14(out) && !args.importForce) {
10228
+ if (existsSync15(out) && !args.importForce) {
9379
10229
  console.error(`${out} already exists; pass --force to overwrite`);
9380
10230
  process.exitCode = 1;
9381
10231
  return;
@@ -9468,29 +10318,30 @@ async function runBuildContext(args) {
9468
10318
  console.log("No keys need context.");
9469
10319
  return;
9470
10320
  }
10321
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10322
+ attachUsageSnippets(targets, cache2, projectRoot);
10323
+ if (args.estimate) {
10324
+ const est = estimateContext(targets, aiCfg);
10325
+ const fmt = (n) => n.toLocaleString("en-US");
10326
+ console.log(`Estimate for ${fmt(est.keys)} key(s) in ${fmt(est.batches)} batch(es) \u2014 ${aiCfg.provider} \xB7 ${aiCfg.model}`);
10327
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
10328
+ if (est.pricing) {
10329
+ const cost = est.estimatedCost;
10330
+ 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)`);
10331
+ } else {
10332
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
10333
+ }
10334
+ return;
10335
+ }
9471
10336
  let provider;
9472
10337
  try {
9473
- provider = makeProvider(loadLocalSettings(projectRoot).ai);
10338
+ provider = makeProvider(aiCfg);
9474
10339
  } catch (e) {
9475
10340
  console.error(e.message);
9476
10341
  process.exitCode = 1;
9477
10342
  return;
9478
10343
  }
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
10344
  const system = buildContextSystemPrompt();
9493
- const aiCfg = loadLocalSettings(projectRoot).ai;
9494
10345
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
9495
10346
  const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
9496
10347
  if (args.batch) {
@@ -9546,6 +10397,96 @@ async function runBuildContext(args) {
9546
10397
  console.log(`Wrote context for ${written} key(s).`);
9547
10398
  for (const e of errors) console.warn(`skip ${e.key}: ${e.error}`);
9548
10399
  }
10400
+ async function runSuggestGlossary(args) {
10401
+ const state = loadState(args.statePath);
10402
+ const projectRoot = dirname5(resolve11(args.statePath));
10403
+ const sources = selectGlossarySources(state, { keyGlob: args.keyGlob, limit: args.limit, since: args.since });
10404
+ if (!sources.length) {
10405
+ console.log("No source strings to scan.");
10406
+ return;
10407
+ }
10408
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10409
+ const known = knownTermList(state);
10410
+ if (args.estimate) {
10411
+ const est = estimateGlossarySuggest(sources, known, aiCfg);
10412
+ const fmt = (n) => n.toLocaleString("en-US");
10413
+ console.log(`Estimate for ${fmt(est.sources)} source string(s) in ${fmt(est.batches)} batch(es) \u2014 ${aiCfg.provider} \xB7 ${aiCfg.model}`);
10414
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
10415
+ if (est.pricing) {
10416
+ const cost = est.estimatedCost;
10417
+ 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)`);
10418
+ } else {
10419
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
10420
+ }
10421
+ return;
10422
+ }
10423
+ let provider;
10424
+ try {
10425
+ provider = makeProvider(aiCfg);
10426
+ } catch (e) {
10427
+ console.error(e.message);
10428
+ process.exitCode = 1;
10429
+ return;
10430
+ }
10431
+ const system = buildGlossarySuggestSystemPrompt();
10432
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
10433
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
10434
+ if (args.batch) {
10435
+ if (!supportsBatchComplete(provider)) {
10436
+ console.error(`Provider "${aiCfg.provider}" does not support batch mode. Currently anthropic only.`);
10437
+ process.exitCode = 1;
10438
+ return;
10439
+ }
10440
+ let pending;
10441
+ try {
10442
+ pending = await submitGlossarySuggestBatch(provider, sources, known, batchSize, aiCfg.model, projectRoot);
10443
+ } catch (e) {
10444
+ console.error(e.message);
10445
+ process.exitCode = 1;
10446
+ return;
10447
+ }
10448
+ appendLog(projectRoot, {
10449
+ at: (/* @__PURE__ */ new Date()).toISOString(),
10450
+ kind: "glossary",
10451
+ summary: `Submitted glossary suggestion batch ${pending.batchId} (${pending.total} sources)`,
10452
+ model: aiCfg.model,
10453
+ system
10454
+ });
10455
+ console.log(`Submitted glossary suggestion batch ${pending.batchId} \u2014 ${pending.total} source string(s) at 50% batch pricing.`);
10456
+ console.log("Check progress with `glotfile batch`; it applies results automatically when finished.");
10457
+ return;
10458
+ }
10459
+ const chunks = [];
10460
+ for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
10461
+ const all = [];
10462
+ let done = 0;
10463
+ let next = 0;
10464
+ async function worker() {
10465
+ while (next < chunks.length) {
10466
+ const chunkRows = chunks[next++];
10467
+ try {
10468
+ const raw = await provider.complete({ system, content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunkRows, known) }], schema: GLOSSARY_SUGGEST_SCHEMA });
10469
+ const batch = raw;
10470
+ all.push(...batch.terms ?? []);
10471
+ } catch (e) {
10472
+ console.warn(`batch failed: ${e.message}`);
10473
+ }
10474
+ done += chunkRows.length;
10475
+ console.log(`[${done}/${sources.length}] scanned`);
10476
+ }
10477
+ }
10478
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
10479
+ const added = mergeGlossarySuggestions(state, dedupeTerms(all));
10480
+ saveState(args.statePath, state);
10481
+ appendLog(projectRoot, {
10482
+ at: (/* @__PURE__ */ new Date()).toISOString(),
10483
+ kind: "glossary",
10484
+ summary: `Suggested ${added.length} glossary term(s)`,
10485
+ model: aiCfg.model
10486
+ });
10487
+ console.log(`Found ${added.length} new candidate term(s). Review them in the glossary UI.`);
10488
+ for (const s of added) console.log(` \u2022 ${s.term}${s.note ? ` \u2014 ${s.note}` : ""}`);
10489
+ }
9549
10490
  async function runScanCmd(args) {
9550
10491
  const state = loadState(args.statePath);
9551
10492
  const projectRoot = dirname5(resolve11(args.statePath));
@@ -9619,19 +10560,19 @@ function runSplit(args) {
9619
10560
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
9620
10561
  );
9621
10562
  }
9622
- var SKILL_SRC = join20(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
10563
+ var SKILL_SRC = join22(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
9623
10564
  function runSkill(args) {
9624
10565
  if (args.print) {
9625
- console.log(readFileSync25(join20(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
10566
+ console.log(readFileSync27(join22(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
9626
10567
  return;
9627
10568
  }
9628
10569
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
9629
- if (existsSync14(dest) && !args.importForce) {
10570
+ if (existsSync15(dest) && !args.importForce) {
9630
10571
  console.error(`${dest} already exists; pass --force to overwrite`);
9631
10572
  process.exitCode = 1;
9632
10573
  return;
9633
10574
  }
9634
- mkdirSync6(dirname5(dest), { recursive: true });
10575
+ mkdirSync7(dirname5(dest), { recursive: true });
9635
10576
  cpSync(SKILL_SRC, dest, { recursive: true });
9636
10577
  console.log(`Installed the glotfile skill to ${dest}. Restart Claude Code to pick it up.`);
9637
10578
  }
@@ -9801,6 +10742,39 @@ function runApply(args) {
9801
10742
  console.log(JSON.stringify({ applied: r.applied, keysTouched: r.keysTouched, saved, dryRun: !!args.dryRun, errors: r.errors }, null, 2));
9802
10743
  if (r.errors.length) process.exitCode = 1;
9803
10744
  }
10745
+ async function runPrices(args) {
10746
+ const projectRoot = dirname5(resolve11(args.statePath));
10747
+ if (args.refresh) {
10748
+ try {
10749
+ const res = await refreshPrices();
10750
+ invalidatePriceCache();
10751
+ console.log(`Updated ${res.modelCount} model price(s) from ${res.source}.`);
10752
+ console.log(`Fetched ${new Date(res.fetchedAt).toLocaleString()} \u2192 ${res.path}`);
10753
+ } catch (e) {
10754
+ console.error(`Could not refresh prices: ${e.message}`);
10755
+ console.error("Existing cached prices (if any) are unchanged.");
10756
+ process.exitCode = 1;
10757
+ }
10758
+ return;
10759
+ }
10760
+ const cache2 = loadPriceCache();
10761
+ if (cache2) {
10762
+ const when = cache2.fetchedAt ? new Date(cache2.fetchedAt).toLocaleString() : "unknown time";
10763
+ console.log(`Price cache: ${Object.keys(cache2.models).length} model(s) from ${cache2.source}, fetched ${when}.`);
10764
+ console.log(`Location: ${defaultPriceCachePath()}`);
10765
+ } else {
10766
+ console.log("No price cache yet. Run `glotfile prices --refresh` to fetch the latest from models.dev.");
10767
+ }
10768
+ const aiCfg = loadLocalSettings(projectRoot).ai;
10769
+ const pricing = resolvePricing(aiCfg, cache2);
10770
+ if (pricing) {
10771
+ console.log(`
10772
+ ${aiCfg.provider} \xB7 ${aiCfg.model}: $${pricing.inputPerMTok}/$${pricing.outputPerMTok} per MTok (${pricing.source}).`);
10773
+ } else {
10774
+ console.log(`
10775
+ No price known for ${aiCfg.provider} \xB7 ${aiCfg.model}. Set inputPricePerMTok/outputPricePerMTok in AI settings, or refresh.`);
10776
+ }
10777
+ }
9804
10778
  var GLOBAL_OPTS = [
9805
10779
  ["-f, --file <path>", "State file to use (default: ./glotfile.json)"],
9806
10780
  ["-h, --help", "Show this help"]
@@ -9879,12 +10853,25 @@ var COMMAND_HELP = {
9879
10853
  },
9880
10854
  "build-context": {
9881
10855
  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]",
10856
+ usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>] [--estimate] [--batch]",
9883
10857
  options: [
9884
10858
  ["--all", "(Re)build context for every key, not just those missing it"],
9885
10859
  ["--key <glob>", "Only keys matching this glob"],
9886
10860
  ["--limit <n>", "Process at most n keys"],
9887
- ["--since <date>", "Only keys added or changed since this date"]
10861
+ ["--since <date>", "Only keys added or changed since this date"],
10862
+ ["--estimate", "Print batches, tokens and estimated cost without building"],
10863
+ ["--batch", "Submit via the provider's batch API (50% cost, async; anthropic only)"]
10864
+ ]
10865
+ },
10866
+ "suggest-glossary": {
10867
+ summary: "AI-scan source strings for candidate glossary terms (adds a review queue; existing terms are skipped).",
10868
+ usage: "glotfile suggest-glossary [--key <glob>] [--limit <n>] [--since <date>] [--estimate] [--batch]",
10869
+ options: [
10870
+ ["--key <glob>", "Only scan keys matching this glob"],
10871
+ ["--limit <n>", "Scan at most n source strings"],
10872
+ ["--since <date>", "Only keys added since this date"],
10873
+ ["--estimate", "Print batches, tokens and estimated cost without scanning"],
10874
+ ["--batch", "Submit via the provider's batch API (50% cost, async; anthropic only)"]
9888
10875
  ]
9889
10876
  },
9890
10877
  scan: {
@@ -9923,6 +10910,13 @@ var COMMAND_HELP = {
9923
10910
  ["cancel", "Cancel the pending batch and discard the handle"]
9924
10911
  ]
9925
10912
  },
10913
+ prices: {
10914
+ summary: "Show or refresh the model price cache used for cost estimates (models.dev).",
10915
+ usage: "glotfile prices [--refresh]",
10916
+ options: [
10917
+ ["--refresh", "Fetch the latest prices from models.dev into the cache (the only command that hits the network)"]
10918
+ ]
10919
+ },
9926
10920
  get: {
9927
10921
  summary: "Extract values from the catalog (filtered) without loading the whole file. Prints JSON.",
9928
10922
  usage: "glotfile get [<key-glob>\u2026] [--key <glob>] [--locale <list>] [--state <list>] [--fields <list>] [--keys-only] [--format json|ndjson]",
@@ -10012,8 +11006,8 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
10012
11006
  );
10013
11007
  }
10014
11008
  function printVersion() {
10015
- const pkgPath = join20(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
10016
- console.log(JSON.parse(readFileSync25(pkgPath, "utf8")).version);
11009
+ const pkgPath = join22(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
11010
+ console.log(JSON.parse(readFileSync27(pkgPath, "utf8")).version);
10017
11011
  }
10018
11012
  async function main(argv) {
10019
11013
  const args = parseArgs(argv);
@@ -10033,11 +11027,13 @@ async function main(argv) {
10033
11027
  if (args.command === "import") return runImportCmd(args);
10034
11028
  if (args.command === "sync") return runSyncCmd(args);
10035
11029
  if (args.command === "build-context") return runBuildContext(args);
11030
+ if (args.command === "suggest-glossary") return runSuggestGlossary(args);
10036
11031
  if (args.command === "scan") return runScanCmd(args);
10037
11032
  if (args.command === "prune") return runPrune(args);
10038
11033
  if (args.command === "split") return runSplit(args);
10039
11034
  if (args.command === "skill") return runSkill(args);
10040
11035
  if (args.command === "batch") return runBatch(args);
11036
+ if (args.command === "prices") return runPrices(args);
10041
11037
  if (args.command === "get") return runGetCmd(args);
10042
11038
  if (args.command === "stats") return runStatsCmd(args);
10043
11039
  if (args.command === "set") return runSet(args);