luca 3.0.0 → 3.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 (388) hide show
  1. package/.github/workflows/release.yaml +1 -0
  2. package/CLAUDE.md +10 -2
  3. package/README.md +130 -112
  4. package/assistants/codingAssistant/CORE.md +6 -1
  5. package/assistants/codingAssistant/hooks.ts +1 -1
  6. package/assistants/inkbot/hooks.ts +1 -1
  7. package/assistants/inkbot/tools.ts +1 -1
  8. package/bun.lock +264 -321
  9. package/commands/audit-docs.ts +2 -2
  10. package/commands/build-bootstrap.ts +2 -3
  11. package/commands/build-python-bridge.ts +2 -3
  12. package/commands/build-scaffolds.ts +2 -3
  13. package/commands/bundle-consumer-project.ts +521 -0
  14. package/commands/generate-api-docs.ts +2 -2
  15. package/commands/inkbot.ts +2 -2
  16. package/commands/release.ts +2 -2
  17. package/commands/social.ts +137 -0
  18. package/commands/try-all-challenges.ts +3 -3
  19. package/commands/try-challenge.ts +3 -3
  20. package/datasets/lora/agentic-loop-session-candidates.jsonl +91 -0
  21. package/datasets/lora/agentic-loop-session-curation-summary.json +123 -0
  22. package/datasets/lora/luca-session-candidates.jsonl +29 -0
  23. package/datasets/lora/luca-session-curation-summary.json +121 -0
  24. package/datasets/lora/review-batch-1.jsonl +30 -0
  25. package/datasets/lora/review-manifest.json +41 -0
  26. package/datasets/lora/review-queue.jsonl +120 -0
  27. package/datasets/lora/review-schema.json +134 -0
  28. package/datasets/lora/review-template.jsonl +2 -0
  29. package/datasets/lora/review-ui.html +725 -0
  30. package/dist/agi/container.server.d.ts +2 -2
  31. package/dist/agi/features/assistant.d.ts +2 -2
  32. package/dist/agi/features/assistants-manager.d.ts +1 -1
  33. package/dist/agi/features/autonomous-assistant.d.ts +1 -1
  34. package/dist/agi/features/browser-use.d.ts +1 -1
  35. package/dist/agi/features/claude-code.d.ts +1 -1
  36. package/dist/agi/features/conversation-history.d.ts +2 -2
  37. package/dist/agi/features/conversation.d.ts +1 -1
  38. package/dist/agi/features/docs-reader.d.ts +1 -1
  39. package/dist/agi/features/file-tools.d.ts +1 -1
  40. package/dist/agi/features/luca-coder.d.ts +1 -1
  41. package/dist/agi/features/openai-codex.d.ts +1 -1
  42. package/dist/agi/features/skills-library.d.ts +1 -1
  43. package/dist/clients/civitai/index.d.ts +4 -4
  44. package/dist/clients/client-template.d.ts +4 -4
  45. package/dist/clients/comfyui/index.d.ts +2 -2
  46. package/dist/clients/elevenlabs/index.d.ts +2 -2
  47. package/dist/clients/openai/index.d.ts +2 -2
  48. package/dist/clients/supabase/index.d.ts +3 -3
  49. package/dist/command.d.ts +1 -1
  50. package/dist/node/container.d.ts +1 -1
  51. package/dist/node/features/helpers.d.ts +3 -3
  52. package/dist/node/features/semantic-search.d.ts +1 -1
  53. package/dist/node/features/vm.d.ts +3 -3
  54. package/dist/node.d.ts +1 -1
  55. package/dist/scaffolds/generated.d.ts +1 -1
  56. package/dist/selector.d.ts +1 -1
  57. package/features/cipher-social.ts +493 -0
  58. package/index.html +217 -190
  59. package/luca.console.ts +1 -1
  60. package/package.json +7 -2
  61. package/public/index.html +217 -190
  62. package/public/slides-ai-native.html +1 -1
  63. package/public/slides-intro.html +2 -2
  64. package/scripts/curate-claude-sessions.ts +561 -0
  65. package/scripts/examples/ask-luca-expert.ts +1 -1
  66. package/scripts/examples/assistant-questions.ts +1 -1
  67. package/scripts/examples/excalidraw-expert.ts +1 -1
  68. package/scripts/examples/file-manager.ts +1 -1
  69. package/scripts/examples/ideas.ts +1 -1
  70. package/scripts/examples/interactive-chat.ts +1 -1
  71. package/scripts/examples/opening-a-web-browser.ts +1 -1
  72. package/scripts/examples/telegram-bot.ts +1 -1
  73. package/scripts/examples/using-assistant-with-mcp.ts +1 -1
  74. package/scripts/examples/using-claude-code.ts +1 -1
  75. package/scripts/examples/using-contentdb.ts +2 -2
  76. package/scripts/examples/using-conversations.ts +1 -1
  77. package/scripts/examples/using-disk-cache.ts +1 -1
  78. package/scripts/examples/using-docker-shell.ts +1 -1
  79. package/scripts/examples/using-elevenlabs.ts +1 -1
  80. package/scripts/examples/using-google-calendar.ts +1 -1
  81. package/scripts/examples/using-google-docs.ts +1 -1
  82. package/scripts/examples/using-google-drive.ts +1 -1
  83. package/scripts/examples/using-google-sheets.ts +1 -1
  84. package/scripts/examples/using-nlp.ts +1 -1
  85. package/scripts/examples/using-ollama.ts +1 -1
  86. package/scripts/examples/using-postgres.ts +1 -1
  87. package/scripts/examples/using-runpod.ts +1 -1
  88. package/scripts/examples/using-tts.ts +1 -1
  89. package/scripts/scaffold.ts +5 -5
  90. package/scripts/scratch.ts +1 -1
  91. package/scripts/test-assistant-hooks.ts +1 -1
  92. package/scripts/test-docs-reader.ts +1 -1
  93. package/src/agi/container.server.ts +6 -2
  94. package/src/agi/features/agent-memory.ts +25 -25
  95. package/src/agi/features/assistant.ts +34 -5
  96. package/src/agi/features/assistants-manager.ts +122 -6
  97. package/src/agi/features/autonomous-assistant.ts +1 -1
  98. package/src/agi/features/browser-use.ts +20 -1
  99. package/src/agi/features/claude-code.ts +51 -5
  100. package/src/agi/features/coding-tools.ts +1 -1
  101. package/src/agi/features/conversation-history.ts +181 -4
  102. package/src/agi/features/conversation.ts +186 -15
  103. package/src/agi/features/docs-reader.ts +2 -2
  104. package/src/agi/features/file-tools.ts +49 -2
  105. package/src/agi/features/luca-coder.ts +7 -5
  106. package/src/agi/features/mcp-bridge.ts +532 -0
  107. package/src/agi/features/openai-codex.ts +2 -2
  108. package/src/agi/features/skills-library.ts +131 -52
  109. package/src/agi/lib/token-counter.ts +80 -0
  110. package/src/bootstrap/generated.ts +56 -57
  111. package/src/browser.ts +1 -1
  112. package/src/cli/build-info.ts +2 -2
  113. package/src/cli/cli.ts +2 -2
  114. package/src/clients/civitai/index.ts +5 -5
  115. package/src/clients/client-template.ts +4 -4
  116. package/src/clients/comfyui/index.ts +4 -4
  117. package/src/clients/elevenlabs/index.ts +4 -4
  118. package/src/clients/openai/index.ts +7 -7
  119. package/src/clients/supabase/index.ts +4 -4
  120. package/src/clients/voicebox/index.ts +4 -4
  121. package/src/command.ts +2 -1
  122. package/src/commands/chat.ts +1 -0
  123. package/src/commands/eval.ts +2 -56
  124. package/src/commands/introspect.ts +1 -1
  125. package/src/commands/prompt.ts +41 -9
  126. package/src/container-describer.ts +8 -1
  127. package/src/container.ts +13 -0
  128. package/src/entity.ts +2 -2
  129. package/src/helper.ts +1 -1
  130. package/src/introspection/generated.agi.ts +29596 -27654
  131. package/src/introspection/generated.node.ts +20284 -19247
  132. package/src/introspection/generated.web.ts +605 -584
  133. package/src/introspection/scan.ts +11 -6
  134. package/src/node/container.ts +9 -1
  135. package/src/node/features/content-db.ts +39 -2
  136. package/src/node/features/display-result.ts +57 -0
  137. package/src/node/features/helpers.ts +46 -7
  138. package/src/node/features/python.ts +25 -19
  139. package/src/node/features/repl.ts +1 -1
  140. package/src/node/features/secure-shell.ts +11 -17
  141. package/src/node/features/semantic-search.ts +2 -2
  142. package/src/node/features/socket-repl.ts +336 -0
  143. package/src/node/features/telnyx-assistant-connector.ts +1206 -0
  144. package/src/node/features/transpiler.ts +2 -3
  145. package/src/node/features/ui.ts +5 -0
  146. package/src/node/features/vm.ts +20 -3
  147. package/src/node.ts +3 -3
  148. package/src/python/generated.ts +0 -1
  149. package/src/scaffolds/generated.ts +82 -83
  150. package/src/selector.ts +1 -1
  151. package/src/servers/express.ts +1 -1
  152. package/src/web/features/helpers.ts +22 -0
  153. package/tsconfig.json +12 -12
  154. package/docs/CLI.md +0 -335
  155. package/docs/CNAME +0 -1
  156. package/docs/README.md +0 -60
  157. package/docs/TABLE-OF-CONTENTS.md +0 -183
  158. package/docs/apis/clients/elevenlabs.md +0 -308
  159. package/docs/apis/clients/graph.md +0 -107
  160. package/docs/apis/clients/openai.md +0 -429
  161. package/docs/apis/clients/rest.md +0 -161
  162. package/docs/apis/clients/websocket.md +0 -174
  163. package/docs/apis/features/agi/assistant.md +0 -625
  164. package/docs/apis/features/agi/assistants-manager.md +0 -282
  165. package/docs/apis/features/agi/auto-assistant.md +0 -279
  166. package/docs/apis/features/agi/browser-use.md +0 -802
  167. package/docs/apis/features/agi/claude-code.md +0 -884
  168. package/docs/apis/features/agi/conversation-history.md +0 -364
  169. package/docs/apis/features/agi/conversation.md +0 -548
  170. package/docs/apis/features/agi/docs-reader.md +0 -99
  171. package/docs/apis/features/agi/file-tools.md +0 -163
  172. package/docs/apis/features/agi/luca-coder.md +0 -407
  173. package/docs/apis/features/agi/openai-codex.md +0 -396
  174. package/docs/apis/features/agi/openapi.md +0 -138
  175. package/docs/apis/features/agi/semantic-search.md +0 -387
  176. package/docs/apis/features/agi/skills-library.md +0 -239
  177. package/docs/apis/features/node/container-link.md +0 -192
  178. package/docs/apis/features/node/content-db.md +0 -450
  179. package/docs/apis/features/node/disk-cache.md +0 -379
  180. package/docs/apis/features/node/dns.md +0 -652
  181. package/docs/apis/features/node/docker.md +0 -706
  182. package/docs/apis/features/node/downloader.md +0 -81
  183. package/docs/apis/features/node/esbuild.md +0 -60
  184. package/docs/apis/features/node/file-manager.md +0 -191
  185. package/docs/apis/features/node/fs.md +0 -1217
  186. package/docs/apis/features/node/git.md +0 -371
  187. package/docs/apis/features/node/google-auth.md +0 -193
  188. package/docs/apis/features/node/google-calendar.md +0 -202
  189. package/docs/apis/features/node/google-docs.md +0 -173
  190. package/docs/apis/features/node/google-drive.md +0 -246
  191. package/docs/apis/features/node/google-mail.md +0 -214
  192. package/docs/apis/features/node/google-sheets.md +0 -194
  193. package/docs/apis/features/node/grep.md +0 -292
  194. package/docs/apis/features/node/helpers.md +0 -164
  195. package/docs/apis/features/node/ink.md +0 -334
  196. package/docs/apis/features/node/ipc-socket.md +0 -249
  197. package/docs/apis/features/node/json-tree.md +0 -86
  198. package/docs/apis/features/node/networking.md +0 -316
  199. package/docs/apis/features/node/nlp.md +0 -133
  200. package/docs/apis/features/node/opener.md +0 -97
  201. package/docs/apis/features/node/os.md +0 -146
  202. package/docs/apis/features/node/package-finder.md +0 -392
  203. package/docs/apis/features/node/postgres.md +0 -234
  204. package/docs/apis/features/node/proc.md +0 -399
  205. package/docs/apis/features/node/process-manager.md +0 -305
  206. package/docs/apis/features/node/python.md +0 -604
  207. package/docs/apis/features/node/redis.md +0 -380
  208. package/docs/apis/features/node/repl.md +0 -88
  209. package/docs/apis/features/node/runpod.md +0 -674
  210. package/docs/apis/features/node/secure-shell.md +0 -176
  211. package/docs/apis/features/node/semantic-search.md +0 -408
  212. package/docs/apis/features/node/sqlite.md +0 -233
  213. package/docs/apis/features/node/telegram.md +0 -279
  214. package/docs/apis/features/node/transpiler.md +0 -74
  215. package/docs/apis/features/node/tts.md +0 -133
  216. package/docs/apis/features/node/ui.md +0 -701
  217. package/docs/apis/features/node/vault.md +0 -59
  218. package/docs/apis/features/node/vm.md +0 -75
  219. package/docs/apis/features/node/yaml-tree.md +0 -85
  220. package/docs/apis/features/node/yaml.md +0 -176
  221. package/docs/apis/features/web/asset-loader.md +0 -59
  222. package/docs/apis/features/web/container-link.md +0 -192
  223. package/docs/apis/features/web/esbuild.md +0 -54
  224. package/docs/apis/features/web/helpers.md +0 -164
  225. package/docs/apis/features/web/network.md +0 -44
  226. package/docs/apis/features/web/speech.md +0 -69
  227. package/docs/apis/features/web/vault.md +0 -59
  228. package/docs/apis/features/web/vm.md +0 -75
  229. package/docs/apis/features/web/voice.md +0 -84
  230. package/docs/apis/servers/express.md +0 -171
  231. package/docs/apis/servers/mcp.md +0 -238
  232. package/docs/apis/servers/websocket.md +0 -170
  233. package/docs/bootstrap/CLAUDE.md +0 -101
  234. package/docs/bootstrap/SKILL.md +0 -341
  235. package/docs/bootstrap/templates/about-command.ts +0 -41
  236. package/docs/bootstrap/templates/docs-models.ts +0 -22
  237. package/docs/bootstrap/templates/docs-readme.md +0 -43
  238. package/docs/bootstrap/templates/example-feature.ts +0 -53
  239. package/docs/bootstrap/templates/health-endpoint.ts +0 -15
  240. package/docs/bootstrap/templates/luca-cli.ts +0 -30
  241. package/docs/bootstrap/templates/runme.md +0 -54
  242. package/docs/challenges/caching-proxy.md +0 -16
  243. package/docs/challenges/content-db-round-trip.md +0 -14
  244. package/docs/challenges/custom-command.md +0 -9
  245. package/docs/challenges/file-watcher-pipeline.md +0 -11
  246. package/docs/challenges/grep-audit-report.md +0 -15
  247. package/docs/challenges/multi-feature-dashboard.md +0 -14
  248. package/docs/challenges/process-orchestrator.md +0 -17
  249. package/docs/challenges/rest-api-server-with-client.md +0 -12
  250. package/docs/challenges/script-runner-with-vm.md +0 -11
  251. package/docs/challenges/simple-rest-api.md +0 -15
  252. package/docs/challenges/websocket-serve-and-client.md +0 -11
  253. package/docs/challenges/yaml-config-system.md +0 -14
  254. package/docs/command-system-overhaul.md +0 -94
  255. package/docs/documentation-audit.md +0 -134
  256. package/docs/examples/assistant/CORE.md +0 -18
  257. package/docs/examples/assistant/hooks.ts +0 -3
  258. package/docs/examples/assistant/tools.ts +0 -10
  259. package/docs/examples/assistant-hooks-reference.ts +0 -171
  260. package/docs/examples/assistant-with-process-manager.md +0 -84
  261. package/docs/examples/content-db.md +0 -77
  262. package/docs/examples/disk-cache.md +0 -83
  263. package/docs/examples/docker.md +0 -101
  264. package/docs/examples/downloader.md +0 -70
  265. package/docs/examples/entity.md +0 -124
  266. package/docs/examples/esbuild.md +0 -80
  267. package/docs/examples/feature-as-tool-provider.md +0 -143
  268. package/docs/examples/file-manager.md +0 -82
  269. package/docs/examples/fs.md +0 -83
  270. package/docs/examples/git.md +0 -85
  271. package/docs/examples/google-auth.md +0 -88
  272. package/docs/examples/google-calendar.md +0 -94
  273. package/docs/examples/google-docs.md +0 -82
  274. package/docs/examples/google-drive.md +0 -96
  275. package/docs/examples/google-sheets.md +0 -95
  276. package/docs/examples/grep.md +0 -85
  277. package/docs/examples/ink-blocks.md +0 -75
  278. package/docs/examples/ink-renderer.md +0 -41
  279. package/docs/examples/ink.md +0 -103
  280. package/docs/examples/ipc-socket.md +0 -103
  281. package/docs/examples/json-tree.md +0 -91
  282. package/docs/examples/networking.md +0 -58
  283. package/docs/examples/nlp.md +0 -91
  284. package/docs/examples/opener.md +0 -78
  285. package/docs/examples/os.md +0 -72
  286. package/docs/examples/package-finder.md +0 -89
  287. package/docs/examples/postgres.md +0 -91
  288. package/docs/examples/proc.md +0 -81
  289. package/docs/examples/process-manager.md +0 -79
  290. package/docs/examples/python.md +0 -132
  291. package/docs/examples/repl.md +0 -93
  292. package/docs/examples/runpod.md +0 -119
  293. package/docs/examples/secure-shell.md +0 -92
  294. package/docs/examples/sqlite.md +0 -86
  295. package/docs/examples/structured-output-with-assistants.md +0 -144
  296. package/docs/examples/telegram.md +0 -77
  297. package/docs/examples/tts.md +0 -86
  298. package/docs/examples/ui.md +0 -80
  299. package/docs/examples/vault.md +0 -70
  300. package/docs/examples/vm.md +0 -86
  301. package/docs/examples/websocket-ask-and-reply-example.md +0 -128
  302. package/docs/examples/yaml-tree.md +0 -93
  303. package/docs/examples/yaml.md +0 -104
  304. package/docs/ideas/assistant-factory-pattern.md +0 -142
  305. package/docs/in-memory-fs.md +0 -4
  306. package/docs/introspection-audit.md +0 -49
  307. package/docs/introspection.md +0 -164
  308. package/docs/mcp/readme.md +0 -162
  309. package/docs/models.ts +0 -41
  310. package/docs/philosophy.md +0 -86
  311. package/docs/principles.md +0 -7
  312. package/docs/prompts/audit-codebase-for-failures-to-use-the-container.md +0 -34
  313. package/docs/prompts/check-for-undocumented-features.md +0 -27
  314. package/docs/prompts/mcp-test-easy-command.md +0 -27
  315. package/docs/scaffolds/client.md +0 -149
  316. package/docs/scaffolds/command.md +0 -120
  317. package/docs/scaffolds/endpoint.md +0 -171
  318. package/docs/scaffolds/feature.md +0 -158
  319. package/docs/scaffolds/selector.md +0 -91
  320. package/docs/scaffolds/server.md +0 -196
  321. package/docs/selectors.md +0 -115
  322. package/docs/sessions/custom-command/attempt-log-2.md +0 -195
  323. package/docs/sessions/file-watcher-pipeline/attempt-log-1.md +0 -728
  324. package/docs/sessions/file-watcher-pipeline/attempt-log-2.md +0 -555
  325. package/docs/sessions/grep-audit-report/attempt-log-1.md +0 -289
  326. package/docs/sessions/multi-feature-dashboard/attempt-log-2.md +0 -679
  327. package/docs/sessions/rest-api-server-with-client/attempt-log-1.md +0 -1
  328. package/docs/sessions/rest-api-server-with-client/attempt-log-3.md +0 -920
  329. package/docs/sessions/simple-rest-api/attempt-log-1.md +0 -593
  330. package/docs/sessions/websocket-serve-and-client/attempt-log-2.md +0 -995
  331. package/docs/tutorials/00-bootstrap.md +0 -166
  332. package/docs/tutorials/01-getting-started.md +0 -106
  333. package/docs/tutorials/02-container.md +0 -210
  334. package/docs/tutorials/03-scripts.md +0 -194
  335. package/docs/tutorials/04-features-overview.md +0 -196
  336. package/docs/tutorials/05-state-and-events.md +0 -171
  337. package/docs/tutorials/06-servers.md +0 -157
  338. package/docs/tutorials/07-endpoints.md +0 -198
  339. package/docs/tutorials/08-commands.md +0 -252
  340. package/docs/tutorials/09-clients.md +0 -162
  341. package/docs/tutorials/10-creating-features.md +0 -203
  342. package/docs/tutorials/11-contentbase.md +0 -191
  343. package/docs/tutorials/12-assistants.md +0 -215
  344. package/docs/tutorials/13-introspection.md +0 -157
  345. package/docs/tutorials/14-type-system.md +0 -174
  346. package/docs/tutorials/15-project-patterns.md +0 -222
  347. package/docs/tutorials/16-google-features.md +0 -534
  348. package/docs/tutorials/17-tui-blocks.md +0 -530
  349. package/docs/tutorials/18-semantic-search.md +0 -334
  350. package/docs/tutorials/19-python-sessions.md +0 -401
  351. package/docs/tutorials/20-browser-esm.md +0 -234
  352. package/index.ts +0 -1
  353. package/src/agi/endpoints/ask.ts +0 -60
  354. package/src/agi/endpoints/conversations/[id].ts +0 -45
  355. package/src/agi/endpoints/conversations.ts +0 -31
  356. package/src/agi/endpoints/experts.ts +0 -37
  357. package/test/assistant-hooks.test.ts +0 -306
  358. package/test/assistant.test.ts +0 -81
  359. package/test/bus.test.ts +0 -134
  360. package/test/clients-servers.test.ts +0 -217
  361. package/test/command.test.ts +0 -267
  362. package/test/container-link.test.ts +0 -274
  363. package/test/conversation.test.ts +0 -220
  364. package/test/features.test.ts +0 -160
  365. package/test/fork-and-research.test.ts +0 -450
  366. package/test/integration.test.ts +0 -787
  367. package/test/interceptor-chain.test.ts +0 -61
  368. package/test/node-container.test.ts +0 -121
  369. package/test/python-session.test.ts +0 -105
  370. package/test/rate-limit.test.ts +0 -272
  371. package/test/semantic-search.test.ts +0 -550
  372. package/test/state.test.ts +0 -121
  373. package/test/vm-context.test.ts +0 -146
  374. package/test/vm-loadmodule.test.ts +0 -213
  375. package/test/websocket-ask.test.ts +0 -101
  376. package/test-integration/assistant.test.ts +0 -138
  377. package/test-integration/assistants-manager.test.ts +0 -113
  378. package/test-integration/claude-code.test.ts +0 -98
  379. package/test-integration/conversation-history.test.ts +0 -205
  380. package/test-integration/conversation.test.ts +0 -137
  381. package/test-integration/elevenlabs.test.ts +0 -55
  382. package/test-integration/google-services.test.ts +0 -80
  383. package/test-integration/helpers.ts +0 -89
  384. package/test-integration/memory.test.ts +0 -204
  385. package/test-integration/openai-codex.test.ts +0 -93
  386. package/test-integration/runpod.test.ts +0 -58
  387. package/test-integration/server-endpoints.test.ts +0 -97
  388. package/test-integration/telegram.test.ts +0 -46
@@ -0,0 +1,1206 @@
1
+ import { z } from 'zod'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
+ import { Feature } from '../feature.js'
4
+
5
+ export const TelnyxAssistantConnectorStateSchema = FeatureStateSchema.extend({
6
+ publicUrl: z.string().optional().describe('The public URL for tool webhooks (tunnel or pre-configured domain)'),
7
+ telnyxAssistantId: z.string().optional().describe('The Telnyx assistant ID created for this session'),
8
+ phoneNumberId: z.string().optional().describe('The Telnyx phone number ID wired to the assistant'),
9
+ port: z.number().optional().describe('The port the express server is listening on'),
10
+ running: z.boolean().default(false).describe('Whether the connector is actively running'),
11
+ })
12
+ export type TelnyxConnectorState = z.infer<typeof TelnyxAssistantConnectorStateSchema>
13
+
14
+ export const TelnyxAssistantConnectorOptionsSchema = FeatureOptionsSchema.extend({
15
+ assistant: z.any().describe('The Luca assistant instance to bridge to Telnyx'),
16
+ port: z.number().default(4567).describe('Port for the local express server'),
17
+ model: z.string().default('meta-llama/Meta-Llama-3.1-70B-Instruct').describe('Telnyx model ID'),
18
+ greeting: z.string().optional().describe('Greeting message for the Telnyx assistant'),
19
+ phoneNumber: z.string().optional().describe('Phone number to wire to the assistant (e.g. +13125552200)'),
20
+ noTools: z.boolean().default(false).describe('Deploy without tools — skip local server and tunnel'),
21
+ debug: z.boolean().default(false).describe('Emit verbose [telnyx] log output'),
22
+ domain: z.string().optional().describe('Pre-configured domain name (e.g. from cloudflared tunnel). Skips ephemeral tunnel creation.'),
23
+ voice: z.string().optional().describe('TTS voice ID (e.g. Telnyx.Ultra.<id> or an ElevenLabs voice ID). If omitted, uses Telnyx default.'),
24
+ ttsProvider: z.string().optional().describe('TTS provider: "telnyx" (default) or "elevenlabs"'),
25
+ apiKeyRef: z.string().optional().describe('Integration secret identifier for the TTS provider API key (required for ElevenLabs)'),
26
+ })
27
+ export type TelnyxConnectorOptions = z.infer<typeof TelnyxAssistantConnectorOptionsSchema>
28
+
29
+ export const TelnyxAssistantConnectorEventsSchema = FeatureEventsSchema.extend({
30
+ started: z.tuple([z.object({
31
+ publicUrl: z.string(),
32
+ telnyxAssistantId: z.string(),
33
+ port: z.number(),
34
+ })]).describe('Emitted when the connector is fully running'),
35
+ toolCall: z.tuple([z.string(), z.any()]).describe('Emitted when a tool is called via webhook'),
36
+ toolError: z.tuple([z.string(), z.instanceof(Error)]).describe('Emitted when a tool call throws'),
37
+ stopped: z.tuple([]).describe('Emitted when the connector is torn down'),
38
+ })
39
+
40
+ /**
41
+ * Bridges a local Luca assistant to Telnyx AI by exposing tool handlers
42
+ * as HTTP endpoints and creating a mirrored Telnyx assistant with webhook bindings.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * const mgr = container.feature('assistantsManager')
47
+ * const chief = mgr.create('chiefOfStaff')
48
+ * const connector = container.feature('telnyxAssistantConnector', { assistant: chief })
49
+ * await connector.start()
50
+ * ```
51
+ *
52
+ * @extends Feature
53
+ */
54
+ export class TelnyxAssistantConnector extends Feature<TelnyxConnectorState, TelnyxConnectorOptions> {
55
+ static override shortcut = 'features.telnyxAssistantConnector' as const
56
+ static override stateSchema = TelnyxAssistantConnectorStateSchema
57
+ static override optionsSchema = TelnyxAssistantConnectorOptionsSchema
58
+ static override eventsSchema = TelnyxAssistantConnectorEventsSchema
59
+ static { Feature.register(this, 'telnyxAssistantConnector') }
60
+
61
+ private _log(...args: any[]) {
62
+ if (this.options.debug) console.log(...args)
63
+ }
64
+
65
+ private _server: any = null
66
+ private _tunnelProcess: any = null
67
+ private _telnyxClient: any = null
68
+ private _previousConnectionId: string | null = null
69
+ private _messagingProfileId: string | null = null
70
+ private _activeCallSid: string | null = null
71
+
72
+ get assistant() {
73
+ return this.options.assistant
74
+ }
75
+
76
+ /**
77
+ * Canonical name derived from the assistant folder (e.g. `receptionist`),
78
+ * used for both the Telnyx assistant and its messaging profile.
79
+ */
80
+ get assistantName(): string {
81
+ const folder = this.assistant?.options?.folder
82
+ if (folder) return String(folder).split('/').pop()!
83
+ return this.assistant?.name || this.assistant?.constructor?.name || 'assistant'
84
+ }
85
+
86
+ /**
87
+ * Get a Telnyx client (uses existing one if running, otherwise creates a fresh one).
88
+ */
89
+ private async _getClient() {
90
+ if (this._telnyxClient) return this._telnyxClient
91
+ const { Telnyx } = await import('telnyx')
92
+ return new Telnyx({ apiKey: process.env.TELNYX_API_KEY! })
93
+ }
94
+
95
+ /**
96
+ * List all messaging profiles on the account.
97
+ */
98
+ async listMessagingProfiles() {
99
+ const client = await this._getClient()
100
+ const profiles = await client.messagingProfiles.list()
101
+ const results: any[] = []
102
+ for await (const p of profiles) {
103
+ results.push({
104
+ id: p.id,
105
+ name: p.name,
106
+ webhook_url: p.webhook_url,
107
+ whitelisted_destinations: p.whitelisted_destinations,
108
+ created_at: p.created_at,
109
+ updated_at: p.updated_at,
110
+ })
111
+ }
112
+ return results
113
+ }
114
+
115
+ /**
116
+ * Get full details of a messaging profile by ID.
117
+ */
118
+ async getMessagingProfile(profileId: string) {
119
+ const client = await this._getClient()
120
+ const resp = await client.messagingProfiles.retrieve(profileId)
121
+ return resp?.data || resp
122
+ }
123
+
124
+ /**
125
+ * List all AI assistants on the account.
126
+ */
127
+ async listAssistants() {
128
+ const client = await this._getClient()
129
+ const resp = await client.ai.assistants.list()
130
+ const items = resp?.data || resp
131
+ return Array.isArray(items) ? items.map((a: any) => ({
132
+ id: a.id,
133
+ name: a.name,
134
+ model: a.model,
135
+ enabled_features: a.enabled_features,
136
+ telephony_settings: a.telephony_settings,
137
+ messaging_settings: a.messaging_settings,
138
+ })) : items
139
+ }
140
+
141
+ /**
142
+ * Get full details of a Telnyx AI assistant by ID.
143
+ */
144
+ async getAssistant(assistantId: string) {
145
+ const client = await this._getClient()
146
+ const resp = await client.ai.assistants.retrieve(assistantId)
147
+ return resp?.data || resp
148
+ }
149
+
150
+ /**
151
+ * List voices available to your Telnyx account. Optionally pass an
152
+ * integration secret ref for ElevenLabs — Telnyx will then include your
153
+ * personal ElevenLabs voices in the response.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * await connector.listVoices() // Telnyx defaults
158
+ * await connector.listVoices({ provider: 'ElevenLabs', // your custom voices
159
+ * apiKeyRef: 'elevenlabs_api_key' })
160
+ * ```
161
+ */
162
+ async listVoices(opts: { provider?: string; apiKeyRef?: string; filter?: string } = {}) {
163
+ const params: any = {}
164
+ if (opts.apiKeyRef) params.elevenlabs_api_key_ref = opts.apiKeyRef
165
+ const query = new URLSearchParams(params).toString()
166
+
167
+ const r = await fetch(
168
+ `https://api.telnyx.com/v2/text-to-speech/voices${query ? `?${query}` : ''}`,
169
+ { headers: { Authorization: `Bearer ${process.env.TELNYX_API_KEY!}` } }
170
+ )
171
+ const body: any = await r.json()
172
+ let voices: any[] = body?.voices || []
173
+ if (opts.provider) {
174
+ const needle = opts.provider.toLowerCase()
175
+ voices = voices.filter((v: any) => (v.provider || '').toLowerCase() === needle)
176
+ }
177
+ const filtered = opts.filter
178
+ ? voices.filter((v: any) => {
179
+ const needle = opts.filter!.toLowerCase()
180
+ return (v.name || '').toLowerCase().includes(needle)
181
+ || (v.id || '').toLowerCase().includes(needle)
182
+ || (v.voice || '').toLowerCase().includes(needle)
183
+ })
184
+ : voices
185
+ return filtered.map((v: any) => ({
186
+ voice: v.id,
187
+ name: v.name,
188
+ provider: v.provider,
189
+ model_id: v.model_id,
190
+ language: v.language,
191
+ gender: v.gender,
192
+ }))
193
+ }
194
+
195
+ /**
196
+ * Patch voice_settings on an existing Telnyx AI assistant. Useful for
197
+ * iterating on the voice string without redeploying.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * await connector.updateAssistantVoice('assistant-abc', {
202
+ * voice: 'ElevenLabs.eleven_v3.ulEiUT06p4S3sHtsvn4T',
203
+ * api_key_ref: 'elevenlabs_api_key',
204
+ * voice_speed: 1.05,
205
+ * })
206
+ * ```
207
+ */
208
+ async updateAssistantVoice(assistantId: string, voiceSettings: any) {
209
+ const client = await this._getClient()
210
+ this._log('[telnyx] Updating assistant voice_settings:', JSON.stringify(voiceSettings, null, 2))
211
+ const resp = await client.ai.assistants.update(assistantId, { voice_settings: voiceSettings })
212
+ const updated = resp?.data || resp
213
+ this._log('[telnyx] Assistant now has voice_settings:', JSON.stringify(updated?.voice_settings, null, 2))
214
+ return updated
215
+ }
216
+
217
+ /**
218
+ * Try a voice_settings object on the standalone TTS command endpoint and
219
+ * save the MP3 locally so you can listen. Fastest way to confirm a voice
220
+ * string is valid without deploying an assistant.
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * await connector.testVoice({
225
+ * voice: 'ElevenLabs.eleven_v3.ulEiUT06p4S3sHtsvn4T',
226
+ * apiKeyRef: 'elevenlabs_api_key',
227
+ * text: 'Top of the morning.',
228
+ * outputPath: 'docs/calls/voice-test.mp3',
229
+ * })
230
+ * ```
231
+ */
232
+ async testVoice(opts: { voice: string; apiKeyRef?: string; text: string; outputPath?: string; voiceSettings?: any }) {
233
+ const body: any = { voice: opts.voice, text: opts.text }
234
+ if (opts.apiKeyRef) body.api_key_ref = opts.apiKeyRef
235
+ if (opts.voiceSettings) body.voice_settings = opts.voiceSettings
236
+
237
+ this._log('[telnyx] Test TTS request:', JSON.stringify(body, null, 2))
238
+
239
+ const resp = await fetch('https://api.telnyx.com/v2/text-to-speech/speak', {
240
+ method: 'POST',
241
+ headers: {
242
+ Authorization: `Bearer ${process.env.TELNYX_API_KEY!}`,
243
+ 'Content-Type': 'application/json',
244
+ },
245
+ body: JSON.stringify(body),
246
+ })
247
+
248
+ if (!resp.ok) {
249
+ const errText = await resp.text()
250
+ this._log('[telnyx] Test TTS failed:', resp.status, errText)
251
+ return { ok: false, status: resp.status, error: errText }
252
+ }
253
+
254
+ const outputPath = opts.outputPath || 'docs/calls/voice-test.mp3'
255
+ const fs = this.container.feature('fs')
256
+ const absPath = this.container.paths.resolve(outputPath)
257
+ const buffer = Buffer.from(await resp.arrayBuffer())
258
+ await fs.writeFile(absPath, buffer)
259
+ this._log(`[telnyx] Saved TTS output → ${absPath} (${buffer.length} bytes)`)
260
+ return { ok: true, path: absPath, bytes: buffer.length }
261
+ }
262
+
263
+ /**
264
+ * Pretty-print the voice-related config of an assistant. Shows the raw
265
+ * voice_settings that Telnyx has stored, so you can compare against what
266
+ * the UI displays.
267
+ */
268
+ async inspectVoice(assistantId: string) {
269
+ const assistant = await this.getAssistant(assistantId)
270
+ const voice = assistant?.voice_settings
271
+ this._log('[telnyx] Current voice_settings on assistant:', JSON.stringify(voice, null, 2))
272
+ return voice
273
+ }
274
+
275
+ // ── Conversations ────────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * List conversations for this assistant. Automatically filters by the
279
+ * assistant ID stored in state when available, so you only see conversations
280
+ * that belong to the current deployment.
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * const convos = await connector.listConversations()
285
+ * const recent = await connector.listConversations({ order: 'last_message_at.desc', limit: 20 })
286
+ * ```
287
+ */
288
+ async listConversations(query: Record<string, any> = {}) {
289
+ const client = await this._getClient()
290
+ const assistantId = this.state.get('telnyxAssistantId')
291
+ const params: any = { ...query }
292
+ if (assistantId && !params['metadata->assistant_id']) {
293
+ params['metadata->assistant_id'] = `eq.${assistantId}`
294
+ }
295
+ const resp = await client.ai.conversations.list(params)
296
+ return resp?.data || resp
297
+ }
298
+
299
+ /**
300
+ * Retrieve a specific conversation by ID.
301
+ */
302
+ async getConversation(conversationId: string) {
303
+ const client = await this._getClient()
304
+ const resp = await client.ai.conversations.retrieve(conversationId)
305
+ return resp?.data || resp
306
+ }
307
+
308
+ /**
309
+ * List all messages in a conversation, including assistant tool calls.
310
+ */
311
+ async getConversationMessages(conversationId: string) {
312
+ const client = await this._getClient()
313
+ const resp = await client.ai.conversations.messages.list(conversationId)
314
+ return resp?.data || resp
315
+ }
316
+
317
+ /**
318
+ * Retrieve post-call insights for a conversation (summaries, extracted data, etc.).
319
+ * Insights are generated asynchronously after the call ends — check `status` field.
320
+ */
321
+ async getConversationInsights(conversationId: string) {
322
+ const client = await this._getClient()
323
+ const resp = await client.ai.conversations.retrieveConversationsInsights(conversationId)
324
+ return resp?.data || resp
325
+ }
326
+
327
+ /**
328
+ * Manually inject a message into a conversation. Useful for adding context
329
+ * or system messages outside of a live call.
330
+ */
331
+ async addConversationMessage(conversationId: string, message: {
332
+ role: string
333
+ content?: string
334
+ name?: string
335
+ sent_at?: string
336
+ tool_call_id?: string
337
+ tool_calls?: Array<Record<string, unknown>>
338
+ }) {
339
+ const client = await this._getClient()
340
+ await client.ai.conversations.addMessage(conversationId, message)
341
+ }
342
+
343
+ /**
344
+ * Disable AI responses on a conversation so a human agent can take over.
345
+ * While disabled, calls to the Telnyx chat endpoint return 400. Re-enable
346
+ * with `handoffToAI()`.
347
+ *
348
+ * @example
349
+ * ```ts
350
+ * await connector.handoffToHuman(conversationId)
351
+ * ```
352
+ */
353
+ async handoffToHuman(conversationId: string) {
354
+ const client = await this._getClient()
355
+ await client.ai.conversations.update(conversationId, {
356
+ metadata: { ai_disabled: 'true' },
357
+ })
358
+ this._log(`[telnyx] Conversation ${conversationId} handed off to human (AI disabled)`)
359
+ }
360
+
361
+ /**
362
+ * Re-enable AI responses on a conversation after a human handoff.
363
+ */
364
+ async handoffToAI(conversationId: string) {
365
+ const client = await this._getClient()
366
+ await client.ai.conversations.update(conversationId, {
367
+ metadata: { ai_disabled: 'false' },
368
+ })
369
+ this._log(`[telnyx] Conversation ${conversationId} handed back to AI`)
370
+ }
371
+
372
+ // ── Insight templates ─────────────────────────────────────────────────────
373
+
374
+ /**
375
+ * Create an insight template — a reusable instruction applied to conversations
376
+ * to extract structured data (summaries, action items, sentiment, etc.).
377
+ * Optionally provide a `json_schema` to enforce structured output.
378
+ *
379
+ * @example
380
+ * ```ts
381
+ * await connector.createInsight({
382
+ * name: 'call-summary',
383
+ * instructions: 'Summarize this call in 2-3 sentences.',
384
+ * })
385
+ * await connector.createInsight({
386
+ * name: 'action-items',
387
+ * instructions: 'Extract any action items promised during the call.',
388
+ * json_schema: { type: 'array', items: { type: 'string' } },
389
+ * })
390
+ * ```
391
+ */
392
+ async createInsight(params: { name: string; instructions: string; json_schema?: unknown; webhook?: string }) {
393
+ const client = await this._getClient()
394
+ const resp = await client.ai.conversations.insights.create(params as any)
395
+ return resp?.data || resp
396
+ }
397
+
398
+ /**
399
+ * List all insight templates on the account.
400
+ */
401
+ async listInsights() {
402
+ const client = await this._getClient()
403
+ const results: any[] = []
404
+ for await (const insight of client.ai.conversations.insights.list()) {
405
+ results.push(insight)
406
+ }
407
+ return results
408
+ }
409
+
410
+ /**
411
+ * Delete an insight template by ID.
412
+ */
413
+ async deleteInsight(insightId: string) {
414
+ const client = await this._getClient()
415
+ await client.ai.conversations.insights.delete(insightId)
416
+ }
417
+
418
+ // ── Phone numbers ─────────────────────────────────────────────────────────
419
+
420
+ /**
421
+ * List all phone numbers on the Telnyx account with their status and connection info.
422
+ */
423
+ async listPhoneNumbers() {
424
+ const client = await this._getClient()
425
+ const numbers = await client.phoneNumbers.list()
426
+ const results: any[] = []
427
+ for await (const num of numbers) {
428
+ results.push({
429
+ id: num.id,
430
+ phone_number: num.phone_number,
431
+ status: num.status,
432
+ connection_id: num.connection_id,
433
+ connection_name: num.connection_name,
434
+ messaging_profile_id: num.messaging_profile_id,
435
+ tags: num.tags,
436
+ })
437
+ }
438
+ return results
439
+ }
440
+
441
+ /**
442
+ * Get the phone number record (voice + messaging config) for an E.164 number.
443
+ */
444
+ async getPhoneNumber(phoneNumber: string) {
445
+ const client = await this._getClient()
446
+ const numbers = await client.phoneNumbers.list({ 'filter[phone_number]': phoneNumber })
447
+ let record: any = null
448
+ for await (const num of numbers) {
449
+ record = num
450
+ break
451
+ }
452
+ if (!record) return null
453
+
454
+ let messagingConfig: any = null
455
+ try {
456
+ const msgResp = await client.phoneNumbers.messaging.retrieve(record.id)
457
+ messagingConfig = msgResp?.data || msgResp
458
+ } catch { }
459
+
460
+ return {
461
+ id: record.id,
462
+ phone_number: record.phone_number,
463
+ connection_id: record.connection_id,
464
+ connection_name: record.connection_name,
465
+ messaging_profile_id: record.messaging_profile_id,
466
+ messaging: messagingConfig,
467
+ tags: record.tags,
468
+ status: record.status,
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Get a TeXML application by ID.
474
+ */
475
+ async getTexmlApp(appId: string) {
476
+ const client = await this._getClient()
477
+ const resp = await client.texmlApplications.retrieve(appId)
478
+ return resp?.data || resp
479
+ }
480
+
481
+ /**
482
+ * List all TeXML applications on the account.
483
+ */
484
+ async listTexmlApps() {
485
+ const client = await this._getClient()
486
+ const resp = await client.texmlApplications.list()
487
+ const results: any[] = []
488
+ const items = resp?.data || resp
489
+ if (Array.isArray(items)) {
490
+ for (const app of items) {
491
+ results.push({
492
+ id: app.id,
493
+ friendly_name: app.friendly_name,
494
+ voice_url: app.voice_url,
495
+ status_callback: app.status_callback,
496
+ created_at: app.created_at,
497
+ updated_at: app.updated_at,
498
+ })
499
+ }
500
+ } else if (items?.[Symbol.asyncIterator]) {
501
+ for await (const app of items) {
502
+ results.push({
503
+ id: app.id,
504
+ friendly_name: app.friendly_name,
505
+ voice_url: app.voice_url,
506
+ status_callback: app.status_callback,
507
+ created_at: app.created_at,
508
+ updated_at: app.updated_at,
509
+ })
510
+ }
511
+ }
512
+ return results
513
+ }
514
+
515
+ /**
516
+ * Delete all TeXML applications on the account.
517
+ * Returns a summary of what was deleted and any failures.
518
+ */
519
+ async deleteAllTexmlApps() {
520
+ const apps = await this.listTexmlApps()
521
+ const client = await this._getClient()
522
+ const results: { id: string; friendly_name: string; status: 'deleted' | 'failed'; error?: string }[] = []
523
+
524
+ for (const app of apps) {
525
+ try {
526
+ await client.texmlApplications.delete(app.id)
527
+ results.push({ id: app.id, friendly_name: app.friendly_name, status: 'deleted' })
528
+ } catch (err: any) {
529
+ results.push({ id: app.id, friendly_name: app.friendly_name, status: 'failed', error: err.message })
530
+ }
531
+ }
532
+
533
+ return { total: apps.length, deleted: results.filter(r => r.status === 'deleted').length, results }
534
+ }
535
+
536
+ /**
537
+ * Inspect the full live config: the current assistant, its messaging profile,
538
+ * the phone number wiring, and the TeXML app. Pass a phone number to include
539
+ * phone config, or omit to just show assistant + profile.
540
+ */
541
+ async inspect(phoneNumber?: string) {
542
+ const result: any = {}
543
+
544
+ const assistantId = this.state.get('telnyxAssistantId')
545
+ if (assistantId) {
546
+ result.assistant = await this.getAssistant(assistantId)
547
+ }
548
+
549
+ if (this._messagingProfileId) {
550
+ result.messagingProfile = await this.getMessagingProfile(this._messagingProfileId)
551
+ }
552
+
553
+ if (phoneNumber || this.options.phoneNumber) {
554
+ result.phoneNumber = await this.getPhoneNumber(phoneNumber || this.options.phoneNumber!)
555
+ }
556
+
557
+ const texmlAppId = result.assistant?.telephony_settings?.default_texml_app_id
558
+ if (texmlAppId) {
559
+ result.texmlApp = await this.getTexmlApp(texmlAppId)
560
+ }
561
+
562
+ return result
563
+ }
564
+
565
+ /**
566
+ * Start the connector: mount tool endpoints, establish public URL, create Telnyx assistant,
567
+ * and optionally wire a phone number to it.
568
+ *
569
+ * @returns The session info including public URL and Telnyx assistant ID
570
+ *
571
+ * @example
572
+ * ```typescript
573
+ * const info = await connector.start()
574
+ * console.log(info.publicUrl, info.telnyxAssistantId)
575
+ * ```
576
+ */
577
+ async start() {
578
+ const { Telnyx } = await import('telnyx')
579
+ this._telnyxClient = new Telnyx({ apiKey: process.env.TELNYX_API_KEY! })
580
+
581
+ let publicUrl: string | null = null
582
+ let port: number | null = null
583
+
584
+ if (this.options.noTools) {
585
+ await this._ensureMessagingProfile(null)
586
+ const telnyxAssistant = await this._createTelnyxAssistant(null)
587
+
588
+ if (this.options.phoneNumber) {
589
+ await this._wirePhoneNumber(telnyxAssistant)
590
+ }
591
+
592
+ this.state.set('telnyxAssistantId', telnyxAssistant.id)
593
+ this.state.set('running', true)
594
+
595
+ const info = {
596
+ publicUrl: null as string | null,
597
+ telnyxAssistantId: telnyxAssistant.id,
598
+ port: null as number | null,
599
+ phoneNumber: this.options.phoneNumber,
600
+ }
601
+ this.emit('started', info)
602
+ return info
603
+ }
604
+
605
+ port = await this._findAvailablePort(this.options.port)
606
+ const server = this.container.server('express', { port, cors: true })
607
+
608
+ this._mountToolEndpoints(server)
609
+ this._mountHangupTool(server)
610
+ this._mountCallEventsEndpoint(server)
611
+ this._mountInboundSmsEndpoint(server)
612
+
613
+ await server.start()
614
+ this._server = server
615
+
616
+ if (this.options.domain) {
617
+ publicUrl = `https://${this.options.domain}`
618
+ this._log(`[telnyx] Using pre-configured domain: ${publicUrl}`)
619
+ await this._waitForTunnelReady(publicUrl)
620
+ } else {
621
+ publicUrl = await this._startTunnel(port)
622
+ await this._waitForTunnelReady(publicUrl)
623
+ }
624
+
625
+ await this._ensureMessagingProfile(publicUrl)
626
+ const telnyxAssistant = await this._createTelnyxAssistant(publicUrl)
627
+
628
+ if (this.options.phoneNumber) {
629
+ await this._wirePhoneNumber(telnyxAssistant)
630
+ }
631
+
632
+ this.state.set('publicUrl', publicUrl)
633
+ this.state.set('telnyxAssistantId', telnyxAssistant.id)
634
+ this.state.set('port', port)
635
+ this.state.set('running', true)
636
+
637
+ const info = {
638
+ publicUrl,
639
+ telnyxAssistantId: telnyxAssistant.id,
640
+ port,
641
+ phoneNumber: this.options.phoneNumber,
642
+ }
643
+ this.emit('started', info)
644
+
645
+ return info
646
+ }
647
+
648
+ /**
649
+ * Stop the connector: restore the phone number's previous connection,
650
+ * delete the Telnyx assistant, kill tunnel (if ephemeral), stop the server.
651
+ *
652
+ * @example
653
+ * ```typescript
654
+ * await connector.stop()
655
+ * ```
656
+ */
657
+ async stop() {
658
+ const phoneNumberId = this.state.get('phoneNumberId')
659
+ if (phoneNumberId && this._telnyxClient) {
660
+ try {
661
+ if (this._previousConnectionId) {
662
+ await this._telnyxClient.phoneNumbers.update(phoneNumberId, {
663
+ connection_id: this._previousConnectionId,
664
+ })
665
+ }
666
+ if (!this._messagingProfileId) {
667
+ await this._telnyxClient.phoneNumbers.messaging.update(phoneNumberId, {
668
+ messaging_profile_id: '',
669
+ })
670
+ this._log('[telnyx] Unset messaging profile on phone number')
671
+ } else {
672
+ this._log('[telnyx] Leaving persistent messaging profile on phone number')
673
+ }
674
+ } catch (e) {
675
+ // best effort
676
+ }
677
+ }
678
+
679
+ const assistantId = this.state.get('telnyxAssistantId')
680
+ if (assistantId && this._telnyxClient) {
681
+ try {
682
+ await this._telnyxClient.ai.assistants.delete(assistantId)
683
+ } catch (e) {
684
+ // best effort cleanup
685
+ }
686
+ }
687
+
688
+ if (this._tunnelProcess) {
689
+ try { this._tunnelProcess.kill() } catch {}
690
+ this._tunnelProcess = null
691
+ }
692
+
693
+ if (this._server) {
694
+ await this._server.stop()
695
+ this._server = null
696
+ }
697
+
698
+ this.state.set('running', false)
699
+ this.emit('stopped')
700
+ }
701
+
702
+ private _mountToolEndpoints(server: any) {
703
+ const tools = this.assistant.tools
704
+
705
+ for (const [name, tool] of Object.entries(tools) as [string, any][]) {
706
+ server.app.post(`/tools/${name}`, async (req: any, res: any) => {
707
+ try {
708
+ this.emit('toolCall', name, req.body)
709
+ const result = await tool.handler(req.body)
710
+ res.json({ result })
711
+ } catch (err: any) {
712
+ this.emit('toolError', name, err instanceof Error ? err : new Error(String(err)))
713
+ res.status(500).json({ error: err.message })
714
+ }
715
+ })
716
+ }
717
+
718
+ server.app.get('/health', (_req: any, res: any) => {
719
+ res.json({
720
+ status: 'ok',
721
+ assistant: this.assistantName,
722
+ tools: Object.keys(tools),
723
+ })
724
+ })
725
+ }
726
+
727
+ private _mountHangupTool(server: any) {
728
+ server.app.post('/tools/hangup', async (_req: any, res: any) => {
729
+ const callSid = this._activeCallSid
730
+ this._log(`[telnyx] Hangup tool called (callSid: ${callSid})`)
731
+ this.emit('toolCall', 'hangup', {})
732
+
733
+ if (!callSid) {
734
+ res.json({ result: 'No active call to hang up' })
735
+ return
736
+ }
737
+
738
+ try {
739
+ await fetch(`https://api.telnyx.com/v2/calls/${encodeURIComponent(callSid)}/actions/hangup`, {
740
+ method: 'POST',
741
+ headers: {
742
+ Authorization: `Bearer ${process.env.TELNYX_API_KEY!}`,
743
+ 'Content-Type': 'application/json',
744
+ },
745
+ body: JSON.stringify({}),
746
+ })
747
+ this._activeCallSid = null
748
+ res.json({ result: 'Call ended' })
749
+ } catch (err: any) {
750
+ this.emit('toolError', 'hangup', err instanceof Error ? err : new Error(String(err)))
751
+ res.json({ result: `Failed to hang up: ${err.message}` })
752
+ }
753
+ })
754
+ }
755
+
756
+ private _mountCallEventsEndpoint(server: any) {
757
+ server.app.post('/call/events', async (req: any, res: any) => {
758
+ try {
759
+ const body = req.body
760
+ const status = body?.CallStatus || body?.DialCallStatus || 'unknown'
761
+ const callSid = body?.CallSid || 'unknown'
762
+ const conversationId = body?.ConversationId || ''
763
+
764
+ this._log(`[telnyx] Call event: ${status} (${callSid})`)
765
+
766
+ const terminalStatuses = ['completed', 'failed', 'busy', 'no-answer', 'canceled', 'conversation_ended', 'analyzed']
767
+ if (callSid && callSid !== 'unknown') {
768
+ if (terminalStatuses.includes(status)) {
769
+ if (this._activeCallSid === callSid) this._activeCallSid = null
770
+ } else {
771
+ this._activeCallSid = callSid
772
+ }
773
+ }
774
+
775
+ let insights: string | null = null
776
+ try {
777
+ const parsed = JSON.parse(body?.ConversationInsights || '[]')
778
+ insights = parsed?.[0]?.conversation_insights?.[0]?.result || null
779
+ } catch {}
780
+
781
+ if (insights) {
782
+ this._log(`[telnyx] Summary: ${insights.slice(0, 200)}${insights.length > 200 ? '...' : ''}`)
783
+ }
784
+
785
+ let cost: any = null
786
+ try { cost = JSON.parse(body?.Cost || '{}') } catch {}
787
+ if (cost?.total) {
788
+ this._log(`[telnyx] Cost: $${cost.total}`)
789
+ }
790
+
791
+ this._saveCallEvent(body, conversationId, status).catch((err: any) =>
792
+ console.error('[telnyx] Failed to save call event:', err.message)
793
+ )
794
+
795
+ res.status(200).json({ status: 'ok' })
796
+ } catch (err: any) {
797
+ console.error('[telnyx] Call event error:', err.message)
798
+ res.status(200).json({ status: 'ok' })
799
+ }
800
+ })
801
+ }
802
+
803
+ private _mountInboundSmsEndpoint(server: any) {
804
+ const assistantsByPhone = new Map<string, any>()
805
+
806
+ server.app.post('/messaging/inbound', async (req: any, res: any) => {
807
+ res.status(200).json({ status: 'ok' })
808
+
809
+ try {
810
+ const payload = req.body?.data?.payload || req.body
811
+ const eventType = req.body?.data?.event_type || ''
812
+ const direction = payload?.direction || ''
813
+
814
+ if (!eventType.includes('inbound') && direction !== 'inbound') return
815
+
816
+ const from = payload?.from?.phone_number || payload?.from || ''
817
+ const to = (payload?.to?.[0]?.phone_number) || payload?.to || ''
818
+ const text = payload?.text || ''
819
+ if (!text || !from) return
820
+
821
+ this._log(`[telnyx] Inbound SMS from ${from}: "${text}"`)
822
+
823
+ let smsAssistant = assistantsByPhone.get(from)
824
+ if (!smsAssistant) {
825
+ const mgr = this.container.feature('assistantsManager')
826
+ smsAssistant = mgr.create(this.assistantName, { historyMode: 'lifecycle' })
827
+ await smsAssistant.start()
828
+ assistantsByPhone.set(from, smsAssistant)
829
+ this._log(`[telnyx] Created local assistant for ${from}`)
830
+ }
831
+
832
+ const reply = await smsAssistant.ask(text)
833
+
834
+ if (!reply) {
835
+ this._log('[telnyx] Assistant returned empty reply')
836
+ return
837
+ }
838
+
839
+ this._log(`[telnyx] Reply to ${from}: "${reply.slice(0, 120)}${reply.length > 120 ? '...' : ''}"`)
840
+ this._log(`[telnyx] Sending SMS: from=${to}, to=${from}, profile=${this._messagingProfileId}`)
841
+
842
+ const sendResult = await this._telnyxClient.messages.send({
843
+ from: to,
844
+ to: from,
845
+ text: reply,
846
+ messaging_profile_id: this._messagingProfileId,
847
+ })
848
+ const sendData = sendResult?.data || sendResult
849
+ this._log(`[telnyx] SMS send response:`, JSON.stringify({
850
+ id: sendData?.id,
851
+ status: sendData?.to?.[0]?.status,
852
+ from: sendData?.from?.phone_number,
853
+ to: sendData?.to?.[0]?.phone_number,
854
+ errors: sendData?.errors,
855
+ }, null, 2))
856
+ this._log(`[telnyx] SMS sent to ${from}`)
857
+ } catch (err: any) {
858
+ console.error('[telnyx] SMS handler error:', err.message)
859
+ }
860
+ })
861
+ }
862
+
863
+ /**
864
+ * Save a call event to docs/calls/{slug}/{status}-{timestamp}.json.
865
+ * Each call gets its own folder (keyed by CallSid). MP3 recordings are
866
+ * downloaded when status is "analyzed".
867
+ */
868
+ private async _saveCallEvent(body: any, conversationId: string, status: string) {
869
+ const fs = this.container.feature('fs')
870
+ const slug = body?.CallSid || conversationId || new Date().toISOString().replace(/[:.]/g, '-')
871
+ const callDir = this.container.paths.resolve(`docs/calls/${slug}`)
872
+ await fs.ensureFolder(callDir)
873
+
874
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
875
+ const jsonPath = this.container.paths.join(callDir, `${status}-${timestamp}.json`)
876
+ await fs.writeFile(jsonPath, JSON.stringify(body, null, 2))
877
+ this._log(`[telnyx] Saved call event → ${jsonPath}`)
878
+
879
+ if (status !== 'analyzed') return
880
+
881
+ let recordings: any[] = []
882
+ try { recordings = JSON.parse(body?.Recordings || '[]') } catch {}
883
+
884
+ for (const rec of recordings) {
885
+ const mp3Url = rec?.download_urls?.mp3
886
+ if (!mp3Url) continue
887
+
888
+ try {
889
+ const resp = await fetch(mp3Url)
890
+ if (!resp.ok) {
891
+ this._log(`[telnyx] Failed to download recording: ${resp.status}`)
892
+ continue
893
+ }
894
+ const buffer = Buffer.from(await resp.arrayBuffer())
895
+ const mp3Path = this.container.paths.join(callDir, `recording.mp3`)
896
+ await fs.writeFile(mp3Path, buffer)
897
+ this._log(`[telnyx] Saved recording → ${mp3Path}`)
898
+ } catch (err: any) {
899
+ this._log(`[telnyx] Failed to download recording: ${err.message}`)
900
+ }
901
+ }
902
+ }
903
+
904
+ private async _findAvailablePort(preferred: number): Promise<number> {
905
+ return this.container.feature('networking').findOpenPort(preferred)
906
+ }
907
+
908
+ private async _waitForTunnelReady(url: string): Promise<void> {
909
+ const timeoutMs = 120000
910
+ const start = Date.now()
911
+ const deadline = start + timeoutMs
912
+ let attempt = 0
913
+ let lastLog = 0
914
+ while (Date.now() < deadline) {
915
+ attempt++
916
+ try {
917
+ const r = await fetch(`${url}/health`, { method: 'GET' })
918
+ if (r.ok) {
919
+ this._log(`[telnyx] tunnel ready after ${Date.now() - start}ms (attempt ${attempt})`)
920
+ return
921
+ }
922
+ } catch {
923
+ // not yet routable
924
+ }
925
+ const elapsed = Date.now() - start
926
+ if (elapsed - lastLog >= 10000) {
927
+ this._log(`[telnyx] tunnel not ready yet (${Math.round(elapsed / 1000)}s, attempt ${attempt})`)
928
+ lastLog = elapsed
929
+ }
930
+ await new Promise((r) => setTimeout(r, 1500))
931
+ }
932
+ throw new Error(`Tunnel ${url} did not become reachable within ${timeoutMs / 1000}s`)
933
+ }
934
+
935
+ /**
936
+ * Start a cloudflared quick tunnel and capture the public trycloudflare.com URL.
937
+ * Each invocation gets a fresh ephemeral hostname — no config or login required.
938
+ */
939
+ private async _startTunnel(port: number): Promise<string> {
940
+ const proc = this.container.feature('proc')
941
+
942
+ const child = proc.spawn('cloudflared', [
943
+ 'tunnel',
944
+ '--no-autoupdate',
945
+ '--url', `http://localhost:${port}`,
946
+ ])
947
+ this._tunnelProcess = child
948
+ this._log(`[telnyx] cloudflared tunneling :${port}`)
949
+
950
+ return await new Promise<string>((resolve, reject) => {
951
+ const timer = setTimeout(() => {
952
+ reject(new Error(`Failed to start cloudflared tunnel for :${port} within 90s`))
953
+ }, 90000)
954
+
955
+ const urlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
956
+ let publicUrl: string | null = null
957
+ let registered = false
958
+ let resolved = false
959
+
960
+ const tryResolve = () => {
961
+ if (resolved || !publicUrl || !registered) return
962
+ resolved = true
963
+ clearTimeout(timer)
964
+ this._log(`[telnyx] tunnel registered with edge → ${publicUrl}`)
965
+ resolve(publicUrl)
966
+ }
967
+
968
+ const onChunk = (chunk: any) => {
969
+ const text = String(chunk)
970
+ for (const line of text.split('\n')) {
971
+ const trimmed = line.trim()
972
+ if (!trimmed) continue
973
+ this._log(`[cloudflared:${port}] ${trimmed.slice(0, 500)}`)
974
+ if (!publicUrl) {
975
+ const match = trimmed.match(urlPattern)
976
+ if (match) publicUrl = match[0]
977
+ }
978
+ if (!registered && /Registered tunnel connection/i.test(trimmed)) {
979
+ registered = true
980
+ }
981
+ tryResolve()
982
+ }
983
+ }
984
+
985
+ child.stdout?.on?.('data', onChunk)
986
+ child.stderr?.on?.('data', onChunk)
987
+ })
988
+ }
989
+
990
+ /**
991
+ * Create a Telnyx assistant that mirrors the local assistant's prompt and tools.
992
+ */
993
+ private async _createTelnyxAssistant(publicUrl: string | null) {
994
+ const webhookTools = []
995
+
996
+ if (publicUrl) {
997
+ const tools = this.assistant.tools
998
+ for (const [name, tool] of Object.entries(tools) as [string, any][]) {
999
+ webhookTools.push({
1000
+ type: 'webhook' as const,
1001
+ webhook: {
1002
+ name,
1003
+ description: tool.description || name,
1004
+ url: `${publicUrl}/tools/${name}`,
1005
+ method: 'POST' as const,
1006
+ body_parameters: tool.parameters || { type: 'object', properties: {} },
1007
+ timeout_ms: 10000,
1008
+ },
1009
+ })
1010
+ }
1011
+
1012
+ webhookTools.push({
1013
+ type: 'webhook' as const,
1014
+ webhook: {
1015
+ name: 'hangup',
1016
+ description: 'End the current phone call. Call this when the conversation is complete or the caller should be disconnected.',
1017
+ url: `${publicUrl}/tools/hangup`,
1018
+ method: 'POST' as const,
1019
+ body_parameters: { type: 'object', properties: {} },
1020
+ timeout_ms: 5000,
1021
+ },
1022
+ })
1023
+ }
1024
+
1025
+ const params: any = {
1026
+ name: `luca-${this.assistantName}`,
1027
+ instructions: this.assistant.effectiveSystemPrompt,
1028
+ model: this.options.model,
1029
+ enabled_features: ['telephony', 'messaging'],
1030
+ }
1031
+
1032
+ const voiceConfig = this.assistant.voiceConfig
1033
+ const isElevenLabs = this.options.ttsProvider === 'elevenlabs' || voiceConfig?.provider === 'elevenlabs'
1034
+ const voiceId = this.options.voice || voiceConfig?.voiceId
1035
+ const apiKeyRef = this.options.apiKeyRef
1036
+
1037
+ this._log('[telnyx] Voice resolution:', JSON.stringify({
1038
+ sources: {
1039
+ 'options.voice': this.options.voice,
1040
+ 'options.ttsProvider': this.options.ttsProvider,
1041
+ 'options.apiKeyRef': this.options.apiKeyRef,
1042
+ 'assistant.voiceConfig': voiceConfig,
1043
+ },
1044
+ resolved: { voiceId, isElevenLabs, apiKeyRef },
1045
+ }, null, 2))
1046
+
1047
+ if (voiceId) {
1048
+ let resolvedVoice = voiceId
1049
+ if (isElevenLabs && !/^ElevenLabs\./i.test(voiceId)) {
1050
+ const supported = new Set([
1051
+ 'eleven_flash_v2', 'eleven_flash_v2_5', 'eleven_multilingual_v1',
1052
+ 'eleven_multilingual_v2', 'eleven_turbo_v2', 'eleven_turbo_v2_5',
1053
+ 'eleven_v2_5_flash', 'eleven_v2_flash',
1054
+ ])
1055
+ const model = voiceConfig?.modelId
1056
+ resolvedVoice = model && supported.has(model)
1057
+ ? `ElevenLabs.${model}.${voiceId}`
1058
+ : `ElevenLabs.${voiceId}`
1059
+ }
1060
+
1061
+ const voiceSettings: any = { voice: resolvedVoice }
1062
+ if (apiKeyRef && isElevenLabs) {
1063
+ voiceSettings.api_key_ref = apiKeyRef
1064
+ }
1065
+ if (isElevenLabs && typeof voiceConfig?.voiceSettings?.speed === 'number') {
1066
+ voiceSettings.voice_speed = voiceConfig.voiceSettings.speed
1067
+ }
1068
+ params.voice_settings = voiceSettings
1069
+ this._log('[telnyx] Sending voice_settings:', JSON.stringify(voiceSettings, null, 2))
1070
+ } else {
1071
+ this._log('[telnyx] No voiceId resolved — using Telnyx default voice')
1072
+ }
1073
+
1074
+ if (this._messagingProfileId) {
1075
+ params.messaging_settings = {
1076
+ default_messaging_profile_id: this._messagingProfileId,
1077
+ }
1078
+ }
1079
+
1080
+ if (webhookTools.length > 0) {
1081
+ params.tools = webhookTools
1082
+ }
1083
+
1084
+ if (this.options.greeting) {
1085
+ params.greeting = this.options.greeting
1086
+ }
1087
+
1088
+ this._log('[telnyx] Creating assistant with params:', JSON.stringify(params, null, 2))
1089
+ const result = await this._telnyxClient.ai.assistants.create(params)
1090
+ this._log('[telnyx] Assistant created:', JSON.stringify({
1091
+ id: result.id,
1092
+ name: result.name,
1093
+ enabled_features: result.enabled_features,
1094
+ telephony_settings: result.telephony_settings,
1095
+ messaging_settings: result.messaging_settings,
1096
+ }, null, 2))
1097
+
1098
+ if (publicUrl) {
1099
+ const texmlAppId = result.telephony_settings?.default_texml_app_id
1100
+ if (texmlAppId) {
1101
+ try {
1102
+ await this._telnyxClient.texmlApplications.update(texmlAppId, {
1103
+ status_callback: `${publicUrl}/call/events`,
1104
+ })
1105
+ this._log(`[telnyx] Wired TeXML app status callback → ${publicUrl}/call/events`)
1106
+ } catch (err: any) {
1107
+ this._log(`[telnyx] Could not set TeXML status callback: ${err.message}`)
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ return result
1113
+ }
1114
+
1115
+ /**
1116
+ * Find or create a single persistent messaging profile named after the assistant.
1117
+ */
1118
+ private async _ensureMessagingProfile(publicUrl: string | null): Promise<string> {
1119
+ const client = this._telnyxClient
1120
+ const profileName = `luca-${this.assistantName}`
1121
+
1122
+ const profiles = await client.messagingProfiles.list()
1123
+ let existing: any = null
1124
+
1125
+ for await (const profile of profiles) {
1126
+ if (profile.name === profileName) {
1127
+ existing = profile
1128
+ break
1129
+ }
1130
+ }
1131
+
1132
+ if (existing) {
1133
+ this._log(`[telnyx] Found existing messaging profile "${profileName}" (${existing.id})`)
1134
+ if (existing.webhook_url) {
1135
+ await client.messagingProfiles.update(existing.id, { webhook_url: '' })
1136
+ this._log(`[telnyx] Cleared messaging profile webhook (letting Telnyx assistant handle SMS natively)`)
1137
+ }
1138
+ this._messagingProfileId = existing.id
1139
+ return existing.id
1140
+ }
1141
+
1142
+ this._log(`[telnyx] Creating messaging profile "${profileName}"`)
1143
+ const created = await client.messagingProfiles.create({
1144
+ name: profileName,
1145
+ webhook_url: '',
1146
+ whitelisted_destinations: ['US'],
1147
+ })
1148
+ const profileId = created?.data?.id || created?.id
1149
+ this._log(`[telnyx] Created messaging profile "${profileName}" (${profileId})`)
1150
+ this._messagingProfileId = profileId
1151
+ return profileId
1152
+ }
1153
+
1154
+ /**
1155
+ * Wire a phone number to the assistant's auto-created TeXML app and
1156
+ * the persistent messaging profile. Saves the previous connection_id
1157
+ * so stop() can restore it.
1158
+ */
1159
+ private async _wirePhoneNumber(telnyxAssistant: any) {
1160
+ const phoneNumber = this.options.phoneNumber!
1161
+ const client = this._telnyxClient
1162
+
1163
+ const numbers = await client.phoneNumbers.list({ 'filter[phone_number]': phoneNumber })
1164
+ let phoneRecord: any = null
1165
+
1166
+ for await (const num of numbers) {
1167
+ phoneRecord = num
1168
+ break
1169
+ }
1170
+
1171
+ if (!phoneRecord) {
1172
+ throw new Error(`Phone number ${phoneNumber} not found in your Telnyx account`)
1173
+ }
1174
+
1175
+ this._log('[telnyx] Phone record:', JSON.stringify({
1176
+ id: phoneRecord.id,
1177
+ phone_number: phoneRecord.phone_number,
1178
+ connection_id: phoneRecord.connection_id,
1179
+ messaging_profile_id: phoneRecord.messaging_profile_id,
1180
+ }, null, 2))
1181
+
1182
+ this._previousConnectionId = phoneRecord.connection_id || null
1183
+ this.state.set('phoneNumberId', phoneRecord.id)
1184
+
1185
+ const texmlAppId = telnyxAssistant.telephony_settings?.default_texml_app_id
1186
+ if (!texmlAppId) {
1187
+ throw new Error('Telnyx assistant did not create a TeXML app — is telephony enabled?')
1188
+ }
1189
+
1190
+ this._log('[telnyx] Wiring voice connection_id:', texmlAppId)
1191
+ await client.phoneNumbers.update(phoneRecord.id, {
1192
+ connection_id: texmlAppId,
1193
+ })
1194
+
1195
+ if (this._messagingProfileId) {
1196
+ this._log('[telnyx] Wiring messaging_profile_id:', this._messagingProfileId)
1197
+ await client.phoneNumbers.messaging.update(phoneRecord.id, {
1198
+ messaging_profile_id: this._messagingProfileId,
1199
+ })
1200
+ } else {
1201
+ this._log('[telnyx] WARNING: No messaging profile available — SMS will not work')
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ export default TelnyxAssistantConnector