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.
@@ -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.",
@@ -2216,6 +2219,73 @@ var init_batch = __esm({
2216
2219
  }
2217
2220
  });
2218
2221
 
2222
+ // src/server/ai/pricing.ts
2223
+ function addUsage(into, add) {
2224
+ into.inputTokens += add.inputTokens;
2225
+ into.outputTokens += add.outputTokens;
2226
+ into.cacheCreationInputTokens += add.cacheCreationInputTokens;
2227
+ into.cacheReadInputTokens += add.cacheReadInputTokens;
2228
+ }
2229
+ function usageCostUsd(usage, ai, multiplier = 1) {
2230
+ if (!usage) return void 0;
2231
+ const pricing = resolvePricing(ai);
2232
+ return pricing ? estimateUsageCostUsd(usage, pricing, multiplier) : void 0;
2233
+ }
2234
+ function estimateUsageCostUsd(usage, pricing, multiplier = 1) {
2235
+ const inputCost = (usage.inputTokens + usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER + usage.cacheReadInputTokens * CACHE_READ_MULTIPLIER) * pricing.inputPerMTok;
2236
+ return (inputCost + usage.outputTokens * pricing.outputPerMTok) / 1e6 * multiplier;
2237
+ }
2238
+ function bareModelId(model) {
2239
+ let id = model.trim().toLowerCase();
2240
+ const slash = id.lastIndexOf("/");
2241
+ if (slash !== -1) id = id.slice(slash + 1);
2242
+ const anth = id.lastIndexOf("anthropic.");
2243
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
2244
+ return id;
2245
+ }
2246
+ function resolvePricing(ai) {
2247
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
2248
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
2249
+ }
2250
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
2251
+ const id = bareModelId(ai.model);
2252
+ let best;
2253
+ for (const row of PRICE_TABLE) {
2254
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
2255
+ }
2256
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
2257
+ }
2258
+ var BATCH_PRICE_MULTIPLIER, CACHE_WRITE_MULTIPLIER, CACHE_READ_MULTIPLIER, PRICE_TABLE, FREE_PROVIDERS;
2259
+ var init_pricing = __esm({
2260
+ "src/server/ai/pricing.ts"() {
2261
+ "use strict";
2262
+ BATCH_PRICE_MULTIPLIER = 0.5;
2263
+ CACHE_WRITE_MULTIPLIER = 1.25;
2264
+ CACHE_READ_MULTIPLIER = 0.1;
2265
+ PRICE_TABLE = [
2266
+ ["claude-fable-5", 10, 50],
2267
+ ["claude-mythos-5", 10, 50],
2268
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
2269
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
2270
+ ["claude-opus-4-1", 15, 75],
2271
+ ["claude-opus-4-0", 15, 75],
2272
+ ["claude-opus-4-2025", 15, 75],
2273
+ ["claude-opus-4", 5, 25],
2274
+ ["claude-sonnet-4", 3, 15],
2275
+ ["claude-haiku-4", 1, 5],
2276
+ ["claude-3-5-haiku", 0.8, 4],
2277
+ ["gpt-5.5-pro", 30, 180],
2278
+ ["gpt-5.5", 5, 30],
2279
+ ["gpt-5.4-pro", 30, 180],
2280
+ ["gpt-5.4-mini", 0.75, 4.5],
2281
+ ["gpt-5.4-nano", 0.2, 1.25],
2282
+ ["gpt-5.4", 2.5, 15],
2283
+ ["gpt-5.3-codex", 1.75, 14]
2284
+ ];
2285
+ FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
2286
+ }
2287
+ });
2288
+
2219
2289
  // src/server/ai/anthropic.ts
2220
2290
  import Anthropic from "@anthropic-ai/sdk";
2221
2291
  var AnthropicProvider;
@@ -2224,6 +2294,7 @@ var init_anthropic = __esm({
2224
2294
  "use strict";
2225
2295
  init_provider();
2226
2296
  init_batch();
2297
+ init_pricing();
2227
2298
  AnthropicProvider = class {
2228
2299
  constructor(config, client) {
2229
2300
  this.config = config;
@@ -2238,9 +2309,25 @@ var init_anthropic = __esm({
2238
2309
  }
2239
2310
  config;
2240
2311
  client;
2312
+ usage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
2241
2313
  supportsVision() {
2242
2314
  return true;
2243
2315
  }
2316
+ recordUsage(usage) {
2317
+ if (!usage) return;
2318
+ addUsage(this.usage, {
2319
+ inputTokens: usage.input_tokens ?? 0,
2320
+ outputTokens: usage.output_tokens ?? 0,
2321
+ cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
2322
+ cacheReadInputTokens: usage.cache_read_input_tokens ?? 0
2323
+ });
2324
+ }
2325
+ takeUsage() {
2326
+ const taken = this.usage;
2327
+ this.usage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
2328
+ const any = taken.inputTokens || taken.outputTokens || taken.cacheCreationInputTokens || taken.cacheReadInputTokens;
2329
+ return any ? taken : void 0;
2330
+ }
2244
2331
  translate(reqs, onBatchComplete, signal, onMalformedReply) {
2245
2332
  return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2246
2333
  }
@@ -2262,10 +2349,13 @@ var init_anthropic = __esm({
2262
2349
  content.push({ type: "text", text: buildBatchPrompt(batch) });
2263
2350
  return content;
2264
2351
  }
2265
- async complete(req) {
2266
- const content = req.content.map(
2352
+ completionContent(req) {
2353
+ return req.content.map(
2267
2354
  (b) => b.type === "image" ? { type: "image", source: { type: "base64", media_type: b.mediaType, data: b.base64 } } : { type: "text", text: b.text ?? "" }
2268
2355
  );
2356
+ }
2357
+ async complete(req) {
2358
+ const content = this.completionContent(req);
2269
2359
  const res = await this.client.messages.create({
2270
2360
  model: this.config.model,
2271
2361
  max_tokens: req.maxTokens ?? 8192,
@@ -2273,6 +2363,7 @@ var init_anthropic = __esm({
2273
2363
  output_config: { format: { type: "json_schema", schema: req.schema } },
2274
2364
  messages: [{ role: "user", content }]
2275
2365
  });
2366
+ this.recordUsage(res.usage);
2276
2367
  const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
2277
2368
  try {
2278
2369
  return JSON.parse(text);
@@ -2314,6 +2405,7 @@ var init_anthropic = __esm({
2314
2405
  out.set(entry.custom_id, { type: "failed", error: entry.result.error?.message ?? entry.result.type });
2315
2406
  continue;
2316
2407
  }
2408
+ this.recordUsage(entry.result.message?.usage);
2317
2409
  const text = entry.result.message?.content.find((b) => b.type === "text")?.text ?? "";
2318
2410
  try {
2319
2411
  out.set(entry.custom_id, { type: "items", items: parseReplyItems(text) });
@@ -2327,6 +2419,40 @@ var init_anthropic = __esm({
2327
2419
  async cancelTranslationBatch(batchId) {
2328
2420
  await this.batchesClient().cancel(batchId);
2329
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
+ }
2330
2456
  async callBatch(batch, signal) {
2331
2457
  const content = this.buildUserContent(batch);
2332
2458
  const res = await this.client.messages.create({
@@ -2336,6 +2462,7 @@ var init_anthropic = __esm({
2336
2462
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
2337
2463
  messages: [{ role: "user", content }]
2338
2464
  }, { signal });
2465
+ this.recordUsage(res.usage);
2339
2466
  const text = res.content.find((b) => b.type === "text")?.text ?? "";
2340
2467
  return parseReplyItems(text);
2341
2468
  }
@@ -3133,6 +3260,30 @@ var init_pending_batch = __esm({
3133
3260
  }
3134
3261
  });
3135
3262
 
3263
+ // src/server/log.ts
3264
+ import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
3265
+ import { resolve as resolve5 } from "path";
3266
+ function logPath(projectRoot) {
3267
+ return resolve5(projectRoot, ".glotfile", "log.jsonl");
3268
+ }
3269
+ function appendLog(projectRoot, entry) {
3270
+ ensureGlotfileDir(projectRoot);
3271
+ appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3272
+ }
3273
+ function readLog(projectRoot, limit = 100) {
3274
+ const path = logPath(projectRoot);
3275
+ if (!existsSync7(path)) return [];
3276
+ const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3277
+ const entries = lines.map((l) => JSON.parse(l));
3278
+ return entries.reverse().slice(0, limit);
3279
+ }
3280
+ var init_log = __esm({
3281
+ "src/server/log.ts"() {
3282
+ "use strict";
3283
+ init_glotfile_dir();
3284
+ }
3285
+ });
3286
+
3136
3287
  // src/server/ai/batch-run.ts
3137
3288
  function buildBatchJobs(reqs, batchSize) {
3138
3289
  const byLocale = /* @__PURE__ */ new Map();
@@ -3180,7 +3331,9 @@ async function submitBatchTranslation(state, provider, reqs, batchSize, model, p
3180
3331
  return pending;
3181
3332
  }
3182
3333
  async function applyBatchResults(load, persist, provider, pending, projectRoot, ai) {
3334
+ provider.takeUsage?.();
3183
3335
  const outcomes = await provider.translationBatchResults(pending.batchId);
3336
+ const batchUsage = provider.takeUsage?.();
3184
3337
  const fresh = load();
3185
3338
  const isStale = (r) => {
3186
3339
  const entry = fresh.keys[r.key];
@@ -3189,13 +3342,19 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3189
3342
  const applied = [];
3190
3343
  const results = [];
3191
3344
  const retryReqs = [];
3192
- let staleSkipped = 0;
3345
+ const stale = [];
3346
+ const jobFailures = [];
3193
3347
  for (const job of pending.jobs) {
3194
3348
  const outcome = outcomes.get(job.customId);
3195
3349
  const itemsById = outcome?.type === "items" ? new Map(outcome.items.map((i) => [i.id, i])) : null;
3350
+ if (!itemsById) {
3351
+ if (!outcome) jobFailures.push({ customId: job.customId, locale: job.locale, type: "missing" });
3352
+ else if (outcome.type === "malformed") jobFailures.push({ customId: job.customId, locale: job.locale, type: "malformed", raw: outcome.raw });
3353
+ else if (outcome.type === "failed") jobFailures.push({ customId: job.customId, locale: job.locale, type: "failed", error: outcome.error });
3354
+ }
3196
3355
  for (const stored of job.requests) {
3197
3356
  if (isStale(stored)) {
3198
- staleSkipped++;
3357
+ stale.push({ key: stored.key, locale: stored.targetLocale });
3199
3358
  continue;
3200
3359
  }
3201
3360
  const { sourceHash: _hash, ...req } = stored;
@@ -3214,7 +3373,20 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3214
3373
  const retryResults = await runLocaleParallel(
3215
3374
  retryReqs,
3216
3375
  provider,
3217
- {},
3376
+ {
3377
+ // Record the raw reply so an unparseable retry response is diagnosable
3378
+ // from the activity log instead of vanishing into per-item errors.
3379
+ onMalformedReply: (raw, batchSize, locale) => {
3380
+ appendLog(projectRoot, {
3381
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3382
+ kind: "translate",
3383
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
3384
+ model: pending.model,
3385
+ locale,
3386
+ raw
3387
+ });
3388
+ }
3389
+ },
3218
3390
  ai.concurrency,
3219
3391
  void 0,
3220
3392
  ai.batchSize
@@ -3222,10 +3394,34 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3222
3394
  applied.push(...retryReqs);
3223
3395
  results.push(...retryResults);
3224
3396
  }
3397
+ const retryUsage = provider.takeUsage?.();
3398
+ const pricing = resolvePricing({ ...ai, model: pending.model });
3399
+ let estimatedCostUsd;
3400
+ if (pricing && (batchUsage || retryUsage)) {
3401
+ estimatedCostUsd = (batchUsage ? estimateUsageCostUsd(batchUsage, pricing, BATCH_PRICE_MULTIPLIER) : 0) + (retryUsage ? estimateUsageCostUsd(retryUsage, pricing) : 0);
3402
+ }
3403
+ let usage;
3404
+ if (batchUsage || retryUsage) {
3405
+ usage = batchUsage ?? { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
3406
+ if (retryUsage) addUsage(usage, retryUsage);
3407
+ }
3225
3408
  const { written, errors } = applyResults(fresh, applied, results);
3226
3409
  persist(fresh);
3227
3410
  clearPendingBatch(projectRoot);
3228
- return { written, errors, staleSkipped, retried: retryReqs.length, screenshotsSkipped };
3411
+ const costSuffix = estimatedCostUsd !== void 0 ? ` (~$${estimatedCostUsd.toFixed(2)})` : "";
3412
+ appendLog(projectRoot, {
3413
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3414
+ kind: "translate",
3415
+ summary: `Applied batch ${pending.batchId}: wrote ${written}, ${errors.length} error(s), ${retryReqs.length} retried, ${stale.length} stale${costSuffix}`,
3416
+ model: pending.model,
3417
+ items: applied.map((r) => ({ id: r.id, key: r.key, source: r.source, targetLocale: r.targetLocale })),
3418
+ results,
3419
+ jobFailures: jobFailures.length ? jobFailures : void 0,
3420
+ stale: stale.length ? stale : void 0,
3421
+ usage,
3422
+ estimatedCostUsd
3423
+ });
3424
+ return { written, errors, staleSkipped: stale.length, retried: retryReqs.length, screenshotsSkipped };
3229
3425
  }
3230
3426
  var init_batch_run = __esm({
3231
3427
  "src/server/ai/batch-run.ts"() {
@@ -3234,97 +3430,352 @@ var init_batch_run = __esm({
3234
3430
  init_batch();
3235
3431
  init_run();
3236
3432
  init_pending_batch();
3433
+ init_log();
3434
+ init_pricing();
3237
3435
  }
3238
3436
  });
3239
3437
 
3240
- // src/server/ai/pricing.ts
3241
- function bareModelId(model) {
3242
- let id = model.trim().toLowerCase();
3243
- const slash = id.lastIndexOf("/");
3244
- if (slash !== -1) id = id.slice(slash + 1);
3245
- const anth = id.lastIndexOf("anthropic.");
3246
- if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3247
- return id;
3248
- }
3249
- function resolvePricing(ai) {
3250
- if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3251
- return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3252
- }
3253
- if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3254
- const id = bareModelId(ai.model);
3255
- let best;
3256
- for (const row of PRICE_TABLE) {
3257
- if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
3258
- }
3259
- return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
3260
- }
3261
- var PRICE_TABLE, FREE_PROVIDERS;
3262
- var init_pricing = __esm({
3263
- "src/server/ai/pricing.ts"() {
3264
- "use strict";
3265
- PRICE_TABLE = [
3266
- ["claude-fable-5", 10, 50],
3267
- ["claude-mythos-5", 10, 50],
3268
- // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3269
- // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3270
- ["claude-opus-4-1", 15, 75],
3271
- ["claude-opus-4-0", 15, 75],
3272
- ["claude-opus-4-2025", 15, 75],
3273
- ["claude-opus-4", 5, 25],
3274
- ["claude-sonnet-4", 3, 15],
3275
- ["claude-haiku-4", 1, 5],
3276
- ["claude-3-5-haiku", 0.8, 4],
3277
- ["gpt-5.5-pro", 30, 180],
3278
- ["gpt-5.5", 5, 30],
3279
- ["gpt-5.4-pro", 30, 180],
3280
- ["gpt-5.4-mini", 0.75, 4.5],
3281
- ["gpt-5.4-nano", 0.2, 1.25],
3282
- ["gpt-5.4", 2.5, 15],
3283
- ["gpt-5.3-codex", 1.75, 14]
3284
- ];
3285
- FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3286
- }
3287
- });
3288
-
3289
- // src/server/ai/estimate.ts
3290
- function estimateTokens(text) {
3291
- const cjk = text.match(CJK_RE)?.length ?? 0;
3292
- 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}$`);
3293
3444
  }
3294
- function estimateOutputTokens(req) {
3295
- const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
3296
- if (req.plural) {
3297
- 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
+ });
3298
3468
  }
3299
- return ITEM_REPLY_OVERHEAD + translated;
3469
+ return snippets;
3300
3470
  }
3301
- function estimateTranslation(state, ai, opts) {
3302
- const reqs = selectRequests(state, opts);
3303
- const byLocale = /* @__PURE__ */ new Map();
3304
- for (const r of reqs) {
3305
- let group = byLocale.get(r.targetLocale);
3306
- if (!group) {
3307
- group = [];
3308
- 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);
3309
3478
  }
3310
- group.push(r);
3311
3479
  }
3312
- const perLocale = [];
3313
- for (const [locale, group] of byLocale) {
3314
- let inputTokens2 = 0;
3315
- let outputTokens2 = 0;
3316
- const batches = chunk(group, Math.max(1, ai.batchSize));
3317
- for (const batch of batches) {
3318
- const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
3319
- inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
3320
- 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;
3321
3496
  }
3322
- 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: [] });
3323
3499
  }
3324
- const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
3325
- const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
3326
- const pricing = resolvePricing(ai);
3327
- return {
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;
3510
+ }
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"() {
3570
+ "use strict";
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
+ };
3596
+ }
3597
+ });
3598
+
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;
3608
+ try {
3609
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
3610
+ if (parsed?.version !== 1) return void 0;
3611
+ return parsed;
3612
+ } catch {
3613
+ return void 0;
3614
+ }
3615
+ }
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");
3622
+ }
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";
3629
+ }
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
+ };
3639
+ }
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 {
3328
3779
  requests: reqs.length,
3329
3780
  batches: perLocale.reduce((n, l) => n + l.batches, 0),
3330
3781
  perLocale,
@@ -3349,45 +3800,21 @@ var init_estimate = __esm({
3349
3800
  }
3350
3801
  });
3351
3802
 
3352
- // src/server/log.ts
3353
- import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
3354
- import { resolve as resolve5 } from "path";
3355
- function logPath(projectRoot) {
3356
- return resolve5(projectRoot, ".glotfile", "log.jsonl");
3357
- }
3358
- function appendLog(projectRoot, entry) {
3359
- ensureGlotfileDir(projectRoot);
3360
- appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3361
- }
3362
- function readLog(projectRoot, limit = 100) {
3363
- const path = logPath(projectRoot);
3364
- if (!existsSync7(path)) return [];
3365
- const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3366
- const entries = lines.map((l) => JSON.parse(l));
3367
- return entries.reverse().slice(0, limit);
3368
- }
3369
- var init_log = __esm({
3370
- "src/server/log.ts"() {
3371
- "use strict";
3372
- init_glotfile_dir();
3373
- }
3374
- });
3375
-
3376
3803
  // src/server/scan.ts
3377
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
3378
- import { resolve as resolve6 } from "path";
3804
+ import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
3805
+ import { resolve as resolve7 } from "path";
3379
3806
  function loadUsageCache(projectRoot) {
3380
- const path = resolve6(projectRoot, ".glotfile", "usage.json");
3381
- if (!existsSync8(path)) return null;
3807
+ const path = resolve7(projectRoot, ".glotfile", "usage.json");
3808
+ if (!existsSync10(path)) return null;
3382
3809
  try {
3383
- return JSON.parse(readFileSync8(path, "utf8"));
3810
+ return JSON.parse(readFileSync10(path, "utf8"));
3384
3811
  } catch {
3385
3812
  return null;
3386
3813
  }
3387
3814
  }
3388
3815
  function saveUsageCache(projectRoot, cache2) {
3389
3816
  ensureGlotfileDir(projectRoot);
3390
- const path = resolve6(projectRoot, ".glotfile", "usage.json");
3817
+ const path = resolve7(projectRoot, ".glotfile", "usage.json");
3391
3818
  writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
3392
3819
  }
3393
3820
  function findMissing(state) {
@@ -3445,8 +3872,8 @@ var init_scan = __esm({
3445
3872
  });
3446
3873
 
3447
3874
  // src/server/scanner.ts
3448
- import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync9 } from "fs";
3449
- 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";
3450
3877
  function scannerForExt(ext) {
3451
3878
  return EXT_SCANNER[ext] ?? null;
3452
3879
  }
@@ -3598,7 +4025,7 @@ function* walkFiles(dir, root, exclude) {
3598
4025
  }
3599
4026
  for (const name of entries) {
3600
4027
  if (ALWAYS_EXCLUDE.has(name)) continue;
3601
- const abs = join4(dir, name);
4028
+ const abs = join5(dir, name);
3602
4029
  const rel = relative(root, abs);
3603
4030
  let st;
3604
4031
  try {
@@ -3628,7 +4055,7 @@ function runScan(projectRoot, opts, existing) {
3628
4055
  const ext = extname2(relPath);
3629
4056
  const scanner = scannerForExt(ext);
3630
4057
  if (!scanner) continue;
3631
- const abs = join4(projectRoot, relPath);
4058
+ const abs = join5(projectRoot, relPath);
3632
4059
  let st;
3633
4060
  try {
3634
4061
  st = statSync2(abs);
@@ -3644,279 +4071,118 @@ function runScan(projectRoot, opts, existing) {
3644
4071
  }
3645
4072
  let content;
3646
4073
  try {
3647
- content = readFileSync9(abs, "utf8");
4074
+ content = readFileSync11(abs, "utf8");
3648
4075
  } catch {
3649
- continue;
3650
- }
3651
- cache2.files[relPath] = {
3652
- mtime,
3653
- size,
3654
- refs: extractRefs(content, scanner, opts),
3655
- prefixes: extractPrefixes(content, scanner),
3656
- literals: extractLiterals(content)
3657
- };
3658
- }
3659
- saveUsageCache(projectRoot, cache2);
3660
- return cache2;
3661
- }
3662
- var PATTERNS, PREFIX_PATTERNS, CACHE_VERSION, EXT_SCANNER, ALWAYS_EXCLUDE, FLUTTER_ACCESSOR_DEFAULTS, KEY_SHAPE, STRING_LITERALS;
3663
- var init_scanner = __esm({
3664
- "src/server/scanner.ts"() {
3665
- "use strict";
3666
- init_scan();
3667
- PATTERNS = {
3668
- laravel: [
3669
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']+)'/g,
3670
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"]+)"/g,
3671
- /@(?:lang|choice)\s*\(\s*'([^']+)'/g,
3672
- /@(?:lang|choice)\s*\(\s*"([^"]+)"/g
3673
- ],
3674
- "js-i18n": [
3675
- /\$t\s*\(\s*'([^']+)'/g,
3676
- /\$t\s*\(\s*"([^"]+)"/g,
3677
- /\$t\s*\(\s*`([^`$\n]+)`/g,
3678
- /\bi18n\.t\s*\(\s*'([^']+)'/g,
3679
- /\bi18n\.t\s*\(\s*"([^"]+)"/g,
3680
- /\bi18next\.t\s*\(\s*'([^']+)'/g,
3681
- /\bi18next\.t\s*\(\s*"([^"]+)"/g,
3682
- // t('key') — word boundary before t, not preceded by dot (excludes i18n.t which is above)
3683
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']+)'/g,
3684
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]+)"/g,
3685
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$\n]+)`/g
3686
- ],
3687
- gettext: [
3688
- /\b(?:gettext|ngettext)\s*\(\s*'([^']+)'/g,
3689
- /\b(?:gettext|ngettext)\s*\(\s*"([^"]+)"/g,
3690
- // _() — word boundary, not preceded by alphanumeric
3691
- /(?<![a-zA-Z0-9_$])_\s*\(\s*'([^']+)'/g,
3692
- /(?<![a-zA-Z0-9_$])_\s*\(\s*"([^"]+)"/g
3693
- ],
3694
- apple: [
3695
- /NSLocalizedString\s*\(\s*@?"([^"]+)"/g,
3696
- /String\s*\(\s*localized:\s*"([^"]+)"/g,
3697
- /localizedString\s*\(\s*forKey:\s*"([^"]+)"/g,
3698
- // The "key".localized / "key".localised String-extension idiom, where the
3699
- // literal IS the key (common when keys are natural-language source text).
3700
- /"([^"]+)"\s*\.\s*localized\b/g,
3701
- /"([^"]+)"\s*\.\s*localised\b/g
3702
- ]
3703
- };
3704
- PREFIX_PATTERNS = {
3705
- laravel: [
3706
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']*)'\s*\./g,
3707
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"]*)"\s*\./g,
3708
- /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"${]*)\{?\$/g
3709
- ],
3710
- "js-i18n": [
3711
- /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*'([^']*)'\s*\+/g,
3712
- /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*"([^"]*)"\s*\+/g,
3713
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']*)'\s*\+/g,
3714
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]*)"\s*\+/g,
3715
- /(?:\$t|i18n\.t|i18next\.t)\s*\(\s*`([^`$]*)\$\{/g,
3716
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$]*)\$\{/g
3717
- ]
3718
- };
3719
- CACHE_VERSION = 6;
3720
- EXT_SCANNER = {
3721
- ".php": "laravel",
3722
- ".vue": "js-i18n",
3723
- ".js": "js-i18n",
3724
- ".ts": "js-i18n",
3725
- ".jsx": "js-i18n",
3726
- ".tsx": "js-i18n",
3727
- ".mjs": "js-i18n",
3728
- ".cjs": "js-i18n",
3729
- ".dart": "flutter",
3730
- ".py": "gettext",
3731
- ".c": "gettext",
3732
- ".cpp": "gettext",
3733
- ".h": "gettext",
3734
- ".swift": "apple",
3735
- ".m": "apple",
3736
- ".mm": "apple"
3737
- };
3738
- ALWAYS_EXCLUDE = /* @__PURE__ */ new Set([
3739
- "node_modules",
3740
- ".git",
3741
- ".glotfile",
3742
- ".claude",
3743
- "dist",
3744
- "build",
3745
- "vendor",
3746
- "coverage",
3747
- ".next",
3748
- ".nuxt",
3749
- ".turbo",
3750
- "__pycache__"
3751
- ]);
3752
- FLUTTER_ACCESSOR_DEFAULTS = ["l10n", "loc", "localizations", "translations"];
3753
- KEY_SHAPE = /^[A-Za-z0-9_][A-Za-z0-9_/-]*(?:\.(?:[A-Za-z0-9_-]+|%[sd]))+\.?$/;
3754
- STRING_LITERALS = [
3755
- /'([^'\\\n]+)'/g,
3756
- /"([^"\\\n]+)"/g,
3757
- /`([^`\\\n]+)`/g
3758
- ];
3759
- }
3760
- });
3761
-
3762
- // src/server/ai/context.ts
3763
- import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
3764
- import { resolve as resolve7 } from "path";
3765
- function globToRegExp2(glob) {
3766
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
3767
- return new RegExp(`^${escaped}$`);
3768
- }
3769
- function extractSnippets(refs, projectRoot, fileCache) {
3770
- const filtered = refs.filter((r) => !EXCLUDED_DIRS.some((d) => r.file.startsWith(d)));
3771
- const sorted = [...filtered].sort((a, b) => a.file.length - b.file.length);
3772
- const selected = sorted.slice(0, MAX_SNIPPETS);
3773
- const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
3774
- const snippets = [];
3775
- for (const ref of selected) {
3776
- const absPath = resolve7(projectRoot, ref.file);
3777
- if (!fileCache.has(ref.file)) {
3778
- if (!existsSync9(absPath)) continue;
3779
- const content = readFileSync10(absPath, "utf8");
3780
- fileCache.set(ref.file, content.split("\n"));
3781
- }
3782
- const lines = fileCache.get(ref.file);
3783
- const start = Math.max(0, ref.line - 1 - SNIPPET_WINDOW);
3784
- const end = Math.min(lines.length, ref.line + SNIPPET_WINDOW);
3785
- snippets.push({
3786
- file: ref.file,
3787
- startLine: start + 1,
3788
- lines: lines.slice(start, end).join("\n"),
3789
- scanner: ref.scanner,
3790
- ...snippets.length === 0 && extraRefs > 0 ? { extraRefs } : {}
3791
- });
3792
- }
3793
- return snippets;
3794
- }
3795
- function buildUsageIndex(cache2) {
3796
- const index = /* @__PURE__ */ new Map();
3797
- for (const [file, entry] of Object.entries(cache2.files)) {
3798
- for (const ref of entry.refs) {
3799
- const existing = index.get(ref.key) ?? [];
3800
- existing.push({ key: ref.key, file, line: ref.line, col: ref.col, scanner: ref.scanner });
3801
- index.set(ref.key, existing);
3802
- }
3803
- }
3804
- return index;
3805
- }
3806
- function selectContextTargets(state, opts, cache2, lastRunAt) {
3807
- const cutoff = opts.all ? void 0 : opts.since ?? lastRunAt;
3808
- const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
3809
- const keySet = opts.keys ? new Set(opts.keys) : null;
3810
- const usageIndex = buildUsageIndex(cache2);
3811
- let candidates = [];
3812
- for (const key of Object.keys(state.keys).sort()) {
3813
- const entry = state.keys[key];
3814
- if (entry.context && !opts.force) continue;
3815
- if (keySet && !keySet.has(key)) continue;
3816
- if (keyRe && !keyRe.test(key)) continue;
3817
- if (cutoff) {
3818
- if (!entry.createdAt) continue;
3819
- if (entry.createdAt < cutoff) continue;
3820
- }
3821
- const source = entry.values[state.config.sourceLocale]?.value ?? "";
3822
- candidates.push({ id: String(candidates.length), key, source, usageSnippets: [] });
3823
- }
3824
- candidates.sort((a, b) => {
3825
- const ta = state.keys[a.key].createdAt ?? "";
3826
- const tb = state.keys[b.key].createdAt ?? "";
3827
- return tb.localeCompare(ta);
3828
- });
3829
- if (opts.limit !== void 0) candidates = candidates.slice(0, opts.limit);
3830
- candidates.forEach((c, i) => {
3831
- c.id = String(i);
3832
- });
3833
- return candidates;
3834
- }
3835
- function buildContextSystemPrompt() {
3836
- return [
3837
- "You are a localization context writer for a UI string catalog.",
3838
- "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.",
3839
- "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.",
3840
- "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?",
3841
- "Rules:",
3842
- "- Use the code snippets as your primary signal. Look at the component name, surrounding labels, event handlers, and variable names.",
3843
- "- Do NOT restate the source string itself.",
3844
- "- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
3845
- "- Keep it under 500 characters.",
3846
- "- If no code snippets are available, infer from the key path and source value."
3847
- ].join("\n");
3848
- }
3849
- function buildContextBatchPrompt(reqs) {
3850
- const items = reqs.map((r) => {
3851
- const snippetText = r.usageSnippets.length > 0 ? r.usageSnippets.map((s) => {
3852
- const extra = s.extraRefs ? ` (and ${s.extraRefs} more call site${s.extraRefs > 1 ? "s" : ""} not shown)` : "";
3853
- return `File: ${s.file} (lines ${s.startLine}+, scanner: ${s.scanner})${extra}
3854
- \`\`\`
3855
- ${s.lines}
3856
- \`\`\``;
3857
- }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
3858
- return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText };
3859
- });
3860
- return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
3861
- }
3862
- function applyContext(state, reqs, results, clock = systemClock, force = false) {
3863
- const byId = new Map(reqs.map((r) => [r.id, r]));
3864
- let written = 0;
3865
- const errors = [];
3866
- for (const res of results) {
3867
- const req = byId.get(res.id);
3868
- if (!req) continue;
3869
- if (res.error) {
3870
- errors.push({ key: req.key, error: res.error });
3871
- continue;
3872
- }
3873
- const context = res.context?.trim() ?? "";
3874
- if (!context) {
3875
- errors.push({ key: req.key, error: "AI returned empty context" });
3876
- continue;
3877
- }
3878
- if (context.length > MAX_CONTEXT_LENGTH) {
3879
- errors.push({ key: req.key, error: `Context too long (${context.length} chars, max ${MAX_CONTEXT_LENGTH})` });
3880
- continue;
3881
- }
3882
- const entry = state.keys[req.key];
3883
- if (!entry || entry.context && !force) continue;
3884
- entry.context = context;
3885
- entry.contextSource = "ai";
3886
- entry.contextAt = clock();
3887
- written++;
3888
- }
3889
- return { written, errors };
3890
- }
3891
- var MAX_CONTEXT_LENGTH, SNIPPET_WINDOW, MAX_SNIPPETS, EXCLUDED_DIRS, CONTEXT_BATCH_SCHEMA;
3892
- var init_context = __esm({
3893
- "src/server/ai/context.ts"() {
3894
- "use strict";
3895
- init_state();
3896
- MAX_CONTEXT_LENGTH = 500;
3897
- SNIPPET_WINDOW = 15;
3898
- MAX_SNIPPETS = 3;
3899
- EXCLUDED_DIRS = ["node_modules/", "vendor/", "dist/", ".git/", ".glotfile/"];
3900
- CONTEXT_BATCH_SCHEMA = {
3901
- type: "object",
3902
- properties: {
3903
- items: {
3904
- type: "array",
3905
- items: {
3906
- type: "object",
3907
- properties: {
3908
- id: { type: "string" },
3909
- context: { type: "string" },
3910
- error: { type: "string" }
3911
- },
3912
- required: ["id"],
3913
- additionalProperties: false
3914
- }
3915
- }
3916
- },
3917
- required: ["items"],
3918
- additionalProperties: false
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"
3919
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
+ ];
3920
4186
  }
3921
4187
  });
3922
4188
 
@@ -4292,7 +4558,7 @@ var init_run2 = __esm({
4292
4558
  });
4293
4559
 
4294
4560
  // src/server/lint/outputs.ts
4295
- import { readFileSync as readFileSync11, existsSync as existsSync10 } from "fs";
4561
+ import { readFileSync as readFileSync12, existsSync as existsSync11 } from "fs";
4296
4562
  import { resolve as resolve8 } from "path";
4297
4563
  function checkOutputs(state, root) {
4298
4564
  const out = [];
@@ -4300,7 +4566,7 @@ function checkOutputs(state, root) {
4300
4566
  const result = getAdapter(output.adapter).export(state, output);
4301
4567
  for (const file of result.files) {
4302
4568
  const abs = resolve8(root, file.path);
4303
- const current = existsSync10(abs) ? readFileSync11(abs, "utf8") : null;
4569
+ const current = existsSync11(abs) ? readFileSync12(abs, "utf8") : null;
4304
4570
  if (current === null) {
4305
4571
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
4306
4572
  } else if (current !== file.contents) {
@@ -4345,8 +4611,8 @@ var init_accept = __esm({
4345
4611
  });
4346
4612
 
4347
4613
  // src/server/import/detect.ts
4348
- import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
4349
- 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";
4350
4616
  function safeIsDir(p) {
4351
4617
  try {
4352
4618
  return statSync3(p).isDirectory();
@@ -4355,7 +4621,7 @@ function safeIsDir(p) {
4355
4621
  }
4356
4622
  }
4357
4623
  function listDirs(dir) {
4358
- return readdirSync4(dir).filter((e) => safeIsDir(join5(dir, e)));
4624
+ return readdirSync4(dir).filter((e) => safeIsDir(join6(dir, e)));
4359
4625
  }
4360
4626
  function fileCount(dir) {
4361
4627
  try {
@@ -4369,23 +4635,23 @@ function pickSource(locales, sizeOf) {
4369
4635
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4370
4636
  }
4371
4637
  function detectLaravel(root) {
4372
- const localeRoot = [join5(root, "resources", "lang"), join5(root, "lang")].find(safeIsDir);
4638
+ const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
4373
4639
  if (!localeRoot) return null;
4374
4640
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4375
4641
  if (locales.length === 0) return null;
4376
- const sourceLocale = pickSource(locales, (loc) => fileCount(join5(localeRoot, loc)));
4642
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
4377
4643
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4378
4644
  }
4379
4645
  function detectVue(root, forced = false) {
4380
4646
  for (const rel of VUE_DIR_CANDIDATES) {
4381
- const localeRoot = join5(root, rel);
4647
+ const localeRoot = join6(root, rel);
4382
4648
  if (!safeIsDir(localeRoot)) continue;
4383
4649
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4384
4650
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4385
4651
  if (enough) {
4386
4652
  const sourceLocale = pickSource(locales, (loc) => {
4387
4653
  try {
4388
- return statSync3(join5(localeRoot, `${loc}.json`)).size;
4654
+ return statSync3(join6(localeRoot, `${loc}.json`)).size;
4389
4655
  } catch {
4390
4656
  return 0;
4391
4657
  }
@@ -4397,7 +4663,7 @@ function detectVue(root, forced = false) {
4397
4663
  }
4398
4664
  function detectArb(root) {
4399
4665
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4400
- const localeRoot = join5(root, rel);
4666
+ const localeRoot = join6(root, rel);
4401
4667
  if (!safeIsDir(localeRoot)) continue;
4402
4668
  const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4403
4669
  if (locales.length >= 1) {
@@ -4407,10 +4673,10 @@ function detectArb(root) {
4407
4673
  return null;
4408
4674
  }
4409
4675
  function lprojLocales(dir) {
4410
- 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")));
4411
4677
  }
4412
4678
  function detectApple(root) {
4413
- const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
4679
+ const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4414
4680
  let best = null;
4415
4681
  for (const dir of candidates) {
4416
4682
  const locales = lprojLocales(dir);
@@ -4422,7 +4688,7 @@ function detectApple(root) {
4422
4688
  locales,
4423
4689
  sourceLocale: pickSource(locales, (loc) => {
4424
4690
  try {
4425
- return statSync3(join5(dir, `${loc}.lproj`, "Localizable.strings")).size;
4691
+ return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4426
4692
  } catch {
4427
4693
  return 0;
4428
4694
  }
@@ -4434,7 +4700,7 @@ function detectApple(root) {
4434
4700
  }
4435
4701
  function detectAngularXliff(root) {
4436
4702
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4437
- const localeRoot = rel === "." ? root : join5(root, rel);
4703
+ const localeRoot = rel === "." ? root : join6(root, rel);
4438
4704
  if (!safeIsDir(localeRoot)) continue;
4439
4705
  const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4440
4706
  if (files.length === 0) continue;
@@ -4442,7 +4708,7 @@ function detectAngularXliff(root) {
4442
4708
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4443
4709
  let sourceLocale;
4444
4710
  try {
4445
- sourceLocale = readFileSync12(join5(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4711
+ sourceLocale = readFileSync13(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4446
4712
  } catch {
4447
4713
  }
4448
4714
  if (!sourceLocale && locales.length === 0) continue;
@@ -4453,14 +4719,14 @@ function detectAngularXliff(root) {
4453
4719
  return null;
4454
4720
  }
4455
4721
  function detectRails(root) {
4456
- const localeRoot = join5(root, "config", "locales");
4722
+ const localeRoot = join6(root, "config", "locales");
4457
4723
  if (!safeIsDir(localeRoot)) return null;
4458
4724
  const locales = [];
4459
4725
  for (const file of readdirSync4(localeRoot).sort()) {
4460
4726
  if (!/\.ya?ml$/.test(file)) continue;
4461
4727
  let text;
4462
4728
  try {
4463
- text = readFileSync12(join5(localeRoot, file), "utf8");
4729
+ text = readFileSync13(join6(localeRoot, file), "utf8");
4464
4730
  } catch {
4465
4731
  continue;
4466
4732
  }
@@ -4474,15 +4740,15 @@ function detectRails(root) {
4474
4740
  }
4475
4741
  function detectI18next(root) {
4476
4742
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4477
- const localeRoot = join5(root, rel);
4743
+ const localeRoot = join6(root, rel);
4478
4744
  if (!safeIsDir(localeRoot)) continue;
4479
4745
  const locales = listDirs(localeRoot).filter(
4480
- (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"))
4481
4747
  );
4482
4748
  if (locales.length === 0) continue;
4483
4749
  const sourceLocale = pickSource(locales, (loc) => {
4484
4750
  try {
4485
- 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);
4486
4752
  } catch {
4487
4753
  return 0;
4488
4754
  }
@@ -4499,8 +4765,8 @@ function gettextLocales(dir) {
4499
4765
  if (!locales.includes(flat)) locales.push(flat);
4500
4766
  continue;
4501
4767
  }
4502
- if (!LOCALE_RE.test(entry) || !safeIsDir(join5(dir, entry))) continue;
4503
- const sub = join5(dir, entry);
4768
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4769
+ const sub = join6(dir, entry);
4504
4770
  const hasPo = (d) => {
4505
4771
  try {
4506
4772
  return readdirSync4(d).some((f) => f.endsWith(".po"));
@@ -4508,7 +4774,7 @@ function gettextLocales(dir) {
4508
4774
  return false;
4509
4775
  }
4510
4776
  };
4511
- if (hasPo(join5(sub, "LC_MESSAGES")) || hasPo(sub)) {
4777
+ if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
4512
4778
  if (!locales.includes(entry)) locales.push(entry);
4513
4779
  }
4514
4780
  }
@@ -4516,7 +4782,7 @@ function gettextLocales(dir) {
4516
4782
  }
4517
4783
  function detectGettext(root) {
4518
4784
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4519
- const localeRoot = join5(root, rel);
4785
+ const localeRoot = join6(root, rel);
4520
4786
  if (!safeIsDir(localeRoot)) continue;
4521
4787
  const locales = gettextLocales(localeRoot);
4522
4788
  if (locales.length === 0) continue;
@@ -4525,10 +4791,10 @@ function detectGettext(root) {
4525
4791
  return null;
4526
4792
  }
4527
4793
  function detectAppleStringsdict(root) {
4528
- const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
4794
+ const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4529
4795
  let best = null;
4530
4796
  for (const dir of candidates) {
4531
- 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")));
4532
4798
  if (locales.length === 0) continue;
4533
4799
  if (!best || locales.length > best.locales.length) {
4534
4800
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4537,7 +4803,7 @@ function detectAppleStringsdict(root) {
4537
4803
  return best;
4538
4804
  }
4539
4805
  function detect(root, formatOverride) {
4540
- if (!existsSync11(root)) return null;
4806
+ if (!existsSync12(root)) return null;
4541
4807
  if (formatOverride) {
4542
4808
  const fn = BY_FORMAT[formatOverride];
4543
4809
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -4611,8 +4877,8 @@ var init_flatten = __esm({
4611
4877
  });
4612
4878
 
4613
4879
  // src/server/import/parsers/vue-i18n-json.ts
4614
- import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4615
- import { join as join6 } from "path";
4880
+ import { readdirSync as readdirSync5, readFileSync as readFileSync14 } from "fs";
4881
+ import { join as join7 } from "path";
4616
4882
  var LOCALE_RE2, vueI18nJson2;
4617
4883
  var init_vue_i18n_json2 = __esm({
4618
4884
  "src/server/import/parsers/vue-i18n-json.ts"() {
@@ -4632,7 +4898,7 @@ var init_vue_i18n_json2 = __esm({
4632
4898
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4633
4899
  let data;
4634
4900
  try {
4635
- data = JSON.parse(readFileSync13(join6(localeRoot, file), "utf8"));
4901
+ data = JSON.parse(readFileSync14(join7(localeRoot, file), "utf8"));
4636
4902
  } catch (e) {
4637
4903
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4638
4904
  continue;
@@ -4660,16 +4926,16 @@ var init_placeholders2 = __esm({
4660
4926
 
4661
4927
  // src/server/import/parsers/laravel-php.ts
4662
4928
  import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
4663
- import { join as join7, relative as relative2 } from "path";
4929
+ import { join as join8, relative as relative2 } from "path";
4664
4930
  import { execFileSync } from "child_process";
4665
4931
  function listDirs2(dir) {
4666
- return readdirSync6(dir).filter((e) => statSync4(join7(dir, e)).isDirectory());
4932
+ return readdirSync6(dir).filter((e) => statSync4(join8(dir, e)).isDirectory());
4667
4933
  }
4668
4934
  function listPhpFiles(dir) {
4669
4935
  const out = [];
4670
4936
  const walk = (d) => {
4671
4937
  for (const e of readdirSync6(d)) {
4672
- const full = join7(d, e);
4938
+ const full = join8(d, e);
4673
4939
  if (statSync4(full).isDirectory()) walk(full);
4674
4940
  else if (e.endsWith(".php")) out.push(full);
4675
4941
  }
@@ -4712,7 +4978,7 @@ var init_laravel_php2 = __esm({
4712
4978
  for (const locale of listDirs2(localeRoot).sort()) {
4713
4979
  if (locale === "vendor") continue;
4714
4980
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4715
- const localeDir = join7(localeRoot, locale);
4981
+ const localeDir = join8(localeRoot, locale);
4716
4982
  locales.push(locale);
4717
4983
  for (const file of listPhpFiles(localeDir)) {
4718
4984
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -4737,8 +5003,8 @@ var init_laravel_php2 = __esm({
4737
5003
  });
4738
5004
 
4739
5005
  // src/server/import/parsers/flutter-arb.ts
4740
- import { readdirSync as readdirSync7, readFileSync as readFileSync14 } from "fs";
4741
- import { join as join8 } from "path";
5006
+ import { readdirSync as readdirSync7, readFileSync as readFileSync15 } from "fs";
5007
+ import { join as join9 } from "path";
4742
5008
  function localeFromArbName(file) {
4743
5009
  const m = file.match(/^(.+)\.arb$/);
4744
5010
  if (!m) return null;
@@ -4778,7 +5044,7 @@ var init_flutter_arb2 = __esm({
4778
5044
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4779
5045
  let data;
4780
5046
  try {
4781
- data = JSON.parse(readFileSync14(join8(localeRoot, file), "utf8"));
5047
+ data = JSON.parse(readFileSync15(join9(localeRoot, file), "utf8"));
4782
5048
  } catch (e) {
4783
5049
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4784
5050
  continue;
@@ -4805,8 +5071,8 @@ var init_flutter_arb2 = __esm({
4805
5071
  });
4806
5072
 
4807
5073
  // src/server/import/parsers/apple-strings.ts
4808
- import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
4809
- 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";
4810
5076
  function localeFromLproj(dir) {
4811
5077
  const m = dir.match(/^(.+)\.lproj$/);
4812
5078
  if (!m) return null;
@@ -4914,16 +5180,16 @@ var init_apple_strings2 = __esm({
4914
5180
  const locale = localeFromLproj(dir);
4915
5181
  if (!locale) continue;
4916
5182
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4917
- const file = join9(localeRoot, dir, TABLE);
5183
+ const file = join10(localeRoot, dir, TABLE);
4918
5184
  let text;
4919
5185
  try {
4920
5186
  if (!statSync5(file).isFile()) continue;
4921
- text = readFileSync15(file, "utf8");
5187
+ text = readFileSync16(file, "utf8");
4922
5188
  } catch {
4923
5189
  continue;
4924
5190
  }
4925
5191
  locales.push(locale);
4926
- 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);
4927
5193
  if (others.length) {
4928
5194
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4929
5195
  }
@@ -4938,8 +5204,8 @@ var init_apple_strings2 = __esm({
4938
5204
  });
4939
5205
 
4940
5206
  // src/server/import/parsers/angular-xliff.ts
4941
- import { readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
4942
- import { join as join10 } from "path";
5207
+ import { readdirSync as readdirSync9, readFileSync as readFileSync17 } from "fs";
5208
+ import { join as join11 } from "path";
4943
5209
  function decodeEntities(s) {
4944
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, "&");
4945
5211
  }
@@ -4991,7 +5257,7 @@ var init_angular_xliff2 = __esm({
4991
5257
  if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
4992
5258
  let xml;
4993
5259
  try {
4994
- xml = readFileSync16(join10(localeRoot, file), "utf8");
5260
+ xml = readFileSync17(join11(localeRoot, file), "utf8");
4995
5261
  } catch (e) {
4996
5262
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
4997
5263
  continue;
@@ -5034,8 +5300,8 @@ var init_angular_xliff2 = __esm({
5034
5300
  });
5035
5301
 
5036
5302
  // src/server/import/parsers/gettext-po.ts
5037
- import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
5038
- import { join as join11 } from "path";
5303
+ import { readdirSync as readdirSync10, readFileSync as readFileSync18 } from "fs";
5304
+ import { join as join12 } from "path";
5039
5305
  function unescapePo(s) {
5040
5306
  return s.replace(
5041
5307
  /\\([\\"ntr])/g,
@@ -5106,17 +5372,17 @@ function discoverPoFiles(root) {
5106
5372
  for (const e of entries) {
5107
5373
  if (e.isFile() && e.name.endsWith(".po")) {
5108
5374
  const base = e.name.slice(0, -3);
5109
- 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 });
5110
5376
  } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
5111
- for (const sub of [join11(e.name, "LC_MESSAGES"), e.name]) {
5377
+ for (const sub of [join12(e.name, "LC_MESSAGES"), e.name]) {
5112
5378
  let names;
5113
5379
  try {
5114
- names = readdirSync10(join11(root, sub)).sort();
5380
+ names = readdirSync10(join12(root, sub)).sort();
5115
5381
  } catch {
5116
5382
  continue;
5117
5383
  }
5118
5384
  for (const f of names) {
5119
- 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 });
5120
5386
  }
5121
5387
  }
5122
5388
  }
@@ -5140,7 +5406,7 @@ var init_gettext_po2 = __esm({
5140
5406
  for (const file of discoverPoFiles(localeRoot)) {
5141
5407
  let entries;
5142
5408
  try {
5143
- entries = parseEntries(readFileSync17(file.path, "utf8"));
5409
+ entries = parseEntries(readFileSync18(file.path, "utf8"));
5144
5410
  } catch (e) {
5145
5411
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5146
5412
  continue;
@@ -5187,8 +5453,8 @@ var init_gettext_po2 = __esm({
5187
5453
  });
5188
5454
 
5189
5455
  // src/server/import/parsers/i18next-json.ts
5190
- import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5191
- 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";
5192
5458
  function safeIsDir2(p) {
5193
5459
  try {
5194
5460
  return statSync6(p).isDirectory();
@@ -5203,7 +5469,7 @@ function fromI18next(value) {
5203
5469
  function ingestFile(path, label, prefix, locale, keys, warnings) {
5204
5470
  let data;
5205
5471
  try {
5206
- data = JSON.parse(readFileSync18(path, "utf8"));
5472
+ data = JSON.parse(readFileSync19(path, "utf8"));
5207
5473
  } catch (e) {
5208
5474
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5209
5475
  return false;
@@ -5256,7 +5522,7 @@ var init_i18next_json2 = __esm({
5256
5522
  const keys = {};
5257
5523
  const locales = [];
5258
5524
  for (const entry of readdirSync11(localeRoot).sort()) {
5259
- const full = join12(localeRoot, entry);
5525
+ const full = join13(localeRoot, entry);
5260
5526
  if (safeIsDir2(full)) {
5261
5527
  if (!LOCALE_RE7.test(entry)) continue;
5262
5528
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -5265,7 +5531,7 @@ var init_i18next_json2 = __esm({
5265
5531
  if (!file.endsWith(".json")) continue;
5266
5532
  const ns = file.slice(0, -".json".length);
5267
5533
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5268
- 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;
5269
5535
  }
5270
5536
  if (any && !locales.includes(entry)) locales.push(entry);
5271
5537
  } else if (entry.endsWith(".json")) {
@@ -5284,8 +5550,8 @@ var init_i18next_json2 = __esm({
5284
5550
  });
5285
5551
 
5286
5552
  // src/server/import/parsers/rails-yaml.ts
5287
- import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5288
- import { join as join13 } from "path";
5553
+ import { readdirSync as readdirSync12, readFileSync as readFileSync20 } from "fs";
5554
+ import { join as join14 } from "path";
5289
5555
  function fromRuby(value) {
5290
5556
  return value.replace(/%\{(\w+)\}/g, "{$1}");
5291
5557
  }
@@ -5502,7 +5768,7 @@ var init_rails_yaml2 = __esm({
5502
5768
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5503
5769
  let text;
5504
5770
  try {
5505
- text = readFileSync19(join13(localeRoot, file), "utf8");
5771
+ text = readFileSync20(join14(localeRoot, file), "utf8");
5506
5772
  } catch (e) {
5507
5773
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5508
5774
  continue;
@@ -5525,8 +5791,8 @@ var init_rails_yaml2 = __esm({
5525
5791
  });
5526
5792
 
5527
5793
  // src/server/import/parsers/apple-stringsdict.ts
5528
- import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5529
- 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";
5530
5796
  function localeFromLproj2(dir) {
5531
5797
  const m = dir.match(/^(.+)\.lproj$/);
5532
5798
  if (!m) return null;
@@ -5668,16 +5934,16 @@ var init_apple_stringsdict2 = __esm({
5668
5934
  const locale = localeFromLproj2(dir);
5669
5935
  if (!locale) continue;
5670
5936
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5671
- const file = join14(localeRoot, dir, TABLE2);
5937
+ const file = join15(localeRoot, dir, TABLE2);
5672
5938
  let text;
5673
5939
  try {
5674
5940
  if (!statSync7(file).isFile()) continue;
5675
- text = readFileSync20(file, "utf8");
5941
+ text = readFileSync21(file, "utf8");
5676
5942
  } catch {
5677
5943
  continue;
5678
5944
  }
5679
5945
  locales.push(locale);
5680
- const others = readdirSync13(join14(localeRoot, dir)).filter(
5946
+ const others = readdirSync13(join15(localeRoot, dir)).filter(
5681
5947
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
5682
5948
  );
5683
5949
  if (others.length) {
@@ -6136,12 +6402,12 @@ var init_checks = __esm({
6136
6402
  });
6137
6403
 
6138
6404
  // src/server/ui-prefs.ts
6139
- import { readFileSync as readFileSync21 } from "fs";
6405
+ import { readFileSync as readFileSync22 } from "fs";
6140
6406
  import { homedir } from "os";
6141
- import { join as join15 } from "path";
6407
+ import { join as join16 } from "path";
6142
6408
  function readJson2(path) {
6143
6409
  try {
6144
- const parsed = JSON.parse(readFileSync21(path, "utf8"));
6410
+ const parsed = JSON.parse(readFileSync22(path, "utf8"));
6145
6411
  return parsed && typeof parsed === "object" ? parsed : {};
6146
6412
  } catch {
6147
6413
  return {};
@@ -6166,7 +6432,7 @@ var init_ui_prefs = __esm({
6166
6432
  THEMES = ["system", "light", "dark"];
6167
6433
  isThemeMode = (v) => THEMES.includes(v);
6168
6434
  isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
6169
- defaultUiPrefsPath = () => join15(homedir(), ".glotfile", "ui.json");
6435
+ defaultUiPrefsPath = () => join16(homedir(), ".glotfile", "ui.json");
6170
6436
  DEFAULTS = { theme: "system" };
6171
6437
  }
6172
6438
  });
@@ -6174,19 +6440,34 @@ var init_ui_prefs = __esm({
6174
6440
  // src/server/api.ts
6175
6441
  import { Hono } from "hono";
6176
6442
  import { streamSSE } from "hono/streaming";
6177
- 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";
6178
6444
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
6179
6445
  function projectName(root) {
6180
6446
  const nameFile = resolve9(root, ".idea", ".name");
6181
- if (existsSync12(nameFile)) {
6447
+ if (existsSync13(nameFile)) {
6182
6448
  try {
6183
- const name = readFileSync22(nameFile, "utf8").trim();
6449
+ const name = readFileSync23(nameFile, "utf8").trim();
6184
6450
  if (name) return name;
6185
6451
  } catch {
6186
6452
  }
6187
6453
  }
6188
6454
  return basename(root);
6189
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
+ }
6190
6471
  function createApi(deps) {
6191
6472
  const app = new Hono();
6192
6473
  const load = () => loadState(deps.statePath);
@@ -6313,7 +6594,7 @@ function createApi(deps) {
6313
6594
  if (name.startsWith(".") || name === "node_modules") continue;
6314
6595
  const abs = resolve9(dir, name);
6315
6596
  let filePath = null;
6316
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync12(resolve9(abs, "config.json"))) {
6597
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync13(resolve9(abs, "config.json"))) {
6317
6598
  filePath = resolve9(dir, `${name}.json`);
6318
6599
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
6319
6600
  filePath = abs;
@@ -6347,7 +6628,7 @@ function createApi(deps) {
6347
6628
  const resolved = resolve9(projectRoot, path);
6348
6629
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
6349
6630
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
6350
- if (!existsSync12(resolved)) return c.json({ error: "file not found" }, 400);
6631
+ if (!existsSync13(resolved)) return c.json({ error: "file not found" }, 400);
6351
6632
  loadState(resolved);
6352
6633
  deps.statePath = resolved;
6353
6634
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -6408,9 +6689,9 @@ function createApi(deps) {
6408
6689
  const abs = resolve9(root, screenshot);
6409
6690
  const rel = relative4(root, abs);
6410
6691
  const seg0 = rel.split(sep2)[0] ?? "";
6411
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync12(abs)) {
6692
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync13(abs)) {
6412
6693
  try {
6413
- rmSync5(abs);
6694
+ rmSync6(abs);
6414
6695
  } catch {
6415
6696
  }
6416
6697
  }
@@ -6836,6 +7117,7 @@ function createApi(deps) {
6836
7117
  persist(fresh);
6837
7118
  totalWritten += written;
6838
7119
  allErrors.push(...errors);
7120
+ const usage = provider.takeUsage?.();
6839
7121
  appendLog(projectRoot, {
6840
7122
  at: (/* @__PURE__ */ new Date()).toISOString(),
6841
7123
  kind: "translate",
@@ -6846,7 +7128,9 @@ function createApi(deps) {
6846
7128
  const req = reqById.get(r.id);
6847
7129
  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 };
6848
7130
  }),
6849
- results: batchResults
7131
+ results: batchResults,
7132
+ usage,
7133
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
6850
7134
  });
6851
7135
  const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
6852
7136
  localeDone.set(locale, ld);
@@ -6918,11 +7202,14 @@ function createApi(deps) {
6918
7202
  }, aiCfg.concurrency, void 0, aiCfg.batchSize);
6919
7203
  const latest = load();
6920
7204
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
7205
+ const usage = provider.takeUsage?.();
6921
7206
  const entry = {
6922
7207
  at: (/* @__PURE__ */ new Date()).toISOString(),
6923
7208
  kind: "translate",
6924
7209
  summary: `Translated ${toTranslate.length} item(s)`,
6925
7210
  model: aiCfg.model,
7211
+ usage,
7212
+ estimatedCostUsd: usageCostUsd(usage, aiCfg),
6926
7213
  system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
6927
7214
  // Log the screenshot PATH only — never the image bytes.
6928
7215
  items: toTranslate.map((r) => ({
@@ -7020,17 +7307,7 @@ function createApi(deps) {
7020
7307
  if (!supportsBatchTranslate(provider)) {
7021
7308
  return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
7022
7309
  }
7023
- const outcome = await applyBatchResults(load, persist, provider, pending, projectRoot, {
7024
- batchSize: aiCfg.batchSize,
7025
- concurrency: aiCfg.concurrency
7026
- });
7027
- appendLog(projectRoot, {
7028
- at: (/* @__PURE__ */ new Date()).toISOString(),
7029
- kind: "translate",
7030
- summary: `Applied batch ${pending.batchId}: wrote ${outcome.written}, ${outcome.retried} retried, ${outcome.staleSkipped} stale`,
7031
- model: aiCfg.model,
7032
- results: []
7033
- });
7310
+ const outcome = await applyBatchResults(load, persist, provider, pending, projectRoot, aiCfg);
7034
7311
  console.log(`[batch] applied ${pending.batchId} \u2014 wrote ${outcome.written}, ${outcome.errors.length} error(s)`);
7035
7312
  return c.json(outcome);
7036
7313
  }));
@@ -7142,19 +7419,7 @@ function createApi(deps) {
7142
7419
  return;
7143
7420
  }
7144
7421
  await stream.writeSSE({ event: "start", data: JSON.stringify({ total: targets.length }) });
7145
- const fileCache = /* @__PURE__ */ new Map();
7146
- for (const target of targets) {
7147
- const allRefs = Object.entries(cache2.files).flatMap(
7148
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
7149
- key: r.key,
7150
- file,
7151
- line: r.line,
7152
- col: r.col,
7153
- scanner: r.scanner
7154
- }))
7155
- );
7156
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
7157
- }
7422
+ attachUsageSnippets(targets, cache2, projectRoot);
7158
7423
  const system = buildContextSystemPrompt();
7159
7424
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
7160
7425
  const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
@@ -7181,6 +7446,7 @@ function createApi(deps) {
7181
7446
  const batch = raw;
7182
7447
  const fresh = load();
7183
7448
  const { written, errors } = applyContext(fresh, chunk2, batch.items ?? [], void 0, body.force === true);
7449
+ const usage = provider.takeUsage?.();
7184
7450
  appendLog(projectRoot, {
7185
7451
  at: (/* @__PURE__ */ new Date()).toISOString(),
7186
7452
  kind: "context",
@@ -7188,7 +7454,9 @@ function createApi(deps) {
7188
7454
  model: aiCfg.model,
7189
7455
  system,
7190
7456
  items: chunk2.map((t) => ({ id: t.id, key: t.key, source: t.source })),
7191
- results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
7457
+ results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error })),
7458
+ usage,
7459
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
7192
7460
  });
7193
7461
  persist(fresh);
7194
7462
  totalWritten += written;
@@ -7203,6 +7471,100 @@ function createApi(deps) {
7203
7471
  await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
7204
7472
  });
7205
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
+ });
7206
7568
  app.onError(
7207
7569
  (err, c) => c.json({ error: err.message }, err instanceof GlotfileError ? 400 : 500)
7208
7570
  );
@@ -7228,7 +7590,10 @@ var init_api = __esm({
7228
7590
  init_provider();
7229
7591
  init_batch_run();
7230
7592
  init_pending_batch();
7593
+ init_context_batch_run();
7594
+ init_pending_context_batch();
7231
7595
  init_estimate();
7596
+ init_pricing();
7232
7597
  init_log();
7233
7598
  init_schema();
7234
7599
  init_run3();
@@ -7250,7 +7615,7 @@ __export(server_exports, {
7250
7615
  import { Hono as Hono2 } from "hono";
7251
7616
  import { serve } from "@hono/node-server";
7252
7617
  import { fileURLToPath } from "url";
7253
- 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";
7254
7619
  import { readFile, stat } from "fs/promises";
7255
7620
  import { createServer } from "net";
7256
7621
  import open from "open";
@@ -7293,7 +7658,7 @@ function buildApp(opts) {
7293
7658
  const file = await readFileResponse(target);
7294
7659
  if (file) return file;
7295
7660
  }
7296
- const index = await readFileResponse(join16(root, "index.html"));
7661
+ const index = await readFileResponse(join17(root, "index.html"));
7297
7662
  if (index) return index;
7298
7663
  return c.notFound();
7299
7664
  });
@@ -7351,7 +7716,7 @@ var init_server = __esm({
7351
7716
  init_scan();
7352
7717
  init_scanner();
7353
7718
  here = dirname4(fileURLToPath(import.meta.url));
7354
- DEFAULT_UI_DIR = join16(here, "..", "ui");
7719
+ DEFAULT_UI_DIR = join17(here, "..", "ui");
7355
7720
  MIME = {
7356
7721
  ".html": "text/html; charset=utf-8",
7357
7722
  ".js": "text/javascript; charset=utf-8",
@@ -7390,15 +7755,18 @@ init_run();
7390
7755
  init_provider();
7391
7756
  init_batch_run();
7392
7757
  init_pending_batch();
7758
+ init_context_batch_run();
7759
+ init_pending_context_batch();
7393
7760
  init_estimate();
7761
+ init_pricing();
7394
7762
  init_log();
7395
7763
  init_scan();
7396
7764
  init_scanner();
7397
7765
  init_context();
7398
7766
  init_run2();
7399
7767
  init_outputs();
7400
- import { resolve as resolve11, dirname as dirname5, join as join17 } from "path";
7401
- 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";
7402
7770
  import { fileURLToPath as fileURLToPath2 } from "url";
7403
7771
 
7404
7772
  // src/server/lint/locate.ts
@@ -7713,11 +8081,14 @@ async function runTranslate(args) {
7713
8081
  if (!batchCallbackFired) {
7714
8082
  ({ written, errors } = applyResults(state, toTranslate, results));
7715
8083
  }
8084
+ const usage = provider.takeUsage?.();
7716
8085
  appendLog(projectRoot, {
7717
8086
  at: (/* @__PURE__ */ new Date()).toISOString(),
7718
8087
  kind: "translate",
7719
8088
  summary: `Translated ${toTranslate.length} item(s)`,
7720
8089
  model: ai.model,
8090
+ usage,
8091
+ estimatedCostUsd: usageCostUsd(usage, ai),
7721
8092
  system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
7722
8093
  items: toTranslate.map((r) => ({
7723
8094
  id: r.id,
@@ -7752,7 +8123,7 @@ async function applyPending(args, provider, pending, ai) {
7752
8123
  provider,
7753
8124
  pending,
7754
8125
  projectRoot,
7755
- { batchSize: ai.batchSize, concurrency: ai.concurrency }
8126
+ ai
7756
8127
  );
7757
8128
  reportApply(outcome);
7758
8129
  }
@@ -7770,11 +8141,16 @@ async function waitAndApply(args, provider, pending, ai) {
7770
8141
  async function runBatch(args) {
7771
8142
  const projectRoot = dirname5(resolve11(args.statePath));
7772
8143
  const pending = loadPendingBatch(projectRoot);
7773
- if (!pending) {
7774
- 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`.");
7775
8147
  return;
7776
8148
  }
7777
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) {
7778
8154
  if (action === "cancel") {
7779
8155
  let remoteFailed = false;
7780
8156
  try {
@@ -7811,6 +8187,53 @@ async function runBatch(args) {
7811
8187
  }
7812
8188
  await applyPending(args, provider, pending, ai);
7813
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
+ }
7814
8237
  function printReport(report, format, rawText) {
7815
8238
  if (format === "json") console.log(formatJson(report).trimEnd());
7816
8239
  else if (format === "sarif") console.log(formatSarif(report, rawText).trimEnd());
@@ -7830,7 +8253,7 @@ async function runLintCmd(args) {
7830
8253
  }
7831
8254
  return;
7832
8255
  }
7833
- const rawText = existsSync13(args.statePath) ? readFileSync23(args.statePath, "utf8") : "";
8256
+ const rawText = existsSync14(args.statePath) ? readFileSync24(args.statePath, "utf8") : "";
7834
8257
  const report = await runLint(state, {
7835
8258
  locales: args.locales,
7836
8259
  ruleIds: args.ruleIds,
@@ -7854,7 +8277,7 @@ async function runCheck(args) {
7854
8277
  process.exitCode = 1;
7855
8278
  return;
7856
8279
  }
7857
- const rawText = existsSync13(args.statePath) ? readFileSync23(args.statePath, "utf8") : "";
8280
+ const rawText = existsSync14(args.statePath) ? readFileSync24(args.statePath, "utf8") : "";
7858
8281
  const root = dirname5(resolve11(args.statePath));
7859
8282
  const lint = await runLint(state, {});
7860
8283
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
@@ -7867,7 +8290,7 @@ async function runImportCmd(args) {
7867
8290
  const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
7868
8291
  const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
7869
8292
  const out = resolve11(projectRoot, "glotfile.json");
7870
- if (existsSync13(out) && !args.importForce) {
8293
+ if (existsSync14(out) && !args.importForce) {
7871
8294
  console.error(`${out} already exists; pass --force to overwrite`);
7872
8295
  process.exitCode = 1;
7873
8296
  return;
@@ -7934,6 +8357,32 @@ async function runBuildContext(args) {
7934
8357
  const aiCfg = loadLocalSettings(projectRoot).ai;
7935
8358
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
7936
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
+ }
7937
8386
  const chunks = [];
7938
8387
  for (let i = 0; i < targets.length; i += batchSize) chunks.push(targets.slice(i, i + batchSize));
7939
8388
  let written = 0;
@@ -8015,19 +8464,19 @@ function runSplit(args) {
8015
8464
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
8016
8465
  );
8017
8466
  }
8018
- var SKILL_SRC = join17(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
8467
+ var SKILL_SRC = join18(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
8019
8468
  function runSkill(args) {
8020
8469
  if (args.print) {
8021
- console.log(readFileSync23(join17(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
8470
+ console.log(readFileSync24(join18(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
8022
8471
  return;
8023
8472
  }
8024
8473
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
8025
- if (existsSync13(dest) && !args.importForce) {
8474
+ if (existsSync14(dest) && !args.importForce) {
8026
8475
  console.error(`${dest} already exists; pass --force to overwrite`);
8027
8476
  process.exitCode = 1;
8028
8477
  return;
8029
8478
  }
8030
- mkdirSync5(dirname5(dest), { recursive: true });
8479
+ mkdirSync6(dirname5(dest), { recursive: true });
8031
8480
  cpSync(SKILL_SRC, dest, { recursive: true });
8032
8481
  console.log(`Installed the glotfile skill to ${dest}. Restart Claude Code to pick it up.`);
8033
8482
  }
@@ -8092,7 +8541,7 @@ var COMMAND_HELP = {
8092
8541
  },
8093
8542
  "build-context": {
8094
8543
  summary: "AI-generate per-key context to improve translation (requires a prior scan).",
8095
- usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>]",
8544
+ usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>] [--batch]",
8096
8545
  options: [
8097
8546
  ["--all", "(Re)build context for every key, not just those missing it"],
8098
8547
  ["--key <glob>", "Only keys matching this glob"],
@@ -8167,8 +8616,8 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
8167
8616
  );
8168
8617
  }
8169
8618
  function printVersion() {
8170
- const pkgPath = join17(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
8171
- 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);
8172
8621
  }
8173
8622
  async function main(argv) {
8174
8623
  const args = parseArgs(argv);