@schlessera/brain-ui-server 0.4.0
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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/agent/backend.d.ts +30 -0
- package/dist/agent/backend.d.ts.map +1 -0
- package/dist/agent/backend.js +226 -0
- package/dist/agent/backend.js.map +1 -0
- package/dist/app.d.ts +27 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +116 -0
- package/dist/app.js.map +1 -0
- package/dist/brain/client.d.ts +43 -0
- package/dist/brain/client.d.ts.map +1 -0
- package/dist/brain/client.js +154 -0
- package/dist/brain/client.js.map +1 -0
- package/dist/brain/types.d.ts +41 -0
- package/dist/brain/types.d.ts.map +1 -0
- package/dist/brain/types.js +2 -0
- package/dist/brain/types.js.map +1 -0
- package/dist/cron/scheduler.d.ts +9 -0
- package/dist/cron/scheduler.d.ts.map +1 -0
- package/dist/cron/scheduler.js +58 -0
- package/dist/cron/scheduler.js.map +1 -0
- package/dist/db/client.d.ts +10 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +73 -0
- package/dist/db/client.js.map +1 -0
- package/dist/files/walker.d.ts +63 -0
- package/dist/files/walker.d.ts.map +1 -0
- package/dist/files/walker.js +416 -0
- package/dist/files/walker.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +59 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +246 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/passkeys.d.ts +36 -0
- package/dist/middleware/passkeys.d.ts.map +1 -0
- package/dist/middleware/passkeys.js +427 -0
- package/dist/middleware/passkeys.js.map +1 -0
- package/dist/middleware/tailscale.d.ts +31 -0
- package/dist/middleware/tailscale.d.ts.map +1 -0
- package/dist/middleware/tailscale.js +64 -0
- package/dist/middleware/tailscale.js.map +1 -0
- package/dist/render/template.d.ts +7 -0
- package/dist/render/template.d.ts.map +1 -0
- package/dist/render/template.js +112 -0
- package/dist/render/template.js.map +1 -0
- package/dist/routes/brain.d.ts +155 -0
- package/dist/routes/brain.d.ts.map +1 -0
- package/dist/routes/brain.js +250 -0
- package/dist/routes/brain.js.map +1 -0
- package/dist/routes/files.d.ts +89 -0
- package/dist/routes/files.d.ts.map +1 -0
- package/dist/routes/files.js +91 -0
- package/dist/routes/files.js.map +1 -0
- package/dist/routes/health.d.ts +37 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +27 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/providers.d.ts +33 -0
- package/dist/routes/providers.d.ts.map +1 -0
- package/dist/routes/providers.js +9 -0
- package/dist/routes/providers.js.map +1 -0
- package/dist/routes/render.d.ts +25 -0
- package/dist/routes/render.d.ts.map +1 -0
- package/dist/routes/render.js +47 -0
- package/dist/routes/render.js.map +1 -0
- package/dist/routes/sessions.d.ts +79 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +37 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/voice.d.ts +92 -0
- package/dist/routes/voice.d.ts.map +1 -0
- package/dist/routes/voice.js +64 -0
- package/dist/routes/voice.js.map +1 -0
- package/dist/voice/deepgram-token.d.ts +5 -0
- package/dist/voice/deepgram-token.d.ts.map +1 -0
- package/dist/voice/deepgram-token.js +63 -0
- package/dist/voice/deepgram-token.js.map +1 -0
- package/dist/voice/keyterm-builder.d.ts +18 -0
- package/dist/voice/keyterm-builder.d.ts.map +1 -0
- package/dist/voice/keyterm-builder.js +391 -0
- package/dist/voice/keyterm-builder.js.map +1 -0
- package/dist/voice/speech-providers.d.ts +25 -0
- package/dist/voice/speech-providers.d.ts.map +1 -0
- package/dist/voice/speech-providers.js +104 -0
- package/dist/voice/speech-providers.js.map +1 -0
- package/dist/ws/attachments.d.ts +10 -0
- package/dist/ws/attachments.d.ts.map +1 -0
- package/dist/ws/attachments.js +63 -0
- package/dist/ws/attachments.js.map +1 -0
- package/dist/ws/bridge.d.ts +7 -0
- package/dist/ws/bridge.d.ts.map +1 -0
- package/dist/ws/bridge.js +86 -0
- package/dist/ws/bridge.js.map +1 -0
- package/dist/ws/clients.d.ts +20 -0
- package/dist/ws/clients.d.ts.map +1 -0
- package/dist/ws/clients.js +48 -0
- package/dist/ws/clients.js.map +1 -0
- package/dist/ws/connection.d.ts +8 -0
- package/dist/ws/connection.d.ts.map +1 -0
- package/dist/ws/connection.js +116 -0
- package/dist/ws/connection.js.map +1 -0
- package/dist/ws/dispatch.d.ts +5 -0
- package/dist/ws/dispatch.d.ts.map +1 -0
- package/dist/ws/dispatch.js +139 -0
- package/dist/ws/dispatch.js.map +1 -0
- package/dist/ws/frames.d.ts +17 -0
- package/dist/ws/frames.d.ts.map +1 -0
- package/dist/ws/frames.js +37 -0
- package/dist/ws/frames.js.map +1 -0
- package/dist/ws/handler.d.ts +34 -0
- package/dist/ws/handler.d.ts.map +1 -0
- package/dist/ws/handler.js +33 -0
- package/dist/ws/handler.js.map +1 -0
- package/dist/ws/history.d.ts +17 -0
- package/dist/ws/history.d.ts.map +1 -0
- package/dist/ws/history.js +45 -0
- package/dist/ws/history.js.map +1 -0
- package/dist/ws/host.d.ts +40 -0
- package/dist/ws/host.d.ts.map +1 -0
- package/dist/ws/host.js +49 -0
- package/dist/ws/host.js.map +1 -0
- package/dist/ws/routing.d.ts +12 -0
- package/dist/ws/routing.d.ts.map +1 -0
- package/dist/ws/routing.js +31 -0
- package/dist/ws/routing.js.map +1 -0
- package/dist/ws/run-session.d.ts +17 -0
- package/dist/ws/run-session.d.ts.map +1 -0
- package/dist/ws/run-session.js +160 -0
- package/dist/ws/run-session.js.map +1 -0
- package/dist/ws/session-catalog.d.ts +25 -0
- package/dist/ws/session-catalog.d.ts.map +1 -0
- package/dist/ws/session-catalog.js +45 -0
- package/dist/ws/session-catalog.js.map +1 -0
- package/dist/ws/shrink.d.ts +34 -0
- package/dist/ws/shrink.d.ts.map +1 -0
- package/dist/ws/shrink.js +91 -0
- package/dist/ws/shrink.js.map +1 -0
- package/dist/ws/turns.d.ts +77 -0
- package/dist/ws/turns.d.ts.map +1 -0
- package/dist/ws/turns.js +89 -0
- package/dist/ws/turns.js.map +1 -0
- package/migrations/001_initial.sql +29 -0
- package/migrations/002_session_provider.sql +1 -0
- package/migrations/003_session_backend.sql +5 -0
- package/migrations/004_drop_gemini_backend.sql +20 -0
- package/migrations/005_passkey_credentials.sql +18 -0
- package/package.json +72 -0
- package/src/agent/backend.ts +308 -0
- package/src/app.ts +159 -0
- package/src/brain/client.ts +186 -0
- package/src/brain/types.ts +44 -0
- package/src/cron/scheduler.ts +80 -0
- package/src/db/client.ts +85 -0
- package/src/files/walker.ts +418 -0
- package/src/index.ts +39 -0
- package/src/middleware/auth.ts +321 -0
- package/src/middleware/passkeys.ts +510 -0
- package/src/middleware/tailscale.ts +81 -0
- package/src/render/template.ts +124 -0
- package/src/routes/brain.ts +305 -0
- package/src/routes/files.ts +99 -0
- package/src/routes/health.ts +29 -0
- package/src/routes/providers.ts +9 -0
- package/src/routes/render.ts +68 -0
- package/src/routes/sessions.ts +49 -0
- package/src/routes/voice.ts +80 -0
- package/src/voice/deepgram-token.ts +80 -0
- package/src/voice/keyterm-builder.ts +426 -0
- package/src/voice/speech-providers.ts +111 -0
- package/src/ws/attachments.ts +86 -0
- package/src/ws/bridge.ts +139 -0
- package/src/ws/clients.ts +57 -0
- package/src/ws/connection.ts +132 -0
- package/src/ws/dispatch.ts +162 -0
- package/src/ws/frames.ts +42 -0
- package/src/ws/handler.ts +50 -0
- package/src/ws/history.ts +51 -0
- package/src/ws/host.ts +69 -0
- package/src/ws/routing.ts +43 -0
- package/src/ws/run-session.ts +192 -0
- package/src/ws/session-catalog.ts +97 -0
- package/src/ws/shrink.ts +95 -0
- package/src/ws/turns.ts +151 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SessionHistoryMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
|
+
import { type WSContext, sendTo } from "./clients.js";
|
|
3
|
+
import { HISTORY_CHUNK_BYTES, shrinkForReplication } from "./shrink.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Send a structured history to one socket as one or more `session_history`
|
|
7
|
+
* frames. Each message is size-bounded, then batched into byte-limited chunks
|
|
8
|
+
* so no single frame exceeds the socket's per-message limit (which would drop
|
|
9
|
+
* the connection and wedge the reconnect loop). The first frame replaces the
|
|
10
|
+
* client transcript; continuation frames carry `append: true`. An empty
|
|
11
|
+
* history still emits one (empty, replacing) frame so the client settles.
|
|
12
|
+
*
|
|
13
|
+
* Every frame carries `sessionId` so a client with multiple sessions open can
|
|
14
|
+
* demux it: a history load for a background session must never replace the
|
|
15
|
+
* transcript in view (a reconnect or an out-of-order getHistory otherwise
|
|
16
|
+
* silently overwrites the wrong pane).
|
|
17
|
+
*/
|
|
18
|
+
export function sendSessionHistory(
|
|
19
|
+
ws: WSContext,
|
|
20
|
+
sessionId: string,
|
|
21
|
+
messages: SessionHistoryMessage[]
|
|
22
|
+
): void {
|
|
23
|
+
const bounded = messages.map((m) => shrinkForReplication(m));
|
|
24
|
+
if (bounded.length === 0) {
|
|
25
|
+
sendTo(ws, { type: "session_history", sessionId, messages: [] });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let batch: SessionHistoryMessage[] = [];
|
|
30
|
+
let batchBytes = 0;
|
|
31
|
+
let isFirst = true;
|
|
32
|
+
const flush = () => {
|
|
33
|
+
sendTo(ws, {
|
|
34
|
+
type: "session_history",
|
|
35
|
+
sessionId,
|
|
36
|
+
messages: batch,
|
|
37
|
+
...(isFirst ? {} : { append: true }),
|
|
38
|
+
});
|
|
39
|
+
isFirst = false;
|
|
40
|
+
batch = [];
|
|
41
|
+
batchBytes = 0;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
for (const message of bounded) {
|
|
45
|
+
const size = JSON.stringify(message).length;
|
|
46
|
+
if (batch.length > 0 && batchBytes + size > HISTORY_CHUNK_BYTES) flush();
|
|
47
|
+
batch.push(message);
|
|
48
|
+
batchBytes += size;
|
|
49
|
+
}
|
|
50
|
+
flush();
|
|
51
|
+
}
|
package/src/ws/host.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
|
+
import { broadcast, sendTo, type WSContext } from "./clients.js";
|
|
3
|
+
import { TurnCoordinator } from "./turns.js";
|
|
4
|
+
import { createSessionCatalog, type SessionCatalog } from "./session-catalog.js";
|
|
5
|
+
|
|
6
|
+
/** Host-side turn timeout. The backend no longer times out — the host owns it. */
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
8
|
+
|
|
9
|
+
/** Cap on host-side follow-up queue depth per session (backends without followUp). */
|
|
10
|
+
export const MAX_SESSION_QUEUE = 5;
|
|
11
|
+
|
|
12
|
+
export interface WsHostOptions {
|
|
13
|
+
/** Session persistence seam; defaults to the package's SQLite catalog. */
|
|
14
|
+
catalog?: SessionCatalog;
|
|
15
|
+
/** Display name used in connection/status copy. */
|
|
16
|
+
appName?: string;
|
|
17
|
+
/** Per-turn timeout in ms (default 10 minutes). */
|
|
18
|
+
turnTimeoutMs?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Deployment-time cap on concurrent RUNNING sessions. Each running turn is
|
|
21
|
+
* roughly one CLI subprocess, so this bounds memory/CPU. Read per request so
|
|
22
|
+
* the deploy-time env is honored without a restart.
|
|
23
|
+
*/
|
|
24
|
+
maxConcurrentSessions?: () => number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function envMaxConcurrentSessions(): number {
|
|
28
|
+
return Math.max(1, Number(process.env.MAX_CONCURRENT_SESSIONS) || 3);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Everything one WebSocket coordinator instance owns: turn state, the session
|
|
33
|
+
* catalog, branding copy, and the frame senders. Handlers receive this host
|
|
34
|
+
* explicitly instead of reaching for module globals.
|
|
35
|
+
*/
|
|
36
|
+
export class WsHost {
|
|
37
|
+
readonly coordinator = new TurnCoordinator();
|
|
38
|
+
catalog: SessionCatalog;
|
|
39
|
+
appName: string;
|
|
40
|
+
turnTimeoutMs: number;
|
|
41
|
+
maxConcurrentSessions: () => number;
|
|
42
|
+
|
|
43
|
+
constructor(options: WsHostOptions = {}) {
|
|
44
|
+
this.catalog = options.catalog ?? createSessionCatalog();
|
|
45
|
+
this.appName = options.appName ?? "Brain UI";
|
|
46
|
+
this.turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
47
|
+
this.maxConcurrentSessions = options.maxConcurrentSessions ?? envMaxConcurrentSessions;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Re-apply embedder options (createApp configures the default host). */
|
|
51
|
+
configure(options: WsHostOptions): void {
|
|
52
|
+
if (options.catalog) this.catalog = options.catalog;
|
|
53
|
+
if (options.appName) this.appName = options.appName;
|
|
54
|
+
if (options.turnTimeoutMs) this.turnTimeoutMs = options.turnTimeoutMs;
|
|
55
|
+
if (options.maxConcurrentSessions) {
|
|
56
|
+
this.maxConcurrentSessions = options.maxConcurrentSessions;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fan a frame out to every attached client (size-bounded per frame). */
|
|
61
|
+
sendToClients(msg: ServerMessage): void {
|
|
62
|
+
broadcast(msg);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Send a frame to one specific socket (size-bounded). */
|
|
66
|
+
sendMessage(ws: WSContext, msg: ServerMessage): void {
|
|
67
|
+
sendTo(ws, msg);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AgentBackend } from "@schlessera/brain-ui-sdk/server";
|
|
2
|
+
import {
|
|
3
|
+
getBackendForProfile,
|
|
4
|
+
getBackendForSession,
|
|
5
|
+
getDefaultBackend,
|
|
6
|
+
listAllProviders,
|
|
7
|
+
} from "../agent/backend.js";
|
|
8
|
+
import type { SessionCatalog } from "./session-catalog.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve one turn slot's pinned backend and profile. Resumed sessions route by
|
|
12
|
+
* their stored backend id; new sessions route by the requested profile owner.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveTurnTarget(
|
|
15
|
+
catalog: SessionCatalog,
|
|
16
|
+
sessionId: string | undefined,
|
|
17
|
+
requested: string | undefined
|
|
18
|
+
): Promise<{ profileId?: string; backend: AgentBackend; droppedPin?: string }> {
|
|
19
|
+
if (sessionId) {
|
|
20
|
+
const backend = await getBackendForSession(catalog.getStoredBackendId(sessionId));
|
|
21
|
+
const stored = catalog.getStoredProviderId(sessionId);
|
|
22
|
+
const available = await backend.listProfiles();
|
|
23
|
+
if (stored && available.some((profile) => profile.id === stored)) {
|
|
24
|
+
return { backend, profileId: stored };
|
|
25
|
+
}
|
|
26
|
+
// A stored pin that no longer resolves: fall back to the default profile
|
|
27
|
+
// but report it (droppedPin) so the caller can surface the switch instead
|
|
28
|
+
// of silently changing model/endpoint/billing with nothing in the logs.
|
|
29
|
+
return { backend, ...(stored ? { droppedPin: stored } : {}) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (requested) {
|
|
33
|
+
const available = await listAllProviders();
|
|
34
|
+
if (available.some((profile) => profile.id === requested)) {
|
|
35
|
+
const backend = await getBackendForProfile(requested);
|
|
36
|
+
if (backend) return { backend, profileId: requested };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const backend = await getDefaultBackend();
|
|
41
|
+
const profileId = (await backend.listProfiles())[0]?.id;
|
|
42
|
+
return { backend, ...(profileId ? { profileId } : {}) };
|
|
43
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { ChatImageAttachment } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
|
+
import type { WSContext } from "./clients.js";
|
|
3
|
+
import { withSessionId, withTurnScope } from "./frames.js";
|
|
4
|
+
import { makeBridge, emitTurnError } from "./bridge.js";
|
|
5
|
+
import { resolveTurnTarget } from "./routing.js";
|
|
6
|
+
import type { QueuedFollowUp, RunningTurn } from "./turns.js";
|
|
7
|
+
import { MAX_SESSION_QUEUE, type WsHost } from "./host.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run one session slot: the initial turn, then any queued follow-up turns in
|
|
11
|
+
* order. The host owns per-turn cancellation (AbortController + timeout). Never
|
|
12
|
+
* throws — runtime failures are emitted as error frames.
|
|
13
|
+
*/
|
|
14
|
+
export async function runSession(
|
|
15
|
+
host: WsHost,
|
|
16
|
+
initial: {
|
|
17
|
+
text: string;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
attachments: ChatImageAttachment[];
|
|
20
|
+
providerId?: string;
|
|
21
|
+
}
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const { coordinator } = host;
|
|
24
|
+
coordinator.startingSessions += 1;
|
|
25
|
+
let target: Awaited<ReturnType<typeof resolveTurnTarget>>;
|
|
26
|
+
try {
|
|
27
|
+
target = await resolveTurnTarget(host.catalog, initial.sessionId, initial.providerId);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
host.sendToClients({
|
|
30
|
+
type: "error",
|
|
31
|
+
code: "BACKEND_ERROR",
|
|
32
|
+
message: err instanceof Error ? err.message : String(err),
|
|
33
|
+
...(initial.sessionId ? { sessionId: initial.sessionId } : {}),
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
} finally {
|
|
37
|
+
coordinator.startingSessions = Math.max(0, coordinator.startingSessions - 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { backend, profileId: initialProfileId } = target;
|
|
41
|
+
if (target.droppedPin) {
|
|
42
|
+
console.warn(
|
|
43
|
+
`[agent] session ${initial.sessionId} pinned profile "${target.droppedPin}" ` +
|
|
44
|
+
`is unavailable; running on the default profile.`
|
|
45
|
+
);
|
|
46
|
+
host.sendToClients({
|
|
47
|
+
type: "status",
|
|
48
|
+
status: "thinking",
|
|
49
|
+
detail: `Pinned model "${target.droppedPin}" is unavailable — running on the default.`,
|
|
50
|
+
...(initial.sessionId ? { sessionId: initial.sessionId } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const turn: RunningTurn = {
|
|
54
|
+
sessionId: initial.sessionId ?? null,
|
|
55
|
+
turnId: crypto.randomUUID(),
|
|
56
|
+
providerId: initialProfileId ?? null,
|
|
57
|
+
backend,
|
|
58
|
+
abortController: new AbortController(),
|
|
59
|
+
timeoutHandle: setTimeout(() => {}, 0),
|
|
60
|
+
queue: [],
|
|
61
|
+
cancelled: false,
|
|
62
|
+
};
|
|
63
|
+
clearTimeout(turn.timeoutHandle);
|
|
64
|
+
coordinator.running.add(turn);
|
|
65
|
+
if (turn.sessionId) coordinator.bySession.set(turn.sessionId, turn);
|
|
66
|
+
|
|
67
|
+
let profileId = initialProfileId;
|
|
68
|
+
let resumeId = initial.sessionId;
|
|
69
|
+
let next: QueuedFollowUp | null = {
|
|
70
|
+
text: initial.text,
|
|
71
|
+
attachments: initial.attachments,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
while (next && !turn.cancelled) {
|
|
76
|
+
const { text, attachments } = next;
|
|
77
|
+
next = null;
|
|
78
|
+
|
|
79
|
+
const abortController = new AbortController();
|
|
80
|
+
turn.abortController = abortController;
|
|
81
|
+
const timeoutHandle = setTimeout(() => {
|
|
82
|
+
console.log("[agent] Turn timed out after", host.turnTimeoutMs, "ms");
|
|
83
|
+
abortController.abort();
|
|
84
|
+
// Reject any pending interactive request for this turn too. A bridge
|
|
85
|
+
// that awaits askUser/approval without racing the abort signal would
|
|
86
|
+
// otherwise leave startTurn parked forever after abort(), leaking a
|
|
87
|
+
// concurrency slot until restart. Draining unblocks the unwind.
|
|
88
|
+
coordinator.drainPendingForTurn(turn, "Turn timed out");
|
|
89
|
+
}, host.turnTimeoutMs);
|
|
90
|
+
turn.timeoutHandle = timeoutHandle;
|
|
91
|
+
|
|
92
|
+
const bridge = makeBridge(host, turn, text, backend.id);
|
|
93
|
+
try {
|
|
94
|
+
await backend.startTurn({
|
|
95
|
+
prompt: text,
|
|
96
|
+
attachments,
|
|
97
|
+
sessionId: resumeId,
|
|
98
|
+
profileId,
|
|
99
|
+
signal: abortController.signal,
|
|
100
|
+
bridge,
|
|
101
|
+
});
|
|
102
|
+
} catch (err) {
|
|
103
|
+
emitTurnError(host, turn, err);
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timeoutHandle);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Subsequent (queued) turns resume the now-known session and keep its
|
|
109
|
+
// pin. turn.providerId is the resolved profile (set from initial routing
|
|
110
|
+
// and re-pinned by session_info); carrying it forward stops a queued
|
|
111
|
+
// follow-up from silently dropping onto the default profile/model/billing.
|
|
112
|
+
resumeId = turn.sessionId ?? resumeId;
|
|
113
|
+
profileId = turn.providerId ?? profileId;
|
|
114
|
+
|
|
115
|
+
if (!turn.cancelled && turn.queue.length > 0) {
|
|
116
|
+
next = turn.queue.shift()!;
|
|
117
|
+
// A queued follow-up is its own turn — give it a fresh identity.
|
|
118
|
+
turn.turnId = crypto.randomUUID();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
if (turn.sessionId) coordinator.bySession.delete(turn.sessionId);
|
|
123
|
+
coordinator.running.delete(turn);
|
|
124
|
+
coordinator.drainPendingForTurn(turn, "Session ended");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Dispatch a chat_message: follow-up to a running session, or a new session. */
|
|
129
|
+
export async function handleChatMessage(
|
|
130
|
+
host: WsHost,
|
|
131
|
+
ws: WSContext,
|
|
132
|
+
text: string,
|
|
133
|
+
sessionId: string | undefined,
|
|
134
|
+
attachments: ChatImageAttachment[],
|
|
135
|
+
requestedProviderId: string | undefined
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const { coordinator } = host;
|
|
138
|
+
const runningTurn = sessionId ? coordinator.bySession.get(sessionId) : undefined;
|
|
139
|
+
|
|
140
|
+
if (runningTurn && sessionId) {
|
|
141
|
+
const backend = runningTurn.backend;
|
|
142
|
+
// Message to a session whose turn is running = a follow-up.
|
|
143
|
+
if (backend.capabilities.followUp && backend.followUp) {
|
|
144
|
+
// Inject into the running turn; frames flow through its bridge.
|
|
145
|
+
backend.followUp({ sessionId, prompt: text, attachments }).catch((err) => {
|
|
146
|
+
host.sendToClients(
|
|
147
|
+
withTurnScope(
|
|
148
|
+
{ type: "error", code: "FOLLOWUP_FAILED", message: err instanceof Error ? err.message : String(err) },
|
|
149
|
+
runningTurn
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
} else if (runningTurn.queue.length >= MAX_SESSION_QUEUE) {
|
|
154
|
+
host.sendMessage(
|
|
155
|
+
ws,
|
|
156
|
+
// Session-scoped only: the rejected message would have become a
|
|
157
|
+
// FUTURE turn in this slot, not the one currently running.
|
|
158
|
+
withSessionId(
|
|
159
|
+
{ type: "error", code: "SESSION_QUEUE_FULL", message: `This session's queue is full (max ${MAX_SESSION_QUEUE}). Wait for it to catch up.` },
|
|
160
|
+
sessionId
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
} else {
|
|
164
|
+
// Queue it as the session's next turn; report queued immediately.
|
|
165
|
+
runningTurn.queue.push({ text, attachments });
|
|
166
|
+
host.sendToClients(withSessionId({ type: "status", status: "queued" }, sessionId));
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// New session (or resume of an idle one) — gated by the concurrency cap.
|
|
172
|
+
const cap = host.maxConcurrentSessions();
|
|
173
|
+
if (coordinator.running.size + coordinator.startingSessions >= cap) {
|
|
174
|
+
host.sendMessage(ws, {
|
|
175
|
+
type: "error",
|
|
176
|
+
code: "SESSION_LIMIT",
|
|
177
|
+
message: `Too many concurrent sessions (max ${cap}). Wait for one to finish.`,
|
|
178
|
+
...(sessionId ? { sessionId } : {}),
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (sessionId) {
|
|
184
|
+
host.sendToClients(withSessionId({ type: "status", status: "thinking" }, sessionId));
|
|
185
|
+
}
|
|
186
|
+
void runSession(host, {
|
|
187
|
+
text,
|
|
188
|
+
sessionId,
|
|
189
|
+
attachments,
|
|
190
|
+
providerId: requestedProviderId,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { ServerResultMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
3
|
+
import { getDb } from "../db/client.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Persistence seam for session ownership + accounting. The ws coordinator only
|
|
7
|
+
* talks to this interface — swapping the store means implementing four
|
|
8
|
+
* methods, not editing the turn loop.
|
|
9
|
+
*/
|
|
10
|
+
export interface SessionCatalog {
|
|
11
|
+
/** The provider/profile a stored session is pinned to, or null. */
|
|
12
|
+
getStoredProviderId(sessionId: string): string | null;
|
|
13
|
+
/** The backend that owns a stored session, or null for legacy/unknown rows. */
|
|
14
|
+
getStoredBackendId(sessionId: string): string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Persist session ownership as soon as `session_info` names the session,
|
|
17
|
+
* with zero cost/turn deltas — the terminal `result` adds the accounting.
|
|
18
|
+
* Without this, a failed or cancelled first turn left the transcript
|
|
19
|
+
* unowned.
|
|
20
|
+
*/
|
|
21
|
+
persistSessionStub(
|
|
22
|
+
sessionId: string,
|
|
23
|
+
promptText: string,
|
|
24
|
+
providerId: string | null,
|
|
25
|
+
backendId: string
|
|
26
|
+
): void;
|
|
27
|
+
/** Persist session accounting on the terminal `result` frame. */
|
|
28
|
+
persistSession(
|
|
29
|
+
msg: ServerResultMessage,
|
|
30
|
+
promptText: string,
|
|
31
|
+
providerId: string | null,
|
|
32
|
+
backendId: string
|
|
33
|
+
): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const UPSERT_SESSION_SQL = `INSERT INTO sessions (id, title, created_at, last_active_at, total_cost_usd, num_turns, provider_id, backend_id)
|
|
37
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
38
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
39
|
+
last_active_at = excluded.last_active_at,
|
|
40
|
+
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
|
|
41
|
+
num_turns = num_turns + excluded.num_turns,
|
|
42
|
+
provider_id = COALESCE(excluded.provider_id, provider_id),
|
|
43
|
+
backend_id = COALESCE(excluded.backend_id, backend_id)`;
|
|
44
|
+
|
|
45
|
+
/** SQLite-backed catalog over the package's session database. */
|
|
46
|
+
export function createSessionCatalog(db: () => Database = getDb): SessionCatalog {
|
|
47
|
+
return {
|
|
48
|
+
getStoredProviderId(sessionId) {
|
|
49
|
+
const row = db()
|
|
50
|
+
.query("SELECT provider_id AS providerId FROM sessions WHERE id = ?")
|
|
51
|
+
.get(sessionId) as { providerId: string | null } | null;
|
|
52
|
+
return row?.providerId ?? null;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
getStoredBackendId(sessionId) {
|
|
56
|
+
const row = db()
|
|
57
|
+
.query("SELECT backend_id AS backendId FROM sessions WHERE id = ?")
|
|
58
|
+
.get(sessionId) as { backendId: string | null } | null;
|
|
59
|
+
return row?.backendId ?? null;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
persistSessionStub(sessionId, promptText, providerId, backendId) {
|
|
63
|
+
try {
|
|
64
|
+
db()
|
|
65
|
+
.prepare(UPSERT_SESSION_SQL)
|
|
66
|
+
.run(
|
|
67
|
+
sessionId,
|
|
68
|
+
promptText.slice(0, 100),
|
|
69
|
+
Date.now(),
|
|
70
|
+
Date.now(),
|
|
71
|
+
0,
|
|
72
|
+
0,
|
|
73
|
+
providerId,
|
|
74
|
+
backendId
|
|
75
|
+
);
|
|
76
|
+
} catch {}
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
persistSession(msg, promptText, providerId, backendId) {
|
|
80
|
+
try {
|
|
81
|
+
db()
|
|
82
|
+
.prepare(UPSERT_SESSION_SQL)
|
|
83
|
+
.run(
|
|
84
|
+
msg.sessionId,
|
|
85
|
+
promptText.slice(0, 100),
|
|
86
|
+
Date.now(),
|
|
87
|
+
Date.now(),
|
|
88
|
+
// costUsd is optional (rev 2): absent = unknown, accounted as 0.
|
|
89
|
+
msg.costUsd ?? 0,
|
|
90
|
+
msg.numTurns,
|
|
91
|
+
providerId,
|
|
92
|
+
backendId
|
|
93
|
+
);
|
|
94
|
+
} catch {}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/ws/shrink.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size-bounding for outbound WebSocket frames.
|
|
3
|
+
*
|
|
4
|
+
* A single oversized frame — typically a multi-megabyte tool result (a big
|
|
5
|
+
* `Read`/`Bash`/`Grep` output), a tool_use with a large input, or a tool
|
|
6
|
+
* result whose content array holds thousands of blocks — can exceed the
|
|
7
|
+
* socket's per-frame limit, drop the connection, and (because the client
|
|
8
|
+
* reconnects and re-requests the same history) wedge into a reconnect loop.
|
|
9
|
+
*
|
|
10
|
+
* This helper bounds any JSON-serializable payload below
|
|
11
|
+
* {@link MAX_WS_MESSAGE_BYTES} by head-truncating long strings AND
|
|
12
|
+
* head-clipping long arrays, leaving small metadata (ids, types, statuses)
|
|
13
|
+
* untouched. Both axes matter: string truncation alone leaves the cap
|
|
14
|
+
* unenforced for a payload built of many short strings. The model already
|
|
15
|
+
* received the full tool output server-side — this only bounds what the
|
|
16
|
+
* browser renders.
|
|
17
|
+
*
|
|
18
|
+
* Ported from oh-my-pi's collab replication-shrink.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Per-frame ceiling for server → client messages. Well under Bun's 16 MB
|
|
22
|
+
* default and typical reverse-proxy WebSocket limits. */
|
|
23
|
+
export const MAX_WS_MESSAGE_BYTES = 512_000;
|
|
24
|
+
|
|
25
|
+
/** Target byte size for one `session_history` chunk. Below the frame cap so a
|
|
26
|
+
* batch plus its wrapper stays comfortably under {@link MAX_WS_MESSAGE_BYTES}. */
|
|
27
|
+
export const HISTORY_CHUNK_BYTES = 400_000;
|
|
28
|
+
|
|
29
|
+
interface ShrinkPass {
|
|
30
|
+
stringCap: number;
|
|
31
|
+
arrayLimit: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Progressive shrink passes. Each tightens both the per-string cap and the
|
|
36
|
+
* per-array head limit; the loop stops at the first pass whose output fits
|
|
37
|
+
* {@link MAX_WS_MESSAGE_BYTES}. The final pass clamps every string to 64 B and
|
|
38
|
+
* every array to one element, so even pathological mixes converge.
|
|
39
|
+
*/
|
|
40
|
+
const SHRINK_PASSES: readonly ShrinkPass[] = [
|
|
41
|
+
{ stringCap: 64 * 1024, arrayLimit: 256 },
|
|
42
|
+
{ stringCap: 16 * 1024, arrayLimit: 128 },
|
|
43
|
+
{ stringCap: 4 * 1024, arrayLimit: 64 },
|
|
44
|
+
{ stringCap: 1 * 1024, arrayLimit: 32 },
|
|
45
|
+
{ stringCap: 256, arrayLimit: 16 },
|
|
46
|
+
{ stringCap: 256, arrayLimit: 4 },
|
|
47
|
+
{ stringCap: 64, arrayLimit: 1 },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const STRING_ELISION_RESERVE = 80;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Recursively walk `value`, head-truncating any string longer than
|
|
54
|
+
* `stringCap` and head-clipping any array longer than `arrayLimit`. Returns a
|
|
55
|
+
* freshly built deep clone; short strings/numbers/booleans pass through.
|
|
56
|
+
*/
|
|
57
|
+
function shrinkWalk(value: unknown, stringCap: number, arrayLimit: number): unknown {
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
if (value.length <= stringCap) return value;
|
|
60
|
+
const headLen = Math.max(0, stringCap - STRING_ELISION_RESERVE);
|
|
61
|
+
return `${value.slice(0, headLen)}\n…[${value.length - headLen} chars elided]`;
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
const keep = Math.min(value.length, arrayLimit);
|
|
65
|
+
const elided = value.length - keep;
|
|
66
|
+
const out: unknown[] = new Array(elided > 0 ? keep + 1 : keep);
|
|
67
|
+
for (let i = 0; i < keep; i++) out[i] = shrinkWalk(value[i], stringCap, arrayLimit);
|
|
68
|
+
if (elided > 0) out[keep] = `…[${elided} items elided]`;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
if (value && typeof value === "object") {
|
|
72
|
+
const src = value as Record<string, unknown>;
|
|
73
|
+
const out: Record<string, unknown> = {};
|
|
74
|
+
for (const k in src) out[k] = shrinkWalk(src[k], stringCap, arrayLimit);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Return `value` unchanged when its JSON serialization already fits
|
|
82
|
+
* {@link MAX_WS_MESSAGE_BYTES}; otherwise return a deep-cloned shadow shrunk
|
|
83
|
+
* along both string and array axes until it fits. The wire shape is preserved:
|
|
84
|
+
* only string leaves and array tails change — discriminator fields, ids, and
|
|
85
|
+
* other small metadata pass through untouched.
|
|
86
|
+
*/
|
|
87
|
+
export function shrinkForReplication<T>(value: T): T {
|
|
88
|
+
if (JSON.stringify(value).length <= MAX_WS_MESSAGE_BYTES) return value;
|
|
89
|
+
let shrunk: unknown = value;
|
|
90
|
+
for (const pass of SHRINK_PASSES) {
|
|
91
|
+
shrunk = shrinkWalk(value, pass.stringCap, pass.arrayLimit);
|
|
92
|
+
if (JSON.stringify(shrunk).length <= MAX_WS_MESSAGE_BYTES) return shrunk as T;
|
|
93
|
+
}
|
|
94
|
+
return shrunk as T;
|
|
95
|
+
}
|