feat: persist draft messages and UI refinements

- Add dedicated draft-store with AsyncStorage persistence for drafts
  that survive app refresh/crash
- Reduce markdown bold weight from bold to medium (500)
- Various theme and component style updates
- Server bootstrap and config improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Mohamed Boudra
2026-01-14 10:07:54 +07:00
parent 34a49c147f
commit 70e2d66dd5
65 changed files with 2466 additions and 570 deletions
+113 -17
View File
@@ -1,12 +1,42 @@
import express, { type Express } from "express";
import express from "express";
import basicAuth from "express-basic-auth";
import { createServer as createHTTPServer, type Server as HTTPServer } from "http";
import { createReadStream } from "fs";
import { createServer as createHTTPServer } from "http";
import { createReadStream, unlinkSync, existsSync } from "fs";
import { stat } from "fs/promises";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
type ListenTarget =
| { type: "tcp"; host: string; port: number }
| { type: "socket"; path: string };
function parseListenString(listen: string): ListenTarget {
// Unix socket: starts with / or ~ or contains .sock
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
return { type: "socket", path: listen };
}
// Explicit unix:// prefix
if (listen.startsWith("unix://")) {
return { type: "socket", path: listen.slice(7) };
}
// TCP: host:port or just port
if (listen.includes(":")) {
const [host, portStr] = listen.split(":");
const port = parseInt(portStr, 10);
if (!Number.isFinite(port)) {
throw new Error(`Invalid port in listen string: ${listen}`);
}
return { type: "tcp", host: host || "127.0.0.1", port };
}
// Just a port number
const port = parseInt(listen, 10);
if (Number.isFinite(port)) {
return { type: "tcp", host: "127.0.0.1", port };
}
throw new Error(`Invalid listen string: ${listen}`);
}
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { initializeSTT, type STTConfig } from "./agent/stt-openai.js";
@@ -40,8 +70,9 @@ export type PaseoOpenAIConfig = {
};
export type PaseoDaemonConfig = {
port: number;
listen: string;
paseoHome: string;
corsAllowedOrigins: string[];
agentMcpRoute: string;
agentMcpAllowedHosts: string[];
auth: PaseoAuthConfig;
@@ -54,18 +85,17 @@ export type PaseoDaemonConfig = {
downloadTokenTtlMs?: number;
};
export type PaseoDaemonHandles = {
httpServer: HTTPServer;
app: Express;
wsServer: VoiceAssistantWebSocketServer;
export interface PaseoDaemon {
config: PaseoDaemonConfig;
agentManager: AgentManager;
agentRegistry: AgentRegistry;
close: () => Promise<void>;
};
start(): Promise<void>;
stop(): Promise<void>;
}
export async function createPaseoDaemon(
config: PaseoDaemonConfig
): Promise<PaseoDaemonHandles> {
): Promise<PaseoDaemon> {
const agentMcpRoute = config.agentMcpRoute;
const basicAuthUsers = config.auth.basicUsers;
const staticDir = config.staticDir;
@@ -76,8 +106,38 @@ export async function createPaseoDaemon(
const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs });
const listenTarget = parseListenString(config.listen);
const app = express();
// CORS - allow same-origin + configured origins
const allowedOrigins = new Set([
...config.corsAllowedOrigins,
// For TCP, add localhost variants
...(listenTarget.type === "tcp"
? [
`http://${listenTarget.host}:${listenTarget.port}`,
`http://localhost:${listenTarget.port}`,
`http://127.0.0.1:${listenTarget.port}`,
]
: []),
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");
}
if (req.method === "OPTIONS") {
res.status(204).end();
return;
}
next();
});
// Serve static files from public directory (no auth required for APK downloads)
app.use("/public", express.static(staticDir));
@@ -314,7 +374,8 @@ export async function createPaseoDaemon(
{
agentMcpUrl: config.agentControlMcp.url,
agentMcpHeaders: config.agentControlMcp.headers,
}
},
{ allowedOrigins }
);
const openaiApiKey = config.openai?.apiKey;
@@ -349,21 +410,56 @@ export async function createPaseoDaemon(
);
}
const close = async () => {
const start = async () => {
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
httpServer.off("listening", onListening);
reject(err);
};
const onListening = () => {
httpServer.off("error", onError);
if (listenTarget.type === "tcp") {
console.log(
`✓ Server listening on http://${listenTarget.host}:${listenTarget.port}`
);
} else {
console.log(`✓ Server listening on ${listenTarget.path}`);
}
resolve();
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host);
} else {
// Remove stale socket file if it exists
if (existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
httpServer.listen(listenTarget.path);
}
});
};
const stop = async () => {
await closeAllAgents(agentManager);
await wsServer.close();
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
// Clean up socket file
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
};
return {
httpServer,
app,
wsServer,
config,
agentManager,
agentRegistry,
close,
start,
stop,
};
}
+33 -44
View File
@@ -1,46 +1,17 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync } from "node:fs";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import type { STTConfig } from "./agent/stt-openai.js";
import type { TTSConfig } from "./agent/tts-openai.js";
import { loadPersistedConfig } from "./persisted-config.js";
function expandHomeDir(input: string): string {
if (input.startsWith("~/")) {
return path.join(os.homedir(), input.slice(2));
}
if (input === "~") {
return os.homedir();
}
return input;
}
export function readPaseoHomeFromEnv(
env: NodeJS.ProcessEnv = process.env
): string {
const raw = env.PASEO_HOME ?? env.PASEO_HOME_DIR ?? "~/.paseo";
const expanded = path.resolve(expandHomeDir(raw));
mkdirSync(expanded, { recursive: true });
return expanded;
}
export function readPaseoPortFromEnv(
env: NodeJS.ProcessEnv = process.env
): number {
const raw = env.PASEO_PORT ?? env.PORT ?? "6767";
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : 6767;
}
const DEFAULT_LISTEN = "127.0.0.1:6767";
const DEFAULT_BASIC_AUTH_USERS = { mo: "bo" } as const;
const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents";
function readOpenAIConfigFromEnv(env: NodeJS.ProcessEnv = process.env) {
function parseOpenAIConfig(env: NodeJS.ProcessEnv) {
const apiKey = env.OPENAI_API_KEY;
if (!apiKey) {
return undefined;
}
if (!apiKey) return undefined;
const sttConfidenceThreshold = env.STT_CONFIDENCE_THRESHOLD
? parseFloat(env.STT_CONFIDENCE_THRESHOLD)
@@ -71,15 +42,34 @@ function readOpenAIConfigFromEnv(env: NodeJS.ProcessEnv = process.env) {
};
}
export function buildPaseoDaemonConfigFromEnv(
function getListenForMcp(listen: string): string {
// For Unix sockets, MCP still needs HTTP - use localhost
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
return "127.0.0.1:6767";
}
if (listen.startsWith("unix://")) {
return "127.0.0.1:6767";
}
// TCP: extract host:port
if (listen.includes(":")) {
const [host, port] = listen.split(":");
return `${host || "127.0.0.1"}:${port}`;
}
// Just port
return `127.0.0.1:${listen}`;
}
export function loadConfig(
paseoHome: string,
env: NodeJS.ProcessEnv = process.env
): PaseoDaemonConfig {
const paseoHome = readPaseoHomeFromEnv(env);
const port = readPaseoPortFromEnv(env);
const persisted = loadPersistedConfig(paseoHome);
const listen = env.PASEO_LISTEN ?? persisted.listen ?? DEFAULT_LISTEN;
const mcpListen = getListenForMcp(listen);
const basicUsers = DEFAULT_BASIC_AUTH_USERS;
const [agentMcpUser, agentMcpPassword] =
Object.entries(basicUsers)[0] ?? [];
const [agentMcpUser, agentMcpPassword] = Object.entries(basicUsers)[0] ?? [];
const agentMcpAuthHeader =
agentMcpUser && agentMcpPassword
? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}`
@@ -89,13 +79,12 @@ export function buildPaseoDaemonConfigFromEnv(
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
const openai = readOpenAIConfigFromEnv(env);
return {
port,
listen,
paseoHome,
corsAllowedOrigins: persisted.cors.allowedOrigins,
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
auth: {
basicUsers,
agentMcpAuthHeader,
@@ -104,7 +93,7 @@ export function buildPaseoDaemonConfigFromEnv(
},
mcpDebug: env.MCP_DEBUG === "1",
agentControlMcp: {
url: `http://127.0.0.1:${port}${DEFAULT_AGENT_MCP_ROUTE}`,
url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`,
...(agentMcpAuthHeader
? { headers: { Authorization: agentMcpAuthHeader } }
: {}),
@@ -112,6 +101,6 @@ export function buildPaseoDaemonConfigFromEnv(
agentRegistryPath: path.join(paseoHome, "agents.json"),
staticDir: "public",
agentClients: {},
openai,
openai: parseOpenAIConfig(env),
};
}
+8 -9
View File
@@ -1,16 +1,15 @@
import "dotenv/config";
import { createPaseoDaemon } from "./bootstrap.js";
import { buildPaseoDaemonConfigFromEnv } from "./config.js";
import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
async function main() {
const daemonConfig = buildPaseoDaemonConfigFromEnv();
const daemon = await createPaseoDaemon(daemonConfig);
const paseoHome = resolvePaseoHome();
const config = loadConfig(paseoHome);
const daemon = await createPaseoDaemon(config);
await daemon.start();
daemon.httpServer.listen(daemonConfig.port, () => {
console.log(
`\n✓ Voice Assistant server running on http://localhost:${daemonConfig.port}`
);
});
const handleShutdown = async (signal: string) => {
console.log(`\n${signal} received, shutting down gracefully...`);
@@ -20,7 +19,7 @@ async function main() {
}, 10000);
try {
await daemon.close();
await daemon.stop();
clearTimeout(forceExit);
console.log("Server closed");
process.exit(0);
+20
View File
@@ -0,0 +1,20 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync } from "node:fs";
function expandHomeDir(input: string): string {
if (input.startsWith("~/")) {
return path.join(os.homedir(), input.slice(2));
}
if (input === "~") {
return os.homedir();
}
return input;
}
export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string {
const raw = env.PASEO_HOME ?? env.PASEO_HOME_DIR ?? "~/.paseo";
const resolved = path.resolve(expandHomeDir(raw));
mkdirSync(resolved, { recursive: true });
return resolved;
}
@@ -0,0 +1,79 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
const PersistedConfigSchema = z.object({
listen: z.string().optional(),
cors: z
.object({
allowedOrigins: z.array(z.string()).default([]),
})
.default({}),
});
export type PersistedConfig = z.infer<typeof PersistedConfigSchema>;
const CONFIG_FILENAME = "config.json";
function getConfigPath(paseoHome: string): string {
return path.join(paseoHome, CONFIG_FILENAME);
}
export function loadPersistedConfig(paseoHome: string): PersistedConfig {
const configPath = getConfigPath(paseoHome);
if (!existsSync(configPath)) {
console.log(`[Config] No config file at ${configPath}, using defaults`);
return PersistedConfigSchema.parse({});
}
let raw: string;
try {
raw = readFileSync(configPath, "utf-8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Failed to read ${configPath}: ${message}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`);
}
const result = PersistedConfigSchema.safeParse(parsed);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`);
}
console.log(`[Config] Loaded from ${configPath}`);
return result.data;
}
export function savePersistedConfig(
paseoHome: string,
config: PersistedConfig
): void {
const configPath = getConfigPath(paseoHome);
const result = PersistedConfigSchema.safeParse(config);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`[Config] Invalid config to save:\n${issues}`);
}
try {
writeFileSync(configPath, JSON.stringify(result.data, null, 2) + "\n");
console.log(`[Config] Saved to ${configPath}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Failed to write ${configPath}: ${message}`);
}
}
@@ -59,8 +59,9 @@ export async function createTestPaseoDaemon(
const port = await getAvailablePort();
const config: PaseoDaemonConfig = {
port,
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
agentMcpRoute: "/mcp/agents",
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
auth: {
@@ -85,11 +86,10 @@ export async function createTestPaseoDaemon(
const daemon = await createPaseoDaemon(config);
try {
await listenOnPort(daemon.httpServer, port);
await daemon.start();
const close = async (): Promise<void> => {
await daemon.close().catch(() => undefined);
// Wait a bit for file handles to release
await daemon.stop().catch(() => undefined);
await new Promise((r) => setTimeout(r, 200));
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
@@ -107,7 +107,7 @@ export async function createTestPaseoDaemon(
};
} catch (error) {
lastError = error;
await daemon.close().catch(() => undefined);
await daemon.stop().catch(() => undefined);
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
@@ -120,30 +120,6 @@ export async function createTestPaseoDaemon(
throw lastError ?? new Error("Failed to start test daemon");
}
async function listenOnPort(server: net.Server, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
server.off("listening", onListening);
reject(error);
};
const onListening = () => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
try {
server.listen(port);
} catch (error) {
server.off("error", onError);
server.off("listening", onListening);
reject(error instanceof Error ? error : new Error("Failed to listen"));
}
});
}
function isAddressInUseError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
+23 -2
View File
@@ -22,6 +22,10 @@ type AgentMcpClientConfig = {
agentMcpHeaders?: Record<string, string>;
};
type WebSocketServerConfig = {
allowedOrigins: Set<string>;
};
/**
* WebSocket server that routes messages between clients and their sessions.
* This is a thin transport layer with no business logic.
@@ -43,7 +47,8 @@ export class VoiceAssistantWebSocketServer {
agentManager: AgentManager,
agentRegistry: AgentRegistry,
downloadTokenStore: DownloadTokenStore,
agentMcpConfig: AgentMcpClientConfig
agentMcpConfig: AgentMcpClientConfig,
wsConfig: WebSocketServerConfig
) {
this.agentManager = agentManager;
this.agentRegistry = agentRegistry;
@@ -51,7 +56,23 @@ export class VoiceAssistantWebSocketServer {
this.pushTokenStore = new PushTokenStore();
this.pushService = new PushService(this.pushTokenStore);
this.agentMcpConfig = agentMcpConfig;
this.wss = new WebSocketServer({ server, path: "/ws" });
const { allowedOrigins } = wsConfig;
this.wss = new WebSocketServer({
server,
path: "/ws",
verifyClient: ({ req }, callback) => {
const origin = req.headers.origin;
// Allow connections with no origin (native apps, curl, etc.)
// or from allowed origins (browsers)
if (!origin || allowedOrigins.has(origin)) {
callback(true);
} else {
console.warn(`[WebSocket] Rejected connection from origin: ${origin}`);
callback(false, 403, "Origin not allowed");
}
},
});
this.wss.on("connection", (ws, request) => {
this.handleConnection(ws, request);