@llm-translate/cli 1.0.0-next.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.
Files changed (157) hide show
  1. package/.dockerignore +51 -0
  2. package/.env.example +33 -0
  3. package/.github/workflows/docs-pages.yml +57 -0
  4. package/.github/workflows/release.yml +49 -0
  5. package/.translaterc.json +44 -0
  6. package/CLAUDE.md +243 -0
  7. package/Dockerfile +55 -0
  8. package/README.md +371 -0
  9. package/RFC.md +1595 -0
  10. package/dist/cli/index.d.ts +2 -0
  11. package/dist/cli/index.js +4494 -0
  12. package/dist/cli/index.js.map +1 -0
  13. package/dist/index.d.ts +1152 -0
  14. package/dist/index.js +3841 -0
  15. package/dist/index.js.map +1 -0
  16. package/docker-compose.yml +56 -0
  17. package/docs/.vitepress/config.ts +161 -0
  18. package/docs/api/agent.md +262 -0
  19. package/docs/api/engine.md +274 -0
  20. package/docs/api/index.md +171 -0
  21. package/docs/api/providers.md +304 -0
  22. package/docs/changelog.md +64 -0
  23. package/docs/cli/dir.md +243 -0
  24. package/docs/cli/file.md +213 -0
  25. package/docs/cli/glossary.md +273 -0
  26. package/docs/cli/index.md +129 -0
  27. package/docs/cli/init.md +158 -0
  28. package/docs/cli/serve.md +211 -0
  29. package/docs/glossary.json +235 -0
  30. package/docs/guide/chunking.md +272 -0
  31. package/docs/guide/configuration.md +139 -0
  32. package/docs/guide/cost-optimization.md +237 -0
  33. package/docs/guide/docker.md +371 -0
  34. package/docs/guide/getting-started.md +150 -0
  35. package/docs/guide/glossary.md +241 -0
  36. package/docs/guide/index.md +86 -0
  37. package/docs/guide/ollama.md +515 -0
  38. package/docs/guide/prompt-caching.md +221 -0
  39. package/docs/guide/providers.md +232 -0
  40. package/docs/guide/quality-control.md +206 -0
  41. package/docs/guide/vitepress-integration.md +265 -0
  42. package/docs/index.md +63 -0
  43. package/docs/ja/api/agent.md +262 -0
  44. package/docs/ja/api/engine.md +274 -0
  45. package/docs/ja/api/index.md +171 -0
  46. package/docs/ja/api/providers.md +304 -0
  47. package/docs/ja/changelog.md +64 -0
  48. package/docs/ja/cli/dir.md +243 -0
  49. package/docs/ja/cli/file.md +213 -0
  50. package/docs/ja/cli/glossary.md +273 -0
  51. package/docs/ja/cli/index.md +111 -0
  52. package/docs/ja/cli/init.md +158 -0
  53. package/docs/ja/guide/chunking.md +271 -0
  54. package/docs/ja/guide/configuration.md +139 -0
  55. package/docs/ja/guide/cost-optimization.md +30 -0
  56. package/docs/ja/guide/getting-started.md +150 -0
  57. package/docs/ja/guide/glossary.md +214 -0
  58. package/docs/ja/guide/index.md +32 -0
  59. package/docs/ja/guide/ollama.md +410 -0
  60. package/docs/ja/guide/prompt-caching.md +221 -0
  61. package/docs/ja/guide/providers.md +232 -0
  62. package/docs/ja/guide/quality-control.md +137 -0
  63. package/docs/ja/guide/vitepress-integration.md +265 -0
  64. package/docs/ja/index.md +58 -0
  65. package/docs/ko/api/agent.md +262 -0
  66. package/docs/ko/api/engine.md +274 -0
  67. package/docs/ko/api/index.md +171 -0
  68. package/docs/ko/api/providers.md +304 -0
  69. package/docs/ko/changelog.md +64 -0
  70. package/docs/ko/cli/dir.md +243 -0
  71. package/docs/ko/cli/file.md +213 -0
  72. package/docs/ko/cli/glossary.md +273 -0
  73. package/docs/ko/cli/index.md +111 -0
  74. package/docs/ko/cli/init.md +158 -0
  75. package/docs/ko/guide/chunking.md +271 -0
  76. package/docs/ko/guide/configuration.md +139 -0
  77. package/docs/ko/guide/cost-optimization.md +30 -0
  78. package/docs/ko/guide/getting-started.md +150 -0
  79. package/docs/ko/guide/glossary.md +214 -0
  80. package/docs/ko/guide/index.md +32 -0
  81. package/docs/ko/guide/ollama.md +410 -0
  82. package/docs/ko/guide/prompt-caching.md +221 -0
  83. package/docs/ko/guide/providers.md +232 -0
  84. package/docs/ko/guide/quality-control.md +137 -0
  85. package/docs/ko/guide/vitepress-integration.md +265 -0
  86. package/docs/ko/index.md +58 -0
  87. package/docs/zh/api/agent.md +262 -0
  88. package/docs/zh/api/engine.md +274 -0
  89. package/docs/zh/api/index.md +171 -0
  90. package/docs/zh/api/providers.md +304 -0
  91. package/docs/zh/changelog.md +64 -0
  92. package/docs/zh/cli/dir.md +243 -0
  93. package/docs/zh/cli/file.md +213 -0
  94. package/docs/zh/cli/glossary.md +273 -0
  95. package/docs/zh/cli/index.md +111 -0
  96. package/docs/zh/cli/init.md +158 -0
  97. package/docs/zh/guide/chunking.md +271 -0
  98. package/docs/zh/guide/configuration.md +139 -0
  99. package/docs/zh/guide/cost-optimization.md +30 -0
  100. package/docs/zh/guide/getting-started.md +150 -0
  101. package/docs/zh/guide/glossary.md +214 -0
  102. package/docs/zh/guide/index.md +32 -0
  103. package/docs/zh/guide/ollama.md +410 -0
  104. package/docs/zh/guide/prompt-caching.md +221 -0
  105. package/docs/zh/guide/providers.md +232 -0
  106. package/docs/zh/guide/quality-control.md +137 -0
  107. package/docs/zh/guide/vitepress-integration.md +265 -0
  108. package/docs/zh/index.md +58 -0
  109. package/package.json +91 -0
  110. package/release.config.mjs +15 -0
  111. package/schemas/glossary.schema.json +110 -0
  112. package/src/cli/commands/dir.ts +469 -0
  113. package/src/cli/commands/file.ts +291 -0
  114. package/src/cli/commands/glossary.ts +221 -0
  115. package/src/cli/commands/init.ts +68 -0
  116. package/src/cli/commands/serve.ts +60 -0
  117. package/src/cli/index.ts +64 -0
  118. package/src/cli/options.ts +59 -0
  119. package/src/core/agent.ts +1119 -0
  120. package/src/core/chunker.ts +391 -0
  121. package/src/core/engine.ts +634 -0
  122. package/src/errors.ts +188 -0
  123. package/src/index.ts +147 -0
  124. package/src/integrations/vitepress.ts +549 -0
  125. package/src/parsers/markdown.ts +383 -0
  126. package/src/providers/claude.ts +259 -0
  127. package/src/providers/interface.ts +109 -0
  128. package/src/providers/ollama.ts +379 -0
  129. package/src/providers/openai.ts +308 -0
  130. package/src/providers/registry.ts +153 -0
  131. package/src/server/index.ts +152 -0
  132. package/src/server/middleware/auth.ts +93 -0
  133. package/src/server/middleware/logger.ts +90 -0
  134. package/src/server/routes/health.ts +84 -0
  135. package/src/server/routes/translate.ts +210 -0
  136. package/src/server/types.ts +138 -0
  137. package/src/services/cache.ts +899 -0
  138. package/src/services/config.ts +217 -0
  139. package/src/services/glossary.ts +247 -0
  140. package/src/types/analysis.ts +164 -0
  141. package/src/types/index.ts +265 -0
  142. package/src/types/modes.ts +121 -0
  143. package/src/types/mqm.ts +157 -0
  144. package/src/utils/logger.ts +141 -0
  145. package/src/utils/tokens.ts +116 -0
  146. package/tests/fixtures/glossaries/ml-glossary.json +53 -0
  147. package/tests/fixtures/input/lynq-installation.ko.md +350 -0
  148. package/tests/fixtures/input/lynq-installation.md +350 -0
  149. package/tests/fixtures/input/simple.ko.md +27 -0
  150. package/tests/fixtures/input/simple.md +27 -0
  151. package/tests/unit/chunker.test.ts +229 -0
  152. package/tests/unit/glossary.test.ts +146 -0
  153. package/tests/unit/markdown.test.ts +205 -0
  154. package/tests/unit/tokens.test.ts +81 -0
  155. package/tsconfig.json +28 -0
  156. package/tsup.config.ts +34 -0
  157. package/vitest.config.ts +16 -0
@@ -0,0 +1,899 @@
1
+ /**
2
+ * Translation Cache Service
3
+ *
4
+ * File-based caching using content hashes to avoid re-translating unchanged content.
5
+ * Cache entries are indexed by a composite key of content hash, target language,
6
+ * glossary hash, provider, and model.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs';
11
+ import { join, dirname } from 'node:path';
12
+ import type { CacheEntry, CacheIndex } from '../types/index.js';
13
+ import { logger } from '../utils/logger.js';
14
+
15
+ // ============================================================================
16
+ // Constants
17
+ // ============================================================================
18
+
19
+ const CACHE_VERSION = '1.0';
20
+ const INDEX_FILE = 'index.json';
21
+ const ENTRIES_DIR = 'entries';
22
+
23
+ // ============================================================================
24
+ // Invalidation Policy Types
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Context provided to invalidation policies for evaluation
29
+ */
30
+ export interface InvalidationContext {
31
+ /** Current glossary hash (if glossary is used) */
32
+ glossaryHash?: string;
33
+ /** Previous glossary hash (stored in cache metadata) */
34
+ previousGlossaryHash?: string;
35
+ /** Provider name */
36
+ provider?: string;
37
+ /** Model name */
38
+ model?: string;
39
+ /** Cache entry creation timestamp */
40
+ entryCreatedAt?: string;
41
+ /** Current timestamp */
42
+ currentTime?: Date;
43
+ /** Custom metadata for policy-specific checks */
44
+ metadata?: Record<string, unknown>;
45
+ }
46
+
47
+ /**
48
+ * Result of an invalidation check
49
+ */
50
+ export interface InvalidationResult {
51
+ /** Whether invalidation should occur */
52
+ shouldInvalidate: boolean;
53
+ /** Reason for invalidation (for logging) */
54
+ reason?: string;
55
+ /** Scope of invalidation */
56
+ scope: 'all' | 'matching' | 'none';
57
+ /** Filter function for 'matching' scope - returns true if entry should be invalidated */
58
+ filter?: (entry: CacheEntry, key: string) => boolean;
59
+ }
60
+
61
+ /**
62
+ * Abstract invalidation policy interface
63
+ */
64
+ export interface InvalidationPolicy {
65
+ /** Policy name for logging */
66
+ readonly name: string;
67
+ /** Check if cache should be invalidated */
68
+ check(context: InvalidationContext): InvalidationResult;
69
+ }
70
+
71
+ // ============================================================================
72
+ // Built-in Invalidation Policies
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Invalidates all cache entries when glossary changes
77
+ */
78
+ export class GlossaryChangePolicy implements InvalidationPolicy {
79
+ readonly name = 'GlossaryChangePolicy';
80
+
81
+ private readonly mode: 'all' | 'matching';
82
+
83
+ /**
84
+ * @param mode - 'all' invalidates entire cache, 'matching' only entries with old glossary hash
85
+ */
86
+ constructor(mode: 'all' | 'matching' = 'all') {
87
+ this.mode = mode;
88
+ }
89
+
90
+ check(context: InvalidationContext): InvalidationResult {
91
+ const { glossaryHash, previousGlossaryHash } = context;
92
+
93
+ // No glossary or no previous hash - no invalidation needed
94
+ if (!glossaryHash || !previousGlossaryHash) {
95
+ return { shouldInvalidate: false, scope: 'none' };
96
+ }
97
+
98
+ // Glossary hasn't changed
99
+ if (glossaryHash === previousGlossaryHash) {
100
+ return { shouldInvalidate: false, scope: 'none' };
101
+ }
102
+
103
+ // Glossary changed
104
+ if (this.mode === 'all') {
105
+ return {
106
+ shouldInvalidate: true,
107
+ reason: `Glossary changed (${previousGlossaryHash.slice(0, 8)} → ${glossaryHash.slice(0, 8)})`,
108
+ scope: 'all',
109
+ };
110
+ }
111
+
112
+ // Matching mode - only invalidate entries with old glossary hash
113
+ return {
114
+ shouldInvalidate: true,
115
+ reason: `Glossary changed, invalidating matching entries`,
116
+ scope: 'matching',
117
+ filter: (entry) => entry.glossaryHash === previousGlossaryHash,
118
+ };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Invalidates cache entries older than specified TTL
124
+ */
125
+ export class TTLPolicy implements InvalidationPolicy {
126
+ readonly name = 'TTLPolicy';
127
+
128
+ private readonly ttlMs: number;
129
+
130
+ /**
131
+ * @param ttlMs - Time-to-live in milliseconds
132
+ */
133
+ constructor(ttlMs: number) {
134
+ this.ttlMs = ttlMs;
135
+ }
136
+
137
+ /**
138
+ * Create policy with TTL in hours
139
+ */
140
+ static hours(hours: number): TTLPolicy {
141
+ return new TTLPolicy(hours * 60 * 60 * 1000);
142
+ }
143
+
144
+ /**
145
+ * Create policy with TTL in days
146
+ */
147
+ static days(days: number): TTLPolicy {
148
+ return new TTLPolicy(days * 24 * 60 * 60 * 1000);
149
+ }
150
+
151
+ check(context: InvalidationContext): InvalidationResult {
152
+ const currentTime = context.currentTime ?? new Date();
153
+ const ttlMs = this.ttlMs;
154
+
155
+ return {
156
+ shouldInvalidate: true,
157
+ reason: `TTL check (${this.ttlMs}ms)`,
158
+ scope: 'matching',
159
+ filter: (entry) => {
160
+ const createdAt = new Date(entry.createdAt);
161
+ const age = currentTime.getTime() - createdAt.getTime();
162
+ return age > ttlMs;
163
+ },
164
+ };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Invalidates cache entries when provider or model changes
170
+ */
171
+ export class ProviderChangePolicy implements InvalidationPolicy {
172
+ readonly name = 'ProviderChangePolicy';
173
+
174
+ private readonly checkModel: boolean;
175
+
176
+ /**
177
+ * @param checkModel - If true, also invalidate when model changes within same provider
178
+ */
179
+ constructor(checkModel: boolean = true) {
180
+ this.checkModel = checkModel;
181
+ }
182
+
183
+ check(context: InvalidationContext): InvalidationResult {
184
+ const { provider, model } = context;
185
+
186
+ if (!provider) {
187
+ return { shouldInvalidate: false, scope: 'none' };
188
+ }
189
+
190
+ return {
191
+ shouldInvalidate: true,
192
+ reason: `Provider/model mismatch check`,
193
+ scope: 'matching',
194
+ filter: (entry) => {
195
+ if (entry.provider !== provider) return true;
196
+ if (this.checkModel && model && entry.model !== model) return true;
197
+ return false;
198
+ },
199
+ };
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Invalidates cache entries below a quality threshold
205
+ */
206
+ export class QualityThresholdPolicy implements InvalidationPolicy {
207
+ readonly name = 'QualityThresholdPolicy';
208
+
209
+ private readonly threshold: number;
210
+
211
+ /**
212
+ * @param threshold - Minimum quality score (0-100)
213
+ */
214
+ constructor(threshold: number) {
215
+ this.threshold = threshold;
216
+ }
217
+
218
+ check(_context: InvalidationContext): InvalidationResult {
219
+ const threshold = this.threshold;
220
+
221
+ return {
222
+ shouldInvalidate: true,
223
+ reason: `Quality below threshold (${threshold})`,
224
+ scope: 'matching',
225
+ filter: (entry) => entry.qualityScore < threshold,
226
+ };
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Combines multiple policies - invalidates if ANY policy triggers
232
+ */
233
+ export class CompositePolicy implements InvalidationPolicy {
234
+ readonly name = 'CompositePolicy';
235
+
236
+ private readonly policies: InvalidationPolicy[];
237
+ private readonly mode: 'any' | 'all';
238
+
239
+ /**
240
+ * @param policies - Array of policies to combine
241
+ * @param mode - 'any' triggers if any policy matches, 'all' requires all policies to match
242
+ */
243
+ constructor(policies: InvalidationPolicy[], mode: 'any' | 'all' = 'any') {
244
+ this.policies = policies;
245
+ this.mode = mode;
246
+ }
247
+
248
+ check(context: InvalidationContext): InvalidationResult {
249
+ const results = this.policies.map((p) => ({
250
+ policy: p,
251
+ result: p.check(context),
252
+ }));
253
+
254
+ // Collect all filters that want to invalidate
255
+ const activeResults = results.filter((r) => r.result.shouldInvalidate);
256
+
257
+ if (activeResults.length === 0) {
258
+ return { shouldInvalidate: false, scope: 'none' };
259
+ }
260
+
261
+ // Check for 'all' scope - takes priority
262
+ const allScope = activeResults.find((r) => r.result.scope === 'all');
263
+ if (allScope) {
264
+ return {
265
+ shouldInvalidate: true,
266
+ reason: `${allScope.policy.name}: ${allScope.result.reason}`,
267
+ scope: 'all',
268
+ };
269
+ }
270
+
271
+ // Combine matching filters
272
+ const filters = activeResults
273
+ .filter((r) => r.result.filter)
274
+ .map((r) => r.result.filter!);
275
+
276
+ if (filters.length === 0) {
277
+ return { shouldInvalidate: false, scope: 'none' };
278
+ }
279
+
280
+ const reasons = activeResults.map((r) => `${r.policy.name}`).join(', ');
281
+
282
+ if (this.mode === 'any') {
283
+ return {
284
+ shouldInvalidate: true,
285
+ reason: `Composite (any): ${reasons}`,
286
+ scope: 'matching',
287
+ filter: (entry, key) => filters.some((f) => f(entry, key)),
288
+ };
289
+ } else {
290
+ return {
291
+ shouldInvalidate: true,
292
+ reason: `Composite (all): ${reasons}`,
293
+ scope: 'matching',
294
+ filter: (entry, key) => filters.every((f) => f(entry, key)),
295
+ };
296
+ }
297
+ }
298
+ }
299
+
300
+ // ============================================================================
301
+ // Types
302
+ // ============================================================================
303
+
304
+ export interface CacheOptions {
305
+ /** Cache directory path */
306
+ cacheDir: string;
307
+ /** Enable verbose logging */
308
+ verbose?: boolean;
309
+ /** Invalidation policies to apply */
310
+ invalidationPolicies?: InvalidationPolicy[];
311
+ }
312
+
313
+ export interface CacheKey {
314
+ /** Source content (will be hashed) */
315
+ content: string;
316
+ /** Source language */
317
+ sourceLang: string;
318
+ /** Target language */
319
+ targetLang: string;
320
+ /** Glossary content or hash (will be hashed if string) */
321
+ glossary?: string;
322
+ /** Provider name */
323
+ provider: string;
324
+ /** Model name */
325
+ model: string;
326
+ }
327
+
328
+ export interface CacheStats {
329
+ /** Total number of cached entries */
330
+ entries: number;
331
+ /** Total cache size in bytes */
332
+ sizeBytes: number;
333
+ /** Cache version */
334
+ version: string;
335
+ /** Number of entries invalidated by policies */
336
+ invalidated?: number;
337
+ }
338
+
339
+ /**
340
+ * Cache metadata stored separately for tracking state across sessions
341
+ */
342
+ export interface CacheMetadata {
343
+ /** Last known glossary hash */
344
+ glossaryHash?: string;
345
+ /** Last known provider */
346
+ provider?: string;
347
+ /** Last known model */
348
+ model?: string;
349
+ /** Last invalidation timestamp */
350
+ lastInvalidation?: string;
351
+ /** Custom metadata */
352
+ custom?: Record<string, unknown>;
353
+ }
354
+
355
+ export interface CacheResult {
356
+ /** Whether the entry was found in cache */
357
+ hit: boolean;
358
+ /** The cached entry if found */
359
+ entry?: CacheEntry;
360
+ }
361
+
362
+ // ============================================================================
363
+ // Hash Utilities
364
+ // ============================================================================
365
+
366
+ /**
367
+ * Generate SHA-256 hash of content
368
+ */
369
+ export function hashContent(content: string): string {
370
+ return createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 16);
371
+ }
372
+
373
+ /**
374
+ * Generate cache key from components
375
+ */
376
+ export function generateCacheKey(key: CacheKey): string {
377
+ const contentHash = hashContent(key.content);
378
+ const glossaryHash = key.glossary ? hashContent(key.glossary) : 'none';
379
+
380
+ // Create composite key: content_source_target_glossary_provider_model
381
+ return `${contentHash}_${key.sourceLang}_${key.targetLang}_${glossaryHash}_${key.provider}_${key.model}`;
382
+ }
383
+
384
+ // ============================================================================
385
+ // Cache Manager
386
+ // ============================================================================
387
+
388
+ export class CacheManager {
389
+ private cacheDir: string;
390
+ private indexPath: string;
391
+ private entriesDir: string;
392
+ private metadataPath: string;
393
+ private verbose: boolean;
394
+ private index: CacheIndex | null = null;
395
+ private policies: InvalidationPolicy[];
396
+ private metadata: CacheMetadata | null = null;
397
+
398
+ constructor(options: CacheOptions) {
399
+ this.cacheDir = options.cacheDir;
400
+ this.indexPath = join(this.cacheDir, INDEX_FILE);
401
+ this.entriesDir = join(this.cacheDir, ENTRIES_DIR);
402
+ this.metadataPath = join(this.cacheDir, 'metadata.json');
403
+ this.verbose = options.verbose ?? false;
404
+ this.policies = options.invalidationPolicies ?? [];
405
+ }
406
+
407
+ /**
408
+ * Initialize cache directory and load index
409
+ */
410
+ private ensureInitialized(): void {
411
+ if (this.index !== null) return;
412
+
413
+ // Create cache directories if needed
414
+ if (!existsSync(this.cacheDir)) {
415
+ mkdirSync(this.cacheDir, { recursive: true });
416
+ }
417
+ if (!existsSync(this.entriesDir)) {
418
+ mkdirSync(this.entriesDir, { recursive: true });
419
+ }
420
+
421
+ // Load or create index
422
+ if (existsSync(this.indexPath)) {
423
+ try {
424
+ const data = readFileSync(this.indexPath, 'utf-8');
425
+ this.index = JSON.parse(data) as CacheIndex;
426
+
427
+ // Check version compatibility
428
+ if (this.index.version !== CACHE_VERSION) {
429
+ if (this.verbose) {
430
+ logger.warn(`Cache version mismatch (${this.index.version} vs ${CACHE_VERSION}), clearing cache`);
431
+ }
432
+ this.clearSync();
433
+ this.index = { version: CACHE_VERSION, entries: {} };
434
+ }
435
+ } catch {
436
+ if (this.verbose) {
437
+ logger.warn('Failed to load cache index, creating new one');
438
+ }
439
+ this.index = { version: CACHE_VERSION, entries: {} };
440
+ }
441
+ } else {
442
+ this.index = { version: CACHE_VERSION, entries: {} };
443
+ }
444
+
445
+ // Load metadata
446
+ this.loadMetadata();
447
+ }
448
+
449
+ /**
450
+ * Load cache metadata from disk
451
+ */
452
+ private loadMetadata(): void {
453
+ if (existsSync(this.metadataPath)) {
454
+ try {
455
+ const data = readFileSync(this.metadataPath, 'utf-8');
456
+ this.metadata = JSON.parse(data) as CacheMetadata;
457
+ } catch {
458
+ this.metadata = {};
459
+ }
460
+ } else {
461
+ this.metadata = {};
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Save cache metadata to disk
467
+ */
468
+ private saveMetadata(): void {
469
+ if (!this.metadata) return;
470
+
471
+ try {
472
+ writeFileSync(this.metadataPath, JSON.stringify(this.metadata, null, 2), 'utf-8');
473
+ } catch (error) {
474
+ if (this.verbose) {
475
+ logger.error(`Failed to save cache metadata: ${error}`);
476
+ }
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Update cache metadata
482
+ */
483
+ updateMetadata(updates: Partial<CacheMetadata>): void {
484
+ this.ensureInitialized();
485
+ this.metadata = { ...this.metadata, ...updates };
486
+ this.saveMetadata();
487
+ }
488
+
489
+ /**
490
+ * Get current cache metadata
491
+ */
492
+ getMetadata(): CacheMetadata {
493
+ this.ensureInitialized();
494
+ return { ...this.metadata! };
495
+ }
496
+
497
+ /**
498
+ * Apply all configured invalidation policies
499
+ * @returns Number of entries invalidated
500
+ */
501
+ applyPolicies(context: InvalidationContext): number {
502
+ this.ensureInitialized();
503
+
504
+ if (this.policies.length === 0) {
505
+ return 0;
506
+ }
507
+
508
+ let totalInvalidated = 0;
509
+
510
+ for (const policy of this.policies) {
511
+ const result = policy.check({
512
+ ...context,
513
+ previousGlossaryHash: this.metadata?.glossaryHash,
514
+ currentTime: context.currentTime ?? new Date(),
515
+ });
516
+
517
+ if (!result.shouldInvalidate) {
518
+ continue;
519
+ }
520
+
521
+ if (this.verbose) {
522
+ logger.info(`Applying ${policy.name}: ${result.reason}`);
523
+ }
524
+
525
+ if (result.scope === 'all') {
526
+ const count = Object.keys(this.index!.entries).length;
527
+ this.clear();
528
+ totalInvalidated += count;
529
+ break; // No need to check other policies after full clear
530
+ }
531
+
532
+ if (result.scope === 'matching' && result.filter) {
533
+ const invalidated = this.invalidateMatching(result.filter);
534
+ totalInvalidated += invalidated;
535
+ }
536
+ }
537
+
538
+ // Update metadata after applying policies
539
+ if (context.glossaryHash) {
540
+ this.metadata!.glossaryHash = context.glossaryHash;
541
+ }
542
+ if (context.provider) {
543
+ this.metadata!.provider = context.provider;
544
+ }
545
+ if (context.model) {
546
+ this.metadata!.model = context.model;
547
+ }
548
+ if (totalInvalidated > 0) {
549
+ this.metadata!.lastInvalidation = new Date().toISOString();
550
+ }
551
+ this.saveMetadata();
552
+
553
+ return totalInvalidated;
554
+ }
555
+
556
+ /**
557
+ * Invalidate entries matching a filter function
558
+ * @returns Number of entries invalidated
559
+ */
560
+ invalidateMatching(filter: (entry: CacheEntry, key: string) => boolean): number {
561
+ this.ensureInitialized();
562
+
563
+ const keysToDelete: string[] = [];
564
+
565
+ for (const [key, entry] of Object.entries(this.index!.entries)) {
566
+ if (filter(entry, key)) {
567
+ keysToDelete.push(key);
568
+ }
569
+ }
570
+
571
+ for (const key of keysToDelete) {
572
+ // Remove entry file
573
+ const entryPath = join(this.entriesDir, `${key}.json`);
574
+ try {
575
+ if (existsSync(entryPath)) {
576
+ rmSync(entryPath);
577
+ }
578
+ } catch {
579
+ // Ignore file deletion errors
580
+ }
581
+
582
+ // Remove from index
583
+ delete this.index!.entries[key];
584
+ }
585
+
586
+ if (keysToDelete.length > 0) {
587
+ this.saveIndex();
588
+ if (this.verbose) {
589
+ logger.info(`Invalidated ${keysToDelete.length} cache entries`);
590
+ }
591
+ }
592
+
593
+ return keysToDelete.length;
594
+ }
595
+
596
+ /**
597
+ * Add an invalidation policy at runtime
598
+ */
599
+ addPolicy(policy: InvalidationPolicy): void {
600
+ this.policies.push(policy);
601
+ }
602
+
603
+ /**
604
+ * Remove an invalidation policy by name
605
+ */
606
+ removePolicy(name: string): boolean {
607
+ const index = this.policies.findIndex((p) => p.name === name);
608
+ if (index !== -1) {
609
+ this.policies.splice(index, 1);
610
+ return true;
611
+ }
612
+ return false;
613
+ }
614
+
615
+ /**
616
+ * Get all configured policies
617
+ */
618
+ getPolicies(): InvalidationPolicy[] {
619
+ return [...this.policies];
620
+ }
621
+
622
+ /**
623
+ * Save index to disk
624
+ */
625
+ private saveIndex(): void {
626
+ if (!this.index) return;
627
+
628
+ try {
629
+ const dir = dirname(this.indexPath);
630
+ if (!existsSync(dir)) {
631
+ mkdirSync(dir, { recursive: true });
632
+ }
633
+ writeFileSync(this.indexPath, JSON.stringify(this.index, null, 2), 'utf-8');
634
+ } catch (error) {
635
+ if (this.verbose) {
636
+ logger.error(`Failed to save cache index: ${error}`);
637
+ }
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Get cached translation if available
643
+ */
644
+ get(key: CacheKey): CacheResult {
645
+ this.ensureInitialized();
646
+
647
+ const cacheKey = generateCacheKey(key);
648
+ const entry = this.index!.entries[cacheKey];
649
+
650
+ if (entry) {
651
+ // Verify the entry file exists
652
+ const entryPath = join(this.entriesDir, `${cacheKey}.json`);
653
+ if (existsSync(entryPath)) {
654
+ if (this.verbose) {
655
+ logger.info(`Cache hit: ${cacheKey.slice(0, 20)}...`);
656
+ }
657
+ return { hit: true, entry };
658
+ } else {
659
+ // Entry in index but file missing, remove from index
660
+ delete this.index!.entries[cacheKey];
661
+ this.saveIndex();
662
+ }
663
+ }
664
+
665
+ if (this.verbose) {
666
+ logger.debug(`Cache miss: ${cacheKey.slice(0, 20)}...`);
667
+ }
668
+ return { hit: false };
669
+ }
670
+
671
+ /**
672
+ * Store translation in cache
673
+ */
674
+ set(key: CacheKey, translation: string, qualityScore: number): void {
675
+ this.ensureInitialized();
676
+
677
+ const cacheKey = generateCacheKey(key);
678
+ const contentHash = hashContent(key.content);
679
+ const glossaryHash = key.glossary ? hashContent(key.glossary) : '';
680
+
681
+ const entry: CacheEntry = {
682
+ sourceHash: contentHash,
683
+ sourceLang: key.sourceLang,
684
+ targetLang: key.targetLang,
685
+ glossaryHash,
686
+ translation,
687
+ qualityScore,
688
+ createdAt: new Date().toISOString(),
689
+ provider: key.provider,
690
+ model: key.model,
691
+ };
692
+
693
+ // Save entry file
694
+ const entryPath = join(this.entriesDir, `${cacheKey}.json`);
695
+ try {
696
+ writeFileSync(entryPath, JSON.stringify(entry, null, 2), 'utf-8');
697
+
698
+ // Update index
699
+ this.index!.entries[cacheKey] = entry;
700
+ this.saveIndex();
701
+
702
+ if (this.verbose) {
703
+ logger.info(`Cached: ${cacheKey.slice(0, 20)}...`);
704
+ }
705
+ } catch (error) {
706
+ if (this.verbose) {
707
+ logger.error(`Failed to cache entry: ${error}`);
708
+ }
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Check if entry exists in cache
714
+ */
715
+ has(key: CacheKey): boolean {
716
+ return this.get(key).hit;
717
+ }
718
+
719
+ /**
720
+ * Remove entry from cache
721
+ */
722
+ delete(key: CacheKey): boolean {
723
+ this.ensureInitialized();
724
+
725
+ const cacheKey = generateCacheKey(key);
726
+
727
+ if (this.index!.entries[cacheKey]) {
728
+ // Remove entry file
729
+ const entryPath = join(this.entriesDir, `${cacheKey}.json`);
730
+ try {
731
+ if (existsSync(entryPath)) {
732
+ rmSync(entryPath);
733
+ }
734
+ } catch {
735
+ // Ignore file deletion errors
736
+ }
737
+
738
+ // Remove from index
739
+ delete this.index!.entries[cacheKey];
740
+ this.saveIndex();
741
+
742
+ return true;
743
+ }
744
+
745
+ return false;
746
+ }
747
+
748
+ /**
749
+ * Clear entire cache (synchronous)
750
+ */
751
+ private clearSync(): void {
752
+ try {
753
+ if (existsSync(this.entriesDir)) {
754
+ rmSync(this.entriesDir, { recursive: true, force: true });
755
+ }
756
+ if (existsSync(this.indexPath)) {
757
+ rmSync(this.indexPath);
758
+ }
759
+ mkdirSync(this.entriesDir, { recursive: true });
760
+ } catch {
761
+ // Ignore errors during clear
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Clear entire cache
767
+ */
768
+ clear(): void {
769
+ this.clearSync();
770
+ this.index = { version: CACHE_VERSION, entries: {} };
771
+ this.saveIndex();
772
+
773
+ if (this.verbose) {
774
+ logger.info('Cache cleared');
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Get cache statistics
780
+ */
781
+ getStats(): CacheStats {
782
+ this.ensureInitialized();
783
+
784
+ let sizeBytes = 0;
785
+
786
+ // Calculate size of all entry files
787
+ if (existsSync(this.entriesDir)) {
788
+ try {
789
+ const files = readdirSync(this.entriesDir);
790
+ for (const file of files) {
791
+ const filePath = join(this.entriesDir, file);
792
+ try {
793
+ const stat = statSync(filePath);
794
+ sizeBytes += stat.size;
795
+ } catch {
796
+ // Ignore stat errors
797
+ }
798
+ }
799
+ } catch {
800
+ // Ignore read errors
801
+ }
802
+ }
803
+
804
+ // Add index file size
805
+ if (existsSync(this.indexPath)) {
806
+ try {
807
+ const stat = statSync(this.indexPath);
808
+ sizeBytes += stat.size;
809
+ } catch {
810
+ // Ignore stat errors
811
+ }
812
+ }
813
+
814
+ return {
815
+ entries: Object.keys(this.index?.entries ?? {}).length,
816
+ sizeBytes,
817
+ version: CACHE_VERSION,
818
+ };
819
+ }
820
+
821
+ /**
822
+ * Get all cached entries (for debugging)
823
+ */
824
+ getAllEntries(): Record<string, CacheEntry> {
825
+ this.ensureInitialized();
826
+ return { ...this.index!.entries };
827
+ }
828
+ }
829
+
830
+ // ============================================================================
831
+ // Factory Function
832
+ // ============================================================================
833
+
834
+ /**
835
+ * Create a cache manager instance
836
+ */
837
+ export function createCacheManager(options: CacheOptions): CacheManager {
838
+ return new CacheManager(options);
839
+ }
840
+
841
+ /**
842
+ * Create a no-op cache manager that never caches (for --no-cache mode)
843
+ */
844
+ export function createNullCacheManager(): CacheManager & { isNull: true } {
845
+ const nullManager = {
846
+ isNull: true as const,
847
+ get: () => ({ hit: false }),
848
+ set: () => {},
849
+ has: () => false,
850
+ delete: () => false,
851
+ clear: () => {},
852
+ getStats: () => ({ entries: 0, sizeBytes: 0, version: CACHE_VERSION }),
853
+ getAllEntries: () => ({}),
854
+ updateMetadata: () => {},
855
+ getMetadata: () => ({}),
856
+ applyPolicies: () => 0,
857
+ invalidateMatching: () => 0,
858
+ addPolicy: () => {},
859
+ removePolicy: () => false,
860
+ getPolicies: () => [],
861
+ };
862
+
863
+ return nullManager as unknown as CacheManager & { isNull: true };
864
+ }
865
+
866
+ // ============================================================================
867
+ // Preset Policy Configurations
868
+ // ============================================================================
869
+
870
+ /**
871
+ * Create default invalidation policies for typical translation workflow
872
+ */
873
+ export function createDefaultPolicies(): InvalidationPolicy[] {
874
+ return [
875
+ new GlossaryChangePolicy('all'),
876
+ TTLPolicy.days(30),
877
+ ];
878
+ }
879
+
880
+ /**
881
+ * Create strict invalidation policies (more aggressive invalidation)
882
+ */
883
+ export function createStrictPolicies(qualityThreshold: number = 85): InvalidationPolicy[] {
884
+ return [
885
+ new GlossaryChangePolicy('all'),
886
+ new ProviderChangePolicy(true),
887
+ new QualityThresholdPolicy(qualityThreshold),
888
+ TTLPolicy.days(7),
889
+ ];
890
+ }
891
+
892
+ /**
893
+ * Create minimal invalidation policies (only glossary changes)
894
+ */
895
+ export function createMinimalPolicies(): InvalidationPolicy[] {
896
+ return [
897
+ new GlossaryChangePolicy('matching'),
898
+ ];
899
+ }