@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
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Public exports for the `letta/` module — the single entry point the rest
3
- * of the server uses to reach Letta. Per §6.4, **every Letta interaction
4
- * goes through `LettaClient`**; no raw HTTP, no other entry points.
5
- *
6
- * @see SPECIFICATIONS.md §6.4
3
+ * of the server uses to reach Letta. Every Letta interaction goes through
4
+ * `LettaClient` (implemented over `@data-club/letta-runtime`'s server-less
5
+ * local backend); no raw HTTP, no other entry points.
7
6
  */
8
7
 
9
8
  export {
10
9
  createDefaultLettaClient,
10
+ ForkSourceNotFoundError,
11
+ ServerOverloadedError,
12
+ type ChatTurnHandle,
11
13
  type CreateLettaClientDeps,
12
14
  type LettaClient,
15
+ type LettaMessage,
13
16
  type StreamUserMessageOpts,
14
17
  } from "./client.js";
15
18
  export {
@@ -22,25 +25,9 @@ export {
22
25
  type UnsequencedEvent,
23
26
  } from "./eventTranslator.js";
24
27
  export {
25
- ServerOverloadedError,
26
28
  SubprocessThrottle,
27
29
  readThrottleConfigFromEnv,
28
30
  type ThrottleConfig,
29
31
  type ThrottleLease,
30
32
  } from "./subprocessThrottle.js";
31
- export {
32
- seedAgentToolsetNone,
33
- type SeedOutcome,
34
- type ToolsetPreference,
35
- } from "./settingsFileSeed.js";
36
- export type {
37
- LettaAssistantMessage,
38
- LettaHiddenReasoningMessage,
39
- LettaMessage,
40
- LettaReasoningMessage,
41
- LettaSdk,
42
- LettaStreamEvent,
43
- LettaToolCallMessage,
44
- LettaToolReturnMessage,
45
- LettaUserMessage,
46
- } from "./types.js";
33
+ export type { SDKMessage } from "./types.js";
@@ -16,8 +16,8 @@
16
16
  * @see docs/server/session-sandboxes.md
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync } from "node:fs";
20
- import { cp, rm } from "node:fs/promises";
19
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
+ import { cp, link, mkdir, readdir, rm } from "node:fs/promises";
21
21
  import { join } from "node:path";
22
22
 
23
23
  /**
@@ -108,16 +108,150 @@ export function resolveSeedDir(filesDir: string, seedName: string): string {
108
108
  }
109
109
 
110
110
  /**
111
- * Recursively copies the seed folder into the (not-yet-existing) sandbox
112
- * target. The target's parent is created via `mkdir -p` if needed; the
113
- * target itself is created by `cp({ recursive: true })`. Throws on any
114
- * I/O failure the caller is responsible for rolling back the just-
115
- * created Letta conversation if this throws.
111
+ * Filename of the optional per-seed materialization manifest, looked up in
112
+ * the seed folder's root. When absent, `materializeSandbox` behaves exactly
113
+ * as before (full recursive copy). The manifest itself is never
114
+ * materialized into the sandbox.
115
+ */
116
+ export const SEED_MANIFEST_FILENAME = "seed.manifest.json";
117
+
118
+ /**
119
+ * Parsed shape of `seed.manifest.json`.
120
+ *
121
+ * `link` — glob patterns (relative to the seed root, `/`-separated) of
122
+ * files to **hardlink** into the sandbox instead of copying. Intended for
123
+ * large read-only assets (multi-hundred-MB SQLite databases) where a
124
+ * per-session copy would cost seconds of latency and gigabytes of disk.
125
+ *
126
+ * Operator contract for linked files (documented in
127
+ * docs/server/session-sandboxes.md):
128
+ * - make them read-only (`chmod 444`) — a hardlink shares the inode, so a
129
+ * write through any sandbox would mutate every session's view;
130
+ * - update them by replace-and-rename (new inode), never in place —
131
+ * running sessions then keep the old data, new sessions get the new;
132
+ * - SQLite seeds should be in `journal_mode=DELETE` (not WAL) so read-only
133
+ * opens need no `-shm`/`-wal` sidecar writes.
134
+ */
135
+ export interface SeedManifest {
136
+ link?: string[];
137
+ }
138
+
139
+ /**
140
+ * Converts a manifest glob into an anchored RegExp over the seed-relative
141
+ * posix path. Supported syntax (deliberately minimal, dependency-free):
142
+ * `**` matches any depth, `*` matches within one path segment, `?` matches
143
+ * a single non-separator char. Everything else is literal.
144
+ */
145
+ function globToRegExp(glob: string): RegExp {
146
+ let out = "";
147
+ for (let i = 0; i < glob.length; i++) {
148
+ const ch = glob[i] as string;
149
+ if (ch === "*") {
150
+ if (glob[i + 1] === "*") {
151
+ out += ".*";
152
+ i++;
153
+ } else {
154
+ out += "[^/]*";
155
+ }
156
+ } else if (ch === "?") {
157
+ out += "[^/]";
158
+ } else {
159
+ out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
160
+ }
161
+ }
162
+ return new RegExp(`^${out}$`);
163
+ }
164
+
165
+ /**
166
+ * Reads and validates the seed manifest, if present. Returns `null` when
167
+ * the seed has no manifest (the common case — plain full-copy seeds).
168
+ * Throws with an operator-actionable message on malformed JSON or shape —
169
+ * a broken manifest should fail session-create loudly, not silently fall
170
+ * back to copying a 500 MB database per session.
171
+ *
172
+ * @param seedDir - absolute seed folder path.
173
+ */
174
+ export function readSeedManifest(seedDir: string): SeedManifest | null {
175
+ const manifestPath = join(seedDir, SEED_MANIFEST_FILENAME);
176
+ if (!existsSync(manifestPath)) return null;
177
+ let parsed: unknown;
178
+ try {
179
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
180
+ } catch (err) {
181
+ throw new Error(
182
+ `Seed manifest is not valid JSON: ${manifestPath} (${String(err)})`,
183
+ );
184
+ }
185
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
186
+ throw new Error(`Seed manifest must be a JSON object: ${manifestPath}`);
187
+ }
188
+ const linkRaw = (parsed as Record<string, unknown>).link;
189
+ if (linkRaw !== undefined) {
190
+ if (
191
+ !Array.isArray(linkRaw) ||
192
+ linkRaw.some((p) => typeof p !== "string" || p.length === 0)
193
+ ) {
194
+ throw new Error(
195
+ `Seed manifest "link" must be an array of non-empty glob strings: ${manifestPath}`,
196
+ );
197
+ }
198
+ }
199
+ return linkRaw === undefined ? {} : { link: linkRaw as string[] };
200
+ }
201
+
202
+ /**
203
+ * Recursively materializes one directory level of the seed into the
204
+ * sandbox: entries whose seed-relative path matches a `link` glob are
205
+ * hardlinked (zero-copy, instant — seeds and sandboxes live under the same
206
+ * `AI_HUB_FILES_DIR`, hence the same filesystem); everything else is
207
+ * copied. A hardlink attempt that fails with `EXDEV` (cross-device — some
208
+ * exotic bind-mount layout) degrades to a copy rather than failing the
209
+ * session.
210
+ */
211
+ async function materializeWithManifest(
212
+ seedDir: string,
213
+ targetDir: string,
214
+ relDir: string,
215
+ linkMatchers: RegExp[],
216
+ ): Promise<void> {
217
+ const srcDir = relDir === "" ? seedDir : join(seedDir, relDir);
218
+ const dstDir = relDir === "" ? targetDir : join(targetDir, relDir);
219
+ await mkdir(dstDir, { recursive: true });
220
+ const entries = await readdir(srcDir, { withFileTypes: true });
221
+ for (const entry of entries) {
222
+ const rel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
223
+ if (rel === SEED_MANIFEST_FILENAME) continue;
224
+ if (entry.isDirectory()) {
225
+ await materializeWithManifest(seedDir, targetDir, rel, linkMatchers);
226
+ continue;
227
+ }
228
+ const src = join(seedDir, rel);
229
+ const dst = join(targetDir, rel);
230
+ if (entry.isFile() && linkMatchers.some((m) => m.test(rel))) {
231
+ try {
232
+ await link(src, dst);
233
+ } catch (err) {
234
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
235
+ await cp(src, dst);
236
+ }
237
+ continue;
238
+ }
239
+ // Non-linked files and symlinks: same per-entry semantics as the
240
+ // full-copy path below.
241
+ await cp(src, dst, { errorOnExist: false });
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Materializes the seed folder into the (not-yet-existing) sandbox target.
116
247
  *
117
- * Symlinks in the seed are followed (`{ verbatimSymlinks: false }` is
118
- * Node's default). Permissions are preserved. The implementation uses
119
- * `fs.promises.cp` so it's a single syscall sequence per file with no
120
- * intermediate I/O on our side.
248
+ * Without a `seed.manifest.json` in the seed root this is a full recursive
249
+ * copy byte-for-byte the pre-manifest behavior. With a manifest, files
250
+ * matching its `link` globs are hardlinked instead of copied (see
251
+ * {@link SeedManifest}); the manifest file itself is skipped.
252
+ *
253
+ * Throws on any I/O failure — the caller is responsible for rolling back
254
+ * the just-created Letta conversation if this throws.
121
255
  *
122
256
  * @param seedDir - source folder (must exist).
123
257
  * @param targetDir - destination folder (must NOT exist).
@@ -129,6 +263,12 @@ export async function materializeSandbox(
129
263
  if (!existsSync(seedDir)) {
130
264
  throw new Error(`Seed folder does not exist on disk: ${seedDir}`);
131
265
  }
266
+ const manifest = readSeedManifest(seedDir);
267
+ if (manifest !== null) {
268
+ const linkMatchers = (manifest.link ?? []).map(globToRegExp);
269
+ await materializeWithManifest(seedDir, targetDir, "", linkMatchers);
270
+ return;
271
+ }
132
272
  // `recursive: true` + `errorOnExist: false` matches `cp -R` semantics —
133
273
  // if `targetDir` somehow already exists (impossible in the happy path
134
274
  // since session ids are fresh UUIDs, but cheap to be safe), the
@@ -1,65 +1,14 @@
1
1
  /**
2
2
  * Project-shaped Letta types.
3
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.
4
+ * Post-migration (server-less local backend), the message shape comes from
5
+ * `@data-club/letta-runtime` — the fork's `messages list` subcommand prints
6
+ * the backend's Letta-format records verbatim, and `LettaMessageRecord`
7
+ * types the fields AI Hub's renderers actually read. The old
8
+ * `@letta-ai/letta-client` aliases are gone with the server.
51
9
  */
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
10
 
64
- /** Full assembled Letta message (used by `messages.list`). */
65
- export type LettaMessage = LettaSdkMessage;
11
+ export type {
12
+ LettaMessageRecord as LettaMessage,
13
+ SDKMessage,
14
+ } from "@data-club/letta-runtime";
@@ -46,8 +46,6 @@ const REDACT_PATHS: ReadonlyArray<string> = [
46
46
  "*.Authorization",
47
47
  "service_token_signing_key",
48
48
  "serviceTokenSigningKey",
49
- "letta_api_key",
50
- "lettaApiKey",
51
49
  "metrics_scrape_token",
52
50
  "metricsScrapeToken",
53
51
  "encryption_key",
@@ -39,7 +39,6 @@ import {
39
39
  } from "../../handlers/responses.js";
40
40
  import type { AIHubServerContext, RouteHandler } from "../../handlers/types.js";
41
41
  import { seedDirExists, validateSeedName } from "../../letta/sandbox.js";
42
- import { seedAgentToolsetNone } from "../../letta/settingsFileSeed.js";
43
42
 
44
43
  /* ────────────────────────────────────────────────────────────────────────── */
45
44
  /* Helpers */
@@ -247,42 +246,6 @@ function respondSpecializedFailure(
247
246
  }
248
247
  }
249
248
 
250
- /**
251
- * Best-effort seed of `~/.letta/settings.json` with `{toolset: "none"}` for
252
- * the given Letta agent id. Idempotent. Logs the outcome but **does not
253
- * throw**: if seeding fails (read-only home dir, permission denied, …) the
254
- * agent creation should still succeed — the toolset suppression is then
255
- * a runtime concern the operator notices when chats start producing weird
256
- * file-edit tool calls. The intent here is hands-off automation, not a
257
- * required precondition.
258
- *
259
- * @param ctx - server context (for logging).
260
- * @param lettaAgentId - id to seed the toolset preference for.
261
- *
262
- * @see ../../letta/settingsFileSeed.ts
263
- */
264
- function seedToolsetNoneBestEffort(
265
- ctx: AIHubServerContext,
266
- lettaAgentId: string,
267
- ): void {
268
- try {
269
- seedAgentToolsetNone({
270
- agentId: lettaAgentId,
271
- lettaBaseUrl: process.env.LETTA_BASE_URL,
272
- logger: ctx.logger,
273
- });
274
- } catch (err) {
275
- ctx.logger.warn(
276
- {
277
- agent_id: lettaAgentId,
278
- err: err instanceof Error ? err.message : String(err),
279
- event: "letta_code_settings_seed_failed",
280
- },
281
- "Failed to seed Letta Code toolset preference; continuing anyway.",
282
- );
283
- }
284
- }
285
-
286
249
  /* ────────────────────────────────────────────────────────────────────────── */
287
250
  /* GET /admin/agents */
288
251
  /* ────────────────────────────────────────────────────────────────────────── */
@@ -415,12 +378,6 @@ export const handleCreateAgent: RouteHandler = protectedRoute(
415
378
  const row = ctx.db.select().from(agent).where(eq(agent.id, id)).get();
416
379
  if (!row) throw new Error("Just-inserted agent vanished.");
417
380
 
418
- // Seed `~/.letta/settings.json` so Letta Code's bundled CLI suppresses
419
- // its auto-toolset (Read/Write/Bash/etc.) when chat sessions spawn
420
- // against this agent. ai-hub wants only the tools the ADE admin
421
- // explicitly attached. Best-effort: a write failure logs + continues.
422
- seedToolsetNoneBestEffort(ctx, row.lettaAgentId);
423
-
424
381
  ctx.logger.info(
425
382
  {
426
383
  actor_id: auth.user.id,
@@ -553,12 +510,6 @@ export const handlePatchAgent: RouteHandler = protectedRoute(
553
510
  // remove the old agent's seeded record — it may still be backing live
554
511
  // sessions per the §11 schema note: existing sessions keep their
555
512
  // original `letta_conversation_id` against the original Letta agent.)
556
- if (
557
- body.letta_agent_id !== undefined &&
558
- body.letta_agent_id !== existing.lettaAgentId
559
- ) {
560
- seedToolsetNoneBestEffort(ctx, body.letta_agent_id);
561
- }
562
513
 
563
514
  ctx.logger.info(
564
515
  {
@@ -35,6 +35,7 @@ import {
35
35
  validationError,
36
36
  } from "../../handlers/responses.js";
37
37
  import type { AIHubServerContext, RouteHandler } from "../../handlers/types.js";
38
+ import { seedDirExists, validateSeedName } from "../../letta/sandbox.js";
38
39
  import {
39
40
  computeNextRotation,
40
41
  runRotation,
@@ -42,6 +43,39 @@ import {
42
43
  type RotationSchedule,
43
44
  } from "../../state/rotationRunner.js";
44
45
 
46
+ /**
47
+ * Validates a `sandbox_seed_name` patch/create value the same way
48
+ * `/admin/agents` does. `null`/`""` mean "clear" and normalize to `null`.
49
+ * Returns an error Response, or the normalized value.
50
+ */
51
+ function checkSandboxSeedName(
52
+ ctx: AIHubServerContext,
53
+ value: string | null,
54
+ ): { ok: true; normalized: string | null } | { ok: false; response: Response } {
55
+ if (value === null || value === "") return { ok: true, normalized: null };
56
+ if (validateSeedName(value) !== "ok") {
57
+ return {
58
+ ok: false,
59
+ response: errorResponse(
60
+ 400,
61
+ "invalid_seed_name",
62
+ "sandbox_seed_name may only contain letters, digits, underscores and dashes.",
63
+ ),
64
+ };
65
+ }
66
+ if (!seedDirExists(ctx.filesDir, value)) {
67
+ return {
68
+ ok: false,
69
+ response: errorResponse(
70
+ 400,
71
+ "seed_dir_not_found",
72
+ "No seed folder with that name exists on disk.",
73
+ ),
74
+ };
75
+ }
76
+ return { ok: true, normalized: value };
77
+ }
78
+
45
79
  /* ────────────────────────────────────────────────────────────────────────── */
46
80
  /* Helpers */
47
81
  /* ────────────────────────────────────────────────────────────────────────── */
@@ -63,6 +97,7 @@ function rotatingMessageView(row: RotatingMessageRow): Record<string, unknown> {
63
97
  last_rotated_at: row.lastRotatedAt,
64
98
  next_rotation_at: row.nextRotationAt,
65
99
  is_archived: row.isArchived,
100
+ sandbox_seed_name: row.sandboxSeedName,
66
101
  reasoning_visible: row.reasoningVisible,
67
102
  tool_calls_visible: row.toolCallsVisible,
68
103
  created_at: row.createdAt,
@@ -132,6 +167,7 @@ const createSchema = z
132
167
  interval_seconds: z.number().int().nullable().optional(),
133
168
  daily_time: z.string().max(5).nullable().optional(),
134
169
  cron_expression: z.string().max(255).nullable().optional(),
170
+ sandbox_seed_name: z.string().max(128).nullable().optional(),
135
171
  reasoning_visible: z.boolean().optional(),
136
172
  tool_calls_visible: z.boolean().optional(),
137
173
  })
@@ -173,6 +209,9 @@ export const handleCreateRotatingMessage: RouteHandler = protectedRoute(
173
209
  return errorResponse(400, "invalid_schedule", scheduleCheck.message);
174
210
  }
175
211
 
212
+ const seedCheck = checkSandboxSeedName(ctx, body.sandbox_seed_name ?? null);
213
+ if (!seedCheck.ok) return seedCheck.response;
214
+
176
215
  const id = `rmsg-${randomUUID()}`;
177
216
  const ts = nowIso();
178
217
  ctx.db
@@ -193,6 +232,7 @@ export const handleCreateRotatingMessage: RouteHandler = protectedRoute(
193
232
  // Due immediately — the scheduler picks it up on the next tick.
194
233
  nextRotationAt: ts,
195
234
  isArchived: false,
235
+ sandboxSeedName: seedCheck.normalized,
196
236
  reasoningVisible: body.reasoning_visible ?? true,
197
237
  toolCallsVisible: body.tool_calls_visible ?? true,
198
238
  createdAt: ts,
@@ -230,6 +270,7 @@ const patchSchema = z
230
270
  daily_time: z.string().max(5).nullable().optional(),
231
271
  cron_expression: z.string().max(255).nullable().optional(),
232
272
  is_archived: z.boolean().optional(),
273
+ sandbox_seed_name: z.string().max(128).nullable().optional(),
233
274
  reasoning_visible: z.boolean().optional(),
234
275
  tool_calls_visible: z.boolean().optional(),
235
276
  })
@@ -274,6 +315,11 @@ export const handlePatchRotatingMessage: RouteHandler = protectedRoute(
274
315
  if (body.prompt !== undefined) update.prompt = body.prompt;
275
316
  if (body.letta_agent_id !== undefined) update.lettaAgentId = body.letta_agent_id;
276
317
  if (body.is_archived !== undefined) update.isArchived = body.is_archived;
318
+ if (body.sandbox_seed_name !== undefined) {
319
+ const seedCheck = checkSandboxSeedName(ctx, body.sandbox_seed_name);
320
+ if (!seedCheck.ok) return seedCheck.response;
321
+ update.sandboxSeedName = seedCheck.normalized;
322
+ }
277
323
  if (body.reasoning_visible !== undefined)
278
324
  update.reasoningVisible = body.reasoning_visible;
279
325
  if (body.tool_calls_visible !== undefined)
@@ -340,7 +386,7 @@ export const handleRotateRotatingMessageNow: RouteHandler = protectedRoute(
340
386
  if (!row) return notFound("Rotating message not found.");
341
387
 
342
388
  const outcome = await runRotation(
343
- { db: ctx.db, lettaClient: ctx.lettaClient, logger: ctx.logger },
389
+ { db: ctx.db, lettaClient: ctx.lettaClient, logger: ctx.logger, filesDir: ctx.filesDir },
344
390
  row,
345
391
  );
346
392
  const fresh = ctx.db.select().from(rotatingMessage).where(eq(rotatingMessage.id, id)).get();