@aperdomoll90/ledger-ai 1.3.0 → 1.4.2

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 (84) hide show
  1. package/dist/cli.js +177 -221
  2. package/dist/commands/add.js +51 -100
  3. package/dist/commands/backfill.js +55 -0
  4. package/dist/commands/backup.js +10 -10
  5. package/dist/commands/check.js +21 -29
  6. package/dist/commands/config.js +13 -12
  7. package/dist/commands/delete.js +22 -17
  8. package/dist/commands/eval-judge.js +11 -0
  9. package/dist/commands/eval.js +321 -0
  10. package/dist/commands/export.js +8 -10
  11. package/dist/commands/get.js +9 -0
  12. package/dist/commands/hunt.js +206 -0
  13. package/dist/commands/ingest.js +15 -14
  14. package/dist/commands/init.js +18 -20
  15. package/dist/commands/list.js +21 -7
  16. package/dist/commands/migrate.js +11 -11
  17. package/dist/commands/onboard.js +2 -2
  18. package/dist/commands/pull.js +3 -2
  19. package/dist/commands/push.js +8 -8
  20. package/dist/commands/restore.js +38 -38
  21. package/dist/commands/show.js +13 -16
  22. package/dist/commands/sync.js +58 -19
  23. package/dist/commands/tag.js +20 -14
  24. package/dist/commands/update.js +50 -18
  25. package/dist/commands/wizard.js +3 -3
  26. package/dist/lib/ai-search.js +163 -0
  27. package/dist/lib/audit.js +19 -0
  28. package/dist/lib/backfill.js +60 -0
  29. package/dist/lib/config.js +19 -2
  30. package/dist/lib/document-classification.js +5 -0
  31. package/dist/lib/document-fetching.js +77 -0
  32. package/dist/lib/document-operations.js +150 -0
  33. package/dist/lib/documents/classification.js +5 -0
  34. package/dist/lib/documents/fetching.js +89 -0
  35. package/dist/lib/documents/operations.js +304 -0
  36. package/dist/lib/domains.js +116 -0
  37. package/dist/lib/embeddings.js +190 -0
  38. package/dist/lib/errors.js +3 -1
  39. package/dist/lib/eval/eval-advanced.js +289 -0
  40. package/dist/lib/eval/eval-judge-session.js +233 -0
  41. package/dist/lib/eval/eval-store.js +105 -0
  42. package/dist/lib/eval/eval.js +303 -0
  43. package/dist/lib/file-writer.js +23 -0
  44. package/dist/lib/generators.js +44 -45
  45. package/dist/lib/hunter-db.js +235 -0
  46. package/dist/lib/hunter-rss.js +30 -0
  47. package/dist/lib/hunter-scoring.js +55 -0
  48. package/dist/lib/hunter-types.js +36 -0
  49. package/dist/lib/lint-configs.js +20 -0
  50. package/dist/lib/migrate.js +2 -2
  51. package/dist/lib/notes.js +173 -59
  52. package/dist/lib/observability.js +296 -0
  53. package/dist/lib/op-add-note-types.test.js +7 -6
  54. package/dist/lib/prompt.js +8 -8
  55. package/dist/lib/rate-limiter.js +103 -0
  56. package/dist/lib/search/ai-search.js +396 -0
  57. package/dist/lib/search/chunk-context-enrichment.js +155 -0
  58. package/dist/lib/search/embeddings.js +293 -0
  59. package/dist/lib/search/reranker.js +120 -0
  60. package/dist/lib/search/semantic-cache.js +53 -0
  61. package/dist/lib/type-registry.test.js +6 -6
  62. package/dist/mcp-server.js +553 -66
  63. package/dist/migrations/migrations/005-audit-log.sql +22 -0
  64. package/dist/migrations/migrations/005_opportunities.sql +48 -0
  65. package/dist/migrations/migrations/006-audited-operations.sql +235 -0
  66. package/dist/migrations/migrations/006_hunt_analytics.sql +38 -0
  67. package/dist/migrations/migrations/007-eval-golden-judgments.sql +119 -0
  68. package/dist/migrations/migrations/008-drop-expected-doc-ids.sql +9 -0
  69. package/dist/migrations/migrations/008-judge-helpers.sql +21 -0
  70. package/dist/migrations/migrations/009-semantic-cache.sql +216 -0
  71. package/dist/scripts/batch-grade.js +344 -0
  72. package/dist/scripts/benchmark-ingestion.js +376 -0
  73. package/dist/scripts/convert-judgments-to-graded.js +88 -0
  74. package/dist/scripts/diagnose-first-result.js +333 -0
  75. package/dist/scripts/drop-golden-query.js +53 -0
  76. package/dist/scripts/eval-search.js +115 -0
  77. package/dist/scripts/grade-unjudged-top1.js +138 -0
  78. package/dist/scripts/hunter-analytics.js +38 -0
  79. package/dist/scripts/hunter-cron.js +63 -0
  80. package/dist/scripts/hunter-purge.js +25 -0
  81. package/dist/scripts/migrate-v2.js +140 -0
  82. package/dist/scripts/reindex.js +74 -0
  83. package/dist/scripts/sync-local-docs.js +153 -0
  84. package/package.json +7 -1
@@ -0,0 +1,296 @@
1
+ // observability.ts
2
+ // Langfuse tracing integration for pipeline observability.
3
+ //
4
+ // Provides trace/span helpers for instrumenting Ledger's ingestion pipeline.
5
+ // When Langfuse env vars are absent, all functions no-op silently.
6
+ // Ledger works identically with or without observability enabled.
7
+ //
8
+ // Built on OpenTelemetry (OTel), the industry-standard tracing protocol.
9
+ // Langfuse acts as the trace collector and dashboard. The OTel foundation
10
+ // means switching to Datadog, Grafana Tempo, or Jaeger requires swapping
11
+ // the exporter, not the instrumentation.
12
+ import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
13
+ import { LangfuseSpanProcessor } from '@langfuse/otel';
14
+ import { setLangfuseTracerProvider, startObservation, startActiveObservation } from '@langfuse/tracing';
15
+ import { propagateAttributes } from '@langfuse/core';
16
+ import { trace as otelTrace, context as otelContext } from '@opentelemetry/api';
17
+ // =============================================================================
18
+ // State
19
+ // =============================================================================
20
+ let provider = null;
21
+ let enabled = false;
22
+ const NOOP_HANDLE = {
23
+ update: () => { },
24
+ end: () => { },
25
+ };
26
+ const NOOP_ACTIVE_HANDLE = {
27
+ update: () => { },
28
+ end: () => { },
29
+ _otelSpan: null,
30
+ };
31
+ // =============================================================================
32
+ // Init / Shutdown
33
+ // =============================================================================
34
+ /**
35
+ * Initialize Langfuse observability.
36
+ * Returns true if enabled, false if skipped (missing env vars).
37
+ *
38
+ * Call once at CLI startup. Safe to call multiple times (idempotent).
39
+ */
40
+ export function initObservability() {
41
+ if (enabled)
42
+ return true;
43
+ const publicKey = process.env.LANGFUSE_PUBLIC_KEY;
44
+ const secretKey = process.env.LANGFUSE_SECRET_KEY;
45
+ const baseUrl = process.env.LANGFUSE_BASE_URL;
46
+ if (!publicKey || !secretKey)
47
+ return false;
48
+ provider = new NodeTracerProvider({
49
+ spanProcessors: [
50
+ new LangfuseSpanProcessor({
51
+ publicKey,
52
+ secretKey,
53
+ baseUrl: baseUrl ?? 'http://localhost:9100',
54
+ environment: process.env.NODE_ENV ?? 'development',
55
+ exportMode: 'batched',
56
+ flushAt: 10,
57
+ flushInterval: 2,
58
+ }),
59
+ ],
60
+ });
61
+ // Register the provider globally AND install an async context manager so
62
+ // propagateAttributes() can pass sessionId/tags through to child spans
63
+ // across `await` boundaries. Without this, propagated attributes never
64
+ // reach the root trace record in Langfuse.
65
+ provider.register();
66
+ setLangfuseTracerProvider(provider);
67
+ enabled = true;
68
+ return true;
69
+ }
70
+ /**
71
+ * Flush pending traces and shut down the provider.
72
+ * Call before process exit to ensure all traces are sent.
73
+ */
74
+ export async function shutdownObservability() {
75
+ if (!provider)
76
+ return;
77
+ await provider.forceFlush();
78
+ await provider.shutdown();
79
+ provider = null;
80
+ enabled = false;
81
+ }
82
+ /**
83
+ * Check if observability is currently enabled.
84
+ */
85
+ export function isObservabilityEnabled() {
86
+ return enabled;
87
+ }
88
+ // =============================================================================
89
+ // Trace / Span helpers
90
+ // =============================================================================
91
+ /**
92
+ * Start a new trace (root-level observation).
93
+ * Use for top-level operations like document ingestion.
94
+ *
95
+ * Returns a handle with update() and end() methods.
96
+ * When observability is disabled, returns a no-op handle.
97
+ */
98
+ export function startTrace(name, options) {
99
+ if (!enabled)
100
+ return NOOP_HANDLE;
101
+ const observation = startObservation(name, {
102
+ input: options?.input,
103
+ metadata: { ...options?.metadata, tags: options?.tags },
104
+ });
105
+ return {
106
+ update: (data) => observation.update(data),
107
+ end: () => observation.end(),
108
+ };
109
+ }
110
+ /**
111
+ * Start a span (child observation within a trace).
112
+ * Use for pipeline steps like chunking, enrichment, embedding, DB write.
113
+ *
114
+ * Uses the OTel tracer so spans automatically nest under the active context
115
+ * set by startActiveObservation in runSearchTrace. Langfuse's startObservation
116
+ * does NOT read OTel context, so using it here would create orphaned traces.
117
+ *
118
+ * Returns a handle with update() and end() methods.
119
+ * When observability is disabled, returns a no-op handle.
120
+ */
121
+ export function startSpan(name, options) {
122
+ if (!enabled)
123
+ return NOOP_ACTIVE_HANDLE;
124
+ const tracer = otelTrace.getTracer('langfuse-sdk');
125
+ const span = tracer.startSpan(name);
126
+ if (options?.input) {
127
+ span.setAttribute('langfuse.span.input', JSON.stringify(options.input));
128
+ }
129
+ if (options?.metadata) {
130
+ span.setAttribute('langfuse.span.metadata', JSON.stringify(options.metadata));
131
+ }
132
+ return {
133
+ update: (data) => {
134
+ for (const [key, value] of Object.entries(data)) {
135
+ span.setAttribute(`langfuse.span.${key}`, typeof value === 'string' ? value : JSON.stringify(value));
136
+ }
137
+ },
138
+ end: () => span.end(),
139
+ _otelSpan: span,
140
+ };
141
+ }
142
+ /**
143
+ * Open a root trace for a search operation.
144
+ *
145
+ * Attaches environment (prod/eval/dev), sessionId, tags, input, metadata so
146
+ * the Langfuse dashboard can slice traces by any of those dimensions.
147
+ *
148
+ * Returns a handle with update() and end() methods. The caller is expected to
149
+ * call .update({ output: {...} }) before .end() to record resultCount, cacheHit,
150
+ * topResultIds, etc. No-op when observability is disabled.
151
+ */
152
+ /**
153
+ * Run a search operation inside an open Langfuse trace.
154
+ *
155
+ * Wraps `work` in a `propagateAttributes` context so sessionId, tags, and
156
+ * environment are attached to the root trace as first-class indexed fields
157
+ * (not metadata). All spans created inside `work` inherit that context.
158
+ *
159
+ * Langfuse's SDK only exposes this via a callback pattern — there is no
160
+ * imperative "open context, return handle, close later" API. Hence the HOF.
161
+ *
162
+ * When observability is disabled, `work` runs with a no-op handle and no
163
+ * tracing overhead.
164
+ */
165
+ export async function runSearchTrace(props, work) {
166
+ if (!enabled)
167
+ return work(NOOP_HANDLE);
168
+ return propagateAttributes({
169
+ sessionId: props.sessionId,
170
+ tags: ['search', props.mode],
171
+ }, async () => {
172
+ // startActiveObservation (not startObservation) makes this the ACTIVE
173
+ // OpenTelemetry span, so any spans created inside `work` nest under it
174
+ // instead of being emitted as orphan top-level traces.
175
+ return startActiveObservation('search', async (observation) => {
176
+ // sessionId and tags are accepted at runtime but not in LangfuseSpanAttributes.
177
+ // propagateAttributes sets them in OTel context (metadata), but observation.update
178
+ // is needed to promote them to first-class indexed fields on the trace record.
179
+ observation.update({
180
+ input: props.input ?? { query: props.query },
181
+ metadata: props.metadata,
182
+ environment: props.environment,
183
+ ...{ sessionId: props.sessionId, tags: ['search', props.mode] },
184
+ });
185
+ const handle = {
186
+ update: (data) => observation.update(data),
187
+ end: () => observation.end(),
188
+ };
189
+ return work(handle);
190
+ });
191
+ });
192
+ }
193
+ /**
194
+ * Run an eval execution inside an open Langfuse trace.
195
+ *
196
+ * Creates a root trace named 'eval-run' that groups all per-query spans
197
+ * under one session. The search traces from Phase 2 (runSearchTrace)
198
+ * auto-nest under per-query spans via OTel context propagation.
199
+ *
200
+ * When observability is disabled, `work` runs with a no-op handle.
201
+ */
202
+ export async function runEvalTrace(props, work) {
203
+ if (!enabled)
204
+ return work(NOOP_HANDLE);
205
+ const tags = props.dryRun ? [...props.tags, 'dry-run'] : props.tags;
206
+ return propagateAttributes({
207
+ sessionId: props.sessionId,
208
+ tags,
209
+ }, async () => {
210
+ return startActiveObservation('eval-run', async (observation) => {
211
+ observation.update({
212
+ input: props.config,
213
+ environment: 'eval',
214
+ ...{ sessionId: props.sessionId, tags },
215
+ });
216
+ const handle = {
217
+ update: (data) => observation.update(data),
218
+ end: () => observation.end(),
219
+ };
220
+ return work(handle);
221
+ });
222
+ });
223
+ }
224
+ /**
225
+ * Run a single eval query inside a child span of the eval trace.
226
+ *
227
+ * Wraps the searchHybrid call + scoring for one golden dataset query.
228
+ * The search trace (runSearchTrace) fires inside this span and auto-nests.
229
+ *
230
+ * When observability is disabled, `work` runs with a no-op handle.
231
+ */
232
+ export async function runEvalQuerySpan(props, work) {
233
+ if (!enabled)
234
+ return work(NOOP_HANDLE);
235
+ return startActiveObservation('eval-query', async (observation) => {
236
+ observation.update({
237
+ input: {
238
+ query: props.query,
239
+ goldenId: props.goldenId,
240
+ tags: props.tags,
241
+ expectedDocs: props.expectedDocs,
242
+ },
243
+ });
244
+ const handle = {
245
+ update: (data) => observation.update(data),
246
+ end: () => observation.end(),
247
+ };
248
+ return work(handle);
249
+ });
250
+ }
251
+ // =============================================================================
252
+ // Child span helpers
253
+ // =============================================================================
254
+ /**
255
+ * Emit a completed span with pre-computed duration.
256
+ *
257
+ * Used for sub-steps whose timing was measured elsewhere (e.g., the three
258
+ * retrieve.* sub-spans derived from the Postgres timing sidecar). Unlike
259
+ * startSpan, this does not return a handle. The span opens and closes
260
+ * immediately, carrying the measured duration as attributes.
261
+ *
262
+ * Uses OTel tracer so spans nest under the active context (same reason as
263
+ * startSpan). The startTime parameter backdates the span to align with the
264
+ * measured window.
265
+ *
266
+ * No-op when observability is disabled.
267
+ */
268
+ export function recordChildSpan(name, startMs, endMs, attributes) {
269
+ if (!enabled)
270
+ return;
271
+ const tracer = otelTrace.getTracer('langfuse-sdk');
272
+ const span = tracer.startSpan(name, { startTime: startMs });
273
+ span.setAttribute('langfuse.span.metadata', JSON.stringify({
274
+ ...attributes,
275
+ startMs,
276
+ endMs,
277
+ durationMs: endMs - startMs,
278
+ synthetic: true,
279
+ }));
280
+ span.end(endMs);
281
+ }
282
+ /**
283
+ * Execute work within an active OTel context for the given span.
284
+ *
285
+ * Child spans created inside `work` (via startSpan or recordChildSpan)
286
+ * will nest under this span. Used to activate a passive span created
287
+ * by startSpan before calling code that needs to emit children.
288
+ *
289
+ * No-op when observability is disabled or span has no OTel reference.
290
+ */
291
+ export async function withActiveSpan(handle, work) {
292
+ const activeHandle = handle;
293
+ if (!enabled || !activeHandle._otelSpan)
294
+ return work();
295
+ return otelContext.with(otelTrace.setSpan(otelContext.active(), activeHandle._otelSpan), work);
296
+ }
@@ -85,21 +85,22 @@ describe('opAddNote — unknown type handling', () => {
85
85
  describe('opAddNote — type registration', () => {
86
86
  it('registers type and saves note when register_type is true', async () => {
87
87
  const clients = createMockClients();
88
- const result = await opAddNote(clients, 'Tasting notes for 2024 Malbec', 'wine-log', 'claude-code', { upsert_key: 'wine-2024-malbec', description: 'Tasting notes', delivery: 'project' }, false, true);
88
+ const result = await opAddNote(clients, 'Tasting notes for 2024 Malbec', 'wine-log', 'claude-code', { upsert_key: 'wine-2024-malbec', description: 'Tasting notes', domain: 'general' }, false, true);
89
89
  // Should succeed (type got registered mid-call)
90
90
  expect(result.status).toBe('ok');
91
- // Type should now be in config
92
- expect(mockConfigState.current.types?.['wine-log']).toBe('project');
91
+ // Unknown type defaults to general domain → knowledge delivery tier
92
+ expect(mockConfigState.current.types?.['wine-log']).toBe('knowledge');
93
93
  });
94
- it('defaults delivery to knowledge when not specified', async () => {
94
+ it('defaults to general tier for unknown types (inferred from domain)', async () => {
95
95
  const clients = createMockClients();
96
- await opAddNote(clients, 'My recipe content', 'recipe', 'claude-code', { upsert_key: 'recipe-pasta', description: 'Pasta recipe' }, false, true);
96
+ await opAddNote(clients, 'My recipe content', 'recipe', 'claude-code', { upsert_key: 'recipe-pasta', description: 'Pasta recipe', status: 'active' }, false, true);
97
+ // Unknown types default to general domain, which maps to knowledge delivery tier
97
98
  expect(mockConfigState.current.types?.['recipe']).toBe('knowledge');
98
99
  });
99
100
  it('registered type persists for subsequent calls', async () => {
100
101
  const clients = createMockClients();
101
102
  // First call: register
102
- await opAddNote(clients, 'First wine note', 'wine-log', 'claude-code', { upsert_key: 'wine-1', description: 'First', delivery: 'project' }, false, true);
103
+ await opAddNote(clients, 'First wine note', 'wine-log', 'claude-code', { upsert_key: 'wine-1', description: 'First', domain: 'project' }, false, true);
103
104
  // Second call: should NOT need register_type anymore
104
105
  const result = await opAddNote(clients, 'Second wine note', 'wine-log', 'claude-code', { upsert_key: 'wine-2', description: 'Second' }, false, false);
105
106
  expect(result.status).toBe('ok');
@@ -19,9 +19,9 @@ export async function askMasked(question) {
19
19
  process.stdin.setRawMode(true);
20
20
  }
21
21
  process.stdin.resume();
22
- const onData = (buf) => {
23
- const c = buf.toString();
24
- if (c === '\n' || c === '\r') {
22
+ const onData = (buffer) => {
23
+ const char = buffer.toString();
24
+ if (char === '\n' || char === '\r') {
25
25
  process.stdin.removeListener('data', onData);
26
26
  if (process.stdin.isTTY) {
27
27
  process.stdin.setRawMode(false);
@@ -30,18 +30,18 @@ export async function askMasked(question) {
30
30
  process.stderr.write('\n');
31
31
  resolve(input.trim());
32
32
  }
33
- else if (c === '\u007f' || c === '\b') {
33
+ else if (char === '\u007f' || char === '\b') {
34
34
  if (input.length > 0) {
35
35
  input = input.slice(0, -1);
36
36
  process.stderr.write('\b \b');
37
37
  }
38
38
  }
39
- else if (c === '\u0003') {
39
+ else if (char === '\u0003') {
40
40
  // Ctrl+C
41
41
  process.exit(1);
42
42
  }
43
43
  else {
44
- input += c;
44
+ input += char;
45
45
  process.stderr.write('*');
46
46
  }
47
47
  };
@@ -53,13 +53,13 @@ export async function confirm(question) {
53
53
  return answer === 'y' || answer === 'yes';
54
54
  }
55
55
  export async function choose(question, options) {
56
- const optionList = options.map((o, i) => ` ${i + 1}. ${o}`).join('\n');
56
+ const optionList = options.map((option, index) => ` ${index + 1}. ${option}`).join('\n');
57
57
  const answer = await ask(`${question}\n${optionList}\n> `);
58
58
  const index = parseInt(answer, 10) - 1;
59
59
  if (index >= 0 && index < options.length) {
60
60
  return options[index];
61
61
  }
62
62
  // Try matching by name
63
- const match = options.find(o => o.toLowerCase().startsWith(answer));
63
+ const match = options.find(option => option.toLowerCase().startsWith(answer));
64
64
  return match || options[0];
65
65
  }
@@ -0,0 +1,103 @@
1
+ // rate-limiter.ts
2
+ // Provider-agnostic rate limiter using Bottleneck.
3
+ //
4
+ // Proactive pacing layer: controls how many API requests go out per minute
5
+ // and how many can run concurrently. Prevents 429 errors before they happen.
6
+ //
7
+ // The OpenAI SDK handles reactive retry (backoff after 429). This module
8
+ // handles proactive pacing (don't hit 429 in the first place).
9
+ //
10
+ // Usage: import the singleton instances (openaiLimiter, cohereLimiter) and
11
+ // wrap API calls with limiter.schedule(() => apiCall()).
12
+ import Bottleneck from 'bottleneck';
13
+ // =============================================================================
14
+ // Provider presets
15
+ // =============================================================================
16
+ // OpenAI Tier 1: 500 RPM. Safety margin: 90% = 450 RPM.
17
+ export const OPENAI_PRESET = {
18
+ maxConcurrent: 10,
19
+ reservoirAmount: 450,
20
+ reservoirRefreshInterval: 60_000,
21
+ minTime: 100,
22
+ retryLimit: 3,
23
+ };
24
+ // Cohere trial: 100 RPM. Safety margin: 90% = 90 RPM.
25
+ export const COHERE_PRESET = {
26
+ maxConcurrent: 5,
27
+ reservoirAmount: 90,
28
+ reservoirRefreshInterval: 60_000,
29
+ minTime: 200,
30
+ retryLimit: 3,
31
+ };
32
+ // =============================================================================
33
+ // Retryable status codes
34
+ // =============================================================================
35
+ const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
36
+ function isRetryableError(error) {
37
+ if (error && typeof error === 'object' && 'status' in error) {
38
+ return RETRYABLE_STATUS_CODES.has(error.status);
39
+ }
40
+ return false;
41
+ }
42
+ // =============================================================================
43
+ // Factory
44
+ // =============================================================================
45
+ /**
46
+ * Create a rate limiter with the given config.
47
+ *
48
+ * Returns a Bottleneck instance with:
49
+ * - Reservoir-based rate limiting (token bucket, refills each window)
50
+ * - Concurrency control (maxConcurrent parallel jobs)
51
+ * - Minimum spacing between requests (minTime)
52
+ * - Automatic retry on 429 and 5xx errors with exponential backoff
53
+ */
54
+ export function createRateLimiter(config) {
55
+ const limiter = new Bottleneck({
56
+ maxConcurrent: config.maxConcurrent,
57
+ reservoir: config.reservoirAmount,
58
+ reservoirRefreshAmount: config.reservoirAmount,
59
+ reservoirRefreshInterval: config.reservoirRefreshInterval,
60
+ minTime: config.minTime,
61
+ });
62
+ // Retry handler: Bottleneck calls this on job failure.
63
+ // Return a number (ms to wait) to retry, or void/undefined to give up.
64
+ limiter.on('failed', (error, jobInfo) => {
65
+ if (isRetryableError(error) && jobInfo.retryCount < config.retryLimit) {
66
+ // Exponential backoff with jitter: 1s, 2s, 4s, ...
67
+ const baseDelay = 1000 * Math.pow(2, jobInfo.retryCount);
68
+ const jitter = baseDelay * 0.25 * Math.random();
69
+ return baseDelay + jitter;
70
+ }
71
+ // Non-retryable or retries exhausted: don't retry (error propagates)
72
+ return undefined;
73
+ });
74
+ return limiter;
75
+ }
76
+ // =============================================================================
77
+ // Adaptive header reading
78
+ // =============================================================================
79
+ /**
80
+ * Adjust the limiter's reservoir based on OpenAI rate limit response headers.
81
+ *
82
+ * If OpenAI reports fewer remaining requests than our reservoir thinks,
83
+ * we adjust downward. This self-tunes without replacing the static baseline.
84
+ *
85
+ * Call this after each successful API request.
86
+ */
87
+ export async function updateLimitsFromHeaders(limiter, headers) {
88
+ const remaining = headers.get('x-ratelimit-remaining-requests');
89
+ if (remaining === null)
90
+ return;
91
+ const remainingCount = parseInt(remaining, 10);
92
+ if (isNaN(remainingCount))
93
+ return;
94
+ const currentReservoir = await limiter.currentReservoir();
95
+ if (currentReservoir !== null && remainingCount < currentReservoir) {
96
+ await limiter.updateSettings({ reservoir: remainingCount });
97
+ }
98
+ }
99
+ // =============================================================================
100
+ // Singleton instances
101
+ // =============================================================================
102
+ export const openaiLimiter = createRateLimiter(OPENAI_PRESET);
103
+ export const cohereLimiter = createRateLimiter(COHERE_PRESET);