glotfile 0.4.4 → 0.4.5

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.
@@ -1853,6 +1853,7 @@ function buildSystemPrompt(hasPluralItems) {
1853
1853
  "- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
1854
1854
  "- 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.",
1855
1855
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
1856
+ `- Quotation marks inside a translation MUST use the target language's typographic quote characters (e.g. \u201EGerman\u201C, \xABFrench\xBB, \u201CEnglish\u201D, \u2019 for apostrophes). Never emit a raw ASCII double-quote (") inside a translated string \u2014 it corrupts the JSON reply. If the source uses ASCII quotes, convert them to the target language's typographic quotes.`,
1856
1857
  "- Match the register and capitalization conventions of the target language and of UI microcopy.",
1857
1858
  "- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations."
1858
1859
  ];
@@ -1942,6 +1943,16 @@ var init_provider = __esm({
1942
1943
  });
1943
1944
 
1944
1945
  // src/server/ai/batch.ts
1946
+ function parseReplyItems(text) {
1947
+ let parsed;
1948
+ try {
1949
+ parsed = JSON.parse(text);
1950
+ } catch {
1951
+ throw new MalformedReplyError(text);
1952
+ }
1953
+ if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
1954
+ return parsed.items;
1955
+ }
1945
1956
  function chunk(items, size) {
1946
1957
  const out = [];
1947
1958
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -1977,24 +1988,46 @@ function validatePlural(req, forms) {
1977
1988
  function validateReply(req, item) {
1978
1989
  return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
1979
1990
  }
1980
- async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal) {
1991
+ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
1992
+ const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
1993
+ async function resolveBatch(batch, isRetry = false) {
1994
+ let reply;
1995
+ try {
1996
+ reply = await callBatch(batch, signal);
1997
+ } catch (err) {
1998
+ if (!(err instanceof MalformedReplyError)) throw err;
1999
+ onMalformedReply?.(err.raw, batch.length);
2000
+ if (signal?.aborted) return failBatch(batch);
2001
+ if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
2002
+ const mid = Math.ceil(batch.length / 2);
2003
+ return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
2004
+ }
2005
+ const byId = new Map(reply.map((r) => [r.id, r]));
2006
+ return batch.map((req) => validateReply(req, byId.get(req.id)));
2007
+ }
1981
2008
  const results = [];
1982
2009
  const total = reqs.length;
1983
2010
  for (const batch of chunk(reqs, Math.max(1, batchSize))) {
1984
2011
  if (signal?.aborted) break;
1985
- const reply = await callBatch(batch, signal);
1986
- const byId = new Map(reply.map((r) => [r.id, r]));
1987
- const batchResults = [];
1988
- for (const req of batch) batchResults.push(validateReply(req, byId.get(req.id)));
2012
+ const batchResults = await resolveBatch(batch);
1989
2013
  results.push(...batchResults);
1990
2014
  onBatchComplete?.(results.length, total, batchResults);
1991
2015
  }
1992
2016
  return results;
1993
2017
  }
2018
+ var MalformedReplyError;
1994
2019
  var init_batch = __esm({
1995
2020
  "src/server/ai/batch.ts"() {
1996
2021
  "use strict";
1997
2022
  init_placeholders();
2023
+ MalformedReplyError = class extends Error {
2024
+ constructor(raw) {
2025
+ super("Model reply was not valid translation JSON.");
2026
+ this.raw = raw;
2027
+ this.name = "MalformedReplyError";
2028
+ }
2029
+ raw;
2030
+ };
1998
2031
  }
1999
2032
  });
2000
2033
 
@@ -2023,8 +2056,8 @@ var init_anthropic = __esm({
2023
2056
  supportsVision() {
2024
2057
  return true;
2025
2058
  }
2026
- translate(reqs, onBatchComplete, signal) {
2027
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2059
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
2060
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2028
2061
  }
2029
2062
  // Build the user message as content blocks: each unique key's screenshot is
2030
2063
  // sent once (a key recurs once per target locale in a batch — dedupe by key),
@@ -2071,13 +2104,8 @@ var init_anthropic = __esm({
2071
2104
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
2072
2105
  messages: [{ role: "user", content }]
2073
2106
  }, { signal });
2074
- const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
2075
- try {
2076
- const parsed = JSON.parse(text);
2077
- return parsed.items ?? [];
2078
- } catch {
2079
- return [];
2080
- }
2107
+ const text = res.content.find((b) => b.type === "text")?.text ?? "";
2108
+ return parseReplyItems(text);
2081
2109
  }
2082
2110
  };
2083
2111
  }
@@ -2119,8 +2147,8 @@ var init_openai = __esm({
2119
2147
  supportsVision() {
2120
2148
  return true;
2121
2149
  }
2122
- translate(reqs, onBatchComplete, signal) {
2123
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2150
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
2151
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2124
2152
  }
2125
2153
  // User content as an array of parts: each unique key's screenshot once (as an
2126
2154
  // image_url data URL), then the batch prompt text describing every item.
@@ -2169,13 +2197,8 @@ var init_openai = __esm({
2169
2197
  { role: "user", content: this.buildUserContent(batch) }
2170
2198
  ]
2171
2199
  }, { signal });
2172
- const text = res.choices?.[0]?.message?.content ?? "{}";
2173
- try {
2174
- const parsed = JSON.parse(text);
2175
- return parsed.items ?? [];
2176
- } catch {
2177
- return [];
2178
- }
2200
+ const text = res.choices?.[0]?.message?.content ?? "";
2201
+ return parseReplyItems(text);
2179
2202
  }
2180
2203
  };
2181
2204
  }
@@ -2228,8 +2251,8 @@ var init_bedrock = __esm({
2228
2251
  supportsVision() {
2229
2252
  return !this.isMeta();
2230
2253
  }
2231
- translate(reqs, onBatchComplete, signal) {
2232
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2254
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
2255
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2233
2256
  }
2234
2257
  buildContentBlocks(batch) {
2235
2258
  const blocks = [];
@@ -2293,13 +2316,8 @@ var init_bedrock = __esm({
2293
2316
  const blocks = res.output?.message?.content ?? [];
2294
2317
  const tool = blocks.find((b) => b.toolUse)?.toolUse;
2295
2318
  if (tool?.input?.items) return tool.input.items;
2296
- const text = blocks.find((b) => b.text)?.text ?? "{}";
2297
- try {
2298
- const parsed = JSON.parse(text);
2299
- return parsed.items ?? [];
2300
- } catch {
2301
- return [];
2302
- }
2319
+ const text = blocks.find((b) => b.text)?.text ?? "";
2320
+ return parseReplyItems(text);
2303
2321
  }
2304
2322
  };
2305
2323
  }
@@ -2442,8 +2460,8 @@ var init_claudecode = __esm({
2442
2460
  supportsVision() {
2443
2461
  return false;
2444
2462
  }
2445
- translate(reqs, onBatchComplete, signal) {
2446
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2463
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
2464
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2447
2465
  }
2448
2466
  async complete(req) {
2449
2467
  const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
@@ -2466,12 +2484,7 @@ var init_claudecode = __esm({
2466
2484
  throw err;
2467
2485
  }
2468
2486
  if (signal?.aborted) return [];
2469
- try {
2470
- const parsed = JSON.parse(stripFences(result));
2471
- return parsed.items ?? [];
2472
- } catch {
2473
- return [];
2474
- }
2487
+ return parseReplyItems(stripFences(result));
2475
2488
  }
2476
2489
  };
2477
2490
  }
@@ -2759,7 +2772,7 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
2759
2772
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
2760
2773
  done += batchResults.length;
2761
2774
  hooks.onBatchComplete?.(done, total, batchResults, locale);
2762
- }, signal);
2775
+ }, signal, (raw, batchSize) => hooks.onMalformedReply?.(raw, batchSize, locale));
2763
2776
  allResults.push(...localeResults);
2764
2777
  if (!signal?.aborted) hooks.onLocaleDone?.(locale);
2765
2778
  }
@@ -5266,6 +5279,19 @@ function createApi(deps) {
5266
5279
  },
5267
5280
  onLocaleDone: (locale) => {
5268
5281
  void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
5282
+ },
5283
+ // Record the raw reply so an unparseable model response is diagnosable
5284
+ // from the activity log instead of vanishing into per-item errors.
5285
+ onMalformedReply: (raw, batchSize, locale) => {
5286
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
5287
+ appendLog(projectRoot, {
5288
+ at: (/* @__PURE__ */ new Date()).toISOString(),
5289
+ kind: "translate",
5290
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
5291
+ model: aiCfg.model,
5292
+ locale,
5293
+ raw
5294
+ });
5269
5295
  }
5270
5296
  }, aiCfg.concurrency, signal);
5271
5297
  if (!signal?.aborted) {
@@ -5298,7 +5324,19 @@ function createApi(deps) {
5298
5324
  }
5299
5325
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
5300
5326
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
5301
- const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
5327
+ const results = await runLocaleParallel(toTranslate, provider, {
5328
+ onMalformedReply: (raw, batchSize, locale) => {
5329
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
5330
+ appendLog(projectRoot, {
5331
+ at: (/* @__PURE__ */ new Date()).toISOString(),
5332
+ kind: "translate",
5333
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
5334
+ model: aiCfg.model,
5335
+ locale,
5336
+ raw
5337
+ });
5338
+ }
5339
+ }, aiCfg.concurrency);
5302
5340
  const latest = load();
5303
5341
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
5304
5342
  const entry = {
@@ -5906,6 +5944,20 @@ async function runTranslate(args) {
5906
5944
  errors.push(...batchApplied.errors);
5907
5945
  saveState(args.statePath, state);
5908
5946
  process.stdout.write(`\r ${done}/${total} translated`);
5947
+ },
5948
+ // Record the raw reply so an unparseable model response is diagnosable
5949
+ // from the activity log instead of vanishing into per-item errors.
5950
+ onMalformedReply: (raw, batchSize, locale) => {
5951
+ console.error(`
5952
+ malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
5953
+ appendLog(projectRoot, {
5954
+ at: (/* @__PURE__ */ new Date()).toISOString(),
5955
+ kind: "translate",
5956
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
5957
+ model: ai.model,
5958
+ locale,
5959
+ raw
5960
+ });
5909
5961
  }
5910
5962
  });
5911
5963
  process.stdout.write("\n");
@@ -1560,7 +1560,7 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
1560
1560
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
1561
1561
  done += batchResults.length;
1562
1562
  hooks.onBatchComplete?.(done, total, batchResults, locale);
1563
- }, signal);
1563
+ }, signal, (raw, batchSize) => hooks.onMalformedReply?.(raw, batchSize, locale));
1564
1564
  allResults.push(...localeResults);
1565
1565
  if (!signal?.aborted) hooks.onLocaleDone?.(locale);
1566
1566
  }
@@ -2869,6 +2869,7 @@ function buildSystemPrompt(hasPluralItems) {
2869
2869
  "- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
2870
2870
  "- 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.",
2871
2871
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
2872
+ `- Quotation marks inside a translation MUST use the target language's typographic quote characters (e.g. \u201EGerman\u201C, \xABFrench\xBB, \u201CEnglish\u201D, \u2019 for apostrophes). Never emit a raw ASCII double-quote (") inside a translated string \u2014 it corrupts the JSON reply. If the source uses ASCII quotes, convert them to the target language's typographic quotes.`,
2872
2873
  "- Match the register and capitalization conventions of the target language and of UI microcopy.",
2873
2874
  "- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations."
2874
2875
  ];
@@ -2952,6 +2953,24 @@ var BATCH_SCHEMA = {
2952
2953
  };
2953
2954
 
2954
2955
  // src/server/ai/batch.ts
2956
+ var MalformedReplyError = class extends Error {
2957
+ constructor(raw) {
2958
+ super("Model reply was not valid translation JSON.");
2959
+ this.raw = raw;
2960
+ this.name = "MalformedReplyError";
2961
+ }
2962
+ raw;
2963
+ };
2964
+ function parseReplyItems(text) {
2965
+ let parsed;
2966
+ try {
2967
+ parsed = JSON.parse(text);
2968
+ } catch {
2969
+ throw new MalformedReplyError(text);
2970
+ }
2971
+ if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
2972
+ return parsed.items;
2973
+ }
2955
2974
  function chunk(items, size) {
2956
2975
  const out = [];
2957
2976
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -2987,15 +3006,28 @@ function validatePlural(req, forms) {
2987
3006
  function validateReply(req, item) {
2988
3007
  return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
2989
3008
  }
2990
- async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal) {
3009
+ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
3010
+ const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
3011
+ async function resolveBatch(batch, isRetry = false) {
3012
+ let reply;
3013
+ try {
3014
+ reply = await callBatch(batch, signal);
3015
+ } catch (err) {
3016
+ if (!(err instanceof MalformedReplyError)) throw err;
3017
+ onMalformedReply?.(err.raw, batch.length);
3018
+ if (signal?.aborted) return failBatch(batch);
3019
+ if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
3020
+ const mid = Math.ceil(batch.length / 2);
3021
+ return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
3022
+ }
3023
+ const byId = new Map(reply.map((r) => [r.id, r]));
3024
+ return batch.map((req) => validateReply(req, byId.get(req.id)));
3025
+ }
2991
3026
  const results = [];
2992
3027
  const total = reqs.length;
2993
3028
  for (const batch of chunk(reqs, Math.max(1, batchSize))) {
2994
3029
  if (signal?.aborted) break;
2995
- const reply = await callBatch(batch, signal);
2996
- const byId = new Map(reply.map((r) => [r.id, r]));
2997
- const batchResults = [];
2998
- for (const req of batch) batchResults.push(validateReply(req, byId.get(req.id)));
3030
+ const batchResults = await resolveBatch(batch);
2999
3031
  results.push(...batchResults);
3000
3032
  onBatchComplete?.(results.length, total, batchResults);
3001
3033
  }
@@ -3020,8 +3052,8 @@ var AnthropicProvider = class {
3020
3052
  supportsVision() {
3021
3053
  return true;
3022
3054
  }
3023
- translate(reqs, onBatchComplete, signal) {
3024
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
3055
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
3056
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3025
3057
  }
3026
3058
  // Build the user message as content blocks: each unique key's screenshot is
3027
3059
  // sent once (a key recurs once per target locale in a batch — dedupe by key),
@@ -3068,13 +3100,8 @@ var AnthropicProvider = class {
3068
3100
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
3069
3101
  messages: [{ role: "user", content }]
3070
3102
  }, { signal });
3071
- const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
3072
- try {
3073
- const parsed = JSON.parse(text);
3074
- return parsed.items ?? [];
3075
- } catch {
3076
- return [];
3077
- }
3103
+ const text = res.content.find((b) => b.type === "text")?.text ?? "";
3104
+ return parseReplyItems(text);
3078
3105
  }
3079
3106
  };
3080
3107
 
@@ -3108,8 +3135,8 @@ var OpenAIProvider = class {
3108
3135
  supportsVision() {
3109
3136
  return true;
3110
3137
  }
3111
- translate(reqs, onBatchComplete, signal) {
3112
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
3138
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
3139
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3113
3140
  }
3114
3141
  // User content as an array of parts: each unique key's screenshot once (as an
3115
3142
  // image_url data URL), then the batch prompt text describing every item.
@@ -3158,13 +3185,8 @@ var OpenAIProvider = class {
3158
3185
  { role: "user", content: this.buildUserContent(batch) }
3159
3186
  ]
3160
3187
  }, { signal });
3161
- const text = res.choices?.[0]?.message?.content ?? "{}";
3162
- try {
3163
- const parsed = JSON.parse(text);
3164
- return parsed.items ?? [];
3165
- } catch {
3166
- return [];
3167
- }
3188
+ const text = res.choices?.[0]?.message?.content ?? "";
3189
+ return parseReplyItems(text);
3168
3190
  }
3169
3191
  };
3170
3192
 
@@ -3209,8 +3231,8 @@ var BedrockProvider = class {
3209
3231
  supportsVision() {
3210
3232
  return !this.isMeta();
3211
3233
  }
3212
- translate(reqs, onBatchComplete, signal) {
3213
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
3234
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
3235
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3214
3236
  }
3215
3237
  buildContentBlocks(batch) {
3216
3238
  const blocks = [];
@@ -3274,13 +3296,8 @@ var BedrockProvider = class {
3274
3296
  const blocks = res.output?.message?.content ?? [];
3275
3297
  const tool = blocks.find((b) => b.toolUse)?.toolUse;
3276
3298
  if (tool?.input?.items) return tool.input.items;
3277
- const text = blocks.find((b) => b.text)?.text ?? "{}";
3278
- try {
3279
- const parsed = JSON.parse(text);
3280
- return parsed.items ?? [];
3281
- } catch {
3282
- return [];
3283
- }
3299
+ const text = blocks.find((b) => b.text)?.text ?? "";
3300
+ return parseReplyItems(text);
3284
3301
  }
3285
3302
  };
3286
3303
 
@@ -3400,8 +3417,8 @@ var ClaudeCodeProvider = class {
3400
3417
  supportsVision() {
3401
3418
  return false;
3402
3419
  }
3403
- translate(reqs, onBatchComplete, signal) {
3404
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
3420
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
3421
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3405
3422
  }
3406
3423
  async complete(req) {
3407
3424
  const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
@@ -3424,12 +3441,7 @@ var ClaudeCodeProvider = class {
3424
3441
  throw err;
3425
3442
  }
3426
3443
  if (signal?.aborted) return [];
3427
- try {
3428
- const parsed = JSON.parse(stripFences(result));
3429
- return parsed.items ?? [];
3430
- } catch {
3431
- return [];
3432
- }
3444
+ return parseReplyItems(stripFences(result));
3433
3445
  }
3434
3446
  };
3435
3447
 
@@ -4836,6 +4848,19 @@ function createApi(deps) {
4836
4848
  },
4837
4849
  onLocaleDone: (locale) => {
4838
4850
  void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4851
+ },
4852
+ // Record the raw reply so an unparseable model response is diagnosable
4853
+ // from the activity log instead of vanishing into per-item errors.
4854
+ onMalformedReply: (raw, batchSize, locale) => {
4855
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
4856
+ appendLog(projectRoot, {
4857
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4858
+ kind: "translate",
4859
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
4860
+ model: aiCfg.model,
4861
+ locale,
4862
+ raw
4863
+ });
4839
4864
  }
4840
4865
  }, aiCfg.concurrency, signal);
4841
4866
  if (!signal?.aborted) {
@@ -4868,7 +4893,19 @@ function createApi(deps) {
4868
4893
  }
4869
4894
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
4870
4895
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4871
- const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
4896
+ const results = await runLocaleParallel(toTranslate, provider, {
4897
+ onMalformedReply: (raw, batchSize, locale) => {
4898
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
4899
+ appendLog(projectRoot, {
4900
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4901
+ kind: "translate",
4902
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
4903
+ model: aiCfg.model,
4904
+ locale,
4905
+ raw
4906
+ });
4907
+ }
4908
+ }, aiCfg.concurrency);
4872
4909
  const latest = load();
4873
4910
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
4874
4911
  const entry = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glotfile",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Local-first, git-native translation management.",
5
5
  "type": "module",
6
6
  "bin": {