@vellumai/assistant 0.5.7 → 0.5.8

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 (197) hide show
  1. package/Dockerfile +2 -1
  2. package/docker-entrypoint.sh +9 -0
  3. package/docs/architecture/memory.md +13 -11
  4. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  5. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  6. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  7. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  8. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/approval-cascade.test.ts +0 -1
  11. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  12. package/src/__tests__/call-controller.test.ts +0 -1
  13. package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
  14. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  15. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  16. package/src/__tests__/config-schema.test.ts +2 -0
  17. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  21. package/src/__tests__/conversation-error.test.ts +15 -1
  22. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  23. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  24. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  25. package/src/__tests__/conversation-queue.test.ts +0 -1
  26. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  27. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  30. package/src/__tests__/credential-execution-client.test.ts +5 -2
  31. package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
  32. package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
  33. package/src/__tests__/credential-security-e2e.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +2 -5
  35. package/src/__tests__/credentials-cli.test.ts +4 -3
  36. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  37. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  38. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  39. package/src/__tests__/journal-context.test.ts +335 -0
  40. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  41. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  42. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  43. package/src/__tests__/memory-regressions.test.ts +408 -363
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  45. package/src/__tests__/non-member-access-request.test.ts +2 -2
  46. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  47. package/src/__tests__/oauth-cli.test.ts +5 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  49. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  50. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  51. package/src/__tests__/relay-server.test.ts +1 -2
  52. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  53. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  54. package/src/__tests__/secure-keys.test.ts +18 -15
  55. package/src/__tests__/skill-memory.test.ts +17 -3
  56. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  57. package/src/__tests__/stt-hints.test.ts +437 -0
  58. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  59. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  60. package/src/__tests__/voice-quality.test.ts +58 -0
  61. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  62. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
  63. package/src/acp/agent-process.ts +9 -1
  64. package/src/agent/loop.ts +1 -1
  65. package/src/approvals/guardian-request-resolvers.ts +164 -38
  66. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  67. package/src/calls/call-controller.ts +9 -5
  68. package/src/calls/fish-audio-client.ts +26 -14
  69. package/src/calls/stt-hints.ts +189 -0
  70. package/src/calls/tts-text-sanitizer.ts +61 -0
  71. package/src/calls/twilio-routes.ts +32 -4
  72. package/src/calls/voice-quality.ts +15 -3
  73. package/src/calls/voice-session-bridge.ts +1 -0
  74. package/src/cli/commands/avatar.ts +2 -2
  75. package/src/cli/commands/credentials.ts +110 -94
  76. package/src/cli/commands/doctor.ts +2 -2
  77. package/src/cli/commands/keys.ts +7 -7
  78. package/src/cli/commands/memory.ts +1 -1
  79. package/src/cli/commands/oauth/connections.ts +11 -29
  80. package/src/cli/commands/oauth/platform.ts +389 -43
  81. package/src/cli/lib/daemon-credential-client.ts +284 -0
  82. package/src/cli.ts +1 -1
  83. package/src/config/bundled-skills/AGENTS.md +34 -0
  84. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  85. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  86. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  87. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  88. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  89. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  90. package/src/config/bundled-skills/settings/TOOLS.json +46 -1
  91. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  92. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  93. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  94. package/src/config/bundled-tool-registry.ts +4 -0
  95. package/src/config/defaults.ts +0 -2
  96. package/src/config/env-registry.ts +4 -4
  97. package/src/config/env.ts +14 -1
  98. package/src/config/feature-flag-registry.json +1 -1
  99. package/src/config/loader.ts +8 -11
  100. package/src/config/schema.ts +5 -16
  101. package/src/config/schemas/calls.ts +17 -0
  102. package/src/config/schemas/inference.ts +2 -2
  103. package/src/config/schemas/journal.ts +16 -0
  104. package/src/config/schemas/memory-processing.ts +2 -2
  105. package/src/config/types.ts +1 -0
  106. package/src/contacts/contact-store.ts +2 -2
  107. package/src/credential-execution/executable-discovery.ts +1 -1
  108. package/src/credential-execution/startup-timeout.ts +36 -0
  109. package/src/daemon/approval-generators.ts +3 -9
  110. package/src/daemon/conversation-error.ts +13 -1
  111. package/src/daemon/conversation-memory.ts +1 -2
  112. package/src/daemon/conversation-process.ts +18 -1
  113. package/src/daemon/conversation-surfaces.ts +30 -1
  114. package/src/daemon/conversation.ts +20 -9
  115. package/src/daemon/guardian-action-generators.ts +3 -9
  116. package/src/daemon/lifecycle.ts +18 -11
  117. package/src/daemon/message-types/conversations.ts +1 -0
  118. package/src/daemon/server.ts +2 -3
  119. package/src/memory/app-store.ts +31 -0
  120. package/src/memory/db-init.ts +4 -0
  121. package/src/memory/indexer.ts +19 -10
  122. package/src/memory/items-extractor.ts +315 -322
  123. package/src/memory/job-handlers/summarization.ts +26 -16
  124. package/src/memory/jobs-store.ts +33 -1
  125. package/src/memory/journal-memory.ts +214 -0
  126. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  127. package/src/memory/migrations/index.ts +1 -0
  128. package/src/memory/migrations/registry.ts +8 -0
  129. package/src/memory/retriever.test.ts +37 -25
  130. package/src/memory/retriever.ts +24 -49
  131. package/src/memory/schema/memory-core.ts +2 -0
  132. package/src/memory/search/formatting.ts +7 -44
  133. package/src/memory/search/staleness.ts +4 -0
  134. package/src/memory/search/tier-classifier.ts +10 -2
  135. package/src/memory/search/types.ts +2 -5
  136. package/src/memory/task-memory-cleanup.ts +4 -3
  137. package/src/notifications/adapters/slack.ts +168 -6
  138. package/src/notifications/broadcaster.ts +1 -0
  139. package/src/notifications/copy-composer.ts +59 -2
  140. package/src/notifications/signal.ts +2 -0
  141. package/src/notifications/types.ts +2 -0
  142. package/src/prompts/journal-context.ts +133 -0
  143. package/src/prompts/persona-resolver.ts +80 -24
  144. package/src/prompts/system-prompt.ts +8 -0
  145. package/src/prompts/templates/SOUL.md +10 -0
  146. package/src/providers/provider-send-message.ts +3 -32
  147. package/src/providers/registry.ts +2 -139
  148. package/src/providers/types.ts +1 -1
  149. package/src/runtime/access-request-helper.ts +4 -0
  150. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  151. package/src/runtime/auth/route-policy.ts +2 -0
  152. package/src/runtime/gateway-client.ts +47 -4
  153. package/src/runtime/guardian-decision-types.ts +45 -4
  154. package/src/runtime/http-server.ts +5 -2
  155. package/src/runtime/routes/access-request-decision.ts +2 -2
  156. package/src/runtime/routes/app-management-routes.ts +2 -1
  157. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  158. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  159. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  160. package/src/runtime/routes/debug-routes.ts +12 -9
  161. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  162. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  163. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  164. package/src/runtime/routes/identity-routes.ts +1 -1
  165. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  166. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  167. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  168. package/src/runtime/routes/integrations/twilio.ts +52 -10
  169. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  170. package/src/runtime/routes/memory-item-routes.ts +25 -11
  171. package/src/runtime/routes/secret-routes.ts +141 -10
  172. package/src/runtime/routes/tts-routes.ts +11 -1
  173. package/src/security/ces-credential-client.ts +18 -9
  174. package/src/security/ces-rpc-credential-backend.ts +4 -3
  175. package/src/security/credential-backend.ts +10 -4
  176. package/src/security/secure-keys.ts +21 -4
  177. package/src/skills/catalog-install.ts +4 -36
  178. package/src/skills/skill-memory.ts +1 -0
  179. package/src/subagent/manager.ts +2 -5
  180. package/src/tools/acp/spawn.ts +78 -1
  181. package/src/tools/credentials/vault.ts +5 -3
  182. package/src/tools/memory/definitions.ts +3 -2
  183. package/src/tools/memory/handlers.ts +10 -7
  184. package/src/tools/terminal/safe-env.ts +1 -0
  185. package/src/util/browser.ts +15 -0
  186. package/src/util/platform.ts +1 -1
  187. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
  188. package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
  189. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  190. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  191. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
  192. package/src/workspace/migrations/registry.ts +4 -0
  193. package/src/workspace/provider-commit-message-generator.ts +12 -21
  194. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  195. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  196. package/src/memory/search/lexical.ts +0 -48
  197. package/src/providers/failover.ts +0 -186
@@ -0,0 +1,254 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { sanitizeForTts } from "../tts-text-sanitizer.js";
4
+
5
+ describe("sanitizeForTts", () => {
6
+ describe("markdown links", () => {
7
+ test("strips markdown links, keeping link text", () => {
8
+ expect(sanitizeForTts("Check [this link](https://example.com)")).toBe(
9
+ "Check this link",
10
+ );
11
+ });
12
+
13
+ test("handles multiple links", () => {
14
+ expect(
15
+ sanitizeForTts("See [foo](http://a.com) and [bar](http://b.com)"),
16
+ ).toBe("See foo and bar");
17
+ });
18
+
19
+ test("preserves Fish Audio S2 bracket annotations", () => {
20
+ expect(sanitizeForTts("Hello [laughter] world")).toBe(
21
+ "Hello [laughter] world",
22
+ );
23
+ expect(sanitizeForTts("[breath] ok")).toBe("[breath] ok");
24
+ });
25
+
26
+ test("handles URLs with balanced parentheses (e.g. Wikipedia)", () => {
27
+ expect(
28
+ sanitizeForTts(
29
+ "See [Function](https://en.wikipedia.org/wiki/Function_(mathematics))",
30
+ ),
31
+ ).toBe("See Function");
32
+ });
33
+
34
+ test("handles URLs with multiple balanced parentheses groups", () => {
35
+ expect(
36
+ sanitizeForTts("[link](http://example.com/a_(b)_c_(d))"),
37
+ ).toBe("link");
38
+ });
39
+ });
40
+
41
+ describe("bold and italic", () => {
42
+ test("strips bold (asterisks)", () => {
43
+ expect(sanitizeForTts("Hello **world**")).toBe("Hello world");
44
+ });
45
+
46
+ test("strips bold (underscores)", () => {
47
+ expect(sanitizeForTts("Hello __world__")).toBe("Hello world");
48
+ });
49
+
50
+ test("strips italic (asterisks)", () => {
51
+ expect(sanitizeForTts("Hello *world*")).toBe("Hello world");
52
+ });
53
+
54
+ test("strips italic (underscores)", () => {
55
+ expect(sanitizeForTts("Hello _world_")).toBe("Hello world");
56
+ });
57
+
58
+ test("strips bold+italic (asterisks)", () => {
59
+ expect(sanitizeForTts("Hello ***world***")).toBe("Hello world");
60
+ });
61
+
62
+ test("strips bold+italic (underscores)", () => {
63
+ expect(sanitizeForTts("Hello ___world___")).toBe("Hello world");
64
+ });
65
+
66
+ test("preserves arithmetic asterisks", () => {
67
+ expect(sanitizeForTts("5 * 3 = 15")).toBe("5 * 3 = 15");
68
+ });
69
+
70
+ test("preserves identifiers with underscores", () => {
71
+ expect(sanitizeForTts("The my_var variable")).toBe(
72
+ "The my_var variable",
73
+ );
74
+ });
75
+
76
+ test("preserves snake_case identifiers", () => {
77
+ expect(sanitizeForTts("use some_function_name here")).toBe(
78
+ "use some_function_name here",
79
+ );
80
+ });
81
+ });
82
+
83
+ describe("headers", () => {
84
+ test("strips h1", () => {
85
+ expect(sanitizeForTts("# Header\n\nSome text")).toBe(
86
+ "Header\n\nSome text",
87
+ );
88
+ });
89
+
90
+ test("strips h2", () => {
91
+ expect(sanitizeForTts("## Sub Header")).toBe("Sub Header");
92
+ });
93
+
94
+ test("strips h3 through h6", () => {
95
+ expect(sanitizeForTts("### H3")).toBe("H3");
96
+ expect(sanitizeForTts("#### H4")).toBe("H4");
97
+ expect(sanitizeForTts("##### H5")).toBe("H5");
98
+ expect(sanitizeForTts("###### H6")).toBe("H6");
99
+ });
100
+
101
+ test("does not strip # in middle of line", () => {
102
+ expect(sanitizeForTts("Issue #42")).toBe("Issue #42");
103
+ });
104
+ });
105
+
106
+ describe("code", () => {
107
+ test("strips code fences, keeping content", () => {
108
+ const input = "Here:\n```js\nconst x = 1;\n```\nDone.";
109
+ expect(sanitizeForTts(input)).toBe("Here:\nconst x = 1;\nDone.");
110
+ });
111
+
112
+ test("strips inline code backticks", () => {
113
+ expect(sanitizeForTts("Use `console.log` here")).toBe(
114
+ "Use console.log here",
115
+ );
116
+ });
117
+
118
+ test("preserves # comments inside code fences", () => {
119
+ const input = "Example:\n```python\n# This is a comment\nprint('hi')\n```\nDone.";
120
+ expect(sanitizeForTts(input)).toBe(
121
+ "Example:\n# This is a comment\nprint('hi')\nDone.",
122
+ );
123
+ });
124
+
125
+ test("preserves shell comments inside code fences", () => {
126
+ const input = "Run:\n```bash\n## Install deps\napt-get install curl\n```";
127
+ expect(sanitizeForTts(input)).toBe(
128
+ "Run:\n## Install deps\napt-get install curl\n",
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("bullet markers", () => {
134
+ test("strips dash bullets", () => {
135
+ expect(sanitizeForTts("- First\n- Second")).toBe("First\nSecond");
136
+ });
137
+
138
+ test("strips asterisk bullets", () => {
139
+ expect(sanitizeForTts("* First\n* Second")).toBe("First\nSecond");
140
+ });
141
+
142
+ test("does not strip dashes mid-line", () => {
143
+ expect(sanitizeForTts("well-known fact")).toBe("well-known fact");
144
+ });
145
+ });
146
+
147
+ describe("emojis", () => {
148
+ test("strips simple emojis", () => {
149
+ expect(sanitizeForTts("Hello world 👋")).toBe("Hello world ");
150
+ });
151
+
152
+ test("strips compound emojis (ZWJ sequences)", () => {
153
+ // Family emoji: man + ZWJ + woman + ZWJ + girl
154
+ expect(sanitizeForTts("Family: 👨‍👩‍👧")).toBe("Family: ");
155
+ });
156
+
157
+ test("strips emojis with skin tone modifiers", () => {
158
+ expect(sanitizeForTts("Wave 👋🏽 hello")).toBe("Wave hello");
159
+ });
160
+
161
+ test("strips flag emojis", () => {
162
+ // Flags are regional indicator sequences (Extended_Pictographic)
163
+ expect(sanitizeForTts("Hello 🇺🇸 world")).toBe("Hello world");
164
+ });
165
+
166
+ test("strips emojis with variation selectors", () => {
167
+ // Heart with variation selector
168
+ expect(sanitizeForTts("Love ❤️ you")).toBe("Love you");
169
+ });
170
+
171
+ test("preserves numbers and currency", () => {
172
+ expect(sanitizeForTts("$100.50 and €200")).toBe("$100.50 and €200");
173
+ });
174
+
175
+ test("preserves punctuation", () => {
176
+ expect(sanitizeForTts("Hello, world! How are you?")).toBe(
177
+ "Hello, world! How are you?",
178
+ );
179
+ });
180
+ });
181
+
182
+ describe("whitespace collapsing", () => {
183
+ test("collapses multiple spaces to single space", () => {
184
+ expect(sanitizeForTts("Hello world")).toBe("Hello world");
185
+ });
186
+
187
+ test("collapses multiple blank lines to single newline", () => {
188
+ expect(sanitizeForTts("Hello\n\n\n\nworld")).toBe("Hello\n\nworld");
189
+ });
190
+ });
191
+
192
+ describe("combined transformations", () => {
193
+ test("acceptance: Hello **world** with emoji", () => {
194
+ expect(sanitizeForTts("Hello **world** 👋")).toBe("Hello world ");
195
+ });
196
+
197
+ test("acceptance: markdown link", () => {
198
+ expect(
199
+ sanitizeForTts("Check [this link](https://example.com)"),
200
+ ).toBe("Check this link");
201
+ });
202
+
203
+ test("acceptance: arithmetic preserved", () => {
204
+ expect(sanitizeForTts("5 * 3 = 15")).toBe("5 * 3 = 15");
205
+ });
206
+
207
+ test("acceptance: header with text", () => {
208
+ expect(sanitizeForTts("# Header\n\nSome text")).toBe(
209
+ "Header\n\nSome text",
210
+ );
211
+ });
212
+
213
+ test("complex mixed markdown and emojis", () => {
214
+ const input =
215
+ "# Welcome 🎉\n\nHello **world**! Check [docs](http://x.com).\n\n- Item *one*\n- Item `two`";
216
+ const expected =
217
+ "Welcome \n\nHello world! Check docs.\n\nItem one\nItem two";
218
+ expect(sanitizeForTts(input)).toBe(expected);
219
+ });
220
+ });
221
+
222
+ describe("edge cases", () => {
223
+ test("empty string", () => {
224
+ expect(sanitizeForTts("")).toBe("");
225
+ });
226
+
227
+ test("already-clean text", () => {
228
+ const clean = "Hello, this is plain text.";
229
+ expect(sanitizeForTts(clean)).toBe(clean);
230
+ });
231
+
232
+ test("nested markdown (bold inside italic)", () => {
233
+ expect(sanitizeForTts("*Hello **world***")).toBe("Hello world");
234
+ });
235
+
236
+ test("partial markdown (unmatched asterisks)", () => {
237
+ expect(sanitizeForTts("Hello **world")).toBe("Hello **world");
238
+ });
239
+
240
+ test("idempotency: applying twice gives same result", () => {
241
+ const input = "# Hello **world** 👋\n\n- Item *one*\n- [link](http://x.com)";
242
+ const once = sanitizeForTts(input);
243
+ const twice = sanitizeForTts(once);
244
+ expect(twice).toBe(once);
245
+ });
246
+
247
+ test("preserves trailing whitespace for streaming chunks", () => {
248
+ // Streaming chunks must keep trailing spaces so word boundaries survive
249
+ // concatenation: "Hello " + "world" = "Hello world", not "Helloworld"
250
+ expect(sanitizeForTts("Hello ")).toBe("Hello ");
251
+ expect(sanitizeForTts("the quick ")).toBe("the quick ");
252
+ });
253
+ });
254
+ });
@@ -50,6 +50,7 @@ import { sendGuardianExpiryNotices } from "./guardian-action-sweep.js";
50
50
  import { dispatchGuardianQuestion } from "./guardian-dispatch.js";
51
51
  import type { RelayConnection } from "./relay-server.js";
52
52
  import type { PromptSpeakerContext } from "./speaker-identification.js";
53
+ import { sanitizeForTts } from "./tts-text-sanitizer.js";
53
54
  import {
54
55
  ASK_GUARDIAN_CAPTURE_REGEX,
55
56
  CALL_OPENING_ACK_MARKER,
@@ -553,10 +554,12 @@ export class CallController {
553
554
 
554
555
  /** Emit a chunk of safe text to the appropriate TTS backend. */
555
556
  const emitSafeChunk = (safeText: string): void => {
557
+ const cleaned = sanitizeForTts(safeText);
558
+ if (cleaned.length === 0) return;
556
559
  if (useFishAudio) {
557
- fishAudioTextBuffer += safeText;
560
+ fishAudioTextBuffer += cleaned;
558
561
  } else {
559
- this.relay.sendTextToken(safeText, false);
562
+ this.relay.sendTextToken(cleaned, false);
560
563
  }
561
564
  };
562
565
 
@@ -671,7 +674,8 @@ export class CallController {
671
674
  // single REST API call. The full text gives Fish Audio better context
672
675
  // for prosody and intonation. Audio streams back via chunked transfer
673
676
  // encoding and is forwarded to Twilio as it arrives.
674
- if (useFishAudio && fishAudioTextBuffer.trim().length > 0) {
677
+ const sanitizedFishText = sanitizeForTts(fishAudioTextBuffer.trim());
678
+ if (useFishAudio && sanitizedFishText.length > 0) {
675
679
  if (!this.isCurrentRun(runVersion)) return fullResponseText;
676
680
  let handle: ReturnType<typeof createStreamingEntry> | null = null;
677
681
  try {
@@ -683,7 +687,7 @@ export class CallController {
683
687
  const abortController = new AbortController();
684
688
  this.activeFishAbort = abortController;
685
689
  await synthesizeWithFishAudio(
686
- fishAudioTextBuffer.trim(),
690
+ sanitizedFishText,
687
691
  config.fishAudio,
688
692
  {
689
693
  onChunk: (chunk) => handle!.push(chunk),
@@ -730,7 +734,7 @@ export class CallController {
730
734
  recordCallEvent(this.callSessionId, "assistant_spoke", {
731
735
  text: responseText,
732
736
  });
733
- const spokenText = stripInternalSpeechMarkers(responseText).trim();
737
+ const spokenText = sanitizeForTts(stripInternalSpeechMarkers(responseText)).trim();
734
738
  if (spokenText.length > 0) {
735
739
  const session = getCallSession(this.callSessionId);
736
740
  if (session) {
@@ -87,21 +87,33 @@ export async function synthesizeWithFishAudio(
87
87
  const reader = response.body.getReader();
88
88
  let isFirstChunk = true;
89
89
 
90
- while (true) {
91
- const timeoutMs = isFirstChunk ? FIRST_CHUNK_TIMEOUT_MS : IDLE_TIMEOUT_MS;
92
- const timeout = new Promise<never>((_, reject) =>
93
- setTimeout(
94
- () => reject(new Error(`Fish Audio read timed out after ${timeoutMs}ms`)),
95
- timeoutMs,
96
- ),
97
- );
98
- const { done, value } = await Promise.race([reader.read(), timeout]);
99
- if (done) break;
100
- if (value) {
101
- isFirstChunk = false;
102
- chunks.push(value);
103
- options?.onChunk?.(value);
90
+ try {
91
+ while (true) {
92
+ const timeoutMs = isFirstChunk ? FIRST_CHUNK_TIMEOUT_MS : IDLE_TIMEOUT_MS;
93
+ let timerId: ReturnType<typeof setTimeout>;
94
+ const timeout = new Promise<never>((_, reject) => {
95
+ timerId = setTimeout(
96
+ () => reject(new Error(`Fish Audio read timed out after ${timeoutMs}ms`)),
97
+ timeoutMs,
98
+ );
99
+ });
100
+ let done: boolean;
101
+ let value: Uint8Array | undefined;
102
+ try {
103
+ ({ done, value } = await Promise.race([reader.read(), timeout]));
104
+ } finally {
105
+ clearTimeout(timerId!);
106
+ }
107
+ if (done) break;
108
+ if (value) {
109
+ isFirstChunk = false;
110
+ chunks.push(value);
111
+ options?.onChunk?.(value);
112
+ }
104
113
  }
114
+ } catch (err) {
115
+ try { await reader.cancel(); } catch { /* Ignore cancellation errors */ }
116
+ throw err;
105
117
  }
106
118
 
107
119
  const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
@@ -0,0 +1,189 @@
1
+ import {
2
+ findContactByAddress,
3
+ findGuardianForChannel,
4
+ listContacts,
5
+ listGuardianChannels,
6
+ } from "../contacts/contact-store.js";
7
+ import { getAssistantName } from "../daemon/identity-helpers.js";
8
+ import { DEFAULT_USER_REFERENCE, resolveGuardianName } from "../prompts/user-reference.js";
9
+ import { getLogger } from "../util/logger.js";
10
+
11
+ const logger = getLogger("stt-hints");
12
+
13
+ export interface SttHintsInput {
14
+ staticHints: string[];
15
+ assistantName: string | null;
16
+ guardianName: string | null;
17
+ taskDescription: string | null;
18
+ targetContactName: string | null;
19
+ callerContactName: string | null;
20
+ inviteFriendName: string | null;
21
+ inviteGuardianName: string | null;
22
+ recentContactNames: string[];
23
+ }
24
+
25
+ const MAX_HINTS_LENGTH = 500;
26
+
27
+ /**
28
+ * Assemble STT vocabulary hints from multiple sources into a single
29
+ * comma-separated string suitable for speech-to-text provider hint APIs.
30
+ *
31
+ * Pure function — no DB or filesystem dependencies.
32
+ */
33
+ export function buildSttHints(input: SttHintsInput): string {
34
+ const hints: string[] = [...input.staticHints];
35
+
36
+ if (input.assistantName != null && input.assistantName.trim().length > 0) {
37
+ hints.push(input.assistantName.trim());
38
+ }
39
+
40
+ if (
41
+ input.guardianName != null &&
42
+ input.guardianName.trim().length > 0 &&
43
+ input.guardianName.trim() !== DEFAULT_USER_REFERENCE
44
+ ) {
45
+ hints.push(input.guardianName.trim());
46
+ }
47
+
48
+ if (input.inviteFriendName != null && input.inviteFriendName.trim().length > 0) {
49
+ hints.push(input.inviteFriendName.trim());
50
+ }
51
+
52
+ if (input.inviteGuardianName != null && input.inviteGuardianName.trim().length > 0) {
53
+ hints.push(input.inviteGuardianName.trim());
54
+ }
55
+
56
+ if (input.targetContactName != null && input.targetContactName.trim().length > 0) {
57
+ hints.push(input.targetContactName.trim());
58
+ }
59
+
60
+ if (input.callerContactName != null && input.callerContactName.trim().length > 0) {
61
+ hints.push(input.callerContactName.trim());
62
+ }
63
+
64
+ // Extract potential proper nouns from task description.
65
+ // Split on sentence boundaries, then for each sentence take words
66
+ // after the first that start with an uppercase letter.
67
+ if (input.taskDescription != null && input.taskDescription.trim().length > 0) {
68
+ // Split on sentence-ending punctuation followed by whitespace, but avoid
69
+ // splitting on periods after common abbreviations (Dr., Mr., etc.) so that
70
+ // names like "Dr. Smith" aren't fragmented and dropped by the first-word skip.
71
+ const sentences = input.taskDescription.split(
72
+ /(?<!\b(?:Mr|Mrs|Ms|Dr|Jr|Sr|St|Rev|Prof|Gen|Sgt|Lt|Col))[.]\s+|[!?]\s+/,
73
+ );
74
+ for (const sentence of sentences) {
75
+ const words = sentence.trim().split(/\s+/);
76
+ // Skip the first word (always capitalized at sentence start)
77
+ for (let i = 1; i < words.length; i++) {
78
+ // Use Unicode-aware \p{L} to preserve accented/non-Latin letters (José, Łukasz, etc.)
79
+ const word = words[i].replace(/[^\p{L}'-]/gu, "");
80
+ if (word.length > 0 && /^\p{Lu}/u.test(word)) {
81
+ hints.push(word);
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ hints.push(...input.recentContactNames);
88
+
89
+ // Deduplicate (case-insensitive), filter empty/whitespace-only, trim each
90
+ const seen = new Set<string>();
91
+ const deduped: string[] = [];
92
+ for (const hint of hints) {
93
+ const trimmed = hint.trim();
94
+ if (trimmed.length === 0) continue;
95
+ const key = trimmed.toLowerCase();
96
+ if (seen.has(key)) continue;
97
+ seen.add(key);
98
+ deduped.push(trimmed);
99
+ }
100
+
101
+ const joined = deduped.join(",");
102
+
103
+ if (joined.length <= MAX_HINTS_LENGTH) {
104
+ return joined;
105
+ }
106
+
107
+ // Truncate at the last comma before the limit to avoid partial words
108
+ const truncated = joined.slice(0, MAX_HINTS_LENGTH);
109
+ const lastComma = truncated.lastIndexOf(",");
110
+ if (lastComma === -1) {
111
+ // Single hint that exceeds the limit — return it truncated
112
+ return truncated;
113
+ }
114
+ return truncated.slice(0, lastComma);
115
+ }
116
+
117
+ /**
118
+ * Wire real data sources (contacts DB, identity helpers, config) into
119
+ * {@link buildSttHints}. All DB lookups are best-effort — errors are
120
+ * logged but never propagate so hints can never fail a call.
121
+ */
122
+ export function resolveCallHints(
123
+ session: {
124
+ task: string | null;
125
+ toNumber: string;
126
+ fromNumber: string;
127
+ direction: "inbound" | "outbound";
128
+ inviteFriendName: string | null;
129
+ inviteGuardianName: string | null;
130
+ } | null,
131
+ staticHints: string[],
132
+ ): string {
133
+ const assistantName = getAssistantName();
134
+
135
+ // Look up the guardian contact for a displayName fallback (mirrors relay-server pattern)
136
+ let guardianDisplayName: string | undefined;
137
+ try {
138
+ const voiceGuardian = findGuardianForChannel("phone");
139
+ const guardianChannels = voiceGuardian ? null : listGuardianChannels();
140
+ const guardianContact = voiceGuardian?.contact ?? guardianChannels?.contact;
141
+ guardianDisplayName = guardianContact?.displayName;
142
+ } catch (err) {
143
+ logger.warn({ err }, "Failed to look up guardian contact for STT hints");
144
+ }
145
+ const guardianName = resolveGuardianName(guardianDisplayName);
146
+
147
+ let targetContactName: string | null = null;
148
+ let callerContactName: string | null = null;
149
+ let recentContactNames: string[] = [];
150
+
151
+ // For inbound calls, fromNumber is the caller (the interesting party);
152
+ // toNumber is the assistant's own Twilio number (not useful for contact lookup).
153
+ // For outbound calls, toNumber is who we're calling.
154
+ try {
155
+ if (session) {
156
+ const otherPartyNumber =
157
+ session.direction === "inbound" ? session.fromNumber : session.toNumber;
158
+ const otherPartyContact = findContactByAddress("phone", otherPartyNumber);
159
+ if (otherPartyContact) {
160
+ if (session.direction === "inbound") {
161
+ callerContactName = otherPartyContact.displayName;
162
+ } else {
163
+ targetContactName = otherPartyContact.displayName;
164
+ }
165
+ }
166
+ }
167
+ } catch (err) {
168
+ logger.warn({ err }, "Failed to look up contact for STT hints");
169
+ }
170
+
171
+ try {
172
+ const recentContacts = listContacts(15);
173
+ recentContactNames = recentContacts.map((c) => c.displayName);
174
+ } catch (err) {
175
+ logger.warn({ err }, "Failed to list recent contacts for STT hints");
176
+ }
177
+
178
+ return buildSttHints({
179
+ staticHints,
180
+ assistantName,
181
+ guardianName,
182
+ taskDescription: session?.task ?? null,
183
+ targetContactName,
184
+ callerContactName,
185
+ inviteFriendName: session?.inviteFriendName ?? null,
186
+ inviteGuardianName: session?.inviteGuardianName ?? null,
187
+ recentContactNames,
188
+ });
189
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Sanitizes text for TTS synthesis by stripping markdown formatting and emojis.
3
+ *
4
+ * Preserves arithmetic expressions (e.g. `5 * 3`), identifiers with underscores
5
+ * (e.g. `my_var`), and Fish Audio S2 bracket annotations (e.g. `[laughter]`).
6
+ */
7
+ export function sanitizeForTts(text: string): string {
8
+ let result = text;
9
+
10
+ // 1. Markdown links: [text](url) → text
11
+ // Only matches the full [...](...) pattern — plain brackets like
12
+ // Fish Audio S2 annotations ([laughter], [breath]) pass through.
13
+ // Handles one level of balanced parentheses in URLs (e.g. Wikipedia links).
14
+ result = result.replace(
15
+ /\[([^\]]+)\]\([^()]*(?:\([^()]*\))*[^()]*\)/g,
16
+ "$1",
17
+ );
18
+
19
+ // 2. Bold+italic: ***text*** or ___text___ → text
20
+ result = result.replace(/\*{3}(.+?)\*{3}/g, "$1");
21
+ result = result.replace(/_{3}(.+?)_{3}/g, "$1");
22
+
23
+ // 3. Bold: **text** or __text__ → text
24
+ result = result.replace(/\*{2}(.+?)\*{2}/g, "$1");
25
+ result = result.replace(/_{2}(.+?)_{2}/g, "$1");
26
+
27
+ // 4. Code fences: strip ```...``` fences but keep content
28
+ // Must run before header stripping so # comments inside code blocks are preserved.
29
+ result = result.replace(/```[^\n]*\n([\s\S]*?)```\n?/g, "$1");
30
+
31
+ // 5. Headers: strip leading # characters at line starts
32
+ result = result.replace(/^#{1,6}\s+/gm, "");
33
+
34
+ // 6. Inline code: strip single backticks
35
+ result = result.replace(/`([^`]+)`/g, "$1");
36
+
37
+ // 7. Bullet markers: strip `- ` or `* ` at line starts
38
+ // Must run before italic stripping so `* item` is treated as a bullet.
39
+ result = result.replace(/^[-*]\s+/gm, "");
40
+
41
+ // 8. Italic: *text* or _text_ → text
42
+ // Word-boundary-aware to preserve arithmetic like `5 * 3` and identifiers like `my_var`.
43
+ result = result.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, "$1");
44
+ result = result.replace(/(?<!\w)_([^_]+)_(?!\w)/g, "$1");
45
+
46
+ // 9. Emojis: strip extended pictographic characters, variation selectors,
47
+ // zero-width joiners, skin tone modifiers, and regional indicator symbols (flags).
48
+ result = result.replace(/[\u200D\uFE00-\uFE0F]/gu, "");
49
+ result = result.replace(/[\u{1F3FB}-\u{1F3FF}]/gu, "");
50
+ result = result.replace(/\p{Extended_Pictographic}/gu, "");
51
+ result = result.replace(/[\u{1F1E6}-\u{1F1FF}]/gu, "");
52
+
53
+ // 10. Collapse whitespace: multiple spaces → single space,
54
+ // multiple blank lines → single newline.
55
+ // Does NOT trim trailing whitespace — callers handle trimming so that
56
+ // streaming chunks preserve inter-word spaces (e.g. "Hello " + "world").
57
+ result = result.replace(/ {2,}/g, " ");
58
+ result = result.replace(/\n{3,}/g, "\n\n");
59
+
60
+ return result;
61
+ }