@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.
Files changed (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/agent/backend.d.ts +30 -0
  4. package/dist/agent/backend.d.ts.map +1 -0
  5. package/dist/agent/backend.js +226 -0
  6. package/dist/agent/backend.js.map +1 -0
  7. package/dist/app.d.ts +27 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +116 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/brain/client.d.ts +43 -0
  12. package/dist/brain/client.d.ts.map +1 -0
  13. package/dist/brain/client.js +154 -0
  14. package/dist/brain/client.js.map +1 -0
  15. package/dist/brain/types.d.ts +41 -0
  16. package/dist/brain/types.d.ts.map +1 -0
  17. package/dist/brain/types.js +2 -0
  18. package/dist/brain/types.js.map +1 -0
  19. package/dist/cron/scheduler.d.ts +9 -0
  20. package/dist/cron/scheduler.d.ts.map +1 -0
  21. package/dist/cron/scheduler.js +58 -0
  22. package/dist/cron/scheduler.js.map +1 -0
  23. package/dist/db/client.d.ts +10 -0
  24. package/dist/db/client.d.ts.map +1 -0
  25. package/dist/db/client.js +73 -0
  26. package/dist/db/client.js.map +1 -0
  27. package/dist/files/walker.d.ts +63 -0
  28. package/dist/files/walker.d.ts.map +1 -0
  29. package/dist/files/walker.js +416 -0
  30. package/dist/files/walker.js.map +1 -0
  31. package/dist/index.d.ts +18 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +25 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/middleware/auth.d.ts +59 -0
  36. package/dist/middleware/auth.d.ts.map +1 -0
  37. package/dist/middleware/auth.js +246 -0
  38. package/dist/middleware/auth.js.map +1 -0
  39. package/dist/middleware/passkeys.d.ts +36 -0
  40. package/dist/middleware/passkeys.d.ts.map +1 -0
  41. package/dist/middleware/passkeys.js +427 -0
  42. package/dist/middleware/passkeys.js.map +1 -0
  43. package/dist/middleware/tailscale.d.ts +31 -0
  44. package/dist/middleware/tailscale.d.ts.map +1 -0
  45. package/dist/middleware/tailscale.js +64 -0
  46. package/dist/middleware/tailscale.js.map +1 -0
  47. package/dist/render/template.d.ts +7 -0
  48. package/dist/render/template.d.ts.map +1 -0
  49. package/dist/render/template.js +112 -0
  50. package/dist/render/template.js.map +1 -0
  51. package/dist/routes/brain.d.ts +155 -0
  52. package/dist/routes/brain.d.ts.map +1 -0
  53. package/dist/routes/brain.js +250 -0
  54. package/dist/routes/brain.js.map +1 -0
  55. package/dist/routes/files.d.ts +89 -0
  56. package/dist/routes/files.d.ts.map +1 -0
  57. package/dist/routes/files.js +91 -0
  58. package/dist/routes/files.js.map +1 -0
  59. package/dist/routes/health.d.ts +37 -0
  60. package/dist/routes/health.d.ts.map +1 -0
  61. package/dist/routes/health.js +27 -0
  62. package/dist/routes/health.js.map +1 -0
  63. package/dist/routes/providers.d.ts +33 -0
  64. package/dist/routes/providers.d.ts.map +1 -0
  65. package/dist/routes/providers.js +9 -0
  66. package/dist/routes/providers.js.map +1 -0
  67. package/dist/routes/render.d.ts +25 -0
  68. package/dist/routes/render.d.ts.map +1 -0
  69. package/dist/routes/render.js +47 -0
  70. package/dist/routes/render.js.map +1 -0
  71. package/dist/routes/sessions.d.ts +79 -0
  72. package/dist/routes/sessions.d.ts.map +1 -0
  73. package/dist/routes/sessions.js +37 -0
  74. package/dist/routes/sessions.js.map +1 -0
  75. package/dist/routes/voice.d.ts +92 -0
  76. package/dist/routes/voice.d.ts.map +1 -0
  77. package/dist/routes/voice.js +64 -0
  78. package/dist/routes/voice.js.map +1 -0
  79. package/dist/voice/deepgram-token.d.ts +5 -0
  80. package/dist/voice/deepgram-token.d.ts.map +1 -0
  81. package/dist/voice/deepgram-token.js +63 -0
  82. package/dist/voice/deepgram-token.js.map +1 -0
  83. package/dist/voice/keyterm-builder.d.ts +18 -0
  84. package/dist/voice/keyterm-builder.d.ts.map +1 -0
  85. package/dist/voice/keyterm-builder.js +391 -0
  86. package/dist/voice/keyterm-builder.js.map +1 -0
  87. package/dist/voice/speech-providers.d.ts +25 -0
  88. package/dist/voice/speech-providers.d.ts.map +1 -0
  89. package/dist/voice/speech-providers.js +104 -0
  90. package/dist/voice/speech-providers.js.map +1 -0
  91. package/dist/ws/attachments.d.ts +10 -0
  92. package/dist/ws/attachments.d.ts.map +1 -0
  93. package/dist/ws/attachments.js +63 -0
  94. package/dist/ws/attachments.js.map +1 -0
  95. package/dist/ws/bridge.d.ts +7 -0
  96. package/dist/ws/bridge.d.ts.map +1 -0
  97. package/dist/ws/bridge.js +86 -0
  98. package/dist/ws/bridge.js.map +1 -0
  99. package/dist/ws/clients.d.ts +20 -0
  100. package/dist/ws/clients.d.ts.map +1 -0
  101. package/dist/ws/clients.js +48 -0
  102. package/dist/ws/clients.js.map +1 -0
  103. package/dist/ws/connection.d.ts +8 -0
  104. package/dist/ws/connection.d.ts.map +1 -0
  105. package/dist/ws/connection.js +116 -0
  106. package/dist/ws/connection.js.map +1 -0
  107. package/dist/ws/dispatch.d.ts +5 -0
  108. package/dist/ws/dispatch.d.ts.map +1 -0
  109. package/dist/ws/dispatch.js +139 -0
  110. package/dist/ws/dispatch.js.map +1 -0
  111. package/dist/ws/frames.d.ts +17 -0
  112. package/dist/ws/frames.d.ts.map +1 -0
  113. package/dist/ws/frames.js +37 -0
  114. package/dist/ws/frames.js.map +1 -0
  115. package/dist/ws/handler.d.ts +34 -0
  116. package/dist/ws/handler.d.ts.map +1 -0
  117. package/dist/ws/handler.js +33 -0
  118. package/dist/ws/handler.js.map +1 -0
  119. package/dist/ws/history.d.ts +17 -0
  120. package/dist/ws/history.d.ts.map +1 -0
  121. package/dist/ws/history.js +45 -0
  122. package/dist/ws/history.js.map +1 -0
  123. package/dist/ws/host.d.ts +40 -0
  124. package/dist/ws/host.d.ts.map +1 -0
  125. package/dist/ws/host.js +49 -0
  126. package/dist/ws/host.js.map +1 -0
  127. package/dist/ws/routing.d.ts +12 -0
  128. package/dist/ws/routing.d.ts.map +1 -0
  129. package/dist/ws/routing.js +31 -0
  130. package/dist/ws/routing.js.map +1 -0
  131. package/dist/ws/run-session.d.ts +17 -0
  132. package/dist/ws/run-session.d.ts.map +1 -0
  133. package/dist/ws/run-session.js +160 -0
  134. package/dist/ws/run-session.js.map +1 -0
  135. package/dist/ws/session-catalog.d.ts +25 -0
  136. package/dist/ws/session-catalog.d.ts.map +1 -0
  137. package/dist/ws/session-catalog.js +45 -0
  138. package/dist/ws/session-catalog.js.map +1 -0
  139. package/dist/ws/shrink.d.ts +34 -0
  140. package/dist/ws/shrink.d.ts.map +1 -0
  141. package/dist/ws/shrink.js +91 -0
  142. package/dist/ws/shrink.js.map +1 -0
  143. package/dist/ws/turns.d.ts +77 -0
  144. package/dist/ws/turns.d.ts.map +1 -0
  145. package/dist/ws/turns.js +89 -0
  146. package/dist/ws/turns.js.map +1 -0
  147. package/migrations/001_initial.sql +29 -0
  148. package/migrations/002_session_provider.sql +1 -0
  149. package/migrations/003_session_backend.sql +5 -0
  150. package/migrations/004_drop_gemini_backend.sql +20 -0
  151. package/migrations/005_passkey_credentials.sql +18 -0
  152. package/package.json +72 -0
  153. package/src/agent/backend.ts +308 -0
  154. package/src/app.ts +159 -0
  155. package/src/brain/client.ts +186 -0
  156. package/src/brain/types.ts +44 -0
  157. package/src/cron/scheduler.ts +80 -0
  158. package/src/db/client.ts +85 -0
  159. package/src/files/walker.ts +418 -0
  160. package/src/index.ts +39 -0
  161. package/src/middleware/auth.ts +321 -0
  162. package/src/middleware/passkeys.ts +510 -0
  163. package/src/middleware/tailscale.ts +81 -0
  164. package/src/render/template.ts +124 -0
  165. package/src/routes/brain.ts +305 -0
  166. package/src/routes/files.ts +99 -0
  167. package/src/routes/health.ts +29 -0
  168. package/src/routes/providers.ts +9 -0
  169. package/src/routes/render.ts +68 -0
  170. package/src/routes/sessions.ts +49 -0
  171. package/src/routes/voice.ts +80 -0
  172. package/src/voice/deepgram-token.ts +80 -0
  173. package/src/voice/keyterm-builder.ts +426 -0
  174. package/src/voice/speech-providers.ts +111 -0
  175. package/src/ws/attachments.ts +86 -0
  176. package/src/ws/bridge.ts +139 -0
  177. package/src/ws/clients.ts +57 -0
  178. package/src/ws/connection.ts +132 -0
  179. package/src/ws/dispatch.ts +162 -0
  180. package/src/ws/frames.ts +42 -0
  181. package/src/ws/handler.ts +50 -0
  182. package/src/ws/history.ts +51 -0
  183. package/src/ws/host.ts +69 -0
  184. package/src/ws/routing.ts +43 -0
  185. package/src/ws/run-session.ts +192 -0
  186. package/src/ws/session-catalog.ts +97 -0
  187. package/src/ws/shrink.ts +95 -0
  188. 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, "&lt;")
80
+ .replace(/>/g, "&gt;")
81
+ .replace(/"/g, "&quot;")
82
+ .replace(/'/g, "&#39;");
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
+ });