@data-club/ai-hub-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/argon2.d.ts +72 -0
- package/dist/auth/argon2.d.ts.map +1 -0
- package/dist/auth/argon2.js +145 -0
- package/dist/auth/argon2.js.map +1 -0
- package/dist/auth/encryption.d.ts +52 -0
- package/dist/auth/encryption.d.ts.map +1 -0
- package/dist/auth/encryption.js +98 -0
- package/dist/auth/encryption.js.map +1 -0
- package/dist/auth/jwt.d.ts +133 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +147 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/middleware.d.ts +101 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +161 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/passwordPolicy.d.ts +72 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +88 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/roleRules.d.ts +200 -0
- package/dist/auth/roleRules.d.ts.map +1 -0
- package/dist/auth/roleRules.js +365 -0
- package/dist/auth/roleRules.js.map +1 -0
- package/dist/auth/userView.d.ts +55 -0
- package/dist/auth/userView.d.ts.map +1 -0
- package/dist/auth/userView.js +77 -0
- package/dist/auth/userView.js.map +1 -0
- package/dist/bootstrap.d.ts +104 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +160 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/db/connection.d.ts +39 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +62 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +31 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +197 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.d.ts +1902 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +662 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/seed.d.ts +92 -0
- package/dist/db/seed.d.ts.map +1 -0
- package/dist/db/seed.js +160 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/handlers/fetch.d.ts +89 -0
- package/dist/handlers/fetch.d.ts.map +1 -0
- package/dist/handlers/fetch.js +173 -0
- package/dist/handlers/fetch.js.map +1 -0
- package/dist/handlers/nextjs.d.ts +40 -0
- package/dist/handlers/nextjs.d.ts.map +1 -0
- package/dist/handlers/nextjs.js +42 -0
- package/dist/handlers/nextjs.js.map +1 -0
- package/dist/handlers/responses.d.ts +46 -0
- package/dist/handlers/responses.d.ts.map +1 -0
- package/dist/handlers/responses.js +74 -0
- package/dist/handlers/responses.js.map +1 -0
- package/dist/handlers/routeTable.d.ts +21 -0
- package/dist/handlers/routeTable.d.ts.map +1 -0
- package/dist/handlers/routeTable.js +133 -0
- package/dist/handlers/routeTable.js.map +1 -0
- package/dist/handlers/router.d.ts +37 -0
- package/dist/handlers/router.d.ts.map +1 -0
- package/dist/handlers/router.js +82 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/handlers/sse.d.ts +38 -0
- package/dist/handlers/sse.d.ts.map +1 -0
- package/dist/handlers/sse.js +52 -0
- package/dist/handlers/sse.js.map +1 -0
- package/dist/handlers/types.d.ts +96 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +14 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/letta/client.d.ts +355 -0
- package/dist/letta/client.d.ts.map +1 -0
- package/dist/letta/client.js +529 -0
- package/dist/letta/client.js.map +1 -0
- package/dist/letta/eventTranslator.d.ts +166 -0
- package/dist/letta/eventTranslator.d.ts.map +1 -0
- package/dist/letta/eventTranslator.js +532 -0
- package/dist/letta/eventTranslator.js.map +1 -0
- package/dist/letta/index.d.ts +13 -0
- package/dist/letta/index.d.ts.map +1 -0
- package/dist/letta/index.js +12 -0
- package/dist/letta/index.js.map +1 -0
- package/dist/letta/modelSettings.d.ts +176 -0
- package/dist/letta/modelSettings.d.ts.map +1 -0
- package/dist/letta/modelSettings.js +314 -0
- package/dist/letta/modelSettings.js.map +1 -0
- package/dist/letta/promptWrapping.d.ts +141 -0
- package/dist/letta/promptWrapping.d.ts.map +1 -0
- package/dist/letta/promptWrapping.js +213 -0
- package/dist/letta/promptWrapping.js.map +1 -0
- package/dist/letta/sandbox.d.ts +100 -0
- package/dist/letta/sandbox.d.ts.map +1 -0
- package/dist/letta/sandbox.js +151 -0
- package/dist/letta/sandbox.js.map +1 -0
- package/dist/letta/settingsFileSeed.d.ts +146 -0
- package/dist/letta/settingsFileSeed.d.ts.map +1 -0
- package/dist/letta/settingsFileSeed.js +177 -0
- package/dist/letta/settingsFileSeed.js.map +1 -0
- package/dist/letta/subprocessThrottle.d.ts +151 -0
- package/dist/letta/subprocessThrottle.d.ts.map +1 -0
- package/dist/letta/subprocessThrottle.js +195 -0
- package/dist/letta/subprocessThrottle.js.map +1 -0
- package/dist/letta/tpmRecovery.d.ts +202 -0
- package/dist/letta/tpmRecovery.d.ts.map +1 -0
- package/dist/letta/tpmRecovery.js +314 -0
- package/dist/letta/tpmRecovery.js.map +1 -0
- package/dist/letta/types.d.ts +54 -0
- package/dist/letta/types.d.ts.map +1 -0
- package/dist/letta/types.js +16 -0
- package/dist/letta/types.js.map +1 -0
- package/dist/observability/logger.d.ts +48 -0
- package/dist/observability/logger.d.ts.map +1 -0
- package/dist/observability/logger.js +81 -0
- package/dist/observability/logger.js.map +1 -0
- package/dist/observability/metrics.d.ts +53 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +92 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/routes/admin/agents.d.ts +63 -0
- package/dist/routes/admin/agents.d.ts.map +1 -0
- package/dist/routes/admin/agents.js +461 -0
- package/dist/routes/admin/agents.js.map +1 -0
- package/dist/routes/admin/rotatingMessages.d.ts +51 -0
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
- package/dist/routes/admin/rotatingMessages.js +292 -0
- package/dist/routes/admin/rotatingMessages.js.map +1 -0
- package/dist/routes/admin/sessions.d.ts +61 -0
- package/dist/routes/admin/sessions.d.ts.map +1 -0
- package/dist/routes/admin/sessions.js +232 -0
- package/dist/routes/admin/sessions.js.map +1 -0
- package/dist/routes/admin/settings.d.ts +110 -0
- package/dist/routes/admin/settings.d.ts.map +1 -0
- package/dist/routes/admin/settings.js +401 -0
- package/dist/routes/admin/settings.js.map +1 -0
- package/dist/routes/admin/users.d.ts +115 -0
- package/dist/routes/admin/users.d.ts.map +1 -0
- package/dist/routes/admin/users.js +374 -0
- package/dist/routes/admin/users.js.map +1 -0
- package/dist/routes/agents.d.ts +30 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +55 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/auth.d.ts +121 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +422 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +97 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +484 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/featureToggles.d.ts +34 -0
- package/dist/routes/featureToggles.d.ts.map +1 -0
- package/dist/routes/featureToggles.js +47 -0
- package/dist/routes/featureToggles.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +37 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/metrics.d.ts +35 -0
- package/dist/routes/metrics.d.ts.map +1 -0
- package/dist/routes/metrics.js +106 -0
- package/dist/routes/metrics.js.map +1 -0
- package/dist/routes/sessions.d.ts +161 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +1501 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stubs.d.ts +25 -0
- package/dist/routes/stubs.d.ts.map +1 -0
- package/dist/routes/stubs.js +25 -0
- package/dist/routes/stubs.js.map +1 -0
- package/dist/state/cron.d.ts +58 -0
- package/dist/state/cron.d.ts.map +1 -0
- package/dist/state/cron.js +160 -0
- package/dist/state/cron.js.map +1 -0
- package/dist/state/rotationRunner.d.ts +96 -0
- package/dist/state/rotationRunner.d.ts.map +1 -0
- package/dist/state/rotationRunner.js +246 -0
- package/dist/state/rotationRunner.js.map +1 -0
- package/dist/state/rotationScheduler.d.ts +76 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -0
- package/dist/state/rotationScheduler.js +124 -0
- package/dist/state/rotationScheduler.js.map +1 -0
- package/dist/state/turnRegistry.d.ts +95 -0
- package/dist/state/turnRegistry.d.ts.map +1 -0
- package/dist/state/turnRegistry.js +149 -0
- package/dist/state/turnRegistry.js.map +1 -0
- package/dist/state/turnRunner.d.ts +134 -0
- package/dist/state/turnRunner.d.ts.map +1 -0
- package/dist/state/turnRunner.js +476 -0
- package/dist/state/turnRunner.js.map +1 -0
- package/package.json +47 -0
- package/src/auth/argon2.ts +152 -0
- package/src/auth/encryption.ts +110 -0
- package/src/auth/jwt.ts +217 -0
- package/src/auth/middleware.ts +250 -0
- package/src/auth/passwordPolicy.ts +99 -0
- package/src/auth/roleRules.ts +497 -0
- package/src/auth/userView.ts +88 -0
- package/src/bootstrap.ts +254 -0
- package/src/db/connection.ts +75 -0
- package/src/db/migrate.ts +212 -0
- package/src/db/schema.ts +750 -0
- package/src/db/seed.ts +236 -0
- package/src/handlers/fetch.ts +251 -0
- package/src/handlers/nextjs.ts +49 -0
- package/src/handlers/responses.ts +95 -0
- package/src/handlers/routeTable.ts +188 -0
- package/src/handlers/router.ts +96 -0
- package/src/handlers/sse.ts +55 -0
- package/src/handlers/types.ts +104 -0
- package/src/index.ts +60 -0
- package/src/letta/client.ts +860 -0
- package/src/letta/eventTranslator.ts +667 -0
- package/src/letta/index.ts +46 -0
- package/src/letta/lettaCliShim.mjs +40 -0
- package/src/letta/sandbox.ts +167 -0
- package/src/letta/settingsFileSeed.ts +254 -0
- package/src/letta/subprocessThrottle.ts +251 -0
- package/src/letta/types.ts +65 -0
- package/src/observability/logger.ts +103 -0
- package/src/observability/metrics.ts +114 -0
- package/src/routes/admin/agents.ts +574 -0
- package/src/routes/admin/rotatingMessages.ts +351 -0
- package/src/routes/admin/sessions.ts +283 -0
- package/src/routes/admin/settings.ts +463 -0
- package/src/routes/admin/users.ts +455 -0
- package/src/routes/agents.ts +66 -0
- package/src/routes/auth.ts +513 -0
- package/src/routes/chat.ts +534 -0
- package/src/routes/featureToggles.ts +52 -0
- package/src/routes/health.ts +44 -0
- package/src/routes/metrics.ts +111 -0
- package/src/routes/sessions.ts +1878 -0
- package/src/state/cron.ts +179 -0
- package/src/state/rotationRunner.ts +326 -0
- package/src/state/rotationScheduler.ts +164 -0
- package/src/state/turnRegistry.ts +210 -0
- package/src/state/turnRunner.ts +588 -0
|
@@ -0,0 +1,1878 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing `/sessions/*` — the caller's own sessions (never cross-user).
|
|
3
|
+
*
|
|
4
|
+
* Step 4 ships the four CRUD endpoints minus DELETE (sessions are archive-only
|
|
5
|
+
* in v1 — §6.3.1). The Letta-conversation-create call on `POST /sessions` is
|
|
6
|
+
* stubbed; Step 5 replaces the placeholder with `client.conversations.create`.
|
|
7
|
+
*
|
|
8
|
+
* - `POST /sessions` — body `{ agent_id, title }`; title required (1..80
|
|
9
|
+
* chars). Creates a session owned by the caller against `agent_id`. The
|
|
10
|
+
* server snapshots `agent.letta_agent_id` into `session.letta_agent_id` and
|
|
11
|
+
* stores a placeholder `letta_conversation_id` (Step 5 fills the real one).
|
|
12
|
+
* Rejects when the agent is archived (`agent_archived`).
|
|
13
|
+
* - `GET /sessions?agent_id=...&include_archived=false` — caller-scoped list.
|
|
14
|
+
* `agent_id` is required; `include_archived` defaults to false.
|
|
15
|
+
* - `GET /sessions/:id` — single session if the caller owns it; 404 with no
|
|
16
|
+
* enumeration leakage otherwise.
|
|
17
|
+
* - `PATCH /sessions/:id` — body `{ title?, is_archived? }`. Rename and / or
|
|
18
|
+
* archive / unarchive. An empty patch is a 200 no-op.
|
|
19
|
+
* - `GET /sessions/:id/messages` — 501 stub; Step 5 wires
|
|
20
|
+
* `client.conversations.messages.list`.
|
|
21
|
+
*
|
|
22
|
+
* @see SPECIFICATIONS.md §6.3.1 — Sessions
|
|
23
|
+
* @see SPECIFICATIONS.md §11 — `session` table
|
|
24
|
+
* @see SPECIFICATIONS.md §13.2 — user isolation
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
|
|
29
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
|
|
32
|
+
import type { AIHubMessage, AIHubToolCallRecord } from "@data-club/ai-hub";
|
|
33
|
+
|
|
34
|
+
import { protectedRoute } from "../auth/middleware.js";
|
|
35
|
+
import {
|
|
36
|
+
agent,
|
|
37
|
+
messageOverride,
|
|
38
|
+
messageUsage,
|
|
39
|
+
rotatingMessage,
|
|
40
|
+
session,
|
|
41
|
+
turn,
|
|
42
|
+
} from "../db/schema.js";
|
|
43
|
+
import type { SessionRow } from "../db/schema.js";
|
|
44
|
+
import type { DrizzleDB } from "../db/connection.js";
|
|
45
|
+
import {
|
|
46
|
+
conflictError,
|
|
47
|
+
internalError,
|
|
48
|
+
jsonResponse,
|
|
49
|
+
notFound,
|
|
50
|
+
validationError,
|
|
51
|
+
} from "../handlers/responses.js";
|
|
52
|
+
import type { RouteHandler } from "../handlers/types.js";
|
|
53
|
+
import { ForkSourceNotFoundError } from "../letta/client.js";
|
|
54
|
+
import {
|
|
55
|
+
materializeSandbox,
|
|
56
|
+
purgeSandbox,
|
|
57
|
+
resolveSandboxDir,
|
|
58
|
+
resolveSeedDir,
|
|
59
|
+
} from "../letta/sandbox.js";
|
|
60
|
+
|
|
61
|
+
// Per-model reasoning capability detection lives in Letta Code itself;
|
|
62
|
+
// ai-hub no longer publishes a `capabilities` map on session responses.
|
|
63
|
+
// The `reasoning.{effort,verbosity,thinking_budget}` columns on `session`
|
|
64
|
+
// are kept for forward compatibility with `PATCH /sessions/:id/reasoning`
|
|
65
|
+
// but are not forwarded to Letta Code in v1.x.
|
|
66
|
+
type ReasoningCapabilities = Record<string, never>;
|
|
67
|
+
import type { LettaMessage } from "../letta/types.js";
|
|
68
|
+
|
|
69
|
+
// Letta's prompt compiler appends a trailing `<memory_metadata>…</memory_metadata>`
|
|
70
|
+
// block to every persisted `system_message.content` with agent id /
|
|
71
|
+
// conversation id / recall-message count. Useful for the LLM but noise
|
|
72
|
+
// for the chat-UI display, so we strip it before returning to the client.
|
|
73
|
+
const MEMORY_METADATA_REGEX = /\s*<memory_metadata>[\s\S]*?<\/memory_metadata>\s*$/;
|
|
74
|
+
function stripMemoryMetadata(content: string): string {
|
|
75
|
+
return content.replace(MEMORY_METADATA_REGEX, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
79
|
+
/* Wire shape */
|
|
80
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Wire-format session row returned to the client. The Letta-side ids
|
|
84
|
+
* (`letta_conversation_id`, `letta_agent_id`) are server-internal and
|
|
85
|
+
* deliberately omitted — the React client only ever sees ai-hub ids.
|
|
86
|
+
*
|
|
87
|
+
* @param row - the `session` row.
|
|
88
|
+
* @returns the JSON-safe response shape.
|
|
89
|
+
*/
|
|
90
|
+
function sessionView(
|
|
91
|
+
row: SessionRow,
|
|
92
|
+
extras?: {
|
|
93
|
+
/** Per-model reasoning capability map. Included only when the caller
|
|
94
|
+
* wants the per-session selector to render (i.e. on `GET /sessions/:id`).
|
|
95
|
+
* Omitted on list responses to save a Letta call per row. */
|
|
96
|
+
capabilities?: ReasoningCapabilities;
|
|
97
|
+
},
|
|
98
|
+
): Record<string, unknown> {
|
|
99
|
+
return {
|
|
100
|
+
id: row.id,
|
|
101
|
+
user_id: row.userId,
|
|
102
|
+
agent_id: row.agentId,
|
|
103
|
+
title: row.title,
|
|
104
|
+
is_archived: row.isArchived,
|
|
105
|
+
reasoning: {
|
|
106
|
+
effort: row.reasoningEffort,
|
|
107
|
+
verbosity: row.verbosity,
|
|
108
|
+
thinking_budget: row.thinkingBudget,
|
|
109
|
+
},
|
|
110
|
+
...(extras?.capabilities !== undefined && { capabilities: extras.capabilities }),
|
|
111
|
+
created_at: row.createdAt,
|
|
112
|
+
updated_at: row.updatedAt,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Current ISO-8601 timestamp with millisecond precision. */
|
|
117
|
+
function nowIso(): string {
|
|
118
|
+
return new Date().toISOString();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolution of an agent's `fork_source_conversation_id` to a concrete Letta
|
|
123
|
+
* conversation to fork at session-create.
|
|
124
|
+
*
|
|
125
|
+
* - `none` — no base configured; start a brand-new conversation.
|
|
126
|
+
* - `ready` — fork `conversationId` (a raw `conv-…`, or a rotating
|
|
127
|
+
* message's current prepared conversation).
|
|
128
|
+
* - `rmsg_missing` — the `rmsg-…` reference points at a deleted/archived
|
|
129
|
+
* rotating message (operator misconfiguration).
|
|
130
|
+
* - `rmsg_not_ready` — the rotating message exists but hasn't produced a
|
|
131
|
+
* conversation yet (no successful rotation so far).
|
|
132
|
+
*/
|
|
133
|
+
type ForkSourceResolution =
|
|
134
|
+
| { kind: "none" }
|
|
135
|
+
| { kind: "ready"; conversationId: string }
|
|
136
|
+
| { kind: "rmsg_missing"; ref: string }
|
|
137
|
+
| { kind: "rmsg_not_ready"; ref: string };
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolves an agent's configured fork source. A plain `conv-…` id passes
|
|
141
|
+
* through unchanged (today's static base-conversation behavior); an `rmsg-…`
|
|
142
|
+
* reference is dereferenced to the rotating message's current prepared
|
|
143
|
+
* conversation. Pure DB read — no Letta call.
|
|
144
|
+
*
|
|
145
|
+
* @param db - Drizzle handle.
|
|
146
|
+
* @param forkSourceConversationId - the agent's stored value (may be null).
|
|
147
|
+
* @returns the resolution variant.
|
|
148
|
+
*/
|
|
149
|
+
function resolveForkSource(
|
|
150
|
+
db: DrizzleDB,
|
|
151
|
+
forkSourceConversationId: string | null,
|
|
152
|
+
): ForkSourceResolution {
|
|
153
|
+
if (!forkSourceConversationId) return { kind: "none" };
|
|
154
|
+
if (!forkSourceConversationId.startsWith("rmsg-")) {
|
|
155
|
+
return { kind: "ready", conversationId: forkSourceConversationId };
|
|
156
|
+
}
|
|
157
|
+
const rmsg = db
|
|
158
|
+
.select()
|
|
159
|
+
.from(rotatingMessage)
|
|
160
|
+
.where(eq(rotatingMessage.id, forkSourceConversationId))
|
|
161
|
+
.get();
|
|
162
|
+
if (!rmsg || rmsg.isArchived) {
|
|
163
|
+
return { kind: "rmsg_missing", ref: forkSourceConversationId };
|
|
164
|
+
}
|
|
165
|
+
if (!rmsg.currentConversationId) {
|
|
166
|
+
return { kind: "rmsg_not_ready", ref: forkSourceConversationId };
|
|
167
|
+
}
|
|
168
|
+
return { kind: "ready", conversationId: rmsg.currentConversationId };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
172
|
+
/* POST /sessions */
|
|
173
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
174
|
+
|
|
175
|
+
/** `POST /sessions` body. `title` is non-empty, 1..80 chars. */
|
|
176
|
+
const createSessionSchema = z
|
|
177
|
+
.object({
|
|
178
|
+
agent_id: z.string().uuid(),
|
|
179
|
+
title: z.string().min(1).max(80),
|
|
180
|
+
})
|
|
181
|
+
.strict();
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `POST /sessions` — create a new session for the caller against `agent_id`.
|
|
185
|
+
*
|
|
186
|
+
* Validates that the referenced ai-hub agent exists and is not archived.
|
|
187
|
+
* Snapshots `agent.letta_agent_id` so a future re-point of the catalog entry
|
|
188
|
+
* does not migrate existing conversations.
|
|
189
|
+
*
|
|
190
|
+
* Step 5 replaces the placeholder `letta_conversation_id` with a real SDK
|
|
191
|
+
* call to `client.conversations.create`.
|
|
192
|
+
*
|
|
193
|
+
* @see SPECIFICATIONS.md §6.3.1
|
|
194
|
+
*/
|
|
195
|
+
export const handleCreateSession: RouteHandler = protectedRoute(
|
|
196
|
+
{},
|
|
197
|
+
async (req, ctx, auth) => {
|
|
198
|
+
let raw: unknown;
|
|
199
|
+
try {
|
|
200
|
+
raw = await req.json();
|
|
201
|
+
} catch {
|
|
202
|
+
return validationError("Request body must be valid JSON.");
|
|
203
|
+
}
|
|
204
|
+
const parsed = createSessionSchema.safeParse(raw);
|
|
205
|
+
if (!parsed.success) {
|
|
206
|
+
const issue = parsed.error.issues[0];
|
|
207
|
+
let code: string = "validation";
|
|
208
|
+
if (issue?.path[0] === "title") {
|
|
209
|
+
if (issue.code === "too_small") code = "title_required";
|
|
210
|
+
else if (issue.code === "too_big") code = "title_too_long";
|
|
211
|
+
}
|
|
212
|
+
return validationError(issue?.message ?? "Invalid request body.", code);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const agentRow = ctx.db
|
|
216
|
+
.select()
|
|
217
|
+
.from(agent)
|
|
218
|
+
.where(eq(agent.id, parsed.data.agent_id))
|
|
219
|
+
.get();
|
|
220
|
+
if (!agentRow) return notFound("Agent not found.");
|
|
221
|
+
if (agentRow.isArchived) {
|
|
222
|
+
return conflictError(
|
|
223
|
+
"agent_archived",
|
|
224
|
+
"Cannot start a new session against an archived agent.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Branch on `agent.fork_source_conversation_id`:
|
|
229
|
+
// set → fork an operator-curated base conversation in Letta. The value
|
|
230
|
+
// is either a raw `conv-…` (static base, Step 12.5) or an
|
|
231
|
+
// `rmsg-…` reference to a rotating starting message, resolved to
|
|
232
|
+
// its current prepared conversation here.
|
|
233
|
+
// null → create a brand-new conversation (today's behavior).
|
|
234
|
+
//
|
|
235
|
+
// Both paths surface SDK failures as 500 — admin-side validation is
|
|
236
|
+
// responsible for catching the configuration-shape problems (missing
|
|
237
|
+
// source, wrong agent) before any user-facing session-create reaches
|
|
238
|
+
// here. A 404 from fork at this point means the operator deleted the
|
|
239
|
+
// source between admin-validate and now.
|
|
240
|
+
const forkResolution = resolveForkSource(ctx.db, agentRow.forkSourceConversationId);
|
|
241
|
+
if (forkResolution.kind === "rmsg_missing") {
|
|
242
|
+
ctx.logger.error(
|
|
243
|
+
{
|
|
244
|
+
event: "session_rotating_message_missing",
|
|
245
|
+
agent_id: agentRow.id,
|
|
246
|
+
fork_source_conversation_id: agentRow.forkSourceConversationId,
|
|
247
|
+
},
|
|
248
|
+
"Agent references a rotating starting message that no longer exists or is archived.",
|
|
249
|
+
);
|
|
250
|
+
return internalError("Configured rotating starting message is unavailable.");
|
|
251
|
+
}
|
|
252
|
+
if (forkResolution.kind === "rmsg_not_ready") {
|
|
253
|
+
ctx.logger.error(
|
|
254
|
+
{
|
|
255
|
+
event: "session_rotating_message_not_ready",
|
|
256
|
+
agent_id: agentRow.id,
|
|
257
|
+
fork_source_conversation_id: agentRow.forkSourceConversationId,
|
|
258
|
+
},
|
|
259
|
+
"Rotating starting message has not produced a prepared conversation yet.",
|
|
260
|
+
);
|
|
261
|
+
return internalError(
|
|
262
|
+
"The rotating starting message has not generated its first conversation yet. Try again shortly.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const forkSourceId =
|
|
266
|
+
forkResolution.kind === "ready" ? forkResolution.conversationId : null;
|
|
267
|
+
|
|
268
|
+
let conv: { id: string };
|
|
269
|
+
try {
|
|
270
|
+
if (forkSourceId) {
|
|
271
|
+
conv = await ctx.lettaClient.forkConversation(forkSourceId);
|
|
272
|
+
} else {
|
|
273
|
+
conv = await ctx.lettaClient.createConversation(agentRow.lettaAgentId);
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
if (err instanceof ForkSourceNotFoundError) {
|
|
277
|
+
ctx.logger.error(
|
|
278
|
+
{
|
|
279
|
+
event: "letta_fork_source_not_found",
|
|
280
|
+
agent_id: agentRow.id,
|
|
281
|
+
fork_source_conversation_id: agentRow.forkSourceConversationId,
|
|
282
|
+
},
|
|
283
|
+
"Letta fork-source conversation no longer exists.",
|
|
284
|
+
);
|
|
285
|
+
return internalError(
|
|
286
|
+
"Configured base conversation no longer exists in Letta.",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
ctx.logger.error(
|
|
290
|
+
{
|
|
291
|
+
event: "letta_create_conversation_failed",
|
|
292
|
+
agent_id: agentRow.id,
|
|
293
|
+
letta_agent_id: agentRow.lettaAgentId,
|
|
294
|
+
fork: !!agentRow.forkSourceConversationId,
|
|
295
|
+
err: err instanceof Error ? err.message : String(err),
|
|
296
|
+
},
|
|
297
|
+
"Letta conversation creation failed.",
|
|
298
|
+
);
|
|
299
|
+
return internalError("Failed to create Letta conversation.");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const id = randomUUID();
|
|
303
|
+
ctx.db
|
|
304
|
+
.insert(session)
|
|
305
|
+
.values({
|
|
306
|
+
id,
|
|
307
|
+
userId: auth.user.id,
|
|
308
|
+
agentId: agentRow.id,
|
|
309
|
+
lettaConversationId: conv.id,
|
|
310
|
+
lettaAgentId: agentRow.lettaAgentId,
|
|
311
|
+
title: parsed.data.title,
|
|
312
|
+
isArchived: false,
|
|
313
|
+
})
|
|
314
|
+
.run();
|
|
315
|
+
|
|
316
|
+
// Step-12.5 — if the agent has a sandbox seed configured, recursively
|
|
317
|
+
// copy the seed folder into `{filesDir}/sandboxes/{session_id}/` and
|
|
318
|
+
// record the absolute path on the session row. On copy failure, roll
|
|
319
|
+
// back: best-effort delete the partial sandbox dir + the just-created
|
|
320
|
+
// Letta conversation, then 500. Better to surface the error to the
|
|
321
|
+
// caller than to leave a dangling conversation + half-written folder.
|
|
322
|
+
if (agentRow.sandboxSeedName) {
|
|
323
|
+
const targetDir = resolveSandboxDir(ctx.filesDir, id);
|
|
324
|
+
let seedDir: string;
|
|
325
|
+
try {
|
|
326
|
+
seedDir = resolveSeedDir(ctx.filesDir, agentRow.sandboxSeedName);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
// Programmer error — should have been caught at admin-write time.
|
|
329
|
+
ctx.logger.error(
|
|
330
|
+
{
|
|
331
|
+
event: "session_sandbox_invalid_seed_name",
|
|
332
|
+
agent_id: agentRow.id,
|
|
333
|
+
sandbox_seed_name: agentRow.sandboxSeedName,
|
|
334
|
+
err: err instanceof Error ? err.message : String(err),
|
|
335
|
+
},
|
|
336
|
+
"Invalid sandbox_seed_name reached POST /sessions; should have been caught at admin-write.",
|
|
337
|
+
);
|
|
338
|
+
ctx.db.delete(session).where(eq(session.id, id)).run();
|
|
339
|
+
return internalError("Failed to materialize session sandbox.");
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
await materializeSandbox(seedDir, targetDir);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
ctx.logger.error(
|
|
345
|
+
{
|
|
346
|
+
event: "session_sandbox_materialize_failed",
|
|
347
|
+
session_id: id,
|
|
348
|
+
agent_id: agentRow.id,
|
|
349
|
+
sandbox_seed_name: agentRow.sandboxSeedName,
|
|
350
|
+
err: err instanceof Error ? err.message : String(err),
|
|
351
|
+
},
|
|
352
|
+
"Failed to materialize per-session sandbox folder.",
|
|
353
|
+
);
|
|
354
|
+
// Best-effort rollback: drop the just-inserted session row +
|
|
355
|
+
// the partially-copied folder. The Letta conversation is left
|
|
356
|
+
// in place — Letta doesn't expose a server-side delete that
|
|
357
|
+
// ai-hub uses (§6.4.1's deprecated-surfaces table), and the
|
|
358
|
+
// orphan conversation is harmless (no users / sessions reach
|
|
359
|
+
// it from ai-hub).
|
|
360
|
+
ctx.db.delete(session).where(eq(session.id, id)).run();
|
|
361
|
+
await purgeSandbox(targetDir).catch(() => {
|
|
362
|
+
/* best effort */
|
|
363
|
+
});
|
|
364
|
+
return internalError("Failed to materialize session sandbox.");
|
|
365
|
+
}
|
|
366
|
+
ctx.db
|
|
367
|
+
.update(session)
|
|
368
|
+
.set({ sandboxDir: targetDir, updatedAt: new Date().toISOString() })
|
|
369
|
+
.where(eq(session.id, id))
|
|
370
|
+
.run();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
374
|
+
if (!row) throw new Error("Just-inserted session vanished.");
|
|
375
|
+
|
|
376
|
+
ctx.logger.info(
|
|
377
|
+
{
|
|
378
|
+
user_id: auth.user.id,
|
|
379
|
+
session_id: row.id,
|
|
380
|
+
agent_id: agentRow.id,
|
|
381
|
+
forked: !!agentRow.forkSourceConversationId,
|
|
382
|
+
sandboxed: !!agentRow.sandboxSeedName,
|
|
383
|
+
event: "session_created",
|
|
384
|
+
},
|
|
385
|
+
"Session created.",
|
|
386
|
+
);
|
|
387
|
+
return jsonResponse(201, sessionView(row));
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
392
|
+
/* GET /sessions?agent_id=...&include_archived=... */
|
|
393
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* `GET /sessions` — list the caller's sessions for a given agent.
|
|
397
|
+
*
|
|
398
|
+
* Query parameters:
|
|
399
|
+
*
|
|
400
|
+
* - `agent_id` — required. The ai-hub agent (catalog entry) to scope the list
|
|
401
|
+
* to. Sessions for other agents are not returned (a single GET is
|
|
402
|
+
* per-agent, mirroring how the React sessions panel mounts inside an
|
|
403
|
+
* opened agent — see [03-chat.md](../../../../docs/ui-sketches/03-chat.md)).
|
|
404
|
+
* - `include_archived` — optional, default `false`. When `true`, archived
|
|
405
|
+
* rows are surfaced alongside active ones with their `is_archived` flag
|
|
406
|
+
* set; the client distinguishes them visually (`--archived` modifier).
|
|
407
|
+
*
|
|
408
|
+
* Order: newest first by `updated_at`.
|
|
409
|
+
*
|
|
410
|
+
* @see SPECIFICATIONS.md §6.3.1
|
|
411
|
+
*/
|
|
412
|
+
export const handleListSessions: RouteHandler = protectedRoute(
|
|
413
|
+
{},
|
|
414
|
+
async (req, _ctx, auth) => {
|
|
415
|
+
const url = new URL(req.url);
|
|
416
|
+
const agentId = url.searchParams.get("agent_id");
|
|
417
|
+
if (!agentId) {
|
|
418
|
+
return validationError(
|
|
419
|
+
"Query parameter `agent_id` is required.",
|
|
420
|
+
"agent_id_required",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
const includeArchived =
|
|
424
|
+
url.searchParams.get("include_archived")?.toLowerCase() === "true";
|
|
425
|
+
|
|
426
|
+
const ctx = _ctx; // alias for clarity
|
|
427
|
+
const where = includeArchived
|
|
428
|
+
? and(eq(session.userId, auth.user.id), eq(session.agentId, agentId))
|
|
429
|
+
: and(
|
|
430
|
+
eq(session.userId, auth.user.id),
|
|
431
|
+
eq(session.agentId, agentId),
|
|
432
|
+
eq(session.isArchived, false),
|
|
433
|
+
);
|
|
434
|
+
const rows = ctx.db
|
|
435
|
+
.select()
|
|
436
|
+
.from(session)
|
|
437
|
+
.where(where)
|
|
438
|
+
.orderBy(desc(session.updatedAt))
|
|
439
|
+
.all();
|
|
440
|
+
return jsonResponse(200, {
|
|
441
|
+
sessions: rows.map((r) => sessionView(r)),
|
|
442
|
+
});
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
447
|
+
/* GET /sessions/:id */
|
|
448
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* `GET /sessions/:id` — single session, owner-scoped.
|
|
452
|
+
*
|
|
453
|
+
* Returns 404 with `not_found` for both "no such session" and "exists but
|
|
454
|
+
* belongs to another user" — no enumeration leakage (§13.1).
|
|
455
|
+
*
|
|
456
|
+
* @see SPECIFICATIONS.md §6.3.1
|
|
457
|
+
*/
|
|
458
|
+
export const handleGetSession: RouteHandler = protectedRoute(
|
|
459
|
+
{},
|
|
460
|
+
async (_req, ctx, auth, params) => {
|
|
461
|
+
const id = params.id;
|
|
462
|
+
if (!id) return notFound();
|
|
463
|
+
const row = ctx.db
|
|
464
|
+
.select()
|
|
465
|
+
.from(session)
|
|
466
|
+
.where(eq(session.id, id))
|
|
467
|
+
.get();
|
|
468
|
+
if (!row || row.userId !== auth.user.id) return notFound();
|
|
469
|
+
|
|
470
|
+
// Per-model reasoning capabilities are no longer published — see
|
|
471
|
+
// migration note at top of file. Empty object preserves the shape
|
|
472
|
+
// for React clients that read the field defensively.
|
|
473
|
+
return jsonResponse(200, sessionView(row, { capabilities: {} }));
|
|
474
|
+
},
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
478
|
+
/* PATCH /sessions/:id */
|
|
479
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
480
|
+
|
|
481
|
+
/** Body for `PATCH /sessions/:id`. At least one field must be present; an empty patch is also fine (no-op). */
|
|
482
|
+
const patchSessionSchema = z
|
|
483
|
+
.object({
|
|
484
|
+
title: z.string().min(1).max(80).optional(),
|
|
485
|
+
is_archived: z.boolean().optional(),
|
|
486
|
+
})
|
|
487
|
+
.strict();
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* `PATCH /sessions/:id` — rename and / or archive / unarchive, owner-scoped.
|
|
491
|
+
*
|
|
492
|
+
* Accepts either field independently — the React UI sends just `title` for
|
|
493
|
+
* rename and just `is_archived` for archive/unarchive. An empty body
|
|
494
|
+
* (`{}`) is a 200 no-op that echoes the row unchanged.
|
|
495
|
+
*
|
|
496
|
+
* @see SPECIFICATIONS.md §6.3.1
|
|
497
|
+
*/
|
|
498
|
+
export const handlePatchSession: RouteHandler = protectedRoute(
|
|
499
|
+
{},
|
|
500
|
+
async (req, ctx, auth, params) => {
|
|
501
|
+
const id = params.id;
|
|
502
|
+
if (!id) return notFound();
|
|
503
|
+
|
|
504
|
+
let raw: unknown;
|
|
505
|
+
try {
|
|
506
|
+
raw = await req.json();
|
|
507
|
+
} catch {
|
|
508
|
+
return validationError("Request body must be valid JSON.");
|
|
509
|
+
}
|
|
510
|
+
const parsed = patchSessionSchema.safeParse(raw);
|
|
511
|
+
if (!parsed.success) {
|
|
512
|
+
const issue = parsed.error.issues[0];
|
|
513
|
+
let code: string = "validation";
|
|
514
|
+
if (issue?.path[0] === "title") {
|
|
515
|
+
if (issue.code === "too_small") code = "title_required";
|
|
516
|
+
else if (issue.code === "too_big") code = "title_too_long";
|
|
517
|
+
}
|
|
518
|
+
return validationError(issue?.message ?? "Invalid request body.", code);
|
|
519
|
+
}
|
|
520
|
+
const body = parsed.data;
|
|
521
|
+
|
|
522
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
523
|
+
if (!row || row.userId !== auth.user.id) return notFound();
|
|
524
|
+
|
|
525
|
+
if (body.title !== undefined || body.is_archived !== undefined) {
|
|
526
|
+
const update: Partial<typeof session.$inferInsert> = {
|
|
527
|
+
updatedAt: nowIso(),
|
|
528
|
+
};
|
|
529
|
+
if (body.title !== undefined) update.title = body.title;
|
|
530
|
+
if (body.is_archived !== undefined) update.isArchived = body.is_archived;
|
|
531
|
+
ctx.db.update(session).set(update).where(eq(session.id, id)).run();
|
|
532
|
+
ctx.logger.info(
|
|
533
|
+
{
|
|
534
|
+
user_id: auth.user.id,
|
|
535
|
+
session_id: id,
|
|
536
|
+
patch: {
|
|
537
|
+
title: body.title !== undefined,
|
|
538
|
+
is_archived: body.is_archived,
|
|
539
|
+
},
|
|
540
|
+
event: "session_patched",
|
|
541
|
+
},
|
|
542
|
+
"Session updated.",
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const fresh = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
547
|
+
if (!fresh) return notFound();
|
|
548
|
+
return jsonResponse(200, sessionView(fresh));
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
553
|
+
/* PATCH /sessions/:id/reasoning */
|
|
554
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Body for `PATCH /sessions/:id/reasoning`. Fields are independent — the
|
|
558
|
+
* React UI may send just `effort`, just `verbosity`, or both depending on
|
|
559
|
+
* what the agent's model supports.
|
|
560
|
+
*
|
|
561
|
+
* Values are stored as-typed: server-side validation against the per-model
|
|
562
|
+
* capability enum is intentionally LOOSE so this layer stays schema-driven.
|
|
563
|
+
* The actual rejection (if any) happens when Letta receives the patched
|
|
564
|
+
* `model_settings` before the turn — matching Letta Code's behavior.
|
|
565
|
+
*
|
|
566
|
+
* `null` explicitly clears a field; omitting it keeps the existing value.
|
|
567
|
+
*/
|
|
568
|
+
const patchSessionReasoningSchema = z
|
|
569
|
+
.object({
|
|
570
|
+
effort: z.string().nullable().optional(),
|
|
571
|
+
verbosity: z.string().nullable().optional(),
|
|
572
|
+
thinking_budget: z.number().int().positive().nullable().optional(),
|
|
573
|
+
})
|
|
574
|
+
.strict();
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* `PATCH /sessions/:id/reasoning` — update the user's per-session reasoning
|
|
578
|
+
* settings. Applied to the agent's `model_settings` automatically before
|
|
579
|
+
* the next chat turn.
|
|
580
|
+
*
|
|
581
|
+
* Owner-scoped: returns 404 for sessions the caller doesn't own.
|
|
582
|
+
*
|
|
583
|
+
* @see SPECIFICATIONS.md §6.3.1
|
|
584
|
+
*/
|
|
585
|
+
export const handlePatchSessionReasoning: RouteHandler = protectedRoute(
|
|
586
|
+
{},
|
|
587
|
+
async (req, ctx, auth, params) => {
|
|
588
|
+
const id = params.id;
|
|
589
|
+
if (!id) return notFound();
|
|
590
|
+
|
|
591
|
+
let raw: unknown;
|
|
592
|
+
try {
|
|
593
|
+
raw = await req.json();
|
|
594
|
+
} catch {
|
|
595
|
+
return validationError("Request body must be valid JSON.");
|
|
596
|
+
}
|
|
597
|
+
const parsed = patchSessionReasoningSchema.safeParse(raw);
|
|
598
|
+
if (!parsed.success) {
|
|
599
|
+
const issue = parsed.error.issues[0];
|
|
600
|
+
return validationError(issue?.message ?? "Invalid request body.");
|
|
601
|
+
}
|
|
602
|
+
const body = parsed.data;
|
|
603
|
+
|
|
604
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
605
|
+
if (!row || row.userId !== auth.user.id) return notFound();
|
|
606
|
+
|
|
607
|
+
const update: Partial<typeof session.$inferInsert> = { updatedAt: nowIso() };
|
|
608
|
+
if (body.effort !== undefined) update.reasoningEffort = body.effort;
|
|
609
|
+
if (body.verbosity !== undefined) update.verbosity = body.verbosity;
|
|
610
|
+
if (body.thinking_budget !== undefined) update.thinkingBudget = body.thinking_budget;
|
|
611
|
+
ctx.db.update(session).set(update).where(eq(session.id, id)).run();
|
|
612
|
+
|
|
613
|
+
ctx.logger.info(
|
|
614
|
+
{
|
|
615
|
+
user_id: auth.user.id,
|
|
616
|
+
session_id: id,
|
|
617
|
+
reasoning: {
|
|
618
|
+
effort: body.effort,
|
|
619
|
+
verbosity: body.verbosity,
|
|
620
|
+
thinking_budget: body.thinking_budget,
|
|
621
|
+
},
|
|
622
|
+
event: "session_reasoning_patched",
|
|
623
|
+
},
|
|
624
|
+
"Session reasoning updated.",
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const fresh = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
628
|
+
if (!fresh) return notFound();
|
|
629
|
+
return jsonResponse(200, sessionView(fresh, { capabilities: {} }));
|
|
630
|
+
},
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
634
|
+
/* GET /sessions/:id/messages — Step 5 placeholder */
|
|
635
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* `GET /sessions/:id/messages` — paginated history pulled from Letta.
|
|
639
|
+
*
|
|
640
|
+
* Calls `client.conversations.messages.list(conversation_id)` per §6.4 and
|
|
641
|
+
* projects the result into the wire-shaped `AIHubMessage` array the
|
|
642
|
+
* React client consumes. Letta is the source of truth for message
|
|
643
|
+
* history (§6.8); ai-hub does not duplicate it in its SQLite.
|
|
644
|
+
*
|
|
645
|
+
* Owner-scoped: returns 404 for sessions not owned by the caller. The
|
|
646
|
+
* `power_user`+ "all-sessions overview" lives at `/admin/sessions`, not
|
|
647
|
+
* here.
|
|
648
|
+
*
|
|
649
|
+
* @see SPECIFICATIONS.md §6.3.1 — sessions
|
|
650
|
+
* @see SPECIFICATIONS.md §6.4 — SDK-only rule
|
|
651
|
+
* @see SPECIFICATIONS.md §6.8 — Letta owns message history
|
|
652
|
+
*/
|
|
653
|
+
export const handleGetSessionMessages: RouteHandler = protectedRoute(
|
|
654
|
+
{},
|
|
655
|
+
async (_req, ctx, auth, params) => {
|
|
656
|
+
const id = params.id;
|
|
657
|
+
if (!id) return notFound();
|
|
658
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
659
|
+
if (!row || row.userId !== auth.user.id) return notFound();
|
|
660
|
+
return buildSessionMessagesResponse(ctx, row);
|
|
661
|
+
},
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* `GET /admin/sessions/:id/messages` — read-only session detail for the
|
|
666
|
+
* admin sessions overview (§8.2, [07-admin-sessions-overview.md]).
|
|
667
|
+
*
|
|
668
|
+
* Mirrors `handleGetSessionMessages` but skips the owner check — power_user+
|
|
669
|
+
* can view any session in the deployment (no write surface; the React side
|
|
670
|
+
* mounts the same `<MessageBubble />`s without input controls).
|
|
671
|
+
*
|
|
672
|
+
* @see SPECIFICATIONS.md §6.3.4 — admin sessions overview
|
|
673
|
+
* @see ui-sketches/07-admin-sessions-overview.md
|
|
674
|
+
*/
|
|
675
|
+
export const handleAdminGetSessionMessages: RouteHandler = protectedRoute(
|
|
676
|
+
{ requiredRole: "power_user" },
|
|
677
|
+
async (_req, ctx, _auth, params) => {
|
|
678
|
+
const id = params.id;
|
|
679
|
+
if (!id) return notFound();
|
|
680
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
681
|
+
if (!row) return notFound();
|
|
682
|
+
return buildSessionMessagesResponse(ctx, row);
|
|
683
|
+
},
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Fetches Letta history for the given session, projects it into the
|
|
688
|
+
* wire-shaped `AIHubMessage[]`, applies the message-override layer +
|
|
689
|
+
* `<memory_metadata>` stripping + per-assistant usage attachment, and
|
|
690
|
+
* returns the final JSON response.
|
|
691
|
+
*
|
|
692
|
+
* Shared between the owner-scoped `/sessions/:id/messages` and the
|
|
693
|
+
* power_user+ `/admin/sessions/:id/messages` — the only difference is the
|
|
694
|
+
* access check the caller performs **before** invoking this helper.
|
|
695
|
+
*
|
|
696
|
+
* @param ctx - server context (carries db + lettaClient + logger).
|
|
697
|
+
* @param row - the resolved session row (the caller's own row for the
|
|
698
|
+
* user-facing route; any session for the admin route).
|
|
699
|
+
* @returns the projected response, or an `internalError` Response on Letta
|
|
700
|
+
* list failure.
|
|
701
|
+
*/
|
|
702
|
+
async function buildSessionMessagesResponse(
|
|
703
|
+
ctx: import("../handlers/types.js").AIHubServerContext,
|
|
704
|
+
row: SessionRow,
|
|
705
|
+
): Promise<Response> {
|
|
706
|
+
const id = row.id;
|
|
707
|
+
let messages: ReadonlyArray<LettaMessage>;
|
|
708
|
+
try {
|
|
709
|
+
messages = await ctx.lettaClient.listMessages(row.lettaConversationId);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
ctx.logger.error(
|
|
712
|
+
{
|
|
713
|
+
event: "letta_list_messages_failed",
|
|
714
|
+
session_id: id,
|
|
715
|
+
letta_conversation_id: row.lettaConversationId,
|
|
716
|
+
err: err instanceof Error ? err.message : String(err),
|
|
717
|
+
},
|
|
718
|
+
"Letta message listing failed.",
|
|
719
|
+
);
|
|
720
|
+
return internalError("Failed to load message history.");
|
|
721
|
+
}
|
|
722
|
+
return projectAndRespond(ctx, row, messages);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Pure-ish projector — given the Letta listMessages result for a session,
|
|
727
|
+
* builds the final `{ messages }` JSON response.
|
|
728
|
+
*
|
|
729
|
+
* Split out from {@link buildSessionMessagesResponse} so the Letta-side
|
|
730
|
+
* fetch can be mocked / faked at the boundary in tests without re-implementing
|
|
731
|
+
* the projection. (DB reads for the overrides / usage tables stay inline —
|
|
732
|
+
* they're synchronous Drizzle calls keyed by ids already in scope.)
|
|
733
|
+
*
|
|
734
|
+
* @param ctx - server context.
|
|
735
|
+
* @param row - the session row.
|
|
736
|
+
* @param messages - raw Letta message list.
|
|
737
|
+
* @returns the projected response.
|
|
738
|
+
*/
|
|
739
|
+
function projectAndRespond(
|
|
740
|
+
ctx: import("../handlers/types.js").AIHubServerContext,
|
|
741
|
+
row: SessionRow,
|
|
742
|
+
messages: ReadonlyArray<LettaMessage>,
|
|
743
|
+
): Response {
|
|
744
|
+
|
|
745
|
+
// Letta groups one logical assistant turn into multiple message-type
|
|
746
|
+
// rows. In single-step runs they share the same `id`; in
|
|
747
|
+
// **multi-step** runs (reasoning → tool call → tool return → final
|
|
748
|
+
// assistant text) each step gets its own `id`, so grouping by id
|
|
749
|
+
// alone misses the cross-step affordances.
|
|
750
|
+
//
|
|
751
|
+
// We aggregate by **turn** instead: walk the rows in chronological
|
|
752
|
+
// order, accumulating reasoning text + tool-call records into a
|
|
753
|
+
// current-turn buffer; when an `assistant_message` row arrives we
|
|
754
|
+
// attach the buffer to that row's id and reset. A `user_message`
|
|
755
|
+
// row also resets the buffer (defensive — guards against runs
|
|
756
|
+
// where the agent emits intermediate "thinking" output without a
|
|
757
|
+
// terminating assistant_message).
|
|
758
|
+
//
|
|
759
|
+
// Step-10 surfaces reasoning + tool-call data inside the assistant
|
|
760
|
+
// bubble (see SPECIFICATIONS.md §7.3, §10.4), so this aggregation
|
|
761
|
+
// is what makes the historical view match the live-streaming
|
|
762
|
+
// bubble.
|
|
763
|
+
const sortedRaw = [...messages].sort((a, b) => {
|
|
764
|
+
const aT = Date.parse((a as { date?: string }).date ?? "");
|
|
765
|
+
const bT = Date.parse((b as { date?: string }).date ?? "");
|
|
766
|
+
if (Number.isNaN(aT) && Number.isNaN(bT)) return 0;
|
|
767
|
+
if (Number.isNaN(aT)) return 1;
|
|
768
|
+
if (Number.isNaN(bT)) return -1;
|
|
769
|
+
return aT - bT;
|
|
770
|
+
});
|
|
771
|
+
const { byAssistantId: aggregates, orphanRuns } = collectAggregates(
|
|
772
|
+
sortedRaw as LettaMessage[],
|
|
773
|
+
);
|
|
774
|
+
const priority: Record<AIHubMessage["role"], number> = {
|
|
775
|
+
assistant: 3,
|
|
776
|
+
user: 2,
|
|
777
|
+
system: 1,
|
|
778
|
+
tool: 0,
|
|
779
|
+
};
|
|
780
|
+
const dedupedById = new Map<string, AIHubMessage>();
|
|
781
|
+
for (const raw of sortedRaw) {
|
|
782
|
+
const projected = toWireMessage(raw);
|
|
783
|
+
if (!projected) continue;
|
|
784
|
+
// Attach aggregates to assistant_messages; user/system are left
|
|
785
|
+
// untouched (they don't carry reasoning or tool calls).
|
|
786
|
+
let withAggregates: AIHubMessage = projected;
|
|
787
|
+
if (projected.role === "assistant") {
|
|
788
|
+
const agg = aggregates.get(projected.id);
|
|
789
|
+
if (agg) {
|
|
790
|
+
const next: AIHubMessage = { ...projected };
|
|
791
|
+
if (agg.reasoning.length > 0) {
|
|
792
|
+
next.reasoning = agg.reasoning;
|
|
793
|
+
}
|
|
794
|
+
if (agg.toolCalls.length > 0) {
|
|
795
|
+
next.tool_calls = agg.toolCalls;
|
|
796
|
+
}
|
|
797
|
+
withAggregates = next;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const incumbent = dedupedById.get(projected.id);
|
|
801
|
+
if (
|
|
802
|
+
!incumbent ||
|
|
803
|
+
priority[withAggregates.role] > priority[incumbent.role]
|
|
804
|
+
) {
|
|
805
|
+
dedupedById.set(projected.id, withAggregates);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Synthesize virtual assistant bubbles for orphan runs (runs that
|
|
810
|
+
// produced reasoning / tool calls but never emitted an
|
|
811
|
+
// assistant_message). Each gets a synthetic id derived from the
|
|
812
|
+
// run_id; created_at is the latest date observed in the run, which
|
|
813
|
+
// places the bubble correctly in the chronological sort below.
|
|
814
|
+
for (const orphan of orphanRuns) {
|
|
815
|
+
const synthId = `synth-run-${orphan.runId}`;
|
|
816
|
+
// Don't clobber a real message that happened to use the same id
|
|
817
|
+
// (impossible in practice — Letta doesn't issue ids in this
|
|
818
|
+
// shape — but cheap to guard).
|
|
819
|
+
if (dedupedById.has(synthId)) continue;
|
|
820
|
+
const virtualBubble: AIHubMessage = {
|
|
821
|
+
id: synthId,
|
|
822
|
+
role: "assistant",
|
|
823
|
+
content: "",
|
|
824
|
+
created_at: orphan.latestDate,
|
|
825
|
+
...(orphan.aggregate.reasoning.length > 0
|
|
826
|
+
? { reasoning: orphan.aggregate.reasoning }
|
|
827
|
+
: {}),
|
|
828
|
+
...(orphan.aggregate.toolCalls.length > 0
|
|
829
|
+
? { tool_calls: orphan.aggregate.toolCalls }
|
|
830
|
+
: {}),
|
|
831
|
+
};
|
|
832
|
+
dedupedById.set(synthId, virtualBubble);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Letta's `messages.list` returns rows in the SDK's iteration order,
|
|
836
|
+
// which is not guaranteed to be chronological (in practice often
|
|
837
|
+
// reverse-chronological). The React chat renders bubbles top-to-bottom
|
|
838
|
+
// in the order received, so sort ascending by `date` here — the
|
|
839
|
+
// ordering is part of the §6.3.1 contract, not a UI concern. Stable
|
|
840
|
+
// sort: messages with identical timestamps keep Letta's relative order.
|
|
841
|
+
const sorted = [...dedupedById.values()].sort((a, b) => {
|
|
842
|
+
const aT = Date.parse(a.created_at);
|
|
843
|
+
const bT = Date.parse(b.created_at);
|
|
844
|
+
if (Number.isNaN(aT) || Number.isNaN(bT)) return 0;
|
|
845
|
+
return aT - bT;
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Apply content overrides.
|
|
849
|
+
//
|
|
850
|
+
// **DB-backed overrides** (`message_override` table) win over the raw
|
|
851
|
+
// wire content. Keyed by `(conversation_id, ordinal_position)` where
|
|
852
|
+
// position is the 0-based index into Letta's chronologically-sorted
|
|
853
|
+
// raw message list (`sortedRaw`). We compute that position for each
|
|
854
|
+
// Letta-issued id once and look it up per surviving wire message.
|
|
855
|
+
// Synthetic `synth-run-*` bubbles have no raw counterpart and are
|
|
856
|
+
// skipped. If no override applies, the wire message passes through
|
|
857
|
+
// untouched apart from `<memory_metadata>` stripping on system rows.
|
|
858
|
+
const positionByLettaId = new Map<string, number>();
|
|
859
|
+
sortedRaw.forEach((raw, idx) => {
|
|
860
|
+
const id = (raw as { id?: string }).id;
|
|
861
|
+
if (typeof id === "string") positionByLettaId.set(id, idx);
|
|
862
|
+
});
|
|
863
|
+
const overrideRows = ctx.db
|
|
864
|
+
.select()
|
|
865
|
+
.from(messageOverride)
|
|
866
|
+
.where(eq(messageOverride.conversationId, row.lettaConversationId))
|
|
867
|
+
.all();
|
|
868
|
+
// Only the `user_override` layer is consulted at display time.
|
|
869
|
+
// `system_override` is stored but inert (see `db/schema.ts` for
|
|
870
|
+
// rationale). Rows where `user_override` is null contribute nothing
|
|
871
|
+
// to display and are skipped.
|
|
872
|
+
const overrideByPosition = new Map<number, string>();
|
|
873
|
+
for (const o of overrideRows) {
|
|
874
|
+
if (o.userOverride !== null) {
|
|
875
|
+
overrideByPosition.set(o.ordinalPosition, o.userOverride);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Per-assistant-message usage (cache hits/writes + token counts).
|
|
880
|
+
// Stamped by the runner on the last assistant message of each
|
|
881
|
+
// turn (see turnRunner.ts step 5.5). Renders as the cache badge
|
|
882
|
+
// on the chat UI's assistant bubbles.
|
|
883
|
+
const usageRows = ctx.db
|
|
884
|
+
.select()
|
|
885
|
+
.from(messageUsage)
|
|
886
|
+
.where(eq(messageUsage.sessionId, row.id))
|
|
887
|
+
.all();
|
|
888
|
+
const usageByMessageId = new Map<string, AIHubMessage["usage"]>();
|
|
889
|
+
for (const u of usageRows) {
|
|
890
|
+
usageByMessageId.set(u.lettaMessageId, {
|
|
891
|
+
input: u.promptTokens,
|
|
892
|
+
output: u.completionTokens,
|
|
893
|
+
cache_read: u.cachedInputTokens,
|
|
894
|
+
cache_write: u.cacheWriteTokens,
|
|
895
|
+
reasoning: u.reasoningTokens,
|
|
896
|
+
context: u.contextTokens,
|
|
897
|
+
total: u.totalTokens,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const projected = sorted.map((msg): AIHubMessage => {
|
|
902
|
+
const pos = positionByLettaId.get(msg.id);
|
|
903
|
+
const usage = usageByMessageId.get(msg.id);
|
|
904
|
+
const withUsage = usage !== undefined ? { ...msg, usage } : msg;
|
|
905
|
+
if (pos !== undefined) {
|
|
906
|
+
const explicit = overrideByPosition.get(pos);
|
|
907
|
+
if (explicit !== undefined) {
|
|
908
|
+
return { ...withUsage, content: explicit };
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (msg.role === "system") {
|
|
912
|
+
const stripped = stripMemoryMetadata(msg.content);
|
|
913
|
+
if (stripped !== msg.content) {
|
|
914
|
+
return { ...withUsage, content: stripped };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return withUsage;
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
return jsonResponse(200, { messages: projected });
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
924
|
+
/* GET /sessions/:id/message-overrides */
|
|
925
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
926
|
+
|
|
927
|
+
/** Wire shape for one row in the message-override list. */
|
|
928
|
+
interface MessageOverrideView {
|
|
929
|
+
/** 0-based index into Letta's chronologically-sorted message list. */
|
|
930
|
+
ordinal_position: number;
|
|
931
|
+
/**
|
|
932
|
+
* What the LLM saw / should see at this position. `null` when no
|
|
933
|
+
* system-layer override was set (only user-layer). Stored but inert
|
|
934
|
+
* at display time today.
|
|
935
|
+
*/
|
|
936
|
+
system_override: string | null;
|
|
937
|
+
/**
|
|
938
|
+
* What the human sees in the chat transcript at this position.
|
|
939
|
+
* `null` when no user-layer override was set (only system-layer).
|
|
940
|
+
*/
|
|
941
|
+
user_override: string | null;
|
|
942
|
+
/** ISO-8601 timestamp the override was first written. */
|
|
943
|
+
created_at: string;
|
|
944
|
+
/** ISO-8601 timestamp of the last update. */
|
|
945
|
+
updated_at: string;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Projects a `MessageOverrideRow` to the snake_case wire shape. Kept here
|
|
950
|
+
* (not in `db/schema.ts`) so the route file owns its own wire contract.
|
|
951
|
+
*/
|
|
952
|
+
function overrideRowToView(row: typeof messageOverride.$inferSelect): MessageOverrideView {
|
|
953
|
+
return {
|
|
954
|
+
ordinal_position: row.ordinalPosition,
|
|
955
|
+
system_override: row.systemOverride,
|
|
956
|
+
user_override: row.userOverride,
|
|
957
|
+
created_at: row.createdAt,
|
|
958
|
+
updated_at: row.updatedAt,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* `GET /sessions/:id/message-overrides` — list the caller's overrides for
|
|
964
|
+
* the session's underlying Letta conversation.
|
|
965
|
+
*
|
|
966
|
+
* Returns the rows sorted by `ordinal_position` ascending so consumers
|
|
967
|
+
* can render them alongside the message list at the matching index. An
|
|
968
|
+
* empty list (no overrides set) returns `{ overrides: [] }` with 200.
|
|
969
|
+
*
|
|
970
|
+
* Owner-scoped: 404 for sessions the caller doesn't own.
|
|
971
|
+
*/
|
|
972
|
+
export const handleListMessageOverrides: RouteHandler = protectedRoute(
|
|
973
|
+
{},
|
|
974
|
+
async (_req, ctx, auth, params) => {
|
|
975
|
+
const id = params.id;
|
|
976
|
+
if (!id) return notFound();
|
|
977
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
978
|
+
if (!row || row.userId !== auth.user.id) return notFound();
|
|
979
|
+
|
|
980
|
+
const rows = ctx.db
|
|
981
|
+
.select()
|
|
982
|
+
.from(messageOverride)
|
|
983
|
+
.where(eq(messageOverride.conversationId, row.lettaConversationId))
|
|
984
|
+
.orderBy(messageOverride.ordinalPosition)
|
|
985
|
+
.all();
|
|
986
|
+
return jsonResponse(200, { overrides: rows.map(overrideRowToView) });
|
|
987
|
+
},
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
991
|
+
/* PUT /sessions/:id/message-overrides/:position */
|
|
992
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
993
|
+
|
|
994
|
+
/** Body for `PUT /sessions/:id/message-overrides/:position`. */
|
|
995
|
+
const putMessageOverrideSchema = z
|
|
996
|
+
.object({
|
|
997
|
+
/**
|
|
998
|
+
* What the LLM saw / should see at this position. Stored but inert
|
|
999
|
+
* at display today. Empty string is valid.
|
|
1000
|
+
*/
|
|
1001
|
+
system_override: z.string().optional(),
|
|
1002
|
+
/**
|
|
1003
|
+
* What the human sees in the chat transcript at this position. Empty
|
|
1004
|
+
* string is valid — it hides the message visually but keeps the bubble
|
|
1005
|
+
* slot. Callers that want to remove the slot entirely use `DELETE`.
|
|
1006
|
+
*/
|
|
1007
|
+
user_override: z.string().optional(),
|
|
1008
|
+
})
|
|
1009
|
+
.strict()
|
|
1010
|
+
.refine(
|
|
1011
|
+
(b) => b.system_override !== undefined || b.user_override !== undefined,
|
|
1012
|
+
{
|
|
1013
|
+
message: "At least one of `system_override` or `user_override` must be set.",
|
|
1014
|
+
},
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* `PUT /sessions/:id/message-overrides/:position` — upsert one or both
|
|
1019
|
+
* override layers for the message at `position`.
|
|
1020
|
+
*
|
|
1021
|
+
* **Validation gates** (in order):
|
|
1022
|
+
* - `404` — session not owned by caller (or doesn't exist).
|
|
1023
|
+
* - `400 invalid_position` — path segment doesn't parse as a non-negative
|
|
1024
|
+
* integer.
|
|
1025
|
+
* - `400 validation` — body missing both fields.
|
|
1026
|
+
* - `409 turn_in_progress` — a `turn` row exists with `status = "running"`
|
|
1027
|
+
* for this session. Honoring writes while a turn is in flight would
|
|
1028
|
+
* race the post-turn writer, so we reject up front.
|
|
1029
|
+
* - `409 message_not_found` — `position` is out of range for the current
|
|
1030
|
+
* Letta message list. The override would point at a message that
|
|
1031
|
+
* doesn't exist; callers must wait for the message to land before
|
|
1032
|
+
* overriding it.
|
|
1033
|
+
*
|
|
1034
|
+
* **Merge semantics.** Only the layers the body provides are written; an
|
|
1035
|
+
* existing value on the other layer is preserved by reading the row,
|
|
1036
|
+
* computing the merged tuple, and upserting the result. This lets the
|
|
1037
|
+
* `/chat` post-turn writer set `user_override` (leaving `system_override`
|
|
1038
|
+
* null) and a later operator PUT set `system_override` without
|
|
1039
|
+
* clobbering the post-turn-written `user_override`.
|
|
1040
|
+
*
|
|
1041
|
+
* Idempotent: same body → same result. Returns 200 with the resulting view.
|
|
1042
|
+
*/
|
|
1043
|
+
export const handlePutMessageOverride: RouteHandler = protectedRoute(
|
|
1044
|
+
{},
|
|
1045
|
+
async (req, ctx, auth, params) => {
|
|
1046
|
+
const id = params.id;
|
|
1047
|
+
if (!id) return notFound();
|
|
1048
|
+
const positionStr = params.position;
|
|
1049
|
+
if (!positionStr) return notFound();
|
|
1050
|
+
const position = Number.parseInt(positionStr, 10);
|
|
1051
|
+
if (!Number.isInteger(position) || position < 0 || String(position) !== positionStr) {
|
|
1052
|
+
return validationError(
|
|
1053
|
+
"ordinal_position must be a non-negative integer.",
|
|
1054
|
+
"invalid_position",
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
let raw: unknown;
|
|
1059
|
+
try {
|
|
1060
|
+
raw = await req.json();
|
|
1061
|
+
} catch {
|
|
1062
|
+
return validationError("Request body must be valid JSON.");
|
|
1063
|
+
}
|
|
1064
|
+
const parsed = putMessageOverrideSchema.safeParse(raw);
|
|
1065
|
+
if (!parsed.success) {
|
|
1066
|
+
const issue = parsed.error.issues[0];
|
|
1067
|
+
return validationError(issue?.message ?? "Invalid request body.");
|
|
1068
|
+
}
|
|
1069
|
+
const { system_override: systemOverride, user_override: userOverride } = parsed.data;
|
|
1070
|
+
|
|
1071
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
1072
|
+
if (!row || row.userId !== auth.user.id) return notFound();
|
|
1073
|
+
|
|
1074
|
+
// Gate 1: no live turn for this session. Avoids racing the post-turn
|
|
1075
|
+
// writer (`/chat`) and keeps the invariant "messages we override
|
|
1076
|
+
// already exist in Letta and aren't moving."
|
|
1077
|
+
const liveTurn = ctx.db
|
|
1078
|
+
.select({ id: turn.id })
|
|
1079
|
+
.from(turn)
|
|
1080
|
+
.where(and(eq(turn.sessionId, id), eq(turn.status, "running")))
|
|
1081
|
+
.get();
|
|
1082
|
+
if (liveTurn) {
|
|
1083
|
+
return conflictError(
|
|
1084
|
+
"turn_in_progress",
|
|
1085
|
+
"Cannot modify message overrides while a turn is in progress.",
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Gate 2: target position must exist in Letta's message list. The
|
|
1090
|
+
// listMessages count is the position-space size; `position < count`
|
|
1091
|
+
// is the existence condition.
|
|
1092
|
+
let lettaMessages: ReadonlyArray<LettaMessage>;
|
|
1093
|
+
try {
|
|
1094
|
+
lettaMessages = await ctx.lettaClient.listMessages(row.lettaConversationId);
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
ctx.logger.error(
|
|
1097
|
+
{
|
|
1098
|
+
event: "message_override_letta_list_failed",
|
|
1099
|
+
session_id: id,
|
|
1100
|
+
letta_conversation_id: row.lettaConversationId,
|
|
1101
|
+
err: err instanceof Error ? err.message : String(err),
|
|
1102
|
+
},
|
|
1103
|
+
"Letta listMessages failed during override validation.",
|
|
1104
|
+
);
|
|
1105
|
+
return internalError("Failed to validate message position against Letta.");
|
|
1106
|
+
}
|
|
1107
|
+
if (position >= lettaMessages.length) {
|
|
1108
|
+
return conflictError(
|
|
1109
|
+
"message_not_found",
|
|
1110
|
+
`No Letta message exists at position ${position} (conversation has ${lettaMessages.length}).`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Merge: read existing row (if any), preserve untouched layer.
|
|
1115
|
+
const existing = ctx.db
|
|
1116
|
+
.select()
|
|
1117
|
+
.from(messageOverride)
|
|
1118
|
+
.where(
|
|
1119
|
+
and(
|
|
1120
|
+
eq(messageOverride.conversationId, row.lettaConversationId),
|
|
1121
|
+
eq(messageOverride.ordinalPosition, position),
|
|
1122
|
+
),
|
|
1123
|
+
)
|
|
1124
|
+
.get();
|
|
1125
|
+
const merged: { systemOverride: string | null; userOverride: string | null } = {
|
|
1126
|
+
systemOverride: systemOverride !== undefined ? systemOverride : existing?.systemOverride ?? null,
|
|
1127
|
+
userOverride: userOverride !== undefined ? userOverride : existing?.userOverride ?? null,
|
|
1128
|
+
};
|
|
1129
|
+
// CHECK constraint is enforced in SQL too, but explicit guard keeps
|
|
1130
|
+
// the error surface clean (caller saw a 400 already if both were
|
|
1131
|
+
// omitted on the wire; this catches the "you cleared both" edge).
|
|
1132
|
+
if (merged.systemOverride === null && merged.userOverride === null) {
|
|
1133
|
+
return validationError(
|
|
1134
|
+
"Resulting override would have both layers null. At least one must be non-null.",
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const now = nowIso();
|
|
1139
|
+
ctx.db
|
|
1140
|
+
.insert(messageOverride)
|
|
1141
|
+
.values({
|
|
1142
|
+
conversationId: row.lettaConversationId,
|
|
1143
|
+
ordinalPosition: position,
|
|
1144
|
+
systemOverride: merged.systemOverride,
|
|
1145
|
+
userOverride: merged.userOverride,
|
|
1146
|
+
createdAt: now,
|
|
1147
|
+
updatedAt: now,
|
|
1148
|
+
})
|
|
1149
|
+
.onConflictDoUpdate({
|
|
1150
|
+
target: [messageOverride.conversationId, messageOverride.ordinalPosition],
|
|
1151
|
+
set: {
|
|
1152
|
+
systemOverride: merged.systemOverride,
|
|
1153
|
+
userOverride: merged.userOverride,
|
|
1154
|
+
updatedAt: now,
|
|
1155
|
+
},
|
|
1156
|
+
})
|
|
1157
|
+
.run();
|
|
1158
|
+
const fresh = ctx.db
|
|
1159
|
+
.select()
|
|
1160
|
+
.from(messageOverride)
|
|
1161
|
+
.where(
|
|
1162
|
+
and(
|
|
1163
|
+
eq(messageOverride.conversationId, row.lettaConversationId),
|
|
1164
|
+
eq(messageOverride.ordinalPosition, position),
|
|
1165
|
+
),
|
|
1166
|
+
)
|
|
1167
|
+
.get();
|
|
1168
|
+
if (!fresh) {
|
|
1169
|
+
return internalError("Failed to persist message override.");
|
|
1170
|
+
}
|
|
1171
|
+
ctx.logger.info(
|
|
1172
|
+
{
|
|
1173
|
+
event: "message_override_upserted",
|
|
1174
|
+
user_id: auth.user.id,
|
|
1175
|
+
session_id: id,
|
|
1176
|
+
ordinal_position: position,
|
|
1177
|
+
layers_written: {
|
|
1178
|
+
system_override: systemOverride !== undefined,
|
|
1179
|
+
user_override: userOverride !== undefined,
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
"Message override upserted.",
|
|
1183
|
+
);
|
|
1184
|
+
return jsonResponse(200, overrideRowToView(fresh));
|
|
1185
|
+
},
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
1189
|
+
/* DELETE /sessions/:id/message-overrides/:position */
|
|
1190
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* `DELETE /sessions/:id/message-overrides/:position` — remove an override.
|
|
1194
|
+
*
|
|
1195
|
+
* Idempotent: returns 204 whether or not the row existed. Owner-scoped:
|
|
1196
|
+
* 404 for sessions the caller doesn't own.
|
|
1197
|
+
*/
|
|
1198
|
+
export const handleDeleteMessageOverride: RouteHandler = protectedRoute(
|
|
1199
|
+
{},
|
|
1200
|
+
async (_req, ctx, auth, params) => {
|
|
1201
|
+
const id = params.id;
|
|
1202
|
+
if (!id) return notFound();
|
|
1203
|
+
const positionStr = params.position;
|
|
1204
|
+
if (!positionStr) return notFound();
|
|
1205
|
+
const position = Number.parseInt(positionStr, 10);
|
|
1206
|
+
if (!Number.isInteger(position) || position < 0 || String(position) !== positionStr) {
|
|
1207
|
+
return validationError(
|
|
1208
|
+
"ordinal_position must be a non-negative integer.",
|
|
1209
|
+
"invalid_position",
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
1213
|
+
if (!row || row.userId !== auth.user.id) return notFound();
|
|
1214
|
+
|
|
1215
|
+
ctx.db
|
|
1216
|
+
.delete(messageOverride)
|
|
1217
|
+
.where(
|
|
1218
|
+
and(
|
|
1219
|
+
eq(messageOverride.conversationId, row.lettaConversationId),
|
|
1220
|
+
eq(messageOverride.ordinalPosition, position),
|
|
1221
|
+
),
|
|
1222
|
+
)
|
|
1223
|
+
.run();
|
|
1224
|
+
return new Response(null, { status: 204 });
|
|
1225
|
+
},
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Per-id aggregate built by `collectAggregates` from sibling Letta
|
|
1230
|
+
* message rows. Attached to the surviving assistant_message during the
|
|
1231
|
+
* projection pass.
|
|
1232
|
+
*/
|
|
1233
|
+
interface TurnAggregate {
|
|
1234
|
+
/** Concatenated reasoning text across every reasoning_message row. */
|
|
1235
|
+
reasoning: string;
|
|
1236
|
+
/**
|
|
1237
|
+
* Tool calls invoked during the turn — one entry per `tool_call_id`,
|
|
1238
|
+
* paired with its matching `tool_return_message` when present.
|
|
1239
|
+
*/
|
|
1240
|
+
toolCalls: AIHubToolCallRecord[];
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* One Letta agent run that produced reasoning / tool calls but never
|
|
1245
|
+
* emitted an `assistant_message`. Some agents fulfill the whole task
|
|
1246
|
+
* via tools and never send text back to the user (verified against
|
|
1247
|
+
* real Letta data — a 29-step agent doing shell + file work surfaces
|
|
1248
|
+
* 0 assistant_message rows). The projector synthesizes a virtual
|
|
1249
|
+
* assistant bubble per orphan run so ai-hub shows the same activity
|
|
1250
|
+
* Letta's ADE shows.
|
|
1251
|
+
*/
|
|
1252
|
+
interface OrphanRun {
|
|
1253
|
+
/** Letta run id — used as the synthetic message id seed. */
|
|
1254
|
+
runId: string;
|
|
1255
|
+
/** ISO date of the latest row in this run, for sort ordering. */
|
|
1256
|
+
latestDate: string;
|
|
1257
|
+
/** Reasoning + tool calls accumulated across the run's steps. */
|
|
1258
|
+
aggregate: TurnAggregate;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Combined return shape of `collectAggregates`. The caller uses
|
|
1263
|
+
* `byAssistantId` for the normal projector merge and `orphanRuns` for
|
|
1264
|
+
* the synthesize-a-bubble path.
|
|
1265
|
+
*/
|
|
1266
|
+
interface CollectedAggregates {
|
|
1267
|
+
/**
|
|
1268
|
+
* Map of `assistant_message.id` → aggregate. Used by the normal
|
|
1269
|
+
* projector pass to attach reasoning + tool calls to an existing
|
|
1270
|
+
* assistant bubble.
|
|
1271
|
+
*/
|
|
1272
|
+
byAssistantId: Map<string, TurnAggregate>;
|
|
1273
|
+
/**
|
|
1274
|
+
* Runs that produced affordances but no `assistant_message`. The
|
|
1275
|
+
* caller synthesizes one virtual assistant bubble per entry.
|
|
1276
|
+
*/
|
|
1277
|
+
orphanRuns: OrphanRun[];
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** A pending tool-call record with its sort order and optional result. */
|
|
1281
|
+
interface PendingToolCall {
|
|
1282
|
+
order: number;
|
|
1283
|
+
name: string;
|
|
1284
|
+
input: string;
|
|
1285
|
+
result?: {
|
|
1286
|
+
output?: string | null;
|
|
1287
|
+
error?: string | null;
|
|
1288
|
+
status: "success" | "error";
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Builds, per `assistant_message` id, the aggregate of reasoning +
|
|
1294
|
+
* tool-call records that the React client renders inside that bubble.
|
|
1295
|
+
*
|
|
1296
|
+
* **Strategy — group by Letta's `run_id`.** Every row Letta returns
|
|
1297
|
+
* from `messages.list` carries a `run_id` that identifies the agent
|
|
1298
|
+
* run (and a `step_id` for the specific step). All rows of one
|
|
1299
|
+
* logical turn share a `run_id` regardless of how Letta stamps the
|
|
1300
|
+
* outer `id`. We use that as the primary grouping key:
|
|
1301
|
+
*
|
|
1302
|
+
* 1. Scan every row, bucketing reasoning + tool-call contributions by
|
|
1303
|
+
* `run_id`.
|
|
1304
|
+
* 2. For each `assistant_message` row, look up its `run_id` and attach
|
|
1305
|
+
* that bucket's contents to the assistant's id.
|
|
1306
|
+
*
|
|
1307
|
+
* Fallback layers (kept for robustness against older Letta versions
|
|
1308
|
+
* that may omit `run_id`):
|
|
1309
|
+
*
|
|
1310
|
+
* - **Same outer-`id` grouping.** When rows share the assistant's
|
|
1311
|
+
* outer `id` (single-step run, no `run_id`), match by id.
|
|
1312
|
+
* - **Chronological turn-walk.** Walk in date order; reset at
|
|
1313
|
+
* `user_message` boundaries; on `assistant_message`, attach the
|
|
1314
|
+
* buffered contributions to that id.
|
|
1315
|
+
*
|
|
1316
|
+
* Per assistant id we union the contributions of all three layers,
|
|
1317
|
+
* deduping tool calls by inner `tool_call_id`.
|
|
1318
|
+
*
|
|
1319
|
+
* Callers MUST pass `messages` sorted ascending by `date`.
|
|
1320
|
+
*
|
|
1321
|
+
* @param messages - rows from `client.conversations.messages.list`,
|
|
1322
|
+
* sorted ascending by `date`.
|
|
1323
|
+
* @returns map of `assistant_message.id` → aggregate.
|
|
1324
|
+
*/
|
|
1325
|
+
function collectAggregates(
|
|
1326
|
+
messages: ReadonlyArray<LettaMessage>,
|
|
1327
|
+
): CollectedAggregates {
|
|
1328
|
+
// Layer 1 (primary): by Letta's run_id.
|
|
1329
|
+
const reasoningByRun = new Map<string, string>();
|
|
1330
|
+
const callsByRun = new Map<string, Map<string, PendingToolCall>>();
|
|
1331
|
+
const callOrderByRun = new Map<string, number>();
|
|
1332
|
+
// Layer 2 (fallback): by outer message id.
|
|
1333
|
+
const reasoningById = new Map<string, string>();
|
|
1334
|
+
const callsById = new Map<string, Map<string, PendingToolCall>>();
|
|
1335
|
+
const callOrderById = new Map<string, number>();
|
|
1336
|
+
// Layer 3 (fallback): chronological turn-walk.
|
|
1337
|
+
const turnReasoningByAssistant = new Map<string, string>();
|
|
1338
|
+
const turnCallsByAssistant = new Map<
|
|
1339
|
+
string,
|
|
1340
|
+
Map<string, PendingToolCall>
|
|
1341
|
+
>();
|
|
1342
|
+
|
|
1343
|
+
// run_id → set of assistant_message ids (typically size 1 for a
|
|
1344
|
+
// straightforward turn; size > 1 when an agent run produces multiple
|
|
1345
|
+
// distinct assistant rows — we attach the aggregate to ALL of them so
|
|
1346
|
+
// every visible bubble gets the affordance data).
|
|
1347
|
+
const assistantIdsByRun = new Map<string, Set<string>>();
|
|
1348
|
+
// run_id → latest ISO date observed for any row in that run. Used
|
|
1349
|
+
// for sort placement when we synthesize a virtual bubble for an
|
|
1350
|
+
// orphan run (no assistant_message ever emitted).
|
|
1351
|
+
const latestDateByRun = new Map<string, string>();
|
|
1352
|
+
function noteRunDate(runId: string, date: string | undefined): void {
|
|
1353
|
+
if (!date) return;
|
|
1354
|
+
const prev = latestDateByRun.get(runId);
|
|
1355
|
+
if (!prev || prev < date) {
|
|
1356
|
+
latestDateByRun.set(runId, date);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Chronological-walk buffer.
|
|
1361
|
+
let bufferReasoning = "";
|
|
1362
|
+
const bufferCalls = new Map<string, PendingToolCall>();
|
|
1363
|
+
let bufferOrder = 0;
|
|
1364
|
+
|
|
1365
|
+
function resetBuffer(): void {
|
|
1366
|
+
bufferReasoning = "";
|
|
1367
|
+
bufferCalls.clear();
|
|
1368
|
+
bufferOrder = 0;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function flushBufferToAssistant(id: string): void {
|
|
1372
|
+
if (bufferReasoning.length > 0) {
|
|
1373
|
+
turnReasoningByAssistant.set(id, bufferReasoning);
|
|
1374
|
+
}
|
|
1375
|
+
if (bufferCalls.size > 0) {
|
|
1376
|
+
const copy = new Map<string, PendingToolCall>();
|
|
1377
|
+
for (const [k, v] of bufferCalls.entries()) copy.set(k, { ...v });
|
|
1378
|
+
turnCallsByAssistant.set(id, copy);
|
|
1379
|
+
}
|
|
1380
|
+
resetBuffer();
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function bumpOrder(map: Map<string, number>, key: string): number {
|
|
1384
|
+
const next = (map.get(key) ?? 0) + 1;
|
|
1385
|
+
map.set(key, next);
|
|
1386
|
+
return next;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function ensureBucket<K>(
|
|
1390
|
+
map: Map<K, Map<string, PendingToolCall>>,
|
|
1391
|
+
key: K,
|
|
1392
|
+
): Map<string, PendingToolCall> {
|
|
1393
|
+
let b = map.get(key);
|
|
1394
|
+
if (!b) {
|
|
1395
|
+
b = new Map<string, PendingToolCall>();
|
|
1396
|
+
map.set(key, b);
|
|
1397
|
+
}
|
|
1398
|
+
return b;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function addInputToBucket(
|
|
1402
|
+
bucket: Map<string, PendingToolCall>,
|
|
1403
|
+
callId: string,
|
|
1404
|
+
call: {
|
|
1405
|
+
name?: string | null;
|
|
1406
|
+
arguments?: string | null;
|
|
1407
|
+
},
|
|
1408
|
+
nextOrder: () => number,
|
|
1409
|
+
): void {
|
|
1410
|
+
const prev = bucket.get(callId);
|
|
1411
|
+
if (prev) {
|
|
1412
|
+
// Letta persists multi-chunk tool_call_message rows where each
|
|
1413
|
+
// row carries the **accumulated** args (not a true delta — `row1
|
|
1414
|
+
// = "{"`; `row2 = "{"command":"`; `rowN = "{...}"`). Naive `+=`
|
|
1415
|
+
// concatenation duplicates the args N times, producing
|
|
1416
|
+
// `{...}{...}{...}` in the rendered tool card. The right
|
|
1417
|
+
// primitive is "keep the longest args we've seen": for a true
|
|
1418
|
+
// accumulation Letta produces, the last row is the longest; for
|
|
1419
|
+
// repeated identical rows the length comparison is a no-op.
|
|
1420
|
+
const incoming = call.arguments ?? "";
|
|
1421
|
+
if (incoming.length > prev.input.length) {
|
|
1422
|
+
prev.input = incoming;
|
|
1423
|
+
}
|
|
1424
|
+
if (call.name && !prev.name) prev.name = call.name;
|
|
1425
|
+
} else {
|
|
1426
|
+
bucket.set(callId, {
|
|
1427
|
+
order: nextOrder(),
|
|
1428
|
+
name: call.name ?? "",
|
|
1429
|
+
input: call.arguments ?? "",
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function addResultToBucket(
|
|
1435
|
+
bucket: Map<string, PendingToolCall>,
|
|
1436
|
+
callId: string,
|
|
1437
|
+
result: {
|
|
1438
|
+
output?: string | null;
|
|
1439
|
+
error?: string | null;
|
|
1440
|
+
status: "success" | "error";
|
|
1441
|
+
},
|
|
1442
|
+
nextOrder: () => number,
|
|
1443
|
+
): void {
|
|
1444
|
+
const prev = bucket.get(callId);
|
|
1445
|
+
if (prev) {
|
|
1446
|
+
prev.result = result;
|
|
1447
|
+
} else {
|
|
1448
|
+
bucket.set(callId, {
|
|
1449
|
+
order: nextOrder(),
|
|
1450
|
+
name: "",
|
|
1451
|
+
input: "",
|
|
1452
|
+
result,
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
for (const raw of messages) {
|
|
1458
|
+
const m = raw as {
|
|
1459
|
+
id?: string;
|
|
1460
|
+
run_id?: string | null;
|
|
1461
|
+
date?: string;
|
|
1462
|
+
message_type?: string;
|
|
1463
|
+
reasoning?: string;
|
|
1464
|
+
tool_call?: {
|
|
1465
|
+
tool_call_id?: string | null;
|
|
1466
|
+
name?: string | null;
|
|
1467
|
+
arguments?: string | null;
|
|
1468
|
+
} | null;
|
|
1469
|
+
tool_calls?:
|
|
1470
|
+
| ReadonlyArray<{
|
|
1471
|
+
tool_call_id?: string | null;
|
|
1472
|
+
name?: string | null;
|
|
1473
|
+
arguments?: string | null;
|
|
1474
|
+
}>
|
|
1475
|
+
| { tool_call_id?: string | null; name?: string | null; arguments?: string | null }
|
|
1476
|
+
| null;
|
|
1477
|
+
tool_call_id?: string;
|
|
1478
|
+
tool_return?: string | Array<unknown>;
|
|
1479
|
+
status?: "success" | "error";
|
|
1480
|
+
tool_returns?: Array<{
|
|
1481
|
+
tool_call_id?: string;
|
|
1482
|
+
status?: "success" | "error";
|
|
1483
|
+
tool_return?: string | Array<unknown>;
|
|
1484
|
+
}> | null;
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
const runId = m.run_id ?? null;
|
|
1488
|
+
if (runId) noteRunDate(runId, m.date);
|
|
1489
|
+
|
|
1490
|
+
if (m.message_type === "user_message") {
|
|
1491
|
+
// User boundary resets the chronological buffer (other layers are
|
|
1492
|
+
// run-id / outer-id-keyed, so they don't need a reset).
|
|
1493
|
+
resetBuffer();
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
if (m.message_type === "reasoning_message") {
|
|
1498
|
+
const text = typeof m.reasoning === "string" ? m.reasoning : "";
|
|
1499
|
+
if (text.length === 0) continue;
|
|
1500
|
+
bufferReasoning += text;
|
|
1501
|
+
if (m.id) {
|
|
1502
|
+
reasoningById.set(m.id, (reasoningById.get(m.id) ?? "") + text);
|
|
1503
|
+
}
|
|
1504
|
+
if (runId) {
|
|
1505
|
+
reasoningByRun.set(runId, (reasoningByRun.get(runId) ?? "") + text);
|
|
1506
|
+
}
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (m.message_type === "tool_call_message") {
|
|
1511
|
+
const list = Array.isArray(m.tool_calls)
|
|
1512
|
+
? m.tool_calls
|
|
1513
|
+
: m.tool_calls && typeof m.tool_calls === "object"
|
|
1514
|
+
? [m.tool_calls]
|
|
1515
|
+
: m.tool_call
|
|
1516
|
+
? [m.tool_call]
|
|
1517
|
+
: [];
|
|
1518
|
+
for (const call of list) {
|
|
1519
|
+
const callId = call.tool_call_id;
|
|
1520
|
+
if (!callId) continue;
|
|
1521
|
+
addInputToBucket(bufferCalls, callId, call, () => bufferOrder++);
|
|
1522
|
+
if (m.id) {
|
|
1523
|
+
addInputToBucket(
|
|
1524
|
+
ensureBucket(callsById, m.id),
|
|
1525
|
+
callId,
|
|
1526
|
+
call,
|
|
1527
|
+
() => bumpOrder(callOrderById, m.id ?? ""),
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
if (runId) {
|
|
1531
|
+
addInputToBucket(
|
|
1532
|
+
ensureBucket(callsByRun, runId),
|
|
1533
|
+
callId,
|
|
1534
|
+
call,
|
|
1535
|
+
() => bumpOrder(callOrderByRun, runId),
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
if (m.message_type === "tool_return_message") {
|
|
1543
|
+
const returns = Array.isArray(m.tool_returns)
|
|
1544
|
+
? m.tool_returns
|
|
1545
|
+
: m.tool_call_id
|
|
1546
|
+
? [
|
|
1547
|
+
{
|
|
1548
|
+
tool_call_id: m.tool_call_id,
|
|
1549
|
+
status: m.status,
|
|
1550
|
+
tool_return: m.tool_return,
|
|
1551
|
+
},
|
|
1552
|
+
]
|
|
1553
|
+
: [];
|
|
1554
|
+
for (const ret of returns) {
|
|
1555
|
+
const callId = ret.tool_call_id;
|
|
1556
|
+
if (!callId) continue;
|
|
1557
|
+
const status: "success" | "error" =
|
|
1558
|
+
ret.status === "error" ? "error" : "success";
|
|
1559
|
+
const text = stringifyReturn(ret.tool_return);
|
|
1560
|
+
const result =
|
|
1561
|
+
status === "error"
|
|
1562
|
+
? { error: text, output: null, status }
|
|
1563
|
+
: { output: text, error: null, status };
|
|
1564
|
+
addResultToBucket(bufferCalls, callId, result, () => bufferOrder++);
|
|
1565
|
+
if (m.id) {
|
|
1566
|
+
addResultToBucket(
|
|
1567
|
+
ensureBucket(callsById, m.id),
|
|
1568
|
+
callId,
|
|
1569
|
+
result,
|
|
1570
|
+
() => bumpOrder(callOrderById, m.id ?? ""),
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1573
|
+
if (runId) {
|
|
1574
|
+
addResultToBucket(
|
|
1575
|
+
ensureBucket(callsByRun, runId),
|
|
1576
|
+
callId,
|
|
1577
|
+
result,
|
|
1578
|
+
() => bumpOrder(callOrderByRun, runId),
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (m.message_type === "assistant_message") {
|
|
1586
|
+
const id = m.id;
|
|
1587
|
+
if (!id) {
|
|
1588
|
+
resetBuffer();
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
flushBufferToAssistant(id);
|
|
1592
|
+
if (runId) {
|
|
1593
|
+
let set = assistantIdsByRun.get(runId);
|
|
1594
|
+
if (!set) {
|
|
1595
|
+
set = new Set();
|
|
1596
|
+
assistantIdsByRun.set(runId, set);
|
|
1597
|
+
}
|
|
1598
|
+
set.add(id);
|
|
1599
|
+
}
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ── Union per assistant id: layer 1 (run_id) ∪ layer 2 (same id) ∪
|
|
1605
|
+
// layer 3 (chronological). Tool calls dedupe by tool_call_id.
|
|
1606
|
+
const out = new Map<string, TurnAggregate>();
|
|
1607
|
+
const allAssistantIds = new Set<string>([
|
|
1608
|
+
...turnReasoningByAssistant.keys(),
|
|
1609
|
+
...turnCallsByAssistant.keys(),
|
|
1610
|
+
...reasoningById.keys(),
|
|
1611
|
+
...callsById.keys(),
|
|
1612
|
+
]);
|
|
1613
|
+
// Also pull in assistant ids that only the run_id layer surfaces.
|
|
1614
|
+
for (const set of assistantIdsByRun.values()) {
|
|
1615
|
+
for (const id of set) allAssistantIds.add(id);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Inverse map for run_id lookup from assistant id.
|
|
1619
|
+
const runIdByAssistant = new Map<string, string>();
|
|
1620
|
+
for (const [run, set] of assistantIdsByRun.entries()) {
|
|
1621
|
+
for (const id of set) runIdByAssistant.set(id, run);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
for (const id of allAssistantIds) {
|
|
1625
|
+
const runId = runIdByAssistant.get(id);
|
|
1626
|
+
// **Reasoning attribution** (multi-step turns): the chronological
|
|
1627
|
+
// turn-walk is the *authoritative* source. It partitions reasoning
|
|
1628
|
+
// text per assistant_message via `flushBufferToAssistant`, so each
|
|
1629
|
+
// bubble carries only the reasoning that arrived between the
|
|
1630
|
+
// previous boundary and its own assistant_message. The `id`-keyed
|
|
1631
|
+
// and `run_id`-keyed layers are NOT siblings the way `??` would
|
|
1632
|
+
// imply — `reasoningByRun` holds the run's *concatenated*
|
|
1633
|
+
// reasoning across every step, so using it as a fallback for
|
|
1634
|
+
// bubbles that have no per-step reasoning (e.g. the agent only
|
|
1635
|
+
// reasoned in step 1 but produced 5 assistant messages) attaches
|
|
1636
|
+
// step 1's text to every bubble in the run. The id/run layers
|
|
1637
|
+
// remain as last-resort fallbacks for orphan-ish cases where the
|
|
1638
|
+
// chronological walk found nothing AND there's exactly one
|
|
1639
|
+
// assistant_message in the run (the single-step case where the
|
|
1640
|
+
// by-run aggregate is unambiguously this bubble's reasoning).
|
|
1641
|
+
const layer3Reasoning = turnReasoningByAssistant.get(id);
|
|
1642
|
+
let reasoning: string;
|
|
1643
|
+
if (layer3Reasoning !== undefined) {
|
|
1644
|
+
reasoning = layer3Reasoning;
|
|
1645
|
+
} else {
|
|
1646
|
+
const idReasoning = reasoningById.get(id);
|
|
1647
|
+
const runReasoning =
|
|
1648
|
+
runId !== undefined ? reasoningByRun.get(runId) : undefined;
|
|
1649
|
+
// Only trust the run-level reasoning when the run has exactly one
|
|
1650
|
+
// assistant_message — otherwise we'd attach concatenated
|
|
1651
|
+
// multi-step reasoning to a bubble that didn't produce any.
|
|
1652
|
+
const runAssistantCount =
|
|
1653
|
+
runId !== undefined
|
|
1654
|
+
? (assistantIdsByRun.get(runId)?.size ?? 0)
|
|
1655
|
+
: 0;
|
|
1656
|
+
reasoning =
|
|
1657
|
+
idReasoning ?? (runAssistantCount === 1 ? (runReasoning ?? "") : "");
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
const merged = new Map<string, PendingToolCall>();
|
|
1661
|
+
const mergeBucket = (
|
|
1662
|
+
bucket: Map<string, PendingToolCall> | undefined,
|
|
1663
|
+
): void => {
|
|
1664
|
+
if (!bucket) return;
|
|
1665
|
+
for (const [callId, info] of bucket.entries()) {
|
|
1666
|
+
const prev = merged.get(callId);
|
|
1667
|
+
if (!prev) {
|
|
1668
|
+
merged.set(callId, { ...info });
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
if (!prev.name && info.name) prev.name = info.name;
|
|
1672
|
+
if (!prev.input && info.input) prev.input = info.input;
|
|
1673
|
+
if (!prev.result && info.result) prev.result = info.result;
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
// **Tool-call attribution** (multi-step turns): same problem as
|
|
1677
|
+
// reasoning. The chronological turn-walk (`turnCallsByAssistant`)
|
|
1678
|
+
// partitions tool calls per assistant_message — that's
|
|
1679
|
+
// authoritative for the common multi-step shape Letta produces
|
|
1680
|
+
// (reasoning → tool → return → assistant text, repeat). The
|
|
1681
|
+
// `callsByRun` layer holds ALL tools in the run, so using it as a
|
|
1682
|
+
// sibling source attaches every step's tools to every bubble (the
|
|
1683
|
+
// bug). We use the chronological walk as the **exclusive** source
|
|
1684
|
+
// when it has any tools for this assistant id; the `id`-keyed and
|
|
1685
|
+
// `runId`-keyed layers are pure fallbacks for the orphan-ish cases
|
|
1686
|
+
// where the chronological walk found nothing (e.g. tools arriving
|
|
1687
|
+
// after their owning assistant_message in pathological orderings).
|
|
1688
|
+
const layer3 = turnCallsByAssistant.get(id);
|
|
1689
|
+
if (layer3 && layer3.size > 0) {
|
|
1690
|
+
mergeBucket(layer3);
|
|
1691
|
+
} else {
|
|
1692
|
+
mergeBucket(callsById.get(id));
|
|
1693
|
+
if (runId) mergeBucket(callsByRun.get(runId));
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const calls: AIHubToolCallRecord[] = [...merged.entries()]
|
|
1697
|
+
.sort(([, a], [, b]) => a.order - b.order)
|
|
1698
|
+
.map(([callId, info]): AIHubToolCallRecord => {
|
|
1699
|
+
const result = info.result;
|
|
1700
|
+
if (result) {
|
|
1701
|
+
return {
|
|
1702
|
+
tool_call_id: callId,
|
|
1703
|
+
tool_name: info.name,
|
|
1704
|
+
input: info.input,
|
|
1705
|
+
status: result.status,
|
|
1706
|
+
output: result.output ?? null,
|
|
1707
|
+
error: result.error ?? null,
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
return {
|
|
1711
|
+
tool_call_id: callId,
|
|
1712
|
+
tool_name: info.name,
|
|
1713
|
+
input: info.input,
|
|
1714
|
+
status: "error",
|
|
1715
|
+
output: null,
|
|
1716
|
+
error: "Tool call has no recorded return.",
|
|
1717
|
+
};
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
if (reasoning.length > 0 || calls.length > 0) {
|
|
1721
|
+
out.set(id, { reasoning, toolCalls: calls });
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// ── Orphan runs: agent runs that produced reasoning / tool calls
|
|
1726
|
+
// but never emitted an assistant_message. Build a separate entry
|
|
1727
|
+
// per run_id; the caller synthesizes a virtual assistant bubble
|
|
1728
|
+
// so the activity is visible in the chat (some agents fulfill the
|
|
1729
|
+
// whole task via tools — verified against real Letta data).
|
|
1730
|
+
const orphanRuns: OrphanRun[] = [];
|
|
1731
|
+
for (const [runId, latestDate] of latestDateByRun.entries()) {
|
|
1732
|
+
if (assistantIdsByRun.has(runId)) continue;
|
|
1733
|
+
const reasoning = reasoningByRun.get(runId) ?? "";
|
|
1734
|
+
const callsBucket = callsByRun.get(runId);
|
|
1735
|
+
if (reasoning.length === 0 && (!callsBucket || callsBucket.size === 0)) {
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
const calls: AIHubToolCallRecord[] = callsBucket
|
|
1739
|
+
? [...callsBucket.entries()]
|
|
1740
|
+
.sort(([, a], [, b]) => a.order - b.order)
|
|
1741
|
+
.map(([callId, info]): AIHubToolCallRecord => {
|
|
1742
|
+
const result = info.result;
|
|
1743
|
+
if (result) {
|
|
1744
|
+
return {
|
|
1745
|
+
tool_call_id: callId,
|
|
1746
|
+
tool_name: info.name,
|
|
1747
|
+
input: info.input,
|
|
1748
|
+
status: result.status,
|
|
1749
|
+
output: result.output ?? null,
|
|
1750
|
+
error: result.error ?? null,
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
return {
|
|
1754
|
+
tool_call_id: callId,
|
|
1755
|
+
tool_name: info.name,
|
|
1756
|
+
input: info.input,
|
|
1757
|
+
status: "error",
|
|
1758
|
+
output: null,
|
|
1759
|
+
error: "Tool call has no recorded return.",
|
|
1760
|
+
};
|
|
1761
|
+
})
|
|
1762
|
+
: [];
|
|
1763
|
+
orphanRuns.push({
|
|
1764
|
+
runId,
|
|
1765
|
+
latestDate,
|
|
1766
|
+
aggregate: { reasoning, toolCalls: calls },
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
// Sort orphan runs by latestDate ascending so they slot into the
|
|
1770
|
+
// wire list in the right chronological position.
|
|
1771
|
+
orphanRuns.sort((a, b) =>
|
|
1772
|
+
a.latestDate < b.latestDate ? -1 : a.latestDate > b.latestDate ? 1 : 0,
|
|
1773
|
+
);
|
|
1774
|
+
|
|
1775
|
+
return { byAssistantId: out, orphanRuns };
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* Pretty-prints a tool-return value into a string the UI can render in
|
|
1780
|
+
* a `<pre>`. Letta's `tool_return` can be a string OR an array of
|
|
1781
|
+
* content parts; arrays collapse into JSON for visibility.
|
|
1782
|
+
*/
|
|
1783
|
+
function stringifyReturn(value: string | Array<unknown> | undefined | null): string {
|
|
1784
|
+
if (typeof value === "string") return value;
|
|
1785
|
+
if (value == null) return "";
|
|
1786
|
+
try {
|
|
1787
|
+
return JSON.stringify(value, null, 2);
|
|
1788
|
+
} catch {
|
|
1789
|
+
return String(value);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Projects one Letta `Message` into ai-hub's wire-shaped `AIHubMessage`.
|
|
1795
|
+
*
|
|
1796
|
+
* Letta's history is a discriminated union over a dozen variants; ai-hub
|
|
1797
|
+
* surfaces the three the React client renders directly (user, assistant,
|
|
1798
|
+
* system). Tool-call / tool-return / reasoning / approval messages are
|
|
1799
|
+
* not rendered as standalone bubbles in v1 — the SSE stream weaves them
|
|
1800
|
+
* into the assistant bubble via `tool_call_*` and `reasoning_delta`
|
|
1801
|
+
* events at turn time, but the historical replay collapses them into the
|
|
1802
|
+
* surrounding assistant text. Returning `null` from this projector means
|
|
1803
|
+
* "skip this row" (filtered out by the caller).
|
|
1804
|
+
*
|
|
1805
|
+
* @param msg - one row from `client.conversations.messages.list`.
|
|
1806
|
+
* @returns the wire-shaped message, or `null` if not surfaced in v1.
|
|
1807
|
+
*/
|
|
1808
|
+
function toWireMessage(msg: LettaMessage): AIHubMessage | null {
|
|
1809
|
+
switch (msg.message_type) {
|
|
1810
|
+
case "user_message":
|
|
1811
|
+
return {
|
|
1812
|
+
id: msg.id,
|
|
1813
|
+
role: "user",
|
|
1814
|
+
content: extractTextContent(msg.content),
|
|
1815
|
+
created_at: msg.date,
|
|
1816
|
+
};
|
|
1817
|
+
case "assistant_message":
|
|
1818
|
+
return {
|
|
1819
|
+
id: msg.id,
|
|
1820
|
+
role: "assistant",
|
|
1821
|
+
content: extractTextContent(msg.content),
|
|
1822
|
+
created_at: msg.date,
|
|
1823
|
+
};
|
|
1824
|
+
case "system_message":
|
|
1825
|
+
return {
|
|
1826
|
+
id: msg.id,
|
|
1827
|
+
role: "system",
|
|
1828
|
+
content: extractTextContent(msg.content),
|
|
1829
|
+
created_at: msg.date,
|
|
1830
|
+
};
|
|
1831
|
+
default:
|
|
1832
|
+
// reasoning_message / hidden_reasoning_message / tool_call_message /
|
|
1833
|
+
// tool_return_message / approval_* / summary_message / event_message
|
|
1834
|
+
// are not v1 history-render targets — see file-level comment.
|
|
1835
|
+
return null;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Flattens a Letta message's content into a display string. Letta
|
|
1841
|
+
* persists messages as either a plain string or a multi-modal array
|
|
1842
|
+
* of `{type: "text", text}` parts; this normalizes both into a
|
|
1843
|
+
* single string suitable for the chat-UI bubble.
|
|
1844
|
+
*
|
|
1845
|
+
* Along the way it strips `<system-reminder>…</system-reminder>`
|
|
1846
|
+
* blocks that Letta Code injects at the head of every user turn
|
|
1847
|
+
* (agent metadata + permission-mode notice). Those are useful to the
|
|
1848
|
+
* LLM but pure noise in the chat transcript.
|
|
1849
|
+
*
|
|
1850
|
+
* Run on every role (user / assistant / system) so the strip is
|
|
1851
|
+
* consistent regardless of where reminders end up.
|
|
1852
|
+
*/
|
|
1853
|
+
function extractTextContent(
|
|
1854
|
+
content: string | Array<{ type?: string; text?: string }>,
|
|
1855
|
+
): string {
|
|
1856
|
+
if (typeof content === "string") return stripSystemReminders(content);
|
|
1857
|
+
if (!Array.isArray(content)) return "";
|
|
1858
|
+
let out = "";
|
|
1859
|
+
for (const part of content) {
|
|
1860
|
+
if (part && (part.type === undefined || part.type === "text") && typeof part.text === "string") {
|
|
1861
|
+
out += part.text;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
return stripSystemReminders(out);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* Strips `<system-reminder>…</system-reminder>` blocks (and the
|
|
1869
|
+
* blank lines that typically trail them) from a user-facing string.
|
|
1870
|
+
* Letta Code injects these blocks at the head of every user turn —
|
|
1871
|
+
* harmless to the LLM but visible noise in chat history.
|
|
1872
|
+
*/
|
|
1873
|
+
function stripSystemReminders(text: string): string {
|
|
1874
|
+
return text
|
|
1875
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, "")
|
|
1876
|
+
.replace(/^\s+/, "");
|
|
1877
|
+
}
|
|
1878
|
+
|