glotfile 0.3.1 → 0.4.1

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 {
@@ -2052,6 +2076,26 @@ var init_ollama = __esm({
2052
2076
  supportsVision() {
2053
2077
  return this.config.vision === true;
2054
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;
2098
+ }
2055
2099
  };
2056
2100
  }
2057
2101
  });
@@ -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;
@@ -2226,18 +2270,36 @@ function coerceAi(raw) {
2226
2270
  endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
2227
2271
  region: typeof a.region === "string" ? a.region : null,
2228
2272
  batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize,
2229
- vision: typeof a.vision === "boolean" ? a.vision : void 0
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
2230
2278
  };
2231
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
+ }
2232
2288
  function loadLocalSettings(projectRoot) {
2233
2289
  const raw = readJson(settingsPath(projectRoot));
2234
- 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 };
2235
2295
  }
2236
2296
  function saveLocalSettings(projectRoot, patch) {
2237
2297
  const path = settingsPath(projectRoot);
2238
2298
  const merged = { ...readJson(path) };
2239
2299
  if (patch.ai !== void 0) merged.ai = patch.ai;
2240
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;
2241
2303
  ensureGlotfileDir(projectRoot);
2242
2304
  writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
2243
2305
  }
@@ -2313,6 +2375,7 @@ function selectRequests(state, opts) {
2313
2375
  id: String(id++),
2314
2376
  key,
2315
2377
  source: other,
2378
+ sourceLocale: state.config.sourceLocale,
2316
2379
  context: entry.context,
2317
2380
  targetLocale: locale,
2318
2381
  maxLength: entry.maxLength,
@@ -2333,6 +2396,7 @@ function selectRequests(state, opts) {
2333
2396
  id: String(id++),
2334
2397
  key,
2335
2398
  source,
2399
+ sourceLocale: state.config.sourceLocale,
2336
2400
  context: entry.context,
2337
2401
  targetLocale: locale,
2338
2402
  maxLength: entry.maxLength,
@@ -2865,7 +2929,7 @@ function selectContextTargets(state, opts, cache2, lastRunAt) {
2865
2929
  let candidates = [];
2866
2930
  for (const key of Object.keys(state.keys).sort()) {
2867
2931
  const entry = state.keys[key];
2868
- if (entry.context) continue;
2932
+ if (entry.context && !opts.force) continue;
2869
2933
  if (keySet && !keySet.has(key)) continue;
2870
2934
  if (keyRe && !keyRe.test(key)) continue;
2871
2935
  if (cutoff) {
@@ -2909,11 +2973,11 @@ function buildContextBatchPrompt(reqs) {
2909
2973
  ${s.lines}
2910
2974
  \`\`\``;
2911
2975
  }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
2912
- 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 };
2913
2977
  });
2914
2978
  return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
2915
2979
  }
2916
- function applyContext(state, reqs, results, clock = systemClock) {
2980
+ function applyContext(state, reqs, results, clock = systemClock, force = false) {
2917
2981
  const byId = new Map(reqs.map((r) => [r.id, r]));
2918
2982
  let written = 0;
2919
2983
  const errors = [];
@@ -2934,7 +2998,7 @@ function applyContext(state, reqs, results, clock = systemClock) {
2934
2998
  continue;
2935
2999
  }
2936
3000
  const entry = state.keys[req.key];
2937
- if (!entry || entry.context) continue;
3001
+ if (!entry || entry.context && !force) continue;
2938
3002
  entry.context = context;
2939
3003
  entry.contextSource = "ai";
2940
3004
  entry.contextAt = clock();
@@ -3893,6 +3957,43 @@ function createApi(deps) {
3893
3957
  saveLocalSettings(projectRoot, patch);
3894
3958
  return c.json({ ok: true });
3895
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
+ });
3896
3997
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3897
3998
  app.get("/files", (c) => {
3898
3999
  const found = /* @__PURE__ */ new Map();
@@ -4071,7 +4172,7 @@ function createApi(deps) {
4071
4172
  return c.json({ removed });
4072
4173
  });
4073
4174
  app.post("/keys/bulk-meta", async (c) => {
4074
- const { keys, addTags, removeTags, skipTranslate } = await c.req.json();
4175
+ const { keys, addTags, removeTags, skipTranslate, clearContext } = await c.req.json();
4075
4176
  if (!Array.isArray(keys) || keys.length === 0) return c.json({ error: "keys must be a non-empty array" }, 400);
4076
4177
  const s = load();
4077
4178
  let updated = 0;
@@ -4089,6 +4190,11 @@ function createApi(deps) {
4089
4190
  if (skipTranslate) setMetadata(s, key, { skipTranslate: true });
4090
4191
  else delete entry.skipTranslate;
4091
4192
  }
4193
+ if (clearContext === true) {
4194
+ delete entry.context;
4195
+ delete entry.contextSource;
4196
+ delete entry.contextAt;
4197
+ }
4092
4198
  updated++;
4093
4199
  }
4094
4200
  persist(s);
@@ -4369,7 +4475,7 @@ function createApi(deps) {
4369
4475
  console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
4370
4476
  let totalWritten = 0;
4371
4477
  const allErrors = [];
4372
- const system = buildSystemPrompt();
4478
+ const system = buildSystemPrompt(reqs.some((r) => r.plural !== void 0));
4373
4479
  const reqById = new Map(reqs.map((r) => [r.id, r]));
4374
4480
  const localeTotals = /* @__PURE__ */ new Map();
4375
4481
  for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
@@ -4398,7 +4504,7 @@ function createApi(deps) {
4398
4504
  system,
4399
4505
  items: batchResults.map((r) => {
4400
4506
  const req = reqById.get(r.id);
4401
- 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 };
4402
4508
  }),
4403
4509
  results: batchResults
4404
4510
  });
@@ -4413,7 +4519,7 @@ function createApi(deps) {
4413
4519
  onLocaleDone: (locale) => {
4414
4520
  void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4415
4521
  }
4416
- }, void 0, signal);
4522
+ }, aiCfg.concurrency, signal);
4417
4523
  if (!signal?.aborted) {
4418
4524
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
4419
4525
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -4444,14 +4550,15 @@ function createApi(deps) {
4444
4550
  }
4445
4551
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
4446
4552
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4447
- const results = await runLocaleParallel(toTranslate, provider);
4448
- ({ 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));
4449
4556
  const entry = {
4450
4557
  at: (/* @__PURE__ */ new Date()).toISOString(),
4451
4558
  kind: "translate",
4452
4559
  summary: `Translated ${toTranslate.length} item(s)`,
4453
4560
  model: aiCfg.model,
4454
- system: buildSystemPrompt(),
4561
+ system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
4455
4562
  // Log the screenshot PATH only — never the image bytes.
4456
4563
  items: toTranslate.map((r) => ({
4457
4564
  id: r.id,
@@ -4460,13 +4567,13 @@ function createApi(deps) {
4460
4567
  targetLocale: r.targetLocale,
4461
4568
  context: r.context,
4462
4569
  glossary: r.glossary,
4463
- screenshot: s.keys[r.key]?.screenshot
4570
+ screenshot: latest.keys[r.key]?.screenshot
4464
4571
  })),
4465
4572
  results
4466
4573
  };
4467
4574
  appendLog(projectRoot, entry);
4575
+ persist(latest);
4468
4576
  }
4469
- persist(s);
4470
4577
  return c.json({ requested: reqs.length, written, errors });
4471
4578
  }));
4472
4579
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
@@ -4522,55 +4629,96 @@ function createApi(deps) {
4522
4629
  return c.json({ indexed: true, scannedAt: cache2.scannedAt, used: computeUsedKeys(load(), cache2) });
4523
4630
  });
4524
4631
  app.post("/context/build", async (c) => {
4632
+ const signal = c.req.raw.signal;
4525
4633
  const body = await c.req.json().catch(() => ({}));
4526
- const s = load();
4527
- const cache2 = loadUsageCache(projectRoot);
4528
- if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
4529
- const targets = selectContextTargets(s, {
4530
- all: body.all,
4531
- keyGlob: body.keyGlob,
4532
- limit: body.limit,
4533
- since: body.since,
4534
- keys: body.keys
4535
- }, cache2, body.lastRunAt);
4536
- if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4537
- const aiCfg = loadLocalSettings(projectRoot).ai;
4538
- let provider;
4539
- try {
4540
- provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4541
- } catch (e) {
4542
- return c.json({ error: e.message }, 400);
4543
- }
4544
- const fileCache = /* @__PURE__ */ new Map();
4545
- for (const target of targets) {
4546
- const allRefs = Object.entries(cache2.files).flatMap(
4547
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
4548
- key: r.key,
4549
- file,
4550
- line: r.line,
4551
- col: r.col,
4552
- scanner: r.scanner
4553
- }))
4554
- );
4555
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
4556
- }
4557
- const system = buildContextSystemPrompt();
4558
- const prompt = buildContextBatchPrompt(targets);
4559
- const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
4560
- const batch = raw;
4561
- const { written, errors } = applyContext(s, targets, batch.items ?? []);
4562
- appendLog(projectRoot, {
4563
- at: (/* @__PURE__ */ new Date()).toISOString(),
4564
- kind: "context",
4565
- summary: `Generated context for ${targets.length} key(s)`,
4566
- model: aiCfg.model,
4567
- system,
4568
- items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4569
- 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
+ force: body.force
4648
+ }, cache2, body.lastRunAt);
4649
+ if (!targets.length) {
4650
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: 0, written: 0, errors: [] }) });
4651
+ return;
4652
+ }
4653
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4654
+ let provider;
4655
+ try {
4656
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4657
+ } catch (e) {
4658
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4659
+ return;
4660
+ }
4661
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: targets.length }) });
4662
+ const fileCache = /* @__PURE__ */ new Map();
4663
+ for (const target of targets) {
4664
+ const allRefs = Object.entries(cache2.files).flatMap(
4665
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
4666
+ key: r.key,
4667
+ file,
4668
+ line: r.line,
4669
+ col: r.col,
4670
+ scanner: r.scanner
4671
+ }))
4672
+ );
4673
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
4674
+ }
4675
+ const system = buildContextSystemPrompt();
4676
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
4677
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
4678
+ const chunks = [];
4679
+ for (let i = 0; i < targets.length; i += batchSize) chunks.push(targets.slice(i, i + batchSize));
4680
+ let totalWritten = 0;
4681
+ let totalDone = 0;
4682
+ const allErrors = [];
4683
+ let next = 0;
4684
+ async function worker() {
4685
+ while (next < chunks.length) {
4686
+ if (signal?.aborted) break;
4687
+ const chunk2 = chunks[next++];
4688
+ let raw;
4689
+ try {
4690
+ raw = await provider.complete({ system, content: [{ type: "text", text: buildContextBatchPrompt(chunk2) }], schema: CONTEXT_BATCH_SCHEMA });
4691
+ } catch (e) {
4692
+ totalDone += chunk2.length;
4693
+ allErrors.push(...chunk2.map((t) => ({ key: t.key, error: e.message })));
4694
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done: totalDone, total: targets.length, written: totalWritten }) });
4695
+ continue;
4696
+ }
4697
+ if (signal?.aborted) break;
4698
+ const batch = raw;
4699
+ const fresh = load();
4700
+ const { written, errors } = applyContext(fresh, chunk2, batch.items ?? [], void 0, body.force === true);
4701
+ appendLog(projectRoot, {
4702
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4703
+ kind: "context",
4704
+ summary: `Generated context for ${chunk2.length} key(s)`,
4705
+ model: aiCfg.model,
4706
+ system,
4707
+ items: chunk2.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4708
+ results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
4709
+ });
4710
+ persist(fresh);
4711
+ totalWritten += written;
4712
+ totalDone += chunk2.length;
4713
+ allErrors.push(...errors);
4714
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done: totalDone, total: targets.length, written: totalWritten }) });
4715
+ }
4716
+ }
4717
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
4718
+ if (signal?.aborted) return;
4719
+ console.log(`[context] ${totalWritten} context(s) written${allErrors.length ? `, ${allErrors.length} error(s)` : ""}`);
4720
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
4570
4721
  });
4571
- persist(s);
4572
- console.log(`[context] ${written} context(s) written${errors.length ? `, ${errors.length} error(s)` : ""}`);
4573
- return c.json({ requested: targets.length, written, errors });
4574
4722
  });
4575
4723
  app.onError(
4576
4724
  (err, c) => c.json({ error: err.message }, err instanceof GlotfileError ? 400 : 500)
@@ -5297,7 +5445,7 @@ async function runTranslate(args) {
5297
5445
  kind: "translate",
5298
5446
  summary: `Translated ${toTranslate.length} item(s)`,
5299
5447
  model: ai.model,
5300
- system: buildSystemPrompt(),
5448
+ system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
5301
5449
  items: toTranslate.map((r) => ({
5302
5450
  id: r.id,
5303
5451
  key: r.key,
@@ -5420,10 +5568,32 @@ async function runBuildContext(args) {
5420
5568
  target.usageSnippets = extractSnippets(refs, projectRoot, fileCache);
5421
5569
  }
5422
5570
  const system = buildContextSystemPrompt();
5423
- const prompt = buildContextBatchPrompt(targets);
5424
- const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
5425
- const batch = raw;
5426
- const { written, errors } = applyContext(state, targets, batch.items ?? []);
5571
+ const aiCfg = loadLocalSettings(projectRoot).ai;
5572
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
5573
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
5574
+ const chunks = [];
5575
+ for (let i = 0; i < targets.length; i += batchSize) chunks.push(targets.slice(i, i + batchSize));
5576
+ let written = 0;
5577
+ const errors = [];
5578
+ let next = 0;
5579
+ async function worker() {
5580
+ while (next < chunks.length) {
5581
+ const chunk2 = chunks[next++];
5582
+ let raw;
5583
+ try {
5584
+ raw = await provider.complete({ system, content: [{ type: "text", text: buildContextBatchPrompt(chunk2) }], schema: CONTEXT_BATCH_SCHEMA });
5585
+ } catch (e2) {
5586
+ errors.push(...chunk2.map((t) => ({ key: t.key, error: e2.message })));
5587
+ continue;
5588
+ }
5589
+ const batch = raw;
5590
+ const { written: w, errors: e } = applyContext(state, chunk2, batch.items ?? []);
5591
+ written += w;
5592
+ errors.push(...e);
5593
+ console.log(`[${next * batchSize}/${targets.length}] wrote ${w}`);
5594
+ }
5595
+ }
5596
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
5427
5597
  saveState(args.statePath, state);
5428
5598
  console.log(`Wrote context for ${written} key(s).`);
5429
5599
  for (const e of errors) console.warn(`skip ${e.key}: ${e.error}`);