@data-club/ai-hub-server 0.0.9 → 0.0.10
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 +0 -17
- package/dist/db/migrate.js.map +1 -1
- package/dist/db/schema.d.ts +0 -263
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +0 -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 +0 -2
- 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/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/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 +0 -17
- package/src/db/schema.ts +0 -68
- package/src/db/seed.ts +6 -47
- package/src/handlers/fetch.ts +6 -9
- 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/settings.ts +9 -113
- package/src/routes/auth.ts +1 -5
- package/src/routes/sessions.ts +11 -32
- package/src/state/turnRunner.ts +1 -77
- package/src/letta/lettaCliShim.mjs +0 -40
- package/src/letta/settingsFileSeed.ts +0 -254
package/src/letta/index.ts
CHANGED
|
@@ -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.
|
|
4
|
-
*
|
|
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";
|
package/src/letta/sandbox.ts
CHANGED
|
@@ -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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* `
|
|
120
|
-
*
|
|
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
|
package/src/letta/types.ts
CHANGED
|
@@ -1,65 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project-shaped Letta types.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* types
|
|
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
|
-
|
|
65
|
-
|
|
11
|
+
export type {
|
|
12
|
+
LettaMessageRecord as LettaMessage,
|
|
13
|
+
SDKMessage,
|
|
14
|
+
} from "@data-club/letta-runtime";
|
|
@@ -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
|
{
|
|
@@ -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
|
);
|