glotfile 0.7.2 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1972,6 +1972,9 @@ var init_export_run = __esm({
1972
1972
  function supportsBatchTranslate(p) {
1973
1973
  return typeof p.submitTranslationBatch === "function";
1974
1974
  }
1975
+ function supportsBatchComplete(p) {
1976
+ return typeof p.submitCompletionBatch === "function";
1977
+ }
1975
1978
  function buildSystemPrompt(hasPluralItems) {
1976
1979
  const lines = [
1977
1980
  "You are a professional software localization engine for a UI string catalog.",
@@ -2346,10 +2349,13 @@ var init_anthropic = __esm({
2346
2349
  content.push({ type: "text", text: buildBatchPrompt(batch) });
2347
2350
  return content;
2348
2351
  }
2349
- async complete(req) {
2350
- const content = req.content.map(
2352
+ completionContent(req) {
2353
+ return req.content.map(
2351
2354
  (b) => b.type === "image" ? { type: "image", source: { type: "base64", media_type: b.mediaType, data: b.base64 } } : { type: "text", text: b.text ?? "" }
2352
2355
  );
2356
+ }
2357
+ async complete(req) {
2358
+ const content = this.completionContent(req);
2353
2359
  const res = await this.client.messages.create({
2354
2360
  model: this.config.model,
2355
2361
  max_tokens: req.maxTokens ?? 8192,
@@ -2413,6 +2419,40 @@ var init_anthropic = __esm({
2413
2419
  async cancelTranslationBatch(batchId) {
2414
2420
  await this.batchesClient().cancel(batchId);
2415
2421
  }
2422
+ // Mirrors complete() exactly — same prompts and schema — so batch and sync
2423
+ // completion replies are interchangeable downstream.
2424
+ async submitCompletionBatch(jobs) {
2425
+ const requests = jobs.map((job) => ({
2426
+ custom_id: job.customId,
2427
+ params: {
2428
+ model: this.config.model,
2429
+ max_tokens: job.request.maxTokens ?? 8192,
2430
+ // Batch entries don't share a live cache window, so cache_control is omitted here.
2431
+ system: [{ type: "text", text: job.request.system }],
2432
+ output_config: { format: { type: "json_schema", schema: job.request.schema } },
2433
+ messages: [{ role: "user", content: this.completionContent(job.request) }]
2434
+ }
2435
+ }));
2436
+ const res = await this.batchesClient().create({ requests });
2437
+ return res.id;
2438
+ }
2439
+ async completionBatchResults(batchId) {
2440
+ const out = /* @__PURE__ */ new Map();
2441
+ for await (const entry of await this.batchesClient().results(batchId)) {
2442
+ if (entry.result.type !== "succeeded") {
2443
+ out.set(entry.custom_id, { type: "failed", error: entry.result.error?.message ?? entry.result.type });
2444
+ continue;
2445
+ }
2446
+ this.recordUsage(entry.result.message?.usage);
2447
+ const text = entry.result.message?.content.find((b) => b.type === "text")?.text ?? "";
2448
+ try {
2449
+ out.set(entry.custom_id, { type: "json", value: JSON.parse(text) });
2450
+ } catch {
2451
+ out.set(entry.custom_id, { type: "malformed", raw: text });
2452
+ }
2453
+ }
2454
+ return out;
2455
+ }
2416
2456
  async callBatch(batch, signal) {
2417
2457
  const content = this.buildUserContent(batch);
2418
2458
  const res = await this.client.messages.create({
@@ -3395,101 +3435,403 @@ var init_batch_run = __esm({
3395
3435
  }
3396
3436
  });
3397
3437
 
3398
- // src/server/ai/estimate.ts
3399
- function estimateTokens(text) {
3400
- const cjk = text.match(CJK_RE)?.length ?? 0;
3401
- return Math.ceil((text.length - cjk) / 4 + cjk / 2);
3438
+ // src/server/ai/context.ts
3439
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
3440
+ import { resolve as resolve6 } from "path";
3441
+ function globToRegExp2(glob) {
3442
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
3443
+ return new RegExp(`^${escaped}$`);
3402
3444
  }
3403
- function estimateOutputTokens(req) {
3404
- const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
3405
- if (req.plural) {
3406
- return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
3445
+ function extractSnippets(refs, projectRoot, fileCache) {
3446
+ const filtered = refs.filter((r) => !EXCLUDED_DIRS.some((d) => r.file.startsWith(d)));
3447
+ const sorted = [...filtered].sort((a, b) => a.file.length - b.file.length);
3448
+ const selected = sorted.slice(0, MAX_SNIPPETS);
3449
+ const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
3450
+ const snippets = [];
3451
+ for (const ref of selected) {
3452
+ const absPath = resolve6(projectRoot, ref.file);
3453
+ if (!fileCache.has(ref.file)) {
3454
+ if (!existsSync8(absPath)) continue;
3455
+ const content = readFileSync8(absPath, "utf8");
3456
+ fileCache.set(ref.file, content.split("\n"));
3457
+ }
3458
+ const lines = fileCache.get(ref.file);
3459
+ const start = Math.max(0, ref.line - 1 - SNIPPET_WINDOW);
3460
+ const end = Math.min(lines.length, ref.line + SNIPPET_WINDOW);
3461
+ snippets.push({
3462
+ file: ref.file,
3463
+ startLine: start + 1,
3464
+ lines: lines.slice(start, end).join("\n"),
3465
+ scanner: ref.scanner,
3466
+ ...snippets.length === 0 && extraRefs > 0 ? { extraRefs } : {}
3467
+ });
3407
3468
  }
3408
- return ITEM_REPLY_OVERHEAD + translated;
3469
+ return snippets;
3409
3470
  }
3410
- function estimateTranslation(state, ai, opts) {
3411
- const reqs = selectRequests(state, opts);
3412
- const byLocale = /* @__PURE__ */ new Map();
3413
- for (const r of reqs) {
3414
- let group = byLocale.get(r.targetLocale);
3415
- if (!group) {
3416
- group = [];
3417
- byLocale.set(r.targetLocale, group);
3471
+ function buildUsageIndex(cache2) {
3472
+ const index = /* @__PURE__ */ new Map();
3473
+ for (const [file, entry] of Object.entries(cache2.files)) {
3474
+ for (const ref of entry.refs) {
3475
+ const existing = index.get(ref.key) ?? [];
3476
+ existing.push({ key: ref.key, file, line: ref.line, col: ref.col, scanner: ref.scanner });
3477
+ index.set(ref.key, existing);
3418
3478
  }
3419
- group.push(r);
3420
3479
  }
3421
- const perLocale = [];
3422
- for (const [locale, group] of byLocale) {
3423
- let inputTokens2 = 0;
3424
- let outputTokens2 = 0;
3425
- const batches = chunk(group, Math.max(1, ai.batchSize));
3426
- for (const batch of batches) {
3427
- const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
3428
- inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
3429
- for (const r of batch) outputTokens2 += estimateOutputTokens(r);
3480
+ return index;
3481
+ }
3482
+ function selectContextTargets(state, opts, cache2, lastRunAt) {
3483
+ const cutoff = opts.all ? void 0 : opts.since ?? lastRunAt;
3484
+ const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
3485
+ const keySet = opts.keys ? new Set(opts.keys) : null;
3486
+ const usageIndex = buildUsageIndex(cache2);
3487
+ let candidates = [];
3488
+ for (const key of Object.keys(state.keys).sort()) {
3489
+ const entry = state.keys[key];
3490
+ if (entry.context && !opts.force) continue;
3491
+ if (keySet && !keySet.has(key)) continue;
3492
+ if (keyRe && !keyRe.test(key)) continue;
3493
+ if (cutoff) {
3494
+ if (!entry.createdAt) continue;
3495
+ if (entry.createdAt < cutoff) continue;
3430
3496
  }
3431
- perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
3497
+ const source = entry.values[state.config.sourceLocale]?.value ?? "";
3498
+ candidates.push({ id: String(candidates.length), key, source, usageSnippets: [] });
3432
3499
  }
3433
- const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
3434
- const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
3435
- const pricing = resolvePricing(ai);
3436
- return {
3437
- requests: reqs.length,
3438
- batches: perLocale.reduce((n, l) => n + l.batches, 0),
3439
- perLocale,
3440
- inputTokens,
3441
- outputTokens,
3442
- pricing,
3443
- estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
3444
- };
3500
+ candidates.sort((a, b) => {
3501
+ const ta = state.keys[a.key].createdAt ?? "";
3502
+ const tb = state.keys[b.key].createdAt ?? "";
3503
+ return tb.localeCompare(ta);
3504
+ });
3505
+ if (opts.limit !== void 0) candidates = candidates.slice(0, opts.limit);
3506
+ candidates.forEach((c, i) => {
3507
+ c.id = String(i);
3508
+ });
3509
+ return candidates;
3445
3510
  }
3446
- var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
3447
- var init_estimate = __esm({
3448
- "src/server/ai/estimate.ts"() {
3511
+ function buildContextSystemPrompt() {
3512
+ return [
3513
+ "You are a localization context writer for a UI string catalog.",
3514
+ "For each translation key you are given: its dot-path name, its source string, and one or more code snippets showing where the string is referenced in the codebase.",
3515
+ "Your task: write a concise 1\u20132 sentence context note that describes WHERE in the UI this string appears and WHAT the user is doing at that point.",
3516
+ "The context is read by human translators AND by an AI translation engine. It must answer: what screen is this on, what element is this (button, label, error, etc.), and what action does it relate to?",
3517
+ "Rules:",
3518
+ "- Use the code snippets as your primary signal. Look at the component name, surrounding labels, event handlers, and variable names.",
3519
+ "- Do NOT restate the source string itself.",
3520
+ "- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
3521
+ "- Keep it under 500 characters.",
3522
+ "- If no code snippets are available, infer from the key path and source value."
3523
+ ].join("\n");
3524
+ }
3525
+ function buildContextBatchPrompt(reqs) {
3526
+ const items = reqs.map((r) => {
3527
+ const snippetText = r.usageSnippets.length > 0 ? r.usageSnippets.map((s) => {
3528
+ const extra = s.extraRefs ? ` (and ${s.extraRefs} more call site${s.extraRefs > 1 ? "s" : ""} not shown)` : "";
3529
+ return `File: ${s.file} (lines ${s.startLine}+, scanner: ${s.scanner})${extra}
3530
+ \`\`\`
3531
+ ${s.lines}
3532
+ \`\`\``;
3533
+ }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
3534
+ return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText };
3535
+ });
3536
+ return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
3537
+ }
3538
+ function applyContext(state, reqs, results, clock = systemClock, force = false) {
3539
+ const byId = new Map(reqs.map((r) => [r.id, r]));
3540
+ let written = 0;
3541
+ const errors = [];
3542
+ for (const res of results) {
3543
+ const req = byId.get(res.id);
3544
+ if (!req) continue;
3545
+ if (res.error) {
3546
+ errors.push({ key: req.key, error: res.error });
3547
+ continue;
3548
+ }
3549
+ const context = res.context?.trim() ?? "";
3550
+ if (!context) {
3551
+ errors.push({ key: req.key, error: "AI returned empty context" });
3552
+ continue;
3553
+ }
3554
+ if (context.length > MAX_CONTEXT_LENGTH) {
3555
+ errors.push({ key: req.key, error: `Context too long (${context.length} chars, max ${MAX_CONTEXT_LENGTH})` });
3556
+ continue;
3557
+ }
3558
+ const entry = state.keys[req.key];
3559
+ if (!entry || entry.context && !force) continue;
3560
+ entry.context = context;
3561
+ entry.contextSource = "ai";
3562
+ entry.contextAt = clock();
3563
+ written++;
3564
+ }
3565
+ return { written, errors };
3566
+ }
3567
+ var MAX_CONTEXT_LENGTH, SNIPPET_WINDOW, MAX_SNIPPETS, EXCLUDED_DIRS, CONTEXT_BATCH_SCHEMA;
3568
+ var init_context = __esm({
3569
+ "src/server/ai/context.ts"() {
3449
3570
  "use strict";
3450
- init_run();
3451
- init_provider();
3452
- init_batch();
3453
- init_pricing();
3454
- CJK_RE = /[ -鿿가-힯豈-﫿]/g;
3455
- EXPANSION = 1.2;
3456
- ITEM_REPLY_OVERHEAD = 16;
3457
- FORM_REPLY_OVERHEAD = 8;
3571
+ init_state();
3572
+ MAX_CONTEXT_LENGTH = 500;
3573
+ SNIPPET_WINDOW = 15;
3574
+ MAX_SNIPPETS = 3;
3575
+ EXCLUDED_DIRS = ["node_modules/", "vendor/", "dist/", ".git/", ".glotfile/"];
3576
+ CONTEXT_BATCH_SCHEMA = {
3577
+ type: "object",
3578
+ properties: {
3579
+ items: {
3580
+ type: "array",
3581
+ items: {
3582
+ type: "object",
3583
+ properties: {
3584
+ id: { type: "string" },
3585
+ context: { type: "string" },
3586
+ error: { type: "string" }
3587
+ },
3588
+ required: ["id"],
3589
+ additionalProperties: false
3590
+ }
3591
+ }
3592
+ },
3593
+ required: ["items"],
3594
+ additionalProperties: false
3595
+ };
3458
3596
  }
3459
3597
  });
3460
3598
 
3461
- // src/server/scan.ts
3462
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
3463
- import { resolve as resolve6 } from "path";
3464
- function loadUsageCache(projectRoot) {
3465
- const path = resolve6(projectRoot, ".glotfile", "usage.json");
3466
- if (!existsSync8(path)) return null;
3599
+ // src/server/ai/pending-context-batch.ts
3600
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
3601
+ import { join as join4 } from "path";
3602
+ function pendingContextBatchPath(projectRoot) {
3603
+ return join4(projectRoot, ".glotfile", "context-batch.json");
3604
+ }
3605
+ function loadPendingContextBatch(projectRoot) {
3606
+ const path = pendingContextBatchPath(projectRoot);
3607
+ if (!existsSync9(path)) return void 0;
3467
3608
  try {
3468
- return JSON.parse(readFileSync8(path, "utf8"));
3609
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
3610
+ if (parsed?.version !== 1) return void 0;
3611
+ return parsed;
3469
3612
  } catch {
3470
- return null;
3613
+ return void 0;
3471
3614
  }
3472
3615
  }
3473
- function saveUsageCache(projectRoot, cache2) {
3474
- ensureGlotfileDir(projectRoot);
3475
- const path = resolve6(projectRoot, ".glotfile", "usage.json");
3476
- writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
3616
+ function savePendingContextBatch(projectRoot, pending) {
3617
+ const dir = join4(projectRoot, ".glotfile");
3618
+ mkdirSync5(dir, { recursive: true });
3619
+ const gitignore = join4(dir, ".gitignore");
3620
+ if (!existsSync9(gitignore)) writeFileSync4(gitignore, "*\n");
3621
+ writeFileSync4(pendingContextBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
3477
3622
  }
3478
- function findMissing(state) {
3479
- const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
3480
- const out = [];
3481
- for (const key of Object.keys(state.keys).sort()) {
3482
- const entry = state.keys[key];
3483
- if (entry.skipTranslate) continue;
3484
- for (const locale of targets) {
3485
- const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value?.trim();
3486
- if (!v) out.push({ key, locale });
3487
- }
3623
+ function clearPendingContextBatch(projectRoot) {
3624
+ rmSync5(pendingContextBatchPath(projectRoot), { force: true });
3625
+ }
3626
+ var init_pending_context_batch = __esm({
3627
+ "src/server/ai/pending-context-batch.ts"() {
3628
+ "use strict";
3488
3629
  }
3489
- return out;
3630
+ });
3631
+
3632
+ // src/server/ai/context-batch-run.ts
3633
+ function completionRequestFor(chunk2) {
3634
+ return {
3635
+ system: buildContextSystemPrompt(),
3636
+ content: [{ type: "text", text: buildContextBatchPrompt(chunk2) }],
3637
+ schema: CONTEXT_BATCH_SCHEMA
3638
+ };
3490
3639
  }
3491
- function computeUsedKeys(state, cache2) {
3492
- const exact = /* @__PURE__ */ new Set();
3640
+ async function submitContextBatch(provider, targets, batchSize, model, projectRoot, force) {
3641
+ if (loadPendingContextBatch(projectRoot)) {
3642
+ throw new Error("A context batch is already pending. Apply or cancel it first.");
3643
+ }
3644
+ const chunks = [];
3645
+ const size = Math.max(1, batchSize);
3646
+ for (let i = 0; i < targets.length; i += size) chunks.push(targets.slice(i, i + size));
3647
+ const jobs = chunks.map((chunk2, i) => ({ customId: `ctx_${i}`, chunk: chunk2 }));
3648
+ const batchId = await provider.submitCompletionBatch(
3649
+ jobs.map((j) => ({ customId: j.customId, request: completionRequestFor(j.chunk) }))
3650
+ );
3651
+ const pending = {
3652
+ version: 1,
3653
+ // Only Anthropic implements completion batches today.
3654
+ provider: "anthropic",
3655
+ model,
3656
+ batchId,
3657
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3658
+ total: targets.length,
3659
+ force,
3660
+ jobs: jobs.map((j) => ({
3661
+ customId: j.customId,
3662
+ requests: j.chunk.map(({ image: _image, ...rest }) => rest)
3663
+ }))
3664
+ };
3665
+ savePendingContextBatch(projectRoot, pending);
3666
+ return pending;
3667
+ }
3668
+ async function applyContextBatchResults(load, persist, provider, pending, projectRoot, ai) {
3669
+ provider.takeUsage?.();
3670
+ const outcomes = await provider.completionBatchResults(pending.batchId);
3671
+ const batchUsage = provider.takeUsage?.();
3672
+ const applied = [];
3673
+ const items = [];
3674
+ const errors = [];
3675
+ const jobFailures = [];
3676
+ const retryChunks = [];
3677
+ for (const job of pending.jobs) {
3678
+ const outcome = outcomes.get(job.customId);
3679
+ if (outcome?.type === "json") {
3680
+ const batch = outcome.value;
3681
+ applied.push(...job.requests);
3682
+ items.push(...batch.items ?? []);
3683
+ continue;
3684
+ }
3685
+ if (!outcome) jobFailures.push({ customId: job.customId, locale: "", type: "missing" });
3686
+ else if (outcome.type === "malformed") jobFailures.push({ customId: job.customId, locale: "", type: "malformed", raw: outcome.raw });
3687
+ else jobFailures.push({ customId: job.customId, locale: "", type: "failed", error: outcome.error });
3688
+ retryChunks.push(job.requests);
3689
+ }
3690
+ for (const chunk2 of retryChunks) {
3691
+ try {
3692
+ const raw = await provider.complete(completionRequestFor(chunk2));
3693
+ const batch = raw;
3694
+ applied.push(...chunk2);
3695
+ items.push(...batch.items ?? []);
3696
+ } catch (e) {
3697
+ errors.push(...chunk2.map((t) => ({ key: t.key, error: e.message })));
3698
+ }
3699
+ }
3700
+ const retryUsage = provider.takeUsage?.();
3701
+ const pricing = resolvePricing({ ...ai, model: pending.model });
3702
+ let estimatedCostUsd;
3703
+ if (pricing && (batchUsage || retryUsage)) {
3704
+ estimatedCostUsd = (batchUsage ? estimateUsageCostUsd(batchUsage, pricing, BATCH_PRICE_MULTIPLIER) : 0) + (retryUsage ? estimateUsageCostUsd(retryUsage, pricing) : 0);
3705
+ }
3706
+ let usage;
3707
+ if (batchUsage || retryUsage) {
3708
+ usage = batchUsage ?? { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
3709
+ if (retryUsage) addUsage(usage, retryUsage);
3710
+ }
3711
+ const fresh = load();
3712
+ const { written, errors: applyErrors } = applyContext(fresh, applied, items, void 0, pending.force);
3713
+ errors.push(...applyErrors);
3714
+ persist(fresh);
3715
+ clearPendingContextBatch(projectRoot);
3716
+ const costSuffix = estimatedCostUsd !== void 0 ? ` (~$${estimatedCostUsd.toFixed(2)})` : "";
3717
+ appendLog(projectRoot, {
3718
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3719
+ kind: "context",
3720
+ summary: `Applied context batch ${pending.batchId}: wrote ${written}, ${errors.length} error(s), ${retryChunks.length} job(s) retried${costSuffix}`,
3721
+ model: pending.model,
3722
+ items: applied.map((r) => ({ id: r.id, key: r.key, source: r.source })),
3723
+ results: items.map((r) => ({ id: r.id, value: r.context, error: r.error })),
3724
+ jobFailures: jobFailures.length ? jobFailures : void 0,
3725
+ usage,
3726
+ estimatedCostUsd
3727
+ });
3728
+ return { written, errors, retried: retryChunks.length };
3729
+ }
3730
+ var init_context_batch_run = __esm({
3731
+ "src/server/ai/context-batch-run.ts"() {
3732
+ "use strict";
3733
+ init_context();
3734
+ init_pending_context_batch();
3735
+ init_log();
3736
+ init_pricing();
3737
+ }
3738
+ });
3739
+
3740
+ // src/server/ai/estimate.ts
3741
+ function estimateTokens(text) {
3742
+ const cjk = text.match(CJK_RE)?.length ?? 0;
3743
+ return Math.ceil((text.length - cjk) / 4 + cjk / 2);
3744
+ }
3745
+ function estimateOutputTokens(req) {
3746
+ const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
3747
+ if (req.plural) {
3748
+ return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
3749
+ }
3750
+ return ITEM_REPLY_OVERHEAD + translated;
3751
+ }
3752
+ function estimateTranslation(state, ai, opts) {
3753
+ const reqs = selectRequests(state, opts);
3754
+ const byLocale = /* @__PURE__ */ new Map();
3755
+ for (const r of reqs) {
3756
+ let group = byLocale.get(r.targetLocale);
3757
+ if (!group) {
3758
+ group = [];
3759
+ byLocale.set(r.targetLocale, group);
3760
+ }
3761
+ group.push(r);
3762
+ }
3763
+ const perLocale = [];
3764
+ for (const [locale, group] of byLocale) {
3765
+ let inputTokens2 = 0;
3766
+ let outputTokens2 = 0;
3767
+ const batches = chunk(group, Math.max(1, ai.batchSize));
3768
+ for (const batch of batches) {
3769
+ const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
3770
+ inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
3771
+ for (const r of batch) outputTokens2 += estimateOutputTokens(r);
3772
+ }
3773
+ perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
3774
+ }
3775
+ const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
3776
+ const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
3777
+ const pricing = resolvePricing(ai);
3778
+ return {
3779
+ requests: reqs.length,
3780
+ batches: perLocale.reduce((n, l) => n + l.batches, 0),
3781
+ perLocale,
3782
+ inputTokens,
3783
+ outputTokens,
3784
+ pricing,
3785
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
3786
+ };
3787
+ }
3788
+ var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
3789
+ var init_estimate = __esm({
3790
+ "src/server/ai/estimate.ts"() {
3791
+ "use strict";
3792
+ init_run();
3793
+ init_provider();
3794
+ init_batch();
3795
+ init_pricing();
3796
+ CJK_RE = /[ -鿿가-힯豈-﫿]/g;
3797
+ EXPANSION = 1.2;
3798
+ ITEM_REPLY_OVERHEAD = 16;
3799
+ FORM_REPLY_OVERHEAD = 8;
3800
+ }
3801
+ });
3802
+
3803
+ // src/server/scan.ts
3804
+ import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
3805
+ import { resolve as resolve7 } from "path";
3806
+ function loadUsageCache(projectRoot) {
3807
+ const path = resolve7(projectRoot, ".glotfile", "usage.json");
3808
+ if (!existsSync10(path)) return null;
3809
+ try {
3810
+ return JSON.parse(readFileSync10(path, "utf8"));
3811
+ } catch {
3812
+ return null;
3813
+ }
3814
+ }
3815
+ function saveUsageCache(projectRoot, cache2) {
3816
+ ensureGlotfileDir(projectRoot);
3817
+ const path = resolve7(projectRoot, ".glotfile", "usage.json");
3818
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
3819
+ }
3820
+ function findMissing(state) {
3821
+ const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
3822
+ const out = [];
3823
+ for (const key of Object.keys(state.keys).sort()) {
3824
+ const entry = state.keys[key];
3825
+ if (entry.skipTranslate) continue;
3826
+ for (const locale of targets) {
3827
+ const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value?.trim();
3828
+ if (!v) out.push({ key, locale });
3829
+ }
3830
+ }
3831
+ return out;
3832
+ }
3833
+ function computeUsedKeys(state, cache2) {
3834
+ const exact = /* @__PURE__ */ new Set();
3493
3835
  const prefixes = [];
3494
3836
  for (const entry of Object.values(cache2.files)) {
3495
3837
  for (const r of entry.refs) exact.add(r.key);
@@ -3530,8 +3872,8 @@ var init_scan = __esm({
3530
3872
  });
3531
3873
 
3532
3874
  // src/server/scanner.ts
3533
- import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync9 } from "fs";
3534
- import { join as join4, extname as extname2, relative } from "path";
3875
+ import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync11 } from "fs";
3876
+ import { join as join5, extname as extname2, relative } from "path";
3535
3877
  function scannerForExt(ext) {
3536
3878
  return EXT_SCANNER[ext] ?? null;
3537
3879
  }
@@ -3683,7 +4025,7 @@ function* walkFiles(dir, root, exclude) {
3683
4025
  }
3684
4026
  for (const name of entries) {
3685
4027
  if (ALWAYS_EXCLUDE.has(name)) continue;
3686
- const abs = join4(dir, name);
4028
+ const abs = join5(dir, name);
3687
4029
  const rel = relative(root, abs);
3688
4030
  let st;
3689
4031
  try {
@@ -3713,7 +4055,7 @@ function runScan(projectRoot, opts, existing) {
3713
4055
  const ext = extname2(relPath);
3714
4056
  const scanner = scannerForExt(ext);
3715
4057
  if (!scanner) continue;
3716
- const abs = join4(projectRoot, relPath);
4058
+ const abs = join5(projectRoot, relPath);
3717
4059
  let st;
3718
4060
  try {
3719
4061
  st = statSync2(abs);
@@ -3724,284 +4066,123 @@ function runScan(projectRoot, opts, existing) {
3724
4066
  const size = st.size;
3725
4067
  const prev = reusable?.files[relPath];
3726
4068
  if (prev && prev.mtime === mtime && prev.size === size) {
3727
- cache2.files[relPath] = prev;
3728
- continue;
3729
- }
3730
- let content;
3731
- try {
3732
- content = readFileSync9(abs, "utf8");
3733
- } catch {
3734
- continue;
3735
- }
3736
- cache2.files[relPath] = {
3737
- mtime,
3738
- size,
3739
- refs: extractRefs(content, scanner, opts),
3740
- prefixes: extractPrefixes(content, scanner),
3741
- literals: extractLiterals(content)
3742
- };
3743
- }
3744
- saveUsageCache(projectRoot, cache2);
3745
- return cache2;
3746
- }
3747
- var PATTERNS, PREFIX_PATTERNS, CACHE_VERSION, EXT_SCANNER, ALWAYS_EXCLUDE, FLUTTER_ACCESSOR_DEFAULTS, KEY_SHAPE, STRING_LITERALS;
3748
- var init_scanner = __esm({
3749
- "src/server/scanner.ts"() {
3750
- "use strict";
3751
- init_scan();
3752
- PATTERNS = {
3753
- laravel: [
3754
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']+)'/g,
3755
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"]+)"/g,
3756
- /@(?:lang|choice)\s*\(\s*'([^']+)'/g,
3757
- /@(?:lang|choice)\s*\(\s*"([^"]+)"/g
3758
- ],
3759
- "js-i18n": [
3760
- /\$t\s*\(\s*'([^']+)'/g,
3761
- /\$t\s*\(\s*"([^"]+)"/g,
3762
- /\$t\s*\(\s*`([^`$\n]+)`/g,
3763
- /\bi18n\.t\s*\(\s*'([^']+)'/g,
3764
- /\bi18n\.t\s*\(\s*"([^"]+)"/g,
3765
- /\bi18next\.t\s*\(\s*'([^']+)'/g,
3766
- /\bi18next\.t\s*\(\s*"([^"]+)"/g,
3767
- // t('key') — word boundary before t, not preceded by dot (excludes i18n.t which is above)
3768
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']+)'/g,
3769
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]+)"/g,
3770
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$\n]+)`/g
3771
- ],
3772
- gettext: [
3773
- /\b(?:gettext|ngettext)\s*\(\s*'([^']+)'/g,
3774
- /\b(?:gettext|ngettext)\s*\(\s*"([^"]+)"/g,
3775
- // _() — word boundary, not preceded by alphanumeric
3776
- /(?<![a-zA-Z0-9_$])_\s*\(\s*'([^']+)'/g,
3777
- /(?<![a-zA-Z0-9_$])_\s*\(\s*"([^"]+)"/g
3778
- ],
3779
- apple: [
3780
- /NSLocalizedString\s*\(\s*@?"([^"]+)"/g,
3781
- /String\s*\(\s*localized:\s*"([^"]+)"/g,
3782
- /localizedString\s*\(\s*forKey:\s*"([^"]+)"/g,
3783
- // The "key".localized / "key".localised String-extension idiom, where the
3784
- // literal IS the key (common when keys are natural-language source text).
3785
- /"([^"]+)"\s*\.\s*localized\b/g,
3786
- /"([^"]+)"\s*\.\s*localised\b/g
3787
- ]
3788
- };
3789
- PREFIX_PATTERNS = {
3790
- laravel: [
3791
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']*)'\s*\./g,
3792
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"]*)"\s*\./g,
3793
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"${]*)\{?\$/g
3794
- ],
3795
- "js-i18n": [
3796
- /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*'([^']*)'\s*\+/g,
3797
- /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*"([^"]*)"\s*\+/g,
3798
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']*)'\s*\+/g,
3799
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]*)"\s*\+/g,
3800
- /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*`([^`$]*)\$\{/g,
3801
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$]*)\$\{/g
3802
- ]
3803
- };
3804
- CACHE_VERSION = 6;
3805
- EXT_SCANNER = {
3806
- ".php": "laravel",
3807
- ".vue": "js-i18n",
3808
- ".js": "js-i18n",
3809
- ".ts": "js-i18n",
3810
- ".jsx": "js-i18n",
3811
- ".tsx": "js-i18n",
3812
- ".mjs": "js-i18n",
3813
- ".cjs": "js-i18n",
3814
- ".dart": "flutter",
3815
- ".py": "gettext",
3816
- ".c": "gettext",
3817
- ".cpp": "gettext",
3818
- ".h": "gettext",
3819
- ".swift": "apple",
3820
- ".m": "apple",
3821
- ".mm": "apple"
3822
- };
3823
- ALWAYS_EXCLUDE = /* @__PURE__ */ new Set([
3824
- "node_modules",
3825
- ".git",
3826
- ".glotfile",
3827
- ".claude",
3828
- "dist",
3829
- "build",
3830
- "vendor",
3831
- "coverage",
3832
- ".next",
3833
- ".nuxt",
3834
- ".turbo",
3835
- "__pycache__"
3836
- ]);
3837
- FLUTTER_ACCESSOR_DEFAULTS = ["l10n", "loc", "localizations", "translations"];
3838
- KEY_SHAPE = /^[A-Za-z0-9_][A-Za-z0-9_/-]*(?:\.(?:[A-Za-z0-9_-]+|%[sd]))+\.?$/;
3839
- STRING_LITERALS = [
3840
- /'([^'\\\n]+)'/g,
3841
- /"([^"\\\n]+)"/g,
3842
- /`([^`\\\n]+)`/g
3843
- ];
3844
- }
3845
- });
3846
-
3847
- // src/server/ai/context.ts
3848
- import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
3849
- import { resolve as resolve7 } from "path";
3850
- function globToRegExp2(glob) {
3851
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
3852
- return new RegExp(`^${escaped}$`);
3853
- }
3854
- function extractSnippets(refs, projectRoot, fileCache) {
3855
- const filtered = refs.filter((r) => !EXCLUDED_DIRS.some((d) => r.file.startsWith(d)));
3856
- const sorted = [...filtered].sort((a, b) => a.file.length - b.file.length);
3857
- const selected = sorted.slice(0, MAX_SNIPPETS);
3858
- const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
3859
- const snippets = [];
3860
- for (const ref of selected) {
3861
- const absPath = resolve7(projectRoot, ref.file);
3862
- if (!fileCache.has(ref.file)) {
3863
- if (!existsSync9(absPath)) continue;
3864
- const content = readFileSync10(absPath, "utf8");
3865
- fileCache.set(ref.file, content.split("\n"));
3866
- }
3867
- const lines = fileCache.get(ref.file);
3868
- const start = Math.max(0, ref.line - 1 - SNIPPET_WINDOW);
3869
- const end = Math.min(lines.length, ref.line + SNIPPET_WINDOW);
3870
- snippets.push({
3871
- file: ref.file,
3872
- startLine: start + 1,
3873
- lines: lines.slice(start, end).join("\n"),
3874
- scanner: ref.scanner,
3875
- ...snippets.length === 0 && extraRefs > 0 ? { extraRefs } : {}
3876
- });
3877
- }
3878
- return snippets;
3879
- }
3880
- function buildUsageIndex(cache2) {
3881
- const index = /* @__PURE__ */ new Map();
3882
- for (const [file, entry] of Object.entries(cache2.files)) {
3883
- for (const ref of entry.refs) {
3884
- const existing = index.get(ref.key) ?? [];
3885
- existing.push({ key: ref.key, file, line: ref.line, col: ref.col, scanner: ref.scanner });
3886
- index.set(ref.key, existing);
3887
- }
3888
- }
3889
- return index;
3890
- }
3891
- function selectContextTargets(state, opts, cache2, lastRunAt) {
3892
- const cutoff = opts.all ? void 0 : opts.since ?? lastRunAt;
3893
- const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
3894
- const keySet = opts.keys ? new Set(opts.keys) : null;
3895
- const usageIndex = buildUsageIndex(cache2);
3896
- let candidates = [];
3897
- for (const key of Object.keys(state.keys).sort()) {
3898
- const entry = state.keys[key];
3899
- if (entry.context && !opts.force) continue;
3900
- if (keySet && !keySet.has(key)) continue;
3901
- if (keyRe && !keyRe.test(key)) continue;
3902
- if (cutoff) {
3903
- if (!entry.createdAt) continue;
3904
- if (entry.createdAt < cutoff) continue;
3905
- }
3906
- const source = entry.values[state.config.sourceLocale]?.value ?? "";
3907
- candidates.push({ id: String(candidates.length), key, source, usageSnippets: [] });
3908
- }
3909
- candidates.sort((a, b) => {
3910
- const ta = state.keys[a.key].createdAt ?? "";
3911
- const tb = state.keys[b.key].createdAt ?? "";
3912
- return tb.localeCompare(ta);
3913
- });
3914
- if (opts.limit !== void 0) candidates = candidates.slice(0, opts.limit);
3915
- candidates.forEach((c, i) => {
3916
- c.id = String(i);
3917
- });
3918
- return candidates;
3919
- }
3920
- function buildContextSystemPrompt() {
3921
- return [
3922
- "You are a localization context writer for a UI string catalog.",
3923
- "For each translation key you are given: its dot-path name, its source string, and one or more code snippets showing where the string is referenced in the codebase.",
3924
- "Your task: write a concise 1\u20132 sentence context note that describes WHERE in the UI this string appears and WHAT the user is doing at that point.",
3925
- "The context is read by human translators AND by an AI translation engine. It must answer: what screen is this on, what element is this (button, label, error, etc.), and what action does it relate to?",
3926
- "Rules:",
3927
- "- Use the code snippets as your primary signal. Look at the component name, surrounding labels, event handlers, and variable names.",
3928
- "- Do NOT restate the source string itself.",
3929
- "- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
3930
- "- Keep it under 500 characters.",
3931
- "- If no code snippets are available, infer from the key path and source value."
3932
- ].join("\n");
3933
- }
3934
- function buildContextBatchPrompt(reqs) {
3935
- const items = reqs.map((r) => {
3936
- const snippetText = r.usageSnippets.length > 0 ? r.usageSnippets.map((s) => {
3937
- const extra = s.extraRefs ? ` (and ${s.extraRefs} more call site${s.extraRefs > 1 ? "s" : ""} not shown)` : "";
3938
- return `File: ${s.file} (lines ${s.startLine}+, scanner: ${s.scanner})${extra}
3939
- \`\`\`
3940
- ${s.lines}
3941
- \`\`\``;
3942
- }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
3943
- return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText };
3944
- });
3945
- return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
3946
- }
3947
- function applyContext(state, reqs, results, clock = systemClock, force = false) {
3948
- const byId = new Map(reqs.map((r) => [r.id, r]));
3949
- let written = 0;
3950
- const errors = [];
3951
- for (const res of results) {
3952
- const req = byId.get(res.id);
3953
- if (!req) continue;
3954
- if (res.error) {
3955
- errors.push({ key: req.key, error: res.error });
3956
- continue;
3957
- }
3958
- const context = res.context?.trim() ?? "";
3959
- if (!context) {
3960
- errors.push({ key: req.key, error: "AI returned empty context" });
3961
- continue;
3962
- }
3963
- if (context.length > MAX_CONTEXT_LENGTH) {
3964
- errors.push({ key: req.key, error: `Context too long (${context.length} chars, max ${MAX_CONTEXT_LENGTH})` });
3965
- continue;
3966
- }
3967
- const entry = state.keys[req.key];
3968
- if (!entry || entry.context && !force) continue;
3969
- entry.context = context;
3970
- entry.contextSource = "ai";
3971
- entry.contextAt = clock();
3972
- written++;
3973
- }
3974
- return { written, errors };
3975
- }
3976
- var MAX_CONTEXT_LENGTH, SNIPPET_WINDOW, MAX_SNIPPETS, EXCLUDED_DIRS, CONTEXT_BATCH_SCHEMA;
3977
- var init_context = __esm({
3978
- "src/server/ai/context.ts"() {
3979
- "use strict";
3980
- init_state();
3981
- MAX_CONTEXT_LENGTH = 500;
3982
- SNIPPET_WINDOW = 15;
3983
- MAX_SNIPPETS = 3;
3984
- EXCLUDED_DIRS = ["node_modules/", "vendor/", "dist/", ".git/", ".glotfile/"];
3985
- CONTEXT_BATCH_SCHEMA = {
3986
- type: "object",
3987
- properties: {
3988
- items: {
3989
- type: "array",
3990
- items: {
3991
- type: "object",
3992
- properties: {
3993
- id: { type: "string" },
3994
- context: { type: "string" },
3995
- error: { type: "string" }
3996
- },
3997
- required: ["id"],
3998
- additionalProperties: false
3999
- }
4000
- }
4001
- },
4002
- required: ["items"],
4003
- additionalProperties: false
4069
+ cache2.files[relPath] = prev;
4070
+ continue;
4071
+ }
4072
+ let content;
4073
+ try {
4074
+ content = readFileSync11(abs, "utf8");
4075
+ } catch {
4076
+ continue;
4077
+ }
4078
+ cache2.files[relPath] = {
4079
+ mtime,
4080
+ size,
4081
+ refs: extractRefs(content, scanner, opts),
4082
+ prefixes: extractPrefixes(content, scanner),
4083
+ literals: extractLiterals(content)
4084
+ };
4085
+ }
4086
+ saveUsageCache(projectRoot, cache2);
4087
+ return cache2;
4088
+ }
4089
+ var PATTERNS, PREFIX_PATTERNS, CACHE_VERSION, EXT_SCANNER, ALWAYS_EXCLUDE, FLUTTER_ACCESSOR_DEFAULTS, KEY_SHAPE, STRING_LITERALS;
4090
+ var init_scanner = __esm({
4091
+ "src/server/scanner.ts"() {
4092
+ "use strict";
4093
+ init_scan();
4094
+ PATTERNS = {
4095
+ laravel: [
4096
+ /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']+)'/g,
4097
+ /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"]+)"/g,
4098
+ /@(?:lang|choice)\s*\(\s*'([^']+)'/g,
4099
+ /@(?:lang|choice)\s*\(\s*"([^"]+)"/g
4100
+ ],
4101
+ "js-i18n": [
4102
+ /\$t\s*\(\s*'([^']+)'/g,
4103
+ /\$t\s*\(\s*"([^"]+)"/g,
4104
+ /\$t\s*\(\s*`([^`$\n]+)`/g,
4105
+ /\bi18n\.t\s*\(\s*'([^']+)'/g,
4106
+ /\bi18n\.t\s*\(\s*"([^"]+)"/g,
4107
+ /\bi18next\.t\s*\(\s*'([^']+)'/g,
4108
+ /\bi18next\.t\s*\(\s*"([^"]+)"/g,
4109
+ // t('key') — word boundary before t, not preceded by dot (excludes i18n.t which is above)
4110
+ /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']+)'/g,
4111
+ /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]+)"/g,
4112
+ /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$\n]+)`/g
4113
+ ],
4114
+ gettext: [
4115
+ /\b(?:gettext|ngettext)\s*\(\s*'([^']+)'/g,
4116
+ /\b(?:gettext|ngettext)\s*\(\s*"([^"]+)"/g,
4117
+ // _() — word boundary, not preceded by alphanumeric
4118
+ /(?<![a-zA-Z0-9_$])_\s*\(\s*'([^']+)'/g,
4119
+ /(?<![a-zA-Z0-9_$])_\s*\(\s*"([^"]+)"/g
4120
+ ],
4121
+ apple: [
4122
+ /NSLocalizedString\s*\(\s*@?"([^"]+)"/g,
4123
+ /String\s*\(\s*localized:\s*"([^"]+)"/g,
4124
+ /localizedString\s*\(\s*forKey:\s*"([^"]+)"/g,
4125
+ // The "key".localized / "key".localised String-extension idiom, where the
4126
+ // literal IS the key (common when keys are natural-language source text).
4127
+ /"([^"]+)"\s*\.\s*localized\b/g,
4128
+ /"([^"]+)"\s*\.\s*localised\b/g
4129
+ ]
4130
+ };
4131
+ PREFIX_PATTERNS = {
4132
+ laravel: [
4133
+ /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']*)'\s*\./g,
4134
+ /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"]*)"\s*\./g,
4135
+ /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"${]*)\{?\$/g
4136
+ ],
4137
+ "js-i18n": [
4138
+ /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*'([^']*)'\s*\+/g,
4139
+ /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*"([^"]*)"\s*\+/g,
4140
+ /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']*)'\s*\+/g,
4141
+ /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]*)"\s*\+/g,
4142
+ /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*`([^`$]*)\$\{/g,
4143
+ /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$]*)\$\{/g
4144
+ ]
4145
+ };
4146
+ CACHE_VERSION = 6;
4147
+ EXT_SCANNER = {
4148
+ ".php": "laravel",
4149
+ ".vue": "js-i18n",
4150
+ ".js": "js-i18n",
4151
+ ".ts": "js-i18n",
4152
+ ".jsx": "js-i18n",
4153
+ ".tsx": "js-i18n",
4154
+ ".mjs": "js-i18n",
4155
+ ".cjs": "js-i18n",
4156
+ ".dart": "flutter",
4157
+ ".py": "gettext",
4158
+ ".c": "gettext",
4159
+ ".cpp": "gettext",
4160
+ ".h": "gettext",
4161
+ ".swift": "apple",
4162
+ ".m": "apple",
4163
+ ".mm": "apple"
4004
4164
  };
4165
+ ALWAYS_EXCLUDE = /* @__PURE__ */ new Set([
4166
+ "node_modules",
4167
+ ".git",
4168
+ ".glotfile",
4169
+ ".claude",
4170
+ "dist",
4171
+ "build",
4172
+ "vendor",
4173
+ "coverage",
4174
+ ".next",
4175
+ ".nuxt",
4176
+ ".turbo",
4177
+ "__pycache__"
4178
+ ]);
4179
+ FLUTTER_ACCESSOR_DEFAULTS = ["l10n", "loc", "localizations", "translations"];
4180
+ KEY_SHAPE = /^[A-Za-z0-9_][A-Za-z0-9_/-]*(?:\.(?:[A-Za-z0-9_-]+|%[sd]))+\.?$/;
4181
+ STRING_LITERALS = [
4182
+ /'([^'\\\n]+)'/g,
4183
+ /"([^"\\\n]+)"/g,
4184
+ /`([^`\\\n]+)`/g
4185
+ ];
4005
4186
  }
4006
4187
  });
4007
4188
 
@@ -4377,7 +4558,7 @@ var init_run2 = __esm({
4377
4558
  });
4378
4559
 
4379
4560
  // src/server/lint/outputs.ts
4380
- import { readFileSync as readFileSync11, existsSync as existsSync10 } from "fs";
4561
+ import { readFileSync as readFileSync12, existsSync as existsSync11 } from "fs";
4381
4562
  import { resolve as resolve8 } from "path";
4382
4563
  function checkOutputs(state, root) {
4383
4564
  const out = [];
@@ -4385,7 +4566,7 @@ function checkOutputs(state, root) {
4385
4566
  const result = getAdapter(output.adapter).export(state, output);
4386
4567
  for (const file of result.files) {
4387
4568
  const abs = resolve8(root, file.path);
4388
- const current = existsSync10(abs) ? readFileSync11(abs, "utf8") : null;
4569
+ const current = existsSync11(abs) ? readFileSync12(abs, "utf8") : null;
4389
4570
  if (current === null) {
4390
4571
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
4391
4572
  } else if (current !== file.contents) {
@@ -4430,8 +4611,8 @@ var init_accept = __esm({
4430
4611
  });
4431
4612
 
4432
4613
  // src/server/import/detect.ts
4433
- import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
4434
- import { join as join5 } from "path";
4614
+ import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync13, statSync as statSync3 } from "fs";
4615
+ import { join as join6 } from "path";
4435
4616
  function safeIsDir(p) {
4436
4617
  try {
4437
4618
  return statSync3(p).isDirectory();
@@ -4440,7 +4621,7 @@ function safeIsDir(p) {
4440
4621
  }
4441
4622
  }
4442
4623
  function listDirs(dir) {
4443
- return readdirSync4(dir).filter((e) => safeIsDir(join5(dir, e)));
4624
+ return readdirSync4(dir).filter((e) => safeIsDir(join6(dir, e)));
4444
4625
  }
4445
4626
  function fileCount(dir) {
4446
4627
  try {
@@ -4454,23 +4635,23 @@ function pickSource(locales, sizeOf) {
4454
4635
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4455
4636
  }
4456
4637
  function detectLaravel(root) {
4457
- const localeRoot = [join5(root, "resources", "lang"), join5(root, "lang")].find(safeIsDir);
4638
+ const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
4458
4639
  if (!localeRoot) return null;
4459
4640
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4460
4641
  if (locales.length === 0) return null;
4461
- const sourceLocale = pickSource(locales, (loc) => fileCount(join5(localeRoot, loc)));
4642
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
4462
4643
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4463
4644
  }
4464
4645
  function detectVue(root, forced = false) {
4465
4646
  for (const rel of VUE_DIR_CANDIDATES) {
4466
- const localeRoot = join5(root, rel);
4647
+ const localeRoot = join6(root, rel);
4467
4648
  if (!safeIsDir(localeRoot)) continue;
4468
4649
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4469
4650
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4470
4651
  if (enough) {
4471
4652
  const sourceLocale = pickSource(locales, (loc) => {
4472
4653
  try {
4473
- return statSync3(join5(localeRoot, `${loc}.json`)).size;
4654
+ return statSync3(join6(localeRoot, `${loc}.json`)).size;
4474
4655
  } catch {
4475
4656
  return 0;
4476
4657
  }
@@ -4482,7 +4663,7 @@ function detectVue(root, forced = false) {
4482
4663
  }
4483
4664
  function detectArb(root) {
4484
4665
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4485
- const localeRoot = join5(root, rel);
4666
+ const localeRoot = join6(root, rel);
4486
4667
  if (!safeIsDir(localeRoot)) continue;
4487
4668
  const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4488
4669
  if (locales.length >= 1) {
@@ -4492,10 +4673,10 @@ function detectArb(root) {
4492
4673
  return null;
4493
4674
  }
4494
4675
  function lprojLocales(dir) {
4495
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join5(dir, `${l}.lproj`, "Localizable.strings")));
4676
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join6(dir, `${l}.lproj`, "Localizable.strings")));
4496
4677
  }
4497
4678
  function detectApple(root) {
4498
- const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
4679
+ const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4499
4680
  let best = null;
4500
4681
  for (const dir of candidates) {
4501
4682
  const locales = lprojLocales(dir);
@@ -4507,7 +4688,7 @@ function detectApple(root) {
4507
4688
  locales,
4508
4689
  sourceLocale: pickSource(locales, (loc) => {
4509
4690
  try {
4510
- return statSync3(join5(dir, `${loc}.lproj`, "Localizable.strings")).size;
4691
+ return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4511
4692
  } catch {
4512
4693
  return 0;
4513
4694
  }
@@ -4519,7 +4700,7 @@ function detectApple(root) {
4519
4700
  }
4520
4701
  function detectAngularXliff(root) {
4521
4702
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4522
- const localeRoot = rel === "." ? root : join5(root, rel);
4703
+ const localeRoot = rel === "." ? root : join6(root, rel);
4523
4704
  if (!safeIsDir(localeRoot)) continue;
4524
4705
  const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4525
4706
  if (files.length === 0) continue;
@@ -4527,7 +4708,7 @@ function detectAngularXliff(root) {
4527
4708
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4528
4709
  let sourceLocale;
4529
4710
  try {
4530
- sourceLocale = readFileSync12(join5(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4711
+ sourceLocale = readFileSync13(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4531
4712
  } catch {
4532
4713
  }
4533
4714
  if (!sourceLocale && locales.length === 0) continue;
@@ -4538,14 +4719,14 @@ function detectAngularXliff(root) {
4538
4719
  return null;
4539
4720
  }
4540
4721
  function detectRails(root) {
4541
- const localeRoot = join5(root, "config", "locales");
4722
+ const localeRoot = join6(root, "config", "locales");
4542
4723
  if (!safeIsDir(localeRoot)) return null;
4543
4724
  const locales = [];
4544
4725
  for (const file of readdirSync4(localeRoot).sort()) {
4545
4726
  if (!/\.ya?ml$/.test(file)) continue;
4546
4727
  let text;
4547
4728
  try {
4548
- text = readFileSync12(join5(localeRoot, file), "utf8");
4729
+ text = readFileSync13(join6(localeRoot, file), "utf8");
4549
4730
  } catch {
4550
4731
  continue;
4551
4732
  }
@@ -4559,15 +4740,15 @@ function detectRails(root) {
4559
4740
  }
4560
4741
  function detectI18next(root) {
4561
4742
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4562
- const localeRoot = join5(root, rel);
4743
+ const localeRoot = join6(root, rel);
4563
4744
  if (!safeIsDir(localeRoot)) continue;
4564
4745
  const locales = listDirs(localeRoot).filter(
4565
- (d) => LOCALE_RE.test(d) && readdirSync4(join5(localeRoot, d)).some((f) => f.endsWith(".json"))
4746
+ (d) => LOCALE_RE.test(d) && readdirSync4(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
4566
4747
  );
4567
4748
  if (locales.length === 0) continue;
4568
4749
  const sourceLocale = pickSource(locales, (loc) => {
4569
4750
  try {
4570
- return readdirSync4(join5(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join5(localeRoot, loc, f)).size, 0);
4751
+ return readdirSync4(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join6(localeRoot, loc, f)).size, 0);
4571
4752
  } catch {
4572
4753
  return 0;
4573
4754
  }
@@ -4584,8 +4765,8 @@ function gettextLocales(dir) {
4584
4765
  if (!locales.includes(flat)) locales.push(flat);
4585
4766
  continue;
4586
4767
  }
4587
- if (!LOCALE_RE.test(entry) || !safeIsDir(join5(dir, entry))) continue;
4588
- const sub = join5(dir, entry);
4768
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4769
+ const sub = join6(dir, entry);
4589
4770
  const hasPo = (d) => {
4590
4771
  try {
4591
4772
  return readdirSync4(d).some((f) => f.endsWith(".po"));
@@ -4593,7 +4774,7 @@ function gettextLocales(dir) {
4593
4774
  return false;
4594
4775
  }
4595
4776
  };
4596
- if (hasPo(join5(sub, "LC_MESSAGES")) || hasPo(sub)) {
4777
+ if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
4597
4778
  if (!locales.includes(entry)) locales.push(entry);
4598
4779
  }
4599
4780
  }
@@ -4601,7 +4782,7 @@ function gettextLocales(dir) {
4601
4782
  }
4602
4783
  function detectGettext(root) {
4603
4784
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4604
- const localeRoot = join5(root, rel);
4785
+ const localeRoot = join6(root, rel);
4605
4786
  if (!safeIsDir(localeRoot)) continue;
4606
4787
  const locales = gettextLocales(localeRoot);
4607
4788
  if (locales.length === 0) continue;
@@ -4610,10 +4791,10 @@ function detectGettext(root) {
4610
4791
  return null;
4611
4792
  }
4612
4793
  function detectAppleStringsdict(root) {
4613
- const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
4794
+ const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4614
4795
  let best = null;
4615
4796
  for (const dir of candidates) {
4616
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join5(dir, `${l}.lproj`, "Localizable.stringsdict")));
4797
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join6(dir, `${l}.lproj`, "Localizable.stringsdict")));
4617
4798
  if (locales.length === 0) continue;
4618
4799
  if (!best || locales.length > best.locales.length) {
4619
4800
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4622,7 +4803,7 @@ function detectAppleStringsdict(root) {
4622
4803
  return best;
4623
4804
  }
4624
4805
  function detect(root, formatOverride) {
4625
- if (!existsSync11(root)) return null;
4806
+ if (!existsSync12(root)) return null;
4626
4807
  if (formatOverride) {
4627
4808
  const fn = BY_FORMAT[formatOverride];
4628
4809
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -4696,8 +4877,8 @@ var init_flatten = __esm({
4696
4877
  });
4697
4878
 
4698
4879
  // src/server/import/parsers/vue-i18n-json.ts
4699
- import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4700
- import { join as join6 } from "path";
4880
+ import { readdirSync as readdirSync5, readFileSync as readFileSync14 } from "fs";
4881
+ import { join as join7 } from "path";
4701
4882
  var LOCALE_RE2, vueI18nJson2;
4702
4883
  var init_vue_i18n_json2 = __esm({
4703
4884
  "src/server/import/parsers/vue-i18n-json.ts"() {
@@ -4717,7 +4898,7 @@ var init_vue_i18n_json2 = __esm({
4717
4898
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4718
4899
  let data;
4719
4900
  try {
4720
- data = JSON.parse(readFileSync13(join6(localeRoot, file), "utf8"));
4901
+ data = JSON.parse(readFileSync14(join7(localeRoot, file), "utf8"));
4721
4902
  } catch (e) {
4722
4903
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4723
4904
  continue;
@@ -4745,16 +4926,16 @@ var init_placeholders2 = __esm({
4745
4926
 
4746
4927
  // src/server/import/parsers/laravel-php.ts
4747
4928
  import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
4748
- import { join as join7, relative as relative2 } from "path";
4929
+ import { join as join8, relative as relative2 } from "path";
4749
4930
  import { execFileSync } from "child_process";
4750
4931
  function listDirs2(dir) {
4751
- return readdirSync6(dir).filter((e) => statSync4(join7(dir, e)).isDirectory());
4932
+ return readdirSync6(dir).filter((e) => statSync4(join8(dir, e)).isDirectory());
4752
4933
  }
4753
4934
  function listPhpFiles(dir) {
4754
4935
  const out = [];
4755
4936
  const walk = (d) => {
4756
4937
  for (const e of readdirSync6(d)) {
4757
- const full = join7(d, e);
4938
+ const full = join8(d, e);
4758
4939
  if (statSync4(full).isDirectory()) walk(full);
4759
4940
  else if (e.endsWith(".php")) out.push(full);
4760
4941
  }
@@ -4797,7 +4978,7 @@ var init_laravel_php2 = __esm({
4797
4978
  for (const locale of listDirs2(localeRoot).sort()) {
4798
4979
  if (locale === "vendor") continue;
4799
4980
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4800
- const localeDir = join7(localeRoot, locale);
4981
+ const localeDir = join8(localeRoot, locale);
4801
4982
  locales.push(locale);
4802
4983
  for (const file of listPhpFiles(localeDir)) {
4803
4984
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -4822,8 +5003,8 @@ var init_laravel_php2 = __esm({
4822
5003
  });
4823
5004
 
4824
5005
  // src/server/import/parsers/flutter-arb.ts
4825
- import { readdirSync as readdirSync7, readFileSync as readFileSync14 } from "fs";
4826
- import { join as join8 } from "path";
5006
+ import { readdirSync as readdirSync7, readFileSync as readFileSync15 } from "fs";
5007
+ import { join as join9 } from "path";
4827
5008
  function localeFromArbName(file) {
4828
5009
  const m = file.match(/^(.+)\.arb$/);
4829
5010
  if (!m) return null;
@@ -4863,7 +5044,7 @@ var init_flutter_arb2 = __esm({
4863
5044
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4864
5045
  let data;
4865
5046
  try {
4866
- data = JSON.parse(readFileSync14(join8(localeRoot, file), "utf8"));
5047
+ data = JSON.parse(readFileSync15(join9(localeRoot, file), "utf8"));
4867
5048
  } catch (e) {
4868
5049
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4869
5050
  continue;
@@ -4890,8 +5071,8 @@ var init_flutter_arb2 = __esm({
4890
5071
  });
4891
5072
 
4892
5073
  // src/server/import/parsers/apple-strings.ts
4893
- import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
4894
- import { join as join9 } from "path";
5074
+ import { readdirSync as readdirSync8, readFileSync as readFileSync16, statSync as statSync5 } from "fs";
5075
+ import { join as join10 } from "path";
4895
5076
  function localeFromLproj(dir) {
4896
5077
  const m = dir.match(/^(.+)\.lproj$/);
4897
5078
  if (!m) return null;
@@ -4999,16 +5180,16 @@ var init_apple_strings2 = __esm({
4999
5180
  const locale = localeFromLproj(dir);
5000
5181
  if (!locale) continue;
5001
5182
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5002
- const file = join9(localeRoot, dir, TABLE);
5183
+ const file = join10(localeRoot, dir, TABLE);
5003
5184
  let text;
5004
5185
  try {
5005
5186
  if (!statSync5(file).isFile()) continue;
5006
- text = readFileSync15(file, "utf8");
5187
+ text = readFileSync16(file, "utf8");
5007
5188
  } catch {
5008
5189
  continue;
5009
5190
  }
5010
5191
  locales.push(locale);
5011
- const others = readdirSync8(join9(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5192
+ const others = readdirSync8(join10(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5012
5193
  if (others.length) {
5013
5194
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
5014
5195
  }
@@ -5023,8 +5204,8 @@ var init_apple_strings2 = __esm({
5023
5204
  });
5024
5205
 
5025
5206
  // src/server/import/parsers/angular-xliff.ts
5026
- import { readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
5027
- import { join as join10 } from "path";
5207
+ import { readdirSync as readdirSync9, readFileSync as readFileSync17 } from "fs";
5208
+ import { join as join11 } from "path";
5028
5209
  function decodeEntities(s) {
5029
5210
  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, "&");
5030
5211
  }
@@ -5076,7 +5257,7 @@ var init_angular_xliff2 = __esm({
5076
5257
  if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
5077
5258
  let xml;
5078
5259
  try {
5079
- xml = readFileSync16(join10(localeRoot, file), "utf8");
5260
+ xml = readFileSync17(join11(localeRoot, file), "utf8");
5080
5261
  } catch (e) {
5081
5262
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
5082
5263
  continue;
@@ -5119,8 +5300,8 @@ var init_angular_xliff2 = __esm({
5119
5300
  });
5120
5301
 
5121
5302
  // src/server/import/parsers/gettext-po.ts
5122
- import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
5123
- import { join as join11 } from "path";
5303
+ import { readdirSync as readdirSync10, readFileSync as readFileSync18 } from "fs";
5304
+ import { join as join12 } from "path";
5124
5305
  function unescapePo(s) {
5125
5306
  return s.replace(
5126
5307
  /\\([\\"ntr])/g,
@@ -5191,17 +5372,17 @@ function discoverPoFiles(root) {
5191
5372
  for (const e of entries) {
5192
5373
  if (e.isFile() && e.name.endsWith(".po")) {
5193
5374
  const base = e.name.slice(0, -3);
5194
- found.push({ path: join11(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
5375
+ found.push({ path: join12(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
5195
5376
  } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
5196
- for (const sub of [join11(e.name, "LC_MESSAGES"), e.name]) {
5377
+ for (const sub of [join12(e.name, "LC_MESSAGES"), e.name]) {
5197
5378
  let names;
5198
5379
  try {
5199
- names = readdirSync10(join11(root, sub)).sort();
5380
+ names = readdirSync10(join12(root, sub)).sort();
5200
5381
  } catch {
5201
5382
  continue;
5202
5383
  }
5203
5384
  for (const f of names) {
5204
- if (f.endsWith(".po")) found.push({ path: join11(root, sub, f), rel: join11(sub, f), locale: e.name });
5385
+ if (f.endsWith(".po")) found.push({ path: join12(root, sub, f), rel: join12(sub, f), locale: e.name });
5205
5386
  }
5206
5387
  }
5207
5388
  }
@@ -5225,7 +5406,7 @@ var init_gettext_po2 = __esm({
5225
5406
  for (const file of discoverPoFiles(localeRoot)) {
5226
5407
  let entries;
5227
5408
  try {
5228
- entries = parseEntries(readFileSync17(file.path, "utf8"));
5409
+ entries = parseEntries(readFileSync18(file.path, "utf8"));
5229
5410
  } catch (e) {
5230
5411
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5231
5412
  continue;
@@ -5272,8 +5453,8 @@ var init_gettext_po2 = __esm({
5272
5453
  });
5273
5454
 
5274
5455
  // src/server/import/parsers/i18next-json.ts
5275
- import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5276
- import { join as join12 } from "path";
5456
+ import { readdirSync as readdirSync11, readFileSync as readFileSync19, statSync as statSync6 } from "fs";
5457
+ import { join as join13 } from "path";
5277
5458
  function safeIsDir2(p) {
5278
5459
  try {
5279
5460
  return statSync6(p).isDirectory();
@@ -5288,7 +5469,7 @@ function fromI18next(value) {
5288
5469
  function ingestFile(path, label, prefix, locale, keys, warnings) {
5289
5470
  let data;
5290
5471
  try {
5291
- data = JSON.parse(readFileSync18(path, "utf8"));
5472
+ data = JSON.parse(readFileSync19(path, "utf8"));
5292
5473
  } catch (e) {
5293
5474
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5294
5475
  return false;
@@ -5341,7 +5522,7 @@ var init_i18next_json2 = __esm({
5341
5522
  const keys = {};
5342
5523
  const locales = [];
5343
5524
  for (const entry of readdirSync11(localeRoot).sort()) {
5344
- const full = join12(localeRoot, entry);
5525
+ const full = join13(localeRoot, entry);
5345
5526
  if (safeIsDir2(full)) {
5346
5527
  if (!LOCALE_RE7.test(entry)) continue;
5347
5528
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -5350,7 +5531,7 @@ var init_i18next_json2 = __esm({
5350
5531
  if (!file.endsWith(".json")) continue;
5351
5532
  const ns = file.slice(0, -".json".length);
5352
5533
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5353
- if (ingestFile(join12(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5534
+ if (ingestFile(join13(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5354
5535
  }
5355
5536
  if (any && !locales.includes(entry)) locales.push(entry);
5356
5537
  } else if (entry.endsWith(".json")) {
@@ -5369,8 +5550,8 @@ var init_i18next_json2 = __esm({
5369
5550
  });
5370
5551
 
5371
5552
  // src/server/import/parsers/rails-yaml.ts
5372
- import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5373
- import { join as join13 } from "path";
5553
+ import { readdirSync as readdirSync12, readFileSync as readFileSync20 } from "fs";
5554
+ import { join as join14 } from "path";
5374
5555
  function fromRuby(value) {
5375
5556
  return value.replace(/%\{(\w+)\}/g, "{$1}");
5376
5557
  }
@@ -5587,7 +5768,7 @@ var init_rails_yaml2 = __esm({
5587
5768
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5588
5769
  let text;
5589
5770
  try {
5590
- text = readFileSync19(join13(localeRoot, file), "utf8");
5771
+ text = readFileSync20(join14(localeRoot, file), "utf8");
5591
5772
  } catch (e) {
5592
5773
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5593
5774
  continue;
@@ -5610,8 +5791,8 @@ var init_rails_yaml2 = __esm({
5610
5791
  });
5611
5792
 
5612
5793
  // src/server/import/parsers/apple-stringsdict.ts
5613
- import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5614
- import { join as join14 } from "path";
5794
+ import { readdirSync as readdirSync13, readFileSync as readFileSync21, statSync as statSync7 } from "fs";
5795
+ import { join as join15 } from "path";
5615
5796
  function localeFromLproj2(dir) {
5616
5797
  const m = dir.match(/^(.+)\.lproj$/);
5617
5798
  if (!m) return null;
@@ -5753,16 +5934,16 @@ var init_apple_stringsdict2 = __esm({
5753
5934
  const locale = localeFromLproj2(dir);
5754
5935
  if (!locale) continue;
5755
5936
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5756
- const file = join14(localeRoot, dir, TABLE2);
5937
+ const file = join15(localeRoot, dir, TABLE2);
5757
5938
  let text;
5758
5939
  try {
5759
5940
  if (!statSync7(file).isFile()) continue;
5760
- text = readFileSync20(file, "utf8");
5941
+ text = readFileSync21(file, "utf8");
5761
5942
  } catch {
5762
5943
  continue;
5763
5944
  }
5764
5945
  locales.push(locale);
5765
- const others = readdirSync13(join14(localeRoot, dir)).filter(
5946
+ const others = readdirSync13(join15(localeRoot, dir)).filter(
5766
5947
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
5767
5948
  );
5768
5949
  if (others.length) {
@@ -6221,12 +6402,12 @@ var init_checks = __esm({
6221
6402
  });
6222
6403
 
6223
6404
  // src/server/ui-prefs.ts
6224
- import { readFileSync as readFileSync21 } from "fs";
6405
+ import { readFileSync as readFileSync22 } from "fs";
6225
6406
  import { homedir } from "os";
6226
- import { join as join15 } from "path";
6407
+ import { join as join16 } from "path";
6227
6408
  function readJson2(path) {
6228
6409
  try {
6229
- const parsed = JSON.parse(readFileSync21(path, "utf8"));
6410
+ const parsed = JSON.parse(readFileSync22(path, "utf8"));
6230
6411
  return parsed && typeof parsed === "object" ? parsed : {};
6231
6412
  } catch {
6232
6413
  return {};
@@ -6251,7 +6432,7 @@ var init_ui_prefs = __esm({
6251
6432
  THEMES = ["system", "light", "dark"];
6252
6433
  isThemeMode = (v) => THEMES.includes(v);
6253
6434
  isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
6254
- defaultUiPrefsPath = () => join15(homedir(), ".glotfile", "ui.json");
6435
+ defaultUiPrefsPath = () => join16(homedir(), ".glotfile", "ui.json");
6255
6436
  DEFAULTS = { theme: "system" };
6256
6437
  }
6257
6438
  });
@@ -6259,19 +6440,34 @@ var init_ui_prefs = __esm({
6259
6440
  // src/server/api.ts
6260
6441
  import { Hono } from "hono";
6261
6442
  import { streamSSE } from "hono/streaming";
6262
- import { readFileSync as readFileSync22, existsSync as existsSync12, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync5 } from "fs";
6443
+ import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync6 } from "fs";
6263
6444
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
6264
6445
  function projectName(root) {
6265
6446
  const nameFile = resolve9(root, ".idea", ".name");
6266
- if (existsSync12(nameFile)) {
6447
+ if (existsSync13(nameFile)) {
6267
6448
  try {
6268
- const name = readFileSync22(nameFile, "utf8").trim();
6449
+ const name = readFileSync23(nameFile, "utf8").trim();
6269
6450
  if (name) return name;
6270
6451
  } catch {
6271
6452
  }
6272
6453
  }
6273
6454
  return basename(root);
6274
6455
  }
6456
+ function attachUsageSnippets(targets, cache2, projectRoot) {
6457
+ const fileCache = /* @__PURE__ */ new Map();
6458
+ for (const target of targets) {
6459
+ const allRefs = Object.entries(cache2.files).flatMap(
6460
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
6461
+ key: r.key,
6462
+ file,
6463
+ line: r.line,
6464
+ col: r.col,
6465
+ scanner: r.scanner
6466
+ }))
6467
+ );
6468
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
6469
+ }
6470
+ }
6275
6471
  function createApi(deps) {
6276
6472
  const app = new Hono();
6277
6473
  const load = () => loadState(deps.statePath);
@@ -6398,7 +6594,7 @@ function createApi(deps) {
6398
6594
  if (name.startsWith(".") || name === "node_modules") continue;
6399
6595
  const abs = resolve9(dir, name);
6400
6596
  let filePath = null;
6401
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync12(resolve9(abs, "config.json"))) {
6597
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync13(resolve9(abs, "config.json"))) {
6402
6598
  filePath = resolve9(dir, `${name}.json`);
6403
6599
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
6404
6600
  filePath = abs;
@@ -6432,7 +6628,7 @@ function createApi(deps) {
6432
6628
  const resolved = resolve9(projectRoot, path);
6433
6629
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
6434
6630
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
6435
- if (!existsSync12(resolved)) return c.json({ error: "file not found" }, 400);
6631
+ if (!existsSync13(resolved)) return c.json({ error: "file not found" }, 400);
6436
6632
  loadState(resolved);
6437
6633
  deps.statePath = resolved;
6438
6634
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -6493,9 +6689,9 @@ function createApi(deps) {
6493
6689
  const abs = resolve9(root, screenshot);
6494
6690
  const rel = relative4(root, abs);
6495
6691
  const seg0 = rel.split(sep2)[0] ?? "";
6496
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync12(abs)) {
6692
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync13(abs)) {
6497
6693
  try {
6498
- rmSync5(abs);
6694
+ rmSync6(abs);
6499
6695
  } catch {
6500
6696
  }
6501
6697
  }
@@ -7223,19 +7419,7 @@ function createApi(deps) {
7223
7419
  return;
7224
7420
  }
7225
7421
  await stream.writeSSE({ event: "start", data: JSON.stringify({ total: targets.length }) });
7226
- const fileCache = /* @__PURE__ */ new Map();
7227
- for (const target of targets) {
7228
- const allRefs = Object.entries(cache2.files).flatMap(
7229
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
7230
- key: r.key,
7231
- file,
7232
- line: r.line,
7233
- col: r.col,
7234
- scanner: r.scanner
7235
- }))
7236
- );
7237
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
7238
- }
7422
+ attachUsageSnippets(targets, cache2, projectRoot);
7239
7423
  const system = buildContextSystemPrompt();
7240
7424
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
7241
7425
  const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
@@ -7287,6 +7471,100 @@ function createApi(deps) {
7287
7471
  await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
7288
7472
  });
7289
7473
  });
7474
+ app.get("/context/batch/status", async (c) => {
7475
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7476
+ let supported = false;
7477
+ let provider;
7478
+ try {
7479
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7480
+ supported = supportsBatchComplete(provider);
7481
+ } catch {
7482
+ }
7483
+ const pending = loadPendingContextBatch(projectRoot);
7484
+ if (!pending) return c.json({ supported, pending: null });
7485
+ const base = { batchId: pending.batchId, createdAt: pending.createdAt, model: pending.model, total: pending.total };
7486
+ if (!provider || !supportsBatchComplete(provider)) {
7487
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null } });
7488
+ }
7489
+ try {
7490
+ const status = await provider.translationBatchStatus(pending.batchId);
7491
+ return c.json({ supported, pending: { ...base, status: status.status, counts: status.counts } });
7492
+ } catch (e) {
7493
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null, error: e.message } });
7494
+ }
7495
+ });
7496
+ app.post("/context/batch", (c) => withTranslateLock(async () => {
7497
+ const body = await c.req.json().catch(() => ({}));
7498
+ const s = load();
7499
+ const cache2 = loadUsageCache(projectRoot);
7500
+ if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
7501
+ const targets = selectContextTargets(s, {
7502
+ all: body.all,
7503
+ keyGlob: body.keyGlob,
7504
+ limit: body.limit,
7505
+ since: body.since,
7506
+ keys: body.keys,
7507
+ force: body.force
7508
+ }, cache2, body.lastRunAt);
7509
+ if (!targets.length) return c.json({ error: "Nothing to build." }, 400);
7510
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7511
+ let provider;
7512
+ try {
7513
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7514
+ } catch (e) {
7515
+ return c.json({ error: e.message }, 400);
7516
+ }
7517
+ if (!supportsBatchComplete(provider)) {
7518
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
7519
+ }
7520
+ attachUsageSnippets(targets, cache2, projectRoot);
7521
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
7522
+ let pending;
7523
+ try {
7524
+ pending = await submitContextBatch(provider, targets, batchSize, aiCfg.model, projectRoot, body.force === true);
7525
+ } catch (e) {
7526
+ return c.json({ error: e.message }, 409);
7527
+ }
7528
+ appendLog(projectRoot, {
7529
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7530
+ kind: "context",
7531
+ summary: `Submitted context batch ${pending.batchId} (${pending.total} keys)`,
7532
+ model: aiCfg.model,
7533
+ system: buildContextSystemPrompt(),
7534
+ items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source }))
7535
+ });
7536
+ console.log(`[context-batch] submitted ${pending.batchId} \u2014 ${pending.total} key(s)`);
7537
+ return c.json({ batchId: pending.batchId, total: pending.total });
7538
+ }));
7539
+ app.post("/context/batch/apply", (c) => withTranslateLock(async () => {
7540
+ const pending = loadPendingContextBatch(projectRoot);
7541
+ if (!pending) return c.json({ error: "No pending context batch." }, 404);
7542
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7543
+ let provider;
7544
+ try {
7545
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7546
+ } catch (e) {
7547
+ return c.json({ error: e.message }, 400);
7548
+ }
7549
+ if (!supportsBatchComplete(provider)) {
7550
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
7551
+ }
7552
+ const outcome = await applyContextBatchResults(load, persist, provider, pending, projectRoot, aiCfg);
7553
+ console.log(`[context-batch] applied ${pending.batchId} \u2014 wrote ${outcome.written}, ${outcome.errors.length} error(s)`);
7554
+ return c.json(outcome);
7555
+ }));
7556
+ app.post("/context/batch/cancel", async (c) => {
7557
+ const pending = loadPendingContextBatch(projectRoot);
7558
+ if (!pending) return c.json({ error: "No pending context batch." }, 404);
7559
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7560
+ try {
7561
+ const provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7562
+ if (supportsBatchComplete(provider)) await provider.cancelTranslationBatch(pending.batchId);
7563
+ } catch {
7564
+ }
7565
+ clearPendingContextBatch(projectRoot);
7566
+ return c.json({ canceled: pending.batchId });
7567
+ });
7290
7568
  app.onError(
7291
7569
  (err, c) => c.json({ error: err.message }, err instanceof GlotfileError ? 400 : 500)
7292
7570
  );
@@ -7312,6 +7590,8 @@ var init_api = __esm({
7312
7590
  init_provider();
7313
7591
  init_batch_run();
7314
7592
  init_pending_batch();
7593
+ init_context_batch_run();
7594
+ init_pending_context_batch();
7315
7595
  init_estimate();
7316
7596
  init_pricing();
7317
7597
  init_log();
@@ -7335,7 +7615,7 @@ __export(server_exports, {
7335
7615
  import { Hono as Hono2 } from "hono";
7336
7616
  import { serve } from "@hono/node-server";
7337
7617
  import { fileURLToPath } from "url";
7338
- import { dirname as dirname4, join as join16, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
7618
+ import { dirname as dirname4, join as join17, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
7339
7619
  import { readFile, stat } from "fs/promises";
7340
7620
  import { createServer } from "net";
7341
7621
  import open from "open";
@@ -7378,7 +7658,7 @@ function buildApp(opts) {
7378
7658
  const file = await readFileResponse(target);
7379
7659
  if (file) return file;
7380
7660
  }
7381
- const index = await readFileResponse(join16(root, "index.html"));
7661
+ const index = await readFileResponse(join17(root, "index.html"));
7382
7662
  if (index) return index;
7383
7663
  return c.notFound();
7384
7664
  });
@@ -7436,7 +7716,7 @@ var init_server = __esm({
7436
7716
  init_scan();
7437
7717
  init_scanner();
7438
7718
  here = dirname4(fileURLToPath(import.meta.url));
7439
- DEFAULT_UI_DIR = join16(here, "..", "ui");
7719
+ DEFAULT_UI_DIR = join17(here, "..", "ui");
7440
7720
  MIME = {
7441
7721
  ".html": "text/html; charset=utf-8",
7442
7722
  ".js": "text/javascript; charset=utf-8",
@@ -7475,6 +7755,8 @@ init_run();
7475
7755
  init_provider();
7476
7756
  init_batch_run();
7477
7757
  init_pending_batch();
7758
+ init_context_batch_run();
7759
+ init_pending_context_batch();
7478
7760
  init_estimate();
7479
7761
  init_pricing();
7480
7762
  init_log();
@@ -7483,8 +7765,8 @@ init_scanner();
7483
7765
  init_context();
7484
7766
  init_run2();
7485
7767
  init_outputs();
7486
- import { resolve as resolve11, dirname as dirname5, join as join17 } from "path";
7487
- import { readFileSync as readFileSync23, existsSync as existsSync13, mkdirSync as mkdirSync5, cpSync } from "fs";
7768
+ import { resolve as resolve11, dirname as dirname5, join as join18 } from "path";
7769
+ import { readFileSync as readFileSync24, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
7488
7770
  import { fileURLToPath as fileURLToPath2 } from "url";
7489
7771
 
7490
7772
  // src/server/lint/locate.ts
@@ -7859,11 +8141,16 @@ async function waitAndApply(args, provider, pending, ai) {
7859
8141
  async function runBatch(args) {
7860
8142
  const projectRoot = dirname5(resolve11(args.statePath));
7861
8143
  const pending = loadPendingBatch(projectRoot);
7862
- if (!pending) {
7863
- console.log("No pending batch. Start one with `glotfile translate --batch`.");
8144
+ const ctxPending = loadPendingContextBatch(projectRoot);
8145
+ if (!pending && !ctxPending) {
8146
+ console.log("No pending batch. Start one with `glotfile translate --batch` or `glotfile build-context --batch`.");
7864
8147
  return;
7865
8148
  }
7866
8149
  const action = args.batchAction ?? "status";
8150
+ if (pending) await runTranslationBatchAction(args, pending, action, projectRoot);
8151
+ if (ctxPending) await runContextBatchAction(args, ctxPending, action, projectRoot);
8152
+ }
8153
+ async function runTranslationBatchAction(args, pending, action, projectRoot) {
7867
8154
  if (action === "cancel") {
7868
8155
  let remoteFailed = false;
7869
8156
  try {
@@ -7900,6 +8187,53 @@ async function runBatch(args) {
7900
8187
  }
7901
8188
  await applyPending(args, provider, pending, ai);
7902
8189
  }
8190
+ async function runContextBatchAction(args, pending, action, projectRoot) {
8191
+ if (action === "cancel") {
8192
+ let remoteFailed = false;
8193
+ try {
8194
+ const ai2 = loadLocalSettings(projectRoot).ai;
8195
+ const provider2 = makeProvider(ai2);
8196
+ if (supportsBatchComplete(provider2)) {
8197
+ await provider2.cancelTranslationBatch(pending.batchId);
8198
+ } else {
8199
+ remoteFailed = true;
8200
+ }
8201
+ } catch {
8202
+ remoteFailed = true;
8203
+ }
8204
+ clearPendingContextBatch(projectRoot);
8205
+ const suffix = remoteFailed ? " (remote cancel failed \u2014 it will expire server-side)" : "";
8206
+ console.log(`Canceled context batch ${pending.batchId}.${suffix}`);
8207
+ return;
8208
+ }
8209
+ const ai = loadLocalSettings(projectRoot).ai;
8210
+ const provider = makeProviderOrExit(ai);
8211
+ if (!provider) return;
8212
+ if (!supportsBatchComplete(provider)) {
8213
+ console.error(`Pending context batch was submitted via anthropic, but the configured provider "${ai.provider}" has no batch support.`);
8214
+ process.exitCode = 1;
8215
+ return;
8216
+ }
8217
+ const status = await provider.translationBatchStatus(pending.batchId);
8218
+ const c = status.counts;
8219
+ console.log(`Context batch ${pending.batchId} (${pending.total} key(s), submitted ${pending.createdAt})`);
8220
+ console.log(` ${status.status} \u2014 ${c.succeeded} succeeded, ${c.processing} processing, ${c.errored} errored, ${c.expired} expired, ${c.canceled} canceled`);
8221
+ if (status.status !== "ended") {
8222
+ if (action === "apply") console.log("Not finished yet \u2014 try again later.");
8223
+ return;
8224
+ }
8225
+ const outcome = await applyContextBatchResults(
8226
+ () => loadState(args.statePath),
8227
+ (s) => saveState(args.statePath, s),
8228
+ provider,
8229
+ pending,
8230
+ projectRoot,
8231
+ ai
8232
+ );
8233
+ console.log(`Wrote context for ${outcome.written} key(s).`);
8234
+ if (outcome.retried) console.log(`${outcome.retried} job(s) re-run synchronously (batch entries failed or were malformed).`);
8235
+ for (const e of outcome.errors) console.warn(`skip ${e.key}: ${e.error}`);
8236
+ }
7903
8237
  function printReport(report, format, rawText) {
7904
8238
  if (format === "json") console.log(formatJson(report).trimEnd());
7905
8239
  else if (format === "sarif") console.log(formatSarif(report, rawText).trimEnd());
@@ -7919,7 +8253,7 @@ async function runLintCmd(args) {
7919
8253
  }
7920
8254
  return;
7921
8255
  }
7922
- const rawText = existsSync13(args.statePath) ? readFileSync23(args.statePath, "utf8") : "";
8256
+ const rawText = existsSync14(args.statePath) ? readFileSync24(args.statePath, "utf8") : "";
7923
8257
  const report = await runLint(state, {
7924
8258
  locales: args.locales,
7925
8259
  ruleIds: args.ruleIds,
@@ -7943,7 +8277,7 @@ async function runCheck(args) {
7943
8277
  process.exitCode = 1;
7944
8278
  return;
7945
8279
  }
7946
- const rawText = existsSync13(args.statePath) ? readFileSync23(args.statePath, "utf8") : "";
8280
+ const rawText = existsSync14(args.statePath) ? readFileSync24(args.statePath, "utf8") : "";
7947
8281
  const root = dirname5(resolve11(args.statePath));
7948
8282
  const lint = await runLint(state, {});
7949
8283
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
@@ -7956,7 +8290,7 @@ async function runImportCmd(args) {
7956
8290
  const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
7957
8291
  const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
7958
8292
  const out = resolve11(projectRoot, "glotfile.json");
7959
- if (existsSync13(out) && !args.importForce) {
8293
+ if (existsSync14(out) && !args.importForce) {
7960
8294
  console.error(`${out} already exists; pass --force to overwrite`);
7961
8295
  process.exitCode = 1;
7962
8296
  return;
@@ -8023,6 +8357,32 @@ async function runBuildContext(args) {
8023
8357
  const aiCfg = loadLocalSettings(projectRoot).ai;
8024
8358
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
8025
8359
  const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
8360
+ if (args.batch) {
8361
+ if (!supportsBatchComplete(provider)) {
8362
+ console.error(`Provider "${aiCfg.provider}" does not support batch mode. Currently anthropic only.`);
8363
+ process.exitCode = 1;
8364
+ return;
8365
+ }
8366
+ let pending;
8367
+ try {
8368
+ pending = await submitContextBatch(provider, targets, batchSize, aiCfg.model, projectRoot, false);
8369
+ } catch (e) {
8370
+ console.error(e.message);
8371
+ process.exitCode = 1;
8372
+ return;
8373
+ }
8374
+ appendLog(projectRoot, {
8375
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8376
+ kind: "context",
8377
+ summary: `Submitted context batch ${pending.batchId} (${pending.total} keys)`,
8378
+ model: aiCfg.model,
8379
+ system,
8380
+ items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source }))
8381
+ });
8382
+ console.log(`Submitted context batch ${pending.batchId} \u2014 ${pending.total} key(s) at 50% batch pricing.`);
8383
+ console.log("Check progress with `glotfile batch`; it applies results automatically when finished.");
8384
+ return;
8385
+ }
8026
8386
  const chunks = [];
8027
8387
  for (let i = 0; i < targets.length; i += batchSize) chunks.push(targets.slice(i, i + batchSize));
8028
8388
  let written = 0;
@@ -8104,19 +8464,19 @@ function runSplit(args) {
8104
8464
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
8105
8465
  );
8106
8466
  }
8107
- var SKILL_SRC = join17(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
8467
+ var SKILL_SRC = join18(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
8108
8468
  function runSkill(args) {
8109
8469
  if (args.print) {
8110
- console.log(readFileSync23(join17(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
8470
+ console.log(readFileSync24(join18(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
8111
8471
  return;
8112
8472
  }
8113
8473
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
8114
- if (existsSync13(dest) && !args.importForce) {
8474
+ if (existsSync14(dest) && !args.importForce) {
8115
8475
  console.error(`${dest} already exists; pass --force to overwrite`);
8116
8476
  process.exitCode = 1;
8117
8477
  return;
8118
8478
  }
8119
- mkdirSync5(dirname5(dest), { recursive: true });
8479
+ mkdirSync6(dirname5(dest), { recursive: true });
8120
8480
  cpSync(SKILL_SRC, dest, { recursive: true });
8121
8481
  console.log(`Installed the glotfile skill to ${dest}. Restart Claude Code to pick it up.`);
8122
8482
  }
@@ -8181,7 +8541,7 @@ var COMMAND_HELP = {
8181
8541
  },
8182
8542
  "build-context": {
8183
8543
  summary: "AI-generate per-key context to improve translation (requires a prior scan).",
8184
- usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>]",
8544
+ usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>] [--batch]",
8185
8545
  options: [
8186
8546
  ["--all", "(Re)build context for every key, not just those missing it"],
8187
8547
  ["--key <glob>", "Only keys matching this glob"],
@@ -8256,8 +8616,8 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
8256
8616
  );
8257
8617
  }
8258
8618
  function printVersion() {
8259
- const pkgPath = join17(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
8260
- console.log(JSON.parse(readFileSync23(pkgPath, "utf8")).version);
8619
+ const pkgPath = join18(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
8620
+ console.log(JSON.parse(readFileSync24(pkgPath, "utf8")).version);
8261
8621
  }
8262
8622
  async function main(argv) {
8263
8623
  const args = parseArgs(argv);