@data-club/ai-hub-server 0.0.1

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 (248) hide show
  1. package/dist/auth/argon2.d.ts +72 -0
  2. package/dist/auth/argon2.d.ts.map +1 -0
  3. package/dist/auth/argon2.js +145 -0
  4. package/dist/auth/argon2.js.map +1 -0
  5. package/dist/auth/encryption.d.ts +52 -0
  6. package/dist/auth/encryption.d.ts.map +1 -0
  7. package/dist/auth/encryption.js +98 -0
  8. package/dist/auth/encryption.js.map +1 -0
  9. package/dist/auth/jwt.d.ts +133 -0
  10. package/dist/auth/jwt.d.ts.map +1 -0
  11. package/dist/auth/jwt.js +147 -0
  12. package/dist/auth/jwt.js.map +1 -0
  13. package/dist/auth/middleware.d.ts +101 -0
  14. package/dist/auth/middleware.d.ts.map +1 -0
  15. package/dist/auth/middleware.js +161 -0
  16. package/dist/auth/middleware.js.map +1 -0
  17. package/dist/auth/passwordPolicy.d.ts +72 -0
  18. package/dist/auth/passwordPolicy.d.ts.map +1 -0
  19. package/dist/auth/passwordPolicy.js +88 -0
  20. package/dist/auth/passwordPolicy.js.map +1 -0
  21. package/dist/auth/roleRules.d.ts +200 -0
  22. package/dist/auth/roleRules.d.ts.map +1 -0
  23. package/dist/auth/roleRules.js +365 -0
  24. package/dist/auth/roleRules.js.map +1 -0
  25. package/dist/auth/userView.d.ts +55 -0
  26. package/dist/auth/userView.d.ts.map +1 -0
  27. package/dist/auth/userView.js +77 -0
  28. package/dist/auth/userView.js.map +1 -0
  29. package/dist/bootstrap.d.ts +104 -0
  30. package/dist/bootstrap.d.ts.map +1 -0
  31. package/dist/bootstrap.js +160 -0
  32. package/dist/bootstrap.js.map +1 -0
  33. package/dist/db/connection.d.ts +39 -0
  34. package/dist/db/connection.d.ts.map +1 -0
  35. package/dist/db/connection.js +62 -0
  36. package/dist/db/connection.js.map +1 -0
  37. package/dist/db/migrate.d.ts +31 -0
  38. package/dist/db/migrate.d.ts.map +1 -0
  39. package/dist/db/migrate.js +197 -0
  40. package/dist/db/migrate.js.map +1 -0
  41. package/dist/db/schema.d.ts +1902 -0
  42. package/dist/db/schema.d.ts.map +1 -0
  43. package/dist/db/schema.js +662 -0
  44. package/dist/db/schema.js.map +1 -0
  45. package/dist/db/seed.d.ts +92 -0
  46. package/dist/db/seed.d.ts.map +1 -0
  47. package/dist/db/seed.js +160 -0
  48. package/dist/db/seed.js.map +1 -0
  49. package/dist/handlers/fetch.d.ts +89 -0
  50. package/dist/handlers/fetch.d.ts.map +1 -0
  51. package/dist/handlers/fetch.js +173 -0
  52. package/dist/handlers/fetch.js.map +1 -0
  53. package/dist/handlers/nextjs.d.ts +40 -0
  54. package/dist/handlers/nextjs.d.ts.map +1 -0
  55. package/dist/handlers/nextjs.js +42 -0
  56. package/dist/handlers/nextjs.js.map +1 -0
  57. package/dist/handlers/responses.d.ts +46 -0
  58. package/dist/handlers/responses.d.ts.map +1 -0
  59. package/dist/handlers/responses.js +74 -0
  60. package/dist/handlers/responses.js.map +1 -0
  61. package/dist/handlers/routeTable.d.ts +21 -0
  62. package/dist/handlers/routeTable.d.ts.map +1 -0
  63. package/dist/handlers/routeTable.js +133 -0
  64. package/dist/handlers/routeTable.js.map +1 -0
  65. package/dist/handlers/router.d.ts +37 -0
  66. package/dist/handlers/router.d.ts.map +1 -0
  67. package/dist/handlers/router.js +82 -0
  68. package/dist/handlers/router.js.map +1 -0
  69. package/dist/handlers/sse.d.ts +38 -0
  70. package/dist/handlers/sse.d.ts.map +1 -0
  71. package/dist/handlers/sse.js +52 -0
  72. package/dist/handlers/sse.js.map +1 -0
  73. package/dist/handlers/types.d.ts +96 -0
  74. package/dist/handlers/types.d.ts.map +1 -0
  75. package/dist/handlers/types.js +14 -0
  76. package/dist/handlers/types.js.map +1 -0
  77. package/dist/index.d.ts +25 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +24 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/letta/client.d.ts +355 -0
  82. package/dist/letta/client.d.ts.map +1 -0
  83. package/dist/letta/client.js +529 -0
  84. package/dist/letta/client.js.map +1 -0
  85. package/dist/letta/eventTranslator.d.ts +166 -0
  86. package/dist/letta/eventTranslator.d.ts.map +1 -0
  87. package/dist/letta/eventTranslator.js +532 -0
  88. package/dist/letta/eventTranslator.js.map +1 -0
  89. package/dist/letta/index.d.ts +13 -0
  90. package/dist/letta/index.d.ts.map +1 -0
  91. package/dist/letta/index.js +12 -0
  92. package/dist/letta/index.js.map +1 -0
  93. package/dist/letta/modelSettings.d.ts +176 -0
  94. package/dist/letta/modelSettings.d.ts.map +1 -0
  95. package/dist/letta/modelSettings.js +314 -0
  96. package/dist/letta/modelSettings.js.map +1 -0
  97. package/dist/letta/promptWrapping.d.ts +141 -0
  98. package/dist/letta/promptWrapping.d.ts.map +1 -0
  99. package/dist/letta/promptWrapping.js +213 -0
  100. package/dist/letta/promptWrapping.js.map +1 -0
  101. package/dist/letta/sandbox.d.ts +100 -0
  102. package/dist/letta/sandbox.d.ts.map +1 -0
  103. package/dist/letta/sandbox.js +151 -0
  104. package/dist/letta/sandbox.js.map +1 -0
  105. package/dist/letta/settingsFileSeed.d.ts +146 -0
  106. package/dist/letta/settingsFileSeed.d.ts.map +1 -0
  107. package/dist/letta/settingsFileSeed.js +177 -0
  108. package/dist/letta/settingsFileSeed.js.map +1 -0
  109. package/dist/letta/subprocessThrottle.d.ts +151 -0
  110. package/dist/letta/subprocessThrottle.d.ts.map +1 -0
  111. package/dist/letta/subprocessThrottle.js +195 -0
  112. package/dist/letta/subprocessThrottle.js.map +1 -0
  113. package/dist/letta/tpmRecovery.d.ts +202 -0
  114. package/dist/letta/tpmRecovery.d.ts.map +1 -0
  115. package/dist/letta/tpmRecovery.js +314 -0
  116. package/dist/letta/tpmRecovery.js.map +1 -0
  117. package/dist/letta/types.d.ts +54 -0
  118. package/dist/letta/types.d.ts.map +1 -0
  119. package/dist/letta/types.js +16 -0
  120. package/dist/letta/types.js.map +1 -0
  121. package/dist/observability/logger.d.ts +48 -0
  122. package/dist/observability/logger.d.ts.map +1 -0
  123. package/dist/observability/logger.js +81 -0
  124. package/dist/observability/logger.js.map +1 -0
  125. package/dist/observability/metrics.d.ts +53 -0
  126. package/dist/observability/metrics.d.ts.map +1 -0
  127. package/dist/observability/metrics.js +92 -0
  128. package/dist/observability/metrics.js.map +1 -0
  129. package/dist/routes/admin/agents.d.ts +63 -0
  130. package/dist/routes/admin/agents.d.ts.map +1 -0
  131. package/dist/routes/admin/agents.js +461 -0
  132. package/dist/routes/admin/agents.js.map +1 -0
  133. package/dist/routes/admin/rotatingMessages.d.ts +51 -0
  134. package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
  135. package/dist/routes/admin/rotatingMessages.js +292 -0
  136. package/dist/routes/admin/rotatingMessages.js.map +1 -0
  137. package/dist/routes/admin/sessions.d.ts +61 -0
  138. package/dist/routes/admin/sessions.d.ts.map +1 -0
  139. package/dist/routes/admin/sessions.js +232 -0
  140. package/dist/routes/admin/sessions.js.map +1 -0
  141. package/dist/routes/admin/settings.d.ts +110 -0
  142. package/dist/routes/admin/settings.d.ts.map +1 -0
  143. package/dist/routes/admin/settings.js +401 -0
  144. package/dist/routes/admin/settings.js.map +1 -0
  145. package/dist/routes/admin/users.d.ts +115 -0
  146. package/dist/routes/admin/users.d.ts.map +1 -0
  147. package/dist/routes/admin/users.js +374 -0
  148. package/dist/routes/admin/users.js.map +1 -0
  149. package/dist/routes/agents.d.ts +30 -0
  150. package/dist/routes/agents.d.ts.map +1 -0
  151. package/dist/routes/agents.js +55 -0
  152. package/dist/routes/agents.js.map +1 -0
  153. package/dist/routes/auth.d.ts +121 -0
  154. package/dist/routes/auth.d.ts.map +1 -0
  155. package/dist/routes/auth.js +422 -0
  156. package/dist/routes/auth.js.map +1 -0
  157. package/dist/routes/chat.d.ts +97 -0
  158. package/dist/routes/chat.d.ts.map +1 -0
  159. package/dist/routes/chat.js +484 -0
  160. package/dist/routes/chat.js.map +1 -0
  161. package/dist/routes/featureToggles.d.ts +34 -0
  162. package/dist/routes/featureToggles.d.ts.map +1 -0
  163. package/dist/routes/featureToggles.js +47 -0
  164. package/dist/routes/featureToggles.js.map +1 -0
  165. package/dist/routes/health.d.ts +24 -0
  166. package/dist/routes/health.d.ts.map +1 -0
  167. package/dist/routes/health.js +37 -0
  168. package/dist/routes/health.js.map +1 -0
  169. package/dist/routes/metrics.d.ts +35 -0
  170. package/dist/routes/metrics.d.ts.map +1 -0
  171. package/dist/routes/metrics.js +106 -0
  172. package/dist/routes/metrics.js.map +1 -0
  173. package/dist/routes/sessions.d.ts +161 -0
  174. package/dist/routes/sessions.d.ts.map +1 -0
  175. package/dist/routes/sessions.js +1501 -0
  176. package/dist/routes/sessions.js.map +1 -0
  177. package/dist/routes/stubs.d.ts +25 -0
  178. package/dist/routes/stubs.d.ts.map +1 -0
  179. package/dist/routes/stubs.js +25 -0
  180. package/dist/routes/stubs.js.map +1 -0
  181. package/dist/state/cron.d.ts +58 -0
  182. package/dist/state/cron.d.ts.map +1 -0
  183. package/dist/state/cron.js +160 -0
  184. package/dist/state/cron.js.map +1 -0
  185. package/dist/state/rotationRunner.d.ts +96 -0
  186. package/dist/state/rotationRunner.d.ts.map +1 -0
  187. package/dist/state/rotationRunner.js +246 -0
  188. package/dist/state/rotationRunner.js.map +1 -0
  189. package/dist/state/rotationScheduler.d.ts +76 -0
  190. package/dist/state/rotationScheduler.d.ts.map +1 -0
  191. package/dist/state/rotationScheduler.js +124 -0
  192. package/dist/state/rotationScheduler.js.map +1 -0
  193. package/dist/state/turnRegistry.d.ts +95 -0
  194. package/dist/state/turnRegistry.d.ts.map +1 -0
  195. package/dist/state/turnRegistry.js +149 -0
  196. package/dist/state/turnRegistry.js.map +1 -0
  197. package/dist/state/turnRunner.d.ts +134 -0
  198. package/dist/state/turnRunner.d.ts.map +1 -0
  199. package/dist/state/turnRunner.js +476 -0
  200. package/dist/state/turnRunner.js.map +1 -0
  201. package/package.json +47 -0
  202. package/src/auth/argon2.ts +152 -0
  203. package/src/auth/encryption.ts +110 -0
  204. package/src/auth/jwt.ts +217 -0
  205. package/src/auth/middleware.ts +250 -0
  206. package/src/auth/passwordPolicy.ts +99 -0
  207. package/src/auth/roleRules.ts +497 -0
  208. package/src/auth/userView.ts +88 -0
  209. package/src/bootstrap.ts +254 -0
  210. package/src/db/connection.ts +75 -0
  211. package/src/db/migrate.ts +212 -0
  212. package/src/db/schema.ts +750 -0
  213. package/src/db/seed.ts +236 -0
  214. package/src/handlers/fetch.ts +251 -0
  215. package/src/handlers/nextjs.ts +49 -0
  216. package/src/handlers/responses.ts +95 -0
  217. package/src/handlers/routeTable.ts +188 -0
  218. package/src/handlers/router.ts +96 -0
  219. package/src/handlers/sse.ts +55 -0
  220. package/src/handlers/types.ts +104 -0
  221. package/src/index.ts +60 -0
  222. package/src/letta/client.ts +860 -0
  223. package/src/letta/eventTranslator.ts +667 -0
  224. package/src/letta/index.ts +46 -0
  225. package/src/letta/lettaCliShim.mjs +40 -0
  226. package/src/letta/sandbox.ts +167 -0
  227. package/src/letta/settingsFileSeed.ts +254 -0
  228. package/src/letta/subprocessThrottle.ts +251 -0
  229. package/src/letta/types.ts +65 -0
  230. package/src/observability/logger.ts +103 -0
  231. package/src/observability/metrics.ts +114 -0
  232. package/src/routes/admin/agents.ts +574 -0
  233. package/src/routes/admin/rotatingMessages.ts +351 -0
  234. package/src/routes/admin/sessions.ts +283 -0
  235. package/src/routes/admin/settings.ts +463 -0
  236. package/src/routes/admin/users.ts +455 -0
  237. package/src/routes/agents.ts +66 -0
  238. package/src/routes/auth.ts +513 -0
  239. package/src/routes/chat.ts +534 -0
  240. package/src/routes/featureToggles.ts +52 -0
  241. package/src/routes/health.ts +44 -0
  242. package/src/routes/metrics.ts +111 -0
  243. package/src/routes/sessions.ts +1878 -0
  244. package/src/state/cron.ts +179 -0
  245. package/src/state/rotationRunner.ts +326 -0
  246. package/src/state/rotationScheduler.ts +164 -0
  247. package/src/state/turnRegistry.ts +210 -0
  248. package/src/state/turnRunner.ts +588 -0
@@ -0,0 +1,1501 @@
1
+ /**
2
+ * User-facing `/sessions/*` — the caller's own sessions (never cross-user).
3
+ *
4
+ * Step 4 ships the four CRUD endpoints minus DELETE (sessions are archive-only
5
+ * in v1 — §6.3.1). The Letta-conversation-create call on `POST /sessions` is
6
+ * stubbed; Step 5 replaces the placeholder with `client.conversations.create`.
7
+ *
8
+ * - `POST /sessions` — body `{ agent_id, title }`; title required (1..80
9
+ * chars). Creates a session owned by the caller against `agent_id`. The
10
+ * server snapshots `agent.letta_agent_id` into `session.letta_agent_id` and
11
+ * stores a placeholder `letta_conversation_id` (Step 5 fills the real one).
12
+ * Rejects when the agent is archived (`agent_archived`).
13
+ * - `GET /sessions?agent_id=...&include_archived=false` — caller-scoped list.
14
+ * `agent_id` is required; `include_archived` defaults to false.
15
+ * - `GET /sessions/:id` — single session if the caller owns it; 404 with no
16
+ * enumeration leakage otherwise.
17
+ * - `PATCH /sessions/:id` — body `{ title?, is_archived? }`. Rename and / or
18
+ * archive / unarchive. An empty patch is a 200 no-op.
19
+ * - `GET /sessions/:id/messages` — 501 stub; Step 5 wires
20
+ * `client.conversations.messages.list`.
21
+ *
22
+ * @see SPECIFICATIONS.md §6.3.1 — Sessions
23
+ * @see SPECIFICATIONS.md §11 — `session` table
24
+ * @see SPECIFICATIONS.md §13.2 — user isolation
25
+ */
26
+ import { randomUUID } from "node:crypto";
27
+ import { and, desc, eq } from "drizzle-orm";
28
+ import { z } from "zod";
29
+ import { protectedRoute } from "../auth/middleware.js";
30
+ import { agent, messageOverride, messageUsage, rotatingMessage, session, turn, } from "../db/schema.js";
31
+ import { conflictError, internalError, jsonResponse, notFound, validationError, } from "../handlers/responses.js";
32
+ import { ForkSourceNotFoundError } from "../letta/client.js";
33
+ import { materializeSandbox, purgeSandbox, resolveSandboxDir, resolveSeedDir, } from "../letta/sandbox.js";
34
+ // Letta's prompt compiler appends a trailing `<memory_metadata>…</memory_metadata>`
35
+ // block to every persisted `system_message.content` with agent id /
36
+ // conversation id / recall-message count. Useful for the LLM but noise
37
+ // for the chat-UI display, so we strip it before returning to the client.
38
+ const MEMORY_METADATA_REGEX = /\s*<memory_metadata>[\s\S]*?<\/memory_metadata>\s*$/;
39
+ function stripMemoryMetadata(content) {
40
+ return content.replace(MEMORY_METADATA_REGEX, "");
41
+ }
42
+ /* ────────────────────────────────────────────────────────────────────────── */
43
+ /* Wire shape */
44
+ /* ────────────────────────────────────────────────────────────────────────── */
45
+ /**
46
+ * Wire-format session row returned to the client. The Letta-side ids
47
+ * (`letta_conversation_id`, `letta_agent_id`) are server-internal and
48
+ * deliberately omitted — the React client only ever sees ai-hub ids.
49
+ *
50
+ * @param row - the `session` row.
51
+ * @returns the JSON-safe response shape.
52
+ */
53
+ function sessionView(row, extras) {
54
+ return {
55
+ id: row.id,
56
+ user_id: row.userId,
57
+ agent_id: row.agentId,
58
+ title: row.title,
59
+ is_archived: row.isArchived,
60
+ reasoning: {
61
+ effort: row.reasoningEffort,
62
+ verbosity: row.verbosity,
63
+ thinking_budget: row.thinkingBudget,
64
+ },
65
+ ...(extras?.capabilities !== undefined && { capabilities: extras.capabilities }),
66
+ created_at: row.createdAt,
67
+ updated_at: row.updatedAt,
68
+ };
69
+ }
70
+ /** Current ISO-8601 timestamp with millisecond precision. */
71
+ function nowIso() {
72
+ return new Date().toISOString();
73
+ }
74
+ /**
75
+ * Resolves an agent's configured fork source. A plain `conv-…` id passes
76
+ * through unchanged (today's static base-conversation behavior); an `rmsg-…`
77
+ * reference is dereferenced to the rotating message's current prepared
78
+ * conversation. Pure DB read — no Letta call.
79
+ *
80
+ * @param db - Drizzle handle.
81
+ * @param forkSourceConversationId - the agent's stored value (may be null).
82
+ * @returns the resolution variant.
83
+ */
84
+ function resolveForkSource(db, forkSourceConversationId) {
85
+ if (!forkSourceConversationId)
86
+ return { kind: "none" };
87
+ if (!forkSourceConversationId.startsWith("rmsg-")) {
88
+ return { kind: "ready", conversationId: forkSourceConversationId };
89
+ }
90
+ const rmsg = db
91
+ .select()
92
+ .from(rotatingMessage)
93
+ .where(eq(rotatingMessage.id, forkSourceConversationId))
94
+ .get();
95
+ if (!rmsg || rmsg.isArchived) {
96
+ return { kind: "rmsg_missing", ref: forkSourceConversationId };
97
+ }
98
+ if (!rmsg.currentConversationId) {
99
+ return { kind: "rmsg_not_ready", ref: forkSourceConversationId };
100
+ }
101
+ return { kind: "ready", conversationId: rmsg.currentConversationId };
102
+ }
103
+ /* ────────────────────────────────────────────────────────────────────────── */
104
+ /* POST /sessions */
105
+ /* ────────────────────────────────────────────────────────────────────────── */
106
+ /** `POST /sessions` body. `title` is non-empty, 1..80 chars. */
107
+ const createSessionSchema = z
108
+ .object({
109
+ agent_id: z.string().uuid(),
110
+ title: z.string().min(1).max(80),
111
+ })
112
+ .strict();
113
+ /**
114
+ * `POST /sessions` — create a new session for the caller against `agent_id`.
115
+ *
116
+ * Validates that the referenced ai-hub agent exists and is not archived.
117
+ * Snapshots `agent.letta_agent_id` so a future re-point of the catalog entry
118
+ * does not migrate existing conversations.
119
+ *
120
+ * Step 5 replaces the placeholder `letta_conversation_id` with a real SDK
121
+ * call to `client.conversations.create`.
122
+ *
123
+ * @see SPECIFICATIONS.md §6.3.1
124
+ */
125
+ export const handleCreateSession = protectedRoute({}, async (req, ctx, auth) => {
126
+ let raw;
127
+ try {
128
+ raw = await req.json();
129
+ }
130
+ catch {
131
+ return validationError("Request body must be valid JSON.");
132
+ }
133
+ const parsed = createSessionSchema.safeParse(raw);
134
+ if (!parsed.success) {
135
+ const issue = parsed.error.issues[0];
136
+ let code = "validation";
137
+ if (issue?.path[0] === "title") {
138
+ if (issue.code === "too_small")
139
+ code = "title_required";
140
+ else if (issue.code === "too_big")
141
+ code = "title_too_long";
142
+ }
143
+ return validationError(issue?.message ?? "Invalid request body.", code);
144
+ }
145
+ const agentRow = ctx.db
146
+ .select()
147
+ .from(agent)
148
+ .where(eq(agent.id, parsed.data.agent_id))
149
+ .get();
150
+ if (!agentRow)
151
+ return notFound("Agent not found.");
152
+ if (agentRow.isArchived) {
153
+ return conflictError("agent_archived", "Cannot start a new session against an archived agent.");
154
+ }
155
+ // Branch on `agent.fork_source_conversation_id`:
156
+ // set → fork an operator-curated base conversation in Letta. The value
157
+ // is either a raw `conv-…` (static base, Step 12.5) or an
158
+ // `rmsg-…` reference to a rotating starting message, resolved to
159
+ // its current prepared conversation here.
160
+ // null → create a brand-new conversation (today's behavior).
161
+ //
162
+ // Both paths surface SDK failures as 500 — admin-side validation is
163
+ // responsible for catching the configuration-shape problems (missing
164
+ // source, wrong agent) before any user-facing session-create reaches
165
+ // here. A 404 from fork at this point means the operator deleted the
166
+ // source between admin-validate and now.
167
+ const forkResolution = resolveForkSource(ctx.db, agentRow.forkSourceConversationId);
168
+ if (forkResolution.kind === "rmsg_missing") {
169
+ ctx.logger.error({
170
+ event: "session_rotating_message_missing",
171
+ agent_id: agentRow.id,
172
+ fork_source_conversation_id: agentRow.forkSourceConversationId,
173
+ }, "Agent references a rotating starting message that no longer exists or is archived.");
174
+ return internalError("Configured rotating starting message is unavailable.");
175
+ }
176
+ if (forkResolution.kind === "rmsg_not_ready") {
177
+ ctx.logger.error({
178
+ event: "session_rotating_message_not_ready",
179
+ agent_id: agentRow.id,
180
+ fork_source_conversation_id: agentRow.forkSourceConversationId,
181
+ }, "Rotating starting message has not produced a prepared conversation yet.");
182
+ return internalError("The rotating starting message has not generated its first conversation yet. Try again shortly.");
183
+ }
184
+ const forkSourceId = forkResolution.kind === "ready" ? forkResolution.conversationId : null;
185
+ let conv;
186
+ try {
187
+ if (forkSourceId) {
188
+ conv = await ctx.lettaClient.forkConversation(forkSourceId);
189
+ }
190
+ else {
191
+ conv = await ctx.lettaClient.createConversation(agentRow.lettaAgentId);
192
+ }
193
+ }
194
+ catch (err) {
195
+ if (err instanceof ForkSourceNotFoundError) {
196
+ ctx.logger.error({
197
+ event: "letta_fork_source_not_found",
198
+ agent_id: agentRow.id,
199
+ fork_source_conversation_id: agentRow.forkSourceConversationId,
200
+ }, "Letta fork-source conversation no longer exists.");
201
+ return internalError("Configured base conversation no longer exists in Letta.");
202
+ }
203
+ ctx.logger.error({
204
+ event: "letta_create_conversation_failed",
205
+ agent_id: agentRow.id,
206
+ letta_agent_id: agentRow.lettaAgentId,
207
+ fork: !!agentRow.forkSourceConversationId,
208
+ err: err instanceof Error ? err.message : String(err),
209
+ }, "Letta conversation creation failed.");
210
+ return internalError("Failed to create Letta conversation.");
211
+ }
212
+ const id = randomUUID();
213
+ ctx.db
214
+ .insert(session)
215
+ .values({
216
+ id,
217
+ userId: auth.user.id,
218
+ agentId: agentRow.id,
219
+ lettaConversationId: conv.id,
220
+ lettaAgentId: agentRow.lettaAgentId,
221
+ title: parsed.data.title,
222
+ isArchived: false,
223
+ })
224
+ .run();
225
+ // Step-12.5 — if the agent has a sandbox seed configured, recursively
226
+ // copy the seed folder into `{filesDir}/sandboxes/{session_id}/` and
227
+ // record the absolute path on the session row. On copy failure, roll
228
+ // back: best-effort delete the partial sandbox dir + the just-created
229
+ // Letta conversation, then 500. Better to surface the error to the
230
+ // caller than to leave a dangling conversation + half-written folder.
231
+ if (agentRow.sandboxSeedName) {
232
+ const targetDir = resolveSandboxDir(ctx.filesDir, id);
233
+ let seedDir;
234
+ try {
235
+ seedDir = resolveSeedDir(ctx.filesDir, agentRow.sandboxSeedName);
236
+ }
237
+ catch (err) {
238
+ // Programmer error — should have been caught at admin-write time.
239
+ ctx.logger.error({
240
+ event: "session_sandbox_invalid_seed_name",
241
+ agent_id: agentRow.id,
242
+ sandbox_seed_name: agentRow.sandboxSeedName,
243
+ err: err instanceof Error ? err.message : String(err),
244
+ }, "Invalid sandbox_seed_name reached POST /sessions; should have been caught at admin-write.");
245
+ ctx.db.delete(session).where(eq(session.id, id)).run();
246
+ return internalError("Failed to materialize session sandbox.");
247
+ }
248
+ try {
249
+ await materializeSandbox(seedDir, targetDir);
250
+ }
251
+ catch (err) {
252
+ ctx.logger.error({
253
+ event: "session_sandbox_materialize_failed",
254
+ session_id: id,
255
+ agent_id: agentRow.id,
256
+ sandbox_seed_name: agentRow.sandboxSeedName,
257
+ err: err instanceof Error ? err.message : String(err),
258
+ }, "Failed to materialize per-session sandbox folder.");
259
+ // Best-effort rollback: drop the just-inserted session row +
260
+ // the partially-copied folder. The Letta conversation is left
261
+ // in place — Letta doesn't expose a server-side delete that
262
+ // ai-hub uses (§6.4.1's deprecated-surfaces table), and the
263
+ // orphan conversation is harmless (no users / sessions reach
264
+ // it from ai-hub).
265
+ ctx.db.delete(session).where(eq(session.id, id)).run();
266
+ await purgeSandbox(targetDir).catch(() => {
267
+ /* best effort */
268
+ });
269
+ return internalError("Failed to materialize session sandbox.");
270
+ }
271
+ ctx.db
272
+ .update(session)
273
+ .set({ sandboxDir: targetDir, updatedAt: new Date().toISOString() })
274
+ .where(eq(session.id, id))
275
+ .run();
276
+ }
277
+ const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
278
+ if (!row)
279
+ throw new Error("Just-inserted session vanished.");
280
+ ctx.logger.info({
281
+ user_id: auth.user.id,
282
+ session_id: row.id,
283
+ agent_id: agentRow.id,
284
+ forked: !!agentRow.forkSourceConversationId,
285
+ sandboxed: !!agentRow.sandboxSeedName,
286
+ event: "session_created",
287
+ }, "Session created.");
288
+ return jsonResponse(201, sessionView(row));
289
+ });
290
+ /* ────────────────────────────────────────────────────────────────────────── */
291
+ /* GET /sessions?agent_id=...&include_archived=... */
292
+ /* ────────────────────────────────────────────────────────────────────────── */
293
+ /**
294
+ * `GET /sessions` — list the caller's sessions for a given agent.
295
+ *
296
+ * Query parameters:
297
+ *
298
+ * - `agent_id` — required. The ai-hub agent (catalog entry) to scope the list
299
+ * to. Sessions for other agents are not returned (a single GET is
300
+ * per-agent, mirroring how the React sessions panel mounts inside an
301
+ * opened agent — see [03-chat.md](../../../../docs/ui-sketches/03-chat.md)).
302
+ * - `include_archived` — optional, default `false`. When `true`, archived
303
+ * rows are surfaced alongside active ones with their `is_archived` flag
304
+ * set; the client distinguishes them visually (`--archived` modifier).
305
+ *
306
+ * Order: newest first by `updated_at`.
307
+ *
308
+ * @see SPECIFICATIONS.md §6.3.1
309
+ */
310
+ export const handleListSessions = protectedRoute({}, async (req, _ctx, auth) => {
311
+ const url = new URL(req.url);
312
+ const agentId = url.searchParams.get("agent_id");
313
+ if (!agentId) {
314
+ return validationError("Query parameter `agent_id` is required.", "agent_id_required");
315
+ }
316
+ const includeArchived = url.searchParams.get("include_archived")?.toLowerCase() === "true";
317
+ const ctx = _ctx; // alias for clarity
318
+ const where = includeArchived
319
+ ? and(eq(session.userId, auth.user.id), eq(session.agentId, agentId))
320
+ : and(eq(session.userId, auth.user.id), eq(session.agentId, agentId), eq(session.isArchived, false));
321
+ const rows = ctx.db
322
+ .select()
323
+ .from(session)
324
+ .where(where)
325
+ .orderBy(desc(session.updatedAt))
326
+ .all();
327
+ return jsonResponse(200, {
328
+ sessions: rows.map((r) => sessionView(r)),
329
+ });
330
+ });
331
+ /* ────────────────────────────────────────────────────────────────────────── */
332
+ /* GET /sessions/:id */
333
+ /* ────────────────────────────────────────────────────────────────────────── */
334
+ /**
335
+ * `GET /sessions/:id` — single session, owner-scoped.
336
+ *
337
+ * Returns 404 with `not_found` for both "no such session" and "exists but
338
+ * belongs to another user" — no enumeration leakage (§13.1).
339
+ *
340
+ * @see SPECIFICATIONS.md §6.3.1
341
+ */
342
+ export const handleGetSession = protectedRoute({}, async (_req, ctx, auth, params) => {
343
+ const id = params.id;
344
+ if (!id)
345
+ return notFound();
346
+ const row = ctx.db
347
+ .select()
348
+ .from(session)
349
+ .where(eq(session.id, id))
350
+ .get();
351
+ if (!row || row.userId !== auth.user.id)
352
+ return notFound();
353
+ // Per-model reasoning capabilities are no longer published — see
354
+ // migration note at top of file. Empty object preserves the shape
355
+ // for React clients that read the field defensively.
356
+ return jsonResponse(200, sessionView(row, { capabilities: {} }));
357
+ });
358
+ /* ────────────────────────────────────────────────────────────────────────── */
359
+ /* PATCH /sessions/:id */
360
+ /* ────────────────────────────────────────────────────────────────────────── */
361
+ /** Body for `PATCH /sessions/:id`. At least one field must be present; an empty patch is also fine (no-op). */
362
+ const patchSessionSchema = z
363
+ .object({
364
+ title: z.string().min(1).max(80).optional(),
365
+ is_archived: z.boolean().optional(),
366
+ })
367
+ .strict();
368
+ /**
369
+ * `PATCH /sessions/:id` — rename and / or archive / unarchive, owner-scoped.
370
+ *
371
+ * Accepts either field independently — the React UI sends just `title` for
372
+ * rename and just `is_archived` for archive/unarchive. An empty body
373
+ * (`{}`) is a 200 no-op that echoes the row unchanged.
374
+ *
375
+ * @see SPECIFICATIONS.md §6.3.1
376
+ */
377
+ export const handlePatchSession = protectedRoute({}, async (req, ctx, auth, params) => {
378
+ const id = params.id;
379
+ if (!id)
380
+ return notFound();
381
+ let raw;
382
+ try {
383
+ raw = await req.json();
384
+ }
385
+ catch {
386
+ return validationError("Request body must be valid JSON.");
387
+ }
388
+ const parsed = patchSessionSchema.safeParse(raw);
389
+ if (!parsed.success) {
390
+ const issue = parsed.error.issues[0];
391
+ let code = "validation";
392
+ if (issue?.path[0] === "title") {
393
+ if (issue.code === "too_small")
394
+ code = "title_required";
395
+ else if (issue.code === "too_big")
396
+ code = "title_too_long";
397
+ }
398
+ return validationError(issue?.message ?? "Invalid request body.", code);
399
+ }
400
+ const body = parsed.data;
401
+ const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
402
+ if (!row || row.userId !== auth.user.id)
403
+ return notFound();
404
+ if (body.title !== undefined || body.is_archived !== undefined) {
405
+ const update = {
406
+ updatedAt: nowIso(),
407
+ };
408
+ if (body.title !== undefined)
409
+ update.title = body.title;
410
+ if (body.is_archived !== undefined)
411
+ update.isArchived = body.is_archived;
412
+ ctx.db.update(session).set(update).where(eq(session.id, id)).run();
413
+ ctx.logger.info({
414
+ user_id: auth.user.id,
415
+ session_id: id,
416
+ patch: {
417
+ title: body.title !== undefined,
418
+ is_archived: body.is_archived,
419
+ },
420
+ event: "session_patched",
421
+ }, "Session updated.");
422
+ }
423
+ const fresh = ctx.db.select().from(session).where(eq(session.id, id)).get();
424
+ if (!fresh)
425
+ return notFound();
426
+ return jsonResponse(200, sessionView(fresh));
427
+ });
428
+ /* ────────────────────────────────────────────────────────────────────────── */
429
+ /* PATCH /sessions/:id/reasoning */
430
+ /* ────────────────────────────────────────────────────────────────────────── */
431
+ /**
432
+ * Body for `PATCH /sessions/:id/reasoning`. Fields are independent — the
433
+ * React UI may send just `effort`, just `verbosity`, or both depending on
434
+ * what the agent's model supports.
435
+ *
436
+ * Values are stored as-typed: server-side validation against the per-model
437
+ * capability enum is intentionally LOOSE so this layer stays schema-driven.
438
+ * The actual rejection (if any) happens when Letta receives the patched
439
+ * `model_settings` before the turn — matching Letta Code's behavior.
440
+ *
441
+ * `null` explicitly clears a field; omitting it keeps the existing value.
442
+ */
443
+ const patchSessionReasoningSchema = z
444
+ .object({
445
+ effort: z.string().nullable().optional(),
446
+ verbosity: z.string().nullable().optional(),
447
+ thinking_budget: z.number().int().positive().nullable().optional(),
448
+ })
449
+ .strict();
450
+ /**
451
+ * `PATCH /sessions/:id/reasoning` — update the user's per-session reasoning
452
+ * settings. Applied to the agent's `model_settings` automatically before
453
+ * the next chat turn.
454
+ *
455
+ * Owner-scoped: returns 404 for sessions the caller doesn't own.
456
+ *
457
+ * @see SPECIFICATIONS.md §6.3.1
458
+ */
459
+ export const handlePatchSessionReasoning = protectedRoute({}, async (req, ctx, auth, params) => {
460
+ const id = params.id;
461
+ if (!id)
462
+ return notFound();
463
+ let raw;
464
+ try {
465
+ raw = await req.json();
466
+ }
467
+ catch {
468
+ return validationError("Request body must be valid JSON.");
469
+ }
470
+ const parsed = patchSessionReasoningSchema.safeParse(raw);
471
+ if (!parsed.success) {
472
+ const issue = parsed.error.issues[0];
473
+ return validationError(issue?.message ?? "Invalid request body.");
474
+ }
475
+ const body = parsed.data;
476
+ const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
477
+ if (!row || row.userId !== auth.user.id)
478
+ return notFound();
479
+ const update = { updatedAt: nowIso() };
480
+ if (body.effort !== undefined)
481
+ update.reasoningEffort = body.effort;
482
+ if (body.verbosity !== undefined)
483
+ update.verbosity = body.verbosity;
484
+ if (body.thinking_budget !== undefined)
485
+ update.thinkingBudget = body.thinking_budget;
486
+ ctx.db.update(session).set(update).where(eq(session.id, id)).run();
487
+ ctx.logger.info({
488
+ user_id: auth.user.id,
489
+ session_id: id,
490
+ reasoning: {
491
+ effort: body.effort,
492
+ verbosity: body.verbosity,
493
+ thinking_budget: body.thinking_budget,
494
+ },
495
+ event: "session_reasoning_patched",
496
+ }, "Session reasoning updated.");
497
+ const fresh = ctx.db.select().from(session).where(eq(session.id, id)).get();
498
+ if (!fresh)
499
+ return notFound();
500
+ return jsonResponse(200, sessionView(fresh, { capabilities: {} }));
501
+ });
502
+ /* ────────────────────────────────────────────────────────────────────────── */
503
+ /* GET /sessions/:id/messages — Step 5 placeholder */
504
+ /* ────────────────────────────────────────────────────────────────────────── */
505
+ /**
506
+ * `GET /sessions/:id/messages` — paginated history pulled from Letta.
507
+ *
508
+ * Calls `client.conversations.messages.list(conversation_id)` per §6.4 and
509
+ * projects the result into the wire-shaped `AIHubMessage` array the
510
+ * React client consumes. Letta is the source of truth for message
511
+ * history (§6.8); ai-hub does not duplicate it in its SQLite.
512
+ *
513
+ * Owner-scoped: returns 404 for sessions not owned by the caller. The
514
+ * `power_user`+ "all-sessions overview" lives at `/admin/sessions`, not
515
+ * here.
516
+ *
517
+ * @see SPECIFICATIONS.md §6.3.1 — sessions
518
+ * @see SPECIFICATIONS.md §6.4 — SDK-only rule
519
+ * @see SPECIFICATIONS.md §6.8 — Letta owns message history
520
+ */
521
+ export const handleGetSessionMessages = protectedRoute({}, async (_req, ctx, auth, params) => {
522
+ const id = params.id;
523
+ if (!id)
524
+ return notFound();
525
+ const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
526
+ if (!row || row.userId !== auth.user.id)
527
+ return notFound();
528
+ return buildSessionMessagesResponse(ctx, row);
529
+ });
530
+ /**
531
+ * `GET /admin/sessions/:id/messages` — read-only session detail for the
532
+ * admin sessions overview (§8.2, [07-admin-sessions-overview.md]).
533
+ *
534
+ * Mirrors `handleGetSessionMessages` but skips the owner check — power_user+
535
+ * can view any session in the deployment (no write surface; the React side
536
+ * mounts the same `<MessageBubble />`s without input controls).
537
+ *
538
+ * @see SPECIFICATIONS.md §6.3.4 — admin sessions overview
539
+ * @see ui-sketches/07-admin-sessions-overview.md
540
+ */
541
+ export const handleAdminGetSessionMessages = protectedRoute({ requiredRole: "power_user" }, async (_req, ctx, _auth, params) => {
542
+ const id = params.id;
543
+ if (!id)
544
+ return notFound();
545
+ const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
546
+ if (!row)
547
+ return notFound();
548
+ return buildSessionMessagesResponse(ctx, row);
549
+ });
550
+ /**
551
+ * Fetches Letta history for the given session, projects it into the
552
+ * wire-shaped `AIHubMessage[]`, applies the message-override layer +
553
+ * `<memory_metadata>` stripping + per-assistant usage attachment, and
554
+ * returns the final JSON response.
555
+ *
556
+ * Shared between the owner-scoped `/sessions/:id/messages` and the
557
+ * power_user+ `/admin/sessions/:id/messages` — the only difference is the
558
+ * access check the caller performs **before** invoking this helper.
559
+ *
560
+ * @param ctx - server context (carries db + lettaClient + logger).
561
+ * @param row - the resolved session row (the caller's own row for the
562
+ * user-facing route; any session for the admin route).
563
+ * @returns the projected response, or an `internalError` Response on Letta
564
+ * list failure.
565
+ */
566
+ async function buildSessionMessagesResponse(ctx, row) {
567
+ const id = row.id;
568
+ let messages;
569
+ try {
570
+ messages = await ctx.lettaClient.listMessages(row.lettaConversationId);
571
+ }
572
+ catch (err) {
573
+ ctx.logger.error({
574
+ event: "letta_list_messages_failed",
575
+ session_id: id,
576
+ letta_conversation_id: row.lettaConversationId,
577
+ err: err instanceof Error ? err.message : String(err),
578
+ }, "Letta message listing failed.");
579
+ return internalError("Failed to load message history.");
580
+ }
581
+ return projectAndRespond(ctx, row, messages);
582
+ }
583
+ /**
584
+ * Pure-ish projector — given the Letta listMessages result for a session,
585
+ * builds the final `{ messages }` JSON response.
586
+ *
587
+ * Split out from {@link buildSessionMessagesResponse} so the Letta-side
588
+ * fetch can be mocked / faked at the boundary in tests without re-implementing
589
+ * the projection. (DB reads for the overrides / usage tables stay inline —
590
+ * they're synchronous Drizzle calls keyed by ids already in scope.)
591
+ *
592
+ * @param ctx - server context.
593
+ * @param row - the session row.
594
+ * @param messages - raw Letta message list.
595
+ * @returns the projected response.
596
+ */
597
+ function projectAndRespond(ctx, row, messages) {
598
+ // Letta groups one logical assistant turn into multiple message-type
599
+ // rows. In single-step runs they share the same `id`; in
600
+ // **multi-step** runs (reasoning → tool call → tool return → final
601
+ // assistant text) each step gets its own `id`, so grouping by id
602
+ // alone misses the cross-step affordances.
603
+ //
604
+ // We aggregate by **turn** instead: walk the rows in chronological
605
+ // order, accumulating reasoning text + tool-call records into a
606
+ // current-turn buffer; when an `assistant_message` row arrives we
607
+ // attach the buffer to that row's id and reset. A `user_message`
608
+ // row also resets the buffer (defensive — guards against runs
609
+ // where the agent emits intermediate "thinking" output without a
610
+ // terminating assistant_message).
611
+ //
612
+ // Step-10 surfaces reasoning + tool-call data inside the assistant
613
+ // bubble (see SPECIFICATIONS.md §7.3, §10.4), so this aggregation
614
+ // is what makes the historical view match the live-streaming
615
+ // bubble.
616
+ const sortedRaw = [...messages].sort((a, b) => {
617
+ const aT = Date.parse(a.date ?? "");
618
+ const bT = Date.parse(b.date ?? "");
619
+ if (Number.isNaN(aT) && Number.isNaN(bT))
620
+ return 0;
621
+ if (Number.isNaN(aT))
622
+ return 1;
623
+ if (Number.isNaN(bT))
624
+ return -1;
625
+ return aT - bT;
626
+ });
627
+ const { byAssistantId: aggregates, orphanRuns } = collectAggregates(sortedRaw);
628
+ const priority = {
629
+ assistant: 3,
630
+ user: 2,
631
+ system: 1,
632
+ tool: 0,
633
+ };
634
+ const dedupedById = new Map();
635
+ for (const raw of sortedRaw) {
636
+ const projected = toWireMessage(raw);
637
+ if (!projected)
638
+ continue;
639
+ // Attach aggregates to assistant_messages; user/system are left
640
+ // untouched (they don't carry reasoning or tool calls).
641
+ let withAggregates = projected;
642
+ if (projected.role === "assistant") {
643
+ const agg = aggregates.get(projected.id);
644
+ if (agg) {
645
+ const next = { ...projected };
646
+ if (agg.reasoning.length > 0) {
647
+ next.reasoning = agg.reasoning;
648
+ }
649
+ if (agg.toolCalls.length > 0) {
650
+ next.tool_calls = agg.toolCalls;
651
+ }
652
+ withAggregates = next;
653
+ }
654
+ }
655
+ const incumbent = dedupedById.get(projected.id);
656
+ if (!incumbent ||
657
+ priority[withAggregates.role] > priority[incumbent.role]) {
658
+ dedupedById.set(projected.id, withAggregates);
659
+ }
660
+ }
661
+ // Synthesize virtual assistant bubbles for orphan runs (runs that
662
+ // produced reasoning / tool calls but never emitted an
663
+ // assistant_message). Each gets a synthetic id derived from the
664
+ // run_id; created_at is the latest date observed in the run, which
665
+ // places the bubble correctly in the chronological sort below.
666
+ for (const orphan of orphanRuns) {
667
+ const synthId = `synth-run-${orphan.runId}`;
668
+ // Don't clobber a real message that happened to use the same id
669
+ // (impossible in practice — Letta doesn't issue ids in this
670
+ // shape — but cheap to guard).
671
+ if (dedupedById.has(synthId))
672
+ continue;
673
+ const virtualBubble = {
674
+ id: synthId,
675
+ role: "assistant",
676
+ content: "",
677
+ created_at: orphan.latestDate,
678
+ ...(orphan.aggregate.reasoning.length > 0
679
+ ? { reasoning: orphan.aggregate.reasoning }
680
+ : {}),
681
+ ...(orphan.aggregate.toolCalls.length > 0
682
+ ? { tool_calls: orphan.aggregate.toolCalls }
683
+ : {}),
684
+ };
685
+ dedupedById.set(synthId, virtualBubble);
686
+ }
687
+ // Letta's `messages.list` returns rows in the SDK's iteration order,
688
+ // which is not guaranteed to be chronological (in practice often
689
+ // reverse-chronological). The React chat renders bubbles top-to-bottom
690
+ // in the order received, so sort ascending by `date` here — the
691
+ // ordering is part of the §6.3.1 contract, not a UI concern. Stable
692
+ // sort: messages with identical timestamps keep Letta's relative order.
693
+ const sorted = [...dedupedById.values()].sort((a, b) => {
694
+ const aT = Date.parse(a.created_at);
695
+ const bT = Date.parse(b.created_at);
696
+ if (Number.isNaN(aT) || Number.isNaN(bT))
697
+ return 0;
698
+ return aT - bT;
699
+ });
700
+ // Apply content overrides.
701
+ //
702
+ // **DB-backed overrides** (`message_override` table) win over the raw
703
+ // wire content. Keyed by `(conversation_id, ordinal_position)` where
704
+ // position is the 0-based index into Letta's chronologically-sorted
705
+ // raw message list (`sortedRaw`). We compute that position for each
706
+ // Letta-issued id once and look it up per surviving wire message.
707
+ // Synthetic `synth-run-*` bubbles have no raw counterpart and are
708
+ // skipped. If no override applies, the wire message passes through
709
+ // untouched apart from `<memory_metadata>` stripping on system rows.
710
+ const positionByLettaId = new Map();
711
+ sortedRaw.forEach((raw, idx) => {
712
+ const id = raw.id;
713
+ if (typeof id === "string")
714
+ positionByLettaId.set(id, idx);
715
+ });
716
+ const overrideRows = ctx.db
717
+ .select()
718
+ .from(messageOverride)
719
+ .where(eq(messageOverride.conversationId, row.lettaConversationId))
720
+ .all();
721
+ // Only the `user_override` layer is consulted at display time.
722
+ // `system_override` is stored but inert (see `db/schema.ts` for
723
+ // rationale). Rows where `user_override` is null contribute nothing
724
+ // to display and are skipped.
725
+ const overrideByPosition = new Map();
726
+ for (const o of overrideRows) {
727
+ if (o.userOverride !== null) {
728
+ overrideByPosition.set(o.ordinalPosition, o.userOverride);
729
+ }
730
+ }
731
+ // Per-assistant-message usage (cache hits/writes + token counts).
732
+ // Stamped by the runner on the last assistant message of each
733
+ // turn (see turnRunner.ts step 5.5). Renders as the cache badge
734
+ // on the chat UI's assistant bubbles.
735
+ const usageRows = ctx.db
736
+ .select()
737
+ .from(messageUsage)
738
+ .where(eq(messageUsage.sessionId, row.id))
739
+ .all();
740
+ const usageByMessageId = new Map();
741
+ for (const u of usageRows) {
742
+ usageByMessageId.set(u.lettaMessageId, {
743
+ input: u.promptTokens,
744
+ output: u.completionTokens,
745
+ cache_read: u.cachedInputTokens,
746
+ cache_write: u.cacheWriteTokens,
747
+ reasoning: u.reasoningTokens,
748
+ context: u.contextTokens,
749
+ total: u.totalTokens,
750
+ });
751
+ }
752
+ const projected = sorted.map((msg) => {
753
+ const pos = positionByLettaId.get(msg.id);
754
+ const usage = usageByMessageId.get(msg.id);
755
+ const withUsage = usage !== undefined ? { ...msg, usage } : msg;
756
+ if (pos !== undefined) {
757
+ const explicit = overrideByPosition.get(pos);
758
+ if (explicit !== undefined) {
759
+ return { ...withUsage, content: explicit };
760
+ }
761
+ }
762
+ if (msg.role === "system") {
763
+ const stripped = stripMemoryMetadata(msg.content);
764
+ if (stripped !== msg.content) {
765
+ return { ...withUsage, content: stripped };
766
+ }
767
+ }
768
+ return withUsage;
769
+ });
770
+ return jsonResponse(200, { messages: projected });
771
+ }
772
+ /**
773
+ * Projects a `MessageOverrideRow` to the snake_case wire shape. Kept here
774
+ * (not in `db/schema.ts`) so the route file owns its own wire contract.
775
+ */
776
+ function overrideRowToView(row) {
777
+ return {
778
+ ordinal_position: row.ordinalPosition,
779
+ system_override: row.systemOverride,
780
+ user_override: row.userOverride,
781
+ created_at: row.createdAt,
782
+ updated_at: row.updatedAt,
783
+ };
784
+ }
785
+ /**
786
+ * `GET /sessions/:id/message-overrides` — list the caller's overrides for
787
+ * the session's underlying Letta conversation.
788
+ *
789
+ * Returns the rows sorted by `ordinal_position` ascending so consumers
790
+ * can render them alongside the message list at the matching index. An
791
+ * empty list (no overrides set) returns `{ overrides: [] }` with 200.
792
+ *
793
+ * Owner-scoped: 404 for sessions the caller doesn't own.
794
+ */
795
+ export const handleListMessageOverrides = protectedRoute({}, async (_req, ctx, auth, params) => {
796
+ const id = params.id;
797
+ if (!id)
798
+ return notFound();
799
+ const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
800
+ if (!row || row.userId !== auth.user.id)
801
+ return notFound();
802
+ const rows = ctx.db
803
+ .select()
804
+ .from(messageOverride)
805
+ .where(eq(messageOverride.conversationId, row.lettaConversationId))
806
+ .orderBy(messageOverride.ordinalPosition)
807
+ .all();
808
+ return jsonResponse(200, { overrides: rows.map(overrideRowToView) });
809
+ });
810
+ /* ────────────────────────────────────────────────────────────────────────── */
811
+ /* PUT /sessions/:id/message-overrides/:position */
812
+ /* ────────────────────────────────────────────────────────────────────────── */
813
+ /** Body for `PUT /sessions/:id/message-overrides/:position`. */
814
+ const putMessageOverrideSchema = z
815
+ .object({
816
+ /**
817
+ * What the LLM saw / should see at this position. Stored but inert
818
+ * at display today. Empty string is valid.
819
+ */
820
+ system_override: z.string().optional(),
821
+ /**
822
+ * What the human sees in the chat transcript at this position. Empty
823
+ * string is valid — it hides the message visually but keeps the bubble
824
+ * slot. Callers that want to remove the slot entirely use `DELETE`.
825
+ */
826
+ user_override: z.string().optional(),
827
+ })
828
+ .strict()
829
+ .refine((b) => b.system_override !== undefined || b.user_override !== undefined, {
830
+ message: "At least one of `system_override` or `user_override` must be set.",
831
+ });
832
+ /**
833
+ * `PUT /sessions/:id/message-overrides/:position` — upsert one or both
834
+ * override layers for the message at `position`.
835
+ *
836
+ * **Validation gates** (in order):
837
+ * - `404` — session not owned by caller (or doesn't exist).
838
+ * - `400 invalid_position` — path segment doesn't parse as a non-negative
839
+ * integer.
840
+ * - `400 validation` — body missing both fields.
841
+ * - `409 turn_in_progress` — a `turn` row exists with `status = "running"`
842
+ * for this session. Honoring writes while a turn is in flight would
843
+ * race the post-turn writer, so we reject up front.
844
+ * - `409 message_not_found` — `position` is out of range for the current
845
+ * Letta message list. The override would point at a message that
846
+ * doesn't exist; callers must wait for the message to land before
847
+ * overriding it.
848
+ *
849
+ * **Merge semantics.** Only the layers the body provides are written; an
850
+ * existing value on the other layer is preserved by reading the row,
851
+ * computing the merged tuple, and upserting the result. This lets the
852
+ * `/chat` post-turn writer set `user_override` (leaving `system_override`
853
+ * null) and a later operator PUT set `system_override` without
854
+ * clobbering the post-turn-written `user_override`.
855
+ *
856
+ * Idempotent: same body → same result. Returns 200 with the resulting view.
857
+ */
858
+ export const handlePutMessageOverride = protectedRoute({}, async (req, ctx, auth, params) => {
859
+ const id = params.id;
860
+ if (!id)
861
+ return notFound();
862
+ const positionStr = params.position;
863
+ if (!positionStr)
864
+ return notFound();
865
+ const position = Number.parseInt(positionStr, 10);
866
+ if (!Number.isInteger(position) || position < 0 || String(position) !== positionStr) {
867
+ return validationError("ordinal_position must be a non-negative integer.", "invalid_position");
868
+ }
869
+ let raw;
870
+ try {
871
+ raw = await req.json();
872
+ }
873
+ catch {
874
+ return validationError("Request body must be valid JSON.");
875
+ }
876
+ const parsed = putMessageOverrideSchema.safeParse(raw);
877
+ if (!parsed.success) {
878
+ const issue = parsed.error.issues[0];
879
+ return validationError(issue?.message ?? "Invalid request body.");
880
+ }
881
+ const { system_override: systemOverride, user_override: userOverride } = parsed.data;
882
+ const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
883
+ if (!row || row.userId !== auth.user.id)
884
+ return notFound();
885
+ // Gate 1: no live turn for this session. Avoids racing the post-turn
886
+ // writer (`/chat`) and keeps the invariant "messages we override
887
+ // already exist in Letta and aren't moving."
888
+ const liveTurn = ctx.db
889
+ .select({ id: turn.id })
890
+ .from(turn)
891
+ .where(and(eq(turn.sessionId, id), eq(turn.status, "running")))
892
+ .get();
893
+ if (liveTurn) {
894
+ return conflictError("turn_in_progress", "Cannot modify message overrides while a turn is in progress.");
895
+ }
896
+ // Gate 2: target position must exist in Letta's message list. The
897
+ // listMessages count is the position-space size; `position < count`
898
+ // is the existence condition.
899
+ let lettaMessages;
900
+ try {
901
+ lettaMessages = await ctx.lettaClient.listMessages(row.lettaConversationId);
902
+ }
903
+ catch (err) {
904
+ ctx.logger.error({
905
+ event: "message_override_letta_list_failed",
906
+ session_id: id,
907
+ letta_conversation_id: row.lettaConversationId,
908
+ err: err instanceof Error ? err.message : String(err),
909
+ }, "Letta listMessages failed during override validation.");
910
+ return internalError("Failed to validate message position against Letta.");
911
+ }
912
+ if (position >= lettaMessages.length) {
913
+ return conflictError("message_not_found", `No Letta message exists at position ${position} (conversation has ${lettaMessages.length}).`);
914
+ }
915
+ // Merge: read existing row (if any), preserve untouched layer.
916
+ const existing = ctx.db
917
+ .select()
918
+ .from(messageOverride)
919
+ .where(and(eq(messageOverride.conversationId, row.lettaConversationId), eq(messageOverride.ordinalPosition, position)))
920
+ .get();
921
+ const merged = {
922
+ systemOverride: systemOverride !== undefined ? systemOverride : existing?.systemOverride ?? null,
923
+ userOverride: userOverride !== undefined ? userOverride : existing?.userOverride ?? null,
924
+ };
925
+ // CHECK constraint is enforced in SQL too, but explicit guard keeps
926
+ // the error surface clean (caller saw a 400 already if both were
927
+ // omitted on the wire; this catches the "you cleared both" edge).
928
+ if (merged.systemOverride === null && merged.userOverride === null) {
929
+ return validationError("Resulting override would have both layers null. At least one must be non-null.");
930
+ }
931
+ const now = nowIso();
932
+ ctx.db
933
+ .insert(messageOverride)
934
+ .values({
935
+ conversationId: row.lettaConversationId,
936
+ ordinalPosition: position,
937
+ systemOverride: merged.systemOverride,
938
+ userOverride: merged.userOverride,
939
+ createdAt: now,
940
+ updatedAt: now,
941
+ })
942
+ .onConflictDoUpdate({
943
+ target: [messageOverride.conversationId, messageOverride.ordinalPosition],
944
+ set: {
945
+ systemOverride: merged.systemOverride,
946
+ userOverride: merged.userOverride,
947
+ updatedAt: now,
948
+ },
949
+ })
950
+ .run();
951
+ const fresh = ctx.db
952
+ .select()
953
+ .from(messageOverride)
954
+ .where(and(eq(messageOverride.conversationId, row.lettaConversationId), eq(messageOverride.ordinalPosition, position)))
955
+ .get();
956
+ if (!fresh) {
957
+ return internalError("Failed to persist message override.");
958
+ }
959
+ ctx.logger.info({
960
+ event: "message_override_upserted",
961
+ user_id: auth.user.id,
962
+ session_id: id,
963
+ ordinal_position: position,
964
+ layers_written: {
965
+ system_override: systemOverride !== undefined,
966
+ user_override: userOverride !== undefined,
967
+ },
968
+ }, "Message override upserted.");
969
+ return jsonResponse(200, overrideRowToView(fresh));
970
+ });
971
+ /* ────────────────────────────────────────────────────────────────────────── */
972
+ /* DELETE /sessions/:id/message-overrides/:position */
973
+ /* ────────────────────────────────────────────────────────────────────────── */
974
+ /**
975
+ * `DELETE /sessions/:id/message-overrides/:position` — remove an override.
976
+ *
977
+ * Idempotent: returns 204 whether or not the row existed. Owner-scoped:
978
+ * 404 for sessions the caller doesn't own.
979
+ */
980
+ export const handleDeleteMessageOverride = protectedRoute({}, async (_req, ctx, auth, params) => {
981
+ const id = params.id;
982
+ if (!id)
983
+ return notFound();
984
+ const positionStr = params.position;
985
+ if (!positionStr)
986
+ return notFound();
987
+ const position = Number.parseInt(positionStr, 10);
988
+ if (!Number.isInteger(position) || position < 0 || String(position) !== positionStr) {
989
+ return validationError("ordinal_position must be a non-negative integer.", "invalid_position");
990
+ }
991
+ const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
992
+ if (!row || row.userId !== auth.user.id)
993
+ return notFound();
994
+ ctx.db
995
+ .delete(messageOverride)
996
+ .where(and(eq(messageOverride.conversationId, row.lettaConversationId), eq(messageOverride.ordinalPosition, position)))
997
+ .run();
998
+ return new Response(null, { status: 204 });
999
+ });
1000
+ /**
1001
+ * Builds, per `assistant_message` id, the aggregate of reasoning +
1002
+ * tool-call records that the React client renders inside that bubble.
1003
+ *
1004
+ * **Strategy — group by Letta's `run_id`.** Every row Letta returns
1005
+ * from `messages.list` carries a `run_id` that identifies the agent
1006
+ * run (and a `step_id` for the specific step). All rows of one
1007
+ * logical turn share a `run_id` regardless of how Letta stamps the
1008
+ * outer `id`. We use that as the primary grouping key:
1009
+ *
1010
+ * 1. Scan every row, bucketing reasoning + tool-call contributions by
1011
+ * `run_id`.
1012
+ * 2. For each `assistant_message` row, look up its `run_id` and attach
1013
+ * that bucket's contents to the assistant's id.
1014
+ *
1015
+ * Fallback layers (kept for robustness against older Letta versions
1016
+ * that may omit `run_id`):
1017
+ *
1018
+ * - **Same outer-`id` grouping.** When rows share the assistant's
1019
+ * outer `id` (single-step run, no `run_id`), match by id.
1020
+ * - **Chronological turn-walk.** Walk in date order; reset at
1021
+ * `user_message` boundaries; on `assistant_message`, attach the
1022
+ * buffered contributions to that id.
1023
+ *
1024
+ * Per assistant id we union the contributions of all three layers,
1025
+ * deduping tool calls by inner `tool_call_id`.
1026
+ *
1027
+ * Callers MUST pass `messages` sorted ascending by `date`.
1028
+ *
1029
+ * @param messages - rows from `client.conversations.messages.list`,
1030
+ * sorted ascending by `date`.
1031
+ * @returns map of `assistant_message.id` → aggregate.
1032
+ */
1033
+ function collectAggregates(messages) {
1034
+ // Layer 1 (primary): by Letta's run_id.
1035
+ const reasoningByRun = new Map();
1036
+ const callsByRun = new Map();
1037
+ const callOrderByRun = new Map();
1038
+ // Layer 2 (fallback): by outer message id.
1039
+ const reasoningById = new Map();
1040
+ const callsById = new Map();
1041
+ const callOrderById = new Map();
1042
+ // Layer 3 (fallback): chronological turn-walk.
1043
+ const turnReasoningByAssistant = new Map();
1044
+ const turnCallsByAssistant = new Map();
1045
+ // run_id → set of assistant_message ids (typically size 1 for a
1046
+ // straightforward turn; size > 1 when an agent run produces multiple
1047
+ // distinct assistant rows — we attach the aggregate to ALL of them so
1048
+ // every visible bubble gets the affordance data).
1049
+ const assistantIdsByRun = new Map();
1050
+ // run_id → latest ISO date observed for any row in that run. Used
1051
+ // for sort placement when we synthesize a virtual bubble for an
1052
+ // orphan run (no assistant_message ever emitted).
1053
+ const latestDateByRun = new Map();
1054
+ function noteRunDate(runId, date) {
1055
+ if (!date)
1056
+ return;
1057
+ const prev = latestDateByRun.get(runId);
1058
+ if (!prev || prev < date) {
1059
+ latestDateByRun.set(runId, date);
1060
+ }
1061
+ }
1062
+ // Chronological-walk buffer.
1063
+ let bufferReasoning = "";
1064
+ const bufferCalls = new Map();
1065
+ let bufferOrder = 0;
1066
+ function resetBuffer() {
1067
+ bufferReasoning = "";
1068
+ bufferCalls.clear();
1069
+ bufferOrder = 0;
1070
+ }
1071
+ function flushBufferToAssistant(id) {
1072
+ if (bufferReasoning.length > 0) {
1073
+ turnReasoningByAssistant.set(id, bufferReasoning);
1074
+ }
1075
+ if (bufferCalls.size > 0) {
1076
+ const copy = new Map();
1077
+ for (const [k, v] of bufferCalls.entries())
1078
+ copy.set(k, { ...v });
1079
+ turnCallsByAssistant.set(id, copy);
1080
+ }
1081
+ resetBuffer();
1082
+ }
1083
+ function bumpOrder(map, key) {
1084
+ const next = (map.get(key) ?? 0) + 1;
1085
+ map.set(key, next);
1086
+ return next;
1087
+ }
1088
+ function ensureBucket(map, key) {
1089
+ let b = map.get(key);
1090
+ if (!b) {
1091
+ b = new Map();
1092
+ map.set(key, b);
1093
+ }
1094
+ return b;
1095
+ }
1096
+ function addInputToBucket(bucket, callId, call, nextOrder) {
1097
+ const prev = bucket.get(callId);
1098
+ if (prev) {
1099
+ // Letta persists multi-chunk tool_call_message rows where each
1100
+ // row carries the **accumulated** args (not a true delta — `row1
1101
+ // = "{"`; `row2 = "{"command":"`; `rowN = "{...}"`). Naive `+=`
1102
+ // concatenation duplicates the args N times, producing
1103
+ // `{...}{...}{...}` in the rendered tool card. The right
1104
+ // primitive is "keep the longest args we've seen": for a true
1105
+ // accumulation Letta produces, the last row is the longest; for
1106
+ // repeated identical rows the length comparison is a no-op.
1107
+ const incoming = call.arguments ?? "";
1108
+ if (incoming.length > prev.input.length) {
1109
+ prev.input = incoming;
1110
+ }
1111
+ if (call.name && !prev.name)
1112
+ prev.name = call.name;
1113
+ }
1114
+ else {
1115
+ bucket.set(callId, {
1116
+ order: nextOrder(),
1117
+ name: call.name ?? "",
1118
+ input: call.arguments ?? "",
1119
+ });
1120
+ }
1121
+ }
1122
+ function addResultToBucket(bucket, callId, result, nextOrder) {
1123
+ const prev = bucket.get(callId);
1124
+ if (prev) {
1125
+ prev.result = result;
1126
+ }
1127
+ else {
1128
+ bucket.set(callId, {
1129
+ order: nextOrder(),
1130
+ name: "",
1131
+ input: "",
1132
+ result,
1133
+ });
1134
+ }
1135
+ }
1136
+ for (const raw of messages) {
1137
+ const m = raw;
1138
+ const runId = m.run_id ?? null;
1139
+ if (runId)
1140
+ noteRunDate(runId, m.date);
1141
+ if (m.message_type === "user_message") {
1142
+ // User boundary resets the chronological buffer (other layers are
1143
+ // run-id / outer-id-keyed, so they don't need a reset).
1144
+ resetBuffer();
1145
+ continue;
1146
+ }
1147
+ if (m.message_type === "reasoning_message") {
1148
+ const text = typeof m.reasoning === "string" ? m.reasoning : "";
1149
+ if (text.length === 0)
1150
+ continue;
1151
+ bufferReasoning += text;
1152
+ if (m.id) {
1153
+ reasoningById.set(m.id, (reasoningById.get(m.id) ?? "") + text);
1154
+ }
1155
+ if (runId) {
1156
+ reasoningByRun.set(runId, (reasoningByRun.get(runId) ?? "") + text);
1157
+ }
1158
+ continue;
1159
+ }
1160
+ if (m.message_type === "tool_call_message") {
1161
+ const list = Array.isArray(m.tool_calls)
1162
+ ? m.tool_calls
1163
+ : m.tool_calls && typeof m.tool_calls === "object"
1164
+ ? [m.tool_calls]
1165
+ : m.tool_call
1166
+ ? [m.tool_call]
1167
+ : [];
1168
+ for (const call of list) {
1169
+ const callId = call.tool_call_id;
1170
+ if (!callId)
1171
+ continue;
1172
+ addInputToBucket(bufferCalls, callId, call, () => bufferOrder++);
1173
+ if (m.id) {
1174
+ addInputToBucket(ensureBucket(callsById, m.id), callId, call, () => bumpOrder(callOrderById, m.id ?? ""));
1175
+ }
1176
+ if (runId) {
1177
+ addInputToBucket(ensureBucket(callsByRun, runId), callId, call, () => bumpOrder(callOrderByRun, runId));
1178
+ }
1179
+ }
1180
+ continue;
1181
+ }
1182
+ if (m.message_type === "tool_return_message") {
1183
+ const returns = Array.isArray(m.tool_returns)
1184
+ ? m.tool_returns
1185
+ : m.tool_call_id
1186
+ ? [
1187
+ {
1188
+ tool_call_id: m.tool_call_id,
1189
+ status: m.status,
1190
+ tool_return: m.tool_return,
1191
+ },
1192
+ ]
1193
+ : [];
1194
+ for (const ret of returns) {
1195
+ const callId = ret.tool_call_id;
1196
+ if (!callId)
1197
+ continue;
1198
+ const status = ret.status === "error" ? "error" : "success";
1199
+ const text = stringifyReturn(ret.tool_return);
1200
+ const result = status === "error"
1201
+ ? { error: text, output: null, status }
1202
+ : { output: text, error: null, status };
1203
+ addResultToBucket(bufferCalls, callId, result, () => bufferOrder++);
1204
+ if (m.id) {
1205
+ addResultToBucket(ensureBucket(callsById, m.id), callId, result, () => bumpOrder(callOrderById, m.id ?? ""));
1206
+ }
1207
+ if (runId) {
1208
+ addResultToBucket(ensureBucket(callsByRun, runId), callId, result, () => bumpOrder(callOrderByRun, runId));
1209
+ }
1210
+ }
1211
+ continue;
1212
+ }
1213
+ if (m.message_type === "assistant_message") {
1214
+ const id = m.id;
1215
+ if (!id) {
1216
+ resetBuffer();
1217
+ continue;
1218
+ }
1219
+ flushBufferToAssistant(id);
1220
+ if (runId) {
1221
+ let set = assistantIdsByRun.get(runId);
1222
+ if (!set) {
1223
+ set = new Set();
1224
+ assistantIdsByRun.set(runId, set);
1225
+ }
1226
+ set.add(id);
1227
+ }
1228
+ continue;
1229
+ }
1230
+ }
1231
+ // ── Union per assistant id: layer 1 (run_id) ∪ layer 2 (same id) ∪
1232
+ // layer 3 (chronological). Tool calls dedupe by tool_call_id.
1233
+ const out = new Map();
1234
+ const allAssistantIds = new Set([
1235
+ ...turnReasoningByAssistant.keys(),
1236
+ ...turnCallsByAssistant.keys(),
1237
+ ...reasoningById.keys(),
1238
+ ...callsById.keys(),
1239
+ ]);
1240
+ // Also pull in assistant ids that only the run_id layer surfaces.
1241
+ for (const set of assistantIdsByRun.values()) {
1242
+ for (const id of set)
1243
+ allAssistantIds.add(id);
1244
+ }
1245
+ // Inverse map for run_id lookup from assistant id.
1246
+ const runIdByAssistant = new Map();
1247
+ for (const [run, set] of assistantIdsByRun.entries()) {
1248
+ for (const id of set)
1249
+ runIdByAssistant.set(id, run);
1250
+ }
1251
+ for (const id of allAssistantIds) {
1252
+ const runId = runIdByAssistant.get(id);
1253
+ // **Reasoning attribution** (multi-step turns): the chronological
1254
+ // turn-walk is the *authoritative* source. It partitions reasoning
1255
+ // text per assistant_message via `flushBufferToAssistant`, so each
1256
+ // bubble carries only the reasoning that arrived between the
1257
+ // previous boundary and its own assistant_message. The `id`-keyed
1258
+ // and `run_id`-keyed layers are NOT siblings the way `??` would
1259
+ // imply — `reasoningByRun` holds the run's *concatenated*
1260
+ // reasoning across every step, so using it as a fallback for
1261
+ // bubbles that have no per-step reasoning (e.g. the agent only
1262
+ // reasoned in step 1 but produced 5 assistant messages) attaches
1263
+ // step 1's text to every bubble in the run. The id/run layers
1264
+ // remain as last-resort fallbacks for orphan-ish cases where the
1265
+ // chronological walk found nothing AND there's exactly one
1266
+ // assistant_message in the run (the single-step case where the
1267
+ // by-run aggregate is unambiguously this bubble's reasoning).
1268
+ const layer3Reasoning = turnReasoningByAssistant.get(id);
1269
+ let reasoning;
1270
+ if (layer3Reasoning !== undefined) {
1271
+ reasoning = layer3Reasoning;
1272
+ }
1273
+ else {
1274
+ const idReasoning = reasoningById.get(id);
1275
+ const runReasoning = runId !== undefined ? reasoningByRun.get(runId) : undefined;
1276
+ // Only trust the run-level reasoning when the run has exactly one
1277
+ // assistant_message — otherwise we'd attach concatenated
1278
+ // multi-step reasoning to a bubble that didn't produce any.
1279
+ const runAssistantCount = runId !== undefined
1280
+ ? (assistantIdsByRun.get(runId)?.size ?? 0)
1281
+ : 0;
1282
+ reasoning =
1283
+ idReasoning ?? (runAssistantCount === 1 ? (runReasoning ?? "") : "");
1284
+ }
1285
+ const merged = new Map();
1286
+ const mergeBucket = (bucket) => {
1287
+ if (!bucket)
1288
+ return;
1289
+ for (const [callId, info] of bucket.entries()) {
1290
+ const prev = merged.get(callId);
1291
+ if (!prev) {
1292
+ merged.set(callId, { ...info });
1293
+ continue;
1294
+ }
1295
+ if (!prev.name && info.name)
1296
+ prev.name = info.name;
1297
+ if (!prev.input && info.input)
1298
+ prev.input = info.input;
1299
+ if (!prev.result && info.result)
1300
+ prev.result = info.result;
1301
+ }
1302
+ };
1303
+ // **Tool-call attribution** (multi-step turns): same problem as
1304
+ // reasoning. The chronological turn-walk (`turnCallsByAssistant`)
1305
+ // partitions tool calls per assistant_message — that's
1306
+ // authoritative for the common multi-step shape Letta produces
1307
+ // (reasoning → tool → return → assistant text, repeat). The
1308
+ // `callsByRun` layer holds ALL tools in the run, so using it as a
1309
+ // sibling source attaches every step's tools to every bubble (the
1310
+ // bug). We use the chronological walk as the **exclusive** source
1311
+ // when it has any tools for this assistant id; the `id`-keyed and
1312
+ // `runId`-keyed layers are pure fallbacks for the orphan-ish cases
1313
+ // where the chronological walk found nothing (e.g. tools arriving
1314
+ // after their owning assistant_message in pathological orderings).
1315
+ const layer3 = turnCallsByAssistant.get(id);
1316
+ if (layer3 && layer3.size > 0) {
1317
+ mergeBucket(layer3);
1318
+ }
1319
+ else {
1320
+ mergeBucket(callsById.get(id));
1321
+ if (runId)
1322
+ mergeBucket(callsByRun.get(runId));
1323
+ }
1324
+ const calls = [...merged.entries()]
1325
+ .sort(([, a], [, b]) => a.order - b.order)
1326
+ .map(([callId, info]) => {
1327
+ const result = info.result;
1328
+ if (result) {
1329
+ return {
1330
+ tool_call_id: callId,
1331
+ tool_name: info.name,
1332
+ input: info.input,
1333
+ status: result.status,
1334
+ output: result.output ?? null,
1335
+ error: result.error ?? null,
1336
+ };
1337
+ }
1338
+ return {
1339
+ tool_call_id: callId,
1340
+ tool_name: info.name,
1341
+ input: info.input,
1342
+ status: "error",
1343
+ output: null,
1344
+ error: "Tool call has no recorded return.",
1345
+ };
1346
+ });
1347
+ if (reasoning.length > 0 || calls.length > 0) {
1348
+ out.set(id, { reasoning, toolCalls: calls });
1349
+ }
1350
+ }
1351
+ // ── Orphan runs: agent runs that produced reasoning / tool calls
1352
+ // but never emitted an assistant_message. Build a separate entry
1353
+ // per run_id; the caller synthesizes a virtual assistant bubble
1354
+ // so the activity is visible in the chat (some agents fulfill the
1355
+ // whole task via tools — verified against real Letta data).
1356
+ const orphanRuns = [];
1357
+ for (const [runId, latestDate] of latestDateByRun.entries()) {
1358
+ if (assistantIdsByRun.has(runId))
1359
+ continue;
1360
+ const reasoning = reasoningByRun.get(runId) ?? "";
1361
+ const callsBucket = callsByRun.get(runId);
1362
+ if (reasoning.length === 0 && (!callsBucket || callsBucket.size === 0)) {
1363
+ continue;
1364
+ }
1365
+ const calls = callsBucket
1366
+ ? [...callsBucket.entries()]
1367
+ .sort(([, a], [, b]) => a.order - b.order)
1368
+ .map(([callId, info]) => {
1369
+ const result = info.result;
1370
+ if (result) {
1371
+ return {
1372
+ tool_call_id: callId,
1373
+ tool_name: info.name,
1374
+ input: info.input,
1375
+ status: result.status,
1376
+ output: result.output ?? null,
1377
+ error: result.error ?? null,
1378
+ };
1379
+ }
1380
+ return {
1381
+ tool_call_id: callId,
1382
+ tool_name: info.name,
1383
+ input: info.input,
1384
+ status: "error",
1385
+ output: null,
1386
+ error: "Tool call has no recorded return.",
1387
+ };
1388
+ })
1389
+ : [];
1390
+ orphanRuns.push({
1391
+ runId,
1392
+ latestDate,
1393
+ aggregate: { reasoning, toolCalls: calls },
1394
+ });
1395
+ }
1396
+ // Sort orphan runs by latestDate ascending so they slot into the
1397
+ // wire list in the right chronological position.
1398
+ orphanRuns.sort((a, b) => a.latestDate < b.latestDate ? -1 : a.latestDate > b.latestDate ? 1 : 0);
1399
+ return { byAssistantId: out, orphanRuns };
1400
+ }
1401
+ /**
1402
+ * Pretty-prints a tool-return value into a string the UI can render in
1403
+ * a `<pre>`. Letta's `tool_return` can be a string OR an array of
1404
+ * content parts; arrays collapse into JSON for visibility.
1405
+ */
1406
+ function stringifyReturn(value) {
1407
+ if (typeof value === "string")
1408
+ return value;
1409
+ if (value == null)
1410
+ return "";
1411
+ try {
1412
+ return JSON.stringify(value, null, 2);
1413
+ }
1414
+ catch {
1415
+ return String(value);
1416
+ }
1417
+ }
1418
+ /**
1419
+ * Projects one Letta `Message` into ai-hub's wire-shaped `AIHubMessage`.
1420
+ *
1421
+ * Letta's history is a discriminated union over a dozen variants; ai-hub
1422
+ * surfaces the three the React client renders directly (user, assistant,
1423
+ * system). Tool-call / tool-return / reasoning / approval messages are
1424
+ * not rendered as standalone bubbles in v1 — the SSE stream weaves them
1425
+ * into the assistant bubble via `tool_call_*` and `reasoning_delta`
1426
+ * events at turn time, but the historical replay collapses them into the
1427
+ * surrounding assistant text. Returning `null` from this projector means
1428
+ * "skip this row" (filtered out by the caller).
1429
+ *
1430
+ * @param msg - one row from `client.conversations.messages.list`.
1431
+ * @returns the wire-shaped message, or `null` if not surfaced in v1.
1432
+ */
1433
+ function toWireMessage(msg) {
1434
+ switch (msg.message_type) {
1435
+ case "user_message":
1436
+ return {
1437
+ id: msg.id,
1438
+ role: "user",
1439
+ content: extractTextContent(msg.content),
1440
+ created_at: msg.date,
1441
+ };
1442
+ case "assistant_message":
1443
+ return {
1444
+ id: msg.id,
1445
+ role: "assistant",
1446
+ content: extractTextContent(msg.content),
1447
+ created_at: msg.date,
1448
+ };
1449
+ case "system_message":
1450
+ return {
1451
+ id: msg.id,
1452
+ role: "system",
1453
+ content: extractTextContent(msg.content),
1454
+ created_at: msg.date,
1455
+ };
1456
+ default:
1457
+ // reasoning_message / hidden_reasoning_message / tool_call_message /
1458
+ // tool_return_message / approval_* / summary_message / event_message
1459
+ // are not v1 history-render targets — see file-level comment.
1460
+ return null;
1461
+ }
1462
+ }
1463
+ /**
1464
+ * Flattens a Letta message's content into a display string. Letta
1465
+ * persists messages as either a plain string or a multi-modal array
1466
+ * of `{type: "text", text}` parts; this normalizes both into a
1467
+ * single string suitable for the chat-UI bubble.
1468
+ *
1469
+ * Along the way it strips `<system-reminder>…</system-reminder>`
1470
+ * blocks that Letta Code injects at the head of every user turn
1471
+ * (agent metadata + permission-mode notice). Those are useful to the
1472
+ * LLM but pure noise in the chat transcript.
1473
+ *
1474
+ * Run on every role (user / assistant / system) so the strip is
1475
+ * consistent regardless of where reminders end up.
1476
+ */
1477
+ function extractTextContent(content) {
1478
+ if (typeof content === "string")
1479
+ return stripSystemReminders(content);
1480
+ if (!Array.isArray(content))
1481
+ return "";
1482
+ let out = "";
1483
+ for (const part of content) {
1484
+ if (part && (part.type === undefined || part.type === "text") && typeof part.text === "string") {
1485
+ out += part.text;
1486
+ }
1487
+ }
1488
+ return stripSystemReminders(out);
1489
+ }
1490
+ /**
1491
+ * Strips `<system-reminder>…</system-reminder>` blocks (and the
1492
+ * blank lines that typically trail them) from a user-facing string.
1493
+ * Letta Code injects these blocks at the head of every user turn —
1494
+ * harmless to the LLM but visible noise in chat history.
1495
+ */
1496
+ function stripSystemReminders(text) {
1497
+ return text
1498
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, "")
1499
+ .replace(/^\s+/, "");
1500
+ }
1501
+ //# sourceMappingURL=sessions.js.map