@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,308 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import {
|
|
3
|
+
createClaudeBackend,
|
|
4
|
+
defineProfiles,
|
|
5
|
+
type InferenceProfile,
|
|
6
|
+
type InferenceProfileInput,
|
|
7
|
+
} from "@schlessera/brain-backend-claude";
|
|
8
|
+
import type { ProviderInfo } from "@schlessera/brain-ui-sdk";
|
|
9
|
+
import type {
|
|
10
|
+
AgentBackend,
|
|
11
|
+
BackendCapabilities,
|
|
12
|
+
} from "@schlessera/brain-ui-sdk/server";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Backend registry for the deployment. Claude is always available.
|
|
16
|
+
* `AGENT_BACKEND=pi` retains the legacy single-backend deployment mode when the
|
|
17
|
+
* optional pi package is installed.
|
|
18
|
+
*
|
|
19
|
+
* The old five hardcoded personal inference profiles are gone: the Claude
|
|
20
|
+
* backend ships the single "claude" default, and additional Anthropic-compatible
|
|
21
|
+
* endpoints are declared via the BRAIN_UI_CLAUDE_PROFILES env var (documented in
|
|
22
|
+
* .env.example) rather than in code.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const BRAIN_PATH =
|
|
26
|
+
process.env.BRAIN_PATH || `${process.env.HOME || "/root"}/brain`;
|
|
27
|
+
|
|
28
|
+
// The Claude Code native binary is not in node_modules — it is installed via
|
|
29
|
+
// the official curl-bash installer (~/.local/bin/claude, symlinked to
|
|
30
|
+
// /usr/local/bin/claude in the container). The Agent SDK can't always
|
|
31
|
+
// auto-discover it, so hand it the explicit path; override with CLAUDE_CODE_PATH.
|
|
32
|
+
const CLAUDE_CODE_PATH = process.env.CLAUDE_CODE_PATH || "/usr/local/bin/claude";
|
|
33
|
+
|
|
34
|
+
interface BackendRegistry {
|
|
35
|
+
backends: AgentBackend[];
|
|
36
|
+
byId: Map<string, AgentBackend>;
|
|
37
|
+
defaultBackendId: string;
|
|
38
|
+
profileOwners: Map<string, string>;
|
|
39
|
+
profiles: Map<string, ProviderInfo[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let cachedRegistry: Promise<BackendRegistry> | null = null;
|
|
43
|
+
|
|
44
|
+
async function createRegistry(
|
|
45
|
+
backends: AgentBackend[],
|
|
46
|
+
defaultBackendId: string
|
|
47
|
+
): Promise<BackendRegistry> {
|
|
48
|
+
if (backends.length === 0) {
|
|
49
|
+
throw new Error("Backend registry requires at least one backend.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const byId = new Map(backends.map((backend) => [backend.id, backend]));
|
|
53
|
+
const resolvedDefaultId = byId.has(defaultBackendId)
|
|
54
|
+
? defaultBackendId
|
|
55
|
+
: backends[0].id;
|
|
56
|
+
const ordered = [
|
|
57
|
+
byId.get(resolvedDefaultId)!,
|
|
58
|
+
...backends.filter((backend) => backend.id !== resolvedDefaultId),
|
|
59
|
+
];
|
|
60
|
+
const listedProfiles = await Promise.all(
|
|
61
|
+
ordered.map(async (backend) => ({
|
|
62
|
+
backend,
|
|
63
|
+
profiles: await backend.listProfiles(),
|
|
64
|
+
}))
|
|
65
|
+
);
|
|
66
|
+
const profileOwners = new Map<string, string>();
|
|
67
|
+
const profiles = new Map<string, ProviderInfo[]>();
|
|
68
|
+
|
|
69
|
+
for (const listed of listedProfiles) {
|
|
70
|
+
profiles.set(listed.backend.id, listed.profiles);
|
|
71
|
+
for (const profile of listed.profiles) {
|
|
72
|
+
const owner = profileOwners.get(profile.id);
|
|
73
|
+
if (owner && owner !== listed.backend.id) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Profile id collision across backends: "${profile.id}" is exposed by "${owner}" and "${listed.backend.id}".`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
profileOwners.set(profile.id, listed.backend.id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
backends: ordered,
|
|
84
|
+
byId,
|
|
85
|
+
defaultBackendId: resolvedDefaultId,
|
|
86
|
+
profileOwners,
|
|
87
|
+
profiles,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function buildRegistry(): Promise<BackendRegistry> {
|
|
92
|
+
const primary = (process.env.AGENT_BACKEND || "claude")
|
|
93
|
+
.trim()
|
|
94
|
+
.toLowerCase();
|
|
95
|
+
|
|
96
|
+
if (primary === "pi") {
|
|
97
|
+
const pi = buildPiBackend();
|
|
98
|
+
return createRegistry([pi], pi.id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const claude = createClaudeBackend({
|
|
102
|
+
brainPath: BRAIN_PATH,
|
|
103
|
+
claudeCodePath: CLAUDE_CODE_PATH,
|
|
104
|
+
profiles: loadClaudeProfiles(),
|
|
105
|
+
});
|
|
106
|
+
const backends = [claude];
|
|
107
|
+
|
|
108
|
+
// AGENT_BACKEND must name a configured backend. The in-process options are
|
|
109
|
+
// "claude" (default) and "pi" (handled above). An unrecognized value is a
|
|
110
|
+
// misconfiguration — fail loudly instead of silently coercing to claude and
|
|
111
|
+
// running on the wrong backend with nothing in the logs.
|
|
112
|
+
if (process.env.AGENT_BACKEND && !backends.some((b) => b.id === primary)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`AGENT_BACKEND="${primary}" does not match any configured backend ` +
|
|
115
|
+
`(expected "claude" or "pi").`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return createRegistry(backends, primary);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getRegistry(): Promise<BackendRegistry> {
|
|
122
|
+
if (!cachedRegistry) cachedRegistry = buildRegistry();
|
|
123
|
+
return cachedRegistry;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** All configured backends, with the default backend first. */
|
|
127
|
+
export async function getBackends(): Promise<AgentBackend[]> {
|
|
128
|
+
return (await getRegistry()).backends;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Find a configured backend by its stable id. */
|
|
132
|
+
export async function getBackendById(
|
|
133
|
+
id: string
|
|
134
|
+
): Promise<AgentBackend | undefined> {
|
|
135
|
+
return (await getRegistry()).byId.get(id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The default backend used for new and legacy sessions. */
|
|
139
|
+
export async function getDefaultBackend(): Promise<AgentBackend> {
|
|
140
|
+
const registry = await getRegistry();
|
|
141
|
+
return registry.byId.get(registry.defaultBackendId)!;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The stable id of the default backend. */
|
|
145
|
+
export async function getDefaultBackendId(): Promise<string> {
|
|
146
|
+
return (await getRegistry()).defaultBackendId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolve the backend that owns a globally unique profile id. */
|
|
150
|
+
export async function getBackendForProfile(
|
|
151
|
+
profileId: string
|
|
152
|
+
): Promise<AgentBackend | undefined> {
|
|
153
|
+
const registry = await getRegistry();
|
|
154
|
+
const backendId = registry.profileOwners.get(profileId);
|
|
155
|
+
return backendId ? registry.byId.get(backendId) : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Resolve a persisted backend id, falling back for legacy/unknown sessions. */
|
|
159
|
+
export async function getBackendForSession(
|
|
160
|
+
backendId: string | null | undefined
|
|
161
|
+
): Promise<AgentBackend> {
|
|
162
|
+
if (backendId) {
|
|
163
|
+
const backend = await getBackendById(backendId);
|
|
164
|
+
if (backend) return backend;
|
|
165
|
+
}
|
|
166
|
+
return getDefaultBackend();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** List every available profile, tagged with its owning backend id. */
|
|
170
|
+
export async function listAllProviders(): Promise<ProviderInfo[]> {
|
|
171
|
+
const registry = await getRegistry();
|
|
172
|
+
return registry.backends.flatMap((backend) =>
|
|
173
|
+
(registry.profiles.get(backend.id) ?? []).map((profile) => ({
|
|
174
|
+
...profile,
|
|
175
|
+
backendId: backend.id,
|
|
176
|
+
}))
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Per-backend capability metadata exposed by the providers route. */
|
|
181
|
+
export async function getBackendsInfo(): Promise<
|
|
182
|
+
Record<string, { id: string; capabilities: BackendCapabilities }>
|
|
183
|
+
> {
|
|
184
|
+
const backends = await getBackends();
|
|
185
|
+
return Object.fromEntries(
|
|
186
|
+
backends.map((backend) => [
|
|
187
|
+
backend.id,
|
|
188
|
+
{ id: backend.id, capabilities: backend.capabilities },
|
|
189
|
+
])
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @deprecated Use getDefaultBackend() or a routing-specific accessor. */
|
|
194
|
+
export async function getBackend(): Promise<AgentBackend> {
|
|
195
|
+
return getDefaultBackend();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Test seam: drop the cached registry so later access rebuilds from env. */
|
|
199
|
+
export function resetBackendForTests(): void {
|
|
200
|
+
cachedRegistry = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Test seam: install one fake backend as the complete registry. */
|
|
204
|
+
export function setBackendForTests(backend: AgentBackend): void {
|
|
205
|
+
cachedRegistry = createRegistry([backend], backend.id);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Test seam: install a complete fake registry with an explicit default. */
|
|
209
|
+
export function setBackendsForTests(
|
|
210
|
+
backends: AgentBackend[],
|
|
211
|
+
defaultBackendId = backends[0]?.id ?? ""
|
|
212
|
+
): void {
|
|
213
|
+
cachedRegistry = createRegistry(backends, defaultBackendId);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The built-in default profile's model. The Claude backend historically pinned
|
|
217
|
+
// the default to a specific model; after the SDK extraction the pin was lost and
|
|
218
|
+
// resumed default-profile sessions drifted to "whatever the CLI defaults to",
|
|
219
|
+
// silently changing model/behaviour/billing on every CLI bump. Re-pin it here
|
|
220
|
+
// (a committed default, overridable per deploy via BRAIN_UI_CLAUDE_DEFAULT_MODEL)
|
|
221
|
+
// so the default stays on a known model.
|
|
222
|
+
const BUILTIN_DEFAULT_MODEL =
|
|
223
|
+
process.env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6";
|
|
224
|
+
|
|
225
|
+
function builtinDefaultProfiles(): InferenceProfile[] {
|
|
226
|
+
return defineProfiles([
|
|
227
|
+
{
|
|
228
|
+
id: "claude",
|
|
229
|
+
label: "Claude",
|
|
230
|
+
vendor: "anthropic",
|
|
231
|
+
model: BUILTIN_DEFAULT_MODEL,
|
|
232
|
+
modelAliases: true,
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The Claude roster: the pinned built-in "claude" default first, plus any extra
|
|
239
|
+
* profiles from BRAIN_UI_CLAUDE_PROFILES (a JSON array of InferenceProfileInput
|
|
240
|
+
* {id,label,model?,baseUrl?,authTokenEnv?,apiKeyEnv?,modelAliases?}).
|
|
241
|
+
*
|
|
242
|
+
* A malformed or duplicate-id roster THROWS — caught at boot by the registry
|
|
243
|
+
* fail-fast — rather than silently degrading to default-only and rebilling
|
|
244
|
+
* every pinned session to the subscription with nothing in the logs.
|
|
245
|
+
*/
|
|
246
|
+
function loadClaudeProfiles(): InferenceProfile[] {
|
|
247
|
+
const base = builtinDefaultProfiles();
|
|
248
|
+
const raw = process.env.BRAIN_UI_CLAUDE_PROFILES?.trim();
|
|
249
|
+
if (!raw) return base;
|
|
250
|
+
|
|
251
|
+
let inputs: unknown;
|
|
252
|
+
try {
|
|
253
|
+
inputs = JSON.parse(raw);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`BRAIN_UI_CLAUDE_PROFILES is not valid JSON: ${
|
|
257
|
+
err instanceof Error ? err.message : String(err)
|
|
258
|
+
}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (!Array.isArray(inputs)) {
|
|
262
|
+
throw new Error("BRAIN_UI_CLAUDE_PROFILES must be a JSON array.");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Reject duplicate ids (including collisions with the built-in "claude"): a
|
|
266
|
+
// duplicate silently shadows and can resolve to the wrong credentials.
|
|
267
|
+
const seen = new Set(base.map((profile) => profile.id));
|
|
268
|
+
for (const input of inputs as InferenceProfileInput[]) {
|
|
269
|
+
if (!input || typeof input.id !== "string" || input.id.length === 0) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
"Each BRAIN_UI_CLAUDE_PROFILES entry needs a non-empty string id."
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (seen.has(input.id)) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Duplicate profile id in BRAIN_UI_CLAUDE_PROFILES: "${input.id}".`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
seen.add(input.id);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return [...base, ...defineProfiles(inputs as InferenceProfileInput[])];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Optional pi backend. @schlessera/brain-backend-pi is NOT a hard dependency — a
|
|
287
|
+
* deployment that never opts into it does not need it (or its pi SDK peer deps)
|
|
288
|
+
* installed. Loaded lazily so the server still boots without it; when requested
|
|
289
|
+
* but absent, fail with an actionable message.
|
|
290
|
+
*/
|
|
291
|
+
function buildPiBackend(): AgentBackend {
|
|
292
|
+
const require = createRequire(import.meta.url);
|
|
293
|
+
let mod: { createPiBackend?: (opts: { brainPath: string }) => AgentBackend };
|
|
294
|
+
try {
|
|
295
|
+
mod = require("@schlessera/brain-backend-pi");
|
|
296
|
+
} catch {
|
|
297
|
+
throw new Error(
|
|
298
|
+
'AGENT_BACKEND=pi but "@schlessera/brain-backend-pi" is not installed. ' +
|
|
299
|
+
"Add it (with its pi SDK dependencies) or set AGENT_BACKEND=claude."
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (typeof mod.createPiBackend !== "function") {
|
|
303
|
+
throw new Error(
|
|
304
|
+
'"@schlessera/brain-backend-pi" does not export createPiBackend.'
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return mod.createPiBackend({ brainPath: BRAIN_PATH });
|
|
308
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { logger } from "hono/logger";
|
|
4
|
+
import { cors } from "hono/cors";
|
|
5
|
+
import { serveStatic } from "hono/bun";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { healthRoutes, statusRoutes } from "./routes/health.js";
|
|
8
|
+
import { brainRoutes } from "./routes/brain.js";
|
|
9
|
+
import { sessionRoutes } from "./routes/sessions.js";
|
|
10
|
+
import { voiceRoutes } from "./routes/voice.js";
|
|
11
|
+
import { filesRoutes } from "./routes/files.js";
|
|
12
|
+
import { createRenderRoutes, type AppRenderer } from "./routes/render.js";
|
|
13
|
+
import { providerRoutes } from "./routes/providers.js";
|
|
14
|
+
import {
|
|
15
|
+
resolveAuthMode,
|
|
16
|
+
assertAuthConfig,
|
|
17
|
+
authGuard,
|
|
18
|
+
authRoutes,
|
|
19
|
+
isWsAuthorized,
|
|
20
|
+
} from "./middleware/auth.js";
|
|
21
|
+
import {
|
|
22
|
+
passkeyPublicRoutes,
|
|
23
|
+
passkeyManagementRoutes,
|
|
24
|
+
passwordLoginDisabled,
|
|
25
|
+
assertPasskeyConfig,
|
|
26
|
+
} from "./middleware/passkeys.js";
|
|
27
|
+
import { configureDb } from "./db/client.js";
|
|
28
|
+
import { configureWsHost, wsUpgrade, websocket } from "./ws/handler.js";
|
|
29
|
+
|
|
30
|
+
export type { AppRenderer };
|
|
31
|
+
|
|
32
|
+
export interface CreateAppOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Directory of a built SPA to serve at `/*` with an index.html fallback.
|
|
35
|
+
* The deployment shell decides whether (and what) to serve — the package
|
|
36
|
+
* has no client build of its own and no NODE_ENV heuristics.
|
|
37
|
+
*/
|
|
38
|
+
staticRoot?: string;
|
|
39
|
+
/** Display name used in connection/status copy. Default "Brain UI". */
|
|
40
|
+
appName?: string;
|
|
41
|
+
/** SQLite path override; falls back to DB_PATH, then ./brain-ui.db. */
|
|
42
|
+
dbPath?: string;
|
|
43
|
+
/**
|
|
44
|
+
* PNG/PDF renderer for `POST /api/render`. The deployment owns the actual
|
|
45
|
+
* renderer instance (e.g. @schlessera/brain-render-puppeteer) — without one
|
|
46
|
+
* the route answers 501.
|
|
47
|
+
*/
|
|
48
|
+
renderer?: AppRenderer;
|
|
49
|
+
/** Per-turn timeout in ms (default 10 minutes). */
|
|
50
|
+
turnTimeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cross-site WebSocket hijacking (CSWSH) defense. CORS does not apply to the WS
|
|
54
|
+
// handshake and browsers do not enforce same-origin on `new WebSocket()`, so
|
|
55
|
+
// rejecting a cross-site upgrade is entirely the server's job. A browser always
|
|
56
|
+
// sends `Origin` on a WS handshake; a non-browser client (no Origin) is allowed
|
|
57
|
+
// through here and still gated by isWsAuthorized. When ALLOWED_ORIGINS is set we
|
|
58
|
+
// use it; otherwise we require the Origin's host to match the request host
|
|
59
|
+
// (same-origin). Applied in every auth mode.
|
|
60
|
+
function isAllowedWsOrigin(c: Context, allowedOrigins: string[]): boolean {
|
|
61
|
+
const origin = c.req.header("origin");
|
|
62
|
+
if (!origin) return true;
|
|
63
|
+
if (allowedOrigins.length > 0) return allowedOrigins.includes(origin);
|
|
64
|
+
try {
|
|
65
|
+
const host = c.req.header("host");
|
|
66
|
+
return !!host && new URL(origin).host === host;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createApp(options: CreateAppOptions = {}) {
|
|
73
|
+
const app = new Hono();
|
|
74
|
+
const authMode = resolveAuthMode();
|
|
75
|
+
// Validate inside the factory, not the bin entry: every consumer of the app
|
|
76
|
+
// (a deployment bin, tests, another embedder) gets the same refuse-to-boot
|
|
77
|
+
// guarantee on an unsafe auth configuration.
|
|
78
|
+
assertAuthConfig(authMode);
|
|
79
|
+
assertPasskeyConfig();
|
|
80
|
+
|
|
81
|
+
if (options.dbPath) configureDb(options.dbPath);
|
|
82
|
+
configureWsHost({
|
|
83
|
+
...(options.appName ? { appName: options.appName } : {}),
|
|
84
|
+
...(options.turnTimeoutMs ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Middleware
|
|
88
|
+
app.use("*", logger());
|
|
89
|
+
|
|
90
|
+
// CORS is only needed for a SPLIT topology where the client is served from a
|
|
91
|
+
// different origin than the API. ALLOWED_ORIGINS is a comma-separated
|
|
92
|
+
// allowlist; empty/unset means same-origin (the default), so the CORS
|
|
93
|
+
// middleware is skipped entirely.
|
|
94
|
+
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "")
|
|
95
|
+
.split(",")
|
|
96
|
+
.map((origin) => origin.trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
if (allowedOrigins.length > 0) {
|
|
99
|
+
app.use(
|
|
100
|
+
"/api/*",
|
|
101
|
+
cors({
|
|
102
|
+
origin: allowedOrigins,
|
|
103
|
+
allowMethods: ["GET", "POST", "PUT", "DELETE"],
|
|
104
|
+
allowHeaders: ["Content-Type"],
|
|
105
|
+
credentials: true,
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Public routes, registered BEFORE the auth guard: only the minimal liveness
|
|
111
|
+
// probe and the login/logout routes are reachable without a session.
|
|
112
|
+
// /api/status is intentionally NOT here — it leaks the git SHA, cron errors,
|
|
113
|
+
// and a session oracle, so it lives behind the guard below.
|
|
114
|
+
app.route("/api", healthRoutes);
|
|
115
|
+
app.route("/api", authRoutes(authMode, { passwordDisabled: passwordLoginDisabled }));
|
|
116
|
+
app.route("/api", passkeyPublicRoutes(authMode));
|
|
117
|
+
|
|
118
|
+
// Auth guard for every other /api/* route. The probe below is intentionally
|
|
119
|
+
// behind it: an unauthenticated client gets 401 (password/proxy) or 403
|
|
120
|
+
// (tailscale) from /api/vpn-check and shows the login / VPN screen.
|
|
121
|
+
app.use("/api/*", authGuard(authMode));
|
|
122
|
+
app.get("/api/vpn-check", (c) => c.json({ vpn: true }));
|
|
123
|
+
// Passkey registration/management: after the guard, so a session is required
|
|
124
|
+
// by mount position (the public assertion routes are registered above).
|
|
125
|
+
app.route("/api", passkeyManagementRoutes(authMode));
|
|
126
|
+
app.route("/api", statusRoutes);
|
|
127
|
+
app.route("/api", brainRoutes);
|
|
128
|
+
app.route("/api", sessionRoutes);
|
|
129
|
+
app.route("/api", voiceRoutes);
|
|
130
|
+
app.route("/api", filesRoutes);
|
|
131
|
+
app.route("/api", createRenderRoutes(options.renderer));
|
|
132
|
+
app.route("/api", providerRoutes);
|
|
133
|
+
|
|
134
|
+
// WebSocket endpoint. Browsers can't set headers on the WS handshake, so the
|
|
135
|
+
// upgrade authenticates via the session cookie (or IP/proxy header) INSIDE
|
|
136
|
+
// the handler — no header-modifying middleware may sit on this route.
|
|
137
|
+
app.get("/ws", async (c, next) => {
|
|
138
|
+
if (!isAllowedWsOrigin(c, allowedOrigins)) {
|
|
139
|
+
return c.json({ error: "Cross-origin WebSocket rejected" }, 403);
|
|
140
|
+
}
|
|
141
|
+
if (!(await isWsAuthorized(c, authMode))) {
|
|
142
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
143
|
+
}
|
|
144
|
+
return wsUpgrade(c, next);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Static files: the deployment shell passes its built client explicitly.
|
|
148
|
+
if (options.staticRoot) {
|
|
149
|
+
const staticRoot = options.staticRoot;
|
|
150
|
+
app.use("/*", serveStatic({ root: staticRoot }));
|
|
151
|
+
// SPA fallback
|
|
152
|
+
app.get("*", serveStatic({ path: join(staticRoot, "index.html") }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
fetch: app.fetch,
|
|
157
|
+
websocket,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import type {
|
|
4
|
+
BrainSearchResult,
|
|
5
|
+
BrainSearchResponse,
|
|
6
|
+
BrainDocument,
|
|
7
|
+
BrainStats,
|
|
8
|
+
BrainSyncResult,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
const BRAIN_PATH = process.env.BRAIN_PATH || join(process.env.HOME || "/root", "brain");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* argv prefix for invoking the brain CLI inside BRAIN_PATH.
|
|
15
|
+
*
|
|
16
|
+
* Two brain-repo layouts exist and both must work: a repo that depends on
|
|
17
|
+
* `@schlessera/brain` gets a real bin at `node_modules/.bin/brain`, while the
|
|
18
|
+
* legacy layout vendors `scripts/brain-cli.ts`. Prefer the packaged bin.
|
|
19
|
+
*
|
|
20
|
+
* Resolved per call (a bare `existsSync` stat) rather than at import time: in
|
|
21
|
+
* the container the brain repo is cloned and `bun install`ed by entrypoint.sh,
|
|
22
|
+
* and a repo can gain the dependency without restarting this server. The same
|
|
23
|
+
* preference order is mirrored in `scripts/entrypoint.sh`, which links whichever
|
|
24
|
+
* it finds onto PATH for cron.
|
|
25
|
+
*/
|
|
26
|
+
export function brainCliCommand(): string[] {
|
|
27
|
+
const packaged = join(BRAIN_PATH, "node_modules", ".bin", "brain");
|
|
28
|
+
if (existsSync(packaged)) return [packaged];
|
|
29
|
+
return ["bun", "scripts/brain-cli.ts"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ExecResult {
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
exitCode: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function execBrain(args: string[]): Promise<ExecResult> {
|
|
39
|
+
const proc = Bun.spawn([...brainCliCommand(), ...args], {
|
|
40
|
+
cwd: BRAIN_PATH,
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
// Force JSON output when not a TTY
|
|
46
|
+
NO_COLOR: "1",
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const [stdout, stderr] = await Promise.all([
|
|
51
|
+
new Response(proc.stdout).text(),
|
|
52
|
+
new Response(proc.stderr).text(),
|
|
53
|
+
]);
|
|
54
|
+
const exitCode = await proc.exited;
|
|
55
|
+
|
|
56
|
+
return { stdout, stderr, exitCode };
|
|
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
|
+
);
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(result.stdout);
|
|
67
|
+
} catch {
|
|
68
|
+
// Some commands output plain text, not JSON
|
|
69
|
+
return result.stdout as unknown as T;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function brainSearch(
|
|
74
|
+
query: string,
|
|
75
|
+
opts?: { type?: string; tag?: string; limit?: number; mode?: string }
|
|
76
|
+
): Promise<BrainSearchResponse> {
|
|
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
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function brainBriefing(): Promise<string> {
|
|
90
|
+
const result = await execBrain(["briefing"]);
|
|
91
|
+
if (result.exitCode !== 0) {
|
|
92
|
+
throw new Error(`brain briefing failed: ${result.stderr}`);
|
|
93
|
+
}
|
|
94
|
+
return result.stdout;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function brainStats(): Promise<BrainStats> {
|
|
98
|
+
const result = await execBrain(["stats"]);
|
|
99
|
+
return parseJsonOutput(result);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function brainList(opts?: {
|
|
103
|
+
type?: string;
|
|
104
|
+
tag?: string;
|
|
105
|
+
status?: string;
|
|
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
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function brainRead(path: string): Promise<string> {
|
|
121
|
+
const result = await execBrain(["read", path]);
|
|
122
|
+
if (result.exitCode !== 0) {
|
|
123
|
+
throw new Error(`brain read failed: ${result.stderr}`);
|
|
124
|
+
}
|
|
125
|
+
return result.stdout;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function brainSync(): Promise<BrainSyncResult> {
|
|
129
|
+
// Note: brain sync spawns Claude Code internally via the /sync skill.
|
|
130
|
+
// For cron jobs, we use a simpler git-based sync instead.
|
|
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
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function brainAdd(
|
|
153
|
+
content: string,
|
|
154
|
+
opts?: { type?: string; title?: string; tags?: string[] }
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const args = ["add", content];
|
|
157
|
+
if (opts?.type) args.push("--type", opts.type);
|
|
158
|
+
if (opts?.title) args.push("--title", opts.title);
|
|
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
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function brainIndex(opts?: {
|
|
168
|
+
/** Full rebuild; indexing is incremental by default since brain 22e82e1. */
|
|
169
|
+
force?: boolean;
|
|
170
|
+
}): Promise<void> {
|
|
171
|
+
const args = ["index"];
|
|
172
|
+
if (opts?.force) args.push("--force");
|
|
173
|
+
|
|
174
|
+
const result = await execBrain(args);
|
|
175
|
+
if (result.exitCode !== 0) {
|
|
176
|
+
throw new Error(`brain index failed: ${result.stderr}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function brainValidate(): Promise<string> {
|
|
181
|
+
const result = await execBrain(["validate"]);
|
|
182
|
+
if (result.exitCode !== 0) {
|
|
183
|
+
throw new Error(`brain validate failed: ${result.stderr}`);
|
|
184
|
+
}
|
|
185
|
+
return result.stdout;
|
|
186
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface BrainSearchResult {
|
|
2
|
+
path: string;
|
|
3
|
+
title: string;
|
|
4
|
+
type: string;
|
|
5
|
+
relevance: string;
|
|
6
|
+
score: number;
|
|
7
|
+
snippet: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BrainSearchResponse {
|
|
11
|
+
results: BrainSearchResult[];
|
|
12
|
+
/** Degraded-mode notices from the CLI, e.g. vector search unavailable so results are FTS-only. */
|
|
13
|
+
warnings: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BrainDocument {
|
|
17
|
+
path: string;
|
|
18
|
+
title: string;
|
|
19
|
+
type: string;
|
|
20
|
+
status: string;
|
|
21
|
+
relevance: string;
|
|
22
|
+
tags: string[];
|
|
23
|
+
created: string;
|
|
24
|
+
updated: string;
|
|
25
|
+
summary?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BrainStats {
|
|
29
|
+
documents: number;
|
|
30
|
+
byType: Record<string, number>;
|
|
31
|
+
byStatus: Record<string, number>;
|
|
32
|
+
byRelevance: Record<string, number>;
|
|
33
|
+
tags: number;
|
|
34
|
+
links: number;
|
|
35
|
+
brokenLinks: number;
|
|
36
|
+
chunks: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BrainSyncResult {
|
|
40
|
+
success: boolean;
|
|
41
|
+
commits: number;
|
|
42
|
+
conflicts: number;
|
|
43
|
+
message: string;
|
|
44
|
+
}
|