@gonzih/meet-the-one-ai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/.env.example +41 -0
  2. package/.node-version +1 -0
  3. package/basis/BERNAYS.md +233 -0
  4. package/basis/FOUNDING_TRANSCRIPT.md +218 -0
  5. package/basis/TECH_SPEC.md +303 -0
  6. package/basis/VALS.md +255 -0
  7. package/basis/layers/L1_IDENTITY_AUTH.md +78 -0
  8. package/basis/layers/L2_CONVERSATION.md +159 -0
  9. package/basis/layers/L3_RECORDING_STORE.md +104 -0
  10. package/basis/layers/L4_ANALYSIS_PIPELINE.md +257 -0
  11. package/basis/layers/L5_MATCHING_ENGINE.md +164 -0
  12. package/basis/layers/L6_CONSENT_INTRODUCTION.md +143 -0
  13. package/basis/layers/L7_PORTABLE_IDENTITY.md +139 -0
  14. package/basis/layers/STACK.md +64 -0
  15. package/basis/schema.sql +203 -0
  16. package/dist/agent.d.ts +2 -0
  17. package/dist/agent.d.ts.map +1 -0
  18. package/dist/agent.js +114 -0
  19. package/dist/agent.js.map +1 -0
  20. package/dist/api/routes/auth.d.ts +2 -0
  21. package/dist/api/routes/auth.d.ts.map +1 -0
  22. package/dist/api/routes/auth.js +79 -0
  23. package/dist/api/routes/auth.js.map +1 -0
  24. package/dist/api/routes/identity.d.ts +2 -0
  25. package/dist/api/routes/identity.d.ts.map +1 -0
  26. package/dist/api/routes/identity.js +92 -0
  27. package/dist/api/routes/identity.js.map +1 -0
  28. package/dist/api/routes/text-submission.d.ts +2 -0
  29. package/dist/api/routes/text-submission.d.ts.map +1 -0
  30. package/dist/api/routes/text-submission.js +56 -0
  31. package/dist/api/routes/text-submission.js.map +1 -0
  32. package/dist/api/webhooks/twilio.d.ts +2 -0
  33. package/dist/api/webhooks/twilio.d.ts.map +1 -0
  34. package/dist/api/webhooks/twilio.js +144 -0
  35. package/dist/api/webhooks/twilio.js.map +1 -0
  36. package/dist/api/webhooks/vapi.d.ts +2 -0
  37. package/dist/api/webhooks/vapi.d.ts.map +1 -0
  38. package/dist/api/webhooks/vapi.js +177 -0
  39. package/dist/api/webhooks/vapi.js.map +1 -0
  40. package/dist/bot.d.ts +3 -0
  41. package/dist/bot.d.ts.map +1 -0
  42. package/dist/bot.js +39 -0
  43. package/dist/bot.js.map +1 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +9 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/jobs/compact-identity.d.ts +2 -0
  49. package/dist/jobs/compact-identity.d.ts.map +1 -0
  50. package/dist/jobs/compact-identity.js +159 -0
  51. package/dist/jobs/compact-identity.js.map +1 -0
  52. package/dist/jobs/consent-call.d.ts +2 -0
  53. package/dist/jobs/consent-call.d.ts.map +1 -0
  54. package/dist/jobs/consent-call.js +70 -0
  55. package/dist/jobs/consent-call.js.map +1 -0
  56. package/dist/jobs/export-identity.d.ts +2 -0
  57. package/dist/jobs/export-identity.d.ts.map +1 -0
  58. package/dist/jobs/export-identity.js +129 -0
  59. package/dist/jobs/export-identity.js.map +1 -0
  60. package/dist/jobs/introduction-call.d.ts +2 -0
  61. package/dist/jobs/introduction-call.d.ts.map +1 -0
  62. package/dist/jobs/introduction-call.js +86 -0
  63. package/dist/jobs/introduction-call.js.map +1 -0
  64. package/dist/jobs/reanalyze-identity.d.ts +2 -0
  65. package/dist/jobs/reanalyze-identity.d.ts.map +1 -0
  66. package/dist/jobs/reanalyze-identity.js +56 -0
  67. package/dist/jobs/reanalyze-identity.js.map +1 -0
  68. package/dist/jobs/run-matching.d.ts +2 -0
  69. package/dist/jobs/run-matching.d.ts.map +1 -0
  70. package/dist/jobs/run-matching.js +200 -0
  71. package/dist/jobs/run-matching.js.map +1 -0
  72. package/dist/jobs/scheduled-matching.d.ts +2 -0
  73. package/dist/jobs/scheduled-matching.d.ts.map +1 -0
  74. package/dist/jobs/scheduled-matching.js +44 -0
  75. package/dist/jobs/scheduled-matching.js.map +1 -0
  76. package/dist/jobs/transcribe-session.d.ts +2 -0
  77. package/dist/jobs/transcribe-session.d.ts.map +1 -0
  78. package/dist/jobs/transcribe-session.js +66 -0
  79. package/dist/jobs/transcribe-session.js.map +1 -0
  80. package/dist/lib/anthropic.d.ts +4 -0
  81. package/dist/lib/anthropic.d.ts.map +1 -0
  82. package/dist/lib/anthropic.js +32 -0
  83. package/dist/lib/anthropic.js.map +1 -0
  84. package/dist/lib/config.d.ts +57 -0
  85. package/dist/lib/config.d.ts.map +1 -0
  86. package/dist/lib/config.js +73 -0
  87. package/dist/lib/config.js.map +1 -0
  88. package/dist/lib/deepgram.d.ts +15 -0
  89. package/dist/lib/deepgram.d.ts.map +1 -0
  90. package/dist/lib/deepgram.js +37 -0
  91. package/dist/lib/deepgram.js.map +1 -0
  92. package/dist/lib/inngest.d.ts +42 -0
  93. package/dist/lib/inngest.d.ts.map +1 -0
  94. package/dist/lib/inngest.js +7 -0
  95. package/dist/lib/inngest.js.map +1 -0
  96. package/dist/lib/openai.d.ts +3 -0
  97. package/dist/lib/openai.d.ts.map +1 -0
  98. package/dist/lib/openai.js +13 -0
  99. package/dist/lib/openai.js.map +1 -0
  100. package/dist/lib/prompts.d.ts +8 -0
  101. package/dist/lib/prompts.d.ts.map +1 -0
  102. package/dist/lib/prompts.js +258 -0
  103. package/dist/lib/prompts.js.map +1 -0
  104. package/dist/lib/r2.d.ts +7 -0
  105. package/dist/lib/r2.d.ts.map +1 -0
  106. package/dist/lib/r2.js +49 -0
  107. package/dist/lib/r2.js.map +1 -0
  108. package/dist/lib/session-helpers.d.ts +8 -0
  109. package/dist/lib/session-helpers.d.ts.map +1 -0
  110. package/dist/lib/session-helpers.js +31 -0
  111. package/dist/lib/session-helpers.js.map +1 -0
  112. package/dist/lib/supabase.d.ts +2 -0
  113. package/dist/lib/supabase.d.ts.map +1 -0
  114. package/dist/lib/supabase.js +11 -0
  115. package/dist/lib/supabase.js.map +1 -0
  116. package/dist/lib/twilio.d.ts +7 -0
  117. package/dist/lib/twilio.d.ts.map +1 -0
  118. package/dist/lib/twilio.js +34 -0
  119. package/dist/lib/twilio.js.map +1 -0
  120. package/dist/lib/vapi.d.ts +4 -0
  121. package/dist/lib/vapi.d.ts.map +1 -0
  122. package/dist/lib/vapi.js +59 -0
  123. package/dist/lib/vapi.js.map +1 -0
  124. package/dist/mcp-server.d.ts +3 -0
  125. package/dist/mcp-server.d.ts.map +1 -0
  126. package/dist/mcp-server.js +177 -0
  127. package/dist/mcp-server.js.map +1 -0
  128. package/dist/types/index.d.ts +104 -0
  129. package/dist/types/index.d.ts.map +1 -0
  130. package/dist/types/index.js +3 -0
  131. package/dist/types/index.js.map +1 -0
  132. package/package.json +28 -0
  133. package/railway.json +14 -0
  134. package/src/agent.ts +123 -0
  135. package/src/api/routes/auth.ts +95 -0
  136. package/src/api/routes/identity.ts +112 -0
  137. package/src/api/routes/text-submission.ts +64 -0
  138. package/src/api/webhooks/twilio.ts +181 -0
  139. package/src/api/webhooks/vapi.ts +219 -0
  140. package/src/bot.ts +44 -0
  141. package/src/index.ts +11 -0
  142. package/src/jobs/compact-identity.ts +211 -0
  143. package/src/jobs/consent-call.ts +87 -0
  144. package/src/jobs/export-identity.ts +166 -0
  145. package/src/jobs/introduction-call.ts +101 -0
  146. package/src/jobs/reanalyze-identity.ts +65 -0
  147. package/src/jobs/run-matching.ts +243 -0
  148. package/src/jobs/scheduled-matching.ts +59 -0
  149. package/src/jobs/transcribe-session.ts +77 -0
  150. package/src/lib/anthropic.ts +37 -0
  151. package/src/lib/config.ts +81 -0
  152. package/src/lib/deepgram.ts +57 -0
  153. package/src/lib/inngest.ts +33 -0
  154. package/src/lib/openai.ts +14 -0
  155. package/src/lib/prompts.ts +266 -0
  156. package/src/lib/r2.ts +79 -0
  157. package/src/lib/session-helpers.ts +37 -0
  158. package/src/lib/supabase.ts +15 -0
  159. package/src/lib/twilio.ts +49 -0
  160. package/src/lib/vapi.ts +80 -0
  161. package/src/mcp-server.ts +195 -0
  162. package/src/types/index.ts +146 -0
  163. package/supabase/.branches/_current_branch +1 -0
  164. package/supabase/.temp/cli-latest +1 -0
  165. package/supabase/.temp/gotrue-version +1 -0
  166. package/supabase/.temp/pooler-url +1 -0
  167. package/supabase/.temp/postgres-version +1 -0
  168. package/supabase/.temp/project-ref +1 -0
  169. package/supabase/.temp/rest-version +1 -0
  170. package/supabase/.temp/storage-migration +1 -0
  171. package/supabase/.temp/storage-version +1 -0
  172. package/supabase/config.toml +384 -0
  173. package/supabase/migrations/20260303000000_initial_schema.sql +203 -0
  174. package/supabase/migrations/20260304000000_brand_consents.sql +13 -0
  175. package/tsconfig.json +25 -0
@@ -0,0 +1,112 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { supabase } from "../../lib/supabase.js";
4
+ import { inngest } from "../../lib/inngest.js";
5
+
6
+ export const identity_router = Router();
7
+
8
+ const UserParams = z.object({ user_id: z.string().uuid() });
9
+
10
+ // GET /api/identity/:user_id/export
11
+ // Triggers async export — user gets SMS when ready
12
+ identity_router.get("/:user_id/export", async (req, res) => {
13
+ const parsed = UserParams.safeParse(req.params);
14
+ if (!parsed.success) {
15
+ res.status(400).json({ error: "Invalid user_id" });
16
+ return;
17
+ }
18
+
19
+ const { user_id } = parsed.data;
20
+
21
+ // Verify user exists and has an identity
22
+ const { data: identity } = await supabase
23
+ .from("identities")
24
+ .select("signal_completeness_score, session_count")
25
+ .eq("user_id", user_id)
26
+ .single();
27
+
28
+ if (!identity) {
29
+ res.status(404).json({ error: "No identity found — complete at least one session first" });
30
+ return;
31
+ }
32
+
33
+ await inngest.send({ name: "identity/export", data: { user_id } });
34
+
35
+ res.json({
36
+ ok: true,
37
+ message: "Export started — you'll receive a download link via SMS shortly",
38
+ completeness: identity.signal_completeness_score,
39
+ sessions: identity.session_count,
40
+ });
41
+ });
42
+
43
+ // GET /api/identity/:user_id
44
+ // Returns current identity profile (for web portal)
45
+ identity_router.get("/:user_id", async (req, res) => {
46
+ const parsed = UserParams.safeParse(req.params);
47
+ if (!parsed.success) {
48
+ res.status(400).json({ error: "Invalid user_id" });
49
+ return;
50
+ }
51
+
52
+ const { data, error } = await supabase
53
+ .from("identities")
54
+ .select("base_profile, modality_weights, signal_completeness_score, session_count, ready_for_matching, last_updated")
55
+ .eq("user_id", parsed.data.user_id)
56
+ .single();
57
+
58
+ if (error || !data) {
59
+ res.status(404).json({ error: "Identity not found" });
60
+ return;
61
+ }
62
+
63
+ res.json(data);
64
+ });
65
+
66
+ // DELETE /api/identity/:user_id
67
+ // GDPR hard delete — removes all user data
68
+ identity_router.delete("/:user_id", async (req, res) => {
69
+ const parsed = UserParams.safeParse(req.params);
70
+ if (!parsed.success) {
71
+ res.status(400).json({ error: "Invalid user_id" });
72
+ return;
73
+ }
74
+
75
+ const { user_id } = parsed.data;
76
+
77
+ // Verify user exists
78
+ const { data: user } = await supabase
79
+ .from("users")
80
+ .select("id")
81
+ .eq("id", user_id)
82
+ .single();
83
+
84
+ if (!user) {
85
+ res.status(404).json({ error: "User not found" });
86
+ return;
87
+ }
88
+
89
+ // Cascade delete — all child records deleted via ON DELETE CASCADE:
90
+ // sessions, identities, identity_modality_embeddings, matches (partial — see note)
91
+ // matches where user is user_b won't cascade — delete explicitly
92
+ await Promise.all([
93
+ supabase.from("matches").delete().eq("user_a_id", user_id),
94
+ supabase.from("matches").delete().eq("user_b_id", user_id),
95
+ supabase.from("declined_pairs").delete().eq("user_a_id", user_id),
96
+ supabase.from("declined_pairs").delete().eq("user_b_id", user_id),
97
+ ]);
98
+
99
+ // Delete user — cascades to sessions, identities, embeddings
100
+ const { error } = await supabase.from("users").delete().eq("id", user_id);
101
+
102
+ if (error) {
103
+ console.error("GDPR delete error", error);
104
+ res.status(500).json({ error: "Deletion failed — contact support" });
105
+ return;
106
+ }
107
+
108
+ // Note: R2 audio/transcript files are NOT deleted here — add async cleanup job
109
+ // for production. R2 lifecycle rules can auto-expire after 30 days.
110
+
111
+ res.json({ ok: true, message: "All data deleted" });
112
+ });
@@ -0,0 +1,64 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { supabase } from "../../lib/supabase.js";
4
+ import { inngest } from "../../lib/inngest.js";
5
+ import { create_session } from "../../lib/session-helpers.js";
6
+
7
+ export const text_submission_router = Router();
8
+
9
+ const TextBody = z.object({
10
+ user_id: z.string().uuid(),
11
+ text: z.string().min(10).max(10000),
12
+ });
13
+
14
+ // POST /api/session/text
15
+ // Accepts a free-text submission (alternative to voice intake)
16
+ text_submission_router.post("/text", async (req, res) => {
17
+ const parsed = TextBody.safeParse(req.body);
18
+ if (!parsed.success) {
19
+ res.status(400).json({ error: parsed.error.flatten() });
20
+ return;
21
+ }
22
+
23
+ const { user_id, text } = parsed.data;
24
+
25
+ // Verify user exists
26
+ const { data: user, error: user_error } = await supabase
27
+ .from("users")
28
+ .select("id")
29
+ .eq("id", user_id)
30
+ .single();
31
+
32
+ if (user_error || !user) {
33
+ res.status(404).json({ error: "User not found" });
34
+ return;
35
+ }
36
+
37
+ let session_id: string;
38
+ try {
39
+ session_id = await create_session(user_id, "text", { raw_text: text });
40
+ } catch (err) {
41
+ console.error("create_session error", err);
42
+ res.status(500).json({ error: "Failed to create session" });
43
+ return;
44
+ }
45
+
46
+ // Fire Inngest event — triggers compact-identity pipeline
47
+ // Text sessions skip transcription and go straight to analysis
48
+ await inngest.send({
49
+ name: "session/transcribed",
50
+ data: {
51
+ user_id,
52
+ session_id,
53
+ transcript_r2_key: "", // empty — compact-identity reads raw_text instead
54
+ },
55
+ });
56
+
57
+ // Update last_active
58
+ await supabase
59
+ .from("users")
60
+ .update({ last_active: new Date().toISOString() })
61
+ .eq("id", user_id);
62
+
63
+ res.json({ ok: true, session_id });
64
+ });
@@ -0,0 +1,181 @@
1
+ import { Router } from "express";
2
+ import { supabase } from "../../lib/supabase.js";
3
+ import { inngest } from "../../lib/inngest.js";
4
+ import { normalize_phone, send_sms } from "../../lib/twilio.js";
5
+
6
+ export const twilio_webhook_router = Router();
7
+
8
+ // POST /api/webhooks/twilio/sms
9
+ // Handles inbound SMS — consent responses and export triggers
10
+ twilio_webhook_router.post("/sms", async (req, res) => {
11
+ // Twilio sends form-encoded body
12
+ const from: string = req.body.From ?? "";
13
+ const body: string = (req.body.Body ?? "").trim();
14
+
15
+ // Always respond with empty TwiML to suppress Twilio error
16
+ res.set("Content-Type", "text/xml");
17
+ res.send(`<?xml version="1.0" encoding="UTF-8"?><Response></Response>`);
18
+
19
+ if (!from || !body) return;
20
+
21
+ const phone = normalize_phone(from);
22
+ const text = body.toUpperCase();
23
+
24
+ try {
25
+ await handle_sms(phone, text, body);
26
+ } catch (err) {
27
+ console.error("twilio sms webhook error", err);
28
+ }
29
+ });
30
+
31
+ // POST /api/webhooks/twilio/conference
32
+ // Status callback when conference ends — no action needed yet
33
+ twilio_webhook_router.post("/conference", (_req, res) => {
34
+ res.sendStatus(204);
35
+ });
36
+
37
+ async function handle_sms(phone: string, text: string, raw_body: string) {
38
+ // Look up user by phone
39
+ const { data: user } = await supabase
40
+ .from("users")
41
+ .select("id, phone, brand")
42
+ .eq("phone", phone)
43
+ .single();
44
+
45
+ if (!user) {
46
+ console.warn(`Inbound SMS from unknown phone: ${phone}`);
47
+ return;
48
+ }
49
+
50
+ // ── EXPORT trigger ────────────────────────────────────────────────────────
51
+ if (text === "EXPORT" || text === "EXPORT IDENTITY" || text === "SEND ME MY IDENTITY") {
52
+ await inngest.send({
53
+ name: "identity/export",
54
+ data: { user_id: user.id },
55
+ });
56
+ await send_sms(
57
+ phone,
58
+ "Preparing your identity export — you'll receive a download link shortly.",
59
+ user.brand as Parameters<typeof send_sms>[2]
60
+ );
61
+ return;
62
+ }
63
+
64
+ // ── DELETE trigger ────────────────────────────────────────────────────────
65
+ if (text === "DELETE MY DATA" || text === "DELETE") {
66
+ // Fire deletion via API — use internal fetch to keep logic in one place
67
+ // In production replace with direct service call
68
+ await supabase.from("users").delete().eq("id", user.id);
69
+ // Note: cascades handle sessions, identities, embeddings
70
+ // matches deleted separately
71
+ await Promise.all([
72
+ supabase.from("matches").delete().eq("user_a_id", user.id),
73
+ supabase.from("matches").delete().eq("user_b_id", user.id),
74
+ supabase.from("declined_pairs").delete().eq("user_a_id", user.id),
75
+ supabase.from("declined_pairs").delete().eq("user_b_id", user.id),
76
+ ]);
77
+ // Can't send SMS after deletion since user no longer exists — no reply needed
78
+ return;
79
+ }
80
+
81
+ // ── Consent responses (ACCEPT / DECLINE with match reference) ────────────
82
+ // Format: "ACCEPT [match:uuid]" or just "ACCEPT" (applies to oldest pending)
83
+ const match_ref = raw_body.match(/\[match:([a-f0-9-]{36})\]/i);
84
+ const match_id_from_msg = match_ref?.[1];
85
+
86
+ if (text.startsWith("ACCEPT") || text.startsWith("YES")) {
87
+ await handle_consent_sms(user.id, "accepted", match_id_from_msg ?? null);
88
+ return;
89
+ }
90
+
91
+ if (text.startsWith("DECLINE") || text.startsWith("NO") || text.startsWith("PASS")) {
92
+ await handle_consent_sms(user.id, "declined", match_id_from_msg ?? null);
93
+ return;
94
+ }
95
+
96
+ // Unknown — send help
97
+ await send_sms(
98
+ phone,
99
+ "Reply ACCEPT or DECLINE to respond to a match. Reply EXPORT to get your identity file.",
100
+ user.brand as Parameters<typeof send_sms>[2]
101
+ );
102
+ }
103
+
104
+ async function handle_consent_sms(
105
+ user_id: string,
106
+ decision: "accepted" | "declined",
107
+ explicit_match_id: string | null
108
+ ) {
109
+ // Find the relevant pending match
110
+ let match_id = explicit_match_id;
111
+
112
+ if (!match_id) {
113
+ // Find oldest pending_consent match for this user
114
+ const { data } = await supabase
115
+ .from("matches")
116
+ .select("id, user_a_id, user_b_id, status")
117
+ .or(`user_a_id.eq.${user_id},user_b_id.eq.${user_id}`)
118
+ .eq("status", "pending_consent")
119
+ .order("created_at", { ascending: true })
120
+ .limit(1)
121
+ .single();
122
+
123
+ if (!data) return; // no pending match
124
+ match_id = data.id;
125
+ }
126
+
127
+ const { data: match } = await supabase
128
+ .from("matches")
129
+ .select("id, user_a_id, user_b_id, status")
130
+ .eq("id", match_id)
131
+ .single();
132
+
133
+ if (!match) return;
134
+
135
+ const is_user_a = user_id === match.user_a_id;
136
+
137
+ if (decision === "declined") {
138
+ await supabase.from("declined_pairs").upsert(
139
+ {
140
+ user_a_id: match.user_a_id,
141
+ user_b_id: match.user_b_id,
142
+ declined_by: user_id,
143
+ reason: "sms_declined",
144
+ created_at: new Date().toISOString(),
145
+ },
146
+ { onConflict: "user_a_id,user_b_id" }
147
+ );
148
+
149
+ await supabase
150
+ .from("matches")
151
+ .update({
152
+ status: is_user_a ? "a_declined" : "b_declined",
153
+ updated_at: new Date().toISOString(),
154
+ })
155
+ .eq("id", match_id);
156
+
157
+ return;
158
+ }
159
+
160
+ // accepted
161
+ const current_status = match.status;
162
+ let new_status: string;
163
+
164
+ if (is_user_a) {
165
+ new_status = current_status === "b_accepted" ? "both_accepted" : "a_accepted";
166
+ } else {
167
+ new_status = current_status === "a_accepted" ? "both_accepted" : "b_accepted";
168
+ }
169
+
170
+ await supabase
171
+ .from("matches")
172
+ .update({ status: new_status, updated_at: new Date().toISOString() })
173
+ .eq("id", match_id);
174
+
175
+ if (new_status === "both_accepted") {
176
+ await inngest.send({
177
+ name: "match/both-accepted",
178
+ data: { match_id },
179
+ });
180
+ }
181
+ }
@@ -0,0 +1,219 @@
1
+ import { Router } from "express";
2
+ import { supabase } from "../../lib/supabase.js";
3
+ import { inngest } from "../../lib/inngest.js";
4
+ import { upload_buffer } from "../../lib/r2.js";
5
+
6
+ export const vapi_webhook_router = Router();
7
+
8
+ // POST /api/webhooks/vapi
9
+ vapi_webhook_router.post("/", async (req, res) => {
10
+ const event = req.body as VapiEvent;
11
+ res.json({ ok: true });
12
+ try {
13
+ await handle_vapi_event(event);
14
+ } catch (err) {
15
+ console.error("vapi webhook handler error", err);
16
+ }
17
+ });
18
+
19
+ async function handle_vapi_event(event: VapiEvent) {
20
+ switch (event.message?.type) {
21
+ case "end-of-call-report":
22
+ await handle_call_ended(event);
23
+ break;
24
+ default:
25
+ break;
26
+ }
27
+ }
28
+
29
+ async function handle_call_ended(event: VapiEvent) {
30
+ const { call, recordingUrl, durationSeconds } = event.message ?? {};
31
+ if (!call?.id) return;
32
+
33
+ const vars = call.assistantOverrides?.variableValues ?? {};
34
+ const user_id = vars.user_id as string | undefined;
35
+ const match_id = vars.match_id as string | undefined;
36
+
37
+ // ── Consent call outcome ──────────────────────────────────────────────────
38
+ if (match_id) {
39
+ await handle_consent_outcome(call, match_id);
40
+ return;
41
+ }
42
+
43
+ // ── Intake call — create session ─────────────────────────────────────────
44
+ if (!user_id) {
45
+ console.warn("vapi call ended but no user_id or match_id in variableValues", call.id);
46
+ return;
47
+ }
48
+
49
+ const { data: session, error } = await supabase
50
+ .from("sessions")
51
+ .insert({
52
+ user_id,
53
+ source: "vapi",
54
+ duration_seconds: durationSeconds ? Math.round(durationSeconds) : null,
55
+ analysis_status: "pending",
56
+ })
57
+ .select("id")
58
+ .single();
59
+
60
+ if (error || !session) {
61
+ console.error("Failed to create session for vapi call", error);
62
+ return;
63
+ }
64
+
65
+ const session_id = session.id;
66
+
67
+ if (recordingUrl) {
68
+ try {
69
+ const audio_res = await fetch(recordingUrl);
70
+ const audio_buf = Buffer.from(await audio_res.arrayBuffer());
71
+ const audio_r2_key = `audio/${user_id}/${session_id}.wav`;
72
+
73
+ await upload_buffer(audio_r2_key, audio_buf, "audio/wav");
74
+ await supabase.from("sessions").update({ audio_r2_key }).eq("id", session_id);
75
+
76
+ await inngest.send({
77
+ name: "session/completed",
78
+ data: { user_id, session_id },
79
+ });
80
+ } catch (err) {
81
+ console.error("Failed to store audio for session", session_id, err);
82
+ }
83
+ } else {
84
+ const transcript_text = call.transcript ?? "";
85
+ if (transcript_text) {
86
+ await supabase.from("sessions").update({ raw_text: transcript_text }).eq("id", session_id);
87
+ await inngest.send({
88
+ name: "session/transcribed",
89
+ data: { user_id, session_id, transcript_r2_key: "" },
90
+ });
91
+ }
92
+ }
93
+
94
+ await supabase
95
+ .from("users")
96
+ .update({ last_active: new Date().toISOString() })
97
+ .eq("id", user_id);
98
+ }
99
+
100
+ async function handle_consent_outcome(
101
+ call: NonNullable<VapiEvent["message"]>["call"],
102
+ match_id: string
103
+ ) {
104
+ if (!call) return;
105
+
106
+ // Vapi assistant sets call.summary or transcript to indicate decision
107
+ // Convention: assistant ends call with structured summary JSON
108
+ // e.g. {"decision": "accepted"} or {"decision": "declined"}
109
+ const transcript = call.transcript ?? "";
110
+ const summary = call.summary ?? "";
111
+
112
+ const decision = extract_decision(summary || transcript);
113
+ if (!decision) {
114
+ console.warn(`No consent decision found for match ${match_id}, call ${call.id}`);
115
+ return;
116
+ }
117
+
118
+ // Load match to determine which user this call was for
119
+ const { data: match, error } = await supabase
120
+ .from("matches")
121
+ .select("id, user_a_id, user_b_id, status")
122
+ .eq("id", match_id)
123
+ .single();
124
+
125
+ if (error || !match) {
126
+ console.error(`Match not found for consent outcome: ${match_id}`);
127
+ return;
128
+ }
129
+
130
+ // Identify which user answered (match call.id to stored call reference — simplified:
131
+ // use variableValues.user_id if present, else infer from status)
132
+ const vars = call.assistantOverrides?.variableValues ?? {};
133
+ const answering_user_id = vars.user_id as string | undefined;
134
+
135
+ if (!answering_user_id) {
136
+ console.warn(`Cannot identify consenting user for match ${match_id}`);
137
+ return;
138
+ }
139
+
140
+ const is_user_a = answering_user_id === match.user_a_id;
141
+
142
+ if (decision === "declined") {
143
+ // Record decline — insert to declined_pairs and update match
144
+ await supabase.from("declined_pairs").upsert(
145
+ {
146
+ user_a_id: match.user_a_id,
147
+ user_b_id: match.user_b_id,
148
+ declined_by: answering_user_id,
149
+ reason: "consent_call_declined",
150
+ created_at: new Date().toISOString(),
151
+ },
152
+ { onConflict: "user_a_id,user_b_id" }
153
+ );
154
+
155
+ const new_status = is_user_a ? "a_declined" : "b_declined";
156
+ await supabase
157
+ .from("matches")
158
+ .update({ status: new_status, updated_at: new Date().toISOString() })
159
+ .eq("id", match_id);
160
+
161
+ return;
162
+ }
163
+
164
+ // decision === "accepted"
165
+ const current_status = match.status;
166
+ let new_status: string;
167
+
168
+ if (is_user_a) {
169
+ new_status = current_status === "b_accepted" ? "both_accepted" : "a_accepted";
170
+ } else {
171
+ new_status = current_status === "a_accepted" ? "both_accepted" : "b_accepted";
172
+ }
173
+
174
+ await supabase
175
+ .from("matches")
176
+ .update({ status: new_status, updated_at: new Date().toISOString() })
177
+ .eq("id", match_id);
178
+
179
+ if (new_status === "both_accepted") {
180
+ await inngest.send({
181
+ name: "match/both-accepted",
182
+ data: { match_id },
183
+ });
184
+ }
185
+ }
186
+
187
+ // Extract accept/decline from Vapi call summary or transcript
188
+ function extract_decision(text: string): "accepted" | "declined" | null {
189
+ // Try structured JSON first (Vapi assistant summary)
190
+ try {
191
+ const parsed = JSON.parse(text);
192
+ if (parsed.decision === "accepted") return "accepted";
193
+ if (parsed.decision === "declined") return "declined";
194
+ } catch {
195
+ // not JSON — fall through to keyword search
196
+ }
197
+
198
+ const lower = text.toLowerCase();
199
+ if (/\b(accept|yes|interested|connect)\b/.test(lower)) return "accepted";
200
+ if (/\b(decline|no|pass|not interested)\b/.test(lower)) return "declined";
201
+ return null;
202
+ }
203
+
204
+ // ─── Vapi event types ─────────────────────────────────────────────────────────
205
+ interface VapiEvent {
206
+ message?: {
207
+ type: string;
208
+ call?: {
209
+ id: string;
210
+ transcript?: string;
211
+ summary?: string;
212
+ assistantOverrides?: {
213
+ variableValues?: Record<string, unknown>;
214
+ };
215
+ };
216
+ recordingUrl?: string;
217
+ durationSeconds?: number;
218
+ };
219
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { Telegraf } from "telegraf";
2
+ import { sendMessage } from "./agent.js";
3
+
4
+ const TOKEN = process.env.TELEGRAM_BOT_TOKEN;
5
+
6
+ export function createBot(): Telegraf {
7
+ if (!TOKEN) throw new Error("TELEGRAM_BOT_TOKEN env var is required");
8
+
9
+ const bot = new Telegraf(TOKEN);
10
+
11
+ bot.start(async (ctx) => {
12
+ const userId = String(ctx.from.id);
13
+
14
+ await ctx.reply(
15
+ "Welcome to Meet The One AI! I'm your personal AI matchmaker.\n\n" +
16
+ "I'll help you find someone truly compatible — not just based on looks, " +
17
+ "but on values, goals, and what matters most to you.\n\n" +
18
+ "Let's start by getting to know you. What's your name?"
19
+ );
20
+
21
+ try {
22
+ const response = await sendMessage(userId, "I just joined. Please help me set up my profile.");
23
+ if (response) await ctx.reply(response);
24
+ } catch (err) {
25
+ console.error(`[bot] /start error for ${userId}:`, err);
26
+ }
27
+ });
28
+
29
+ bot.on("text", async (ctx) => {
30
+ const userId = String(ctx.from.id);
31
+
32
+ try {
33
+ const response = await sendMessage(userId, ctx.message.text);
34
+ if (response) await ctx.reply(response);
35
+ } catch (err) {
36
+ console.error(`[bot] message error for ${userId}:`, err);
37
+ await ctx
38
+ .reply("I ran into a technical issue. Please try again in a moment.")
39
+ .catch(() => {});
40
+ }
41
+ });
42
+
43
+ return bot;
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import "dotenv/config";
2
+ import { createBot } from "./bot.js";
3
+
4
+ const bot = createBot();
5
+
6
+ bot.launch().then(() => {
7
+ console.log("meet-the-one-ai is running...");
8
+ });
9
+
10
+ process.once("SIGTERM", () => bot.stop("SIGTERM"));
11
+ process.once("SIGINT", () => bot.stop("SIGINT"));