@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,124 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import type { RenderContentType } from "@schlessera/brain-ui-sdk/protocol";
|
|
3
|
+
|
|
4
|
+
marked.setOptions({ gfm: true, breaks: false });
|
|
5
|
+
|
|
6
|
+
const STYLES = `
|
|
7
|
+
:root {
|
|
8
|
+
color-scheme: light;
|
|
9
|
+
--bg: #ffffff;
|
|
10
|
+
--fg: #1f2937;
|
|
11
|
+
--muted: #6b7280;
|
|
12
|
+
--border: #e5e7eb;
|
|
13
|
+
--surface: #f8fafc;
|
|
14
|
+
--primary: #2563eb;
|
|
15
|
+
--code-bg: #f3f4f6;
|
|
16
|
+
}
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
html, body { margin: 0; padding: 0; background: var(--bg); }
|
|
19
|
+
body {
|
|
20
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji";
|
|
21
|
+
color: var(--fg);
|
|
22
|
+
font-size: 16px;
|
|
23
|
+
line-height: 1.6;
|
|
24
|
+
padding: 28px 32px;
|
|
25
|
+
word-wrap: break-word;
|
|
26
|
+
}
|
|
27
|
+
h1, h2, h3, h4, h5, h6 { font-weight: 600; line-height: 1.25; margin: 1.4em 0 0.6em; color: var(--fg); }
|
|
28
|
+
h1 { font-size: 1.75em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
|
|
29
|
+
h2 { font-size: 1.4em; border-bottom: 1px solid var(--border); padding-bottom: 0.25em; }
|
|
30
|
+
h3 { font-size: 1.2em; }
|
|
31
|
+
h4 { font-size: 1.05em; }
|
|
32
|
+
p { margin: 0.7em 0; }
|
|
33
|
+
a { color: var(--primary); text-decoration: none; }
|
|
34
|
+
a:hover { text-decoration: underline; }
|
|
35
|
+
ul, ol { padding-left: 1.6em; margin: 0.6em 0; }
|
|
36
|
+
li { margin: 0.2em 0; }
|
|
37
|
+
blockquote {
|
|
38
|
+
margin: 0.8em 0;
|
|
39
|
+
padding: 0.4em 1em;
|
|
40
|
+
border-left: 3px solid var(--border);
|
|
41
|
+
color: var(--muted);
|
|
42
|
+
background: var(--surface);
|
|
43
|
+
border-radius: 0 6px 6px 0;
|
|
44
|
+
}
|
|
45
|
+
code {
|
|
46
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
47
|
+
font-size: 0.9em;
|
|
48
|
+
background: var(--code-bg);
|
|
49
|
+
padding: 0.15em 0.35em;
|
|
50
|
+
border-radius: 4px;
|
|
51
|
+
}
|
|
52
|
+
pre {
|
|
53
|
+
background: var(--code-bg);
|
|
54
|
+
border: 1px solid var(--border);
|
|
55
|
+
border-radius: 8px;
|
|
56
|
+
padding: 0.8em 1em;
|
|
57
|
+
overflow-x: auto;
|
|
58
|
+
font-size: 0.88em;
|
|
59
|
+
line-height: 1.5;
|
|
60
|
+
}
|
|
61
|
+
pre code { background: transparent; padding: 0; border-radius: 0; }
|
|
62
|
+
hr { border: 0; border-top: 1px solid var(--border); margin: 1.6em 0; }
|
|
63
|
+
table { border-collapse: collapse; margin: 0.8em 0; width: 100%; }
|
|
64
|
+
th, td { border: 1px solid var(--border); padding: 0.45em 0.7em; text-align: left; }
|
|
65
|
+
th { background: var(--surface); font-weight: 600; }
|
|
66
|
+
img { max-width: 100%; height: auto; }
|
|
67
|
+
.remote-image {
|
|
68
|
+
display: inline-block; padding: 2px 8px; border: 1px dashed #b0b0b0;
|
|
69
|
+
border-radius: 4px; color: #6b6b6b; font-size: 0.9em;
|
|
70
|
+
}
|
|
71
|
+
/* Trim trailing whitespace at the bottom so screenshots crop tightly */
|
|
72
|
+
body > *:last-child { margin-bottom: 0; }
|
|
73
|
+
body > *:first-child { margin-top: 0; }
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
function escapeHtml(s: string): string {
|
|
77
|
+
return s
|
|
78
|
+
.replace(/&/g, "&")
|
|
79
|
+
.replace(/</g, "<")
|
|
80
|
+
.replace(/>/g, ">")
|
|
81
|
+
.replace(/"/g, """)
|
|
82
|
+
.replace(/'/g, "'");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The renderer denies the page all network access (see
|
|
87
|
+
* @schlessera/brain-render-puppeteer), so a remote `<img>` would silently render
|
|
88
|
+
* as a broken-image box. Replace those with a visible, honest placeholder —
|
|
89
|
+
* inlined `data:` images still render normally.
|
|
90
|
+
*/
|
|
91
|
+
function placeholderRemoteImages(html: string): string {
|
|
92
|
+
return html.replace(
|
|
93
|
+
/<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi,
|
|
94
|
+
(tag, src: string) => {
|
|
95
|
+
if (/^data:/i.test(src)) return tag;
|
|
96
|
+
const alt = /\balt\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1];
|
|
97
|
+
const label = alt ? escapeHtml(alt) : "remote image";
|
|
98
|
+
return `<span class="remote-image">[${label} — not embedded]</span>`;
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildHtmlDocument(opts: {
|
|
104
|
+
content: string;
|
|
105
|
+
contentType: RenderContentType;
|
|
106
|
+
title?: string;
|
|
107
|
+
}): string {
|
|
108
|
+
const rendered =
|
|
109
|
+
opts.contentType === "markdown"
|
|
110
|
+
? (marked.parse(opts.content, { async: false }) as string)
|
|
111
|
+
: opts.content;
|
|
112
|
+
const inner = placeholderRemoteImages(rendered);
|
|
113
|
+
const title = opts.title ?? "Shared from Brain";
|
|
114
|
+
return `<!doctype html>
|
|
115
|
+
<html lang="en">
|
|
116
|
+
<head>
|
|
117
|
+
<meta charset="utf-8" />
|
|
118
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
119
|
+
<title>${escapeHtml(title)}</title>
|
|
120
|
+
<style>${STYLES}</style>
|
|
121
|
+
</head>
|
|
122
|
+
<body>${inner}</body>
|
|
123
|
+
</html>`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import {
|
|
4
|
+
brainSearch,
|
|
5
|
+
brainBriefing,
|
|
6
|
+
brainStats,
|
|
7
|
+
brainList,
|
|
8
|
+
brainAdd,
|
|
9
|
+
brainCliCommand,
|
|
10
|
+
} from "../brain/client.js";
|
|
11
|
+
import { buildKeyterms, writeCache } from "../voice/keyterm-builder.js";
|
|
12
|
+
import { existsSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Locate the brain repo's `whatsup` script, relative to the repo root.
|
|
17
|
+
*
|
|
18
|
+
* Unlike the commands in brain/client.ts, whatsup is NOT part of the packaged
|
|
19
|
+
* @schlessera/brain CLI (it is absent from CORE_COMMAND_NAMES) — it stays a
|
|
20
|
+
* repo-local script. A brain repo migrated onto the published packages deletes
|
|
21
|
+
* the vendored `scripts/` tree and keeps whatsup at `private/whatsup.ts`; the
|
|
22
|
+
* legacy layout has it at `scripts/whatsup.ts`. Try both, newest layout first.
|
|
23
|
+
*
|
|
24
|
+
* Returns null when neither exists, so the route can report that plainly
|
|
25
|
+
* instead of surfacing bun's "module not found" through the SSE stream.
|
|
26
|
+
*/
|
|
27
|
+
function findWhatsupScript(brainPath: string): string | null {
|
|
28
|
+
for (const rel of ["private/whatsup.ts", "scripts/whatsup.ts"]) {
|
|
29
|
+
if (existsSync(join(brainPath, rel))) return rel;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const brainRoutes = new Hono()
|
|
35
|
+
.get("/brain/search", async (c) => {
|
|
36
|
+
const q = c.req.query("q");
|
|
37
|
+
if (!q) {
|
|
38
|
+
return c.json({ error: "Query parameter 'q' is required" }, 400);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const { results, warnings } = await brainSearch(q, {
|
|
42
|
+
type: c.req.query("type"),
|
|
43
|
+
tag: c.req.query("tag"),
|
|
44
|
+
limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined,
|
|
45
|
+
mode: c.req.query("mode"),
|
|
46
|
+
});
|
|
47
|
+
return c.json({ results, warnings });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return c.json(
|
|
50
|
+
{ error: err instanceof Error ? err.message : "Search failed" },
|
|
51
|
+
500
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
.get("/brain/briefing", async (c) => {
|
|
57
|
+
try {
|
|
58
|
+
const content = await brainBriefing();
|
|
59
|
+
return c.json({ content });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return c.json(
|
|
62
|
+
{ error: err instanceof Error ? err.message : "Briefing failed" },
|
|
63
|
+
500
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
.post("/brain/whatsup", async (c) => {
|
|
69
|
+
const BRAIN_PATH =
|
|
70
|
+
process.env.BRAIN_PATH ||
|
|
71
|
+
`${process.env.HOME || "/root"}/brain`;
|
|
72
|
+
|
|
73
|
+
// Defeat reverse-proxy buffering for SSE
|
|
74
|
+
c.header("X-Accel-Buffering", "no");
|
|
75
|
+
|
|
76
|
+
return streamSSE(c, async (stream) => {
|
|
77
|
+
const send = (data: {
|
|
78
|
+
type: string;
|
|
79
|
+
text?: string;
|
|
80
|
+
success?: boolean;
|
|
81
|
+
}) => stream.writeSSE({ data: JSON.stringify(data) });
|
|
82
|
+
|
|
83
|
+
// Heartbeat keeps the proxy from idling out the connection while
|
|
84
|
+
// the upstream LLM call is in flight (~5-15s with no stdout)
|
|
85
|
+
const heartbeat = setInterval(() => {
|
|
86
|
+
stream
|
|
87
|
+
.writeSSE({ event: "ping", data: "" })
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
}, 3000);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await send({ type: "start", text: "Running whatsup briefing..." });
|
|
93
|
+
|
|
94
|
+
const script = findWhatsupScript(BRAIN_PATH);
|
|
95
|
+
if (!script) {
|
|
96
|
+
// Terminate with the frames the client actually handles: it only
|
|
97
|
+
// reads "progress" and "done", so an "error" type would leave the
|
|
98
|
+
// panel spinning on an empty body.
|
|
99
|
+
await send({
|
|
100
|
+
type: "progress",
|
|
101
|
+
text: "whatsup script not found in the brain repo (looked for private/whatsup.ts and scripts/whatsup.ts).",
|
|
102
|
+
});
|
|
103
|
+
await send({ type: "done", success: false, text: "Not available" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --gemini: the default backend needs ANTHROPIC_API_KEY, which is no
|
|
108
|
+
// longer set (Claude runs on subscription auth, and the script's
|
|
109
|
+
// --claude backend uses --bare mode, which can't read the
|
|
110
|
+
// CLAUDE_CODE_OAUTH_TOKEN either).
|
|
111
|
+
const proc = Bun.spawn(["bun", script, "--gemini"], {
|
|
112
|
+
cwd: BRAIN_PATH,
|
|
113
|
+
stdout: "pipe",
|
|
114
|
+
stderr: "pipe",
|
|
115
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const reader = proc.stdout.getReader();
|
|
119
|
+
const decoder = new TextDecoder();
|
|
120
|
+
let buffer = "";
|
|
121
|
+
try {
|
|
122
|
+
while (true) {
|
|
123
|
+
const { done, value } = await reader.read();
|
|
124
|
+
if (done) break;
|
|
125
|
+
buffer += decoder.decode(value, { stream: true });
|
|
126
|
+
const lines = buffer.split("\n");
|
|
127
|
+
buffer = lines.pop() || "";
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
await send({ type: "progress", text: line });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (buffer.trim()) {
|
|
133
|
+
await send({ type: "progress", text: buffer });
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
|
|
137
|
+
const exitCode = await proc.exited;
|
|
138
|
+
|
|
139
|
+
const stderr = await new Response(proc.stderr).text();
|
|
140
|
+
if (stderr.trim()) {
|
|
141
|
+
for (const line of stderr.split("\n")) {
|
|
142
|
+
if (line.trim()) await send({ type: "progress", text: line });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await send({
|
|
147
|
+
type: "done",
|
|
148
|
+
success: exitCode === 0,
|
|
149
|
+
text:
|
|
150
|
+
exitCode === 0
|
|
151
|
+
? "Briefing complete"
|
|
152
|
+
: `Failed (exit ${exitCode})`,
|
|
153
|
+
});
|
|
154
|
+
} finally {
|
|
155
|
+
clearInterval(heartbeat);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
.get("/brain/stats", async (c) => {
|
|
161
|
+
try {
|
|
162
|
+
const stats = await brainStats();
|
|
163
|
+
return c.json(stats);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return c.json(
|
|
166
|
+
{ error: err instanceof Error ? err.message : "Stats failed" },
|
|
167
|
+
500
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
.get("/brain/list", async (c) => {
|
|
173
|
+
try {
|
|
174
|
+
const results = await brainList({
|
|
175
|
+
type: c.req.query("type"),
|
|
176
|
+
tag: c.req.query("tag"),
|
|
177
|
+
status: c.req.query("status"),
|
|
178
|
+
relevance: c.req.query("relevance"),
|
|
179
|
+
limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined,
|
|
180
|
+
});
|
|
181
|
+
return c.json({ results });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return c.json(
|
|
184
|
+
{ error: err instanceof Error ? err.message : "List failed" },
|
|
185
|
+
500
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
.post("/brain/sync", async (c) => {
|
|
191
|
+
// Stream sync output via SSE so the client sees live progress.
|
|
192
|
+
// brain sync spawns a nested Claude Code process that can take minutes.
|
|
193
|
+
const BRAIN_PATH =
|
|
194
|
+
process.env.BRAIN_PATH ||
|
|
195
|
+
`${process.env.HOME || "/root"}/brain`;
|
|
196
|
+
|
|
197
|
+
return streamSSE(c, async (stream) => {
|
|
198
|
+
const send = (data: {
|
|
199
|
+
type: string;
|
|
200
|
+
text?: string;
|
|
201
|
+
success?: boolean;
|
|
202
|
+
}) => stream.writeSSE({ data: JSON.stringify(data) });
|
|
203
|
+
|
|
204
|
+
await send({ type: "start", text: "Starting brain sync..." });
|
|
205
|
+
|
|
206
|
+
// The nested Claude process can be silent for minutes; without traffic
|
|
207
|
+
// the socket gets cut by idle timeouts (Bun's own, or a proxy's). SSE
|
|
208
|
+
// comment lines are ignored by EventSource and data-line parsers.
|
|
209
|
+
const keepalive = setInterval(() => {
|
|
210
|
+
stream.write(": keepalive\n\n").catch(() => {});
|
|
211
|
+
}, 15_000);
|
|
212
|
+
|
|
213
|
+
// Merge stderr into stdout — brain sync spawns a nested Claude Code
|
|
214
|
+
// process whose output goes to stderr. The command comes from
|
|
215
|
+
// brainCliCommand() (packaged bin or legacy vendored script), passed to
|
|
216
|
+
// bash as positional args rather than interpolated into the script, so a
|
|
217
|
+
// BRAIN_PATH containing spaces or shell metacharacters stays inert.
|
|
218
|
+
const [cliBin, ...cliArgs] = brainCliCommand();
|
|
219
|
+
const proc = Bun.spawn(
|
|
220
|
+
["bash", "-c", '"$0" "$@" 2>&1', cliBin!, ...cliArgs, "sync"],
|
|
221
|
+
{
|
|
222
|
+
cwd: BRAIN_PATH,
|
|
223
|
+
stdout: "pipe",
|
|
224
|
+
stderr: "pipe",
|
|
225
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const reader = proc.stdout.getReader();
|
|
230
|
+
const decoder = new TextDecoder();
|
|
231
|
+
let buffer = "";
|
|
232
|
+
try {
|
|
233
|
+
while (true) {
|
|
234
|
+
const { done, value } = await reader.read();
|
|
235
|
+
if (done) break;
|
|
236
|
+
buffer += decoder.decode(value, { stream: true });
|
|
237
|
+
const lines = buffer.split("\n");
|
|
238
|
+
buffer = lines.pop() || "";
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
if (line.trim()) {
|
|
241
|
+
await send({ type: "progress", text: line });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (buffer.trim()) {
|
|
246
|
+
await send({ type: "progress", text: buffer });
|
|
247
|
+
}
|
|
248
|
+
} catch {} finally {
|
|
249
|
+
clearInterval(keepalive);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const exitCode = await proc.exited;
|
|
253
|
+
|
|
254
|
+
// Rebuild voice keyterms cache after a successful sync.
|
|
255
|
+
if (exitCode === 0) {
|
|
256
|
+
try {
|
|
257
|
+
const cache = buildKeyterms();
|
|
258
|
+
writeCache(cache);
|
|
259
|
+
await send({
|
|
260
|
+
type: "progress",
|
|
261
|
+
text: `[voice] Rebuilt keyterms cache (${cache.count} terms)`,
|
|
262
|
+
});
|
|
263
|
+
} catch (err) {
|
|
264
|
+
await send({
|
|
265
|
+
type: "progress",
|
|
266
|
+
text: `[voice] Keyterm rebuild failed: ${
|
|
267
|
+
err instanceof Error ? err.message : String(err)
|
|
268
|
+
}`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await send({
|
|
274
|
+
type: "done",
|
|
275
|
+
success: exitCode === 0,
|
|
276
|
+
text:
|
|
277
|
+
exitCode === 0 ? "Sync completed" : `Sync failed (exit ${exitCode})`,
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
.post("/brain/add", async (c) => {
|
|
283
|
+
const body = await c.req.json<{
|
|
284
|
+
content: string;
|
|
285
|
+
type?: string;
|
|
286
|
+
title?: string;
|
|
287
|
+
tags?: string[];
|
|
288
|
+
}>();
|
|
289
|
+
if (!body.content) {
|
|
290
|
+
return c.json({ error: "Field 'content' is required" }, 400);
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
await brainAdd(body.content, {
|
|
294
|
+
type: body.type,
|
|
295
|
+
title: body.title,
|
|
296
|
+
tags: body.tags,
|
|
297
|
+
});
|
|
298
|
+
return c.json({ success: true });
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return c.json(
|
|
301
|
+
{ error: err instanceof Error ? err.message : "Add failed" },
|
|
302
|
+
500
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import {
|
|
3
|
+
listDirectory,
|
|
4
|
+
readFileContent,
|
|
5
|
+
resolveAncestors,
|
|
6
|
+
resolveForRaw,
|
|
7
|
+
buildWikilinkMap,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PathEscapeError,
|
|
10
|
+
TooLargeError,
|
|
11
|
+
} from "../files/walker.js";
|
|
12
|
+
|
|
13
|
+
let wikilinkCache: { generatedAt: number; map: Record<string, string> } | null = null;
|
|
14
|
+
const WIKILINK_TTL_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
function errorResponse(err: unknown): { body: { error: string; size?: number }; status: 400 | 404 | 413 | 500 } {
|
|
17
|
+
if (err instanceof PathEscapeError) {
|
|
18
|
+
return { body: { error: "invalid_path" }, status: 400 };
|
|
19
|
+
}
|
|
20
|
+
if (err instanceof NotFoundError) {
|
|
21
|
+
return { body: { error: "not_found" }, status: 404 };
|
|
22
|
+
}
|
|
23
|
+
if (err instanceof TooLargeError) {
|
|
24
|
+
return { body: { error: "file_too_large", size: err.size }, status: 413 };
|
|
25
|
+
}
|
|
26
|
+
console.error("[files]", err);
|
|
27
|
+
return { body: { error: err instanceof Error ? err.message : "internal_error" }, status: 500 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const filesRoutes = new Hono()
|
|
31
|
+
.get("/files/tree", async (c) => {
|
|
32
|
+
const path = c.req.query("path") ?? "";
|
|
33
|
+
try {
|
|
34
|
+
const entries = await listDirectory(path);
|
|
35
|
+
return c.json({ path, entries });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
const { body, status } = errorResponse(err);
|
|
38
|
+
return c.json(body, status);
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
.get("/files/content", async (c) => {
|
|
43
|
+
const path = c.req.query("path");
|
|
44
|
+
if (!path) return c.json({ error: "missing_path" }, 400);
|
|
45
|
+
const raw = c.req.query("raw") === "1";
|
|
46
|
+
try {
|
|
47
|
+
if (raw) {
|
|
48
|
+
const { abs, mime, size } = await resolveForRaw(path);
|
|
49
|
+
const file = Bun.file(abs);
|
|
50
|
+
return new Response(file, {
|
|
51
|
+
status: 200,
|
|
52
|
+
headers: {
|
|
53
|
+
"Content-Type": mime,
|
|
54
|
+
"Content-Length": String(size),
|
|
55
|
+
"Content-Disposition": "inline",
|
|
56
|
+
"X-Content-Type-Options": "nosniff",
|
|
57
|
+
"Content-Security-Policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'",
|
|
58
|
+
"Cache-Control": "private, max-age=0, must-revalidate",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const result = await readFileContent(path);
|
|
63
|
+
return c.json({ path, ...result });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const { body, status } = errorResponse(err);
|
|
66
|
+
return c.json(body, status);
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
.get("/files/resolve", async (c) => {
|
|
71
|
+
const path = c.req.query("path");
|
|
72
|
+
if (!path) return c.json({ error: "missing_path" }, 400);
|
|
73
|
+
try {
|
|
74
|
+
const result = await resolveAncestors(path);
|
|
75
|
+
return c.json({ path, ...result });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const { body, status } = errorResponse(err);
|
|
78
|
+
return c.json(body, status);
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
.get("/files/wikilinks", async (c) => {
|
|
83
|
+
const refresh = c.req.query("refresh") === "1";
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
try {
|
|
86
|
+
if (refresh || !wikilinkCache || now - wikilinkCache.generatedAt > WIKILINK_TTL_MS) {
|
|
87
|
+
const map = await buildWikilinkMap();
|
|
88
|
+
wikilinkCache = { generatedAt: now, map };
|
|
89
|
+
}
|
|
90
|
+
return c.json({
|
|
91
|
+
generatedAt: wikilinkCache.generatedAt,
|
|
92
|
+
count: Object.keys(wikilinkCache.map).length,
|
|
93
|
+
slugs: wikilinkCache.map,
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const { body, status } = errorResponse(err);
|
|
97
|
+
return c.json(body, status);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getCronStatus } from "../cron/scheduler.js";
|
|
3
|
+
import { isTurnActive } from "../ws/handler.js";
|
|
4
|
+
|
|
5
|
+
const startTime = Date.now();
|
|
6
|
+
|
|
7
|
+
// Public liveness probe. Deliberately minimal: no version/commit, no cron
|
|
8
|
+
// detail, no session oracle — this route sits in front of the auth guard and
|
|
9
|
+
// is reachable by anyone on the origin.
|
|
10
|
+
export const healthRoutes = new Hono().get("/health", (c) => {
|
|
11
|
+
return c.json({
|
|
12
|
+
status: "healthy",
|
|
13
|
+
uptime: Date.now() - startTime,
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Operational status. Registered BEHIND the auth guard: it exposes the git SHA,
|
|
19
|
+
// cron job errors (raw stderr with filesystem paths), and whether a turn is
|
|
20
|
+
// active — none of which should be readable unauthenticated.
|
|
21
|
+
export const statusRoutes = new Hono().get("/status", (c) => {
|
|
22
|
+
return c.json({
|
|
23
|
+
healthy: true,
|
|
24
|
+
uptime: Date.now() - startTime,
|
|
25
|
+
version: process.env.SOURCE_COMMIT ?? "dev",
|
|
26
|
+
cronJobs: getCronStatus(),
|
|
27
|
+
activeSession: isTurnActive(),
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getBackendsInfo, listAllProviders } from "../agent/backend.js";
|
|
3
|
+
|
|
4
|
+
export const providerRoutes = new Hono().get("/providers", async (c) => {
|
|
5
|
+
return c.json({
|
|
6
|
+
providers: await listAllProviders(),
|
|
7
|
+
backends: await getBackendsInfo(),
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { RenderRequest } from "@schlessera/brain-ui-sdk/protocol";
|
|
4
|
+
import { buildHtmlDocument } from "../render/template.js";
|
|
5
|
+
|
|
6
|
+
const MAX_CONTENT_BYTES = 512 * 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rendering seam. The deployment owns the actual renderer process (e.g. the
|
|
10
|
+
* network-denied headless Chrome in @schlessera/brain-render-puppeteer) and
|
|
11
|
+
* injects these two functions via `createApp({ renderer })` — the package
|
|
12
|
+
* itself never spawns a browser.
|
|
13
|
+
*/
|
|
14
|
+
export interface AppRenderer {
|
|
15
|
+
renderPng(options: { html: string }): Promise<Buffer | Uint8Array>;
|
|
16
|
+
renderPdf(options: { html: string }): Promise<Buffer | Uint8Array>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const bodySchema = z.object({
|
|
20
|
+
content: z.string().min(1).max(MAX_CONTENT_BYTES),
|
|
21
|
+
contentType: z.enum(["markdown", "html"]),
|
|
22
|
+
format: z.enum(["png", "pdf"]),
|
|
23
|
+
title: z.string().max(200).optional(),
|
|
24
|
+
}) satisfies z.ZodType<RenderRequest>;
|
|
25
|
+
|
|
26
|
+
export function createRenderRoutes(renderer?: AppRenderer) {
|
|
27
|
+
return new Hono().post("/render", async (c) => {
|
|
28
|
+
if (!renderer) {
|
|
29
|
+
return c.json(
|
|
30
|
+
{ error: "render_unavailable", detail: "This deployment has no renderer configured" },
|
|
31
|
+
501
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = bodySchema.parse(await c.req.json());
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return c.json({ error: "invalid_request", detail: err instanceof Error ? err.message : "bad body" }, 400);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const html = buildHtmlDocument(parsed);
|
|
44
|
+
const buf =
|
|
45
|
+
parsed.format === "png"
|
|
46
|
+
? await renderer.renderPng({ html })
|
|
47
|
+
: await renderer.renderPdf({ html });
|
|
48
|
+
const filename = (parsed.title ?? "share")
|
|
49
|
+
.replace(/[^a-z0-9_.-]+/gi, "_")
|
|
50
|
+
.slice(0, 60) || "share";
|
|
51
|
+
return new Response(new Uint8Array(buf), {
|
|
52
|
+
status: 200,
|
|
53
|
+
headers: {
|
|
54
|
+
"Content-Type": parsed.format === "png" ? "image/png" : "application/pdf",
|
|
55
|
+
"Content-Length": String(buf.byteLength),
|
|
56
|
+
"Content-Disposition": `inline; filename="${filename}.${parsed.format}"`,
|
|
57
|
+
"Cache-Control": "private, max-age=0, must-revalidate",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error("[render]", err);
|
|
62
|
+
return c.json(
|
|
63
|
+
{ error: "render_failed", detail: err instanceof Error ? err.message : String(err) },
|
|
64
|
+
500
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getBackendForSession, getBackends } from "../agent/backend.js";
|
|
3
|
+
import { getDb } from "../db/client.js";
|
|
4
|
+
|
|
5
|
+
function getStoredBackendId(sessionId: string): string | null {
|
|
6
|
+
const row = getDb()
|
|
7
|
+
.query("SELECT backend_id AS backendId FROM sessions WHERE id = ?")
|
|
8
|
+
.get(sessionId) as { backendId: string | null } | null;
|
|
9
|
+
return row?.backendId ?? null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const sessionRoutes = new Hono()
|
|
13
|
+
.get("/sessions", async (c) => {
|
|
14
|
+
try {
|
|
15
|
+
const backends = await getBackends();
|
|
16
|
+
const sessions = (
|
|
17
|
+
await Promise.all(
|
|
18
|
+
backends.map(async (backend) =>
|
|
19
|
+
(await backend.listSessions()).map((session) => ({
|
|
20
|
+
...session,
|
|
21
|
+
backendId: backend.id,
|
|
22
|
+
}))
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
.flat()
|
|
27
|
+
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
28
|
+
return c.json({ sessions });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return c.json(
|
|
31
|
+
{ error: err instanceof Error ? err.message : "Failed to list sessions" },
|
|
32
|
+
500
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
.get("/sessions/:id", async (c) => {
|
|
38
|
+
const id = c.req.param("id");
|
|
39
|
+
try {
|
|
40
|
+
const backend = await getBackendForSession(getStoredBackendId(id));
|
|
41
|
+
const messages = await backend.getHistory(id);
|
|
42
|
+
return c.json({ id, messages });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return c.json(
|
|
45
|
+
{ error: err instanceof Error ? err.message : "Failed to get session" },
|
|
46
|
+
500
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
});
|