heyhank 0.1.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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,457 @@
1
+ // ─── Call Manager ───────────────────���────────────────────────────���────────────
2
+ // Manages active phone calls: FreeSWITCH ESL control + Gemini Live audio bridge.
3
+ // Each call gets its own AudioBridge instance connected to Gemini.
4
+
5
+ import type { ServerWebSocket } from "bun";
6
+ import type { CallConfig, CallState, CallEvent, TranscriptEntry } from "./call-types.js";
7
+ import { AudioBridge, downsampleTo8k, base64ToBuffer } from "./audio-bridge.js";
8
+ import { getSettings, saveCall } from "./telephony-store.js";
9
+ import { getSettings as getMainSettings } from "../settings-manager.js";
10
+ import { randomUUID } from "node:crypto";
11
+
12
+ // Gemini tool declarations for telephony calls (subset — focused on conversation)
13
+ const TELEPHONY_TOOL_DECLARATIONS = [{
14
+ functionDeclarations: [
15
+ {
16
+ name: "end_call",
17
+ description: "End/hang up the current phone call. Use when the conversation is complete, the task is done, or the other person wants to end the call.",
18
+ parameters: { type: "OBJECT", properties: {} },
19
+ },
20
+ {
21
+ name: "transfer_call",
22
+ description: "Request to transfer the call to a human or another number. Use when the AI can't handle the request.",
23
+ parameters: {
24
+ type: "OBJECT",
25
+ properties: {
26
+ reason: { type: "STRING", description: "Why the transfer is needed" },
27
+ },
28
+ },
29
+ },
30
+ ],
31
+ }];
32
+
33
+ type EventListener = (event: CallEvent) => void;
34
+
35
+ /**
36
+ * CallManager orchestrates phone calls.
37
+ *
38
+ * Flow:
39
+ * 1. startCall() → creates CallState, connects AudioBridge to Gemini
40
+ * 2. FreeSWITCH sends audio via WebSocket to handleFreeSwitchAudio()
41
+ * 3. AudioBridge sends audio to Gemini, gets response audio back
42
+ * 4. Response audio sent back to FreeSWITCH via the same WebSocket
43
+ * 5. endCall() → hangup FreeSWITCH, disconnect Gemini, save transcript
44
+ */
45
+ export class CallManager {
46
+ private activeCalls = new Map<string, {
47
+ state: CallState;
48
+ bridge: AudioBridge;
49
+ fsSockets: Set<ServerWebSocket<unknown>>; // FreeSWITCH audio fork WebSockets
50
+ transcriptSockets: Set<ServerWebSocket<unknown>>; // Browser WebSockets for live transcript
51
+ maxDurationTimer?: ReturnType<typeof setTimeout>;
52
+ }>();
53
+
54
+ private listeners = new Set<EventListener>();
55
+
56
+ /** Subscribe to call events */
57
+ onEvent(listener: EventListener): () => void {
58
+ this.listeners.add(listener);
59
+ return () => this.listeners.delete(listener);
60
+ }
61
+
62
+ private emit(event: CallEvent): void {
63
+ for (const listener of this.listeners) {
64
+ try { listener(event); } catch { /* ignore */ }
65
+ }
66
+ }
67
+
68
+ /** Start a new outbound call */
69
+ async startCall(config: CallConfig): Promise<CallState> {
70
+ const telSettings = getSettings();
71
+
72
+ if (!telSettings.enabled) {
73
+ throw new Error("Telephony is not enabled. Configure it in Settings → Telephony.");
74
+ }
75
+
76
+ // Resolve Gemini API key
77
+ const mainSettings = getMainSettings();
78
+ const geminiKey = telSettings.geminiApiKey || mainSettings.geminiApiKey;
79
+ if (!geminiKey) {
80
+ throw new Error("Gemini API key not configured.");
81
+ }
82
+
83
+ // Resolve SIP trunk
84
+ const trunkId = config.trunkId || telSettings.defaultTrunkId;
85
+ const trunk = trunkId ? telSettings.trunks.find((t) => t.id === trunkId) : telSettings.trunks[0];
86
+ if (!trunk) {
87
+ throw new Error("No SIP trunk configured. Add one in Settings → Telephony.");
88
+ }
89
+
90
+ const callId = randomUUID();
91
+ const voice = config.voice || telSettings.defaultVoice || "Kore";
92
+ const maxDuration = config.maxDurationSeconds || telSettings.maxCallDurationSeconds || 600;
93
+
94
+ // Build telephony-specific system prompt
95
+ const systemPrompt = buildTelephonyPrompt(config.prompt, config.phone);
96
+
97
+ // Create call state
98
+ const callState: CallState = {
99
+ id: callId,
100
+ phone: config.phone,
101
+ prompt: config.prompt,
102
+ voice,
103
+ status: "initiating",
104
+ trunkId: trunk.id,
105
+ callerId: config.callerId || trunk.callerId,
106
+ transcript: [],
107
+ summary: null,
108
+ durationSeconds: 0,
109
+ startedAt: Date.now(),
110
+ connectedAt: null,
111
+ endedAt: null,
112
+ error: null,
113
+ };
114
+
115
+ // Create audio bridge to Gemini
116
+ const bridge = new AudioBridge(callId, {
117
+ geminiApiKey: geminiKey,
118
+ voice,
119
+ systemPrompt,
120
+ tools: [...TELEPHONY_TOOL_DECLARATIONS, { googleSearch: {} }],
121
+ onTranscript: (entry) => {
122
+ callState.transcript.push(entry);
123
+ this.emit({ type: "transcript", callId, entry });
124
+ this.broadcastTranscript(callId, entry);
125
+ },
126
+ onStatusChange: (status) => {
127
+ callState.status = status;
128
+ if (status === "active" && !callState.connectedAt) {
129
+ callState.connectedAt = Date.now();
130
+ }
131
+ this.emit({ type: "status", callId, status });
132
+ },
133
+ onToolCall: async (calls) => {
134
+ return this.handleToolCalls(callId, calls);
135
+ },
136
+ });
137
+
138
+ // When Gemini produces response audio, send it to FreeSWITCH
139
+ bridge.onGeminiAudio = (base64Pcm: string) => {
140
+ const pcm = base64ToBuffer(base64Pcm);
141
+ // Gemini outputs 24kHz PCM, FreeSWITCH expects 8kHz
142
+ const downsampled = downsampleTo8k(pcm, 24000);
143
+ const call = this.activeCalls.get(callId);
144
+ if (call) {
145
+ for (const ws of call.fsSockets) {
146
+ try {
147
+ ws.send(downsampled);
148
+ } catch { /* socket closed */ }
149
+ }
150
+ }
151
+ };
152
+
153
+ this.activeCalls.set(callId, {
154
+ state: callState,
155
+ bridge,
156
+ fsSockets: new Set(),
157
+ transcriptSockets: new Set(),
158
+ });
159
+
160
+ // Connect to Gemini first
161
+ try {
162
+ await bridge.connect();
163
+ } catch (err) {
164
+ callState.status = "failed";
165
+ callState.error = err instanceof Error ? err.message : "Gemini connection failed";
166
+ callState.endedAt = Date.now();
167
+ saveCall(callState);
168
+ this.activeCalls.delete(callId);
169
+ throw err;
170
+ }
171
+
172
+ // Now initiate the FreeSWITCH call via ESL
173
+ callState.status = "dialing";
174
+ this.emit({ type: "status", callId, status: "dialing" });
175
+
176
+ try {
177
+ await this.eslOriginate(callId, config.phone, trunk, callState.callerId);
178
+ } catch (err) {
179
+ callState.status = "failed";
180
+ callState.error = err instanceof Error ? err.message : "FreeSWITCH originate failed";
181
+ callState.endedAt = Date.now();
182
+ bridge.disconnect();
183
+ saveCall(callState);
184
+ this.activeCalls.delete(callId);
185
+ throw err;
186
+ }
187
+
188
+ // Safety: auto-hangup after max duration
189
+ const timer = setTimeout(() => {
190
+ console.log(`[telephony] Call ${callId} hit max duration (${maxDuration}s), hanging up`);
191
+ this.endCall(callId).catch(() => {});
192
+ }, maxDuration * 1000);
193
+
194
+ const call = this.activeCalls.get(callId);
195
+ if (call) call.maxDurationTimer = timer;
196
+
197
+ saveCall(callState);
198
+ return callState;
199
+ }
200
+
201
+ /** End an active call */
202
+ async endCall(callId: string): Promise<CallState | null> {
203
+ const call = this.activeCalls.get(callId);
204
+ if (!call) return null;
205
+
206
+ const { state, bridge } = call;
207
+
208
+ // Clear safety timer
209
+ if (call.maxDurationTimer) clearTimeout(call.maxDurationTimer);
210
+
211
+ // Disconnect Gemini
212
+ bridge.disconnect();
213
+
214
+ // Hangup FreeSWITCH
215
+ try {
216
+ await this.eslHangup(callId);
217
+ } catch { /* might already be hung up */ }
218
+
219
+ // Calculate duration
220
+ state.status = "ended";
221
+ state.endedAt = Date.now();
222
+ if (state.connectedAt) {
223
+ state.durationSeconds = Math.round((state.endedAt - state.connectedAt) / 1000);
224
+ }
225
+
226
+ // Generate summary from transcript
227
+ state.summary = this.generateSummary(state);
228
+
229
+ this.emit({ type: "ended", callId, summary: state.summary });
230
+ saveCall(state);
231
+ this.activeCalls.delete(callId);
232
+
233
+ return state;
234
+ }
235
+
236
+ /** Handle audio from FreeSWITCH mod_audio_fork WebSocket */
237
+ handleFreeSwitchAudio(callId: string, data: Buffer | Uint8Array): void {
238
+ const call = this.activeCalls.get(callId);
239
+ if (!call) return;
240
+ call.bridge.sendCallerAudio(data);
241
+ }
242
+
243
+ /** Register a FreeSWITCH audio WebSocket for a call */
244
+ addFreeSwitchSocket(callId: string, ws: ServerWebSocket<unknown>): void {
245
+ const call = this.activeCalls.get(callId);
246
+ if (call) call.fsSockets.add(ws);
247
+ }
248
+
249
+ removeFreeSwitchSocket(callId: string, ws: ServerWebSocket<unknown>): void {
250
+ const call = this.activeCalls.get(callId);
251
+ if (call) call.fsSockets.delete(ws);
252
+ }
253
+
254
+ /** Register a browser WebSocket for live transcript */
255
+ addTranscriptSocket(callId: string, ws: ServerWebSocket<unknown>): void {
256
+ const call = this.activeCalls.get(callId);
257
+ if (call) {
258
+ call.transcriptSockets.add(ws);
259
+ // Send existing transcript
260
+ for (const entry of call.state.transcript) {
261
+ try {
262
+ ws.send(JSON.stringify({ type: "transcript", entry }));
263
+ } catch { /* ignore */ }
264
+ }
265
+ }
266
+ }
267
+
268
+ removeTranscriptSocket(callId: string, ws: ServerWebSocket<unknown>): void {
269
+ const call = this.activeCalls.get(callId);
270
+ if (call) call.transcriptSockets.delete(ws);
271
+ }
272
+
273
+ private broadcastTranscript(callId: string, entry: TranscriptEntry): void {
274
+ const call = this.activeCalls.get(callId);
275
+ if (!call) return;
276
+ const msg = JSON.stringify({ type: "transcript", entry });
277
+ for (const ws of call.transcriptSockets) {
278
+ try { ws.send(msg); } catch { /* ignore */ }
279
+ }
280
+ }
281
+
282
+ /** Get state of a specific call */
283
+ getCallState(callId: string): CallState | null {
284
+ return this.activeCalls.get(callId)?.state || null;
285
+ }
286
+
287
+ /** List all active calls */
288
+ getActiveCalls(): CallState[] {
289
+ return Array.from(this.activeCalls.values()).map((c) => c.state);
290
+ }
291
+
292
+ /** Handle tool calls from Gemini during a phone call */
293
+ private async handleToolCalls(
294
+ callId: string,
295
+ calls: Array<{ id: string; name: string; args: Record<string, unknown> }>,
296
+ ): Promise<Array<{ id: string; name: string; response: unknown }>> {
297
+ const results: Array<{ id: string; name: string; response: unknown }> = [];
298
+
299
+ for (const call of calls) {
300
+ switch (call.name) {
301
+ case "end_call":
302
+ // Gemini decided to hang up
303
+ setTimeout(() => this.endCall(callId).catch(() => {}), 500); // short delay for final audio
304
+ results.push({ id: call.id, name: call.name, response: { success: true, message: "Hanging up" } });
305
+ break;
306
+ case "transfer_call":
307
+ results.push({ id: call.id, name: call.name, response: { error: "Transfer not yet implemented" } });
308
+ break;
309
+ default:
310
+ // Forward to main tool handler (for todos, notes, etc.)
311
+ try {
312
+ const res = await fetch(`http://127.0.0.1:${process.env.PORT || 3100}/api/gemini/tool-call`, {
313
+ method: "POST",
314
+ headers: { "Content-Type": "application/json" },
315
+ body: JSON.stringify({ name: call.name, args: call.args }),
316
+ });
317
+ const data = await res.json();
318
+ results.push({ id: call.id, name: call.name, response: data.result || { error: "No result" } });
319
+ } catch (err) {
320
+ results.push({ id: call.id, name: call.name, response: { error: String(err) } });
321
+ }
322
+ }
323
+ }
324
+
325
+ return results;
326
+ }
327
+
328
+ // ─── FreeSWITCH ESL Commands ────���──────────────────────────────────────────
329
+
330
+ /**
331
+ * Originate a call via FreeSWITCH ESL.
332
+ * Uses HTTP API (mod_xml_rpc or mod_httapi) for simplicity —
333
+ * no need for a persistent ESL TCP connection.
334
+ */
335
+ private async eslOriginate(
336
+ callId: string,
337
+ phone: string,
338
+ trunk: { id: string; name: string },
339
+ callerId: string,
340
+ ): Promise<void> {
341
+ const settings = getSettings();
342
+ const { eslHost, eslPort, eslPassword } = settings.freeswitch;
343
+
344
+ // FreeSWITCH ESL over HTTP (mod_xml_rpc)
345
+ // Format: api originate {vars}sofia/gateway/trunk/number &park()
346
+ const vars = [
347
+ `origination_caller_id_number=${callerId}`,
348
+ `origination_caller_id_name=HeyHank`,
349
+ `origination_uuid=${callId}`,
350
+ `ignore_early_media=true`,
351
+ ].join(",");
352
+
353
+ const cmd = `originate {${vars}}sofia/gateway/${trunk.name}/${phone} &park()`;
354
+
355
+ console.log(`[telephony] ESL originate: ${cmd}`);
356
+
357
+ // Try ESL HTTP API first (port 8080 default for mod_xml_rpc)
358
+ try {
359
+ const eslUrl = `http://${eslHost}:${eslPort}/api`;
360
+ const res = await fetch(eslUrl, {
361
+ method: "POST",
362
+ headers: {
363
+ "Content-Type": "text/plain",
364
+ "Authorization": `Basic ${btoa(`freeswitch:${eslPassword}`)}`,
365
+ },
366
+ body: cmd,
367
+ });
368
+
369
+ if (!res.ok) {
370
+ const text = await res.text();
371
+ throw new Error(`ESL error: ${res.status} ${text}`);
372
+ }
373
+
374
+ const result = await res.text();
375
+ console.log(`[telephony] ESL originate result: ${result.trim()}`);
376
+
377
+ if (result.includes("-ERR")) {
378
+ throw new Error(`FreeSWITCH error: ${result.trim()}`);
379
+ }
380
+ } catch (err) {
381
+ console.error(`[telephony] ESL originate failed:`, err);
382
+ throw err;
383
+ }
384
+ }
385
+
386
+ private async eslHangup(callId: string): Promise<void> {
387
+ const settings = getSettings();
388
+ const { eslHost, eslPort, eslPassword } = settings.freeswitch;
389
+
390
+ try {
391
+ const eslUrl = `http://${eslHost}:${eslPort}/api`;
392
+ await fetch(eslUrl, {
393
+ method: "POST",
394
+ headers: {
395
+ "Content-Type": "text/plain",
396
+ "Authorization": `Basic ${btoa(`freeswitch:${eslPassword}`)}`,
397
+ },
398
+ body: `uuid_kill ${callId}`,
399
+ });
400
+ } catch {
401
+ // Might already be disconnected
402
+ }
403
+ }
404
+
405
+ /** Generate a simple summary from the transcript */
406
+ private generateSummary(state: CallState): string {
407
+ const meaningful = state.transcript.filter((t) => t.speaker !== "system");
408
+ if (meaningful.length === 0) return "No conversation recorded.";
409
+
410
+ const lines = meaningful.map((t) =>
411
+ `${t.speaker === "callee" ? "Callee" : "AI"}: ${t.text}`
412
+ );
413
+
414
+ return `Call to ${state.phone} (${state.durationSeconds}s). ` +
415
+ `${meaningful.filter((t) => t.speaker === "callee").length} callee messages, ` +
416
+ `${meaningful.filter((t) => t.speaker === "ai").length} AI responses.\n\n` +
417
+ lines.slice(-6).join("\n"); // Last 6 lines as summary
418
+ }
419
+
420
+ /** Shutdown: end all calls */
421
+ async shutdown(): Promise<void> {
422
+ const callIds = Array.from(this.activeCalls.keys());
423
+ await Promise.all(callIds.map((id) => this.endCall(id)));
424
+ }
425
+ }
426
+
427
+ // ─── Prompt Builder ──────────────────────────────────────────────────────────
428
+
429
+ function buildTelephonyPrompt(taskPrompt: string, phoneNumber: string): string {
430
+ return `You are conducting a PHONE CALL. You are speaking to a real person on the telephone.
431
+
432
+ YOUR TASK: ${taskPrompt}
433
+
434
+ CRITICAL PHONE CALL RULES:
435
+ - You are on a TELEPHONE. Speak naturally, briefly, and conversationally.
436
+ - Maximum 2-3 short sentences per response. Nobody likes long monologues on the phone.
437
+ - Use natural filler words ("well", "I see", "right", "exactly") — this sounds human.
438
+ - If you don't understand something, politely ask them to repeat.
439
+ - When your task is complete, summarize the result and say goodbye politely.
440
+ - If asked directly whether you're an AI, be honest.
441
+ - NEVER mention that you are calling on behalf of "HeyHank" or a "platform" unless asked.
442
+ - Speak in the same language as the person you're calling.
443
+ - The phone number you're calling is: ${phoneNumber}
444
+
445
+ CALL FLOW:
446
+ 1. Greet the person naturally (e.g., "Hello, good day!")
447
+ 2. State your request concisely
448
+ 3. Listen and respond to their questions
449
+ 4. When done, thank them and say goodbye
450
+ 5. Use the end_call tool when the conversation is finished
451
+
452
+ You also have access to Google Search if you need to look something up during the call.
453
+ When the task is completed, call end_call to hang up.`;
454
+ }
455
+
456
+ // Export singleton
457
+ export const callManager = new CallManager();
@@ -0,0 +1,108 @@
1
+ // ─── Telephony Types ──────────────────────────────────────────────────────────
2
+ // Types for the KI-Telephony system: FreeSWITCH ↔ Gemini Live bridge.
3
+
4
+ export interface SipTrunkConfig {
5
+ id: string;
6
+ name: string;
7
+ provider: "sipgate" | "easybell" | "peoplefone" | "custom";
8
+ username: string;
9
+ password: string;
10
+ server: string;
11
+ callerId: string; // e.g. "+4312345678"
12
+ enabled: boolean;
13
+ }
14
+
15
+ export interface FreeSwitchConfig {
16
+ eslHost: string;
17
+ eslPort: number;
18
+ eslPassword: string;
19
+ }
20
+
21
+ export interface CallConfig {
22
+ phone: string; // E.164 format: "+4366412345"
23
+ prompt: string; // Task/persona for Gemini
24
+ voice?: string; // Gemini voice (default: "Kore")
25
+ trunkId?: string; // Which SIP trunk to use
26
+ callerId?: string; // Override caller ID
27
+ maxDurationSeconds?: number; // Auto-hangup after N seconds (safety)
28
+ }
29
+
30
+ export type CallStatus =
31
+ | "initiating" // Server preparing the call
32
+ | "dialing" // FreeSWITCH placing the call
33
+ | "ringing" // Remote phone ringing
34
+ | "active" // Call connected, AI speaking
35
+ | "ended" // Call ended normally
36
+ | "failed" // Call failed (busy, no answer, error)
37
+ | "cancelled"; // Cancelled by user before connecting
38
+
39
+ export interface TranscriptEntry {
40
+ speaker: "callee" | "ai" | "system";
41
+ text: string;
42
+ isFinal: boolean;
43
+ ts: number;
44
+ }
45
+
46
+ export interface CallState {
47
+ id: string;
48
+ phone: string;
49
+ prompt: string;
50
+ voice: string;
51
+ status: CallStatus;
52
+ trunkId: string;
53
+ callerId: string;
54
+ transcript: TranscriptEntry[];
55
+ summary: string | null;
56
+ durationSeconds: number;
57
+ startedAt: number;
58
+ connectedAt: number | null;
59
+ endedAt: number | null;
60
+ error: string | null;
61
+ }
62
+
63
+ /** Message from FreeSWITCH audio fork WebSocket (PCM audio chunks) */
64
+ export interface AudioChunk {
65
+ data: Buffer | Uint8Array;
66
+ sampleRate: number; // 8000 from FreeSWITCH
67
+ channels: 1;
68
+ }
69
+
70
+ /** Events emitted by CallManager */
71
+ export type CallEvent =
72
+ | { type: "status"; callId: string; status: CallStatus }
73
+ | { type: "transcript"; callId: string; entry: TranscriptEntry }
74
+ | { type: "ended"; callId: string; summary: string | null };
75
+
76
+ /** A phone contact that Gemini can look up and call by name */
77
+ export interface TelephonyContact {
78
+ id: string;
79
+ name: string; // Display name (e.g. "Mama", "Restaurant Steirereck")
80
+ phone: string; // E.164 format (e.g. "+4366412345")
81
+ notes?: string; // Optional context (e.g. "Mon-Sat 10-18 Uhr")
82
+ }
83
+
84
+ /** Telephony settings stored in ~/.heyhank/telephony.json */
85
+ export interface TelephonySettings {
86
+ enabled: boolean;
87
+ freeswitch: FreeSwitchConfig;
88
+ trunks: SipTrunkConfig[];
89
+ contacts: TelephonyContact[];
90
+ defaultTrunkId: string | null;
91
+ defaultVoice: string;
92
+ maxCallDurationSeconds: number;
93
+ geminiApiKey?: string; // Override; falls back to main Gemini key
94
+ }
95
+
96
+ export const DEFAULT_TELEPHONY_SETTINGS: TelephonySettings = {
97
+ enabled: false,
98
+ freeswitch: {
99
+ eslHost: "localhost",
100
+ eslPort: 8021,
101
+ eslPassword: "ClueCon",
102
+ },
103
+ trunks: [],
104
+ contacts: [],
105
+ defaultTrunkId: null,
106
+ defaultVoice: "Kore",
107
+ maxCallDurationSeconds: 600, // 10 min safety limit
108
+ };
@@ -0,0 +1,119 @@
1
+ // ─── Telephony Store ──────────────────────────────────────────────────────────
2
+ // File-based persistence for telephony settings and call history.
3
+
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { TelephonySettings, CallState, TelephonyContact } from "./call-types.js";
8
+ import { DEFAULT_TELEPHONY_SETTINGS } from "./call-types.js";
9
+
10
+ const BASE_DIR = join(homedir(), ".heyhank", "telephony");
11
+ const SETTINGS_FILE = join(BASE_DIR, "settings.json");
12
+ const CALLS_DIR = join(BASE_DIR, "calls");
13
+
14
+ function ensureDirs(): void {
15
+ if (!existsSync(BASE_DIR)) mkdirSync(BASE_DIR, { recursive: true });
16
+ if (!existsSync(CALLS_DIR)) mkdirSync(CALLS_DIR, { recursive: true });
17
+ }
18
+
19
+ // ─── Settings ────────────────────────────────────────────────────────────────
20
+
21
+ export function getSettings(): TelephonySettings {
22
+ ensureDirs();
23
+ if (!existsSync(SETTINGS_FILE)) return { ...DEFAULT_TELEPHONY_SETTINGS };
24
+ try {
25
+ const raw = readFileSync(SETTINGS_FILE, "utf-8");
26
+ return { ...DEFAULT_TELEPHONY_SETTINGS, ...JSON.parse(raw) };
27
+ } catch {
28
+ return { ...DEFAULT_TELEPHONY_SETTINGS };
29
+ }
30
+ }
31
+
32
+ export function saveSettings(settings: TelephonySettings): void {
33
+ ensureDirs();
34
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf-8");
35
+ }
36
+
37
+ // ─── Contacts ───────────────────────────────────────────────────────────────
38
+
39
+ export function getContacts(): TelephonyContact[] {
40
+ return getSettings().contacts || [];
41
+ }
42
+
43
+ export function addContact(contact: TelephonyContact): void {
44
+ const settings = getSettings();
45
+ if (!settings.contacts) settings.contacts = [];
46
+ settings.contacts.push(contact);
47
+ saveSettings(settings);
48
+ }
49
+
50
+ export function updateContact(id: string, patch: Partial<Omit<TelephonyContact, "id">>): TelephonyContact | null {
51
+ const settings = getSettings();
52
+ if (!settings.contacts) return null;
53
+ const idx = settings.contacts.findIndex((c) => c.id === id);
54
+ if (idx < 0) return null;
55
+ settings.contacts[idx] = { ...settings.contacts[idx], ...patch };
56
+ saveSettings(settings);
57
+ return settings.contacts[idx];
58
+ }
59
+
60
+ export function deleteContact(id: string): boolean {
61
+ const settings = getSettings();
62
+ if (!settings.contacts) return false;
63
+ const before = settings.contacts.length;
64
+ settings.contacts = settings.contacts.filter((c) => c.id !== id);
65
+ if (settings.contacts.length === before) return false;
66
+ saveSettings(settings);
67
+ return true;
68
+ }
69
+
70
+ /** Resolve a contact name (fuzzy) to a phone number */
71
+ export function resolveContactByName(nameQuery: string): TelephonyContact | null {
72
+ const contacts = getContacts();
73
+ if (contacts.length === 0) return null;
74
+ const q = nameQuery.toLowerCase().trim();
75
+ // Exact match first
76
+ const exact = contacts.find((c) => c.name.toLowerCase() === q);
77
+ if (exact) return exact;
78
+ // Partial match
79
+ const partial = contacts.find((c) => c.name.toLowerCase().includes(q) || q.includes(c.name.toLowerCase()));
80
+ return partial || null;
81
+ }
82
+
83
+ // ─── Call History ────────────────────────────────────────────────────────────
84
+
85
+ export function saveCall(call: CallState): void {
86
+ ensureDirs();
87
+ const file = join(CALLS_DIR, `${call.id}.json`);
88
+ writeFileSync(file, JSON.stringify(call, null, 2), "utf-8");
89
+ }
90
+
91
+ export function getCall(callId: string): CallState | null {
92
+ const file = join(CALLS_DIR, `${callId}.json`);
93
+ if (!existsSync(file)) return null;
94
+ try {
95
+ return JSON.parse(readFileSync(file, "utf-8"));
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ export function listCalls(limit = 50): CallState[] {
102
+ ensureDirs();
103
+ try {
104
+ const files = readdirSync(CALLS_DIR)
105
+ .filter((f) => f.endsWith(".json"))
106
+ .sort()
107
+ .reverse()
108
+ .slice(0, limit);
109
+ return files.map((f) => {
110
+ try {
111
+ return JSON.parse(readFileSync(join(CALLS_DIR, f), "utf-8")) as CallState;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }).filter(Boolean) as CallState[];
116
+ } catch {
117
+ return [];
118
+ }
119
+ }