@vellumai/assistant 0.5.7 → 0.5.9
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/Dockerfile +2 -1
- package/docker-entrypoint.sh +9 -0
- package/docs/architecture/memory.md +13 -11
- package/eslint.config.mjs +0 -31
- package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/approval-cascade.test.ts +0 -1
- package/src/__tests__/browser-fill-credential.test.ts +1 -1
- package/src/__tests__/call-controller.test.ts +0 -1
- package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
- package/src/__tests__/ces-startup-timeout.test.ts +40 -0
- package/src/__tests__/config-schema-cmd.test.ts +0 -1
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
- package/src/__tests__/conversation-agent-loop.test.ts +2 -4
- package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
- package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
- package/src/__tests__/conversation-queue.test.ts +0 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
- package/src/__tests__/conversation-slash-queue.test.ts +0 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
- package/src/__tests__/credential-execution-client.test.ts +5 -2
- package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
- package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
- package/src/__tests__/credential-security-e2e.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +2 -5
- package/src/__tests__/credentials-cli.test.ts +4 -3
- package/src/__tests__/daemon-credential-client.test.ts +123 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
- package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
- package/src/__tests__/journal-context.test.ts +335 -0
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
- package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
- package/src/__tests__/memory-recall-quality.test.ts +48 -17
- package/src/__tests__/memory-regressions.test.ts +408 -363
- package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
- package/src/__tests__/non-member-access-request.test.ts +2 -2
- package/src/__tests__/notification-decision-strategy.test.ts +71 -0
- package/src/__tests__/oauth-cli.test.ts +5 -1
- package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
- package/src/__tests__/provider-error-scenarios.test.ts +0 -267
- package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
- package/src/__tests__/relay-server.test.ts +1 -2
- package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +1 -1
- package/src/__tests__/secure-keys.test.ts +18 -15
- package/src/__tests__/skill-memory.test.ts +17 -3
- package/src/__tests__/stale-approval-dedup.test.ts +171 -0
- package/src/__tests__/stt-hints.test.ts +437 -0
- package/src/__tests__/task-memory-cleanup.test.ts +14 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
- package/src/__tests__/voice-quality.test.ts +58 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
- package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
- package/src/acp/agent-process.ts +9 -1
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-request-resolvers.ts +164 -38
- package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
- package/src/calls/call-controller.ts +9 -5
- package/src/calls/fish-audio-client.ts +26 -14
- package/src/calls/stt-hints.ts +189 -0
- package/src/calls/tts-text-sanitizer.ts +61 -0
- package/src/calls/twilio-routes.ts +32 -4
- package/src/calls/voice-quality.ts +15 -3
- package/src/calls/voice-session-bridge.ts +1 -0
- package/src/cli/commands/avatar.ts +2 -2
- package/src/cli/commands/credentials.ts +110 -94
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/keys.ts +7 -7
- package/src/cli/commands/memory.ts +1 -1
- package/src/cli/commands/oauth/connections.ts +11 -29
- package/src/cli/commands/oauth/platform.ts +389 -43
- package/src/cli/lib/daemon-credential-client.ts +284 -0
- package/src/cli.ts +1 -1
- package/src/config/bundled-skills/AGENTS.md +34 -0
- package/src/config/bundled-skills/acp/SKILL.md +10 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
- package/src/config/bundled-skills/settings/SKILL.md +15 -2
- package/src/config/bundled-skills/settings/TOOLS.json +46 -1
- package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
- package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
- package/src/config/bundled-skills/slack/SKILL.md +1 -1
- package/src/config/bundled-tool-registry.ts +4 -0
- package/src/config/defaults.ts +0 -2
- package/src/config/env-registry.ts +4 -4
- package/src/config/env.ts +14 -1
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/loader.ts +8 -11
- package/src/config/schema.ts +5 -16
- package/src/config/schemas/calls.ts +17 -0
- package/src/config/schemas/inference.ts +2 -2
- package/src/config/schemas/journal.ts +16 -0
- package/src/config/schemas/memory-processing.ts +2 -2
- package/src/config/types.ts +1 -0
- package/src/contacts/contact-store.ts +2 -2
- package/src/credential-execution/executable-discovery.ts +1 -1
- package/src/credential-execution/startup-timeout.ts +36 -0
- package/src/daemon/approval-generators.ts +3 -9
- package/src/daemon/conversation-agent-loop.ts +6 -0
- package/src/daemon/conversation-error.ts +13 -1
- package/src/daemon/conversation-memory.ts +1 -2
- package/src/daemon/conversation-process.ts +18 -1
- package/src/daemon/conversation-runtime-assembly.ts +61 -1
- package/src/daemon/conversation-surfaces.ts +30 -1
- package/src/daemon/conversation.ts +20 -9
- package/src/daemon/guardian-action-generators.ts +3 -9
- package/src/daemon/lifecycle.ts +18 -11
- package/src/daemon/message-types/conversations.ts +1 -0
- package/src/daemon/server.ts +2 -3
- package/src/memory/app-store.ts +31 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/indexer.ts +19 -10
- package/src/memory/items-extractor.ts +315 -322
- package/src/memory/job-handlers/summarization.ts +26 -16
- package/src/memory/jobs-store.ts +33 -1
- package/src/memory/journal-memory.ts +214 -0
- package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/retriever.test.ts +37 -25
- package/src/memory/retriever.ts +24 -49
- package/src/memory/schema/memory-core.ts +2 -0
- package/src/memory/search/formatting.ts +7 -44
- package/src/memory/search/staleness.ts +4 -0
- package/src/memory/search/tier-classifier.ts +10 -2
- package/src/memory/search/types.ts +2 -5
- package/src/memory/task-memory-cleanup.ts +4 -3
- package/src/notifications/adapters/slack.ts +168 -6
- package/src/notifications/broadcaster.ts +1 -0
- package/src/notifications/copy-composer.ts +59 -2
- package/src/notifications/signal.ts +2 -0
- package/src/notifications/types.ts +2 -0
- package/src/prompts/journal-context.ts +133 -0
- package/src/prompts/persona-resolver.ts +80 -24
- package/src/prompts/system-prompt.ts +30 -0
- package/src/prompts/templates/NOW.md +26 -0
- package/src/prompts/templates/SOUL.md +20 -0
- package/src/prompts/update-bulletin-format.ts +0 -2
- package/src/providers/provider-send-message.ts +3 -32
- package/src/providers/registry.ts +2 -139
- package/src/providers/types.ts +1 -1
- package/src/runtime/access-request-helper.ts +4 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
- package/src/runtime/auth/route-policy.ts +2 -0
- package/src/runtime/gateway-client.ts +47 -4
- package/src/runtime/guardian-decision-types.ts +45 -4
- package/src/runtime/http-server.ts +5 -2
- package/src/runtime/routes/access-request-decision.ts +2 -2
- package/src/runtime/routes/app-management-routes.ts +2 -1
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
- package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
- package/src/runtime/routes/channel-readiness-routes.ts +9 -4
- package/src/runtime/routes/debug-routes.ts +12 -9
- package/src/runtime/routes/guardian-approval-interception.ts +168 -11
- package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
- package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
- package/src/runtime/routes/identity-routes.ts +1 -1
- package/src/runtime/routes/inbound-message-handler.ts +31 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
- package/src/runtime/routes/integrations/twilio.ts +52 -10
- package/src/runtime/routes/memory-item-routes.test.ts +3 -3
- package/src/runtime/routes/memory-item-routes.ts +25 -11
- package/src/runtime/routes/secret-routes.ts +141 -10
- package/src/runtime/routes/tts-routes.ts +11 -1
- package/src/security/ces-credential-client.ts +18 -9
- package/src/security/ces-rpc-credential-backend.ts +4 -3
- package/src/security/credential-backend.ts +10 -4
- package/src/security/secure-keys.ts +21 -4
- package/src/skills/catalog-install.ts +4 -36
- package/src/skills/inline-command-expansions.ts +7 -7
- package/src/skills/skill-memory.ts +1 -0
- package/src/subagent/manager.ts +2 -5
- package/src/tools/acp/spawn.ts +78 -1
- package/src/tools/credentials/vault.ts +5 -3
- package/src/tools/memory/definitions.ts +3 -2
- package/src/tools/memory/handlers.ts +10 -7
- package/src/tools/sensitive-output-placeholders.ts +2 -2
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/util/browser.ts +15 -0
- package/src/util/platform.ts +1 -1
- package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
- package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
- package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
- package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/workspace/provider-commit-message-generator.ts +12 -21
- package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
- package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
- package/src/memory/search/lexical.ts +0 -48
- 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 +=
|
|
560
|
+
fishAudioTextBuffer += cleaned;
|
|
558
561
|
} else {
|
|
559
|
-
this.relay.sendTextToken(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
setTimeout
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
}
|