@data-club/ai-hub-server 0.0.9 → 0.0.11

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 (96) hide show
  1. package/dist/auth/encryption.d.ts +5 -5
  2. package/dist/auth/encryption.js +5 -5
  3. package/dist/auth/userView.d.ts +8 -15
  4. package/dist/auth/userView.d.ts.map +1 -1
  5. package/dist/auth/userView.js +10 -23
  6. package/dist/auth/userView.js.map +1 -1
  7. package/dist/bootstrap.d.ts +2 -8
  8. package/dist/bootstrap.d.ts.map +1 -1
  9. package/dist/bootstrap.js +2 -35
  10. package/dist/bootstrap.js.map +1 -1
  11. package/dist/db/migrate.js +6 -17
  12. package/dist/db/migrate.js.map +1 -1
  13. package/dist/db/schema.d.ts +17 -263
  14. package/dist/db/schema.d.ts.map +1 -1
  15. package/dist/db/schema.js +8 -57
  16. package/dist/db/schema.js.map +1 -1
  17. package/dist/db/seed.d.ts +4 -19
  18. package/dist/db/seed.d.ts.map +1 -1
  19. package/dist/db/seed.js +6 -33
  20. package/dist/db/seed.js.map +1 -1
  21. package/dist/handlers/fetch.d.ts +6 -7
  22. package/dist/handlers/fetch.d.ts.map +1 -1
  23. package/dist/handlers/fetch.js +1 -3
  24. package/dist/handlers/fetch.js.map +1 -1
  25. package/dist/handlers/types.d.ts +5 -6
  26. package/dist/handlers/types.d.ts.map +1 -1
  27. package/dist/letta/client.d.ts +86 -263
  28. package/dist/letta/client.d.ts.map +1 -1
  29. package/dist/letta/client.js +99 -404
  30. package/dist/letta/client.js.map +1 -1
  31. package/dist/letta/index.d.ts +6 -8
  32. package/dist/letta/index.d.ts.map +1 -1
  33. package/dist/letta/index.js +5 -7
  34. package/dist/letta/index.js.map +1 -1
  35. package/dist/letta/sandbox.d.ts +45 -9
  36. package/dist/letta/sandbox.d.ts.map +1 -1
  37. package/dist/letta/sandbox.js +123 -11
  38. package/dist/letta/sandbox.js.map +1 -1
  39. package/dist/letta/types.d.ts +6 -49
  40. package/dist/letta/types.d.ts.map +1 -1
  41. package/dist/letta/types.js +5 -10
  42. package/dist/letta/types.js.map +1 -1
  43. package/dist/observability/logger.d.ts.map +1 -1
  44. package/dist/observability/logger.js +0 -2
  45. package/dist/observability/logger.js.map +1 -1
  46. package/dist/routes/admin/agents.d.ts.map +1 -1
  47. package/dist/routes/admin/agents.js +0 -40
  48. package/dist/routes/admin/agents.js.map +1 -1
  49. package/dist/routes/admin/rotatingMessages.d.ts.map +1 -1
  50. package/dist/routes/admin/rotatingMessages.js +37 -1
  51. package/dist/routes/admin/rotatingMessages.js.map +1 -1
  52. package/dist/routes/admin/settings.d.ts +6 -15
  53. package/dist/routes/admin/settings.d.ts.map +1 -1
  54. package/dist/routes/admin/settings.js +9 -107
  55. package/dist/routes/admin/settings.js.map +1 -1
  56. package/dist/routes/auth.d.ts +0 -4
  57. package/dist/routes/auth.d.ts.map +1 -1
  58. package/dist/routes/auth.js +1 -5
  59. package/dist/routes/auth.js.map +1 -1
  60. package/dist/routes/sessions.d.ts.map +1 -1
  61. package/dist/routes/sessions.js +9 -30
  62. package/dist/routes/sessions.js.map +1 -1
  63. package/dist/state/rotationRunner.d.ts +6 -0
  64. package/dist/state/rotationRunner.d.ts.map +1 -1
  65. package/dist/state/rotationRunner.js +19 -2
  66. package/dist/state/rotationRunner.js.map +1 -1
  67. package/dist/state/rotationScheduler.d.ts +2 -0
  68. package/dist/state/rotationScheduler.d.ts.map +1 -1
  69. package/dist/state/rotationScheduler.js.map +1 -1
  70. package/dist/state/turnRunner.d.ts.map +1 -1
  71. package/dist/state/turnRunner.js +1 -75
  72. package/dist/state/turnRunner.js.map +1 -1
  73. package/package.json +4 -4
  74. package/src/auth/encryption.ts +5 -5
  75. package/src/auth/userView.ts +9 -22
  76. package/src/bootstrap.ts +4 -43
  77. package/src/db/migrate.ts +6 -17
  78. package/src/db/schema.ts +8 -68
  79. package/src/db/seed.ts +6 -47
  80. package/src/handlers/fetch.ts +7 -10
  81. package/src/handlers/types.ts +5 -6
  82. package/src/letta/client.ts +170 -634
  83. package/src/letta/index.ts +8 -21
  84. package/src/letta/sandbox.ts +151 -11
  85. package/src/letta/types.ts +9 -60
  86. package/src/observability/logger.ts +0 -2
  87. package/src/routes/admin/agents.ts +0 -49
  88. package/src/routes/admin/rotatingMessages.ts +47 -1
  89. package/src/routes/admin/settings.ts +9 -113
  90. package/src/routes/auth.ts +1 -5
  91. package/src/routes/sessions.ts +11 -32
  92. package/src/state/rotationRunner.ts +37 -1
  93. package/src/state/rotationScheduler.ts +2 -0
  94. package/src/state/turnRunner.ts +1 -77
  95. package/src/letta/lettaCliShim.mjs +0 -40
  96. package/src/letta/settingsFileSeed.ts +0 -254
@@ -3,13 +3,10 @@
3
3
  *
4
4
  * Five endpoints:
5
5
  *
6
- * - `GET /admin/settings` — Letta connection (api key **masked**), theme,
7
- * metrics + signing-key rotation timestamps. Encrypted Letta API key
8
- * surfaces only as `letta_api_key_masked` (`••••••••<last4>`); plaintext
9
- * never leaves the server outside the `Reveal once` flow (Step 12).
10
- * - `PATCH /admin/settings` — body `{ letta_base_url?, letta_ade_url?, letta_api_key?, theme?: { default?, switcher_enabled? } }`.
11
- * API key, when supplied, is encrypted under `AI_HUB_ENCRYPTION_KEY`. Theme
12
- * fields live as **separate columns**, not in `feature_toggles`.
6
+ * - `GET /admin/settings` — theme / language / agents settings plus
7
+ * metrics + signing-key rotation timestamps.
8
+ * - `PATCH /admin/settings` body `{ theme?: { default?, switcher_enabled? }, language?, agents? }`.
9
+ * Theme fields live as **separate columns**, not in `feature_toggles`.
13
10
  * - `GET /admin/settings/feature-toggles` — flat JSON blob from §8.4.
14
11
  * - `PATCH /admin/settings/feature-toggles` — merge-patch the same blob.
15
12
  * Unknown keys are accepted (forward-compat for future toggles).
@@ -36,7 +33,6 @@ import { randomBytes } from "node:crypto";
36
33
  import { eq } from "drizzle-orm";
37
34
  import { z } from "zod";
38
35
 
39
- import { encryptString } from "../../auth/encryption.js";
40
36
  import { protectedRoute } from "../../auth/middleware.js";
41
37
  import { adminSetting } from "../../db/schema.js";
42
38
  import type { AdminSettingRow } from "../../db/schema.js";
@@ -56,60 +52,6 @@ function nowIso(): string {
56
52
  return new Date().toISOString();
57
53
  }
58
54
 
59
- /**
60
- * Computes a privacy-preserving display of the encrypted Letta API key.
61
- *
62
- * Per [08-admin-settings.md], the UI shows `••••••••` while the value is in
63
- * the field — operators reveal-once if they need to copy. The server's
64
- * `GET /admin/settings` never returns plaintext.
65
- *
66
- * @param row - the admin_setting row.
67
- * @returns a masked string, or `null` when no key is stored.
68
- */
69
- function maskLettaApiKey(row: AdminSettingRow): string | null {
70
- if (!row.lettaApiKeyEncrypted) return null;
71
- // We can't decrypt without the key, and decrypting just to show "<last 4>"
72
- // would defeat the purpose of encrypting at rest. Use a constant placeholder
73
- // so the UI knows a key is configured without revealing length / contents.
74
- return "••••••••";
75
- }
76
-
77
- /** Possible sources for the effective Letta API key at runtime. */
78
- type LettaApiKeySource = "db" | "env" | "none";
79
-
80
- /**
81
- * Determines the *effective* source of the Letta API key at this moment.
82
- * Mirrors `letta/client.ts:resolveApiKey` (DB → env → throw) so the admin
83
- * UI can show the truth about which source the runtime will use, not just
84
- * what's stored in the DB.
85
- *
86
- * Returning the SOURCE — not the value — keeps plaintext off the wire.
87
- *
88
- * @param row - the admin_setting row (may be missing).
89
- * @param envSource - env-var record from the server context. Lets tests
90
- * exercise the source-resolution without touching the real `process.env`.
91
- * @returns `"db"` when DB column has a non-empty value (the runtime will
92
- * use it); `"env"` when DB is empty AND `LETTA_API_KEY` in `envSource`
93
- * is set and non-empty; `"none"` otherwise.
94
- */
95
- function resolveApiKeySource(
96
- row: AdminSettingRow | null,
97
- envSource: Record<string, string | undefined>,
98
- ): LettaApiKeySource {
99
- // We deliberately can't tell here whether the DB-encrypted bytes decrypt
100
- // to a non-whitespace string without doing the decrypt — but the rule is
101
- // "non-empty bytes ⇒ candidate." The runtime resolver does the actual
102
- // whitespace check; the UI source is just "is there a DB value worth
103
- // showing?" The mismatch (DB has whitespace-only after decrypt) is rare
104
- // enough that we don't pay the cost of decrypting here just to detect
105
- // it; `letta_api_key_set` aligned with `lettaApiKeyEncrypted IS NOT NULL`
106
- // matches the existing PATCH semantics (we never write whitespace).
107
- if (row?.lettaApiKeyEncrypted) return "db";
108
- const envKey = envSource.LETTA_API_KEY;
109
- if (envKey && envKey.trim().length > 0) return "env";
110
- return "none";
111
- }
112
-
113
55
  /**
114
56
  * Reads the singleton admin-settings row. Throws if the row hasn't been
115
57
  * seeded — should not happen after first-boot.
@@ -130,27 +72,8 @@ function readSettings(ctx: {
130
72
  * @param row - the admin_setting row.
131
73
  * @returns the JSON-safe response shape.
132
74
  */
133
- function settingsView(
134
- row: AdminSettingRow,
135
- envSource: Record<string, string | undefined>,
136
- ): Record<string, unknown> {
137
- const source = resolveApiKeySource(row, envSource);
75
+ function settingsView(row: AdminSettingRow): Record<string, unknown> {
138
76
  return {
139
- letta_base_url: row.lettaBaseUrl ?? null,
140
- letta_ade_url: row.lettaAdeUrl ?? null,
141
- letta_api_key_masked: maskLettaApiKey(row),
142
- // True when EITHER the DB column is populated OR env.LETTA_API_KEY is
143
- // set — matches the runtime resolver's "is the chat path going to
144
- // work?" answer. The admin UI uses this to decide whether to render a
145
- // "configured" badge vs. a "missing" warning, regardless of which
146
- // source supplies the value.
147
- letta_api_key_set: source !== "none",
148
- // Source the runtime is *currently* using (per resolveApiKey precedence
149
- // in letta/client.ts). Drives the admin UI's source-indicator text —
150
- // "(from env)" vs "(from admin panel)" vs "(not configured)" — so
151
- // operators don't get confused when the DB row is empty but chat
152
- // still works (env fallback) or vice versa.
153
- letta_api_key_source: source,
154
77
  theme: {
155
78
  default: row.themeDefault,
156
79
  switcher_enabled: row.themeSwitcherEnabled,
@@ -179,11 +102,6 @@ function settingsView(
179
102
  /**
180
103
  * `GET /admin/settings` — super_admin-only deployment configuration.
181
104
  *
182
- * Encrypted Letta API key surfaces only as the masked placeholder
183
- * (`letta_api_key_masked: "••••••••"`) + the boolean `letta_api_key_set`;
184
- * plaintext never leaves the server through this endpoint. Step 12 wires the
185
- * `Reveal once` flow as a separate UI affordance.
186
- *
187
105
  * @see SPECIFICATIONS.md §6.3.4
188
106
  * @see ui-sketches/08-admin-settings.md
189
107
  */
@@ -192,7 +110,7 @@ export const handleGetSettings: RouteHandler = protectedRoute(
192
110
  async (_req, ctx) => {
193
111
  const row = readSettings(ctx);
194
112
  if (!row) return internalError("Admin settings row is missing — server misconfigured.");
195
- return jsonResponse(200, settingsView(row, ctx.envSource));
113
+ return jsonResponse(200, settingsView(row));
196
114
  },
197
115
  );
198
116
 
@@ -203,16 +121,6 @@ export const handleGetSettings: RouteHandler = protectedRoute(
203
121
  /** Body for `PATCH /admin/settings`. Every field optional; empty body is a no-op. */
204
122
  const patchSettingsSchema = z
205
123
  .object({
206
- letta_base_url: z.string().url().nullable().optional(),
207
- letta_ade_url: z.string().url().nullable().optional(),
208
- // The API key accepts:
209
- // - a non-empty string → encrypt + store; runtime will use this DB
210
- // value over env on the next request.
211
- // - an empty/whitespace string → clear the DB column to NULL; the
212
- // runtime then falls back to env.LETTA_API_KEY (the operator-spec'd
213
- // "if admin panel is empty, use env" precedence).
214
- // - omitted → no change.
215
- letta_api_key: z.string().max(2048).optional(),
216
124
  theme: z
217
125
  .object({
218
126
  default: z.enum(["light", "dark"]).optional(),
@@ -241,9 +149,8 @@ const patchSettingsSchema = z
241
149
  /**
242
150
  * `PATCH /admin/settings` — partial update of deployment configuration.
243
151
  *
244
- * The Letta API key, when supplied, is encrypted under
245
- * `AI_HUB_ENCRYPTION_KEY` (env-only) before write. Theme fields live as
246
- * separate columns (`theme_default`, `theme_switcher_enabled`) per §8.4.
152
+ * Theme fields live as separate columns (`theme_default`,
153
+ * `theme_switcher_enabled`) per §8.4.
247
154
  *
248
155
  * @see SPECIFICATIONS.md §6.3.4
249
156
  * @see SPECIFICATIONS.md §8.4 — theme settings are explicit columns
@@ -264,17 +171,6 @@ export const handlePatchSettings: RouteHandler = protectedRoute(
264
171
  const body = parsed.data;
265
172
 
266
173
  const update: Partial<typeof adminSetting.$inferInsert> = { updatedAt: nowIso() };
267
- if (body.letta_base_url !== undefined) update.lettaBaseUrl = body.letta_base_url;
268
- if (body.letta_ade_url !== undefined) update.lettaAdeUrl = body.letta_ade_url;
269
- if (body.letta_api_key !== undefined) {
270
- // Empty / whitespace-only string clears the DB column to NULL. The
271
- // runtime then falls back to env.LETTA_API_KEY (see
272
- // letta/client.ts:resolveApiKey). Non-empty values are encrypted +
273
- // stored as the operator's override of env.
274
- const trimmed = body.letta_api_key.trim();
275
- update.lettaApiKeyEncrypted =
276
- trimmed.length === 0 ? null : encryptString(ctx.encryptionKey, body.letta_api_key);
277
- }
278
174
  if (body.theme?.default !== undefined) update.themeDefault = body.theme.default;
279
175
  if (body.theme?.switcher_enabled !== undefined)
280
176
  update.themeSwitcherEnabled = body.theme.switcher_enabled;
@@ -305,7 +201,7 @@ export const handlePatchSettings: RouteHandler = protectedRoute(
305
201
 
306
202
  const fresh = readSettings(ctx);
307
203
  if (!fresh) return internalError();
308
- return jsonResponse(200, settingsView(fresh, ctx.envSource));
204
+ return jsonResponse(200, settingsView(fresh));
309
205
  },
310
206
  );
311
207
 
@@ -384,10 +384,6 @@ export const handleLogout: RouteHandler = protectedRoute({}, async () => {
384
384
  /**
385
385
  * `GET /auth/me` — returns the `{ user, deployment }` envelope.
386
386
  *
387
- * `deployment.letta_ade_url` is included only for callers with role >=
388
- * `power_user` (handled inside `buildDeploymentView`); `user`-role callers
389
- * don't even see the field exists — by design (§6.3.4).
390
- *
391
387
  * The resolved theme is computed client-side from
392
388
  * `user.theme_preference + deployment.{theme_default, theme_switcher_enabled}`
393
389
  * per §7.6 — the server does not pre-compute it.
@@ -493,7 +489,7 @@ export const handleMePreferences: RouteHandler = protectedRoute(
493
489
  .get();
494
490
  return jsonResponse(200, {
495
491
  user: buildUserView(freshRow),
496
- deployment: buildDeploymentView(settings ?? null, freshRow.role),
492
+ deployment: buildDeploymentView(settings ?? null),
497
493
  });
498
494
  },
499
495
  );
@@ -35,7 +35,6 @@ import { protectedRoute } from "../auth/middleware.js";
35
35
  import {
36
36
  agent,
37
37
  messageOverride,
38
- messageUsage,
39
38
  rotatingMessage,
40
39
  session,
41
40
  turn,
@@ -893,45 +892,21 @@ function projectAndRespond(
893
892
  }
894
893
  }
895
894
 
896
- // Per-assistant-message usage (cache hits/writes + token counts).
897
- // Stamped by the runner on the last assistant message of each
898
- // turn (see turnRunner.ts step 5.5). Renders as the cache badge
899
- // on the chat UI's assistant bubbles.
900
- const usageRows = ctx.db
901
- .select()
902
- .from(messageUsage)
903
- .where(eq(messageUsage.sessionId, row.id))
904
- .all();
905
- const usageByMessageId = new Map<string, AIHubMessage["usage"]>();
906
- for (const u of usageRows) {
907
- usageByMessageId.set(u.lettaMessageId, {
908
- input: u.promptTokens,
909
- output: u.completionTokens,
910
- cache_read: u.cachedInputTokens,
911
- cache_write: u.cacheWriteTokens,
912
- reasoning: u.reasoningTokens,
913
- context: u.contextTokens,
914
- total: u.totalTokens,
915
- });
916
- }
917
-
918
895
  const projected = sorted.map((msg): AIHubMessage => {
919
896
  const pos = positionByLettaId.get(msg.id);
920
- const usage = usageByMessageId.get(msg.id);
921
- const withUsage = usage !== undefined ? { ...msg, usage } : msg;
922
897
  if (pos !== undefined) {
923
898
  const explicit = overrideByPosition.get(pos);
924
899
  if (explicit !== undefined) {
925
- return { ...withUsage, content: explicit };
900
+ return { ...msg, content: explicit };
926
901
  }
927
902
  }
928
903
  if (msg.role === "system") {
929
904
  const stripped = stripMemoryMetadata(msg.content);
930
905
  if (stripped !== msg.content) {
931
- return { ...withUsage, content: stripped };
906
+ return { ...msg, content: stripped };
932
907
  }
933
908
  }
934
- return withUsage;
909
+ return msg;
935
910
  });
936
911
 
937
912
  const finalMessages = applyRotationDisplayFilter
@@ -1882,21 +1857,21 @@ function toWireMessage(msg: LettaMessage): AIHubMessage | null {
1882
1857
  id: msg.id,
1883
1858
  role: "user",
1884
1859
  content: extractTextContent(msg.content),
1885
- created_at: msg.date,
1860
+ created_at: msg.date ?? "",
1886
1861
  };
1887
1862
  case "assistant_message":
1888
1863
  return {
1889
1864
  id: msg.id,
1890
1865
  role: "assistant",
1891
1866
  content: extractTextContent(msg.content),
1892
- created_at: msg.date,
1867
+ created_at: msg.date ?? "",
1893
1868
  };
1894
1869
  case "system_message":
1895
1870
  return {
1896
1871
  id: msg.id,
1897
1872
  role: "system",
1898
1873
  content: extractTextContent(msg.content),
1899
- created_at: msg.date,
1874
+ created_at: msg.date ?? "",
1900
1875
  };
1901
1876
  default:
1902
1877
  // reasoning_message / hidden_reasoning_message / tool_call_message /
@@ -1921,8 +1896,12 @@ function toWireMessage(msg: LettaMessage): AIHubMessage | null {
1921
1896
  * consistent regardless of where reminders end up.
1922
1897
  */
1923
1898
  function extractTextContent(
1924
- content: string | Array<{ type?: string; text?: string }>,
1899
+ content:
1900
+ | string
1901
+ | ReadonlyArray<{ type?: string; text?: string }>
1902
+ | undefined,
1925
1903
  ): string {
1904
+ if (content === undefined) return "";
1926
1905
  if (typeof content === "string") return stripSystemReminders(content);
1927
1906
  if (!Array.isArray(content)) return "";
1928
1907
  let out = "";
@@ -24,11 +24,18 @@
24
24
  * @see docs/server/rotating-messages.md
25
25
  */
26
26
 
27
+ import { join } from "node:path";
28
+
27
29
  import { eq } from "drizzle-orm";
28
30
 
29
31
  import type { DrizzleDB } from "../db/connection.js";
30
32
  import { rotatingMessage, type RotatingMessageRow } from "../db/schema.js";
31
33
  import type { LettaClient } from "../letta/client.js";
34
+ import {
35
+ materializeSandbox,
36
+ purgeSandbox,
37
+ resolveSeedDir,
38
+ } from "../letta/sandbox.js";
32
39
  import type { Logger } from "../observability/logger.js";
33
40
 
34
41
  import { CronParseError, cronNextRun, parseCron } from "./cron.js";
@@ -157,6 +164,12 @@ export interface RotationRunnerDeps {
157
164
  lettaClient: LettaClient;
158
165
  /** Logger for this rotation. */
159
166
  logger: Logger;
167
+ /**
168
+ * Base files dir (`resolveFilesDir`) — parent of `seeds/` + `sandboxes/`.
169
+ * Needed to materialize the rotation sandbox when the row has a
170
+ * `sandbox_seed_name`.
171
+ */
172
+ filesDir: string;
160
173
  /** Override "now" for deterministic tests. Defaults to `new Date()`. */
161
174
  now?: () => Date;
162
175
  }
@@ -189,10 +202,31 @@ export async function runRotation(
189
202
 
190
203
  let createdConversationId: string | null = null;
191
204
  try {
205
+ // Rotation sandbox — when the row names a seed, materialize a fresh
206
+ // working directory for THIS rotation run so the agent's filesystem/Bash
207
+ // tools are enabled (streamUserMessage widens the toolset when `cwd` is
208
+ // set) and the prompt can query the seeded data. The dir is rebuilt from
209
+ // the seed on every rotation (purge + materialize) and keyed by the
210
+ // rotating-message id (`rmsg-<uuid>` — safe path chars), NOT by
211
+ // conversation, so failed runs never accumulate orphan dirs.
212
+ let sandboxDir: string | undefined;
213
+ if (row.sandboxSeedName) {
214
+ const seedDir = resolveSeedDir(deps.filesDir, row.sandboxSeedName);
215
+ sandboxDir = join(deps.filesDir, "sandboxes", row.id);
216
+ await purgeSandbox(sandboxDir);
217
+ await materializeSandbox(seedDir, sandboxDir);
218
+ }
219
+
192
220
  const conv = await deps.lettaClient.createConversation(row.lettaAgentId);
193
221
  createdConversationId = conv.id;
194
222
 
195
- await sendPromptAndDrain(deps.lettaClient, conv.id, row.lettaAgentId, row.prompt);
223
+ await sendPromptAndDrain(
224
+ deps.lettaClient,
225
+ conv.id,
226
+ row.lettaAgentId,
227
+ row.prompt,
228
+ sandboxDir,
229
+ );
196
230
 
197
231
  // Delete the prompt's user message so the conversation opens with the
198
232
  // agent's reply. listMessages is oldest-first; the first user_message is
@@ -299,11 +333,13 @@ async function sendPromptAndDrain(
299
333
  conversationId: string,
300
334
  lettaAgentId: string,
301
335
  prompt: string,
336
+ cwd?: string,
302
337
  ): Promise<void> {
303
338
  const handle = await client.streamUserMessage({
304
339
  conversationId,
305
340
  lettaAgentId,
306
341
  userMessage: prompt,
342
+ ...(cwd !== undefined ? { cwd } : {}),
307
343
  });
308
344
  let sawResult = false;
309
345
  let success = false;
@@ -38,6 +38,8 @@ export interface RotationSchedulerDeps {
38
38
  db: DrizzleDB;
39
39
  lettaClient: LettaClient;
40
40
  logger: Logger;
41
+ /** Base files dir — forwarded to `runRotation` for rotation sandboxes. */
42
+ filesDir: string;
41
43
  }
42
44
 
43
45
  /** Handle for stopping a running scheduler. */
@@ -45,7 +45,7 @@ import type {
45
45
  TurnStatus,
46
46
  } from "@data-club/ai-hub";
47
47
 
48
- import { messageUsage, turn, turnEvent } from "../db/schema.js";
48
+ import { turn, turnEvent } from "../db/schema.js";
49
49
  import type { DrizzleDB } from "../db/connection.js";
50
50
  import { ServerOverloadedError, type ChatTurnHandle, type LettaClient } from "../letta/client.js";
51
51
  import {
@@ -269,20 +269,8 @@ export async function runTurn(opts: RunTurnOpts): Promise<RunTurnResult> {
269
269
  cancelController.signal.addEventListener("abort", onCancel, { once: true });
270
270
 
271
271
  // Step 5 — drain the SDK message stream.
272
- // Track the last Letta run id we see — used to fetch usage stats
273
- // after the terminal `result`. The SDK strips Letta message ids
274
- // from its translated payloads, so we re-fetch via REST
275
- // (`getRunUsageStats(runId)`) to get both the usage_statistics
276
- // and the last assistant message id we'll stamp the badge on.
277
- let lastRunId: string | null = null;
278
272
  try {
279
273
  for await (const sdkMsg of handle.events) {
280
- // SDKAssistantMessage / SDKReasoningMessage / SDKToolCallMessage
281
- // each carry `runId`. Take the most recent — a multi-step turn
282
- // can have several runs, and we want the latest.
283
- if ((sdkMsg as { runId?: string }).runId) {
284
- lastRunId = (sdkMsg as { runId: string }).runId;
285
- }
286
274
  if (sdkMsg.type === "result") {
287
275
  // Terminal — finalizeTerminal emits the right closing events
288
276
  // based on success / interrupted / error.
@@ -315,70 +303,6 @@ export async function runTurn(opts: RunTurnOpts): Promise<RunTurnResult> {
315
303
  metrics.lettaRequestDurationMs.set(Date.now() - lettaStartedMs);
316
304
  }
317
305
 
318
- // Step 5.5 — usage emission + persistence. Only fire on successful
319
- // termination (cancelled / failed turns may or may not have a
320
- // usable runId, and the badge isn't load-bearing). Best-effort:
321
- // any failure here logs and continues — we already emitted
322
- // stream_end above, so the turn is "done" from the client's POV.
323
- if (
324
- lastRunId !== null &&
325
- translatorState.status === "done"
326
- ) {
327
- try {
328
- const usage = await opts.lettaClient.getRunUsageStats(lastRunId);
329
- if (usage && usage.lastAssistantMessageId) {
330
- // Persist for /sessions/{id}/messages history rendering.
331
- opts.db
332
- .insert(messageUsage)
333
- .values({
334
- lettaMessageId: usage.lastAssistantMessageId,
335
- sessionId: opts.sessionId,
336
- promptTokens: usage.promptTokens,
337
- completionTokens: usage.completionTokens,
338
- totalTokens: usage.totalTokens,
339
- cachedInputTokens: usage.cachedInputTokens,
340
- cacheWriteTokens: usage.cacheWriteTokens,
341
- reasoningTokens: usage.reasoningTokens,
342
- contextTokens: usage.contextTokens,
343
- runId: lastRunId,
344
- })
345
- .onConflictDoUpdate({
346
- target: messageUsage.lettaMessageId,
347
- set: {
348
- promptTokens: usage.promptTokens,
349
- completionTokens: usage.completionTokens,
350
- totalTokens: usage.totalTokens,
351
- cachedInputTokens: usage.cachedInputTokens,
352
- cacheWriteTokens: usage.cacheWriteTokens,
353
- reasoningTokens: usage.reasoningTokens,
354
- contextTokens: usage.contextTokens,
355
- runId: lastRunId,
356
- },
357
- })
358
- .run();
359
- // Live wire event — UI can attach the badge immediately
360
- // without waiting for a /messages refetch.
361
- opts.onEvent({
362
- seq: 0, // emit() bypassed here; the stream's already drained.
363
- event: "usage",
364
- data: {
365
- letta_message_id: usage.lastAssistantMessageId,
366
- ...(usage.promptTokens !== null ? { input: usage.promptTokens } : {}),
367
- ...(usage.completionTokens !== null ? { output: usage.completionTokens } : {}),
368
- ...(usage.cachedInputTokens !== null ? { cache_read: usage.cachedInputTokens } : {}),
369
- ...(usage.cacheWriteTokens !== null ? { cache_write: usage.cacheWriteTokens } : {}),
370
- ...(usage.reasoningTokens !== null ? { reasoning: usage.reasoningTokens } : {}),
371
- ...(usage.contextTokens !== null ? { context: usage.contextTokens } : {}),
372
- ...(usage.totalTokens !== null ? { total: usage.totalTokens } : {}),
373
- },
374
- state: { status: "done", phase: null } satisfies AIHubEventState,
375
- });
376
- }
377
- } catch (err) {
378
- opts.logger.warn({ err, runId: lastRunId }, "usage-stats fetch/persist failed");
379
- }
380
- }
381
-
382
306
  publisher.close();
383
307
  opts.onEvent(null);
384
308
  metrics.chatDurationMs.set(Date.now() - startedMs);
@@ -1,40 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Letta Code CLI shim — runs in front of the spawned CLI subprocess
4
- * (via `LETTA_CLI_PATH`) and injects `--tools ""` into argv before
5
- * loading the CLI bundle.
6
- *
7
- * **Why `--tools ""`.** The SDK doesn't expose the flag that actually
8
- * empties the CLI's tool registry. It only exposes `--allowedTools` /
9
- * `--disallowedTools`, which are runtime permission gates, not
10
- * registry filters. The `--tools` flag is the only mechanism that
11
- * makes the LLM see ONLY the agent's ADE-attached tools (equivalent
12
- * to the TUI `/toolset none` action).
13
- *
14
- * **CLI resolution** — `require.resolve("@data-club/letta-code")` from
15
- * node_modules. The DataClub fork is published to npmjs.org under the
16
- * `@data-club` scope (forked from `@letta-ai/letta-code`); locally it's
17
- * `link:`-overridden to `../letta-code-fork` via `pnpm.overrides`.
18
- */
19
-
20
- import { createRequire } from "node:module";
21
-
22
- const require = createRequire(import.meta.url);
23
- const lettaCliPath = require.resolve("@data-club/letta-code");
24
-
25
- // Rewrite argv to point at the real CLI + inject `--tools ""`. argv[0]
26
- // (the node binary path) is preserved; argv[1] becomes the real CLI
27
- // path so any path-relative resolution inside the CLI (e.g. finding
28
- // the bundled `skills/` dir) works correctly.
29
- process.argv = [
30
- process.argv[0],
31
- lettaCliPath,
32
- "--tools",
33
- "",
34
- ...process.argv.slice(2),
35
- ];
36
-
37
- // Load the real CLI. The bundle's top-level code parses argv, sees
38
- // our injected `--tools ""`, calls `toolFilter.setEnabledTools("")`,
39
- // and starts up with an empty tool-registration allowlist.
40
- await import(lettaCliPath);