glotfile 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  import { Hono as Hono2 } from "hono";
3
3
  import { serve } from "@hono/node-server";
4
4
  import { fileURLToPath } from "url";
5
- import { dirname as dirname4, join as join16, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
5
+ import { dirname as dirname4, join as join17, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
6
6
  import { readFile, stat } from "fs/promises";
7
7
  import { createServer } from "net";
8
8
  import open from "open";
@@ -2841,7 +2841,7 @@ function checkOutputs(state, root) {
2841
2841
  }
2842
2842
 
2843
2843
  // src/server/api.ts
2844
- import { readFileSync as readFileSync22, existsSync as existsSync12, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync5 } from "fs";
2844
+ import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync6 } from "fs";
2845
2845
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
2846
2846
 
2847
2847
  // src/server/ai/anthropic.ts
@@ -2851,6 +2851,9 @@ import Anthropic from "@anthropic-ai/sdk";
2851
2851
  function supportsBatchTranslate(p) {
2852
2852
  return typeof p.submitTranslationBatch === "function";
2853
2853
  }
2854
+ function supportsBatchComplete(p) {
2855
+ return typeof p.submitCompletionBatch === "function";
2856
+ }
2854
2857
  function buildSystemPrompt(hasPluralItems) {
2855
2858
  const lines = [
2856
2859
  "You are a professional software localization engine for a UI string catalog.",
@@ -3082,6 +3085,67 @@ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, o
3082
3085
  return results;
3083
3086
  }
3084
3087
 
3088
+ // src/server/ai/pricing.ts
3089
+ function addUsage(into, add) {
3090
+ into.inputTokens += add.inputTokens;
3091
+ into.outputTokens += add.outputTokens;
3092
+ into.cacheCreationInputTokens += add.cacheCreationInputTokens;
3093
+ into.cacheReadInputTokens += add.cacheReadInputTokens;
3094
+ }
3095
+ var BATCH_PRICE_MULTIPLIER = 0.5;
3096
+ var CACHE_WRITE_MULTIPLIER = 1.25;
3097
+ var CACHE_READ_MULTIPLIER = 0.1;
3098
+ function usageCostUsd(usage, ai, multiplier = 1) {
3099
+ if (!usage) return void 0;
3100
+ const pricing = resolvePricing(ai);
3101
+ return pricing ? estimateUsageCostUsd(usage, pricing, multiplier) : void 0;
3102
+ }
3103
+ function estimateUsageCostUsd(usage, pricing, multiplier = 1) {
3104
+ const inputCost = (usage.inputTokens + usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER + usage.cacheReadInputTokens * CACHE_READ_MULTIPLIER) * pricing.inputPerMTok;
3105
+ return (inputCost + usage.outputTokens * pricing.outputPerMTok) / 1e6 * multiplier;
3106
+ }
3107
+ var PRICE_TABLE = [
3108
+ ["claude-fable-5", 10, 50],
3109
+ ["claude-mythos-5", 10, 50],
3110
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3111
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3112
+ ["claude-opus-4-1", 15, 75],
3113
+ ["claude-opus-4-0", 15, 75],
3114
+ ["claude-opus-4-2025", 15, 75],
3115
+ ["claude-opus-4", 5, 25],
3116
+ ["claude-sonnet-4", 3, 15],
3117
+ ["claude-haiku-4", 1, 5],
3118
+ ["claude-3-5-haiku", 0.8, 4],
3119
+ ["gpt-5.5-pro", 30, 180],
3120
+ ["gpt-5.5", 5, 30],
3121
+ ["gpt-5.4-pro", 30, 180],
3122
+ ["gpt-5.4-mini", 0.75, 4.5],
3123
+ ["gpt-5.4-nano", 0.2, 1.25],
3124
+ ["gpt-5.4", 2.5, 15],
3125
+ ["gpt-5.3-codex", 1.75, 14]
3126
+ ];
3127
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3128
+ function bareModelId(model) {
3129
+ let id = model.trim().toLowerCase();
3130
+ const slash = id.lastIndexOf("/");
3131
+ if (slash !== -1) id = id.slice(slash + 1);
3132
+ const anth = id.lastIndexOf("anthropic.");
3133
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3134
+ return id;
3135
+ }
3136
+ function resolvePricing(ai) {
3137
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3138
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3139
+ }
3140
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3141
+ const id = bareModelId(ai.model);
3142
+ let best;
3143
+ for (const row of PRICE_TABLE) {
3144
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
3145
+ }
3146
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
3147
+ }
3148
+
3085
3149
  // src/server/ai/anthropic.ts
3086
3150
  var AnthropicProvider = class {
3087
3151
  constructor(config, client) {
@@ -3097,9 +3161,25 @@ var AnthropicProvider = class {
3097
3161
  }
3098
3162
  config;
3099
3163
  client;
3164
+ usage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
3100
3165
  supportsVision() {
3101
3166
  return true;
3102
3167
  }
3168
+ recordUsage(usage) {
3169
+ if (!usage) return;
3170
+ addUsage(this.usage, {
3171
+ inputTokens: usage.input_tokens ?? 0,
3172
+ outputTokens: usage.output_tokens ?? 0,
3173
+ cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
3174
+ cacheReadInputTokens: usage.cache_read_input_tokens ?? 0
3175
+ });
3176
+ }
3177
+ takeUsage() {
3178
+ const taken = this.usage;
3179
+ this.usage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
3180
+ const any = taken.inputTokens || taken.outputTokens || taken.cacheCreationInputTokens || taken.cacheReadInputTokens;
3181
+ return any ? taken : void 0;
3182
+ }
3103
3183
  translate(reqs, onBatchComplete, signal, onMalformedReply) {
3104
3184
  return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3105
3185
  }
@@ -3121,10 +3201,13 @@ var AnthropicProvider = class {
3121
3201
  content.push({ type: "text", text: buildBatchPrompt(batch) });
3122
3202
  return content;
3123
3203
  }
3124
- async complete(req) {
3125
- const content = req.content.map(
3204
+ completionContent(req) {
3205
+ return req.content.map(
3126
3206
  (b) => b.type === "image" ? { type: "image", source: { type: "base64", media_type: b.mediaType, data: b.base64 } } : { type: "text", text: b.text ?? "" }
3127
3207
  );
3208
+ }
3209
+ async complete(req) {
3210
+ const content = this.completionContent(req);
3128
3211
  const res = await this.client.messages.create({
3129
3212
  model: this.config.model,
3130
3213
  max_tokens: req.maxTokens ?? 8192,
@@ -3132,6 +3215,7 @@ var AnthropicProvider = class {
3132
3215
  output_config: { format: { type: "json_schema", schema: req.schema } },
3133
3216
  messages: [{ role: "user", content }]
3134
3217
  });
3218
+ this.recordUsage(res.usage);
3135
3219
  const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
3136
3220
  try {
3137
3221
  return JSON.parse(text);
@@ -3173,6 +3257,7 @@ var AnthropicProvider = class {
3173
3257
  out.set(entry.custom_id, { type: "failed", error: entry.result.error?.message ?? entry.result.type });
3174
3258
  continue;
3175
3259
  }
3260
+ this.recordUsage(entry.result.message?.usage);
3176
3261
  const text = entry.result.message?.content.find((b) => b.type === "text")?.text ?? "";
3177
3262
  try {
3178
3263
  out.set(entry.custom_id, { type: "items", items: parseReplyItems(text) });
@@ -3186,6 +3271,40 @@ var AnthropicProvider = class {
3186
3271
  async cancelTranslationBatch(batchId) {
3187
3272
  await this.batchesClient().cancel(batchId);
3188
3273
  }
3274
+ // Mirrors complete() exactly — same prompts and schema — so batch and sync
3275
+ // completion replies are interchangeable downstream.
3276
+ async submitCompletionBatch(jobs) {
3277
+ const requests = jobs.map((job) => ({
3278
+ custom_id: job.customId,
3279
+ params: {
3280
+ model: this.config.model,
3281
+ max_tokens: job.request.maxTokens ?? 8192,
3282
+ // Batch entries don't share a live cache window, so cache_control is omitted here.
3283
+ system: [{ type: "text", text: job.request.system }],
3284
+ output_config: { format: { type: "json_schema", schema: job.request.schema } },
3285
+ messages: [{ role: "user", content: this.completionContent(job.request) }]
3286
+ }
3287
+ }));
3288
+ const res = await this.batchesClient().create({ requests });
3289
+ return res.id;
3290
+ }
3291
+ async completionBatchResults(batchId) {
3292
+ const out = /* @__PURE__ */ new Map();
3293
+ for await (const entry of await this.batchesClient().results(batchId)) {
3294
+ if (entry.result.type !== "succeeded") {
3295
+ out.set(entry.custom_id, { type: "failed", error: entry.result.error?.message ?? entry.result.type });
3296
+ continue;
3297
+ }
3298
+ this.recordUsage(entry.result.message?.usage);
3299
+ const text = entry.result.message?.content.find((b) => b.type === "text")?.text ?? "";
3300
+ try {
3301
+ out.set(entry.custom_id, { type: "json", value: JSON.parse(text) });
3302
+ } catch {
3303
+ out.set(entry.custom_id, { type: "malformed", raw: text });
3304
+ }
3305
+ }
3306
+ return out;
3307
+ }
3189
3308
  async callBatch(batch, signal) {
3190
3309
  const content = this.buildUserContent(batch);
3191
3310
  const res = await this.client.messages.create({
@@ -3195,6 +3314,7 @@ var AnthropicProvider = class {
3195
3314
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
3196
3315
  messages: [{ role: "user", content }]
3197
3316
  }, { signal });
3317
+ this.recordUsage(res.usage);
3198
3318
  const text = res.content.find((b) => b.type === "text")?.text ?? "";
3199
3319
  return parseReplyItems(text);
3200
3320
  }
@@ -3760,6 +3880,24 @@ function clearPendingBatch(projectRoot) {
3760
3880
  rmSync4(pendingBatchPath(projectRoot), { force: true });
3761
3881
  }
3762
3882
 
3883
+ // src/server/log.ts
3884
+ import { appendFileSync, readFileSync as readFileSync9, existsSync as existsSync9 } from "fs";
3885
+ import { resolve as resolve6 } from "path";
3886
+ function logPath(projectRoot) {
3887
+ return resolve6(projectRoot, ".glotfile", "log.jsonl");
3888
+ }
3889
+ function appendLog(projectRoot, entry) {
3890
+ ensureGlotfileDir(projectRoot);
3891
+ appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3892
+ }
3893
+ function readLog(projectRoot, limit = 100) {
3894
+ const path = logPath(projectRoot);
3895
+ if (!existsSync9(path)) return [];
3896
+ const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3897
+ const entries = lines.map((l) => JSON.parse(l));
3898
+ return entries.reverse().slice(0, limit);
3899
+ }
3900
+
3763
3901
  // src/server/ai/batch-run.ts
3764
3902
  function buildBatchJobs(reqs, batchSize) {
3765
3903
  const byLocale = /* @__PURE__ */ new Map();
@@ -3807,7 +3945,9 @@ async function submitBatchTranslation(state, provider, reqs, batchSize, model, p
3807
3945
  return pending;
3808
3946
  }
3809
3947
  async function applyBatchResults(load, persist, provider, pending, projectRoot, ai) {
3948
+ provider.takeUsage?.();
3810
3949
  const outcomes = await provider.translationBatchResults(pending.batchId);
3950
+ const batchUsage = provider.takeUsage?.();
3811
3951
  const fresh = load();
3812
3952
  const isStale = (r) => {
3813
3953
  const entry = fresh.keys[r.key];
@@ -3816,13 +3956,19 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3816
3956
  const applied = [];
3817
3957
  const results = [];
3818
3958
  const retryReqs = [];
3819
- let staleSkipped = 0;
3959
+ const stale = [];
3960
+ const jobFailures = [];
3820
3961
  for (const job of pending.jobs) {
3821
3962
  const outcome = outcomes.get(job.customId);
3822
3963
  const itemsById = outcome?.type === "items" ? new Map(outcome.items.map((i) => [i.id, i])) : null;
3964
+ if (!itemsById) {
3965
+ if (!outcome) jobFailures.push({ customId: job.customId, locale: job.locale, type: "missing" });
3966
+ else if (outcome.type === "malformed") jobFailures.push({ customId: job.customId, locale: job.locale, type: "malformed", raw: outcome.raw });
3967
+ else if (outcome.type === "failed") jobFailures.push({ customId: job.customId, locale: job.locale, type: "failed", error: outcome.error });
3968
+ }
3823
3969
  for (const stored of job.requests) {
3824
3970
  if (isStale(stored)) {
3825
- staleSkipped++;
3971
+ stale.push({ key: stored.key, locale: stored.targetLocale });
3826
3972
  continue;
3827
3973
  }
3828
3974
  const { sourceHash: _hash, ...req } = stored;
@@ -3841,7 +3987,20 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3841
3987
  const retryResults = await runLocaleParallel(
3842
3988
  retryReqs,
3843
3989
  provider,
3844
- {},
3990
+ {
3991
+ // Record the raw reply so an unparseable retry response is diagnosable
3992
+ // from the activity log instead of vanishing into per-item errors.
3993
+ onMalformedReply: (raw, batchSize, locale) => {
3994
+ appendLog(projectRoot, {
3995
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3996
+ kind: "translate",
3997
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
3998
+ model: pending.model,
3999
+ locale,
4000
+ raw
4001
+ });
4002
+ }
4003
+ },
3845
4004
  ai.concurrency,
3846
4005
  void 0,
3847
4006
  ai.batchSize
@@ -3849,53 +4008,161 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3849
4008
  applied.push(...retryReqs);
3850
4009
  results.push(...retryResults);
3851
4010
  }
4011
+ const retryUsage = provider.takeUsage?.();
4012
+ const pricing = resolvePricing({ ...ai, model: pending.model });
4013
+ let estimatedCostUsd;
4014
+ if (pricing && (batchUsage || retryUsage)) {
4015
+ estimatedCostUsd = (batchUsage ? estimateUsageCostUsd(batchUsage, pricing, BATCH_PRICE_MULTIPLIER) : 0) + (retryUsage ? estimateUsageCostUsd(retryUsage, pricing) : 0);
4016
+ }
4017
+ let usage;
4018
+ if (batchUsage || retryUsage) {
4019
+ usage = batchUsage ?? { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
4020
+ if (retryUsage) addUsage(usage, retryUsage);
4021
+ }
3852
4022
  const { written, errors } = applyResults(fresh, applied, results);
3853
4023
  persist(fresh);
3854
4024
  clearPendingBatch(projectRoot);
3855
- return { written, errors, staleSkipped, retried: retryReqs.length, screenshotsSkipped };
4025
+ const costSuffix = estimatedCostUsd !== void 0 ? ` (~$${estimatedCostUsd.toFixed(2)})` : "";
4026
+ appendLog(projectRoot, {
4027
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4028
+ kind: "translate",
4029
+ summary: `Applied batch ${pending.batchId}: wrote ${written}, ${errors.length} error(s), ${retryReqs.length} retried, ${stale.length} stale${costSuffix}`,
4030
+ model: pending.model,
4031
+ items: applied.map((r) => ({ id: r.id, key: r.key, source: r.source, targetLocale: r.targetLocale })),
4032
+ results,
4033
+ jobFailures: jobFailures.length ? jobFailures : void 0,
4034
+ stale: stale.length ? stale : void 0,
4035
+ usage,
4036
+ estimatedCostUsd
4037
+ });
4038
+ return { written, errors, staleSkipped: stale.length, retried: retryReqs.length, screenshotsSkipped };
3856
4039
  }
3857
4040
 
3858
- // src/server/ai/pricing.ts
3859
- var PRICE_TABLE = [
3860
- ["claude-fable-5", 10, 50],
3861
- ["claude-mythos-5", 10, 50],
3862
- // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3863
- // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3864
- ["claude-opus-4-1", 15, 75],
3865
- ["claude-opus-4-0", 15, 75],
3866
- ["claude-opus-4-2025", 15, 75],
3867
- ["claude-opus-4", 5, 25],
3868
- ["claude-sonnet-4", 3, 15],
3869
- ["claude-haiku-4", 1, 5],
3870
- ["claude-3-5-haiku", 0.8, 4],
3871
- ["gpt-5.5-pro", 30, 180],
3872
- ["gpt-5.5", 5, 30],
3873
- ["gpt-5.4-pro", 30, 180],
3874
- ["gpt-5.4-mini", 0.75, 4.5],
3875
- ["gpt-5.4-nano", 0.2, 1.25],
3876
- ["gpt-5.4", 2.5, 15],
3877
- ["gpt-5.3-codex", 1.75, 14]
3878
- ];
3879
- var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3880
- function bareModelId(model) {
3881
- let id = model.trim().toLowerCase();
3882
- const slash = id.lastIndexOf("/");
3883
- if (slash !== -1) id = id.slice(slash + 1);
3884
- const anth = id.lastIndexOf("anthropic.");
3885
- if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3886
- return id;
4041
+ // src/server/ai/pending-context-batch.ts
4042
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
4043
+ import { join as join5 } from "path";
4044
+ function pendingContextBatchPath(projectRoot) {
4045
+ return join5(projectRoot, ".glotfile", "context-batch.json");
3887
4046
  }
3888
- function resolvePricing(ai) {
3889
- if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3890
- return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
4047
+ function loadPendingContextBatch(projectRoot) {
4048
+ const path = pendingContextBatchPath(projectRoot);
4049
+ if (!existsSync10(path)) return void 0;
4050
+ try {
4051
+ const parsed = JSON.parse(readFileSync10(path, "utf8"));
4052
+ if (parsed?.version !== 1) return void 0;
4053
+ return parsed;
4054
+ } catch {
4055
+ return void 0;
3891
4056
  }
3892
- if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3893
- const id = bareModelId(ai.model);
3894
- let best;
3895
- for (const row of PRICE_TABLE) {
3896
- if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
4057
+ }
4058
+ function savePendingContextBatch(projectRoot, pending) {
4059
+ const dir = join5(projectRoot, ".glotfile");
4060
+ mkdirSync5(dir, { recursive: true });
4061
+ const gitignore = join5(dir, ".gitignore");
4062
+ if (!existsSync10(gitignore)) writeFileSync4(gitignore, "*\n");
4063
+ writeFileSync4(pendingContextBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4064
+ }
4065
+ function clearPendingContextBatch(projectRoot) {
4066
+ rmSync5(pendingContextBatchPath(projectRoot), { force: true });
4067
+ }
4068
+
4069
+ // src/server/ai/context-batch-run.ts
4070
+ function completionRequestFor(chunk2) {
4071
+ return {
4072
+ system: buildContextSystemPrompt(),
4073
+ content: [{ type: "text", text: buildContextBatchPrompt(chunk2) }],
4074
+ schema: CONTEXT_BATCH_SCHEMA
4075
+ };
4076
+ }
4077
+ async function submitContextBatch(provider, targets, batchSize, model, projectRoot, force) {
4078
+ if (loadPendingContextBatch(projectRoot)) {
4079
+ throw new Error("A context batch is already pending. Apply or cancel it first.");
3897
4080
  }
3898
- return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
4081
+ const chunks = [];
4082
+ const size = Math.max(1, batchSize);
4083
+ for (let i = 0; i < targets.length; i += size) chunks.push(targets.slice(i, i + size));
4084
+ const jobs = chunks.map((chunk2, i) => ({ customId: `ctx_${i}`, chunk: chunk2 }));
4085
+ const batchId = await provider.submitCompletionBatch(
4086
+ jobs.map((j) => ({ customId: j.customId, request: completionRequestFor(j.chunk) }))
4087
+ );
4088
+ const pending = {
4089
+ version: 1,
4090
+ // Only Anthropic implements completion batches today.
4091
+ provider: "anthropic",
4092
+ model,
4093
+ batchId,
4094
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4095
+ total: targets.length,
4096
+ force,
4097
+ jobs: jobs.map((j) => ({
4098
+ customId: j.customId,
4099
+ requests: j.chunk.map(({ image: _image, ...rest }) => rest)
4100
+ }))
4101
+ };
4102
+ savePendingContextBatch(projectRoot, pending);
4103
+ return pending;
4104
+ }
4105
+ async function applyContextBatchResults(load, persist, provider, pending, projectRoot, ai) {
4106
+ provider.takeUsage?.();
4107
+ const outcomes = await provider.completionBatchResults(pending.batchId);
4108
+ const batchUsage = provider.takeUsage?.();
4109
+ const applied = [];
4110
+ const items = [];
4111
+ const errors = [];
4112
+ const jobFailures = [];
4113
+ const retryChunks = [];
4114
+ for (const job of pending.jobs) {
4115
+ const outcome = outcomes.get(job.customId);
4116
+ if (outcome?.type === "json") {
4117
+ const batch = outcome.value;
4118
+ applied.push(...job.requests);
4119
+ items.push(...batch.items ?? []);
4120
+ continue;
4121
+ }
4122
+ if (!outcome) jobFailures.push({ customId: job.customId, locale: "", type: "missing" });
4123
+ else if (outcome.type === "malformed") jobFailures.push({ customId: job.customId, locale: "", type: "malformed", raw: outcome.raw });
4124
+ else jobFailures.push({ customId: job.customId, locale: "", type: "failed", error: outcome.error });
4125
+ retryChunks.push(job.requests);
4126
+ }
4127
+ for (const chunk2 of retryChunks) {
4128
+ try {
4129
+ const raw = await provider.complete(completionRequestFor(chunk2));
4130
+ const batch = raw;
4131
+ applied.push(...chunk2);
4132
+ items.push(...batch.items ?? []);
4133
+ } catch (e) {
4134
+ errors.push(...chunk2.map((t) => ({ key: t.key, error: e.message })));
4135
+ }
4136
+ }
4137
+ const retryUsage = provider.takeUsage?.();
4138
+ const pricing = resolvePricing({ ...ai, model: pending.model });
4139
+ let estimatedCostUsd;
4140
+ if (pricing && (batchUsage || retryUsage)) {
4141
+ estimatedCostUsd = (batchUsage ? estimateUsageCostUsd(batchUsage, pricing, BATCH_PRICE_MULTIPLIER) : 0) + (retryUsage ? estimateUsageCostUsd(retryUsage, pricing) : 0);
4142
+ }
4143
+ let usage;
4144
+ if (batchUsage || retryUsage) {
4145
+ usage = batchUsage ?? { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
4146
+ if (retryUsage) addUsage(usage, retryUsage);
4147
+ }
4148
+ const fresh = load();
4149
+ const { written, errors: applyErrors } = applyContext(fresh, applied, items, void 0, pending.force);
4150
+ errors.push(...applyErrors);
4151
+ persist(fresh);
4152
+ clearPendingContextBatch(projectRoot);
4153
+ const costSuffix = estimatedCostUsd !== void 0 ? ` (~$${estimatedCostUsd.toFixed(2)})` : "";
4154
+ appendLog(projectRoot, {
4155
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4156
+ kind: "context",
4157
+ summary: `Applied context batch ${pending.batchId}: wrote ${written}, ${errors.length} error(s), ${retryChunks.length} job(s) retried${costSuffix}`,
4158
+ model: pending.model,
4159
+ items: applied.map((r) => ({ id: r.id, key: r.key, source: r.source })),
4160
+ results: items.map((r) => ({ id: r.id, value: r.context, error: r.error })),
4161
+ jobFailures: jobFailures.length ? jobFailures : void 0,
4162
+ usage,
4163
+ estimatedCostUsd
4164
+ });
4165
+ return { written, errors, retried: retryChunks.length };
3899
4166
  }
3900
4167
 
3901
4168
  // src/server/ai/estimate.ts
@@ -3951,30 +4218,12 @@ function estimateTranslation(state, ai, opts) {
3951
4218
  };
3952
4219
  }
3953
4220
 
3954
- // src/server/log.ts
3955
- import { appendFileSync, readFileSync as readFileSync9, existsSync as existsSync9 } from "fs";
3956
- import { resolve as resolve6 } from "path";
3957
- function logPath(projectRoot) {
3958
- return resolve6(projectRoot, ".glotfile", "log.jsonl");
3959
- }
3960
- function appendLog(projectRoot, entry) {
3961
- ensureGlotfileDir(projectRoot);
3962
- appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3963
- }
3964
- function readLog(projectRoot, limit = 100) {
3965
- const path = logPath(projectRoot);
3966
- if (!existsSync9(path)) return [];
3967
- const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3968
- const entries = lines.map((l) => JSON.parse(l));
3969
- return entries.reverse().slice(0, limit);
3970
- }
3971
-
3972
4221
  // src/server/import/run.ts
3973
4222
  import { relative as relative3 } from "path";
3974
4223
 
3975
4224
  // src/server/import/detect.ts
3976
- import { existsSync as existsSync10, readdirSync as readdirSync3, readFileSync as readFileSync10, statSync as statSync2 } from "fs";
3977
- import { join as join5 } from "path";
4225
+ import { existsSync as existsSync11, readdirSync as readdirSync3, readFileSync as readFileSync11, statSync as statSync2 } from "fs";
4226
+ import { join as join6 } from "path";
3978
4227
  var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
3979
4228
  var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
3980
4229
  function safeIsDir(p) {
@@ -3985,7 +4234,7 @@ function safeIsDir(p) {
3985
4234
  }
3986
4235
  }
3987
4236
  function listDirs(dir) {
3988
- return readdirSync3(dir).filter((e) => safeIsDir(join5(dir, e)));
4237
+ return readdirSync3(dir).filter((e) => safeIsDir(join6(dir, e)));
3989
4238
  }
3990
4239
  function fileCount(dir) {
3991
4240
  try {
@@ -3999,23 +4248,23 @@ function pickSource(locales, sizeOf) {
3999
4248
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4000
4249
  }
4001
4250
  function detectLaravel(root) {
4002
- const localeRoot = [join5(root, "resources", "lang"), join5(root, "lang")].find(safeIsDir);
4251
+ const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
4003
4252
  if (!localeRoot) return null;
4004
4253
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4005
4254
  if (locales.length === 0) return null;
4006
- const sourceLocale = pickSource(locales, (loc) => fileCount(join5(localeRoot, loc)));
4255
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
4007
4256
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4008
4257
  }
4009
4258
  function detectVue(root, forced = false) {
4010
4259
  for (const rel of VUE_DIR_CANDIDATES) {
4011
- const localeRoot = join5(root, rel);
4260
+ const localeRoot = join6(root, rel);
4012
4261
  if (!safeIsDir(localeRoot)) continue;
4013
4262
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4014
4263
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4015
4264
  if (enough) {
4016
4265
  const sourceLocale = pickSource(locales, (loc) => {
4017
4266
  try {
4018
- return statSync2(join5(localeRoot, `${loc}.json`)).size;
4267
+ return statSync2(join6(localeRoot, `${loc}.json`)).size;
4019
4268
  } catch {
4020
4269
  return 0;
4021
4270
  }
@@ -4027,7 +4276,7 @@ function detectVue(root, forced = false) {
4027
4276
  }
4028
4277
  function detectArb(root) {
4029
4278
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4030
- const localeRoot = join5(root, rel);
4279
+ const localeRoot = join6(root, rel);
4031
4280
  if (!safeIsDir(localeRoot)) continue;
4032
4281
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4033
4282
  if (locales.length >= 1) {
@@ -4037,10 +4286,10 @@ function detectArb(root) {
4037
4286
  return null;
4038
4287
  }
4039
4288
  function lprojLocales(dir) {
4040
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync10(join5(dir, `${l}.lproj`, "Localizable.strings")));
4289
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.strings")));
4041
4290
  }
4042
4291
  function detectApple(root) {
4043
- const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
4292
+ const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4044
4293
  let best = null;
4045
4294
  for (const dir of candidates) {
4046
4295
  const locales = lprojLocales(dir);
@@ -4052,7 +4301,7 @@ function detectApple(root) {
4052
4301
  locales,
4053
4302
  sourceLocale: pickSource(locales, (loc) => {
4054
4303
  try {
4055
- return statSync2(join5(dir, `${loc}.lproj`, "Localizable.strings")).size;
4304
+ return statSync2(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4056
4305
  } catch {
4057
4306
  return 0;
4058
4307
  }
@@ -4065,7 +4314,7 @@ function detectApple(root) {
4065
4314
  var ANGULAR_DIR_CANDIDATES = [".", "src/locale", "src/locales", "src/i18n", "locale", "locales", "i18n", "translations"];
4066
4315
  function detectAngularXliff(root) {
4067
4316
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4068
- const localeRoot = rel === "." ? root : join5(root, rel);
4317
+ const localeRoot = rel === "." ? root : join6(root, rel);
4069
4318
  if (!safeIsDir(localeRoot)) continue;
4070
4319
  const files = readdirSync3(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4071
4320
  if (files.length === 0) continue;
@@ -4073,7 +4322,7 @@ function detectAngularXliff(root) {
4073
4322
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4074
4323
  let sourceLocale;
4075
4324
  try {
4076
- sourceLocale = readFileSync10(join5(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4325
+ sourceLocale = readFileSync11(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4077
4326
  } catch {
4078
4327
  }
4079
4328
  if (!sourceLocale && locales.length === 0) continue;
@@ -4084,14 +4333,14 @@ function detectAngularXliff(root) {
4084
4333
  return null;
4085
4334
  }
4086
4335
  function detectRails(root) {
4087
- const localeRoot = join5(root, "config", "locales");
4336
+ const localeRoot = join6(root, "config", "locales");
4088
4337
  if (!safeIsDir(localeRoot)) return null;
4089
4338
  const locales = [];
4090
4339
  for (const file of readdirSync3(localeRoot).sort()) {
4091
4340
  if (!/\.ya?ml$/.test(file)) continue;
4092
4341
  let text;
4093
4342
  try {
4094
- text = readFileSync10(join5(localeRoot, file), "utf8");
4343
+ text = readFileSync11(join6(localeRoot, file), "utf8");
4095
4344
  } catch {
4096
4345
  continue;
4097
4346
  }
@@ -4106,15 +4355,15 @@ function detectRails(root) {
4106
4355
  var I18NEXT_DIR_CANDIDATES = ["public/locales", "static/locales", "locales", "src/locales", "src/i18n/locales"];
4107
4356
  function detectI18next(root) {
4108
4357
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4109
- const localeRoot = join5(root, rel);
4358
+ const localeRoot = join6(root, rel);
4110
4359
  if (!safeIsDir(localeRoot)) continue;
4111
4360
  const locales = listDirs(localeRoot).filter(
4112
- (d) => LOCALE_RE.test(d) && readdirSync3(join5(localeRoot, d)).some((f) => f.endsWith(".json"))
4361
+ (d) => LOCALE_RE.test(d) && readdirSync3(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
4113
4362
  );
4114
4363
  if (locales.length === 0) continue;
4115
4364
  const sourceLocale = pickSource(locales, (loc) => {
4116
4365
  try {
4117
- return readdirSync3(join5(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync2(join5(localeRoot, loc, f)).size, 0);
4366
+ return readdirSync3(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync2(join6(localeRoot, loc, f)).size, 0);
4118
4367
  } catch {
4119
4368
  return 0;
4120
4369
  }
@@ -4131,8 +4380,8 @@ function gettextLocales(dir) {
4131
4380
  if (!locales.includes(flat)) locales.push(flat);
4132
4381
  continue;
4133
4382
  }
4134
- if (!LOCALE_RE.test(entry) || !safeIsDir(join5(dir, entry))) continue;
4135
- const sub = join5(dir, entry);
4383
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4384
+ const sub = join6(dir, entry);
4136
4385
  const hasPo = (d) => {
4137
4386
  try {
4138
4387
  return readdirSync3(d).some((f) => f.endsWith(".po"));
@@ -4140,7 +4389,7 @@ function gettextLocales(dir) {
4140
4389
  return false;
4141
4390
  }
4142
4391
  };
4143
- if (hasPo(join5(sub, "LC_MESSAGES")) || hasPo(sub)) {
4392
+ if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
4144
4393
  if (!locales.includes(entry)) locales.push(entry);
4145
4394
  }
4146
4395
  }
@@ -4149,7 +4398,7 @@ function gettextLocales(dir) {
4149
4398
  var GETTEXT_DIR_CANDIDATES = ["locale", "locales", "po", "translations"];
4150
4399
  function detectGettext(root) {
4151
4400
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4152
- const localeRoot = join5(root, rel);
4401
+ const localeRoot = join6(root, rel);
4153
4402
  if (!safeIsDir(localeRoot)) continue;
4154
4403
  const locales = gettextLocales(localeRoot);
4155
4404
  if (locales.length === 0) continue;
@@ -4158,10 +4407,10 @@ function detectGettext(root) {
4158
4407
  return null;
4159
4408
  }
4160
4409
  function detectAppleStringsdict(root) {
4161
- const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
4410
+ const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4162
4411
  let best = null;
4163
4412
  for (const dir of candidates) {
4164
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync10(join5(dir, `${l}.lproj`, "Localizable.stringsdict")));
4413
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.stringsdict")));
4165
4414
  if (locales.length === 0) continue;
4166
4415
  if (!best || locales.length > best.locales.length) {
4167
4416
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4192,7 +4441,7 @@ var BY_FORMAT = {
4192
4441
  "apple-stringsdict": detectAppleStringsdict
4193
4442
  };
4194
4443
  function detect(root, formatOverride) {
4195
- if (!existsSync10(root)) return null;
4444
+ if (!existsSync11(root)) return null;
4196
4445
  if (formatOverride) {
4197
4446
  const fn = BY_FORMAT[formatOverride];
4198
4447
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -4206,8 +4455,8 @@ function detect(root, formatOverride) {
4206
4455
  }
4207
4456
 
4208
4457
  // src/server/import/parsers/vue-i18n-json.ts
4209
- import { readdirSync as readdirSync4, readFileSync as readFileSync11 } from "fs";
4210
- import { join as join6 } from "path";
4458
+ import { readdirSync as readdirSync4, readFileSync as readFileSync12 } from "fs";
4459
+ import { join as join7 } from "path";
4211
4460
 
4212
4461
  // src/server/import/flatten.ts
4213
4462
  function flattenObject(value, prefix, warnings) {
@@ -4246,7 +4495,7 @@ var vueI18nJson2 = {
4246
4495
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4247
4496
  let data;
4248
4497
  try {
4249
- data = JSON.parse(readFileSync11(join6(localeRoot, file), "utf8"));
4498
+ data = JSON.parse(readFileSync12(join7(localeRoot, file), "utf8"));
4250
4499
  } catch (e) {
4251
4500
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4252
4501
  continue;
@@ -4262,7 +4511,7 @@ var vueI18nJson2 = {
4262
4511
 
4263
4512
  // src/server/import/parsers/laravel-php.ts
4264
4513
  import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
4265
- import { join as join7, relative as relative2 } from "path";
4514
+ import { join as join8, relative as relative2 } from "path";
4266
4515
  import { execFileSync } from "child_process";
4267
4516
 
4268
4517
  // src/server/import/placeholders.ts
@@ -4272,13 +4521,13 @@ function laravelToCanonical(value) {
4272
4521
 
4273
4522
  // src/server/import/parsers/laravel-php.ts
4274
4523
  function listDirs2(dir) {
4275
- return readdirSync5(dir).filter((e) => statSync3(join7(dir, e)).isDirectory());
4524
+ return readdirSync5(dir).filter((e) => statSync3(join8(dir, e)).isDirectory());
4276
4525
  }
4277
4526
  function listPhpFiles(dir) {
4278
4527
  const out = [];
4279
4528
  const walk = (d) => {
4280
4529
  for (const e of readdirSync5(d)) {
4281
- const full = join7(d, e);
4530
+ const full = join8(d, e);
4282
4531
  if (statSync3(full).isDirectory()) walk(full);
4283
4532
  else if (e.endsWith(".php")) out.push(full);
4284
4533
  }
@@ -4315,7 +4564,7 @@ var laravelPhp2 = {
4315
4564
  for (const locale of listDirs2(localeRoot).sort()) {
4316
4565
  if (locale === "vendor") continue;
4317
4566
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4318
- const localeDir = join7(localeRoot, locale);
4567
+ const localeDir = join8(localeRoot, locale);
4319
4568
  locales.push(locale);
4320
4569
  for (const file of listPhpFiles(localeDir)) {
4321
4570
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -4338,8 +4587,8 @@ var laravelPhp2 = {
4338
4587
  };
4339
4588
 
4340
4589
  // src/server/import/parsers/flutter-arb.ts
4341
- import { readdirSync as readdirSync6, readFileSync as readFileSync12 } from "fs";
4342
- import { join as join8 } from "path";
4590
+ import { readdirSync as readdirSync6, readFileSync as readFileSync13 } from "fs";
4591
+ import { join as join9 } from "path";
4343
4592
  var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4344
4593
  function localeFromArbName(file) {
4345
4594
  const m = file.match(/^(.+)\.arb$/);
@@ -4375,7 +4624,7 @@ var flutterArb2 = {
4375
4624
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4376
4625
  let data;
4377
4626
  try {
4378
- data = JSON.parse(readFileSync12(join8(localeRoot, file), "utf8"));
4627
+ data = JSON.parse(readFileSync13(join9(localeRoot, file), "utf8"));
4379
4628
  } catch (e) {
4380
4629
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4381
4630
  continue;
@@ -4400,8 +4649,8 @@ var flutterArb2 = {
4400
4649
  };
4401
4650
 
4402
4651
  // src/server/import/parsers/apple-strings.ts
4403
- import { readdirSync as readdirSync7, readFileSync as readFileSync13, statSync as statSync4 } from "fs";
4404
- import { join as join9 } from "path";
4652
+ import { readdirSync as readdirSync7, readFileSync as readFileSync14, statSync as statSync4 } from "fs";
4653
+ import { join as join10 } from "path";
4405
4654
  var LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4406
4655
  var TABLE = "Localizable.strings";
4407
4656
  function localeFromLproj(dir) {
@@ -4505,16 +4754,16 @@ var appleStrings2 = {
4505
4754
  const locale = localeFromLproj(dir);
4506
4755
  if (!locale) continue;
4507
4756
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4508
- const file = join9(localeRoot, dir, TABLE);
4757
+ const file = join10(localeRoot, dir, TABLE);
4509
4758
  let text;
4510
4759
  try {
4511
4760
  if (!statSync4(file).isFile()) continue;
4512
- text = readFileSync13(file, "utf8");
4761
+ text = readFileSync14(file, "utf8");
4513
4762
  } catch {
4514
4763
  continue;
4515
4764
  }
4516
4765
  locales.push(locale);
4517
- const others = readdirSync7(join9(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
4766
+ const others = readdirSync7(join10(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
4518
4767
  if (others.length) {
4519
4768
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4520
4769
  }
@@ -4527,8 +4776,8 @@ var appleStrings2 = {
4527
4776
  };
4528
4777
 
4529
4778
  // src/server/import/parsers/angular-xliff.ts
4530
- import { readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
4531
- import { join as join10 } from "path";
4779
+ import { readdirSync as readdirSync8, readFileSync as readFileSync15 } from "fs";
4780
+ import { join as join11 } from "path";
4532
4781
  var LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4533
4782
  var FILE_RE = /^messages(?:\.(.+))?\.xlf$/;
4534
4783
  function decodeEntities(s) {
@@ -4576,7 +4825,7 @@ var angularXliff2 = {
4576
4825
  if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
4577
4826
  let xml;
4578
4827
  try {
4579
- xml = readFileSync14(join10(localeRoot, file), "utf8");
4828
+ xml = readFileSync15(join11(localeRoot, file), "utf8");
4580
4829
  } catch (e) {
4581
4830
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
4582
4831
  continue;
@@ -4617,8 +4866,8 @@ var angularXliff2 = {
4617
4866
  };
4618
4867
 
4619
4868
  // src/server/import/parsers/gettext-po.ts
4620
- import { readdirSync as readdirSync9, readFileSync as readFileSync15 } from "fs";
4621
- import { join as join11 } from "path";
4869
+ import { readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
4870
+ import { join as join12 } from "path";
4622
4871
  var LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4623
4872
  var DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
4624
4873
  var CONT_RE = /^[ \t]*"(.*)"\s*$/;
@@ -4692,17 +4941,17 @@ function discoverPoFiles(root) {
4692
4941
  for (const e of entries) {
4693
4942
  if (e.isFile() && e.name.endsWith(".po")) {
4694
4943
  const base = e.name.slice(0, -3);
4695
- found.push({ path: join11(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
4944
+ found.push({ path: join12(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
4696
4945
  } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
4697
- for (const sub of [join11(e.name, "LC_MESSAGES"), e.name]) {
4946
+ for (const sub of [join12(e.name, "LC_MESSAGES"), e.name]) {
4698
4947
  let names;
4699
4948
  try {
4700
- names = readdirSync9(join11(root, sub)).sort();
4949
+ names = readdirSync9(join12(root, sub)).sort();
4701
4950
  } catch {
4702
4951
  continue;
4703
4952
  }
4704
4953
  for (const f of names) {
4705
- if (f.endsWith(".po")) found.push({ path: join11(root, sub, f), rel: join11(sub, f), locale: e.name });
4954
+ if (f.endsWith(".po")) found.push({ path: join12(root, sub, f), rel: join12(sub, f), locale: e.name });
4706
4955
  }
4707
4956
  }
4708
4957
  }
@@ -4718,7 +4967,7 @@ var gettextPo2 = {
4718
4967
  for (const file of discoverPoFiles(localeRoot)) {
4719
4968
  let entries;
4720
4969
  try {
4721
- entries = parseEntries(readFileSync15(file.path, "utf8"));
4970
+ entries = parseEntries(readFileSync16(file.path, "utf8"));
4722
4971
  } catch (e) {
4723
4972
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
4724
4973
  continue;
@@ -4763,8 +5012,8 @@ var gettextPo2 = {
4763
5012
  };
4764
5013
 
4765
5014
  // src/server/import/parsers/i18next-json.ts
4766
- import { readdirSync as readdirSync10, readFileSync as readFileSync16, statSync as statSync5 } from "fs";
4767
- import { join as join12 } from "path";
5015
+ import { readdirSync as readdirSync10, readFileSync as readFileSync17, statSync as statSync5 } from "fs";
5016
+ import { join as join13 } from "path";
4768
5017
  var LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4769
5018
  var PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
4770
5019
  var PLURAL_ARG = "count";
@@ -4783,7 +5032,7 @@ function fromI18next(value) {
4783
5032
  function ingestFile(path, label, prefix, locale, keys, warnings) {
4784
5033
  let data;
4785
5034
  try {
4786
- data = JSON.parse(readFileSync16(path, "utf8"));
5035
+ data = JSON.parse(readFileSync17(path, "utf8"));
4787
5036
  } catch (e) {
4788
5037
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
4789
5038
  return false;
@@ -4825,7 +5074,7 @@ var i18nextJson2 = {
4825
5074
  const keys = {};
4826
5075
  const locales = [];
4827
5076
  for (const entry of readdirSync10(localeRoot).sort()) {
4828
- const full = join12(localeRoot, entry);
5077
+ const full = join13(localeRoot, entry);
4829
5078
  if (safeIsDir2(full)) {
4830
5079
  if (!LOCALE_RE7.test(entry)) continue;
4831
5080
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -4834,7 +5083,7 @@ var i18nextJson2 = {
4834
5083
  if (!file.endsWith(".json")) continue;
4835
5084
  const ns = file.slice(0, -".json".length);
4836
5085
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
4837
- if (ingestFile(join12(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5086
+ if (ingestFile(join13(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
4838
5087
  }
4839
5088
  if (any && !locales.includes(entry)) locales.push(entry);
4840
5089
  } else if (entry.endsWith(".json")) {
@@ -4851,8 +5100,8 @@ var i18nextJson2 = {
4851
5100
  };
4852
5101
 
4853
5102
  // src/server/import/parsers/rails-yaml.ts
4854
- import { readdirSync as readdirSync11, readFileSync as readFileSync17 } from "fs";
4855
- import { join as join13 } from "path";
5103
+ import { readdirSync as readdirSync11, readFileSync as readFileSync18 } from "fs";
5104
+ import { join as join14 } from "path";
4856
5105
  var LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
4857
5106
  var CATEGORY_SET = new Set(PLURAL_CATEGORIES);
4858
5107
  function fromRuby(value) {
@@ -5064,7 +5313,7 @@ var railsYaml2 = {
5064
5313
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5065
5314
  let text;
5066
5315
  try {
5067
- text = readFileSync17(join13(localeRoot, file), "utf8");
5316
+ text = readFileSync18(join14(localeRoot, file), "utf8");
5068
5317
  } catch (e) {
5069
5318
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5070
5319
  continue;
@@ -5085,8 +5334,8 @@ var railsYaml2 = {
5085
5334
  };
5086
5335
 
5087
5336
  // src/server/import/parsers/apple-stringsdict.ts
5088
- import { readdirSync as readdirSync12, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5089
- import { join as join14 } from "path";
5337
+ import { readdirSync as readdirSync12, readFileSync as readFileSync19, statSync as statSync6 } from "fs";
5338
+ import { join as join15 } from "path";
5090
5339
  var LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5091
5340
  var TABLE2 = "Localizable.stringsdict";
5092
5341
  function localeFromLproj2(dir) {
@@ -5222,16 +5471,16 @@ var appleStringsdict2 = {
5222
5471
  const locale = localeFromLproj2(dir);
5223
5472
  if (!locale) continue;
5224
5473
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5225
- const file = join14(localeRoot, dir, TABLE2);
5474
+ const file = join15(localeRoot, dir, TABLE2);
5226
5475
  let text;
5227
5476
  try {
5228
5477
  if (!statSync6(file).isFile()) continue;
5229
- text = readFileSync18(file, "utf8");
5478
+ text = readFileSync19(file, "utf8");
5230
5479
  } catch {
5231
5480
  continue;
5232
5481
  }
5233
5482
  locales.push(locale);
5234
- const others = readdirSync12(join14(localeRoot, dir)).filter(
5483
+ const others = readdirSync12(join15(localeRoot, dir)).filter(
5235
5484
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
5236
5485
  );
5237
5486
  if (others.length) {
@@ -5404,7 +5653,7 @@ function runImport(opts) {
5404
5653
  }
5405
5654
 
5406
5655
  // src/server/export-run.ts
5407
- import { existsSync as existsSync11, readFileSync as readFileSync19, readdirSync as readdirSync13, rmdirSync, statSync as statSync7, unlinkSync } from "fs";
5656
+ import { existsSync as existsSync12, readFileSync as readFileSync20, readdirSync as readdirSync13, rmdirSync, statSync as statSync7, unlinkSync } from "fs";
5408
5657
  import { dirname as dirname2, resolve as resolve7, sep } from "path";
5409
5658
  function effectiveLocales(config) {
5410
5659
  const limit = config.exportLocales;
@@ -5447,7 +5696,7 @@ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
5447
5696
  if (!segment.includes("{locale}") && !segment.includes("{namespace}")) {
5448
5697
  const next = resolve7(dir, segment);
5449
5698
  if (isLast) {
5450
- if (stale(locale) && existsSync11(next) && statSync7(next).isFile()) {
5699
+ if (stale(locale) && existsSync12(next) && statSync7(next).isFile()) {
5451
5700
  unlinkSync(next);
5452
5701
  deleted++;
5453
5702
  removeEmptyDirs(dir, root);
@@ -5503,7 +5752,7 @@ function exportToDisk(state, projectRoot, opts) {
5503
5752
  writtenPaths.add(abs);
5504
5753
  let current = null;
5505
5754
  try {
5506
- current = readFileSync19(abs, "utf8");
5755
+ current = readFileSync20(abs, "utf8");
5507
5756
  } catch {
5508
5757
  }
5509
5758
  if (current === f.contents) {
@@ -5520,17 +5769,17 @@ function exportToDisk(state, projectRoot, opts) {
5520
5769
  }
5521
5770
 
5522
5771
  // src/server/ui-prefs.ts
5523
- import { readFileSync as readFileSync20 } from "fs";
5772
+ import { readFileSync as readFileSync21 } from "fs";
5524
5773
  import { homedir } from "os";
5525
- import { join as join15 } from "path";
5774
+ import { join as join16 } from "path";
5526
5775
  var THEMES = ["system", "light", "dark"];
5527
5776
  var isThemeMode = (v) => THEMES.includes(v);
5528
5777
  var isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
5529
- var defaultUiPrefsPath = () => join15(homedir(), ".glotfile", "ui.json");
5778
+ var defaultUiPrefsPath = () => join16(homedir(), ".glotfile", "ui.json");
5530
5779
  var DEFAULTS = { theme: "system" };
5531
5780
  function readJson(path) {
5532
5781
  try {
5533
- const parsed = JSON.parse(readFileSync20(path, "utf8"));
5782
+ const parsed = JSON.parse(readFileSync21(path, "utf8"));
5534
5783
  return parsed && typeof parsed === "object" ? parsed : {};
5535
5784
  } catch {
5536
5785
  return {};
@@ -5549,7 +5798,7 @@ function saveUiPrefs(path, prefs) {
5549
5798
  }
5550
5799
 
5551
5800
  // src/server/local-settings.ts
5552
- import { readFileSync as readFileSync21 } from "fs";
5801
+ import { readFileSync as readFileSync22 } from "fs";
5553
5802
  import { resolve as resolve8 } from "path";
5554
5803
  var EDITOR_IDS = ["vscode", "zed", "phpstorm"];
5555
5804
  var isEditorId = (v) => EDITOR_IDS.includes(v);
@@ -5564,7 +5813,7 @@ var DEFAULT_EDITOR = "vscode";
5564
5813
  var settingsPath = (projectRoot) => resolve8(projectRoot, ".glotfile", "settings.json");
5565
5814
  function readJson2(path) {
5566
5815
  try {
5567
- const parsed = JSON.parse(readFileSync21(path, "utf8"));
5816
+ const parsed = JSON.parse(readFileSync22(path, "utf8"));
5568
5817
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
5569
5818
  } catch {
5570
5819
  return {};
@@ -5635,15 +5884,30 @@ var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
5635
5884
  var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
5636
5885
  function projectName(root) {
5637
5886
  const nameFile = resolve9(root, ".idea", ".name");
5638
- if (existsSync12(nameFile)) {
5887
+ if (existsSync13(nameFile)) {
5639
5888
  try {
5640
- const name = readFileSync22(nameFile, "utf8").trim();
5889
+ const name = readFileSync23(nameFile, "utf8").trim();
5641
5890
  if (name) return name;
5642
5891
  } catch {
5643
5892
  }
5644
5893
  }
5645
5894
  return basename(root);
5646
5895
  }
5896
+ function attachUsageSnippets(targets, cache2, projectRoot) {
5897
+ const fileCache = /* @__PURE__ */ new Map();
5898
+ for (const target of targets) {
5899
+ const allRefs = Object.entries(cache2.files).flatMap(
5900
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
5901
+ key: r.key,
5902
+ file,
5903
+ line: r.line,
5904
+ col: r.col,
5905
+ scanner: r.scanner
5906
+ }))
5907
+ );
5908
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
5909
+ }
5910
+ }
5647
5911
  function createApi(deps) {
5648
5912
  const app = new Hono();
5649
5913
  const load = () => loadState(deps.statePath);
@@ -5770,7 +6034,7 @@ function createApi(deps) {
5770
6034
  if (name.startsWith(".") || name === "node_modules") continue;
5771
6035
  const abs = resolve9(dir, name);
5772
6036
  let filePath = null;
5773
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync12(resolve9(abs, "config.json"))) {
6037
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync13(resolve9(abs, "config.json"))) {
5774
6038
  filePath = resolve9(dir, `${name}.json`);
5775
6039
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
5776
6040
  filePath = abs;
@@ -5804,7 +6068,7 @@ function createApi(deps) {
5804
6068
  const resolved = resolve9(projectRoot, path);
5805
6069
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
5806
6070
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
5807
- if (!existsSync12(resolved)) return c.json({ error: "file not found" }, 400);
6071
+ if (!existsSync13(resolved)) return c.json({ error: "file not found" }, 400);
5808
6072
  loadState(resolved);
5809
6073
  deps.statePath = resolved;
5810
6074
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -5865,9 +6129,9 @@ function createApi(deps) {
5865
6129
  const abs = resolve9(root, screenshot);
5866
6130
  const rel = relative4(root, abs);
5867
6131
  const seg0 = rel.split(sep2)[0] ?? "";
5868
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync12(abs)) {
6132
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync13(abs)) {
5869
6133
  try {
5870
- rmSync5(abs);
6134
+ rmSync6(abs);
5871
6135
  } catch {
5872
6136
  }
5873
6137
  }
@@ -6293,6 +6557,7 @@ function createApi(deps) {
6293
6557
  persist(fresh);
6294
6558
  totalWritten += written;
6295
6559
  allErrors.push(...errors);
6560
+ const usage = provider.takeUsage?.();
6296
6561
  appendLog(projectRoot, {
6297
6562
  at: (/* @__PURE__ */ new Date()).toISOString(),
6298
6563
  kind: "translate",
@@ -6303,7 +6568,9 @@ function createApi(deps) {
6303
6568
  const req = reqById.get(r.id);
6304
6569
  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 };
6305
6570
  }),
6306
- results: batchResults
6571
+ results: batchResults,
6572
+ usage,
6573
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
6307
6574
  });
6308
6575
  const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
6309
6576
  localeDone.set(locale, ld);
@@ -6375,11 +6642,14 @@ function createApi(deps) {
6375
6642
  }, aiCfg.concurrency, void 0, aiCfg.batchSize);
6376
6643
  const latest = load();
6377
6644
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
6645
+ const usage = provider.takeUsage?.();
6378
6646
  const entry = {
6379
6647
  at: (/* @__PURE__ */ new Date()).toISOString(),
6380
6648
  kind: "translate",
6381
6649
  summary: `Translated ${toTranslate.length} item(s)`,
6382
6650
  model: aiCfg.model,
6651
+ usage,
6652
+ estimatedCostUsd: usageCostUsd(usage, aiCfg),
6383
6653
  system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
6384
6654
  // Log the screenshot PATH only — never the image bytes.
6385
6655
  items: toTranslate.map((r) => ({
@@ -6477,17 +6747,7 @@ function createApi(deps) {
6477
6747
  if (!supportsBatchTranslate(provider)) {
6478
6748
  return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
6479
6749
  }
6480
- const outcome = await applyBatchResults(load, persist, provider, pending, projectRoot, {
6481
- batchSize: aiCfg.batchSize,
6482
- concurrency: aiCfg.concurrency
6483
- });
6484
- appendLog(projectRoot, {
6485
- at: (/* @__PURE__ */ new Date()).toISOString(),
6486
- kind: "translate",
6487
- summary: `Applied batch ${pending.batchId}: wrote ${outcome.written}, ${outcome.retried} retried, ${outcome.staleSkipped} stale`,
6488
- model: aiCfg.model,
6489
- results: []
6490
- });
6750
+ const outcome = await applyBatchResults(load, persist, provider, pending, projectRoot, aiCfg);
6491
6751
  console.log(`[batch] applied ${pending.batchId} \u2014 wrote ${outcome.written}, ${outcome.errors.length} error(s)`);
6492
6752
  return c.json(outcome);
6493
6753
  }));
@@ -6599,19 +6859,7 @@ function createApi(deps) {
6599
6859
  return;
6600
6860
  }
6601
6861
  await stream.writeSSE({ event: "start", data: JSON.stringify({ total: targets.length }) });
6602
- const fileCache = /* @__PURE__ */ new Map();
6603
- for (const target of targets) {
6604
- const allRefs = Object.entries(cache2.files).flatMap(
6605
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
6606
- key: r.key,
6607
- file,
6608
- line: r.line,
6609
- col: r.col,
6610
- scanner: r.scanner
6611
- }))
6612
- );
6613
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
6614
- }
6862
+ attachUsageSnippets(targets, cache2, projectRoot);
6615
6863
  const system = buildContextSystemPrompt();
6616
6864
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
6617
6865
  const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
@@ -6638,6 +6886,7 @@ function createApi(deps) {
6638
6886
  const batch = raw;
6639
6887
  const fresh = load();
6640
6888
  const { written, errors } = applyContext(fresh, chunk2, batch.items ?? [], void 0, body.force === true);
6889
+ const usage = provider.takeUsage?.();
6641
6890
  appendLog(projectRoot, {
6642
6891
  at: (/* @__PURE__ */ new Date()).toISOString(),
6643
6892
  kind: "context",
@@ -6645,7 +6894,9 @@ function createApi(deps) {
6645
6894
  model: aiCfg.model,
6646
6895
  system,
6647
6896
  items: chunk2.map((t) => ({ id: t.id, key: t.key, source: t.source })),
6648
- results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
6897
+ results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error })),
6898
+ usage,
6899
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
6649
6900
  });
6650
6901
  persist(fresh);
6651
6902
  totalWritten += written;
@@ -6660,6 +6911,100 @@ function createApi(deps) {
6660
6911
  await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
6661
6912
  });
6662
6913
  });
6914
+ app.get("/context/batch/status", async (c) => {
6915
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6916
+ let supported = false;
6917
+ let provider;
6918
+ try {
6919
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6920
+ supported = supportsBatchComplete(provider);
6921
+ } catch {
6922
+ }
6923
+ const pending = loadPendingContextBatch(projectRoot);
6924
+ if (!pending) return c.json({ supported, pending: null });
6925
+ const base = { batchId: pending.batchId, createdAt: pending.createdAt, model: pending.model, total: pending.total };
6926
+ if (!provider || !supportsBatchComplete(provider)) {
6927
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null } });
6928
+ }
6929
+ try {
6930
+ const status = await provider.translationBatchStatus(pending.batchId);
6931
+ return c.json({ supported, pending: { ...base, status: status.status, counts: status.counts } });
6932
+ } catch (e) {
6933
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null, error: e.message } });
6934
+ }
6935
+ });
6936
+ app.post("/context/batch", (c) => withTranslateLock(async () => {
6937
+ const body = await c.req.json().catch(() => ({}));
6938
+ const s = load();
6939
+ const cache2 = loadUsageCache(projectRoot);
6940
+ if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
6941
+ const targets = selectContextTargets(s, {
6942
+ all: body.all,
6943
+ keyGlob: body.keyGlob,
6944
+ limit: body.limit,
6945
+ since: body.since,
6946
+ keys: body.keys,
6947
+ force: body.force
6948
+ }, cache2, body.lastRunAt);
6949
+ if (!targets.length) return c.json({ error: "Nothing to build." }, 400);
6950
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6951
+ let provider;
6952
+ try {
6953
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6954
+ } catch (e) {
6955
+ return c.json({ error: e.message }, 400);
6956
+ }
6957
+ if (!supportsBatchComplete(provider)) {
6958
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
6959
+ }
6960
+ attachUsageSnippets(targets, cache2, projectRoot);
6961
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
6962
+ let pending;
6963
+ try {
6964
+ pending = await submitContextBatch(provider, targets, batchSize, aiCfg.model, projectRoot, body.force === true);
6965
+ } catch (e) {
6966
+ return c.json({ error: e.message }, 409);
6967
+ }
6968
+ appendLog(projectRoot, {
6969
+ at: (/* @__PURE__ */ new Date()).toISOString(),
6970
+ kind: "context",
6971
+ summary: `Submitted context batch ${pending.batchId} (${pending.total} keys)`,
6972
+ model: aiCfg.model,
6973
+ system: buildContextSystemPrompt(),
6974
+ items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source }))
6975
+ });
6976
+ console.log(`[context-batch] submitted ${pending.batchId} \u2014 ${pending.total} key(s)`);
6977
+ return c.json({ batchId: pending.batchId, total: pending.total });
6978
+ }));
6979
+ app.post("/context/batch/apply", (c) => withTranslateLock(async () => {
6980
+ const pending = loadPendingContextBatch(projectRoot);
6981
+ if (!pending) return c.json({ error: "No pending context batch." }, 404);
6982
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6983
+ let provider;
6984
+ try {
6985
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6986
+ } catch (e) {
6987
+ return c.json({ error: e.message }, 400);
6988
+ }
6989
+ if (!supportsBatchComplete(provider)) {
6990
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
6991
+ }
6992
+ const outcome = await applyContextBatchResults(load, persist, provider, pending, projectRoot, aiCfg);
6993
+ console.log(`[context-batch] applied ${pending.batchId} \u2014 wrote ${outcome.written}, ${outcome.errors.length} error(s)`);
6994
+ return c.json(outcome);
6995
+ }));
6996
+ app.post("/context/batch/cancel", async (c) => {
6997
+ const pending = loadPendingContextBatch(projectRoot);
6998
+ if (!pending) return c.json({ error: "No pending context batch." }, 404);
6999
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7000
+ try {
7001
+ const provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7002
+ if (supportsBatchComplete(provider)) await provider.cancelTranslationBatch(pending.batchId);
7003
+ } catch {
7004
+ }
7005
+ clearPendingContextBatch(projectRoot);
7006
+ return c.json({ canceled: pending.batchId });
7007
+ });
6663
7008
  app.onError(
6664
7009
  (err, c) => c.json({ error: err.message }, err instanceof GlotfileError ? 400 : 500)
6665
7010
  );
@@ -6668,7 +7013,7 @@ function createApi(deps) {
6668
7013
 
6669
7014
  // src/server/server.ts
6670
7015
  var here = dirname4(fileURLToPath(import.meta.url));
6671
- var DEFAULT_UI_DIR = join16(here, "..", "ui");
7016
+ var DEFAULT_UI_DIR = join17(here, "..", "ui");
6672
7017
  var MIME = {
6673
7018
  ".html": "text/html; charset=utf-8",
6674
7019
  ".js": "text/javascript; charset=utf-8",
@@ -6722,7 +7067,7 @@ function buildApp(opts) {
6722
7067
  const file = await readFileResponse(target);
6723
7068
  if (file) return file;
6724
7069
  }
6725
- const index = await readFileResponse(join16(root, "index.html"));
7070
+ const index = await readFileResponse(join17(root, "index.html"));
6726
7071
  if (index) return index;
6727
7072
  return c.notFound();
6728
7073
  });