glotfile 0.2.0 → 0.3.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.
@@ -29,7 +29,7 @@ var init_dictionary_en = __esm({
29
29
  import { Hono as Hono2 } from "hono";
30
30
  import { serve } from "@hono/node-server";
31
31
  import { fileURLToPath } from "url";
32
- import { dirname as dirname6, join as join7, resolve as resolve7, extname as extname3, sep as sep2 } from "path";
32
+ import { dirname as dirname3, join as join9, resolve as resolve9, extname as extname3, sep as sep2 } from "path";
33
33
  import { readFile, stat } from "fs/promises";
34
34
  import { createServer } from "net";
35
35
  import open from "open";
@@ -39,8 +39,7 @@ import { Hono } from "hono";
39
39
  import { streamSSE } from "hono/streaming";
40
40
 
41
41
  // src/server/state.ts
42
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
43
- import { dirname as dirname2 } from "path";
42
+ import { readFileSync as readFileSync2, existsSync as existsSync2, rmSync as rmSync3 } from "fs";
44
43
  import { randomUUID } from "crypto";
45
44
 
46
45
  // src/server/format.ts
@@ -61,6 +60,26 @@ function serializeJson(value, opts) {
61
60
  return opts.finalNewline ? body + "\n" : body;
62
61
  }
63
62
 
63
+ // src/server/atomic-write.ts
64
+ import { writeFileSync, renameSync, mkdirSync, rmSync } from "fs";
65
+ import { dirname, join } from "path";
66
+ var counter = 0;
67
+ function writeFileAtomic(path, data) {
68
+ const dir = dirname(path);
69
+ mkdirSync(dir, { recursive: true });
70
+ const tmp = join(dir, `.${process.pid}.${counter++}.tmp`);
71
+ try {
72
+ writeFileSync(tmp, data);
73
+ renameSync(tmp, path);
74
+ } catch (e) {
75
+ try {
76
+ rmSync(tmp, { force: true });
77
+ } catch {
78
+ }
79
+ throw e;
80
+ }
81
+ }
82
+
64
83
  // src/server/lint/registry.ts
65
84
  var RULE_IDS = [
66
85
  "empty-source",
@@ -83,7 +102,7 @@ function isPluralForm(key) {
83
102
  return PLURAL_CATEGORIES.includes(key) || EXACT_SELECTOR_RE.test(key);
84
103
  }
85
104
  var LOCALE_CASES = ["lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"];
86
- var PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter"];
105
+ var PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter", "ollama", "claude-code"];
87
106
  var GlotfileError = class extends Error {
88
107
  };
89
108
  function isObject(v) {
@@ -138,17 +157,6 @@ function validate(raw) {
138
157
  }
139
158
  }
140
159
  }
141
- if (!isObject(config.ai)) fail("config.ai must be an object");
142
- const ai = config.ai;
143
- if (typeof ai.provider !== "string" || !PROVIDERS.includes(ai.provider)) {
144
- fail(`config.ai.provider must be one of: ${PROVIDERS.join(", ")}`);
145
- }
146
- if (typeof ai.model !== "string") fail("config.ai.model must be a string");
147
- if (!(ai.endpoint === null || typeof ai.endpoint === "string")) fail("config.ai.endpoint must be a string or null");
148
- if (!(ai.region === void 0 || ai.region === null || typeof ai.region === "string")) {
149
- fail("config.ai.region must be a string or null");
150
- }
151
- if (typeof ai.batchSize !== "number") fail("config.ai.batchSize must be a number");
152
160
  if (!isObject(config.format)) fail("config.format must be an object");
153
161
  const fmt = config.format;
154
162
  if (typeof fmt.indent !== "number") fail("config.format.indent must be a number");
@@ -279,7 +287,6 @@ function defaultState() {
279
287
  { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
280
288
  { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" }
281
289
  ],
282
- ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", endpoint: null, region: null, batchSize: 25 },
283
290
  format: { indent: 2, sortKeys: true, finalNewline: true },
284
291
  spelling: { customWords: [] },
285
292
  autoExport: true
@@ -402,13 +409,13 @@ function gettextPluralForms(locale) {
402
409
  }
403
410
 
404
411
  // src/server/storage.ts
405
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from "fs";
406
- import { join, dirname } from "path";
412
+ import { existsSync, readFileSync, mkdirSync as mkdirSync2, readdirSync, rmSync as rmSync2 } from "fs";
413
+ import { join as join2 } from "path";
407
414
  function splitDirFor(statePath) {
408
415
  return statePath.replace(/\.json$/i, "");
409
416
  }
410
417
  function detectFormat(statePath) {
411
- if (existsSync(join(splitDirFor(statePath), "config.json"))) return "split";
418
+ if (existsSync(join2(splitDirFor(statePath), "config.json"))) return "split";
412
419
  if (existsSync(statePath)) return "single";
413
420
  return "none";
414
421
  }
@@ -438,15 +445,15 @@ function assemble(parts) {
438
445
  return { ...parts.manifest, keys };
439
446
  }
440
447
  function loadSplit(splitDir) {
441
- const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
442
- const manifest = readJson(join(splitDir, "config.json"));
443
- const keysPath = join(splitDir, "keys.json");
444
- const keys = existsSync(keysPath) ? readJson(keysPath) : {};
445
- const localesDir = join(splitDir, "locales");
448
+ const readJson3 = (p) => JSON.parse(readFileSync(p, "utf8"));
449
+ const manifest = readJson3(join2(splitDir, "config.json"));
450
+ const keysPath = join2(splitDir, "keys.json");
451
+ const keys = existsSync(keysPath) ? readJson3(keysPath) : {};
452
+ const localesDir = join2(splitDir, "locales");
446
453
  const locales = {};
447
454
  if (existsSync(localesDir)) {
448
455
  for (const name of readdirSync(localesDir)) {
449
- if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson(join(localesDir, name));
456
+ if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson3(join2(localesDir, name));
450
457
  }
451
458
  }
452
459
  return assemble({ manifest, keys, locales });
@@ -458,15 +465,14 @@ function writeIfChanged(path, contents) {
458
465
  } catch {
459
466
  }
460
467
  if (current === contents) return false;
461
- mkdirSync(dirname(path), { recursive: true });
462
- writeFileSync(path, contents, "utf8");
468
+ writeFileAtomic(path, contents);
463
469
  return true;
464
470
  }
465
471
  function saveSplit(splitDir, state) {
466
472
  const fmt = state.config.format;
467
473
  const parts = disassemble(state);
468
- const localesDir = join(splitDir, "locales");
469
- mkdirSync(localesDir, { recursive: true });
474
+ const localesDir = join2(splitDir, "locales");
475
+ mkdirSync2(localesDir, { recursive: true });
470
476
  let written = 0;
471
477
  let skipped = 0;
472
478
  let deleted = 0;
@@ -474,15 +480,15 @@ function saveSplit(splitDir, state) {
474
480
  if (changed) written++;
475
481
  else skipped++;
476
482
  };
477
- track(writeIfChanged(join(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
478
- track(writeIfChanged(join(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
483
+ track(writeIfChanged(join2(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
484
+ track(writeIfChanged(join2(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
479
485
  for (const [locale, entries] of Object.entries(parts.locales)) {
480
- track(writeIfChanged(join(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
486
+ track(writeIfChanged(join2(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
481
487
  }
482
488
  const live = new Set(Object.keys(parts.locales).map((l) => `${l}.json`));
483
489
  for (const name of readdirSync(localesDir)) {
484
490
  if (name.endsWith(".json") && !live.has(name)) {
485
- rmSync(join(localesDir, name));
491
+ rmSync2(join2(localesDir, name));
486
492
  deleted++;
487
493
  }
488
494
  }
@@ -539,10 +545,9 @@ function saveState(path, state) {
539
545
  normalizeState(state);
540
546
  if (state.config.storage === "split") {
541
547
  saveSplit(splitDirFor(path), state);
542
- if (existsSync2(path)) rmSync2(path);
548
+ if (existsSync2(path)) rmSync3(path);
543
549
  } else {
544
- mkdirSync2(dirname2(path), { recursive: true });
545
- writeFileSync2(path, serializeJson(state, state.config.format), "utf8");
550
+ writeFileAtomic(path, serializeJson(state, state.config.format));
546
551
  }
547
552
  }
548
553
  function requireKey(state, key) {
@@ -681,6 +686,9 @@ function setMetadata(state, key, partial) {
681
686
  delete entry.contextAt;
682
687
  }
683
688
  Object.assign(entry, safe);
689
+ if ("context" in safe && !entry.context) delete entry.context;
690
+ if ("tags" in safe && !entry.tags?.length) delete entry.tags;
691
+ if ("maxLength" in safe && !entry.maxLength) delete entry.maxLength;
684
692
  }
685
693
  function addNote(state, key, text, clock = systemClock) {
686
694
  const entry = requireKey(state, key);
@@ -736,11 +744,29 @@ function applyMachineTranslationForms(state, key, locale, forms, clock = systemC
736
744
  }
737
745
 
738
746
  // src/server/scan.ts
739
- import { existsSync as existsSync3, readFileSync as readFileSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
740
- import { resolve, dirname as dirname3 } from "path";
747
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
748
+ import { resolve as resolve2 } from "path";
749
+
750
+ // src/server/glotfile-dir.ts
751
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
752
+ import { resolve } from "path";
753
+ function ensureGlotfileDir(projectRoot) {
754
+ const dir = resolve(projectRoot, ".glotfile");
755
+ mkdirSync3(dir, { recursive: true });
756
+ const ignore = resolve(dir, ".gitignore");
757
+ if (!existsSync3(ignore)) {
758
+ try {
759
+ writeFileSync2(ignore, "*\n");
760
+ } catch {
761
+ }
762
+ }
763
+ return dir;
764
+ }
765
+
766
+ // src/server/scan.ts
741
767
  function loadUsageCache(projectRoot) {
742
- const path = resolve(projectRoot, ".glotfile", "usage.json");
743
- if (!existsSync3(path)) return null;
768
+ const path = resolve2(projectRoot, ".glotfile", "usage.json");
769
+ if (!existsSync4(path)) return null;
744
770
  try {
745
771
  return JSON.parse(readFileSync3(path, "utf8"));
746
772
  } catch {
@@ -748,9 +774,9 @@ function loadUsageCache(projectRoot) {
748
774
  }
749
775
  }
750
776
  function saveUsageCache(projectRoot, cache2) {
751
- const path = resolve(projectRoot, ".glotfile", "usage.json");
752
- mkdirSync3(dirname3(path), { recursive: true });
753
- writeFileSync3(path, JSON.stringify(cache2, null, 2) + "\n", "utf8");
777
+ ensureGlotfileDir(projectRoot);
778
+ const path = resolve2(projectRoot, ".glotfile", "usage.json");
779
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
754
780
  }
755
781
  function findMissing(state) {
756
782
  const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
@@ -777,7 +803,7 @@ function computeUsedKeys(state, cache2) {
777
803
 
778
804
  // src/server/scanner.ts
779
805
  import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync4 } from "fs";
780
- import { join as join2, extname, relative } from "path";
806
+ import { join as join3, extname, relative } from "path";
781
807
  var PATTERNS = {
782
808
  laravel: [
783
809
  /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']+)'/g,
@@ -985,7 +1011,7 @@ function* walkFiles(dir, root, exclude) {
985
1011
  }
986
1012
  for (const name of entries) {
987
1013
  if (ALWAYS_EXCLUDE.has(name)) continue;
988
- const abs = join2(dir, name);
1014
+ const abs = join3(dir, name);
989
1015
  const rel = relative(root, abs);
990
1016
  let st;
991
1017
  try {
@@ -1015,7 +1041,7 @@ function runScan(projectRoot, opts, existing) {
1015
1041
  const ext = extname(relPath);
1016
1042
  const scanner = scannerForExt(ext);
1017
1043
  if (!scanner) continue;
1018
- const abs = join2(projectRoot, relPath);
1044
+ const abs = join3(projectRoot, relPath);
1019
1045
  let st;
1020
1046
  try {
1021
1047
  st = statSync(abs);
@@ -1047,8 +1073,8 @@ function runScan(projectRoot, opts, existing) {
1047
1073
  }
1048
1074
 
1049
1075
  // src/server/ai/context.ts
1050
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
1051
- import { resolve as resolve2 } from "path";
1076
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
1077
+ import { resolve as resolve3 } from "path";
1052
1078
  var MAX_CONTEXT_LENGTH = 500;
1053
1079
  var SNIPPET_WINDOW = 15;
1054
1080
  var MAX_SNIPPETS = 3;
@@ -1064,9 +1090,9 @@ function extractSnippets(refs, projectRoot, fileCache) {
1064
1090
  const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
1065
1091
  const snippets = [];
1066
1092
  for (const ref of selected) {
1067
- const absPath = resolve2(projectRoot, ref.file);
1093
+ const absPath = resolve3(projectRoot, ref.file);
1068
1094
  if (!fileCache.has(ref.file)) {
1069
- if (!existsSync4(absPath)) continue;
1095
+ if (!existsSync5(absPath)) continue;
1070
1096
  const content = readFileSync5(absPath, "utf8");
1071
1097
  fileCache.set(ref.file, content.split("\n"));
1072
1098
  }
@@ -1372,8 +1398,8 @@ function pluralFormPlaceholdersMatch(category, source, form) {
1372
1398
  }
1373
1399
 
1374
1400
  // src/server/ai/run.ts
1375
- import { readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
1376
- import { resolve as resolve3, extname as extname2 } from "path";
1401
+ import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
1402
+ import { resolve as resolve4, extname as extname2 } from "path";
1377
1403
 
1378
1404
  // src/server/glob.ts
1379
1405
  function globToRegExp2(glob) {
@@ -1468,8 +1494,8 @@ function attachScreenshots(reqs, state, projectRoot) {
1468
1494
  const mediaType = MEDIA_TYPES[extname2(screenshot).toLowerCase()];
1469
1495
  if (!mediaType) continue;
1470
1496
  if (!cache2.has(screenshot)) {
1471
- const abs = resolve3(projectRoot, screenshot);
1472
- if (!existsSync5(abs)) {
1497
+ const abs = resolve4(projectRoot, screenshot);
1498
+ if (!existsSync6(abs)) {
1473
1499
  cache2.set(screenshot, null);
1474
1500
  } else {
1475
1501
  const buf = readFileSync6(abs);
@@ -1489,7 +1515,7 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
1489
1515
  return { skipped: keys.size };
1490
1516
  }
1491
1517
  var DEFAULT_LOCALE_CONCURRENCY = 3;
1492
- async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
1518
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
1493
1519
  if (!reqs.length) return [];
1494
1520
  const byLocale = /* @__PURE__ */ new Map();
1495
1521
  for (const req of reqs) {
@@ -1509,11 +1535,14 @@ async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency =
1509
1535
  while (next < groups.length) {
1510
1536
  if (signal?.aborted) break;
1511
1537
  const group = groups[next++];
1538
+ const locale = group[0].targetLocale;
1539
+ hooks.onLocaleStart?.(locale);
1512
1540
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
1513
1541
  done += batchResults.length;
1514
- onBatchComplete?.(done, total, batchResults);
1542
+ hooks.onBatchComplete?.(done, total, batchResults, locale);
1515
1543
  }, signal);
1516
1544
  allResults.push(...localeResults);
1545
+ if (!signal?.aborted) hooks.onLocaleDone?.(locale);
1517
1546
  }
1518
1547
  }
1519
1548
  const workers = Array.from({ length: Math.min(concurrency, groups.length) }, worker);
@@ -1535,10 +1564,11 @@ function applyResults(state, reqs, results, clock = systemClock, force = false)
1535
1564
  if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
1536
1565
  continue;
1537
1566
  }
1538
- if (res.error || res.translation === void 0) {
1567
+ if (res.translation === void 0) {
1539
1568
  errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
1540
1569
  continue;
1541
1570
  }
1571
+ if (res.error) errors.push({ key: req.key, locale: req.targetLocale, error: res.error });
1542
1572
  if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
1543
1573
  }
1544
1574
  return { written, errors };
@@ -2310,8 +2340,8 @@ function getAdapter(name) {
2310
2340
  }
2311
2341
 
2312
2342
  // src/server/api.ts
2313
- import { writeFileSync as writeFileSync5, readFileSync as readFileSync11, mkdirSync as mkdirSync6, existsSync as existsSync8, readdirSync as readdirSync7, rmSync as rmSync3 } from "fs";
2314
- import { dirname as dirname5, resolve as resolve6, basename, relative as relative3, sep } from "path";
2343
+ import { readFileSync as readFileSync13, existsSync as existsSync9, readdirSync as readdirSync7, rmSync as rmSync4 } from "fs";
2344
+ import { dirname as dirname2, resolve as resolve8, basename, relative as relative3, sep } from "path";
2315
2345
 
2316
2346
  // src/server/ai/anthropic.ts
2317
2347
  import Anthropic from "@anthropic-ai/sdk";
@@ -2400,7 +2430,7 @@ function validateTranslation(req, translation) {
2400
2430
  return { id: req.id, error: "Placeholder mismatch between source and translation." };
2401
2431
  }
2402
2432
  if (req.maxLength !== void 0 && translation.length > req.maxLength) {
2403
- return { id: req.id, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
2433
+ return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
2404
2434
  }
2405
2435
  return { id: req.id, translation };
2406
2436
  }
@@ -2623,7 +2653,7 @@ var BedrockProvider = class {
2623
2653
  }
2624
2654
  const region = config.region ?? process.env.AWS_REGION;
2625
2655
  if (!region) {
2626
- throw new Error("AWS region is not set. Set config.ai.region or the AWS_REGION environment variable for the bedrock provider.");
2656
+ throw new Error("AWS region is not set. Set the Region in your local AI settings (.glotfile/settings.json) or the AWS_REGION environment variable for the bedrock provider.");
2627
2657
  }
2628
2658
  const require2 = createRequire2(import.meta.url);
2629
2659
  let sdk;
@@ -2742,9 +2772,116 @@ var OpenRouterProvider = class extends OpenAIProvider {
2742
2772
  }
2743
2773
  };
2744
2774
 
2775
+ // src/server/ai/ollama.ts
2776
+ var OLLAMA_BASE_URL = "http://localhost:11434/v1";
2777
+ function ollamaClientOptions(config) {
2778
+ return {
2779
+ apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
2780
+ baseURL: config.endpoint ?? OLLAMA_BASE_URL
2781
+ };
2782
+ }
2783
+ var OllamaProvider = class extends OpenAIProvider {
2784
+ constructor(config, client) {
2785
+ super(config, client ?? loadOpenAIClient(ollamaClientOptions(config)));
2786
+ }
2787
+ supportsVision() {
2788
+ return false;
2789
+ }
2790
+ };
2791
+
2792
+ // src/server/ai/claudecode.ts
2793
+ import { spawn } from "child_process";
2794
+ function stripFences(s) {
2795
+ return s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
2796
+ }
2797
+ function defaultSpawn(prompt, systemPrompt, model) {
2798
+ return new Promise((resolve10, reject) => {
2799
+ const args = [
2800
+ "--print",
2801
+ "--output-format",
2802
+ "json",
2803
+ "--system-prompt",
2804
+ systemPrompt,
2805
+ // Only pass --model when explicitly configured; otherwise use the session default.
2806
+ ...model ? ["--model", model] : []
2807
+ ];
2808
+ const child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
2809
+ let stdout = "";
2810
+ let stderr = "";
2811
+ child.stdout.on("data", (chunk2) => {
2812
+ stdout += chunk2.toString();
2813
+ });
2814
+ child.stderr.on("data", (chunk2) => {
2815
+ stderr += chunk2.toString();
2816
+ });
2817
+ child.stdin.write(prompt);
2818
+ child.stdin.end();
2819
+ child.on("close", (code) => {
2820
+ if (code !== 0) {
2821
+ reject(new Error(`claude exited with code ${code}: ${stderr.trim() || stdout.trim()}`));
2822
+ return;
2823
+ }
2824
+ try {
2825
+ const envelope = JSON.parse(stdout.trim());
2826
+ if (envelope.is_error) {
2827
+ reject(new Error(`claude error: ${envelope.result ?? "unknown error"}`));
2828
+ return;
2829
+ }
2830
+ resolve10(envelope.result ?? "");
2831
+ } catch {
2832
+ reject(new Error(`Failed to parse claude JSON output: ${stdout.slice(0, 200)}`));
2833
+ }
2834
+ });
2835
+ child.on("error", (err) => {
2836
+ reject(new Error(`Failed to spawn claude: ${err.message}. Is Claude Code installed?`));
2837
+ });
2838
+ });
2839
+ }
2840
+ var ClaudeCodeProvider = class {
2841
+ constructor(config, spawnFn) {
2842
+ this.config = config;
2843
+ this.spawnFn = spawnFn ?? defaultSpawn;
2844
+ }
2845
+ config;
2846
+ spawnFn;
2847
+ supportsVision() {
2848
+ return false;
2849
+ }
2850
+ translate(reqs, onBatchComplete, signal) {
2851
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2852
+ }
2853
+ async complete(req) {
2854
+ const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
2855
+ const textBlocks = req.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
2856
+ const result = await this.spawnFn(textBlocks, systemParts.join("\n\n"), this.config.model);
2857
+ try {
2858
+ return JSON.parse(stripFences(result));
2859
+ } catch {
2860
+ return {};
2861
+ }
2862
+ }
2863
+ async callBatch(batch, signal) {
2864
+ if (signal?.aborted) return [];
2865
+ const prompt = buildBatchPrompt(batch);
2866
+ let result;
2867
+ try {
2868
+ result = await this.spawnFn(prompt, buildSystemPrompt(), this.config.model);
2869
+ } catch (err) {
2870
+ if (signal?.aborted) return [];
2871
+ throw err;
2872
+ }
2873
+ if (signal?.aborted) return [];
2874
+ try {
2875
+ const parsed = JSON.parse(stripFences(result));
2876
+ return parsed.items ?? [];
2877
+ } catch {
2878
+ return [];
2879
+ }
2880
+ }
2881
+ };
2882
+
2745
2883
  // src/server/ai/index.ts
2746
- function makeProvider(config) {
2747
- const ai = config.ai;
2884
+ function makeProvider(ai) {
2748
2885
  switch (ai.provider) {
2749
2886
  case "anthropic":
2750
2887
  return new AnthropicProvider(ai);
@@ -2754,36 +2891,36 @@ function makeProvider(config) {
2754
2891
  return new BedrockProvider(ai);
2755
2892
  case "openrouter":
2756
2893
  return new OpenRouterProvider(ai);
2894
+ case "ollama":
2895
+ return new OllamaProvider(ai);
2896
+ case "claude-code":
2897
+ return new ClaudeCodeProvider(ai);
2757
2898
  default:
2758
- throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter.`);
2899
+ throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter, ollama, claude-code.`);
2759
2900
  }
2760
2901
  }
2761
2902
 
2762
- // src/server/ai/log.ts
2763
- import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
2764
- import { resolve as resolve4 } from "path";
2903
+ // src/server/log.ts
2904
+ import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
2905
+ import { resolve as resolve5 } from "path";
2765
2906
  function logPath(projectRoot) {
2766
- return resolve4(projectRoot, ".glotfile", "ai-log.jsonl");
2907
+ return resolve5(projectRoot, ".glotfile", "log.jsonl");
2767
2908
  }
2768
- function appendAiLog(projectRoot, entry) {
2769
- mkdirSync4(resolve4(projectRoot, ".glotfile"), { recursive: true });
2909
+ function appendLog(projectRoot, entry) {
2910
+ ensureGlotfileDir(projectRoot);
2770
2911
  appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
2771
2912
  }
2772
- function readAiLog(projectRoot, limit = 100) {
2913
+ function readLog(projectRoot, limit = 100) {
2773
2914
  const path = logPath(projectRoot);
2774
- if (!existsSync6(path)) return [];
2915
+ if (!existsSync7(path)) return [];
2775
2916
  const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2776
- const entries = lines.map((l) => {
2777
- const e = JSON.parse(l);
2778
- e.kind ??= "translate";
2779
- return e;
2780
- });
2917
+ const entries = lines.map((l) => JSON.parse(l));
2781
2918
  return entries.reverse().slice(0, limit);
2782
2919
  }
2783
2920
 
2784
2921
  // src/server/import/detect.ts
2785
- import { existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2786
- import { join as join3 } from "path";
2922
+ import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2923
+ import { join as join4 } from "path";
2787
2924
  var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
2788
2925
  var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
2789
2926
  function safeIsDir(p) {
@@ -2794,7 +2931,7 @@ function safeIsDir(p) {
2794
2931
  }
2795
2932
  }
2796
2933
  function listDirs(dir) {
2797
- return readdirSync3(dir).filter((e) => safeIsDir(join3(dir, e)));
2934
+ return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
2798
2935
  }
2799
2936
  function fileCount(dir) {
2800
2937
  try {
@@ -2808,22 +2945,22 @@ function pickSource(locales, sizeOf) {
2808
2945
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
2809
2946
  }
2810
2947
  function detectLaravel(root) {
2811
- const localeRoot = [join3(root, "resources", "lang"), join3(root, "lang")].find(safeIsDir);
2948
+ const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
2812
2949
  if (!localeRoot) return null;
2813
2950
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
2814
2951
  if (locales.length === 0) return null;
2815
- const sourceLocale = pickSource(locales, (loc) => fileCount(join3(localeRoot, loc)));
2952
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
2816
2953
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
2817
2954
  }
2818
2955
  function detectVue(root) {
2819
2956
  for (const rel of VUE_DIR_CANDIDATES) {
2820
- const localeRoot = join3(root, rel);
2957
+ const localeRoot = join4(root, rel);
2821
2958
  if (!safeIsDir(localeRoot)) continue;
2822
2959
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
2823
2960
  if (locales.length >= 2) {
2824
2961
  const sourceLocale = pickSource(locales, (loc) => {
2825
2962
  try {
2826
- return statSync2(join3(localeRoot, `${loc}.json`)).size;
2963
+ return statSync2(join4(localeRoot, `${loc}.json`)).size;
2827
2964
  } catch {
2828
2965
  return 0;
2829
2966
  }
@@ -2835,7 +2972,7 @@ function detectVue(root) {
2835
2972
  }
2836
2973
  function detectArb(root) {
2837
2974
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
2838
- const localeRoot = join3(root, rel);
2975
+ const localeRoot = join4(root, rel);
2839
2976
  if (!safeIsDir(localeRoot)) continue;
2840
2977
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
2841
2978
  if (locales.length >= 1) {
@@ -2851,7 +2988,7 @@ var BY_FORMAT = {
2851
2988
  "flutter-arb": detectArb
2852
2989
  };
2853
2990
  function detect(root, formatOverride) {
2854
- if (!existsSync7(root)) return null;
2991
+ if (!existsSync8(root)) return null;
2855
2992
  if (formatOverride) {
2856
2993
  const fn = BY_FORMAT[formatOverride];
2857
2994
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -2866,7 +3003,7 @@ function detect(root, formatOverride) {
2866
3003
 
2867
3004
  // src/server/import/parsers/vue-i18n-json.ts
2868
3005
  import { readdirSync as readdirSync4, readFileSync as readFileSync8 } from "fs";
2869
- import { join as join4 } from "path";
3006
+ import { join as join5 } from "path";
2870
3007
 
2871
3008
  // src/server/import/flatten.ts
2872
3009
  function flattenObject(value, prefix, warnings) {
@@ -2905,7 +3042,7 @@ var vueI18nJson2 = {
2905
3042
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2906
3043
  let data;
2907
3044
  try {
2908
- data = JSON.parse(readFileSync8(join4(localeRoot, file), "utf8"));
3045
+ data = JSON.parse(readFileSync8(join5(localeRoot, file), "utf8"));
2909
3046
  } catch (e) {
2910
3047
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
2911
3048
  continue;
@@ -2921,7 +3058,7 @@ var vueI18nJson2 = {
2921
3058
 
2922
3059
  // src/server/import/parsers/laravel-php.ts
2923
3060
  import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
2924
- import { join as join5, relative as relative2 } from "path";
3061
+ import { join as join6, relative as relative2 } from "path";
2925
3062
  import { execFileSync } from "child_process";
2926
3063
 
2927
3064
  // src/server/import/placeholders.ts
@@ -2931,13 +3068,13 @@ function laravelToCanonical(value) {
2931
3068
 
2932
3069
  // src/server/import/parsers/laravel-php.ts
2933
3070
  function listDirs2(dir) {
2934
- return readdirSync5(dir).filter((e) => statSync3(join5(dir, e)).isDirectory());
3071
+ return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
2935
3072
  }
2936
3073
  function listPhpFiles(dir) {
2937
3074
  const out = [];
2938
3075
  const walk = (d) => {
2939
3076
  for (const e of readdirSync5(d)) {
2940
- const full = join5(d, e);
3077
+ const full = join6(d, e);
2941
3078
  if (statSync3(full).isDirectory()) walk(full);
2942
3079
  else if (e.endsWith(".php")) out.push(full);
2943
3080
  }
@@ -2974,7 +3111,7 @@ var laravelPhp2 = {
2974
3111
  for (const locale of listDirs2(localeRoot).sort()) {
2975
3112
  if (locale === "vendor") continue;
2976
3113
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2977
- const localeDir = join5(localeRoot, locale);
3114
+ const localeDir = join6(localeRoot, locale);
2978
3115
  locales.push(locale);
2979
3116
  for (const file of listPhpFiles(localeDir)) {
2980
3117
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -2998,7 +3135,7 @@ var laravelPhp2 = {
2998
3135
 
2999
3136
  // src/server/import/parsers/flutter-arb.ts
3000
3137
  import { readdirSync as readdirSync6, readFileSync as readFileSync9 } from "fs";
3001
- import { join as join6 } from "path";
3138
+ import { join as join7 } from "path";
3002
3139
  var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
3003
3140
  function localeFromArbName(file) {
3004
3141
  const m = file.match(/^(.+)\.arb$/);
@@ -3034,7 +3171,7 @@ var flutterArb2 = {
3034
3171
  if (opts?.locales && !opts.locales.includes(locale)) continue;
3035
3172
  let data;
3036
3173
  try {
3037
- data = JSON.parse(readFileSync9(join6(localeRoot, file), "utf8"));
3174
+ data = JSON.parse(readFileSync9(join7(localeRoot, file), "utf8"));
3038
3175
  } catch (e) {
3039
3176
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
3040
3177
  continue;
@@ -3132,7 +3269,6 @@ function assemble2(parsed, opts) {
3132
3269
  sourceLocale,
3133
3270
  locales,
3134
3271
  outputs: [output],
3135
- ai: { provider: "anthropic", model: "claude-opus-4-8", endpoint: null, batchSize: 25 },
3136
3272
  format: { indent: 2, sortKeys: true, finalNewline: true },
3137
3273
  spelling: { customWords: [] }
3138
3274
  },
@@ -3189,8 +3325,8 @@ function runImport(opts) {
3189
3325
  }
3190
3326
 
3191
3327
  // src/server/export-run.ts
3192
- import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync10 } from "fs";
3193
- import { dirname as dirname4, resolve as resolve5 } from "path";
3328
+ import { readFileSync as readFileSync10 } from "fs";
3329
+ import { resolve as resolve6 } from "path";
3194
3330
  function effectiveLocales(config) {
3195
3331
  const limit = config.exportLocales;
3196
3332
  if (!limit || limit.length === 0) return config.locales;
@@ -3212,7 +3348,7 @@ function exportToDisk(state, projectRoot, opts) {
3212
3348
  warnings.push(...result.warnings);
3213
3349
  const writtenPaths = /* @__PURE__ */ new Set();
3214
3350
  for (const f of result.files) {
3215
- const abs = resolve5(projectRoot, f.path);
3351
+ const abs = resolve6(projectRoot, f.path);
3216
3352
  if (writtenPaths.has(abs)) {
3217
3353
  skipped++;
3218
3354
  continue;
@@ -3227,22 +3363,103 @@ function exportToDisk(state, projectRoot, opts) {
3227
3363
  skipped++;
3228
3364
  continue;
3229
3365
  }
3230
- mkdirSync5(dirname4(abs), { recursive: true });
3231
- writeFileSync4(abs, f.contents, "utf8");
3366
+ writeFileAtomic(abs, f.contents);
3232
3367
  written++;
3233
3368
  }
3234
3369
  }
3235
3370
  return { written, skipped, warnings };
3236
3371
  }
3237
3372
 
3373
+ // src/server/ui-prefs.ts
3374
+ import { readFileSync as readFileSync11 } from "fs";
3375
+ import { homedir } from "os";
3376
+ import { join as join8 } from "path";
3377
+ var THEMES = ["system", "light", "dark"];
3378
+ var isThemeMode = (v) => THEMES.includes(v);
3379
+ var defaultUiPrefsPath = () => join8(homedir(), ".glotfile", "ui.json");
3380
+ var DEFAULTS = { theme: "system" };
3381
+ function readJson(path) {
3382
+ try {
3383
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
3384
+ return parsed && typeof parsed === "object" ? parsed : {};
3385
+ } catch {
3386
+ return {};
3387
+ }
3388
+ }
3389
+ function loadUiPrefs(path) {
3390
+ const raw = readJson(path);
3391
+ return { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
3392
+ }
3393
+ function saveUiPrefs(path, prefs) {
3394
+ const merged = { ...readJson(path), ...prefs };
3395
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3396
+ }
3397
+
3398
+ // src/server/local-settings.ts
3399
+ import { readFileSync as readFileSync12 } from "fs";
3400
+ import { resolve as resolve7 } from "path";
3401
+ var EDITOR_IDS = ["vscode", "zed", "phpstorm"];
3402
+ var isEditorId = (v) => EDITOR_IDS.includes(v);
3403
+ var DEFAULT_AI = {
3404
+ provider: "anthropic",
3405
+ model: "claude-haiku-4-5-20251001",
3406
+ endpoint: null,
3407
+ region: null,
3408
+ batchSize: 25
3409
+ };
3410
+ var DEFAULT_EDITOR = "vscode";
3411
+ var settingsPath = (projectRoot) => resolve7(projectRoot, ".glotfile", "settings.json");
3412
+ function readJson2(path) {
3413
+ try {
3414
+ const parsed = JSON.parse(readFileSync12(path, "utf8"));
3415
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3416
+ } catch {
3417
+ return {};
3418
+ }
3419
+ }
3420
+ function coerceAi(raw) {
3421
+ const a = raw && typeof raw === "object" ? raw : {};
3422
+ return {
3423
+ provider: PROVIDERS.includes(a.provider) ? a.provider : DEFAULT_AI.provider,
3424
+ model: typeof a.model === "string" && a.model ? a.model : DEFAULT_AI.model,
3425
+ endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
3426
+ region: typeof a.region === "string" ? a.region : null,
3427
+ batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize
3428
+ };
3429
+ }
3430
+ function loadLocalSettings(projectRoot) {
3431
+ const raw = readJson2(settingsPath(projectRoot));
3432
+ return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
3433
+ }
3434
+ function saveLocalSettings(projectRoot, patch) {
3435
+ const path = settingsPath(projectRoot);
3436
+ const merged = { ...readJson2(path) };
3437
+ if (patch.ai !== void 0) merged.ai = patch.ai;
3438
+ if (patch.editor !== void 0) merged.editor = patch.editor;
3439
+ ensureGlotfileDir(projectRoot);
3440
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3441
+ }
3442
+ function aiConfigError(ai) {
3443
+ if (!ai || typeof ai !== "object") return "ai must be an object";
3444
+ const a = ai;
3445
+ if (typeof a.provider !== "string" || !PROVIDERS.includes(a.provider)) {
3446
+ return `ai.provider must be one of: ${PROVIDERS.join(", ")}`;
3447
+ }
3448
+ if (typeof a.model !== "string") return "ai.model must be a string";
3449
+ if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
3450
+ if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
3451
+ if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
3452
+ return null;
3453
+ }
3454
+
3238
3455
  // src/server/api.ts
3239
3456
  var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
3240
3457
  var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
3241
3458
  function projectName(root) {
3242
- const nameFile = resolve6(root, ".idea", ".name");
3243
- if (existsSync8(nameFile)) {
3459
+ const nameFile = resolve8(root, ".idea", ".name");
3460
+ if (existsSync9(nameFile)) {
3244
3461
  try {
3245
- const name = readFileSync11(nameFile, "utf8").trim();
3462
+ const name = readFileSync13(nameFile, "utf8").trim();
3246
3463
  if (name) return name;
3247
3464
  } catch {
3248
3465
  }
@@ -3252,7 +3469,7 @@ function projectName(root) {
3252
3469
  function createApi(deps) {
3253
3470
  const app = new Hono();
3254
3471
  const load = () => loadState(deps.statePath);
3255
- const projectRoot = dirname5(resolve6(deps.statePath));
3472
+ const projectRoot = dirname2(resolve8(deps.statePath));
3256
3473
  let translateQueue = Promise.resolve();
3257
3474
  const withTranslateLock = (fn) => {
3258
3475
  const next = translateQueue.then(fn, fn);
@@ -3276,7 +3493,36 @@ function createApi(deps) {
3276
3493
  saveState(deps.statePath, s);
3277
3494
  scheduleAutoExport(s);
3278
3495
  };
3496
+ const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
3497
+ const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
3498
+ const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
3279
3499
  app.get("/state", (c) => c.json(load()));
3500
+ app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
3501
+ app.put("/ui-prefs", async (c) => {
3502
+ const { theme } = await c.req.json();
3503
+ if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
3504
+ saveUiPrefs(uiPrefsPath, { theme });
3505
+ return c.json({ ok: true });
3506
+ });
3507
+ app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
3508
+ app.put("/local-settings", async (c) => {
3509
+ const body = await c.req.json().catch(() => ({}));
3510
+ const patch = {};
3511
+ if (body.ai !== void 0) {
3512
+ const err = aiConfigError(body.ai);
3513
+ if (err) return c.json({ error: err }, 400);
3514
+ patch.ai = body.ai;
3515
+ }
3516
+ if (body.editor !== void 0) {
3517
+ if (!isEditorId(body.editor)) return c.json({ error: "editor must be one of: vscode, zed, phpstorm" }, 400);
3518
+ patch.editor = body.editor;
3519
+ }
3520
+ if (patch.ai === void 0 && patch.editor === void 0) {
3521
+ return c.json({ error: "provide ai and/or editor" }, 400);
3522
+ }
3523
+ saveLocalSettings(projectRoot, patch);
3524
+ return c.json({ ok: true });
3525
+ });
3280
3526
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3281
3527
  app.get("/files", (c) => {
3282
3528
  const found = /* @__PURE__ */ new Map();
@@ -3288,10 +3534,10 @@ function createApi(deps) {
3288
3534
  }
3289
3535
  for (const name of entries) {
3290
3536
  let abs;
3291
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync8(resolve6(projectRoot, name, "config.json"))) {
3292
- abs = resolve6(projectRoot, `${name}.json`);
3537
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve8(projectRoot, name, "config.json"))) {
3538
+ abs = resolve8(projectRoot, `${name}.json`);
3293
3539
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3294
- abs = resolve6(projectRoot, name);
3540
+ abs = resolve8(projectRoot, name);
3295
3541
  } else {
3296
3542
  continue;
3297
3543
  }
@@ -3308,10 +3554,10 @@ function createApi(deps) {
3308
3554
  app.post("/file", async (c) => {
3309
3555
  const { path } = await c.req.json();
3310
3556
  if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
3311
- const resolved = resolve6(projectRoot, path);
3557
+ const resolved = resolve8(projectRoot, path);
3312
3558
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
3313
3559
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
3314
- if (!existsSync8(resolved)) return c.json({ error: "file not found" }, 400);
3560
+ if (!existsSync9(resolved)) return c.json({ error: "file not found" }, 400);
3315
3561
  loadState(resolved);
3316
3562
  deps.statePath = resolved;
3317
3563
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -3326,6 +3572,7 @@ function createApi(deps) {
3326
3572
  const s = load();
3327
3573
  createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
3328
3574
  persist(s);
3575
+ logChange({ kind: "key", summary: `Created key ${key}`, key, after: value });
3329
3576
  console.log(`[key] created ${key}`);
3330
3577
  return c.json({ ok: true });
3331
3578
  });
@@ -3335,37 +3582,45 @@ function createApi(deps) {
3335
3582
  const s = load();
3336
3583
  addCustomWord(s, word);
3337
3584
  persist(s);
3585
+ logChange({ kind: "dictionary", summary: `Added "${word}" to dictionary`, after: word });
3338
3586
  return c.json({ ok: true });
3339
3587
  });
3340
3588
  app.delete("/dictionary/:word", (c) => {
3341
3589
  const s = load();
3342
- removeCustomWord(s, c.req.param("word"));
3590
+ const word = c.req.param("word");
3591
+ removeCustomWord(s, word);
3343
3592
  persist(s);
3593
+ logChange({ kind: "dictionary", summary: `Removed "${word}" from dictionary`, before: word });
3344
3594
  return c.json({ ok: true });
3345
3595
  });
3346
3596
  app.patch("/keys/:key", async (c) => {
3347
3597
  const key = c.req.param("key");
3348
3598
  const body = await c.req.json();
3349
3599
  const s = load();
3600
+ const beforeSource = typeof body.source === "string" ? valueText(s, key, s.config.sourceLocale) : void 0;
3350
3601
  if (typeof body.rename === "string") renameKey(s, key, body.rename);
3351
3602
  const target = typeof body.rename === "string" ? body.rename : key;
3352
3603
  if (body.metadata) setMetadata(s, target, body.metadata);
3353
3604
  if (typeof body.source === "string") setSourceValue(s, target, body.source);
3354
3605
  if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
3355
3606
  persist(s);
3607
+ if (typeof body.rename === "string") logChange({ kind: "key", summary: `Renamed ${key} \u2192 ${body.rename}`, key: target, before: key, after: body.rename });
3608
+ if (body.metadata) logChange({ kind: "metadata", summary: `Updated metadata of ${target}`, key: target, after: body.metadata });
3609
+ if (typeof body.source === "string") logChange({ kind: "translation", summary: `Set source value of ${target}`, key: target, locale: s.config.sourceLocale, before: beforeSource, after: body.source });
3610
+ if (typeof body.pluralArg === "string" && body.pluralArg.trim()) logChange({ kind: "key", summary: `Changed plural arg of ${target}`, key: target, after: body.pluralArg.trim() });
3356
3611
  if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
3357
3612
  return c.json({ ok: true });
3358
3613
  });
3359
3614
  function removeOrphanScreenshot(s, screenshot) {
3360
3615
  if (!screenshot) return;
3361
3616
  for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
3362
- const root = dirname5(resolve6(deps.statePath));
3363
- const abs = resolve6(root, screenshot);
3617
+ const root = dirname2(resolve8(deps.statePath));
3618
+ const abs = resolve8(root, screenshot);
3364
3619
  const rel = relative3(root, abs);
3365
3620
  const seg0 = rel.split(sep)[0] ?? "";
3366
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync8(abs)) {
3621
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync9(abs)) {
3367
3622
  try {
3368
- rmSync3(abs);
3623
+ rmSync4(abs);
3369
3624
  } catch {
3370
3625
  }
3371
3626
  }
@@ -3374,9 +3629,11 @@ function createApi(deps) {
3374
3629
  const s = load();
3375
3630
  const key = c.req.param("key");
3376
3631
  const shot = s.keys[key]?.screenshot;
3632
+ const before = valueText(s, key, s.config.sourceLocale);
3377
3633
  deleteKey(s, key);
3378
3634
  removeOrphanScreenshot(s, shot);
3379
3635
  persist(s);
3636
+ logChange({ kind: "key", summary: `Deleted key ${key}`, key, before });
3380
3637
  console.log(`[key] deleted ${key}`);
3381
3638
  return c.json({ ok: true });
3382
3639
  });
@@ -3400,6 +3657,7 @@ function createApi(deps) {
3400
3657
  }
3401
3658
  }
3402
3659
  persist(s);
3660
+ if (cleared) logChange({ kind: "translation", summary: `Cleared ${cleared} value(s) across ${keys.length} key(s)`, after: { locales } });
3403
3661
  console.log(`[bulk] cleared ${cleared} value(s)`);
3404
3662
  return c.json({ cleared });
3405
3663
  });
@@ -3417,6 +3675,7 @@ function createApi(deps) {
3417
3675
  }
3418
3676
  for (const shot of shots) removeOrphanScreenshot(s, shot);
3419
3677
  persist(s);
3678
+ if (removed.length) logChange({ kind: "key", summary: `Deleted ${removed.length} key(s)`, before: removed });
3420
3679
  console.log(`[bulk] deleted ${removed.length} key(s)`);
3421
3680
  return c.json({ removed });
3422
3681
  });
@@ -3442,6 +3701,7 @@ function createApi(deps) {
3442
3701
  updated++;
3443
3702
  }
3444
3703
  persist(s);
3704
+ if (updated) logChange({ kind: "metadata", summary: `Updated metadata on ${updated} key(s)` });
3445
3705
  console.log(`[bulk] updated metadata on ${updated} key(s)`);
3446
3706
  return c.json({ updated });
3447
3707
  });
@@ -3465,6 +3725,7 @@ function createApi(deps) {
3465
3725
  }
3466
3726
  }
3467
3727
  persist(s);
3728
+ if (updated) logChange({ kind: "translation", summary: `Marked ${updated} value(s) as ${next}`, after: next });
3468
3729
  console.log(`[bulk] set state ${next} on ${updated} value(s)`);
3469
3730
  return c.json({ updated });
3470
3731
  });
@@ -3474,15 +3735,21 @@ function createApi(deps) {
3474
3735
  const s = load();
3475
3736
  const key = c.req.param("key");
3476
3737
  const locale = c.req.param("locale");
3738
+ const before = valueText(s, key, locale);
3477
3739
  if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
3478
3740
  else setTargetValue(s, key, locale, value);
3479
3741
  persist(s);
3742
+ logChange({ kind: "translation", summary: `Set ${locale} value of ${key}`, key, locale, before, after: value });
3480
3743
  return c.json({ ok: true });
3481
3744
  });
3482
3745
  app.delete("/keys/:key/values/:locale", (c) => {
3483
3746
  const s = load();
3484
- clearValue(s, c.req.param("key"), c.req.param("locale"));
3747
+ const key = c.req.param("key");
3748
+ const locale = c.req.param("locale");
3749
+ const before = valueText(s, key, locale);
3750
+ clearValue(s, key, locale);
3485
3751
  persist(s);
3752
+ logChange({ kind: "translation", summary: `Cleared ${locale} value of ${key}`, key, locale, before });
3486
3753
  return c.json({ ok: true });
3487
3754
  });
3488
3755
  app.put("/keys/:key/plural/:locale", async (c) => {
@@ -3491,52 +3758,68 @@ function createApi(deps) {
3491
3758
  const s = load();
3492
3759
  const key = c.req.param("key");
3493
3760
  const locale = c.req.param("locale");
3761
+ const before = s.keys[key]?.values[locale]?.forms;
3494
3762
  if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
3495
3763
  else setPluralForms(s, key, locale, forms);
3496
3764
  persist(s);
3765
+ logChange({ kind: "translation", summary: `Set ${locale} plural forms of ${key}`, key, locale, before, after: forms });
3497
3766
  return c.json({ ok: true });
3498
3767
  });
3499
3768
  app.post("/keys/:key/plural", async (c) => {
3500
3769
  const { arg } = await c.req.json();
3501
3770
  if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
3502
3771
  const s = load();
3503
- convertToPlural(s, c.req.param("key"), arg);
3772
+ const key = c.req.param("key");
3773
+ convertToPlural(s, key, arg);
3504
3774
  persist(s);
3775
+ logChange({ kind: "key", summary: `Converted ${key} to plural`, key, after: arg });
3505
3776
  return c.json({ ok: true });
3506
3777
  });
3507
3778
  app.delete("/keys/:key/plural", (c) => {
3508
3779
  const s = load();
3509
- convertToScalar(s, c.req.param("key"));
3780
+ const key = c.req.param("key");
3781
+ convertToScalar(s, key);
3510
3782
  persist(s);
3783
+ logChange({ kind: "key", summary: `Converted ${key} to scalar`, key });
3511
3784
  return c.json({ ok: true });
3512
3785
  });
3513
3786
  app.put("/keys/:key/values/:locale/state", async (c) => {
3514
3787
  const { state } = await c.req.json();
3515
3788
  const s = load();
3516
- setKeyState(s, c.req.param("key"), c.req.param("locale"), state);
3789
+ const key = c.req.param("key");
3790
+ const locale = c.req.param("locale");
3791
+ const before = s.keys[key]?.values[locale]?.state;
3792
+ setKeyState(s, key, locale, state);
3517
3793
  persist(s);
3794
+ logChange({ kind: "translation", summary: `Marked ${key} ${locale} as ${state}`, key, locale, before, after: state });
3518
3795
  return c.json({ ok: true });
3519
3796
  });
3520
3797
  app.post("/keys/:key/notes", async (c) => {
3521
3798
  const { text } = await c.req.json();
3522
3799
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3523
3800
  const s = load();
3524
- const note = addNote(s, c.req.param("key"), text);
3801
+ const key = c.req.param("key");
3802
+ const note = addNote(s, key, text);
3525
3803
  persist(s);
3804
+ logChange({ kind: "note", summary: `Added note to ${key}`, key, after: text });
3526
3805
  return c.json(note);
3527
3806
  });
3528
3807
  app.put("/keys/:key/notes/:id", async (c) => {
3529
3808
  const { text } = await c.req.json();
3530
3809
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3531
3810
  const s = load();
3532
- editNote(s, c.req.param("key"), c.req.param("id"), text);
3811
+ const key = c.req.param("key");
3812
+ editNote(s, key, c.req.param("id"), text);
3533
3813
  persist(s);
3814
+ logChange({ kind: "note", summary: `Edited note on ${key}`, key, after: text });
3534
3815
  return c.json({ ok: true });
3535
3816
  });
3536
3817
  app.delete("/keys/:key/notes/:id", (c) => {
3537
3818
  const s = load();
3538
- deleteNote(s, c.req.param("key"), c.req.param("id"));
3819
+ const key = c.req.param("key");
3820
+ deleteNote(s, key, c.req.param("id"));
3539
3821
  persist(s);
3822
+ logChange({ kind: "note", summary: `Deleted note on ${key}`, key });
3540
3823
  return c.json({ ok: true });
3541
3824
  });
3542
3825
  app.put("/config", async (c) => {
@@ -3545,6 +3828,7 @@ function createApi(deps) {
3545
3828
  return c.json({ error: "config.locales must be an array" }, 400);
3546
3829
  }
3547
3830
  const s = load();
3831
+ const beforeCfg = { locales: s.config.locales };
3548
3832
  const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
3549
3833
  for (const l of removed) {
3550
3834
  for (const e of Object.values(s.keys)) delete e.values[l];
@@ -3552,7 +3836,8 @@ function createApi(deps) {
3552
3836
  s.config = newConfig;
3553
3837
  validate(s);
3554
3838
  persist(s);
3555
- console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s), model ${newConfig.ai.model}`);
3839
+ logChange({ kind: "config", summary: `Saved config (${newConfig.locales.length} locale(s))`, before: beforeCfg, after: { locales: newConfig.locales } });
3840
+ console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s)`);
3556
3841
  return c.json({ ok: true });
3557
3842
  });
3558
3843
  app.get("/glossary", (c) => c.json(load().glossary));
@@ -3560,14 +3845,19 @@ function createApi(deps) {
3560
3845
  const entry = await c.req.json();
3561
3846
  if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
3562
3847
  const s = load();
3848
+ const before = s.glossary.find((g) => g.term === entry.term);
3563
3849
  upsertGlossaryEntry(s, entry);
3564
3850
  persist(s);
3851
+ logChange({ kind: "glossary", summary: `${before ? "Updated" : "Added"} glossary term "${entry.term}"`, before, after: entry });
3565
3852
  return c.json({ ok: true });
3566
3853
  });
3567
3854
  app.delete("/glossary/:term", (c) => {
3568
3855
  const s = load();
3569
- deleteGlossaryEntry(s, decodeURIComponent(c.req.param("term")));
3856
+ const term = decodeURIComponent(c.req.param("term"));
3857
+ const before = s.glossary.find((g) => g.term === term);
3858
+ deleteGlossaryEntry(s, term);
3570
3859
  persist(s);
3860
+ logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
3571
3861
  return c.json({ ok: true });
3572
3862
  });
3573
3863
  app.post("/keys/:key/screenshot", async (c) => {
@@ -3575,18 +3865,18 @@ function createApi(deps) {
3575
3865
  const body = await c.req.parseBody();
3576
3866
  const file = body["file"];
3577
3867
  if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
3578
- const root = dirname5(resolve6(deps.statePath));
3868
+ const root = dirname2(resolve8(deps.statePath));
3579
3869
  const dirName = screenshotDirName(deps.statePath);
3580
- const dir = resolve6(root, dirName);
3581
- mkdirSync6(dir, { recursive: true });
3870
+ const dir = resolve8(root, dirName);
3582
3871
  const filename = `${sanitize(key)}__${sanitize(file.name)}`;
3583
- writeFileSync5(resolve6(dir, filename), Buffer.from(await file.arrayBuffer()));
3872
+ writeFileAtomic(resolve8(dir, filename), Buffer.from(await file.arrayBuffer()));
3584
3873
  const path = `${dirName}/${filename}`;
3585
3874
  const s = load();
3586
3875
  const prev = s.keys[key]?.screenshot;
3587
3876
  setMetadata(s, key, { screenshot: path });
3588
3877
  if (prev && prev !== path) removeOrphanScreenshot(s, prev);
3589
3878
  persist(s);
3879
+ logChange({ kind: "metadata", summary: `${prev ? "Replaced" : "Added"} screenshot on ${key}`, key, before: prev, after: path });
3590
3880
  return c.json({ path });
3591
3881
  });
3592
3882
  app.delete("/keys/:key/screenshot", (c) => {
@@ -3596,6 +3886,7 @@ function createApi(deps) {
3596
3886
  setMetadata(s, key, { screenshot: void 0 });
3597
3887
  removeOrphanScreenshot(s, shot);
3598
3888
  persist(s);
3889
+ logChange({ kind: "metadata", summary: `Removed screenshot from ${key}`, key, before: shot });
3599
3890
  return c.json({ ok: true });
3600
3891
  });
3601
3892
  app.get("/export/preview", (c) => {
@@ -3640,12 +3931,13 @@ function createApi(deps) {
3640
3931
  return c.json({ error: e.message }, 400);
3641
3932
  }
3642
3933
  persist(result.state);
3934
+ logChange({ kind: "import", summary: `Imported ${result.keyCount} key(s) across ${result.localeCount} locale(s)` });
3643
3935
  console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
3644
3936
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
3645
3937
  });
3646
3938
  app.post("/export", (c) => {
3647
3939
  const s = narrowForExport(load());
3648
- const root = dirname5(resolve6(deps.statePath));
3940
+ const root = dirname2(resolve8(deps.statePath));
3649
3941
  const warnings = [];
3650
3942
  let count = 0;
3651
3943
  for (const output of s.config.outputs) {
@@ -3653,9 +3945,8 @@ function createApi(deps) {
3653
3945
  const result = adapter.export(s, output);
3654
3946
  warnings.push(...result.warnings);
3655
3947
  for (const f of result.files) {
3656
- const abs = resolve6(root, f.path);
3657
- mkdirSync6(dirname5(abs), { recursive: true });
3658
- writeFileSync5(abs, f.contents, "utf8");
3948
+ const abs = resolve8(root, f.path);
3949
+ writeFileAtomic(abs, f.contents);
3659
3950
  count++;
3660
3951
  }
3661
3952
  }
@@ -3674,41 +3965,62 @@ function createApi(deps) {
3674
3965
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
3675
3966
  return;
3676
3967
  }
3968
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3677
3969
  let provider;
3678
3970
  try {
3679
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
3971
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
3680
3972
  } catch (e) {
3681
3973
  await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
3682
3974
  return;
3683
3975
  }
3684
3976
  const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
3685
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
3686
- console.log(`[translate] ${reqs.length} string(s) \u2192 ${s.config.ai.model}`);
3977
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
3978
+ console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
3687
3979
  let totalWritten = 0;
3688
3980
  const allErrors = [];
3689
3981
  const system = buildSystemPrompt();
3690
3982
  const reqById = new Map(reqs.map((r) => [r.id, r]));
3691
- await runLocaleParallel(reqs, provider, (done, total, batchResults) => {
3692
- const { written, errors } = applyResults(s, reqs, batchResults);
3693
- persist(s);
3694
- totalWritten += written;
3695
- allErrors.push(...errors);
3696
- appendAiLog(projectRoot, {
3697
- at: (/* @__PURE__ */ new Date()).toISOString(),
3698
- kind: "translate",
3699
- model: s.config.ai.model,
3700
- system,
3701
- items: batchResults.map((r) => {
3702
- const req = reqById.get(r.id);
3703
- 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 };
3704
- }),
3705
- results: batchResults
3706
- });
3707
- console.log(`[translate] ${done}/${total}`);
3708
- void stream.writeSSE({
3709
- event: "progress",
3710
- data: JSON.stringify({ done, total, written: totalWritten, errors })
3711
- });
3983
+ const localeTotals = /* @__PURE__ */ new Map();
3984
+ for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
3985
+ const localeDone = /* @__PURE__ */ new Map();
3986
+ await stream.writeSSE({
3987
+ event: "start",
3988
+ data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
3989
+ });
3990
+ await runLocaleParallel(reqs, provider, {
3991
+ // Announce a language the moment a worker picks it up — this is the
3992
+ // signal that "something is happening" during the long first LLM call.
3993
+ onLocaleStart: (locale) => {
3994
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
3995
+ },
3996
+ onBatchComplete: (done, total, batchResults, locale) => {
3997
+ const { written, errors } = applyResults(s, reqs, batchResults);
3998
+ persist(s);
3999
+ totalWritten += written;
4000
+ allErrors.push(...errors);
4001
+ appendLog(projectRoot, {
4002
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4003
+ kind: "translate",
4004
+ summary: `Translated ${batchResults.length} item(s)`,
4005
+ model: aiCfg.model,
4006
+ system,
4007
+ items: batchResults.map((r) => {
4008
+ const req = reqById.get(r.id);
4009
+ return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
4010
+ }),
4011
+ results: batchResults
4012
+ });
4013
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
4014
+ localeDone.set(locale, ld);
4015
+ console.log(`[translate] ${done}/${total}`);
4016
+ void stream.writeSSE({
4017
+ event: "progress",
4018
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
4019
+ });
4020
+ },
4021
+ onLocaleDone: (locale) => {
4022
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4023
+ }
3712
4024
  }, void 0, signal);
3713
4025
  if (!signal?.aborted) {
3714
4026
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
@@ -3731,19 +4043,22 @@ function createApi(deps) {
3731
4043
  let written = 0;
3732
4044
  let errors = [];
3733
4045
  if (toTranslate.length) {
4046
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3734
4047
  let provider;
3735
4048
  try {
3736
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4049
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
3737
4050
  } catch (e) {
3738
4051
  return c.json({ error: e.message }, 400);
3739
4052
  }
3740
4053
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
3741
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4054
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
3742
4055
  const results = await runLocaleParallel(toTranslate, provider);
3743
4056
  ({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
3744
4057
  const entry = {
3745
4058
  at: (/* @__PURE__ */ new Date()).toISOString(),
3746
- model: s.config.ai.model,
4059
+ kind: "translate",
4060
+ summary: `Translated ${toTranslate.length} item(s)`,
4061
+ model: aiCfg.model,
3747
4062
  system: buildSystemPrompt(),
3748
4063
  // Log the screenshot PATH only — never the image bytes.
3749
4064
  items: toTranslate.map((r) => ({
@@ -3757,12 +4072,12 @@ function createApi(deps) {
3757
4072
  })),
3758
4073
  results
3759
4074
  };
3760
- appendAiLog(projectRoot, entry);
4075
+ appendLog(projectRoot, entry);
3761
4076
  }
3762
4077
  persist(s);
3763
4078
  return c.json({ requested: reqs.length, written, errors });
3764
4079
  }));
3765
- app.get("/ai-log", (c) => c.json(readAiLog(projectRoot, 100)));
4080
+ app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
3766
4081
  app.post("/scan", async (c) => {
3767
4082
  const s = load();
3768
4083
  const existing = loadUsageCache(projectRoot);
@@ -3786,7 +4101,7 @@ function createApi(deps) {
3786
4101
  const refs = [];
3787
4102
  const prefixRefs = [];
3788
4103
  for (const [file, entry] of Object.entries(cache2.files)) {
3789
- const abs = resolve6(projectRoot, file);
4104
+ const abs = resolve8(projectRoot, file);
3790
4105
  for (const r of entry.refs) {
3791
4106
  if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
3792
4107
  }
@@ -3827,9 +4142,10 @@ function createApi(deps) {
3827
4142
  keys: body.keys
3828
4143
  }, cache2, body.lastRunAt);
3829
4144
  if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4145
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3830
4146
  let provider;
3831
4147
  try {
3832
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4148
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
3833
4149
  } catch (e) {
3834
4150
  return c.json({ error: e.message }, 400);
3835
4151
  }
@@ -3851,10 +4167,11 @@ function createApi(deps) {
3851
4167
  const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
3852
4168
  const batch = raw;
3853
4169
  const { written, errors } = applyContext(s, targets, batch.items ?? []);
3854
- appendAiLog(projectRoot, {
4170
+ appendLog(projectRoot, {
3855
4171
  at: (/* @__PURE__ */ new Date()).toISOString(),
3856
4172
  kind: "context",
3857
- model: s.config.ai.model,
4173
+ summary: `Generated context for ${targets.length} key(s)`,
4174
+ model: aiCfg.model,
3858
4175
  system,
3859
4176
  items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
3860
4177
  results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
@@ -3870,8 +4187,8 @@ function createApi(deps) {
3870
4187
  }
3871
4188
 
3872
4189
  // src/server/server.ts
3873
- var here = dirname6(fileURLToPath(import.meta.url));
3874
- var DEFAULT_UI_DIR = join7(here, "..", "ui");
4190
+ var here = dirname3(fileURLToPath(import.meta.url));
4191
+ var DEFAULT_UI_DIR = join9(here, "..", "ui");
3875
4192
  var MIME = {
3876
4193
  ".html": "text/html; charset=utf-8",
3877
4194
  ".js": "text/javascript; charset=utf-8",
@@ -3900,14 +4217,14 @@ async function readFileResponse(absPath) {
3900
4217
  function buildApp(opts) {
3901
4218
  const app = new Hono2();
3902
4219
  app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
3903
- const projectRoot = dirname6(resolve7(opts.statePath));
4220
+ const projectRoot = dirname3(resolve9(opts.statePath));
3904
4221
  app.get("/:dir/*", async (c, next) => {
3905
4222
  const dirSeg = c.req.param("dir");
3906
4223
  if (!dirSeg.endsWith("-screenshots")) return next();
3907
- const shotsRoot = resolve7(projectRoot, dirSeg);
4224
+ const shotsRoot = resolve9(projectRoot, dirSeg);
3908
4225
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
3909
4226
  const rest = pathname.slice(`/${dirSeg}`.length);
3910
- const target = resolve7(shotsRoot, "." + rest);
4227
+ const target = resolve9(shotsRoot, "." + rest);
3911
4228
  const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
3912
4229
  if (inside) {
3913
4230
  const file = await readFileResponse(target);
@@ -3916,24 +4233,35 @@ function buildApp(opts) {
3916
4233
  return c.notFound();
3917
4234
  });
3918
4235
  if (!opts.dev) {
3919
- const root = resolve7(opts.uiDir ?? DEFAULT_UI_DIR);
4236
+ const root = resolve9(opts.uiDir ?? DEFAULT_UI_DIR);
3920
4237
  app.get("/*", async (c) => {
3921
4238
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
3922
- const target = resolve7(root, "." + pathname);
4239
+ const target = resolve9(root, "." + pathname);
3923
4240
  const inside = target === root || target.startsWith(root + sep2);
3924
4241
  if (inside && pathname !== "/") {
3925
4242
  const file = await readFileResponse(target);
3926
4243
  if (file) return file;
3927
4244
  }
3928
- const index = await readFileResponse(join7(root, "index.html"));
4245
+ const index = await readFileResponse(join9(root, "index.html"));
3929
4246
  if (index) return index;
3930
4247
  return c.notFound();
3931
4248
  });
4249
+ } else {
4250
+ app.get("/", (c) => c.html(DEV_LANDING_PAGE));
3932
4251
  }
3933
4252
  return app;
3934
4253
  }
3935
4254
  var DEFAULT_PORT = 3e3;
3936
4255
  var DEV_PORT = 8787;
4256
+ var DEV_UI_URL = "http://localhost:5173";
4257
+ var DEV_LANDING_PAGE = `<!doctype html>
4258
+ <html lang="en"><head><meta charset="utf-8"><title>Glotfile \u2014 dev API</title>
4259
+ <style>body{font:16px/1.6 system-ui,sans-serif;max-width:34rem;margin:16vh auto;padding:0 1.5rem;color:#1f2937}h1{font-size:1.4rem}a{color:#2563eb}code{background:#f3f4f6;padding:.1em .35em;border-radius:.3em}</style>
4260
+ </head><body>
4261
+ <h1>Glotfile \u2014 dev API server</h1>
4262
+ <p>This port serves the <strong>API only</strong>. In dev, the app is served by Vite.</p>
4263
+ <p>Open the app \u2192 <a href="${DEV_UI_URL}">${DEV_UI_URL}</a> (the <code>[ui] Local:</code> URL in your terminal).</p>
4264
+ </body></html>`;
3937
4265
  function findAvailablePort(start) {
3938
4266
  return new Promise((resolveP, reject) => {
3939
4267
  const probe = createServer();
@@ -3962,7 +4290,7 @@ async function startServer(opts) {
3962
4290
  });
3963
4291
  }
3964
4292
  function backgroundScan(statePath) {
3965
- const projectRoot = dirname6(resolve7(statePath));
4293
+ const projectRoot = dirname3(resolve9(statePath));
3966
4294
  Promise.resolve().then(() => {
3967
4295
  const state = loadState(statePath);
3968
4296
  const existing = loadUsageCache(projectRoot);