diff --git a/CLAUDE.md b/CLAUDE.md index 208b765..eb540b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Overview -NeoCompanion is a local-first desktop AI assistant built with **Tauri v2** (Rust) + **Vue 3** (Vite) + **Fastify** (TypeScript sidecar) + **SQLite** (Drizzle ORM). +NeoCompanion is a local-first desktop AI assistant built with **Tauri v2** (Rust) + **Vue 3** (Vite) + **Fastify** (TypeScript sidecar) + **SQLite** (`node:sqlite`). It provides: @@ -22,7 +22,7 @@ Tauri Rust Core Fastify Sidecar ├─ REST API for tasks, focus timer, AI chat, TTS, weather, hooks, windows ├─ WebSocket hub at /ws for streaming AI replies and companion feedback - └─ SQLite via Drizzle ORM + └─ SQLite via the built-in Node.js `node:sqlite` driver Vue 3 Frontend ├─ 5 views selected by ?view= query param @@ -36,7 +36,7 @@ Vue 3 Frontend | `apps/desktop/src-tauri/` | Rust backend, Tauri commands, window config | | `apps/desktop/src/` | Vue 3 frontend, views, components, composables | | `packages/server-local/` | Fastify sidecar with all business logic | -| `packages/db/` | SQLite schema and stores via Drizzle ORM | +| `packages/db/` | SQLite schema, migrations, and stores via `node:sqlite` | | `packages/shared/` | Shared TypeScript types | | `packages/ai/` | DeepSeek streaming chat adapter | | `packages/tts/` | MiMo TTS adapter | diff --git a/README.md b/README.md index 5621432..58b6aec 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ NeoCompanion 的能力由浅入深分为四层: **知识与 AI 层**——在单一本地工作空间中组织项目、Markdown 笔记、任务与看板;通过全文检索和向量检索为 AI 对话提供可核验的本地上下文。 -**Hook 与系统层**——安全本地多通道 Hook(HTTP / UDS / File Watcher / MQTT);浮动权限审批气泡;本地隐私感知引擎;本地长期记忆。 +**Hook 与系统层**——本地 Hook(HTTP / WebSocket 已实现;UDS / File Watcher / MQTT 规划中);浮动权限审批气泡;本地隐私感知引擎;本地长期记忆。 --- @@ -145,7 +145,7 @@ NeoCompanion 的能力由浅入深分为四层: | 桌面运行时 | **Tauri v2** (Rust) | | 前端 UI | **Vue 3** + Vite + Pinia + TanStack Query | | 本地服务 | **Fastify** (TypeScript Sidecar) | -| 数据库 | **SQLite** (Drizzle ORM) | +| 数据库 | **SQLite** (`node:sqlite` + FTS5 + sqlite-vec) | | AI | 聊天模型适配器 + OpenAI-compatible Embedding Adapter | 架构核心:Tauri (Rust) 提供系统级能力与系统钥匙链 → Fastify (TypeScript) 处理业务逻辑、知识索引与 AI 调度 → Vue 提供 UI → SQLite 统一存储业务数据。知识工作空间已接入 FTS5 全文索引、可选 sqlite-vec 混合检索、文件镜像与带来源的 RAG。 diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index a46648f..372dafb 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -7,6 +7,7 @@ import type { IndexStatus, KnowledgeSource, Task, + TaskListResponse, TtsResult, WeatherSummary, WsMessage @@ -77,7 +78,7 @@ function getAuthToken(): Promise { export const api = { health: () => request<{ ok: boolean }>("/health"), - listTasks: () => request("/api/tasks"), + listTasks: () => request("/api/tasks").then((r) => r.items), createTask: (title: string) => request("/api/tasks", { method: "POST", body: JSON.stringify({ title }) }), patchTask: (id: string, patch: Partial>) => request(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 919ebf7..ab31a66 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ 本文档同时描述 **已实现** 与 **规划中** 的技术设计。已实现的内容指当前代码中真实存在并运行的模块;规划中的内容标注为 `(Planned)` 或 `(Partial)`,例如: -- **已实现**:Tauri 多窗口管理、Fastify Sidecar TCP 模式、SQLite + Drizzle ORM、任务/专注/天气/AI/TTS/窗口/Hook 等核心端点、WebSocket 实时推送、GitHub Actions CI/CD。知识工作空间 v2(Phase 0–4):文件型存储方向、后端 SQLite CRUD + 混合文件镜像、FTS5 全文检索(trigram,支持 CJK)、`sqlite-vec` 向量检索 + RRF 融合、Embedding Adapter(OpenAI 兼容,落库持久化 + 环境变量双通道)、AI Chat/Ask 双模式 + 三级上下文权限 + 引用审计反幻觉 + 多轮会话持久化。 +- **已实现**:Tauri 多窗口管理、Fastify Sidecar TCP 模式、`node:sqlite` 数据库层、任务/专注/天气/AI/TTS/窗口/Hook 等核心端点、WebSocket 实时推送、GitHub Actions CI/CD。知识工作空间 v2(Phase 0–4):文件型存储方向、后端 SQLite CRUD + 混合文件镜像、FTS5 全文检索(trigram,支持 CJK)、`sqlite-vec` 向量检索 + RRF 融合、Embedding Adapter(OpenAI 兼容,落库持久化 + 环境变量双通道)、AI Chat/Ask 双模式 + 三级上下文权限 + 引用审计反幻觉 + 多轮会话持久化。 - **部分实现 (Partial)**:v1 `tasks(open|done)` 与 v2 `knowledge_tasks`(四态)的状态枚举统一尚未执行(延后决策,见 TODO_INVENTORY)。 - **规划中 (Planned)**:零端口 UDS Socket 模式 B、文件监听 Hook、MQTT 接入、屏幕上下文感知与本地长期记忆模块。 @@ -119,7 +119,7 @@ NeoCompanion 是一个本地优先的桌面应用。 └────────────────┘ ``` -#### 模式 B:零端口本地域套接字挂载 (Zero-Port IPC Socket) +#### 模式 B:零端口本地域套接字挂载 (Zero-Port IPC Socket) `(Planned — 见 §1.2;当前仅模式 A 已实现)` *100% 避免本地网络端口冲突,绕过防火墙,极高通信效率与低延迟。* ``` @@ -292,21 +292,21 @@ neo-companion/ │ │ ├── index.html │ │ ├── vite.config.ts │ │ └── package.json -│ └── web/ # Web 管理端 (v2+) +│ └── web/ # Web 管理端 (v2+, Planned — 尚未创建) (Planned — 尚未创建) │ ├── src/ │ ├── vite.config.ts │ └── package.json ├── packages/ │ ├── server-local/ # Fastify 本地服务 (sidecar) │ │ ├── src/ -│ │ │ ├── modules/ # 业务模块 (project, note, board, task, knowledge, ai, hook, settings) -│ │ │ ├── plugins/ # Fastify 插件 +│ │ │ ├── modules/ # 业务模块 (Partial — 仅 modules/knowledge, modules/ai 存在;其余为 routes/ + services/ 扁平结构) +│ │ │ ├── plugins/ # Fastify 插件 (Planned — 实际插件直接在 app.ts 内联注册) │ │ │ └── index.ts # 入口 │ │ └── package.json │ ├── shared/ # 类型、校验、常量 │ │ ├── src/ │ │ │ ├── types/ # TypeScript 类型定义 -│ │ │ ├── validators/ # Zod schema +│ │ │ ├── schemas.ts # TypeBox schema (非 Zod;见 packages/shared/src/) │ │ │ └── constants/ # 枚举、常量 │ │ └── package.json │ ├── ai/ # LLM 抽象层 @@ -318,17 +318,16 @@ neo-companion/ │ │ └── package.json │ ├── db/ # 数据库层 │ │ ├── src/ -│ │ │ ├── schema/ # Drizzle schema 定义 -│ │ │ ├── migrations/ # 迁移文件 -│ │ │ ├── queries/ # 查询封装 -│ │ │ └── index.ts +│ │ │ ├── index.ts # node:sqlite schema、迁移与 store +│ │ │ ├── types.ts # 内部数据库行类型 +│ │ │ └── knowledge-fs.ts # 知识库文件系统辅助 │ │ └── package.json -│ └── ui/ # 共享 UI 组件 +│ └── ui/ # 共享 UI 组件 (Planned — 尚未创建) │ ├── src/ │ │ └── components/ # shadcn-vue 组件 (desktop + web 复用) │ ├── tailwind.config.ts │ └── package.json -├── crates/ # Tauri Rust 插件 +├── crates/ # Tauri Rust 插件 (Planned — 目录尚未创建) │ ├── plugin-window-detect/ # 窗口检测 │ ├── plugin-screen-context/ # 屏幕内容获取 │ └── plugin-app-events/ # 应用状态事件 @@ -341,11 +340,12 @@ neo-companion/ ### 4.1 Package Dependencies ``` -apps/desktop → packages/ui, packages/shared -apps/web → packages/ui, packages/shared +apps/desktop → packages/shared # packages/ui 为 Planned,当前未依赖 +apps/web → packages/ui, packages/shared (Planned — apps/web 与 packages/ui 尚未创建) packages/server-local → packages/ai, packages/db, packages/shared packages/ai → packages/shared packages/db → packages/shared +packages/tts → packages/shared ``` ### 4.2 Build Pipeline (Turborepo) @@ -374,10 +374,10 @@ packages/db → packages/shared | 归属位置 | 放什么 | 示例 | |----------|--------|------| | `packages/shared/src/types/` | 跨包共享的 DTO、API 请求/响应类型、枚举 | `TaskStatus`, `ApiResponse`, `AssistantStatus` | -| `packages/shared/src/validators/` | 跨包共享的运行时校验 (Zod schema) | `createTaskSchema`, `settingsSchema` | +| `packages/shared/src/schemas.ts` | 跨包共享的运行时校验 (TypeBox schema,非 Zod) | `AiChatBodySchema`, `TaskCreateBodySchema` | | `server-local/modules/context/models/` | 上下文模型类型 (v1 单一真相源,v2 抽离为 `packages/context-engine`) | `TaskContext`, `BehaviorContext`, `FullContext` | | `packages/ai/src/adapters/types.ts` | AI 相关接口类型 | `ModelAdapter`, `ChatParams`, `ChatChunk` | -| `packages/db/src/schema/` | 数据库表结构类型 (Drizzle 推断) | `typeof tasks.$inferSelect` | +| `packages/db/src/types.ts` | 数据库包内部行类型(不跨包导出) | `TaskRow`, `KnowledgeChunkRow` | | `server-local/modules/*/schema.ts` | 请求参数验证 schema (不定义业务模型) | Fastify JSON Schema | **原则**: 业务模型类型由拥有该领域逻辑的包定义,`shared` 只放"多包都需要导入"的公共类型。避免在 `shared` 中堆积所有接口。 @@ -390,23 +390,27 @@ packages/db → packages/shared Rust 侧只负责 WebView 和 Node.js 无法直接完成的系统级能力,并在零端口模式下充当关键的通信桥梁: +> **实现状态**:当前 `apps/desktop/src-tauri/src/lib.rs` 仅实现"系统托盘"+ 4 个 keychain 命令 + 三窗口管理 + wallpaper 插件。下表除"系统托盘"外均为 `(Planned)`;sidecar 实际由 Node `scripts/dev.mjs` 拉起(见 §5.3)。实际命令见 `lib.rs`,非下文 §5.2 草图。 + | 能力 | 说明 | 阶段 | |------|------|------| -| 窗口检测 | 获取当前活跃窗口标题、进程名、应用类型 | v1 | -| 系统托盘 | 常驻托盘图标、快捷菜单 | v1 | -| 全局快捷键 | 唤醒/隐藏/快速操作 | v1 | -| Sidecar 管理 | 启动/停止 Fastify 进程、健康检查 | v1 | -| 应用切换事件 | 监听焦点变化、推送事件到 Fastify | v1 | -| UDS/管道 IPC 代理 | **模式 B 下的通信关键**。由于 Webview 浏览器沙箱限制无法直接访问 Unix 套接字与命名管道,由 Rust Core 建立原生 IPC 连接并作为 Bridge 双向透传 WebView 与 Fastify 间的 API 请求与推送事件 | v2 | -| 子进程 stdio 挂载 | 对用户显式启动并授权的外部进程,以管道提取通用状态事件 | v2 | -| 屏幕内容获取 | 用户授权下截图/OCR (v2) | v2 | -| 选中文本提取 | 获取用户选中内容 (v2) | v2 | -| 文件系统监听 | 监听指定目录变化 (v2)。在模式 B 中用于挂载文件监听哨兵 (File Watcher) | v2 | -| 内部系统能力端点 | **模式 A** 下由 Rust Core 暴露 localhost-only 端点安全获取系统凭据中的 API Key(携带 APP_AUTH_TOKEN 校验,不参与向量检索或数据库业务);**模式 B** 下通过本地域套接字安全通信。 | v1 | +| 窗口检测 | 获取当前活跃窗口标题、进程名、应用类型 | v1 (Planned) | +| 系统托盘 | 常驻托盘图标、快捷菜单 | v1 (已实现) | +| 全局快捷键 | 唤醒/隐藏/快速操作 | v1 (Planned) | +| Sidecar 管理 | 启动/停止 Fastify 进程、健康检查 | v1 (Planned — 当前由 Node dev 脚本管理) | +| 应用切换事件 | 监听焦点变化、推送事件到 Fastify | v1 (Planned) | +| UDS/管道 IPC 代理 | **模式 B 下的通信关键**。由于 Webview 浏览器沙箱限制无法直接访问 Unix 套接字与命名管道,由 Rust Core 建立原生 IPC 连接并作为 Bridge 双向透传 WebView 与 Fastify 间的 API 请求与推送事件 | v2 (Planned) | +| 子进程 stdio 挂载 | 对用户显式启动并授权的外部进程,以管道提取通用状态事件 | v2 (Planned) | +| 屏幕内容获取 | 用户授权下截图/OCR (v2) | v2 (Planned) | +| 选中文本提取 | 获取用户选中内容 (v2) | v2 (Planned) | +| 文件系统监听 | 监听指定目录变化 (v2)。在模式 B 中用于挂载文件监听哨兵 (File Watcher) | v2 (Planned) | +| 内部系统能力端点 | **模式 A** 下由 Rust Core 暴露 localhost-only 端点安全获取系统凭据中的 API Key(携带 APP_AUTH_TOKEN 校验,不参与向量检索或数据库业务);**模式 B** 下通过本地域套接字安全通信。 | v1 (Planned — 实际 keychain 经 Tauri 命令直接访问,无 Rust HTTP 端点) | ### 5.2 Tauri IPC 接口设计 +> `(Planned / 草图)` 以下命令签名均为设计草图,**均未实现**。实际 `lib.rs` 仅注册:`get_app_auth_token`、`set_embedding_api_key`、`get_embedding_api_key`、`delete_embedding_api_key`。 + ```rust // src-tauri/src/commands.rs @@ -436,6 +440,8 @@ async fn uds_request( ### 5.3 Sidecar 生命周期 +> `(Partial)` 下文描述 Rust 拉起 sidecar 的设计。**实际**:sidecar 当前由 Node `scripts/dev.mjs` 拉起(生成 `APP_AUTH_TOKEN` 经 env 注入两个子进程);Rust 侧无 spawn/健康检查/UDS 清理代码。生产环境 Rust spawn 为 `(Planned)`。 + Sidecar 进程根据挂载模式进行动态初始化: ``` @@ -630,6 +636,8 @@ Fastify 作为本地 sidecar 进程运行,提供 HTTP API 供 WebView 调用 ### 6.2 Module Structure +> `(Partial)` 下方目录树为设计意图。**实际**为扁平结构:`routes/{ws,tts,tasks,hooks,focus,ai,window,weather,health}.ts` + `services/{weather-service,hook-manager,focus-manager,window-service}.ts` + `ws-hub.ts`/`errors.ts`。仅 `modules/knowledge/` 与 `modules/ai/` 存在;`modules/{task,context,project,note,board,event,companion,review,hook,settings}/` 及 `plugins/` 目录均未创建(插件直接在 `app.ts` 内联注册)。 + ``` packages/server-local/src/ ├── index.ts # 服务入口、端口管理 @@ -764,8 +772,8 @@ esbuild bundle (server-local → 单文件 JS) **⚠️ 原生 C++ 模块打包避坑指南 (SEA 兼容性)**: * **痛点**:Node.js SEA **无法直接打包原生的 `.node` 二进制 C++ 插件**(如 `better-sqlite3` 依赖的 `better_sqlite3.node`)。若打包,Sidecar 会在运行时因加载不到原生驱动而直接崩溃。 -* **解决策略**:必须采用 **Node.js 22.5.0+ 的内置 SQLite 接口 (`node:sqlite`)**。它是编译并直接内嵌入 Node.js 运行时本身的,没有任何外部二进制依赖,完美支持 esbuild 编译和 SEA 单文件注入。 -* **ORM 配套**:Drizzle ORM 原生支持 `node:sqlite` 驱动,开发中统一使用内置的 `node:sqlite` 替代 `better-sqlite3`,从而兼顾极小体积分发与 100% 的打包兼容性。 +* **数据库驱动**:项目要求 Node.js 24+,直接使用内置 `node:sqlite` 和手写参数化 SQL,不依赖第三方 ORM 或 `.node` 数据库驱动。 +* **剩余原生资源**:`sqlite-vec` 通过 `loadExtension()` 加载平台 `.dll` / `.so` / `.dylib`。SEA 构建仍需将对应扩展作为 loose asset 分发,不能宣称整个 sidecar 已是无外部文件的单二进制。 **备选方案**: Bun compile (`bun build --compile`),打包更简单但生态兼容性需验证。 @@ -1057,17 +1065,17 @@ const DEFAULT_RULES: PrivacyRule[] = [ ### 9.1 Technology -- **SQLite + Drizzle ORM**:业务数据的唯一事实源,使用 WAL 保证桌面场景下的并发稳定性。 +- **SQLite + `node:sqlite`**:业务数据的唯一事实源,使用 WAL 和 5 秒 busy timeout 保证桌面场景下的并发稳定性。 - **SQLite FTS5**:标题、正文、标签和任务描述的全文检索,向量能力不可用时仍可独立工作。 - **sqlite-vec**:与主数据库同进程加载的向量扩展,不在 Rust Core 或独立数据库中维护第二份数据。 - **系统凭据存储**:保存 Chat / Embedding API Key;数据库只保存 Provider、端点、模型和索引元数据。 ### 9.2 Core Schema -```typescript -// packages/db/src/schema/index.ts +> **注意**:下方是早期产品数据模型草图,不是可执行 schema。实际结构以 `packages/db/src/index.ts` 的 `initSchema()` 和 `runSchemaMigrations()` 为准。主要差异:无 `boards` 表;`memories`/`reviews` 尚未创建;`tasks` 与 `knowledge_tasks` 分表。 -import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; +```text +// 声明式概念伪代码;当前实现不使用 ORM。 // 项目。系统迁移时创建默认“收件箱”。 export const projects = sqliteTable('projects', { @@ -1081,6 +1089,7 @@ export const projects = sqliteTable('projects', { updatedAt: text('updated_at').notNull(), }); +// (Planned — 实际无 boards 表;board_columns 直接挂 project_id,见 index.ts) export const boards = sqliteTable('boards', { id: text('id').primaryKey(), projectId: text('project_id').notNull().references(() => projects.id), @@ -1090,6 +1099,7 @@ export const boards = sqliteTable('boards', { export const boardColumns = sqliteTable('board_columns', { id: text('id').primaryKey(), + // 实际字段为 projectId(非 boardId);status 枚举 todo|doing|done|archived;order(非 position) boardId: text('board_id').notNull().references(() => boards.id), title: text('title').notNull(), status: text('status').notNull().default('todo'), // 'todo' | 'doing' | 'done' | 'archived' @@ -1171,6 +1181,7 @@ export const events = sqliteTable('events', { }); // 助手长期记忆与用户知识分表管理,不进入知识库索引,除非用户后续显式开启。 +// (Planned — 表尚未在 index.ts 中创建) export const memories = sqliteTable('memories', { id: text('id').primaryKey(), content: text('content').notNull(), @@ -1181,7 +1192,7 @@ export const memories = sqliteTable('memories', { accessCount: integer('access_count').default(0), }); -// 复盘 +// 复盘 (Planned — 表尚未在 index.ts 中创建) export const reviews = sqliteTable('reviews', { id: text('id').primaryKey(), date: text('date').notNull(), @@ -1305,21 +1316,18 @@ Embedding provider 的非敏感配置(provider/baseUrl/model)持久化到 `a ### 9.6 Migration Strategy -使用 Drizzle Kit 管理数据库迁移: +使用手写版本表迁移:`packages/db/src/index.ts` 的 `runSchemaMigrations()` 读取 `schema_migrations` 版本表,逐版本 `apply(version, migrate)`,每个版本在事务内执行 DDL 并记录版本号。当前已应用 v1–v5。 ```bash -pnpm --filter db drizzle-kit generate # 生成迁移 -pnpm --filter db drizzle-kit migrate # 执行迁移 +# 迁移随应用启动自动执行(initSchema → runSchemaMigrations),无需手动命令。 +# 新增版本:在 runSchemaMigrations 中追加 apply(N, () => { ... }) 块。 ``` 应用启动时自动检查并执行待应用的迁移。 -知识工作空间首次迁移必须在单一事务中完成: +知识工作空间表结构在 `initSchema` 中以 `CREATE TABLE IF NOT EXISTS` 创建;`ensureInbox()` 在运行时确保默认"收件箱"项目存在。默认看板/四列的批量 bootstrap 在迁移中尚未实现(见 TODO_INVENTORY)。 -1. 创建默认“收件箱”项目、默认看板和“待办 / 进行中 / 已完成 / 归档”四列;若项目已存在自定义列,则保留已有列。 -2. 为旧 `tasks` 行补齐项目、看板、列、位置和更新时间;`open` 映射为 `todo` 并进入“待办”列,`done` 映射为 `done` 并进入“已完成”列,保留原 ID、标题和完成时间。 -3. 创建 notes、tags、knowledge_chunks、FTS5 与 sqlite-vec 结构;旧任务随后进入增量索引队列。 -4. sqlite-vec 加载失败不得回滚业务表迁移;记录 `fts-only` 能力状态并继续启动。 +sqlite-vec 加载失败不得回滚业务表迁移;记录 `fts-only` 能力状态并继续启动。 ### 9.7 SQLite 并发并发安全与高频事件防抖写入 (SQLite Concurrency & Debounced Writes) @@ -1333,7 +1341,7 @@ pnpm --filter db drizzle-kit migrate # 执行迁移 db.run(sql`PRAGMA busy_timeout = 5000;`); // 5秒锁等待超时,防止锁冲突直接抛错 ``` 2. **高频事件内存队列防抖合并(Debounced Write Queue)**: - 活跃检测相关的日志、小步长的心跳事件等,严禁直接触发 Drizzle 写入。 + 活跃检测相关的日志、小步长的心跳事件等,严禁逐条同步写入 SQLite。 * Fastify 的 `EventService` 内部维持一个 `memoryQueue: Event[]` 缓冲数组。 * **缓冲策略**:采用 5 秒周期性批量合并写入 (`db.insert().values(queue)`),或只在**用户活动状态发生断裂变化时**(如从专注 Focus 进入分心 Distracted,或者转为空闲 Idle)立即清空队列执行写入。 * 这一合并机制可降低 SQLite 95% 以上的写事务吞吐,极大保护了用户的固态硬盘寿命,并消除助手动作渲染卡帧现象。 @@ -1348,10 +1356,12 @@ Hook 机制与权限控制系统是 NeoCompanion 接收外部状态并提供通 NeoCompanion 只公开接入协议和用户可复制的配置片段,不扫描第三方配置文件,也不自动写入插件或 Hook。外部程序由用户主动选择以下通道: -- HTTP:向 `/api/hook/push` 或 `/api/hook/permission` 主动发送请求。 -- File Watcher:写入 `~/.neo-companion/hooks/` 下的 JSON 文件。 -- UDS / Named Pipe:在零端口模式下使用与 HTTP 等价的消息结构。 -- WebSocket:已建立连接的客户端订阅状态和审批结果。 +> `(Partial)` 当前仅 HTTP 与 WebSocket 通道已实现;File Watcher、UDS/Named Pipe 为 `(Planned)`。 + +- HTTP:向 `/api/hook/push` 或 `/api/hook/permission` 主动发送请求。(已实现) +- File Watcher:写入 `~/.neo-companion/hooks/` 下的 JSON 文件。(Planned) +- UDS / Named Pipe:在零端口模式下使用与 HTTP 等价的消息结构。(Planned) +- WebSocket:已建立连接的客户端订阅状态和审批结果。(已实现) 所有接入都使用通用 `agentId`、`state`、`description` 与 `metadata` 字段。设置页可以展示端点、协议示例和连接状态,但不得枚举或修改其它应用的配置。 @@ -1361,7 +1371,7 @@ NeoCompanion 只公开接入协议和用户可复制的配置片段,不扫描 当已接入的外部程序发起涉及系统敏感指令(如危险脚本执行或文件覆写)的请求时,Fastify 端通过**内存挂起 Promise** 机制阻断请求,直至用户审批或超时。 -#### 10.2.0 零端口模式 (Mode B) 下外部 Hook 的三维连通机制 +#### 10.2.0 零端口模式 (Mode B) 下外部 Hook 的三维连通机制 `(Planned — 依赖模式 B)` 在 **模式 B (零端口模式)** 下,本地没有开放任何 TCP 端口,外部 Executor 无法通过标准 localhost TCP 网络直接连接。为此,系统采用“免网络文件哨兵 + 原生 UDS + 局域回环代理”的三维通信保障: @@ -1415,7 +1425,8 @@ NeoCompanion 只公开接入协议和用户可复制的配置片段,不扫描 后端维护一个全局活跃审批映射表 `PendingApprovalsMap`,保存挂起状态以及 Promise 的句柄: ```typescript -// packages/server-local/src/modules/hook/types.ts +// 实际: types 在 @neo-companion/shared + services/hook-manager.ts (已实现,形状略异) +// packages/server-local/src/modules/hook/types.ts (设计草图路径) export interface PendingApproval { requestId: string; command: string; @@ -1433,7 +1444,8 @@ export const pendingApprovals = new Map(); 在路由接收对敏感操作的请求时,生成唯一的 `requestId`,并将挂起句柄写入 Map,同时通过 WebSocket 异步推送到 WebView 渲染气泡卡片: ```typescript -// packages/server-local/src/modules/hook/routes.ts +// 实际: packages/server-local/src/routes/hooks.ts (已实现) +// packages/server-local/src/modules/hook/routes.ts (设计草图路径) import { FastifyInstance } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { pendingApprovals } from './service'; @@ -1486,7 +1498,8 @@ export async function hookRoutes(fastify: FastifyInstance) { 当用户通过 UI 点击或系统快捷键回应后,WebSocket 连接触发解挂: ```typescript -// packages/server-local/src/modules/hook/websocket.ts +// 实际: packages/server-local/src/ws-hub.ts + routes/ws.ts (已实现) +// packages/server-local/src/modules/hook/websocket.ts (设计草图路径) import { pendingApprovals } from './service'; export function handleWebSocketMessage(message: any) { @@ -1553,7 +1566,7 @@ interface HookEventSyncMessage { --- -### 10.4 全局热键拦截与状态驱动流 (Global Hotkeys Registry) +### 10.4 全局热键拦截与状态驱动流 (Global Hotkeys Registry) `(Planned — shortcuts.rs 与 usePermissionShortcuts.ts 均未实现)` 为保障极致心流,用户无需点击浮窗,可直接使用全局系统热键 `Ctrl+Shift+Y` (放行) 和 `Ctrl+Shift+N` (拒绝) 响应最新的审批请求。 @@ -1713,7 +1726,7 @@ export function usePermissionShortcuts() { 2. **本地环境变量覆写(开发/测试环境)**: 系统在初始化模型实例时,优先检查系统变量或本地 `.env` 中的 `DEEPSEEK_API_KEY` 等参数。若检测到配置,则直接覆写并采用此变量。这避免了开发人员调试或 CI/CD 自动化测试时需要通过 GUI 手动设置的麻烦。 -3. **动态单次 Token 鉴权握手**: +3. **动态单次 Token 鉴权握手** `(Partial — 当前由 Node scripts/dev.mjs 生成随机 Token 经 env 变量注入;Rust spawn + stdio pipe 为 Planned)`: 为防止命令行参数被同用户下的其他进程通过系统工具(如 Windows `wmic` 或 Linux `/proc`)嗅探并越权窃取 Token,Tauri Rust Core 启动 Fastify Sidecar 进程时,**不得在命令行参数中直接包含明文 Token**。 * **安全传输方案**:Tauri 应在 Spawn 子进程时通过环境内存表注入,或在 Spawn 后立即通过 Stdio Pipe (标准输入流管道) 异步将随机高维 `APP_AUTH_TOKEN` (UUIDv4) 传给 Sidecar,由 Fastify 保存为内存环境变量。 * **请求验证**:Fastify 调用 Rust Core 任何内部敏感系统端点时,必须在 HTTP Header 中携带 `Authorization: Bearer ` 鉴权头进行动态令牌验证,保障网络沙箱完全隔离。 @@ -1889,7 +1902,7 @@ NeoCompanion 是一个**本地优先**的桌面 AI 陪伴与知识工作空间 - **Tauri (Rust)** 提供系统级能力和桌面运行时; - **Fastify (TypeScript)** 作为本地 sidecar 处理业务逻辑和 AI 调度; - **Vue + Vite** 提供 UI; -- **SQLite + Drizzle + FTS5 + sqlite-vec** 统一存储业务数据与搜索索引; +- **SQLite + `node:sqlite` + FTS5 + sqlite-vec** 统一存储业务数据与搜索索引; - **Chat Model Adapter + EmbeddingAdapter** 提供可替换的云端 AI 能力; - **Knowledge Pipeline** 将项目、笔记和统一任务转化为可检索、可引用的上下文; - **Context Pipeline** 继续将系统事件转化为结构化任务上下文。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cb738ca..8b128c7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -6,7 +6,7 @@ | 工具 | 版本 | 说明 | |------|------|------| -| Node.js | 22.x | 建议使用 LTS | +| Node.js | 24.x | 必需;数据库使用内置 `node:sqlite` | | pnpm | 10.32+ | 由 `packageManager` 字段强制锁定 | | Rust | stable | 用于 Tauri v2 后端 | | Git | 任意 | | @@ -104,7 +104,7 @@ NeoCompanion/ ├── apps/desktop/src-tauri/ # Rust / Tauri 后端 ├── apps/desktop/src/ # Vue 3 前端 ├── packages/server-local/ # Fastify sidecar -├── packages/db/ # SQLite + Drizzle ORM +├── packages/db/ # SQLite + node:sqlite ├── packages/shared/ # 共享 TypeScript 类型 ├── packages/ai/ # DeepSeek 聊天适配器 ├── packages/tts/ # MiMo TTS 适配器 @@ -117,9 +117,9 @@ NeoCompanion/ ## 常见问题 -### `pnpm install` 在 `better-sqlite3` 上失败 +### 启动时报 `node:sqlite` 不可用 -`better-sqlite3` 是原生模块。请确保 Node.js 版本兼容,且 `node-gyp` 能找到 Python。Windows 上建议安装 Visual Studio Build Tools 的"使用 C++ 的桌面开发"工作负载。 +项目要求 Node.js 24+。请先运行 `node --version`;旧版 Node 无法加载内置 SQLite 驱动。`sqlite-vec` 仍按平台安装可加载扩展,若向量检索不可用,请重新执行 `pnpm install` 并确认对应平台包未被包管理器裁剪。 ### Linux 上 Tauri 构建失败 diff --git a/docs/SPIKE_NODE_SQLITE.md b/docs/SPIKE_NODE_SQLITE.md new file mode 100644 index 0000000..222f7f2 --- /dev/null +++ b/docs/SPIKE_NODE_SQLITE.md @@ -0,0 +1,72 @@ +# Spike: node:sqlite 迁移可行性验证 + +- **日期**: 2026-06-22 +- **环境**: Windows 10 (win32 x64), Node v24.14.0 +- **脚本**: `scripts/spike-node-sqlite.mjs` +- **目的**: 验证 `better-sqlite3` → `node:sqlite` 迁移的可行性,为 SEA 打包扫清障碍 + +## 背景 + +`ARCHITECTURE.md` §6.4 设计 sidecar 用 Node.js SEA 打包分发,但 SEA 无法装载原生 `.node` 插件(如 `better-sqlite3`),需迁移到内置的 `node:sqlite`。本 spike 验证 5 个未知项。 + +## 验证结果 + +| # | 验证项 | 结果 | 详情 | +|---|--------|------|------| +| 1 | node:sqlite 可用性 | ✅ PASS | Node v24.14.0,`DatabaseSync` 导入成功(有 experimental 警告) | +| 2 | FTS5(trigram,CJK) | ✅ PASS | `ENABLE_FTS5` 编译选项已启用;trigram tokenizer 可创建;3字中文词 MATCH 命中 | +| 3 | sqlite-vec 加载 + KNN | ✅ PASS | `sqlite-vec@0.1.9` 经 `loadExtension` 加载成功;vec0 虚拟表创建 + KNN 查询正确返回最近邻 | +| 4 | 旧 ORM/驱动依赖清理 | ✅ PASS | `drizzle-orm` 与 `better-sqlite3` 均已不可解析 | +| 5 | transaction shim(嵌套+回滚) | ✅ PASS | 手写 `BEGIN/COMMIT/ROLLBACK` + `SAVEPOINT` 嵌套方案可行 | + +### 关键发现 + +**1. FTS5 在 Windows 的 node:sqlite 中可用** + +- `PRAGMA compile_options` 确认 `ENABLE_FTS5` 已编译进 Node 内置 SQLite。 +- trigram tokenizer 可正常创建和查询。 +- 注意:trigram tokenizer 要求查询词 ≥3 字符(按 Unicode 码点),2字中文词(如"向量")无法 MATCH,需走 LIKE fallback —— 这与项目现有 `searchFts` 的 `useMatch` 逻辑一致(`packages/db/src/index.ts`)。 + +**2. sqlite-vec 与 node:sqlite 兼容** + +- `sqlite-vec` 的 `load(db)` 仅要求 db 对象有 `loadExtension(path)` 方法,`DatabaseSync` 满足。 +- 关键前提:构造时必须传 `{ allowExtension: true }`,否则 `loadExtension` 抛异常。 +- 平台二进制(`sqlite-vec-windows-x64`)是 `.dll` 而非 `.node`,可作为 loose asset 随 SEA 分发。 + +**3. Drizzle node-sqlite driver 尚未进入稳定版** + +- 当前 npm 稳定版 `drizzle-orm@0.45.2` **没有** `node-sqlite` driver(目录和 exports 均无)。 +- 经查 `drizzle-orm@1.0.0-rc.4-5d5b77c`(rc 通道)**已包含**完整 `node-sqlite` driver(`driver.js`/`session.js`/`migrator.js`)。 +- 如果坚持保留 ORM,迁移需使用 `1.0.0-rc.*` 通道;本项目最终选择直接使用参数化 SQL,避免引入该预发布依赖。 + +**4. transaction API 差异可填补** + +- `node:sqlite` 的 `DatabaseSync` 没有 `.transaction()` 方法(better-sqlite3 有)。 +- 项目有 8 处 `sqlite.transaction(fn)()` 调用,可用手写 `withTransaction(db, fn)` shim(BEGIN/COMMIT/ROLLBACK + SAVEPOINT 嵌套)替代,spike 已验证可行。 + +## 结论 + +### 已选择直接使用 node:sqlite + +| 维度 | 状态 | +|------|------| +| node:sqlite 运行时 | ✅ 可用(项目固定 Node 24+) | +| FTS5 | ✅ 可用(ENABLE_FTS5 已编译) | +| sqlite-vec | ✅ 兼容(需 `{ allowExtension: true }`) | +| transaction 重写 | ✅ 已使用 SAVEPOINT 支持嵌套与局部回滚 | +| 旧 ORM/驱动 | ✅ 已清除 | + +### 已采用方案 + +- 数据库 store 直接使用参数化 SQL 和内部行类型,不引入预发布 ORM。 +- 数据库连接启用 WAL、外键、5 秒 busy timeout 和扩展加载。 +- 事务统一使用 `BEGIN` / `SAVEPOINT` / `ROLLBACK TO`,支持镜像导入触发索引重建时的嵌套调用。 +- `sqlite-vec` 的平台 `.dll` / `.so` / `.dylib` 仍需作为 SEA loose asset 分发;本次迁移不等于完成生产 sidecar 打包。 + +## 复现 + +```bash +node scripts/spike-node-sqlite.mjs +``` + +预期输出:5 PASS / 0 FAIL。 diff --git a/package.json b/package.json index a2759d7..9c0409a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", + "engines": { + "node": ">=24.0.0" + }, "packageManager": "pnpm@10.32.0", "scripts": { "dev": "node scripts/dev.mjs web", @@ -19,7 +22,6 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "better-sqlite3", "esbuild" ] } diff --git a/packages/db/package.json b/packages/db/package.json index 3edf42c..aa5e8d7 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -14,12 +14,9 @@ }, "dependencies": { "@neo-companion/shared": "workspace:*", - "better-sqlite3": "12.10.0", - "drizzle-orm": "0.45.2", "sqlite-vec": "^0.1.9" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", "typescript": "6.0.3", "vitest": "4.1.7" diff --git a/packages/db/src/db.test.ts b/packages/db/src/db.test.ts index c148398..b9e6a3f 100644 --- a/packages/db/src/db.test.ts +++ b/packages/db/src/db.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { createDatabase, createFocusStore, createKnowledgeStore, createTaskStore, createWindowEventStore } from "./index"; -// better-sqlite3's native binding may be unavailable in some environments -// (e.g. Node 24 without a prebuilt binary). The knowledge store requires the -// sqlite path; skip its tests when only the memory fallback is reachable. +// The knowledge store requires the sqlite path; skip its tests when only the +// memory fallback is reachable. const SQLITE_AVAILABLE = createDatabase(":memory:").kind === "sqlite"; describe("db stores", () => { @@ -14,7 +17,8 @@ describe("db stores", () => { const task = tasks.create("写 v1 骨架"); expect(task.status).toBe("open"); - expect(tasks.list()).toHaveLength(1); + expect(tasks.list().items).toHaveLength(1); + expect(tasks.list().total).toBe(1); const done = tasks.patch(task.id, { status: "done" }); expect(done?.completedAt).toBeTruthy(); @@ -43,6 +47,74 @@ describe("db stores", () => { }); describe.skipIf(!SQLITE_AVAILABLE)("knowledge store", () => { + it("supports nested transactions and rolls back only the failing savepoint", () => { + const database = createDatabase(":memory:"); + const kw = createKnowledgeStore(database); + + kw.runInTransaction(() => { + kw.createProject({ title: "outer-before" }); + try { + kw.runInTransaction(() => { + kw.createProject({ title: "inner-rollback" }); + throw new Error("intentional nested rollback"); + }); + } catch { + // The outer transaction must remain usable after the savepoint rollback. + } + const project = kw.createProject({ title: "outer-after" }); + const note = kw.createNote(project.id, "nested index"); + kw.reindexNote(note, (text) => [{ content: text, contentHash: "nested-hash" }]); + }); + + expect(kw.listProjects().map((project) => project.title)).toEqual(["outer-before", "outer-after"]); + expect(kw.searchFts(null, "nested index", 5)).toHaveLength(1); + database.close(); + }); + + it("reorders tasks within the uncolumned lane while preserving null storage", () => { + const database = createDatabase(":memory:"); + const kw = createKnowledgeStore(database); + + const project = kw.createProject({ title: "uncolumned" }); + const first = kw.createTask(project.id, "", "first"); + const second = kw.createTask(project.id, "", "second"); + kw.updateTask(first.id, { order: 0 }); + kw.updateTask(second.id, { order: 1 }); + + kw.moveTask(second.id, "", 0); + + const ordered = kw.tasksForProject(project.id); + expect(ordered.map((task) => task.id)).toEqual([second.id, first.id]); + expect(ordered.map((task) => task.order)).toEqual([0, 1]); + expect(ordered.map((task) => task.columnId)).toEqual(["", ""]); + if (database.kind === "sqlite") { + const rows = database.sqlite + .prepare('SELECT id, column_id FROM knowledge_tasks WHERE id IN (?, ?) ORDER BY "order"') + .all(second.id, first.id) as unknown as Array<{ id: string; column_id: string | null }>; + expect(rows.map((row) => row.column_id)).toEqual([null, null]); + } + database.close(); + }); + + it("moves a columned task to the uncolumned lane as null storage", () => { + const database = createDatabase(":memory:"); + const kw = createKnowledgeStore(database); + + const project = kw.createProject({ title: "move-to-uncolumned" }); + const column = kw.createColumn(project.id, { title: "todo", status: "todo", order: 0 }); + const task = kw.createTask(project.id, column.id, "columned"); + + kw.moveTask(task.id, "", 0); + + const moved = kw.tasksForProject(project.id)[0]; + expect(moved).toMatchObject({ id: task.id, columnId: "", order: 0 }); + if (database.kind === "sqlite") { + const row = database.sqlite.prepare("SELECT column_id FROM knowledge_tasks WHERE id = ?").get(task.id) as { column_id: string | null }; + expect(row.column_id).toBeNull(); + } + database.close(); + }); + it("persists projects, notes, columns, tasks with mock-compatible shapes", () => { const database = createDatabase(":memory:"); const kw = createKnowledgeStore(database); @@ -91,6 +163,57 @@ describe.skipIf(!SQLITE_AVAILABLE)("knowledge store", () => { }); }); +describe.skipIf(!SQLITE_AVAILABLE)("legacy database compatibility", () => { + it("opens an existing database and preserves rows while applying migrations", () => { + const dir = mkdtempSync(join(tmpdir(), "neo-db-legacy-")); + const filename = join(dir, "legacy.sqlite"); + const legacy = new DatabaseSync(filename); + const vector = new Uint8Array(new Float32Array([0.25, 0.75]).buffer); + + try { + legacy.exec(` + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_at TEXT NOT NULL, + completed_at TEXT + ); + CREATE TABLE embedding_cache ( + content_hash TEXT PRIMARY KEY, + embedding BLOB NOT NULL, + model TEXT NOT NULL, + dimensions INTEGER NOT NULL + ); + `); + legacy.prepare("INSERT INTO tasks (id, title, status, created_at, completed_at) VALUES (?, ?, ?, ?, ?)") + .run("legacy-task", "preserved task", "open", "2026-01-01T00:00:00.000Z", null); + legacy.prepare("INSERT INTO embedding_cache (content_hash, embedding, model, dimensions) VALUES (?, ?, ?, ?)") + .run("legacy-hash", vector, "legacy-model", 2); + } finally { + legacy.close(); + } + + const database = createDatabase(filename); + try { + expect(database.kind).toBe("sqlite"); + expect(createTaskStore(database).list().items[0]).toMatchObject({ + id: "legacy-task", + title: "preserved task" + }); + expect(createKnowledgeStore(database).getCachedEmbedding("legacy-hash", "legacy-model")?.vector) + .toEqual(expect.arrayContaining([0.25, 0.75])); + if (database.kind === "sqlite") { + const versions = database.sqlite.prepare("SELECT version FROM schema_migrations ORDER BY version").all(); + expect(versions).toHaveLength(5); + } + } finally { + database.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe.skipIf(!SQLITE_AVAILABLE)("knowledge vector index", () => { // Minimal chunk fn: one chunk per whole text (db test stays free of the // server-local chunker). content-hash via a simple substring stamp. diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f2207c2..af83011 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,10 +1,8 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { mkdirSync } from "node:fs"; -import Database from "better-sqlite3"; +import { DatabaseSync } from "node:sqlite"; import * as sqliteVec from "sqlite-vec"; -import { drizzle } from "drizzle-orm/better-sqlite3"; -import { and, eq, like, asc, inArray, ne, or, isNull, lt, lte } from "drizzle-orm"; import type { AiConversation, AiMessage, @@ -20,30 +18,26 @@ import type { Task, WindowSnapshot } from "@neo-companion/shared"; -import { - aiConversations, - aiMessages, - focusSessions, - knowledgeBoardColumns, - knowledgeChunks, - knowledgeNoteTags, - knowledgeNotes, - knowledgeProjects, - knowledgeTags, - knowledgeTasks, - embeddingCache, - tasks, - windowEvents -} from "./schema"; - -export * from "./schema"; +import type { + AiConversationRow, + AiMessageRow, + BoardColumnRow, + EmbeddingCacheRow, + FocusSessionRow, + KnowledgeChunkRow, + KnowledgeTaskRow, + NoteRow, + ProjectRow, + TaskRow, + WindowEventRow +} from "./types"; + export * from "./knowledge-fs"; export type NeoDatabase = | { kind: "sqlite"; - sqlite: Database.Database; - db: ReturnType; + sqlite: DatabaseSync; close: () => void; } | { @@ -64,7 +58,7 @@ export type NeoDatabase = * - macOS: `~/Library/Application Support/NeoCompanion/neo-companion.sqlite` * - Linux: `${XDG_DATA_HOME:-~/.local/share}/NeoCompanion/neo-companion.sqlite` * - * Ensures the parent directory exists so better-sqlite3 can open the file. + * Ensures the parent directory exists so the database can open the file. */ export function resolveDefaultDbPath(): string { const override = process.env.NEO_DB_PATH; @@ -87,16 +81,17 @@ export function resolveDefaultDbPath(): string { export function createDatabase(filename = resolveDefaultDbPath()) { try { - const sqlite = new Database(filename); - sqlite.pragma("journal_mode = WAL"); - sqlite.pragma("foreign_keys = ON"); - const db = drizzle(sqlite); + const sqlite = new DatabaseSync(filename, { + allowExtension: true, + timeout: 5_000 + }); + sqlite.exec("PRAGMA journal_mode = WAL"); + sqlite.exec("PRAGMA foreign_keys = ON"); initSchema(sqlite); return { kind: "sqlite" as const, sqlite, - db, close: () => sqlite.close() }; } catch (error) { @@ -113,10 +108,10 @@ export function createDatabase(filename = resolveDefaultDbPath()) { } } -/** True when the better-sqlite3 native binding loads (sqlite path reachable). */ +/** True when the sqlite native binding loads (sqlite path reachable). */ export function isSqliteAvailable(): boolean { try { - const probe = new Database(":memory:"); + const probe = new DatabaseSync(":memory:", { allowExtension: true }); probe.close(); return true; } catch { @@ -124,7 +119,40 @@ export function isSqliteAvailable(): boolean { } } -export function initSchema(sqlite: Database.Database) { +interface TransactionState { + depth: number; + nextSavepoint: number; +} + +const transactionStates = new WeakMap(); + +function withTransaction(sqlite: DatabaseSync, fn: () => T): T { + const state = transactionStates.get(sqlite) ?? { depth: 0, nextSavepoint: 0 }; + transactionStates.set(sqlite, state); + + const savepoint = state.depth === 0 ? null : `neo_tx_${state.nextSavepoint++}`; + sqlite.exec(savepoint ? `SAVEPOINT ${savepoint}` : "BEGIN"); + state.depth += 1; + + try { + const result = fn(); + sqlite.exec(savepoint ? `RELEASE SAVEPOINT ${savepoint}` : "COMMIT"); + return result; + } catch (error) { + if (savepoint) { + sqlite.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`); + sqlite.exec(`RELEASE SAVEPOINT ${savepoint}`); + } else { + sqlite.exec("ROLLBACK"); + } + throw error; + } finally { + state.depth -= 1; + if (state.depth === 0) transactionStates.delete(sqlite); + } +} + +export function initSchema(sqlite: DatabaseSync) { sqlite.exec(` CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, @@ -299,18 +327,18 @@ export function initSchema(sqlite: Database.Database) { runSchemaMigrations(sqlite); } -function runSchemaMigrations(sqlite: Database.Database): void { - const applied = new Set((sqlite.prepare("SELECT version FROM schema_migrations").all() as Array<{ version: number }>).map((r) => r.version)); +function runSchemaMigrations(sqlite: DatabaseSync): void { + const applied = new Set((sqlite.prepare("SELECT version FROM schema_migrations").all() as unknown as Array<{ version: number }>).map((r) => r.version)); const apply = (version: number, migrate: () => void) => { if (applied.has(version)) return; - sqlite.transaction(() => { + withTransaction(sqlite, () => { migrate(); sqlite.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(version, new Date().toISOString()); - })(); + }); }; apply(1, () => { - const columns = sqlite.prepare("PRAGMA table_info(embedding_cache)").all() as Array<{ name: string; pk: number }>; + const columns = sqlite.prepare("PRAGMA table_info(embedding_cache)").all() as unknown as Array<{ name: string; pk: number }>; const composite = columns.filter((c) => c.pk > 0).length === 2; if (!composite) { sqlite.exec(` @@ -328,7 +356,7 @@ function runSchemaMigrations(sqlite: Database.Database): void { } }); apply(2, () => { - const names = new Set((sqlite.prepare("PRAGMA table_info(knowledge_chunks)").all() as Array<{ name: string }>).map((c) => c.name)); + const names = new Set((sqlite.prepare("PRAGMA table_info(knowledge_chunks)").all() as unknown as Array<{ name: string }>).map((c) => c.name)); if (!names.has("retry_count")) sqlite.exec("ALTER TABLE knowledge_chunks ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"); if (!names.has("next_retry_at")) sqlite.exec("ALTER TABLE knowledge_chunks ADD COLUMN next_retry_at TEXT"); }); @@ -433,7 +461,7 @@ export function createHookRulesStore(database: NeoDatabase): HookRulesStore { 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 }>; + const rows = sqlite.prepare("SELECT agent_id, command_prefix, created_at FROM hook_always_rules").all() as unknown 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) => { @@ -461,21 +489,25 @@ export function loadVecExtension(database: NeoDatabase): { loaded: boolean; vers } } -/** Serialize a vector for better-sqlite3 BLOB binding (Float32 little-endian). */ -export function toVecBuffer(vec: number[]): Buffer { - return Buffer.from(new Float32Array(vec).buffer); +/** Serialize a vector for sqlite BLOB binding (Float32 little-endian). */ +export function toVecBuffer(vec: number[]): Uint8Array { + return new Uint8Array(new Float32Array(vec).buffer); } /** Deserialize a stored BLOB back to a number[]. */ -function bufferToVec(buf: Buffer | Uint8Array): number[] { +function bufferToVec(buf: Uint8Array): number[] { return Array.from(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4)); } export function createTaskStore(database: NeoDatabase) { if (database.kind === "memory") { return { - list(): Task[] { - return [...database.tasks].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + list(opts?: { limit?: number; offset?: number }): { items: Task[]; total: number } { + const sorted = [...database.tasks].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + const total = sorted.length; + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? sorted.length; + return { items: sorted.slice(offset, offset + limit), total }; }, create(title: string): Task { const task = createTaskValue(title); @@ -494,23 +526,27 @@ export function createTaskStore(database: NeoDatabase) { }; } - const { db } = database; + const { sqlite } = database; return { - list(): Task[] { - return db.select().from(tasks).orderBy(tasks.createdAt).all().map(rowToTask); + list(opts?: { limit?: number; offset?: number }): { items: Task[]; total: number } { + const all = (sqlite.prepare("SELECT id, title, status, created_at, completed_at FROM tasks ORDER BY created_at").all() as unknown as TaskRow[]).map(rowToTask); + const total = all.length; + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? all.length; + return { items: all.slice(offset, offset + limit), total }; }, create(title: string): Task { const task = createTaskValue(title); - db.insert(tasks).values(toTaskRow(task)).run(); + sqlite.prepare("INSERT INTO tasks (id, title, status, created_at, completed_at) VALUES (?, ?, ?, ?, ?)").run(task.id, task.title, task.status, task.createdAt, task.completedAt); return task; }, patch(id: string, patch: Partial>): Task | null { - const existing = db.select().from(tasks).where(eq(tasks.id, id)).get(); + const existing = sqlite.prepare("SELECT id, title, status, created_at, completed_at FROM tasks WHERE id = ?").get(id) as unknown as TaskRow | undefined; if (!existing) return null; const next = patchTaskValue(rowToTask(existing), patch); - db.update(tasks).set(toTaskRow(next)).where(eq(tasks.id, id)).run(); + sqlite.prepare("UPDATE tasks SET title = ?, status = ?, completed_at = ? WHERE id = ?").run(next.title, next.status, next.completedAt, id); return next; } }; @@ -537,24 +573,24 @@ export function createFocusStore(database: NeoDatabase) { }; } - const { db } = database; + const { sqlite } = database; return { create(taskId: string | null, durationMinutes: number): FocusSession { const session = createFocusValue(taskId, durationMinutes); - db.insert(focusSessions).values(toFocusRow(session)).run(); + sqlite.prepare("INSERT INTO focus_sessions (id, task_id, status, started_at, completed_at, duration_minutes) VALUES (?, ?, ?, ?, ?, ?)").run(session.id, session.taskId, session.status, session.startedAt, session.completedAt, session.durationMinutes); return session; }, updateStatus(id: string, status: FocusSession["status"]): FocusSession | null { - const existing = db.select().from(focusSessions).where(eq(focusSessions.id, id)).get(); + const existing = sqlite.prepare("SELECT id, task_id, status, started_at, completed_at, duration_minutes FROM focus_sessions WHERE id = ?").get(id) as unknown as FocusSessionRow | undefined; if (!existing) return null; const next = patchFocusStatus(rowToFocus(existing), status); - db.update(focusSessions).set(toFocusRow(next)).where(eq(focusSessions.id, id)).run(); + sqlite.prepare("UPDATE focus_sessions SET status = ?, completed_at = ? WHERE id = ?").run(next.status, next.completedAt, id); return next; }, get(id: string): FocusSession | null { - const existing = db.select().from(focusSessions).where(eq(focusSessions.id, id)).get(); + const existing = sqlite.prepare("SELECT id, task_id, status, started_at, completed_at, duration_minutes FROM focus_sessions WHERE id = ?").get(id) as unknown as FocusSessionRow | undefined; return existing ? rowToFocus(existing) : null; } }; @@ -575,28 +611,19 @@ export function createWindowEventStore(database: NeoDatabase) { }; } - const { db } = database; + const { sqlite } = database; return { create(snapshot: WindowSnapshot): WindowSnapshot { - db.insert(windowEvents) - .values({ - id: crypto.randomUUID(), - title: snapshot.title, - processName: snapshot.processName, - capturedAt: snapshot.capturedAt, - dwellSeconds: snapshot.dwellSeconds, - classification: snapshot.classification - }) - .run(); + sqlite.prepare("INSERT INTO window_events (id, title, process_name, captured_at, dwell_seconds, classification) VALUES (?, ?, ?, ?, ?, ?)").run(crypto.randomUUID(), snapshot.title, snapshot.processName, snapshot.capturedAt, snapshot.dwellSeconds, snapshot.classification); return snapshot; }, latest(limit = 20): WindowSnapshot[] { - return db.select().from(windowEvents).orderBy(windowEvents.capturedAt).limit(limit).all().map((row) => ({ + return (sqlite.prepare("SELECT title, process_name, captured_at, dwell_seconds, classification FROM window_events ORDER BY captured_at LIMIT ?").all(limit) as unknown as WindowEventRow[]).map((row) => ({ title: row.title, - processName: row.processName, - capturedAt: row.capturedAt, - dwellSeconds: row.dwellSeconds, + processName: row.process_name, + capturedAt: row.captured_at, + dwellSeconds: row.dwell_seconds, classification: row.classification })); } @@ -642,50 +669,29 @@ function patchFocusStatus(existing: FocusSession, status: FocusSession["status"] }; } -function rowToTask(row: typeof tasks.$inferSelect): Task { +function rowToTask(row: TaskRow): Task { return { id: row.id, title: row.title, status: row.status, - createdAt: row.createdAt, - completedAt: row.completedAt + createdAt: row.created_at, + completedAt: row.completed_at }; } -function toTaskRow(task: Task): typeof tasks.$inferInsert { - return { - id: task.id, - title: task.title, - status: task.status, - createdAt: task.createdAt, - completedAt: task.completedAt - }; -} - -function rowToFocus(row: typeof focusSessions.$inferSelect): FocusSession { +function rowToFocus(row: FocusSessionRow): FocusSession { return { id: row.id, - taskId: row.taskId, + taskId: row.task_id, status: row.status, - startedAt: row.startedAt, - completedAt: row.completedAt, - durationMinutes: row.durationMinutes - }; -} - -function toFocusRow(session: FocusSession): typeof focusSessions.$inferInsert { - return { - id: session.id, - taskId: session.taskId, - status: session.status, - startedAt: session.startedAt, - completedAt: session.completedAt, - durationMinutes: session.durationMinutes + startedAt: row.started_at, + completedAt: row.completed_at, + durationMinutes: row.duration_minutes }; } // ── Knowledge Workspace store ── -// Sync CRUD over better-sqlite3. Timestamps are stored as ISO TEXT and mapped +// Sync CRUD over sqlite. Timestamps are stored as ISO TEXT and mapped // to epoch-ms numbers to match the frontend mock contract. Tags are synced via // the note_tags join on note write. The memory fallback is not supported for // knowledge (tests use the sqlite :memory: path, which goes through the sqlite @@ -758,7 +764,7 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { if (database.kind !== "sqlite") { throw new Error("Knowledge store requires a sqlite database (memory fallback unsupported)"); } - const { db, sqlite } = database; + const { sqlite } = database; const vecState = loadVecExtension(database); let vecLoaded = vecState.loaded; const vecVersion = vecState.version; @@ -779,21 +785,20 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { } function runInTransaction(operation: () => T): T { - return sqlite.transaction(operation)(); + return withTransaction(sqlite, operation); } - // ── tags helpers ── function ensureTagIds(names: string[]): string[] { const ids: string[] = []; for (const name of names) { const trimmed = name.trim(); if (!trimmed) continue; - const existing = db.select().from(knowledgeTags).where(eq(knowledgeTags.name, trimmed)).get(); + const existing = sqlite.prepare("SELECT id FROM tags WHERE name = ?").get(trimmed) as { id: string } | undefined; if (existing) { ids.push(existing.id); } else { const id = crypto.randomUUID(); - db.insert(knowledgeTags).values({ id, name: trimmed }).run(); + sqlite.prepare("INSERT INTO tags (id, name) VALUES (?, ?)").run(id, trimmed); ids.push(id); } } @@ -801,74 +806,44 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { } function loadNoteTags(noteId: string): string[] { - const rows = db - .select({ name: knowledgeTags.name }) - .from(knowledgeNoteTags) - .innerJoin(knowledgeTags, eq(knowledgeNoteTags.tagId, knowledgeTags.id)) - .where(eq(knowledgeNoteTags.noteId, noteId)) - .all(); + const rows = sqlite.prepare("SELECT t.name AS name FROM note_tags n JOIN tags t ON n.tag_id = t.id WHERE n.note_id = ?").all(noteId) as unknown as Array<{ name: string }>; return rows.map((r) => r.name); } function syncNoteTags(noteId: string, tags: string[]): void { - db.delete(knowledgeNoteTags).where(eq(knowledgeNoteTags.noteId, noteId)).run(); + sqlite.prepare("DELETE FROM note_tags WHERE note_id = ?").run(noteId); const ids = ensureTagIds(tags); if (ids.length) { - db.insert(knowledgeNoteTags).values(ids.map((tagId) => ({ noteId, tagId }))).run(); + const stmt = sqlite.prepare("INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)"); + for (const tagId of ids) stmt.run(noteId, tagId); } } - // ── mappers ── - function rowToProject(row: typeof knowledgeProjects.$inferSelect): KnowledgeProject { + function rowToProject(row: ProjectRow): KnowledgeProject { return { - id: row.id, - title: row.title, - description: row.description ?? undefined, - parentId: row.parentId, - color: row.color ?? undefined, - icon: row.icon ?? undefined, - isInbox: row.isInbox, - order: row.order, - createdAt: isoToMs(row.createdAt), - updatedAt: isoToMs(row.updatedAt) + id: row.id, title: row.title, description: row.description ?? undefined, parentId: row.parent_id, + color: row.color ?? undefined, icon: row.icon ?? undefined, isInbox: !!row.is_inbox, order: row.order, + createdAt: isoToMs(row.created_at), updatedAt: isoToMs(row.updated_at) }; } - function rowToNote(row: typeof knowledgeNotes.$inferSelect): KnowledgeNote { + function rowToNote(row: NoteRow): KnowledgeNote { return { - id: row.id, - projectId: row.projectId, - title: row.title, - body: row.body, - tags: loadNoteTags(row.id), - createdAt: isoToMs(row.createdAt), - updatedAt: isoToMs(row.updatedAt) + id: row.id, projectId: row.project_id, title: row.title, body: row.body, tags: loadNoteTags(row.id), + createdAt: isoToMs(row.created_at), updatedAt: isoToMs(row.updated_at) }; } - function rowToColumn(row: typeof knowledgeBoardColumns.$inferSelect): KnowledgeBoardColumn { - return { - id: row.id, - projectId: row.projectId, - title: row.title, - status: row.status, - order: row.order - }; + function rowToColumn(row: BoardColumnRow): KnowledgeBoardColumn { + return { id: row.id, projectId: row.project_id, title: row.title, status: row.status, order: row.order }; } - function rowToTask(row: typeof knowledgeTasks.$inferSelect): KnowledgeTask { + function rowToKnowledgeTask(row: KnowledgeTaskRow): KnowledgeTask { return { - id: row.id, - projectId: row.projectId, - columnId: row.columnId ?? "", - title: row.title, - description: row.description ?? undefined, - status: row.status, - order: row.order, - linkedNoteId: row.linkedNoteId ?? undefined, - tags: [], - createdAt: isoToMs(row.createdAt), - updatedAt: isoToMs(row.updatedAt) + id: row.id, projectId: row.project_id, columnId: row.column_id ?? "", title: row.title, + description: row.description ?? undefined, status: row.status, order: row.order, + linkedNoteId: row.linked_note_id ?? undefined, tags: [], + createdAt: isoToMs(row.created_at), updatedAt: isoToMs(row.updated_at) }; } @@ -876,16 +851,15 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { return new Date().toISOString(); } - // ── projects ── function listProjects(): KnowledgeProject[] { - return db.select().from(knowledgeProjects).orderBy(asc(knowledgeProjects.order)).all().map(rowToProject); + return (sqlite.prepare('SELECT id, title, description, parent_id, color, icon, is_inbox, "order", created_at, updated_at FROM projects ORDER BY "order"').all() as unknown as ProjectRow[]).map(rowToProject); } function getProject(id: string): KnowledgeProject | null { - const row = db.select().from(knowledgeProjects).where(eq(knowledgeProjects.id, id)).get(); + const row = sqlite.prepare('SELECT id, title, description, parent_id, color, icon, is_inbox, "order", created_at, updated_at FROM projects WHERE id = ?').get(id) as unknown as ProjectRow | undefined; return row ? rowToProject(row) : null; } function childProjects(parentId: string): KnowledgeProject[] { - return db.select().from(knowledgeProjects).where(eq(knowledgeProjects.parentId, parentId)).orderBy(asc(knowledgeProjects.order)).all().map(rowToProject); + return (sqlite.prepare('SELECT id, title, description, parent_id, color, icon, is_inbox, "order", created_at, updated_at FROM projects WHERE parent_id = ? ORDER BY "order"').all(parentId) as unknown as ProjectRow[]).map(rowToProject); } function projectPath(id: string): KnowledgeProject[] { const path: KnowledgeProject[] = []; @@ -901,48 +875,37 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { function createProject(input: { title: string; parentId?: string | null; description?: string; color?: string; icon?: string; isInbox?: boolean; order?: number }): KnowledgeProject { const id = crypto.randomUUID(); const ts = nowIso(); - db.insert(knowledgeProjects).values({ - id, - title: input.title.trim(), - description: input.description ?? null, - parentId: input.parentId ?? null, - color: input.color ?? null, - icon: input.icon ?? null, - isInbox: input.isInbox ?? false, - order: input.order ?? 0, - createdAt: ts, - updatedAt: ts - }).run(); + sqlite.prepare('INSERT INTO projects (id, title, description, parent_id, color, icon, is_inbox, "order", created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run( + id, input.title.trim(), input.description ?? null, input.parentId ?? null, input.color ?? null, input.icon ?? null, + input.isInbox ? 1 : 0, input.order ?? 0, ts, ts + ); return getProject(id)!; } function upsertImportedProject(project: KnowledgeProject): void { - db.insert(knowledgeProjects).values({ - id: project.id, title: project.title, description: project.description ?? null, - parentId: project.parentId, color: project.color ?? null, icon: project.icon ?? null, - isInbox: project.isInbox ?? false, order: project.order, - createdAt: new Date(project.createdAt).toISOString(), updatedAt: new Date(project.updatedAt).toISOString() - }).onConflictDoUpdate({ target: knowledgeProjects.id, set: { - title: project.title, description: project.description ?? null, parentId: project.parentId, - color: project.color ?? null, icon: project.icon ?? null, isInbox: project.isInbox ?? false, - order: project.order, updatedAt: new Date(project.updatedAt).toISOString() - }}).run(); + sqlite.prepare(`INSERT INTO projects (id, title, description, parent_id, color, icon, is_inbox, "order", created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET title=excluded.title, description=excluded.description, parent_id=excluded.parent_id, + color=excluded.color, icon=excluded.icon, is_inbox=excluded.is_inbox, "order"=excluded."order", updated_at=excluded.updated_at`).run( + project.id, project.title, project.description ?? null, project.parentId, project.color ?? null, project.icon ?? null, + project.isInbox ? 1 : 0, project.order, new Date(project.createdAt).toISOString(), new Date(project.updatedAt).toISOString() + ); } function updateProject(id: string, patch: Partial>): KnowledgeProject | null { - const existing = db.select().from(knowledgeProjects).where(eq(knowledgeProjects.id, id)).get(); + const existing = sqlite.prepare('SELECT id, title, description, parent_id, color, icon, is_inbox, "order", created_at, updated_at FROM projects WHERE id = ?').get(id) as unknown as ProjectRow | undefined; if (!existing) return null; - db.update(knowledgeProjects).set({ - title: patch.title?.trim() ?? existing.title, - description: patch.description !== undefined ? patch.description : existing.description, - color: patch.color !== undefined ? patch.color : existing.color, - icon: patch.icon !== undefined ? patch.icon : existing.icon, - parentId: patch.parentId !== undefined ? patch.parentId : existing.parentId, - order: patch.order ?? existing.order, - updatedAt: nowIso() - }).where(eq(knowledgeProjects.id, id)).run(); + sqlite.prepare('UPDATE projects SET title = ?, description = ?, color = ?, icon = ?, parent_id = ?, "order" = ?, updated_at = ? WHERE id = ?').run( + patch.title?.trim() ?? existing.title, + patch.description !== undefined ? patch.description : existing.description, + patch.color !== undefined ? patch.color : existing.color, + patch.icon !== undefined ? patch.icon : existing.icon, + patch.parentId !== undefined ? patch.parentId : existing.parent_id, + patch.order ?? existing.order, + nowIso(), id + ); return getProject(id); } function deleteProject(id: string): void { - sqlite.transaction(() => { + withTransaction(sqlite, () => { const projectIds: string[] = []; const visited = new Set(); const collect = (projectId: string) => { @@ -955,291 +918,221 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { for (const projectId of projectIds) { for (const note of notesForProject(projectId)) { removeIndex("note", note.id); - db.delete(knowledgeNoteTags).where(eq(knowledgeNoteTags.noteId, note.id)).run(); + sqlite.prepare("DELETE FROM note_tags WHERE note_id = ?").run(note.id); } for (const task of tasksForProject(projectId)) removeIndex("task", task.id); - db.delete(knowledgeNotes).where(eq(knowledgeNotes.projectId, projectId)).run(); - db.delete(knowledgeBoardColumns).where(eq(knowledgeBoardColumns.projectId, projectId)).run(); - db.delete(knowledgeTasks).where(eq(knowledgeTasks.projectId, projectId)).run(); - db.delete(knowledgeProjects).where(eq(knowledgeProjects.id, projectId)).run(); + sqlite.prepare("DELETE FROM notes WHERE project_id = ?").run(projectId); + sqlite.prepare("DELETE FROM board_columns WHERE project_id = ?").run(projectId); + sqlite.prepare("DELETE FROM knowledge_tasks WHERE project_id = ?").run(projectId); + sqlite.prepare("DELETE FROM projects WHERE id = ?").run(projectId); } - })(); + }); } function ensureInbox(): KnowledgeProject { - const existing = db.select().from(knowledgeProjects).where(eq(knowledgeProjects.isInbox, true)).get(); - if (existing) return rowToProject(existing); + const existing = sqlite.prepare("SELECT id FROM projects WHERE is_inbox = 1").get() as { id: string } | undefined; + if (existing) return getProject(existing.id)!; return createProject({ title: "收件箱", description: "临时想法与未分类内容", color: "#6b7280", isInbox: true, order: 0 }); } - // ── notes ── function notesForProject(projectId: string): KnowledgeNote[] { - return db.select().from(knowledgeNotes).where(eq(knowledgeNotes.projectId, projectId)).orderBy(asc(knowledgeNotes.updatedAt)).all().map(rowToNote); + return (sqlite.prepare("SELECT id, project_id, title, body, created_at, updated_at FROM notes WHERE project_id = ? ORDER BY updated_at").all(projectId) as unknown as NoteRow[]).map(rowToNote); } function getNote(id: string): KnowledgeNote | null { - const row = db.select().from(knowledgeNotes).where(eq(knowledgeNotes.id, id)).get(); + const row = sqlite.prepare("SELECT id, project_id, title, body, created_at, updated_at FROM notes WHERE id = ?").get(id) as unknown as NoteRow | undefined; return row ? rowToNote(row) : null; } function createNote(projectId: string, title: string): KnowledgeNote { const id = crypto.randomUUID(); const ts = nowIso(); - db.insert(knowledgeNotes).values({ - id, - projectId, - title: title.trim() || "无标题笔记", - body: "", - createdAt: ts, - updatedAt: ts - }).run(); + sqlite.prepare("INSERT INTO notes (id, project_id, title, body, created_at, updated_at) VALUES (?, ?, ?, '', ?, ?)").run(id, projectId, title.trim() || "无标题笔记", ts, ts); return getNote(id)!; } function upsertImportedNote(note: KnowledgeNote): void { - db.insert(knowledgeNotes).values({ - id: note.id, projectId: note.projectId, title: note.title, body: note.body, - createdAt: new Date(note.createdAt).toISOString(), updatedAt: new Date(note.updatedAt).toISOString() - }).onConflictDoUpdate({ target: knowledgeNotes.id, set: { - projectId: note.projectId, title: note.title, body: note.body, - updatedAt: new Date(note.updatedAt).toISOString() - }}).run(); + sqlite.prepare(`INSERT INTO notes (id, project_id, title, body, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET project_id=excluded.project_id, title=excluded.title, body=excluded.body, updated_at=excluded.updated_at`).run( + note.id, note.projectId, note.title, note.body, new Date(note.createdAt).toISOString(), new Date(note.updatedAt).toISOString() + ); syncNoteTags(note.id, note.tags); } function updateNote(id: string, patch: Partial>): KnowledgeNote | null { - const existing = db.select().from(knowledgeNotes).where(eq(knowledgeNotes.id, id)).get(); + const existing = sqlite.prepare("SELECT id, project_id, title, body, created_at, updated_at FROM notes WHERE id = ?").get(id) as unknown as NoteRow | undefined; if (!existing) return null; - db.update(knowledgeNotes).set({ - title: patch.title !== undefined ? patch.title.trim() : existing.title, - body: patch.body !== undefined ? patch.body : existing.body, - updatedAt: nowIso() - }).where(eq(knowledgeNotes.id, id)).run(); + sqlite.prepare("UPDATE notes SET title = ?, body = ?, updated_at = ? WHERE id = ?").run( + patch.title !== undefined ? patch.title.trim() : existing.title, + patch.body !== undefined ? patch.body : existing.body, + nowIso(), id + ); if (patch.tags !== undefined) syncNoteTags(id, patch.tags); return getNote(id); } function deleteNote(id: string): void { - sqlite.transaction(() => { + withTransaction(sqlite, () => { removeIndex("note", id); - db.delete(knowledgeNoteTags).where(eq(knowledgeNoteTags.noteId, id)).run(); - db.delete(knowledgeNotes).where(eq(knowledgeNotes.id, id)).run(); - })(); + sqlite.prepare("DELETE FROM note_tags WHERE note_id = ?").run(id); + sqlite.prepare("DELETE FROM notes WHERE id = ?").run(id); + }); } function backlinksFor(targetId: string): { sourceType: "note" | "task"; sourceId: string }[] { - // Basic [[target]] scan; upgraded to FTS in Phase 2. const pattern = `%[[${targetId}]%`; - const notes = db - .select({ id: knowledgeNotes.id }) - .from(knowledgeNotes) - .where(like(knowledgeNotes.body, pattern)) - .all() - .map((r) => ({ sourceType: "note" as const, sourceId: r.id })); - return notes; + const rows = sqlite.prepare("SELECT id FROM notes WHERE body LIKE ?").all(pattern) as unknown as Array<{ id: string }>; + return rows.map((r) => ({ sourceType: "note" as const, sourceId: r.id })); } - // ── columns ── function columnsForProject(projectId: string): KnowledgeBoardColumn[] { - return db.select().from(knowledgeBoardColumns).where(eq(knowledgeBoardColumns.projectId, projectId)).orderBy(asc(knowledgeBoardColumns.order)).all().map(rowToColumn); + return (sqlite.prepare('SELECT id, project_id, title, status, "order" FROM board_columns WHERE project_id = ? ORDER BY "order"').all(projectId) as unknown as BoardColumnRow[]).map(rowToColumn); } function createColumn(projectId: string, input: { title: string; status: KnowledgeTaskStatus; order: number }): KnowledgeBoardColumn { const id = crypto.randomUUID(); - db.insert(knowledgeBoardColumns).values({ id, projectId, title: input.title.trim(), status: input.status, order: input.order }).run(); - const row = db.select().from(knowledgeBoardColumns).where(eq(knowledgeBoardColumns.id, id)).get(); - return rowToColumn(row!); + sqlite.prepare('INSERT INTO board_columns (id, project_id, title, status, "order") VALUES (?, ?, ?, ?, ?)').run(id, projectId, input.title.trim(), input.status, input.order); + const row = sqlite.prepare('SELECT id, project_id, title, status, "order" FROM board_columns WHERE id = ?').get(id) as unknown as BoardColumnRow; + return rowToColumn(row); } function upsertImportedColumn(column: KnowledgeBoardColumn): void { - db.insert(knowledgeBoardColumns).values({ - id: column.id, projectId: column.projectId, title: column.title, - status: column.status, order: column.order - }).onConflictDoUpdate({ target: knowledgeBoardColumns.id, set: { - projectId: column.projectId, title: column.title, status: column.status, order: column.order - }}).run(); + sqlite.prepare(`INSERT INTO board_columns (id, project_id, title, status, "order") + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET project_id=excluded.project_id, title=excluded.title, status=excluded.status, "order"=excluded."order"`).run( + column.id, column.projectId, column.title, column.status, column.order + ); } function updateColumn(id: string, patch: Partial>): KnowledgeBoardColumn | null { - const existing = db.select().from(knowledgeBoardColumns).where(eq(knowledgeBoardColumns.id, id)).get(); + const existing = sqlite.prepare('SELECT id, project_id, title, status, "order" FROM board_columns WHERE id = ?').get(id) as unknown as BoardColumnRow | undefined; if (!existing) return null; - db.update(knowledgeBoardColumns).set({ - title: patch.title?.trim() ?? existing.title, - status: patch.status ?? existing.status, - order: patch.order ?? existing.order - }).where(eq(knowledgeBoardColumns.id, id)).run(); - const row = db.select().from(knowledgeBoardColumns).where(eq(knowledgeBoardColumns.id, id)).get(); + sqlite.prepare('UPDATE board_columns SET title = ?, status = ?, "order" = ? WHERE id = ?').run( + patch.title?.trim() ?? existing.title, + patch.status ?? existing.status, + patch.order ?? existing.order, + id + ); + const row = sqlite.prepare('SELECT id, project_id, title, status, "order" FROM board_columns WHERE id = ?').get(id) as unknown as BoardColumnRow | undefined; return row ? rowToColumn(row) : null; } function deleteColumn(id: string): void { - // unhook tasks in this column rather than deleting them - db.update(knowledgeTasks).set({ columnId: null }).where(eq(knowledgeTasks.columnId, id)).run(); - db.delete(knowledgeBoardColumns).where(eq(knowledgeBoardColumns.id, id)).run(); + sqlite.prepare("UPDATE knowledge_tasks SET column_id = NULL WHERE column_id = ?").run(id); + sqlite.prepare("DELETE FROM board_columns WHERE id = ?").run(id); } - // ── tasks ── function tasksForProject(projectId: string): KnowledgeTask[] { - return db.select().from(knowledgeTasks).where(eq(knowledgeTasks.projectId, projectId)).orderBy(asc(knowledgeTasks.order)).all().map(rowToTask); + return (sqlite.prepare('SELECT id, project_id, column_id, title, description, status, "order", linked_note_id, created_at, updated_at FROM knowledge_tasks WHERE project_id = ? ORDER BY "order"').all(projectId) as unknown as KnowledgeTaskRow[]).map(rowToKnowledgeTask); } function createTask(projectId: string, columnId: string, title: string): KnowledgeTask { const id = crypto.randomUUID(); const ts = nowIso(); - db.insert(knowledgeTasks).values({ - id, - projectId, - columnId: columnId || null, - title: title.trim(), - status: "todo", - order: 0, - linkedNoteId: null, - createdAt: ts, - updatedAt: ts - }).run(); - return rowToTask(db.select().from(knowledgeTasks).where(eq(knowledgeTasks.id, id)).get()!); + sqlite.prepare('INSERT INTO knowledge_tasks (id, project_id, column_id, title, status, "order", linked_note_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run( + id, projectId, columnId || null, title.trim(), "todo", 0, null, ts, ts + ); + const row = sqlite.prepare('SELECT id, project_id, column_id, title, description, status, "order", linked_note_id, created_at, updated_at FROM knowledge_tasks WHERE id = ?').get(id) as unknown as KnowledgeTaskRow; + return rowToKnowledgeTask(row); } function upsertImportedTask(task: KnowledgeTask): void { - db.insert(knowledgeTasks).values({ - id: task.id, projectId: task.projectId, columnId: task.columnId || null, - title: task.title, description: task.description ?? null, status: task.status, - order: task.order, linkedNoteId: task.linkedNoteId ?? null, - createdAt: new Date(task.createdAt).toISOString(), updatedAt: new Date(task.updatedAt).toISOString() - }).onConflictDoUpdate({ target: knowledgeTasks.id, set: { - projectId: task.projectId, columnId: task.columnId || null, title: task.title, - description: task.description ?? null, status: task.status, order: task.order, - linkedNoteId: task.linkedNoteId ?? null, updatedAt: new Date(task.updatedAt).toISOString() - }}).run(); + sqlite.prepare(`INSERT INTO knowledge_tasks (id, project_id, column_id, title, description, status, "order", linked_note_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET project_id=excluded.project_id, column_id=excluded.column_id, title=excluded.title, + description=excluded.description, status=excluded.status, "order"=excluded."order", linked_note_id=excluded.linked_note_id, updated_at=excluded.updated_at`).run( + task.id, task.projectId, task.columnId || null, task.title, task.description ?? null, task.status, task.order, task.linkedNoteId ?? null, + new Date(task.createdAt).toISOString(), new Date(task.updatedAt).toISOString() + ); } function updateTask(id: string, patch: { title?: string; description?: string; status?: KnowledgeTaskStatus; columnId?: string; order?: number; linkedNoteId?: string | null }): KnowledgeTask | null { - const existing = db.select().from(knowledgeTasks).where(eq(knowledgeTasks.id, id)).get(); + const existing = sqlite.prepare('SELECT id, project_id, column_id, title, description, status, "order", linked_note_id, created_at, updated_at FROM knowledge_tasks WHERE id = ?').get(id) as unknown as KnowledgeTaskRow | undefined; if (!existing) return null; - db.update(knowledgeTasks).set({ - title: patch.title !== undefined ? patch.title.trim() : existing.title, - description: patch.description !== undefined ? patch.description : existing.description, - status: patch.status ?? existing.status, - columnId: patch.columnId !== undefined ? (patch.columnId || null) : existing.columnId, - order: patch.order ?? existing.order, - linkedNoteId: patch.linkedNoteId !== undefined ? patch.linkedNoteId : existing.linkedNoteId, - updatedAt: nowIso() - }).where(eq(knowledgeTasks.id, id)).run(); - const row = db.select().from(knowledgeTasks).where(eq(knowledgeTasks.id, id)).get(); - return row ? rowToTask(row) : null; + sqlite.prepare('UPDATE knowledge_tasks SET title = ?, description = ?, status = ?, column_id = ?, "order" = ?, linked_note_id = ?, updated_at = ? WHERE id = ?').run( + patch.title !== undefined ? patch.title.trim() : existing.title, + patch.description !== undefined ? patch.description : existing.description, + patch.status ?? existing.status, + patch.columnId !== undefined ? (patch.columnId || null) : existing.column_id, + patch.order ?? existing.order, + patch.linkedNoteId !== undefined ? patch.linkedNoteId : existing.linked_note_id, + nowIso(), id + ); + const row = sqlite.prepare('SELECT id, project_id, column_id, title, description, status, "order", linked_note_id, created_at, updated_at FROM knowledge_tasks WHERE id = ?').get(id) as unknown as KnowledgeTaskRow | undefined; + return row ? rowToKnowledgeTask(row) : null; } function deleteTask(id: string): void { - sqlite.transaction(() => { + withTransaction(sqlite, () => { removeIndex("task", id); - db.delete(knowledgeTasks).where(eq(knowledgeTasks.id, id)).run(); - })(); + sqlite.prepare("DELETE FROM knowledge_tasks WHERE id = ?").run(id); + }); } function moveTask(taskId: string, targetColumnId: string, targetIndex: number): void { - const task = db.select().from(knowledgeTasks).where(eq(knowledgeTasks.id, taskId)).get(); + const task = sqlite.prepare("SELECT id, project_id, column_id FROM knowledge_tasks WHERE id = ?").get(taskId) as { id: string; project_id: string; column_id: string | null } | undefined; if (!task) return; - const projectId = task.projectId; - // shift siblings in target column then place - const siblings = db.select().from(knowledgeTasks) - .where(and(eq(knowledgeTasks.projectId, projectId), eq(knowledgeTasks.columnId, targetColumnId))) - .orderBy(asc(knowledgeTasks.order)).all().filter((t) => t.id !== taskId); - const clamped = Math.max(0, Math.min(targetIndex, siblings.length)); - siblings.splice(clamped, 0, task); - siblings.forEach((t, idx) => { - db.update(knowledgeTasks).set({ columnId: targetColumnId, order: idx }).where(eq(knowledgeTasks.id, t.id)).run(); + const projectId = task.project_id; + const normalizedColumnId = targetColumnId || null; + const siblings = (normalizedColumnId === null + ? sqlite.prepare('SELECT id FROM knowledge_tasks WHERE project_id = ? AND column_id IS NULL ORDER BY "order"').all(projectId) + : sqlite.prepare('SELECT id FROM knowledge_tasks WHERE project_id = ? AND column_id = ? ORDER BY "order"').all(projectId, normalizedColumnId)) as unknown as Array<{ id: string }>; + const ordered = siblings.filter((t) => t.id !== taskId); + const clamped = Math.max(0, Math.min(targetIndex, ordered.length)); + ordered.splice(clamped, 0, { id: task.id }); + withTransaction(sqlite, () => { + const stmt = sqlite.prepare('UPDATE knowledge_tasks SET column_id = ?, "order" = ? WHERE id = ?'); + ordered.forEach((t, idx) => stmt.run(normalizedColumnId, idx, t.id)); }); } - // ── indexing (Phase 2): chunk dedup + FTS5 sync ── - // Re-chunks a note, replacing its existing chunks. content-hash dedup: chunks - // whose hash already exists for this source are reused (skipping re-embed in - // Phase 3). FTS5 mirror is written synchronously; embedding stays 'pending'. function reindexNote(note: KnowledgeNote, chunk: (text: string) => { content: string; contentHash: string }[]): void { const text = note.body ? `${note.title}\n\n${note.body}` : note.title; const pieces = chunk(text); - sqlite.transaction(() => { + withTransaction(sqlite, () => { removeIndex("note", note.id); const now = nowIso(); + const insertChunk = sqlite.prepare("INSERT INTO knowledge_chunks (id, project_id, source_type, source_id, ordinal, content, content_hash, embedding_model, embedding_dimensions, index_status, index_error, retry_count, next_retry_at, updated_at) VALUES (?, ?, 'note', ?, ?, ?, ?, NULL, NULL, 'pending', NULL, 0, NULL, ?)"); + const insertFts = sqlite.prepare("INSERT INTO knowledge_chunks_fts (chunk_id, content, project_id, source_type, source_id, content_hash) VALUES (?, ?, ?, 'note', ?, ?)"); pieces.forEach((piece, ordinal) => { const chunkId = crypto.randomUUID(); - db.insert(knowledgeChunks).values({ - id: chunkId, projectId: note.projectId, sourceType: "note", sourceId: note.id, - ordinal, content: piece.content, contentHash: piece.contentHash, - embeddingModel: null, embeddingDimensions: null, indexStatus: "pending", - indexError: null, retryCount: 0, nextRetryAt: null, updatedAt: now - }).run(); - sqlite.prepare(`INSERT INTO knowledge_chunks_fts - (chunk_id, content, project_id, source_type, source_id, content_hash) VALUES (?, ?, ?, 'note', ?, ?)`) - .run(chunkId, piece.content, note.projectId, note.id, piece.contentHash); + insertChunk.run(chunkId, note.projectId, note.id, ordinal, piece.content, piece.contentHash, now); + insertFts.run(chunkId, piece.content, note.projectId, note.id, piece.contentHash); }); - })(); + }); } function reindexTask(task: KnowledgeTask, chunk: (text: string) => { content: string; contentHash: string }[]): void { const text = task.description ? `${task.title}\n\n${task.description}` : task.title; const pieces = chunk(text); - sqlite.transaction(() => { + withTransaction(sqlite, () => { removeIndex("task", task.id); const now = nowIso(); + const insertChunk = sqlite.prepare("INSERT INTO knowledge_chunks (id, project_id, source_type, source_id, ordinal, content, content_hash, embedding_model, embedding_dimensions, index_status, index_error, retry_count, next_retry_at, updated_at) VALUES (?, ?, 'task', ?, ?, ?, ?, NULL, NULL, 'pending', NULL, 0, NULL, ?)"); + const insertFts = sqlite.prepare("INSERT INTO knowledge_chunks_fts (chunk_id, content, project_id, source_type, source_id, content_hash) VALUES (?, ?, ?, 'task', ?, ?)"); pieces.forEach((piece, ordinal) => { const chunkId = crypto.randomUUID(); - db.insert(knowledgeChunks).values({ - id: chunkId, projectId: task.projectId, sourceType: "task", sourceId: task.id, - ordinal, content: piece.content, contentHash: piece.contentHash, - embeddingModel: null, embeddingDimensions: null, indexStatus: "pending", - indexError: null, retryCount: 0, nextRetryAt: null, updatedAt: now - }).run(); - sqlite.prepare(`INSERT INTO knowledge_chunks_fts - (chunk_id, content, project_id, source_type, source_id, content_hash) VALUES (?, ?, ?, 'task', ?, ?)`) - .run(chunkId, piece.content, task.projectId, task.id, piece.contentHash); + insertChunk.run(chunkId, task.projectId, task.id, ordinal, piece.content, piece.contentHash, now); + insertFts.run(chunkId, piece.content, task.projectId, task.id, piece.contentHash); }); - })(); + }); } function removeIndex(sourceType: "note" | "task", sourceId: string): void { - // Collect chunk ids so we can also drop their vec0 rows before deleting. - const chunkIds = db.select({ id: knowledgeChunks.id }) - .from(knowledgeChunks) - .where(and(eq(knowledgeChunks.sourceType, sourceType), eq(knowledgeChunks.sourceId, sourceId))) - .all().map((r) => r.id); + const chunkIds = (sqlite.prepare("SELECT id FROM knowledge_chunks WHERE source_type = ? AND source_id = ?").all(sourceType, sourceId) as unknown as Array<{ id: string }>).map((r) => r.id); for (const cid of chunkIds) delVecChunk(cid); - // FTS5 delete-then-reinsert: drop matching FTS rows, then the chunk rows. - sqlite.exec( - `DELETE FROM knowledge_chunks_fts WHERE source_type = ${sqlStr(sourceType)} AND source_id = ${sqlStr(sourceId)}` - ); - db.delete(knowledgeChunks) - .where(and(eq(knowledgeChunks.sourceType, sourceType), eq(knowledgeChunks.sourceId, sourceId))) - .run(); + sqlite.exec(`DELETE FROM knowledge_chunks_fts WHERE source_type = ${sqlStr(sourceType)} AND source_id = ${sqlStr(sourceId)}`); + sqlite.prepare("DELETE FROM knowledge_chunks WHERE source_type = ? AND source_id = ?").run(sourceType, sourceId); } function searchFts(projectId: string | null, query: string, limit: number): KnowledgeSource[] { - // trigram tokenizer indexes 3-grams, so CJK queries shorter than 3 chars - // (e.g. "向量") won't MATCH reliably. Use FTS5 MATCH for BM25 ranking when - // the query is long enough, and fall back to a LIKE substring scan for - // short queries so 2-char Chinese terms still hit. const terms = extractTerms(query); if (!terms.length) return []; const useMatch = terms.every((t) => [...t].length >= 3); const cap = Math.max(1, Math.min(limit, 50)); const projectClause = projectId ? ` AND k.project_id = ${sqlStr(projectId)}` : ""; - let sql: string; if (useMatch) { const ftsQuery = terms.map((t) => `"${t.replace(/"/g, "")}"*`).join(" OR "); - sql = `SELECT k.chunk_id, k.source_type, k.source_id, k.project_id, k.content, - bm25(knowledge_chunks_fts) AS rank - FROM knowledge_chunks_fts k - WHERE k.content MATCH ${sqlStr(ftsQuery)}${projectClause} - ORDER BY rank LIMIT ${cap}`; + sql = `SELECT k.chunk_id, k.source_type, k.source_id, k.project_id, k.content, bm25(knowledge_chunks_fts) AS rank FROM knowledge_chunks_fts k WHERE k.content MATCH ${sqlStr(ftsQuery)}${projectClause} ORDER BY rank LIMIT ${cap}`; } else { - // LIKE fallback for short CJK terms; rank by position (earlier = better) const likeTerms = terms.map((t) => `k.content LIKE ${sqlStr(`%${t.replace(/[%_]/g, "\\$&")}%`)} ESCAPE '\\'`); - sql = `SELECT k.chunk_id, k.source_type, k.source_id, k.project_id, k.content, 0 AS rank - FROM knowledge_chunks_fts k - WHERE (${likeTerms.join(" OR ")})${projectClause} - LIMIT ${cap}`; + sql = `SELECT k.chunk_id, k.source_type, k.source_id, k.project_id, k.content, 0 AS rank FROM knowledge_chunks_fts k WHERE (${likeTerms.join(" OR ")})${projectClause} LIMIT ${cap}`; } - const rows = sqlite.prepare(sql).all() as Array<{ - chunk_id: string; source_type: "note" | "task"; source_id: string; project_id: string; content: string; rank: number; - }>; - // resolve titles + dedup by source (return best chunk per source) + const rows = sqlite.prepare(sql).all() as unknown as Array<{ chunk_id: string; source_type: "note" | "task"; source_id: string; project_id: string; content: string; rank: number }>; const bySource = new Map(); for (const row of rows) { const key = `${row.source_type}:${row.source_id}`; if (bySource.has(key)) continue; - const title = resolveSourceTitle(row.source_type, row.source_id); bySource.set(key, { - sourceType: row.source_type, - sourceId: row.source_id, - projectId: row.project_id, - title, - excerpt: deriveExcerpt(row.content), - chunkId: row.chunk_id + sourceType: row.source_type, sourceId: row.source_id, projectId: row.project_id, + title: resolveSourceTitle(row.source_type, row.source_id), + excerpt: deriveExcerpt(row.content), chunkId: row.chunk_id }); } return [...bySource.values()]; @@ -1247,17 +1140,17 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { function getChunkContents(chunkIds: string[]): Map { if (!chunkIds.length) return new Map(); - const rows = db.select({ id: knowledgeChunks.id, content: knowledgeChunks.content }) - .from(knowledgeChunks).where(inArray(knowledgeChunks.id, chunkIds)).all(); + const placeholders = chunkIds.map(() => "?").join(","); + const rows = sqlite.prepare(`SELECT id, content FROM knowledge_chunks WHERE id IN (${placeholders})`).all(...chunkIds) as unknown as Array<{ id: string; content: string }>; return new Map(rows.map((row) => [row.id, row.content])); } function resolveSourceTitle(sourceType: "note" | "task", sourceId: string): string { if (sourceType === "note") { - const row = db.select().from(knowledgeNotes).where(eq(knowledgeNotes.id, sourceId)).get(); + const row = sqlite.prepare("SELECT title FROM notes WHERE id = ?").get(sourceId) as { title?: string } | undefined; return row?.title ?? "未命名笔记"; } - const row = db.select().from(knowledgeTasks).where(eq(knowledgeTasks.id, sourceId)).get(); + const row = sqlite.prepare("SELECT title FROM knowledge_tasks WHERE id = ?").get(sourceId) as { title?: string } | undefined; return row?.title ?? "未命名任务"; } @@ -1267,52 +1160,26 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { } function markStale(embeddingModel?: string): void { - // mark indexed chunks stale when the embedding model changes (Phase 3) if (embeddingModel) { - db.update(knowledgeChunks) - .set({ indexStatus: "stale" }) - .where(and( - eq(knowledgeChunks.indexStatus, "indexed"), - or(isNull(knowledgeChunks.embeddingModel), ne(knowledgeChunks.embeddingModel, embeddingModel)) - )) - .run(); + sqlite.prepare("UPDATE knowledge_chunks SET index_status = 'stale' WHERE index_status = 'indexed' AND (embedding_model IS NULL OR embedding_model != ?)").run(embeddingModel); } else { - db.update(knowledgeChunks).set({ indexStatus: "stale" }).where(eq(knowledgeChunks.indexStatus, "indexed")).run(); + sqlite.exec("UPDATE knowledge_chunks SET index_status = 'stale' WHERE index_status = 'indexed'"); } } function getIndexStatus(providerConfigured: boolean): IndexStatus { const countBy = (status: string): number => { - const row = sqlite.prepare( - `SELECT COUNT(*) AS n FROM knowledge_chunks WHERE index_status = ${sqlStr(status)}` - ).get() as { n: number } | undefined; + const row = sqlite.prepare("SELECT COUNT(*) AS n FROM knowledge_chunks WHERE index_status = ?").get(status) as { n: number } | undefined; return row?.n ?? 0; }; const pending = countBy("pending"); const stale = countBy("stale"); - const retrying = (sqlite.prepare( - "SELECT COUNT(*) AS n FROM knowledge_chunks WHERE index_status = 'failed' AND retry_count < 3 AND next_retry_at IS NOT NULL" - ).get() as { n: number }).n; + const retrying = (sqlite.prepare("SELECT COUNT(*) AS n FROM knowledge_chunks WHERE index_status = 'failed' AND retry_count < 3 AND next_retry_at IS NOT NULL").get() as { n: number }).n; const hybridCapable = vecLoaded && providerConfigured; - const mode: IndexStatus["mode"] = hybridCapable - ? pending + stale + retrying > 0 - ? "indexing" - : "hybrid" - : "fts-only"; - return { - mode, - pending, - failed: countBy("failed"), - stale, - retrying, - providerConfigured, - vectorExtensionAvailable: vecLoaded, - vecVersion, - vecLoadError: vecLoaded ? undefined : vecLoadError - }; + const mode: IndexStatus["mode"] = hybridCapable ? (pending + stale + retrying > 0 ? "indexing" : "hybrid") : "fts-only"; + return { mode, pending, failed: countBy("failed"), stale, retrying, providerConfigured, vectorExtensionAvailable: vecLoaded, vecVersion, vecLoadError: vecLoaded ? undefined : vecLoadError }; } - // ── vector indexing (Phase 3) ── function ensureVecTable(dim: number): boolean { if (!vecLoaded) return false; if (vecDim === null) { @@ -1323,22 +1190,11 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { sqlite.exec("DROP TABLE knowledge_chunks_vec"); markStale(); } - sqlite.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_chunks_vec USING vec0( - chunk_id TEXT PRIMARY KEY, - project_id TEXT PARTITION KEY, - embedding FLOAT[${dim}] - )`); + sqlite.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_chunks_vec USING vec0(chunk_id TEXT PRIMARY KEY, project_id TEXT PARTITION KEY, embedding FLOAT[${dim}])`); vecDim = dim; } else if (vecDim !== dim) { - // dimension changed (model swap) → drop, recreate, force full re-embed sqlite.exec("DROP TABLE IF EXISTS knowledge_chunks_vec"); - sqlite.exec( - `CREATE VIRTUAL TABLE knowledge_chunks_vec USING vec0( - chunk_id TEXT PRIMARY KEY, - project_id TEXT PARTITION KEY, - embedding FLOAT[${dim}] - )` - ); + sqlite.exec(`CREATE VIRTUAL TABLE knowledge_chunks_vec USING vec0(chunk_id TEXT PRIMARY KEY, project_id TEXT PARTITION KEY, embedding FLOAT[${dim}])`); vecDim = dim; markStale(); } @@ -1347,9 +1203,7 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { function putVecChunk(chunkId: string, projectId: string, vec: number[]): void { if (!vecLoaded || vecDim === null) return; - sqlite.prepare( - "INSERT OR REPLACE INTO knowledge_chunks_vec (chunk_id, project_id, embedding) VALUES (?, ?, ?)" - ).run(chunkId, projectId, toVecBuffer(vec)); + sqlite.prepare("INSERT OR REPLACE INTO knowledge_chunks_vec (chunk_id, project_id, embedding) VALUES (?, ?, ?)").run(chunkId, projectId, toVecBuffer(vec)); } function delVecChunk(chunkId: string): void { @@ -1358,127 +1212,68 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { if (exists) sqlite.prepare("DELETE FROM knowledge_chunks_vec WHERE chunk_id = ?").run(chunkId); } - function searchKnn( - queryVec: number[], - k: number, - projectId: string | null - ): KnowledgeSource[] { + function searchKnn(queryVec: number[], k: number, projectId: string | null): KnowledgeSource[] { if (!vecLoaded || vecDim === null || queryVec.length !== vecDim) return []; const cap = Math.max(1, Math.min(k, 50)); const rows = projectId - ? sqlite.prepare(`SELECT v.chunk_id AS chunk_id, v.distance AS distance - FROM knowledge_chunks_vec v - WHERE v.embedding MATCH ? AND v.project_id = ? AND k = ? - ORDER BY v.distance`).all(toVecBuffer(queryVec), projectId, cap) as Array<{ chunk_id: string; distance: number }> - : sqlite.prepare(`SELECT v.chunk_id AS chunk_id, v.distance AS distance - FROM knowledge_chunks_vec v - WHERE v.embedding MATCH ? AND k = ? - ORDER BY v.distance`).all(toVecBuffer(queryVec), cap) as Array<{ chunk_id: string; distance: number }>; + ? sqlite.prepare("SELECT v.chunk_id AS chunk_id, v.distance AS distance FROM knowledge_chunks_vec v WHERE v.embedding MATCH ? AND v.project_id = ? AND k = ? ORDER BY v.distance").all(toVecBuffer(queryVec), projectId, cap) as unknown as Array<{ chunk_id: string; distance: number }> + : sqlite.prepare("SELECT v.chunk_id AS chunk_id, v.distance AS distance FROM knowledge_chunks_vec v WHERE v.embedding MATCH ? AND k = ? ORDER BY v.distance").all(toVecBuffer(queryVec), cap) as unknown as Array<{ chunk_id: string; distance: number }>; if (!rows.length) return []; const ids = rows.map((r) => r.chunk_id); - const chunkRows = db.select().from(knowledgeChunks).where(inArray(knowledgeChunks.id, ids)).all(); + const placeholders = ids.map(() => "?").join(","); + const chunkRows = sqlite.prepare(`SELECT id, project_id, source_type, source_id, ordinal, content, content_hash, embedding_model, embedding_dimensions, index_status, index_error, retry_count, next_retry_at, updated_at FROM knowledge_chunks WHERE id IN (${placeholders})`).all(...ids) as unknown as KnowledgeChunkRow[]; const byId = new Map(chunkRows.map((r) => [r.id, r])); - // dedup by source (first hit wins — rows are distance-sorted) + resolve title/excerpt const bySource = new Map(); for (const r of rows) { const c = byId.get(r.chunk_id); if (!c) continue; - if (projectId && c.projectId !== projectId) continue; - const key = `${c.sourceType}:${c.sourceId}`; + if (projectId && c.project_id !== projectId) continue; + const key = `${c.source_type}:${c.source_id}`; if (bySource.has(key)) continue; bySource.set(key, { - sourceType: c.sourceType, - sourceId: c.sourceId, - projectId: c.projectId, - title: resolveSourceTitle(c.sourceType, c.sourceId), - excerpt: deriveExcerpt(c.content), - chunkId: r.chunk_id + sourceType: c.source_type, sourceId: c.source_id, projectId: c.project_id, + title: resolveSourceTitle(c.source_type, c.source_id), + excerpt: deriveExcerpt(c.content), chunkId: r.chunk_id }); } return [...bySource.values()]; } function getCachedEmbedding(contentHash: string, model: string): { vector: number[]; dimensions: number } | null { - const row = db.select().from(embeddingCache) - .where(and(eq(embeddingCache.contentHash, contentHash), eq(embeddingCache.model, model))).get(); + const row = sqlite.prepare("SELECT embedding, dimensions FROM embedding_cache WHERE content_hash = ? AND model = ?").get(contentHash, model) as { embedding: Uint8Array; dimensions: number } | undefined; if (!row) return null; return { vector: bufferToVec(row.embedding), dimensions: row.dimensions }; } function putCachedEmbedding(contentHash: string, vec: number[], model: string, dim: number): void { - db.insert(embeddingCache).values({ - contentHash, - embedding: toVecBuffer(vec), - model, - dimensions: dim - }).onConflictDoUpdate({ - target: [embeddingCache.contentHash, embeddingCache.model], - set: { embedding: toVecBuffer(vec), model, dimensions: dim } - }).run(); - } - - function listPendingChunks(limit: number): Array<{ - id: string; content: string; contentHash: string; projectId: string; sourceType: "note" | "task"; sourceId: string; - }> { - return db.select({ - id: knowledgeChunks.id, - content: knowledgeChunks.content, - contentHash: knowledgeChunks.contentHash, - projectId: knowledgeChunks.projectId, - sourceType: knowledgeChunks.sourceType, - sourceId: knowledgeChunks.sourceId - }) - .from(knowledgeChunks) - .where(or( - inArray(knowledgeChunks.indexStatus, ["pending", "stale"]), - and( - eq(knowledgeChunks.indexStatus, "failed"), - lt(knowledgeChunks.retryCount, 3), - lte(knowledgeChunks.nextRetryAt, nowIso()) - ) - )) - .limit(limit) - .all(); + sqlite.prepare("INSERT INTO embedding_cache (content_hash, embedding, model, dimensions) VALUES (?, ?, ?, ?) ON CONFLICT(content_hash, model) DO UPDATE SET embedding=excluded.embedding, model=excluded.model, dimensions=excluded.dimensions").run(contentHash, toVecBuffer(vec), model, dim); + } + + function listPendingChunks(limit: number): Array<{ id: string; content: string; contentHash: string; projectId: string; sourceType: "note" | "task"; sourceId: string }> { + const rows = sqlite.prepare("SELECT id, content, content_hash, project_id, source_type, source_id FROM knowledge_chunks WHERE index_status IN ('pending', 'stale') OR (index_status = 'failed' AND retry_count < 3 AND next_retry_at <= ?) LIMIT ?").all(nowIso(), limit) as unknown as Array<{ id: string; content: string; content_hash: string; project_id: string; source_type: "note" | "task"; source_id: string }>; + return rows.map((r) => ({ id: r.id, content: r.content, contentHash: r.content_hash, projectId: r.project_id, sourceType: r.source_type, sourceId: r.source_id })); } function markChunkIndexed(chunkId: string, model: string, dim: number): void { - db.update(knowledgeChunks).set({ - indexStatus: "indexed", - embeddingModel: model, - embeddingDimensions: dim, - indexError: null, - retryCount: 0, - nextRetryAt: null, - updatedAt: nowIso() - }).where(eq(knowledgeChunks.id, chunkId)).run(); + sqlite.prepare("UPDATE knowledge_chunks SET index_status = 'indexed', embedding_model = ?, embedding_dimensions = ?, index_error = NULL, retry_count = 0, next_retry_at = NULL, updated_at = ? WHERE id = ?").run(model, dim, nowIso(), chunkId); } function markChunkFailed(chunkId: string, error: string): void { - const current = db.select({ retryCount: knowledgeChunks.retryCount }) - .from(knowledgeChunks).where(eq(knowledgeChunks.id, chunkId)).get(); - const retryCount = (current?.retryCount ?? 0) + 1; + const current = sqlite.prepare("SELECT retry_count FROM knowledge_chunks WHERE id = ?").get(chunkId) as { retry_count: number } | undefined; + const retryCount = (current?.retry_count ?? 0) + 1; const retryDelays = [60_000, 300_000, 1_800_000]; - db.update(knowledgeChunks).set({ - indexStatus: "failed", - indexError: error, - retryCount, - nextRetryAt: retryCount <= retryDelays.length - ? new Date(Date.now() + retryDelays[retryCount - 1]).toISOString() - : null, - updatedAt: nowIso() - }).where(eq(knowledgeChunks.id, chunkId)).run(); - } - - // safe SQL string literal (single-quote escape) for raw FTS queries + sqlite.prepare("UPDATE knowledge_chunks SET index_status = 'failed', index_error = ?, retry_count = ?, next_retry_at = ?, updated_at = ? WHERE id = ?").run( + error, retryCount, + retryCount <= retryDelays.length ? new Date(Date.now() + retryDelays[retryCount - 1]).toISOString() : null, + nowIso(), chunkId + ); + } + function sqlStr(value: string): string { return `'${value.replace(/'/g, "''")}'`; } function extractTerms(query: string): string[] { - // Split on whitespace + common CJK punctuation; keep letters/numbers (incl. CJK). - return query - .split(/[\s,,。、;;!!??]+/) - .map((t) => t.replace(/[^\p{L}\p{N}]+/gu, "")) - .filter(Boolean); + return query.split(/[\s,,。、;;!!??]+/).map((t) => t.replace(/[^\p{L}\p{N}]+/gu, "")).filter(Boolean); } return { @@ -1494,64 +1289,37 @@ export function createKnowledgeStore(database: NeoDatabase): KnowledgeStore { }; } -/** AI conversation store (Phase 4): multi-turn chat persistence with sources. */ export function createAiConversationStore(database: NeoDatabase) { if (database.kind !== "sqlite") { throw new Error("AI conversation store requires a sqlite database"); } - const { db } = database; + const { sqlite } = database; function createConversation(projectId: string | null, mode: AiRetrievalMode): AiConversation { const id = crypto.randomUUID(); const ts = new Date().toISOString(); - db.insert(aiConversations).values({ id, projectId, mode, createdAt: ts, updatedAt: ts }).run(); + sqlite.prepare("INSERT INTO ai_conversations (id, project_id, mode, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(id, projectId, mode, ts, ts); return { id, projectId, mode, createdAt: Date.parse(ts), updatedAt: Date.parse(ts) }; } function getConversation(id: string): AiConversation | null { - const row = db.select().from(aiConversations).where(eq(aiConversations.id, id)).get(); + const row = sqlite.prepare("SELECT id, project_id, mode, created_at, updated_at FROM ai_conversations WHERE id = ?").get(id) as unknown as AiConversationRow | undefined; if (!row) return null; - return { - id: row.id, - projectId: row.projectId, - mode: row.mode, - createdAt: Date.parse(row.createdAt), - updatedAt: Date.parse(row.updatedAt) - }; + return { id: row.id, projectId: row.project_id, mode: row.mode, createdAt: Date.parse(row.created_at), updatedAt: Date.parse(row.updated_at) }; } function listMessages(conversationId: string): AiMessage[] { - return db.select().from(aiMessages) - .where(eq(aiMessages.conversationId, conversationId)) - .orderBy(asc(aiMessages.createdAt)) - .all() - .map((row) => ({ - id: row.id, - conversationId: row.conversationId, - role: row.role, - content: row.content, - sources: row.sourcesJson ? safeParseSources(row.sourcesJson) : [], - createdAt: Date.parse(row.createdAt) - })); + return (sqlite.prepare("SELECT id, conversation_id, role, content, sources_json, created_at FROM ai_messages WHERE conversation_id = ? ORDER BY created_at").all(conversationId) as unknown as AiMessageRow[]).map((row) => ({ + id: row.id, conversationId: row.conversation_id, role: row.role, content: row.content, + sources: row.sources_json ? safeParseSources(row.sources_json) : [], createdAt: Date.parse(row.created_at) + })); } - function appendMessage( - conversationId: string, - role: AiMessage["role"], - content: string, - sources: KnowledgeSource[] = [] - ): AiMessage { + function appendMessage(conversationId: string, role: AiMessage["role"], content: string, sources: KnowledgeSource[] = []): AiMessage { const id = crypto.randomUUID(); const ts = new Date().toISOString(); - db.insert(aiMessages).values({ - id, - conversationId, - role, - content, - sourcesJson: sources.length ? JSON.stringify(sources) : null, - createdAt: ts - }).run(); - db.update(aiConversations).set({ updatedAt: ts }).where(eq(aiConversations.id, conversationId)).run(); + sqlite.prepare("INSERT INTO ai_messages (id, conversation_id, role, content, sources_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(id, conversationId, role, content, sources.length ? JSON.stringify(sources) : null, ts); + sqlite.prepare("UPDATE ai_conversations SET updated_at = ? WHERE id = ?").run(ts, conversationId); return { id, conversationId, role, content, sources, createdAt: Date.parse(ts) }; } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts deleted file mode 100644 index 6d14ec8..0000000 --- a/packages/db/src/schema.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { blob, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -export const tasks = sqliteTable("tasks", { - id: text("id").primaryKey(), - title: text("title").notNull(), - status: text("status", { enum: ["open", "done"] }).notNull().default("open"), - createdAt: text("created_at").notNull(), - completedAt: text("completed_at") -}); - -export const focusSessions = sqliteTable("focus_sessions", { - id: text("id").primaryKey(), - taskId: text("task_id"), - status: text("status", { enum: ["active", "completed", "cancelled"] }).notNull(), - startedAt: text("started_at").notNull(), - completedAt: text("completed_at"), - durationMinutes: integer("duration_minutes").notNull() -}); - -export const conversations = sqliteTable("conversations", { - id: text("id").primaryKey(), - title: text("title").notNull(), - createdAt: text("created_at").notNull() -}); - -export const messages = sqliteTable("messages", { - id: text("id").primaryKey(), - conversationId: text("conversation_id").notNull(), - role: text("role", { enum: ["system", "user", "assistant"] }).notNull(), - content: text("content").notNull(), - createdAt: text("created_at").notNull() -}); - -export const settings = sqliteTable("settings", { - key: text("key").primaryKey(), - value: text("value").notNull(), - updatedAt: text("updated_at").notNull() -}); - -export const windowEvents = sqliteTable("window_events", { - id: text("id").primaryKey(), - title: text("title").notNull(), - processName: text("process_name").notNull(), - capturedAt: text("captured_at").notNull(), - dwellSeconds: integer("dwell_seconds").notNull(), - classification: text("classification", { enum: ["focused", "distracted", "stuck"] }).notNull() -}); - -// ── Knowledge Workspace (v2) ── -// Kept separate from the v1 `tasks` table (which drives the pet-panel checklist -// with `open|done`). Knowledge kanban tasks use a 4-state status. Unification is -// tracked in docs/TODO_INVENTORY.md. - -export const knowledgeProjects = sqliteTable("projects", { - id: text("id").primaryKey(), - title: text("title").notNull(), - description: text("description"), - parentId: text("parent_id"), - color: text("color"), - icon: text("icon"), - isInbox: integer("is_inbox", { mode: "boolean" }).notNull().default(false), - order: integer("order").notNull().default(0), - createdAt: text("created_at").notNull(), - updatedAt: text("updated_at").notNull() -}); - -export const knowledgeNotes = sqliteTable("notes", { - id: text("id").primaryKey(), - projectId: text("project_id").notNull(), - title: text("title").notNull(), - body: text("body").notNull().default(""), - createdAt: text("created_at").notNull(), - updatedAt: text("updated_at").notNull() -}); - -export const knowledgeTags = sqliteTable("tags", { - id: text("id").primaryKey(), - name: text("name").notNull().unique() -}); - -export const knowledgeNoteTags = sqliteTable("note_tags", { - noteId: text("note_id").notNull(), - tagId: text("tag_id").notNull() -}); - -export const knowledgeBoardColumns = sqliteTable("board_columns", { - id: text("id").primaryKey(), - projectId: text("project_id").notNull(), - title: text("title").notNull(), - status: text("status", { enum: ["todo", "doing", "done", "archived"] }).notNull().default("todo"), - order: integer("order").notNull().default(0) -}); - -export const knowledgeTasks = sqliteTable("knowledge_tasks", { - id: text("id").primaryKey(), - projectId: text("project_id").notNull(), - columnId: text("column_id"), - title: text("title").notNull(), - description: text("description"), - status: text("status", { enum: ["todo", "doing", "done", "archived"] }).notNull().default("todo"), - order: integer("order").notNull().default(0), - linkedNoteId: text("linked_note_id"), - createdAt: text("created_at").notNull(), - updatedAt: text("updated_at").notNull() -}); - -// FTS5 + sqlite-vec retrieval targets. `knowledge_chunks` holds chunk text + -// content hash (dedup) + index lifecycle; FTS5 mirror is managed by the -// knowledge service; vec0 virtual table is created lazily when the sqlite-vec -// extension loads (Phase 3). -export const knowledgeChunks = sqliteTable("knowledge_chunks", { - id: text("id").primaryKey(), - projectId: text("project_id").notNull(), - sourceType: text("source_type", { enum: ["note", "task"] }).notNull(), - sourceId: text("source_id").notNull(), - ordinal: integer("ordinal").notNull(), - content: text("content").notNull(), - contentHash: text("content_hash").notNull(), - embeddingModel: text("embedding_model"), - embeddingDimensions: integer("embedding_dimensions"), - indexStatus: text("index_status", { enum: ["pending", "indexed", "failed", "stale"] }).notNull().default("pending"), - indexError: text("index_error"), - retryCount: integer("retry_count").notNull().default(0), - nextRetryAt: text("next_retry_at"), - updatedAt: text("updated_at").notNull() -}); - -// Embedding cache keyed by content hash. Same-hash chunks reuse the stored -// vector, skipping redundant embedding API calls (Phase 3). -export const embeddingCache = sqliteTable("embedding_cache", { - contentHash: text("content_hash").notNull(), - embedding: blob("embedding", { mode: "buffer" }).notNull(), - model: text("model").notNull(), - dimensions: integer("dimensions").notNull() -}, (table) => [primaryKey({ columns: [table.contentHash, table.model] })]); - -// AI conversations (Phase 4). Separate from the v1 `conversations`/`messages` -// tables (pet-panel chat) — these carry knowledge RAG context + sources. -export const aiConversations = sqliteTable("ai_conversations", { - id: text("id").primaryKey(), - projectId: text("project_id"), - mode: text("mode", { enum: ["chat", "ask"] }).notNull(), - createdAt: text("created_at").notNull(), - updatedAt: text("updated_at").notNull() -}); - -export const aiMessages = sqliteTable("ai_messages", { - id: text("id").primaryKey(), - conversationId: text("conversation_id").notNull(), - role: text("role", { enum: ["system", "user", "assistant"] }).notNull(), - content: text("content").notNull(), - sourcesJson: text("sources_json"), - createdAt: text("created_at").notNull() -}); - diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts new file mode 100644 index 0000000..275e625 --- /dev/null +++ b/packages/db/src/types.ts @@ -0,0 +1,128 @@ +/** + * 手写的数据库行类型,对应 node:sqlite 查询返回的原始行。 + * 字段名用 snake_case,与 `initSchema` 中的 CREATE TABLE 列名一致。 + * mapper 函数(rowToTask 等)负责将 snake_case 转为 camelCase 业务类型。 + * + * 仅覆盖实际被查询的表;conversations/messages/settings(v1 遗留,未被 store 使用) + * 和 app_config/hook_always_rules/schema_migrations(已用原生 SQL)不在此列。 + */ + +export interface TaskRow { + id: string; + title: string; + status: "open" | "done"; + created_at: string; + completed_at: string | null; +} + +export interface FocusSessionRow { + id: string; + task_id: string | null; + status: "active" | "completed" | "cancelled"; + started_at: string; + completed_at: string | null; + duration_minutes: number; +} + +export interface WindowEventRow { + id: string; + title: string; + process_name: string; + captured_at: string; + dwell_seconds: number; + classification: "focused" | "distracted" | "stuck"; +} + +export interface ProjectRow { + id: string; + title: string; + description: string | null; + parent_id: string | null; + color: string | null; + icon: string | null; + is_inbox: number; // INTEGER 0/1,mapper 转 boolean + order: number; + created_at: string; + updated_at: string; +} + +export interface NoteRow { + id: string; + project_id: string; + title: string; + body: string; + created_at: string; + updated_at: string; +} + +export interface TagRow { + id: string; + name: string; +} + +export interface NoteTagRow { + note_id: string; + tag_id: string; +} + +export interface BoardColumnRow { + id: string; + project_id: string; + title: string; + status: "todo" | "doing" | "done" | "archived"; + order: number; +} + +export interface KnowledgeTaskRow { + id: string; + project_id: string; + column_id: string | null; + title: string; + description: string | null; + status: "todo" | "doing" | "done" | "archived"; + order: number; + linked_note_id: string | null; + created_at: string; + updated_at: string; +} + +export interface KnowledgeChunkRow { + id: string; + project_id: string; + source_type: "note" | "task"; + source_id: string; + ordinal: number; + content: string; + content_hash: string; + embedding_model: string | null; + embedding_dimensions: number | null; + index_status: "pending" | "indexed" | "failed" | "stale"; + index_error: string | null; + retry_count: number; + next_retry_at: string | null; + updated_at: string; +} + +export interface EmbeddingCacheRow { + content_hash: string; + embedding: Uint8Array; // BLOB,node:sqlite 读回为 Uint8Array + model: string; + dimensions: number; +} + +export interface AiConversationRow { + id: string; + project_id: string | null; + mode: "chat" | "ask"; + created_at: string; + updated_at: string; +} + +export interface AiMessageRow { + id: string; + conversation_id: string; + role: "system" | "user" | "assistant"; + content: string; + sources_json: string | null; + created_at: string; +} diff --git a/packages/server-local/package.json b/packages/server-local/package.json index 31cd6e2..82d5c99 100644 --- a/packages/server-local/package.json +++ b/packages/server-local/package.json @@ -15,6 +15,9 @@ }, "dependencies": { "@fastify/cors": "^11.1.0", + "@fastify/helmet": "^13.0.2", + "@fastify/rate-limit": "^11.0.0", + "@fastify/type-provider-typebox": "^6.1.0", "@fastify/websocket": "11.2.0", "@neo-companion/ai": "workspace:*", "@neo-companion/db": "workspace:*", diff --git a/packages/server-local/src/app.ts b/packages/server-local/src/app.ts index 6742f31..dfdd775 100644 --- a/packages/server-local/src/app.ts +++ b/packages/server-local/src/app.ts @@ -1,11 +1,15 @@ import cors from "@fastify/cors"; import websocket from "@fastify/websocket"; +import helmet from "@fastify/helmet"; +import rateLimit from "@fastify/rate-limit"; +import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; import Fastify, { type FastifyInstance } from "fastify"; import { timingSafeEqual } from "node:crypto"; import { streamDeepSeekChat, embedContents } from "@neo-companion/ai"; 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 { isHttpError } from "./errors"; import { createFocusManager } from "./services/focus-manager"; import { createHookManager, type HookManagerEvents } from "./services/hook-manager"; import { getWeatherSummary } from "./services/weather-service"; @@ -41,7 +45,7 @@ export async function createApp(dependencies: AppDependencies = {}) { if (!authToken) throw new Error("APP_AUTH_TOKEN is required"); const database = dependencies.database ?? createDatabase(); // Knowledge store requires the sqlite path; null when only the memory - // fallback is reachable (better-sqlite3 native binding unavailable). Routes + // fallback is reachable (for example, when the database cannot be opened). Routes // degrade to 503 in that case. const knowledgeStore: KnowledgeStore | null = database.kind === "sqlite" ? createKnowledgeStore(database) : null; // Embedding provider config persisted to the app_config table so it survives @@ -63,8 +67,10 @@ export async function createApp(dependencies: AppDependencies = {}) { let embeddingConfig: EmbeddingConfig = { ...loadedEmbeddingConfig, apiKey: undefined }; const persistEmbeddingConfig = () => { if (database.kind !== "sqlite") return; - const persisted = { ...embeddingConfig, apiKey: legacyEmbeddingApiKey }; - if (!legacyEmbeddingApiKey) delete persisted.apiKey; + // 持久化的 JSON 永远不含 apiKey:新 key 仅存 keychain/env(runtime 内存), + // legacy 明文 key 仅保留在内存供本会话迁移,不再写回磁盘。这样每次 set() + // 都会用干净的配置覆盖旧行,存量用户的明文 key 在下次保存时自动消失。 + const persisted = { ...embeddingConfig }; setAppConfig(database, EMBEDDING_CONFIG_KEY, JSON.stringify(persisted)); }; const embeddingConfigController = { @@ -88,7 +94,12 @@ export async function createApp(dependencies: AppDependencies = {}) { : null; void knowledgeService?.drainEmbeddings(); const hub = new WsHub(); - const app = Fastify({ logger: true }); + const app = Fastify({ + logger: true, + // Default body limit: 1MB. Knowledge mirror import endpoints may need more + // but currently they only accept { path } so 1MB is plenty. + bodyLimit: 1_048_576 + }).withTypeProvider(); const aiStream = dependencies.aiStream ?? ((messages) => streamDeepSeekChat(messages)); const aiConversationStore: AiConversationStore | null = database.kind === "sqlite" ? createAiConversationStore(database) : null; const aiService = createAiService({ @@ -114,6 +125,18 @@ export async function createApp(dependencies: AppDependencies = {}) { else callback(null, false); } }); + // Helmet: sidecar serves JSON APIs (no HTML), so disable CSP. + // HSTS disabled because the sidecar is HTTP on localhost. + await app.register(helmet, { + contentSecurityPolicy: false, + hsts: false, + }); + // Rate limit: guard against request storms from frontend bugs. + // Sidecar is single-user on localhost, so default IP key is sufficient. + await app.register(rateLimit, { + max: 200, + timeWindow: "1 minute", + }); await app.register(websocket); const tokenMatches = (candidate: string | undefined): boolean => { @@ -168,6 +191,8 @@ export async function createApp(dependencies: AppDependencies = {}) { void ttsSpeak(feedback.text, "温柔、轻快、像陪伴学习的朋友").then((result) => { hub.broadcast({ type: "tts:done", payload: result }); }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : "TTS feedback failed"; + hub.broadcast({ type: "tts:error", payload: { message, text: feedback.text } }); app.log.warn({ error }, "TTS feedback failed"); }); } @@ -189,6 +214,33 @@ export async function createApp(dependencies: AppDependencies = {}) { }; const hookManager = dependencies.hookManager ?? createHookManager(hookEvents, createHookRulesStore(database)); + // ── Global Error Handler ── + app.setErrorHandler((error, _request, reply) => { + const err = error as Error & { validation?: unknown }; + // Our typed business errors: return consistent shape + if (isHttpError(err)) { + reply.code(err.statusCode).send({ + error: err.message, + code: err.code + }); + return; + } + // Fastify validation errors (400 from schema checks) + if (err.validation) { + reply.code(400).send({ + error: "validation failed", + code: "VALIDATION_ERROR", + details: err.validation + }); + return; + } + // Status code set by the route via reply.code() before throwing + const status = reply.statusCode >= 400 ? reply.statusCode : 500; + const message = status === 500 ? "internal server error" : err.message; + if (status === 500) app.log.error(err, "unhandled error"); + reply.code(status).send({ error: message }); + }); + // ── Route Registration ── registerHealthRoutes(app); registerTaskRoutes(app, database, hub); @@ -209,6 +261,7 @@ export async function createApp(dependencies: AppDependencies = {}) { } app.addHook("onClose", async () => { + hub.close(); hookManager.close(); focus.close(); if (windowTimer) clearInterval(windowTimer); diff --git a/packages/server-local/src/errors.ts b/packages/server-local/src/errors.ts new file mode 100644 index 0000000..c73e6d9 --- /dev/null +++ b/packages/server-local/src/errors.ts @@ -0,0 +1,88 @@ +/** + * Typed error hierarchy for the server. + * All business errors inherit from HttpError so setErrorHandler can map them + * to consistent HTTP responses. + * + * Usage: + * throw new BadRequestError("title is required") + * throw new NotFoundError("task", taskId) + */ + +export abstract class HttpError extends Error { + abstract readonly statusCode: number; + abstract readonly code: string; + + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +// ── 400 Bad Request ── + +export class BadRequestError extends HttpError { + readonly statusCode = 400; + readonly code = "BAD_REQUEST"; +} + +export class ValidationError extends HttpError { + readonly statusCode = 400; + readonly code = "VALIDATION_ERROR"; + constructor(message: string, readonly details?: unknown) { + super(message); + } +} + +// ── 401 Unauthorized ── + +export class UnauthorizedError extends HttpError { + readonly statusCode = 401; + readonly code = "UNAUTHORIZED"; +} + +// ── 403 Forbidden ── + +export class ForbiddenError extends HttpError { + readonly statusCode = 403; + readonly code = "FORBIDDEN"; + constructor(message = "forbidden") { + super(message); + } +} + +// ── 404 Not Found ── + +export class NotFoundError extends HttpError { + readonly statusCode = 404; + readonly code = "NOT_FOUND"; + constructor(kind: string, id?: string) { + super(id ? `${kind} "${id}" not found` : `${kind} not found`); + } +} + +// ── 410 Gone (for stale permission requests) ── + +export class StaleError extends HttpError { + readonly statusCode = 410; + readonly code = "STALE"; + constructor(message = "request stale") { + super(message); + } +} + +// ── 503 Service Unavailable ── + +export class ServiceUnavailableError extends HttpError { + readonly statusCode = 503; + readonly code = "SERVICE_UNAVAILABLE"; + constructor(message = "service unavailable") { + super(message); + } +} + +// ── Type Guard ── + +export function isHttpError(error: unknown): error is HttpError { + return error instanceof HttpError; +} diff --git a/packages/server-local/src/index.ts b/packages/server-local/src/index.ts index ddf3331..f86d2bc 100644 --- a/packages/server-local/src/index.ts +++ b/packages/server-local/src/index.ts @@ -20,6 +20,16 @@ const host = process.env.NEO_SERVER_HOST ?? "127.0.0.1"; const app = await createApp(); await app.listen({ port, host }); +for (const sig of ["SIGINT", "SIGTERM"] as const) { + process.on(sig, () => { + app.log.info({ sig }, "shutting down"); + app.close().then(() => process.exit(0)).catch((err) => { + app.log.error({ err }, "graceful shutdown failed"); + process.exit(1); + }); + }); +} + function unique(values: string[]) { return [...new Set(values)]; } diff --git a/packages/server-local/src/modules/knowledge/indexer.test.ts b/packages/server-local/src/modules/knowledge/indexer.test.ts index a1f6874..1f640e8 100644 --- a/packages/server-local/src/modules/knowledge/indexer.test.ts +++ b/packages/server-local/src/modules/knowledge/indexer.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createDatabase, createKnowledgeStore, isSqliteAvailable } from "@neo-companion/db"; import { createKnowledgeService } from "./service"; -// Skipped when better-sqlite3 native binding is unavailable (this environment). +// Skipped when the SQLite database cannot be opened in this environment. describe.skipIf(!isSqliteAvailable())("knowledge indexer (FTS5)", () => { it("reindexes a note and returns it via FTS5 search", () => { const database = createDatabase(":memory:"); diff --git a/packages/server-local/src/modules/knowledge/mirror.test.ts b/packages/server-local/src/modules/knowledge/mirror.test.ts index 2bb456c..70bc9fd 100644 --- a/packages/server-local/src/modules/knowledge/mirror.test.ts +++ b/packages/server-local/src/modules/knowledge/mirror.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { KnowledgeStore } from "@neo-companion/db"; +import { createDatabase, createKnowledgeStore, type KnowledgeStore } from "@neo-companion/db"; import { exportToDir, importFromDir } from "./mirror"; +import { createKnowledgeService } from "./service"; import type { KnowledgeBoardColumn, KnowledgeNote, @@ -179,4 +180,35 @@ describe("knowledge file mirror", () => { expect(second.importedProjects).toBe(0); expect(second.importedNotes).toBe(0); }); + + it("imports and reindexes through the real sqlite store in one transaction", async () => { + const source = createFakeStore(); + const project = source.createProject({ title: "真实导入", order: 1 }); + const note = source.createNote(project.id, "嵌套事务笔记"); + source.updateNote(note.id, { + body: "镜像导入索引内容用于验证真实数据库事务和全文检索重建", + tags: ["sqlite"] + }); + await exportToDir(source, dir); + + const database = createDatabase(":memory:"); + try { + const store = createKnowledgeStore(database); + const service = createKnowledgeService(store); + const stats = await importFromDir(store, dir, { + noteChanged: (changed) => service.reindexNote(changed), + taskChanged: (changed) => service.reindexTask(changed) + }); + + expect(stats.errors).toEqual([]); + expect(stats.importedNotes).toBe(1); + expect(stats.reindexedNotes).toBe(1); + expect(store.searchFts(project.id, "镜像导入索引", 5)[0]).toMatchObject({ + sourceId: note.id, + sourceType: "note" + }); + } finally { + database.close(); + } + }); }); diff --git a/packages/server-local/src/modules/knowledge/routes.ts b/packages/server-local/src/modules/knowledge/routes.ts index 9a58fba..aab90fb 100644 --- a/packages/server-local/src/modules/knowledge/routes.ts +++ b/packages/server-local/src/modules/knowledge/routes.ts @@ -1,8 +1,46 @@ import type { FastifyInstance } from "fastify"; import type { KnowledgeStore } from "@neo-companion/db"; -import type { - KnowledgeTaskStatus +import { + ProjectListQuerySchema, + ProjectIdParamSchema, + ProjectCreateBodySchema, + ProjectPatchBodySchema, + NoteCreateBodySchema, + NoteIdParamSchema, + NotePatchBodySchema, + ColumnCreateBodySchema, + ColumnIdParamSchema, + ColumnPatchBodySchema, + KnowledgeTaskCreateBodySchema, + KnowledgeTaskPatchBodySchema, + TaskIdParamSchema, + KnowledgeTaskMoveBodySchema, + KnowledgeSearchQuerySchema, + KnowledgeReindexBodySchema, + EmbeddingConfigBodySchema, + RootPathBodySchema, + MirrorPathBodySchema, + type ProjectListQuery, + type ProjectIdParam, + type ProjectCreateBody, + type ProjectPatchBody, + type NoteCreateBody, + type NoteIdParam, + type NotePatchBody, + type ColumnCreateBody, + type ColumnIdParam, + type ColumnPatchBody, + type KnowledgeTaskCreateBody, + type KnowledgeTaskPatchBody, + type TaskIdParam, + type KnowledgeTaskMoveBody, + type KnowledgeSearchQuery, + type KnowledgeReindexBody, + type EmbeddingConfigBody, + type RootPathBody, + type MirrorPathBody } from "@neo-companion/shared"; +import { NotFoundError, ServiceUnavailableError, BadRequestError } from "../../errors"; import { exportToDir, importFromDir } from "./mirror"; import type { EmbeddingConfig, KnowledgeService } from "./service"; @@ -22,7 +60,7 @@ export interface RootPathController { * Knowledge workspace REST routes (Phase 1 CRUD). * * The knowledge store requires the sqlite path; when only the memory fallback - * is reachable (e.g. better-sqlite3 native binding unavailable), `store` is null + * is reachable (e.g. the configured database cannot be opened), `store` is null * and these routes return 503 so the rest of the app keeps working. * * Retrieval (search / index-status / reindex) and AI integration land in @@ -40,234 +78,273 @@ export function registerKnowledgeRoutes( // export/import. Empty until the user picks a folder. let rootPath = rootPathController?.get() ?? ""; - const requireStore = (reply: import("fastify").FastifyReply): KnowledgeStore | null => { - if (!store) { - reply.code(503).send({ error: "knowledge store unavailable (sqlite not loaded)" }); - return null; - } + const requireStore = (): KnowledgeStore => { + if (!store) throw new ServiceUnavailableError("knowledge store unavailable (sqlite not loaded)"); return store; }; - const requireService = (reply: import("fastify").FastifyReply): KnowledgeService | null => { - if (!service) { - reply.code(503).send({ error: "knowledge index unavailable (sqlite not loaded)" }); - return null; - } + const requireService = (): KnowledgeService => { + if (!service) throw new ServiceUnavailableError("knowledge index unavailable (sqlite not loaded)"); return service; }; // ── Projects ── - app.get("/api/knowledge/projects", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const query = request.query as { parentId?: string; root?: string }; - if (query.parentId) return kw.childProjects(query.parentId); - if (query.root === "1") { - return kw.listProjects().filter((p) => p.parentId === null); + app.get<{ Querystring: ProjectListQuery }>( + "/api/knowledge/projects", + { schema: { querystring: ProjectListQuerySchema } }, + async (request) => { + const kw = requireStore(); + if (request.query.parentId) return kw.childProjects(request.query.parentId); + if (request.query.root === "1") { + return kw.listProjects().filter((p) => p.parentId === null); + } + return kw.listProjects(); } - return kw.listProjects(); - }); - - app.get("/api/knowledge/projects/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const project = kw.getProject(id); - if (!project) return reply.code(404).send({ error: "project not found" }); - return project; - }); - - app.get("/api/knowledge/projects/:id/path", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - return kw.projectPath(id); - }); - - app.post("/api/knowledge/projects", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const body = request.body as { title?: string; parentId?: string | null; description?: string; color?: string; icon?: string }; - if (!body.title?.trim()) return reply.code(400).send({ error: "title is required" }); - return kw.createProject({ title: body.title, parentId: body.parentId ?? null, description: body.description, color: body.color, icon: body.icon }); - }); - - app.patch("/api/knowledge/projects/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const body = request.body as { title?: string; description?: string; color?: string; icon?: string; parentId?: string | null; order?: number }; - const project = kw.updateProject(id, body); - if (!project) return reply.code(404).send({ error: "project not found" }); - return project; - }); - - app.delete("/api/knowledge/projects/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - kw.deleteProject(id); - return reply.code(204).send(); - }); + ); + + app.get<{ Params: ProjectIdParam }>( + "/api/knowledge/projects/:id", + { schema: { params: ProjectIdParamSchema } }, + async (request) => { + const kw = requireStore(); + const project = kw.getProject(request.params.id); + if (!project) throw new NotFoundError("project", request.params.id); + return project; + } + ); + + app.get<{ Params: ProjectIdParam }>( + "/api/knowledge/projects/:id/path", + { schema: { params: ProjectIdParamSchema } }, + async (request) => { + const kw = requireStore(); + return kw.projectPath(request.params.id); + } + ); + + app.post<{ Body: ProjectCreateBody }>( + "/api/knowledge/projects", + { schema: { body: ProjectCreateBodySchema } }, + async (request) => { + const kw = requireStore(); + return kw.createProject({ + title: request.body.title, + parentId: request.body.parentId ?? null, + description: request.body.description, + color: request.body.color, + icon: request.body.icon + }); + } + ); + + app.patch<{ Params: ProjectIdParam; Body: ProjectPatchBody }>( + "/api/knowledge/projects/:id", + { schema: { params: ProjectIdParamSchema, body: ProjectPatchBodySchema } }, + async (request) => { + const kw = requireStore(); + const project = kw.updateProject(request.params.id, request.body); + if (!project) throw new NotFoundError("project", request.params.id); + return project; + } + ); + + app.delete<{ Params: ProjectIdParam }>( + "/api/knowledge/projects/:id", + { schema: { params: ProjectIdParamSchema } }, + async (request, reply) => { + const kw = requireStore(); + kw.deleteProject(request.params.id); + return reply.code(204).send(); + } + ); // ── Notes ── - app.get("/api/knowledge/projects/:id/notes", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - return kw.notesForProject(id); - }); - - app.post("/api/knowledge/projects/:id/notes", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const body = request.body as { title?: string }; - const note = kw.createNote(id, body.title?.trim() || "无标题笔记"); - service?.reindexNote(note); - return note; - }); - - app.get("/api/knowledge/notes/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const note = kw.getNote(id); - if (!note) return reply.code(404).send({ error: "note not found" }); - return note; - }); - - app.patch("/api/knowledge/notes/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const body = request.body as { title?: string; body?: string; tags?: string[] }; - const note = kw.updateNote(id, body); - if (!note) return reply.code(404).send({ error: "note not found" }); - service?.reindexNote(note); - return note; - }); - - app.delete("/api/knowledge/notes/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - kw.deleteNote(id); - service && kw; // removeIndex handled by store.deleteNote cascade (chunks deleted) - return reply.code(204).send(); - }); - - app.get("/api/knowledge/notes/:id/backlinks", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - return kw.backlinksFor(id); - }); + app.get<{ Params: ProjectIdParam }>( + "/api/knowledge/projects/:id/notes", + { schema: { params: ProjectIdParamSchema } }, + async (request) => { + const kw = requireStore(); + return kw.notesForProject(request.params.id); + } + ); + + app.post<{ Params: ProjectIdParam; Body: NoteCreateBody }>( + "/api/knowledge/projects/:id/notes", + { schema: { params: ProjectIdParamSchema, body: NoteCreateBodySchema } }, + async (request) => { + const kw = requireStore(); + const note = kw.createNote(request.params.id, request.body.title?.trim() || "无标题笔记"); + service?.reindexNote(note); + return note; + } + ); + + app.get<{ Params: NoteIdParam }>( + "/api/knowledge/notes/:id", + { schema: { params: NoteIdParamSchema } }, + async (request) => { + const kw = requireStore(); + const note = kw.getNote(request.params.id); + if (!note) throw new NotFoundError("note", request.params.id); + return note; + } + ); + + app.patch<{ Params: NoteIdParam; Body: NotePatchBody }>( + "/api/knowledge/notes/:id", + { schema: { params: NoteIdParamSchema, body: NotePatchBodySchema } }, + async (request) => { + const kw = requireStore(); + const note = kw.updateNote(request.params.id, request.body); + if (!note) throw new NotFoundError("note", request.params.id); + service?.reindexNote(note); + return note; + } + ); + + app.delete<{ Params: NoteIdParam }>( + "/api/knowledge/notes/:id", + { schema: { params: NoteIdParamSchema } }, + async (request, reply) => { + const kw = requireStore(); + kw.deleteNote(request.params.id); + // removeIndex handled by store.deleteNote cascade (chunks deleted) + return reply.code(204).send(); + } + ); + + app.get<{ Params: NoteIdParam }>( + "/api/knowledge/notes/:id/backlinks", + { schema: { params: NoteIdParamSchema } }, + async (request) => { + const kw = requireStore(); + return kw.backlinksFor(request.params.id); + } + ); // ── Board columns ── - app.get("/api/knowledge/projects/:id/columns", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - return kw.columnsForProject(id); - }); - - app.post("/api/knowledge/projects/:id/columns", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const body = request.body as { title?: string; status?: KnowledgeTaskStatus; order?: number }; - if (!body.title?.trim()) return reply.code(400).send({ error: "title is required" }); - return kw.createColumn(id, { title: body.title, status: body.status ?? "todo", order: body.order ?? 0 }); - }); - - app.patch("/api/knowledge/columns/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const body = request.body as { title?: string; status?: KnowledgeTaskStatus; order?: number }; - const column = kw.updateColumn(id, body); - if (!column) return reply.code(404).send({ error: "column not found" }); - return column; - }); - - app.delete("/api/knowledge/columns/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - kw.deleteColumn(id); - return reply.code(204).send(); - }); + app.get<{ Params: ProjectIdParam }>( + "/api/knowledge/projects/:id/columns", + { schema: { params: ProjectIdParamSchema } }, + async (request) => { + const kw = requireStore(); + return kw.columnsForProject(request.params.id); + } + ); + + app.post<{ Params: ProjectIdParam; Body: ColumnCreateBody }>( + "/api/knowledge/projects/:id/columns", + { schema: { params: ProjectIdParamSchema, body: ColumnCreateBodySchema } }, + async (request) => { + const kw = requireStore(); + return kw.createColumn(request.params.id, { + title: request.body.title, + status: request.body.status ?? "todo", + order: request.body.order ?? 0 + }); + } + ); + + app.patch<{ Params: ColumnIdParam; Body: ColumnPatchBody }>( + "/api/knowledge/columns/:id", + { schema: { params: ColumnIdParamSchema, body: ColumnPatchBodySchema } }, + async (request) => { + const kw = requireStore(); + const column = kw.updateColumn(request.params.id, request.body); + if (!column) throw new NotFoundError("column", request.params.id); + return column; + } + ); + + app.delete<{ Params: ColumnIdParam }>( + "/api/knowledge/columns/:id", + { schema: { params: ColumnIdParamSchema } }, + async (request, reply) => { + const kw = requireStore(); + kw.deleteColumn(request.params.id); + return reply.code(204).send(); + } + ); // ── Tasks ── - app.get("/api/knowledge/projects/:id/tasks", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - return kw.tasksForProject(id); - }); - - app.post("/api/knowledge/projects/:id/tasks", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const body = request.body as { columnId?: string; title?: string }; - if (!body.title?.trim()) return reply.code(400).send({ error: "title is required" }); - const task = kw.createTask(id, body.columnId ?? "", body.title); - service?.reindexTask(task); - return task; - }); - - app.patch("/api/knowledge/tasks/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const body = request.body as { title?: string; description?: string; status?: KnowledgeTaskStatus; columnId?: string; order?: number; linkedNoteId?: string | null }; - const task = kw.updateTask(id, body); - if (!task) return reply.code(404).send({ error: "task not found" }); - if (body.title !== undefined || body.description !== undefined) service?.reindexTask(task); - return task; - }); - - app.delete("/api/knowledge/tasks/:id", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - kw.deleteTask(id); - return reply.code(204).send(); - }); - - app.post("/api/knowledge/tasks/:id/move", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const { id } = request.params as { id: string }; - const body = request.body as { columnId?: string; index?: number }; - kw.moveTask(id, body.columnId ?? "", body.index ?? 0); - return reply.code(204).send(); - }); + app.get<{ Params: ProjectIdParam }>( + "/api/knowledge/projects/:id/tasks", + { schema: { params: ProjectIdParamSchema } }, + async (request) => { + const kw = requireStore(); + return kw.tasksForProject(request.params.id); + } + ); + + app.post<{ Params: ProjectIdParam; Body: KnowledgeTaskCreateBody }>( + "/api/knowledge/projects/:id/tasks", + { schema: { params: ProjectIdParamSchema, body: KnowledgeTaskCreateBodySchema } }, + async (request) => { + const kw = requireStore(); + const task = kw.createTask(request.params.id, request.body.columnId ?? "", request.body.title); + service?.reindexTask(task); + return task; + } + ); + + app.patch<{ Params: TaskIdParam; Body: KnowledgeTaskPatchBody }>( + "/api/knowledge/tasks/:id", + { schema: { params: TaskIdParamSchema, body: KnowledgeTaskPatchBodySchema } }, + async (request) => { + const kw = requireStore(); + const task = kw.updateTask(request.params.id, request.body); + if (!task) throw new NotFoundError("task", request.params.id); + if (request.body.title !== undefined || request.body.description !== undefined) { + service?.reindexTask(task); + } + return task; + } + ); + + app.delete<{ Params: TaskIdParam }>( + "/api/knowledge/tasks/:id", + { schema: { params: TaskIdParamSchema } }, + async (request, reply) => { + const kw = requireStore(); + kw.deleteTask(request.params.id); + return reply.code(204).send(); + } + ); + + app.post<{ Params: TaskIdParam; Body: KnowledgeTaskMoveBody }>( + "/api/knowledge/tasks/:id/move", + { schema: { params: TaskIdParamSchema, body: KnowledgeTaskMoveBodySchema } }, + async (request, reply) => { + const kw = requireStore(); + kw.moveTask(request.params.id, request.body.columnId ?? "", request.body.index ?? 0); + return reply.code(204).send(); + } + ); // ── Retrieval (Phase 2: FTS5; Phase 3 adds vec + RRF) ── - app.get("/api/knowledge/search", async (request, reply) => { - const svc = requireService(reply); - if (!svc) return; - const query = request.query as { q?: string; projectId?: string; limit?: string }; - if (!query.q?.trim()) return reply.code(400).send({ error: "q is required" }); - const limit = query.limit ? Number.parseInt(query.limit, 10) : 20; - return svc.searchHybrid(query.projectId ?? null, query.q, Number.isFinite(limit) ? limit : 20); - }); + app.get<{ Querystring: KnowledgeSearchQuery }>( + "/api/knowledge/search", + { schema: { querystring: KnowledgeSearchQuerySchema } }, + async (request) => { + const svc = requireService(); + const limit = request.query.limit ? Number.parseInt(request.query.limit, 10) : 20; + return svc.searchHybrid(request.query.projectId ?? null, request.query.q, Number.isFinite(limit) ? limit : 20); + } + ); - app.get("/api/knowledge/index-status", async (request, reply) => { - const svc = requireService(reply); - if (!svc) return; + app.get("/api/knowledge/index-status", async () => { + const svc = requireService(); return svc.indexStatus(); }); - app.post("/api/knowledge/reindex", async (request, reply) => { - const svc = requireService(reply); - if (!svc) return; - const body = (request.body as { embeddingModel?: string } | null) ?? {}; - if (body.embeddingModel) svc.markStale(body.embeddingModel); - return svc.reindexAll(); - }); + app.post<{ Body: KnowledgeReindexBody }>( + "/api/knowledge/reindex", + { schema: { body: KnowledgeReindexBodySchema } }, + async (request) => { + const svc = requireService(); + if (request.body.embeddingModel) svc.markStale(request.body.embeddingModel); + return svc.reindexAll(); + } + ); // ── Embedding provider config (Phase 3) ── // Persisted to app_config (survives restart); apiKey lives only in the local @@ -288,78 +365,87 @@ export function registerKnowledgeRoutes( }; }); - app.post("/api/knowledge/embedding-config/legacy-secret/claim", async (request, reply) => { + app.post("/api/knowledge/embedding-config/legacy-secret/claim", async () => { const apiKey = embeddingConfigController?.getLegacySecret(); - if (!apiKey) return reply.code(404).send({ error: "legacy secret not found" }); + if (!apiKey) throw new NotFoundError("legacy secret"); return { apiKey }; }); - app.delete("/api/knowledge/embedding-config/legacy-secret", async (request, reply) => { + app.delete("/api/knowledge/embedding-config/legacy-secret", async (_request, reply) => { embeddingConfigController?.clearLegacySecret(); return reply.code(204).send(); }); - app.put("/api/knowledge/embedding-config", async (request, reply) => { - if (!embeddingConfigController) return reply.code(503).send({ error: "embedding config unavailable" }); - const body = request.body as Partial; - const current = embeddingConfigController.get(); - // A non-empty apiKey overwrites the stored secret; an absent/empty apiKey - // keeps the existing stored key (UI masks the secret). To switch to env-var - // mode the client sends an explicit apiKey: null which clears the stored key. - let apiKey: string | undefined; - if (body.apiKey) { - apiKey = body.apiKey; - } else if (body.apiKey === null) { - apiKey = undefined; // clear stored key → fall back to env var - } else { - apiKey = current.apiKey; - } - const next: EmbeddingConfig = { - provider: body.provider ?? current.provider, - baseUrl: body.baseUrl ?? current.baseUrl, - apiKey, - model: body.model ?? current.model, - apiKeySource: body.apiKeySource ?? current.apiKeySource - }; - embeddingConfigController.set(next); - // model change → mark stale so chunks re-embed with the new model - if (next.model && next.model !== current.model) { - service?.markStale(next.model); + app.put<{ Body: EmbeddingConfigBody }>( + "/api/knowledge/embedding-config", + { schema: { body: EmbeddingConfigBodySchema } }, + async (request) => { + if (!embeddingConfigController) throw new ServiceUnavailableError("embedding config unavailable"); + const body = request.body; + const current = embeddingConfigController.get(); + // A non-empty apiKey overwrites the stored secret; an absent/empty apiKey + // keeps the existing stored key (UI masks the secret). To switch to env-var + // mode the client sends an explicit apiKey: null which clears the stored key. + let apiKey: string | undefined; + if (body.apiKey) { + apiKey = body.apiKey; + } else if (body.apiKey === null) { + apiKey = undefined; // clear stored key → fall back to env var + } else { + apiKey = current.apiKey; + } + const next: EmbeddingConfig = { + provider: body.provider ?? current.provider, + baseUrl: body.baseUrl ?? current.baseUrl, + apiKey, + model: body.model ?? current.model, + apiKeySource: body.apiKeySource ?? current.apiKeySource + }; + embeddingConfigController.set(next); + // model change → mark stale so chunks re-embed with the new model + if (next.model && next.model !== current.model) { + service?.markStale(next.model); + } + await service?.drainEmbeddings(); + return { ok: true }; } - await service?.drainEmbeddings(); - return { ok: true }; - }); + ); // ── File mirror (hybrid SQLite + Markdown/JSONL) ── app.get("/api/knowledge/root-path", async () => ({ path: rootPath })); - app.put("/api/knowledge/root-path", async (request, reply) => { - const body = request.body as { path?: string }; - rootPath = (body.path ?? "").trim(); - rootPathController?.set(rootPath); - return { path: rootPath }; - }); - - app.post("/api/knowledge/mirror/export", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const body = (request.body as { path?: string } | null) ?? {}; - const target = (body.path ?? rootPath).trim(); - if (!target) return reply.code(400).send({ error: "rootPath not set" }); - const stats = await exportToDir(kw, target); - return stats; - }); - - app.post("/api/knowledge/mirror/import", async (request, reply) => { - const kw = requireStore(reply); - if (!kw) return; - const body = (request.body as { path?: string } | null) ?? {}; - const source = (body.path ?? rootPath).trim(); - if (!source) return reply.code(400).send({ error: "rootPath not set" }); - const stats = await importFromDir(kw, source, { - noteChanged: (note) => service?.reindexNote(note), - taskChanged: (task) => service?.reindexTask(task) - }); - return stats; - }); + app.put<{ Body: RootPathBody }>( + "/api/knowledge/root-path", + { schema: { body: RootPathBodySchema } }, + async (request) => { + rootPath = (request.body.path ?? "").trim(); + rootPathController?.set(rootPath); + return { path: rootPath }; + } + ); + + app.post<{ Body: MirrorPathBody }>( + "/api/knowledge/mirror/export", + { schema: { body: MirrorPathBodySchema } }, + async (request) => { + const kw = requireStore(); + const target = (request.body.path ?? rootPath).trim(); + if (!target) throw new BadRequestError("rootPath not set"); + return await exportToDir(kw, target); + } + ); + + app.post<{ Body: MirrorPathBody }>( + "/api/knowledge/mirror/import", + { schema: { body: MirrorPathBodySchema } }, + async (request) => { + const kw = requireStore(); + const source = (request.body.path ?? rootPath).trim(); + if (!source) throw new BadRequestError("rootPath not set"); + return await importFromDir(kw, source, { + noteChanged: (note) => service?.reindexNote(note), + taskChanged: (task) => service?.reindexTask(task) + }); + } + ); } diff --git a/packages/server-local/src/routes/ai.ts b/packages/server-local/src/routes/ai.ts index 13d6087..5cf6df1 100644 --- a/packages/server-local/src/routes/ai.ts +++ b/packages/server-local/src/routes/ai.ts @@ -1,32 +1,25 @@ import type { FastifyInstance } from "fastify"; +import { AiChatBodySchema, type AiChatBody } from "@neo-companion/shared"; 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 }) + app.post<{ Body: AiChatBody }>( + "/api/ai/chat", + { schema: { body: AiChatBodySchema } }, + async (request) => { + const mode = resolveMode(request.body.mode); + return mode === "ask" + ? await aiService.handleAsk({ + message: request.body.message, + projectId: request.body.projectId ?? null + }) : await aiService.handleChat({ - message: body.message, - projectId: body.projectId ?? null, - context: body.context, - conversationId: body.conversationId + message: request.body.message, + projectId: request.body.projectId ?? null, + context: request.body.context as ChatContextSelection | undefined, + conversationId: request.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 index 433a589..12fa6df 100644 --- a/packages/server-local/src/routes/focus.ts +++ b/packages/server-local/src/routes/focus.ts @@ -1,24 +1,37 @@ import type { FastifyInstance } from "fastify"; import type { CompanionFeedback } from "@neo-companion/shared"; +import { + FocusStartBodySchema, + IdParamSchema, + type FocusStartBody, + type IdParam +} from "@neo-companion/shared"; +import { NotFoundError } from "../errors"; 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<{ Body: FocusStartBody }>( + "/api/focus/start", + { schema: { body: FocusStartBodySchema } }, + async (request) => { + const session = focus.start(request.body.taskId ?? null, request.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.post<{ Params: IdParam }>( + "/api/focus/:id/complete", + { schema: { params: IdParamSchema } }, + async (request) => { + const session = focus.complete(request.params.id); + if (!session) throw new NotFoundError("focus session", request.params.id); + return session; + } + ); } diff --git a/packages/server-local/src/routes/hooks.ts b/packages/server-local/src/routes/hooks.ts index 483dbb7..0e4bc86 100644 --- a/packages/server-local/src/routes/hooks.ts +++ b/packages/server-local/src/routes/hooks.ts @@ -1,59 +1,66 @@ import type { FastifyInstance } from "fastify"; import type { AgentState } from "@neo-companion/shared"; +import { + HookPushBodySchema, + HookPermissionBodySchema, + HookAlwaysRuleDeleteBodySchema, + type HookPushBody, + type HookPermissionBody, + type HookAlwaysRuleDeleteBody +} from "@neo-companion/shared"; +import { StaleError, ServiceUnavailableError } from "../errors"; 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 + app.post<{ Body: HookPushBody }>( + "/api/hook/push", + { schema: { body: HookPushBodySchema } }, + async (request, reply) => { + hookManager.pushEvent({ + agentId: request.body.agentId, + type: "status", + state: request.body.state as AgentState, + description: request.body.description, + timestamp: request.body.timestamp ?? Date.now() }); - 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(204).send(); + } + ); + + app.post<{ Body: HookPermissionBody }>( + "/api/hook/permission", + { schema: { body: HookPermissionBodySchema } }, + async (request) => { + try { + return await hookManager.requestPermission({ + agentId: request.body.agentId, + command: request.body.command, + severity: request.body.severity, + description: request.body.description + }); + } catch (error) { + const message = error instanceof Error ? error.message : "permission request failed"; + if (message === "stale" || message === "agentStateChanged") { + throw new StaleError("request stale"); + } + if (message === "shutdown") { + throw new ServiceUnavailableError("server shutting down"); + } + throw error; } - 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.delete<{ Body: HookAlwaysRuleDeleteBody }>( + "/api/hook/always-rules", + { schema: { body: HookAlwaysRuleDeleteBodySchema } }, + async (request, reply) => { + hookManager.removeAlwaysRule(request.body.agentId, request.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 index 5921c4a..ada525e 100644 --- a/packages/server-local/src/routes/tasks.ts +++ b/packages/server-local/src/routes/tasks.ts @@ -1,26 +1,49 @@ import type { FastifyInstance } from "fastify"; import type { NeoDatabase } from "@neo-companion/db"; +import { + TaskCreateBodySchema, + TaskPatchBodySchema, + TaskListQuerySchema, + IdParamSchema, + type TaskCreateBody, + type TaskPatchBody, + type TaskListQuery, + type IdParam +} from "@neo-companion/shared"; +import { NotFoundError } from "../errors"; 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.get<{ Querystring: TaskListQuery }>( + "/api/tasks", + { schema: { querystring: TaskListQuerySchema } }, + async (request) => { + const { limit, offset } = request.query; + return taskStore.list({ limit: limit ?? 20, offset: offset ?? 0 }); + } + ); - 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.post<{ Body: TaskCreateBody }>( + "/api/tasks", + { schema: { body: TaskCreateBodySchema } }, + async (request) => { + const task = taskStore.create(request.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; - }); + app.patch<{ Params: IdParam; Body: TaskPatchBody }>( + "/api/tasks/:id", + { schema: { params: IdParamSchema, body: TaskPatchBodySchema } }, + async (request) => { + const task = taskStore.patch(request.params.id, request.body); + if (!task) throw new NotFoundError("task", request.params.id); + 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 index 2b295ac..e60fb0e 100644 --- a/packages/server-local/src/routes/tts.ts +++ b/packages/server-local/src/routes/tts.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from "fastify"; import { speakWithMimo } from "@neo-companion/tts"; +import { TtsSpeakBodySchema, type TtsSpeakBody } from "@neo-companion/shared"; import type { WsHub } from "../ws-hub"; export function registerTtsRoutes( @@ -7,13 +8,22 @@ export function registerTtsRoutes( 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; - }); + app.post<{ Body: TtsSpeakBody }>( + "/api/tts/speak", + { schema: { body: TtsSpeakBodySchema } }, + async (request) => { + const { text, style } = request.body; + hub.broadcast({ type: "tts:started", payload: { text } }); + try { + const result = await (ttsSpeak ?? speakWithMimo)(text, style ?? "温柔、自然"); + hub.broadcast({ type: "tts:done", payload: result }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "TTS synthesis failed"; + // Notify the frontend that the started TTS won't complete. + hub.broadcast({ type: "tts:error", payload: { message, text } }); + throw error; + } + } + ); } diff --git a/packages/server-local/src/routes/ws.ts b/packages/server-local/src/routes/ws.ts index efe6b86..7a5b54b 100644 --- a/packages/server-local/src/routes/ws.ts +++ b/packages/server-local/src/routes/ws.ts @@ -5,10 +5,37 @@ import type { WsHub } from "../ws-hub"; type HookManager = ReturnType; +/** Max inbound WS message size (bytes). Oversized messages get close(1009). */ +const MAX_MESSAGE_BYTES = 64 * 1024; +/** Max inbound messages per client per window before close(1008). */ +const MAX_MESSAGES_PER_WINDOW = 30; +const MESSAGE_WINDOW_MS = 1000; + export function registerWsRoutes(app: FastifyInstance, hub: WsHub, hookManager: HookManager) { app.get("/ws", { websocket: true }, (socket) => { hub.add(socket); + // Simple per-connection token bucket for inbound message rate. + let tokens = MAX_MESSAGES_PER_WINDOW; + let lastRefill = Date.now(); + const consumeToken = (): boolean => { + const now = Date.now(); + const elapsed = now - lastRefill; + tokens = Math.min(MAX_MESSAGES_PER_WINDOW, tokens + (elapsed / MESSAGE_WINDOW_MS) * MAX_MESSAGES_PER_WINDOW); + lastRefill = now; + if (tokens < 1) return false; + tokens -= 1; + return true; + }; + socket.on("message", (raw: Buffer) => { + if (raw.length > MAX_MESSAGE_BYTES) { + socket.close(1009, "message too big"); + return; + } + if (!consumeToken()) { + socket.close(1008, "rate limit exceeded"); + return; + } try { const message = JSON.parse(raw.toString()) as { type?: string; payload?: Record }; if (message.type === "ping") { diff --git a/packages/server-local/src/services/weather-service.ts b/packages/server-local/src/services/weather-service.ts index a222ef7..e34e54e 100644 --- a/packages/server-local/src/services/weather-service.ts +++ b/packages/server-local/src/services/weather-service.ts @@ -8,7 +8,11 @@ interface ForecastResponse { current?: { temperature_2m?: number; precipitation?: number }; } -export async function getWeatherSummary(fetcher: typeof fetch = fetch): Promise { +/** Cache weather for 10 minutes to avoid hammering open-meteo on every poll. */ +const CACHE_TTL_MS = Number(process.env.NEO_WEATHER_CACHE_TTL_MS ?? 10 * 60 * 1000); +let cache: { value: WeatherSummary; expiresAt: number } | null = null; + +async function fetchWeatherSummary(fetcher: typeof fetch): Promise { const city = process.env.NEO_CITY?.trim(); let latitude = Number(process.env.NEO_LAT); let longitude = Number(process.env.NEO_LON); @@ -49,3 +53,18 @@ export async function getWeatherSummary(fetcher: typeof fetch = fetch): Promise< : `${resolvedCity} 现在约 ${temperatureC ?? "未知"}°C,适合慢慢进入今天的节奏。` }; } + +export async function getWeatherSummary(fetcher: typeof fetch = fetch): Promise { + // Serve from cache when fresh. + if (cache && cache.expiresAt > Date.now()) { + return cache.value; + } + const value = await fetchWeatherSummary(fetcher); + cache = { value, expiresAt: Date.now() + CACHE_TTL_MS }; + return value; +} + +/** Bypass cache (used by tests / force-refresh query). */ +export function clearWeatherCache(): void { + cache = null; +} diff --git a/packages/server-local/src/tests/app.test.ts b/packages/server-local/src/tests/app.test.ts index 0cfb350..0fdb722 100644 --- a/packages/server-local/src/tests/app.test.ts +++ b/packages/server-local/src/tests/app.test.ts @@ -1,4 +1,4 @@ -import { createDatabase, getAppConfig, type NeoDatabase } from "@neo-companion/db"; +import { createDatabase, getAppConfig, setAppConfig, type NeoDatabase } from "@neo-companion/db"; import type { ChatMessage } from "@neo-companion/shared"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { WebSocket } from "ws"; @@ -50,6 +50,53 @@ describe("server app", () => { const status = (await app.inject({ method: "GET", url: "/api/knowledge/embedding-config" })).json(); expect(status.apiKeySource).toBe("keychain"); }); + + it("does not re-persist a legacy plaintext key when saving config", async () => { + // Simulate an upgraded install where a plaintext embedding key was left in + // app_config by an older sidecar version. createApp loads it into memory as + // legacyEmbeddingApiKey; the fix ensures subsequent saves never write it back. + const legacyDb = createDatabase(":memory:"); + setAppConfig(legacyDb, "embedding", JSON.stringify({ + provider: "openai", + baseUrl: "https://api.openai.com", + model: "text-embedding-3-small", + apiKey: "legacy-plaintext-secret" + })); + expect(getAppConfig(legacyDb, "embedding")).toContain("legacy-plaintext-secret"); + + const legacyApp = await createApp({ + authToken: "test-token", + database: legacyDb, + startBackground: false, + aiStream: async function* () { yield "ok"; } + }); + try { + // Saving a config change (model swap) without providing an apiKey must + // overwrite the stored row with a clean, key-less JSON. + const res = await legacyApp.inject({ + method: "PUT", + url: "/api/knowledge/embedding-config", + headers: { authorization: "Bearer test-token" }, + payload: { model: "text-embedding-3-large" } + }); + expect(res.statusCode).toBe(200); + + const stored = getAppConfig(legacyDb, "embedding"); + expect(stored).not.toContain("legacy-plaintext-secret"); + expect(stored).toContain("text-embedding-3-large"); + + // The legacy key stays in memory so the frontend bootstrap can still + // migrate it to the keychain this session. + const status = (await legacyApp.inject({ + method: "GET", + url: "/api/knowledge/embedding-config", + headers: { authorization: "Bearer test-token" } + })).json(); + expect(status.legacyMigrationRequired).toBe(true); + } finally { + await legacyApp.close(); + } + }); it("serves health and task CRUD", async () => { const health = await app.inject({ method: "GET", url: "/health" }); expect(health.statusCode).toBe(200); diff --git a/packages/server-local/src/ws-hub.ts b/packages/server-local/src/ws-hub.ts index dd7b3f7..e6c2fcc 100644 --- a/packages/server-local/src/ws-hub.ts +++ b/packages/server-local/src/ws-hub.ts @@ -1,20 +1,88 @@ import type { WebSocket } from "ws"; import type { WsMessage } from "@neo-companion/shared"; +/** Drop clients whose send buffer exceeds this many bytes (slow consumer). */ +const BACKPRESSURE_THRESHOLD = 1_048_576; // 1 MB +/** Drop clients that fail to respond to a protocol-level ping within 2 cycles. */ +const HEARTBEAT_INTERVAL_MS = 30_000; + +interface ClientState { + socket: WebSocket; + isAlive: boolean; + droppedCount: number; +} + +/** + * WebSocket hub: tracks connected clients, broadcasts messages with backpressure + * protection, and runs a heartbeat loop to terminate dead connections. + */ export class WsHub { - private readonly clients = new Set(); + private readonly clients = new Map(); + private heartbeat: NodeJS.Timeout | null = null; add(client: WebSocket) { - this.clients.add(client); - client.on("close", () => this.clients.delete(client)); + const state: ClientState = { socket: client, isAlive: true, droppedCount: 0 }; + this.clients.set(client, state); + client.on("pong", () => { + state.isAlive = true; + }); + client.on("close", () => { + this.clients.delete(client); + }); + // Lazily start the heartbeat on the first client. + if (!this.heartbeat) { + this.heartbeat = setInterval(() => this.tick(), HEARTBEAT_INTERVAL_MS); + // Don't keep the process alive solely for the heartbeat. + this.heartbeat.unref?.(); + } } broadcast(message: WsMessage) { const serialized = JSON.stringify(message); - for (const client of this.clients) { - if (client.readyState === 1) { - client.send(serialized); + for (const [client, state] of this.clients) { + if (client.readyState !== 1) continue; + // Backpressure: skip this message if the kernel buffer is already large. + if (client.bufferedAmount > BACKPRESSURE_THRESHOLD) { + state.droppedCount++; + // Repeated offenders are likely dead; terminate them. + if (state.droppedCount > 5) { + client.terminate(); + this.clients.delete(client); + } + continue; } + // Reset the drop counter once we successfully send. + state.droppedCount = 0; + client.send(serialized); + } + } + + /** Heartbeat tick: ping alive clients, terminate those that didn't pong last cycle. */ + private tick() { + for (const [client, state] of this.clients) { + if (!state.isAlive) { + // No pong since last ping → dead connection. + client.terminate(); + this.clients.delete(client); + continue; + } + state.isAlive = false; + client.ping(); + } + } + + /** Broadcast a shutdown signal and close all WS connections (1001). */ + close() { + this.broadcast({ type: "server:shutdown", payload: {} }); + for (const [client] of this.clients) { + if (client.readyState === 1 || client.readyState === 2) { + client.close(1001, "server shutdown"); + } + } + this.clients.clear(); + if (this.heartbeat) { + clearInterval(this.heartbeat); + this.heartbeat = null; } } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 179e8f7..160fd2f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,5 +15,8 @@ "devDependencies": { "typescript": "6.0.3", "vitest": "4.1.7" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.49" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4451687..098773e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,6 +10,11 @@ export interface Task { completedAt: string | null; } +export interface TaskListResponse { + items: Task[]; + total: number; +} + export interface FocusSession { id: string; taskId: string | null; @@ -56,12 +61,14 @@ export interface WsMessage { | "window:activeChanged" | "tts:started" | "tts:done" + | "tts:error" | "hook:statusChanged" | "permission:request" | "permission:resolved" | "permission:autoDismiss" | "permission:response" - | "pong"; + | "pong" + | "server:shutdown"; payload: TPayload; id?: string; replyTo?: string; @@ -273,3 +280,7 @@ export interface AiMessage { } export type KnowledgeChunkIndexStatus = "pending" | "indexed" | "failed" | "stale"; + +// ── Request/Response Schemas (TypeBox) ── +// Shared runtime-validatable schemas for Fastify route validation. See schemas.ts. +export * from "./schemas"; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts new file mode 100644 index 0000000..d2d71d7 --- /dev/null +++ b/packages/shared/src/schemas.ts @@ -0,0 +1,242 @@ +import { Static, Type } from "@sinclair/typebox"; + +// ── Common Params ── +export const IdParamSchema = Type.Object({ id: Type.String({ minLength: 1 }) }); +export type IdParam = Static; + +export const ProjectIdParamSchema = Type.Object({ id: Type.String({ minLength: 1 }) }); +export type ProjectIdParam = Static; + +export const NoteIdParamSchema = Type.Object({ id: Type.String({ minLength: 1 }) }); +export type NoteIdParam = Static; + +export const ColumnIdParamSchema = Type.Object({ id: Type.String({ minLength: 1 }) }); +export type ColumnIdParam = Static; + +export const TaskIdParamSchema = Type.Object({ id: Type.String({ minLength: 1 }) }); +export type TaskIdParam = Static; + +// ── Error Response ── +export const ErrorSchema = Type.Object({ + error: Type.String(), + code: Type.Optional(Type.String()), + details: Type.Optional(Type.Unknown()) +}); +export type Error = Static; + +// ── Tasks (pet-panel simple task) ── +export const TaskListQuerySchema = Type.Object({ + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })), + offset: Type.Optional(Type.Number({ minimum: 0, default: 0 })) +}); +export type TaskListQuery = Static; + +export const TaskListResponseSchema = Type.Object({ + items: Type.Array( + Type.Object({ + id: Type.String(), + title: Type.String(), + status: Type.Union([Type.Literal("open"), Type.Literal("done")]), + createdAt: Type.String(), + completedAt: Type.Union([Type.String(), Type.Null()]) + }) + ), + total: Type.Integer({ minimum: 0 }) +}); +export type TaskListResponse = Static; + +export const TaskCreateBodySchema = Type.Object({ + title: Type.String({ minLength: 1, maxLength: 500 }) +}); +export type TaskCreateBody = Static; + +export const TaskPatchBodySchema = Type.Object({ + title: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })), + status: Type.Optional(Type.Union([Type.Literal("open"), Type.Literal("done")])) +}); +export type TaskPatchBody = Static; + +// ── TTS ── +export const TtsSpeakBodySchema = Type.Object({ + text: Type.String({ minLength: 1, maxLength: 10000 }), + style: Type.Optional(Type.String({ maxLength: 200 })) +}); +export type TtsSpeakBody = Static; + +// ── AI Chat ── +export const ContextLevelSchema = Type.Union([ + Type.Literal("full"), + Type.Literal("summary"), + Type.Literal("excluded") +]); +export const ChatContextSelectionSchema = Type.Object({ + notes: Type.Record(Type.String(), ContextLevelSchema), + tasks: Type.Record(Type.String(), ContextLevelSchema) +}); + +export const AiChatBodySchema = Type.Object({ + message: Type.String({ minLength: 1, maxLength: 50000 }), + mode: Type.Optional(Type.Union([Type.Literal("chat"), Type.Literal("ask")])), + projectId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + context: Type.Optional(ChatContextSelectionSchema), + conversationId: Type.Optional(Type.String()) +}); +export type AiChatBody = Static; + +// ── Focus ── +export const FocusStartBodySchema = Type.Object({ + taskId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + durationMinutes: Type.Optional(Type.Number({ minimum: 1, maximum: 180, default: 25 })) +}); +export type FocusStartBody = Static; + +// ── Hooks ── +export const HookPushBodySchema = Type.Object({ + agentId: Type.String({ minLength: 1 }), + type: Type.Literal("status"), + state: Type.String({ minLength: 1 }), + description: Type.Optional(Type.String()), + timestamp: Type.Optional(Type.Number({ minimum: 0 })) +}); +export type HookPushBody = Static; + +export const HookPermissionBodySchema = Type.Object({ + agentId: Type.String({ minLength: 1 }), + command: Type.String({ minLength: 1 }), + severity: Type.Number({ minimum: 0, maximum: 10 }), + description: Type.Optional(Type.String()) +}); +export type HookPermissionBody = Static; + +export const HookAlwaysRuleDeleteBodySchema = Type.Object({ + agentId: Type.String({ minLength: 1 }), + commandPrefix: Type.String({ minLength: 1 }) +}); +export type HookAlwaysRuleDeleteBody = Static; + +// ── Knowledge: Projects ── +export const ProjectListQuerySchema = Type.Object({ + parentId: Type.Optional(Type.String()), + root: Type.Optional(Type.Union([Type.Literal("1"), Type.Literal("0")])) +}); +export type ProjectListQuery = Static; + +export const ProjectCreateBodySchema = Type.Object({ + title: Type.String({ minLength: 1, maxLength: 200 }), + parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + description: Type.Optional(Type.String()), + color: Type.Optional(Type.String({ maxLength: 50 })), + icon: Type.Optional(Type.String({ maxLength: 50 })) +}); +export type ProjectCreateBody = Static; + +export const ProjectPatchBodySchema = Type.Object({ + title: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), + description: Type.Optional(Type.String()), + color: Type.Optional(Type.String({ maxLength: 50 })), + icon: Type.Optional(Type.String({ maxLength: 50 })), + parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + order: Type.Optional(Type.Number()) +}); +export type ProjectPatchBody = Static; + +// ── Knowledge: Notes ── +export const NoteCreateBodySchema = Type.Object({ + title: Type.Optional(Type.String({ maxLength: 500 })) +}); +export type NoteCreateBody = Static; + +export const NotePatchBodySchema = Type.Object({ + title: Type.Optional(Type.String({ maxLength: 500 })), + body: Type.Optional(Type.String()), + tags: Type.Optional(Type.Array(Type.String())) +}); +export type NotePatchBody = Static; + +// ── Knowledge: Columns ── +export const KnowledgeTaskStatusSchema = Type.Union([ + Type.Literal("todo"), + Type.Literal("doing"), + Type.Literal("done"), + Type.Literal("archived") +]); + +export const ColumnCreateBodySchema = Type.Object({ + title: Type.String({ minLength: 1, maxLength: 200 }), + status: Type.Optional(KnowledgeTaskStatusSchema), + order: Type.Optional(Type.Number()) +}); +export type ColumnCreateBody = Static; + +export const ColumnPatchBodySchema = Type.Object({ + title: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), + status: Type.Optional(KnowledgeTaskStatusSchema), + order: Type.Optional(Type.Number()) +}); +export type ColumnPatchBody = Static; + +// ── Knowledge: Tasks (kanban) ── +export const KnowledgeTaskCreateBodySchema = Type.Object({ + columnId: Type.String(), + title: Type.String({ minLength: 1, maxLength: 500 }) +}); +export type KnowledgeTaskCreateBody = Static; + +export const KnowledgeTaskPatchBodySchema = Type.Object({ + title: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })), + description: Type.Optional(Type.String()), + status: Type.Optional(KnowledgeTaskStatusSchema), + columnId: Type.Optional(Type.String()), + order: Type.Optional(Type.Number()), + linkedNoteId: Type.Optional(Type.Union([Type.String(), Type.Null()])) +}); +export type KnowledgeTaskPatchBody = Static; + +export const KnowledgeTaskMoveBodySchema = Type.Object({ + columnId: Type.Optional(Type.String()), + index: Type.Optional(Type.Number({ minimum: 0, default: 0 })) +}); +export type KnowledgeTaskMoveBody = Static; + +// ── Knowledge: Search ── +export const KnowledgeSearchQuerySchema = Type.Object({ + q: Type.String({ minLength: 1 }), + projectId: Type.Optional(Type.String()), + limit: Type.Optional(Type.String()) // Number.parseInt later +}); +export type KnowledgeSearchQuery = Static; + +// ── Knowledge: Reindex ── +export const KnowledgeReindexBodySchema = Type.Object({ + embeddingModel: Type.Optional(Type.String()) +}); +export type KnowledgeReindexBody = Static; + +// ── Knowledge: Embedding Config ── +export const EmbeddingProviderSchema = Type.Union([ + Type.Literal("none"), + Type.Literal("openai"), + Type.Literal("siliconflow"), + Type.Literal("custom") +]); + +export const EmbeddingConfigBodySchema = Type.Object({ + provider: Type.Optional(EmbeddingProviderSchema), + baseUrl: Type.Optional(Type.String()), + apiKey: Type.Optional(Type.Union([Type.String(), Type.Null()])), + model: Type.Optional(Type.String()), + apiKeySource: Type.Optional(Type.Union([Type.Literal("env"), Type.Literal("keychain")])) +}); +export type EmbeddingConfigBody = Static; + +// ── Knowledge: Root Path ── +export const RootPathBodySchema = Type.Object({ + path: Type.String() +}); +export type RootPathBody = Static; + +// ── Knowledge: Mirror ── +export const MirrorPathBodySchema = Type.Object({ + path: Type.Optional(Type.String()) +}); +export type MirrorPathBody = Static; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 623476b..f906d14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,19 +112,10 @@ importers: '@neo-companion/shared': specifier: workspace:* version: link:../shared - better-sqlite3: - specifier: 12.10.0 - version: 12.10.0 - drizzle-orm: - specifier: 0.45.2 - version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0) sqlite-vec: specifier: ^0.1.9 version: 0.1.9 devDependencies: - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 '@types/node': specifier: ^24.10.1 version: 24.12.4 @@ -140,6 +131,15 @@ importers: '@fastify/cors': specifier: ^11.1.0 version: 11.2.0 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 + '@fastify/rate-limit': + specifier: ^11.0.0 + version: 11.0.0 + '@fastify/type-provider-typebox': + specifier: ^6.1.0 + version: 6.1.0(typebox@1.3.0) '@fastify/websocket': specifier: 11.2.0 version: 11.2.0 @@ -182,6 +182,10 @@ importers: version: 4.1.7(@types/node@24.12.4)(jsdom@29.1.1)(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)) packages/shared: + dependencies: + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 devDependencies: typescript: specifier: 6.0.3 @@ -469,12 +473,23 @@ packages: '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@11.0.0': + resolution: {integrity: sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==} + + '@fastify/type-provider-typebox@6.1.0': + resolution: {integrity: sha512-k29cOitDRcZhMXVjtRq0+caKxdWoArz7su+dQWGzGWnFG+fSKhevgiZ7nexHWuXOEEQzgJlh6cptIMu69beaTA==} + peerDependencies: + typebox: ^1.0.13 + '@fastify/websocket@11.2.0': resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} @@ -490,6 +505,10 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -537,42 +556,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.2': resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.2': resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.2': resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.2': resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.2': resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.2': resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} @@ -600,6 +613,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -629,35 +645,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.2': resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.2': resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.2': resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.2': resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==} @@ -847,9 +858,6 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -981,25 +989,9 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - better-sqlite3@12.10.0: - resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} - bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1008,9 +1000,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1048,14 +1037,6 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1068,98 +1049,6 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} - drizzle-orm@0.45.2: - resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} - peerDependencies: - '@aws-sdk/client-rds-data': '>=3' - '@cloudflare/workers-types': '>=4' - '@electric-sql/pglite': '>=0.2.0' - '@libsql/client': '>=0.10.0' - '@libsql/client-wasm': '>=0.10.0' - '@neondatabase/serverless': '>=0.10.0' - '@op-engineering/op-sqlite': '>=2' - '@opentelemetry/api': ^1.4.1 - '@planetscale/database': '>=1.13' - '@prisma/client': '*' - '@tidbcloud/serverless': '*' - '@types/better-sqlite3': '*' - '@types/pg': '*' - '@types/sql.js': '*' - '@upstash/redis': '>=1.34.7' - '@vercel/postgres': '>=0.8.0' - '@xata.io/client': '*' - better-sqlite3: '>=7' - bun-types: '*' - expo-sqlite: '>=14.0.0' - gel: '>=2' - knex: '*' - kysely: '*' - mysql2: '>=2' - pg: '>=8' - postgres: '>=3' - prisma: '*' - sql.js: '>=1' - sqlite3: '>=5' - peerDependenciesMeta: - '@aws-sdk/client-rds-data': - optional: true - '@cloudflare/workers-types': - optional: true - '@electric-sql/pglite': - optional: true - '@libsql/client': - optional: true - '@libsql/client-wasm': - optional: true - '@neondatabase/serverless': - optional: true - '@op-engineering/op-sqlite': - optional: true - '@opentelemetry/api': - optional: true - '@planetscale/database': - optional: true - '@prisma/client': - optional: true - '@tidbcloud/serverless': - optional: true - '@types/better-sqlite3': - optional: true - '@types/pg': - optional: true - '@types/sql.js': - optional: true - '@upstash/redis': - optional: true - '@vercel/postgres': - optional: true - '@xata.io/client': - optional: true - better-sqlite3: - optional: true - bun-types: - optional: true - expo-sqlite: - optional: true - gel: - optional: true - knex: - optional: true - kysely: - optional: true - mysql2: - optional: true - pg: - optional: true - postgres: - optional: true - prisma: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} @@ -1195,10 +1084,6 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1236,16 +1121,10 @@ packages: picomatch: optional: true - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - find-my-way@9.6.0: resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} engines: {node: '>=20'} - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1255,26 +1134,21 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + helmet@8.2.0: + resolution: {integrity: sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==} + engines: {node: '>=18.0.0'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} @@ -1339,28 +1213,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1396,16 +1266,6 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -1414,13 +1274,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - node-abi@3.92.0: - resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} - engines: {node: '>=10'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1464,12 +1317,6 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} @@ -1515,9 +1362,6 @@ packages: prosemirror-view@1.41.9: resolution: {integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==} - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1525,10 +1369,6 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1603,12 +1443,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1668,10 +1502,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1683,13 +1513,6 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - textarea-caret@3.1.0: resolution: {integrity: sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==} @@ -1743,8 +1566,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + typebox@1.3.0: + resolution: {integrity: sha512-3HaX5iZ13wSzcLSflDH1UJwaXnRghtc8LhQtKnq8qnlcnZvmGOpBOM6rY9PdyPBKNOYBpDHfE9r6BmI41fvp4g==} typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} @@ -2102,6 +1925,11 @@ snapshots: '@fastify/forwarded@3.0.1': {} + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.1.0 + helmet: 8.2.0 + '@fastify/merge-json-schemas@0.2.1': dependencies: dequal: 2.0.3 @@ -2111,6 +1939,16 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@fastify/rate-limit@11.0.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 5.1.0 + toad-cache: 3.7.1 + + '@fastify/type-provider-typebox@6.1.0(typebox@1.3.0)': + dependencies: + typebox: 1.3.0 + '@fastify/websocket@11.2.0': dependencies: duplexify: 4.1.3 @@ -2133,6 +1971,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@lukeed/ms@2.0.2': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2195,6 +2035,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sinclair/typebox@0.34.49': {} + '@standard-schema/spec@1.1.0': {} '@tauri-apps/api@2.11.0': {} @@ -2431,10 +2273,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 24.12.4 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2607,32 +2445,10 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - base64-js@1.5.1: {} - - better-sqlite3@12.10.0: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - chai@6.2.2: {} chalk@4.1.2: @@ -2640,8 +2456,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chownr@1.1.4: {} - cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -2683,23 +2497,12 @@ snapshots: decimal.js@10.6.0: {} - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - deep-extend@0.6.0: {} - dequal@2.0.3: {} detect-libc@2.1.2: {} dotenv@17.4.2: {} - drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0): - optionalDependencies: - '@types/better-sqlite3': 7.6.13 - better-sqlite3: 12.10.0 - duplexify@4.1.3: dependencies: end-of-stream: 1.4.5 @@ -2756,8 +2559,6 @@ snapshots: dependencies: '@types/estree': 1.0.9 - expand-template@2.0.3: {} - expect-type@1.3.0: {} fast-decode-uri-component@1.0.1: {} @@ -2807,37 +2608,29 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - file-uri-to-path@1.0.0: {} - find-my-way@9.6.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.1.1 - fs-constants@1.0.0: {} - fsevents@2.3.3: optional: true get-caller-file@2.0.5: {} - github-from-package@0.0.0: {} - has-flag@4.0.0: {} + helmet@8.2.0: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 transitivePeerDependencies: - '@noble/hashes' - ieee754@1.2.1: {} - inherits@2.0.4: {} - ini@1.3.8: {} - ipaddr.js@2.4.0: {} is-fullwidth-code-point@3.0.0: {} @@ -2943,22 +2736,10 @@ snapshots: mdn-data@2.27.1: {} - mimic-response@3.1.0: {} - - minimist@1.2.8: {} - - mkdirp-classic@0.5.3: {} - muggle-string@0.4.1: {} nanoid@3.3.12: {} - napi-build-utils@2.0.0: {} - - node-abi@3.92.0: - dependencies: - semver: 7.8.1 - obug@2.1.1: {} on-exit-leak-free@2.1.2: {} @@ -3007,21 +2788,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.92.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - process-warning@4.0.1: {} process-warning@5.0.0: {} @@ -3100,22 +2866,10 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode@2.3.1: {} quick-format-unescaped@4.0.4: {} - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -3185,14 +2939,6 @@ snapshots: siginfo@2.0.0: {} - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -3244,8 +2990,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-json-comments@2.0.1: {} - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3256,21 +3000,6 @@ snapshots: symbol-tree@3.2.4: {} - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - textarea-caret@3.1.0: {} thread-stream@4.2.0: @@ -3314,9 +3043,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 + typebox@1.3.0: {} typescript@6.0.3: {} diff --git a/scripts/spike-node-sqlite.mjs b/scripts/spike-node-sqlite.mjs new file mode 100644 index 0000000..e6e43ad --- /dev/null +++ b/scripts/spike-node-sqlite.mjs @@ -0,0 +1,171 @@ +// Spike: 验证 node:sqlite 迁移可行性(不改生产代码)。 +// 运行: node scripts/spike-node-sqlite.mjs +// +// 验证项: +// 1. Node 版本与 node:sqlite 可用性 +// 2. FTS5(trigram tokenizer,CJK)可用性 +// 3. sqlite-vec 加载 + vec0 虚拟表 + KNN 查询 +// 4. 已清除旧 ORM/驱动依赖 +// 5. transaction shim 原型(BEGIN/COMMIT/ROLLBACK + SAVEPOINT 嵌套) + +import { DatabaseSync } from "node:sqlite"; +import { createRequire } from "node:module"; +import { join } from "node:path"; + +const require = createRequire(import.meta.url); +const results = []; + +function record(name, status, detail = "") { + results.push({ name, status, detail }); + const icon = status === "PASS" ? "✓" : status === "FAIL" ? "✗" : "?"; + console.log(`${icon} [${status}] ${name}${detail ? " — " + detail : ""}`); +} + +// ── 1. Node 版本与 node:sqlite ── +try { + const version = process.version; + const major = Number(version.slice(1).split(".")[0]); + if (major >= 24) { + record("node:sqlite 可用性", "PASS", `${version} — DatabaseSync 导入成功`); + } else { + record("node:sqlite 可用性", "FAIL", `${version} — 项目要求 Node >= 24`); + } +} catch (e) { + record("node:sqlite 可用性", "FAIL", e.message); +} + +// ── 2. FTS5(trigram,CJK) ── +// 注意:trigram tokenizer 要求查询词 ≥3 字符(中文按 Unicode 码点计)。 +// 2 字中文词(如"向量")无法 MATCH,需走 LIKE fallback —— 这是已知行为, +// 与项目代码 searchFts 的 useMatch 判断一致。测试用 3+ 字中文词。 +try { + const db = new DatabaseSync(":memory:", { allowExtension: true }); + // 确认 FTS5 编译选项 + const opts = db.prepare("PRAGMA compile_options").all(); + const fts5Enabled = opts.some((o) => /ENABLE_FTS5/i.test(o.compile_options)); + if (!fts5Enabled) { + record("FTS5 trigram(CJK)", "FAIL", "FTS5 未编译进 node:sqlite(无 ENABLE_FTS5)"); + db.close(); + } else { + db.exec("CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')"); + db.prepare("INSERT INTO docs (content) VALUES (?)").run("向量检索是知识库的核心能力"); + db.prepare("INSERT INTO docs (content) VALUES (?)").run("今天天气不错"); + // 3 字中文词 → trigram 可 MATCH + const rows = db.prepare("SELECT content FROM docs WHERE content MATCH ? ORDER BY rank").all("向量检"); + if (rows.length === 1 && rows[0].content.includes("向量")) { + record("FTS5 trigram(CJK)", "PASS", `ENABLE_FTS5=yes, MATCH 命中(3字词)`); + } else { + record("FTS5 trigram(CJK)", "FAIL", `期望 1 行命中,实际 ${rows.length} 行: ${JSON.stringify(rows)}`); + } + db.close(); + } +} catch (e) { + record("FTS5 trigram(CJK)", "FAIL", e.message); +} + +// ── 3. sqlite-vec 加载 + vec0 + KNN ── +try { + // 定位 sqlite-vec 包(项目通过 pnpm 安装在 packages/db 的 node_modules) + const sqliteVecPath = require.resolve("sqlite-vec", { paths: [join(process.cwd(), "packages/db")] }); + const sqliteVec = require(sqliteVecPath); + const db = new DatabaseSync(":memory:", { allowExtension: true }); + sqliteVec.load(db); + const ver = db.prepare("SELECT vec_version() AS v").get().v; + db.exec(`CREATE VIRTUAL TABLE vec_test USING vec0(id TEXT PRIMARY KEY, embedding FLOAT[4])`); + const buf = (vec) => Buffer.from(new Float32Array(vec).buffer); + db.prepare("INSERT INTO vec_test (id, embedding) VALUES (?, ?)").run("a", buf([1.0, 0.0, 0.0, 0.0])); + db.prepare("INSERT INTO vec_test (id, embedding) VALUES (?, ?)").run("b", buf([0.0, 1.0, 0.0, 0.0])); + const knn = db.prepare("SELECT id, distance FROM vec_test WHERE embedding MATCH ? AND k = ? ORDER BY distance").all(buf([1.0, 0.1, 0.0, 0.0]), 2); + if (knn.length === 2 && knn[0].id === "a") { + record("sqlite-vec 加载 + KNN", "PASS", `vec_version=${ver}, 最近邻=${knn[0].id}(dist=${knn[0].distance.toFixed(3)})`); + } else { + record("sqlite-vec 加载 + KNN", "FAIL", `KNN 结果异常: ${JSON.stringify(knn)}`); + } + db.close(); +} catch (e) { + record("sqlite-vec 加载 + KNN", "FAIL", e.message); +} + +// ── 4. 旧 ORM/驱动依赖清理 ── +try { + require.resolve("drizzle-orm", { paths: [join(process.cwd(), "packages/db")] }); + record("旧 ORM/驱动依赖清理", "FAIL", "packages/db 仍可解析 drizzle-orm"); +} catch { + try { + require.resolve("better-sqlite3", { paths: [join(process.cwd(), "packages/db")] }); + record("旧 ORM/驱动依赖清理", "FAIL", "packages/db 仍可解析 better-sqlite3"); + } catch { + record("旧 ORM/驱动依赖清理", "PASS", "drizzle-orm 与 better-sqlite3 均不可解析"); + } +} + +// ── 5. transaction shim 原型 ── +try { + const db = new DatabaseSync(":memory:", { allowExtension: true }); + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); + let depth = 0; + function withTransaction(fn) { + const savepoint = depth === 0 ? null : `sp_${depth}`; + if (savepoint) db.exec(`SAVEPOINT ${savepoint}`); + else db.exec("BEGIN"); + depth++; + try { + const result = fn(); + if (savepoint) db.exec(`RELEASE SAVEPOINT ${savepoint}`); + else db.exec("COMMIT"); + return result; + } catch (e) { + if (savepoint) { + db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`); + db.exec(`RELEASE SAVEPOINT ${savepoint}`); + } else db.exec("ROLLBACK"); + throw e; + } finally { + depth--; + } + } + // 嵌套调用测试 + withTransaction(() => { + db.prepare("INSERT INTO t (v) VALUES (?)").run("outer"); + withTransaction(() => { + db.prepare("INSERT INTO t (v) VALUES (?)").run("inner"); + }); + }); + const count = db.prepare("SELECT COUNT(*) AS n FROM t").get().n; + if (count === 2) { + record("transaction shim(嵌套)", "PASS", "外层+内层各插入 1 行,共 2 行"); + } else { + record("transaction shim(嵌套)", "FAIL", `期望 2 行,实际 ${count} 行`); + } + // 回滚测试 + let threw = false; + try { + withTransaction(() => { + db.prepare("INSERT INTO t (v) VALUES (?)").run("will-rollback"); + throw new Error("intentional"); + }); + } catch { + threw = true; + } + const countAfterRollback = db.prepare("SELECT COUNT(*) AS n FROM t").get().n; + if (threw && countAfterRollback === 2) { + console.log(` ↳ 回滚测试通过:异常后行数仍为 ${countAfterRollback}`); + } else { + record("transaction shim(回滚)", "FAIL", `回滚异常: threw=${threw}, 行数=${countAfterRollback}`); + } + db.close(); +} catch (e) { + record("transaction shim", "FAIL", e.message); +} + +// ── 汇总 ── +console.log("\n=== Spike 汇总 ==="); +const pass = results.filter((r) => r.status === "PASS").length; +const fail = results.filter((r) => r.status === "FAIL").length; +console.log(`PASS: ${pass} / FAIL: ${fail} / 总计: ${results.length}`); +if (fail === 0) { + console.log("结论: 全部通过 — node:sqlite 迁移与旧依赖清理验证完成"); +} else { + console.log("结论: 存在失败项 — 见上方详情"); +} +process.exit(fail === 0 ? 0 : 1); diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh index 1030169..868cc68 100755 --- a/scripts/verify-docs.sh +++ b/scripts/verify-docs.sh @@ -47,12 +47,7 @@ if [[ -n "$PET_VIOLATIONS" ]]; then log_error "Found deprecated term '宠物' in markdown files (see above)" fi -# 5. README must not claim FTS5 + sqlite-vec is already shipped. -if grep -nE 'SQLite \(Drizzle ORM \+ FTS5\).*sqlite-vec' README.md; then - log_error "README claims FTS5 + sqlite-vec is shipped; it is currently planned, not implemented" -fi - -# 6. TTS error message must reference the real setup doc. +# 5. TTS error message must reference the real setup doc. if grep -q 'Xiaomi MiMo TTS console documentation' packages/tts/src/index.ts; then log_error "TTS error still references non-existent 'Xiaomi MiMo TTS console documentation'" fi