diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 1e9d1c0..f2207c2 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -285,6 +285,12 @@ export function initSchema(sqlite: Database.Database) { value TEXT NOT NULL, updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS hook_always_rules ( + agent_id TEXT NOT NULL, + command_prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (agent_id, command_prefix) + ); CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL @@ -345,6 +351,42 @@ function runSchemaMigrations(sqlite: Database.Database): void { `); } }); + // v4: indexes for hot query paths. All CREATE INDEX IF NOT EXISTS so reruns + // are no-ops. Covers the WHERE/ORDER BY columns used by stores in this file. + apply(4, () => { + sqlite.exec(` + CREATE INDEX IF NOT EXISTS idx_projects_parent_id ON projects(parent_id); + CREATE INDEX IF NOT EXISTS idx_projects_is_inbox ON projects(is_inbox); + CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id); + CREATE INDEX IF NOT EXISTS idx_note_tags_note_id ON note_tags(note_id); + CREATE INDEX IF NOT EXISTS idx_note_tags_tag_id ON note_tags(tag_id); + CREATE INDEX IF NOT EXISTS idx_board_columns_project_id ON board_columns(project_id); + CREATE INDEX IF NOT EXISTS idx_knowledge_tasks_project_id ON knowledge_tasks(project_id); + CREATE INDEX IF NOT EXISTS idx_knowledge_tasks_column_id ON knowledge_tasks(column_id); + CREATE INDEX IF NOT EXISTS idx_knowledge_tasks_project_column ON knowledge_tasks(project_id, column_id); + CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_source ON knowledge_chunks(source_type, source_id); + CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_project_id ON knowledge_chunks(project_id); + CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_index_status ON knowledge_chunks(index_status); + CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_retry ON knowledge_chunks(index_status, next_retry_at); + CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation ON ai_messages(conversation_id, created_at); + CREATE INDEX IF NOT EXISTS idx_ai_conversations_project ON ai_conversations(project_id); + CREATE INDEX IF NOT EXISTS idx_focus_sessions_task_id ON focus_sessions(task_id); + CREATE INDEX IF NOT EXISTS idx_focus_sessions_status ON focus_sessions(status); + CREATE INDEX IF NOT EXISTS idx_window_events_captured_at ON window_events(captured_at); + CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at); + `); + }); + // v5: hook_always_rules table for hook permission persistence (survives restart). + apply(5, () => { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS hook_always_rules ( + agent_id TEXT NOT NULL, + command_prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (agent_id, command_prefix) + ); + `); + }); } /** Read a single app_config value (JSON string), or null when absent. */ @@ -362,6 +404,51 @@ export function setAppConfig(database: NeoDatabase, key: string, value: string): .run(key, value, new Date().toISOString()); } +/** + * Hook permission "always-allow" rules store. Survives sidecar restarts. + * On the memory fallback (no native sqlite) returns an in-memory shim so the + * hook manager stays consistent — rules just won't persist across restarts. + */ +export interface HookRulesStore { + list(): Array<{ agentId: string; commandPrefix: string; createdAt: number }>; + add(agentId: string, commandPrefix: string, createdAt: number): void; + remove(agentId: string, commandPrefix: string): void; +} + +export function createHookRulesStore(database: NeoDatabase): HookRulesStore { + if (database.kind !== "sqlite") { + const rules: Array<{ agentId: string; commandPrefix: string; createdAt: number }> = []; + return { + list: () => [...rules], + add: (agentId, commandPrefix, createdAt) => { + if (rules.some((r) => r.agentId === agentId && r.commandPrefix === commandPrefix)) return; + rules.push({ agentId, commandPrefix, createdAt }); + }, + remove: (agentId, commandPrefix) => { + const idx = rules.findIndex((r) => r.agentId === agentId && r.commandPrefix === commandPrefix); + if (idx !== -1) rules.splice(idx, 1); + } + }; + } + const sqlite = database.sqlite; + return { + list: () => { + const rows = sqlite.prepare("SELECT agent_id, command_prefix, created_at FROM hook_always_rules").all() as Array<{ agent_id: string; command_prefix: string; created_at: number }>; + return rows.map((r) => ({ agentId: r.agent_id, commandPrefix: r.command_prefix, createdAt: r.created_at })); + }, + add: (agentId, commandPrefix, createdAt) => { + sqlite + .prepare("INSERT OR IGNORE INTO hook_always_rules (agent_id, command_prefix, created_at) VALUES (?, ?, ?)") + .run(agentId, commandPrefix, createdAt); + }, + remove: (agentId, commandPrefix) => { + sqlite + .prepare("DELETE FROM hook_always_rules WHERE agent_id = ? AND command_prefix = ?") + .run(agentId, commandPrefix); + } + }; +} + /** Load the sqlite-vec extension into a connection. Never throws; returns loaded state + error reason. */ export function loadVecExtension(database: NeoDatabase): { loaded: boolean; version?: string; error?: string } { if (database.kind !== "sqlite") return { loaded: false, error: "memory database (no native sqlite)" }; diff --git a/packages/server-local/src/app.ts b/packages/server-local/src/app.ts index 30b4df8..6742f31 100644 --- a/packages/server-local/src/app.ts +++ b/packages/server-local/src/app.ts @@ -3,7 +3,7 @@ import websocket from "@fastify/websocket"; import Fastify, { type FastifyInstance } from "fastify"; import { timingSafeEqual } from "node:crypto"; import { streamDeepSeekChat, embedContents } from "@neo-companion/ai"; -import { createAiConversationStore, createDatabase, createKnowledgeStore, createTaskStore, createWindowEventStore, getAppConfig, setAppConfig, type AiConversationStore, type KnowledgeStore, type NeoDatabase } from "@neo-companion/db"; +import { createAiConversationStore, createDatabase, createHookRulesStore, createKnowledgeStore, getAppConfig, setAppConfig, type AiConversationStore, type KnowledgeStore, type NeoDatabase } from "@neo-companion/db"; import type { ChatMessage, CompanionFeedback, TtsResult } from "@neo-companion/shared"; import { speakWithMimo } from "@neo-companion/tts"; import { createFocusManager } from "./services/focus-manager"; @@ -12,8 +12,17 @@ import { getWeatherSummary } from "./services/weather-service"; import { getActiveWindowSnapshot } from "./services/window-service"; import { registerKnowledgeRoutes } from "./modules/knowledge/routes"; import { createKnowledgeService, type EmbeddingConfig, type KnowledgeService } from "./modules/knowledge/service"; -import { createAiService, resolveMode, type AiService, type ChatContextSelection } from "./modules/ai/service"; +import { createAiService, type AiService } from "./modules/ai/service"; import { WsHub } from "./ws-hub"; +import { registerHealthRoutes } from "./routes/health"; +import { registerTaskRoutes } from "./routes/tasks"; +import { registerFocusRoutes } from "./routes/focus"; +import { registerWeatherRoutes } from "./routes/weather"; +import { registerAiRoutes } from "./routes/ai"; +import { registerTtsRoutes } from "./routes/tts"; +import { registerWindowRoutes } from "./routes/window"; +import { registerHookRoutes } from "./routes/hooks"; +import { registerWsRoutes } from "./routes/ws"; export interface AppDependencies { database?: NeoDatabase; @@ -31,8 +40,6 @@ export async function createApp(dependencies: AppDependencies = {}) { const authToken = dependencies.authToken ?? process.env.APP_AUTH_TOKEN; if (!authToken) throw new Error("APP_AUTH_TOKEN is required"); const database = dependencies.database ?? createDatabase(); - const taskStore = createTaskStore(database); - const windowStore = createWindowEventStore(database); // Knowledge store requires the sqlite path; null when only the memory // fallback is reachable (better-sqlite3 native binding unavailable). Routes // degrade to 503 in that case. @@ -180,176 +187,19 @@ export async function createApp(dependencies: AppDependencies = {}) { hub.broadcast({ type: "permission:autoDismiss", payload }); }, }; - const hookManager = dependencies.hookManager ?? createHookManager(hookEvents); - - app.get("/health", async () => ({ - ok: true, - service: "neo-companion-server-local", - time: new Date().toISOString() - })); - - app.get("/api/tasks", async () => taskStore.list()); - app.post("/api/tasks", async (request, reply) => { - const body = request.body as { title?: string }; - if (!body.title?.trim()) return reply.code(400).send({ error: "title is required" }); - const task = taskStore.create(body.title); - hub.broadcast({ type: "task:statusChanged", payload: task }); - return task; - }); - app.patch("/api/tasks/:id", async (request, reply) => { - const params = request.params as { id: string }; - const task = taskStore.patch(params.id, request.body as { title?: string; status?: "open" | "done" }); - if (!task) return reply.code(404).send({ error: "task not found" }); - hub.broadcast({ type: "task:statusChanged", payload: task }); - return task; - }); + const hookManager = dependencies.hookManager ?? createHookManager(hookEvents, createHookRulesStore(database)); + // ── Route Registration ── + registerHealthRoutes(app); + registerTaskRoutes(app, database, hub); registerKnowledgeRoutes(app, knowledgeStore, knowledgeService, embeddingConfigController, rootPathController); - - app.post("/api/focus/start", async (request) => { - const body = request.body as { taskId?: string | null; durationMinutes?: number }; - const session = focus.start(body.taskId ?? null, body.durationMinutes ?? 25); - hub.broadcast({ - type: "companion:feedback", - payload: { state: "focus", text: "我们开始这一轮专注吧,我会安静陪着你。", speak: true } satisfies CompanionFeedback - }); - return session; - }); - app.post("/api/focus/:id/complete", async (request, reply) => { - const params = request.params as { id: string }; - const session = focus.complete(params.id); - if (!session) return reply.code(404).send({ error: "focus session not found" }); - return session; - }); - - app.get("/api/weather", async () => dependencies.weather?.() ?? getWeatherSummary()); - - app.post("/api/ai/chat", async (request, reply) => { - const body = request.body as { - message?: string; - mode?: string; - projectId?: string; - context?: ChatContextSelection; - conversationId?: string; - }; - if (!body.message?.trim()) return reply.code(400).send({ error: "message is required" }); - - try { - const mode = resolveMode(body.mode); - const answer = mode === "ask" - ? await aiService.handleAsk({ message: body.message, projectId: body.projectId ?? null }) - : await aiService.handleChat({ - message: body.message, - projectId: body.projectId ?? null, - context: body.context, - conversationId: body.conversationId - }); - return answer; - } catch (error) { - const message = error instanceof Error ? error.message : "AI request failed"; - return reply.code(500).send({ error: message }); - } - }); - - app.post("/api/tts/speak", async (request, reply) => { - const body = request.body as { text?: string; style?: string }; - if (!body.text?.trim()) return reply.code(400).send({ error: "text is required" }); - - hub.broadcast({ type: "tts:started", payload: { text: body.text } }); - const result = await ttsSpeak(body.text, body.style ?? "温柔、自然"); - hub.broadcast({ type: "tts:done", payload: result }); - return result; - }); - - app.get("/api/window/active", async () => { - const snapshot = await windowSnapshot(); - windowStore.create(snapshot); - hub.broadcast({ type: "window:activeChanged", payload: snapshot }); - if (snapshot.classification === "distracted") { - hub.broadcast({ - type: "companion:feedback", - payload: { state: "warn", text: "好像有点偏离啦,要不要先回到刚才的任务?", speak: true } satisfies CompanionFeedback - }); - } - return snapshot; - }); - - // ── Hook System Routes ── - - app.post("/api/hook/push", async (request, reply) => { - const body = request.body as { agentId?: string; type?: string; state?: string; description?: string; timestamp?: number }; - if (!body.agentId?.trim()) return reply.code(400).send({ error: "agentId is required" }); - if (body.type !== "status") return reply.code(400).send({ error: "type must be 'status'" }); - if (!body.state) return reply.code(400).send({ error: "state is required" }); - - hookManager.pushEvent({ - agentId: body.agentId, - type: "status", - state: body.state as import("@neo-companion/shared").AgentState, - description: body.description, - timestamp: body.timestamp ?? Date.now(), - }); - return reply.code(204).send(); - }); - - app.post("/api/hook/permission", async (request, reply) => { - const body = request.body as { agentId?: string; command?: string; severity?: number; description?: string }; - if (!body.agentId?.trim()) return reply.code(400).send({ error: "agentId is required" }); - if (!body.command?.trim()) return reply.code(400).send({ error: "command is required" }); - if (typeof body.severity !== "number") return reply.code(400).send({ error: "severity is required" }); - - try { - const response = await hookManager.requestPermission({ - agentId: body.agentId, - command: body.command, - severity: body.severity, - description: body.description, - }); - return response; - } catch (error) { - const message = error instanceof Error ? error.message : "permission request failed"; - if (message === "stale" || message === "agentStateChanged") { - return reply.code(410).send({ error: "request stale" }); - } - if (message === "shutdown") { - return reply.code(503).send({ error: "server shutting down" }); - } - return reply.code(500).send({ error: message }); - } - }); - - app.get("/api/hook/always-rules", async () => hookManager.getAlwaysRules()); - - app.delete("/api/hook/always-rules", async (request, reply) => { - const body = request.body as { agentId?: string; commandPrefix?: string }; - if (!body.agentId?.trim()) return reply.code(400).send({ error: "agentId is required" }); - if (!body.commandPrefix?.trim()) return reply.code(400).send({ error: "commandPrefix is required" }); - hookManager.removeAlwaysRule(body.agentId, body.commandPrefix); - return reply.code(204).send(); - }); - - app.get("/ws", { websocket: true }, (socket) => { - hub.add(socket); - socket.on("message", (raw: Buffer) => { - try { - const message = JSON.parse(raw.toString()) as { type?: string; payload?: Record }; - if (message.type === "ping") { - socket.send(JSON.stringify({ type: "pong", payload: {} })); - } - if (message.type === "permission:response") { - const payload = message.payload as { requestId?: string; decision?: string }; - if (payload.requestId && payload.decision) { - hookManager.resolvePermission( - payload.requestId, - payload.decision as import("@neo-companion/shared").PermissionDecision, - ); - } - } - } catch { - socket.send(JSON.stringify({ type: "ai:error", payload: { message: "Invalid WS message" } })); - } - }); - }); + registerFocusRoutes(app, focus, hub); + registerWeatherRoutes(app, dependencies.weather); + registerAiRoutes(app, aiService); + registerTtsRoutes(app, hub, ttsSpeak); + registerWindowRoutes(app, { database, hub, snapshot: windowSnapshot }); + registerHookRoutes(app, hookManager); + registerWsRoutes(app, hub, hookManager); let windowTimer: NodeJS.Timeout | null = null; if (dependencies.startBackground ?? true) { diff --git a/packages/server-local/src/routes/ai.ts b/packages/server-local/src/routes/ai.ts new file mode 100644 index 0000000..13d6087 --- /dev/null +++ b/packages/server-local/src/routes/ai.ts @@ -0,0 +1,32 @@ +import type { FastifyInstance } from "fastify"; +import type { AiService, ChatContextSelection } from "../modules/ai/service"; +import { resolveMode } from "../modules/ai/service"; + +export function registerAiRoutes(app: FastifyInstance, aiService: AiService) { + app.post("/api/ai/chat", async (request, reply) => { + const body = request.body as { + message?: string; + mode?: string; + projectId?: string; + context?: ChatContextSelection; + conversationId?: string; + }; + if (!body.message?.trim()) return reply.code(400).send({ error: "message is required" }); + + try { + const mode = resolveMode(body.mode); + const answer = mode === "ask" + ? await aiService.handleAsk({ message: body.message, projectId: body.projectId ?? null }) + : await aiService.handleChat({ + message: body.message, + projectId: body.projectId ?? null, + context: body.context, + conversationId: body.conversationId + }); + return answer; + } catch (error) { + const message = error instanceof Error ? error.message : "AI request failed"; + return reply.code(500).send({ error: message }); + } + }); +} diff --git a/packages/server-local/src/routes/focus.ts b/packages/server-local/src/routes/focus.ts new file mode 100644 index 0000000..433a589 --- /dev/null +++ b/packages/server-local/src/routes/focus.ts @@ -0,0 +1,24 @@ +import type { FastifyInstance } from "fastify"; +import type { CompanionFeedback } from "@neo-companion/shared"; +import type { createFocusManager } from "../services/focus-manager"; + +type FocusManager = ReturnType; + +export function registerFocusRoutes(app: FastifyInstance, focus: FocusManager, hub: import("../ws-hub").WsHub) { + app.post("/api/focus/start", async (request) => { + const body = request.body as { taskId?: string | null; durationMinutes?: number }; + const session = focus.start(body.taskId ?? null, body.durationMinutes ?? 25); + hub.broadcast({ + type: "companion:feedback", + payload: { state: "focus", text: "我们开始这一轮专注吧,我会安静陪着你。", speak: true } satisfies CompanionFeedback + }); + return session; + }); + + app.post("/api/focus/:id/complete", async (request, reply) => { + const params = request.params as { id: string }; + const session = focus.complete(params.id); + if (!session) return reply.code(404).send({ error: "focus session not found" }); + return session; + }); +} diff --git a/packages/server-local/src/routes/health.ts b/packages/server-local/src/routes/health.ts new file mode 100644 index 0000000..c9e5a0a --- /dev/null +++ b/packages/server-local/src/routes/health.ts @@ -0,0 +1,9 @@ +import type { FastifyInstance } from "fastify"; + +export function registerHealthRoutes(app: FastifyInstance) { + app.get("/health", async () => ({ + ok: true, + service: "neo-companion-server-local", + time: new Date().toISOString() + })); +} diff --git a/packages/server-local/src/routes/hooks.ts b/packages/server-local/src/routes/hooks.ts new file mode 100644 index 0000000..483dbb7 --- /dev/null +++ b/packages/server-local/src/routes/hooks.ts @@ -0,0 +1,59 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentState } from "@neo-companion/shared"; +import type { createHookManager } from "../services/hook-manager"; + +type HookManager = ReturnType; + +export function registerHookRoutes(app: FastifyInstance, hookManager: HookManager) { + app.post("/api/hook/push", async (request, reply) => { + const body = request.body as { agentId?: string; type?: string; state?: string; description?: string; timestamp?: number }; + if (!body.agentId?.trim()) return reply.code(400).send({ error: "agentId is required" }); + if (body.type !== "status") return reply.code(400).send({ error: "type must be 'status'" }); + if (!body.state) return reply.code(400).send({ error: "state is required" }); + + hookManager.pushEvent({ + agentId: body.agentId, + type: "status", + state: body.state as AgentState, + description: body.description, + timestamp: body.timestamp ?? Date.now() + }); + return reply.code(204).send(); + }); + + app.post("/api/hook/permission", async (request, reply) => { + const body = request.body as { agentId?: string; command?: string; severity?: number; description?: string }; + if (!body.agentId?.trim()) return reply.code(400).send({ error: "agentId is required" }); + if (!body.command?.trim()) return reply.code(400).send({ error: "command is required" }); + if (typeof body.severity !== "number") return reply.code(400).send({ error: "severity is required" }); + + try { + const response = await hookManager.requestPermission({ + agentId: body.agentId, + command: body.command, + severity: body.severity, + description: body.description + }); + return response; + } catch (error) { + const message = error instanceof Error ? error.message : "permission request failed"; + if (message === "stale" || message === "agentStateChanged") { + return reply.code(410).send({ error: "request stale" }); + } + if (message === "shutdown") { + return reply.code(503).send({ error: "server shutting down" }); + } + return reply.code(500).send({ error: message }); + } + }); + + app.get("/api/hook/always-rules", async () => hookManager.getAlwaysRules()); + + app.delete("/api/hook/always-rules", async (request, reply) => { + const body = request.body as { agentId?: string; commandPrefix?: string }; + if (!body.agentId?.trim()) return reply.code(400).send({ error: "agentId is required" }); + if (!body.commandPrefix?.trim()) return reply.code(400).send({ error: "commandPrefix is required" }); + hookManager.removeAlwaysRule(body.agentId, body.commandPrefix); + return reply.code(204).send(); + }); +} diff --git a/packages/server-local/src/routes/tasks.ts b/packages/server-local/src/routes/tasks.ts new file mode 100644 index 0000000..5921c4a --- /dev/null +++ b/packages/server-local/src/routes/tasks.ts @@ -0,0 +1,26 @@ +import type { FastifyInstance } from "fastify"; +import type { NeoDatabase } from "@neo-companion/db"; +import { createTaskStore } from "@neo-companion/db"; +import { WsHub } from "../ws-hub"; + +export function registerTaskRoutes(app: FastifyInstance, database: NeoDatabase, hub: WsHub) { + const taskStore = createTaskStore(database); + + app.get("/api/tasks", async () => taskStore.list()); + + app.post("/api/tasks", async (request, reply) => { + const body = request.body as { title?: string }; + if (!body.title?.trim()) return reply.code(400).send({ error: "title is required" }); + const task = taskStore.create(body.title); + hub.broadcast({ type: "task:statusChanged", payload: task }); + return task; + }); + + app.patch("/api/tasks/:id", async (request, reply) => { + const params = request.params as { id: string }; + const task = taskStore.patch(params.id, request.body as { title?: string; status?: "open" | "done" }); + if (!task) return reply.code(404).send({ error: "task not found" }); + hub.broadcast({ type: "task:statusChanged", payload: task }); + return task; + }); +} diff --git a/packages/server-local/src/routes/tts.ts b/packages/server-local/src/routes/tts.ts new file mode 100644 index 0000000..2b295ac --- /dev/null +++ b/packages/server-local/src/routes/tts.ts @@ -0,0 +1,19 @@ +import type { FastifyInstance } from "fastify"; +import { speakWithMimo } from "@neo-companion/tts"; +import type { WsHub } from "../ws-hub"; + +export function registerTtsRoutes( + app: FastifyInstance, + hub: WsHub, + ttsSpeak: (text: string, style?: string) => Promise +) { + app.post("/api/tts/speak", async (request, reply) => { + const body = request.body as { text?: string; style?: string }; + if (!body.text?.trim()) return reply.code(400).send({ error: "text is required" }); + + hub.broadcast({ type: "tts:started", payload: { text: body.text } }); + const result = await (ttsSpeak ?? speakWithMimo)(body.text, body.style ?? "温柔、自然"); + hub.broadcast({ type: "tts:done", payload: result }); + return result; + }); +} diff --git a/packages/server-local/src/routes/weather.ts b/packages/server-local/src/routes/weather.ts new file mode 100644 index 0000000..eb901a8 --- /dev/null +++ b/packages/server-local/src/routes/weather.ts @@ -0,0 +1,9 @@ +import type { FastifyInstance } from "fastify"; +import { getWeatherSummary } from "../services/weather-service"; + +export function registerWeatherRoutes( + app: FastifyInstance, + weather: (() => Promise) | undefined +) { + app.get("/api/weather", async () => weather?.() ?? getWeatherSummary()); +} diff --git a/packages/server-local/src/routes/window.ts b/packages/server-local/src/routes/window.ts new file mode 100644 index 0000000..fd22f26 --- /dev/null +++ b/packages/server-local/src/routes/window.ts @@ -0,0 +1,29 @@ +import type { FastifyInstance } from "fastify"; +import type { CompanionFeedback } from "@neo-companion/shared"; +import type { NeoDatabase } from "@neo-companion/db"; +import { createWindowEventStore } from "@neo-companion/db"; +import type { getActiveWindowSnapshot } from "../services/window-service"; +import type { WsHub } from "../ws-hub"; + +export interface WindowRouteContext { + database: NeoDatabase; + hub: WsHub; + snapshot: typeof getActiveWindowSnapshot; +} + +export function registerWindowRoutes(app: FastifyInstance, ctx: WindowRouteContext) { + const windowStore = createWindowEventStore(ctx.database); + + app.get("/api/window/active", async () => { + const snapshot = await ctx.snapshot(); + windowStore.create(snapshot); + ctx.hub.broadcast({ type: "window:activeChanged", payload: snapshot }); + if (snapshot.classification === "distracted") { + ctx.hub.broadcast({ + type: "companion:feedback", + payload: { state: "warn", text: "好像有点偏离啦,要不要先回到刚才的任务?", speak: true } satisfies CompanionFeedback + }); + } + return snapshot; + }); +} diff --git a/packages/server-local/src/routes/ws.ts b/packages/server-local/src/routes/ws.ts new file mode 100644 index 0000000..efe6b86 --- /dev/null +++ b/packages/server-local/src/routes/ws.ts @@ -0,0 +1,28 @@ +import type { FastifyInstance } from "fastify"; +import type { PermissionDecision } from "@neo-companion/shared"; +import type { createHookManager } from "../services/hook-manager"; +import type { WsHub } from "../ws-hub"; + +type HookManager = ReturnType; + +export function registerWsRoutes(app: FastifyInstance, hub: WsHub, hookManager: HookManager) { + app.get("/ws", { websocket: true }, (socket) => { + hub.add(socket); + socket.on("message", (raw: Buffer) => { + try { + const message = JSON.parse(raw.toString()) as { type?: string; payload?: Record }; + if (message.type === "ping") { + socket.send(JSON.stringify({ type: "pong", payload: {} })); + } + if (message.type === "permission:response") { + const payload = message.payload as { requestId?: string; decision?: string }; + if (payload.requestId && payload.decision) { + hookManager.resolvePermission(payload.requestId, payload.decision as PermissionDecision); + } + } + } catch { + socket.send(JSON.stringify({ type: "ai:error", payload: { message: "Invalid WS message" } })); + } + }); + }); +} diff --git a/packages/server-local/src/services/hook-manager.ts b/packages/server-local/src/services/hook-manager.ts index e91ef6d..0b4cb98 100644 --- a/packages/server-local/src/services/hook-manager.ts +++ b/packages/server-local/src/services/hook-manager.ts @@ -22,16 +22,28 @@ export interface HookManagerEvents { onPermissionAutoDismiss(payload: PermissionAutoDismissPayload): void; } +/** + * Optional persistence backend for always-allow rules. When omitted the manager + * stays in-memory only (used by some unit tests). Production wiring injects the + * sqlite-backed store from @neo-companion/db so rules survive restarts. + */ +export interface HookRulesPersistence { + list(): Array<{ agentId: string; commandPrefix: string; createdAt: number }>; + add(agentId: string, commandPrefix: string, createdAt: number): void; + remove(agentId: string, commandPrefix: string): void; +} + interface PendingApproval { request: PermissionRequest; resolve: (value: PermissionResponse) => void; reject: (reason: Error) => void; } -export function createHookManager(events: HookManagerEvents) { +export function createHookManager(events: HookManagerEvents, rulesStore?: HookRulesPersistence) { const agentStates = new Map(); const pendingApprovals = new Map(); - const alwaysRules: AlwaysRule[] = []; + // Seed from persistence (if any); subsequent add/remove write through. + const alwaysRules: AlwaysRule[] = rulesStore?.list() ?? []; function pushEvent(event: HookEvent): void { agentStates.set(event.agentId, event.state); @@ -119,14 +131,19 @@ export function createHookManager(events: HookManagerEvents) { function addAlwaysRule(agentId: string, command: string): void { // Don't duplicate if (checkAlways(agentId, command)) return; - alwaysRules.push({ agentId, commandPrefix: command, createdAt: Date.now() }); + const createdAt = Date.now(); + alwaysRules.push({ agentId, commandPrefix: command, createdAt }); + rulesStore?.add(agentId, command, createdAt); } function removeAlwaysRule(agentId: string, commandPrefix: string): void { const idx = alwaysRules.findIndex( (r) => r.agentId === agentId && r.commandPrefix === commandPrefix, ); - if (idx !== -1) alwaysRules.splice(idx, 1); + if (idx !== -1) { + alwaysRules.splice(idx, 1); + rulesStore?.remove(agentId, commandPrefix); + } } function getAlwaysRules(): AlwaysRule[] { diff --git a/packages/server-local/src/tests/hook.test.ts b/packages/server-local/src/tests/hook.test.ts index 2cbf9c8..fa84b0f 100644 --- a/packages/server-local/src/tests/hook.test.ts +++ b/packages/server-local/src/tests/hook.test.ts @@ -301,3 +301,109 @@ describe("hook system", () => { }); }); }); + +// Persistence test runs in its own describe block: it needs a real on-disk +// SQLite file (not :memory:) so a second createApp() call can re-open the same +// database and see the rules that the first instance committed. +describe("Hook always-rules persistence across restart", () => { + it("survives sidecar restart via hook_always_rules table", async () => { + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { createDatabase } = await import("@neo-companion/db"); + + const tmpDir = mkdtempSync(join(tmpdir(), "neo-hook-persist-")); + const dbPath = join(tmpDir, "test.sqlite"); + + try { + // ── Run #1: create rule via always decision ── + const app1 = await createApp({ + authToken: "test-token", + database: createDatabase(dbPath), + startBackground: false + }); + const raw1 = app1.inject.bind(app1); + app1.inject = ((options: any) => raw1(typeof options === "string" ? options : { + ...options, + headers: { authorization: "Bearer test-token", ...options.headers } + })) as typeof app1.inject; + await app1.listen({ port: 0, host: "127.0.0.1" }); + const addr1 = app1.server.address() as AddressInfo; + const baseUrl1 = `http://127.0.0.1:${addr1.port}`; + + const wsConn = await new Promise<{ ws: WebSocket; messages: unknown[] }>((resolve) => { + const ws = new WebSocket(`${baseUrl1.replace("http", "ws")}/ws`, ["neo-companion", "auth.test-token"]); + const messages: unknown[] = []; + ws.on("message", (data: Buffer) => messages.push(JSON.parse(data.toString()))); + ws.on("open", () => resolve({ ws, messages })); + }); + + const reqPromise = app1.inject({ + method: "POST", + url: "/api/hook/permission", + payload: { agentId: "persist/agent", command: "rm -rf", severity: 1 } + }); + // Wait for permission:request to land in WS + await new Promise((resolve) => { + const check = () => { + const msg = wsConn.messages.find((m: any) => m.type === "permission:request"); + if (msg) return resolve(); + setTimeout(check, 10); + }; + check(); + }); + const reqMsg = wsConn.messages.find((m: any) => m.type === "permission:request") as { payload: PermissionRequestPayload }; + wsConn.ws.send(JSON.stringify({ + type: "permission:response", + payload: { requestId: reqMsg.payload.requestId, decision: "always" } + })); + await reqPromise; + + wsConn.ws.close(); + await app1.close(); + + // ── Run #2: fresh app, same db file → rule should be restored ── + const app2 = await createApp({ + authToken: "test-token", + database: createDatabase(dbPath), + startBackground: false + }); + const raw2 = app2.inject.bind(app2); + app2.inject = ((options: any) => raw2(typeof options === "string" ? options : { + ...options, + headers: { authorization: "Bearer test-token", ...options.headers } + })) as typeof app2.inject; + + const listRes = await app2.inject({ method: "GET", url: "/api/hook/always-rules" }); + expect(listRes.statusCode).toBe(200); + const rules = listRes.json() as Array<{ agentId: string; commandPrefix: string }>; + expect(rules.length).toBe(1); + expect(rules[0].agentId).toBe("persist/agent"); + expect(rules[0].commandPrefix).toBe("rm -rf"); + + // Delete rule, then verify deletion is also persisted + await app2.inject({ + method: "DELETE", + url: "/api/hook/always-rules", + payload: { agentId: "persist/agent", commandPrefix: "rm -rf" } + }); + await app2.close(); + + const app3 = await createApp({ + authToken: "test-token", + database: createDatabase(dbPath), + startBackground: false + }); + const raw3 = app3.inject.bind(app3); + app3.inject = ((options: any) => raw3(typeof options === "string" ? options : { + ...options, + headers: { authorization: "Bearer test-token", ...options.headers } + })) as typeof app3.inject; + const listRes3 = await app3.inject({ method: "GET", url: "/api/hook/always-rules" }); + expect((listRes3.json() as unknown[]).length).toBe(0); + await app3.close(); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +});