transduck 0.7.0 → 0.9.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.
package/dist/cli.js CHANGED
@@ -637,7 +637,7 @@ export async function runStats(opts) {
637
637
  }
638
638
  // CLI entry point
639
639
  const program = new Command();
640
- program.name('transduck').description('AI-native translation tool').version('0.7.0');
640
+ program.name('transduck').description('AI-native translation tool').version('0.9.0');
641
641
  program.command('init')
642
642
  .description('Initialize a new transduck project')
643
643
  .action(async () => {
package/dist/config.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface TransduckConfig {
11
11
  backendModel: string;
12
12
  backendTimeout: number;
13
13
  backendMaxRetries: number;
14
+ backendMaxConcurrency: number;
14
15
  sharedUrl: string | null;
15
16
  readOnly: boolean;
16
17
  }
package/dist/config.js CHANGED
@@ -37,6 +37,10 @@ export function loadConfig(path) {
37
37
  const backendModel = backend.model ?? 'gpt-4.1-mini';
38
38
  const backendTimeout = backend.timeout_seconds ?? 10;
39
39
  const backendMaxRetries = backend.max_retries ?? 2;
40
+ const backendMaxConcurrency = backend.max_concurrency ?? 10;
41
+ if (!Number.isInteger(backendMaxConcurrency) || backendMaxConcurrency < 1) {
42
+ throw new Error(`backend.max_concurrency must be a positive integer (got ${backend.max_concurrency})`);
43
+ }
40
44
  const sharedUrl = process.env.TRANSDUCK_SHARED_URL || raw.storage?.shared_url || null;
41
45
  let readOnly = raw.runtime?.read_only ?? false;
42
46
  if (provider === 'claude_code') {
@@ -55,6 +59,7 @@ export function loadConfig(path) {
55
59
  backendModel,
56
60
  backendTimeout,
57
61
  backendMaxRetries,
62
+ backendMaxConcurrency,
58
63
  readOnly,
59
64
  };
60
65
  }
package/dist/handler.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  import { type TranslationHook } from './hooks.js';
2
+ /**
3
+ * Bounded-concurrency semaphore. Workers call `acquire()` to get a slot,
4
+ * then `release()` when done. When the active count is at the limit,
5
+ * further `acquire()` calls queue until a slot frees up.
6
+ */
7
+ export declare function createSemaphore(limit: number): {
8
+ acquire(): Promise<void>;
9
+ release(): void;
10
+ };
2
11
  interface TranslationRequestString {
3
12
  text: string;
4
13
  context?: string;
package/dist/handler.js CHANGED
@@ -8,6 +8,46 @@ import { fireHooks } from './hooks.js';
8
8
  function hash(text) {
9
9
  return createHash('sha256').update(text).digest('hex');
10
10
  }
11
+ /**
12
+ * Bounded-concurrency semaphore. Workers call `acquire()` to get a slot,
13
+ * then `release()` when done. When the active count is at the limit,
14
+ * further `acquire()` calls queue until a slot frees up.
15
+ */
16
+ export function createSemaphore(limit) {
17
+ let active = 0;
18
+ const queue = [];
19
+ return {
20
+ acquire() {
21
+ return new Promise(resolve => {
22
+ if (active < limit) {
23
+ active++;
24
+ resolve();
25
+ }
26
+ else {
27
+ queue.push(() => {
28
+ active++;
29
+ resolve();
30
+ });
31
+ }
32
+ });
33
+ },
34
+ release() {
35
+ active--;
36
+ const next = queue.shift();
37
+ if (next)
38
+ next();
39
+ },
40
+ };
41
+ }
42
+ async function withSlot(sem, fn) {
43
+ await sem.acquire();
44
+ try {
45
+ return await fn();
46
+ }
47
+ finally {
48
+ sem.release();
49
+ }
50
+ }
11
51
  let _store = null;
12
52
  let _sharedStore = null;
13
53
  async function getStore(configPath) {
@@ -48,34 +88,30 @@ export async function handleTranslationRequest(body, configPath, opts) {
48
88
  const targetLang = body.language.toUpperCase();
49
89
  const bodySourceLang = body.sourceLang?.toUpperCase();
50
90
  const projectContextHash = hash(cfg.projectContext);
51
- const translations = {};
52
- const plurals = {};
53
- // Translate regular strings
54
- for (const item of body.strings ?? []) {
91
+ // Single semaphore shared across strings and plurals so total outbound
92
+ // concurrency to the backend is bounded across both categories.
93
+ const sem = createSemaphore(cfg.backendMaxConcurrency);
94
+ const stringWork = (body.strings ?? []).map(item => withSlot(sem, async () => {
55
95
  const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
56
96
  const stringContextHash = hash(item.context ?? '');
57
- const key = `${item.text}||${item.context ?? ''}`;
97
+ const key = `${item.text}||${item.context ?? ''}||${sourceLang}`;
58
98
  const lookupParams = {
59
99
  sourceText: item.text, sourceLang, targetLang,
60
100
  projectContextHash, stringContextHash,
61
101
  };
62
102
  // Tier 1: local cache
63
103
  const cached = await store.lookup(lookupParams);
64
- if (cached !== null) {
65
- translations[key] = cached;
66
- continue;
67
- }
104
+ if (cached !== null)
105
+ return [key, cached];
68
106
  // Tier 2: shared store
69
107
  if (shared) {
70
108
  const sharedCached = await shared.lookup(lookupParams);
71
109
  if (sharedCached !== null) {
72
- // Propagate to local
73
110
  await store.insert({
74
111
  ...lookupParams, stringContext: item.context ?? '',
75
112
  translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
76
113
  });
77
- translations[key] = sharedCached;
78
- continue;
114
+ return [key, sharedCached];
79
115
  }
80
116
  }
81
117
  // Backend call
@@ -98,37 +134,31 @@ export async function handleTranslationRequest(body, configPath, opts) {
98
134
  };
99
135
  await fireHooks([], event, opts.onTranslate);
100
136
  }
101
- translations[key] = translated;
102
- }
103
- else {
104
- translations[key] = item.text;
137
+ return [key, translated];
105
138
  }
139
+ return [key, item.text];
106
140
  }
107
141
  catch {
108
- translations[key] = item.text;
142
+ return [key, item.text];
109
143
  }
110
- }
111
- // Translate plurals
112
- for (const item of body.plurals ?? []) {
144
+ }));
145
+ const pluralWork = (body.plurals ?? []).map(item => withSlot(sem, async () => {
113
146
  const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
114
147
  const stringContextHash = hash(item.context ?? '');
115
148
  const sourceKey = item.one + '\x00' + item.other;
116
- const responseKey = `${sourceKey}||${item.context ?? ''}`;
149
+ const responseKey = `${sourceKey}||${item.context ?? ''}||${sourceLang}`;
117
150
  const lookupParams = {
118
151
  sourceText: sourceKey, sourceLang, targetLang,
119
152
  projectContextHash, stringContextHash,
120
153
  };
121
154
  // Tier 1: local cache
122
155
  const cachedForms = await store.lookupPlural(lookupParams);
123
- if (Object.keys(cachedForms).length > 0) {
124
- plurals[responseKey] = cachedForms;
125
- continue;
126
- }
156
+ if (Object.keys(cachedForms).length > 0)
157
+ return [responseKey, cachedForms];
127
158
  // Tier 2: shared store
128
159
  if (shared) {
129
160
  const sharedForms = await shared.lookupPlural(lookupParams);
130
161
  if (Object.keys(sharedForms).length > 0) {
131
- // Propagate to local
132
162
  for (const [cat, text] of Object.entries(sharedForms)) {
133
163
  await store.insertPlural({
134
164
  ...lookupParams, stringContext: item.context ?? '',
@@ -136,8 +166,7 @@ export async function handleTranslationRequest(body, configPath, opts) {
136
166
  model: cfg.backendModel, status: 'translated',
137
167
  });
138
168
  }
139
- plurals[responseKey] = sharedForms;
140
- continue;
169
+ return [responseKey, sharedForms];
141
170
  }
142
171
  }
143
172
  // Backend call
@@ -162,12 +191,22 @@ export async function handleTranslationRequest(body, configPath, opts) {
162
191
  };
163
192
  await fireHooks([], event, opts.onTranslate);
164
193
  }
165
- plurals[responseKey] = forms;
194
+ return [responseKey, forms];
166
195
  }
167
196
  catch {
168
- plurals[responseKey] = { one: item.one, other: item.other };
197
+ return [responseKey, { one: item.one, other: item.other }];
169
198
  }
170
- }
199
+ }));
200
+ const [stringResults, pluralResults] = await Promise.all([
201
+ Promise.all(stringWork),
202
+ Promise.all(pluralWork),
203
+ ]);
204
+ const translations = {};
205
+ for (const [key, value] of stringResults)
206
+ translations[key] = value;
207
+ const plurals = {};
208
+ for (const [key, value] of pluralResults)
209
+ plurals[key] = value;
171
210
  return { translations, plurals };
172
211
  }
173
212
  export function createTransDuckHandler(configPath, opts) {
@@ -30,11 +30,16 @@ export interface UseTransDuckReturn {
30
30
  export declare function useTransDuck(): UseTransDuckReturn;
31
31
  export declare function _resetReactState(): void;
32
32
  export declare function _getReactState(): ReactState;
33
- export declare function t(sourceText: string, context?: string, vars?: Record<string, string | number>): TranslationResult;
34
- export declare function tPlural(one: string, other: string, count: number, opts?: {
33
+ export interface TOptions {
35
34
  context?: string;
36
35
  vars?: Record<string, string | number>;
37
- }): TranslationResult;
36
+ /** Override the provider's source language for this call. */
37
+ sourceLang?: string;
38
+ }
39
+ export interface TPluralOptions extends TOptions {
40
+ }
41
+ export declare function t(sourceText: string, contextOrOpts?: string | TOptions, vars?: Record<string, string | number>): TranslationResult;
42
+ export declare function tPlural(one: string, other: string, count: number, opts?: TPluralOptions): TranslationResult;
38
43
  export declare const ait: typeof t;
39
44
  export declare const aitPlural: typeof tPlural;
40
45
  interface TransDuckProviderProps {
@@ -77,24 +77,43 @@ function schedulePendingFlush() {
77
77
  _state.triggerFetch?.();
78
78
  });
79
79
  }
80
- // --- Stable exported functions ---
81
- export function t(sourceText, context, vars) {
82
- const key = `${sourceText}||${context ?? ''}`;
80
+ /**
81
+ * Build the client-side cache / pending-set key.
82
+ * Format: text || context || sourceLang (last two always present)
83
+ */
84
+ function buildKey(text, context, sourceLang) {
85
+ return `${text}||${context ?? ''}||${sourceLang}`;
86
+ }
87
+ export function t(sourceText, contextOrOpts, vars) {
88
+ let context;
89
+ let resolvedVars;
90
+ let sourceLangOverride;
91
+ if (typeof contextOrOpts === 'string' || contextOrOpts === undefined) {
92
+ context = contextOrOpts;
93
+ resolvedVars = vars;
94
+ }
95
+ else {
96
+ context = contextOrOpts.context;
97
+ resolvedVars = contextOrOpts.vars;
98
+ sourceLangOverride = contextOrOpts.sourceLang;
99
+ }
100
+ const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
101
+ const key = buildKey(sourceText, context, effectiveSourceLang);
83
102
  _state.knownKeys.add(key);
84
103
  // Same language — return source
85
- if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
86
- return new TranslationResult(interpolateVars(sourceText, vars), {
104
+ if (_state.language.toUpperCase() === effectiveSourceLang) {
105
+ return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
87
106
  pending: false,
88
- sourceLang: _state.sourceLang,
107
+ sourceLang: effectiveSourceLang,
89
108
  lang: _state.language,
90
109
  });
91
110
  }
92
111
  // Cache hit
93
112
  const cached = _state.cache.get(key);
94
113
  if (cached !== undefined) {
95
- return new TranslationResult(interpolateVars(cached, vars), {
114
+ return new TranslationResult(interpolateVars(cached, resolvedVars), {
96
115
  pending: false,
97
- sourceLang: _state.sourceLang,
116
+ sourceLang: effectiveSourceLang,
98
117
  lang: _state.language,
99
118
  source: 'cache',
100
119
  });
@@ -103,14 +122,16 @@ export function t(sourceText, context, vars) {
103
122
  _state.pendingStrings.add(key);
104
123
  schedulePendingFlush();
105
124
  // Return source text as fallback, marked pending
106
- return new TranslationResult(interpolateVars(sourceText, vars), {
125
+ return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
107
126
  pending: true,
108
- sourceLang: _state.sourceLang,
127
+ sourceLang: effectiveSourceLang,
109
128
  lang: _state.language,
110
129
  });
111
130
  }
112
131
  export function tPlural(one, other, count, opts) {
113
132
  const context = opts?.context;
133
+ const sourceLangOverride = opts?.sourceLang;
134
+ const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
114
135
  let vars;
115
136
  if (!opts?.vars) {
116
137
  vars = { count };
@@ -122,15 +143,15 @@ export function tPlural(one, other, count, opts) {
122
143
  vars = { ...opts.vars };
123
144
  }
124
145
  const sourceKey = `${one}\x00${other}`;
125
- const cacheKey = `${sourceKey}||${context ?? ''}`;
146
+ const cacheKey = buildKey(sourceKey, context, effectiveSourceLang);
126
147
  _state.knownPlurals.add(cacheKey);
127
148
  // Same language
128
- if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
129
- const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
149
+ if (_state.language.toUpperCase() === effectiveSourceLang) {
150
+ const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
130
151
  const form = rules.select(count) === 'one' ? one : other;
131
152
  return new TranslationResult(interpolateVars(form, vars), {
132
153
  pending: false,
133
- sourceLang: _state.sourceLang,
154
+ sourceLang: effectiveSourceLang,
134
155
  lang: _state.language,
135
156
  });
136
157
  }
@@ -142,7 +163,7 @@ export function tPlural(one, other, count, opts) {
142
163
  const form = cachedForms[category] ?? cachedForms['other'] ?? other;
143
164
  return new TranslationResult(interpolateVars(form, vars), {
144
165
  pending: false,
145
- sourceLang: _state.sourceLang,
166
+ sourceLang: effectiveSourceLang,
146
167
  lang: _state.language,
147
168
  source: 'cache',
148
169
  });
@@ -151,11 +172,11 @@ export function tPlural(one, other, count, opts) {
151
172
  _state.pendingPlurals.add(cacheKey);
152
173
  schedulePendingFlush();
153
174
  // Fallback to source form, marked pending
154
- const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
175
+ const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
155
176
  const form = rules.select(count) === 'one' ? one : other;
156
177
  return new TranslationResult(interpolateVars(form, vars), {
157
178
  pending: true,
158
- sourceLang: _state.sourceLang,
179
+ sourceLang: effectiveSourceLang,
159
180
  lang: _state.language,
160
181
  });
161
182
  }
@@ -266,15 +287,23 @@ export function TransDuckProvider({ language, sourceLang = 'EN', endpoint = '/ap
266
287
  }
267
288
  return;
268
289
  }
269
- // Build request body
290
+ // Build request body.
291
+ // Key format: text || context || sourceLang. Pop the tail segments so text
292
+ // can contain "||" safely.
270
293
  const strings = Array.from(stringsToFetch).map(key => {
271
- const [text, context] = key.split('||');
272
- return { text, context: context || undefined };
294
+ const parts = key.split('||');
295
+ const sourceLang = parts.pop();
296
+ const context = parts.pop();
297
+ const text = parts.join('||');
298
+ return { text, context: context || undefined, sourceLang };
273
299
  });
274
300
  const plurals = Array.from(pluralsToFetch).map(key => {
275
- const [sourceKey, context] = key.split('||');
301
+ const parts = key.split('||');
302
+ const sourceLang = parts.pop();
303
+ const context = parts.pop();
304
+ const sourceKey = parts.join('||');
276
305
  const [one, other] = sourceKey.split('\x00');
277
- return { one, other, context: context || undefined };
306
+ return { one, other, context: context || undefined, sourceLang };
278
307
  });
279
308
  try {
280
309
  const response = await fetch(endpoint, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "AI-native translation tool using source text as keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/cli.ts CHANGED
@@ -743,7 +743,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
743
743
  // CLI entry point
744
744
  const program = new Command();
745
745
 
746
- program.name('transduck').description('AI-native translation tool').version('0.7.0');
746
+ program.name('transduck').description('AI-native translation tool').version('0.9.0');
747
747
 
748
748
  program.command('init')
749
749
  .description('Initialize a new transduck project')
package/src/config.ts CHANGED
@@ -17,6 +17,7 @@ export interface TransduckConfig {
17
17
  backendModel: string;
18
18
  backendTimeout: number;
19
19
  backendMaxRetries: number;
20
+ backendMaxConcurrency: number;
20
21
  sharedUrl: string | null;
21
22
  readOnly: boolean;
22
23
  }
@@ -61,6 +62,13 @@ export function loadConfig(path?: string): TransduckConfig {
61
62
  const backendTimeout = backend.timeout_seconds ?? 10;
62
63
  const backendMaxRetries = backend.max_retries ?? 2;
63
64
 
65
+ const backendMaxConcurrency = backend.max_concurrency ?? 10;
66
+ if (!Number.isInteger(backendMaxConcurrency) || backendMaxConcurrency < 1) {
67
+ throw new Error(
68
+ `backend.max_concurrency must be a positive integer (got ${backend.max_concurrency})`
69
+ );
70
+ }
71
+
64
72
  const sharedUrl = process.env.TRANSDUCK_SHARED_URL || raw.storage?.shared_url || null;
65
73
 
66
74
  let readOnly = raw.runtime?.read_only ?? false;
@@ -81,6 +89,7 @@ export function loadConfig(path?: string): TransduckConfig {
81
89
  backendModel,
82
90
  backendTimeout,
83
91
  backendMaxRetries,
92
+ backendMaxConcurrency,
84
93
  readOnly,
85
94
  };
86
95
  }