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