@vellumai/assistant 0.5.6 → 0.5.7
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/.env.example +16 -2
- package/ARCHITECTURE.md +6 -75
- package/Dockerfile +1 -1
- package/README.md +0 -2
- package/bun.lock +0 -414
- package/docs/architecture/keychain-broker.md +45 -240
- package/docs/architecture/security.md +0 -17
- package/docs/credential-execution-service.md +2 -2
- package/node_modules/@vellumai/ces-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +119 -0
- package/node_modules/@vellumai/credential-storage/package.json +1 -0
- package/node_modules/@vellumai/egress-proxy/package.json +1 -0
- package/package.json +2 -3
- package/src/__tests__/actor-token-service.test.ts +0 -114
- package/src/__tests__/assistant-feature-flags-integration.test.ts +30 -29
- package/src/__tests__/browser-skill-endstate.test.ts +6 -5
- package/src/__tests__/btw-routes.test.ts +0 -39
- package/src/__tests__/call-domain.test.ts +0 -128
- package/src/__tests__/ces-rpc-credential-backend.test.ts +199 -0
- package/src/__tests__/channel-approval-routes.test.ts +0 -5
- package/src/__tests__/channel-readiness-service.test.ts +1 -60
- package/src/__tests__/checker.test.ts +4 -2
- package/src/__tests__/cli-command-risk-guard.test.ts +112 -0
- package/src/__tests__/config-schema-cmd.test.ts +0 -1
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/conversation-attention-telegram.test.ts +0 -5
- package/src/__tests__/conversation-init.benchmark.test.ts +0 -2
- package/src/__tests__/conversation-skill-tools.test.ts +0 -54
- package/src/__tests__/conversation-title-service.test.ts +87 -0
- package/src/__tests__/credential-execution-feature-gates.test.ts +28 -14
- package/src/__tests__/credential-execution-managed-contract.test.ts +33 -18
- package/src/__tests__/credential-security-e2e.test.ts +0 -66
- package/src/__tests__/credential-security-invariants.test.ts +4 -45
- package/src/__tests__/credentials-cli.test.ts +78 -0
- package/src/__tests__/db-migration-rollback.test.ts +2015 -1
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +34 -143
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -4
- package/src/__tests__/guardian-routing-state.test.ts +0 -5
- package/src/__tests__/host-shell-tool.test.ts +6 -7
- package/src/__tests__/http-user-message-parity.test.ts +3 -103
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -4
- package/src/__tests__/inline-skill-load-permissions.test.ts +6 -8
- package/src/__tests__/intent-routing.test.ts +0 -13
- package/src/__tests__/jobs-store-qdrant-breaker.test.ts +178 -0
- package/src/__tests__/keychain-broker-client.test.ts +161 -22
- package/src/__tests__/memory-jobs-worker-backoff.test.ts +150 -0
- package/src/__tests__/migration-export-http.test.ts +2 -2
- package/src/__tests__/migration-import-commit-http.test.ts +2 -2
- package/src/__tests__/migration-import-preflight-http.test.ts +2 -2
- package/src/__tests__/migration-validate-http.test.ts +2 -2
- package/src/__tests__/non-member-access-request.test.ts +0 -5
- package/src/__tests__/notification-decision-fallback.test.ts +4 -0
- package/src/__tests__/notification-decision-identity.test.ts +4 -0
- package/src/__tests__/permission-types.test.ts +1 -0
- package/src/__tests__/provider-managed-proxy-integration.test.ts +5 -6
- package/src/__tests__/qdrant-manager.test.ts +28 -2
- package/src/__tests__/registry.test.ts +0 -6
- package/src/__tests__/runtime-attachment-metadata.test.ts +0 -4
- package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -4
- package/src/__tests__/secure-keys.test.ts +83 -263
- package/src/__tests__/shell-identity.test.ts +96 -6
- package/src/__tests__/skill-feature-flags-integration.test.ts +22 -14
- package/src/__tests__/skill-feature-flags.test.ts +46 -45
- package/src/__tests__/skill-load-feature-flag.test.ts +7 -10
- package/src/__tests__/skill-load-inline-command.test.ts +8 -12
- package/src/__tests__/skill-load-inline-includes.test.ts +6 -10
- package/src/__tests__/skill-load-tool.test.ts +0 -2
- package/src/__tests__/skill-projection-feature-flag.test.ts +33 -29
- package/src/__tests__/skills.test.ts +0 -2
- package/src/__tests__/slack-inbound-verification.test.ts +0 -4
- package/src/__tests__/suggestion-routes.test.ts +1 -32
- package/src/__tests__/system-prompt.test.ts +0 -1
- package/src/__tests__/tool-executor-shell-integration.test.ts +5 -3
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -5
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -4
- package/src/__tests__/update-bulletin.test.ts +0 -2
- package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +6 -9
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -6
- package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +252 -0
- package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +218 -0
- package/src/__tests__/workspace-migration-down-functions.test.ts +1009 -0
- package/src/__tests__/workspace-migrations-runner.test.ts +114 -0
- package/src/calls/audio-store.test.ts +97 -0
- package/src/calls/audio-store.ts +205 -0
- package/src/calls/call-controller.ts +85 -7
- package/src/calls/call-domain.ts +3 -0
- package/src/calls/call-store.ts +10 -3
- package/src/calls/fish-audio-client.ts +117 -0
- package/src/calls/relay-server.ts +27 -0
- package/src/calls/twilio-routes.ts +2 -1
- package/src/calls/types.ts +1 -0
- package/src/calls/voice-ingress-preflight.ts +0 -42
- package/src/calls/voice-quality.ts +26 -5
- package/src/calls/voice-session-bridge.ts +6 -12
- package/src/cli/commands/config.ts +1 -4
- package/src/cli/commands/credentials.ts +34 -4
- package/src/cli/commands/oauth/index.ts +7 -0
- package/src/cli/commands/oauth/platform.ts +179 -0
- package/src/cli/commands/platform.ts +3 -3
- package/src/config/assistant-feature-flags.ts +186 -5
- package/src/config/bundled-skills/messaging/SKILL.md +5 -5
- package/src/config/bundled-skills/phone-calls/TOOLS.json +4 -0
- package/src/config/bundled-skills/settings/TOOLS.json +2 -2
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +42 -0
- package/src/config/bundled-tool-registry.ts +1 -11
- package/src/config/env-registry.ts +1 -1
- package/src/config/env.ts +8 -14
- package/src/config/feature-flag-registry.json +48 -8
- package/src/config/loader.ts +98 -31
- package/src/config/schema.ts +4 -13
- package/src/config/schemas/calls.ts +13 -0
- package/src/config/schemas/fish-audio.ts +39 -0
- package/src/config/schemas/security.ts +0 -4
- package/src/config/types.ts +0 -1
- package/src/contacts/contact-store.ts +39 -0
- package/src/contacts/types.ts +2 -0
- package/src/credential-execution/approval-bridge.ts +1 -0
- package/src/credential-execution/executable-discovery.ts +28 -4
- package/src/credential-execution/feature-gates.ts +16 -0
- package/src/credential-execution/process-manager.ts +38 -0
- package/src/daemon/assistant-attachments.ts +9 -0
- package/src/daemon/config-watcher.ts +5 -0
- package/src/daemon/conversation-tool-setup.ts +0 -105
- package/src/daemon/conversation.ts +10 -1
- package/src/daemon/handlers/config-vercel.ts +92 -0
- package/src/daemon/handlers/skills.ts +2 -15
- package/src/daemon/install-symlink.ts +195 -0
- package/src/daemon/lifecycle.ts +227 -51
- package/src/daemon/message-types/conversations.ts +3 -4
- package/src/daemon/message-types/diagnostics.ts +3 -22
- package/src/daemon/message-types/messages.ts +0 -2
- package/src/daemon/message-types/upgrades.ts +8 -0
- package/src/daemon/server.ts +30 -92
- package/src/events/domain-events.ts +2 -1
- package/src/inbound/platform-callback-registration.ts +3 -3
- package/src/instrument.ts +8 -5
- package/src/memory/conversation-title-service.ts +50 -1
- package/src/memory/db-init.ts +12 -0
- package/src/memory/items-extractor.ts +15 -1
- package/src/memory/job-handlers/conversation-starters.ts +4 -1
- package/src/memory/jobs-store.ts +30 -5
- package/src/memory/jobs-worker.ts +31 -7
- package/src/memory/migrations/001-job-deferrals.ts +19 -0
- package/src/memory/migrations/004-entity-relation-dedup.ts +10 -0
- package/src/memory/migrations/005-fingerprint-scope-unique.ts +76 -0
- package/src/memory/migrations/006-scope-salted-fingerprints.ts +50 -0
- package/src/memory/migrations/007-assistant-id-to-self.ts +10 -0
- package/src/memory/migrations/008-remove-assistant-id-columns.ts +34 -0
- package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +26 -0
- package/src/memory/migrations/014-backfill-inbox-thread-state.ts +10 -0
- package/src/memory/migrations/015-drop-active-search-index.ts +17 -0
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +12 -0
- package/src/memory/migrations/020-rename-macos-ios-channel-to-vellum.ts +121 -0
- package/src/memory/migrations/024-embedding-vector-blob.ts +74 -0
- package/src/memory/migrations/026a-embeddings-nullable-vector-json.ts +82 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +11 -0
- package/src/memory/migrations/116-messages-fts.ts +106 -1
- package/src/memory/migrations/126-backfill-guardian-principal-id.ts +52 -0
- package/src/memory/migrations/127-guardian-principal-id-not-null.ts +77 -0
- package/src/memory/migrations/134-contacts-notes-column.ts +13 -0
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +20 -0
- package/src/memory/migrations/136-drop-assistant-id-columns.ts +52 -0
- package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +13 -0
- package/src/memory/migrations/141-rename-verification-table.ts +54 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +25 -0
- package/src/memory/migrations/143-rename-guardian-verification-values.ts +35 -0
- package/src/memory/migrations/144-rename-voice-to-phone.ts +136 -0
- package/src/memory/migrations/145-drop-accounts-table.ts +32 -0
- package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +14 -1
- package/src/memory/migrations/148-drop-reminders-table.ts +35 -1
- package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +69 -1
- package/src/memory/migrations/162-guardian-timestamps-epoch-ms.ts +290 -0
- package/src/memory/migrations/169-rename-gmail-provider-key-to-google.ts +51 -1
- package/src/memory/migrations/174-rename-thread-starters-table.ts +47 -1
- package/src/memory/migrations/176-drop-capability-card-state.ts +13 -0
- package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +16 -0
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +28 -1
- package/src/memory/migrations/190-call-session-skip-disclosure.ts +15 -0
- package/src/memory/migrations/191-backfill-audio-attachment-mime-types.ts +64 -0
- package/src/memory/migrations/192-contacts-user-file-column.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +90 -0
- package/src/memory/migrations/validate-migration-state.ts +137 -11
- package/src/memory/qdrant-circuit-breaker.ts +9 -0
- package/src/memory/qdrant-manager.ts +64 -7
- package/src/memory/schema/calls.ts +1 -0
- package/src/memory/schema/contacts.ts +1 -0
- package/src/notifications/decision-engine.ts +4 -1
- package/src/oauth/connection-resolver.ts +6 -4
- package/src/permissions/checker.ts +0 -38
- package/src/permissions/shell-identity.ts +76 -22
- package/src/permissions/types.ts +4 -2
- package/src/platform/client.ts +35 -7
- package/src/prompts/persona-resolver.ts +138 -0
- package/src/prompts/system-prompt.ts +36 -4
- package/src/prompts/templates/users/default.md +1 -0
- package/src/providers/registry.ts +27 -40
- package/src/runtime/auth/__tests__/credential-service.test.ts +0 -1
- package/src/runtime/auth/__tests__/external-assistant-id.test.ts +13 -68
- package/src/runtime/auth/external-assistant-id.ts +13 -59
- package/src/runtime/auth/route-policy.ts +15 -1
- package/src/runtime/auth/token-service.ts +43 -138
- package/src/runtime/channel-readiness-service.ts +1 -16
- package/src/runtime/http-server.ts +27 -2
- package/src/runtime/middleware/error-handler.ts +1 -9
- package/src/runtime/routes/audio-routes.ts +40 -0
- package/src/runtime/routes/btw-routes.ts +0 -17
- package/src/runtime/routes/conversation-query-routes.ts +63 -1
- package/src/runtime/routes/conversation-routes.ts +4 -44
- package/src/runtime/routes/diagnostics-routes.ts +1 -477
- package/src/runtime/routes/identity-routes.ts +18 -29
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +4 -33
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +1 -1
- package/src/runtime/routes/integrations/vercel.ts +89 -0
- package/src/runtime/routes/log-export-routes.ts +5 -0
- package/src/runtime/routes/memory-item-routes.ts +24 -6
- package/src/runtime/routes/migration-rollback-routes.ts +209 -0
- package/src/runtime/routes/migration-routes.ts +17 -1
- package/src/runtime/routes/notification-routes.ts +58 -0
- package/src/runtime/routes/schedule-routes.ts +65 -0
- package/src/runtime/routes/settings-routes.ts +41 -1
- package/src/runtime/routes/tts-routes.ts +86 -0
- package/src/runtime/routes/upgrade-broadcast-routes.ts +26 -2
- package/src/runtime/routes/workspace-commit-routes.ts +62 -0
- package/src/runtime/routes/workspace-routes.test.ts +22 -1
- package/src/runtime/routes/workspace-routes.ts +1 -1
- package/src/runtime/routes/workspace-utils.ts +86 -2
- package/src/security/ces-credential-client.ts +59 -22
- package/src/security/ces-rpc-credential-backend.ts +85 -0
- package/src/security/credential-backend.ts +12 -88
- package/src/security/keychain-broker-client.ts +10 -2
- package/src/security/secure-keys.ts +94 -113
- package/src/skills/catalog-install.ts +13 -7
- package/src/telemetry/usage-telemetry-reporter.ts +4 -2
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/executor.ts +0 -4
- package/src/tools/network/script-proxy/session-manager.ts +19 -4
- package/src/tools/network/web-fetch.ts +3 -1
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/types.ts +0 -8
- package/src/util/errors.ts +0 -12
- package/src/util/platform.ts +3 -50
- package/src/workspace/git-service.ts +5 -2
- package/src/workspace/migrations/001-avatar-rename.ts +15 -0
- package/src/workspace/migrations/003-seed-device-id.ts +17 -1
- package/src/workspace/migrations/004-extract-collect-usage-data.ts +33 -0
- package/src/workspace/migrations/005-add-send-diagnostics.ts +3 -0
- package/src/workspace/migrations/006-services-config.ts +49 -0
- package/src/workspace/migrations/007-web-search-provider-rename.ts +27 -0
- package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +3 -0
- package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +4 -0
- package/src/workspace/migrations/010-app-dir-rename.ts +78 -0
- package/src/workspace/migrations/011-backfill-installation-id.ts +11 -0
- package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +44 -0
- package/src/workspace/migrations/013-repair-conversation-disk-view.ts +5 -0
- package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +153 -0
- package/src/workspace/migrations/016-extract-feature-flags-to-protected.ts +156 -0
- package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +150 -0
- package/src/workspace/migrations/017-seed-persona-dirs.ts +95 -0
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +23 -1
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/workspace/migrations/runner.ts +106 -2
- package/src/workspace/migrations/types.ts +4 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +0 -206
- package/src/__tests__/claude-code-tool-profiles.test.ts +0 -99
- package/src/__tests__/diagnostics-export.test.ts +0 -288
- package/src/__tests__/local-gateway-health.test.ts +0 -209
- package/src/__tests__/secret-ingress-handler.test.ts +0 -120
- package/src/__tests__/swarm-conversation-integration.test.ts +0 -358
- package/src/__tests__/swarm-dag-pathological.test.ts +0 -547
- package/src/__tests__/swarm-orchestrator.test.ts +0 -463
- package/src/__tests__/swarm-plan-validator.test.ts +0 -384
- package/src/__tests__/swarm-recursion.test.ts +0 -197
- package/src/__tests__/swarm-router-planner.test.ts +0 -234
- package/src/__tests__/swarm-tool.test.ts +0 -185
- package/src/__tests__/swarm-worker-backend.test.ts +0 -144
- package/src/__tests__/swarm-worker-runner.test.ts +0 -288
- package/src/commands/__tests__/cc-command-registry.test.ts +0 -396
- package/src/commands/cc-command-registry.ts +0 -248
- package/src/config/bundled-skills/claude-code/SKILL.md +0 -53
- package/src/config/bundled-skills/claude-code/TOOLS.json +0 -47
- package/src/config/bundled-skills/claude-code/tools/claude-code.ts +0 -12
- package/src/config/bundled-skills/orchestration/SKILL.md +0 -33
- package/src/config/bundled-skills/orchestration/TOOLS.json +0 -35
- package/src/config/bundled-skills/orchestration/tools/swarm-delegate.ts +0 -12
- package/src/config/schemas/swarm.ts +0 -82
- package/src/logfire.ts +0 -135
- package/src/runtime/local-gateway-health.ts +0 -275
- package/src/security/secret-ingress.ts +0 -68
- package/src/swarm/backend-claude-code.ts +0 -225
- package/src/swarm/checkpoint.ts +0 -137
- package/src/swarm/graph-utils.ts +0 -53
- package/src/swarm/index.ts +0 -55
- package/src/swarm/limits.ts +0 -66
- package/src/swarm/orchestrator.ts +0 -424
- package/src/swarm/plan-validator.ts +0 -117
- package/src/swarm/router-planner.ts +0 -162
- package/src/swarm/router-prompts.ts +0 -39
- package/src/swarm/synthesizer.ts +0 -81
- package/src/swarm/types.ts +0 -72
- package/src/swarm/worker-backend.ts +0 -131
- package/src/swarm/worker-prompts.ts +0 -80
- package/src/swarm/worker-runner.ts +0 -170
- package/src/tools/claude-code/claude-code.ts +0 -610
- package/src/tools/swarm/delegate.ts +0 -205
|
@@ -54,6 +54,7 @@ mock.module("node:fs", () => ({
|
|
|
54
54
|
// Import after mocking
|
|
55
55
|
import {
|
|
56
56
|
loadCheckpoints,
|
|
57
|
+
rollbackWorkspaceMigrations,
|
|
57
58
|
runWorkspaceMigrations,
|
|
58
59
|
} from "../workspace/migrations/runner.js";
|
|
59
60
|
|
|
@@ -68,6 +69,7 @@ function makeMigration(id: string): WorkspaceMigration {
|
|
|
68
69
|
id,
|
|
69
70
|
description: `Migration ${id}`,
|
|
70
71
|
run: mock(() => {}),
|
|
72
|
+
down: mock(() => {}),
|
|
71
73
|
};
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -244,6 +246,7 @@ describe("runWorkspaceMigrations", () => {
|
|
|
244
246
|
// Simulate async work
|
|
245
247
|
await Promise.resolve();
|
|
246
248
|
}),
|
|
249
|
+
down: mock(() => {}),
|
|
247
250
|
};
|
|
248
251
|
|
|
249
252
|
await runWorkspaceMigrations(WORKSPACE_DIR, [asyncMigration]);
|
|
@@ -291,3 +294,114 @@ describe("runWorkspaceMigrations", () => {
|
|
|
291
294
|
);
|
|
292
295
|
});
|
|
293
296
|
});
|
|
297
|
+
|
|
298
|
+
describe("rollbackWorkspaceMigrations", () => {
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
mockCheckpointContents = null;
|
|
301
|
+
readTextFileSyncFn.mockClear();
|
|
302
|
+
ensureDirFn.mockClear();
|
|
303
|
+
writeFileSyncFn.mockClear();
|
|
304
|
+
renameSyncFn.mockClear();
|
|
305
|
+
logWarnFn.mockClear();
|
|
306
|
+
logInfoFn.mockClear();
|
|
307
|
+
logErrorFn.mockClear();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("rolls back migrations in reverse order", async () => {
|
|
311
|
+
const m1 = makeMigration("001");
|
|
312
|
+
const m2 = makeMigration("002");
|
|
313
|
+
const m3 = makeMigration("003");
|
|
314
|
+
|
|
315
|
+
// All three migrations are marked as completed in checkpoints
|
|
316
|
+
mockCheckpointContents = JSON.stringify({
|
|
317
|
+
applied: {
|
|
318
|
+
"001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "completed" },
|
|
319
|
+
"002": { appliedAt: "2025-01-02T00:00:00.000Z", status: "completed" },
|
|
320
|
+
"003": { appliedAt: "2025-01-03T00:00:00.000Z", status: "completed" },
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const callOrder: string[] = [];
|
|
325
|
+
(m2.down as ReturnType<typeof mock>).mockImplementation(() => {
|
|
326
|
+
callOrder.push("002");
|
|
327
|
+
});
|
|
328
|
+
(m3.down as ReturnType<typeof mock>).mockImplementation(() => {
|
|
329
|
+
callOrder.push("003");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Roll back to m1 — should reverse m3 then m2, but not m1
|
|
333
|
+
await rollbackWorkspaceMigrations(WORKSPACE_DIR, [m1, m2, m3], "001");
|
|
334
|
+
|
|
335
|
+
expect(m3.down).toHaveBeenCalledTimes(1);
|
|
336
|
+
expect(m2.down).toHaveBeenCalledTimes(1);
|
|
337
|
+
expect(m1.down).not.toHaveBeenCalled();
|
|
338
|
+
expect(callOrder).toEqual(["003", "002"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("handles crash during rollback (rolling_back status)", async () => {
|
|
342
|
+
const m1 = makeMigration("001");
|
|
343
|
+
|
|
344
|
+
// Simulate a crash during a previous rollback — m1 is left in rolling_back state
|
|
345
|
+
mockCheckpointContents = JSON.stringify({
|
|
346
|
+
applied: {
|
|
347
|
+
"001": {
|
|
348
|
+
appliedAt: "2025-01-01T00:00:00.000Z",
|
|
349
|
+
status: "rolling_back",
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// runWorkspaceMigrations should clear the rolling_back status and re-run forward
|
|
355
|
+
await runWorkspaceMigrations(WORKSPACE_DIR, [m1]);
|
|
356
|
+
|
|
357
|
+
// The runner treats "rolling_back" like "started" — it clears the entry and re-runs
|
|
358
|
+
expect(m1.run).toHaveBeenCalledTimes(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("removes checkpoints for rolled-back migrations", async () => {
|
|
362
|
+
const m1 = makeMigration("001");
|
|
363
|
+
const m2 = makeMigration("002");
|
|
364
|
+
const m3 = makeMigration("003");
|
|
365
|
+
|
|
366
|
+
mockCheckpointContents = JSON.stringify({
|
|
367
|
+
applied: {
|
|
368
|
+
"001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "completed" },
|
|
369
|
+
"002": { appliedAt: "2025-01-02T00:00:00.000Z", status: "completed" },
|
|
370
|
+
"003": { appliedAt: "2025-01-03T00:00:00.000Z", status: "completed" },
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await rollbackWorkspaceMigrations(WORKSPACE_DIR, [m1, m2, m3], "001");
|
|
375
|
+
|
|
376
|
+
// The last checkpoint write should only contain m1 (002 and 003 were rolled back)
|
|
377
|
+
const lastWriteCall = writeFileSyncFn.mock.calls.at(-1) as unknown[];
|
|
378
|
+
const finalCheckpoint = JSON.parse(lastWriteCall[1] as string);
|
|
379
|
+
expect(finalCheckpoint.applied["001"]).toBeDefined();
|
|
380
|
+
expect(finalCheckpoint.applied["002"]).toBeUndefined();
|
|
381
|
+
expect(finalCheckpoint.applied["003"]).toBeUndefined();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("no-op when already at target", async () => {
|
|
385
|
+
const m1 = makeMigration("001");
|
|
386
|
+
const m2 = makeMigration("002");
|
|
387
|
+
const m3 = makeMigration("003");
|
|
388
|
+
|
|
389
|
+
mockCheckpointContents = JSON.stringify({
|
|
390
|
+
applied: {
|
|
391
|
+
"001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "completed" },
|
|
392
|
+
"002": { appliedAt: "2025-01-02T00:00:00.000Z", status: "completed" },
|
|
393
|
+
"003": { appliedAt: "2025-01-03T00:00:00.000Z", status: "completed" },
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Target is the last migration — nothing to roll back
|
|
398
|
+
await rollbackWorkspaceMigrations(WORKSPACE_DIR, [m1, m2, m3], "003");
|
|
399
|
+
|
|
400
|
+
expect(m1.down).not.toHaveBeenCalled();
|
|
401
|
+
expect(m2.down).not.toHaveBeenCalled();
|
|
402
|
+
expect(m3.down).not.toHaveBeenCalled();
|
|
403
|
+
|
|
404
|
+
// No checkpoint writes should have occurred (no rollback happened)
|
|
405
|
+
expect(writeFileSyncFn).not.toHaveBeenCalled();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { getAudio, storeAudio } from "./audio-store.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reset module-level state between tests by re-importing.
|
|
11
|
+
* Since the store uses module-level variables, we isolate via fresh imports
|
|
12
|
+
* where needed, but for most tests the shared module state is fine as long
|
|
13
|
+
* as we account for it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function makeBuffer(sizeBytes: number): Buffer {
|
|
17
|
+
return Buffer.alloc(sizeBytes, 0x42);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Tests
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
describe("audio-store", () => {
|
|
25
|
+
describe("storeAudio / getAudio", () => {
|
|
26
|
+
test("stores and retrieves audio by id", () => {
|
|
27
|
+
const buf = makeBuffer(1024);
|
|
28
|
+
const id = storeAudio(buf, "mp3");
|
|
29
|
+
const result = getAudio(id);
|
|
30
|
+
expect(result).not.toBeNull();
|
|
31
|
+
expect(result!.type).toBe("buffer");
|
|
32
|
+
if (result!.type === "buffer") {
|
|
33
|
+
expect(result!.buffer).toEqual(buf);
|
|
34
|
+
}
|
|
35
|
+
expect(result!.contentType).toBe("audio/mpeg");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns correct content type for each format", () => {
|
|
39
|
+
const buf = makeBuffer(64);
|
|
40
|
+
|
|
41
|
+
const mp3Id = storeAudio(buf, "mp3");
|
|
42
|
+
expect(getAudio(mp3Id)!.contentType).toBe("audio/mpeg");
|
|
43
|
+
|
|
44
|
+
const wavId = storeAudio(buf, "wav");
|
|
45
|
+
expect(getAudio(wavId)!.contentType).toBe("audio/wav");
|
|
46
|
+
|
|
47
|
+
const opusId = storeAudio(buf, "opus");
|
|
48
|
+
expect(getAudio(opusId)!.contentType).toBe("audio/opus");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns null for unknown id", () => {
|
|
52
|
+
expect(getAudio("nonexistent-id")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("TTL expiration", () => {
|
|
57
|
+
test("expired entries return null", () => {
|
|
58
|
+
const buf = makeBuffer(128);
|
|
59
|
+
const id = storeAudio(buf, "wav");
|
|
60
|
+
|
|
61
|
+
// Fast-forward time past TTL (60s)
|
|
62
|
+
const originalNow = Date.now;
|
|
63
|
+
Date.now = () => originalNow() + 61_000;
|
|
64
|
+
try {
|
|
65
|
+
const result = getAudio(id);
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
} finally {
|
|
68
|
+
Date.now = originalNow;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("capacity eviction", () => {
|
|
74
|
+
test("evicts oldest entries when capacity is exceeded", () => {
|
|
75
|
+
// The store has a 50MB cap. Fill it with entries, then add one more
|
|
76
|
+
// that would exceed the cap. The oldest should be evicted.
|
|
77
|
+
const chunkSize = 10 * 1024 * 1024; // 10MB per chunk
|
|
78
|
+
const ids: string[] = [];
|
|
79
|
+
|
|
80
|
+
// Store 5 x 10MB = 50MB (at capacity)
|
|
81
|
+
for (let i = 0; i < 5; i++) {
|
|
82
|
+
ids.push(storeAudio(makeBuffer(chunkSize), "opus"));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// All 5 should be retrievable
|
|
86
|
+
for (const id of ids) {
|
|
87
|
+
expect(getAudio(id)).not.toBeNull();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Add one more 10MB entry — should evict the oldest
|
|
91
|
+
const newId = storeAudio(makeBuffer(chunkSize), "mp3");
|
|
92
|
+
expect(getAudio(newId)).not.toBeNull();
|
|
93
|
+
// The first entry should have been evicted
|
|
94
|
+
expect(getAudio(ids[0]!)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
interface AudioEntry {
|
|
4
|
+
buffer: Buffer;
|
|
5
|
+
contentType: string;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface StreamingAudioEntry {
|
|
10
|
+
contentType: string;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
chunks: Uint8Array[];
|
|
13
|
+
totalBytes: number;
|
|
14
|
+
complete: boolean;
|
|
15
|
+
subscribers: Set<ReadableStreamDefaultController<Uint8Array>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const store = new Map<string, AudioEntry>();
|
|
19
|
+
const streamingStore = new Map<string, StreamingAudioEntry>();
|
|
20
|
+
const MAX_STORE_BYTES = 50 * 1024 * 1024; // 50MB cap
|
|
21
|
+
const TTL_MS = 60_000; // 60 seconds
|
|
22
|
+
|
|
23
|
+
let currentBytes = 0;
|
|
24
|
+
|
|
25
|
+
export function storeAudio(
|
|
26
|
+
buffer: Buffer,
|
|
27
|
+
format: "mp3" | "wav" | "opus",
|
|
28
|
+
): string {
|
|
29
|
+
evictExpired();
|
|
30
|
+
// Evict oldest if over capacity
|
|
31
|
+
while (currentBytes + buffer.length > MAX_STORE_BYTES && store.size > 0) {
|
|
32
|
+
const oldest = store.keys().next().value;
|
|
33
|
+
if (oldest) removeEntry(oldest);
|
|
34
|
+
}
|
|
35
|
+
const id = randomUUID();
|
|
36
|
+
const contentType = contentTypeForFormat(format);
|
|
37
|
+
store.set(id, { buffer, contentType, expiresAt: Date.now() + TTL_MS });
|
|
38
|
+
currentBytes += buffer.length;
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Streaming entries — audio is pushed chunk-by-chunk while being served
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface StreamingAudioHandle {
|
|
47
|
+
audioId: string;
|
|
48
|
+
push: (chunk: Uint8Array) => void;
|
|
49
|
+
finalize: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createStreamingEntry(
|
|
53
|
+
format: "mp3" | "wav" | "opus",
|
|
54
|
+
): StreamingAudioHandle {
|
|
55
|
+
evictExpired();
|
|
56
|
+
const id = randomUUID();
|
|
57
|
+
const contentType = contentTypeForFormat(format);
|
|
58
|
+
const entry: StreamingAudioEntry = {
|
|
59
|
+
contentType,
|
|
60
|
+
expiresAt: Date.now() + TTL_MS,
|
|
61
|
+
chunks: [],
|
|
62
|
+
totalBytes: 0,
|
|
63
|
+
complete: false,
|
|
64
|
+
subscribers: new Set(),
|
|
65
|
+
};
|
|
66
|
+
streamingStore.set(id, entry);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
audioId: id,
|
|
70
|
+
push(chunk: Uint8Array) {
|
|
71
|
+
entry.chunks.push(chunk);
|
|
72
|
+
entry.totalBytes += chunk.byteLength;
|
|
73
|
+
for (const controller of entry.subscribers) {
|
|
74
|
+
try {
|
|
75
|
+
controller.enqueue(chunk);
|
|
76
|
+
} catch {
|
|
77
|
+
entry.subscribers.delete(controller);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
finalize() {
|
|
82
|
+
entry.complete = true;
|
|
83
|
+
for (const controller of entry.subscribers) {
|
|
84
|
+
try {
|
|
85
|
+
controller.close();
|
|
86
|
+
} catch {
|
|
87
|
+
// Already closed
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
entry.subscribers.clear();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Retrieval — handles both regular and streaming entries
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export type AudioResult =
|
|
100
|
+
| { type: "buffer"; buffer: Buffer; contentType: string }
|
|
101
|
+
| { type: "stream"; stream: ReadableStream<Uint8Array>; contentType: string };
|
|
102
|
+
|
|
103
|
+
export function getAudio(id: string): AudioResult | null {
|
|
104
|
+
evictExpired();
|
|
105
|
+
|
|
106
|
+
// Check streaming store first
|
|
107
|
+
const streamingEntry = streamingStore.get(id);
|
|
108
|
+
if (streamingEntry) {
|
|
109
|
+
if (Date.now() > streamingEntry.expiresAt) {
|
|
110
|
+
streamingStore.delete(id);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (streamingEntry.complete) {
|
|
115
|
+
// Synthesis finished — serve the complete buffer
|
|
116
|
+
const merged = mergeChunks(streamingEntry.chunks);
|
|
117
|
+
return {
|
|
118
|
+
type: "buffer",
|
|
119
|
+
buffer: Buffer.from(merged),
|
|
120
|
+
contentType: streamingEntry.contentType,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Still streaming — return a ReadableStream that replays existing
|
|
125
|
+
// chunks and subscribes for future ones.
|
|
126
|
+
let ctrl: ReadableStreamDefaultController<Uint8Array>;
|
|
127
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
128
|
+
start(controller) {
|
|
129
|
+
ctrl = controller;
|
|
130
|
+
for (const chunk of streamingEntry.chunks) {
|
|
131
|
+
controller.enqueue(chunk);
|
|
132
|
+
}
|
|
133
|
+
if (streamingEntry.complete) {
|
|
134
|
+
controller.close();
|
|
135
|
+
} else {
|
|
136
|
+
streamingEntry.subscribers.add(controller);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
cancel() {
|
|
140
|
+
streamingEntry.subscribers.delete(ctrl);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return { type: "stream", stream, contentType: streamingEntry.contentType };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check regular store
|
|
148
|
+
const entry = store.get(id);
|
|
149
|
+
if (!entry) return null;
|
|
150
|
+
if (Date.now() > entry.expiresAt) {
|
|
151
|
+
removeEntry(id);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return { type: "buffer", buffer: entry.buffer, contentType: entry.contentType };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Internal helpers
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
function contentTypeForFormat(format: "mp3" | "wav" | "opus"): string {
|
|
162
|
+
return format === "mp3"
|
|
163
|
+
? "audio/mpeg"
|
|
164
|
+
: format === "wav"
|
|
165
|
+
? "audio/wav"
|
|
166
|
+
: "audio/opus";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function mergeChunks(chunks: Uint8Array[]): Uint8Array {
|
|
170
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
|
|
171
|
+
const merged = new Uint8Array(totalLength);
|
|
172
|
+
let offset = 0;
|
|
173
|
+
for (const chunk of chunks) {
|
|
174
|
+
merged.set(chunk, offset);
|
|
175
|
+
offset += chunk.byteLength;
|
|
176
|
+
}
|
|
177
|
+
return merged;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function removeEntry(id: string): void {
|
|
181
|
+
const entry = store.get(id);
|
|
182
|
+
if (entry) {
|
|
183
|
+
currentBytes -= entry.buffer.length;
|
|
184
|
+
store.delete(id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function evictExpired(): void {
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
for (const [id, entry] of store) {
|
|
191
|
+
if (now > entry.expiresAt) removeEntry(id);
|
|
192
|
+
}
|
|
193
|
+
for (const [id, entry] of streamingStore) {
|
|
194
|
+
if (now > entry.expiresAt) {
|
|
195
|
+
for (const controller of entry.subscribers) {
|
|
196
|
+
try {
|
|
197
|
+
controller.close();
|
|
198
|
+
} catch {
|
|
199
|
+
// noop
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
streamingStore.delete(id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { getGatewayInternalBaseUrl } from "../config/env.js";
|
|
12
|
+
import { loadConfig } from "../config/loader.js";
|
|
12
13
|
import type { TrustContext } from "../daemon/conversation-runtime-assembly.js";
|
|
13
14
|
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
15
|
+
import { getPublicBaseUrl } from "../inbound/public-ingress-urls.js";
|
|
14
16
|
import {
|
|
15
17
|
expireCanonicalGuardianRequest,
|
|
16
18
|
getCanonicalRequestByPendingQuestionId,
|
|
@@ -22,6 +24,7 @@ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
|
|
|
22
24
|
import { mintDaemonDeliveryToken } from "../runtime/auth/token-service.js";
|
|
23
25
|
import { computeToolApprovalDigest } from "../security/tool-approval-digest.js";
|
|
24
26
|
import { getLogger } from "../util/logger.js";
|
|
27
|
+
import { createStreamingEntry } from "./audio-store.js";
|
|
25
28
|
import {
|
|
26
29
|
getMaxCallDurationMs,
|
|
27
30
|
getSilenceTimeoutMs,
|
|
@@ -42,6 +45,7 @@ import {
|
|
|
42
45
|
updateCallSession,
|
|
43
46
|
} from "./call-store.js";
|
|
44
47
|
import { finalizeCall } from "./finalize-call.js";
|
|
48
|
+
import { synthesizeWithFishAudio } from "./fish-audio-client.js";
|
|
45
49
|
import { sendGuardianExpiryNotices } from "./guardian-action-sweep.js";
|
|
46
50
|
import { dispatchGuardianQuestion } from "./guardian-dispatch.js";
|
|
47
51
|
import type { RelayConnection } from "./relay-server.js";
|
|
@@ -56,6 +60,7 @@ import {
|
|
|
56
60
|
extractBalancedJson,
|
|
57
61
|
stripInternalSpeechMarkers,
|
|
58
62
|
} from "./voice-control-protocol.js";
|
|
63
|
+
import { isFishAudioTts } from "./voice-quality.js";
|
|
59
64
|
import {
|
|
60
65
|
startVoiceTurn,
|
|
61
66
|
type VoiceTurnHandle,
|
|
@@ -101,6 +106,8 @@ export class CallController {
|
|
|
101
106
|
private task: string | null;
|
|
102
107
|
/** True when the call session was created via the inbound path (no outbound task). */
|
|
103
108
|
private isInbound: boolean;
|
|
109
|
+
/** When true, the disclosure announcement is skipped for this call. */
|
|
110
|
+
private skipDisclosure: boolean;
|
|
104
111
|
/** Instructions queued while an LLM turn is in-flight or during pending guardian input */
|
|
105
112
|
private pendingInstructions: string[] = [];
|
|
106
113
|
/** Ensures the call opener is triggered at most once per call. */
|
|
@@ -131,6 +138,8 @@ export class CallController {
|
|
|
131
138
|
* without blocking the caller.
|
|
132
139
|
*/
|
|
133
140
|
private guardianUnavailableForCall = false;
|
|
141
|
+
/** Active Fish Audio session — tracked so interrupt handling can close it. */
|
|
142
|
+
private activeFishAbort: AbortController | null = null;
|
|
134
143
|
|
|
135
144
|
constructor(
|
|
136
145
|
callSessionId: string,
|
|
@@ -150,9 +159,10 @@ export class CallController {
|
|
|
150
159
|
this.assistantId = opts?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
|
|
151
160
|
this.trustContext = opts?.trustContext ?? null;
|
|
152
161
|
|
|
153
|
-
// Resolve the conversation ID from the call session
|
|
162
|
+
// Resolve the conversation ID and skipDisclosure from the call session
|
|
154
163
|
const session = getCallSession(callSessionId);
|
|
155
164
|
this.conversationId = session?.conversationId ?? callSessionId;
|
|
165
|
+
this.skipDisclosure = session?.skipDisclosure ?? false;
|
|
156
166
|
|
|
157
167
|
this.startDurationTimer();
|
|
158
168
|
this.resetSilenceTimer();
|
|
@@ -340,6 +350,11 @@ export class CallController {
|
|
|
340
350
|
const wasSpeaking = this.state === "speaking";
|
|
341
351
|
this.abortCurrentTurn();
|
|
342
352
|
this.llmRunVersion++;
|
|
353
|
+
// Cancel in-flight Fish Audio synthesis on barge-in
|
|
354
|
+
if (this.activeFishAbort) {
|
|
355
|
+
this.activeFishAbort.abort();
|
|
356
|
+
this.activeFishAbort = null;
|
|
357
|
+
}
|
|
343
358
|
// Explicitly terminate the in-progress TTS turn so the relay can
|
|
344
359
|
// immediately hand control back to the caller after barge-in.
|
|
345
360
|
if (wasSpeaking) {
|
|
@@ -370,6 +385,10 @@ export class CallController {
|
|
|
370
385
|
this.pendingInstructions = [];
|
|
371
386
|
this.llmRunVersion++;
|
|
372
387
|
this.abortCurrentTurn();
|
|
388
|
+
if (this.activeFishAbort) {
|
|
389
|
+
this.activeFishAbort.abort();
|
|
390
|
+
this.activeFishAbort = null;
|
|
391
|
+
}
|
|
373
392
|
this.currentTurnPromise = null;
|
|
374
393
|
unregisterCallController(this.callSessionId);
|
|
375
394
|
|
|
@@ -516,24 +535,43 @@ export class CallController {
|
|
|
516
535
|
runVersion: number,
|
|
517
536
|
runSignal: AbortSignal,
|
|
518
537
|
): Promise<string> {
|
|
538
|
+
// Fish Audio TTS routing: when configured, buffer text by sentence
|
|
539
|
+
// boundaries and synthesize via Fish Audio instead of streaming text
|
|
540
|
+
// tokens for ElevenLabs TTS.
|
|
541
|
+
const config = loadConfig();
|
|
542
|
+
const useFishAudio = isFishAudioTts(config);
|
|
543
|
+
|
|
519
544
|
// Buffer incoming tokens so we can strip control markers ([ASK_GUARDIAN:...], [END_CALL])
|
|
520
545
|
// before they reach TTS. We hold text whenever an unmatched '[' appears, since it
|
|
521
546
|
// could be the start of a control marker.
|
|
522
547
|
let ttsBuffer = "";
|
|
523
548
|
let fullResponseText = "";
|
|
524
549
|
|
|
550
|
+
// When using Fish Audio, we accumulate all text and synthesize
|
|
551
|
+
// the complete response at the end of the turn (better prosody).
|
|
552
|
+
let fishAudioTextBuffer = "";
|
|
553
|
+
|
|
554
|
+
/** Emit a chunk of safe text to the appropriate TTS backend. */
|
|
555
|
+
const emitSafeChunk = (safeText: string): void => {
|
|
556
|
+
if (useFishAudio) {
|
|
557
|
+
fishAudioTextBuffer += safeText;
|
|
558
|
+
} else {
|
|
559
|
+
this.relay.sendTextToken(safeText, false);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
525
563
|
const flushSafeText = (): void => {
|
|
526
564
|
if (!this.isCurrentRun(runVersion)) return;
|
|
527
565
|
if (ttsBuffer.length === 0) return;
|
|
528
566
|
const bracketIdx = ttsBuffer.indexOf("[");
|
|
529
567
|
if (bracketIdx === -1) {
|
|
530
568
|
// No bracket at all — safe to flush everything
|
|
531
|
-
|
|
569
|
+
emitSafeChunk(ttsBuffer);
|
|
532
570
|
ttsBuffer = "";
|
|
533
571
|
} else {
|
|
534
572
|
// Flush everything before the bracket
|
|
535
573
|
if (bracketIdx > 0) {
|
|
536
|
-
|
|
574
|
+
emitSafeChunk(ttsBuffer.slice(0, bracketIdx));
|
|
537
575
|
ttsBuffer = ttsBuffer.slice(bracketIdx);
|
|
538
576
|
}
|
|
539
577
|
|
|
@@ -547,10 +585,10 @@ export class CallController {
|
|
|
547
585
|
// Not a control marker prefix — flush up to the next '[' (if any)
|
|
548
586
|
const nextBracket = ttsBuffer.indexOf("[", 1);
|
|
549
587
|
if (nextBracket === -1) {
|
|
550
|
-
|
|
588
|
+
emitSafeChunk(ttsBuffer);
|
|
551
589
|
ttsBuffer = "";
|
|
552
590
|
} else {
|
|
553
|
-
|
|
591
|
+
emitSafeChunk(ttsBuffer.slice(0, nextBracket));
|
|
554
592
|
ttsBuffer = ttsBuffer.slice(nextBracket);
|
|
555
593
|
}
|
|
556
594
|
}
|
|
@@ -585,6 +623,7 @@ export class CallController {
|
|
|
585
623
|
trustContext: this.trustContext ?? undefined,
|
|
586
624
|
isInbound: this.isInbound,
|
|
587
625
|
task: this.task,
|
|
626
|
+
skipDisclosure: this.skipDisclosure,
|
|
588
627
|
onTextDelta,
|
|
589
628
|
onComplete,
|
|
590
629
|
onError,
|
|
@@ -625,10 +664,49 @@ export class CallController {
|
|
|
625
664
|
// Final sweep: strip any remaining control markers from the buffer
|
|
626
665
|
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
627
666
|
if (ttsBuffer.length > 0) {
|
|
628
|
-
|
|
667
|
+
emitSafeChunk(ttsBuffer);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// When using Fish Audio, synthesize the complete response text in a
|
|
671
|
+
// single REST API call. The full text gives Fish Audio better context
|
|
672
|
+
// for prosody and intonation. Audio streams back via chunked transfer
|
|
673
|
+
// encoding and is forwarded to Twilio as it arrives.
|
|
674
|
+
if (useFishAudio && fishAudioTextBuffer.trim().length > 0) {
|
|
675
|
+
if (!this.isCurrentRun(runVersion)) return fullResponseText;
|
|
676
|
+
let handle: ReturnType<typeof createStreamingEntry> | null = null;
|
|
677
|
+
try {
|
|
678
|
+
const format = config.fishAudio.format ?? "mp3";
|
|
679
|
+
handle = createStreamingEntry(format as "mp3" | "wav" | "opus");
|
|
680
|
+
const baseUrl = getPublicBaseUrl(config);
|
|
681
|
+
const url = `${baseUrl}/v1/audio/${handle.audioId}`;
|
|
682
|
+
this.relay.sendPlayUrl(url);
|
|
683
|
+
const abortController = new AbortController();
|
|
684
|
+
this.activeFishAbort = abortController;
|
|
685
|
+
await synthesizeWithFishAudio(
|
|
686
|
+
fishAudioTextBuffer.trim(),
|
|
687
|
+
config.fishAudio,
|
|
688
|
+
{
|
|
689
|
+
onChunk: (chunk) => handle!.push(chunk),
|
|
690
|
+
signal: abortController.signal,
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
} catch (err) {
|
|
694
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
695
|
+
log.debug("Fish Audio synthesis aborted (barge-in)");
|
|
696
|
+
} else {
|
|
697
|
+
log.error({ err }, "Fish Audio synthesis failed — skipping");
|
|
698
|
+
}
|
|
699
|
+
} finally {
|
|
700
|
+
this.activeFishAbort = null;
|
|
701
|
+
handle?.finalize();
|
|
702
|
+
}
|
|
629
703
|
}
|
|
630
704
|
|
|
631
|
-
// Signal end of this turn's speech
|
|
705
|
+
// Signal end of this turn's speech. An empty token with `last: true`
|
|
706
|
+
// tells ConversationRelay to start listening — it does NOT trigger TTS
|
|
707
|
+
// synthesis. This is required even when Fish Audio handled all audio
|
|
708
|
+
// playback, because ConversationRelay still needs the end-of-turn signal
|
|
709
|
+
// to transition from "assistant speaking" to "caller speaking" state.
|
|
632
710
|
this.relay.sendTextToken("", true);
|
|
633
711
|
|
|
634
712
|
// Mark the greeting's first response as awaiting ack
|
package/src/calls/call-domain.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type StartCallInput = {
|
|
|
85
85
|
conversationId: string;
|
|
86
86
|
assistantId?: string;
|
|
87
87
|
callerIdentityMode?: "assistant_number" | "user_number";
|
|
88
|
+
skipDisclosure?: boolean;
|
|
88
89
|
};
|
|
89
90
|
|
|
90
91
|
export type CancelCallInput = {
|
|
@@ -364,6 +365,7 @@ export async function startCall(
|
|
|
364
365
|
context: callContext,
|
|
365
366
|
conversationId,
|
|
366
367
|
callerIdentityMode,
|
|
368
|
+
skipDisclosure,
|
|
367
369
|
assistantId = DAEMON_INTERNAL_ASSISTANT_ID,
|
|
368
370
|
} = input;
|
|
369
371
|
|
|
@@ -440,6 +442,7 @@ export async function startCall(
|
|
|
440
442
|
task: callContext ? `${task}\n\nContext: ${callContext}` : task,
|
|
441
443
|
callerIdentityMode: identityResult.mode,
|
|
442
444
|
callerIdentitySource: identityResult.source,
|
|
445
|
+
skipDisclosure,
|
|
443
446
|
initiatedFromConversationId: conversationId,
|
|
444
447
|
});
|
|
445
448
|
sessionId = session.id;
|