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.
@@ -281,7 +281,7 @@ function defaultState() {
281
281
  keys: {}
282
282
  };
283
283
  }
284
- var CURRENT_VERSION, STATES, PLURAL_CATEGORIES, EXACT_SELECTOR_RE, LOCALE_CASES, PROVIDERS, GlotfileError;
284
+ var CURRENT_VERSION, STATES, PLURAL_CATEGORIES, EXACT_SELECTOR_RE, LOCALE_CASES, PROVIDERS, PROMPT_STYLES, GlotfileError;
285
285
  var init_schema = __esm({
286
286
  "src/server/schema.ts"() {
287
287
  "use strict";
@@ -292,6 +292,7 @@ var init_schema = __esm({
292
292
  EXACT_SELECTOR_RE = /^=\d+$/;
293
293
  LOCALE_CASES = ["lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"];
294
294
  PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter", "ollama", "claude-code"];
295
+ PROMPT_STYLES = ["default", "translategemma"];
295
296
  GlotfileError = class extends Error {
296
297
  };
297
298
  }
@@ -1563,8 +1564,8 @@ var init_export_run = __esm({
1563
1564
  });
1564
1565
 
1565
1566
  // src/server/ai/provider.ts
1566
- function buildSystemPrompt() {
1567
- return [
1567
+ function buildSystemPrompt(hasPluralItems) {
1568
+ const lines = [
1568
1569
  "You are a professional software localization engine for a UI string catalog.",
1569
1570
  "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.",
1570
1571
  "",
@@ -1576,20 +1577,28 @@ function buildSystemPrompt() {
1576
1577
  "- 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.",
1577
1578
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
1578
1579
  "- Match the register and capitalization conventions of the target language and of UI microcopy.",
1579
- "- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations.",
1580
- "",
1581
- '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`.'
1582
- ].join("\n");
1580
+ "- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations."
1581
+ ];
1582
+ if (hasPluralItems) {
1583
+ lines.push(
1584
+ "",
1585
+ '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`.'
1586
+ );
1587
+ }
1588
+ return lines.join("\n");
1583
1589
  }
1584
1590
  function buildBatchPrompt(reqs) {
1585
1591
  const targetLocale = reqs[0]?.targetLocale ?? "";
1592
+ const hasPluralItems = reqs.some((r) => r.plural !== void 0);
1586
1593
  const items = reqs.map((r) => {
1587
1594
  const base = {
1588
1595
  id: r.id,
1589
1596
  key: r.key,
1590
1597
  context: r.context ?? null,
1591
1598
  maxLength: r.maxLength ?? null,
1592
- placeholders: r.placeholders,
1599
+ // Wrap in braces so the model sees "{site}" not "site" — makes the visual
1600
+ // connection to the source string obvious and reduces rename errors.
1601
+ placeholders: r.placeholders.map((p) => `{${p}}`),
1593
1602
  glossary: r.glossary ?? [],
1594
1603
  hasScreenshot: r.image !== void 0
1595
1604
  };
@@ -1598,10 +1607,24 @@ function buildBatchPrompt(reqs) {
1598
1607
  }
1599
1608
  return { ...base, source: r.source };
1600
1609
  });
1610
+ 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.';
1601
1611
  return `Translate every item below into the target locale: ${targetLocale}. All items share this one target language.
1602
- 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]}.
1612
+ 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]}.
1603
1613
  ` + JSON.stringify(items, null, 2);
1604
1614
  }
1615
+ function buildTranslateGemmaSystemPrompt(sourceLocale, targetLocale) {
1616
+ 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}:`;
1617
+ }
1618
+ function buildTranslateGemmaUserPrompt(source) {
1619
+ return `
1620
+
1621
+ ${source}`;
1622
+ }
1623
+ function parseTranslateGemmaResponse(text, id) {
1624
+ const translation = text.trim();
1625
+ if (!translation) return { id, error: "No translation returned" };
1626
+ return { id, translation };
1627
+ }
1605
1628
  var BATCH_SCHEMA;
1606
1629
  var init_provider = __esm({
1607
1630
  "src/server/ai/provider.ts"() {
@@ -1766,7 +1789,7 @@ var init_anthropic = __esm({
1766
1789
  const res = await this.client.messages.create({
1767
1790
  model: this.config.model,
1768
1791
  max_tokens: 8192,
1769
- system: [{ type: "text", text: buildSystemPrompt(), cache_control: { type: "ephemeral" } }],
1792
+ system: [{ type: "text", text: buildSystemPrompt(batch.some((r) => r.plural !== void 0)), cache_control: { type: "ephemeral" } }],
1770
1793
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
1771
1794
  messages: [{ role: "user", content }]
1772
1795
  }, { signal });
@@ -1864,7 +1887,7 @@ var init_openai = __esm({
1864
1887
  // via runBatched, so non-strict schema guidance is sufficient.
1865
1888
  response_format: { type: "json_schema", json_schema: { name: "translations", schema: BATCH_SCHEMA, strict: false } },
1866
1889
  messages: [
1867
- { role: "system", content: buildSystemPrompt() },
1890
+ { role: "system", content: buildSystemPrompt(batch.some((r) => r.plural !== void 0)) },
1868
1891
  { role: "user", content: this.buildUserContent(batch) }
1869
1892
  ]
1870
1893
  }, { signal });
@@ -1976,7 +1999,7 @@ var init_bedrock = __esm({
1976
1999
  buildInput(batch) {
1977
2000
  const input = {
1978
2001
  modelId: this.config.model,
1979
- system: [{ text: buildSystemPrompt() }],
2002
+ system: [{ text: buildSystemPrompt(batch.some((r) => r.plural !== void 0)) }],
1980
2003
  messages: [{ role: "user", content: this.buildContentBlocks(batch) }]
1981
2004
  };
1982
2005
  if (!this.isMeta()) {
@@ -2043,6 +2066,7 @@ var OLLAMA_BASE_URL, OllamaProvider;
2043
2066
  var init_ollama = __esm({
2044
2067
  "src/server/ai/ollama.ts"() {
2045
2068
  "use strict";
2069
+ init_provider();
2046
2070
  init_openai();
2047
2071
  OLLAMA_BASE_URL = "http://localhost:11434/v1";
2048
2072
  OllamaProvider = class extends OpenAIProvider {
@@ -2050,7 +2074,27 @@ var init_ollama = __esm({
2050
2074
  super(config, client ?? loadOpenAIClient(ollamaClientOptions(config)));
2051
2075
  }
2052
2076
  supportsVision() {
2053
- return false;
2077
+ return this.config.vision === true;
2078
+ }
2079
+ // translategemma expects a per-item system prompt, a plain-text user message
2080
+ // (no JSON, no structured output), and returns a plain-text translation.
2081
+ async callBatch(batch, signal) {
2082
+ if (this.config.promptStyle !== "translategemma") {
2083
+ return super.callBatch(batch, signal);
2084
+ }
2085
+ const results = [];
2086
+ for (const req of batch) {
2087
+ const res = await this.client.chat.completions.create({
2088
+ model: this.config.model,
2089
+ messages: [
2090
+ { role: "system", content: buildTranslateGemmaSystemPrompt(req.sourceLocale, req.targetLocale) },
2091
+ { role: "user", content: buildTranslateGemmaUserPrompt(req.source) }
2092
+ ]
2093
+ }, { signal });
2094
+ const text = res.choices?.[0]?.message?.content ?? "";
2095
+ results.push(parseTranslateGemmaResponse(text, req.id));
2096
+ }
2097
+ return results;
2054
2098
  }
2055
2099
  };
2056
2100
  }
@@ -2138,7 +2182,7 @@ var init_claudecode = __esm({
2138
2182
  const prompt = buildBatchPrompt(batch);
2139
2183
  let result;
2140
2184
  try {
2141
- result = await this.spawnFn(prompt, buildSystemPrompt(), this.config.model);
2185
+ result = await this.spawnFn(prompt, buildSystemPrompt(batch.some((r) => r.plural !== void 0)), this.config.model);
2142
2186
  } catch (err) {
2143
2187
  if (signal?.aborted) return [];
2144
2188
  throw err;
@@ -2225,18 +2269,37 @@ function coerceAi(raw) {
2225
2269
  model: typeof a.model === "string" && a.model ? a.model : DEFAULT_AI.model,
2226
2270
  endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
2227
2271
  region: typeof a.region === "string" ? a.region : null,
2228
- batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize
2272
+ batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize,
2273
+ concurrency: typeof a.concurrency === "number" && a.concurrency > 0 ? a.concurrency : void 0,
2274
+ contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
2275
+ contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
2276
+ vision: typeof a.vision === "boolean" ? a.vision : void 0,
2277
+ promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
2229
2278
  };
2230
2279
  }
2280
+ function coerceProfiles(raw) {
2281
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
2282
+ const result = {};
2283
+ for (const [k, v] of Object.entries(raw)) {
2284
+ if (typeof k === "string" && k.trim()) result[k] = coerceAi(v);
2285
+ }
2286
+ return result;
2287
+ }
2231
2288
  function loadLocalSettings(projectRoot) {
2232
2289
  const raw = readJson(settingsPath(projectRoot));
2233
- return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
2290
+ const profiles = coerceProfiles(raw.profiles);
2291
+ const activeProfile = typeof raw.activeProfile === "string" && raw.activeProfile in profiles ? raw.activeProfile : null;
2292
+ const baseAi = coerceAi(raw.ai);
2293
+ const ai = activeProfile ? profiles[activeProfile] : baseAi;
2294
+ return { ai, editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR, profiles, activeProfile };
2234
2295
  }
2235
2296
  function saveLocalSettings(projectRoot, patch) {
2236
2297
  const path = settingsPath(projectRoot);
2237
2298
  const merged = { ...readJson(path) };
2238
2299
  if (patch.ai !== void 0) merged.ai = patch.ai;
2239
2300
  if (patch.editor !== void 0) merged.editor = patch.editor;
2301
+ if (patch.profiles !== void 0) merged.profiles = patch.profiles;
2302
+ if (patch.activeProfile !== void 0) merged.activeProfile = patch.activeProfile;
2240
2303
  ensureGlotfileDir(projectRoot);
2241
2304
  writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
2242
2305
  }
@@ -2312,6 +2375,7 @@ function selectRequests(state, opts) {
2312
2375
  id: String(id++),
2313
2376
  key,
2314
2377
  source: other,
2378
+ sourceLocale: state.config.sourceLocale,
2315
2379
  context: entry.context,
2316
2380
  targetLocale: locale,
2317
2381
  maxLength: entry.maxLength,
@@ -2332,6 +2396,7 @@ function selectRequests(state, opts) {
2332
2396
  id: String(id++),
2333
2397
  key,
2334
2398
  source,
2399
+ sourceLocale: state.config.sourceLocale,
2335
2400
  context: entry.context,
2336
2401
  targetLocale: locale,
2337
2402
  maxLength: entry.maxLength,
@@ -2908,7 +2973,7 @@ function buildContextBatchPrompt(reqs) {
2908
2973
  ${s.lines}
2909
2974
  \`\`\``;
2910
2975
  }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
2911
- return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText, hasScreenshot: r.image !== void 0 };
2976
+ return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText };
2912
2977
  });
2913
2978
  return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
2914
2979
  }
@@ -3822,7 +3887,7 @@ var init_ui_prefs = __esm({
3822
3887
  // src/server/api.ts
3823
3888
  import { Hono } from "hono";
3824
3889
  import { streamSSE } from "hono/streaming";
3825
- import { readFileSync as readFileSync14, existsSync as existsSync10, readdirSync as readdirSync7, rmSync as rmSync4 } from "fs";
3890
+ import { readFileSync as readFileSync14, existsSync as existsSync10, readdirSync as readdirSync7, statSync as statSync4, rmSync as rmSync4 } from "fs";
3826
3891
  import { dirname as dirname2, resolve as resolve9, basename, relative as relative3, sep } from "path";
3827
3892
  function projectName(root) {
3828
3893
  const nameFile = resolve9(root, ".idea", ".name");
@@ -3892,32 +3957,90 @@ function createApi(deps) {
3892
3957
  saveLocalSettings(projectRoot, patch);
3893
3958
  return c.json({ ok: true });
3894
3959
  });
3960
+ app.get("/ai-profiles", (c) => {
3961
+ const ls = loadLocalSettings(projectRoot);
3962
+ return c.json({ profiles: ls.profiles, activeProfile: ls.activeProfile });
3963
+ });
3964
+ app.put("/ai-profiles/:name", async (c) => {
3965
+ const name = c.req.param("name").trim();
3966
+ if (!name) return c.json({ error: "name required" }, 400);
3967
+ const body = await c.req.json().catch(() => ({}));
3968
+ const err = aiConfigError(body);
3969
+ if (err) return c.json({ error: err }, 400);
3970
+ const ls = loadLocalSettings(projectRoot);
3971
+ saveLocalSettings(projectRoot, { profiles: { ...ls.profiles, [name]: body } });
3972
+ return c.json({ ok: true });
3973
+ });
3974
+ app.delete("/ai-profiles/:name", (c) => {
3975
+ const name = c.req.param("name");
3976
+ const ls = loadLocalSettings(projectRoot);
3977
+ if (!(name in ls.profiles)) return c.json({ error: "profile not found" }, 404);
3978
+ const profiles = { ...ls.profiles };
3979
+ delete profiles[name];
3980
+ const patch = { profiles };
3981
+ if (ls.activeProfile === name) patch.activeProfile = null;
3982
+ saveLocalSettings(projectRoot, patch);
3983
+ return c.json({ ok: true });
3984
+ });
3985
+ app.post("/ai-profiles/active", async (c) => {
3986
+ const { name } = await c.req.json().catch(() => ({}));
3987
+ if (name !== null && name !== void 0) {
3988
+ if (typeof name !== "string") return c.json({ error: "name must be a string or null" }, 400);
3989
+ const ls = loadLocalSettings(projectRoot);
3990
+ if (name !== "" && !(name in ls.profiles)) return c.json({ error: "profile not found" }, 404);
3991
+ saveLocalSettings(projectRoot, { activeProfile: name || null });
3992
+ } else {
3993
+ saveLocalSettings(projectRoot, { activeProfile: null });
3994
+ }
3995
+ return c.json({ ok: true });
3996
+ });
3895
3997
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3896
3998
  app.get("/files", (c) => {
3897
3999
  const found = /* @__PURE__ */ new Map();
3898
- found.set(deps.statePath, { name: basename(deps.statePath), path: deps.statePath });
3899
- let entries = [];
3900
- try {
3901
- entries = readdirSync7(projectRoot);
3902
- } catch {
3903
- }
3904
- for (const name of entries) {
3905
- let abs;
3906
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync10(resolve9(projectRoot, name, "config.json"))) {
3907
- abs = resolve9(projectRoot, `${name}.json`);
3908
- } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3909
- abs = resolve9(projectRoot, name);
3910
- } else {
3911
- continue;
3912
- }
3913
- if (found.has(abs)) continue;
4000
+ const activeRel = relative3(projectRoot, deps.statePath);
4001
+ found.set(deps.statePath, {
4002
+ name: basename(deps.statePath),
4003
+ path: deps.statePath,
4004
+ relDir: activeRel !== basename(activeRel) ? dirname2(activeRel) : void 0
4005
+ });
4006
+ function walk(dir, depth) {
4007
+ if (depth > 4) return;
4008
+ let entries = [];
3914
4009
  try {
3915
- loadState(abs);
3916
- found.set(abs, { name: basename(abs), path: abs });
4010
+ entries = readdirSync7(dir);
3917
4011
  } catch {
4012
+ return;
4013
+ }
4014
+ for (const name of entries) {
4015
+ if (name.startsWith(".") || name === "node_modules") continue;
4016
+ const abs = resolve9(dir, name);
4017
+ let filePath = null;
4018
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync10(resolve9(abs, "config.json"))) {
4019
+ filePath = resolve9(dir, `${name}.json`);
4020
+ } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
4021
+ filePath = abs;
4022
+ } else {
4023
+ try {
4024
+ if (statSync4(abs).isDirectory()) walk(abs, depth + 1);
4025
+ } catch {
4026
+ }
4027
+ continue;
4028
+ }
4029
+ if (found.has(filePath)) continue;
4030
+ try {
4031
+ loadState(filePath);
4032
+ const rel = relative3(projectRoot, filePath);
4033
+ found.set(filePath, { name: basename(filePath), path: filePath, relDir: rel !== basename(filePath) ? dirname2(rel) : void 0 });
4034
+ } catch {
4035
+ }
3918
4036
  }
3919
4037
  }
3920
- const files = [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
4038
+ walk(projectRoot, 0);
4039
+ const files = [...found.values()].sort((a, b) => {
4040
+ const ka = a.relDir ? `${a.relDir}/${a.name}` : a.name;
4041
+ const kb = b.relDir ? `${b.relDir}/${b.name}` : b.name;
4042
+ return ka.localeCompare(kb);
4043
+ });
3921
4044
  return c.json(files);
3922
4045
  });
3923
4046
  app.post("/file", async (c) => {
@@ -4049,7 +4172,7 @@ function createApi(deps) {
4049
4172
  return c.json({ removed });
4050
4173
  });
4051
4174
  app.post("/keys/bulk-meta", async (c) => {
4052
- const { keys, addTags, removeTags, skipTranslate } = await c.req.json();
4175
+ const { keys, addTags, removeTags, skipTranslate, clearContext } = await c.req.json();
4053
4176
  if (!Array.isArray(keys) || keys.length === 0) return c.json({ error: "keys must be a non-empty array" }, 400);
4054
4177
  const s = load();
4055
4178
  let updated = 0;
@@ -4067,6 +4190,11 @@ function createApi(deps) {
4067
4190
  if (skipTranslate) setMetadata(s, key, { skipTranslate: true });
4068
4191
  else delete entry.skipTranslate;
4069
4192
  }
4193
+ if (clearContext === true) {
4194
+ delete entry.context;
4195
+ delete entry.contextSource;
4196
+ delete entry.contextAt;
4197
+ }
4070
4198
  updated++;
4071
4199
  }
4072
4200
  persist(s);
@@ -4347,7 +4475,7 @@ function createApi(deps) {
4347
4475
  console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
4348
4476
  let totalWritten = 0;
4349
4477
  const allErrors = [];
4350
- const system = buildSystemPrompt();
4478
+ const system = buildSystemPrompt(reqs.some((r) => r.plural !== void 0));
4351
4479
  const reqById = new Map(reqs.map((r) => [r.id, r]));
4352
4480
  const localeTotals = /* @__PURE__ */ new Map();
4353
4481
  for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
@@ -4363,8 +4491,9 @@ function createApi(deps) {
4363
4491
  void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
4364
4492
  },
4365
4493
  onBatchComplete: (done, total, batchResults, locale) => {
4366
- const { written, errors } = applyResults(s, reqs, batchResults);
4367
- persist(s);
4494
+ const fresh = load();
4495
+ const { written, errors } = applyResults(fresh, reqs, batchResults);
4496
+ persist(fresh);
4368
4497
  totalWritten += written;
4369
4498
  allErrors.push(...errors);
4370
4499
  appendLog(projectRoot, {
@@ -4375,7 +4504,7 @@ function createApi(deps) {
4375
4504
  system,
4376
4505
  items: batchResults.map((r) => {
4377
4506
  const req = reqById.get(r.id);
4378
- 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 };
4507
+ 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 };
4379
4508
  }),
4380
4509
  results: batchResults
4381
4510
  });
@@ -4390,7 +4519,7 @@ function createApi(deps) {
4390
4519
  onLocaleDone: (locale) => {
4391
4520
  void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4392
4521
  }
4393
- }, void 0, signal);
4522
+ }, aiCfg.concurrency, signal);
4394
4523
  if (!signal?.aborted) {
4395
4524
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
4396
4525
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -4421,14 +4550,15 @@ function createApi(deps) {
4421
4550
  }
4422
4551
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
4423
4552
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4424
- const results = await runLocaleParallel(toTranslate, provider);
4425
- ({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
4553
+ const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
4554
+ const latest = load();
4555
+ ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
4426
4556
  const entry = {
4427
4557
  at: (/* @__PURE__ */ new Date()).toISOString(),
4428
4558
  kind: "translate",
4429
4559
  summary: `Translated ${toTranslate.length} item(s)`,
4430
4560
  model: aiCfg.model,
4431
- system: buildSystemPrompt(),
4561
+ system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
4432
4562
  // Log the screenshot PATH only — never the image bytes.
4433
4563
  items: toTranslate.map((r) => ({
4434
4564
  id: r.id,
@@ -4437,13 +4567,13 @@ function createApi(deps) {
4437
4567
  targetLocale: r.targetLocale,
4438
4568
  context: r.context,
4439
4569
  glossary: r.glossary,
4440
- screenshot: s.keys[r.key]?.screenshot
4570
+ screenshot: latest.keys[r.key]?.screenshot
4441
4571
  })),
4442
4572
  results
4443
4573
  };
4444
4574
  appendLog(projectRoot, entry);
4575
+ persist(latest);
4445
4576
  }
4446
- persist(s);
4447
4577
  return c.json({ requested: reqs.length, written, errors });
4448
4578
  }));
4449
4579
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
@@ -4499,55 +4629,95 @@ function createApi(deps) {
4499
4629
  return c.json({ indexed: true, scannedAt: cache2.scannedAt, used: computeUsedKeys(load(), cache2) });
4500
4630
  });
4501
4631
  app.post("/context/build", async (c) => {
4632
+ const signal = c.req.raw.signal;
4502
4633
  const body = await c.req.json().catch(() => ({}));
4503
- const s = load();
4504
- const cache2 = loadUsageCache(projectRoot);
4505
- if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
4506
- const targets = selectContextTargets(s, {
4507
- all: body.all,
4508
- keyGlob: body.keyGlob,
4509
- limit: body.limit,
4510
- since: body.since,
4511
- keys: body.keys
4512
- }, cache2, body.lastRunAt);
4513
- if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4514
- const aiCfg = loadLocalSettings(projectRoot).ai;
4515
- let provider;
4516
- try {
4517
- provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4518
- } catch (e) {
4519
- return c.json({ error: e.message }, 400);
4520
- }
4521
- const fileCache = /* @__PURE__ */ new Map();
4522
- for (const target of targets) {
4523
- const allRefs = Object.entries(cache2.files).flatMap(
4524
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
4525
- key: r.key,
4526
- file,
4527
- line: r.line,
4528
- col: r.col,
4529
- scanner: r.scanner
4530
- }))
4531
- );
4532
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
4533
- }
4534
- const system = buildContextSystemPrompt();
4535
- const prompt = buildContextBatchPrompt(targets);
4536
- const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
4537
- const batch = raw;
4538
- const { written, errors } = applyContext(s, targets, batch.items ?? []);
4539
- appendLog(projectRoot, {
4540
- at: (/* @__PURE__ */ new Date()).toISOString(),
4541
- kind: "context",
4542
- summary: `Generated context for ${targets.length} key(s)`,
4543
- model: aiCfg.model,
4544
- system,
4545
- items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4546
- results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
4634
+ return streamSSE(c, async (stream) => {
4635
+ const s = load();
4636
+ const cache2 = loadUsageCache(projectRoot);
4637
+ if (!cache2) {
4638
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: "No usage index found. Run 'glotfile scan' first." }) });
4639
+ return;
4640
+ }
4641
+ const targets = selectContextTargets(s, {
4642
+ all: body.all,
4643
+ keyGlob: body.keyGlob,
4644
+ limit: body.limit,
4645
+ since: body.since,
4646
+ keys: body.keys
4647
+ }, cache2, body.lastRunAt);
4648
+ if (!targets.length) {
4649
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: 0, written: 0, errors: [] }) });
4650
+ return;
4651
+ }
4652
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4653
+ let provider;
4654
+ try {
4655
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4656
+ } catch (e) {
4657
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4658
+ return;
4659
+ }
4660
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: targets.length }) });
4661
+ const fileCache = /* @__PURE__ */ new Map();
4662
+ for (const target of targets) {
4663
+ const allRefs = Object.entries(cache2.files).flatMap(
4664
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
4665
+ key: r.key,
4666
+ file,
4667
+ line: r.line,
4668
+ col: r.col,
4669
+ scanner: r.scanner
4670
+ }))
4671
+ );
4672
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
4673
+ }
4674
+ const system = buildContextSystemPrompt();
4675
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
4676
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
4677
+ const chunks = [];
4678
+ for (let i = 0; i < targets.length; i += batchSize) chunks.push(targets.slice(i, i + batchSize));
4679
+ let totalWritten = 0;
4680
+ let totalDone = 0;
4681
+ const allErrors = [];
4682
+ let next = 0;
4683
+ async function worker() {
4684
+ while (next < chunks.length) {
4685
+ if (signal?.aborted) break;
4686
+ const chunk2 = chunks[next++];
4687
+ let raw;
4688
+ try {
4689
+ raw = await provider.complete({ system, content: [{ type: "text", text: buildContextBatchPrompt(chunk2) }], schema: CONTEXT_BATCH_SCHEMA });
4690
+ } catch (e) {
4691
+ totalDone += chunk2.length;
4692
+ allErrors.push(...chunk2.map((t) => ({ key: t.key, error: e.message })));
4693
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done: totalDone, total: targets.length, written: totalWritten }) });
4694
+ continue;
4695
+ }
4696
+ if (signal?.aborted) break;
4697
+ const batch = raw;
4698
+ const fresh = load();
4699
+ const { written, errors } = applyContext(fresh, chunk2, batch.items ?? []);
4700
+ appendLog(projectRoot, {
4701
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4702
+ kind: "context",
4703
+ summary: `Generated context for ${chunk2.length} key(s)`,
4704
+ model: aiCfg.model,
4705
+ system,
4706
+ items: chunk2.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4707
+ results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
4708
+ });
4709
+ persist(fresh);
4710
+ totalWritten += written;
4711
+ totalDone += chunk2.length;
4712
+ allErrors.push(...errors);
4713
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done: totalDone, total: targets.length, written: totalWritten }) });
4714
+ }
4715
+ }
4716
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
4717
+ if (signal?.aborted) return;
4718
+ console.log(`[context] ${totalWritten} context(s) written${allErrors.length ? `, ${allErrors.length} error(s)` : ""}`);
4719
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
4547
4720
  });
4548
- persist(s);
4549
- console.log(`[context] ${written} context(s) written${errors.length ? `, ${errors.length} error(s)` : ""}`);
4550
- return c.json({ requested: targets.length, written, errors });
4551
4721
  });
4552
4722
  app.onError(
4553
4723
  (err, c) => c.json({ error: err.message }, err instanceof GlotfileError ? 400 : 500)
@@ -5274,7 +5444,7 @@ async function runTranslate(args) {
5274
5444
  kind: "translate",
5275
5445
  summary: `Translated ${toTranslate.length} item(s)`,
5276
5446
  model: ai.model,
5277
- system: buildSystemPrompt(),
5447
+ system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
5278
5448
  items: toTranslate.map((r) => ({
5279
5449
  id: r.id,
5280
5450
  key: r.key,
@@ -5397,10 +5567,32 @@ async function runBuildContext(args) {
5397
5567
  target.usageSnippets = extractSnippets(refs, projectRoot, fileCache);
5398
5568
  }
5399
5569
  const system = buildContextSystemPrompt();
5400
- const prompt = buildContextBatchPrompt(targets);
5401
- const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
5402
- const batch = raw;
5403
- const { written, errors } = applyContext(state, targets, batch.items ?? []);
5570
+ const aiCfg = loadLocalSettings(projectRoot).ai;
5571
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
5572
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
5573
+ const chunks = [];
5574
+ for (let i = 0; i < targets.length; i += batchSize) chunks.push(targets.slice(i, i + batchSize));
5575
+ let written = 0;
5576
+ const errors = [];
5577
+ let next = 0;
5578
+ async function worker() {
5579
+ while (next < chunks.length) {
5580
+ const chunk2 = chunks[next++];
5581
+ let raw;
5582
+ try {
5583
+ raw = await provider.complete({ system, content: [{ type: "text", text: buildContextBatchPrompt(chunk2) }], schema: CONTEXT_BATCH_SCHEMA });
5584
+ } catch (e2) {
5585
+ errors.push(...chunk2.map((t) => ({ key: t.key, error: e2.message })));
5586
+ continue;
5587
+ }
5588
+ const batch = raw;
5589
+ const { written: w, errors: e } = applyContext(state, chunk2, batch.items ?? []);
5590
+ written += w;
5591
+ errors.push(...e);
5592
+ console.log(`[${next * batchSize}/${targets.length}] wrote ${w}`);
5593
+ }
5594
+ }
5595
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
5404
5596
  saveState(args.statePath, state);
5405
5597
  console.log(`Wrote context for ${written} key(s).`);
5406
5598
  for (const e of errors) console.warn(`skip ${e.key}: ${e.error}`);