@schlessera/brain-ui-server 0.13.1 → 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/routes/graph.ts
CHANGED
|
@@ -49,8 +49,10 @@ function invalid(param: string) {
|
|
|
49
49
|
return { error: "invalid_param", param } as const;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export
|
|
53
|
-
|
|
52
|
+
export function createGraphRoutes(deps: { brainRoot: string }): Hono {
|
|
53
|
+
const { brainRoot } = deps;
|
|
54
|
+
return new Hono()
|
|
55
|
+
.get("/graph/meta", (c) => c.json(getGraphMeta({ brainPath: brainRoot })))
|
|
54
56
|
|
|
55
57
|
.get("/graph/clusters", (c) => {
|
|
56
58
|
const rawCommunity = c.req.query("community");
|
|
@@ -61,7 +63,7 @@ export const graphRoutes = new Hono()
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
try {
|
|
64
|
-
return c.json(getClusters({ community, includeIsolates: c.req.query("isolates") === "1" }));
|
|
66
|
+
return c.json(getClusters({ brainPath: brainRoot, community, includeIsolates: c.req.query("isolates") === "1" }));
|
|
65
67
|
} catch (err) {
|
|
66
68
|
const { body, status } = errorResponse(err);
|
|
67
69
|
return c.json(body, status);
|
|
@@ -77,7 +79,7 @@ export const graphRoutes = new Hono()
|
|
|
77
79
|
if (direction === null) return c.json(invalid("direction"), 400);
|
|
78
80
|
|
|
79
81
|
try {
|
|
80
|
-
return c.json(getNeighborhood({ center, depth, direction }));
|
|
82
|
+
return c.json(getNeighborhood({ brainPath: brainRoot, center, depth, direction }));
|
|
81
83
|
} catch (err) {
|
|
82
84
|
const { body, status } = errorResponse(err);
|
|
83
85
|
return c.json(body, status);
|
|
@@ -92,7 +94,7 @@ export const graphRoutes = new Hono()
|
|
|
92
94
|
if (maxDepth === null) return c.json(invalid("maxDepth"), 400);
|
|
93
95
|
|
|
94
96
|
try {
|
|
95
|
-
return c.json(getDiscovery({ root, direction, maxDepth }));
|
|
97
|
+
return c.json(getDiscovery({ brainPath: brainRoot, root, direction, maxDepth }));
|
|
96
98
|
} catch (err) {
|
|
97
99
|
const { body, status } = errorResponse(err);
|
|
98
100
|
return c.json(body, status);
|
|
@@ -104,9 +106,10 @@ export const graphRoutes = new Hono()
|
|
|
104
106
|
if (staleDays === null) return c.json(invalid("staleDays"), 400);
|
|
105
107
|
|
|
106
108
|
try {
|
|
107
|
-
return c.json(getMaintenance({ staleDays }));
|
|
109
|
+
return c.json(getMaintenance({ brainPath: brainRoot, staleDays }));
|
|
108
110
|
} catch (err) {
|
|
109
111
|
const { body, status } = errorResponse(err);
|
|
110
112
|
return c.json(body, status);
|
|
111
113
|
}
|
|
112
114
|
});
|
|
115
|
+
}
|
package/src/routes/health.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import { getCronStatus } from "../cron/scheduler.js";
|
|
3
|
-
import { isTurnActive } from "../ws/handler.js";
|
|
4
2
|
|
|
5
3
|
const startTime = Date.now();
|
|
6
4
|
|
|
@@ -15,15 +13,24 @@ export const healthRoutes = new Hono().get("/health", (c) => {
|
|
|
15
13
|
});
|
|
16
14
|
});
|
|
17
15
|
|
|
16
|
+
export interface StatusDeps {
|
|
17
|
+
/** Git SHA baked at build time (SOURCE_COMMIT), "dev" when unset. */
|
|
18
|
+
sourceCommit: string;
|
|
19
|
+
getCronStatus(): unknown;
|
|
20
|
+
isTurnActive(): boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
// Operational status. Registered BEHIND the auth guard: it exposes the git SHA,
|
|
19
24
|
// cron job errors (raw stderr with filesystem paths), and whether a turn is
|
|
20
25
|
// active — none of which should be readable unauthenticated.
|
|
21
|
-
export
|
|
22
|
-
return
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
export function createStatusRoutes(deps: StatusDeps): Hono {
|
|
27
|
+
return new Hono().get("/status", (c) => {
|
|
28
|
+
return c.json({
|
|
29
|
+
healthy: true,
|
|
30
|
+
uptime: Date.now() - startTime,
|
|
31
|
+
version: deps.sourceCommit,
|
|
32
|
+
cronJobs: deps.getCronStatus(),
|
|
33
|
+
activeSession: deps.isTurnActive(),
|
|
34
|
+
});
|
|
28
35
|
});
|
|
29
|
-
}
|
|
36
|
+
}
|
package/src/routes/models.ts
CHANGED
|
@@ -6,22 +6,25 @@
|
|
|
6
6
|
// was last refreshed and why it might be stale.
|
|
7
7
|
|
|
8
8
|
import { Hono } from "hono";
|
|
9
|
+
import type { Database } from "bun:sqlite";
|
|
9
10
|
import type {
|
|
10
11
|
ModelCatalogEntry,
|
|
11
12
|
ModelCatalogResponse,
|
|
12
13
|
} from "@schlessera/brain-ui-sdk";
|
|
13
|
-
import {
|
|
14
|
-
getModelSource,
|
|
15
|
-
invalidateProfiles,
|
|
16
|
-
listAllProviders,
|
|
17
|
-
} from "../agent/backend.js";
|
|
14
|
+
import type { BackendRegistry } from "../agent/backend.js";
|
|
18
15
|
import { getHiddenModelIds, setHiddenModelIds } from "../db/settings.js";
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
export function createModelRoutes(deps: {
|
|
18
|
+
registry: BackendRegistry;
|
|
19
|
+
db: Database;
|
|
20
|
+
}): Hono {
|
|
21
|
+
const { registry, db } = deps;
|
|
22
|
+
|
|
23
|
+
async function buildCatalog(): Promise<ModelCatalogResponse> {
|
|
24
|
+
const source = await registry.getModelSource();
|
|
25
|
+
const hidden = new Set(getHiddenModelIds(db));
|
|
23
26
|
const models: ModelCatalogEntry[] = (
|
|
24
|
-
await listAllProviders({ includeHidden: true })
|
|
27
|
+
await registry.listAllProviders({ includeHidden: true })
|
|
25
28
|
).map((profile) => ({ ...profile, hidden: hidden.has(profile.id) }));
|
|
26
29
|
|
|
27
30
|
const state = source?.state();
|
|
@@ -36,11 +39,11 @@ async function buildCatalog(): Promise<ModelCatalogResponse> {
|
|
|
36
39
|
};
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
return new Hono()
|
|
40
43
|
.get("/models", async (c) => {
|
|
41
44
|
// Serves the cached roster immediately and refreshes behind the response
|
|
42
45
|
// when stale; only a cold start (nothing cached) waits on the network.
|
|
43
|
-
const source = await getModelSource();
|
|
46
|
+
const source = await registry.getModelSource();
|
|
44
47
|
await source?.ensureFresh();
|
|
45
48
|
return c.json(await buildCatalog());
|
|
46
49
|
})
|
|
@@ -55,14 +58,14 @@ export const modelRoutes = new Hono()
|
|
|
55
58
|
return c.json({ error: "hidden must be an array of profile ids" }, 400);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
setHiddenModelIds(hidden as string[]);
|
|
61
|
+
setHiddenModelIds(db, hidden as string[]);
|
|
59
62
|
// The picker reads through a memo — drop it so the change is immediate.
|
|
60
|
-
invalidateProfiles();
|
|
63
|
+
registry.invalidateProfiles();
|
|
61
64
|
return c.json(await buildCatalog());
|
|
62
65
|
})
|
|
63
66
|
|
|
64
67
|
.post("/models/refresh", async (c) => {
|
|
65
|
-
const source = await getModelSource();
|
|
68
|
+
const source = await registry.getModelSource();
|
|
66
69
|
if (!source) {
|
|
67
70
|
return c.json(
|
|
68
71
|
{ error: "Model discovery is not available on this backend" },
|
|
@@ -80,6 +83,7 @@ export const modelRoutes = new Hono()
|
|
|
80
83
|
}`
|
|
81
84
|
);
|
|
82
85
|
}
|
|
83
|
-
invalidateProfiles();
|
|
86
|
+
registry.invalidateProfiles();
|
|
84
87
|
return c.json(await buildCatalog());
|
|
85
88
|
});
|
|
89
|
+
}
|
package/src/routes/providers.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import {
|
|
3
|
-
getBackendsInfo,
|
|
4
|
-
getModelSource,
|
|
5
|
-
listAllProviders,
|
|
6
|
-
} from "../agent/backend.js";
|
|
2
|
+
import type { BackendRegistry } from "../agent/backend.js";
|
|
7
3
|
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
export function createProviderRoutes(deps: { registry: BackendRegistry }): Hono {
|
|
5
|
+
const { registry } = deps;
|
|
6
|
+
return new Hono().get("/providers", async (c) => {
|
|
7
|
+
// Keeps the roster current without a restart: serves the cached list and
|
|
8
|
+
// refreshes behind the response when it has gone stale. Only a cold start
|
|
9
|
+
// (nothing cached yet) waits on the Models API.
|
|
10
|
+
const source = await registry.getModelSource();
|
|
11
|
+
await source?.ensureFresh();
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
return c.json({
|
|
14
|
+
providers: await registry.listAllProviders(),
|
|
15
|
+
backends: await registry.getBackendsInfo(),
|
|
16
|
+
});
|
|
18
17
|
});
|
|
19
|
-
}
|
|
18
|
+
}
|
package/src/routes/sessions.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
3
|
+
import type { BackendRegistry } from "../agent/backend.js";
|
|
4
4
|
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
export function createSessionRoutes(deps: {
|
|
6
|
+
registry: BackendRegistry;
|
|
7
|
+
db: Database;
|
|
8
|
+
}): Hono {
|
|
9
|
+
const { registry, db } = deps;
|
|
10
|
+
|
|
11
|
+
function getStoredBackendId(sessionId: string): string | null {
|
|
12
|
+
const row = db
|
|
13
|
+
.query("SELECT backend_id AS backendId FROM sessions WHERE id = ?")
|
|
14
|
+
.get(sessionId) as { backendId: string | null } | null;
|
|
15
|
+
return row?.backendId ?? null;
|
|
16
|
+
}
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
return new Hono()
|
|
13
19
|
.get("/sessions", async (c) => {
|
|
14
20
|
try {
|
|
15
|
-
const backends = await getBackends();
|
|
21
|
+
const backends = await registry.getBackends();
|
|
16
22
|
const sessions = (
|
|
17
23
|
await Promise.all(
|
|
18
24
|
backends.map(async (backend) =>
|
|
@@ -37,7 +43,7 @@ export const sessionRoutes = new Hono()
|
|
|
37
43
|
.get("/sessions/:id", async (c) => {
|
|
38
44
|
const id = c.req.param("id");
|
|
39
45
|
try {
|
|
40
|
-
const backend = await getBackendForSession(getStoredBackendId(id));
|
|
46
|
+
const backend = await registry.getBackendForSession(getStoredBackendId(id));
|
|
41
47
|
const messages = await backend.getHistory(id);
|
|
42
48
|
return c.json({ id, messages });
|
|
43
49
|
} catch (err) {
|
|
@@ -47,3 +53,4 @@ export const sessionRoutes = new Hono()
|
|
|
47
53
|
);
|
|
48
54
|
}
|
|
49
55
|
});
|
|
56
|
+
}
|
package/src/routes/share.ts
CHANGED
|
@@ -82,39 +82,49 @@ export const shareTargetFallbackRoutes = new Hono().post("/share-target", (c) =>
|
|
|
82
82
|
c.redirect("/?share_error=no_worker", 303)
|
|
83
83
|
);
|
|
84
84
|
|
|
85
|
-
/**
|
|
86
|
-
* In-flight intakes. Each one holds its whole payload in memory while the
|
|
87
|
-
* multipart parser runs, so without a bound the per-share cap multiplies by
|
|
88
|
-
* however many clients ask at once — on a box that also runs headless Chrome
|
|
89
|
-
* for the renderer.
|
|
90
|
-
*/
|
|
91
|
-
let inFlight = 0;
|
|
92
|
-
|
|
93
|
-
/** At most one sweep per interval: the intake path should not stat the inbox on every upload. */
|
|
94
|
-
let lastPrune = 0;
|
|
95
85
|
const PRUNE_INTERVAL_MS = 10 * 60 * 1000;
|
|
96
86
|
|
|
97
|
-
function maybePrune(): void {
|
|
98
|
-
const now = Date.now();
|
|
99
|
-
if (now - lastPrune < PRUNE_INTERVAL_MS) return;
|
|
100
|
-
lastPrune = now;
|
|
101
|
-
// Deliberately not awaited: pruning is housekeeping, and the client is
|
|
102
|
-
// waiting on the staging result, not on it.
|
|
103
|
-
void pruneShareStaging().catch((err) => {
|
|
104
|
-
console.error("[share] prune failed:", err);
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
87
|
function firstString(form: FormData, name: string): string | undefined {
|
|
109
88
|
const value = form.get(name);
|
|
110
89
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
111
90
|
}
|
|
112
91
|
|
|
113
|
-
export
|
|
92
|
+
export interface ShareRoutesDeps {
|
|
93
|
+
brainRoot: string;
|
|
94
|
+
/** ALLOWED_ORIGINS — the same-origin check's split-topology allowlist. */
|
|
95
|
+
allowedOrigins: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createShareRoutes(deps: ShareRoutesDeps): Hono {
|
|
99
|
+
const { brainRoot, allowedOrigins } = deps;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* In-flight intakes. Each one holds its whole payload in memory while the
|
|
103
|
+
* multipart parser runs, so without a bound the per-share cap multiplies by
|
|
104
|
+
* however many clients ask at once — on a box that also runs headless Chrome
|
|
105
|
+
* for the renderer.
|
|
106
|
+
*/
|
|
107
|
+
let inFlight = 0;
|
|
108
|
+
|
|
109
|
+
/** At most one sweep per interval: the intake path should not stat the inbox on every upload. */
|
|
110
|
+
let lastPrune = 0;
|
|
111
|
+
|
|
112
|
+
function maybePrune(): void {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
if (now - lastPrune < PRUNE_INTERVAL_MS) return;
|
|
115
|
+
lastPrune = now;
|
|
116
|
+
// Deliberately not awaited: pruning is housekeeping, and the client is
|
|
117
|
+
// waiting on the staging result, not on it.
|
|
118
|
+
void pruneShareStaging(brainRoot).catch((err) => {
|
|
119
|
+
console.error("[share] prune failed:", err);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return new Hono().post("/share", async (c) => {
|
|
114
124
|
// See middleware/origin.ts: a multipart POST is a CORS-simple request, so it
|
|
115
125
|
// reaches this route with no preflight, and in tailscale mode the credential
|
|
116
126
|
// is the source IP. This route is only ever called by the app itself.
|
|
117
|
-
if (!isSameOriginRequest(c)) {
|
|
127
|
+
if (!isSameOriginRequest(c, allowedOrigins)) {
|
|
118
128
|
return c.json({ error: "cross_origin_rejected" }, 403);
|
|
119
129
|
}
|
|
120
130
|
|
|
@@ -154,7 +164,7 @@ export const shareRoutes = new Hono().post("/share", async (c) => {
|
|
|
154
164
|
|
|
155
165
|
inFlight += 1;
|
|
156
166
|
try {
|
|
157
|
-
const result = await stageShare({
|
|
167
|
+
const result = await stageShare(brainRoot, {
|
|
158
168
|
title: firstString(form, "title"),
|
|
159
169
|
text: firstString(form, "text"),
|
|
160
170
|
url: firstString(form, "url"),
|
|
@@ -176,3 +186,4 @@ export const shareRoutes = new Hono().post("/share", async (c) => {
|
|
|
176
186
|
inFlight -= 1;
|
|
177
187
|
}
|
|
178
188
|
});
|
|
189
|
+
}
|
package/src/routes/voice.ts
CHANGED
|
@@ -4,77 +4,87 @@ import type {
|
|
|
4
4
|
VoiceTokenResponse,
|
|
5
5
|
VoiceSessionResponse,
|
|
6
6
|
} from "@schlessera/brain-ui-sdk/protocol";
|
|
7
|
+
import type { VoiceConfig } from "../config/env.js";
|
|
7
8
|
import { mintDeepgramToken } from "../voice/deepgram-token.js";
|
|
8
|
-
import { getKeyterms } from "../voice/keyterm-builder.js";
|
|
9
|
+
import { getKeyterms, type KeytermSettings } from "../voice/keyterm-builder.js";
|
|
9
10
|
import { pickSpeechProvider } from "../voice/speech-providers.js";
|
|
10
11
|
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// Only fetch domain keyterms when the provider can use them.
|
|
16
|
-
const keyterms = provider.capabilities.keyterms
|
|
17
|
-
? getKeyterms(false).keyterms
|
|
18
|
-
: [];
|
|
19
|
-
const session = await provider.createSession({ keyterms });
|
|
20
|
-
const body: VoiceSessionResponse = {
|
|
21
|
-
providerId: provider.id,
|
|
22
|
-
url: session.url,
|
|
23
|
-
...(session.token !== undefined ? { token: session.token } : {}),
|
|
24
|
-
...(session.params !== undefined ? { params: session.params } : {}),
|
|
25
|
-
expiresAt: session.expiresAt,
|
|
26
|
-
capabilities: provider.capabilities,
|
|
27
|
-
};
|
|
28
|
-
return c.json(body);
|
|
29
|
-
} catch (err) {
|
|
30
|
-
return c.json(
|
|
31
|
-
{ error: err instanceof Error ? err.message : "Voice session failed" },
|
|
32
|
-
500
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
})
|
|
12
|
+
export interface VoiceRoutesDeps {
|
|
13
|
+
voice: VoiceConfig;
|
|
14
|
+
keyterms: KeytermSettings;
|
|
15
|
+
}
|
|
36
16
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.post("/voice/token", async (c) => {
|
|
40
|
-
try {
|
|
41
|
-
const { token, expiresAt } = await mintDeepgramToken(60);
|
|
42
|
-
const body: VoiceTokenResponse = { token, expiresAt };
|
|
43
|
-
return c.json(body);
|
|
44
|
-
} catch (err) {
|
|
45
|
-
return c.json(
|
|
46
|
-
{ error: err instanceof Error ? err.message : "Token mint failed" },
|
|
47
|
-
500
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
})
|
|
17
|
+
export function createVoiceRoutes(deps: VoiceRoutesDeps): Hono {
|
|
18
|
+
const { voice, keyterms } = deps;
|
|
51
19
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
20
|
+
return new Hono()
|
|
21
|
+
.post("/voice/session", async (c) => {
|
|
22
|
+
try {
|
|
23
|
+
const provider = pickSpeechProvider(voice);
|
|
24
|
+
// Only fetch domain keyterms when the provider can use them.
|
|
25
|
+
const terms = provider.capabilities.keyterms
|
|
26
|
+
? getKeyterms(keyterms, false).keyterms
|
|
27
|
+
: [];
|
|
28
|
+
const session = await provider.createSession({ keyterms: terms });
|
|
29
|
+
const body: VoiceSessionResponse = {
|
|
30
|
+
providerId: provider.id,
|
|
31
|
+
url: session.url,
|
|
32
|
+
...(session.token !== undefined ? { token: session.token } : {}),
|
|
33
|
+
...(session.params !== undefined ? { params: session.params } : {}),
|
|
34
|
+
expiresAt: session.expiresAt,
|
|
35
|
+
capabilities: provider.capabilities,
|
|
36
|
+
};
|
|
37
|
+
return c.json(body);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return c.json(
|
|
40
|
+
{ error: err instanceof Error ? err.message : "Voice session failed" },
|
|
41
|
+
500
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
})
|
|
69
45
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
46
|
+
// Deprecated alias, kept for client transition. Returns the old token shape
|
|
47
|
+
// by minting a Deepgram token directly; new clients use /voice/session.
|
|
48
|
+
.post("/voice/token", async (c) => {
|
|
49
|
+
try {
|
|
50
|
+
const { token, expiresAt } = await mintDeepgramToken(voice.deepgramApiKey, 60);
|
|
51
|
+
const body: VoiceTokenResponse = { token, expiresAt };
|
|
52
|
+
return c.json(body);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return c.json(
|
|
55
|
+
{ error: err instanceof Error ? err.message : "Token mint failed" },
|
|
56
|
+
500
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
.get("/voice/keyterms", async (c) => {
|
|
62
|
+
const force = c.req.query("rebuild") === "1";
|
|
63
|
+
try {
|
|
64
|
+
const cache = getKeyterms(keyterms, force);
|
|
65
|
+
const body: VoiceKeytermsResponse = {
|
|
66
|
+
keyterms: cache.keyterms,
|
|
67
|
+
generatedAt: cache.generatedAt,
|
|
68
|
+
count: cache.count,
|
|
69
|
+
};
|
|
70
|
+
return c.json(body);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return c.json(
|
|
73
|
+
{ error: err instanceof Error ? err.message : "Keyterms failed" },
|
|
74
|
+
500
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
.get("/voice/overrides", async (c) => {
|
|
80
|
+
try {
|
|
81
|
+
const cache = getKeyterms(keyterms, false);
|
|
82
|
+
return c.json({ overrides: cache.overrides });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return c.json(
|
|
85
|
+
{ error: err instanceof Error ? err.message : "Overrides failed" },
|
|
86
|
+
500
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
package/src/share/staging.ts
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
type ShareIntakeResult,
|
|
13
13
|
type ShareStagingManifest,
|
|
14
14
|
} from "@schlessera/brain-ui-sdk/protocol";
|
|
15
|
-
import { getBrainRoot } from "../files/walker.js";
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Staging for an incoming system share.
|
|
@@ -123,9 +122,9 @@ export interface ShareInput {
|
|
|
123
122
|
files: File[];
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
/** Absolute staging root
|
|
127
|
-
export function shareStagingRoot(): string {
|
|
128
|
-
return join(
|
|
125
|
+
/** Absolute staging root inside the given brain repo. */
|
|
126
|
+
export function shareStagingRoot(brainPath: string): string {
|
|
127
|
+
return join(brainPath, SHARE_STAGING_DIR);
|
|
129
128
|
}
|
|
130
129
|
|
|
131
130
|
/**
|
|
@@ -270,7 +269,10 @@ async function countStaged(root: string): Promise<number> {
|
|
|
270
269
|
* observe a share that is missing files or a manifest — not even if the process
|
|
271
270
|
* is killed mid-write, which `try/catch` cleanup cannot cover.
|
|
272
271
|
*/
|
|
273
|
-
export async function stageShare(
|
|
272
|
+
export async function stageShare(
|
|
273
|
+
brainPath: string,
|
|
274
|
+
input: ShareInput
|
|
275
|
+
): Promise<ShareIntakeResult> {
|
|
274
276
|
const title = cleanTextField(input.title);
|
|
275
277
|
const rawUrl = cleanTextField(input.url);
|
|
276
278
|
const { url, leftover } = splitUrl(rawUrl);
|
|
@@ -297,7 +299,7 @@ export async function stageShare(input: ShareInput): Promise<ShareIntakeResult>
|
|
|
297
299
|
throw new ShareTooLargeError("share_too_large", SHARE_MAX_TOTAL_BYTES);
|
|
298
300
|
}
|
|
299
301
|
|
|
300
|
-
const root = shareStagingRoot();
|
|
302
|
+
const root = shareStagingRoot(brainPath);
|
|
301
303
|
if ((await countStaged(root)) >= SHARE_MAX_STAGED) {
|
|
302
304
|
throw new ShareTooLargeError("inbox_full", SHARE_MAX_STAGED);
|
|
303
305
|
}
|
|
@@ -392,8 +394,8 @@ export async function stageShare(input: ShareInput): Promise<ShareIntakeResult>
|
|
|
392
394
|
* deployment can sweep at boot too — scheduling belongs to the container
|
|
393
395
|
* crontab, and this sweep is cheap and bounded.
|
|
394
396
|
*/
|
|
395
|
-
export async function pruneShareStaging(now = Date.now()): Promise<number> {
|
|
396
|
-
const root = shareStagingRoot();
|
|
397
|
+
export async function pruneShareStaging(brainPath: string, now = Date.now()): Promise<number> {
|
|
398
|
+
const root = shareStagingRoot(brainPath);
|
|
397
399
|
let entries;
|
|
398
400
|
try {
|
|
399
401
|
entries = await readdir(root, { withFileTypes: true });
|
|
@@ -32,11 +32,13 @@ function requestGrant(apiKey: string, ttlSeconds: number): Promise<Response> {
|
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export async function mintDeepgramToken(
|
|
35
|
+
export async function mintDeepgramToken(
|
|
36
|
+
apiKey: string | null,
|
|
37
|
+
ttlSeconds = 60
|
|
38
|
+
): Promise<{
|
|
36
39
|
token: string;
|
|
37
40
|
expiresAt: number;
|
|
38
41
|
}> {
|
|
39
|
-
const apiKey = process.env.DEEPGRAM_API_KEY;
|
|
40
42
|
if (!apiKey) {
|
|
41
43
|
throw new Error("DEEPGRAM_API_KEY is not set");
|
|
42
44
|
}
|