Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand All @@ -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)" };
Expand Down
194 changes: 22 additions & 172 deletions packages/server-local/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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<string, unknown> };
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) {
Expand Down
32 changes: 32 additions & 0 deletions packages/server-local/src/routes/ai.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
}
24 changes: 24 additions & 0 deletions packages/server-local/src/routes/focus.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createFocusManager>;

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;
});
}
Loading
Loading