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.
@@ -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) {
@@ -1128,7 +1129,7 @@ function selectContextTargets(state, opts, cache2, lastRunAt) {
1128
1129
  let candidates = [];
1129
1130
  for (const key of Object.keys(state.keys).sort()) {
1130
1131
  const entry = state.keys[key];
1131
- if (entry.context) continue;
1132
+ if (entry.context && !opts.force) continue;
1132
1133
  if (keySet && !keySet.has(key)) continue;
1133
1134
  if (keyRe && !keyRe.test(key)) continue;
1134
1135
  if (cutoff) {
@@ -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
  }
@@ -1196,7 +1197,7 @@ var CONTEXT_BATCH_SCHEMA = {
1196
1197
  required: ["items"],
1197
1198
  additionalProperties: false
1198
1199
  };
1199
- function applyContext(state, reqs, results, clock = systemClock) {
1200
+ function applyContext(state, reqs, results, clock = systemClock, force = false) {
1200
1201
  const byId = new Map(reqs.map((r) => [r.id, r]));
1201
1202
  let written = 0;
1202
1203
  const errors = [];
@@ -1217,7 +1218,7 @@ function applyContext(state, reqs, results, clock = systemClock) {
1217
1218
  continue;
1218
1219
  }
1219
1220
  const entry = state.keys[req.key];
1220
- if (!entry || entry.context) continue;
1221
+ if (!entry || entry.context && !force) continue;
1221
1222
  entry.context = context;
1222
1223
  entry.contextSource = "ai";
1223
1224
  entry.contextAt = clock();
@@ -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,
@@ -2347,8 +2350,8 @@ import { dirname as dirname2, resolve as resolve8, basename, relative as relativ
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()) {
@@ -2787,6 +2812,26 @@ var OllamaProvider = class extends OpenAIProvider {
2787
2812
  supportsVision() {
2788
2813
  return this.config.vision === true;
2789
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;
2834
+ }
2790
2835
  };
2791
2836
 
2792
2837
  // src/server/ai/claudecode.ts
@@ -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;
@@ -3425,18 +3470,36 @@ function coerceAi(raw) {
3425
3470
  endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
3426
3471
  region: typeof a.region === "string" ? a.region : null,
3427
3472
  batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize,
3428
- vision: typeof a.vision === "boolean" ? a.vision : void 0
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
3429
3478
  };
3430
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
+ }
3431
3488
  function loadLocalSettings(projectRoot) {
3432
3489
  const raw = readJson2(settingsPath(projectRoot));
3433
- 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 };
3434
3495
  }
3435
3496
  function saveLocalSettings(projectRoot, patch) {
3436
3497
  const path = settingsPath(projectRoot);
3437
3498
  const merged = { ...readJson2(path) };
3438
3499
  if (patch.ai !== void 0) merged.ai = patch.ai;
3439
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;
3440
3503
  ensureGlotfileDir(projectRoot);
3441
3504
  writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3442
3505
  }
@@ -3524,6 +3587,43 @@ function createApi(deps) {
3524
3587
  saveLocalSettings(projectRoot, patch);
3525
3588
  return c.json({ ok: true });
3526
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
+ });
3527
3627
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3528
3628
  app.get("/files", (c) => {
3529
3629
  const found = /* @__PURE__ */ new Map();
@@ -3702,7 +3802,7 @@ function createApi(deps) {
3702
3802
  return c.json({ removed });
3703
3803
  });
3704
3804
  app.post("/keys/bulk-meta", async (c) => {
3705
- const { keys, addTags, removeTags, skipTranslate } = await c.req.json();
3805
+ const { keys, addTags, removeTags, skipTranslate, clearContext } = await c.req.json();
3706
3806
  if (!Array.isArray(keys) || keys.length === 0) return c.json({ error: "keys must be a non-empty array" }, 400);
3707
3807
  const s = load();
3708
3808
  let updated = 0;
@@ -3720,6 +3820,11 @@ function createApi(deps) {
3720
3820
  if (skipTranslate) setMetadata(s, key, { skipTranslate: true });
3721
3821
  else delete entry.skipTranslate;
3722
3822
  }
3823
+ if (clearContext === true) {
3824
+ delete entry.context;
3825
+ delete entry.contextSource;
3826
+ delete entry.contextAt;
3827
+ }
3723
3828
  updated++;
3724
3829
  }
3725
3830
  persist(s);
@@ -4000,7 +4105,7 @@ function createApi(deps) {
4000
4105
  console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
4001
4106
  let totalWritten = 0;
4002
4107
  const allErrors = [];
4003
- const system = buildSystemPrompt();
4108
+ const system = buildSystemPrompt(reqs.some((r) => r.plural !== void 0));
4004
4109
  const reqById = new Map(reqs.map((r) => [r.id, r]));
4005
4110
  const localeTotals = /* @__PURE__ */ new Map();
4006
4111
  for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
@@ -4029,7 +4134,7 @@ function createApi(deps) {
4029
4134
  system,
4030
4135
  items: batchResults.map((r) => {
4031
4136
  const req = reqById.get(r.id);
4032
- 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 };
4033
4138
  }),
4034
4139
  results: batchResults
4035
4140
  });
@@ -4044,7 +4149,7 @@ function createApi(deps) {
4044
4149
  onLocaleDone: (locale) => {
4045
4150
  void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4046
4151
  }
4047
- }, void 0, signal);
4152
+ }, aiCfg.concurrency, signal);
4048
4153
  if (!signal?.aborted) {
4049
4154
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
4050
4155
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -4075,14 +4180,15 @@ function createApi(deps) {
4075
4180
  }
4076
4181
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
4077
4182
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4078
- const results = await runLocaleParallel(toTranslate, provider);
4079
- ({ 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));
4080
4186
  const entry = {
4081
4187
  at: (/* @__PURE__ */ new Date()).toISOString(),
4082
4188
  kind: "translate",
4083
4189
  summary: `Translated ${toTranslate.length} item(s)`,
4084
4190
  model: aiCfg.model,
4085
- system: buildSystemPrompt(),
4191
+ system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
4086
4192
  // Log the screenshot PATH only — never the image bytes.
4087
4193
  items: toTranslate.map((r) => ({
4088
4194
  id: r.id,
@@ -4091,13 +4197,13 @@ function createApi(deps) {
4091
4197
  targetLocale: r.targetLocale,
4092
4198
  context: r.context,
4093
4199
  glossary: r.glossary,
4094
- screenshot: s.keys[r.key]?.screenshot
4200
+ screenshot: latest.keys[r.key]?.screenshot
4095
4201
  })),
4096
4202
  results
4097
4203
  };
4098
4204
  appendLog(projectRoot, entry);
4205
+ persist(latest);
4099
4206
  }
4100
- persist(s);
4101
4207
  return c.json({ requested: reqs.length, written, errors });
4102
4208
  }));
4103
4209
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
@@ -4153,55 +4259,96 @@ function createApi(deps) {
4153
4259
  return c.json({ indexed: true, scannedAt: cache2.scannedAt, used: computeUsedKeys(load(), cache2) });
4154
4260
  });
4155
4261
  app.post("/context/build", async (c) => {
4262
+ const signal = c.req.raw.signal;
4156
4263
  const body = await c.req.json().catch(() => ({}));
4157
- const s = load();
4158
- const cache2 = loadUsageCache(projectRoot);
4159
- if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
4160
- const targets = selectContextTargets(s, {
4161
- all: body.all,
4162
- keyGlob: body.keyGlob,
4163
- limit: body.limit,
4164
- since: body.since,
4165
- keys: body.keys
4166
- }, cache2, body.lastRunAt);
4167
- if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4168
- const aiCfg = loadLocalSettings(projectRoot).ai;
4169
- let provider;
4170
- try {
4171
- provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4172
- } catch (e) {
4173
- return c.json({ error: e.message }, 400);
4174
- }
4175
- const fileCache = /* @__PURE__ */ new Map();
4176
- for (const target of targets) {
4177
- const allRefs = Object.entries(cache2.files).flatMap(
4178
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
4179
- key: r.key,
4180
- file,
4181
- line: r.line,
4182
- col: r.col,
4183
- scanner: r.scanner
4184
- }))
4185
- );
4186
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
4187
- }
4188
- const system = buildContextSystemPrompt();
4189
- const prompt = buildContextBatchPrompt(targets);
4190
- const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
4191
- const batch = raw;
4192
- const { written, errors } = applyContext(s, targets, batch.items ?? []);
4193
- appendLog(projectRoot, {
4194
- at: (/* @__PURE__ */ new Date()).toISOString(),
4195
- kind: "context",
4196
- summary: `Generated context for ${targets.length} key(s)`,
4197
- model: aiCfg.model,
4198
- system,
4199
- items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4200
- 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
+ force: body.force
4278
+ }, cache2, body.lastRunAt);
4279
+ if (!targets.length) {
4280
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: 0, written: 0, errors: [] }) });
4281
+ return;
4282
+ }
4283
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4284
+ let provider;
4285
+ try {
4286
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4287
+ } catch (e) {
4288
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4289
+ return;
4290
+ }
4291
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: targets.length }) });
4292
+ const fileCache = /* @__PURE__ */ new Map();
4293
+ for (const target of targets) {
4294
+ const allRefs = Object.entries(cache2.files).flatMap(
4295
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
4296
+ key: r.key,
4297
+ file,
4298
+ line: r.line,
4299
+ col: r.col,
4300
+ scanner: r.scanner
4301
+ }))
4302
+ );
4303
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
4304
+ }
4305
+ const system = buildContextSystemPrompt();
4306
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
4307
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
4308
+ const chunks = [];
4309
+ for (let i = 0; i < targets.length; i += batchSize) chunks.push(targets.slice(i, i + batchSize));
4310
+ let totalWritten = 0;
4311
+ let totalDone = 0;
4312
+ const allErrors = [];
4313
+ let next = 0;
4314
+ async function worker() {
4315
+ while (next < chunks.length) {
4316
+ if (signal?.aborted) break;
4317
+ const chunk2 = chunks[next++];
4318
+ let raw;
4319
+ try {
4320
+ raw = await provider.complete({ system, content: [{ type: "text", text: buildContextBatchPrompt(chunk2) }], schema: CONTEXT_BATCH_SCHEMA });
4321
+ } catch (e) {
4322
+ totalDone += chunk2.length;
4323
+ allErrors.push(...chunk2.map((t) => ({ key: t.key, error: e.message })));
4324
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done: totalDone, total: targets.length, written: totalWritten }) });
4325
+ continue;
4326
+ }
4327
+ if (signal?.aborted) break;
4328
+ const batch = raw;
4329
+ const fresh = load();
4330
+ const { written, errors } = applyContext(fresh, chunk2, batch.items ?? [], void 0, body.force === true);
4331
+ appendLog(projectRoot, {
4332
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4333
+ kind: "context",
4334
+ summary: `Generated context for ${chunk2.length} key(s)`,
4335
+ model: aiCfg.model,
4336
+ system,
4337
+ items: chunk2.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4338
+ results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
4339
+ });
4340
+ persist(fresh);
4341
+ totalWritten += written;
4342
+ totalDone += chunk2.length;
4343
+ allErrors.push(...errors);
4344
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done: totalDone, total: targets.length, written: totalWritten }) });
4345
+ }
4346
+ }
4347
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
4348
+ if (signal?.aborted) return;
4349
+ console.log(`[context] ${totalWritten} context(s) written${allErrors.length ? `, ${allErrors.length} error(s)` : ""}`);
4350
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
4201
4351
  });
4202
- persist(s);
4203
- console.log(`[context] ${written} context(s) written${errors.length ? `, ${errors.length} error(s)` : ""}`);
4204
- return c.json({ requested: targets.length, written, errors });
4205
4352
  });
4206
4353
  app.onError(
4207
4354
  (err, c) => c.json({ error: err.message }, err instanceof GlotfileError ? 400 : 500)