@strav/brain 1.0.0-alpha.31 → 1.0.0-alpha.34

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/brain",
3
- "version": "1.0.0-alpha.31",
3
+ "version": "1.0.0-alpha.34",
4
4
  "description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching, tools / agents / MCP. Anthropic + OpenAI providers; Gemini / DeepSeek follow.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -9,6 +9,7 @@
9
9
  ".": "./src/index.ts",
10
10
  "./mcp": "./src/mcp/index.ts",
11
11
  "./persistence": "./src/persistence/index.ts",
12
+ "./translate": "./src/translate/index.ts",
12
13
  "./zod": "./src/zod/index.ts"
13
14
  },
14
15
  "files": [
@@ -25,8 +26,8 @@
25
26
  "@anthropic-ai/sdk": "^0.100.0",
26
27
  "@google/genai": "^2.7.0",
27
28
  "@modelcontextprotocol/sdk": "^1.29.0",
28
- "@strav/database": "1.0.0-alpha.31",
29
- "@strav/kernel": "1.0.0-alpha.31",
29
+ "@strav/database": "1.0.0-alpha.34",
30
+ "@strav/kernel": "1.0.0-alpha.34",
30
31
  "openai": "^6.0.0"
31
32
  },
32
33
  "peerDependencies": {
@@ -0,0 +1,19 @@
1
+ // Public API of `@strav/brain/translate`.
2
+ //
3
+ // LLM-backed translation primitive on top of `BrainManager`. Sonnet-
4
+ // uniform by default (tier='balanced'), with parallel fan-out across
5
+ // target languages, JSON-schema constrained output, prompt caching on
6
+ // the system prompt, and a process-local LRU for repeat strings.
7
+
8
+ export { TranslateCache, cacheKey } from './translate_cache.ts'
9
+ export {
10
+ type TranslateConfig,
11
+ TranslatorProvider,
12
+ } from './translate_provider.ts'
13
+ export {
14
+ type BatchTranslateOptions,
15
+ DEFAULT_SYSTEM_PROMPT,
16
+ type TranslateOptions,
17
+ Translator,
18
+ type TranslatorOptions,
19
+ } from './translator.ts'
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `TranslateCache` — tiny LRU keyed on `(model, from, to, text)`. Keeps
3
+ * repeated translations of the same phrase from hitting the model
4
+ * twice during a single fan-out (or during a batch where the same
5
+ * field value recurs across drafts).
6
+ *
7
+ * Intentionally in-memory + process-local. Apps that want persistent
8
+ * caching (e.g. across job retries / restarts) wrap their own
9
+ * Repository around `Translator` and call into it themselves; this
10
+ * cache exists to make the hot path cheap, not to be a system of
11
+ * record.
12
+ *
13
+ * Eviction is FIFO via Map insertion order — re-inserting on hit
14
+ * (`delete`+`set`) bumps the entry to the end so cold entries fall
15
+ * off first. `capacity: 0` disables the cache entirely.
16
+ */
17
+
18
+ export class TranslateCache {
19
+ private readonly store = new Map<string, string>()
20
+
21
+ constructor(readonly capacity: number) {}
22
+
23
+ get(key: string): string | undefined {
24
+ if (this.capacity === 0) return undefined
25
+ const hit = this.store.get(key)
26
+ if (hit === undefined) return undefined
27
+ // Bump recency.
28
+ this.store.delete(key)
29
+ this.store.set(key, hit)
30
+ return hit
31
+ }
32
+
33
+ set(key: string, value: string): void {
34
+ if (this.capacity === 0) return
35
+ if (this.store.has(key)) this.store.delete(key)
36
+ this.store.set(key, value)
37
+ if (this.store.size > this.capacity) {
38
+ // Evict the oldest entry (the first key in insertion order).
39
+ const oldest = this.store.keys().next().value
40
+ if (oldest !== undefined) this.store.delete(oldest)
41
+ }
42
+ }
43
+
44
+ clear(): void {
45
+ this.store.clear()
46
+ }
47
+
48
+ get size(): number {
49
+ return this.store.size
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Stable cache key for a single (text → language) translation.
55
+ * Inputs are joined with a separator that can't appear in BCP-47
56
+ * codes; the text is hashed (FNV-1a 32-bit) to keep keys bounded.
57
+ *
58
+ * Collision risk on FNV-1a 32-bit is non-zero but acceptable for a
59
+ * best-effort cache: the cost of a collision is one extra LLM call
60
+ * the next time the loser's text hashes to the same key.
61
+ */
62
+ export function cacheKey(input: {
63
+ model: string
64
+ from: string | undefined
65
+ to: string
66
+ text: string
67
+ }): string {
68
+ return `${input.model}|${input.from ?? 'auto'}|${input.to}|${fnv1a32(input.text)}`
69
+ }
70
+
71
+ function fnv1a32(text: string): string {
72
+ let hash = 0x811c9dc5
73
+ for (let i = 0; i < text.length; i++) {
74
+ hash ^= text.charCodeAt(i)
75
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0
76
+ }
77
+ return hash.toString(16).padStart(8, '0')
78
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `TranslatorProvider` — `ServiceProvider` that binds a default
3
+ * `Translator` singleton resolved against the registered
4
+ * `BrainManager`.
5
+ *
6
+ * Reads `config.brain.translate` (optional) for defaults — provider,
7
+ * tier, model, cacheSize. Apps that need multiple translators with
8
+ * different defaults (e.g. one for headlines, one for body) skip the
9
+ * provider and construct `new Translator({ brain, ... })` directly.
10
+ */
11
+
12
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
13
+ import { BrainManager } from '../brain_manager.ts'
14
+ import type { ModelTier } from '../types.ts'
15
+ import { Translator } from './translator.ts'
16
+
17
+ export interface TranslateConfig {
18
+ provider?: string
19
+ tier?: ModelTier
20
+ model?: string
21
+ systemPrompt?: string
22
+ cacheSize?: number
23
+ cache?: boolean
24
+ }
25
+
26
+ export class TranslatorProvider extends ServiceProvider {
27
+ override readonly name = 'brain.translate'
28
+ override readonly dependencies = ['brain']
29
+
30
+ override register(app: Application): void {
31
+ app.singleton(Translator, (c) => {
32
+ const brain = c.resolve(BrainManager)
33
+ const cfg =
34
+ (c.resolve(ConfigRepository).get('brain.translate') as TranslateConfig | undefined) ?? {}
35
+ return new Translator({
36
+ brain,
37
+ ...(cfg.provider !== undefined ? { provider: cfg.provider } : {}),
38
+ ...(cfg.tier !== undefined ? { tier: cfg.tier } : {}),
39
+ ...(cfg.model !== undefined ? { model: cfg.model } : {}),
40
+ ...(cfg.systemPrompt !== undefined ? { systemPrompt: cfg.systemPrompt } : {}),
41
+ ...(cfg.cacheSize !== undefined ? { cacheSize: cfg.cacheSize } : {}),
42
+ ...(cfg.cache !== undefined ? { cache: cfg.cache } : {}),
43
+ })
44
+ })
45
+ }
46
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `Translator` — LLM-backed translation primitive on top of
3
+ * `BrainManager`. Sonnet-uniform by default (`tier: 'balanced'`),
4
+ * which routes to `claude-sonnet-4-6` on the Anthropic driver — apps
5
+ * override with `options.model` or `options.provider` per call.
6
+ *
7
+ * Two entry points:
8
+ *
9
+ * - `translate(text, { to: [...] })` — fan-out one string into
10
+ * every target language in parallel. Returns
11
+ * `{ [langCode]: translated }`.
12
+ *
13
+ * - `translateBatch(fields, { to: [...] })` — translate a
14
+ * fixed-shape object (`{ title, body }`) into every target
15
+ * language. Each target language runs in parallel; within a
16
+ * language, all fields land in one model call so the model
17
+ * keeps shared context (a `title` and `body` translated
18
+ * together stay tonally consistent).
19
+ *
20
+ * Cross-cutting:
21
+ *
22
+ * - **Structured output.** Uses `brain.generate(input, schema)`
23
+ * with a JSON Schema that locks the response to the expected
24
+ * keys, so models never sneak in commentary or transliterations.
25
+ *
26
+ * - **Prompt caching.** The system prompt is identical across
27
+ * every call (per-language hints ride in the user message), so
28
+ * Anthropic prompt caching kicks in once the cache window warms.
29
+ * Set `cache: false` on the constructor to opt out.
30
+ *
31
+ * - **In-memory cache.** Identical `(model, from, to, text)`
32
+ * tuples are served from a process-local LRU (default 1000
33
+ * entries) — see `TranslateCache`. Pass `cacheSize: 0` to
34
+ * disable.
35
+ *
36
+ * - **Source language auto-detect.** Omit `from` and the user
37
+ * message tells the model to detect the source. Apps that know
38
+ * the source pass it explicitly for marginal quality + token
39
+ * savings.
40
+ */
41
+
42
+ import type { BrainManager } from '../brain_manager.ts'
43
+ import type { OutputSchema } from '../output_schema.ts'
44
+ import type { ChatOptions, ModelTier } from '../types.ts'
45
+ import { cacheKey, TranslateCache } from './translate_cache.ts'
46
+
47
+ export interface TranslatorOptions {
48
+ brain: BrainManager
49
+ /** Brain provider name. Defaults to the configured `brain.default`. */
50
+ provider?: string
51
+ /** Brain tier sugar — overridden by `model`. Default `'balanced'` (Sonnet on Anthropic per ADR-0004-style routing). */
52
+ tier?: ModelTier
53
+ /** Explicit model id. Wins over `tier`. */
54
+ model?: string
55
+ /** Override the system prompt. Apps localising the prompt itself reach for this. */
56
+ systemPrompt?: string
57
+ /** LRU capacity for the translation cache. `0` disables. Default `1000`. */
58
+ cacheSize?: number
59
+ /** Enable Anthropic prompt caching on the system prompt. Default `true`. Non-Anthropic providers ignore. */
60
+ cache?: boolean
61
+ }
62
+
63
+ export interface TranslateOptions {
64
+ /** Target BCP-47 language codes (`'th'`, `'zh-Hant'`, `'ja'`). */
65
+ to: readonly string[]
66
+ /** Source BCP-47 code. Omit to ask the model to detect. */
67
+ from?: string
68
+ /** Per-call model override (wins over the constructor's tier/model). */
69
+ model?: string
70
+ /** Per-call provider override. */
71
+ provider?: string
72
+ /** Cancellation signal — forwarded to every parallel `brain.generate` call. */
73
+ signal?: AbortSignal
74
+ }
75
+
76
+ export type BatchTranslateOptions = TranslateOptions
77
+
78
+ /**
79
+ * Default system prompt — kept stable across every call so prompt
80
+ * caching can warm. Per-call specifics (source/target language,
81
+ * text, field shape) ride in the user message.
82
+ */
83
+ export const DEFAULT_SYSTEM_PROMPT = `You are a translation engine.
84
+
85
+ The user supplies (a) a source-language code (or "auto"), (b) a target BCP-47 language code, and (c) the source text or a JSON object of named source fields. Translate the source into the target language and output ONLY the translation in the required JSON shape.
86
+
87
+ Rules:
88
+ - Output ONLY the translated text in the requested JSON shape. Do not add explanations, notes, alternatives, or transliterations.
89
+ - Preserve Markdown, HTML tags, links, mentions, hashtags, code spans, and emoji exactly as in the source.
90
+ - Keep numbers, dates, currency symbols, and proper nouns recognisable in the target locale; do not invent translations for brand names.
91
+ - If the source is already in the target language, output it unchanged.
92
+ - For batch translations, every requested field must appear in the output — never drop a field.`
93
+
94
+ export class Translator {
95
+ private readonly brain: BrainManager
96
+ private readonly provider: string | undefined
97
+ private readonly tier: ModelTier
98
+ private readonly explicitModel: string | undefined
99
+ private readonly systemPrompt: string
100
+ private readonly cache: TranslateCache
101
+ private readonly promptCache: boolean
102
+
103
+ constructor(options: TranslatorOptions) {
104
+ this.brain = options.brain
105
+ this.provider = options.provider
106
+ this.tier = options.tier ?? 'balanced'
107
+ this.explicitModel = options.model
108
+ this.systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT
109
+ this.cache = new TranslateCache(options.cacheSize ?? 1000)
110
+ this.promptCache = options.cache ?? true
111
+ }
112
+
113
+ /**
114
+ * Translate one string into every target language in parallel.
115
+ * Returns a `{ [lang]: translated }` map containing one entry per
116
+ * code in `options.to`. Calls fan out concurrently; a single
117
+ * thrown call rejects the whole `Promise.all`.
118
+ */
119
+ async translate(
120
+ text: string,
121
+ options: TranslateOptions,
122
+ ): Promise<Record<string, string>> {
123
+ if (options.to.length === 0) return {}
124
+
125
+ const results = await Promise.all(
126
+ options.to.map(async (lang) => {
127
+ const translated = await this.translateOne(text, lang, options)
128
+ return [lang, translated] as const
129
+ }),
130
+ )
131
+ return Object.fromEntries(results)
132
+ }
133
+
134
+ /**
135
+ * Translate a fixed-shape object of fields into every target
136
+ * language. Each target language runs in parallel; within a
137
+ * language, all fields are translated in one model call so context
138
+ * is shared.
139
+ *
140
+ * Returns `{ [lang]: { ...fields } }`. The shape of every per-
141
+ * language object matches the input keys exactly — missing keys
142
+ * are treated as a hard error (the model is instructed to never
143
+ * drop a field) and surface as a `BrainError` from `generate`'s
144
+ * schema parser.
145
+ */
146
+ async translateBatch<T extends Record<string, string>>(
147
+ fields: T,
148
+ options: BatchTranslateOptions,
149
+ ): Promise<Record<string, T>> {
150
+ if (options.to.length === 0) return {}
151
+ const fieldNames = Object.keys(fields) as Array<keyof T & string>
152
+ if (fieldNames.length === 0) return Object.fromEntries(options.to.map((l) => [l, {} as T]))
153
+
154
+ const results = await Promise.all(
155
+ options.to.map(async (lang) => {
156
+ const translated = await this.translateBatchOne(fields, fieldNames, lang, options)
157
+ return [lang, translated] as const
158
+ }),
159
+ )
160
+ return Object.fromEntries(results)
161
+ }
162
+
163
+ /** Drop the in-memory LRU. Useful in tests to keep cases isolated. */
164
+ clearCache(): void {
165
+ this.cache.clear()
166
+ }
167
+
168
+ // ─── internals ──────────────────────────────────────────────────────
169
+
170
+ private resolvedModel(per: TranslateOptions): string {
171
+ return per.model ?? this.explicitModel ?? this.tier
172
+ }
173
+
174
+ private buildChatOptions(per: TranslateOptions): ChatOptions {
175
+ const opts: ChatOptions = {
176
+ system: this.promptCache
177
+ ? { text: this.systemPrompt, cache: true }
178
+ : this.systemPrompt,
179
+ }
180
+ if (per.model) opts.model = per.model
181
+ else if (this.explicitModel) opts.model = this.explicitModel
182
+ else opts.tier = this.tier
183
+ if (per.provider ?? this.provider) opts.provider = (per.provider ?? this.provider)!
184
+ if (per.signal) opts.signal = per.signal
185
+ return opts
186
+ }
187
+
188
+ private async translateOne(
189
+ text: string,
190
+ lang: string,
191
+ per: TranslateOptions,
192
+ ): Promise<string> {
193
+ const model = this.resolvedModel(per)
194
+ const key = cacheKey({ model, from: per.from, to: lang, text })
195
+ const hit = this.cache.get(key)
196
+ if (hit !== undefined) return hit
197
+
198
+ const schema: OutputSchema<{ translation: string }> = {
199
+ name: 'translation',
200
+ description: `Translation of the source text into ${lang}.`,
201
+ jsonSchema: {
202
+ type: 'object',
203
+ properties: { translation: { type: 'string' } },
204
+ required: ['translation'],
205
+ additionalProperties: false,
206
+ },
207
+ }
208
+
209
+ const userMessage = `SOURCE_LANGUAGE: ${per.from ?? 'auto'}\nTARGET_LANGUAGE: ${lang}\nTEXT:\n${text}`
210
+
211
+ const result = await this.brain.generate(userMessage, schema, this.buildChatOptions(per))
212
+ const translated = result.value.translation
213
+ this.cache.set(key, translated)
214
+ return translated
215
+ }
216
+
217
+ private async translateBatchOne<T extends Record<string, string>>(
218
+ fields: T,
219
+ fieldNames: readonly (keyof T & string)[],
220
+ lang: string,
221
+ per: BatchTranslateOptions,
222
+ ): Promise<T> {
223
+ const model = this.resolvedModel(per)
224
+
225
+ // Per-field cache: check every field; only call the model when at
226
+ // least one field is missing. The single model call still covers
227
+ // all fields (we don't sub-call per missing field — the context
228
+ // gain from a single call outweighs the extra translation work).
229
+ const fromCache: Partial<Record<string, string>> = {}
230
+ let allHit = true
231
+ for (const name of fieldNames) {
232
+ const hit = this.cache.get(
233
+ cacheKey({ model, from: per.from, to: lang, text: fields[name]! }),
234
+ )
235
+ if (hit === undefined) {
236
+ allHit = false
237
+ } else {
238
+ fromCache[name] = hit
239
+ }
240
+ }
241
+ if (allHit) return fromCache as T
242
+
243
+ const properties: Record<string, unknown> = {}
244
+ for (const name of fieldNames) properties[name] = { type: 'string' }
245
+ const schema: OutputSchema<T> = {
246
+ name: 'batch_translation',
247
+ description: `Translation of every named field into ${lang}.`,
248
+ jsonSchema: {
249
+ type: 'object',
250
+ properties,
251
+ required: [...fieldNames],
252
+ additionalProperties: false,
253
+ },
254
+ }
255
+
256
+ const fieldsBlock = fieldNames
257
+ .map((n) => `- ${n}: ${JSON.stringify(fields[n]!)}`)
258
+ .join('\n')
259
+ const userMessage = `SOURCE_LANGUAGE: ${per.from ?? 'auto'}\nTARGET_LANGUAGE: ${lang}\nFIELDS:\n${fieldsBlock}\n\nOutput a JSON object with these exact keys: ${fieldNames.join(', ')}.`
260
+
261
+ const result = await this.brain.generate(userMessage, schema, this.buildChatOptions(per))
262
+ const translated = result.value
263
+ for (const name of fieldNames) {
264
+ this.cache.set(
265
+ cacheKey({ model, from: per.from, to: lang, text: fields[name]! }),
266
+ translated[name]!,
267
+ )
268
+ }
269
+ return translated
270
+ }
271
+ }