@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.
- package/dist/auth/encryption.d.ts +5 -5
- package/dist/auth/encryption.js +5 -5
- package/dist/auth/userView.d.ts +8 -15
- package/dist/auth/userView.d.ts.map +1 -1
- package/dist/auth/userView.js +10 -23
- package/dist/auth/userView.js.map +1 -1
- package/dist/bootstrap.d.ts +2 -8
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +2 -35
- package/dist/bootstrap.js.map +1 -1
- package/dist/db/migrate.js +6 -17
- package/dist/db/migrate.js.map +1 -1
- package/dist/db/schema.d.ts +17 -263
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +8 -57
- package/dist/db/schema.js.map +1 -1
- package/dist/db/seed.d.ts +4 -19
- package/dist/db/seed.d.ts.map +1 -1
- package/dist/db/seed.js +6 -33
- package/dist/db/seed.js.map +1 -1
- package/dist/handlers/fetch.d.ts +6 -7
- package/dist/handlers/fetch.d.ts.map +1 -1
- package/dist/handlers/fetch.js +1 -3
- package/dist/handlers/fetch.js.map +1 -1
- package/dist/handlers/types.d.ts +5 -6
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/letta/client.d.ts +86 -263
- package/dist/letta/client.d.ts.map +1 -1
- package/dist/letta/client.js +99 -404
- package/dist/letta/client.js.map +1 -1
- package/dist/letta/index.d.ts +6 -8
- package/dist/letta/index.d.ts.map +1 -1
- package/dist/letta/index.js +5 -7
- package/dist/letta/index.js.map +1 -1
- package/dist/letta/sandbox.d.ts +45 -9
- package/dist/letta/sandbox.d.ts.map +1 -1
- package/dist/letta/sandbox.js +123 -11
- package/dist/letta/sandbox.js.map +1 -1
- package/dist/letta/types.d.ts +6 -49
- package/dist/letta/types.d.ts.map +1 -1
- package/dist/letta/types.js +5 -10
- package/dist/letta/types.js.map +1 -1
- package/dist/observability/logger.d.ts.map +1 -1
- package/dist/observability/logger.js +0 -2
- package/dist/observability/logger.js.map +1 -1
- package/dist/routes/admin/agents.d.ts.map +1 -1
- package/dist/routes/admin/agents.js +0 -40
- package/dist/routes/admin/agents.js.map +1 -1
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -1
- package/dist/routes/admin/rotatingMessages.js +37 -1
- package/dist/routes/admin/rotatingMessages.js.map +1 -1
- package/dist/routes/admin/settings.d.ts +6 -15
- package/dist/routes/admin/settings.d.ts.map +1 -1
- package/dist/routes/admin/settings.js +9 -107
- package/dist/routes/admin/settings.js.map +1 -1
- package/dist/routes/auth.d.ts +0 -4
- package/dist/routes/auth.d.ts.map +1 -1
- package/dist/routes/auth.js +1 -5
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +9 -30
- package/dist/routes/sessions.js.map +1 -1
- package/dist/state/rotationRunner.d.ts +6 -0
- package/dist/state/rotationRunner.d.ts.map +1 -1
- package/dist/state/rotationRunner.js +19 -2
- package/dist/state/rotationRunner.js.map +1 -1
- package/dist/state/rotationScheduler.d.ts +2 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -1
- package/dist/state/rotationScheduler.js.map +1 -1
- package/dist/state/turnRunner.d.ts.map +1 -1
- package/dist/state/turnRunner.js +1 -75
- package/dist/state/turnRunner.js.map +1 -1
- package/package.json +4 -4
- package/src/auth/encryption.ts +5 -5
- package/src/auth/userView.ts +9 -22
- package/src/bootstrap.ts +4 -43
- package/src/db/migrate.ts +6 -17
- package/src/db/schema.ts +8 -68
- package/src/db/seed.ts +6 -47
- package/src/handlers/fetch.ts +7 -10
- package/src/handlers/types.ts +5 -6
- package/src/letta/client.ts +170 -634
- package/src/letta/index.ts +8 -21
- package/src/letta/sandbox.ts +151 -11
- package/src/letta/types.ts +9 -60
- package/src/observability/logger.ts +0 -2
- package/src/routes/admin/agents.ts +0 -49
- package/src/routes/admin/rotatingMessages.ts +47 -1
- package/src/routes/admin/settings.ts +9 -113
- package/src/routes/auth.ts +1 -5
- package/src/routes/sessions.ts +11 -32
- package/src/state/rotationRunner.ts +37 -1
- package/src/state/rotationScheduler.ts +2 -0
- package/src/state/turnRunner.ts +1 -77
- package/src/letta/lettaCliShim.mjs +0 -40
- package/src/letta/settingsFileSeed.ts +0 -254
|
@@ -3,13 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Five endpoints:
|
|
5
5
|
*
|
|
6
|
-
* - `GET /admin/settings` —
|
|
7
|
-
* metrics + signing-key rotation timestamps.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
-
*
|
|
245
|
-
* `
|
|
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
|
|
204
|
+
return jsonResponse(200, settingsView(fresh));
|
|
309
205
|
},
|
|
310
206
|
);
|
|
311
207
|
|
package/src/routes/auth.ts
CHANGED
|
@@ -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
|
|
492
|
+
deployment: buildDeploymentView(settings ?? null),
|
|
497
493
|
});
|
|
498
494
|
},
|
|
499
495
|
);
|
package/src/routes/sessions.ts
CHANGED
|
@@ -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 { ...
|
|
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 { ...
|
|
906
|
+
return { ...msg, content: stripped };
|
|
932
907
|
}
|
|
933
908
|
}
|
|
934
|
-
return
|
|
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:
|
|
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(
|
|
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. */
|
package/src/state/turnRunner.ts
CHANGED
|
@@ -45,7 +45,7 @@ import type {
|
|
|
45
45
|
TurnStatus,
|
|
46
46
|
} from "@data-club/ai-hub";
|
|
47
47
|
|
|
48
|
-
import {
|
|
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);
|