glotfile 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -103,6 +103,7 @@ function isPluralForm(key) {
103
103
  }
104
104
  var LOCALE_CASES = ["lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"];
105
105
  var PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter", "ollama", "claude-code"];
106
+ var PROMPT_STYLES = ["default", "translategemma"];
106
107
  var GlotfileError = class extends Error {
107
108
  };
108
109
  function isObject(v) {
@@ -1172,7 +1173,7 @@ function buildContextBatchPrompt(reqs) {
1172
1173
  ${s.lines}
1173
1174
  \`\`\``;
1174
1175
  }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
1175
- return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText, hasScreenshot: r.image !== void 0 };
1176
+ return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText };
1176
1177
  });
1177
1178
  return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
1178
1179
  }
@@ -1433,6 +1434,7 @@ function selectRequests(state, opts) {
1433
1434
  id: String(id++),
1434
1435
  key,
1435
1436
  source: other,
1437
+ sourceLocale: state.config.sourceLocale,
1436
1438
  context: entry.context,
1437
1439
  targetLocale: locale,
1438
1440
  maxLength: entry.maxLength,
@@ -1453,6 +1455,7 @@ function selectRequests(state, opts) {
1453
1455
  id: String(id++),
1454
1456
  key,
1455
1457
  source,
1458
+ sourceLocale: state.config.sourceLocale,
1456
1459
  context: entry.context,
1457
1460
  targetLocale: locale,
1458
1461
  maxLength: entry.maxLength,
@@ -2340,15 +2343,15 @@ function getAdapter(name) {
2340
2343
  }
2341
2344
 
2342
2345
  // src/server/api.ts
2343
- import { readFileSync as readFileSync13, existsSync as existsSync9, readdirSync as readdirSync7, rmSync as rmSync4 } from "fs";
2346
+ import { readFileSync as readFileSync13, existsSync as existsSync9, readdirSync as readdirSync7, statSync as statSync4, rmSync as rmSync4 } from "fs";
2344
2347
  import { dirname as dirname2, resolve as resolve8, basename, relative as relative3, sep } from "path";
2345
2348
 
2346
2349
  // src/server/ai/anthropic.ts
2347
2350
  import Anthropic from "@anthropic-ai/sdk";
2348
2351
 
2349
2352
  // src/server/ai/provider.ts
2350
- function buildSystemPrompt() {
2351
- return [
2353
+ function buildSystemPrompt(hasPluralItems) {
2354
+ const lines = [
2352
2355
  "You are a professional software localization engine for a UI string catalog.",
2353
2356
  "Your goal: translate each source UI string into its target locale accurately and idiomatically, as a native speaker would phrase it in a real app interface.",
2354
2357
  "",
@@ -2360,20 +2363,28 @@ function buildSystemPrompt() {
2360
2363
  "- Glossary: a term marked do-not-translate MUST appear unchanged in the translation. A term with a forced translation for the target locale MUST use that exact translation.",
2361
2364
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
2362
2365
  "- Match the register and capitalization conventions of the target language and of UI microcopy.",
2363
- "- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations.",
2364
- "",
2365
- 'Plural items: an item with a `plural` field gives you the source plural FORMS (keyed by CLDR category) and the `categories` REQUIRED for the target language. Return a `forms` object with one idiomatic translation per REQUIRED category \u2014 including categories the source language does not have (infer them from meaning). Keep the count token shown in the source forms (e.g. {count}) in every form that states a quantity; the `zero`, `one`, and `two` forms MAY omit it when that is natural in the target language \u2014 e.g. "No files", "One file", or a dual form that encodes the count grammatically (Arabic \u0645\u0644\u0641\u0627\u0646). Never introduce a placeholder the source did not have. For these items return `forms` instead of `translation`.'
2366
- ].join("\n");
2366
+ "- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations."
2367
+ ];
2368
+ if (hasPluralItems) {
2369
+ lines.push(
2370
+ "",
2371
+ 'Plural items: an item with a `plural` field gives you the source plural FORMS (keyed by CLDR category) and the `categories` REQUIRED for the target language. Return a `forms` object with one idiomatic translation per REQUIRED category \u2014 including categories the source language does not have (infer them from meaning). Keep the count token shown in the source forms (e.g. {count}) in every form that states a quantity; the `zero`, `one`, and `two` forms MAY omit it when that is natural in the target language \u2014 e.g. "No files", "One file", or a dual form that encodes the count grammatically (Arabic \u0645\u0644\u0641\u0627\u0646). Never introduce a placeholder the source did not have. For these items return `forms` instead of `translation`.'
2372
+ );
2373
+ }
2374
+ return lines.join("\n");
2367
2375
  }
2368
2376
  function buildBatchPrompt(reqs) {
2369
2377
  const targetLocale = reqs[0]?.targetLocale ?? "";
2378
+ const hasPluralItems = reqs.some((r) => r.plural !== void 0);
2370
2379
  const items = reqs.map((r) => {
2371
2380
  const base = {
2372
2381
  id: r.id,
2373
2382
  key: r.key,
2374
2383
  context: r.context ?? null,
2375
2384
  maxLength: r.maxLength ?? null,
2376
- placeholders: r.placeholders,
2385
+ // Wrap in braces so the model sees "{site}" not "site" — makes the visual
2386
+ // connection to the source string obvious and reduces rename errors.
2387
+ placeholders: r.placeholders.map((p) => `{${p}}`),
2377
2388
  glossary: r.glossary ?? [],
2378
2389
  hasScreenshot: r.image !== void 0
2379
2390
  };
@@ -2382,10 +2393,24 @@ function buildBatchPrompt(reqs) {
2382
2393
  }
2383
2394
  return { ...base, source: r.source };
2384
2395
  });
2396
+ const returnFormat = hasPluralItems ? 'For a scalar item (has `source`) return {"id","translation"}; for a plural item (has `plural`) return {"id","forms"} with one string per required category.' : 'Return {"id","translation"} for each item.';
2385
2397
  return `Translate every item below into the target locale: ${targetLocale}. All items share this one target language.
2386
- Glossary entries are constraints you MUST apply. Items with hasScreenshot:true have a screenshot supplied as a separate image block above; use it for context. For a scalar item (has \`source\`) return {"id","translation"}; for a plural item (has \`plural\`) return {"id","forms"} with one string per required category. Return JSON {"items":[\u2026]}.
2398
+ Glossary entries are constraints you MUST apply. Items with hasScreenshot:true have a screenshot supplied as a separate image block above; use it for context. ${returnFormat} Return JSON {"items":[\u2026]}.
2387
2399
  ` + JSON.stringify(items, null, 2);
2388
2400
  }
2401
+ function buildTranslateGemmaSystemPrompt(sourceLocale, targetLocale) {
2402
+ return `You are a professional ${sourceLocale} to ${targetLocale} translator. Your goal is to accurately convey the meaning and nuances of the original ${sourceLocale} text while adhering to ${targetLocale} grammar, vocabulary, and cultural sensitivities. Produce only the ${targetLocale} translation, without any additional explanations or commentary. Preserve every interpolation placeholder exactly as written (e.g. {site}, {count}, {name}) \u2014 do not translate, rename, or remove them. Preserve markdown formatting markers exactly as written (e.g. **bold**, *italic*, __underline__) \u2014 copy them into the translation in the same positions. Please translate the following ${sourceLocale} text into ${targetLocale}:`;
2403
+ }
2404
+ function buildTranslateGemmaUserPrompt(source) {
2405
+ return `
2406
+
2407
+ ${source}`;
2408
+ }
2409
+ function parseTranslateGemmaResponse(text, id) {
2410
+ const translation = text.trim();
2411
+ if (!translation) return { id, error: "No translation returned" };
2412
+ return { id, translation };
2413
+ }
2389
2414
  var BATCH_SCHEMA = {
2390
2415
  type: "object",
2391
2416
  properties: {
@@ -2531,7 +2556,7 @@ var AnthropicProvider = class {
2531
2556
  const res = await this.client.messages.create({
2532
2557
  model: this.config.model,
2533
2558
  max_tokens: 8192,
2534
- system: [{ type: "text", text: buildSystemPrompt(), cache_control: { type: "ephemeral" } }],
2559
+ system: [{ type: "text", text: buildSystemPrompt(batch.some((r) => r.plural !== void 0)), cache_control: { type: "ephemeral" } }],
2535
2560
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
2536
2561
  messages: [{ role: "user", content }]
2537
2562
  }, { signal });
@@ -2621,7 +2646,7 @@ var OpenAIProvider = class {
2621
2646
  // via runBatched, so non-strict schema guidance is sufficient.
2622
2647
  response_format: { type: "json_schema", json_schema: { name: "translations", schema: BATCH_SCHEMA, strict: false } },
2623
2648
  messages: [
2624
- { role: "system", content: buildSystemPrompt() },
2649
+ { role: "system", content: buildSystemPrompt(batch.some((r) => r.plural !== void 0)) },
2625
2650
  { role: "user", content: this.buildUserContent(batch) }
2626
2651
  ]
2627
2652
  }, { signal });
@@ -2725,7 +2750,7 @@ var BedrockProvider = class {
2725
2750
  buildInput(batch) {
2726
2751
  const input = {
2727
2752
  modelId: this.config.model,
2728
- system: [{ text: buildSystemPrompt() }],
2753
+ system: [{ text: buildSystemPrompt(batch.some((r) => r.plural !== void 0)) }],
2729
2754
  messages: [{ role: "user", content: this.buildContentBlocks(batch) }]
2730
2755
  };
2731
2756
  if (!this.isMeta()) {
@@ -2785,7 +2810,27 @@ var OllamaProvider = class extends OpenAIProvider {
2785
2810
  super(config, client ?? loadOpenAIClient(ollamaClientOptions(config)));
2786
2811
  }
2787
2812
  supportsVision() {
2788
- return false;
2813
+ return this.config.vision === true;
2814
+ }
2815
+ // translategemma expects a per-item system prompt, a plain-text user message
2816
+ // (no JSON, no structured output), and returns a plain-text translation.
2817
+ async callBatch(batch, signal) {
2818
+ if (this.config.promptStyle !== "translategemma") {
2819
+ return super.callBatch(batch, signal);
2820
+ }
2821
+ const results = [];
2822
+ for (const req of batch) {
2823
+ const res = await this.client.chat.completions.create({
2824
+ model: this.config.model,
2825
+ messages: [
2826
+ { role: "system", content: buildTranslateGemmaSystemPrompt(req.sourceLocale, req.targetLocale) },
2827
+ { role: "user", content: buildTranslateGemmaUserPrompt(req.source) }
2828
+ ]
2829
+ }, { signal });
2830
+ const text = res.choices?.[0]?.message?.content ?? "";
2831
+ results.push(parseTranslateGemmaResponse(text, req.id));
2832
+ }
2833
+ return results;
2789
2834
  }
2790
2835
  };
2791
2836
 
@@ -2865,7 +2910,7 @@ var ClaudeCodeProvider = class {
2865
2910
  const prompt = buildBatchPrompt(batch);
2866
2911
  let result;
2867
2912
  try {
2868
- result = await this.spawnFn(prompt, buildSystemPrompt(), this.config.model);
2913
+ result = await this.spawnFn(prompt, buildSystemPrompt(batch.some((r) => r.plural !== void 0)), this.config.model);
2869
2914
  } catch (err) {
2870
2915
  if (signal?.aborted) return [];
2871
2916
  throw err;
@@ -3424,18 +3469,37 @@ function coerceAi(raw) {
3424
3469
  model: typeof a.model === "string" && a.model ? a.model : DEFAULT_AI.model,
3425
3470
  endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
3426
3471
  region: typeof a.region === "string" ? a.region : null,
3427
- batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize
3472
+ batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize,
3473
+ concurrency: typeof a.concurrency === "number" && a.concurrency > 0 ? a.concurrency : void 0,
3474
+ contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
3475
+ contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
3476
+ vision: typeof a.vision === "boolean" ? a.vision : void 0,
3477
+ promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
3428
3478
  };
3429
3479
  }
3480
+ function coerceProfiles(raw) {
3481
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
3482
+ const result = {};
3483
+ for (const [k, v] of Object.entries(raw)) {
3484
+ if (typeof k === "string" && k.trim()) result[k] = coerceAi(v);
3485
+ }
3486
+ return result;
3487
+ }
3430
3488
  function loadLocalSettings(projectRoot) {
3431
3489
  const raw = readJson2(settingsPath(projectRoot));
3432
- return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
3490
+ const profiles = coerceProfiles(raw.profiles);
3491
+ const activeProfile = typeof raw.activeProfile === "string" && raw.activeProfile in profiles ? raw.activeProfile : null;
3492
+ const baseAi = coerceAi(raw.ai);
3493
+ const ai = activeProfile ? profiles[activeProfile] : baseAi;
3494
+ return { ai, editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR, profiles, activeProfile };
3433
3495
  }
3434
3496
  function saveLocalSettings(projectRoot, patch) {
3435
3497
  const path = settingsPath(projectRoot);
3436
3498
  const merged = { ...readJson2(path) };
3437
3499
  if (patch.ai !== void 0) merged.ai = patch.ai;
3438
3500
  if (patch.editor !== void 0) merged.editor = patch.editor;
3501
+ if (patch.profiles !== void 0) merged.profiles = patch.profiles;
3502
+ if (patch.activeProfile !== void 0) merged.activeProfile = patch.activeProfile;
3439
3503
  ensureGlotfileDir(projectRoot);
3440
3504
  writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3441
3505
  }
@@ -3523,32 +3587,90 @@ function createApi(deps) {
3523
3587
  saveLocalSettings(projectRoot, patch);
3524
3588
  return c.json({ ok: true });
3525
3589
  });
3590
+ app.get("/ai-profiles", (c) => {
3591
+ const ls = loadLocalSettings(projectRoot);
3592
+ return c.json({ profiles: ls.profiles, activeProfile: ls.activeProfile });
3593
+ });
3594
+ app.put("/ai-profiles/:name", async (c) => {
3595
+ const name = c.req.param("name").trim();
3596
+ if (!name) return c.json({ error: "name required" }, 400);
3597
+ const body = await c.req.json().catch(() => ({}));
3598
+ const err = aiConfigError(body);
3599
+ if (err) return c.json({ error: err }, 400);
3600
+ const ls = loadLocalSettings(projectRoot);
3601
+ saveLocalSettings(projectRoot, { profiles: { ...ls.profiles, [name]: body } });
3602
+ return c.json({ ok: true });
3603
+ });
3604
+ app.delete("/ai-profiles/:name", (c) => {
3605
+ const name = c.req.param("name");
3606
+ const ls = loadLocalSettings(projectRoot);
3607
+ if (!(name in ls.profiles)) return c.json({ error: "profile not found" }, 404);
3608
+ const profiles = { ...ls.profiles };
3609
+ delete profiles[name];
3610
+ const patch = { profiles };
3611
+ if (ls.activeProfile === name) patch.activeProfile = null;
3612
+ saveLocalSettings(projectRoot, patch);
3613
+ return c.json({ ok: true });
3614
+ });
3615
+ app.post("/ai-profiles/active", async (c) => {
3616
+ const { name } = await c.req.json().catch(() => ({}));
3617
+ if (name !== null && name !== void 0) {
3618
+ if (typeof name !== "string") return c.json({ error: "name must be a string or null" }, 400);
3619
+ const ls = loadLocalSettings(projectRoot);
3620
+ if (name !== "" && !(name in ls.profiles)) return c.json({ error: "profile not found" }, 404);
3621
+ saveLocalSettings(projectRoot, { activeProfile: name || null });
3622
+ } else {
3623
+ saveLocalSettings(projectRoot, { activeProfile: null });
3624
+ }
3625
+ return c.json({ ok: true });
3626
+ });
3526
3627
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3527
3628
  app.get("/files", (c) => {
3528
3629
  const found = /* @__PURE__ */ new Map();
3529
- found.set(deps.statePath, { name: basename(deps.statePath), path: deps.statePath });
3530
- let entries = [];
3531
- try {
3532
- entries = readdirSync7(projectRoot);
3533
- } catch {
3534
- }
3535
- for (const name of entries) {
3536
- let abs;
3537
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve8(projectRoot, name, "config.json"))) {
3538
- abs = resolve8(projectRoot, `${name}.json`);
3539
- } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3540
- abs = resolve8(projectRoot, name);
3541
- } else {
3542
- continue;
3543
- }
3544
- if (found.has(abs)) continue;
3630
+ const activeRel = relative3(projectRoot, deps.statePath);
3631
+ found.set(deps.statePath, {
3632
+ name: basename(deps.statePath),
3633
+ path: deps.statePath,
3634
+ relDir: activeRel !== basename(activeRel) ? dirname2(activeRel) : void 0
3635
+ });
3636
+ function walk(dir, depth) {
3637
+ if (depth > 4) return;
3638
+ let entries = [];
3545
3639
  try {
3546
- loadState(abs);
3547
- found.set(abs, { name: basename(abs), path: abs });
3640
+ entries = readdirSync7(dir);
3548
3641
  } catch {
3642
+ return;
3643
+ }
3644
+ for (const name of entries) {
3645
+ if (name.startsWith(".") || name === "node_modules") continue;
3646
+ const abs = resolve8(dir, name);
3647
+ let filePath = null;
3648
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve8(abs, "config.json"))) {
3649
+ filePath = resolve8(dir, `${name}.json`);
3650
+ } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3651
+ filePath = abs;
3652
+ } else {
3653
+ try {
3654
+ if (statSync4(abs).isDirectory()) walk(abs, depth + 1);
3655
+ } catch {
3656
+ }
3657
+ continue;
3658
+ }
3659
+ if (found.has(filePath)) continue;
3660
+ try {
3661
+ loadState(filePath);
3662
+ const rel = relative3(projectRoot, filePath);
3663
+ found.set(filePath, { name: basename(filePath), path: filePath, relDir: rel !== basename(filePath) ? dirname2(rel) : void 0 });
3664
+ } catch {
3665
+ }
3549
3666
  }
3550
3667
  }
3551
- const files = [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
3668
+ walk(projectRoot, 0);
3669
+ const files = [...found.values()].sort((a, b) => {
3670
+ const ka = a.relDir ? `${a.relDir}/${a.name}` : a.name;
3671
+ const kb = b.relDir ? `${b.relDir}/${b.name}` : b.name;
3672
+ return ka.localeCompare(kb);
3673
+ });
3552
3674
  return c.json(files);
3553
3675
  });
3554
3676
  app.post("/file", async (c) => {
@@ -3680,7 +3802,7 @@ function createApi(deps) {
3680
3802
  return c.json({ removed });
3681
3803
  });
3682
3804
  app.post("/keys/bulk-meta", async (c) => {
3683
- const { keys, addTags, removeTags, skipTranslate } = await c.req.json();
3805
+ const { keys, addTags, removeTags, skipTranslate, clearContext } = await c.req.json();
3684
3806
  if (!Array.isArray(keys) || keys.length === 0) return c.json({ error: "keys must be a non-empty array" }, 400);
3685
3807
  const s = load();
3686
3808
  let updated = 0;
@@ -3698,6 +3820,11 @@ function createApi(deps) {
3698
3820
  if (skipTranslate) setMetadata(s, key, { skipTranslate: true });
3699
3821
  else delete entry.skipTranslate;
3700
3822
  }
3823
+ if (clearContext === true) {
3824
+ delete entry.context;
3825
+ delete entry.contextSource;
3826
+ delete entry.contextAt;
3827
+ }
3701
3828
  updated++;
3702
3829
  }
3703
3830
  persist(s);
@@ -3978,7 +4105,7 @@ function createApi(deps) {
3978
4105
  console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
3979
4106
  let totalWritten = 0;
3980
4107
  const allErrors = [];
3981
- const system = buildSystemPrompt();
4108
+ const system = buildSystemPrompt(reqs.some((r) => r.plural !== void 0));
3982
4109
  const reqById = new Map(reqs.map((r) => [r.id, r]));
3983
4110
  const localeTotals = /* @__PURE__ */ new Map();
3984
4111
  for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
@@ -3994,8 +4121,9 @@ function createApi(deps) {
3994
4121
  void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
3995
4122
  },
3996
4123
  onBatchComplete: (done, total, batchResults, locale) => {
3997
- const { written, errors } = applyResults(s, reqs, batchResults);
3998
- persist(s);
4124
+ const fresh = load();
4125
+ const { written, errors } = applyResults(fresh, reqs, batchResults);
4126
+ persist(fresh);
3999
4127
  totalWritten += written;
4000
4128
  allErrors.push(...errors);
4001
4129
  appendLog(projectRoot, {
@@ -4006,7 +4134,7 @@ function createApi(deps) {
4006
4134
  system,
4007
4135
  items: batchResults.map((r) => {
4008
4136
  const req = reqById.get(r.id);
4009
- return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
4137
+ 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 };
4010
4138
  }),
4011
4139
  results: batchResults
4012
4140
  });
@@ -4021,7 +4149,7 @@ function createApi(deps) {
4021
4149
  onLocaleDone: (locale) => {
4022
4150
  void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4023
4151
  }
4024
- }, void 0, signal);
4152
+ }, aiCfg.concurrency, signal);
4025
4153
  if (!signal?.aborted) {
4026
4154
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
4027
4155
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -4052,14 +4180,15 @@ function createApi(deps) {
4052
4180
  }
4053
4181
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
4054
4182
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4055
- const results = await runLocaleParallel(toTranslate, provider);
4056
- ({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
4183
+ const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
4184
+ const latest = load();
4185
+ ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
4057
4186
  const entry = {
4058
4187
  at: (/* @__PURE__ */ new Date()).toISOString(),
4059
4188
  kind: "translate",
4060
4189
  summary: `Translated ${toTranslate.length} item(s)`,
4061
4190
  model: aiCfg.model,
4062
- system: buildSystemPrompt(),
4191
+ system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
4063
4192
  // Log the screenshot PATH only — never the image bytes.
4064
4193
  items: toTranslate.map((r) => ({
4065
4194
  id: r.id,
@@ -4068,13 +4197,13 @@ function createApi(deps) {
4068
4197
  targetLocale: r.targetLocale,
4069
4198
  context: r.context,
4070
4199
  glossary: r.glossary,
4071
- screenshot: s.keys[r.key]?.screenshot
4200
+ screenshot: latest.keys[r.key]?.screenshot
4072
4201
  })),
4073
4202
  results
4074
4203
  };
4075
4204
  appendLog(projectRoot, entry);
4205
+ persist(latest);
4076
4206
  }
4077
- persist(s);
4078
4207
  return c.json({ requested: reqs.length, written, errors });
4079
4208
  }));
4080
4209
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
@@ -4130,55 +4259,95 @@ function createApi(deps) {
4130
4259
  return c.json({ indexed: true, scannedAt: cache2.scannedAt, used: computeUsedKeys(load(), cache2) });
4131
4260
  });
4132
4261
  app.post("/context/build", async (c) => {
4262
+ const signal = c.req.raw.signal;
4133
4263
  const body = await c.req.json().catch(() => ({}));
4134
- const s = load();
4135
- const cache2 = loadUsageCache(projectRoot);
4136
- if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
4137
- const targets = selectContextTargets(s, {
4138
- all: body.all,
4139
- keyGlob: body.keyGlob,
4140
- limit: body.limit,
4141
- since: body.since,
4142
- keys: body.keys
4143
- }, cache2, body.lastRunAt);
4144
- if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4145
- const aiCfg = loadLocalSettings(projectRoot).ai;
4146
- let provider;
4147
- try {
4148
- provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4149
- } catch (e) {
4150
- return c.json({ error: e.message }, 400);
4151
- }
4152
- const fileCache = /* @__PURE__ */ new Map();
4153
- for (const target of targets) {
4154
- const allRefs = Object.entries(cache2.files).flatMap(
4155
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
4156
- key: r.key,
4157
- file,
4158
- line: r.line,
4159
- col: r.col,
4160
- scanner: r.scanner
4161
- }))
4162
- );
4163
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
4164
- }
4165
- const system = buildContextSystemPrompt();
4166
- const prompt = buildContextBatchPrompt(targets);
4167
- const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
4168
- const batch = raw;
4169
- const { written, errors } = applyContext(s, targets, batch.items ?? []);
4170
- appendLog(projectRoot, {
4171
- at: (/* @__PURE__ */ new Date()).toISOString(),
4172
- kind: "context",
4173
- summary: `Generated context for ${targets.length} key(s)`,
4174
- model: aiCfg.model,
4175
- system,
4176
- items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4177
- results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
4264
+ return streamSSE(c, async (stream) => {
4265
+ const s = load();
4266
+ const cache2 = loadUsageCache(projectRoot);
4267
+ if (!cache2) {
4268
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: "No usage index found. Run 'glotfile scan' first." }) });
4269
+ return;
4270
+ }
4271
+ const targets = selectContextTargets(s, {
4272
+ all: body.all,
4273
+ keyGlob: body.keyGlob,
4274
+ limit: body.limit,
4275
+ since: body.since,
4276
+ keys: body.keys
4277
+ }, cache2, body.lastRunAt);
4278
+ if (!targets.length) {
4279
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: 0, written: 0, errors: [] }) });
4280
+ return;
4281
+ }
4282
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4283
+ let provider;
4284
+ try {
4285
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4286
+ } catch (e) {
4287
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4288
+ return;
4289
+ }
4290
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: targets.length }) });
4291
+ const fileCache = /* @__PURE__ */ new Map();
4292
+ for (const target of targets) {
4293
+ const allRefs = Object.entries(cache2.files).flatMap(
4294
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
4295
+ key: r.key,
4296
+ file,
4297
+ line: r.line,
4298
+ col: r.col,
4299
+ scanner: r.scanner
4300
+ }))
4301
+ );
4302
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
4303
+ }
4304
+ const system = buildContextSystemPrompt();
4305
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
4306
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
4307
+ const chunks = [];
4308
+ for (let i = 0; i < targets.length; i += batchSize) chunks.push(targets.slice(i, i + batchSize));
4309
+ let totalWritten = 0;
4310
+ let totalDone = 0;
4311
+ const allErrors = [];
4312
+ let next = 0;
4313
+ async function worker() {
4314
+ while (next < chunks.length) {
4315
+ if (signal?.aborted) break;
4316
+ const chunk2 = chunks[next++];
4317
+ let raw;
4318
+ try {
4319
+ raw = await provider.complete({ system, content: [{ type: "text", text: buildContextBatchPrompt(chunk2) }], schema: CONTEXT_BATCH_SCHEMA });
4320
+ } catch (e) {
4321
+ totalDone += chunk2.length;
4322
+ allErrors.push(...chunk2.map((t) => ({ key: t.key, error: e.message })));
4323
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done: totalDone, total: targets.length, written: totalWritten }) });
4324
+ continue;
4325
+ }
4326
+ if (signal?.aborted) break;
4327
+ const batch = raw;
4328
+ const fresh = load();
4329
+ const { written, errors } = applyContext(fresh, chunk2, batch.items ?? []);
4330
+ appendLog(projectRoot, {
4331
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4332
+ kind: "context",
4333
+ summary: `Generated context for ${chunk2.length} key(s)`,
4334
+ model: aiCfg.model,
4335
+ system,
4336
+ items: chunk2.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4337
+ results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
4338
+ });
4339
+ persist(fresh);
4340
+ totalWritten += written;
4341
+ totalDone += chunk2.length;
4342
+ allErrors.push(...errors);
4343
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done: totalDone, total: targets.length, written: totalWritten }) });
4344
+ }
4345
+ }
4346
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
4347
+ if (signal?.aborted) return;
4348
+ console.log(`[context] ${totalWritten} context(s) written${allErrors.length ? `, ${allErrors.length} error(s)` : ""}`);
4349
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
4178
4350
  });
4179
- persist(s);
4180
- console.log(`[context] ${written} context(s) written${errors.length ? `, ${errors.length} error(s)` : ""}`);
4181
- return c.json({ requested: targets.length, written, errors });
4182
4351
  });
4183
4352
  app.onError(
4184
4353
  (err, c) => c.json({ error: err.message }, err instanceof GlotfileError ? 400 : 500)