@juspay/neurolink 11.25.3 → 11.26.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.
@@ -4,10 +4,6 @@ import type { NeuroLink } from "../neurolink.js";
4
4
  import type { UnknownRecord, MiddlewareFactoryOptions, StreamOptions, StreamResult, AIProvider, AnalyticsData, EnhancedGenerateResult, TextGenerationOptions, TextGenerationResult, ValidationSchema } from "../types/index.js";
5
5
  import { TelemetryHandler } from "./modules/TelemetryHandler.js";
6
6
  import type { LanguageModel, ModelMessage, Tool, ToolCallRepairFunction, ToolSet } from "../types/index.js";
7
- /**
8
- * Abstract base class for all AI providers
9
- * Tools are integrated as first-class citizens - always available by default
10
- */
11
7
  export declare abstract class BaseProvider implements AIProvider {
12
8
  protected modelName: string;
13
9
  protected readonly providerName: AIProviderName;
@@ -76,6 +72,11 @@ export declare abstract class BaseProvider implements AIProvider {
76
72
  * that supplied `onError`).
77
73
  */
78
74
  private fireLifecycleErrorCallback;
75
+ /**
76
+ * Build the fake-stream output and apply the same incremental TTS wrapper
77
+ * used by the standard NeuroLink stream path.
78
+ */
79
+ private createFakeStreamingOutput;
79
80
  /**
80
81
  * Execute fake streaming - extracted method for reusability
81
82
  */
@@ -387,6 +388,56 @@ export declare abstract class BaseProvider implements AIProvider {
387
388
  * original identity so that isAbortError() can detect them in
388
389
  * retry/fallback loops (directProviderGeneration, performMCPGenerationRetries).
389
390
  */
391
+ /**
392
+ * Classify an error that escaped while the consumer was ITERATING a stream.
393
+ *
394
+ * `stream()` only awaits the CONSTRUCTION of the provider's stream object, and a provider
395
+ * that discovers its failure lazily throws on first pull instead — so the raw upstream
396
+ * error reached the consumer with no provider tag and no classification, while the same
397
+ * failure through `generate()` was classified normally. Measured on OpenAI:
398
+ *
399
+ * streaming "You have no credits remaining."
400
+ * non-streaming "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
401
+ *
402
+ * Three guards. Only the third is load-bearing against today's code; the first two are
403
+ * deliberate depth against a hazard that is real but not currently reachable. Measured,
404
+ * rather than assumed — see the note after the list.
405
+ *
406
+ * 1. ALREADY STAMPED — everything that went through `handleProviderError` carries the mark,
407
+ * including the two formatters whose results escape the ProviderError hierarchy
408
+ * (`amazonSagemaker` returns SageMakerError, `replicate` returns NeuroLinkError; both
409
+ * extend Error directly). Without this they would be classified a second time and
410
+ * degraded.
411
+ * 2. ALREADY A ProviderError — covers providers that call `formatProviderError` DIRECTLY,
412
+ * bypassing `handleProviderError` and therefore the stamp. Anthropic does this in its
413
+ * own streaming catch (`anthropic/client.ts`), and its result is a ProviderError.
414
+ * 3. HAS AN HTTP STATUS — without this, an ordinary bug is relabelled as a provider
415
+ * failure. `classifyProviderError` ends in an unconditional catch-all
416
+ * (`utils/errorClassifier.ts`: `if (!rule) return new ProviderError(...)`) that is not
417
+ * gated on the error having come off the wire. Measured with the guard absent:
418
+ * TypeError "Cannot read properties of undefined (reading 'content')"
419
+ * became ProviderError "[openai] openai error: Cannot read properties of undefined..."
420
+ * which would hide a real defect behind a plausible provider message.
421
+ *
422
+ * WHY 1 AND 2 ARE NOT CURRENTLY REACHABLE, and why they stay anyway. A statusCode is
423
+ * attached to a formatted error in exactly one place — `handleProviderError` below — and
424
+ * that same method applies the stamp. So a ProviderError carrying a status is always
425
+ * stamped (caught by guard 1's own condition), and a ProviderError produced by a direct
426
+ * `formatProviderError` call carries no status, so guard 3 already returns it untouched.
427
+ * Verified by disabling guards 1 and 2 together, rebuilding, and re-running: the mocked
428
+ * provider contract suite stayed 64/64 and a mocked Anthropic streaming 429 was
429
+ * byte-identical (`RateLimitError`, one `[anthropic]` prefix).
430
+ *
431
+ * They remain because the hazard they cover is measured and real: `handleProviderError`
432
+ * is NOT idempotent — it copies statusCode onto its own output, so a second pass
433
+ * re-matches the bare 429 rule and DEGRADES the classification:
434
+ * pass 1 ProviderError "...quota exhausted — this will not resolve by retrying..."
435
+ * pass 2 RateLimitError "...rate limit exceeded..."
436
+ * The day any provider attaches a status to an error it formats itself, guard 3 stops
437
+ * covering that case and this degradation becomes live. Cheap insurance, not dead code —
438
+ * but do not cite guards 1 and 2 as proven-by-failure the way guard 3 is.
439
+ */
440
+ protected classifyStreamError(error: unknown): Error;
390
441
  protected handleProviderError(error: unknown): Error;
391
442
  /**
392
443
  * Image generation method. Providers that support it should override this.
@@ -1,10 +1,11 @@
1
1
  import { context, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
2
  import { directAgentTools } from "../agent/directTools.js";
3
- import { isImageGenerationModel } from "../core/constants.js";
3
+ import { isImageGenerationModel } from "./constants.js";
4
4
  import { MiddlewareFactory } from "../middleware/factory.js";
5
5
  import { modelSupports } from "../models/modelRegistry.js";
6
6
  import { ATTR, tracers } from "../telemetry/index.js";
7
7
  import { ERROR_CODES, isAbortError, NeuroLinkError, } from "../utils/errorHandling.js";
8
+ import { ProviderError } from "../types/index.js";
8
9
  import { sanitizeErrorCause } from "../utils/logSanitize.js";
9
10
  import { createAnalytics as buildAnalytics } from "./analytics.js";
10
11
  import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
@@ -12,6 +13,7 @@ import { duckTypedStatusCode, extractRetryAfterMsFromError, } from "../utils/pro
12
13
  import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifecycleCallbacks.js";
13
14
  import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
14
15
  import { logger } from "../utils/logger.js";
16
+ import { interleaveTTSStream } from "../utils/ttsStream.js";
15
17
  import { TimeoutError as AsyncTimeoutError, withTimeoutFn, } from "../utils/async/withTimeout.js";
16
18
  import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
17
19
  import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
@@ -48,6 +50,47 @@ function getLifecycleMiddlewareConfig(options) {
48
50
  * Abstract base class for all AI providers
49
51
  * Tools are integrated as first-class citizens - always available by default
50
52
  */
53
+ /**
54
+ * Marks an error as already run through `formatProviderError`.
55
+ *
56
+ * `handleProviderError` is NOT idempotent — measured: feeding its own output back in
57
+ * degrades a specific classification into a generic one, because it copies `statusCode`
58
+ * onto the formatted error, so a second pass re-matches the bare 429 rule while the more
59
+ * specific rule (which keyed on the raw body) no longer can:
60
+ *
61
+ * pass 1 ProviderError "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
62
+ * pass 2 RateLimitError "[openai] OpenAI rate limit exceeded. Please try again later."
63
+ *
64
+ * Since the stream path can now reach a classifier that other paths already reached, that
65
+ * second pass became possible and had to be made impossible.
66
+ *
67
+ * Same `Symbol.for` stamping technique as `utils/lifecycleCallbacks.ts` and for the same
68
+ * reason: it survives across module copies where a closed-over WeakSet would not, and a
69
+ * frozen error degrades to one extra classification rather than a throw.
70
+ */
71
+ const PROVIDER_ERROR_CLASSIFIED = Symbol.for("neurolink.providerErrorClassified");
72
+ function markProviderErrorClassified(error) {
73
+ if (error === null || typeof error !== "object") {
74
+ return;
75
+ }
76
+ try {
77
+ Object.defineProperty(error, PROVIDER_ERROR_CLASSIFIED, {
78
+ value: true,
79
+ enumerable: false,
80
+ writable: false,
81
+ configurable: false,
82
+ });
83
+ }
84
+ catch {
85
+ // Non-extensible error — worst case is one redundant classification.
86
+ }
87
+ }
88
+ function isProviderErrorClassified(error) {
89
+ if (error === null || typeof error !== "object") {
90
+ return false;
91
+ }
92
+ return error[PROVIDER_ERROR_CLASSIFIED] === true;
93
+ }
51
94
  export class BaseProvider {
52
95
  // Not `readonly` because providers that auto-discover the model from a
53
96
  // /v1/models endpoint (lm-studio, llamacpp) need to update modelName after
@@ -307,10 +350,14 @@ export class BaseProvider {
307
350
  */
308
351
  wrapStreamWithLifecycleCallbacks(result, options) {
309
352
  const lifecycle = getLifecycleMiddlewareConfig(options);
310
- if (!lifecycle?.onChunk && !lifecycle?.onFinish && !lifecycle?.onError) {
311
- return result;
312
- }
313
- const { onChunk, onFinish, onError } = lifecycle;
353
+ // No early return when there are no callbacks. This wrapper is the only point
354
+ // every provider's stream passes through unconditionally (the real-streaming path
355
+ // plus all three fake-streaming paths), and returning `result` untouched here is
356
+ // exactly why a provider error that surfaces during ITERATION reached the consumer
357
+ // unclassified: the object handed back was the provider's own generator, by
358
+ // reference, and every layer below is a proven passthrough. The callbacks below are
359
+ // each individually guarded, so with none registered this only adds the catch.
360
+ const { onChunk, onFinish, onError } = lifecycle ?? {};
314
361
  const startTime = Date.now();
315
362
  const originalStream = result.stream;
316
363
  // Lifecycle callbacks are awaited with a bounded deadline so callers
@@ -334,6 +381,9 @@ export class BaseProvider {
334
381
  logger.warn(`[lifecycle] ${label} callback error:`, e);
335
382
  }
336
383
  };
384
+ // Arrow, like `safeFire` above: the generator is a plain function expression, so
385
+ // `this` is not bound inside it.
386
+ const classifyStreamError = (e) => this.classifyStreamError(e);
337
387
  const wrappedStream = (async function* () {
338
388
  let accumulated = "";
339
389
  let seq = 0;
@@ -382,7 +432,7 @@ export class BaseProvider {
382
432
  recoverable: false,
383
433
  }), "onError");
384
434
  }
385
- throw err;
435
+ throw classifyStreamError(err);
386
436
  }
387
437
  })();
388
438
  return { ...result, stream: wrappedStream };
@@ -437,6 +487,61 @@ export class BaseProvider {
437
487
  logger.warn("[lifecycle] onError callback error:", e);
438
488
  }
439
489
  }
490
+ /**
491
+ * Build the fake-stream output and apply the same incremental TTS wrapper
492
+ * used by the standard NeuroLink stream path.
493
+ */
494
+ createFakeStreamingOutput(result, options, onTTSComplete) {
495
+ const incrementalTTS = options.tts?.enabled === true;
496
+ const source = (async function* () {
497
+ if (result?.content) {
498
+ const words = result.content.split(/(\s+)/);
499
+ let buffer = "";
500
+ for (let i = 0; i < words.length; i++) {
501
+ buffer += words[i];
502
+ const shouldYield = i === words.length - 1 ||
503
+ buffer.length > 50 ||
504
+ /[.!?;,]\s*$/.test(buffer);
505
+ if (shouldYield && buffer.trim()) {
506
+ yield { content: buffer };
507
+ buffer = "";
508
+ await new Promise((resolve) => {
509
+ setTimeout(resolve, Math.random() * 9 + 1);
510
+ });
511
+ }
512
+ }
513
+ if (buffer.trim()) {
514
+ yield { content: buffer };
515
+ }
516
+ }
517
+ if (result?.imageOutput) {
518
+ yield { type: "image", imageOutput: result.imageOutput };
519
+ }
520
+ if (result?.audio && !incrementalTTS) {
521
+ yield {
522
+ type: "tts_audio",
523
+ audio: {
524
+ data: result.audio.buffer,
525
+ format: result.audio.format,
526
+ index: 0,
527
+ isFinal: true,
528
+ cumulativeSize: result.audio.size,
529
+ voice: result.audio.voice,
530
+ sampleRate: result.audio.sampleRate,
531
+ },
532
+ };
533
+ }
534
+ })();
535
+ if (!incrementalTTS || !options.tts) {
536
+ return source;
537
+ }
538
+ return interleaveTTSStream({
539
+ stream: source,
540
+ provider: options.tts.provider ?? options.provider ?? this.providerName,
541
+ options: options.tts,
542
+ onComplete: onTTSComplete,
543
+ });
544
+ }
440
545
  /**
441
546
  * Execute fake streaming - extracted method for reusability
442
547
  */
@@ -476,10 +581,10 @@ export class BaseProvider {
476
581
  skipToolPromptInjection: options.skipToolPromptInjection,
477
582
  timeout: options.timeout,
478
583
  stt: options.stt,
479
- // Forward TTS options too without this, the fake-streaming fallback
480
- // path silently drops `tts` and the resulting StreamResult never
481
- // produces a `tts_audio` chunk even when synthesis was requested.
482
- tts: options.tts,
584
+ // Streaming TTS is synthesized incrementally by
585
+ // createFakeStreamingOutput; do not let generate() perform a duplicate
586
+ // input- or whole-response synthesis first.
587
+ tts: options.tts?.enabled ? undefined : options.tts,
483
588
  };
484
589
  logger.debug(`Calling generate for fake streaming`, {
485
590
  provider: this.providerName,
@@ -496,58 +601,45 @@ export class BaseProvider {
496
601
  hasImageOutput: !!result?.imageOutput,
497
602
  timestamp: Date.now(),
498
603
  });
499
- // Create a synthetic stream from the generate result that simulates progressive delivery
500
- return {
501
- stream: (async function* () {
502
- if (result?.content) {
503
- // Split content into words for more natural streaming
504
- const words = result.content.split(/(\s+)/); // Keep whitespace
505
- let buffer = "";
506
- for (let i = 0; i < words.length; i++) {
507
- buffer += words[i];
508
- // Yield chunks of roughly 5-10 words or at punctuation
509
- const shouldYield = i === words.length - 1 || // Last word
510
- buffer.length > 50 || // Buffer getting long
511
- /[.!?;,]\s*$/.test(buffer); // End of sentence/clause
512
- if (shouldYield && buffer.trim()) {
513
- yield { content: buffer };
514
- buffer = "";
515
- // Small delay to simulate streaming (1-10ms)
516
- await new Promise((resolve) => {
517
- setTimeout(resolve, Math.random() * 9 + 1);
518
- });
519
- }
604
+ const incrementalTTS = options.tts?.enabled === true;
605
+ const ttsProvider = options.tts?.provider ?? options.provider ?? this.providerName;
606
+ const ttsStartedAt = Date.now();
607
+ let resolveAudio;
608
+ let audioSettled = false;
609
+ const audio = incrementalTTS
610
+ ? new Promise((resolve) => {
611
+ resolveAudio = resolve;
612
+ }).catch(() => undefined)
613
+ : undefined;
614
+ const ttsMetadata = incrementalTTS
615
+ ? {
616
+ attempted: TTSProcessor.supports(ttsProvider),
617
+ success: false,
618
+ }
619
+ : result?.ttsMetadata;
620
+ const onTTSComplete = incrementalTTS
621
+ ? (ttsResult, error) => {
622
+ if (audioSettled) {
623
+ return;
624
+ }
625
+ audioSettled = true;
626
+ if (ttsMetadata) {
627
+ ttsMetadata.success =
628
+ error === undefined && ttsResult !== undefined;
629
+ if (error) {
630
+ ttsMetadata.error = error;
520
631
  }
521
- // Yield all remaining content
522
- if (buffer.trim()) {
523
- yield { content: buffer };
632
+ else {
633
+ delete ttsMetadata.error;
524
634
  }
635
+ ttsMetadata.latency = Date.now() - ttsStartedAt;
525
636
  }
526
- // 🔧 CRITICAL FIX: Yield image output if present
527
- if (result?.imageOutput) {
528
- yield {
529
- type: "image",
530
- imageOutput: result.imageOutput,
531
- };
532
- }
533
- // Yield synthesized audio so callers using stream() with tts.enabled
534
- // still receive a tts_audio chunk on the fake-streaming fallback
535
- // path (matches the discriminator used by the real streaming path).
536
- if (result?.audio) {
537
- yield {
538
- type: "tts_audio",
539
- audio: {
540
- data: result.audio.buffer,
541
- format: result.audio.format,
542
- index: 0,
543
- isFinal: true,
544
- cumulativeSize: result.audio.size,
545
- voice: result.audio.voice,
546
- sampleRate: result.audio.sampleRate,
547
- },
548
- };
549
- }
550
- })(),
637
+ resolveAudio?.(ttsResult);
638
+ }
639
+ : undefined;
640
+ // Create a synthetic stream from the generate result that simulates progressive delivery
641
+ return {
642
+ stream: this.createFakeStreamingOutput(result, options, onTTSComplete),
551
643
  usage: result?.usage,
552
644
  provider: result?.provider,
553
645
  model: result?.model,
@@ -569,6 +661,8 @@ export class BaseProvider {
569
661
  // 🔧 FIX: Include analytics and evaluation from generate result
570
662
  analytics: result?.analytics,
571
663
  evaluation: result?.evaluation,
664
+ audio,
665
+ ttsMetadata,
572
666
  };
573
667
  }
574
668
  catch (error) {
@@ -1573,6 +1667,65 @@ export class BaseProvider {
1573
1667
  * original identity so that isAbortError() can detect them in
1574
1668
  * retry/fallback loops (directProviderGeneration, performMCPGenerationRetries).
1575
1669
  */
1670
+ /**
1671
+ * Classify an error that escaped while the consumer was ITERATING a stream.
1672
+ *
1673
+ * `stream()` only awaits the CONSTRUCTION of the provider's stream object, and a provider
1674
+ * that discovers its failure lazily throws on first pull instead — so the raw upstream
1675
+ * error reached the consumer with no provider tag and no classification, while the same
1676
+ * failure through `generate()` was classified normally. Measured on OpenAI:
1677
+ *
1678
+ * streaming "You have no credits remaining."
1679
+ * non-streaming "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
1680
+ *
1681
+ * Three guards. Only the third is load-bearing against today's code; the first two are
1682
+ * deliberate depth against a hazard that is real but not currently reachable. Measured,
1683
+ * rather than assumed — see the note after the list.
1684
+ *
1685
+ * 1. ALREADY STAMPED — everything that went through `handleProviderError` carries the mark,
1686
+ * including the two formatters whose results escape the ProviderError hierarchy
1687
+ * (`amazonSagemaker` returns SageMakerError, `replicate` returns NeuroLinkError; both
1688
+ * extend Error directly). Without this they would be classified a second time and
1689
+ * degraded.
1690
+ * 2. ALREADY A ProviderError — covers providers that call `formatProviderError` DIRECTLY,
1691
+ * bypassing `handleProviderError` and therefore the stamp. Anthropic does this in its
1692
+ * own streaming catch (`anthropic/client.ts`), and its result is a ProviderError.
1693
+ * 3. HAS AN HTTP STATUS — without this, an ordinary bug is relabelled as a provider
1694
+ * failure. `classifyProviderError` ends in an unconditional catch-all
1695
+ * (`utils/errorClassifier.ts`: `if (!rule) return new ProviderError(...)`) that is not
1696
+ * gated on the error having come off the wire. Measured with the guard absent:
1697
+ * TypeError "Cannot read properties of undefined (reading 'content')"
1698
+ * became ProviderError "[openai] openai error: Cannot read properties of undefined..."
1699
+ * which would hide a real defect behind a plausible provider message.
1700
+ *
1701
+ * WHY 1 AND 2 ARE NOT CURRENTLY REACHABLE, and why they stay anyway. A statusCode is
1702
+ * attached to a formatted error in exactly one place — `handleProviderError` below — and
1703
+ * that same method applies the stamp. So a ProviderError carrying a status is always
1704
+ * stamped (caught by guard 1's own condition), and a ProviderError produced by a direct
1705
+ * `formatProviderError` call carries no status, so guard 3 already returns it untouched.
1706
+ * Verified by disabling guards 1 and 2 together, rebuilding, and re-running: the mocked
1707
+ * provider contract suite stayed 64/64 and a mocked Anthropic streaming 429 was
1708
+ * byte-identical (`RateLimitError`, one `[anthropic]` prefix).
1709
+ *
1710
+ * They remain because the hazard they cover is measured and real: `handleProviderError`
1711
+ * is NOT idempotent — it copies statusCode onto its own output, so a second pass
1712
+ * re-matches the bare 429 rule and DEGRADES the classification:
1713
+ * pass 1 ProviderError "...quota exhausted — this will not resolve by retrying..."
1714
+ * pass 2 RateLimitError "...rate limit exceeded..."
1715
+ * The day any provider attaches a status to an error it formats itself, guard 3 stops
1716
+ * covering that case and this degradation becomes live. Cheap insurance, not dead code —
1717
+ * but do not cite guards 1 and 2 as proven-by-failure the way guard 3 is.
1718
+ */
1719
+ classifyStreamError(error) {
1720
+ const err = error instanceof Error ? error : new Error(String(error));
1721
+ if (isProviderErrorClassified(err) || err instanceof ProviderError) {
1722
+ return err;
1723
+ }
1724
+ if (duckTypedStatusCode(err) === undefined) {
1725
+ return err;
1726
+ }
1727
+ return this.handleProviderError(err);
1728
+ }
1576
1729
  handleProviderError(error) {
1577
1730
  if (isAbortError(error)) {
1578
1731
  // Preserve AbortError identity — never wrap in provider-specific formatting
@@ -1644,6 +1797,10 @@ export class BaseProvider {
1644
1797
  catch {
1645
1798
  // Non-blocking — telemetry failures shouldn't mask the original error
1646
1799
  }
1800
+ // Stamp AFTER formatting so any later catch site can tell this error has
1801
+ // already been classified. Deliberately not applied to the AbortError
1802
+ // passthrough above — that returns the original error untouched.
1803
+ markProviderErrorClassified(formatted);
1647
1804
  return formatted;
1648
1805
  }
1649
1806
  /**
@@ -27,8 +27,8 @@ import { readdir, stat } from "fs/promises";
27
27
  import { homedir } from "os";
28
28
  import { join } from "path";
29
29
  import { calculateCost, hasPricing } from "../utils/pricing.js";
30
+ import { resolveScanCutoffMs } from "./scanWindow.js";
30
31
  const CLI_ID = "claude-code";
31
- const DEFAULT_SINCE_DAYS = 30;
32
32
  const PROVIDER = "anthropic";
33
33
  function projectsRoot() {
34
34
  return join(homedir(), ".claude", "projects");
@@ -190,11 +190,7 @@ export async function createClaudeCodeReader() {
190
190
  // 32.8s — the same unbounded sweep this guard exists to prevent, reached
191
191
  // by a different door. The old guard caught it with Number.isFinite and
192
192
  // the replacement dropped that check.
193
- const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
194
- const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
195
- const cutoff = sinceDays === Infinity
196
- ? undefined
197
- : Date.now() - Math.max(0, sinceDays) * 86_400_000;
193
+ const cutoff = resolveScanCutoffMs(options?.sinceDays);
198
194
  let filesScanned = 0;
199
195
  for (const file of files) {
200
196
  try {
@@ -31,8 +31,8 @@ import { createInterface } from "readline";
31
31
  import { readdir, stat } from "fs/promises";
32
32
  import { homedir } from "os";
33
33
  import { join } from "path";
34
+ import { resolveScanCutoffMs } from "./scanWindow.js";
34
35
  const CLI_ID = "codex";
35
- const DEFAULT_SINCE_DAYS = 30;
36
36
  function sessionsRoot() {
37
37
  return join(homedir(), ".codex", "sessions");
38
38
  }
@@ -178,11 +178,7 @@ export async function createCodexReader() {
178
178
  // 32.8s — the same unbounded sweep this guard exists to prevent, reached
179
179
  // by a different door. The old guard caught it with Number.isFinite and
180
180
  // the replacement dropped that check.
181
- const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
182
- const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
183
- const cutoff = sinceDays === Infinity
184
- ? undefined
185
- : Date.now() - Math.max(0, sinceDays) * 86_400_000;
181
+ const cutoff = resolveScanCutoffMs(options?.sinceDays);
186
182
  let filesScanned = 0;
187
183
  for (const file of files) {
188
184
  try {
@@ -35,8 +35,8 @@
35
35
  import { stat } from "fs/promises";
36
36
  import { homedir } from "os";
37
37
  import { join } from "path";
38
+ import { resolveScanCutoffMs } from "./scanWindow.js";
38
39
  const CLI_ID = "opencode";
39
- const DEFAULT_SINCE_DAYS = 30;
40
40
  function databasePath() {
41
41
  return join(homedir(), ".local", "share", "opencode", "opencode.db");
42
42
  }
@@ -131,11 +131,9 @@ export async function createOpenCodeReader() {
131
131
  // "no such column: NaN" — so the whole scan failed rather than
132
132
  // over-reading. The cutoff is a bound parameter now, so a value can
133
133
  // never be SQL syntax whatever it is.
134
- const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
135
- const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
136
- const cutoffMs = sinceDays === Infinity
137
- ? 0
138
- : Date.now() - Math.max(0, sinceDays) * 86_400_000;
134
+ // `?? 0` because this value becomes a SQL parameter: an unbounded scan
135
+ // is expressed as "since the epoch", never as a non-finite number.
136
+ const cutoffMs = resolveScanCutoffMs(options?.sinceDays) ?? 0;
139
137
  let db;
140
138
  try {
141
139
  // Read-only: this is the user's live store and OpenCode may be running.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * One definition of "how far back does a local-usage scan reach".
3
+ *
4
+ * This calculation lived in three copies — claudeCodeReader, codexReader and
5
+ * openCodeReader — and has been wrong three separate times, each a different
6
+ * door into the same failure: an unbounded sweep of every transcript on the
7
+ * machine in answer to the narrowest possible request.
8
+ *
9
+ * sinceDays: 0 left the cutoff undefined 17,534 files, 35.9s
10
+ * sinceDays: NaN Math.max(0, NaN) is NaN, and every comparison
11
+ * against NaN is false, so the filter passed
12
+ * everything 17,537 files, 32.8s
13
+ * sinceDays: MAX_VALUE MAX_VALUE * 86_400_000 overflows to Infinity, so the
14
+ * cutoff became -Infinity and every file passed
15
+ *
16
+ * Three fixes in three files each closed one door and left the others open.
17
+ * The logic lives here once now, so the next door closes everywhere at once.
18
+ *
19
+ * Contract: `Infinity` is the ONLY value meaning all-history. Anything that is
20
+ * not a usable finite window — NaN, negative, or so large the arithmetic stops
21
+ * being finite — collapses to a zero-length window, which is what a
22
+ * nonsensical request should read as.
23
+ */
24
+ export declare const DEFAULT_SINCE_DAYS = 30;
25
+ /**
26
+ * @returns the epoch-ms cutoff a scan must not read past, or `undefined` for an
27
+ * unbounded (all-history) scan — which only `Infinity` produces.
28
+ */
29
+ export declare function resolveScanCutoffMs(sinceDays: number | undefined, defaultDays?: number): number | undefined;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * One definition of "how far back does a local-usage scan reach".
3
+ *
4
+ * This calculation lived in three copies — claudeCodeReader, codexReader and
5
+ * openCodeReader — and has been wrong three separate times, each a different
6
+ * door into the same failure: an unbounded sweep of every transcript on the
7
+ * machine in answer to the narrowest possible request.
8
+ *
9
+ * sinceDays: 0 left the cutoff undefined 17,534 files, 35.9s
10
+ * sinceDays: NaN Math.max(0, NaN) is NaN, and every comparison
11
+ * against NaN is false, so the filter passed
12
+ * everything 17,537 files, 32.8s
13
+ * sinceDays: MAX_VALUE MAX_VALUE * 86_400_000 overflows to Infinity, so the
14
+ * cutoff became -Infinity and every file passed
15
+ *
16
+ * Three fixes in three files each closed one door and left the others open.
17
+ * The logic lives here once now, so the next door closes everywhere at once.
18
+ *
19
+ * Contract: `Infinity` is the ONLY value meaning all-history. Anything that is
20
+ * not a usable finite window — NaN, negative, or so large the arithmetic stops
21
+ * being finite — collapses to a zero-length window, which is what a
22
+ * nonsensical request should read as.
23
+ */
24
+ export const DEFAULT_SINCE_DAYS = 30;
25
+ const MS_PER_DAY = 86_400_000;
26
+ /**
27
+ * @returns the epoch-ms cutoff a scan must not read past, or `undefined` for an
28
+ * unbounded (all-history) scan — which only `Infinity` produces.
29
+ */
30
+ export function resolveScanCutoffMs(sinceDays, defaultDays = DEFAULT_SINCE_DAYS) {
31
+ const requested = sinceDays ?? defaultDays;
32
+ const days = Number.isNaN(requested) ? 0 : requested;
33
+ if (days === Infinity) {
34
+ return undefined;
35
+ }
36
+ const span = Math.max(0, days) * MS_PER_DAY;
37
+ // A finite `days` can still produce a non-finite span: Number.MAX_VALUE and
38
+ // anything near it overflow on the multiply. Treat that as no window rather
39
+ // than letting `Date.now() - Infinity` become -Infinity, which every
40
+ // timestamp compares as newer than.
41
+ return Date.now() - (Number.isFinite(span) ? span : 0);
42
+ }
@@ -945,7 +945,9 @@ export declare class NeuroLink {
945
945
  *
946
946
  * // Consume the stream
947
947
  * for await (const chunk of result.stream) {
948
- * process.stdout.write(chunk.content);
948
+ * if ("content" in chunk) {
949
+ * process.stdout.write(chunk.content);
950
+ * }
949
951
  * }
950
952
  *
951
953
  * // Advanced streaming with options
@@ -1031,25 +1033,10 @@ export declare class NeuroLink {
1031
1033
  private validateStreamRequestOptions;
1032
1034
  private maybeHandleWorkflowStreamRequest;
1033
1035
  private runStandardStreamRequest;
1034
- /**
1035
- * TTS Mode 2 synthesis helper for the stream() pipeline.
1036
- *
1037
- * m5 — extracted from runStandardStreamRequest so the surrounding generator
1038
- * stays under the max-lines-per-function lint budget. Behaviour preserved
1039
- * exactly:
1040
- * - When Mode 2 is enabled (`tts.enabled && tts.useAiResponse`) AND the
1041
- * model produced non-empty content: synthesises one final audio buffer
1042
- * and returns it as an `audioChunk` for the caller to `yield`. Resolves
1043
- * `ttsResolver` with the `TTSResult`.
1044
- * - When Mode 2 is enabled but synthesis fails: logs a warning and resolves
1045
- * `ttsResolver` with `undefined`.
1046
- * - When Mode 2 is requested but skipped (empty content / wrong mode):
1047
- * resolves `ttsResolver` with `undefined` early so callers awaiting
1048
- * `result.audio` unblock before the surrounding `finally` cleanup
1049
- * completes (Issue 7 latency micro-opt — the finally block also resolves
1050
- * defensively, so this is a redundant early signal, not a coverage fix).
1051
- */
1052
- private synthesizeStreamModeTwo;
1036
+ /** Wrap one provider stream with incremental TTS synthesis. */
1037
+ private createIncrementalTTSStream;
1038
+ /** Prevent provider fallback streams from duplicating outer streaming TTS. */
1039
+ private deferProviderStreamTTS;
1053
1040
  /**
1054
1041
  * Prepare stream options: initialize memory, MCP, retrieval, orchestration,
1055
1042
  * Ollama tool auto-disable, factory processing, and tool detection.