vellum 0.2.1 → 0.2.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/README.md +15 -2
- package/bun.lock +71 -100
- package/package.json +5 -3
- package/scripts/capture-x-graphql.ts +562 -0
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
- package/scripts/test.sh +5 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +133 -34
- package/src/__tests__/account-registry.test.ts +2 -1
- package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
- package/src/__tests__/asset-materialize-tool.test.ts +16 -15
- package/src/__tests__/asset-search-tool.test.ts +23 -22
- package/src/__tests__/attachments-store.test.ts +56 -127
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
- package/src/__tests__/browser-skill-endstate.test.ts +4 -3
- package/src/__tests__/call-bridge.test.ts +385 -0
- package/src/__tests__/call-constants.test.ts +40 -0
- package/src/__tests__/call-orchestrator.test.ts +130 -4
- package/src/__tests__/call-recovery.test.ts +518 -0
- package/src/__tests__/call-routes-http.test.ts +459 -0
- package/src/__tests__/call-state-machine.test.ts +143 -0
- package/src/__tests__/call-store.test.ts +216 -1
- package/src/__tests__/cli-discover.test.ts +1 -1
- package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
- package/src/__tests__/compaction.benchmark.test.ts +176 -0
- package/src/__tests__/computer-use-tools.test.ts +250 -0
- package/src/__tests__/config-schema.test.ts +305 -3
- package/src/__tests__/conflict-store.test.ts +2 -1
- package/src/__tests__/contacts-tools.test.ts +331 -0
- package/src/__tests__/conversation-store.test.ts +30 -32
- package/src/__tests__/credential-security-invariants.test.ts +4 -0
- package/src/__tests__/date-context.test.ts +373 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
- package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
- package/src/__tests__/followup-tools.test.ts +303 -0
- package/src/__tests__/handlers-twilio-config.test.ts +221 -0
- package/src/__tests__/handlers-twitter-config.test.ts +718 -0
- package/src/__tests__/intent-routing.test.ts +64 -57
- package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
- package/src/__tests__/ipc-snapshot.test.ts +71 -28
- package/src/__tests__/llm-usage-store.test.ts +3 -8
- package/src/__tests__/media-generate-image.test.ts +1 -1
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-regressions.test.ts +100 -2
- package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
- package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
- package/src/__tests__/playbook-tools.test.ts +342 -0
- package/src/__tests__/profile-compiler.test.ts +2 -1
- package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
- package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
- package/src/__tests__/recurrence-engine.test.ts +69 -0
- package/src/__tests__/recurrence-types.test.ts +71 -0
- package/src/__tests__/registry.test.ts +5 -3
- package/src/__tests__/relay-server.test.ts +633 -0
- package/src/__tests__/reminder-store.test.ts +6 -3
- package/src/__tests__/reminder.test.ts +43 -77
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
- package/src/__tests__/run-orchestrator.test.ts +4 -4
- package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
- package/src/__tests__/runtime-runs-http.test.ts +4 -4
- package/src/__tests__/runtime-runs.test.ts +4 -4
- package/src/__tests__/schedule-store.test.ts +482 -0
- package/src/__tests__/schedule-tools.test.ts +700 -0
- package/src/__tests__/scheduler-recurrence.test.ts +329 -0
- package/src/__tests__/server-history-render.test.ts +14 -13
- package/src/__tests__/session-conflict-gate.test.ts +28 -25
- package/src/__tests__/session-error.test.ts +28 -0
- package/src/__tests__/session-init.benchmark.test.ts +462 -0
- package/src/__tests__/session-queue.test.ts +71 -48
- package/src/__tests__/session-runtime-assembly.test.ts +161 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
- package/src/__tests__/signup-e2e.test.ts +2 -1
- package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
- package/src/__tests__/skill-script-runner.test.ts +159 -0
- package/src/__tests__/speaker-identification.test.ts +52 -0
- package/src/__tests__/subagent-manager-notify.test.ts +42 -10
- package/src/__tests__/subagent-tools.test.ts +141 -41
- package/src/__tests__/task-compiler.test.ts +2 -1
- package/src/__tests__/task-runner.test.ts +2 -1
- package/src/__tests__/task-scheduler.test.ts +2 -1
- package/src/__tests__/task-tools.test.ts +49 -56
- package/src/__tests__/tool-audit-listener.test.ts +1 -0
- package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
- package/src/__tests__/tool-executor.test.ts +13 -17
- package/src/__tests__/turn-commit.test.ts +218 -3
- package/src/__tests__/twilio-provider.test.ts +143 -0
- package/src/__tests__/twilio-routes.test.ts +789 -0
- package/src/__tests__/twitter-auth-handler.test.ts +581 -0
- package/src/__tests__/view-image-tool.test.ts +217 -0
- package/src/__tests__/workspace-git-service.test.ts +186 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
- package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
- package/src/bundler/app-bundler.ts +12 -8
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
- package/src/calls/call-bridge.ts +95 -0
- package/src/calls/call-constants.ts +43 -5
- package/src/calls/call-domain.ts +276 -0
- package/src/calls/call-orchestrator.ts +43 -17
- package/src/calls/call-recovery.ts +207 -0
- package/src/calls/call-state-machine.ts +68 -0
- package/src/calls/call-store.ts +192 -5
- package/src/calls/relay-server.ts +41 -4
- package/src/calls/speaker-identification.ts +213 -0
- package/src/calls/twilio-config.ts +8 -8
- package/src/calls/twilio-provider.ts +13 -9
- package/src/calls/twilio-routes.ts +90 -76
- package/src/calls/twilio-webhook-urls.ts +50 -0
- package/src/calls/types.ts +1 -1
- package/src/cli/config-commands.ts +334 -0
- package/src/cli/core-commands.ts +776 -0
- package/src/cli/doordash.ts +251 -1
- package/src/cli/ipc-client.ts +82 -0
- package/src/cli/map.ts +270 -0
- package/src/cli/twitter.ts +575 -0
- package/src/cli.ts +7 -5
- package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
- package/src/commands/cc-command-registry.ts +209 -0
- package/src/config/bundled-skills/contacts/SKILL.md +39 -0
- package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
- package/src/config/bundled-skills/document/SKILL.md +18 -0
- package/src/config/bundled-skills/document/TOOLS.json +53 -0
- package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
- package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
- package/src/config/bundled-skills/doordash/SKILL.md +82 -23
- package/src/config/bundled-skills/followups/SKILL.md +32 -0
- package/src/config/bundled-skills/followups/TOOLS.json +100 -0
- package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
- package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
- package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
- package/src/config/bundled-skills/reminder/SKILL.md +20 -0
- package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
- package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
- package/src/config/bundled-skills/schedule/SKILL.md +74 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
- package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
- package/src/config/bundled-skills/subagent/SKILL.md +25 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
- package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
- package/src/config/bundled-skills/tasks/SKILL.md +28 -0
- package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
- package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
- package/src/config/bundled-skills/twitter/SKILL.md +134 -0
- package/src/config/bundled-skills/watcher/SKILL.md +27 -0
- package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
- package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
- package/src/config/defaults.ts +34 -0
- package/src/config/loader.ts +4 -1
- package/src/config/schema.ts +165 -1
- package/src/config/system-prompt.ts +61 -16
- package/src/config/templates/IDENTITY.md +7 -0
- package/src/config/types.ts +4 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
- package/src/contacts/contact-store.ts +4 -4
- package/src/daemon/assistant-attachments.ts +10 -0
- package/src/daemon/classifier.ts +3 -1
- package/src/daemon/computer-use-session.ts +3 -1
- package/src/daemon/date-context.ts +136 -0
- package/src/daemon/handlers/apps.ts +16 -1
- package/src/daemon/handlers/browser.ts +54 -0
- package/src/daemon/handlers/computer-use.ts +7 -1
- package/src/daemon/handlers/config.ts +205 -5
- package/src/daemon/handlers/diagnostics.ts +5 -1
- package/src/daemon/handlers/documents.ts +18 -29
- package/src/daemon/handlers/home-base.ts +5 -1
- package/src/daemon/handlers/index.ts +40 -277
- package/src/daemon/handlers/misc.ts +9 -1
- package/src/daemon/handlers/publish.ts +6 -1
- package/src/daemon/handlers/sessions.ts +65 -12
- package/src/daemon/handlers/shared.ts +36 -1
- package/src/daemon/handlers/signing.ts +37 -0
- package/src/daemon/handlers/skills.ts +20 -6
- package/src/daemon/handlers/subagents.ts +8 -3
- package/src/daemon/handlers/twitter-auth.ts +169 -0
- package/src/daemon/handlers/work-items.ts +384 -68
- package/src/daemon/ipc-contract-inventory.json +32 -4
- package/src/daemon/ipc-contract.ts +156 -37
- package/src/daemon/ipc-protocol.ts +7 -2
- package/src/daemon/lifecycle.ts +21 -0
- package/src/daemon/main.ts +10 -4
- package/src/daemon/ride-shotgun-handler.ts +75 -10
- package/src/daemon/server.ts +143 -26
- package/src/daemon/session-agent-loop.ts +922 -0
- package/src/daemon/session-attachments.ts +28 -5
- package/src/daemon/session-conflict-gate.ts +18 -109
- package/src/daemon/session-error.ts +24 -3
- package/src/daemon/session-lifecycle.ts +147 -0
- package/src/daemon/session-media-retry.ts +147 -0
- package/src/daemon/session-messaging.ts +145 -0
- package/src/daemon/session-notifiers.ts +164 -0
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-queue-manager.ts +1 -0
- package/src/daemon/session-runtime-assembly.ts +52 -0
- package/src/daemon/session-skill-tools.ts +124 -5
- package/src/daemon/session-slash.ts +3 -0
- package/src/daemon/session-surfaces.ts +77 -2
- package/src/daemon/session-tool-setup.ts +216 -2
- package/src/daemon/session-usage.ts +0 -2
- package/src/daemon/session.ts +114 -1404
- package/src/daemon/video-thumbnail.ts +60 -0
- package/src/doordash/client.ts +121 -27
- package/src/doordash/queries.ts +1 -2
- package/src/export/formatter.ts +3 -1
- package/src/followups/followup-store.ts +4 -2
- package/src/followups/types.ts +6 -0
- package/src/hooks/templates.ts +1 -1
- package/src/index.ts +32 -1153
- package/src/memory/attachments-store.ts +28 -83
- package/src/memory/channel-delivery-store.ts +7 -21
- package/src/memory/clarification-resolver.ts +6 -5
- package/src/memory/conflict-intent.ts +114 -0
- package/src/memory/contradiction-checker.ts +3 -2
- package/src/memory/conversation-key-store.ts +10 -29
- package/src/memory/conversation-store.ts +2 -1
- package/src/memory/db.ts +96 -2
- package/src/memory/entity-extractor.ts +6 -3
- package/src/memory/items-extractor.ts +5 -4
- package/src/memory/job-handlers/conflict.ts +23 -1
- package/src/memory/jobs-store.ts +3 -2
- package/src/memory/llm-usage-store.ts +1 -2
- package/src/memory/runs-store.ts +1 -2
- package/src/memory/schema.ts +23 -2
- package/src/messaging/style-analyzer.ts +3 -2
- package/src/messaging/thread-summarizer.ts +8 -12
- package/src/messaging/triage-engine.ts +4 -2
- package/src/providers/openrouter/client.ts +20 -0
- package/src/providers/registry.ts +8 -0
- package/src/runtime/gateway-client.ts +36 -0
- package/src/runtime/http-server.ts +166 -22
- package/src/runtime/routes/attachment-routes.ts +2 -3
- package/src/runtime/routes/call-routes.ts +140 -0
- package/src/runtime/routes/channel-routes.ts +125 -88
- package/src/runtime/routes/conversation-routes.ts +5 -5
- package/src/runtime/routes/run-routes.ts +2 -2
- package/src/runtime/run-orchestrator.ts +9 -3
- package/src/schedule/recurrence-engine.ts +138 -0
- package/src/schedule/recurrence-types.ts +67 -0
- package/src/schedule/schedule-store.ts +102 -57
- package/src/schedule/scheduler.ts +9 -6
- package/src/security/oauth2.ts +29 -4
- package/src/security/secret-allowlist.ts +46 -0
- package/src/skills/clawhub.ts +1 -1
- package/src/subagent/manager.ts +40 -8
- package/src/swarm/backend-claude-code.ts +64 -9
- package/src/swarm/worker-prompts.ts +2 -1
- package/src/tasks/SPEC.md +34 -28
- package/src/tasks/ephemeral-permissions.ts +16 -7
- package/src/tasks/task-compiler.ts +5 -4
- package/src/tasks/task-runner.ts +10 -5
- package/src/tasks/task-scheduler.ts +1 -1
- package/src/tasks/tool-sanitizer.ts +36 -0
- package/src/tools/assets/search.ts +4 -4
- package/src/tools/browser/api-map.ts +293 -0
- package/src/tools/browser/auto-navigate.ts +270 -0
- package/src/tools/browser/browser-execution.ts +2 -1
- package/src/tools/browser/browser-manager.ts +2 -2
- package/src/tools/browser/network-recorder.ts +5 -4
- package/src/tools/browser/x-auto-navigate.ts +207 -0
- package/src/tools/calls/call-end.ts +17 -67
- package/src/tools/calls/call-start.ts +24 -85
- package/src/tools/calls/call-status.ts +35 -51
- package/src/tools/claude-code/claude-code.ts +207 -11
- package/src/tools/contacts/contact-merge.ts +46 -78
- package/src/tools/contacts/contact-search.ts +35 -79
- package/src/tools/contacts/contact-upsert.ts +35 -108
- package/src/tools/credentials/vault.ts +20 -4
- package/src/tools/document/document-tool.ts +71 -144
- package/src/tools/executor.ts +129 -10
- package/src/tools/followups/followup_create.ts +46 -88
- package/src/tools/followups/followup_list.ts +34 -74
- package/src/tools/followups/followup_resolve.ts +31 -66
- package/src/tools/host-terminal/cli-discover.ts +2 -1
- package/src/tools/host-terminal/host-shell.ts +10 -0
- package/src/tools/memory/handlers.ts +5 -4
- package/src/tools/network/__tests__/web-search.test.ts +427 -0
- package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
- package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
- package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
- package/src/tools/network/web-fetch.ts +18 -6
- package/src/tools/playbooks/index.ts +4 -5
- package/src/tools/playbooks/playbook-create.ts +3 -47
- package/src/tools/playbooks/playbook-delete.ts +1 -25
- package/src/tools/playbooks/playbook-list.ts +1 -28
- package/src/tools/playbooks/playbook-update.ts +3 -51
- package/src/tools/reminder/reminder.ts +5 -78
- package/src/tools/schedule/create.ts +69 -74
- package/src/tools/schedule/delete.ts +21 -47
- package/src/tools/schedule/list.ts +55 -74
- package/src/tools/schedule/update.ts +77 -84
- package/src/tools/subagent/abort.ts +29 -58
- package/src/tools/subagent/message.ts +30 -63
- package/src/tools/subagent/read.ts +53 -84
- package/src/tools/subagent/spawn.ts +43 -82
- package/src/tools/subagent/status.ts +42 -71
- package/src/tools/swarm/delegate.ts +2 -1
- package/src/tools/tasks/index.ts +8 -8
- package/src/tools/tasks/task-delete.ts +60 -88
- package/src/tools/tasks/task-list.ts +31 -52
- package/src/tools/tasks/task-run.ts +72 -108
- package/src/tools/tasks/task-save.ts +33 -65
- package/src/tools/tasks/work-item-enqueue.ts +183 -215
- package/src/tools/tasks/work-item-list.ts +33 -63
- package/src/tools/tasks/work-item-remove.ts +45 -97
- package/src/tools/tasks/work-item-update.ts +91 -163
- package/src/tools/terminal/backends/native.ts +3 -1
- package/src/tools/tool-manifest.ts +0 -62
- package/src/tools/types.ts +6 -0
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/tools/watch/screen-watch.ts +3 -1
- package/src/tools/watcher/create.ts +52 -98
- package/src/tools/watcher/delete.ts +20 -46
- package/src/tools/watcher/digest.ts +36 -70
- package/src/tools/watcher/list.ts +49 -79
- package/src/tools/watcher/update.ts +45 -91
- package/src/twitter/client.ts +690 -0
- package/src/twitter/session.ts +91 -0
- package/src/usage/types.ts +0 -1
- package/src/util/truncate.ts +6 -0
- package/src/watcher/providers/slack.ts +2 -1
- package/src/watcher/watcher-store.ts +3 -2
- package/src/work-items/work-item-store.ts +27 -2
- package/src/workspace/commit-message-enrichment-service.ts +31 -7
- package/src/workspace/git-service.ts +87 -22
- package/src/workspace/provider-commit-message-generator.ts +269 -0
- package/src/workspace/turn-commit.ts +62 -3
- package/src/tools/contacts/index.ts +0 -4
- package/src/tools/document/index.ts +0 -5
- package/src/tools/followups/index.ts +0 -3
- package/src/tools/subagent/index.ts +0 -5
- /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
createPendingQuestion,
|
|
17
17
|
expirePendingQuestions,
|
|
18
18
|
} from './call-store.js';
|
|
19
|
-
import {
|
|
19
|
+
import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
|
|
20
20
|
import type { RelayConnection } from './relay-server.js';
|
|
21
21
|
import { registerCallOrchestrator, unregisterCallOrchestrator, fireCallQuestionNotifier, fireCallCompletionNotifier } from './call-state.js';
|
|
22
|
+
import type { PromptSpeakerContext } from './speaker-identification.js';
|
|
22
23
|
|
|
23
24
|
const log = getLogger('call-orchestrator');
|
|
24
25
|
|
|
@@ -60,7 +61,7 @@ export class CallOrchestrator {
|
|
|
60
61
|
/**
|
|
61
62
|
* Handle a final caller utterance from the ConversationRelay.
|
|
62
63
|
*/
|
|
63
|
-
async handleCallerUtterance(transcript: string): Promise<void> {
|
|
64
|
+
async handleCallerUtterance(transcript: string, speaker?: PromptSpeakerContext): Promise<void> {
|
|
64
65
|
// If we're already processing or speaking, abort the in-flight generation
|
|
65
66
|
if (this.state === 'processing' || this.state === 'speaking') {
|
|
66
67
|
this.abortController.abort();
|
|
@@ -71,7 +72,10 @@ export class CallOrchestrator {
|
|
|
71
72
|
this.resetSilenceTimer();
|
|
72
73
|
|
|
73
74
|
// Append caller utterance
|
|
74
|
-
this.conversationHistory.push({
|
|
75
|
+
this.conversationHistory.push({
|
|
76
|
+
role: 'user',
|
|
77
|
+
content: this.formatCallerUtterance(transcript, speaker),
|
|
78
|
+
});
|
|
75
79
|
|
|
76
80
|
await this.runLlm();
|
|
77
81
|
}
|
|
@@ -100,7 +104,11 @@ export class CallOrchestrator {
|
|
|
100
104
|
// Append the user's answer as a special message the model recognizes
|
|
101
105
|
this.conversationHistory.push({ role: 'user', content: `[USER_ANSWERED: ${answerText}]` });
|
|
102
106
|
|
|
103
|
-
|
|
107
|
+
// Fire-and-forget: unblock the caller so the HTTP response and answer
|
|
108
|
+
// persistence happen immediately, before LLM streaming begins.
|
|
109
|
+
this.runLlm().catch((err) =>
|
|
110
|
+
log.error({ err, callSessionId: this.callSessionId }, 'runLlm failed after user answer'),
|
|
111
|
+
);
|
|
104
112
|
return true;
|
|
105
113
|
}
|
|
106
114
|
|
|
@@ -130,6 +138,11 @@ export class CallOrchestrator {
|
|
|
130
138
|
// ── Private ──────────────────────────────────────────────────────
|
|
131
139
|
|
|
132
140
|
private buildSystemPrompt(): string {
|
|
141
|
+
const config = getConfig();
|
|
142
|
+
const disclosureRule = config.calls.disclosure.enabled
|
|
143
|
+
? `1. ${config.calls.disclosure.text}`
|
|
144
|
+
: '1. Begin the conversation naturally.';
|
|
145
|
+
|
|
133
146
|
return [
|
|
134
147
|
'You are on a live phone call on behalf of your user.',
|
|
135
148
|
this.task ? `Task: ${this.task}` : '',
|
|
@@ -138,18 +151,27 @@ export class CallOrchestrator {
|
|
|
138
151
|
'Respond naturally and conversationally — speak as you would in a real phone conversation.',
|
|
139
152
|
'',
|
|
140
153
|
'IMPORTANT RULES:',
|
|
141
|
-
|
|
154
|
+
disclosureRule,
|
|
142
155
|
'2. Be concise — phone conversations should be brief and natural.',
|
|
143
156
|
'3. If the callee asks something you don\'t know, include [ASK_USER: your question here] in your response along with a hold message like "Let me check on that for you."',
|
|
144
157
|
'4. If the callee provides information preceded by [USER_ANSWERED: ...], use that answer naturally in the conversation.',
|
|
145
158
|
'5. When the call\'s purpose is fulfilled, include [END_CALL] in your response along with a polite goodbye.',
|
|
146
159
|
'6. Do not make up information — ask the user if unsure.',
|
|
147
160
|
'7. Keep responses short — 1-3 sentences is ideal for phone conversation.',
|
|
161
|
+
'8. When caller text includes [SPEAKER id="..." label="..."], treat each speaker as a distinct person and personalize responses using that speaker\'s prior context in this call.',
|
|
148
162
|
]
|
|
149
163
|
.filter(Boolean)
|
|
150
164
|
.join('\n');
|
|
151
165
|
}
|
|
152
166
|
|
|
167
|
+
private formatCallerUtterance(transcript: string, speaker?: PromptSpeakerContext): string {
|
|
168
|
+
if (!speaker) return transcript;
|
|
169
|
+
const safeId = speaker.speakerId.replaceAll('"', '\'');
|
|
170
|
+
const safeLabel = speaker.speakerLabel.replaceAll('"', '\'');
|
|
171
|
+
const confidencePart = speaker.speakerConfidence !== null ? ` confidence="${speaker.speakerConfidence.toFixed(2)}"` : '';
|
|
172
|
+
return `[SPEAKER id="${safeId}" label="${safeLabel}" source="${speaker.source}"${confidencePart}] ${transcript}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
153
175
|
/**
|
|
154
176
|
* Run the LLM with the current conversation history and stream
|
|
155
177
|
* the response back through the relay.
|
|
@@ -215,7 +237,8 @@ export class CallOrchestrator {
|
|
|
215
237
|
'[ASK_USER:'.startsWith(afterBracket) ||
|
|
216
238
|
'[END_CALL]'.startsWith(afterBracket) ||
|
|
217
239
|
afterBracket.startsWith('[ASK_USER:') ||
|
|
218
|
-
afterBracket
|
|
240
|
+
afterBracket === '[END_CALL' ||
|
|
241
|
+
afterBracket.startsWith('[END_CALL]');
|
|
219
242
|
|
|
220
243
|
if (!couldBeControl) {
|
|
221
244
|
// Not a control marker prefix — flush up to the next '[' (if any)
|
|
@@ -295,7 +318,7 @@ export class CallOrchestrator {
|
|
|
295
318
|
updateCallSession(this.callSessionId, { status: 'in_progress' });
|
|
296
319
|
expirePendingQuestions(this.callSessionId);
|
|
297
320
|
}
|
|
298
|
-
},
|
|
321
|
+
}, getUserConsultationTimeoutMs());
|
|
299
322
|
return;
|
|
300
323
|
}
|
|
301
324
|
|
|
@@ -329,15 +352,18 @@ export class CallOrchestrator {
|
|
|
329
352
|
}
|
|
330
353
|
|
|
331
354
|
private startDurationTimer(): void {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
this.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
355
|
+
const maxDurationMs = getMaxCallDurationMs();
|
|
356
|
+
const warningMs = maxDurationMs - 2 * 60 * 1000; // 2 minutes before max
|
|
357
|
+
|
|
358
|
+
if (warningMs > 0) {
|
|
359
|
+
this.durationWarningTimer = setTimeout(() => {
|
|
360
|
+
log.info({ callSessionId: this.callSessionId }, 'Call duration warning');
|
|
361
|
+
this.relay.sendTextToken(
|
|
362
|
+
'Just to let you know, we\'re running low on time for this call.',
|
|
363
|
+
true,
|
|
364
|
+
);
|
|
365
|
+
}, warningMs);
|
|
366
|
+
}
|
|
341
367
|
|
|
342
368
|
this.durationTimer = setTimeout(() => {
|
|
343
369
|
log.info({ callSessionId: this.callSessionId }, 'Call duration limit reached');
|
|
@@ -351,7 +377,7 @@ export class CallOrchestrator {
|
|
|
351
377
|
updateCallSession(this.callSessionId, { status: 'completed', endedAt: Date.now() });
|
|
352
378
|
recordCallEvent(this.callSessionId, 'call_ended', { reason: 'max_duration' });
|
|
353
379
|
}, 3000);
|
|
354
|
-
},
|
|
380
|
+
}, maxDurationMs);
|
|
355
381
|
}
|
|
356
382
|
|
|
357
383
|
private resetSilenceTimer(): void {
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call recovery — reconciles in-flight calls on daemon restart.
|
|
3
|
+
*
|
|
4
|
+
* When the daemon restarts, any calls left in non-terminal states may be stale
|
|
5
|
+
* (the daemon crashed mid-call) or still active on the provider side. This
|
|
6
|
+
* module fetches the actual provider status and transitions each call
|
|
7
|
+
* accordingly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLogger } from '../util/logger.js';
|
|
11
|
+
import { listRecoverableCalls, updateCallSession, expirePendingQuestions } from './call-store.js';
|
|
12
|
+
import type { VoiceProvider } from './voice-provider.js';
|
|
13
|
+
import type { CallStatus } from './types.js';
|
|
14
|
+
|
|
15
|
+
type Logger = ReturnType<typeof getLogger>;
|
|
16
|
+
|
|
17
|
+
const defaultLog = getLogger('call-recovery');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Grace period (in ms) for no-SID sessions during startup recovery.
|
|
21
|
+
*
|
|
22
|
+
* A daemon crash can leave a live Twilio call without a persisted SID
|
|
23
|
+
* (crash after `initiateCall` succeeds but before the SID is written).
|
|
24
|
+
* Webhooks carrying the SID may still arrive after restart.
|
|
25
|
+
*
|
|
26
|
+
* Sessions younger than this threshold are annotated but left in their
|
|
27
|
+
* current non-terminal state so incoming webhooks can still deliver the
|
|
28
|
+
* SID and resume the call normally.
|
|
29
|
+
*
|
|
30
|
+
* Sessions older than this threshold are transitioned to `failed` to
|
|
31
|
+
* prevent orphan sessions from creating false "active call" state
|
|
32
|
+
* indefinitely. 5 minutes is long enough for any legitimate webhook
|
|
33
|
+
* to arrive; after that the session is considered abandoned.
|
|
34
|
+
*/
|
|
35
|
+
export const NO_SID_GRACE_PERIOD_MS = 5 * 60_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map a Twilio provider status string to our internal CallStatus.
|
|
39
|
+
* Returns the mapped status or null if the status is unrecognised.
|
|
40
|
+
*/
|
|
41
|
+
function mapProviderStatus(providerStatus: string): CallStatus | null {
|
|
42
|
+
switch (providerStatus) {
|
|
43
|
+
case 'queued':
|
|
44
|
+
case 'ringing':
|
|
45
|
+
return 'ringing';
|
|
46
|
+
case 'in-progress':
|
|
47
|
+
return 'in_progress';
|
|
48
|
+
case 'completed':
|
|
49
|
+
return 'completed';
|
|
50
|
+
case 'failed':
|
|
51
|
+
case 'busy':
|
|
52
|
+
case 'no-answer':
|
|
53
|
+
case 'canceled':
|
|
54
|
+
return 'failed';
|
|
55
|
+
default:
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check whether a CallStatus is terminal (no further transitions allowed).
|
|
62
|
+
*/
|
|
63
|
+
function isTerminal(status: CallStatus): boolean {
|
|
64
|
+
return status === 'completed' || status === 'failed' || status === 'cancelled';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reconcile all non-terminal call sessions at daemon startup.
|
|
69
|
+
*
|
|
70
|
+
* For each recoverable call:
|
|
71
|
+
* - If it has a provider SID, fetch the current status from the provider
|
|
72
|
+
* and transition the call to match.
|
|
73
|
+
* - If no provider SID exists and the session is older than the grace
|
|
74
|
+
* period, transition it to `failed` to prevent orphan sessions from
|
|
75
|
+
* creating false "active call" state indefinitely.
|
|
76
|
+
* - If no provider SID exists but the session is within the grace period,
|
|
77
|
+
* leave it non-terminal so webhooks can still deliver the SID.
|
|
78
|
+
* - If the call transitions to a terminal state, expire any pending questions.
|
|
79
|
+
*/
|
|
80
|
+
export async function reconcileCallsOnStartup(
|
|
81
|
+
provider: VoiceProvider,
|
|
82
|
+
log: Logger = defaultLog,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const recoverableCalls = listRecoverableCalls();
|
|
85
|
+
|
|
86
|
+
if (recoverableCalls.length === 0) {
|
|
87
|
+
log.info('No recoverable calls found at startup');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
log.info({ count: recoverableCalls.length }, 'Reconciling non-terminal calls at startup');
|
|
92
|
+
|
|
93
|
+
for (const session of recoverableCalls) {
|
|
94
|
+
try {
|
|
95
|
+
if (!session.providerCallSid) {
|
|
96
|
+
const sessionAgeMs = Date.now() - session.createdAt;
|
|
97
|
+
const isStale = sessionAgeMs >= NO_SID_GRACE_PERIOD_MS;
|
|
98
|
+
|
|
99
|
+
if (isStale) {
|
|
100
|
+
// Session is old enough that any legitimate webhook should
|
|
101
|
+
// have arrived by now. Transition to `failed` so it no
|
|
102
|
+
// longer appears as an active call.
|
|
103
|
+
log.info(
|
|
104
|
+
{ callSessionId: session.id, previousStatus: session.status, sessionAgeMs },
|
|
105
|
+
'No-SID session past grace period — failing orphan session',
|
|
106
|
+
);
|
|
107
|
+
updateCallSession(session.id, {
|
|
108
|
+
status: 'failed',
|
|
109
|
+
endedAt: Date.now(),
|
|
110
|
+
lastError: 'Daemon restarted before provider SID persisted; grace period expired — orphan session failed',
|
|
111
|
+
});
|
|
112
|
+
expirePendingQuestions(session.id);
|
|
113
|
+
} else {
|
|
114
|
+
// Recent session — webhooks carrying the SID may still arrive.
|
|
115
|
+
// Leave in its current non-terminal state.
|
|
116
|
+
log.info(
|
|
117
|
+
{ callSessionId: session.id, previousStatus: session.status, sessionAgeMs },
|
|
118
|
+
'Skipping recent no-SID session (within grace period, webhooks may still arrive)',
|
|
119
|
+
);
|
|
120
|
+
updateCallSession(session.id, {
|
|
121
|
+
lastError: 'Daemon restarted before provider SID persisted; awaiting webhook',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fetch actual status from provider
|
|
128
|
+
let providerStatus: string;
|
|
129
|
+
try {
|
|
130
|
+
providerStatus = await provider.getCallStatus(session.providerCallSid);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
133
|
+
log.warn(
|
|
134
|
+
{ callSessionId: session.id, callSid: session.providerCallSid, err },
|
|
135
|
+
'Failed to fetch provider status during recovery — failing call',
|
|
136
|
+
);
|
|
137
|
+
updateCallSession(session.id, {
|
|
138
|
+
status: 'failed',
|
|
139
|
+
endedAt: Date.now(),
|
|
140
|
+
lastError: `Recovery: failed to fetch provider status: ${msg}`,
|
|
141
|
+
});
|
|
142
|
+
expirePendingQuestions(session.id);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const mappedStatus = mapProviderStatus(providerStatus);
|
|
147
|
+
|
|
148
|
+
if (!mappedStatus) {
|
|
149
|
+
log.warn(
|
|
150
|
+
{ callSessionId: session.id, providerStatus },
|
|
151
|
+
'Unrecognised provider status during recovery — failing call',
|
|
152
|
+
);
|
|
153
|
+
updateCallSession(session.id, {
|
|
154
|
+
status: 'failed',
|
|
155
|
+
endedAt: Date.now(),
|
|
156
|
+
lastError: `Recovery: unrecognised provider status '${providerStatus}'`,
|
|
157
|
+
});
|
|
158
|
+
expirePendingQuestions(session.id);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isTerminal(mappedStatus)) {
|
|
163
|
+
// Provider says the call has ended
|
|
164
|
+
log.info(
|
|
165
|
+
{ callSessionId: session.id, providerStatus, mappedStatus },
|
|
166
|
+
'Provider reports call ended — transitioning to terminal state',
|
|
167
|
+
);
|
|
168
|
+
updateCallSession(session.id, {
|
|
169
|
+
status: mappedStatus,
|
|
170
|
+
endedAt: Date.now(),
|
|
171
|
+
});
|
|
172
|
+
expirePendingQuestions(session.id);
|
|
173
|
+
} else {
|
|
174
|
+
// Provider says call is still active — leave it for webhooks to handle
|
|
175
|
+
log.info(
|
|
176
|
+
{ callSessionId: session.id, providerStatus, mappedStatus },
|
|
177
|
+
'Provider reports call still active — leaving for webhook handling',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
log.error(
|
|
182
|
+
{ callSessionId: session.id, err },
|
|
183
|
+
'Unexpected error during call recovery',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
log.info('Call recovery reconciliation complete');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Log a dead-letter provider event — a provider callback payload that
|
|
193
|
+
* could not be processed (malformed, unknown format, etc.).
|
|
194
|
+
*
|
|
195
|
+
* Rather than silently dropping these events, we log the full payload
|
|
196
|
+
* so operators can investigate later.
|
|
197
|
+
*/
|
|
198
|
+
export function logDeadLetterEvent(
|
|
199
|
+
reason: string,
|
|
200
|
+
payload: unknown,
|
|
201
|
+
log: Logger = defaultLog,
|
|
202
|
+
): void {
|
|
203
|
+
log.error(
|
|
204
|
+
{ reason, payload },
|
|
205
|
+
'Dead-letter provider event: callback could not be processed',
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call state machine — defines allowed status transitions and validates
|
|
3
|
+
* all state changes in a single place.
|
|
4
|
+
*
|
|
5
|
+
* Terminal states (completed, failed, cancelled) are immutable: no further
|
|
6
|
+
* transitions are permitted once a call reaches one of these states.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CallStatus } from './types.js';
|
|
10
|
+
|
|
11
|
+
// ── Transition table ─────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maps each call status to the set of statuses it may transition to.
|
|
15
|
+
* Terminal states map to an empty set.
|
|
16
|
+
*/
|
|
17
|
+
const ALLOWED_TRANSITIONS: Record<CallStatus, Set<CallStatus>> = {
|
|
18
|
+
initiated: new Set<CallStatus>(['ringing', 'in_progress', 'waiting_on_user', 'completed', 'failed', 'cancelled']),
|
|
19
|
+
ringing: new Set<CallStatus>(['in_progress', 'waiting_on_user', 'completed', 'failed', 'cancelled']),
|
|
20
|
+
in_progress: new Set<CallStatus>(['waiting_on_user', 'completed', 'failed', 'cancelled']),
|
|
21
|
+
waiting_on_user: new Set<CallStatus>(['in_progress', 'completed', 'failed', 'cancelled']),
|
|
22
|
+
// Terminal states — no further transitions allowed
|
|
23
|
+
completed: new Set<CallStatus>(),
|
|
24
|
+
failed: new Set<CallStatus>(),
|
|
25
|
+
cancelled: new Set<CallStatus>(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const TERMINAL_STATES: Set<CallStatus> = new Set(['completed', 'failed', 'cancelled']);
|
|
29
|
+
|
|
30
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export interface TransitionResult {
|
|
33
|
+
valid: boolean;
|
|
34
|
+
reason?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check whether a transition from `current` to `next` is allowed.
|
|
39
|
+
*/
|
|
40
|
+
export function validateTransition(current: CallStatus, next: CallStatus): TransitionResult {
|
|
41
|
+
if (current === next) {
|
|
42
|
+
return { valid: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (isTerminalState(current)) {
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
reason: `Cannot transition from terminal state '${current}'`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const allowed = ALLOWED_TRANSITIONS[current];
|
|
53
|
+
if (!allowed || !allowed.has(next)) {
|
|
54
|
+
return {
|
|
55
|
+
valid: false,
|
|
56
|
+
reason: `Invalid transition from '${current}' to '${next}'`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { valid: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns true if the given status is a terminal (immutable) state.
|
|
65
|
+
*/
|
|
66
|
+
export function isTerminalState(status: CallStatus): boolean {
|
|
67
|
+
return TERMINAL_STATES.has(status);
|
|
68
|
+
}
|
package/src/calls/call-store.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { eq, and, notInArray, desc } from 'drizzle-orm';
|
|
|
2
2
|
import { v4 as uuid } from 'uuid';
|
|
3
3
|
import { getDb } from '../memory/db.js';
|
|
4
4
|
import { callSessions, callEvents, callPendingQuestions } from '../memory/schema.js';
|
|
5
|
-
import type { CallSession, CallEvent, CallPendingQuestion, CallEventType } from './types.js';
|
|
5
|
+
import type { CallSession, CallEvent, CallPendingQuestion, CallEventType, CallStatus } from './types.js';
|
|
6
|
+
import { validateTransition } from './call-state-machine.js';
|
|
6
7
|
import { getLogger } from '../util/logger.js';
|
|
7
8
|
|
|
8
9
|
const log = getLogger('call-store');
|
|
@@ -105,7 +106,7 @@ export function getActiveCallSessionForConversation(conversationId: string): Cal
|
|
|
105
106
|
.where(
|
|
106
107
|
and(
|
|
107
108
|
eq(callSessions.conversationId, conversationId),
|
|
108
|
-
notInArray(callSessions.status, ['completed', 'failed']),
|
|
109
|
+
notInArray(callSessions.status, ['completed', 'failed', 'cancelled']),
|
|
109
110
|
),
|
|
110
111
|
)
|
|
111
112
|
.orderBy(desc(callSessions.createdAt))
|
|
@@ -119,12 +120,44 @@ export function updateCallSession(
|
|
|
119
120
|
updates: Partial<Pick<CallSession, 'status' | 'providerCallSid' | 'startedAt' | 'endedAt' | 'lastError'>>,
|
|
120
121
|
): void {
|
|
121
122
|
const db = getDb();
|
|
123
|
+
|
|
124
|
+
// Validate status transition when a new status is provided
|
|
125
|
+
if (updates.status) {
|
|
126
|
+
const current = getCallSession(id);
|
|
127
|
+
if (current) {
|
|
128
|
+
const result = validateTransition(current.status, updates.status as CallStatus);
|
|
129
|
+
if (!result.valid) {
|
|
130
|
+
log.warn({ callSessionId: id, from: current.status, to: updates.status, reason: result.reason }, 'Invalid call status transition — skipping update');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
122
136
|
db.update(callSessions)
|
|
123
137
|
.set({ ...updates, updatedAt: Date.now() })
|
|
124
138
|
.where(eq(callSessions.id, id))
|
|
125
139
|
.run();
|
|
126
140
|
}
|
|
127
141
|
|
|
142
|
+
// ── Recovery queries ─────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Returns all call sessions that are in a non-terminal state
|
|
146
|
+
* (i.e. not completed, failed, or cancelled). Used during daemon startup
|
|
147
|
+
* to reconcile in-flight calls.
|
|
148
|
+
*/
|
|
149
|
+
export function listRecoverableCalls(): CallSession[] {
|
|
150
|
+
const db = getDb();
|
|
151
|
+
const rows = db
|
|
152
|
+
.select()
|
|
153
|
+
.from(callSessions)
|
|
154
|
+
.where(
|
|
155
|
+
notInArray(callSessions.status, ['completed', 'failed', 'cancelled']),
|
|
156
|
+
)
|
|
157
|
+
.all();
|
|
158
|
+
return rows.map(parseCallSession);
|
|
159
|
+
}
|
|
160
|
+
|
|
128
161
|
// ── Call Events ──────────────────────────────────────────────────────
|
|
129
162
|
|
|
130
163
|
export function recordCallEvent(
|
|
@@ -207,10 +240,10 @@ export function answerPendingQuestion(id: string, answerText: string): void {
|
|
|
207
240
|
),
|
|
208
241
|
)
|
|
209
242
|
.run();
|
|
210
|
-
// Drizzle's .run() returns void for bun:sqlite, so
|
|
243
|
+
// Drizzle's .run() returns void for bun:sqlite, so check affected rows via raw client.
|
|
211
244
|
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
245
|
+
const changes = raw.query('SELECT changes() as c').get() as { c: number };
|
|
246
|
+
if (changes.c === 0) {
|
|
214
247
|
log.warn({ questionId: id }, 'answerPendingQuestion: no rows updated — question may have already been answered or expired');
|
|
215
248
|
}
|
|
216
249
|
}
|
|
@@ -227,3 +260,157 @@ export function expirePendingQuestions(callSessionId: string): void {
|
|
|
227
260
|
)
|
|
228
261
|
.run();
|
|
229
262
|
}
|
|
263
|
+
|
|
264
|
+
// ── Callback Idempotency ─────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/** Claims older than this are considered orphaned (crashed mid-processing) and can be reclaimed. */
|
|
267
|
+
const CLAIM_EXPIRY_MS = 60_000; // 60 seconds
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Build a dedupe key for a Twilio status callback.
|
|
271
|
+
* Combines CallSid + CallStatus + Timestamp (or SequenceNumber if present)
|
|
272
|
+
* to uniquely identify each callback.
|
|
273
|
+
*/
|
|
274
|
+
export function buildCallbackDedupeKey(
|
|
275
|
+
callSid: string,
|
|
276
|
+
callStatus: string,
|
|
277
|
+
timestamp?: string | null,
|
|
278
|
+
sequenceNumber?: string | null,
|
|
279
|
+
): string {
|
|
280
|
+
const discriminator = sequenceNumber ?? timestamp ?? '';
|
|
281
|
+
return `${callSid}:${callStatus}:${discriminator}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check whether a callback dedupe key has already been processed (read-only).
|
|
286
|
+
* Returns true if the key already exists, false otherwise.
|
|
287
|
+
*/
|
|
288
|
+
export function isCallbackProcessed(dedupeKey: string): boolean {
|
|
289
|
+
const db = getDb();
|
|
290
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
291
|
+
|
|
292
|
+
const row = raw.query(
|
|
293
|
+
`SELECT 1 FROM processed_callbacks WHERE dedupe_key = ?`,
|
|
294
|
+
).get(dedupeKey);
|
|
295
|
+
return row != null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Record a callback as processed. Should be called AFTER downstream writes
|
|
300
|
+
* (session updates, event recording) have succeeded so that Twilio retries
|
|
301
|
+
* are not silently dropped if those writes fail.
|
|
302
|
+
*
|
|
303
|
+
* Uses INSERT OR IGNORE so concurrent calls for the same key are safe.
|
|
304
|
+
*/
|
|
305
|
+
export function recordProcessedCallback(
|
|
306
|
+
dedupeKey: string,
|
|
307
|
+
callSessionId: string,
|
|
308
|
+
): void {
|
|
309
|
+
const db = getDb();
|
|
310
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
311
|
+
|
|
312
|
+
raw.query(
|
|
313
|
+
`INSERT OR IGNORE INTO processed_callbacks (id, dedupe_key, call_session_id, created_at) VALUES (?, ?, ?, ?)`,
|
|
314
|
+
).run(uuid(), dedupeKey, callSessionId, Date.now());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Try to record a processed callback. Returns true if this is a new callback
|
|
319
|
+
* (inserted successfully). Returns false if the callback was already processed
|
|
320
|
+
* (dedupe key already exists), indicating a replay.
|
|
321
|
+
*
|
|
322
|
+
* Uses INSERT ... ON CONFLICT DO NOTHING pattern for atomicity.
|
|
323
|
+
*
|
|
324
|
+
* @deprecated Use claimCallback + releaseCallbackClaim instead to
|
|
325
|
+
* atomically claim a callback and release on failure.
|
|
326
|
+
*/
|
|
327
|
+
export function tryRecordProcessedCallback(
|
|
328
|
+
dedupeKey: string,
|
|
329
|
+
callSessionId: string,
|
|
330
|
+
): boolean {
|
|
331
|
+
const db = getDb();
|
|
332
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
333
|
+
|
|
334
|
+
raw.query(
|
|
335
|
+
`INSERT OR IGNORE INTO processed_callbacks (id, dedupe_key, call_session_id, created_at) VALUES (?, ?, ?, ?)`,
|
|
336
|
+
).run(uuid(), dedupeKey, callSessionId, Date.now());
|
|
337
|
+
|
|
338
|
+
const changes = raw.query('SELECT changes() as c').get() as { c: number };
|
|
339
|
+
return changes.c > 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Atomically claim a callback for processing. Returns a unique claim ID
|
|
344
|
+
* (string) if this caller won the claim, or null if another caller already
|
|
345
|
+
* claimed it (dedupe_key conflict).
|
|
346
|
+
*
|
|
347
|
+
* Expired orphaned claims (older than CLAIM_EXPIRY_MS) are automatically
|
|
348
|
+
* cleared before attempting the insert, so crashes mid-processing don't
|
|
349
|
+
* permanently block retries.
|
|
350
|
+
*
|
|
351
|
+
* If processing fails, call `releaseCallbackClaim(dedupeKey, claimId)` to allow retries.
|
|
352
|
+
* On success, call `finalizeCallbackClaim(dedupeKey, claimId)` to make the claim permanent.
|
|
353
|
+
*
|
|
354
|
+
* The returned claim ID acts as an ownership token: release and finalize
|
|
355
|
+
* operations require it so that handler A cannot accidentally release or
|
|
356
|
+
* finalize a claim that was reclaimed by handler B after expiry.
|
|
357
|
+
*/
|
|
358
|
+
export function claimCallback(dedupeKey: string, callSessionId: string): string | null {
|
|
359
|
+
const db = getDb();
|
|
360
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
361
|
+
|
|
362
|
+
// Clear any expired orphaned claims so they can be reprocessed
|
|
363
|
+
raw.query(
|
|
364
|
+
`DELETE FROM processed_callbacks WHERE dedupe_key = ? AND created_at < ?`,
|
|
365
|
+
).run(dedupeKey, Date.now() - CLAIM_EXPIRY_MS);
|
|
366
|
+
|
|
367
|
+
const claimId = uuid();
|
|
368
|
+
raw.query(
|
|
369
|
+
`INSERT OR IGNORE INTO processed_callbacks (id, dedupe_key, call_session_id, claim_id, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
370
|
+
).run(uuid(), dedupeKey, callSessionId, claimId, Date.now());
|
|
371
|
+
const changes = raw.query('SELECT changes() as c').get() as { c: number };
|
|
372
|
+
return changes.c > 0 ? claimId : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Release a callback claim so that retries can reprocess it.
|
|
377
|
+
* Called when processing fails after a successful claim.
|
|
378
|
+
*
|
|
379
|
+
* Only deletes the row if both dedupe_key AND claim_id match, preventing
|
|
380
|
+
* handler A from releasing a claim that was reclaimed by handler B.
|
|
381
|
+
*/
|
|
382
|
+
export function releaseCallbackClaim(dedupeKey: string, claimId: string): void {
|
|
383
|
+
const db = getDb();
|
|
384
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
385
|
+
raw.query(`DELETE FROM processed_callbacks WHERE dedupe_key = ? AND claim_id = ?`).run(dedupeKey, claimId);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Finalize a callback claim after successful processing.
|
|
390
|
+
* Sets the created_at to a far-future value so the claim never expires,
|
|
391
|
+
* distinguishing it from in-flight claims that may need to be reclaimed.
|
|
392
|
+
*
|
|
393
|
+
* Only updates the row if both dedupe_key AND claim_id match, preventing
|
|
394
|
+
* handler A from finalizing a claim that was reclaimed by handler B.
|
|
395
|
+
*
|
|
396
|
+
* Returns true if the claim was successfully finalized, or false if 0 rows
|
|
397
|
+
* were updated — meaning the claim was reclaimed by another handler after
|
|
398
|
+
* expiry. Callers should treat a false return as a lost-claim signal: the
|
|
399
|
+
* business writes already happened but the dedupe row belongs to someone
|
|
400
|
+
* else, so duplicate processing may occur on later retries.
|
|
401
|
+
*/
|
|
402
|
+
export function finalizeCallbackClaim(dedupeKey: string, claimId: string): boolean {
|
|
403
|
+
const db = getDb();
|
|
404
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
405
|
+
// Set created_at far in the future so expiry check never matches
|
|
406
|
+
const NEVER_EXPIRE = Date.now() + 100 * 365 * 24 * 60 * 60 * 1000; // ~100 years
|
|
407
|
+
raw.query(
|
|
408
|
+
`UPDATE processed_callbacks SET created_at = ? WHERE dedupe_key = ? AND claim_id = ?`,
|
|
409
|
+
).run(NEVER_EXPIRE, dedupeKey, claimId);
|
|
410
|
+
const changes = raw.query('SELECT changes() as c').get() as { c: number };
|
|
411
|
+
if (changes.c === 0) {
|
|
412
|
+
log.warn({ dedupeKey, claimId }, 'finalizeCallbackClaim: claim was lost — another handler reclaimed this key after expiry');
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|