@vellumai/assistant 0.11.3-dev.202608132218.8ea1f84 → 0.11.3-dev.202608140136.76df6b7

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": "@vellumai/assistant",
3
- "version": "0.11.3-dev.202608132218.8ea1f84",
3
+ "version": "0.11.3-dev.202608140136.76df6b7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,7 @@ import {
20
20
  packageName,
21
21
  resolvePackage,
22
22
  } from "../bundler/package-resolver.js";
23
+ import { ensureBun } from "../util/bun-runtime.js";
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Shared temp directory
@@ -56,6 +57,29 @@ async function scaffold(
56
57
  return appDir;
57
58
  }
58
59
 
60
+ /**
61
+ * Download `pkg` into bun's global module cache via a throwaway install.
62
+ *
63
+ * `resolvePackage` bounds its own install at INSTALL_TIMEOUT_MS (10s), which a
64
+ * cold global cache can exceed for a large package on a slow network. Priming
65
+ * first is unbounded, so the install the test measures resolves from cache and
66
+ * stays well inside that bound.
67
+ */
68
+ async function primeBunCache(pkg: string): Promise<void> {
69
+ const primeDir = join(tempDir, `prime-${pkg.replaceAll(/[^a-z0-9]/gi, "-")}`);
70
+ await mkdir(primeDir, { recursive: true });
71
+ await writeFile(
72
+ join(primeDir, "package.json"),
73
+ JSON.stringify({ name: "prime-cache", private: true }),
74
+ );
75
+ const proc = Bun.spawn([await ensureBun(), "install", "--no-save", pkg], {
76
+ cwd: primeDir,
77
+ stdout: "ignore",
78
+ stderr: "ignore",
79
+ });
80
+ await proc.exited;
81
+ }
82
+
59
83
  const MINIMAL_HTML = `<!DOCTYPE html>
60
84
  <html>
61
85
  <head><title>Test</title></head>
@@ -466,10 +490,21 @@ describe("package-resolver", () => {
466
490
  expect(ALLOWED_PACKAGES).toContain("clsx");
467
491
  });
468
492
 
469
- // `lucide` (the vanilla package) unpacks to ~29 MB, far above the resolver's
470
- // size cap, so it is installed then deleted and never resolves. It also
471
- // exports icon data arrays, not Preact components. Apps use inline SVG.
493
+ // `lucide` (the vanilla package) exports icon data arrays, not Preact
494
+ // components, and unpacks to ~29 MB for no benefit. Apps use inline SVG.
472
495
  test("ALLOWED_PACKAGES excludes lucide", () => {
473
496
  expect(ALLOWED_PACKAGES).not.toContain("lucide");
474
497
  });
498
+
499
+ // `chart.js` is a large allowlisted package: it must install into the shared
500
+ // cache and resolve so apps importing it can compile.
501
+ test("resolvePackage installs a large allowlisted package", async () => {
502
+ await primeBunCache("chart.js");
503
+
504
+ const result = await resolvePackage("chart.js");
505
+ expect(result).not.toBeNull();
506
+ expect(existsSync(join(getCacheDir(), "node_modules", "chart.js"))).toBe(
507
+ true,
508
+ );
509
+ }, 60_000);
475
510
  });
@@ -30,10 +30,10 @@ import {
30
30
  } from "bun:test";
31
31
 
32
32
  import { _inspectHookCacheForTests } from "../hooks/hook-loader.js";
33
+ import { getUserHooksFor } from "../hooks/registry.js";
33
34
  import {
34
35
  _inspectToolCacheForTests,
35
36
  getCachedUserTools,
36
- getUserHooksFor,
37
37
  populateCacheAtBoot,
38
38
  reconcilePluginSourcesNow,
39
39
  resetPluginCacheForTests,
@@ -34,8 +34,8 @@ import {
34
34
  } from "bun:test";
35
35
 
36
36
  import { bootstrapPlugins } from "../daemon/external-plugins-bootstrap.js";
37
+ import { getUserHooksFor } from "../hooks/registry.js";
37
38
  import {
38
- getUserHooksFor,
39
39
  populateCacheAtBoot,
40
40
  reconcilePluginSourcesNow,
41
41
  resetPluginCacheForTests,
@@ -23,9 +23,9 @@ import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { beforeEach, describe, expect, test } from "bun:test";
25
25
 
26
+ import { getUserHooksFor } from "../hooks/registry.js";
26
27
  import {
27
28
  getCachedUserTools,
28
- getUserHooksFor,
29
29
  resetPluginCacheForTests,
30
30
  } from "../plugins/mtime-cache.js";
31
31
  import { loadUserPlugins } from "../plugins/user-loader.js";
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { existsSync } from "node:fs";
9
- import { mkdir, stat } from "node:fs/promises";
9
+ import { mkdir } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
 
12
12
  import { ensureBun } from "../util/bun-runtime.js";
@@ -24,8 +24,8 @@ export const ALLOWED_PACKAGES: readonly string[] = [
24
24
  "clsx",
25
25
  ] as const;
26
26
 
27
+ /** Bounds a runaway install; the allowlist above bounds what may be installed. */
27
28
  const INSTALL_TIMEOUT_MS = 10_000;
28
- const MAX_PACKAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
29
29
 
30
30
  /** In-flight install promises keyed by package name, to deduplicate concurrent requests. */
31
31
  const inflight = new Map<string, Promise<string | null>>();
@@ -148,37 +148,9 @@ async function installPackage(
148
148
  return null;
149
149
  }
150
150
 
151
- // Enforce max size
152
- if (existsSync(pkgDir)) {
153
- const size = await dirSize(pkgDir);
154
- if (size > MAX_PACKAGE_SIZE_BYTES) {
155
- log.warn({ pkg, size }, "Package exceeds size limit, removing");
156
- const { rm } = await import("node:fs/promises");
157
- await rm(pkgDir, { recursive: true, force: true });
158
- return null;
159
- }
160
- }
161
-
162
151
  return existsSync(pkgDir) ? nodeModulesDir : null;
163
152
  } catch (err) {
164
153
  log.warn({ pkg, err }, "Package resolution failed");
165
154
  return null;
166
155
  }
167
156
  }
168
-
169
- /** Recursively sum file sizes under a directory. */
170
- async function dirSize(dir: string): Promise<number> {
171
- const { readdir } = await import("node:fs/promises");
172
- const entries = await readdir(dir, { withFileTypes: true });
173
- let total = 0;
174
- for (const entry of entries) {
175
- const full = join(dir, entry.name);
176
- if (entry.isDirectory()) {
177
- total += await dirSize(full);
178
- } else {
179
- const s = await stat(full);
180
- total += s.size;
181
- }
182
- }
183
- return total;
184
- }
@@ -21,6 +21,7 @@ import type { TrustContext } from "../daemon/trust-context-types.js";
21
21
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
22
22
  import { computeToolApprovalDigest } from "../security/tool-approval-digest.js";
23
23
  import { getCatalogProvider } from "../tts/provider-catalog.js";
24
+ import { createReasoningTagFilter } from "../tts/reasoning-tag-filter.js";
24
25
  import { extractSpeakableSegments } from "../tts/speakable-segments.js";
25
26
  import {
26
27
  type AudioStoreSink,
@@ -728,6 +729,10 @@ export class CallController {
728
729
  // could be the start of a control marker.
729
730
  let ttsBuffer = "";
730
731
  let fullResponseText = "";
732
+ // Reasoning models can inline <think> spans in the content stream when a
733
+ // profile has not opted into parseThinkTags; the spoken path must never
734
+ // read them aloud. fullResponseText keeps the raw stream.
735
+ const reasoningFilter = createReasoningTagFilter();
731
736
 
732
737
  // Synthesized path: text is split at speakable boundaries as it streams
733
738
  // and each segment is synthesized while the LLM keeps generating. The
@@ -931,7 +936,7 @@ export class CallController {
931
936
  return;
932
937
  }
933
938
  fullResponseText += text;
934
- ttsBuffer += text;
939
+ ttsBuffer += reasoningFilter.push(text);
935
940
  ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
936
941
  flushSafeText();
937
942
  };
@@ -1010,7 +1015,9 @@ export class CallController {
1010
1015
  return fullResponseText;
1011
1016
  }
1012
1017
 
1013
- // Final sweep: strip any remaining control markers from the buffer
1018
+ // Final sweep: release any held-back partial tag, then strip any
1019
+ // remaining control markers from the buffer.
1020
+ ttsBuffer += reasoningFilter.flush();
1014
1021
  ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
1015
1022
  if (ttsBuffer.length > 0) {
1016
1023
  emitSafeChunk(ttsBuffer);
@@ -39,9 +39,9 @@
39
39
  * registry, so the next read re-resolves fresh.
40
40
  *
41
41
  * Plugin *discovery* (which plugin directories exist, in what order) lives in
42
- * `../plugins/mtime-cache.ts`; the orchestrator there passes the discovered
43
- * directories into {@link collectUserHookEntries}. Keeping discovery out of
44
- * this module lets it sit below the plugin cache with no import cycle.
42
+ * `../plugins/mtime-cache.ts`; hook lookup in `./registry.ts` passes the
43
+ * discovered names into {@link collectUserHookEntries}. Keeping discovery out
44
+ * of this module lets it sit below the plugin cache with no import cycle.
45
45
  */
46
46
 
47
47
  import {
@@ -6,20 +6,22 @@
6
6
  * hook-chain ordering.
7
7
  *
8
8
  * User-land hooks (from the filesystem) are owned by
9
- * {@link ./hook-loader.ts} and surfaced through `getUserHooksFor` in
10
- * `plugins/mtime-cache.ts`. This module owns only the in-process hooks that
11
- * default plugins register at boot.
9
+ * {@link ./hook-loader.ts} and surfaced through {@link getUserHooksFor}.
10
+ * This module owns the in-process hooks that default plugins register at
11
+ * boot, plus the user-land lookup that walks discovered plugin names from
12
+ * the plugin cache.
12
13
  *
13
14
  * {@link getHooksFor} combines both sources: in-process hooks from this
14
15
  * registry (filtered by `isPluginDisabled` at read time) and user-land hooks
15
16
  * from the plugin cache. The read-time filtering is what makes `assistant
16
- * plugins disable default-*` take effect immediately in a running assistant
17
- * — the hooks stay registered but are filtered out on the next turn.
17
+ * plugins disable default-*` take effect immediately in a running assistant:
18
+ * the hooks stay registered but are filtered out on the next turn.
18
19
  */
19
20
 
20
21
  import { isPluginDisabled } from "../plugins/disabled-state.js";
21
- import { getUserHookEntriesFor } from "../plugins/mtime-cache.js";
22
+ import { getDiscoveredUserPluginNames } from "../plugins/mtime-cache.js";
22
23
  import type { HookEntry, HookFunction } from "../plugins/types.js";
24
+ import { collectUserHookEntries } from "./hook-loader.js";
23
25
 
24
26
  // ─── Internal state ──────────────────────────────────────────────────────────
25
27
 
@@ -75,6 +77,48 @@ export function unregisterPluginHooks(pluginName: string): void {
75
77
 
76
78
  // ─── Queries ─────────────────────────────────────────────────────────────────
77
79
 
80
+ /**
81
+ * Get all hooks for a given event name from user plugins and standalone
82
+ * workspace hooks, from the hook loader's in-memory cache. Plugin hooks run
83
+ * in install-date order, the workspace hook runs last.
84
+ *
85
+ * This is a pure cache read: it never scans disk, activates a plugin, or
86
+ * runs `init`. Activation happens only at boot (`populateCacheAtBoot`)
87
+ * and through the imperative install/uninstall poke
88
+ * (`reconcilePluginSourcesNow`), both main-daemon paths, so a
89
+ * sidecar process that dispatches hooks (a worker running conversation
90
+ * turns) can never bring a plugin up in its own process.
91
+ *
92
+ * `effectiveEnabledPlugins` carries the per-chat plugin scope: when non-null,
93
+ * user plugins outside the set are skipped (standalone workspace hooks always
94
+ * run). `null`/omitted means no per-chat restriction.
95
+ */
96
+ export async function getUserHookEntriesFor<TCtx = unknown>(
97
+ hookName: string,
98
+ effectiveEnabledPlugins?: Set<string> | null,
99
+ ): Promise<HookEntry<TCtx>[]> {
100
+ return collectUserHookEntries<TCtx>(
101
+ hookName,
102
+ getDiscoveredUserPluginNames(),
103
+ effectiveEnabledPlugins,
104
+ );
105
+ }
106
+
107
+ /**
108
+ * {@link getUserHookEntriesFor} without owner attribution. Returns just the
109
+ * hook functions in the same order.
110
+ */
111
+ export async function getUserHooksFor<TCtx = unknown>(
112
+ hookName: string,
113
+ effectiveEnabledPlugins?: Set<string> | null,
114
+ ): Promise<HookFunction<TCtx>[]> {
115
+ const entries = await getUserHookEntriesFor<TCtx>(
116
+ hookName,
117
+ effectiveEnabledPlugins,
118
+ );
119
+ return entries.map((e) => e.fn);
120
+ }
121
+
78
122
  /**
79
123
  * Collect every registered hook for the given name, in registration order.
80
124
  * Plugins that don't declare a hook for `name` are skipped. Used by the
@@ -73,6 +73,10 @@ import type {
73
73
  import { getSubagentManager } from "../subagent/index.js";
74
74
  import { liveVoiceEndScreen } from "../telemetry/live-voice-funnel.js";
75
75
  import { getToolOwner } from "../tools/registry.js";
76
+ import {
77
+ createReasoningTagFilter,
78
+ type ReasoningTagFilter,
79
+ } from "../tts/reasoning-tag-filter.js";
76
80
  import { extractSpeakableSegments } from "../tts/speakable-segments.js";
77
81
  import { createAbortReason } from "../util/abort-reasons.js";
78
82
  import { hasLocalizedEntry } from "../util/language-subtag.js";
@@ -717,6 +721,10 @@ interface ActiveAssistantTurn {
717
721
  // the hand-off idempotent.
718
722
  escalationHandedOff: boolean;
719
723
  ttsBuffer: string;
724
+ // Strips inline <think> reasoning spans from the spoken stream. Stateful
725
+ // per turn because spans and tags cross delta boundaries; display frames
726
+ // keep the raw text.
727
+ ttsReasoningFilter: ReasoningTagFilter;
720
728
  // A non-empty speakable segment reached the TTS queue — gates the eager
721
729
  // first-segment flush that trades clause quality for speech onset.
722
730
  ttsSegmentEnqueued: boolean;
@@ -4503,6 +4511,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4503
4511
  deltaEpoch: 0,
4504
4512
  escalationHandedOff: false,
4505
4513
  ttsBuffer: "",
4514
+ ttsReasoningFilter: createReasoningTagFilter(),
4506
4515
  ttsSegmentEnqueued: false,
4507
4516
  ttsJobs: [],
4508
4517
  ttsQueue: Promise.resolve(),
@@ -5492,7 +5501,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5492
5501
  return;
5493
5502
  }
5494
5503
 
5495
- activeTurn.ttsBuffer += text;
5504
+ activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.push(text);
5496
5505
  this.flushTtsBuffer(token, false);
5497
5506
  }
5498
5507
 
@@ -5503,6 +5512,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5503
5512
  }
5504
5513
 
5505
5514
  this.clearFillerTimers(activeTurn);
5515
+ activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.flush();
5506
5516
  this.flushTtsBuffer(token, true);
5507
5517
  activeTurn.ttsQueue = activeTurn.ttsQueue
5508
5518
  .catch(() => {})
@@ -26,9 +26,9 @@
26
26
  * - Plugins are never "registered" as a unit — we register their tools into
27
27
  * the global tool registry.
28
28
  *
29
- * Tools are populated at boot by `loadUserPlugins()`; `getHooksFor` reads
30
- * hooks on every dispatch via {@link getUserHookEntriesFor}, which resolves
31
- * each hook through the hook loader on demand.
29
+ * Tools are populated at boot by `loadUserPlugins()`; hook dispatch reads
30
+ * discovered plugin names from this cache via {@link getDiscoveredUserPluginNames}
31
+ * and resolves each hook through the hook loader on demand.
32
32
  */
33
33
 
34
34
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
@@ -38,7 +38,6 @@ import type { Logger } from "pino";
38
38
 
39
39
  import {
40
40
  clearPluginHooks,
41
- collectUserHookEntries,
42
41
  evictHooksForOwner,
43
42
  hasWorkspaceHooks,
44
43
  type HookOwnerKind,
@@ -47,7 +46,7 @@ import {
47
46
  runShutdownHook,
48
47
  WORKSPACE_HOOKS_OWNER,
49
48
  } from "../hooks/hook-loader.js";
50
- import type { HookFunction, ShutdownReason } from "../plugin-api/types.js";
49
+ import type { ShutdownReason } from "../plugin-api/types.js";
51
50
  import {
52
51
  registerPluginSecretPatterns,
53
52
  resetPluginSecretPatternsForTests,
@@ -76,11 +75,7 @@ import {
76
75
  importWithTimeout,
77
76
  setSurfaceImportTimeout,
78
77
  } from "./surface-import.js";
79
- import type { HookEntry, PluginCredentialKeyPattern } from "./types.js";
80
-
81
- // Re-export for type compat — consumers that import HookFunction from
82
- // the mtime cache module still resolve.
83
- export type { HookFunction } from "./types.js";
78
+ import type { PluginCredentialKeyPattern } from "./types.js";
84
79
 
85
80
  const log = getLogger("plugin-mtime-cache");
86
81
 
@@ -206,48 +201,15 @@ let lastVersions: Record<string, PluginSourceVersion> = {};
206
201
  /** In-flight reconcile — concurrent imperative pokes await it rather than racing. */
207
202
  let reconcileInFlight: Promise<void> | null = null;
208
203
 
209
- // ─── Hook reads ──────────────────────────────────────────────────────────────
204
+ // ─── Discovery reads ─────────────────────────────────────────────────────────
210
205
 
211
206
  /**
212
- * Get all hooks for a given event name from user plugins and standalone
213
- * workspace hooks, from the hook loader's in-memory cache. Plugin hooks run
214
- * in install-date order, the workspace hook runs last.
215
- *
216
- * This is a pure cache read: it never scans disk, activates a plugin, or
217
- * runs `init`. Activation happens only at boot ({@link populateCacheAtBoot})
218
- * and through the imperative install/uninstall poke
219
- * ({@link reconcilePluginSourcesNow}) — both main-daemon paths — so a
220
- * sidecar process that dispatches hooks (a worker running conversation
221
- * turns) can never bring a plugin up in its own process.
222
- *
223
- * `effectiveEnabledPlugins` carries the per-chat plugin scope: when non-null,
224
- * user plugins outside the set are skipped (standalone workspace hooks always
225
- * run). `null`/omitted means no per-chat restriction.
207
+ * Plugin names currently in the discovery cache, in install-date order.
208
+ * Hook lookup in `hooks/registry.ts` walks this set; the cache itself does
209
+ * not resolve hooks.
226
210
  */
227
- export async function getUserHookEntriesFor<TCtx = unknown>(
228
- hookName: string,
229
- effectiveEnabledPlugins?: Set<string> | null,
230
- ): Promise<HookEntry<TCtx>[]> {
231
- return collectUserHookEntries<TCtx>(
232
- hookName,
233
- discoveredPluginDirs.values(),
234
- effectiveEnabledPlugins,
235
- );
236
- }
237
-
238
- /**
239
- * {@link getUserHookEntriesFor} without owner attribution — returns just the
240
- * hook functions in the same order.
241
- */
242
- export async function getUserHooksFor<TCtx = unknown>(
243
- hookName: string,
244
- effectiveEnabledPlugins?: Set<string> | null,
245
- ): Promise<HookFunction<TCtx>[]> {
246
- const entries = await getUserHookEntriesFor<TCtx>(
247
- hookName,
248
- effectiveEnabledPlugins,
249
- );
250
- return entries.map((e) => e.fn);
211
+ export function getDiscoveredUserPluginNames(): Iterable<string> {
212
+ return discoveredPluginDirs.values();
251
213
  }
252
214
 
253
215
  // ─── Source-versions reconcile ───────────────────────────────────────────────
@@ -431,7 +431,8 @@ export function unregisterPluginTools(pluginName: string): void {
431
431
  * {@link import("../plugins/mtime-cache.js").getActiveUserPluginTools} and diffs
432
432
  * the result into the registry: plugins that vanished are unregistered, new
433
433
  * plugins register, and a plugin whose per-tool source mtimes moved is
434
- * re-registered. Mirrors how hooks resolve through `getUserHookEntriesFor`.
434
+ * re-registered. Mirrors how hooks resolve through `getUserHookEntriesFor`
435
+ * in `hooks/registry.ts`.
435
436
  *
436
437
  * This is a pure read of the plugin cache — it does NOT reconcile the cache
437
438
  * against the source-versions sentinel, so it never activates a plugin or runs
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { createReasoningTagFilter } from "../reasoning-tag-filter.js";
4
+
5
+ function run(chunks: string[]): string {
6
+ const filter = createReasoningTagFilter();
7
+ let out = "";
8
+ for (const chunk of chunks) {
9
+ out += filter.push(chunk);
10
+ }
11
+ out += filter.flush();
12
+ return out;
13
+ }
14
+
15
+ describe("ReasoningTagFilter", () => {
16
+ test("passes plain text through unchanged", () => {
17
+ expect(run(["Hello ", "there."])).toBe("Hello there.");
18
+ });
19
+
20
+ test("drops a complete think span within one delta", () => {
21
+ expect(run(["<think>hm, let me reason</think>Answer."])).toBe("Answer.");
22
+ });
23
+
24
+ test("drops a reasoning span crossing many deltas", () => {
25
+ expect(
26
+ run(["<thi", "nk>step one", " step two", "</th", "ink>Spoken reply"]),
27
+ ).toBe("Spoken reply");
28
+ });
29
+
30
+ test("drops thinking-tag variant and is case-insensitive", () => {
31
+ expect(run(["<Thinking>inner</Thinking>ok"])).toBe("ok");
32
+ });
33
+
34
+ test("keeps text before and after the span", () => {
35
+ expect(run(["Sure. <think>secret</think> Done."])).toBe("Sure. Done.");
36
+ });
37
+
38
+ test("handles multiple spans in one turn", () => {
39
+ expect(run(["a<think>x</think>b", "<think>y", "z</think>c"])).toBe("abc");
40
+ });
41
+
42
+ test("an unclosed span at end of stream stays dropped", () => {
43
+ expect(run(["ok<think>never closes"])).toBe("ok");
44
+ });
45
+
46
+ test("a dangling partial open tag is released as literal text at flush", () => {
47
+ expect(run(["price <th"])).toBe("price <th");
48
+ });
49
+
50
+ test("a false partial that disambiguates to text is released", () => {
51
+ expect(run(["a <th", "ree-step plan"])).toBe("a <three-step plan");
52
+ });
53
+
54
+ test("does not touch angle brackets that are not think tags", () => {
55
+ expect(run(["use <b>bold</b> and x < y"])).toBe(
56
+ "use <b>bold</b> and x < y",
57
+ );
58
+ });
59
+
60
+ test("holds back nothing across an empty stream", () => {
61
+ expect(run([""])).toBe("");
62
+ });
63
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Streaming reasoning-tag filter for the TTS pipelines.
3
+ *
4
+ * Some OpenAI-compatible reasoning models emit their chain of thought inline
5
+ * in the content stream as `<think>...</think>` (or `<thinking>...</thinking>`)
6
+ * spans. When a profile has not opted into the chat-completions provider's
7
+ * `parseThinkTags` (which re-routes those spans to `thinking_delta`), the raw
8
+ * reasoning rides `text_delta`, and anything downstream that speaks text
9
+ * deltas would read the model's inner monologue aloud and delay real speech
10
+ * by the full length of the reasoning.
11
+ *
12
+ * This filter is the TTS-boundary guard: voice output must never speak
13
+ * reasoning regardless of profile configuration. It is stateful because a
14
+ * tag or a reasoning span routinely crosses delta boundaries; feed every
15
+ * delta through {@link ReasoningTagFilter.push} and call
16
+ * {@link ReasoningTagFilter.flush} when the turn's stream ends.
17
+ *
18
+ * Display paths are deliberately untouched: `assistant_text_delta` frames
19
+ * keep the raw text, exactly like the markdown handling in
20
+ * `calls/tts-text-sanitizer.ts`.
21
+ */
22
+
23
+ const OPEN_TAGS = ["<think>", "<thinking>"] as const;
24
+ const CLOSE_TAGS = ["</think>", "</thinking>"] as const;
25
+
26
+ /** Longest suffix of `text` that is a proper prefix of any listed tag. */
27
+ function partialTagSuffix(text: string, tags: readonly string[]): number {
28
+ const max = Math.max(...tags.map((tag) => tag.length)) - 1;
29
+ const window = Math.min(max, text.length);
30
+ for (let len = window; len > 0; len -= 1) {
31
+ const suffix = text.slice(text.length - len).toLowerCase();
32
+ if (tags.some((tag) => tag.startsWith(suffix))) {
33
+ return len;
34
+ }
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ function indexOfAny(
40
+ haystack: string,
41
+ tags: readonly string[],
42
+ ): { index: number; tag: string } | null {
43
+ const lower = haystack.toLowerCase();
44
+ let best: { index: number; tag: string } | null = null;
45
+ for (const tag of tags) {
46
+ const index = lower.indexOf(tag);
47
+ if (index >= 0 && (best === null || index < best.index)) {
48
+ best = { index, tag };
49
+ }
50
+ }
51
+ return best;
52
+ }
53
+
54
+ export class ReasoningTagFilter {
55
+ private insideReasoning = false;
56
+ private pending = "";
57
+
58
+ /**
59
+ * Feed one streamed delta; returns the speakable text it releases.
60
+ * Reasoning spans are dropped; a partial tag at the end of the buffer is
61
+ * held back until the next delta disambiguates it.
62
+ */
63
+ push(text: string): string {
64
+ this.pending += text;
65
+ let out = "";
66
+ for (;;) {
67
+ if (this.insideReasoning) {
68
+ const close = indexOfAny(this.pending, CLOSE_TAGS);
69
+ if (close) {
70
+ this.pending = this.pending.slice(close.index + close.tag.length);
71
+ this.insideReasoning = false;
72
+ continue;
73
+ }
74
+ // Everything buffered is reasoning except a possible partial close
75
+ // tag at the end; drop the reasoning, keep the partial.
76
+ const partial = partialTagSuffix(this.pending, CLOSE_TAGS);
77
+ this.pending = partial > 0 ? this.pending.slice(-partial) : "";
78
+ return out;
79
+ }
80
+ const open = indexOfAny(this.pending, OPEN_TAGS);
81
+ if (open) {
82
+ out += this.pending.slice(0, open.index);
83
+ this.pending = this.pending.slice(open.index + open.tag.length);
84
+ this.insideReasoning = true;
85
+ continue;
86
+ }
87
+ const partial = partialTagSuffix(this.pending, OPEN_TAGS);
88
+ const safeLength = this.pending.length - partial;
89
+ out += this.pending.slice(0, safeLength);
90
+ this.pending = partial > 0 ? this.pending.slice(safeLength) : "";
91
+ return out;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * End of stream: release any held-back partial. Outside a reasoning span a
97
+ * dangling "<thin" was ordinary text after all; inside one, the span never
98
+ * closed and its content stays dropped.
99
+ */
100
+ flush(): string {
101
+ const rest = this.insideReasoning ? "" : this.pending;
102
+ this.pending = "";
103
+ this.insideReasoning = false;
104
+ return rest;
105
+ }
106
+ }
107
+
108
+ export function createReasoningTagFilter(): ReasoningTagFilter {
109
+ return new ReasoningTagFilter();
110
+ }