@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,80 @@
|
|
|
1
|
+
import { getDb } from "../db/client.js";
|
|
2
|
+
import { brainSync, brainIndex, brainValidate } from "../brain/client.js";
|
|
3
|
+
|
|
4
|
+
// Scheduling is owned by the container crontab (config/crontab) — sync at
|
|
5
|
+
// 02:00, validate at 03:00, job scrape at 04:00. This module only provides
|
|
6
|
+
// manual triggers (triggerJob) and run history for the /api/status endpoint.
|
|
7
|
+
// Runs started by the crontab do not appear here; they log to syslog.
|
|
8
|
+
|
|
9
|
+
interface CronJob {
|
|
10
|
+
name: string;
|
|
11
|
+
handler: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const jobs: CronJob[] = [
|
|
15
|
+
{
|
|
16
|
+
name: "sync",
|
|
17
|
+
handler: async () => {
|
|
18
|
+
await brainSync();
|
|
19
|
+
await brainIndex();
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "validate",
|
|
24
|
+
handler: async () => {
|
|
25
|
+
await brainValidate();
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
async function runJob(job: CronJob) {
|
|
31
|
+
const db = getDb();
|
|
32
|
+
const startedAt = Date.now();
|
|
33
|
+
|
|
34
|
+
db.prepare(
|
|
35
|
+
"INSERT INTO cron_runs (job_name, started_at, status) VALUES (?, ?, 'running')"
|
|
36
|
+
).run(job.name, startedAt);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await job.handler();
|
|
40
|
+
const durationMs = Date.now() - startedAt;
|
|
41
|
+
db.prepare(
|
|
42
|
+
"UPDATE cron_runs SET status = 'success', finished_at = ?, duration_ms = ? WHERE job_name = ? AND started_at = ?"
|
|
43
|
+
).run(Date.now(), durationMs, job.name, startedAt);
|
|
44
|
+
console.log(`[cron] ${job.name} completed in ${durationMs}ms`);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const durationMs = Date.now() - startedAt;
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
db.prepare(
|
|
49
|
+
"UPDATE cron_runs SET status = 'error', finished_at = ?, duration_ms = ?, error_message = ? WHERE job_name = ? AND started_at = ?"
|
|
50
|
+
).run(Date.now(), durationMs, message, job.name, startedAt);
|
|
51
|
+
console.error(`[cron] ${job.name} failed: ${message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getCronStatus() {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
return jobs.map((job) => {
|
|
58
|
+
const lastRun = db
|
|
59
|
+
.query(
|
|
60
|
+
"SELECT * FROM cron_runs WHERE job_name = ? ORDER BY started_at DESC LIMIT 1"
|
|
61
|
+
)
|
|
62
|
+
.get(job.name) as any;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: job.name,
|
|
66
|
+
lastRunAt: lastRun?.started_at ?? null,
|
|
67
|
+
lastStatus: lastRun?.status ?? null,
|
|
68
|
+
lastDurationMs: lastRun?.duration_ms ?? null,
|
|
69
|
+
lastError: lastRun?.error_message ?? null,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function triggerJob(name: string): Promise<boolean> {
|
|
75
|
+
const job = jobs.find((j) => j.name === name);
|
|
76
|
+
if (!job) return false;
|
|
77
|
+
// Run async (don't await - let it run in background)
|
|
78
|
+
runJob(job);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
package/src/db/client.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { readdirSync, readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
let db: Database | null = null;
|
|
6
|
+
let configuredPath: string | null = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Override the database path before the first `getDb()` call (used by
|
|
10
|
+
* `createApp({ dbPath })`). Configuring after the handle exists would silently
|
|
11
|
+
* split state across two files — refuse instead.
|
|
12
|
+
*/
|
|
13
|
+
export function configureDb(dbPath: string): void {
|
|
14
|
+
if (db && configuredPath !== dbPath) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"configureDb() called after the database was opened; set dbPath before the first use"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
configuredPath = dbPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getDb(): Database {
|
|
23
|
+
if (!db) {
|
|
24
|
+
const dbPath =
|
|
25
|
+
configuredPath || process.env.DB_PATH || join(process.cwd(), "brain-ui.db");
|
|
26
|
+
db = new Database(dbPath, { create: true });
|
|
27
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
28
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
29
|
+
runMigrations(db);
|
|
30
|
+
}
|
|
31
|
+
return db;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function closeDb() {
|
|
35
|
+
if (db) {
|
|
36
|
+
db.close();
|
|
37
|
+
db = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runMigrations(database: Database) {
|
|
42
|
+
// Ensure _migrations table exists
|
|
43
|
+
database.exec(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
filename TEXT NOT NULL UNIQUE,
|
|
47
|
+
applied_at INTEGER NOT NULL
|
|
48
|
+
)
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
// The migrations ship inside the package (they are listed in `files`).
|
|
52
|
+
// src/db/ and dist/db/ sit at the same depth, so the relative hop to the
|
|
53
|
+
// package root resolves identically for the Bun (src) and Node (dist) paths.
|
|
54
|
+
const migrationsDir = join(import.meta.dir, "../../migrations");
|
|
55
|
+
let files: string[];
|
|
56
|
+
try {
|
|
57
|
+
files = readdirSync(migrationsDir)
|
|
58
|
+
.filter((f) => f.endsWith(".sql"))
|
|
59
|
+
.sort();
|
|
60
|
+
} catch {
|
|
61
|
+
console.warn("[db] No migrations directory found, skipping migrations");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const applied = new Set(
|
|
66
|
+
database
|
|
67
|
+
.query("SELECT filename FROM _migrations")
|
|
68
|
+
.all()
|
|
69
|
+
.map((r: any) => r.filename)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
if (applied.has(file)) continue;
|
|
74
|
+
|
|
75
|
+
console.log(`[db] Applying migration: ${file}`);
|
|
76
|
+
const sql = readFileSync(join(migrationsDir, file), "utf-8");
|
|
77
|
+
|
|
78
|
+
database.transaction(() => {
|
|
79
|
+
database.exec(sql);
|
|
80
|
+
database
|
|
81
|
+
.prepare("INSERT INTO _migrations (filename, applied_at) VALUES (?, ?)")
|
|
82
|
+
.run(file, Date.now());
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { readdir, readFile, stat, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, sep, posix, dirname } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import ignore, { type Ignore } from "ignore";
|
|
5
|
+
import type { FileEntry, FileContentKind } from "@schlessera/brain-ui-sdk/protocol";
|
|
6
|
+
import { FILE_SIZE_CAP_BYTES } from "@schlessera/brain-ui-sdk/protocol";
|
|
7
|
+
|
|
8
|
+
const HARD_EXCLUDE_DIRS = new Set([".git", "node_modules"]);
|
|
9
|
+
const HARD_EXCLUDE_FILE_PATTERNS = [
|
|
10
|
+
/\.db$/i,
|
|
11
|
+
/\.db-wal$/i,
|
|
12
|
+
/\.db-shm$/i,
|
|
13
|
+
];
|
|
14
|
+
// Build / dependency manifests that aren't browseable content. Matched by
|
|
15
|
+
// exact name (case-insensitive) at any depth.
|
|
16
|
+
const HARD_EXCLUDE_FILE_NAMES = new Set(
|
|
17
|
+
[
|
|
18
|
+
"package.json",
|
|
19
|
+
"package-lock.json",
|
|
20
|
+
"bun.lock",
|
|
21
|
+
"bun.lockb",
|
|
22
|
+
"yarn.lock",
|
|
23
|
+
"pnpm-lock.yaml",
|
|
24
|
+
"skills-lock.json",
|
|
25
|
+
].map((n) => n.toLowerCase())
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export function getBrainRoot(): string {
|
|
29
|
+
return process.env.BRAIN_PATH || join(process.env.HOME || "/root", "brain");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class PathEscapeError extends Error {
|
|
33
|
+
constructor(rel: string) {
|
|
34
|
+
super(`path_escape: ${rel}`);
|
|
35
|
+
this.name = "PathEscapeError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class NotFoundError extends Error {
|
|
40
|
+
constructor(rel: string) {
|
|
41
|
+
super(`not_found: ${rel}`);
|
|
42
|
+
this.name = "NotFoundError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class TooLargeError extends Error {
|
|
47
|
+
constructor(public size: number) {
|
|
48
|
+
super(`file_too_large: ${size}`);
|
|
49
|
+
this.name = "TooLargeError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a repo-relative path to an absolute path, rejecting any escape
|
|
55
|
+
* outside the brain root (including via symlinks).
|
|
56
|
+
*/
|
|
57
|
+
export async function safeResolve(rel: string, root = getBrainRoot()): Promise<string> {
|
|
58
|
+
if (typeof rel !== "string") throw new PathEscapeError(String(rel));
|
|
59
|
+
// Reject absolute paths and null bytes
|
|
60
|
+
if (rel.startsWith("/") || rel.startsWith("\\") || rel.includes("\0")) {
|
|
61
|
+
throw new PathEscapeError(rel);
|
|
62
|
+
}
|
|
63
|
+
// Normalize separators; reject ".." segments anywhere
|
|
64
|
+
const normalized = rel.replace(/\\/g, "/");
|
|
65
|
+
for (const seg of normalized.split("/")) {
|
|
66
|
+
if (seg === "..") throw new PathEscapeError(rel);
|
|
67
|
+
}
|
|
68
|
+
const absRoot = resolve(root);
|
|
69
|
+
const abs = resolve(absRoot, normalized);
|
|
70
|
+
if (abs !== absRoot && !abs.startsWith(absRoot + sep)) {
|
|
71
|
+
throw new PathEscapeError(rel);
|
|
72
|
+
}
|
|
73
|
+
// Check for symlink escape via realpath when the path exists
|
|
74
|
+
if (existsSync(abs)) {
|
|
75
|
+
const real = await realpath(abs);
|
|
76
|
+
if (real !== absRoot && !real.startsWith(absRoot + sep)) {
|
|
77
|
+
throw new PathEscapeError(rel);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return abs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface CachedIgnore {
|
|
84
|
+
matcher: Ignore;
|
|
85
|
+
mtime: number;
|
|
86
|
+
}
|
|
87
|
+
const ignoreCache = new Map<string, CachedIgnore>();
|
|
88
|
+
|
|
89
|
+
async function loadIgnore(root: string): Promise<Ignore> {
|
|
90
|
+
const gitignore = join(root, ".gitignore");
|
|
91
|
+
let mtime = 0;
|
|
92
|
+
try {
|
|
93
|
+
const s = await stat(gitignore);
|
|
94
|
+
mtime = s.mtimeMs;
|
|
95
|
+
} catch {
|
|
96
|
+
// no .gitignore — empty matcher
|
|
97
|
+
}
|
|
98
|
+
const cached = ignoreCache.get(root);
|
|
99
|
+
if (cached && cached.mtime === mtime) return cached.matcher;
|
|
100
|
+
|
|
101
|
+
const matcher = ignore();
|
|
102
|
+
if (mtime > 0) {
|
|
103
|
+
try {
|
|
104
|
+
const text = await readFile(gitignore, "utf8");
|
|
105
|
+
matcher.add(text);
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
ignoreCache.set(root, { matcher, mtime });
|
|
109
|
+
return matcher;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isHardExcluded(name: string, isDir: boolean): boolean {
|
|
113
|
+
// Hide all dotfiles and dot-directories — they are tooling/config noise
|
|
114
|
+
// (.git, .gitignore, .agents, .claude, .brain-ui, etc.) that the user
|
|
115
|
+
// doesn't want to navigate as content.
|
|
116
|
+
if (name.startsWith(".")) return true;
|
|
117
|
+
if (isDir) return HARD_EXCLUDE_DIRS.has(name);
|
|
118
|
+
if (HARD_EXCLUDE_FILE_NAMES.has(name.toLowerCase())) return true;
|
|
119
|
+
return HARD_EXCLUDE_FILE_PATTERNS.some((re) => re.test(name));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* List entries of a directory, applying ignore rules.
|
|
124
|
+
* Always excludes: .git, node_modules, *.db*. Then applies .gitignore.
|
|
125
|
+
*/
|
|
126
|
+
export async function listDirectory(rel: string, root = getBrainRoot()): Promise<FileEntry[]> {
|
|
127
|
+
const abs = await safeResolve(rel, root);
|
|
128
|
+
let s;
|
|
129
|
+
try {
|
|
130
|
+
s = await stat(abs);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new NotFoundError(rel);
|
|
133
|
+
}
|
|
134
|
+
if (!s.isDirectory()) throw new NotFoundError(rel);
|
|
135
|
+
|
|
136
|
+
const matcher = await loadIgnore(root);
|
|
137
|
+
const dirents = await readdir(abs, { withFileTypes: true });
|
|
138
|
+
|
|
139
|
+
const entries: FileEntry[] = [];
|
|
140
|
+
for (const d of dirents) {
|
|
141
|
+
const isDir = d.isDirectory();
|
|
142
|
+
const isFile = d.isFile();
|
|
143
|
+
const isSymlink = d.isSymbolicLink();
|
|
144
|
+
if (!isDir && !isFile && !isSymlink) continue;
|
|
145
|
+
if (isHardExcluded(d.name, isDir)) continue;
|
|
146
|
+
|
|
147
|
+
const relChild = rel ? posix.join(rel, d.name) : d.name;
|
|
148
|
+
// ignore expects trailing slash on dirs to match dir-only rules
|
|
149
|
+
const matchPath = isDir ? `${relChild}/` : relChild;
|
|
150
|
+
if (matcher.ignores(matchPath)) continue;
|
|
151
|
+
|
|
152
|
+
let childStat;
|
|
153
|
+
try {
|
|
154
|
+
childStat = await stat(join(abs, d.name));
|
|
155
|
+
} catch {
|
|
156
|
+
continue; // broken symlink
|
|
157
|
+
}
|
|
158
|
+
// Resolve symlinks: drop ones that escape the root
|
|
159
|
+
if (isSymlink) {
|
|
160
|
+
try {
|
|
161
|
+
const real = await realpath(join(abs, d.name));
|
|
162
|
+
const absRoot = resolve(root);
|
|
163
|
+
if (real !== absRoot && !real.startsWith(absRoot + sep)) continue;
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
entries.push({
|
|
170
|
+
name: d.name,
|
|
171
|
+
path: relChild,
|
|
172
|
+
type: childStat.isDirectory() ? "dir" : "file",
|
|
173
|
+
size: childStat.isFile() ? childStat.size : undefined,
|
|
174
|
+
mtime: Math.floor(childStat.mtimeMs),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
entries.sort((a, b) => {
|
|
179
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
180
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
|
181
|
+
});
|
|
182
|
+
return entries;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const MARKDOWN_EXT = new Set([".md", ".markdown", ".mdx"]);
|
|
186
|
+
const HTML_EXT = new Set([".html", ".htm"]);
|
|
187
|
+
const KNOWN_TEXT_EXT = new Set([
|
|
188
|
+
".txt", ".text", ".log",
|
|
189
|
+
".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".csv", ".tsv",
|
|
190
|
+
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
|
191
|
+
".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".h",
|
|
192
|
+
".cpp", ".hpp", ".cc", ".cs", ".php", ".sh", ".bash", ".zsh", ".fish",
|
|
193
|
+
".css", ".scss", ".sass", ".less",
|
|
194
|
+
".sql", ".env", ".ini", ".conf", ".cfg",
|
|
195
|
+
".gitignore", ".gitattributes", ".editorconfig", ".prettierrc",
|
|
196
|
+
".dockerfile", ".lock",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
function extOf(name: string): string {
|
|
200
|
+
const i = name.lastIndexOf(".");
|
|
201
|
+
if (i < 0) return "";
|
|
202
|
+
return name.slice(i).toLowerCase();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function classifyKind(name: string, bytes?: Buffer): FileContentKind {
|
|
206
|
+
const ext = extOf(name);
|
|
207
|
+
if (MARKDOWN_EXT.has(ext)) return "markdown";
|
|
208
|
+
if (HTML_EXT.has(ext)) return "html";
|
|
209
|
+
if (KNOWN_TEXT_EXT.has(ext)) return "text";
|
|
210
|
+
// Bare names like "Dockerfile", "Makefile", "LICENSE", "README"
|
|
211
|
+
const base = name.toLowerCase();
|
|
212
|
+
if (["dockerfile", "makefile", "license", "licence", "readme", "changelog", "authors", "contributors", "notice"].includes(base)) {
|
|
213
|
+
return "text";
|
|
214
|
+
}
|
|
215
|
+
// Fall back to byte sniff: a buffer is "text" if no NUL byte in first 4KB and decodes as UTF-8.
|
|
216
|
+
if (bytes) {
|
|
217
|
+
const slice = bytes.subarray(0, Math.min(bytes.length, 4096));
|
|
218
|
+
if (slice.includes(0)) return "binary";
|
|
219
|
+
try {
|
|
220
|
+
new TextDecoder("utf-8", { fatal: true }).decode(slice);
|
|
221
|
+
return "text";
|
|
222
|
+
} catch {
|
|
223
|
+
return "binary";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return "binary";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
230
|
+
".png": "image/png",
|
|
231
|
+
".jpg": "image/jpeg",
|
|
232
|
+
".jpeg": "image/jpeg",
|
|
233
|
+
".gif": "image/gif",
|
|
234
|
+
".webp": "image/webp",
|
|
235
|
+
".svg": "image/svg+xml",
|
|
236
|
+
".avif": "image/avif",
|
|
237
|
+
".bmp": "image/bmp",
|
|
238
|
+
".ico": "image/x-icon",
|
|
239
|
+
".pdf": "application/pdf",
|
|
240
|
+
".mp3": "audio/mpeg",
|
|
241
|
+
".wav": "audio/wav",
|
|
242
|
+
".mp4": "video/mp4",
|
|
243
|
+
".webm": "video/webm",
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export function mimeFor(name: string, kind: FileContentKind): string {
|
|
247
|
+
const ext = extOf(name);
|
|
248
|
+
if (MIME_BY_EXT[ext]) return MIME_BY_EXT[ext];
|
|
249
|
+
if (kind === "markdown") return "text/markdown; charset=utf-8";
|
|
250
|
+
if (kind === "html") return "text/html; charset=utf-8";
|
|
251
|
+
if (kind === "text") return "text/plain; charset=utf-8";
|
|
252
|
+
return "application/octet-stream";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface FileContent {
|
|
256
|
+
kind: FileContentKind;
|
|
257
|
+
size: number;
|
|
258
|
+
mtime: number;
|
|
259
|
+
mime: string;
|
|
260
|
+
content?: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function readFileContent(rel: string, root = getBrainRoot()): Promise<FileContent> {
|
|
264
|
+
const abs = await safeResolve(rel, root);
|
|
265
|
+
let s;
|
|
266
|
+
try {
|
|
267
|
+
s = await stat(abs);
|
|
268
|
+
} catch {
|
|
269
|
+
throw new NotFoundError(rel);
|
|
270
|
+
}
|
|
271
|
+
if (!s.isFile()) throw new NotFoundError(rel);
|
|
272
|
+
if (s.size > FILE_SIZE_CAP_BYTES) throw new TooLargeError(s.size);
|
|
273
|
+
|
|
274
|
+
const ext = extOf(abs);
|
|
275
|
+
const presumed: FileContentKind | null = MARKDOWN_EXT.has(ext)
|
|
276
|
+
? "markdown"
|
|
277
|
+
: HTML_EXT.has(ext)
|
|
278
|
+
? "html"
|
|
279
|
+
: KNOWN_TEXT_EXT.has(ext)
|
|
280
|
+
? "text"
|
|
281
|
+
: null;
|
|
282
|
+
|
|
283
|
+
if (presumed) {
|
|
284
|
+
const content = await readFile(abs, "utf8");
|
|
285
|
+
return {
|
|
286
|
+
kind: presumed,
|
|
287
|
+
size: s.size,
|
|
288
|
+
mtime: Math.floor(s.mtimeMs),
|
|
289
|
+
mime: mimeFor(rel, presumed),
|
|
290
|
+
content,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const buf = await readFile(abs);
|
|
295
|
+
const kind = classifyKind(abs, buf);
|
|
296
|
+
if (kind === "text" || kind === "markdown" || kind === "html") {
|
|
297
|
+
return {
|
|
298
|
+
kind,
|
|
299
|
+
size: s.size,
|
|
300
|
+
mtime: Math.floor(s.mtimeMs),
|
|
301
|
+
mime: mimeFor(rel, kind),
|
|
302
|
+
content: buf.toString("utf8"),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
kind: "binary",
|
|
307
|
+
size: s.size,
|
|
308
|
+
mtime: Math.floor(s.mtimeMs),
|
|
309
|
+
mime: mimeFor(rel, "binary"),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Stream raw bytes of a file (for image/binary preview).
|
|
315
|
+
* Returns the absolute path; caller uses Bun.file() to stream.
|
|
316
|
+
*/
|
|
317
|
+
export async function resolveForRaw(rel: string, root = getBrainRoot()): Promise<{ abs: string; size: number; mime: string; kind: FileContentKind }> {
|
|
318
|
+
const abs = await safeResolve(rel, root);
|
|
319
|
+
let s;
|
|
320
|
+
try {
|
|
321
|
+
s = await stat(abs);
|
|
322
|
+
} catch {
|
|
323
|
+
throw new NotFoundError(rel);
|
|
324
|
+
}
|
|
325
|
+
if (!s.isFile()) throw new NotFoundError(rel);
|
|
326
|
+
if (s.size > FILE_SIZE_CAP_BYTES) throw new TooLargeError(s.size);
|
|
327
|
+
const kind = classifyKind(abs);
|
|
328
|
+
return { abs, size: s.size, mime: mimeFor(rel, kind), kind };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Walk the brain repo and build a slug -> repo-relative-path map for all
|
|
333
|
+
* markdown files (.md / .markdown / .mdx), respecting the same ignore
|
|
334
|
+
* rules as listDirectory. Slug = basename without extension, lowercase.
|
|
335
|
+
*
|
|
336
|
+
* If two files share a slug (rare), the first one encountered wins —
|
|
337
|
+
* deterministic given the sorted directory traversal.
|
|
338
|
+
*/
|
|
339
|
+
export async function buildWikilinkMap(root = getBrainRoot()): Promise<Record<string, string>> {
|
|
340
|
+
const matcher = await loadIgnore(root);
|
|
341
|
+
const out: Record<string, string> = {};
|
|
342
|
+
|
|
343
|
+
async function walk(rel: string): Promise<void> {
|
|
344
|
+
const abs = await safeResolve(rel, root);
|
|
345
|
+
let dirents;
|
|
346
|
+
try {
|
|
347
|
+
dirents = await readdir(abs, { withFileTypes: true });
|
|
348
|
+
} catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Sort for deterministic precedence
|
|
352
|
+
dirents.sort((a, b) => a.name.localeCompare(b.name));
|
|
353
|
+
for (const d of dirents) {
|
|
354
|
+
const isDir = d.isDirectory();
|
|
355
|
+
const isFile = d.isFile();
|
|
356
|
+
const isSymlink = d.isSymbolicLink();
|
|
357
|
+
if (!isDir && !isFile && !isSymlink) continue;
|
|
358
|
+
if (isHardExcluded(d.name, isDir)) continue;
|
|
359
|
+
const relChild = rel ? posix.join(rel, d.name) : d.name;
|
|
360
|
+
const matchPath = isDir ? `${relChild}/` : relChild;
|
|
361
|
+
if (matcher.ignores(matchPath)) continue;
|
|
362
|
+
|
|
363
|
+
let childStat;
|
|
364
|
+
try {
|
|
365
|
+
childStat = await stat(join(abs, d.name));
|
|
366
|
+
} catch {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (isSymlink) {
|
|
370
|
+
try {
|
|
371
|
+
const real = await realpath(join(abs, d.name));
|
|
372
|
+
const absRoot = resolve(root);
|
|
373
|
+
if (real !== absRoot && !real.startsWith(absRoot + sep)) continue;
|
|
374
|
+
} catch {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (childStat.isDirectory()) {
|
|
380
|
+
await walk(relChild);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const lower = d.name.toLowerCase();
|
|
384
|
+
const dot = lower.lastIndexOf(".");
|
|
385
|
+
const ext = dot >= 0 ? lower.slice(dot) : "";
|
|
386
|
+
if (ext !== ".md" && ext !== ".markdown" && ext !== ".mdx") continue;
|
|
387
|
+
const slug = lower.slice(0, dot);
|
|
388
|
+
if (slug && !out[slug]) out[slug] = relChild;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await walk("");
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Return ancestor directories of a path, root-first (excluding root and the path itself).
|
|
398
|
+
* resolve("a/b/c.md") -> { ancestors: ["a", "a/b"], exists, type }
|
|
399
|
+
*/
|
|
400
|
+
export async function resolveAncestors(rel: string, root = getBrainRoot()): Promise<{ ancestors: string[]; exists: boolean; type?: "dir" | "file" }> {
|
|
401
|
+
const normalized = rel.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
402
|
+
if (!normalized) return { ancestors: [], exists: true, type: "dir" };
|
|
403
|
+
const parts = normalized.split("/");
|
|
404
|
+
const ancestors: string[] = [];
|
|
405
|
+
for (let i = 1; i < parts.length; i++) {
|
|
406
|
+
ancestors.push(parts.slice(0, i).join("/"));
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const abs = await safeResolve(normalized, root);
|
|
410
|
+
const s = await stat(abs);
|
|
411
|
+
return { ancestors, exists: true, type: s.isDirectory() ? "dir" : "file" };
|
|
412
|
+
} catch {
|
|
413
|
+
return { ancestors, exists: false };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Re-export for callers
|
|
418
|
+
export { dirname };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @schlessera/brain-ui-server — the chat-UI backend as a library.
|
|
3
|
+
*
|
|
4
|
+
* The deployment shell owns the process: it builds the Bun.serve() object,
|
|
5
|
+
* decides the port/idleTimeout, wires SIGTERM, and injects deployment-only
|
|
6
|
+
* pieces (static client build, PNG/PDF renderer). Everything else — routes,
|
|
7
|
+
* auth, passkeys, the WebSocket turn coordinator, the session catalog — lives
|
|
8
|
+
* behind createApp().
|
|
9
|
+
*/
|
|
10
|
+
export { createApp, type CreateAppOptions, type AppRenderer } from "./app.js";
|
|
11
|
+
|
|
12
|
+
// Process lifecycle helpers for the bin entry.
|
|
13
|
+
export { websocket, cancelActiveTurn, isTurnActive } from "./ws/handler.js";
|
|
14
|
+
export { getDb, closeDb, configureDb } from "./db/client.js";
|
|
15
|
+
|
|
16
|
+
// Boot-time diagnostics (fail fast on a bad AGENT_BACKEND / profile config,
|
|
17
|
+
// log the resolved auth mode).
|
|
18
|
+
export { getBackends, getBackendsInfo } from "./agent/backend.js";
|
|
19
|
+
export { resolveAuthMode, type AuthMode } from "./middleware/auth.js";
|
|
20
|
+
|
|
21
|
+
// WebSocket internals for embedders and tests.
|
|
22
|
+
export {
|
|
23
|
+
WsHost,
|
|
24
|
+
type WsHostOptions,
|
|
25
|
+
defaultWsHost,
|
|
26
|
+
configureWsHost,
|
|
27
|
+
handleClientMessage,
|
|
28
|
+
resetForTests,
|
|
29
|
+
resolveTurnTarget,
|
|
30
|
+
createSessionCatalog,
|
|
31
|
+
type SessionCatalog,
|
|
32
|
+
} from "./ws/handler.js";
|
|
33
|
+
|
|
34
|
+
// Brain repo access (spawned CLI wrapper) — useful for embedders that add
|
|
35
|
+
// their own routes on top.
|
|
36
|
+
export * as brainClient from "./brain/client.js";
|
|
37
|
+
|
|
38
|
+
// Voice keyterm cache rebuild (used by deployments after `brain sync`).
|
|
39
|
+
export { buildKeyterms, writeCache } from "./voice/keyterm-builder.js";
|