@clawvoice/voice-assistant 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +125 -0
  2. package/CHANGELOG.md +112 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/dist/cli.d.ts +10 -0
  6. package/dist/cli.js +272 -0
  7. package/dist/config.d.ts +42 -0
  8. package/dist/config.js +182 -0
  9. package/dist/diagnostics/health.d.ts +14 -0
  10. package/dist/diagnostics/health.js +182 -0
  11. package/dist/hooks.d.ts +16 -0
  12. package/dist/hooks.js +113 -0
  13. package/dist/inbound/classifier.d.ts +5 -0
  14. package/dist/inbound/classifier.js +72 -0
  15. package/dist/inbound/types.d.ts +30 -0
  16. package/dist/inbound/types.js +2 -0
  17. package/dist/index.d.ts +5 -0
  18. package/dist/index.js +52 -0
  19. package/dist/routes.d.ts +6 -0
  20. package/dist/routes.js +89 -0
  21. package/dist/services/memory-extraction.d.ts +42 -0
  22. package/dist/services/memory-extraction.js +117 -0
  23. package/dist/services/post-call.d.ts +56 -0
  24. package/dist/services/post-call.js +112 -0
  25. package/dist/services/relay.d.ts +9 -0
  26. package/dist/services/relay.js +19 -0
  27. package/dist/services/voice-call.d.ts +61 -0
  28. package/dist/services/voice-call.js +189 -0
  29. package/dist/telephony/telnyx.d.ts +12 -0
  30. package/dist/telephony/telnyx.js +60 -0
  31. package/dist/telephony/twilio.d.ts +12 -0
  32. package/dist/telephony/twilio.js +63 -0
  33. package/dist/telephony/types.d.ts +15 -0
  34. package/dist/telephony/types.js +2 -0
  35. package/dist/telephony/util.d.ts +2 -0
  36. package/dist/telephony/util.js +25 -0
  37. package/dist/tools.d.ts +5 -0
  38. package/dist/tools.js +167 -0
  39. package/dist/voice/bridge.d.ts +47 -0
  40. package/dist/voice/bridge.js +411 -0
  41. package/dist/voice/types.d.ts +168 -0
  42. package/dist/voice/types.js +42 -0
  43. package/dist/webhooks/verify.d.ts +30 -0
  44. package/dist/webhooks/verify.js +95 -0
  45. package/docs/FEATURES.md +36 -0
  46. package/docs/OPENCLAW_PLUGIN_GUIDE.md +1202 -0
  47. package/docs/SETUP.md +303 -0
  48. package/openclaw.plugin.json +137 -0
  49. package/package.json +37 -0
  50. package/skills/voice-assistant/SKILL.md +15 -0
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runDiagnostics = runDiagnostics;
4
+ function runDiagnostics(config) {
5
+ const checks = [];
6
+ checks.push(checkMode(config));
7
+ checks.push(checkTelephonyProvider(config));
8
+ checks.push(checkVoiceProvider(config));
9
+ checks.push(checkTelephonyCredentials(config));
10
+ checks.push(checkVoiceCredentials(config));
11
+ checks.push(checkWebhookConfig(config));
12
+ checks.push(checkDisclosure(config));
13
+ checks.push(checkCallDuration(config));
14
+ const overall = deriveOverall(checks);
15
+ return {
16
+ overall,
17
+ checks,
18
+ generatedAt: new Date().toISOString(),
19
+ };
20
+ }
21
+ function checkMode(config) {
22
+ return {
23
+ name: "mode",
24
+ status: "pass",
25
+ detail: `Inbound: ${config.inboundEnabled ? "enabled" : "disabled"}`,
26
+ };
27
+ }
28
+ function checkTelephonyProvider(config) {
29
+ const valid = ["twilio", "telnyx"];
30
+ if (!valid.includes(config.telephonyProvider)) {
31
+ return {
32
+ name: "telephony-provider",
33
+ status: "fail",
34
+ detail: `Unknown telephony provider: ${config.telephonyProvider}`,
35
+ remediation: `Set CLAWVOICE_TELEPHONY_PROVIDER to one of: ${valid.join(", ")}`,
36
+ };
37
+ }
38
+ return {
39
+ name: "telephony-provider",
40
+ status: "pass",
41
+ detail: `Telephony: ${config.telephonyProvider}`,
42
+ };
43
+ }
44
+ function checkVoiceProvider(config) {
45
+ const valid = ["deepgram-agent", "elevenlabs-conversational"];
46
+ if (!valid.includes(config.voiceProvider)) {
47
+ return {
48
+ name: "voice-provider",
49
+ status: "fail",
50
+ detail: `Unknown voice provider: ${config.voiceProvider}`,
51
+ remediation: `Set CLAWVOICE_VOICE_PROVIDER to one of: ${valid.join(", ")}`,
52
+ };
53
+ }
54
+ return {
55
+ name: "voice-provider",
56
+ status: "pass",
57
+ detail: `Voice: ${config.voiceProvider}`,
58
+ };
59
+ }
60
+ function checkTelephonyCredentials(config) {
61
+ if (config.telephonyProvider === "twilio") {
62
+ if (!config.twilioAccountSid || !config.twilioAuthToken) {
63
+ return {
64
+ name: "telephony-credentials",
65
+ status: "fail",
66
+ detail: "Twilio credentials missing.",
67
+ remediation: "Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN, or run 'clawvoice setup'.",
68
+ };
69
+ }
70
+ }
71
+ if (config.telephonyProvider === "telnyx") {
72
+ if (!config.telnyxApiKey) {
73
+ return {
74
+ name: "telephony-credentials",
75
+ status: "fail",
76
+ detail: "Telnyx API key missing.",
77
+ remediation: "Set TELNYX_API_KEY, or run 'clawvoice setup'.",
78
+ };
79
+ }
80
+ }
81
+ return {
82
+ name: "telephony-credentials",
83
+ status: "pass",
84
+ detail: "Telephony credentials configured.",
85
+ };
86
+ }
87
+ function checkVoiceCredentials(config) {
88
+ if (config.voiceProvider === "deepgram-agent") {
89
+ if (!config.deepgramApiKey) {
90
+ return {
91
+ name: "voice-credentials",
92
+ status: "fail",
93
+ detail: "Deepgram API key missing.",
94
+ remediation: "Set DEEPGRAM_API_KEY, or run 'clawvoice setup'.",
95
+ };
96
+ }
97
+ }
98
+ if (config.voiceProvider === "elevenlabs-conversational") {
99
+ if (!config.elevenlabsApiKey || !config.elevenlabsAgentId) {
100
+ return {
101
+ name: "voice-credentials",
102
+ status: "fail",
103
+ detail: "ElevenLabs credentials missing.",
104
+ remediation: "Set ELEVENLABS_API_KEY and ELEVENLABS_AGENT_ID, or run 'clawvoice setup'.",
105
+ };
106
+ }
107
+ }
108
+ return {
109
+ name: "voice-credentials",
110
+ status: "pass",
111
+ detail: "Voice credentials configured.",
112
+ };
113
+ }
114
+ function checkWebhookConfig(config) {
115
+ if (config.telephonyProvider === "telnyx" && !config.telnyxWebhookSecret) {
116
+ return {
117
+ name: "webhook-config",
118
+ status: "warn",
119
+ detail: "Telnyx webhook secret not configured. Webhooks will not be verified.",
120
+ remediation: "Set TELNYX_WEBHOOK_SECRET for production security.",
121
+ };
122
+ }
123
+ if (config.telephonyProvider === "twilio" && !config.twilioAuthToken) {
124
+ return {
125
+ name: "webhook-config",
126
+ status: "warn",
127
+ detail: "Twilio auth token needed for webhook signature verification.",
128
+ remediation: "Ensure TWILIO_AUTH_TOKEN is set.",
129
+ };
130
+ }
131
+ return {
132
+ name: "webhook-config",
133
+ status: "pass",
134
+ detail: "Webhook verification keys present.",
135
+ };
136
+ }
137
+ function checkDisclosure(config) {
138
+ if (config.disclosureEnabled && !config.disclosureStatement) {
139
+ return {
140
+ name: "disclosure",
141
+ status: "warn",
142
+ detail: "Disclosure enabled but statement is empty.",
143
+ remediation: "Set CLAWVOICE_DISCLOSURE_STATEMENT or disable disclosure.",
144
+ };
145
+ }
146
+ return {
147
+ name: "disclosure",
148
+ status: "pass",
149
+ detail: config.disclosureEnabled
150
+ ? "Disclosure enabled."
151
+ : "Disclosure disabled.",
152
+ };
153
+ }
154
+ function checkCallDuration(config) {
155
+ if (config.maxCallDuration <= 0 || !Number.isFinite(config.maxCallDuration)) {
156
+ return {
157
+ name: "call-duration",
158
+ status: "fail",
159
+ detail: `Invalid maxCallDuration: ${config.maxCallDuration}`,
160
+ remediation: "Set CLAWVOICE_MAX_CALL_DURATION to a positive number (seconds).",
161
+ };
162
+ }
163
+ if (config.maxCallDuration > 7200) {
164
+ return {
165
+ name: "call-duration",
166
+ status: "warn",
167
+ detail: `maxCallDuration is ${config.maxCallDuration}s (over 2 hours). This may incur high costs.`,
168
+ };
169
+ }
170
+ return {
171
+ name: "call-duration",
172
+ status: "pass",
173
+ detail: `Max call duration: ${config.maxCallDuration}s`,
174
+ };
175
+ }
176
+ function deriveOverall(checks) {
177
+ if (checks.some((c) => c.status === "fail"))
178
+ return "fail";
179
+ if (checks.some((c) => c.status === "warn"))
180
+ return "warn";
181
+ return "pass";
182
+ }
@@ -0,0 +1,16 @@
1
+ import { PluginAPI } from "@openclaw/plugin-sdk";
2
+ import { ClawVoiceConfig } from "./config";
3
+ export declare function isVoiceSession(context: unknown): boolean;
4
+ export declare function getMemoryWritePolicy(config: ClawVoiceConfig): {
5
+ namespace: string;
6
+ };
7
+ export declare function getMemoryReadPolicy(config: ClawVoiceConfig): {
8
+ allowed: boolean;
9
+ reason?: string;
10
+ };
11
+ export declare function getToolDenyList(config: ClawVoiceConfig): string[];
12
+ export declare function detectPromptInjection(text: string): {
13
+ detected: boolean;
14
+ pattern?: string;
15
+ };
16
+ export declare function registerHooks(api: PluginAPI, config: ClawVoiceConfig): void;
package/dist/hooks.js ADDED
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isVoiceSession = isVoiceSession;
4
+ exports.getMemoryWritePolicy = getMemoryWritePolicy;
5
+ exports.getMemoryReadPolicy = getMemoryReadPolicy;
6
+ exports.getToolDenyList = getToolDenyList;
7
+ exports.detectPromptInjection = detectPromptInjection;
8
+ exports.registerHooks = registerHooks;
9
+ const VOICE_MEMORY_NAMESPACE = "voice-memory";
10
+ /**
11
+ * Tools that are always blocked in voice sessions regardless of config.
12
+ * These pose safety/security risks when executed via voice channel.
13
+ */
14
+ const BUILT_IN_DENIED_TOOLS = [
15
+ "exec",
16
+ "browser",
17
+ "web_fetch",
18
+ ];
19
+ /**
20
+ * Patterns in user/agent messages that indicate prompt injection attempts.
21
+ * Matches common injection vectors: role override, system prompt leak, ignore instructions.
22
+ */
23
+ const PROMPT_INJECTION_PATTERNS = [
24
+ /ignore\s+(previous|prior|above|all)\s+(instructions?|prompts?|rules?)/i,
25
+ /you\s+are\s+now\s+(a|an|the)\s+/i,
26
+ /system\s*:\s*/i,
27
+ /\[system\]/i,
28
+ /pretend\s+(you\s+are|to\s+be|that)/i,
29
+ /override\s+(your|the)\s+(instructions?|rules?|prompt)/i,
30
+ /disregard\s+(your|the|all|previous)/i,
31
+ /reveal\s+(your|the)\s+(system|original|initial)\s+(prompt|instructions?)/i,
32
+ ];
33
+ function isVoiceSession(context) {
34
+ if (typeof context !== "object" || context === null) {
35
+ return false;
36
+ }
37
+ const ctx = context;
38
+ if (typeof ctx.session !== "object" || ctx.session === null) {
39
+ return false;
40
+ }
41
+ const session = ctx.session;
42
+ return session.channel === "voice";
43
+ }
44
+ function getMemoryWritePolicy(config) {
45
+ return { namespace: VOICE_MEMORY_NAMESPACE };
46
+ }
47
+ function getMemoryReadPolicy(config) {
48
+ if (config.mainMemoryAccess === "read") {
49
+ return { allowed: true };
50
+ }
51
+ return {
52
+ allowed: false,
53
+ reason: "Main memory access is disabled for voice sessions (mainMemoryAccess=none).",
54
+ };
55
+ }
56
+ function getToolDenyList(config) {
57
+ const denied = new Set(BUILT_IN_DENIED_TOOLS);
58
+ if (config.restrictTools) {
59
+ for (const tool of config.deniedTools) {
60
+ denied.add(tool);
61
+ }
62
+ }
63
+ return [...denied];
64
+ }
65
+ function detectPromptInjection(text) {
66
+ for (const pattern of PROMPT_INJECTION_PATTERNS) {
67
+ if (pattern.test(text)) {
68
+ return { detected: true, pattern: pattern.source };
69
+ }
70
+ }
71
+ return { detected: false };
72
+ }
73
+ function registerHooks(api, config) {
74
+ api.hooks.on("before_tool_execute", (_event, context) => {
75
+ if (!isVoiceSession(context)) {
76
+ return null;
77
+ }
78
+ return { deniedTools: getToolDenyList(config) };
79
+ });
80
+ api.hooks.on("before_memory_write", (_event, context) => {
81
+ if (!isVoiceSession(context)) {
82
+ return null;
83
+ }
84
+ const policy = getMemoryWritePolicy(config);
85
+ return { namespace: policy.namespace };
86
+ });
87
+ api.hooks.on("before_memory_read", (_event, context) => {
88
+ if (!isVoiceSession(context)) {
89
+ return null;
90
+ }
91
+ const policy = getMemoryReadPolicy(config);
92
+ if (!policy.allowed) {
93
+ return { blocked: true, reason: policy.reason };
94
+ }
95
+ return null;
96
+ });
97
+ api.hooks.on("before_response", (event, context) => {
98
+ if (!isVoiceSession(context)) {
99
+ return null;
100
+ }
101
+ const text = typeof event === "object" && event !== null
102
+ ? String(event.text ?? "")
103
+ : "";
104
+ const result = detectPromptInjection(text);
105
+ if (result.detected) {
106
+ return {
107
+ blocked: true,
108
+ reason: "Voice session prompt-injection guard triggered. The message was blocked for safety.",
109
+ };
110
+ }
111
+ return null;
112
+ });
113
+ }
@@ -0,0 +1,5 @@
1
+ import { ClawVoiceConfig } from "../config";
2
+ import { AmdResult, InboundCallEvent, InboundCallRecord, InboundDecision } from "./types";
3
+ export declare function classifyInboundEvent(providerCallId: string, from: string, to: string, provider: "telnyx" | "twilio", amdResult?: AmdResult): InboundCallEvent;
4
+ export declare function decideInboundAction(event: InboundCallEvent, config: ClawVoiceConfig): InboundDecision;
5
+ export declare function buildInboundRecord(event: InboundCallEvent, decision: InboundDecision): InboundCallRecord;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyInboundEvent = classifyInboundEvent;
4
+ exports.decideInboundAction = decideInboundAction;
5
+ exports.buildInboundRecord = buildInboundRecord;
6
+ const node_crypto_1 = require("node:crypto");
7
+ function classifyInboundEvent(providerCallId, from, to, provider, amdResult) {
8
+ let eventType;
9
+ if (amdResult === "machine_start") {
10
+ eventType = "amd_machine_detected";
11
+ }
12
+ else if (amdResult === "human") {
13
+ eventType = "amd_human_detected";
14
+ }
15
+ else if (amdResult === "fax") {
16
+ eventType = "call_failed";
17
+ }
18
+ else {
19
+ eventType = "incoming_call";
20
+ }
21
+ return {
22
+ eventType,
23
+ providerCallId,
24
+ from,
25
+ to,
26
+ provider,
27
+ timestamp: new Date().toISOString(),
28
+ amdResult,
29
+ };
30
+ }
31
+ function decideInboundAction(event, config) {
32
+ if (event.eventType === "call_failed") {
33
+ return { action: "reject", reason: "Fax or unsupported media detected" };
34
+ }
35
+ if (event.eventType === "amd_machine_detected") {
36
+ return {
37
+ action: "send_to_voicemail",
38
+ reason: "Answering machine detected — recording voicemail greeting",
39
+ };
40
+ }
41
+ if (!config.amdEnabled && event.eventType === "incoming_call") {
42
+ return {
43
+ action: "answer_and_bridge",
44
+ reason: "AMD disabled — answering directly",
45
+ };
46
+ }
47
+ if (event.eventType === "incoming_call" ||
48
+ event.eventType === "amd_human_detected") {
49
+ return {
50
+ action: "answer_and_bridge",
51
+ reason: "Human caller detected — bridging to voice agent",
52
+ };
53
+ }
54
+ return { action: "log_only", reason: "Unrecognized event — logging only" };
55
+ }
56
+ function createInboundCallId() {
57
+ return `inbound-${(0, node_crypto_1.randomUUID)()}`;
58
+ }
59
+ function buildInboundRecord(event, decision) {
60
+ return {
61
+ callId: createInboundCallId(),
62
+ providerCallId: event.providerCallId,
63
+ from: event.from,
64
+ to: event.to,
65
+ provider: event.provider,
66
+ direction: "inbound",
67
+ eventType: event.eventType,
68
+ decision,
69
+ amdResult: event.amdResult,
70
+ receivedAt: event.timestamp,
71
+ };
72
+ }
@@ -0,0 +1,30 @@
1
+ export type InboundEventType = "incoming_call" | "amd_machine_detected" | "amd_human_detected" | "voicemail_greeting" | "call_completed" | "call_failed";
2
+ export type AmdResult = "machine_start" | "human" | "fax" | "unknown";
3
+ export interface InboundCallEvent {
4
+ eventType: InboundEventType;
5
+ providerCallId: string;
6
+ from: string;
7
+ to: string;
8
+ provider: "telnyx" | "twilio";
9
+ timestamp: string;
10
+ amdResult?: AmdResult;
11
+ detail?: string;
12
+ }
13
+ export type InboundAction = "answer_and_bridge" | "send_to_voicemail" | "reject" | "log_only";
14
+ export interface InboundDecision {
15
+ action: InboundAction;
16
+ reason: string;
17
+ }
18
+ export interface InboundCallRecord {
19
+ callId: string;
20
+ providerCallId: string;
21
+ from: string;
22
+ to: string;
23
+ provider: "telnyx" | "twilio";
24
+ direction: "inbound";
25
+ eventType: InboundEventType;
26
+ decision: InboundDecision;
27
+ amdResult?: AmdResult;
28
+ receivedAt: string;
29
+ completedAt?: string;
30
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
1
+ import { Plugin, PluginAPI } from "@openclaw/plugin-sdk";
2
+ declare const plugin: Plugin;
3
+ export declare function activate(api: PluginAPI): Promise<void>;
4
+ export declare function register(api: PluginAPI): Promise<void>;
5
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.activate = activate;
4
+ exports.register = register;
5
+ const cli_1 = require("./cli");
6
+ const config_1 = require("./config");
7
+ const health_1 = require("./diagnostics/health");
8
+ const hooks_1 = require("./hooks");
9
+ const routes_1 = require("./routes");
10
+ const memory_extraction_1 = require("./services/memory-extraction");
11
+ const voice_call_1 = require("./services/voice-call");
12
+ const tools_1 = require("./tools");
13
+ const plugin = {
14
+ name: "clawvoice",
15
+ async init(api) {
16
+ const config = (0, config_1.resolveConfig)(api.config);
17
+ const validation = (0, config_1.validateConfig)(config);
18
+ if (!validation.ok) {
19
+ throw new Error(validation.errors.join("; "));
20
+ }
21
+ const diagnostics = (0, health_1.runDiagnostics)(config);
22
+ for (const check of diagnostics.checks) {
23
+ if (check.status === "fail" || check.status === "warn") {
24
+ api.log.warn(`ClawVoice config ${check.status}: ${check.name}`, {
25
+ detail: check.detail,
26
+ remediation: check.remediation,
27
+ });
28
+ }
29
+ }
30
+ const callService = new voice_call_1.VoiceCallService(config);
31
+ const memoryService = new memory_extraction_1.MemoryExtractionService(config);
32
+ (0, tools_1.registerTools)(api, config, callService, memoryService);
33
+ (0, cli_1.registerCLI)(api, config, callService, memoryService);
34
+ (0, routes_1.registerRoutes)(api, config, (record) => {
35
+ callService.trackInboundCall(record);
36
+ });
37
+ (0, hooks_1.registerHooks)(api, config);
38
+ api.services.register("clawvoice-calls", callService);
39
+ api.log.info("ClawVoice initialized", {
40
+ telephonyProvider: config.telephonyProvider,
41
+ voiceProvider: config.voiceProvider,
42
+ inboundEnabled: config.inboundEnabled,
43
+ });
44
+ },
45
+ };
46
+ async function activate(api) {
47
+ await plugin.init(api);
48
+ }
49
+ async function register(api) {
50
+ await activate(api);
51
+ }
52
+ exports.default = plugin;
@@ -0,0 +1,6 @@
1
+ import { PluginAPI } from "@openclaw/plugin-sdk";
2
+ import { ClawVoiceConfig } from "./config";
3
+ import { InboundCallRecord } from "./inbound/types";
4
+ type InboundHandler = (record: InboundCallRecord) => void;
5
+ export declare function registerRoutes(api: PluginAPI, config: ClawVoiceConfig, onInbound?: InboundHandler): void;
6
+ export {};
package/dist/routes.js ADDED
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerRoutes = registerRoutes;
4
+ const classifier_1 = require("./inbound/classifier");
5
+ const verify_1 = require("./webhooks/verify");
6
+ function registerRoutes(api, config, onInbound) {
7
+ const router = api.http.router("/clawvoice");
8
+ router.post("/webhooks/telnyx", async (req, response) => {
9
+ const request = req;
10
+ const body = typeof request.body === "string" ? request.body : JSON.stringify(request.body ?? "");
11
+ const result = (0, verify_1.verifyTelnyxSignature)(body, request.headers?.["telnyx-signature-ed25519"], request.headers?.["telnyx-timestamp"], config.telnyxWebhookSecret);
12
+ if (!result.valid) {
13
+ response.status(401).json({ error: "Unauthorized", reason: result.reason });
14
+ return;
15
+ }
16
+ if (config.inboundEnabled) {
17
+ const parsed = parseWebhookBody(request.body);
18
+ if (parsed) {
19
+ const event = (0, classifier_1.classifyInboundEvent)(parsed.providerCallId, parsed.from, parsed.to, "telnyx", parsed.amdResult);
20
+ const decision = (0, classifier_1.decideInboundAction)(event, config);
21
+ const record = (0, classifier_1.buildInboundRecord)(event, decision);
22
+ onInbound?.(record);
23
+ }
24
+ }
25
+ response.status(200).json({ ok: true });
26
+ });
27
+ router.post("/webhooks/twilio/voice", async (req, response) => {
28
+ const request = req;
29
+ const url = `${request.protocol ?? "https"}://${request.headers?.host ?? "localhost"}${request.url ?? "/"}`;
30
+ const params = typeof request.body === "object" && request.body !== null ? request.body : {};
31
+ const result = (0, verify_1.verifyTwilioSignature)(url, params, request.headers?.["x-twilio-signature"], config.twilioAuthToken);
32
+ if (!result.valid) {
33
+ response.status(401).json({ error: "Unauthorized", reason: result.reason });
34
+ return;
35
+ }
36
+ if (config.inboundEnabled) {
37
+ const parsed = parseWebhookBody(request.body);
38
+ if (parsed) {
39
+ const event = (0, classifier_1.classifyInboundEvent)(parsed.providerCallId, parsed.from, parsed.to, "twilio", parsed.amdResult);
40
+ const decision = (0, classifier_1.decideInboundAction)(event, config);
41
+ const record = (0, classifier_1.buildInboundRecord)(event, decision);
42
+ onInbound?.(record);
43
+ }
44
+ }
45
+ response.status(200).json({ ok: true });
46
+ });
47
+ router.post("/webhooks/twilio/amd", async (req, response) => {
48
+ const request = req;
49
+ const url = `${request.protocol ?? "https"}://${request.headers?.host ?? "localhost"}${request.url ?? "/"}`;
50
+ const params = typeof request.body === "object" && request.body !== null ? request.body : {};
51
+ const result = (0, verify_1.verifyTwilioSignature)(url, params, request.headers?.["x-twilio-signature"], config.twilioAuthToken);
52
+ if (!result.valid) {
53
+ response.status(401).json({ error: "Unauthorized", reason: result.reason });
54
+ return;
55
+ }
56
+ const amdStatus = typeof params.AnsweredBy === "string" ? params.AnsweredBy : undefined;
57
+ const callSid = typeof params.CallSid === "string" ? params.CallSid : "unknown";
58
+ const amdResult = amdStatus === "human" ? "human"
59
+ : amdStatus === "machine_start" ? "machine_start"
60
+ : amdStatus === "fax" ? "fax"
61
+ : "unknown";
62
+ if (config.inboundEnabled) {
63
+ const event = (0, classifier_1.classifyInboundEvent)(callSid, typeof params.From === "string" ? params.From : "", typeof params.To === "string" ? params.To : "", "twilio", amdResult);
64
+ const decision = (0, classifier_1.decideInboundAction)(event, config);
65
+ const record = (0, classifier_1.buildInboundRecord)(event, decision);
66
+ onInbound?.(record);
67
+ }
68
+ response.status(200).json({ ok: true });
69
+ });
70
+ }
71
+ function parseWebhookBody(body) {
72
+ if (typeof body !== "object" || body === null) {
73
+ return null;
74
+ }
75
+ const b = body;
76
+ const providerCallId = typeof b.CallSid === "string" ? b.CallSid
77
+ : typeof b.call_control_id === "string" ? b.call_control_id
78
+ : undefined;
79
+ if (!providerCallId) {
80
+ return null;
81
+ }
82
+ const from = typeof b.From === "string" ? b.From
83
+ : typeof b.from === "string" ? b.from
84
+ : "";
85
+ const to = typeof b.To === "string" ? b.To
86
+ : typeof b.to === "string" ? b.to
87
+ : "";
88
+ return { providerCallId, from, to };
89
+ }
@@ -0,0 +1,42 @@
1
+ import { ClawVoiceConfig } from "../config";
2
+ import { TranscriptEntry } from "../voice/types";
3
+ export type MemoryCategory = "preference" | "health" | "relationship" | "schedule" | "interest" | "other";
4
+ export type MemoryStatus = "pending" | "approved" | "rejected" | "promoted";
5
+ export interface MemoryCandidate {
6
+ id: string;
7
+ callId: string;
8
+ category: MemoryCategory;
9
+ content: string;
10
+ confidence: number;
11
+ sourceQuote: string;
12
+ status: MemoryStatus;
13
+ extractedAt: string;
14
+ promotedAt?: string;
15
+ }
16
+ export interface ExtractionResult {
17
+ callId: string;
18
+ candidates: MemoryCandidate[];
19
+ extractedAt: string;
20
+ }
21
+ export type MemoryWriter = (namespace: string, key: string, value: unknown) => Promise<void>;
22
+ export type MemoryReader = (namespace: string, key: string) => Promise<unknown>;
23
+ export declare class MemoryExtractionService {
24
+ private readonly config;
25
+ private readonly candidates;
26
+ private memoryWriter;
27
+ private memoryReader;
28
+ private idCounter;
29
+ constructor(config: ClawVoiceConfig);
30
+ setMemoryWriter(writer: MemoryWriter): void;
31
+ setMemoryReader(reader: MemoryReader): void;
32
+ extractFromTranscript(callId: string, transcript: TranscriptEntry[]): ExtractionResult;
33
+ getPendingCandidates(callId?: string): MemoryCandidate[];
34
+ getCandidate(memoryId: string): MemoryCandidate | undefined;
35
+ approveAndPromote(memoryId: string): Promise<{
36
+ promoted: boolean;
37
+ reason?: string;
38
+ }>;
39
+ rejectCandidate(memoryId: string): boolean;
40
+ getAllCandidates(): MemoryCandidate[];
41
+ resetIdCounter(): void;
42
+ }