@schlessera/brain-ui-server 0.13.0 → 0.14.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/README.md +51 -12
- package/dist/agent/backend.d.ts +163 -37
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +281 -286
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts +46 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +94 -35
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +41 -30
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +138 -132
- package/dist/brain/client.js.map +1 -1
- package/dist/config/env.d.ts +104 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +296 -0
- package/dist/config/env.js.map +1 -0
- package/dist/cron/scheduler.d.ts +16 -8
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +54 -53
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/brain-db.d.ts +50 -0
- package/dist/db/brain-db.d.ts.map +1 -0
- package/dist/db/brain-db.js +75 -0
- package/dist/db/brain-db.js.map +1 -0
- package/dist/db/client.d.ts +8 -6
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +12 -25
- package/dist/db/client.js.map +1 -1
- package/dist/db/settings.d.ts +5 -4
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +9 -12
- package/dist/db/settings.js.map +1 -1
- package/dist/files/walker.d.ts +6 -7
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +6 -9
- package/dist/files/walker.js.map +1 -1
- package/dist/graph/reader.d.ts +6 -6
- package/dist/graph/reader.d.ts.map +1 -1
- package/dist/graph/reader.js +22 -12
- package/dist/graph/reader.js.map +1 -1
- package/dist/index.d.ts +15 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -10
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +17 -6
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +34 -43
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/origin.d.ts +1 -2
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +1 -7
- package/dist/middleware/origin.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +29 -5
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +47 -76
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/middleware/tailscale.d.ts +14 -11
- package/dist/middleware/tailscale.d.ts.map +1 -1
- package/dist/middleware/tailscale.js +11 -10
- package/dist/middleware/tailscale.js.map +1 -1
- package/dist/routes/brain.d.ts +9 -154
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +187 -188
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts +4 -88
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +74 -71
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/graph.d.ts +4 -255
- package/dist/routes/graph.d.ts.map +1 -1
- package/dist/routes/graph.js +67 -64
- package/dist/routes/graph.js.map +1 -1
- package/dist/routes/health.d.ts +8 -22
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +10 -10
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/models.d.ts +7 -90
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +53 -51
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/providers.d.ts +5 -34
- package/dist/routes/providers.d.ts.map +1 -1
- package/dist/routes/providers.js +13 -11
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/sessions.d.ts +7 -78
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +35 -34
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/share.d.ts +7 -60
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +80 -77
- package/dist/routes/share.js.map +1 -1
- package/dist/routes/voice.d.ts +8 -91
- package/dist/routes/voice.d.ts.map +1 -1
- package/dist/routes/voice.js +62 -59
- package/dist/routes/voice.js.map +1 -1
- package/dist/share/staging.d.ts +4 -4
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +7 -8
- package/dist/share/staging.js.map +1 -1
- package/dist/voice/deepgram-token.d.ts +1 -1
- package/dist/voice/deepgram-token.d.ts.map +1 -1
- package/dist/voice/deepgram-token.js +1 -2
- package/dist/voice/deepgram-token.js.map +1 -1
- package/dist/voice/keyterm-builder.d.ts +23 -8
- package/dist/voice/keyterm-builder.d.ts.map +1 -1
- package/dist/voice/keyterm-builder.js +67 -43
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/voice/speech-providers.d.ts +6 -4
- package/dist/voice/speech-providers.d.ts.map +1 -1
- package/dist/voice/speech-providers.js +28 -25
- package/dist/voice/speech-providers.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +2 -3
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +18 -10
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +35 -37
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +5 -7
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +1 -2
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +16 -12
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +14 -23
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/routing.d.ts +2 -1
- package/dist/ws/routing.d.ts.map +1 -1
- package/dist/ws/routing.js +5 -6
- package/dist/ws/routing.js.map +1 -1
- package/dist/ws/run-session.js +1 -1
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/session-catalog.d.ts +2 -2
- package/dist/ws/session-catalog.d.ts.map +1 -1
- package/dist/ws/session-catalog.js +2 -3
- package/dist/ws/session-catalog.js.map +1 -1
- package/package.json +8 -5
- package/src/agent/backend.ts +509 -340
- package/src/app.ts +157 -36
- package/src/brain/client.ts +166 -143
- package/src/config/env.ts +401 -0
- package/src/cron/scheduler.ts +77 -59
- package/src/db/brain-db.ts +104 -0
- package/src/db/client.ts +12 -31
- package/src/db/settings.ts +12 -14
- package/src/files/walker.ts +6 -10
- package/src/graph/reader.ts +26 -15
- package/src/index.ts +56 -17
- package/src/middleware/auth.ts +51 -45
- package/src/middleware/origin.ts +1 -8
- package/src/middleware/passkeys.ts +72 -68
- package/src/middleware/tailscale.ts +22 -17
- package/src/routes/brain.ts +31 -30
- package/src/routes/files.ts +10 -7
- package/src/routes/graph.ts +9 -6
- package/src/routes/health.ts +17 -10
- package/src/routes/models.ts +19 -15
- package/src/routes/providers.ts +14 -15
- package/src/routes/sessions.ts +18 -11
- package/src/routes/share.ts +35 -24
- package/src/routes/voice.ts +78 -68
- package/src/share/staging.ts +10 -8
- package/src/voice/deepgram-token.ts +4 -2
- package/src/voice/keyterm-builder.ts +90 -56
- package/src/voice/speech-providers.ts +28 -24
- package/src/ws/bridge.ts +2 -3
- package/src/ws/clients.ts +38 -41
- package/src/ws/connection.ts +5 -7
- package/src/ws/dispatch.ts +1 -2
- package/src/ws/host.ts +24 -27
- package/src/ws/routing.ts +6 -10
- package/src/ws/run-session.ts +1 -1
- package/src/ws/session-catalog.ts +2 -3
- package/dist/ws/handler.d.ts +0 -34
- package/dist/ws/handler.d.ts.map +0 -1
- package/dist/ws/handler.js +0 -33
- package/dist/ws/handler.js.map +0 -1
- package/src/ws/handler.ts +0 -50
package/src/app.ts
CHANGED
|
@@ -1,38 +1,62 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
|
+
import type { Database } from "bun:sqlite";
|
|
3
4
|
import { logger } from "hono/logger";
|
|
4
5
|
import { cors } from "hono/cors";
|
|
5
6
|
import { serveStatic } from "hono/bun";
|
|
6
7
|
import { join } from "path";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
8
|
+
import { resolveServerConfig, type ServerConfig } from "./config/env.js";
|
|
9
|
+
import { healthRoutes, createStatusRoutes } from "./routes/health.js";
|
|
10
|
+
import { createBrainRoutes } from "./routes/brain.js";
|
|
11
|
+
import { createSessionRoutes } from "./routes/sessions.js";
|
|
12
|
+
import { createVoiceRoutes } from "./routes/voice.js";
|
|
13
|
+
import { createFilesRoutes } from "./routes/files.js";
|
|
14
|
+
import { createShareRoutes, shareTargetFallbackRoutes } from "./routes/share.js";
|
|
13
15
|
import { createRenderRoutes, type AppRenderer } from "./routes/render.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { createProviderRoutes } from "./routes/providers.js";
|
|
17
|
+
import { createModelRoutes } from "./routes/models.js";
|
|
18
|
+
import { createGraphRoutes } from "./routes/graph.js";
|
|
17
19
|
import {
|
|
18
20
|
resolveAuthMode,
|
|
19
21
|
assertAuthConfig,
|
|
20
22
|
authGuard,
|
|
21
23
|
authRoutes,
|
|
22
24
|
isWsAuthorized,
|
|
25
|
+
type AuthMode,
|
|
26
|
+
type AuthRuntime,
|
|
23
27
|
} from "./middleware/auth.js";
|
|
24
28
|
import {
|
|
25
29
|
passkeyPublicRoutes,
|
|
26
30
|
passkeyManagementRoutes,
|
|
27
31
|
passwordLoginDisabled,
|
|
28
32
|
assertPasskeyConfig,
|
|
33
|
+
type PasskeyContext,
|
|
29
34
|
} from "./middleware/passkeys.js";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
35
|
+
import { createUiDb } from "./db/client.js";
|
|
36
|
+
import { getHiddenModelIds } from "./db/settings.js";
|
|
37
|
+
import {
|
|
38
|
+
assertBackendResolvable,
|
|
39
|
+
createBackendRegistry,
|
|
40
|
+
type BackendRegistry,
|
|
41
|
+
} from "./agent/backend.js";
|
|
42
|
+
import { createBrainClient } from "./brain/client.js";
|
|
43
|
+
import { createCronScheduler } from "./cron/scheduler.js";
|
|
44
|
+
import { WsHost } from "./ws/host.js";
|
|
45
|
+
import { createWsUpgrade, websocket } from "./ws/connection.js";
|
|
46
|
+
import { createSessionCatalog } from "./ws/session-catalog.js";
|
|
47
|
+
import type { KeytermSettings } from "./voice/keyterm-builder.js";
|
|
32
48
|
|
|
33
49
|
export type { AppRenderer };
|
|
34
50
|
|
|
35
51
|
export interface CreateAppOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Fully-resolved configuration. When omitted, `createApp` resolves it from
|
|
54
|
+
* the process environment ONCE, here at the edge — nothing deeper in the
|
|
55
|
+
* package touches the ambient environment. Pass an explicit object (e.g. from
|
|
56
|
+
* `resolveServerConfig(customEnv)`) to run two differently-configured apps
|
|
57
|
+
* in one process or to vary configuration in tests without env mutation.
|
|
58
|
+
*/
|
|
59
|
+
config?: ServerConfig;
|
|
36
60
|
/**
|
|
37
61
|
* Directory of a built SPA to serve at `/*` with an index.html fallback.
|
|
38
62
|
* The deployment shell decides whether (and what) to serve — the package
|
|
@@ -41,7 +65,11 @@ export interface CreateAppOptions {
|
|
|
41
65
|
staticRoot?: string;
|
|
42
66
|
/** Display name used in connection/status copy. Default "Brain UI". */
|
|
43
67
|
appName?: string;
|
|
44
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* SQLite path override; folded into the effective config, so `app.config.dbPath`
|
|
70
|
+
* always names the database actually opened. Falls back to config.dbPath
|
|
71
|
+
* (DB_PATH, ./brain-ui.db).
|
|
72
|
+
*/
|
|
45
73
|
dbPath?: string;
|
|
46
74
|
/**
|
|
47
75
|
* PNG/PDF renderer for `POST /api/render`. The deployment owns the actual
|
|
@@ -51,6 +79,32 @@ export interface CreateAppOptions {
|
|
|
51
79
|
renderer?: AppRenderer;
|
|
52
80
|
/** Per-turn timeout in ms (default 10 minutes). */
|
|
53
81
|
turnTimeoutMs?: number;
|
|
82
|
+
/** Backend registry override (tests/embedders); default is built from config. */
|
|
83
|
+
registry?: BackendRegistry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What `createApp` hands back to the deployment shell. */
|
|
87
|
+
export interface BrainUiApp {
|
|
88
|
+
fetch: Hono["fetch"];
|
|
89
|
+
websocket: typeof websocket;
|
|
90
|
+
/** The configuration this instance runs on (resolved or injected). */
|
|
91
|
+
config: ServerConfig;
|
|
92
|
+
/**
|
|
93
|
+
* The auth mode this instance resolved and validated at boot — so the
|
|
94
|
+
* deployment shell can log it without re-deriving the AuthRuntime that
|
|
95
|
+
* resolveAuthMode needs.
|
|
96
|
+
*/
|
|
97
|
+
authMode: AuthMode;
|
|
98
|
+
/** The app's own SQLite handle (sessions, passkeys, settings). */
|
|
99
|
+
db: Database;
|
|
100
|
+
/** The WebSocket coordinator (turn state, clients, catalog, registry). */
|
|
101
|
+
wsHost: WsHost;
|
|
102
|
+
/** True while any session has a running turn. */
|
|
103
|
+
isTurnActive(): boolean;
|
|
104
|
+
/** Cancel every running turn (used on shutdown). Returns true if any was. */
|
|
105
|
+
cancelActiveTurns(): boolean;
|
|
106
|
+
/** Release process-held resources (the SQLite handle). */
|
|
107
|
+
close(): void;
|
|
54
108
|
}
|
|
55
109
|
|
|
56
110
|
// Cross-site WebSocket hijacking (CSWSH) defense. CORS does not apply to the WS
|
|
@@ -72,20 +126,63 @@ function isAllowedWsOrigin(c: Context, allowedOrigins: string[]): boolean {
|
|
|
72
126
|
}
|
|
73
127
|
}
|
|
74
128
|
|
|
75
|
-
export function createApp(options: CreateAppOptions = {}) {
|
|
129
|
+
export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
130
|
+
// The edge: ambient environment becomes explicit configuration exactly once.
|
|
131
|
+
// options.dbPath folds into the config here, so the handle's `config` and
|
|
132
|
+
// the database actually opened can never disagree.
|
|
133
|
+
const resolved = options.config ?? resolveServerConfig();
|
|
134
|
+
// Checked against undefined, not truthiness: SQLite treats "" as a valid
|
|
135
|
+
// anonymous temporary database, and an explicit empty override must not
|
|
136
|
+
// silently fall back to the resolved path.
|
|
137
|
+
const config: ServerConfig =
|
|
138
|
+
options.dbPath !== undefined ? { ...resolved, dbPath: options.dbPath } : resolved;
|
|
139
|
+
const auth: AuthRuntime = { ...config.auth, host: config.host };
|
|
140
|
+
|
|
76
141
|
const app = new Hono();
|
|
77
|
-
const authMode = resolveAuthMode();
|
|
142
|
+
const authMode = resolveAuthMode(auth);
|
|
78
143
|
// Validate inside the factory, not the bin entry: every consumer of the app
|
|
79
144
|
// (a deployment bin, tests, another embedder) gets the same refuse-to-boot
|
|
80
145
|
// guarantee on an unsafe auth configuration.
|
|
81
|
-
assertAuthConfig(authMode);
|
|
82
|
-
assertPasskeyConfig();
|
|
146
|
+
assertAuthConfig(authMode, auth);
|
|
147
|
+
assertPasskeyConfig(config.webauthn);
|
|
148
|
+
// A missing (or unrecognized) agent backend refuses to boot HERE, not on the
|
|
149
|
+
// first turn — otherwise /api/health reports healthy while every turn is
|
|
150
|
+
// guaranteed to fail. Resolution only; the module still loads lazily.
|
|
151
|
+
// Skipped when the embedder injects its own registry.
|
|
152
|
+
if (!options.registry) assertBackendResolvable(config.agent);
|
|
83
153
|
|
|
84
|
-
|
|
85
|
-
|
|
154
|
+
// Per-instance state: the app's own database, the brain CLI wrapper, the
|
|
155
|
+
// backend registry, and the WebSocket host. No module-level singletons —
|
|
156
|
+
// two apps with different configuration coexist in one process.
|
|
157
|
+
const db = createUiDb(config.dbPath);
|
|
158
|
+
const brain = createBrainClient({ brainPath: config.brainPath });
|
|
159
|
+
const cron = createCronScheduler({ db, brain });
|
|
160
|
+
const registry =
|
|
161
|
+
options.registry ??
|
|
162
|
+
createBackendRegistry({
|
|
163
|
+
brainPath: config.brainPath,
|
|
164
|
+
agent: config.agent,
|
|
165
|
+
getHiddenModelIds: () => getHiddenModelIds(db),
|
|
166
|
+
});
|
|
167
|
+
const host = new WsHost({
|
|
168
|
+
registry,
|
|
169
|
+
catalog: createSessionCatalog(() => db),
|
|
86
170
|
...(options.appName ? { appName: options.appName } : {}),
|
|
87
171
|
...(options.turnTimeoutMs ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
|
|
172
|
+
maxConcurrentSessions: () => config.maxConcurrentSessions,
|
|
88
173
|
});
|
|
174
|
+
const wsUpgrade = createWsUpgrade(host);
|
|
175
|
+
const passkeyCtx: PasskeyContext = {
|
|
176
|
+
db,
|
|
177
|
+
webauthn: config.webauthn,
|
|
178
|
+
auth,
|
|
179
|
+
allowedOrigins: config.allowedOrigins,
|
|
180
|
+
};
|
|
181
|
+
const keyterms: KeytermSettings = {
|
|
182
|
+
brainPath: config.brainPath,
|
|
183
|
+
cacheDir: config.voice.cacheDir,
|
|
184
|
+
limit: config.voice.keytermLimit,
|
|
185
|
+
};
|
|
89
186
|
|
|
90
187
|
// Middleware
|
|
91
188
|
app.use("*", logger());
|
|
@@ -94,10 +191,7 @@ export function createApp(options: CreateAppOptions = {}) {
|
|
|
94
191
|
// different origin than the API. ALLOWED_ORIGINS is a comma-separated
|
|
95
192
|
// allowlist; empty/unset means same-origin (the default), so the CORS
|
|
96
193
|
// middleware is skipped entirely.
|
|
97
|
-
const allowedOrigins =
|
|
98
|
-
.split(",")
|
|
99
|
-
.map((origin) => origin.trim())
|
|
100
|
-
.filter(Boolean);
|
|
194
|
+
const allowedOrigins = config.allowedOrigins;
|
|
101
195
|
if (allowedOrigins.length > 0) {
|
|
102
196
|
app.use(
|
|
103
197
|
"/api/*",
|
|
@@ -118,27 +212,45 @@ export function createApp(options: CreateAppOptions = {}) {
|
|
|
118
212
|
// Not under /api, and not behind the guard: this is where a system share
|
|
119
213
|
// lands when no service worker was around to intercept it. See the route.
|
|
120
214
|
app.route("/", shareTargetFallbackRoutes);
|
|
121
|
-
app.route(
|
|
122
|
-
|
|
215
|
+
app.route(
|
|
216
|
+
"/api",
|
|
217
|
+
authRoutes(authMode, auth, {
|
|
218
|
+
passwordDisabled: (c) => passwordLoginDisabled(c, passkeyCtx),
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
app.route("/api", passkeyPublicRoutes(authMode, passkeyCtx));
|
|
123
222
|
|
|
124
223
|
// Auth guard for every other /api/* route. The probe below is intentionally
|
|
125
224
|
// behind it: an unauthenticated client gets 401 (password/proxy) or 403
|
|
126
225
|
// (tailscale) from /api/vpn-check and shows the login / VPN screen.
|
|
127
|
-
app.use("/api/*", authGuard(authMode));
|
|
226
|
+
app.use("/api/*", authGuard(authMode, auth));
|
|
128
227
|
app.get("/api/vpn-check", (c) => c.json({ vpn: true }));
|
|
129
228
|
// Passkey registration/management: after the guard, so a session is required
|
|
130
229
|
// by mount position (the public assertion routes are registered above).
|
|
131
|
-
app.route("/api", passkeyManagementRoutes(authMode));
|
|
132
|
-
app.route(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
230
|
+
app.route("/api", passkeyManagementRoutes(authMode, passkeyCtx));
|
|
231
|
+
app.route(
|
|
232
|
+
"/api",
|
|
233
|
+
createStatusRoutes({
|
|
234
|
+
sourceCommit: config.sourceCommit,
|
|
235
|
+
getCronStatus: () => cron.getCronStatus(),
|
|
236
|
+
isTurnActive: () => host.coordinator.isTurnActive(),
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
app.route(
|
|
240
|
+
"/api",
|
|
241
|
+
createBrainRoutes({ brain, brainPath: config.brainPath, keyterms })
|
|
242
|
+
);
|
|
243
|
+
app.route("/api", createSessionRoutes({ registry, db }));
|
|
244
|
+
app.route("/api", createVoiceRoutes({ voice: config.voice, keyterms }));
|
|
245
|
+
app.route("/api", createFilesRoutes({ brainRoot: config.brainPath }));
|
|
246
|
+
app.route(
|
|
247
|
+
"/api",
|
|
248
|
+
createShareRoutes({ brainRoot: config.brainPath, allowedOrigins })
|
|
249
|
+
);
|
|
138
250
|
app.route("/api", createRenderRoutes(options.renderer));
|
|
139
|
-
app.route("/api",
|
|
140
|
-
app.route("/api",
|
|
141
|
-
app.route("/api",
|
|
251
|
+
app.route("/api", createProviderRoutes({ registry }));
|
|
252
|
+
app.route("/api", createModelRoutes({ registry, db }));
|
|
253
|
+
app.route("/api", createGraphRoutes({ brainRoot: config.brainPath }));
|
|
142
254
|
|
|
143
255
|
// WebSocket endpoint. Browsers can't set headers on the WS handshake, so the
|
|
144
256
|
// upgrade authenticates via the session cookie (or IP/proxy header) INSIDE
|
|
@@ -147,7 +259,7 @@ export function createApp(options: CreateAppOptions = {}) {
|
|
|
147
259
|
if (!isAllowedWsOrigin(c, allowedOrigins)) {
|
|
148
260
|
return c.json({ error: "Cross-origin WebSocket rejected" }, 403);
|
|
149
261
|
}
|
|
150
|
-
if (!(await isWsAuthorized(c, authMode))) {
|
|
262
|
+
if (!(await isWsAuthorized(c, authMode, auth))) {
|
|
151
263
|
return c.json({ error: "Authentication required" }, 401);
|
|
152
264
|
}
|
|
153
265
|
return wsUpgrade(c, next);
|
|
@@ -164,5 +276,14 @@ export function createApp(options: CreateAppOptions = {}) {
|
|
|
164
276
|
return {
|
|
165
277
|
fetch: app.fetch,
|
|
166
278
|
websocket,
|
|
279
|
+
config,
|
|
280
|
+
authMode,
|
|
281
|
+
db,
|
|
282
|
+
wsHost: host,
|
|
283
|
+
isTurnActive: () => host.coordinator.isTurnActive(),
|
|
284
|
+
cancelActiveTurns: () => host.coordinator.cancelAll("Server shutting down"),
|
|
285
|
+
close: () => {
|
|
286
|
+
db.close();
|
|
287
|
+
},
|
|
167
288
|
};
|
|
168
289
|
}
|
package/src/brain/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
|
+
import { subprocessEnv } from "../config/env.js";
|
|
3
4
|
import type {
|
|
4
5
|
BrainSearchResult,
|
|
5
6
|
BrainSearchResponse,
|
|
@@ -8,179 +9,201 @@ import type {
|
|
|
8
9
|
BrainSyncResult,
|
|
9
10
|
} from "./types.js";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Wrapper around the brain CLI, bound to one brain repo. Constructed by
|
|
14
|
+
* `createApp()` (or an embedder) with the resolved brain path — no ambient
|
|
15
|
+
* environment, so two clients against different repos can coexist.
|
|
16
|
+
*/
|
|
17
|
+
export interface BrainClient {
|
|
18
|
+
/** argv prefix for invoking the brain CLI inside the repo. */
|
|
19
|
+
cliCommand(): string[];
|
|
20
|
+
search(
|
|
21
|
+
query: string,
|
|
22
|
+
opts?: { type?: string; tag?: string; limit?: number; mode?: string }
|
|
23
|
+
): Promise<BrainSearchResponse>;
|
|
24
|
+
briefing(): Promise<string>;
|
|
25
|
+
stats(): Promise<BrainStats>;
|
|
26
|
+
list(opts?: {
|
|
27
|
+
type?: string;
|
|
28
|
+
tag?: string;
|
|
29
|
+
status?: string;
|
|
30
|
+
relevance?: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
}): Promise<BrainDocument[]>;
|
|
33
|
+
read(path: string): Promise<string>;
|
|
34
|
+
sync(): Promise<BrainSyncResult>;
|
|
35
|
+
add(
|
|
36
|
+
content: string,
|
|
37
|
+
opts?: { type?: string; title?: string; tags?: string[] }
|
|
38
|
+
): Promise<void>;
|
|
39
|
+
index(opts?: { force?: boolean }): Promise<void>;
|
|
40
|
+
validate(): Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ExecResult {
|
|
44
|
+
stdout: string;
|
|
45
|
+
stderr: string;
|
|
46
|
+
exitCode: number;
|
|
47
|
+
}
|
|
12
48
|
|
|
13
49
|
/**
|
|
14
|
-
* argv prefix for invoking the brain CLI inside
|
|
50
|
+
* argv prefix for invoking the brain CLI inside `brainPath`.
|
|
15
51
|
*
|
|
16
52
|
* Two brain-repo layouts exist and both must work: a repo that depends on
|
|
17
53
|
* `@schlessera/brain` gets a real bin at `node_modules/.bin/brain`, while the
|
|
18
54
|
* legacy layout vendors `scripts/brain-cli.ts`. Prefer the packaged bin.
|
|
19
55
|
*
|
|
20
|
-
* Resolved per call (a bare `existsSync` stat) rather than at
|
|
56
|
+
* Resolved per call (a bare `existsSync` stat) rather than at construction: in
|
|
21
57
|
* the container the brain repo is cloned and `bun install`ed by entrypoint.sh,
|
|
22
58
|
* and a repo can gain the dependency without restarting this server. The same
|
|
23
59
|
* preference order is mirrored in `scripts/entrypoint.sh`, which links whichever
|
|
24
60
|
* it finds onto PATH for cron.
|
|
25
61
|
*/
|
|
26
|
-
export function brainCliCommand(): string[] {
|
|
27
|
-
const packaged = join(
|
|
62
|
+
export function brainCliCommand(brainPath: string): string[] {
|
|
63
|
+
const packaged = join(brainPath, "node_modules", ".bin", "brain");
|
|
28
64
|
if (existsSync(packaged)) return [packaged];
|
|
29
65
|
return ["bun", "scripts/brain-cli.ts"];
|
|
30
66
|
}
|
|
31
67
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
stderr: string;
|
|
35
|
-
exitCode: number;
|
|
36
|
-
}
|
|
68
|
+
export function createBrainClient(opts: { brainPath: string }): BrainClient {
|
|
69
|
+
const { brainPath } = opts;
|
|
37
70
|
|
|
38
|
-
async function execBrain(args: string[]): Promise<ExecResult> {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
env: {
|
|
44
|
-
...process.env,
|
|
71
|
+
async function execBrain(args: string[]): Promise<ExecResult> {
|
|
72
|
+
const proc = Bun.spawn([...brainCliCommand(brainPath), ...args], {
|
|
73
|
+
cwd: brainPath,
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
45
76
|
// Force JSON output when not a TTY
|
|
46
|
-
NO_COLOR: "1",
|
|
47
|
-
}
|
|
48
|
-
});
|
|
77
|
+
env: subprocessEnv({ NO_COLOR: "1" }),
|
|
78
|
+
});
|
|
49
79
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
80
|
+
const [stdout, stderr] = await Promise.all([
|
|
81
|
+
new Response(proc.stdout).text(),
|
|
82
|
+
new Response(proc.stderr).text(),
|
|
83
|
+
]);
|
|
84
|
+
const exitCode = await proc.exited;
|
|
55
85
|
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function parseJsonOutput<T>(result: ExecResult): T {
|
|
60
|
-
if (result.exitCode !== 0) {
|
|
61
|
-
throw new Error(
|
|
62
|
-
`brain CLI failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
|
|
63
|
-
);
|
|
86
|
+
return { stdout, stderr, exitCode };
|
|
64
87
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
|
|
89
|
+
function parseJsonOutput<T>(result: ExecResult): T {
|
|
90
|
+
if (result.exitCode !== 0) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`brain CLI failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(result.stdout);
|
|
97
|
+
} catch {
|
|
98
|
+
// Some commands output plain text, not JSON
|
|
99
|
+
return result.stdout as unknown as T;
|
|
100
|
+
}
|
|
70
101
|
}
|
|
71
|
-
}
|
|
72
102
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const args = ["search", query];
|
|
78
|
-
if (opts?.type) args.push("--type", opts.type);
|
|
79
|
-
if (opts?.tag) args.push("--tag", opts.tag);
|
|
80
|
-
if (opts?.limit) args.push("--limit", String(opts.limit));
|
|
81
|
-
if (opts?.mode) args.push("--mode", opts.mode);
|
|
82
|
-
|
|
83
|
-
const result = await execBrain(args);
|
|
84
|
-
const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
|
|
85
|
-
// brain returns {results, warnings} since 22e82e1; tolerate the old bare array
|
|
86
|
-
return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
|
|
87
|
-
}
|
|
103
|
+
return {
|
|
104
|
+
cliCommand() {
|
|
105
|
+
return brainCliCommand(brainPath);
|
|
106
|
+
},
|
|
88
107
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
async search(query, opts) {
|
|
109
|
+
const args = ["search", query];
|
|
110
|
+
if (opts?.type) args.push("--type", opts.type);
|
|
111
|
+
if (opts?.tag) args.push("--tag", opts.tag);
|
|
112
|
+
if (opts?.limit) args.push("--limit", String(opts.limit));
|
|
113
|
+
if (opts?.mode) args.push("--mode", opts.mode);
|
|
114
|
+
|
|
115
|
+
const result = await execBrain(args);
|
|
116
|
+
const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
|
|
117
|
+
// brain returns {results, warnings} since 22e82e1; tolerate the old bare array
|
|
118
|
+
return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
|
|
119
|
+
},
|
|
96
120
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
121
|
+
async briefing() {
|
|
122
|
+
const result = await execBrain(["briefing"]);
|
|
123
|
+
if (result.exitCode !== 0) {
|
|
124
|
+
throw new Error(`brain briefing failed: ${result.stderr}`);
|
|
125
|
+
}
|
|
126
|
+
return result.stdout;
|
|
127
|
+
},
|
|
101
128
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
relevance?: string;
|
|
107
|
-
limit?: number;
|
|
108
|
-
}): Promise<BrainDocument[]> {
|
|
109
|
-
const args = ["list"];
|
|
110
|
-
if (opts?.type) args.push("--type", opts.type);
|
|
111
|
-
if (opts?.tag) args.push("--tag", opts.tag);
|
|
112
|
-
if (opts?.status) args.push("--status", opts.status);
|
|
113
|
-
if (opts?.relevance) args.push("--relevance", opts.relevance);
|
|
114
|
-
if (opts?.limit) args.push("--limit", String(opts.limit));
|
|
115
|
-
|
|
116
|
-
const result = await execBrain(args);
|
|
117
|
-
return parseJsonOutput(result);
|
|
118
|
-
}
|
|
129
|
+
async stats() {
|
|
130
|
+
const result = await execBrain(["stats"]);
|
|
131
|
+
return parseJsonOutput(result);
|
|
132
|
+
},
|
|
119
133
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
async list(opts) {
|
|
135
|
+
const args = ["list"];
|
|
136
|
+
if (opts?.type) args.push("--type", opts.type);
|
|
137
|
+
if (opts?.tag) args.push("--tag", opts.tag);
|
|
138
|
+
if (opts?.status) args.push("--status", opts.status);
|
|
139
|
+
if (opts?.relevance) args.push("--relevance", opts.relevance);
|
|
140
|
+
if (opts?.limit) args.push("--limit", String(opts.limit));
|
|
127
141
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const result = await execBrain(["sync"]);
|
|
132
|
-
if (result.exitCode !== 0) {
|
|
133
|
-
return {
|
|
134
|
-
success: false,
|
|
135
|
-
commits: 0,
|
|
136
|
-
conflicts: 0,
|
|
137
|
-
message: result.stderr || result.stdout,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
try {
|
|
141
|
-
return parseJsonOutput(result);
|
|
142
|
-
} catch {
|
|
143
|
-
return {
|
|
144
|
-
success: true,
|
|
145
|
-
commits: 0,
|
|
146
|
-
conflicts: 0,
|
|
147
|
-
message: result.stdout,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
}
|
|
142
|
+
const result = await execBrain(args);
|
|
143
|
+
return parseJsonOutput(result);
|
|
144
|
+
},
|
|
151
145
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (opts?.tags) args.push("--tags", opts.tags.join(","));
|
|
160
|
-
|
|
161
|
-
const result = await execBrain(args);
|
|
162
|
-
if (result.exitCode !== 0) {
|
|
163
|
-
throw new Error(`brain add failed: ${result.stderr}`);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
146
|
+
async read(path) {
|
|
147
|
+
const result = await execBrain(["read", path]);
|
|
148
|
+
if (result.exitCode !== 0) {
|
|
149
|
+
throw new Error(`brain read failed: ${result.stderr}`);
|
|
150
|
+
}
|
|
151
|
+
return result.stdout;
|
|
152
|
+
},
|
|
166
153
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
154
|
+
async sync() {
|
|
155
|
+
// Note: brain sync spawns Claude Code internally via the /sync skill.
|
|
156
|
+
// For cron jobs, we use a simpler git-based sync instead.
|
|
157
|
+
const result = await execBrain(["sync"]);
|
|
158
|
+
if (result.exitCode !== 0) {
|
|
159
|
+
return {
|
|
160
|
+
success: false,
|
|
161
|
+
commits: 0,
|
|
162
|
+
conflicts: 0,
|
|
163
|
+
message: result.stderr || result.stdout,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return parseJsonOutput(result);
|
|
168
|
+
} catch {
|
|
169
|
+
return {
|
|
170
|
+
success: true,
|
|
171
|
+
commits: 0,
|
|
172
|
+
conflicts: 0,
|
|
173
|
+
message: result.stdout,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
},
|
|
173
177
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
async add(content, opts) {
|
|
179
|
+
const args = ["add", content];
|
|
180
|
+
if (opts?.type) args.push("--type", opts.type);
|
|
181
|
+
if (opts?.title) args.push("--title", opts.title);
|
|
182
|
+
if (opts?.tags) args.push("--tags", opts.tags.join(","));
|
|
179
183
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
const result = await execBrain(args);
|
|
185
|
+
if (result.exitCode !== 0) {
|
|
186
|
+
throw new Error(`brain add failed: ${result.stderr}`);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async index(opts) {
|
|
191
|
+
const args = ["index"];
|
|
192
|
+
// Full rebuild; indexing is incremental by default since brain 22e82e1.
|
|
193
|
+
if (opts?.force) args.push("--force");
|
|
194
|
+
|
|
195
|
+
const result = await execBrain(args);
|
|
196
|
+
if (result.exitCode !== 0) {
|
|
197
|
+
throw new Error(`brain index failed: ${result.stderr}`);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async validate() {
|
|
202
|
+
const result = await execBrain(["validate"]);
|
|
203
|
+
if (result.exitCode !== 0) {
|
|
204
|
+
throw new Error(`brain validate failed: ${result.stderr}`);
|
|
205
|
+
}
|
|
206
|
+
return result.stdout;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
186
209
|
}
|