@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,667 @@
1
+ /**
2
+ * Pure function: Letta Code SDK message → ai-hub SSE events (§9).
3
+ *
4
+ * Input type: `SDKMessage` (Letta Code SDK's stdin/stdout JSON wire
5
+ * shape). Output type: ai-hub's stable SSE events from §9.
6
+ *
7
+ * The translator has **no I/O** — it takes one `SDKMessage` and the
8
+ * current translation state (running text length, open tool-call ids)
9
+ * and returns zero or more ai-hub event payloads + the next state.
10
+ * Persistence, `seq` assignment, and SSE writing are the caller's
11
+ * responsibility (see `state/turnRunner.ts`).
12
+ *
13
+ * ## SDK message variants we handle
14
+ *
15
+ * - `assistant` — streaming chunk of assistant text. Same `uuid` across
16
+ * chunks of one logical assistant message; different `uuid` signals a
17
+ * new logical step (multi-step turn). Emits `assistant_delta`.
18
+ * - `reasoning` — same as `assistant` but for the reasoning channel.
19
+ * Emits `reasoning_delta`. Phase → `model_reasoning`.
20
+ * - `tool_call` — incremental tool-call event. Same `toolCallId` across
21
+ * chunks; `rawArguments` carries one chunk of the partial JSON
22
+ * arguments. First sighting emits `tool_call_started`; subsequent
23
+ * sightings (or first sighting with a non-empty `rawArguments`) emit
24
+ * `tool_call_input_delta`. Phase → `model_tool_input`.
25
+ * - `tool_result` — one event per call. Emits `tool_call_dispatched`
26
+ * (if not already emitted for this id, with the assembled JSON input)
27
+ * then `tool_call_result` (success or error variant).
28
+ * - `result` — terminal. `success: true` → `assistant_message_complete`
29
+ * followed by `stream_end {done}`. `success: false` with
30
+ * `errorCode: "interrupted"` → `stream_end {cancelled}`. Any other
31
+ * failure → `error` + `stream_end {error}`.
32
+ * - `error` — non-terminal error inside the stream (rare; Letta Code
33
+ * surfaces most errors through the terminal `result` event). Emits
34
+ * `error` and does NOT change phase — the runner decides whether the
35
+ * turn fails based on subsequent events.
36
+ * - `retry` — Letta Code's internal retry notice (its own TPM
37
+ * recovery). We drop it; users don't need to see it in v1.x.
38
+ * - `init` — never appears on `stream()`; it's the return of
39
+ * `session.initialize()`. We don't process it here.
40
+ * - `stream_event` — only emitted when `--include-partial-messages` is
41
+ * set on the CLI. We don't enable that flag.
42
+ *
43
+ * @see SPECIFICATIONS.md §9 — wire protocol
44
+ * @see SPECIFICATIONS.md §10 — state machine
45
+ */
46
+
47
+ import type {
48
+ AIHubEvent,
49
+ AIHubEventState,
50
+ AIHubMessage,
51
+ RunningPhase,
52
+ TurnStatus,
53
+ } from "@data-club/ai-hub";
54
+ import type { SDKMessage } from "@letta-ai/letta-code-sdk";
55
+
56
+ /* ────────────────────────────────────────────────────────────────────────── */
57
+ /* Translator state */
58
+ /* ────────────────────────────────────────────────────────────────────────── */
59
+
60
+ /**
61
+ * Carried across events for a single turn. The translator is otherwise
62
+ * pure — no module-level mutable state.
63
+ *
64
+ * The shape is preserved verbatim from the pre-migration translator so
65
+ * `state/turnRunner.ts` keeps its existing call sites unchanged. The
66
+ * `assistantText` / `reasoningText` accumulators are still the canonical
67
+ * assembled payloads (the SDK delivers deltas in `content`); the
68
+ * step-boundary detection now uses `uuid` change instead of Letta's
69
+ * `id` field on `assistant_message`.
70
+ */
71
+ export interface TranslatorState {
72
+ /** Current `(status, phase)` pair. Pre-emission state. */
73
+ status: TurnStatus;
74
+ /** Current sub-phase or `null`. */
75
+ phase: RunningPhase | null;
76
+ /** Running concatenation of assistant deltas across the current step. */
77
+ assistantText: string;
78
+ /** Same idea for reasoning content (only populated when effort != none). */
79
+ reasoningText: string;
80
+ /**
81
+ * Tool-call ids we've already announced via `tool_call_started`. The
82
+ * translator only emits `started` on first sight; subsequent arrivals
83
+ * with the same id emit deltas.
84
+ */
85
+ openToolCalls: Set<string>;
86
+ /**
87
+ * Per-id accumulator for the tool-call's `rawArguments` chunks. Used
88
+ * to assemble the full JSON input we send on `tool_call_dispatched`.
89
+ */
90
+ toolCallArguments: Map<string, string>;
91
+ /**
92
+ * Tool-call ids we've already emitted `tool_call_dispatched` for. The
93
+ * runner doesn't depend on per-call timing, just "dispatched yet or
94
+ * not"; we transition phase → `tool_executing` on first sighting of
95
+ * a `tool_result` for an undispatched call.
96
+ */
97
+ dispatchedToolCalls: Set<string>;
98
+ /**
99
+ * Stable `uuid` of the assistant message currently being assembled,
100
+ * if any. Used by the step-boundary detector to spot when a new
101
+ * logical step starts (new uuid means new step).
102
+ */
103
+ assistantMessageUuid: string | null;
104
+ }
105
+
106
+ /** Builds the per-turn initial state. */
107
+ export function initialTranslatorState(): TranslatorState {
108
+ return {
109
+ status: "running",
110
+ phase: "model_text",
111
+ assistantText: "",
112
+ reasoningText: "",
113
+ openToolCalls: new Set(),
114
+ toolCallArguments: new Map(),
115
+ dispatchedToolCalls: new Set(),
116
+ assistantMessageUuid: null,
117
+ };
118
+ }
119
+
120
+ /** Output of a single translation step. */
121
+ export interface TranslateStep {
122
+ /** Zero-or-more ai-hub event payloads (without `seq` — the runner assigns). */
123
+ events: ReadonlyArray<UnsequencedEvent>;
124
+ /** Next translator state. */
125
+ state: TranslatorState;
126
+ }
127
+
128
+ /**
129
+ * An ai-hub event without the `seq` field. The runner assigns seq when
130
+ * it persists + writes the event. The `error` event additionally
131
+ * carries a required `request_id` that only the runner knows — we
132
+ * strip it from the translator surface so the per-variant translators
133
+ * stay pure (no need to thread the requestId through every call).
134
+ * `emit()` in the runner stamps it on every error event before it goes
135
+ * out on the wire.
136
+ *
137
+ * Uses a distributive conditional so each variant of `AIHubEvent` is
138
+ * mapped independently — without this, the default non-distributive
139
+ * `Omit` would collapse the union to just the keys common to every
140
+ * variant (which loses `data` on every variant except `heartbeat`).
141
+ */
142
+ export type UnsequencedEvent = AIHubEvent extends infer T
143
+ ? T extends AIHubEvent
144
+ ? T extends { event: "error"; data: infer D }
145
+ ? Omit<T, "seq" | "data"> & { data: Omit<D, "request_id"> }
146
+ : Omit<T, "seq">
147
+ : never
148
+ : never;
149
+
150
+ /* ────────────────────────────────────────────────────────────────────────── */
151
+ /* Entry point */
152
+ /* ────────────────────────────────────────────────────────────────────────── */
153
+
154
+ /**
155
+ * Translates one SDK message into zero-or-more ai-hub events.
156
+ *
157
+ * Pure: no I/O, no module state, no clocks (the caller stamps
158
+ * timestamps).
159
+ *
160
+ * @param msg - the Letta Code SDK message.
161
+ * @param state - the translator state.
162
+ * @returns the events to emit + the next state.
163
+ */
164
+ export function translateSdkMessage(
165
+ msg: SDKMessage,
166
+ state: TranslatorState,
167
+ ): TranslateStep {
168
+ // Mid-stream step-boundary detection. A turn that runs in multiple
169
+ // steps (reasoning + tool + assistant text, then reasoning + tool +
170
+ // assistant text again …) produces multiple logical assistant
171
+ // messages within one streaming response. The boundary fires when
172
+ // we've already accumulated assistant text and the next event is
173
+ // either:
174
+ //
175
+ // - a `reasoning` chunk (new step's reasoning starting), or
176
+ // - a `tool_call` chunk (new step's first tool call), or
177
+ // - an `assistant` chunk with a different `uuid` than the one
178
+ // currently being assembled.
179
+ if (isStepBoundary(msg, state)) {
180
+ const boundaryEvents = finalizeAssistantMessageMidStream(state);
181
+ const fresh = resetForNewStep(state);
182
+ const inner = translateInner(msg, fresh);
183
+ return {
184
+ events: [...boundaryEvents, ...inner.events],
185
+ state: inner.state,
186
+ };
187
+ }
188
+ return translateInner(msg, state);
189
+ }
190
+
191
+ /**
192
+ * Returns true when `msg` signals that the current logical assistant
193
+ * step is done and a new one is starting. Triggers only when the
194
+ * translator already has accumulated assistant text — otherwise there's
195
+ * nothing to finalize and the message just enters the current step.
196
+ */
197
+ function isStepBoundary(msg: SDKMessage, state: TranslatorState): boolean {
198
+ if (state.assistantText.length === 0) return false;
199
+ switch (msg.type) {
200
+ case "reasoning":
201
+ case "tool_call":
202
+ return true;
203
+ case "assistant":
204
+ return (
205
+ state.assistantMessageUuid !== null &&
206
+ msg.uuid !== state.assistantMessageUuid
207
+ );
208
+ default:
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Resets the per-step accumulators for a new logical step. Carries
215
+ * `status`/`phase` through — the next event will set them via its
216
+ * variant translator.
217
+ */
218
+ function resetForNewStep(state: TranslatorState): TranslatorState {
219
+ return {
220
+ ...state,
221
+ assistantText: "",
222
+ reasoningText: "",
223
+ openToolCalls: new Set(),
224
+ toolCallArguments: new Map(),
225
+ dispatchedToolCalls: new Set(),
226
+ assistantMessageUuid: null,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Mid-stream sibling of `finalizeAssistantMessage`. Emits the same
232
+ * `assistant_message_complete` for the accumulated step but snapshots
233
+ * the state as `{ status: "running", phase: <current> }` — the turn
234
+ * isn't done yet, only this logical step is. Returns empty when no
235
+ * assistant text was accumulated.
236
+ */
237
+ function finalizeAssistantMessageMidStream(
238
+ state: TranslatorState,
239
+ ): ReadonlyArray<UnsequencedEvent> {
240
+ if (state.assistantText.length === 0) return [];
241
+ const message: AIHubMessage = {
242
+ id: state.assistantMessageUuid ?? "",
243
+ role: "assistant",
244
+ content: state.assistantText,
245
+ created_at: new Date().toISOString(),
246
+ };
247
+ return [
248
+ {
249
+ event: "assistant_message_complete",
250
+ data: { message },
251
+ state: snapshot(state),
252
+ },
253
+ ];
254
+ }
255
+
256
+ /** The original per-variant switch — extracted so the entry-point can
257
+ * wrap it with the step-boundary check above. */
258
+ function translateInner(msg: SDKMessage, state: TranslatorState): TranslateStep {
259
+ switch (msg.type) {
260
+ case "assistant":
261
+ return translateAssistant(msg, state);
262
+ case "reasoning":
263
+ return translateReasoning(msg, state);
264
+ case "tool_call":
265
+ return translateToolCall(msg, state);
266
+ case "tool_result":
267
+ return translateToolResult(msg, state);
268
+ case "error":
269
+ return translateError(msg, state);
270
+ case "result":
271
+ case "init":
272
+ case "retry":
273
+ case "stream_event":
274
+ default:
275
+ // `result` is terminal — the runner detects it and calls
276
+ // `finalizeAssistantMessage` + `finalizeTerminal` itself. The
277
+ // others are either no-ops (`init` never appears in stream();
278
+ // `retry` is internal; `stream_event` requires a flag we don't
279
+ // set) or unknown future variants we pass through.
280
+ return { events: [], state };
281
+ }
282
+ }
283
+
284
+ /* ── per-variant translators ──────────────────────────────────────────────── */
285
+
286
+ /**
287
+ * `assistant` → `assistant_delta`. Each chunk carries the **delta** text
288
+ * in `content` (not cumulative); we forward the delta verbatim and
289
+ * accumulate the full text in `assistantText` for the eventual
290
+ * `assistant_message_complete`.
291
+ */
292
+ function translateAssistant(
293
+ msg: Extract<SDKMessage, { type: "assistant" }>,
294
+ state: TranslatorState,
295
+ ): TranslateStep {
296
+ if (!msg.content || msg.content.length === 0) {
297
+ return { events: [], state };
298
+ }
299
+ const next: TranslatorState = {
300
+ ...state,
301
+ status: "running",
302
+ phase: "model_text",
303
+ assistantText: state.assistantText + msg.content,
304
+ assistantMessageUuid: state.assistantMessageUuid ?? msg.uuid,
305
+ };
306
+ return {
307
+ events: [
308
+ {
309
+ event: "assistant_delta",
310
+ data: { delta: msg.content },
311
+ state: snapshot(next),
312
+ },
313
+ ],
314
+ state: next,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * `reasoning` → `reasoning_delta`. Phase transitions to
320
+ * `model_reasoning` while the block is in flight; the next non-
321
+ * reasoning message drives it back to `model_text`.
322
+ */
323
+ function translateReasoning(
324
+ msg: Extract<SDKMessage, { type: "reasoning" }>,
325
+ state: TranslatorState,
326
+ ): TranslateStep {
327
+ if (!msg.content || msg.content.length === 0) {
328
+ return { events: [], state };
329
+ }
330
+ const next: TranslatorState = {
331
+ ...state,
332
+ status: "running",
333
+ phase: "model_reasoning",
334
+ reasoningText: state.reasoningText + msg.content,
335
+ };
336
+ return {
337
+ events: [
338
+ {
339
+ event: "reasoning_delta",
340
+ data: { delta: msg.content },
341
+ state: snapshot(next),
342
+ },
343
+ ],
344
+ state: next,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * `tool_call` → `tool_call_started` on first sighting of the id, then
350
+ * `tool_call_input_delta` for each chunk of `rawArguments`. The
351
+ * SDK delivers tool-call arguments in multiple chunks (e.g. 3 events
352
+ * for `{"path": "/etc/hostname"}`) sharing the same `toolCallId`.
353
+ *
354
+ * We accumulate `rawArguments` per id so `translateToolResult` can
355
+ * include the assembled parsed input in `tool_call_dispatched`.
356
+ */
357
+ function translateToolCall(
358
+ msg: Extract<SDKMessage, { type: "tool_call" }>,
359
+ state: TranslatorState,
360
+ ): TranslateStep {
361
+ const id = msg.toolCallId;
362
+ if (!id) return { events: [], state };
363
+
364
+ const events: UnsequencedEvent[] = [];
365
+ let next: TranslatorState = {
366
+ ...state,
367
+ status: "running",
368
+ phase: "model_tool_input",
369
+ };
370
+
371
+ if (!next.openToolCalls.has(id)) {
372
+ events.push({
373
+ event: "tool_call_started",
374
+ data: { tool_call_id: id, tool_name: msg.toolName },
375
+ state: snapshot(next),
376
+ });
377
+ const opened = new Set(next.openToolCalls);
378
+ opened.add(id);
379
+ next = { ...next, openToolCalls: opened };
380
+ }
381
+
382
+ // Per-chunk rawArguments accumulation. The 1st event sometimes
383
+ // arrives with toolInput={raw:""} and no rawArguments; subsequent
384
+ // events carry chunks of the JSON args string.
385
+ const chunk = msg.rawArguments ?? "";
386
+ if (chunk.length > 0) {
387
+ const accumulated = (next.toolCallArguments.get(id) ?? "") + chunk;
388
+ const nextArgs = new Map(next.toolCallArguments);
389
+ nextArgs.set(id, accumulated);
390
+ next = { ...next, toolCallArguments: nextArgs };
391
+
392
+ events.push({
393
+ event: "tool_call_input_delta",
394
+ data: { tool_call_id: id, partial_json: chunk },
395
+ state: snapshot(next),
396
+ });
397
+ }
398
+
399
+ return { events, state: next };
400
+ }
401
+
402
+ /**
403
+ * `tool_result` → `tool_call_dispatched` (if not already) +
404
+ * `tool_call_result`.
405
+ *
406
+ * Phase transitions: `model_tool_input` → `tool_executing` (on
407
+ * dispatched) → `model_text` (after the result lands). Per §10.3 a
408
+ * tool error does NOT fail the turn — phase reverts to `model_text`
409
+ * either way.
410
+ */
411
+ function translateToolResult(
412
+ msg: Extract<SDKMessage, { type: "tool_result" }>,
413
+ state: TranslatorState,
414
+ ): TranslateStep {
415
+ const id = msg.toolCallId;
416
+ if (!id) return { events: [], state };
417
+
418
+ const events: UnsequencedEvent[] = [];
419
+ let next: TranslatorState = { ...state };
420
+
421
+ if (!next.dispatchedToolCalls.has(id)) {
422
+ const assembled = next.toolCallArguments.get(id);
423
+ events.push({
424
+ event: "tool_call_dispatched",
425
+ data: {
426
+ tool_call_id: id,
427
+ input: tryParseJson(assembled),
428
+ },
429
+ state: snapshot({ ...next, phase: "tool_executing" }),
430
+ });
431
+ const dispatched = new Set(next.dispatchedToolCalls);
432
+ dispatched.add(id);
433
+ next = {
434
+ ...next,
435
+ phase: "tool_executing",
436
+ dispatchedToolCalls: dispatched,
437
+ };
438
+ }
439
+
440
+ if (msg.isError) {
441
+ events.push({
442
+ event: "tool_call_result",
443
+ data: {
444
+ tool_call_id: id,
445
+ error: { code: "tool_error", message: msg.content ?? "" },
446
+ },
447
+ state: snapshot({ ...next, phase: "model_text" }),
448
+ });
449
+ } else {
450
+ events.push({
451
+ event: "tool_call_result",
452
+ data: { tool_call_id: id, output: msg.content ?? "" },
453
+ state: snapshot({ ...next, phase: "model_text" }),
454
+ });
455
+ }
456
+
457
+ next = { ...next, phase: "model_text" };
458
+ return { events, state: next };
459
+ }
460
+
461
+ /**
462
+ * `error` SDK message → `error` SSE event.
463
+ *
464
+ * Letta Code surfaces most errors through the terminal `result` event
465
+ * (see `finalizeTerminal`), but a few error conditions also produce a
466
+ * mid-stream `error` event with `stopReason` and an optional
467
+ * `apiError`. We pass them through as ai-hub `error` events; the
468
+ * runner decides whether the turn should fail (it typically does, but
469
+ * the terminal `result` event is the authoritative signal).
470
+ */
471
+ function translateError(
472
+ msg: Extract<SDKMessage, { type: "error" }>,
473
+ state: TranslatorState,
474
+ ): TranslateStep {
475
+ const code = classifyErrorCode(msg.errorCode, msg.stopReason);
476
+ return {
477
+ events: [
478
+ {
479
+ event: "error",
480
+ data: {
481
+ code,
482
+ message: msg.message ?? "Letta Code reported an error.",
483
+ },
484
+ state: snapshot(state),
485
+ },
486
+ ],
487
+ state,
488
+ };
489
+ }
490
+
491
+ /* ────────────────────────────────────────────────────────────────────────── */
492
+ /* Finalizers */
493
+ /* ────────────────────────────────────────────────────────────────────────── */
494
+
495
+ /**
496
+ * Emits the closing `assistant_message_complete` event for a turn that
497
+ * accumulated assistant text. Called by the runner exactly once when
498
+ * the terminal `result` SDK message arrives — but only when the turn
499
+ * succeeded. For a cancelled / failed turn we still emit it (the user
500
+ * deserves to see whatever partial text was generated), but the
501
+ * snapshot reflects the terminal status.
502
+ *
503
+ * @param state - the translator state at end-of-stream.
504
+ * @param terminalStatus - the status to snapshot into the envelope.
505
+ * @returns the closing events (empty if no assistant text was produced).
506
+ */
507
+ export function finalizeAssistantMessage(
508
+ state: TranslatorState,
509
+ terminalStatus: TurnStatus = "done",
510
+ ): ReadonlyArray<UnsequencedEvent> {
511
+ if (state.assistantText.length === 0) return [];
512
+ const message: AIHubMessage = {
513
+ id: state.assistantMessageUuid ?? "",
514
+ role: "assistant",
515
+ content: state.assistantText,
516
+ created_at: new Date().toISOString(),
517
+ };
518
+ return [
519
+ {
520
+ event: "assistant_message_complete",
521
+ data: { message },
522
+ state: snapshot({ ...state, status: terminalStatus, phase: null }),
523
+ },
524
+ ];
525
+ }
526
+
527
+ /**
528
+ * Interprets the terminal SDK `result` message into the right closing
529
+ * sequence: `assistant_message_complete` (if any) + the final
530
+ * `stream_end` with the appropriate reason. Pure function — no I/O.
531
+ *
532
+ * Behavior matrix:
533
+ *
534
+ * | result.success | result.errorCode | Emitted closing events |
535
+ * |---|---|---|
536
+ * | true | — | assistant_message_complete + stream_end{done} |
537
+ * | false | "interrupted" | assistant_message_complete (with partial text, if any) + stream_end{cancelled} |
538
+ * | false | anything else | (assistant_message_complete if partial text) + error + stream_end{error} |
539
+ *
540
+ * @param msg - the terminal SDK result message.
541
+ * @param state - the translator state right before the result arrived.
542
+ * @returns the events to emit + the (terminal) state.
543
+ */
544
+ export function finalizeTerminal(
545
+ msg: Extract<SDKMessage, { type: "result" }>,
546
+ state: TranslatorState,
547
+ ): TranslateStep {
548
+ if (msg.success) {
549
+ const completion = finalizeAssistantMessage(state, "done");
550
+ const finalState: TranslatorState = { ...state, status: "done", phase: null };
551
+ return {
552
+ events: [
553
+ ...completion,
554
+ {
555
+ event: "stream_end",
556
+ data: { reason: "done" },
557
+ state: snapshot(finalState),
558
+ },
559
+ ],
560
+ state: finalState,
561
+ };
562
+ }
563
+
564
+ if (msg.errorCode === "interrupted") {
565
+ const completion = finalizeAssistantMessage(state, "cancelled");
566
+ const finalState: TranslatorState = { ...state, status: "cancelled", phase: null };
567
+ return {
568
+ events: [
569
+ ...completion,
570
+ {
571
+ event: "stream_end",
572
+ data: { reason: "cancelled" },
573
+ state: snapshot(finalState),
574
+ },
575
+ ],
576
+ state: finalState,
577
+ };
578
+ }
579
+
580
+ // Any other failure (llm_api_error, max_steps, protocol_error,
581
+ // stream_closed, …) is a hard fail. Still emit the partial assistant
582
+ // text if any was produced, then the error and stream_end.
583
+ const partial = finalizeAssistantMessage(state, "failed");
584
+ const finalState: TranslatorState = { ...state, status: "failed", phase: null };
585
+ const code = classifyErrorCode(msg.errorCode, msg.stopReason);
586
+ return {
587
+ events: [
588
+ ...partial,
589
+ {
590
+ event: "error",
591
+ data: {
592
+ code,
593
+ message: msg.errorDetail ?? msg.error ?? "Letta Code reported a terminal error.",
594
+ },
595
+ state: snapshot(finalState),
596
+ },
597
+ {
598
+ event: "stream_end",
599
+ data: { reason: "error" },
600
+ state: snapshot(finalState),
601
+ },
602
+ ],
603
+ state: finalState,
604
+ };
605
+ }
606
+
607
+ /* ────────────────────────────────────────────────────────────────────────── */
608
+ /* Internal helpers */
609
+ /* ────────────────────────────────────────────────────────────────────────── */
610
+
611
+ /** Snapshots `(status, phase)` for the SSE event envelope. */
612
+ function snapshot(state: TranslatorState): AIHubEventState {
613
+ return { status: state.status, phase: state.phase };
614
+ }
615
+
616
+ /**
617
+ * Best-effort JSON parse. Returns `undefined` on failure so the
618
+ * `tool_call_dispatched.data.input` is null-shaped rather than
619
+ * throwing. If `raw` is `undefined` we also return `undefined` —
620
+ * happens when the tool was called with no args at all.
621
+ */
622
+ function tryParseJson(raw: string | undefined): unknown {
623
+ if (raw === undefined || raw === "") return undefined;
624
+ try {
625
+ return JSON.parse(raw);
626
+ } catch {
627
+ return raw;
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Maps the SDK's `errorCode` + `stopReason` strings onto ai-hub's
633
+ * stable `error.code` enum.
634
+ *
635
+ * SDK error codes we know about:
636
+ * - `approval_conflict` — non-fatal approval flow snag; treat as `internal`
637
+ * (ai-hub doesn't surface approvals to end-users in v1).
638
+ * - `protocol_error` — SDK ↔ CLI wire-shape mismatch; `internal`.
639
+ * - `llm_api_error` — the underlying LLM call failed; `provider_error`.
640
+ * - `max_steps` — agent hit its step ceiling; `internal` for now (a
641
+ * `max_steps` ai-hub code is a future v1.x enhancement).
642
+ * - `interrupted` — handled separately in `finalizeTerminal`.
643
+ * - `stream_closed` — SDK pump closed early; `internal`.
644
+ * - `error` (generic) — fall back to `stopReason` sniffing.
645
+ *
646
+ * The set of legitimate ai-hub `error.code` values lives in
647
+ * `@data-club/ai-hub` (§9.4). We keep `tpm_exhausted` reserved for
648
+ * future use (it is no longer emitted post-migration — the recovery
649
+ * loop is gone — but we keep the enum value so a future restore can
650
+ * use it without protocol-version churn).
651
+ */
652
+ function classifyErrorCode(
653
+ errorCode: string | undefined,
654
+ stopReason: string | undefined,
655
+ ): "tpm_exhausted" | "provider_error" | "internal" {
656
+ if (errorCode === "llm_api_error") return "provider_error";
657
+ if (errorCode === "approval_conflict" || errorCode === "approval_conflict_terminal") return "internal";
658
+ if (errorCode === "protocol_error" || errorCode === "stream_closed") return "internal";
659
+ if (errorCode === "max_steps") return "internal";
660
+ if (errorCode && errorCode !== "error" && errorCode !== "interrupted") return "internal";
661
+ // Fall back on stopReason heuristics. Future Letta Code versions may
662
+ // expose richer error codes; we'll extend the map here.
663
+ const l = (stopReason ?? "").toLowerCase();
664
+ if (l.includes("rate") || l.includes("limit") || l.includes("429")) return "tpm_exhausted";
665
+ if (l.includes("provider") || l.includes("upstream") || l.includes("llm")) return "provider_error";
666
+ return "internal";
667
+ }