# RFC-001: Nerve — Observation Engine **Author:** 主人 & 小橘 🍊(NEKO Team) **Date:** 2026-04-21 **Status:** Draft **Repo:** `@uncaged/nerve` --- ## 1. 动机 现有的 agent 架构中,事件处理、状态投影、工作流触发、角色调度等概念交织在一起,边界模糊。我们需要一组正交的、最小化的抽象,使得 agent 的能力可以声明式地组合和扩展。 ## 2. 核心洞察 ### 2.1 两类事件 系统中流通的事件分为两类: - **Signal**:Sense compute 返回非 null 值时发出的通知。纯事实,无意图,不期待响应。例:CPU 占用 95%。compute 返回 null 时不产生 Signal。 - **Command Event**:Workflow Thread 内部的流转事件。有明确因果链,必须被响应。例:reviewer 返回 rejected。 两者不在同一层级。Signal 驱动系统的前半部(感知),Command Event 驱动后半部(行动)。 ### 2.2 Projection 不是独立概念 传统 Event Sourcing 中 Projection 是一等公民。在本设计中,Projection 只是"一个 compute 从存储中拉数据的 Sense"。它的输出和原始采样一样,都是 Signal。不需要单独的机制。 ### 2.3 触发条件与计算逻辑解耦 一个 Sense **怎么算**(compute)和**什么时候算**(reflex)是两个独立的关注点。同一个 Sense 可以被定时触发、被事件触发、被按需查询触发。 ### 2.4 Log 是数据资产,不是触发源 系统运行过程中产生的各种记录(reflex 执行记录、workflow 状态变迁、错误日志等)统称为 **Log**。Log 是有价值的数据资产,用于审计、溯源、分析。 **Log 与 Signal 的本质区别:** - **Signal** — 来自外部世界的感知数据,可以触发 Reflex - **Log** — 系统内部产出的副作用记录,**不能触发 Reflex** 因果链是单向的: ``` 外部世界 → Sense → Signal → Reflex → Action + Log ↑ Reflex 可以读 Log(查询/聚合) Log 不能触发 Reflex ✗ ``` 禁止 Log 触发 Reflex 是防止雪崩的关键约束——如果 reflex 执行产生的 log 又能触发 reflex,系统会形成无限循环。Log 是因果链的终点,不是起点。 ## 3. 术语表 | 术语 | 隐喻 | 含义 | |------|------|------| | **Sense** | 感官 | 定义怎么感知——compute 函数,每种 Sense 有自己的数据类型和独立存储 | | **Reflex** | 反射 | 定义什么时候感知——声明式触发条件 | | **Signal** | 信号 | Sense compute 返回非 null 时发出的通知,其他 Reflex 可以监听 | | **Log** | 日志 | 系统内部产出的记录(执行记录、状态变迁、错误等),数据资产,不触发 Reflex | | **Workflow** | 行动 | 定义怎么做——内含 Moderator(调度)和 Role(执行)| | **Moderator** | 协调者 | Workflow 内部概念,在 Role 之间递话筒 | | **Role** | 执行者 | Workflow 内部概念,执行具体动作,有副作用 | | **Thread** | 工作流实例 | 一个 Workflow 的一次执行上下文 | ## 4. 三个扩展点 整个系统只有三个用户可扩展的概念: | 扩展点 | 回答的问题 | 性质 | |--------|-----------|------| | **Sense** | 是什么(怎么算) | compute 函数 | | **Reflex** | 什么时候算 | 声明式 YAML | | **Workflow** | 怎么做 | Role + Moderator | 三者职责完全正交。Reflex 不知道 compute 的内容,Sense 不知道自己什么时候被触发,Workflow 不知道自己为什么被发起。 ### 4.1 Sense Sense 是系统中唯一的一等公民。一个 Sense 定义一个 compute 函数。 Sense 是多态的——每种 Sense 有自己的 Payload 类型。 所有 Sense 的 compute 返回 `T | null`。返回值时发 signal,返回 `null` 时静默——不写存储、不发 signal、不触发下游 reflex。 ```typescript // 原始采样:读物理世界,每次都有值 // senses/cpu-usage.ts export async function compute(): Promise { return os.loadavg()[0] // 实际上总是有值 } // 派生计算(即"Projection"):读其他 Sense 的存储 // senses/active-tasks.ts export async function compute(): Promise { const prev = myDb.prepare('SELECT * FROM tasks').all() const newEvents = taskEventsDb.prepare( 'SELECT * FROM events WHERE ts > ?' ).all(lastSync) const result = applyChanges(prev, newEvents) return hasChanges(result, prev) ? result : null // 无变化则静默 } ``` **关键设计:每个 Sense 拥有自己独立的存储。** 没有统一的 Event Store。每种 Sense 按自己的数据结构定义自己的表、自己的库文件。Sense 之间需要查询时,以只读方式打开对方的库。 这是合理的,因为 Signal 本质是 append-only 的时序数据,不会跨 Sense join。每个 Sense 最了解自己的查询模式。 #### Schema 管理:Drizzle 作为标准工具链 每个 Sense 用 Drizzle ORM 定义 schema,**`schema.ts` 是 single source of truth**: ```typescript // senses/cpu-usage/schema.ts import { sqliteTable, integer, real } from 'drizzle-orm/sqlite-core' export const samples = sqliteTable('samples', { ts: integer('ts').primaryKey(), value: real('value').notNull(), }) ``` Migration 由 `drizzle-kit generate` 自动生成,提交进 git: ``` senses/ cpu-usage/ schema.ts ← 开发者(agent)写这个 index.ts ← compute,查询有类型推导 migrations/ ← drizzle-kit 自动生成 0001_init.sql ``` compute 拿到的 db 实例是 Drizzle 包装过的,查询全部 type-safe: ```typescript // senses/cpu-usage/index.ts import { samples } from './schema' // db 和 peers 由 engine runtime 注入,不需要用户创建 export async function compute( db: DrizzleDB, peers: Readonly> ): Promise { const load = os.loadavg()[0] await db.insert(samples).values({ ts: Date.now(), value: load }) return load } ``` **Cross-sense 类型安全读取:** compute 通过 `peers` 参数拿到其他 Sense 的只读 db 实例。类型来自 import 对方的 schema: ```typescript // senses/active-tasks/index.ts import { tasks } from './schema' import { samples } from '../cpu-usage/schema' // 只导入类型定义 export async function compute( db: DrizzleDB, peers: Readonly> ): Promise { // 自己的 db:读写 const activeTasks = await db.select().from(tasks).where(...) // peer 的 db:只读,类型安全 const cpuLoad = await peers['cpu-usage'].select().from(samples).orderBy(desc(samples.ts)).limit(1) return { activeTasks, cpuLoad } } ``` **为什么用 Drizzle 而不是手写 SQL migration:** Nerve 的 Sense 开发者是机器上的 Coding Agent(通过 Nerve 自身的 Workflow 自举开发)。Agent 行为的正确性应靠确定性工具保证,不应依赖概率模型的"自律"。Drizzle 让 agent 只写一个东西(`schema.ts`),migration 和类型都是机械派生,消除了 TS 与 SQL 不一致的风险。 **引擎职责:** 运行时只执行 migration SQL(`drizzle migrate`),不依赖 drizzle-kit。生成 migration 是开发时(workflow role action)的事。 **Migration rollback:** Drizzle 不支持 down migration,但 Sense 的 SQLite 是派生数据——坏了删 `.db` 文件,engine 重启时自动重跑 migration 重建。不需要 rollback 机制。 **drizzle.config:** 不需要每个 Sense 各一份。Engine runtime 统一参数化——根据 sense name 确定 `.db` 路径和 `migrations/` 目录,调用 `drizzle-kit generate` 时动态传入。Sense 开发者只写 `schema.ts`。 **Schema 新鲜度:** Engine 启动时不校验 `schema.ts` 与 `migrations/` 是否同步。这是开发时的职责——创建/修改 Sense 的 Workflow 负责跑 `drizzle-kit generate` 并提交 migration。运行时只管执行已有的 migration SQL。 #### Sense 不知道 Workflow Sense 只感知世界并产出 Signal,永远不关心"谁在听"或"听了之后要做什么"。Sense 不引用 Workflow、不返回 `ThreadStart`、不知道自己的 Signal 会触发什么动作。 ```typescript // senses/disk-usage.ts — 只关心磁盘状态 export async function compute(): Promise { const usage = await getDiskUsage() return usage // 纯事实,不关心下游 } ``` 启动 Workflow 是 Reflex 的职责,见 §4.2。 ### 4.2 Reflex Reflex 是纯声明式的 YAML 配置,定义**引擎对 Signal 的反应**。Reflex 是连接 Sense 与 Sense、Sense 与 Workflow 的唯一纽带。 Reflex 有两种 action: | action | 含义 | |--------|------| | 触发 compute | 让某个 Sense 重新计算(默认行为) | | 启动 workflow | 创建一个 Thread(Post-MVP) | ```yaml # nerve.yaml senses: cpu-usage: group: system throttle: 5s timeout: 3s disk-usage: group: system throttle: 30s active-tasks: group: tasks throttle: 10s timeout: 30s reflexes: # Sense → Sense(触发 compute,默认 action) - sense: cpu-usage interval: 10s - sense: disk-usage interval: 5m - sense: active-tasks on: ["task.created", "task.completed"] interval: 10m # Sense → Workflow(启动 thread) - workflow: cleanup on: ["disk-usage"] ``` 两种触发条件: | 条件 | 含义 | |------|------| | `interval` | 定时触发 | | `on: [signals]` | 当指定的 Sense 发出新 Signal 时触发 | 一个 Reflex 可以有多个触发条件(如 `active-tasks` 同时有事件触发和定时兜底)。 OnDemand(按需触发)不需要声明——引擎内置提供,任何 Sense 都可以被外部 API 调用触发。 #### Reflex 是 Event Mesh 所有的 Reflex 声明合在一起,构成了一个声明式的事件路由网格(Event Mesh)。引擎启动时解析所有 Reflex,构建出完整的事件流拓扑: ``` Signal Bus ──→ Reflex Mesh ──→ Sense compute ──→ Workflow thread ``` 每个 Signal 进入 bus 后,Reflex Mesh 决定它的去向——触发哪些 Sense 重算、启动哪些 Workflow。这不是 Sense 主动"发消息"给下游,而是引擎根据声明式规则自动路由。 **进程视角:** Sense Worker 只负责执行 compute 并把 Signal 交给 Kernel。Kernel 里的 Reflex Mesh 决定下一步——该触发谁的 compute、该起哪个 Workflow Worker。Worker 之间永远不直连,所有路由决策都在 Kernel 完成。 #### Reflex 语义规则 **Interval 起点**:以库中记录的上次 compute 完成时间为准,不是 daemon 启动时间。daemon 重启时,若已过期则立即执行一次,然后恢复正常节奏。不会对停机期间的缺失进行逐次补偿。 **Event 补偿**:OnEvent 的语义是"有新数据了,该重新算一下",不是"每个 event 都要处理一次"。daemon 重启时最多触发一次 compute,compute 内部通过 `pullSince(lastTimestamp)` 拉取所有积压数据。 **合并与幂等**:同一个 Sense 同一时刻最多一个 compute 在执行。多个触发条件同时满足时合并为一次调用。compute 执行期间收到新 trigger,标记为 pending,当前完成后再执行一次。 ``` ┌─ OnInterval 到了 ──┐ ├─ OnEvent 来了 ─────┤──→ 合并 → 一次 compute └─ OnEvent 又来了 ──┘ compute 执行中 → 新 trigger 到达 → pending → 当前完成后再执行一次 ``` 这些规则与 Sense 的语义一致——compute 是"重新感知当前状态",不是"处理某个具体事件"。无论触发几次,做的是同一个动作。 ### 4.3 Workflow(Post-MVP) Workflow 定义一个有状态的工作流执行上下文(Thread)。内部包含: - **Role**:执行具体动作的角色,有副作用 - **Moderator**:在 Role 之间递话筒的调度逻辑 Role 和 Moderator 是 Workflow 的内部细节,不跨 Workflow 共享,不作为顶层扩展点。 #### Workflow 配置 ```yaml # nerve.yaml workflows: cleanup: concurrency: 1 # 同时最多 1 个 thread overflow: drop # 已有活跃 thread 时丢弃新请求 execute-task: concurrency: 10 # 可并行 10 个 thread overflow: queue # 超出时排队 code-review: concurrency: 3 overflow: queue max_queue: 20 # 队列上限,超出丢弃最旧请求 ``` - **concurrency**:同时允许的最大活跃 Thread 数 - **overflow**:达到上限时的策略 - `drop`:丢弃,适用于幂等操作(如 cleanup) - `queue`:排队等待,适用于每次都需要执行的操作(如 deploy) - **max_queue**(仅 `overflow: queue` 时生效):队列上限,默认 100。超出时丢弃最旧的请求 不需要 throttle——Workflow 的触发频率由上游 Sense 的 throttle 控制,Workflow 层只管并发。 #### Signal 系统与 Thread 的关系 Signal 和 Thread 是两个独立的循环,单向桥接: ``` Signal 循环 ──→ ThreadStart ──→ Thread 循环 (无状态,幂等,可合并) (有状态,顺序,Command Event 驱动) │ Thread 产出 Log (执行日志,供 retrospection) ``` **Signal 只负责 kickoff Thread**。Thread 启动后,由自己的事件循环驱动——Moderator 递话筒、Role 执行、Command Event 流转。Thread 内部不走 Signal 系统。 **Thread 产出的 Log 是执行日志**,记录 Thread 的中间状态和最终结果。这些 Log 可以被 Sense 的 compute 查询用于 retrospection(如统计成功率、平均耗时),但 Log 不能触发 Reflex(见 §2.4)。 这保证了两个循环的性质不被污染: - Signal 循环:无状态、幂等、可合并、可丢弃 - Thread 循环:有状态、严格顺序、每个 Command Event 必须响应 ## 5. 运行时模型 ### 5.1 进程架构 Engine 主进程是整个系统的 **kernel 和 event hub**。它持有 Signal Bus、Scheduler、Process Manager,是所有 worker 的唯一通信对象。Worker 之间永远不直连——所有信息流都经过 engine 中转。 Engine 与用户代码完全分离。主进程**永远不加载用户代码**,所有用户代码在独立子进程中运行。 ``` systemd / pm2 └─ nerve-engine (永驻主进程,纯引擎代码) ├─ Scheduler ← nerve.yaml ├─ Signal Bus ← worker 产出的 signal ├─ File Watcher ← 监听 ~/.uncaged-nerve/ 变化 └─ Process Manager ├─ nerve-worker group=system (永驻,用户 sense 代码) ├─ nerve-worker group=tasks (永驻,用户 sense 代码) ├─ nerve-worker workflow=cleanup (按需启动,用户 workflow 代码) └─ nerve-worker workflow=code-review (按需启动,用户 workflow 代码) ``` #### 隔离理由 worker_thread 的隔离是假的——用户代码的 `process.exit()`、native module segfault、OOM 都会杀死主进程。只有进程边界才是真正的隔离墙。 #### Worker 架构:Engine Runtime + 用户代码 Worker 不是用户代码直接跑的进程,而是 **engine 提供的 runtime 加载用户代码**。用户代码是被 import 的模块,不是入口。 ``` nerve-engine (kernel) └─ nerve worker sense --group system ├─ runtime bootstrap(engine 代码) │ ├─ 建立 IPC │ ├─ 读 nerve.yaml,找到 group 里有哪些 sense │ ├─ 对每个 sense:打开 .db → 跑 migration → drizzle 包装 │ ├─ 构建 peers 只读连接 │ └─ 发 { type: 'ready' } │ └─ 用户代码(被 import 进来) ├─ cpu-usage/schema.ts ← 纯类型定义 └─ cpu-usage/index.ts ← compute 函数,拿到注入的 db ``` 这意味着: - **用户代码不操心基础设施**——IPC、db 初始化、migration 都是 runtime 的事 - **compute 函数签名简单**——engine 注入 `db`(自己的)和 `peers`(只读),用户只写业务逻辑 - **隔离天然成立**——用户代码跑在 engine 控制的沙箱里,crash 只影响同 group #### Sense Worker Sense 按 group 分组,同 group 共享一个 worker 进程。用户决定隔离粒度。 ```yaml senses: cpu-usage: group: system disk-usage: group: system memory-usage: group: system active-tasks: group: tasks ``` - 同 group 的 sense 一个出问题会影响同组,但不影响其他组和引擎 - Worker 长驻,跟 engine 一起活 - 崩溃后 engine 自动 respawn #### Workflow Worker 同一个 workflow 的所有 thread 共享一个 worker 进程。`concurrency` 控制进程内的并发 async task 数。 ```yaml workflows: cleanup: concurrency: 1 overflow: drop code-review: concurrency: 3 overflow: queue ``` - 进程数 = workflow 种类数,不会膨胀 - 有活跃 thread 时启动,所有 thread 完成后退出(或保持待命一段时间) - 崩溃后 engine respawn worker,从持久化状态恢复 thread #### 进程对比 | | Sense Worker | Workflow Worker | |---|---|---| | 粒度 | 按 group | 按 workflow 种类 | | 内部并发 | 多 sense 的 compute | 多 thread(async) | | 生命周期 | 永驻 | 有活跃 thread 时存活 | | 崩溃恢复 | respawn,继续调度 | respawn,从持久化状态恢复 | | 状态 | 无 | 持久化状态机 | ### 5.2 主进程 ↔ Worker 通信 所有 worker 只与 engine 通信,worker 之间无任何直接通道。这是 hub-and-spoke 拓扑——engine 是 hub,worker 是 spoke。 子进程通过 stdio/IPC 与主进程通信,协议极简: ```typescript // 主进程 → worker { type: 'compute', sense: 'cpu-usage' } // 触发一次 compute { type: 'shutdown' } // 优雅退出 // worker → 主进程 { type: 'signal', sense: 'cpu-usage', payload: ... } // compute 完成 { type: 'error', sense: 'cpu-usage', error: ... } // compute 失败 { type: 'ready' } // worker 启动完成 ``` 主进程不关心 payload 的内容,只负责转发 signal 到 bus。 **Signal Bus 是纯内存结构,不持久化。** Engine 崩溃重启后 bus 中的 signal 丢失,但这不影响正确性——reflex 的语义是"重新感知当前状态"而非"处理每个历史事件"。重启后 scheduler 按 interval 和 lastComputeTime 自然恢复节奏,不需要回放丢失的 signal。 ### 5.3 Sense 运行时配置 Sense 的运行时属性(`group`、`throttle`、`timeout`)在 `nerve.yaml` 的 `senses` 字段中声明,完整示例见 §4.2。 - **group**:隔离分组,同 group 共享 worker 进程 - **throttle**:最小触发间隔,防止高频 signal 导致的无意义重算 - **timeout**:compute 超时上限(soft timeout),超时后 abort 当前 compute,记录错误 signal - **grace_period**:soft timeout 后的宽限期(默认 timeout × 3),超过后 hard kill 整个 group worker 并 respawn。防止跑飞的 compute 堵住同 group ### 5.4 存储架构 系统有两大类持久化数据,全部 append-only: #### Signal 存储 每个 Sense 独立一个 SQLite 文件(见 §8),由 Sense 自行管理 schema。这部分不变。 #### Log 存储 所有 Log 写入统一的 SQLite 文件 `data/logs.db`,单表: ```sql CREATE TABLE logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, -- "sense_scheduler", "sense", "workflow", "system" type TEXT NOT NULL, -- "run_start", "run_complete", "error", "state_change" ref_id TEXT, -- 关联的 reflex name / workflow run_id payload TEXT, -- JSON ts INTEGER NOT NULL -- unix ms ); CREATE INDEX idx_logs_source_type ON logs(source, type); CREATE INDEX idx_logs_ts ON logs(ts); CREATE INDEX idx_logs_ref_id ON logs(ref_id); ``` - **统一一张表**,通过 `source + type` 区分 log 来源和类型 - Reflex 可以查询 logs 表(只读),但 log 不能触发 reflex(见 §2.4) #### Workflow 状态:事件溯源 + 物化表 Workflow Thread 的状态以 append-only 事件流为 source of truth: ```sql -- 也在 logs 表中,source = "workflow" -- type 取值:queued, started, step_complete, completed, failed, crashed ``` 当前状态 = 该 run_id 最后一条 log entry。例: ``` source=workflow, type=queued, ref_id=run-7, ts=1000 source=workflow, type=started, ref_id=run-7, ts=1001 source=workflow, type=completed, ref_id=run-7, ts=1005 ``` 为避免每次查活跃 workflow 都扫描全表,引擎维护一张 **物化表**,在写 log 的同一事务中 UPSERT: ```sql CREATE TABLE workflow_runs ( run_id TEXT PRIMARY KEY, workflow TEXT NOT NULL, -- workflow 名 status TEXT NOT NULL, -- 最新状态:queued, started, completed, failed, crashed ts INTEGER NOT NULL -- 最新状态的时间戳 ); CREATE INDEX idx_workflow_runs_status ON workflow_runs(status); ``` 写入流程(同一事务): ```sql BEGIN; INSERT INTO logs (source, type, ref_id, payload, ts) VALUES ('workflow', 'started', 'run-7', '{}', 1001); INSERT OR REPLACE INTO workflow_runs (run_id, workflow, status, ts) VALUES ('run-7', 'alert', 'started', 1001); COMMIT; ``` 查询当前活跃 workflow 变为 O(活跃数): ```sql SELECT * FROM workflow_runs WHERE status IN ('queued', 'started') ``` 物化表是 logs 的派生数据——数据丢失时可从 logs 重建。logs 表仍是 source of truth。 进程重启时从 log 重建内存状态。运行时用内存 materialized view 进一步加速。 #### 冷归档 Engine 定期(cron 或内置 task)将超过 30 天的 log 和 signal 数据导出为按天 JSONL 文件归档: ``` data/ logs.db # 热数据(近 30 天) archive/ logs/ 2026-03-22.jsonl # 冷数据,按天归档 2026-03-23.jsonl senses/ cpu-usage/ 2026-03-22.jsonl ``` 导出后从主库 DELETE + VACUUM。冷数据用 grep/jq 即可查询,不需要 SQL。 **水位标记**:归档进度记录在 meta 表中,确保任一步崩溃都能安全恢复: ```sql CREATE TABLE meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); -- 归档水位:已成功归档到哪一天 -- key = "archived_up_to", value = "2026-03-22" ``` 归档流程: ``` 1. 读 meta.archived_up_to,确定从哪天开始 2. 导出该天数据到 JSONL(幂等:同一天重复导出会覆盖文件) 3. 同一事务:DELETE 该天数据 + UPDATE meta.archived_up_to 4. VACUUM(可选,非事务内) ``` 任何一步崩溃,重启后从水位标记处继续,不会丢数据也不会重复删除。 ### 5.5 热更新 主进程 watch `~/.uncaged-nerve/` 文件变化,按类型处理: | 变化 | 处理 | |------|------| | sense ts 文件修改 | 等当前 compute 完成 → kill 对应 group worker → respawn | | workflow ts 文件修改 | drain(等活跃 thread 完成,`drain_timeout` 后 force kill + 标记 crashed)→ respawn | | nerve.yaml 修改 | 主进程重新解析,diff 变更(见下) | nerve.yaml diff 处理: | 变更 | 处理 | |------|------| | 新增 sense | spawn worker(或加入已有 group) | | 删除 sense | 从 worker 中移除 | | 修改 reflex | 更新 scheduler,不动 worker | | 修改 throttle/timeout | 更新 scheduler | | 修改 group | kill 旧 worker,按新分组 respawn | ### 5.6 错误处理 单个 sense 或 workflow 的失败不影响其他组件。 | 情况 | 处理 | |------|------| | compute 抛异常 | 写 log(source=system, type=error),下次触发重试 | | compute 超时 | soft timeout → abort + 写 error log;grace_period 后 hard kill worker + respawn | | 存储写入失败 | 写 error log,不发 signal(未成功产出) | | nerve.yaml 语法错误 | daemon 拒绝加载,保持当前配置 | | sense ts 语法错误 | 该 group worker 加载失败,其他 group 正常 | | workflow worker 崩溃 | 幂等 thread 自动恢复,非幂等标记 crashed | ### 5.7 自监测 Daemon 用自己的 Sense 机制监测自身健康: ```typescript // senses/nerve-health.ts export async function compute(): Promise { return { uptime: process.uptime(), activeSenses: scheduler.activeSenseCount(), pendingComputes: scheduler.pendingCount(), lastErrors: errorLog.recent(10), memoryUsage: process.memoryUsage(), } } ``` 但 daemon 自身挂了这个 sense 也不会跑,所以外层 systemd/pm2 是最后防线: ``` systemd (最后防线,只管进程存活) └─ nerve-engine (自监测 + 丰富健康数据) └─ nerve-health sense → 可产出 signal 通知外部 ``` ## 6. 函数式性质 用 Haskell 描述核心类型和函数: ```haskell -- Sense 是多态的,compute 返回 Maybe class Sense a where type Payload a compute :: IO (Maybe (Payload a)) -- Nothing → 静默,Just → 发 signal -- Reflex 是纯数据,连接 Signal 与 Action data Reflex = Reflex { condition :: ReflexCondition , action :: ReflexAction , enabled :: Bool } data ReflexCondition = OnInterval Interval | OnSignal [SenseId] -- OnDemand 是引擎内置能力,不需要声明 data ReflexAction = TriggerCompute SenseId -- 触发 Sense 重算 | StartWorkflow WorkflowId -- 启动 Workflow Thread -- Reflex 构成 Event Mesh:Signal → ReflexCondition → ReflexAction -- Workflow 内部 moderate :: Thread -> CommandEvent -> (RoleId, Prompt) -- 纯函数 ✅ execute :: Role -> Prompt -> IO CommandEvent -- 有副作用 ❌ ``` 核心的纯函数/副作用边界: | 函数 | 纯/IO | |------|-------| | `compute` (Sense) | IO — 读世界或读存储,返回 Maybe | | `moderate` (Workflow) | 纯 ✅ | | `execute` (Role) | IO — 调 API、改文件 | | Reflex 条件判断 | 纯数据,引擎硬编码 | ## 7. 依赖关系与生命周期 ### 依赖图 ``` Reflex ──→ Sense ──→ Sense (复合依赖) │ └──→ Workflow (Reflex 声明触发) ``` ### 软删除与级联 出于历史不可变性,不删除实体,只关停(`enabled: false`)。 | 操作 | 级联 | |------|------| | disable Sense | disable 依赖它的 Reflex + 依赖它的复合 Sense | | disable Workflow | disable 触发它的 Reflex | | disable Reflex | 无级联 | 清理是可选的离线 GC 过程。 ## 8. 用户本地配置 用户的 nerve 实例位于 `~/.uncaged-nerve/`,作为一个 local git repo 管理: ``` ~/.uncaged-nerve/ package.json # 依赖管理(含 drizzle-orm) nerve.yaml # 主配置(含 reflexes) senses/ cpu-usage/ schema.ts # Drizzle schema(single source of truth) index.ts # compute 逻辑 migrations/ # drizzle-kit 自动生成 0001_init.sql disk-usage/ schema.ts index.ts migrations/ 0001_init.sql active-tasks/ schema.ts index.ts migrations/ 0001_init.sql workflows/ # post-MVP cleanup.ts data/ # ⛔ gitignored logs.db # 统一 log 存储(append-only) senses/ cpu-usage.db # 每个 sense 独立的 sqlite disk-usage.db active-tasks.db archive/ # 冷归档(>30天) logs/ 2026-03-22.jsonl senses/ cpu-usage/ 2026-03-22.jsonl blobs/ # CAS blob store,sha256 寻址 ab/ cd1234... node_modules/ # ⛔ gitignored .gitignore .git/ ``` ### 设计要点 - **local git repo** — 配置和 sense 逻辑可回滚,data 不进 git - **package.json** — 标准 npm 包,`npm install` 管理依赖(含 `drizzle-orm`,`drizzle-kit` 为 devDependency) - **nerve.yaml** — 单一配置入口,含 senses(运行时属性如 throttle)和 reflexes(触发条件)两个字段 - **senses/{name}/** — 每个 sense 一个目录,含 `schema.ts`(Drizzle schema)、`index.ts`(compute)、`migrations/`(自动生成) - **data/senses/** — 每个 sense 一个 sqlite 文件,引擎启动时自动执行 migration - **data/blobs/** — CAS(Content-Addressable Storage),sha256 前两位分片目录,sense 存大对象时写 blob 拿 hash,db 里只存 hash 引用 - **data/ 和 node_modules/ gitignored** — 只有逻辑和配置进版本控制 ## 9. MVP 范围 | 范围 | 状态 | |------|------| | Sense 定义(compute + 独立存储) | ✅ MVP | | Reflex 配置(nerve.yaml) | ✅ MVP | | Nerve Daemon(读 yaml,按条件调 compute) | ✅ MVP | | Workflow(Role + Moderator + Thread) | ❌ Post-MVP | ## 10. 设计原则 1. **Sense 是唯一的一等公民** — 原始采样和派生计算统一为 Sense,Sense 不知道下游 2. **计算与触发解耦** — compute 不知道自己什么时候被调用 3. **Reflex 是 Event Mesh** — 所有事件路由都声明在 Reflex 中,Sense 和 Workflow 之间无直连 4. **Log 是终点不是起点** — Log 是数据资产,Reflex 可读但不被 Log 触发,防止雪崩 5. **存储去中心化** — 每个 Sense 自管存储,Log 统一一张表,全部 append-only 6. **不删除只关停** — 历史不可变,生命周期通过 enabled 控制 7. **Workflow 是可选扩展** — MVP 不需要,后续按需加入 8. **引擎极简** — 只做调度,不做业务逻辑