@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,251 @@
1
+ /**
2
+ * Dynamic RAM-based concurrent-spawn throttle for Letta Code subprocesses.
3
+ *
4
+ * Every time we're about to spawn a Letta Code CLI subprocess (which happens
5
+ * once per chat turn — see `letta/client.ts` → `streamUserMessage`), we call
6
+ * `acquire()` first. If the host's *available* RAM has dropped below a
7
+ * configured threshold OR we've already got the configured hard-cap of live
8
+ * subprocesses, `acquire()` throws `ServerOverloadedError`. The chat route
9
+ * catches that and returns HTTP 503 with the `server_overloaded` SSE error
10
+ * event so the React client can surface a "server is busy" banner.
11
+ *
12
+ * **Why MemAvailable, not MemFree.** Linux uses unused RAM as page cache;
13
+ * `os.freemem()` / `/proc/meminfo`'s `MemFree` line reports memory that's
14
+ * *literally* unallocated, which on any healthy server is misleadingly low
15
+ * (often ~5% even when ~50% is actually allocatable without swapping).
16
+ * `/proc/meminfo`'s `MemAvailable` line is the kernel's own estimate of
17
+ * "memory that could be allocated without entering swap" — that's what we
18
+ * compare against the threshold.
19
+ *
20
+ * **Why two gates and not one.** The RAM gate is the primary throttle and
21
+ * adapts to whatever EC2 instance the deployment runs on. The hard-cap is a
22
+ * circuit breaker: if our `/proc/meminfo` math is ever wrong (kernel reports
23
+ * stale numbers under load, container cgroup limits trip earlier than host
24
+ * MemAvailable suggests, …), an unbounded subprocess pool would still be
25
+ * able to OOM-kill the box. The hard-cap is the floor on how bad it can get.
26
+ *
27
+ * @see ../../docs/operations/env-vars.md
28
+ */
29
+
30
+ import { readFileSync } from "node:fs";
31
+ import { totalmem } from "node:os";
32
+
33
+ /* ────────────────────────────────────────────────────────────────────────── */
34
+ /* Config */
35
+ /* ────────────────────────────────────────────────────────────────────────── */
36
+
37
+ /** Default minimum percentage of total RAM that must be available before we'll
38
+ * spawn a new subprocess. Overridable via env. */
39
+ export const DEFAULT_RAM_THRESHOLD_PCT = 20;
40
+ /** Default hard cap on concurrent live subprocesses. Overridable via env. */
41
+ export const DEFAULT_HARD_CAP = 100;
42
+
43
+ /**
44
+ * Resolved throttle config. Pulled from env at construction time; tests pass
45
+ * an explicit one to bypass env reading.
46
+ */
47
+ export interface ThrottleConfig {
48
+ /** Minimum % of total RAM that must remain available to accept a spawn. */
49
+ ramThresholdPct: number;
50
+ /** Maximum concurrent live subprocesses, regardless of RAM. */
51
+ hardCap: number;
52
+ }
53
+
54
+ /**
55
+ * Reads throttle config from the process environment, with defaults.
56
+ *
57
+ * Env vars:
58
+ * - `AI_HUB_SUBPROCESS_RAM_THRESHOLD_PCT` — integer 0..100. Defaults to 20.
59
+ * A value <= 0 disables the RAM gate (every acquire passes the RAM check).
60
+ * A value >= 100 means every acquire fails the RAM check (useful for
61
+ * testing the overload path).
62
+ * - `AI_HUB_SUBPROCESS_HARD_CAP` — positive integer. Defaults to 100.
63
+ *
64
+ * @returns the parsed config bundle.
65
+ */
66
+ export function readThrottleConfigFromEnv(): ThrottleConfig {
67
+ const rawPct = process.env.AI_HUB_SUBPROCESS_RAM_THRESHOLD_PCT;
68
+ const rawCap = process.env.AI_HUB_SUBPROCESS_HARD_CAP;
69
+ return {
70
+ ramThresholdPct: parseIntInRange(rawPct, 0, 100, DEFAULT_RAM_THRESHOLD_PCT),
71
+ hardCap: parseIntInRange(rawCap, 1, 10_000, DEFAULT_HARD_CAP),
72
+ };
73
+ }
74
+
75
+ function parseIntInRange(raw: string | undefined, min: number, max: number, fallback: number): number {
76
+ if (raw === undefined || raw === "") return fallback;
77
+ const parsed = Number.parseInt(raw, 10);
78
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max) return fallback;
79
+ return parsed;
80
+ }
81
+
82
+ /* ────────────────────────────────────────────────────────────────────────── */
83
+ /* Memory reader */
84
+ /* ────────────────────────────────────────────────────────────────────────── */
85
+
86
+ /**
87
+ * Reader for the host's MemAvailable + MemTotal. Returns kilobytes (matches
88
+ * `/proc/meminfo` units). Tests inject a fake reader so they don't depend on
89
+ * the host's actual memory state.
90
+ */
91
+ export interface MemReader {
92
+ /** @returns `{availableKb, totalKb}` snapshot at the moment of call. */
93
+ read(): { availableKb: number; totalKb: number };
94
+ }
95
+
96
+ /**
97
+ * Default reader — parses `/proc/meminfo` directly. Falls back to
98
+ * `os.totalmem()` for `totalKb` when /proc is unreadable (non-Linux), and
99
+ * returns `MemTotal` as `availableKb` in that case so the RAM gate is
100
+ * effectively disabled there (the hard-cap still applies).
101
+ *
102
+ * This file lives at a stable kernel-exposed path on every Linux host
103
+ * (including in Docker without any special mounts) so no privileges are
104
+ * needed.
105
+ *
106
+ * @see https://www.kernel.org/doc/Documentation/filesystems/proc.txt — the
107
+ * meminfo section documents MemAvailable's semantics ("estimate of how
108
+ * much memory is available for starting new applications, without
109
+ * swapping").
110
+ */
111
+ export const procMeminfoReader: MemReader = {
112
+ read() {
113
+ try {
114
+ const raw = readFileSync("/proc/meminfo", "utf8");
115
+ const total = raw.match(/^MemTotal:\s+(\d+)\s+kB/m);
116
+ const available = raw.match(/^MemAvailable:\s+(\d+)\s+kB/m);
117
+ if (total && available) {
118
+ return { totalKb: Number(total[1]), availableKb: Number(available[1]) };
119
+ }
120
+ } catch {
121
+ // Fall through to the os.totalmem fallback.
122
+ }
123
+ const totalKb = Math.round(totalmem() / 1024);
124
+ return { totalKb, availableKb: totalKb };
125
+ },
126
+ };
127
+
128
+ /* ────────────────────────────────────────────────────────────────────────── */
129
+ /* Throttle */
130
+ /* ────────────────────────────────────────────────────────────────────────── */
131
+
132
+ /**
133
+ * Typed error thrown by `SubprocessThrottle.acquire()` when the host is too
134
+ * loaded to accept another subprocess. The chat route catches this and
135
+ * returns HTTP 503 with an `error {server_overloaded}` SSE event.
136
+ */
137
+ export class ServerOverloadedError extends Error {
138
+ /** Stable error code for the SSE wire protocol's `error.code` field. */
139
+ readonly code = "server_overloaded" as const;
140
+ /** Which gate tripped — useful for logs + the eventual operator playbook. */
141
+ readonly gate: "ram" | "hard_cap";
142
+ /** Snapshot of the trigger at the moment we rejected. */
143
+ readonly detail: {
144
+ /** MemAvailable as a fraction of MemTotal at the time of rejection. */
145
+ availablePct: number;
146
+ /** Live subprocess count at the time of rejection. */
147
+ liveCount: number;
148
+ /** Configured RAM threshold (percent). */
149
+ ramThresholdPct: number;
150
+ /** Configured hard cap. */
151
+ hardCap: number;
152
+ };
153
+
154
+ constructor(gate: "ram" | "hard_cap", detail: ServerOverloadedError["detail"]) {
155
+ super(
156
+ gate === "ram"
157
+ ? `Server overloaded: only ${detail.availablePct.toFixed(1)}% RAM available (< ${detail.ramThresholdPct}% required).`
158
+ : `Server overloaded: ${detail.liveCount} live subprocesses (>= ${detail.hardCap} hard cap).`,
159
+ );
160
+ this.name = "ServerOverloadedError";
161
+ this.gate = gate;
162
+ this.detail = detail;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * A leased slot in the throttle's live-subprocess counter. Returned by
168
+ * `acquire()`; callers must call `release()` exactly once (use try/finally).
169
+ */
170
+ export interface ThrottleLease {
171
+ /** Releases the slot back to the throttle. Idempotent — calling twice
172
+ * is a no-op. */
173
+ release(): void;
174
+ }
175
+
176
+ /**
177
+ * The throttle.
178
+ *
179
+ * Construct one per ai-hub-server process (it's stateful — the live-count
180
+ * counter is in-memory). Production wires this into `AIHubServerContext` via
181
+ * `createFetchHandler`; tests pass a fake `MemReader` to drive the RAM gate
182
+ * deterministically.
183
+ */
184
+ export class SubprocessThrottle {
185
+ readonly config: ThrottleConfig;
186
+ private readonly memReader: MemReader;
187
+ private liveCount = 0;
188
+
189
+ /**
190
+ * @param config - resolved throttle config (use `readThrottleConfigFromEnv`).
191
+ * @param memReader - mem reader (defaults to /proc/meminfo).
192
+ */
193
+ constructor(config: ThrottleConfig, memReader: MemReader = procMeminfoReader) {
194
+ this.config = config;
195
+ this.memReader = memReader;
196
+ }
197
+
198
+ /**
199
+ * Attempts to acquire a slot. Throws `ServerOverloadedError` synchronously
200
+ * if either gate trips; otherwise increments the live counter and returns
201
+ * a lease the caller must release when the subprocess exits.
202
+ *
203
+ * The acquire-then-spawn dance has a small race: between the RAM check
204
+ * here and the actual spawn, another concurrent acquire could land. That's
205
+ * acceptable for v1 — the next acquire will see the updated MemAvailable
206
+ * and the hard cap is the absolute backstop.
207
+ *
208
+ * @returns a lease the caller must `.release()` in finally.
209
+ * @throws `ServerOverloadedError` if either gate trips.
210
+ */
211
+ acquire(): ThrottleLease {
212
+ // Hard cap check first — it's cheap and the more deterministic gate.
213
+ if (this.liveCount >= this.config.hardCap) {
214
+ throw new ServerOverloadedError("hard_cap", {
215
+ availablePct: 100, // unknown but irrelevant; the cap gate fired
216
+ liveCount: this.liveCount,
217
+ ramThresholdPct: this.config.ramThresholdPct,
218
+ hardCap: this.config.hardCap,
219
+ });
220
+ }
221
+
222
+ // RAM gate. ramThresholdPct=0 disables this gate entirely.
223
+ if (this.config.ramThresholdPct > 0) {
224
+ const { availableKb, totalKb } = this.memReader.read();
225
+ const availablePct = totalKb > 0 ? (availableKb / totalKb) * 100 : 100;
226
+ if (availablePct < this.config.ramThresholdPct) {
227
+ throw new ServerOverloadedError("ram", {
228
+ availablePct,
229
+ liveCount: this.liveCount,
230
+ ramThresholdPct: this.config.ramThresholdPct,
231
+ hardCap: this.config.hardCap,
232
+ });
233
+ }
234
+ }
235
+
236
+ this.liveCount++;
237
+ let released = false;
238
+ return {
239
+ release: () => {
240
+ if (released) return;
241
+ released = true;
242
+ this.liveCount--;
243
+ },
244
+ };
245
+ }
246
+
247
+ /** @returns the current live-subprocess count (for observability + tests). */
248
+ get currentLiveCount(): number {
249
+ return this.liveCount;
250
+ }
251
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Project-shaped Letta types.
3
+ *
4
+ * Re-exports only the SDK surfaces ai-hub uses, so the rest of the server
5
+ * code never has to traverse `@letta-ai/letta-client`'s deep namespace
6
+ * structure. **No raw HTTP, ever** — these are aliases over the SDK's own
7
+ * types, not parallel inventions (§6.4).
8
+ *
9
+ * If the SDK rev removes or renames one of these, the typechecker flags it
10
+ * at upgrade time and `letta/client.ts` is the single place to adapt.
11
+ *
12
+ * @see SPECIFICATIONS.md §6.4 — SDK-only access pattern
13
+ * @see SPECIFICATIONS.md §6.4.1 — first-class surfaces we depend on
14
+ */
15
+
16
+ import type Letta from "@letta-ai/letta-client";
17
+ import type {
18
+ AssistantMessage,
19
+ HiddenReasoningMessage,
20
+ Message as LettaSdkMessage,
21
+ ReasoningMessage,
22
+ ToolCallMessage,
23
+ UserMessage,
24
+ } from "@letta-ai/letta-client/resources/agents/messages.js";
25
+ import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages.js";
26
+ import type { ToolReturnMessage } from "@letta-ai/letta-client/resources/tools.js";
27
+
28
+ /**
29
+ * The underlying SDK client. We `import type` everywhere except in
30
+ * `letta/client.ts` (where the constructor actually runs) so the rest of the
31
+ * codebase stays SDK-version-agnostic in shape.
32
+ */
33
+ export type LettaSdk = Letta;
34
+
35
+ /**
36
+ * One streaming event from `client.conversations.messages.create({ streaming: true })`.
37
+ *
38
+ * The union spans every variant Letta emits in an SSE stream — assistant
39
+ * text, reasoning, tool calls, tool returns, errors, pings, stop reasons,
40
+ * usage stats. `letta/eventTranslator.ts` is the pure function from this
41
+ * union to ai-hub's wire-protocol events (§9).
42
+ *
43
+ * @see SPECIFICATIONS.md §9 — wire protocol
44
+ */
45
+ export type LettaStreamEvent = LettaStreamingResponse;
46
+
47
+ /**
48
+ * Alias for `AssistantMessage` — used by the translator + tests. See
49
+ * `letta/eventTranslator.ts` for the streamed-delta vs. cumulative-content
50
+ * note.
51
+ */
52
+ export type LettaAssistantMessage = AssistantMessage;
53
+ /** Alias for `ReasoningMessage` — see `eventTranslator.ts`. */
54
+ export type LettaReasoningMessage = ReasoningMessage;
55
+ /** Alias for `HiddenReasoningMessage` — see `eventTranslator.ts`. */
56
+ export type LettaHiddenReasoningMessage = HiddenReasoningMessage;
57
+ /** Alias for `ToolCallMessage` — see `eventTranslator.ts`. */
58
+ export type LettaToolCallMessage = ToolCallMessage;
59
+ /** Alias for `ToolReturnMessage` — see `eventTranslator.ts`. */
60
+ export type LettaToolReturnMessage = ToolReturnMessage;
61
+ /** Alias for `UserMessage` — only appears in history reads, not in streams. */
62
+ export type LettaUserMessage = UserMessage;
63
+
64
+ /** Full assembled Letta message (used by `messages.list`). */
65
+ export type LettaMessage = LettaSdkMessage;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Structured logging via pino.
3
+ *
4
+ * Production: raw JSON to stdout, one line per record (§6.10 — twelve-factor
5
+ * pattern). Dev: piped through `pino-pretty` for human-readable output, gated
6
+ * on `NODE_ENV !== "production"` or `LOG_LEVEL=debug`.
7
+ *
8
+ * **Redaction is a load-bearing security control** (§13.3). pino's built-in
9
+ * `redact` option is the single point of truth — every log call goes through
10
+ * the configured logger, and a regression test asserts a known-secret value
11
+ * never appears in captured stdout.
12
+ *
13
+ * @see SPECIFICATIONS.md §6.10 — observability
14
+ * @see SPECIFICATIONS.md §13.3 — secrets
15
+ */
16
+
17
+ import { pino, type Logger as PinoLogger, type LoggerOptions } from "pino";
18
+
19
+ /** What the rest of the server consumes — keeps pino out of import surfaces. */
20
+ export type Logger = PinoLogger;
21
+
22
+ /**
23
+ * The redaction paths pino watches on every log record.
24
+ *
25
+ * The list explicitly covers the keys the spec calls out (`*_key`, `*_token`,
26
+ * `password`, `authorization`) plus their nested forms inside common places
27
+ * (`req.headers.authorization`, `body.password`, etc.). Adding a new
28
+ * sensitive field is a matter of appending a path here — there is no second
29
+ * redaction layer to keep in sync.
30
+ */
31
+ const REDACT_PATHS: ReadonlyArray<string> = [
32
+ "password",
33
+ "current_password",
34
+ "new_password",
35
+ "passwordHash",
36
+ "password_hash",
37
+ "authorization",
38
+ "Authorization",
39
+ "headers.authorization",
40
+ "headers.Authorization",
41
+ "req.headers.authorization",
42
+ "req.headers.Authorization",
43
+ "request.headers.authorization",
44
+ "request.headers.Authorization",
45
+ "*.authorization",
46
+ "*.Authorization",
47
+ "service_token_signing_key",
48
+ "serviceTokenSigningKey",
49
+ "letta_api_key",
50
+ "lettaApiKey",
51
+ "metrics_scrape_token",
52
+ "metricsScrapeToken",
53
+ "encryption_key",
54
+ "encryptionKey",
55
+ "token",
56
+ "*.token",
57
+ "generated_password",
58
+ "*.generated_password",
59
+ ];
60
+
61
+ /**
62
+ * Optional shape passed to `createLogger`. The dev-mode pretty-print stream
63
+ * is set up by the caller (the dev/server entrypoint) — `createLogger` itself
64
+ * stays format-agnostic so tests can pass a writable stream.
65
+ */
66
+ export interface CreateLoggerOptions {
67
+ /** pino level (`fatal | error | warn | info | debug | trace`). Default `info`. */
68
+ level?: string;
69
+ /**
70
+ * Optional destination. Tests pass a memory stream so they can assert on
71
+ * the captured output. Production passes nothing — pino writes to stdout
72
+ * by default.
73
+ */
74
+ destination?: NodeJS.WritableStream;
75
+ }
76
+
77
+ /**
78
+ * Builds a pino logger with the project's redaction policy applied.
79
+ *
80
+ * @param opts - level + optional destination override.
81
+ * @returns the pino logger instance.
82
+ */
83
+ export function createLogger(opts: CreateLoggerOptions = {}): Logger {
84
+ const config: LoggerOptions = {
85
+ level: opts.level ?? process.env.LOG_LEVEL ?? "info",
86
+ redact: {
87
+ paths: [...REDACT_PATHS],
88
+ censor: "[redacted]",
89
+ remove: false,
90
+ },
91
+ };
92
+ return opts.destination ? pino(config, opts.destination) : pino(config);
93
+ }
94
+
95
+ /**
96
+ * The redaction-path list — exposed for tests so a new path added in
97
+ * `REDACT_PATHS` is automatically covered without test churn.
98
+ *
99
+ * @returns a fresh array of the redaction paths used by `createLogger`.
100
+ */
101
+ export function getRedactPaths(): string[] {
102
+ return [...REDACT_PATHS];
103
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Process-local Prometheus-shaped metrics registry.
3
+ *
4
+ * v1 keeps this tiny — three counters + two gauges — to avoid pulling
5
+ * `prom-client` for what is a five-series surface. The names + types
6
+ * match §6.10's contract verbatim so an operator's existing dashboards
7
+ * keep working when a future version swaps in a heavier library.
8
+ *
9
+ * Counters monotonically increment (and persist across the process'
10
+ * lifetime); gauges hold a single most-recent value. Step 5 wires the
11
+ * chat-side series; Step 6 fills in the TPM-recovery counters.
12
+ *
13
+ * @see SPECIFICATIONS.md §6.10 — observability / metrics
14
+ */
15
+
16
+ /** A monotonic counter. */
17
+ export interface Counter {
18
+ /** Adds `n` (default 1) to the counter. */
19
+ inc(n?: number): void;
20
+ /** Reads the current value. */
21
+ value(): number;
22
+ }
23
+
24
+ /** A single-value gauge. */
25
+ export interface Gauge {
26
+ /** Replaces the gauge's current value. */
27
+ set(value: number): void;
28
+ /** Reads the current value. */
29
+ value(): number;
30
+ }
31
+
32
+ /** Internal counter factory. */
33
+ function counter(): Counter {
34
+ let v = 0;
35
+ return {
36
+ inc(n = 1) {
37
+ v += n;
38
+ },
39
+ value() {
40
+ return v;
41
+ },
42
+ };
43
+ }
44
+
45
+ /** Internal gauge factory. */
46
+ function gauge(): Gauge {
47
+ let v = 0;
48
+ return {
49
+ set(value) {
50
+ v = value;
51
+ },
52
+ value() {
53
+ return v;
54
+ },
55
+ };
56
+ }
57
+
58
+ /** Total `/chat` requests received. Bumped on every accepted POST. */
59
+ export const chatRequestsTotal: Counter = counter();
60
+ /** TPM recovery attempts started — populated by Step 6. */
61
+ export const tpmRecoveryAttemptsTotal: Counter = counter();
62
+ /** TPM recoveries that exhausted their budget — populated by Step 6. */
63
+ export const tpmExhaustedTotal: Counter = counter();
64
+ /** Most-recent `/chat` turn duration in ms. */
65
+ export const chatDurationMs: Gauge = gauge();
66
+ /** Most-recent Letta SDK call duration in ms. */
67
+ export const lettaRequestDurationMs: Gauge = gauge();
68
+
69
+ /**
70
+ * Renders the registry as a Prometheus-text v0.0.4 exposition.
71
+ *
72
+ * The route handler in `routes/metrics.ts` calls this after the bearer
73
+ * token gate passes.
74
+ *
75
+ * @returns the full response body.
76
+ */
77
+ export function renderMetrics(): string {
78
+ return (
79
+ [
80
+ "# HELP chat_requests_total Total /chat requests received.",
81
+ "# TYPE chat_requests_total counter",
82
+ `chat_requests_total ${chatRequestsTotal.value()}`,
83
+ "",
84
+ "# HELP tpm_recovery_attempts_total TPM recovery attempts started.",
85
+ "# TYPE tpm_recovery_attempts_total counter",
86
+ `tpm_recovery_attempts_total ${tpmRecoveryAttemptsTotal.value()}`,
87
+ "",
88
+ "# HELP tpm_exhausted_total TPM recoveries that exhausted their budget.",
89
+ "# TYPE tpm_exhausted_total counter",
90
+ `tpm_exhausted_total ${tpmExhaustedTotal.value()}`,
91
+ "",
92
+ "# HELP chat_duration_ms Most-recent /chat turn duration in ms.",
93
+ "# TYPE chat_duration_ms gauge",
94
+ `chat_duration_ms ${chatDurationMs.value()}`,
95
+ "",
96
+ "# HELP letta_request_duration_ms Most-recent Letta SDK call duration in ms.",
97
+ "# TYPE letta_request_duration_ms gauge",
98
+ `letta_request_duration_ms ${lettaRequestDurationMs.value()}`,
99
+ "",
100
+ ].join("\n")
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Resets every series. **Test-only** — production never calls this.
106
+ * Without it, vitest's module-cache means counters carry across tests.
107
+ */
108
+ export function _resetMetricsForTests(): void {
109
+ chatRequestsTotal.inc(-chatRequestsTotal.value());
110
+ tpmRecoveryAttemptsTotal.inc(-tpmRecoveryAttemptsTotal.value());
111
+ tpmExhaustedTotal.inc(-tpmExhaustedTotal.value());
112
+ chatDurationMs.set(0);
113
+ lettaRequestDurationMs.set(0);
114
+ }