@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,860 @@
1
+ /**
2
+ * Hybrid Letta access layer.
3
+ *
4
+ * **Two SDKs, two roles:**
5
+ *
6
+ * - **`@letta-ai/letta-client`** owns *direct REST operations* —
7
+ * `retrieveAgent`, `createConversation`, `listMessages`. These are
8
+ * stable agent / conversation / message CRUD against the Letta
9
+ * server's REST surface; they don't involve model routing or
10
+ * provider shape.
11
+ * - **`@letta-ai/letta-code-sdk`** owns the *chat-streaming path* —
12
+ * `streamUserMessage`. Spawns a Letta Code CLI subprocess per turn;
13
+ * the CLI's internal Pi (Provider Interface) + bundled `models.json`
14
+ * handle model selection, reasoning-tier presets, and provider
15
+ * fallback routing (e.g. Anthropic → Bedrock Opus 4.7). This is what
16
+ * keeps ai-hub current with model releases without code changes.
17
+ *
18
+ * **The hard rule from §6.4 is preserved** — every Letta interaction
19
+ * goes through one of these two SDKs; no raw HTTP, no parallel fetch.
20
+ *
21
+ * Configuration source: `admin_setting.letta_base_url` +
22
+ * `admin_setting.letta_api_key_encrypted` (decrypted with
23
+ * `AI_HUB_ENCRYPTION_KEY`). The Letta-Client wrapper memoizes one SDK
24
+ * instance per `(apiKey, baseURL)` pair so super-admin connection
25
+ * edits propagate without restart. The Letta-Code subprocess inherits
26
+ * `process.env` (which must carry `LETTA_BASE_URL` + `LETTA_API_KEY` —
27
+ * the boot script sets these from the same decrypted DB row).
28
+ *
29
+ * @see SPECIFICATIONS.md §6.4 — Letta integration
30
+ * @see SPECIFICATIONS.md §6.4.1 — first-class SDK surfaces
31
+ */
32
+
33
+ import { eq } from "drizzle-orm";
34
+
35
+ import LettaSdkDefault, {
36
+ NotFoundError,
37
+ } from "@letta-ai/letta-client";
38
+ import {
39
+ resumeSession,
40
+ type SDKMessage,
41
+ type Session,
42
+ } from "@letta-ai/letta-code-sdk";
43
+
44
+ import { decryptString } from "../auth/encryption.js";
45
+ import { adminSetting } from "../db/schema.js";
46
+ import type { DrizzleDB } from "../db/connection.js";
47
+
48
+ import { seedAgentToolsetNone } from "./settingsFileSeed.js";
49
+ import {
50
+ type SubprocessThrottle,
51
+ type ThrottleLease,
52
+ } from "./subprocessThrottle.js";
53
+
54
+ import type {
55
+ LettaMessage,
56
+ LettaSdk,
57
+ } from "./types.js";
58
+
59
+ // Re-export so consumers don't need to know about the throttle module.
60
+ export { ServerOverloadedError, SubprocessThrottle } from "./subprocessThrottle.js";
61
+
62
+ /**
63
+ * Thrown when `forkConversation(sourceId)` or `retrieveConversation(id)`
64
+ * resolves to "no such conversation" (Letta 404). The admin agents
65
+ * validator catches this and returns `400 fork_source_not_found` inline
66
+ * on the field; the chat route surfaces it as a 500 (admin-side
67
+ * validation should have caught it before any user-facing session
68
+ * create reached the fork path).
69
+ *
70
+ * @see docs/server/session-sandboxes.md
71
+ */
72
+ export class ForkSourceNotFoundError extends Error {
73
+ constructor(public readonly conversationId: string) {
74
+ super(`Letta conversation not found: ${conversationId}`);
75
+ this.name = "ForkSourceNotFoundError";
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Letta Code's bundled tools that are ALWAYS disallowed regardless of
81
+ * whether a per-session sandbox is configured.
82
+ *
83
+ * Includes:
84
+ * - Memory tools (the v1 no-memory stance keeps these forbidden; see
85
+ * docs/admin/agent-management.md).
86
+ * - Sub-agent management (`Task` / `TaskOutput` / `TaskStop` / `TodoWrite`
87
+ * / `Skill`) — not exposed to ai-hub end users.
88
+ * - Interactive tools (`AskUserQuestion` / `EnterPlanMode` /
89
+ * `ExitPlanMode`) — hang the headless code path.
90
+ * - Web tools (`WebFetch` / `WebSearch`) — operator-curated agents
91
+ * should reach the web through ADE-attached MCP tools, not Letta
92
+ * Code's defaults.
93
+ *
94
+ * The filesystem tools (Read / Write / Edit / etc.) live in
95
+ * `FILESYSTEM_TOOLS` below and are disallowed ONLY when no sandbox is
96
+ * configured for the session.
97
+ *
98
+ * @see docs/server/session-sandboxes.md — the disallow-list split (Step 12.5)
99
+ */
100
+ const ALWAYS_DISALLOWED_TOOLS: ReadonlyArray<string> = [
101
+ // Sub-agent + task management
102
+ "Task",
103
+ "TaskOutput",
104
+ "TaskStop",
105
+ "TodoWrite",
106
+ // Skills + slash invocation
107
+ "Skill",
108
+ // Memory (Letta Code's built-in memory file convenience tool) — the v1
109
+ // no-memory stance keeps these forbidden even when a sandbox is enabled.
110
+ "memory",
111
+ "memory_apply_patch",
112
+ "memory_insert",
113
+ "memory_replace",
114
+ "memory_rethink",
115
+ // Interactive tools that require runtime user input
116
+ "AskUserQuestion",
117
+ "EnterPlanMode",
118
+ "ExitPlanMode",
119
+ // Web tools (Letta Code adds these in the Claude toolset; harmless to
120
+ // disallow here if absent; future-proofs against upstream additions)
121
+ "WebFetch",
122
+ "WebSearch",
123
+ ];
124
+
125
+ /**
126
+ * Letta Code's bundled filesystem / shell tools — disallowed when the
127
+ * session has NO sandbox configured (no `cwd` to confine them to), and
128
+ * re-enabled when the parent agent's `sandbox_seed_name` is set so the
129
+ * agent can actually use the seeded files (Step 12.5).
130
+ */
131
+ const FILESYSTEM_TOOLS: ReadonlyArray<string> = [
132
+ // File / shell — every preset toolset's core set
133
+ "Read",
134
+ "Write",
135
+ "Edit",
136
+ "ApplyPatch",
137
+ "Bash",
138
+ "Glob",
139
+ "Grep",
140
+ // Snake-case variants from the codex_snake / gemini_snake toolsets
141
+ "read_file_lc",
142
+ "write_file_lc",
143
+ "edit_file_lc",
144
+ "execute_shell_lc",
145
+ ];
146
+
147
+ /* ────────────────────────────────────────────────────────────────────────── */
148
+ /* Project surface */
149
+ /* ────────────────────────────────────────────────────────────────────────── */
150
+
151
+ /**
152
+ * Provider-reported token usage for a single Letta run + the run's
153
+ * last assistant-message id (so callers can stamp the badge on the
154
+ * visible bubble). All token fields are nullable because the
155
+ * producing provider may not report every dimension — Anthropic
156
+ * reports `cache_write_tokens`, others don't; reasoning tokens come
157
+ * back only when the model produced any; etc.
158
+ *
159
+ * Sourced via a single `GET /v1/runs/{run_id}/messages` call.
160
+ */
161
+ export interface RunUsageStats {
162
+ /**
163
+ * Id of the LAST `assistant_message`-typed entry in the run. `null`
164
+ * when the run had no assistant messages (rare — error-only runs).
165
+ * Callers stamp `message_usage` against this id.
166
+ */
167
+ lastAssistantMessageId: string | null;
168
+ promptTokens: number | null;
169
+ completionTokens: number | null;
170
+ totalTokens: number | null;
171
+ cachedInputTokens: number | null;
172
+ cacheWriteTokens: number | null;
173
+ reasoningTokens: number | null;
174
+ contextTokens: number | null;
175
+ }
176
+
177
+ /** Parameters for `streamUserMessage`. */
178
+ export interface StreamUserMessageOpts {
179
+ /** The Letta conversation id (`session.letta_conversation_id`). */
180
+ conversationId: string;
181
+ /**
182
+ * The Letta agent id the session is against. Required so we can
183
+ * re-seed `~/.letta/settings.json` on every spawn — concurrent CLI
184
+ * subprocesses race on writes and can wipe the entry, so the
185
+ * agent-registration-time seed isn't reliable on its own. The
186
+ * seed-on-spawn call below is idempotent (no-op when the entry
187
+ * is already present with the same value).
188
+ */
189
+ lettaAgentId: string;
190
+ /**
191
+ * Plain-text user message body. Sent verbatim to Letta. The admin's
192
+ * system prompt is delivered through Letta's normal channel (the
193
+ * agent's persisted `system` field), so we don't re-inject it here.
194
+ */
195
+ userMessage: string;
196
+ /**
197
+ * Step-12.5 — optional working directory for the spawned Letta Code
198
+ * subprocess. When set, threaded into the SDK's `resumeSession({ cwd })`
199
+ * option (maps to the CLI's `--cwd` flag). Used to confine the agent's
200
+ * filesystem-tool access to a per-session sandbox folder; when
201
+ * provided, the spawn ALSO drops the `FILESYSTEM_TOOLS` group from
202
+ * the `disallowedTools` list so the agent can actually read / write
203
+ * inside that folder.
204
+ *
205
+ * When `undefined`, no `cwd` is passed (the CLI inherits the
206
+ * ai-hub-server process's working directory, as it always has) AND
207
+ * the filesystem tools remain disallowed — today's stateless agent
208
+ * shape.
209
+ *
210
+ * @see docs/server/session-sandboxes.md
211
+ */
212
+ cwd?: string;
213
+ }
214
+
215
+ /**
216
+ * Handle returned by `streamUserMessage`. Wraps the Letta Code SDK
217
+ * `Session` + the subprocess-throttle lease so the caller owns one
218
+ * resource bundle to drain, cancel, and clean up.
219
+ *
220
+ * Lifecycle expectations:
221
+ *
222
+ * 1. Caller iterates `events` to consume `SDKMessage`s in real time.
223
+ * 2. At any point, calling `abort()` sends an `interrupt` control to
224
+ * the spawned CLI. Behavior depends on what the CLI is doing —
225
+ * during model generation interrupts work cleanly; during a
226
+ * long-running tool call the interrupt is currently queued (see
227
+ * migration plan §7 open question 5).
228
+ * 3. Caller MUST call `close()` in a `finally` regardless of how the
229
+ * iteration ended (done, cancelled, error). `close()` terminates
230
+ * the CLI subprocess + releases the throttle lease. Idempotent.
231
+ */
232
+ export interface ChatTurnHandle {
233
+ /** Async iterable of Letta Code SDK messages — caller drains this. */
234
+ events: AsyncIterable<SDKMessage>;
235
+ /**
236
+ * Sends an `interrupt` control to the spawned CLI. Best-effort; if
237
+ * the CLI is mid-tool the interrupt may be queued. The caller still
238
+ * needs to call `close()` to release subprocess + lease.
239
+ */
240
+ abort(): Promise<void>;
241
+ /**
242
+ * Tears down the Letta Code subprocess + releases the throttle slot.
243
+ * Idempotent — safe to call twice. Caller MUST invoke this in a
244
+ * `finally`. Sync because the SDK's `session.close()` is sync.
245
+ */
246
+ close(): void;
247
+ }
248
+
249
+ /**
250
+ * Project-shaped Letta client. The handlers depend on this interface,
251
+ * not on the underlying SDKs, so tests can plug a mock implementation
252
+ * in via `getLettaClient` on the server context.
253
+ *
254
+ * @see SPECIFICATIONS.md §6.4
255
+ */
256
+ export interface LettaClient {
257
+ /**
258
+ * Retrieves a Letta agent by id (via `@letta-ai/letta-client`).
259
+ * Returns `null` (not throws) on miss so the agent-validation flow
260
+ * on `POST/PATCH /admin/agents` can return a 400
261
+ * `letta_agent_not_found` without try/catch noise. Any non-404 SDK
262
+ * error rethrows — the admin handler returns 500.
263
+ *
264
+ * @param lettaAgentId - candidate Letta-side agent id.
265
+ * @returns the agent state or `null` if not found.
266
+ */
267
+ retrieveAgent(lettaAgentId: string): Promise<{
268
+ id: string;
269
+ name: string;
270
+ /** Provider-prefixed model handle (e.g. `"anthropic/claude-opus-4-7"`). */
271
+ model: string;
272
+ } | null>;
273
+ /**
274
+ * Creates a fresh conversation against the given Letta agent (via
275
+ * `@letta-ai/letta-client`). Returns the conversation id we store
276
+ * on the new session row.
277
+ *
278
+ * @param lettaAgentId - the Letta-side agent id.
279
+ * @returns the new conversation's id.
280
+ */
281
+ createConversation(lettaAgentId: string): Promise<{ id: string }>;
282
+ /**
283
+ * Step-12.5 — retrieves a Letta conversation by id (via
284
+ * `client.conversations.retrieve`). Used by the admin agents
285
+ * validator to verify `fork_source_conversation_id` resolves to a
286
+ * real conversation AND that its `agent_id` matches the agent's
287
+ * `letta_agent_id` (Letta's fork API requires same-agent forking).
288
+ *
289
+ * Returns `null` on Letta 404 so the handler can surface
290
+ * `400 fork_source_not_found` without try/catch noise; non-404
291
+ * SDK errors propagate.
292
+ *
293
+ * @param conversationId - the candidate Letta conversation id.
294
+ * @returns `{ id, agentId }` or `null` if not found.
295
+ */
296
+ retrieveConversation(
297
+ conversationId: string,
298
+ ): Promise<{ id: string; agentId: string } | null>;
299
+ /**
300
+ * Step-12.5 — forks an existing Letta conversation into a new
301
+ * conversation belonging to the same agent (via
302
+ * `client.conversations.fork`). The new conversation shares the
303
+ * source's in-context messages and gets a freshly compiled system
304
+ * message reflecting the agent's current memory-block values.
305
+ *
306
+ * Used by `POST /sessions` when the parent agent's
307
+ * `fork_source_conversation_id` is set. The admin endpoint validates
308
+ * source existence + agent match at write time, so this call is
309
+ * expected to succeed in the happy path; a 404 here is treated as
310
+ * "operator deleted the source conversation between admin-validate
311
+ * and user-session-create" and surfaced as `ForkSourceNotFoundError`.
312
+ *
313
+ * @param sourceConversationId - the Letta conversation id to fork.
314
+ * @returns the new conversation's `{ id, agentId }`.
315
+ * @throws ForkSourceNotFoundError on Letta 404.
316
+ */
317
+ forkConversation(
318
+ sourceConversationId: string,
319
+ ): Promise<{ id: string; agentId: string }>;
320
+ /**
321
+ * Lists every message in a conversation, oldest first (via
322
+ * `@letta-ai/letta-client`). Used for `GET /sessions/{id}/messages`
323
+ * history rendering.
324
+ *
325
+ * @param conversationId - the Letta conversation id.
326
+ * @returns the (oldest-first) array of messages.
327
+ */
328
+ listMessages(conversationId: string): Promise<ReadonlyArray<LettaMessage>>;
329
+ /**
330
+ * Deletes a single message from an agent's history by id.
331
+ *
332
+ * The typed SDK only exposes `create` / `list` on conversation messages,
333
+ * so this reaches the Letta server's `DELETE /v1/agents/{agent_id}/messages/{message_id}`
334
+ * endpoint via the SDK's raw HTTP escape hatch (`sdk.delete(...)`) — the
335
+ * same pattern `getRunUsageStats` uses for `sdk.get(...)`. A 404 is treated
336
+ * as "already gone" and swallowed; other errors propagate.
337
+ *
338
+ * Used by the rotation runner to remove the prompt's `user_message` so a
339
+ * rotating starting message's prepared conversation opens with the agent's
340
+ * reply.
341
+ *
342
+ * @param lettaAgentId - the agent the message belongs to.
343
+ * @param messageId - the message id (from `listMessages`).
344
+ */
345
+ deleteMessage(lettaAgentId: string, messageId: string): Promise<void>;
346
+ /**
347
+ * Deletes an entire Letta conversation (via `client.conversations.delete`).
348
+ * Best-effort cleanup of a rotating message's previous prepared
349
+ * conversation after a successful rotation produces a fresh one — a 404 is
350
+ * swallowed; other errors propagate to the caller (which logs + continues).
351
+ *
352
+ * @param conversationId - the Letta conversation id to delete.
353
+ */
354
+ deleteConversation(conversationId: string): Promise<void>;
355
+ /**
356
+ * Fetches provider-reported token usage for a Letta run, including
357
+ * prompt-cache hit / write counts. The SDK's `session.stream()`
358
+ * drops `usage_statistics` messages, so we re-fetch via REST after
359
+ * the turn lands; the runner uses this to stamp a usage badge on
360
+ * the last assistant message.
361
+ *
362
+ * Returns `null` when:
363
+ * - the run id is unknown to Letta (404),
364
+ * - the run hasn't emitted a usage_statistics message yet,
365
+ * - the network call fails (best-effort — the badge is a nicety,
366
+ * not load-bearing).
367
+ *
368
+ * @param runId - Letta run id captured from `SDKAssistantMessage.runId`.
369
+ */
370
+ getRunUsageStats(runId: string): Promise<RunUsageStats | null>;
371
+ /**
372
+ * Spawns a Letta Code CLI subprocess and streams a chat turn against
373
+ * the given conversation. Returns a `ChatTurnHandle` the caller
374
+ * drains + cancels + closes.
375
+ *
376
+ * Pre-flight checks (in order, all may throw):
377
+ * 1. **Subprocess throttle.** `SubprocessThrottle.acquire()` runs
378
+ * first — throws `ServerOverloadedError` if RAM is under the
379
+ * threshold or the hard-cap is reached. The chat route catches
380
+ * this and returns HTTP 503 `server_overloaded`.
381
+ * 2. **Session initialization.** `session.initialize()` may throw if
382
+ * the CLI subprocess fails to spawn or can't reach Letta. The
383
+ * lease is released before the throw so the throttle counter
384
+ * doesn't leak.
385
+ *
386
+ * The Letta Code session is configured for the ai-hub use case:
387
+ * - `permissionMode: "bypassPermissions"` — no interactive approvals
388
+ * (we don't expose them to end-users in v1).
389
+ * - `skillSources: []` — no auto-loaded skills (challenge 2 in the
390
+ * migration brief).
391
+ * - `systemInfoReminder: false` — no first-turn environment dump.
392
+ * - `memfsStartup: "skip"` — no git-backed memory filesystem.
393
+ * - `disallowedTools: ["AskUserQuestion", "EnterPlanMode",
394
+ * "ExitPlanMode"]` — tools that require runtime user input.
395
+ *
396
+ * Toolset suppression is handled out-of-band by the
397
+ * `seedAgentToolsetNone` upsert into `~/.letta/settings.json` at
398
+ * agent-registration time.
399
+ *
400
+ * @param opts - see `StreamUserMessageOpts`.
401
+ * @returns a `ChatTurnHandle` the caller drains and closes.
402
+ * @throws `ServerOverloadedError` when the throttle rejects.
403
+ */
404
+ streamUserMessage(opts: StreamUserMessageOpts): Promise<ChatTurnHandle>;
405
+ }
406
+
407
+ /* ────────────────────────────────────────────────────────────────────────── */
408
+ /* Default implementation */
409
+ /* ────────────────────────────────────────────────────────────────────────── */
410
+
411
+ /** Dependency bundle for `createDefaultLettaClient`. */
412
+ export interface CreateLettaClientDeps {
413
+ /** Drizzle handle. */
414
+ db: DrizzleDB;
415
+ /** Parsed encryption key for decrypting the Letta API key at rest. */
416
+ encryptionKey: Buffer;
417
+ /** Subprocess-spawn throttle (shared across the server instance). */
418
+ subprocessThrottle: SubprocessThrottle;
419
+ /**
420
+ * Env source — used as the fallback when the DB column is empty or
421
+ * whitespace. Production passes `process.env`; tests pass a synthesized
422
+ * record so the env precedence behaviour is exercisable without
423
+ * touching the real environment. Re-read on every `getSdk()` call so a
424
+ * server restart with a different `.env` reflects immediately.
425
+ */
426
+ envSource?: Record<string, string | undefined>;
427
+ /**
428
+ * Override the Letta Client SDK constructor — only used by tests.
429
+ * Production passes the SDK's `Letta` class (or omits to use the
430
+ * default).
431
+ */
432
+ sdkConstructor?: typeof LettaSdkDefault;
433
+ /**
434
+ * Override `resumeSession` from `@letta-ai/letta-code-sdk` — only
435
+ * used by tests, which inject a fake session factory.
436
+ */
437
+ resumeSessionFn?: typeof resumeSession;
438
+ }
439
+
440
+ /**
441
+ * Constructs the default `LettaClient` implementation.
442
+ *
443
+ * Reads `admin_setting.letta_base_url` + `letta_api_key_encrypted` on
444
+ * each Letta-Client call (no in-flight polling — fresh-on-demand) and
445
+ * memoizes the underlying SDK instance against `(apiKey, baseURL)` so
446
+ * super-admin connection-settings edits propagate without restart.
447
+ *
448
+ * Throws on the first Letta-Client call if `admin_setting` has no
449
+ * `letta_base_url` or no decryptable API key.
450
+ *
451
+ * @param deps - see `CreateLettaClientDeps`.
452
+ * @returns the project-shaped Letta client.
453
+ *
454
+ * @see SPECIFICATIONS.md §6.4
455
+ * @see SPECIFICATIONS.md §11 — `admin_setting` schema
456
+ */
457
+ export function createDefaultLettaClient(
458
+ deps: CreateLettaClientDeps,
459
+ ): LettaClient {
460
+ const SdkCtor = deps.sdkConstructor ?? LettaSdkDefault;
461
+ const resume = deps.resumeSessionFn ?? resumeSession;
462
+ let cached:
463
+ | { apiKey: string; baseURL: string; sdk: LettaSdk }
464
+ | null = null;
465
+
466
+ const envSource = deps.envSource ?? (process.env as Record<string, string | undefined>);
467
+
468
+ /**
469
+ * Resolves the current `(apiKey, baseURL)` pair and returns a memoized
470
+ * Letta-Client SDK instance.
471
+ *
472
+ * **API key precedence** (per the operator-spec'd model):
473
+ * 1. If `admin_setting.letta_api_key_encrypted` decrypts to a non-empty,
474
+ * non-whitespace string → use that (super-admin set it via the
475
+ * admin panel; DB wins over env).
476
+ * 2. Otherwise → fall back to `env.LETTA_API_KEY` (whitespace-trimmed).
477
+ * 3. Otherwise → throw — neither source has a usable value.
478
+ *
479
+ * Evaluated on every call, so:
480
+ * - A super-admin PATCH to /admin/settings takes effect immediately.
481
+ * - Clearing the value via the admin panel (empty/whitespace) falls
482
+ * back to env on the very next request.
483
+ * - A new `.env` after a restart is reflected immediately when the DB
484
+ * column is empty.
485
+ */
486
+ function resolveApiKey(): { apiKey: string; source: "db" | "env" } {
487
+ const settings = deps.db
488
+ .select()
489
+ .from(adminSetting)
490
+ .where(eq(adminSetting.id, 1))
491
+ .get();
492
+ if (settings?.lettaApiKeyEncrypted) {
493
+ const decrypted = decryptString(deps.encryptionKey, settings.lettaApiKeyEncrypted);
494
+ if (decrypted.trim().length > 0) {
495
+ return { apiKey: decrypted, source: "db" };
496
+ }
497
+ }
498
+ const envKey = envSource.LETTA_API_KEY;
499
+ if (envKey && envKey.trim().length > 0) {
500
+ return { apiKey: envKey, source: "env" };
501
+ }
502
+ throw new Error(
503
+ "No Letta API key configured. Set LETTA_API_KEY in .env, OR PATCH it via /admin/settings.",
504
+ );
505
+ }
506
+
507
+ function getSdk(): LettaSdk {
508
+ const settings = deps.db
509
+ .select()
510
+ .from(adminSetting)
511
+ .where(eq(adminSetting.id, 1))
512
+ .get();
513
+ if (!settings?.lettaBaseUrl) {
514
+ throw new Error(
515
+ "admin_setting.letta_base_url is empty — configure it via /admin/settings or LETTA_BASE_URL.",
516
+ );
517
+ }
518
+ const { apiKey } = resolveApiKey();
519
+ // The Letta Client SDK appends its own `/v1/...` paths, so a
520
+ // `letta_base_url` like `http://localhost:8283/v1` produces
521
+ // double-v1 calls. Strip a trailing `/v1` defensively.
522
+ const baseURL = settings.lettaBaseUrl.replace(/\/v1\/?$/i, "");
523
+ if (cached && cached.apiKey === apiKey && cached.baseURL === baseURL) {
524
+ return cached.sdk;
525
+ }
526
+ const sdk = new SdkCtor({ apiKey, baseURL });
527
+ cached = { apiKey, baseURL, sdk };
528
+ return sdk;
529
+ }
530
+
531
+ return {
532
+ async retrieveAgent(lettaAgentId) {
533
+ const sdk = getSdk();
534
+ try {
535
+ const state = await sdk.agents.retrieve(lettaAgentId);
536
+ return { id: state.id, name: state.name, model: state.model ?? "" };
537
+ } catch (err) {
538
+ if (err instanceof NotFoundError) return null;
539
+ throw err;
540
+ }
541
+ },
542
+
543
+ async createConversation(lettaAgentId) {
544
+ const sdk = getSdk();
545
+ const conv = await sdk.conversations.create({ agent_id: lettaAgentId });
546
+ return { id: conv.id };
547
+ },
548
+
549
+ async retrieveConversation(conversationId) {
550
+ const sdk = getSdk();
551
+ try {
552
+ const conv = await sdk.conversations.retrieve(conversationId);
553
+ return { id: conv.id, agentId: conv.agent_id };
554
+ } catch (err) {
555
+ if (err instanceof NotFoundError) return null;
556
+ throw err;
557
+ }
558
+ },
559
+
560
+ async forkConversation(sourceConversationId) {
561
+ const sdk = getSdk();
562
+ try {
563
+ // The Letta-Client SDK exposes `fork(conversationId, params?)`.
564
+ // We don't pass a body — defaults are fine for our use case
565
+ // (forked conversation is not hidden, no extra params needed).
566
+ const conv = await sdk.conversations.fork(sourceConversationId);
567
+ return { id: conv.id, agentId: conv.agent_id };
568
+ } catch (err) {
569
+ if (err instanceof NotFoundError) {
570
+ throw new ForkSourceNotFoundError(sourceConversationId);
571
+ }
572
+ throw err;
573
+ }
574
+ },
575
+
576
+ async listMessages(conversationId) {
577
+ const sdk = getSdk();
578
+ const out: LettaMessage[] = [];
579
+ const page = await sdk.conversations.messages.list(conversationId);
580
+ for await (const msg of page) {
581
+ out.push(msg);
582
+ }
583
+ return out;
584
+ },
585
+
586
+ async deleteMessage(lettaAgentId, messageId) {
587
+ const sdk = getSdk();
588
+ try {
589
+ // The typed `conversations.messages` resource is create/list-only;
590
+ // the message-delete capability lives on the agent-scoped REST route.
591
+ // Same raw-HTTP escape hatch `getRunUsageStats` uses for `sdk.get`.
592
+ await sdk.delete(
593
+ `/v1/agents/${encodeURIComponent(lettaAgentId)}/messages/${encodeURIComponent(messageId)}`,
594
+ );
595
+ } catch (err) {
596
+ // Already gone is fine — the goal state (message absent) is reached.
597
+ if (err instanceof NotFoundError) return;
598
+ throw err;
599
+ }
600
+ },
601
+
602
+ async deleteConversation(conversationId) {
603
+ const sdk = getSdk();
604
+ try {
605
+ await sdk.conversations.delete(conversationId);
606
+ } catch (err) {
607
+ if (err instanceof NotFoundError) return;
608
+ throw err;
609
+ }
610
+ },
611
+
612
+ async getRunUsageStats(runId) {
613
+ const sdk = getSdk();
614
+ // Two endpoints in parallel:
615
+ // - `/v1/runs/{id}/messages` carries persisted assistant
616
+ // messages — we need the LAST one's id to stamp the badge.
617
+ // The `usage_statistics` SDK stream event is NOT persisted
618
+ // here; we used to look for it but it's stream-only.
619
+ // - `/v1/runs/{id}/steps` carries per-LLM-call usage with the
620
+ // full cache breakdown (`cached_input_tokens`,
621
+ // `cache_write_tokens`, `reasoning_tokens`). For multi-step
622
+ // turns we sum across steps; for single-step turns we just
623
+ // read the one row.
624
+ //
625
+ // Best-effort: any failure on either side returns null and the
626
+ // badge just doesn't render. Never blocks the chat turn.
627
+ let messagesRaw: unknown;
628
+ let stepsRaw: unknown;
629
+ try {
630
+ [messagesRaw, stepsRaw] = await Promise.all([
631
+ sdk.get(`/v1/runs/${runId}/messages`),
632
+ sdk.get(`/v1/runs/${runId}/steps`),
633
+ ]);
634
+ } catch {
635
+ return null;
636
+ }
637
+
638
+ // Pull the last assistant message id (anchor for the badge).
639
+ let lastAssistantMessageId: string | null = null;
640
+ if (Array.isArray(messagesRaw)) {
641
+ for (const item of messagesRaw) {
642
+ if (!item || typeof item !== "object") continue;
643
+ const m = item as Record<string, unknown>;
644
+ if (m["message_type"] === "assistant_message" && typeof m["id"] === "string") {
645
+ lastAssistantMessageId = m["id"];
646
+ }
647
+ }
648
+ }
649
+
650
+ // Sum tokens across steps. Missing fields stay null in the sum
651
+ // (the sum only goes non-null if at least one step reported it),
652
+ // so providers that don't expose cache_write keep it null at the
653
+ // top level rather than silently appearing as 0.
654
+ if (!Array.isArray(stepsRaw)) return null;
655
+ const sum = {
656
+ promptTokens: null as number | null,
657
+ completionTokens: null as number | null,
658
+ totalTokens: null as number | null,
659
+ cachedInputTokens: null as number | null,
660
+ cacheWriteTokens: null as number | null,
661
+ reasoningTokens: null as number | null,
662
+ contextTokens: null as number | null,
663
+ };
664
+ const addInto = (key: keyof typeof sum, v: unknown): void => {
665
+ if (typeof v !== "number") return;
666
+ sum[key] = (sum[key] ?? 0) + v;
667
+ };
668
+ let anyStep = false;
669
+ for (const item of stepsRaw) {
670
+ if (!item || typeof item !== "object") continue;
671
+ anyStep = true;
672
+ const s = item as Record<string, unknown>;
673
+ addInto("promptTokens", s["prompt_tokens"]);
674
+ addInto("completionTokens", s["completion_tokens"]);
675
+ addInto("totalTokens", s["total_tokens"]);
676
+ addInto("cachedInputTokens", s["cached_input_tokens"]);
677
+ addInto("cacheWriteTokens", s["cache_write_tokens"]);
678
+ addInto("reasoningTokens", s["reasoning_tokens"]);
679
+ }
680
+ if (!anyStep) return null;
681
+ return {
682
+ lastAssistantMessageId,
683
+ ...sum,
684
+ };
685
+ },
686
+
687
+ async streamUserMessage(opts) {
688
+ // Throttle gate first — synchronous, throws ServerOverloadedError.
689
+ // Caller's chat-route catch turns that into HTTP 503.
690
+ const lease: ThrottleLease = deps.subprocessThrottle.acquire();
691
+
692
+ // Sync `process.env.LETTA_API_KEY` to the currently-resolved value
693
+ // (DB > env precedence — see `resolveApiKey`). The Letta Code
694
+ // subprocess inherits `{...process.env}`, so whatever is in
695
+ // process.env at spawn time is what the CLI sends to Letta. Mutating
696
+ // it here on every spawn means a super-admin's PATCH to
697
+ // /admin/settings takes effect on the very next chat turn without
698
+ // a process restart — matches the same hot-reload behavior the
699
+ // direct-REST `getSdk()` path already had via SDK memoization on
700
+ // the `(apiKey, baseURL)` pair.
701
+ //
702
+ // If resolveApiKey throws (no usable value anywhere), we release
703
+ // the throttle lease before propagating — otherwise the slot would
704
+ // be leaked.
705
+ try {
706
+ const resolved = resolveApiKey();
707
+ process.env.LETTA_API_KEY = resolved.apiKey;
708
+ } catch (err) {
709
+ lease.release();
710
+ throw err;
711
+ }
712
+
713
+ // Letta Code's CLI reads `LETTA_BASE_URL` from env and appends
714
+ // `/v1/...` to it. If the operator set the env to
715
+ // `http://localhost:8283/v1` (matching the Letta Client SDK's
716
+ // expectation), the CLI produces double-v1 URLs that 404 — the
717
+ // CLI surfaces this as "Failed to connect to Letta server /
718
+ // credentials may be invalid", which is misleading. Strip the
719
+ // trailing `/v1` in process.env on every spawn (idempotent —
720
+ // already-stripped values pass through unchanged). Operators
721
+ // who use the bare-host form aren't affected. (Also done in
722
+ // `bootstrap.ts` at boot for the primary case; this is
723
+ // defense-in-depth.)
724
+ const rawBaseUrl = process.env.LETTA_BASE_URL;
725
+ if (rawBaseUrl) {
726
+ const stripped = rawBaseUrl.replace(/\/v1\/?$/i, "");
727
+ if (stripped !== rawBaseUrl) {
728
+ process.env.LETTA_BASE_URL = stripped;
729
+ }
730
+ }
731
+
732
+ // Re-seed `~/.letta/settings.json` with `{toolset: "none"}` for
733
+ // this agent on every spawn. Necessary because concurrent CLI
734
+ // subprocesses race on settings-file writes (each maintains its
735
+ // own in-memory snapshot, writes back on exit, last-writer-wins)
736
+ // and the agent-registration-time seed can be wiped between
737
+ // spawns. Idempotent — when the entry already matches, this is
738
+ // a no-op (doesn't touch the file's mtime). Best-effort: a
739
+ // write failure logs but doesn't block the chat turn (the shim
740
+ // is the primary mechanism; the settings file is defense-in-
741
+ // depth + TUI-compatibility).
742
+ try {
743
+ seedAgentToolsetNone({
744
+ agentId: opts.lettaAgentId,
745
+ lettaBaseUrl: process.env.LETTA_BASE_URL,
746
+ });
747
+ } catch {
748
+ // intentionally ignored — see above
749
+ }
750
+
751
+ // Point the SDK at our CLI shim. The shim injects `--tools ""`
752
+ // into argv before invoking the real Letta Code CLI, which
753
+ // mimics the TUI `/toolset none` action — both end up with an
754
+ // empty `toolRegistry` so the LLM sees only the agent's
755
+ // ADE-attached tools (no Read/Write/Bash/etc. from Letta
756
+ // Code's bundled presets). See `lettaCliShim.mjs` for the
757
+ // full rationale + traceability to the Letta Code source.
758
+ //
759
+ // This is set once per spawn (cheap — just a process.env write).
760
+ // We use the migration's source path so the shim is in the
761
+ // same repo and ships with ai-hub-server.
762
+ if (process.env.LETTA_CLI_PATH === undefined) {
763
+ const { dirname, join } = await import("node:path");
764
+ const { fileURLToPath } = await import("node:url");
765
+ const here = dirname(fileURLToPath(import.meta.url));
766
+ process.env.LETTA_CLI_PATH = join(here, "lettaCliShim.mjs");
767
+ }
768
+
769
+ // We send the raw user message verbatim; the admin's system prompt
770
+ // flows through Letta's normal channel (the agent's persisted
771
+ // `system` field).
772
+ const userContent: string = opts.userMessage;
773
+
774
+ // Step-12.5 — disallow-list composition.
775
+ //
776
+ // When the parent session has a sandbox folder (opts.cwd set), the
777
+ // filesystem tools are RE-ENABLED so the agent can use the seeded
778
+ // files. Without a sandbox, those tools stay disallowed (today's
779
+ // stateless agent shape; regression-locked by a snapshot test).
780
+ //
781
+ // The always-disallowed group (memory / sub-agent / interactive /
782
+ // web) stays disallowed regardless — the v1 no-memory stance is
783
+ // unchanged. See docs/server/session-sandboxes.md.
784
+ const disallowedTools: string[] =
785
+ opts.cwd === undefined
786
+ ? [...ALWAYS_DISALLOWED_TOOLS, ...FILESYSTEM_TOOLS]
787
+ : [...ALWAYS_DISALLOWED_TOOLS];
788
+
789
+ let session: Session | null = null;
790
+ try {
791
+ session = resume(opts.conversationId, {
792
+ permissionMode: "bypassPermissions",
793
+ skillSources: [],
794
+ systemInfoReminder: false,
795
+ memfsStartup: "skip",
796
+ // `disallowedTools` mirrors the effect of TUI `/toolset none` —
797
+ // the persisted-setting approach (`~/.letta/settings.json`) is
798
+ // only consulted by Letta Code's interactive code path, NOT
799
+ // the headless `--input-format stream-json` mode the SDK uses.
800
+ // And `--toolset none` is rejected as a CLI arg in headless
801
+ // mode (only auto/codex/default/gemini accepted there).
802
+ //
803
+ // Adding tools to this list as Letta Code adds them upstream
804
+ // is maintenance debt — grep `ALWAYS_DISALLOWED_TOOLS` /
805
+ // `FILESYSTEM_TOOLS` to find the constants. The longer-term
806
+ // fix is upstream: either accept `--toolset none` in headless
807
+ // mode or expose a `--no-tools` flag. Filed against
808
+ // letta-ai/letta-code (TBD).
809
+ disallowedTools,
810
+ // Step-12.5 — per-session sandbox folder; see opts.cwd JSDoc.
811
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
812
+ });
813
+ await session.initialize();
814
+ await session.send(userContent);
815
+ } catch (err) {
816
+ // Spawn / init / send failure — release lease + tear down the
817
+ // subprocess if we got that far, then re-throw for the caller.
818
+ if (session) {
819
+ try {
820
+ session.close();
821
+ } catch {
822
+ // best-effort
823
+ }
824
+ }
825
+ lease.release();
826
+ throw err;
827
+ }
828
+
829
+ const activeSession = session;
830
+ let closed = false;
831
+ return {
832
+ events: activeSession.stream(),
833
+ async abort() {
834
+ try {
835
+ await activeSession.abort();
836
+ } catch {
837
+ // The CLI may already be tearing down — swallow to keep
838
+ // the cancel route idempotent. `close()` still runs.
839
+ }
840
+ },
841
+ close() {
842
+ if (closed) return;
843
+ closed = true;
844
+ try {
845
+ activeSession.close();
846
+ } catch {
847
+ // best-effort
848
+ }
849
+ lease.release();
850
+ },
851
+ };
852
+ },
853
+ };
854
+ }
855
+
856
+ /* ────────────────────────────────────────────────────────────────────────── */
857
+ /* Re-exports */
858
+ /* ────────────────────────────────────────────────────────────────────────── */
859
+
860
+ export type { LettaMessage, LettaSdk } from "./types.js";