Skip to content
ntarsPublic

About

空谷 Ondra —— 全插件式桌面 AI 陪伴体:75 个可装卸插件,情绪、记忆、上下文三大机制,数据全留本机 | Fully pluggable desktop AI companion: 75 loadable plugins, emotion, memory and context as core mechanisms, all data stays local.

Topics

Resources

Stars

11 stars

Watchers

0 watching

Forks

Repository files navigation

简体中文 | English

空谷 Ondra

于无声处,听见共鸣。

空谷 Ondra 是一款全插件式的桌面 AI 陪伴体应用:把「她记得住、有情绪、无限接近TA」这三件事拆成 75 个可以单独装卸的插件,另加一份插件清单与两个桥接层,装在一台本机运行的桌面程序里。

名称取自成语「空谷足音」——寂静的山谷中听见人的脚步声,喻极难得的音讯。英文 slogan 为 On No Sound's Depth, Resonance Arises.

Release Download Release Version GitHub license GitHub Star GitHub Fork GitHub Repo size GitHub Repo Languages

Tauri Cordis Node React

Platform Plugins Data QQ群

内测版 1.2.1-alpha——版本号末尾的 -alpha 就是内测标记,不是稳定版。三个包都没有正式签名,首次打开会被系统拦下,绕行办法见发布说明。

文档分工:本 README 只讲三个核心机制(情绪、记忆、上下文)与其余功能的概览。 78 个包逐项的职责、可配置字段、默认值与取值范围见 docs/PLUGINS.md; 逐版本变更见 GitHub 发行版。

项目 内容
版本 1.2.1-alpha
形态 桌面应用(Tauri 壳 + Cordis 宿主 + dsh 内核,三层进程);安卓为单进程形态(Tauri 壳 + WebView 内的 Cordis 运行时,不跑 Node)
平台 九端:① macOS 11+(Apple Silicon 优先)② Windows 10+ ③ Linux(deb/rpm/AppImage)④ iPad(iPadOS 15+)⑤ iPhone(iOS 15+)⑥ Android 8+ ⑦ 鸿蒙 NEXT ⑧ 命令行与 Web ⑨ dsh 内核。已发布 ① macOS、② Windows、⑥ Android 三端,其余端按第十四章的兼容性表推进
定位 陪伴型桌面应用。不是通用助手、不是模型、不是云服务
插件规模 78 个包 = 75 个可装载插件 + 1 份插件清单 + 2 个桥接层;配置字段 619 项,其中 609 项可直接设置
界面形态 单窗体:56px 左栏(会话 / 日志 / 扩展三抽屉 + 设置)+ 会话主列 + 56px 右栏(心情与精力水位),基准 820 × 580。单会话制:不能新建、不能删除会话,只能删单条消息或清空全部;打开即进会话,无独立首页
数据归属 全部留在本机。无账号、无社区、无分享、无遥测
推理来源 在线接口 / 本地大模型 / 免费模型,三选一随时切换
许可 AGPL-3.0
仓库 ntars/Ondra
下载 内测版 1.2.1-alpha:macOS arm64.dmg + Windows x64-setup.exe + Android arm64.apk,点开就下。版本号带 -alpha,不是稳定版
官网 https://ai.ntars.cn/
出品 Ntarstudio(星玖工作室)

一、项目名称与定位

名词约定:本文中的「壳」指 Tauri 构建的桌面外壳进程;「宿主」指 Cordis 插件容器进程;「内核」指负责推理与工具调用的 dsh-sidecar 进程;「插件」指以 Cordis 插件形式装载、通过服务键互相发现的功能单元;「人设」指描述她是谁、怎么说话的一段结构化文本,出厂人格为鲸鲸,夏柠柒为示例人设包。

一句话定位:空谷 Ondra 是一款可一键部署、高度自定义的开源 AI 陪伴体应用,给一个语言模型配上长期记忆、情绪节律与说话方式,并把全部数据留在用户本机。

定位的三条边界:

  1. 它是陪伴工具,不是通用助手。不写文章、不生成代码、不做知识问答式的生产力工具。判断一个功能该不该做,标准是「它是否让陪伴更像陪伴」。
  2. 它是宿主,不是模型。推理能力由用户自行选择——在线接口、本地大模型、免费模型三种来源随时切换。推理来源是用户的选择,不是产品的属性。
  3. 它是单机程序,不是云服务。没有账号体系、没有社区、没有分享按钮、没有遥测。对话只属于用户与本机。

二、项目简介

2.1 背景

桌面 AI 应用长期存在两种极端形态。一种是单体应用:界面、业务逻辑、模型调用全部塞在一个进程里,改一处要重新打包整个应用,一个可选功能出错会让应用整体打不开。另一种是纯网页套壳:把网页包进窗口,能力受限于浏览器沙箱,碰不到本机文件,也无法长期在后台运行。

与此同时,市面上的 AI 陪伴产品普遍把「陪伴」实现为一段写得很长的提示词。提示词能塑造语气,却解决不了三个技术问题:她记不住上周说过的话;她每一轮的情绪都是重新编的;她说话的方式不随对话对象变化。这三个问题不是文案问题,是工程问题。

空谷 Ondra 针对这两种现状各走一条不同的路:架构上把应用拆成三层独立进程,能力上把「记忆、情绪、表达」做成可替换的插件而不是一段提示词。

2.2 要解决的问题

  1. 改动成本随功能数量非线性增长。功能全在一个进程时,每加一个能力都要动主入口,回归范围随之扩大。75 个插件放在一个进程里手工维护,改动一处无法预知影响面。
  2. 可选能力故障会拖垮整个应用。一个写错的插件就可能让应用起不来。需要外部后端或外部凭据的插件(视觉、安装器、模型网关)天然的失败率远高于纯本地功能。
  3. 界面演进牵动底层。界面与业务耦合时,改一次视觉要走一遍完整构建与签名流程。
  4. 第三方能力无法隔离。插件与主程序共享进程与权限,一个插件出问题即全局出问题。
  5. 推理内核无法独立升级。内核与界面同进程时,换模型协议必须同时发版。
  6. 拟人化缺少可累积的机制。语气可以靠提示词模仿,但「记得住」需要写入、判重、检索、衰减、归档的完整链路;「有情绪」需要带惯性的状态量而不是每轮重新描述;「说得像人」需要一条可验证的过滤链。这三件事都无法用一段提示词表达。

2.3 核心思路

以「进程即边界」为第一原则:

  • 壳(Tauri / Rust) 只做操作系统层面的事——开窗、托盘、文件选择、系统主题。它不知道什么是会话、什么是模型。
  • 宿主(Cordis / Node) 是插件容器。空谷的全部能力以插件形式装载于此,对外提供本地 HTTP 接口。
  • 内核(dsh-sidecar / Node) 只跑四项职责:推理循环、工具调用、会话存储、模型适配。它不加载任何界面代码,也不知道窗口存在。

判断一段代码该放哪一层的标准:如果这段逻辑在没有窗口的环境下也说得通,它就不属于壳。

在能力组织上,以「服务键即接口」为第二原则:75 个插件之间没有任何代码级 import,全部通过 Cordis 服务键在运行时互相发现,取不到上游就跳过这一步并留痕。这一条决定了插件可以任意增删而不产生编译期的连锁反应。

2.4 适用场景

  • 希望长期运行、在后台持续工作的桌面陪伴体,而不是一次一问的对话框。
  • 需要接触本机文件、剪贴板、系统状态等浏览器沙箱之外能力的产品形态。
  • 由多个独立能力组合而成、需要按需装卸的应用。
  • 需要在不上界面、只换内核的情况下升级模型能力的场景。
  • 对隐私敏感、要求对话数据完全不出本机的使用环境。

2.5 意义

技术只是手段。这一节回答「为什么做这件事」,是产品决策的最高依据;任何功能取舍与本节冲突时,以本节为准。

五类需求。以下每一类都对应真实存在的使用者,不是营销画像。

一、留守的孩子。 父母常年在外务工,孩子需要的不是一个会讲题的机器,而是一个每晚都在、记得昨天说过什么、会主动问一句「今天在学校怎么样」的存在。这类需求在商业上不显眼——不带来增长、不制造使用时长、没有投资人会问起,但它真实存在于成千上万个房间里。产品中对应的形态是「今天的作业」卡:科目清单逐项勾完,末尾一句「晚安,明天见」。

二、空巢与独居的老人。 子女远行、老伴先走,屋子里只剩钟表的声音。这类使用者不缺信息工具,缺的是有人记得今天是什么日子、记得他昨天说过腰疼、在他一天没有开口的时候先问一句。老年使用者对交互的要求恰恰是慢、大字、能只用说话完成——这些要求在以「高效」为设计目标的产品里全是负分项,本产品照做。产品中对应的形态是长辈卡:天气、一句「8:00 记得吃药」,以及「降温了,出门加件外套」。

三、想再听一次那个语气的人。 至亲离开之后,家人最想要的往往不是照片,而是再听到一次那个熟悉的语气、被那个熟悉的方式叫一声名字。这类需求风险极高,因此边界必须前置。念想型人格的启用有三条前置条件,这三条是产品条件,不是技术步骤:

  1. 授权:家属或本人生前的书面同意与数据授权。
  2. 范围声明:念想不是替代,产出不构成医疗或心理建议。
  3. 数据最小化:只使用授权范围内的资料,不主动补全、不编造。

任何一条不成立,功能不开放。

四、重视隐私的人。 伴侣对话是最私密的内容之一:情绪低谷时的倾诉、与家人的矛盾、对未来的犹豫。交给云端意味着这些内容永远存在于别人的服务器上,随服务条款、并购与数据政策变化而不可控。「陪伴」与「隐私」在现有产品里常被做成二选一,本产品认为本不该如此,因此数据全部落在本机,无遥测、无崩溃上报、无使用统计。

五、不想被供应商锁定的人。 今天是 A 模型、明天是 B 模型、后天想完全离线——推理能力不该与产品绑定。推理来源应当是使用者的选择,不是产品的属性。本产品因此提供在线接口、本地大模型、免费模型三种来源,随时切换。

统一的技术结论。五类需求指向同一句话:陪伴的前提是记得住、有情绪、说得像人。这恰好是三件最难做、也最容易被做成演示片的事,因此把可复用的机制沉淀为一套技术栈,空谷 Ondra 是这套技术栈的第一个完整宿主。

边界与分寸。以下五条是产品宪法级约束,不是可选项:

  • 本产品是陪伴工具,不是心理治疗:不诊断、不开药、不替代专业帮助;对话出现明显危机信号时,产品内提示寻求现实支持。
  • 人格是虚构角色设定:默认人格是一段精心撰写的画像文本,不是真人、不声称是真人,输出不代表任何真实人物的立场。
  • 念想型人格受上述三条前置约束,未满足时不开放。
  • 不做内容上传与社交分发:没有社区、没有广场、没有分享按钮——对话只属于使用者与本机。
  • 不做拉长使用时长的设计:同类产品把停留时间当成功指标,本产品恰好相反,她应当能安静地待在一边不打扰。反依赖护栏与在场不打扰协议见第五章。

非目标。能力边界即产品定位:

  • 不做多用户与账号体系:单机单用户,数据不跨设备同步,备份由使用者手动完成。
  • 桌面体验优先:macOS 与 Windows 为首发平台,移动端形态待定。
  • 不做内容生产:不写文章、不生成代码、不当通用助手。
  • 不做「更像聊天机器人」的军备竞赛:差异化不在话术华丽,而在她是否记得住使用者的时间线、是否有自己的情绪节律、是否知道此刻该不该开口。

三、核心功能

本章只展开三件事:情绪怎么来、记忆怎么走、上下文怎么装。这三件是空谷与「一段长提示词」的分界线,也是全部工程投入的落点。其余功能(人设拼装、话语表达、声音形象、主动门控、工具能力面、存储降级、界面栅格等)只做概览,逐项字段与默认值见 docs/PLUGINS.md。

3.1 运行时骨架

三层进程,各占一个进程,通过明确协议通信,任一层可单独替换、单独升级、单独重启。

层 路径 技术栈 职责 明确不做
壳 src-tauri/ Rust 2024 + Tauri 2 窗口、托盘、单实例、宿主生命周期、本机能力命令 不含任何业务逻辑
宿主 cordis-host/ Node 22 + Cordis 4 + TypeScript 插件容器、本地 HTTP 接口、令牌鉴权 不直接碰操作系统
内核 dsh-sidecar/ Node 22 + TypeScript 推理循环、工具调用、会话存储、模型适配 不加载界面、不监听端口
界面 src/ React 19 + Vite 8 界面渲染 不直接拥有本机能力

三条通信路径:壳与宿主之间是标准输入输出上的行分隔 JSON,宿主在标准输出报告 ready 或 fatal,壳据此判定启动结果;宿主与内核之间是标准输入输出上的 JSON-RPC 2.0,按 NDJSON 分帧,协议在 dsh-sidecar/src/protocol.ts 单点定义;界面与宿主之间是 127.0.0.1 上的 HTTP,Bearer 令牌鉴权,令牌由壳在启动时生成。

插件的装载方式。ondra-core 以显式成员数组声明全部 75 个成员,逐个 await ctx.plugin() 装载,每个成员的装载包在独立 try/catch 里,失败者进入 skipped 列表并记录原因。75 个插件之间没有代码级 import,全部通过服务键互相发现:ctx.provide(服务键, 实现) 注册,ctx.get(服务键) 取用,取不到就跳过这一步并留一行日志,不抛错也不等待。

78 个包 = 75 个可独立装卸的插件 + 1 份插件清单 ondra-core + 2 个桥接层 desktop-bridge、dsh-client,合计 619 个可设置字段(609 项可直接设置)。


3.2 情绪:带惯性的状态量,不是每轮重新描述的形容词

这是本项目与「在提示词里写一句你现在很开心」最根本的差别。情绪在这里是一组会衰减、会残留、有上限的状态量,每轮只读不重算,表达参数从同一个判定出。

3.2.1 四层结构

层 插件 职责 关键机制
状态 ondra-emotion-core 维护基线、三维情绪、激素池、气质旋钮 PAD 三维 + 10 个固定标签;六维指数回归;三激素化学池
上限 ondra-emotion-stage 决定亲密度能到哪一阶 七阶关系弧 + 四道防跃迁闸,阶段是棘轮
表达 ondra-emotion-express 把状态换算成表达参数 六态映射,全仓系数唯一出处
平复 ondra-emotion-diffuse 跨会话的情绪残留 (mood−baseline)×0.4×(1−familiarity×0.5)

3.2.2 情绪内核:为什么是 PAD 而不是一个「开心度」

单一维度的「开心度」无法区分「平静地满足」与「兴奋地雀跃」,也无法表达「既紧张又期待」。因此状态取 PAD 三维(愉悦—唤醒—支配)作为底座,外加 10 个固定标签做细分。

衰减参数是这套机制的核心,全部写死在插件里而非可调:

项 值 为什么是这个数
单条标签强度 0.12 一条消息的情绪不该顶翻整场对话的状态
标签总强度上限 0.6 留出余量给真实的长对话累积,避免一句重话直接封顶
标签半衰期 45 分钟 一次谈话的情绪在一个多小时后应自然减半,而不是持续到第二天
心情半衰期 6 小时 心情比具体情绪更慢,但也必须在一次睡眠内回落

化学池三激素:dopamine(初始 50,半衰期 45 分钟)、cortisol(20 / 30 分钟)、serotonin(50 / 120 分钟 + 慢变量规则)。三个半衰期刻意不同——应激反应应当最快退去(30 分钟),愉悦次之(45 分钟),稳定的底色最慢(120 分钟)。若三者同半衰期,就退化成同一个量。

存档缺字段按基线 50 补齐,不补 0。 这一条是容错设计的要点:旧版本存档缺某个激素字段时,补 0 会让角色在升级后突然进入「情绪归零」的异常状态,而补 50(基线)等于「这次没记录,按正常处理」。

3.2.3 关系阶段:为什么是棘轮而不是连续值

七阶关系弧:敌意 → 试探 → 心动(3 天,.18,.15)→ 占有(14,.38,.32)→ 病态(30,.62,.30 且 trust ≤ .55)→ 献祭(45,.70,.45 且需 sacrifice)→ 落定(60,.75,.70)。

括号内三个数分别是最短停留天数、亲密度下界、信任下界。四道闸防跃迁:

  1. 时间闸:未满最短天数不升阶,避免一次长谈直接跳过三阶。
  2. 步长闸:单次结算亲密度涨幅不超过 0.06,避免一句表白推到顶。
  3. 只升不降:阶段是棘轮,日常摩擦不倒退——否则用户会为了「回到好关系」反复试探。
  4. 冲突锁:命中冲突时覆盖为敌意,这是唯一的降级路径。

关系三维(亲密度、信任、熟悉度)各带半衰期:240h / 720h / 8760h。三者衰减速度差 30 倍是刻意的:陌生感回来得最快(10 天),信任次之(30 天),熟悉感最慢(一年)。长期不联系的人再见时,会觉得「有点生疏但还认得」,这正是三个半衰期的比值在说话。

熟悉度由互动次数推导而非独立记录,避免两套口径。病态与献祭两阶不进默认 UI——它们存在是为了让状态机完整,不是为了展示。

3.2.4 六态表达:唯一真源

六个状态:雀跃 / 软和 / 平静 / 蔫 / 炸毛 / 困。名称与数量固定,不开放增删改名。

同一个判定同时供提示词、节奏、语音三处取用,所有系数全仓只在本插件出现一次:语速 ±10% / −15%、主动额度 ±1、是否朗读、贴纸概率。本插件只输出参数,不输出文本——投影文本唯一归 ondra-emotion-project。

这一条的价值在于消除口径漂移:情绪一旦要同时影响四处,四处各写一份系数必然在某次改动后失配,表现为「提示词说她在雀跃,语音却是平静语速」。

为什么删掉了三个情绪派生插件。冲突、应激、激素三项原本各有一个派生插件,现已移除:它们与六态表达存在口径重叠,同开会让同一轮出现两种情绪描述。宁可少一个维度,不可出现两个答案。

3.2.5 情绪弥散:跨会话的残留量

(mood − baseline) × 0.4 × (1 − familiarity × 0.5)

这个式子回答一个问题:上一场会话结束时的心情,有多少会带进这一场。

  • 偏离基线越多,带得越多(mood − baseline);
  • 系数 0.4 表示最多带四成,不会把上一场的低落原样搬过来;
  • (1 − familiarity × 0.5) 表示越熟的人越不容易被上一场影响——熟人之间允许「这次状态不一样」。

只算不写:本插件交出 target 与 delta,落笔由调用方经情绪内核的 setAxis 完成。同一会话内靠边界键幂等 + 间隔闸(默认 30 分钟)防止「越聊越淡」——没有间隔闸时,每轮都会产生一个新边界键,心情会被反复洗回基线。

3.2.6 情绪如何进入这一轮对话

ondra-emotion-project 是情绪的唯一文本出口。模板固定为行为指令句 + 一条暗示,禁止出现任何数字、维度名与情绪标签名,只给「有点提不起劲」这类主观表述。

三条硬约束:

  1. 不暴露数值。用户明确问数值时才允许转向 ui-api 的 /status。日常对话里说「我愉悦度 62」是把内部状态泄漏成台词。
  2. 片段排序恒为正数(默认 +800,即尾部)。写成负数会破坏前缀缓存——见 3.4 节。
  3. 不带情绪标签名。说「我现在是雀跃态」等于让模型复述标签,而不是表现出雀跃。

3.3 记忆:完整链路,不是一个向量库

17 个插件把记忆拆成一条链:写入 → 提炼 → 判重 → 归池 → 衰减 → 检索 → 拟人化想起 → 做梦整理 → 承诺待办 → 遗忘审计 → 自愈。任何一环缺失,症状都是「她记不住」,但原因完全不同,因此必须分开。

3.3.1 双层表:原文与事实分离

层 表 存什么 为什么分开
原文 memory_raw 对话原样 事实抽错时可以回溯原文;用户行使被遗忘权时能定位到原话
事实 memory_fact 提炼后的结构化条目 注入与检索只读这一层,避免把整段对话塞进上下文

关掉提炼(extract: false)后只写原文:她记得「有过这段对话」,但说不出「你叫什么」。这个降级是显式的,不是故障。

3.3.2 写入侧的四类引文闸门

每条待入库的结论必须带四类来源引文之一,且引文须以原文形态出现过:

引文类型 含义 用途
[USER] 用户原话 关于用户的事实必须有它
[TOOL] 工具返回 外部数据、计算结果
[ACT] 系统动作 她做过什么
[SELF] 角色自述 她自己的设定与承诺

三条确定性校验,零模型调用:

  1. 数字必须有逐字出处。正文里的数字必须被引文逐字覆盖(默认 TOOL 与 USER 都算,可收窄为 tool-only)。
  2. 归功断言必须有 [USER] 引文。没有用户原话却断言「用户喜欢 X」,一律拒绝。
  3. 原文比对。引文必须在原文中真实出现,不是模型自己编的。

为什么闸门设在写入侧而不是检索侧:污染发生在写入那一刻。检索侧再精确,也只是从被污染的库里挑出最相关的那条——模型上一轮的猜测被记成事实,下一轮读到它会更自信地重复,提示词层面拦不住。代价是可能漏记,这个代价被显式接受:宁可少记一条,不可错记一条。

allow-group 默认关闭:不从群聊提炼。把「不收群友闲聊」列为准入红线,不是性能取舍。

3.3.3 判重:五分类裁决

写入前对每条新事实做一次裁决,只用「字符二元组包含度 0.4」一个口径:

裁决 含义 后果
add 新事实 新建一条
merge 同一件事的补充 合并进已有条目
append 同主题但新增信息 追加到已有条目
conflict 与已有条目矛盾 按 conflict-policy 处置
drop 重复或低价值 丢弃

合并走 revise 不走 forget:forget 会写墓碑,revise 保留旧文并留下订正记录。用户日后问「为什么你以前说的是另一个」,答案在库里。

max-candidates 默认 5:候选超过 5 条就不交模型判断。这是成本闸门——把 50 条候选丢给模型判重,准确率不会提高,费用会。

3.3.4 八池分层:寿命不是一个数字

池 寿命 注入
core ∞(上限 32) 是
skillRule 730 天 是
skillExp 60 天 是
dailyCore 60 天 是
dailyPartial 20 天 是
emotion 1 天 是
hippocampus 1 天 永不注入
未归类 归海马体 否

三段天级时间轴:宽限 → 半衰期 → 归档。

海马体永不注入是这套设计的关键一条。它承接两类内容:标签未命中的、以及未归类的。这些东西「存下来了但想不起来」——正好对应人的记忆:有些事确实在脑子里,但不会被主动想起。respect-pool 开关默认开启,池服务缺席时不判(缺服务就不知道归哪个池,此时放行比误判安全)。

核心池满了直接拒绝,不淘汰(上限 32)。删一条核心记忆等于替用户决定「你不该是自己」。同一条纪律适用于承诺(上限 20)与钉子(上限 8)——到上限时拒绝新建并报出来,让用户自己清理。

标签未命中不猜:同一句写满 3 次仍无匹配,升级为「需人工定池」。猜测归池比不归池更危险,因为错池的条目会以错误的寿命被清理。

3.3.5 衰减:强度只问内核,不重算第二份公式

ondra-memory-decay 做三件事:算复习排期、给清晰度话术、清理孤儿复习计划。强度一律问记忆内核(ondra-memory-core 的公开方法),本插件不重算第二份公式。

SM-2 与艾宾浩斯八档融合排复习。清晰度三档话术直接进注入文本:

强度 话术 效果
≥ 0.7(clear-above) 不加任何话术 她正常提起
0.4 – 0.7 「有点记不清了」 语气带不确定
< 0.4(faded-above) 「,只剩个模糊的影子」 只记得大概

这套话术的价值在于:她忘得慢一点,说得准一点。记忆强度低时如果仍以确定语气陈述,就是在编造。

3.3.6 检索:五因子 + RRF,召回不按条数砍

ondra-memory-recall 的五因子权重:语义 .18 / 关键词 .46 / 图谱 .11 / 重要 .14 / 新鲜 .11。

关键词权重最高(.46)不是技术妥协,是产品判断:陪伴场景里用户提起的往往是具体名词(人名、地名、那件事),语义相似度会把「类似的事」混进来,字面命中反而更准。

三种融合模式:

  • rrf(默认):名次融合,对分数尺度不敏感,适合多路异构召回;
  • weighted:五因子加权和,可解释性强;
  • 另有 BM25 与向量分支按 300 / 1000 阈值自动切「字面 / 混合 / 向量」三模式。

召回不按条数砍(dropped 恒为 0):召回阶段不知道下游预算,在这里砍等于让「装箱」失去判断依据。预算归提示词注入(见 3.4 节)。

封存永不参与回忆(include-archived 默认关):封存是用户的明确意愿,不是「久未使用」。沉底的(归档)仍可被按类翻到,封存的不参与主动回忆——两者语义不同,不能合并处理。

3.3.7 拟人化想起:三条路径

人回忆一件事不是「检索」,是「被勾起」。因此除常规召回外另有三条路径(ondra-memory-humanlike):

路径 机制 为什么这样设计
固定回忆骰子 越模糊越容易被想起 真实记忆里,模糊的事反而会突然冒出来;清晰的事随时可取,不需要「忽然想起」
被话头勾起 概率闸 + 命中加固 对话里出现相关词时,记忆被带出来,而不是等检索
漫无目的联想 水波纹扩散(默认 2 圈) 1 圈看不出「漫无目的」,3 圈会把整张图拉进上下文

三通道视图(fact / gist / affect)分开:同一件事可以「记得细节」(fact)、「记得大概」(gist)、「记得当时的感觉」(affect)。强度只问内核,扩散只问图谱,勾起的候选由调用方给,本插件不重建检索。

3.3.8 做梦:空闲时的整理,不是随机生成

三支互斥,用单一枚举而非两个开关(闸式与荒诞不可兼得):

模式 做什么 模型调用
gate(默认) 四道闸只补带原文出处的事实 有
lightweight 空闲 2 分钟浅睡 / 5 分钟深睡,纯规则挖五类卡片 零
surreal 荒诞联想与梦话,surreal 边永不进检索 有

lightweight 零模型调用是关键取舍:整理记忆这件事不该产生推理费用。用纯规则挖五类卡片(事实卡、工具踩坑、工具剧本、热点调用、未解决失败),在用户离开时静默完成。

surreal 边权重 0.03 起且永不进检索——荒诞联想只影响「想到什么」,不影响「相信什么」。

3.3.9 承诺与待办:承诺进记忆,待办不进

类型 是否进记忆链 寿命 上限
承诺 是(promise 类) 8000 小时 20
待办 否 3 天未提即过期 3

待办不进记忆链是刻意的:待办是短期事务,进了记忆链会以「她记得你说过要做某事」的形式长期存在,而实际上那件事早该过期了。

承诺锁原文,带反向权重(越近越重),kept / broken 结算只出关系增量建议,不直接改关系——关系归 ondra-emotion-stage,一处改另一处会打架。纯正则抽取,不调模型:extract-min-chars 默认 4(3 字以内分不清「承诺」与「随口一说」),extract-max-chars 默认 120(再长的句子通常是叙述)。

3.3.10 遗忘审计:回答「她为什么忘了」

三档话术(强度 / 半衰期 / 剩余时间)+ 钉住(≤8)+ 四态删除:

动作 行为 可恢复
forget 写墓碑 是(墓碑留痕)
seal 封存 是(不参与回忆但仍在库)
自然沉底 归档 是(可按类翻到)
revise 订正留旧文 是

只调记忆内核的公开方法,不写 memory_fact 任何一列。被遗忘权是这条链的终点:用户要求删除时,原文与事实都要真删,不是标记。

3.3.11 记忆自愈

ondra-memory-heal 修复不一致:孤儿事实、悬空边、失效复习计划、池与寿命不匹配。4 个可设置项——修复行为不开放太多配置,避免修出一个用户不认识的状态。


3.4 上下文:冻结前缀、增量注入、预算装箱

这一节是本项目最容易被忽略、也最影响实际体验的部分。三个机制各自解决一个具体问题,且互相约束。

3.4.1 冻结前缀:为什么每轮重拼 system prompt 是灾难

ondra-conversation-compose(对话编排)的做法:稳定段在会话开头拼一次就锁死,易变段追加尾部,历史只追加不重拼,压缩只在用户回合边界。

段 内容 何时变
稳定段(前缀) 人格 + 世界书常驻 会话开头一次,之后锁死
易变段(尾部) 日期、情绪投影、注入片段 每轮追加
历史 对话记录 只追加,不改写

实测数据:每轮重拼 system prompt 会让缓存命中率从 99% 掉到 0,成本从 ¥19.96 涨到 ¥879.97。这是全仓唯一守住那条线的地方——其它优化都可以让步,这一条不行。

因此下游所有插件的片段排序必须恒为正数(追加到尾部):

  • ondra-emotion-project 的 projection-order 默认 +800;
  • ondra-prompt-inject 的注入块一律追加;
  • ondra-live(日期事实)的输出绝不进冻结前缀——它每天变,进前缀等于每天废掉一次缓存。

压缩只在用户回合边界:模型回合中途压缩会让同一轮的前后文不一致,且会打断流式输出。等用户说完再压,是唯一安全点。

3.4.2 增量注入:首轮大块,之后只推增量

ondra-inject-dedupe 解决「同一条记忆在多个轮次、多个块里重复出现」:

  1. 首轮做一次性大块注入;
  2. 之后只推增量——同一条记忆只出现一次;
  3. 上下文一旦被压缩,释放已见账本,重新整块注入。

记账按「记忆」不按「块」:mem: / pin: 前缀统一归一为 fact:。否则同一条事实通过不同块进来会被当成两条,去重失效。

分两段提交:begin() 只出待记账,commit() 才真写。这样调用方在真正注入成功后才确认,避免「记账了但没注入」导致下次不再注入。

失败时放行整块而不是半块:半块会让注入内容不完整,模型读到残缺片段反而更容易误判。

auto-detect-compaction 默认开启:读对话编排的压缩计数器自动释放已见。只在单会话时生效,多会话时明确不猜——猜错会导致另一个会话的记忆被重复注入。

3.4.3 预算装箱:装不下就跳过,绝不切字

ondra-prompt-inject 的八块装配:

块 内容 是否参与装箱
说好的事 承诺 是
不能忘的 钉住 / 核心 否(永不裁剪)
我认识的人 person 类记忆(≤5) 是
那年今日 同日期的旧事 是
这会儿想起来的 拟人化想起 是
记下的道理 skillRule 是
顺着话头想起来的 被话头勾起 是
今天想起来的 最低曝光候选 是

装箱规则:全局按权重逐条试,装不下跳过这条继续试下一条,段标题写「另有 N 条未展开」。

三条硬约束:

  1. 绝不切字。切字会毁掉引文与正文的对应关系,模型读到半句话比读不到更糟。
  2. 绝不静默丢。装不下的必须在段标题里报数,让用户知道「还有 12 条没展开」。
  3. 到期提醒与钉住永不参与装箱。到期的事和用户亲手钉住的事,优先级高于任何预算。

3.4.4 三段配合的完整链路

一轮对话的上下文是这样装起来的:

用户消息
  ↓
[对话编排] 稳定段(已锁死)+ 易变段(日期/情绪/注入)+ 历史(追加)
  ↓
[注入去重] begin() 出待记账 → 只推增量
  ↓
[提示词注入] 八块装箱,按权重试装,报「另有 N 条未展开」
  ↓
[对话编排] commit() 记账 → 送模型
  ↓
[模型返回]
  ↓
[九步过滤链] 剥离复读、旁白、客服腔、过度解释
  ↓
[记忆写入] 原文落 memory_raw → 提炼 → 判重 → 归池

链路里任何一环缺席,症状都是「她不像记得我」,但原因完全不同:

缺席的环节 症状
缺 ondra-memory-pool 事实全落海马体并置 unclassified,而海马体永不注入
缺 ondra-prompt-inject 归池照常有数,但没人把它渲染成提示词
缺 systemPrompt 服务键 注入装箱就绪但无人阅读,只打一行 warn
缺 ondra-inject-dedupe 同一条记忆反复出现,挤占预算
前缀未冻结 功能全对,成本涨 44 倍

最后一行的意思是:前缀缓存失效不会有任何功能报错,只会让账单变难看。这类问题必须靠文档与检查脚本兜住,不能靠用户发现。


3.5 其余功能:逐项说明

本章前三节深写了情绪、记忆、上下文三个核心机制。其余 57 个插件同样承担具体职责,只是篇幅按重要性递减——每个都写清做什么与怎么实现,字段级默认值见 docs/PLUGINS.md。

3.5.1 人设细节(18 个插件,161 字段)

这个分组回答「她是谁、怎么说话、以什么身份陪你」。人格被拆成可替换的部件,而不是一段写死的提示词。

插件 做什么 怎么实现
人格内核 persona-core 定义「她是谁」 把 SKILL.md 切成六槽位(身份/说话/驱动力/场景/格式/自检),配五关系变体(女友/妹妹/女儿/朋友/助手)+ 自定义;只改关系不改人格
人格拼装 persona-assemble 把部件拼成完整人格 四段固定顺序:内核 → 关系微调 → 浓度 → 边界;按字符预算逐条装箱,装不下跳过继续试;边界层整层豁免裁剪;超预算报 overBudget 并把「另有 N 段未展开」写进段标题
人格变量与浓度 persona-vars 替换指代、控制说话浓淡 把 {{user}}/{{char}}/{{relation}} 换成用户填的值(无值一律原样保留);0–100 滑杆出五档浓度提示段;浓度永不写进内核
人设包 persona-loader 装载可替换的人设 只读扫描 ondra-persona-skills/*.skill.zip,解析 meta.json 与 SKILL.md,注册可用人设;出厂鲸鲸,夏柠柒为示例包;包本体永不回写
人格演化 persona-evolve 让性格随相处漂移 只动数值不动正文:特质分按「基线 + 未回滚增量」派生(有界、可回滚、可解释);阈值按 base × (1 + 0.6 × min(1, interactions/5000)) 上移,上限 +60%;×1.00 与 0 都是合法值,所以不漂也不报错
世界书 worldbook 提供背景知识 六层预算硬分桶(总 1600 token,L1 350 / L2 180 / L3 100 / L4 500 / L5 200 / L6 150);撞限整条丢、绝不截字;常驻条目单独成段并冻结为会话前缀;层内排序稳定不随命中次数浮动
口语化 oral 把书面语改成人话 口头禅池每人格 12–20 条、按六态分档、单场最多 2 次;不完整句 8%;标点习惯(不用句号结尾/连续问号表疑惑/省略号表犹豫);语气词只做减法;贴纸最小间隔 3 条只做闸门不发贴纸
输出过滤 filter-chain 剥离九类非人话 九步链:草稿腔 → AI 自我暴露 → 硬红线 → 关系越线 → 阶段越线 → 旁白叙述 → 4-gram 复读 → 客服腔 → 长度兜底;逐条剥离、只在句边界动手;带锚点检测与事后评审回环
语感 lexicon 统计「主人怎么说话」 取最近 300 条真人消息,算中位句长、无标点率、语气词频率,压成 56–62 字带 [语感] marker 的易变段;只存形状不存原话,原文当次丢弃
对话编排 dialogue 决定上下文怎么拼 会话内冻结前缀:稳定段开头拼一次锁死,易变段追加尾部,历史只追加不重拼,压缩只在用户回合边界(详见 3.4)
对话协议 dialogue-protocol 定义流式帧格式 五帧:text-delta / text-replace / crisis / usage / done;结束原因四值闭集;跨帧纪律(终帧唯一、usage 与 crisis 各一次)与 NDJSON 编解码只在这里存在一份
情感投影 emotion-project 把情绪变成一句话 模板固定为「行为指令句 + 一条暗示」,禁止出现数字、维度名与标签名;片段排序恒为正数(尾部)以守住前缀缓存(详见 3.2.6)
提示词注入 prompt-inject 把记忆渲染进提示词 八块装配,按字符预算装箱;到期提醒与钉住两块永不参与装箱(详见 3.4.3)
空窗期礼仪 absence 隔了很久回来怎么接第一句 五档分级(今天/昨天/几天/一阵子/很久),每档只给必须做到的事、绝对不许做的事、句数上限、要不要承认隔了一段、允不允许一点温度;一个数字都不给——「我们 17 天没说话了」不是关心,是把重逢变成审讯
初见剧本 first-meet 第一次见面怎么走 切成五拍:到场 → 自我介绍 → 立边界 → 邀请 → 收束;每拍只给目的、必做、禁做与句数上限,一个字的话术都不给;边界排在邀请之前(先讲清我不会做什么,再问第一个问题)
分条与停顿 rhythm 一条回复分几条、停多久 纯函数:≤4 条、每条 ≤40 字、6 字/秒、标点附加 300/500/150ms、停顿 300–6000ms(先卡限→深夜 ×1.6→再卡限)、首字延迟 800–2000ms、长停顿 20% × 3000–9000ms(整轮最多一次)
打字与停顿执行 rhythm-typing 把分条计划跑成时间线 首条「在想」、两条之间「正在输入」、长停顿期间不装打字;可按会话取消;同会话新一轮顶掉旧一轮;缺节奏服务时退回一次性整段发出
呼吸感 breath 该不该显示「正在输入」 把打字阶段映射成「此刻该不该显示正在输入」(走神的长停顿期间不显示);一天最多两次额度内给一个括号动作(伸了个懒腰这类)

3.5.2 声音形象(4 个插件,48 字段)

这个分组负责「她听起来、看起来是什么样」。

插件 做什么 怎么实现
语音合成 tts 文字转语音 三通道并存:本地 GPT-SoVITS(api_v2)、macOS say(零依赖)、Edge TTS(19 音色 + 8 套预设);只做接线、选路与降级,不重写合成机制;音频落 cache/tts 内容寻址缓存;失败返回原因码而不抛错
语音闸门 tts-gate 这句该不该念出来 四道闸:开关、6–30 字、情绪强度 ≥0.15(或本轮有情绪词)、3 秒频控;外加「连续两条后强制留文本」;只判定不发声,结果带原因码、真实字数、情绪强度与六态语速
语音转写 asr 语音转文字 本地 whisper 优先、云端兜底;只做接线:拼一条 whisper 命令、等它结束、把 JSON 读成毫秒时间戳;本地不可用才走云端 multipart;失败静默(拿不到转写就当没听见);不编说话人(whisper 不做分离,diarized 恒为 false)
表情判定 expression-decide 这句话说出口时脸上该是什么 按线索词、标点与句形把已定稿正文判成六态之一,交给立绘换脸;不往正文插标记、不往提示词写状态名;无线索时回落到当前六态,两者都无时给最不易出错的默认值,并让调用方看得见「这次没有依据」

3.5.3 关怀与引导(2 个插件,7 字段)

插件 做什么 怎么实现
危机干预分级 crisis 判断这句话里有没有需要停下来认真对待的信号 五类信号(间接表达/直接表达/计划/告别/自伤)各映射四档之一,再过一遍语境标记,最后给三个布尔建议(暂停人设/给求助资源/直接问一句);不诊断、不打分、不落盘、不内置热线号码、不因情绪低落判危机;假设语境只影响措辞不降级,否定与过去时只降一档不归零
首启引导 onboarding 第一次启动的四段流程 状态机四段:检查运行环境 → 复用已装 .ondra → 挑空闲端口 → 带 token 打开界面;每段必须带证据才算完成,跳过只允许发生在唯一可选的那段;进度百分比只算不编

3.5.4 门控与合规(4 个插件,34 字段)

这个分组是「她能不能做、能不能发」的判定层。共同纪律:能被开关关掉的红线等于没有,因此红线部分不接受配置。

插件 做什么 怎么实现
隐私与出站 privacy 这次能不能发、发给谁、谁点过头 能力方先 declare() 登记(发什么、发去哪、为什么必须出去、需要哪个环境变量),再在真正发之前 evaluate();没登记一律拒(不是默认允许);风险三层按「能不能撤销」分界:只发元数据走 L1 告知,发图片语音文件或带身份走 L2 确认,密钥形状内容出域走 L3 阻断;同意按「能力 + 主机」落盘;凭据只存变量名,值从不经过本插件
能力分级沙箱 sandbox 这只插件这一次能干多大的事 四级(read / suggest / act / spend),发一张有期限、有次数上限的票据,越权即拒;四条裁决写死:默认拒绝(没票就没有任何能力)、票过期即失效且必须查得到是哪张票、次数用完即失效、越权尝试要留痕
工具门控 organ-gate 这轮真正可能用到哪些工具 按本轮用户原话走关键词规则,算出「可能要用的工具族」,把其余工具从该 agent 的 schema 视图里摘掉,省下每轮输入 token;实测 48 条命令 55,154 → 8,433 token(84.7%),但不承诺同一比例
主动门控 proactive-gate 一条待投递消息此刻能不能发 九道闸:总开关与群聊红线、熔断与退避、内容红线、静默时段、每日预算、空闲阈值、会话冷却、重复去重、三层令牌桶;只判定不发送、不排闹钟;状态落盘 user/proactive-state.json(0600、原子写),因为「重启若清零 = 重复骚扰」

3.5.5 工具与能力面(3 个插件,26 字段)

插件 做什么 怎么实现
主动节律 proactive-quota 她此刻还想不想说、今天还剩几句 额度档位(缓慢/标准/快速)、两次主动的最小间隔、性格化沉默(确定性抽签的放弃率)、连续无回应后的退场句、一键静音入口;不判断能不能发,不重算额度衰减
主动话由 proactive-reason 此刻该拿什么当由头 八类候选(执念/到点承诺/情绪偏离/待办/近期事件/时间线/环境/台词池)按优先级排序(字典序,权重只在同种类内比较);72 小时话题冷却保证「同一件事三天内只说一次」;只出由头不生成正文
器官宿主 organ-host 管理可插拔能力的装载与结算 装载拓扑、能力白名单、能量会计、反馈仲裁、提示词拼装、遥测;三条不可让渡约束:器官永不直接写状态(只能 feedback() 提议,flush() 里按目标累加后一次性 apply 再 clamp)、该约束器官必须为自己负责、注册顺序不影响结果

3.5.6 存储与数据(5 个插件,0 字段)

这个分组按「降级链」组织:SQLite → JSONL → 内存。三者均为 0 个可设置项——降级行为不开放配置,避免用户把「自动降级」关掉后得到一个半坏的存储层。

插件 做什么 怎么实现
SQLite 后端 store-sqlite 主存储 WAL 模式、外键约束、表结构 STRICT、写入后把文件权限收紧到 0600;失败时不抛错中断,而是以「降级」状态注册
JSON 后端 store-json 第二级兜底 无 SQLite 或库损坏时的多方案分支;追加日志 + 压缩原子写;失败只降级不阻断
内存后端 store-memory 第三级兜底 SQLite 打不开时让主对话还能进行,明确声明不持久化,界面显示「当前为内存模式,重启后丢失」
快照 snapshot 记忆备份与恢复 VACUUM INTO 一致副本、KEEP=3 轮转、quick_check 校验;恢复必须显式确认且绝不自动执行
版本迁移 data-migrate 数据格式升级(出厂关闭) 只注册 MigrationRunner 服务;仓库里还没有任何一条迁移脚本,所以出厂关闭——没有脚本的迁移器装上等于假开关
视觉 vision 图片理解、OCR、物体定位、视频抽帧(出厂关闭) 走可插拔 provider(云端 OpenAI 兼容 / 本地 Ollama / 通义千问),默认关;与 privacy 联动,有它按裁决走三态,没有它云端一律不发;失败安静返回原因码、不编内容(猜出来的描述会变成一条假记忆)

3.5.7 基础与宿主(12 个插件,27 字段)

这一层是「应用能起来」的地基,多数为 0 字段:它们提供的是契约,不是可调项。

插件 做什么 怎么实现
核心装配 core 装配全部成员 以显式清单声明 75 个成员,逐个 await ctx.plugin(),每个包在独立 try/catch 里,失败进 skipped 并记原因
桌面桥接 desktop-bridge 桥接 Tauri 壳的本机能力 文件选择、系统主题、窗口标题、外部链接四类命令;在主装载名单之外无条件挂载
内核客户端 dsh-client 与 dsh 内核通信 封装 JSON-RPC 2.0:进程生命周期管理与请求配对;NDJSON 分帧
时钟 clock 统一时间源 时区固定、自然日按日历换算、定时器生命周期可撤销;全仓不直接用 Date.now()
身份 identity 名称与路径的唯一来源 产品名、进程名、pidfile 名、环境变量前缀、目录名与日志命名空间根全部从这里取,不允许第二处硬编码
配置真源 config-schema 配置的四层合并 出厂 → 组合包 → 用户 → 运行时;原文段回退与来源追溯的唯一实现
路径布局 path-layout 数据根与目录权限 解析数据根、创建五个区域目录、收紧到 0700/0600 的唯一入口
模型网关 llm-gateway 选模型、发请求、记账 三来源清单(在线 API / 本地 / 免费)、优先级选路、首块与块间隔超时、首块前重试与跨来源降级、用量记账;密钥只做存在性预检 + 引用登记,不注入
中文日志 log-zh 中文日志格式化(出厂关闭) 提供的 ondra-log-zh 服务键在全仓没有运行期消费方,装上就是空跑,所以出厂关闭
端口挑选 port-picker 挑一个能用的端口 把「要一个号」拆成两种意图:给监听方要一个现在没人绑的号(走系统分配,等价 --port 0),给界面要一个宿主已绑的号(读既成事实);混用两种意图,子进程就会拿到宿主自己占着的端口
进程托管 process-host 管子进程 把 argv/cwd/stdio/宽限期显式交给上游 ctx.subprocess;崩了按预算重启一次;收尾走 provider 的托管进程范围(组杀);状态落 process-host 目录;刻意不自己 kill
窗口 window 窗口规格与版本守卫 把标题区 48、可拖热区 44、红绿灯 20/19/12、圆心 25、三栏 272/520/300 与五个断点收成一处定义;给出 A(hiddenInset 加系统红绿灯,默认最稳)与 B(hidden 加自绘,仅 macOS)两条分支;检查壳侧补丁的版本白名单

3.5.8 生活与感知(2 个插件,18 字段)

插件 做什么 怎么实现
生活与作息 life-rhythm 让她自己过日子 一边是活动:六类活动、兴趣权重与标签(烧脑/放松/创造)、10% 概率探索新兴趣、心情差时优先放松型、做一次加权、长期没碰按半衰期衰减、笔记分长期与临时;一边是能量:清醒 +0.2/min、深睡或深夜 +1/min、自然上限 60、喂到 100 按 0.1/min 回落
缺口池 drive-gap 记住她心里没弄明白的事 三源生成(模型自认不知道 / 工具查空 / 请求失败);张力按 created_at 幂等派生(base + 小时×rate − relief + 判重加压);相似度 ≥0.6 只加压不新建并保留最早措辞;闭合时连坐相似的 open 条目;唤醒四条件

3.5.9 安装与装配(3 个插件,20 字段)

插件 做什么 怎么实现
安装器 installer 清单制安装 九步流水线,每组件独立 sha256,落盘前后各验一次,账本写在用户 .ondra-bootstrap.json,最后跑一次 bootstrap-ok 自检;校验硬排在落盘之前(哈希不对的文件不进磁盘)
安装自检 install-doctor 这份装配会漏掉谁 用移植的 parsePatchYaml / applyEntryPatches / composeEntries 算出「宿主会看到的那份入口树」,报告哪些 bundle 会被跳过、补丁告警几条
加载自检 startup-check 它到底有没有被装上 十项检查,只判定不拦启动;验的不是代码对不对,而是「它有没有生效」;列了三条静默失败:package.json 漏 ondra.bundle.patch 时宿主只往 stderr 打一行、不影响启动、界面上也看不出来

3.5.10 情绪与记忆的剩余插件

情绪引擎的四个插件已在 3.2 节展开,记忆系统的十七个已在 3.3 节展开。此处只补两项在 3.3 中未单列的:

插件 做什么 怎么实现
记忆图谱 memory-graph 记住「哪些事有关」 四类边与 1 跳扩散:同主题 0.72 起每次 +0.05、共现 0.15 起每次 +0.10、荒诞 0.03 永不进检索;扩散门槛 0.4、枢纽度上限 40、90 天 ×0.5(带 decayed_at 游标);只碰边表,绝不写 memory_fact
记忆识别 memory-recognition 第二条检索路径 全索引(每条记忆一行识别触发词)进 prompt,小模型挑 slug,再沿 [[links]] 邻域游走;500 条约 6k token;索引块按 seq 升序固定,因此可做前缀缓存
向量检索 memory-vector 第三条检索路径(默认关闭) 确定性哈希嵌入 + 余弦召回 + IVF 分桶;300/1000 两个阈值自动切「字面 / 混合 / 向量」三模式,结果走 RRF 融合;0 个可设置项
多模态记忆 multimodal-memory 把图片描述与语音转写按普通记忆入库 记忆正文只有一份(写进 memory_fact),本插件不建第二份;带 modality 与 source_path 记住来源;图片→描述、语音→转写、视频→4 帧 + 音轨
被遗忘权 forget-right 用户说「把这件事忘掉」时把要清的地方数清 十一面:正文、钉子、图边、向量、待办与承诺、时间胶囊、语感样本、日记、叙事日志、快照、决策日志;两段式:规划只读并出清单,执行必须带显式确认令牌;任一面失败或该清未清则 completeness = false——被遗忘权只有「全做完」与「没做完」两种结果

3.5.11 合计

一级菜单 分组 插件数 字段数
人设细节 6 18 161
声音形象 2 4 48
开发者功能 8 35 192
情绪引擎 1 4 13
记忆系统 1 17 205
合计 18 78 619

出厂关闭 3 个:ondra-vision(读屏并调外部模型)、ondra-data-migrate(无迁移脚本)、ondra-log-zh(服务键无消费方)。每一个关闭都有理由,不是为了精简名单。


四、核心亮点

以下六条均可复现验证。每条给出名称、价值、实现方式、与常见方案的差异,以及支持它的具体证据。

亮点一:以服务键互相发现,而非代码级依赖

  • 亮点名称:服务键发现机制。
  • 价值:解决「改动成本随功能数量非线性增长」的问题。75 个插件放在一个代码库里,若互相 import,任何一个共用模块的签名变化都会引发全仓编译失败,回归范围无法收敛。
  • 实现方式:插件之间没有一行跨包 import。每个插件在 apply(ctx, config) 里通过 ctx.provide(服务键, 实现) 注册能力,通过 ctx.get(服务键) 取用上游。取不到时不抛错、不等待,而是跳过这一步并在日志里留一行「上游缺席」。Cordis 负责服务注册与依赖排序,插件本身不知道谁在用它。
  • 差异化:常见的插件系统用 interface 或注册表声明依赖,编译期就能发现不匹配;代价是任何一处改动都要求全量重编。本方案把耦合推迟到运行时,用「取不到就降级」换取「增删零编译影响」。这一取舍的前提是每个插件都必须写好降级路径,因此降级不是可选优化,而是准入条件。
  • 证据:cordis-host/check-core.mjs 对 75 个成员逐一装载,实测 75/75 成功、0 跳过,单次装载耗时约 200 毫秒。零跳过这一结果本身就说明每个插件在其上游缺失时都能独立起来——若某个插件硬依赖上游,装载时会因取不到服务而失败。

亮点二:记忆写入比记忆检索更根本,因此写入有确定性闸门

  • 亮点名称:写入侧的证据门控。
  • 价值:解决「自我强化幻觉污染整库」的问题。若模型把自己上一轮的猜测当成事实记下来,下一轮又会读到这条「事实」并更自信地重复,提示词层面拦不住这个过程。
  • 实现方式:每条待入库的结论必须带四类来源引文之一——[USER](用户原话)、[TOOL](工具返回)、[ACT](系统动作)、[SELF](角色自述),并校验引文确实以原文形态出现过。数字类断言必须被逐字引文覆盖(默认 TOOL 与 USER 都算出处,digit-source 可收窄为 tool-only),无 [USER] 引文的归功断言一律拒绝。校验由 ondra-evidence-gate 以确定性代码执行,不交给模型判断。
  • 差异化:多数记忆方案把质量控制放在检索侧——检索后按相关度筛掉噪声。本方案认为污染发生在写入侧,检索侧再精确也只是从被污染的库里挑出最相关的那条。写入侧设闸门的代价是可能漏记,这个代价被显式接受:宁可少记一条,不可错记一条。
  • 证据:ondra-evidence-gate 的 13 个可设置项中,require-tool-for-digits(数字出处只认工具)与最小引文字数上限均为可调,默认值取「开」与 4 字符。ondra-memory-extract 的 allow-group 默认关闭,把「不收群友闲聊」列为准入红线而非性能取舍。

亮点三:内存与存储分层,会话不因重启丢失

  • 亮点名称:三层存储降级。
  • 价值:解决「SQLite 打不开就整个应用不可用」的问题。SQLite 依赖文件系统权限、磁盘空间与 WAL 锁,在企业环境或磁盘异常时确实会失败,而失败时用户最需要的是「至少还能说话」。
  • 实现方式:ondra-store-sqlite 是主存储,建表用 WAL 模式、开启外键约束、表结构声明为 STRICT、写入后把文件权限收紧到 0600。它失败时不抛错中断,而是把服务以「降级」状态注册,ondra-store-memory 接替工作,并明确声明「不持久化」。ondra-store-json 提供介于两者之间的第三个选项。
  • 差异化:常见做法是「存储失败即启动失败」,逻辑简单但把可用性完全押在存储上。本方案把降级做成显式契约——降级不是静默的,界面上能看见「当前为内存模式,重启后丢失」,用户据此决定是否先解决磁盘问题。
  • 证据:ondra-store-memory 的描述明确写着「SQLite 打不开时让主对话还能进行,明确不持久化」。三个存储插件均为 0 个可设置项——降级行为不开放配置,避免用户把「自动降级」关掉后得到一个半坏的存储层。

亮点四:情绪的六态表达由单一真源决定

  • 亮点名称:情绪表达的唯一真源。
  • 价值:解决「同一轮对话里两处描述情绪,说法互相矛盾」的问题。情绪一旦要从提示词、语速、贴纸、节奏四个地方同时生效,四处各写一份系数必然漂移。
  • 实现方式:ondra-emotion-express 把情绪定死为六个状态——雀跃、软和、平静、蔫、炸毛、困,六态的名称与数量固定不开放增删。同一个判定结果同时供提示词、节奏、语音三处取用,语速系数(+10% / −15%)、主动额度(±1)、是否朗读、贴纸概率这些数字全仓只在本插件出现一次。本插件只输出参数,不输出文本;投影文本唯一归 ondra-emotion-project。
  • 差异化:常见方案让每个模块自己读取情绪并各自决定如何表达,改动一处系数要同步改四处。本方案把「情绪如何影响表达」压缩成一个参数包,其余模块只能读不能算。激素维度扩展(ondra-emotion-hormone)出厂关闭,正因为它与六态表达存在口径重叠,同开会让同一轮出现两种情绪描述。
  • 证据:ondra-emotion-express 仅 2 个可设置项,ondra-emotion-core 4 个,ondra-emotion-conflict 4 个——整个情绪引擎 7 个插件合计 25 个可设置项,是全项目配置面最小的一级菜单。配置面小不是功能缺失,而是刻意把自由度收在真源一处。

亮点五:后台主动行为受三重闸门约束

  • 亮点名称:主动行为的三重约束。
  • 价值:解决「陪伴体变成骚扰源」的问题。一个有主动能力的程序如果没有节制机制,用户第一次觉得贴心,第三次就会关掉它并再也不打开。
  • 实现方式:ondra-proactive-gate(21 个可设置项)负责「这一轮该不该开口」,判定依据包括静默时段、空闲时长、最近的开口次数;ondra-proactive-quota(9 个可设置项)负责「今天还能开口几次」,按日预算与投递间隔双重限制;ondra-proactive-reason(4 个可设置项)负责「开口说什么」,只接受有具体来由的话由——未完成的待办、做过的承诺、环境变化,不接受「随便聊聊」。
  • 差异化:多数主动功能只有一个频率参数,用户只能整体调高或关掉。本方案把「该不该」「还有额度吗」「说什么」拆成三个独立判定,任一条不通过就不开口,并且三条各有独立的可配置阈值。这样用户可以把频率调低但保留「今天下雨了提醒带伞」这类有实际来由的主动。
  • 证据:三个插件合计 34 个可设置项,ondra-proactive-gate 是扩展与开发者两域中配置项最多的单个插件之一。ondra-digital-health 另有 8 个可设置项,按连续使用段与当日累计时长分为四档(正常、温和提醒、明确劝阻、停止),出厂不锁屏、不弹窗、不打断。

亮点六:说话方式经过可验证的过滤链,而不是靠提示词约束

  • 亮点名称:九步输出过滤链。
  • 价值:解决「模型说话像客服」的问题。语气可以靠提示词模仿,但复读、旁白、客服腔、过度解释这四类特征在长对话里必然出现,提示词只能降低概率,不能清零。
  • 实现方式:ondra-filter-chain(20 个可设置项)在模型输出之后、进入对话之前逐条剥离:复读判定用 n-gram 重合率,旁白判定用括号与星号包裹的动作描述,客服腔判定用「请问还有什么可以帮您」一类的固定句式清单,过长判定用回复长度上限。每一步都是可单独关闭的开关加一个阈值,不是整体风格改写。
  • 差异化:常见做法是在系统提示词里写「不要像客服」「不要说教」,属于概率性约束,且随上下文增长而衰减。本方案把约束放在输出之后,与模型无关、与上下文长度无关,可逐条关闭也可逐条加严。
  • 证据:ondra-filter-chain 是话语表达组中可设置项最多的插件(20 项)。配套的 ondra-lexicon(10 项)只统计最近 300 条真人消息的中位句长、无标点率与语气词频率,把结果作为约 130 字的注入块,只统计不覆盖人格——统计结果给出「这个人说话是什么样」,过滤链负责「她不要说成什么样」,两者分工不重叠。

五、设计细节与小巧思

本章记录非显而易见的设计决定。每条都说明具体做法与背后的取舍。

5.1 异常输入与边界情况

引文按原文比对而非相似度比对。 证据门控不使用相似度阈值判断引文是否成立,而是要求引文在原文里原样出现。相似度校验会让「他今年二十八」与「他今年三十八」通过,而数字恰恰是最不能错的部分。

超长记忆截断而不拒绝。 单条记忆正文超过上限(默认 200 字)时截断并记一条 issue,不做整条拒绝。理由写在字段说明里:拒绝等于这条记忆彻底消失,而截断至少保住了前半段。

摘要缺失时用正文替代。 模型没有返回摘要时,取正文前若干字作为摘要,不让摘要字段为空——空摘要会让这条记忆在列表视图里显示为一行空白。

上游缺席不抛错。 所有 ctx.get() 调用都按「可能取不到」处理。取不到上游插件时服务照常注册,只是标记为「不可用」。这一条让 75 个插件可以任意顺序装载,也让单独调试某一个插件成为可能。

空数组的语义逐个写明,不统一约定。 全仓 10 个结构化数组字段有两种「空」:ondra-proactive-gate 的 block-words 为空是「不额外屏蔽」,ondra-crisis 的 hotlines 为空却是「一个求助号码都不内置」——号码有地区差异且会变更,内置一份过期清单比空着更危险。同一个形状,两种含义,所以字段说明里逐条写清,不靠使用者猜;统一成「空即不限」会让危机干预那条变成假承诺。

5.2 超时、重试、降级、容错

需要外部后端的插件默认关闭。 ondra-vision 是全仓唯一 enable 默认为 false 的插件:它既读屏,又把截图发给外部视觉模型,两件事都在本机之外。默认关闭让「装好就能用」与「未经同意不读屏」同时成立;同一菜单下另外 34 个插件默认开启。

重复写入靠包含度判重,而不是靠时间窗。 同一件事第二次被记下时,ondra-memory-dedupe 按新旧两条正文的包含度决定去向:低于 0.4 视为两件事直接新建;达到 0.95 认为近乎全等,再由新颖度决定是复述还是补充(新增二元组占比 ≤ 0.25 且个数 ≤ 2 才算复述);介于两者之间按槽位覆盖线 0.45 判是不是「同一句式换了个值」。时间窗在跨天、跨设备、补记这三种情形下都不成立,包含度不依赖时钟。候选池只取最近 200 条、单次扫描上限 300 行——两两比对是 O(n²),再大就会卡。

失败归因分八类,arg_error 永不自动重试。 工具调用失败时不笼统记为「失败」,而是分到 tool_missing、arg_error、permission、timeout、network、not_found、conflict、unknown 之一。八类各有处方,但 arg_error 的重试预算在第一行就被写死为 0——参数错误重试一百次结果相同,让不知道密码的人重试一百次还是登不进去。

伤口只在下一次成功时闭合。 失败归因会为每个失败的工具「开一个伤口」,伤口只在该工具下次成功时闭合,不按超时闭合。按超时闭合会让一个一直坏着的工具看起来自愈了。五分钟未闭合的伤口标记为慢性并停止重试。

端口冲突自动让位。 ondra-port-picker(9 个可设置项)在首选端口被占用时按区间向上寻找空闲端口,找到后把实际端口回报给壳。这样同时开两个实例不会有一个静默失败。

5.3 缓存与性能优化

人格内核按人设版本号缓存。 ondra-persona-core 的 cache-kernels 默认开启,缓存键包含人设版本号,换人设包即失效。关掉这一项只对「一边改人设包一边调试」有用,日常运行没有理由重算。

零常驻上下文:用指针替换常驻内容。 ondra-zero-residence(7 个可设置项)在上下文里保留的是确定性指针而非完整内容,被替换掉的字节可以从会话日志里原样恢复。这套机制解决的是长期对话里上下文被历史内容挤满的问题——常驻内容占据的 token 是每一轮都要付的。

注入去重:首轮大块 + 只推增量。 ondra-inject-dedupe(7 个可设置项)在第一轮做一次性大块注入,之后只推增量,并维护已见条目 id 的账本。上下文压缩信号出现时释放已见账本重新注入,避免压缩把关键信息一并压掉。

工具 schema 门控。 ondra-organ-gate(9 个可设置项)按意图用确定性规则决定本轮暴露哪些工具,而不是把所有工具定义一次性塞进提示词。工具定义是上下文消耗的第一大项,门控后只暴露当前意图可能用到的子集。

插件串行装载,不做并发。 75 个插件的装载是逐个 await 的,实测约 200 毫秒。并发装载会让依赖方在被依赖方还没 provide 服务时就开始初始化,产生难以复现的竞态。两百余毫秒换掉一整类偶发失败,这个交换是划算的。

5.4 并发与数据一致性

器官状态累加后一次性生效。 ondra-organ-host 的 flush() 把本轮的多个状态提议按目标累加,最后一次性写入并夹取到合法区间。逐条写入会让最终结果取决于插件的注册顺序——同一个器官先注册和后注册会得到不同的状态值。

并发上限只有一个旋钮,按最稀缺的资源设。 全仓只有 ondra-memory-intake 的 maxConcurrent 暴露了并发上限,默认 1:记忆提炼每轮只需要一次模型调用,开大只会同时压满模型网关。其余插件不各自开一个并发旋钮——多个旋钮叠加会让「为什么慢」变成一道算术题。

记忆写入在同一事务内完成。 ondra-memory-core 的 appendTurn() 把原文与提炼结果写在同一个事务里,避免出现「原文写进去了但事实没提炼」或「事实提炼了但原文丢失」的中间态。

5.5 内存与存储优化

数据目录权限在创建时收紧。 ondra-path-layout 是数据根解析与目录创建的唯一入口,创建五个区域目录时一次性设为 0700,文件设为 0600。权限在创建时设定而非之后修复,避免中间存在一段任何人可读的窗口。

日志按 4MB 三轮转,同错去重。 ondra-log-zh 采用双层日志:给人看的句子与给开发者看的代码日志分开写。轮转上限 4MB × 3 轮,同一错误在短时间内重复出现时只在首次写入完整内容,后续只累加计数——这防止了一个持续报错的插件把磁盘写满。

状态文件留空即用默认值。 20 个 state-file 类配置项的默认值均为空,留空时使用内建默认路径。这些字段在设置界面里不出现,因为它们的功能是「把状态放到别处」,而绝大多数使用者不需要把状态放到别处。

依赖不重复占用项目空间。 pnpm 的内容寻址存储把所有包的真实文件放在全局 store 里,项目内的 node_modules 只有符号链接。实测项目内 6131 个文件的硬链接数全部为 1,逻辑大小与磁盘占用均为 0 字节。

5.6 安全与防攻击措施

管道即鉴权。 壳与宿主、宿主与内核之间通过标准输入输出通信,不经过网络。操作系统层面保证了只有父进程能读写子进程的管道,因此这条通道不需要额外的鉴权层。

HTTP 服务只监听回环地址。 宿主的本地 HTTP 服务绑定 127.0.0.1,不监听 0.0.0.0。局域网内的其他设备无法连接。

令牌在启动时生成,不落盘。 32 字节随机令牌由壳在启动宿主时生成并只经内存传递,每个请求校验 Authorization: Bearer,未授权的请求在解析请求体之前即被拒绝。响应头统一带 cache-control: no-store。

浏览器护栏拒绝内网目标。 ondra-browser(10 个可设置项)拦截在页面里执行任意代码的工具,并拒绝 file:、javascript:、data: 等协议与 127.0.0.1、localhost、*.local 等私有目标。这些是服务端请求伪造攻击的入门形态。

工具结果按预算截断并如实报数。 浏览器快照这类工具返回的内容可能达到数百 KB,护栏按 4000 字符与 80 行双重预算砍掉尾部,并在结果开头写出「原始大小是多少、下面没有展开」——不静默截断,使用者知道有内容被省略。

出站闸门做提醒而非代理。 ondra-privacy(6 个可设置项)的定位是合规告知与三层风控提醒,不做加密,也不做代理。做成代理就必须让数据流经该插件,凭据值会进入插件的内存——这会把一个隐私保护功能变成新的隐私风险点。

能力权限默认拒绝。 ondra-organ-host 的能力白名单是 default-deny 的:调用未声明的能力会抛 CapabilityDeniedError,不静默忽略。静默忽略会让插件作者以为自己调用的能力生效了。

5.7 用户体验上的微小设计决策

权限档位放在输入框左下角。 权限状态是本产品最需要被看见的设置——它决定了本次对话的数据会不会出本机。把它从设置页移到输入框旁边,是为了让使用者每次发消息都能看见当前档位,而不是发完之后才想起去查。

语音输入不支持时给明确提示。 语音走 Web Speech API,在多数桌面 WebView 中不可用。不可用时按钮给出「当前环境不支持语音输入」的提示,不静默失败——静默失败会让使用者反复点按并怀疑麦克风坏了。

窗口尺寸每次启动强制复位。 主窗口默认与最小尺寸统一为 820×580,每次启动回到该尺寸,无论上次被拖成多大。理由是对话界面的阅读宽度有最优区间,宽到 2000 像素时单行过长反而难读。

设置项分两档呈现。 619 个字段中,276 项在界面里直接显示,343 项折叠在「进阶」里。单个插件的字段最多 30 项(ondra-memory-dream),全部平铺会让设置页变成一张无法浏览的表。折叠不删减功能,只是把「很少需要改的」收进第二层。

每一条设置都带说明文字。 619 个字段全部带非空 doc(100%),平均 36 字,最长 115 字。说明缺失时的兜底策略仍在:界面遇到无 doc 的字段直接显示字段键名,不编造说明。说明直接取自编译产物中的字段声明,与代码同源,因此不会出现「文档说默认开、实际默认关」的漂移。

禁用态也显示。 出厂关闭的那一个插件(ondra-vision)在插件列表里以灰点显示而非隐藏。灰点的含义是「装着但没开」,与「没装」是两件事——隐藏会让使用者误以为这些能力不存在。

5.8 为什么选择某个默认值

关系默认 friend。 五种关系(女友、妹妹、女儿、朋友、助手)里,friend 是唯一不预设亲密度的一个。产品不替使用者决定「她跟你是什么关系」,因此把最中性的选项设为默认。

说话浓度默认 50。 浓度滑杆范围 0–100,50 表示人设包原本的说法。默认值取中间不是折中,而是「不改动人设作者的原始表达」——往两端调都是使用者的主动选择。

人格拼装预算默认 12000 字符。 实测一份人设包完整装载需 7,725 字(出厂包「哦鲸鲸」)到 10,057 字(示例包「夏柠柒」),再留约 20% 给片段与关系微调。这个默认值不是「够大就行」的整数:原来的 2600 会让「说话」段(2169 字)整段掉出提示词——她知道自己是谁,却读不到自己怎么说话,而三处装配代码都不报错。出厂模型档声明上下文窗口 1,000,000,2600 并不是被能力逼出来的数字。

重构检查两项关闭的代价被明确写出。 exactOptionalPropertyTypes 与 noUncheckedIndexedAccess 对 75 个迁入插件不生效,这两项在其余部分开启。关闭的代价是这 75 个插件的类型精确度低于本仓其余部分,这是迁入既有资产换来的,不是遗漏。

出厂人设为鲸鲸,夏柠柒为示例包。 鲸鲸是出厂人格,夏柠柒是示例人格包,可随时替换。把两者分开是为了让「换人设」这件事从第一次启动就是一等操作,而不是一个隐藏功能。

插件串行装载而非并发。 见 5.3,约 200 毫秒换掉一整类竞态。

情绪激素维度出厂关闭。 它与六态表达存在口径重叠,同开会让同一轮出现两种情绪描述。默认关闭不是因为它不好,而是因为两套描述同一件事的机制不该同时生效。

5.9 兼容旧版本与跨平台的策略

旧装配声明保留作对照。 74 份 cordis.patch.yml 保留在插件目录里,但不再被读取。装配语义已转写为 ondra-core 中的显式数组。保留旧文件的目的是对照——出问题时可以比对「旧的补丁声明说它该在哪」与「新的数组说它在哪」。比成员数少一份,因为 ondra-memory-intake 从一开始就按显式数组写,从未生成过补丁文件。

私有 scope 用依赖别名重定向。 空谷自有插件历史上把框架声明为 @ntars/cordis,该 scope 从未发布到公共 npm。本项目在 pnpm-workspace.yaml 里通过 overrides 把它指向官方 cordis@4.0.0-rc.12。这是依赖解析层面的重定向,不修改 Cordis 源码,也不改变其许可证。

时间只认外部喂进来的时刻。 ondra-study 不装定时器、不按文件时间戳推断「过了多久」,只接受外部传入的时刻。两次打点间隔超过 15 分钟即判为中断,那一段一秒都不计入。理由是常驻定时器在没人看着的时候照样走,会导致「关掉窗口八小时、回来读今天学了八小时」。

跨天清零但保留「曾经开始过」。 学伴的当日累计在跨天时归零,但保留「曾经开始过」的标记。清掉它,第二天第一次读取会报「从来没开始过」,对一个昨天学了两小时的人是假平安。

Windows 与 macOS 的界面差异被显式记录。 Windows 的安装向导窗口尺寸(410×290)无法在配置里直接设定,需要自定义 NSIS 模板。当前保持默认 NSIS 以保证 Windows 打包可用,这一差异写在已知问题里而非隐藏。

六、技术栈与依赖清单

6.1 运行时依赖

随应用分发,构成最终安装包的一部分。

名称 版本 许可证 在本项目中的用途
Tauri 2.12.5 Apache-2.0 / MIT 桌面外壳框架:窗口、WebView 注入、IPC 命令通道、打包(dmg / app / nsis)
tauri-plugin-opener 2.7.2 Apache-2.0 / MIT 在系统默认浏览器中打开外部链接,统一处理各平台差异与权限校验
tokio 1.54.1 MIT Rust 异步运行时:宿主子进程启动、标准输入输出读取、超时控制、退出等待
serde 1.0.229 Apache-2.0 / MIT 壳与宿主之间消息的序列化与反序列化
serde_json 1.0.154 Apache-2.0 / MIT JSON 数据格式支持
Cordis 4.0.0-rc.12 MIT 宿主侧插件框架:上下文、服务注册与注入、插件生命周期与依赖排序
React 19.3.1 MIT 界面渲染层,不依赖任何 UI 组件库
React DOM 19.3.1 MIT React 的浏览器渲染实现
morphicons 2.0.2 MIT 界面中的形变图标组件
lucide 1.68.0 ISC 界面图标数据。统一 24×24 网格、1.5 描边、圆头端点,由 src/components/icons.ts 按语义包装后经 Icon.tsx 渲染

6.2 开发期依赖

只在开发与构建阶段使用,不进入最终产物。

名称 版本 许可证 用途
TypeScript 7.0.2 Apache-2.0 全部 TypeScript 代码的类型检查与向 ES 模块的编译
Vite 8.3.6 MIT 前端开发服务器与生产构建
@vitejs/plugin-react 6.2.1 MIT 为 Vite 提供 React 的 JSX 转换与热更新
@tauri-apps/cli 2.12.4 Apache-2.0 / MIT tauri dev 与 tauri build 命令行入口
tauri-build 2.7.4 Apache-2.0 / MIT 构建脚本:生成 Tauri 上下文代码、权限 schema 与打包元数据
@types/node 26.6.6 MIT Node.js 内置模块的类型定义
@types/react 19.3.0 MIT React 的类型定义
@types/react-dom 19.3.0 MIT React DOM 的类型定义

6.3 依赖规模

依赖树 包数 来源
Rust crate(含传递依赖) 442 src-tauri/Cargo.lock
npm 包(含传递依赖) 87 pnpm-lock.yaml

上述数字由锁文件解析得出,不是估算值。全部 529 个第三方组件的名称、版本、版权方、许可证、官方地址、在本项目中的用途与修改状态,逐项列于 NOTICE。重跑依赖后需同步更新该文件。

许可兼容性。本项目采用 AGPL-3.0,529 个第三方组件没有一个携带与之冲突的许可证。

许可证类型 组件数 说明
MIT / Apache-2.0 及两者组合 448 绝大多数;宽松,与 AGPL-3.0 完全兼容
Zlib(含多选组合) 22 宽松,与 MIT 同级
Unicode-3.0 19 Unicode 数据表;宽松
MPL-2.0 17 弱著佐权,见下
BSD-3-Clause 10 宽松
Unlicense / 0BSD / CC0-1.0 / MIT-0 8 公有领域等价,最宽松一档
ISC 3 宽松
LGPL-2.1-or-later,仅作多选项之一 2 r-efi 另提供 MIT 与 Apache-2.0,本项目按宽松选项使用
合计 529

17 个 MPL-2.0 组件中,Rust 侧 5 个(cssparser、cssparser-macros、dtoa-short、option-ext、selectors),npm 侧 12 个(lightningcss 及其 11 个平台二进制包)。全部以未修改的发布版本引入,项目内没有改动其中任何一行的源码,因此不产生 MPL-2.0 第 3 条的衍生作品义务,也没有公开源码的义务;若将来需要修改其中任何一个,被修改的文件须以 MPL-2.0 发布。

r-efi 的许可证声明为 MIT OR Apache-2.0 OR LGPL-2.1-or-later,即三者任选其一。本项目按宽松选项使用,不触发 LGPL 条款。

没有任何依赖被修改。全部第三方组件以未修改的发布版本引入,项目内不存在 vendor/ 目录,Cargo.toml 中不存在 [patch] 段。


七、安装与部署

7.1 环境要求

项目 要求 说明
Node.js 22 及以上 宿主与内核均运行于 Node 22;package.json 的 engines 字段已声明
pnpm 11.28.5 由 packageManager 字段固定,使用其他版本可能产生锁文件差异
Rust 1.85 及以上 仅编译壳时需要;只跑开发模式可不装
系统 macOS 11+、Windows 10+ Linux 的系统集成部分尚未适配

7.2 安装 Rust 工具链

只在需要构建桌面安装包时执行。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"

7.3 获取源码与安装依赖

git clone https://github.com/ntars/Ondra.git
cd Ondra
pnpm install

pnpm install 会为 cordis-host/plugins/ 下的 78 个包建立工作区链接。首次执行耗时较长,后续增量安装很快。

7.4 构建

# 依次构建前端、插件、宿主、内核
pnpm run build

# 构建桌面安装包(需 Rust 工具链)
pnpm run build:app

# 构建并把安装包搬运到仓库根的 releases/
pnpm run release

克隆后必须先构建,才能运行 check:core、smoke 或开发模式。 仓库不保存 TypeScript 编译产物:78 个包与宿主、内核的 lib/ 目录均由 tsc -b 从各自的 src/ 生成(合计 2201 个文件、约 16 MB),已在 .gitignore 中排除。而各包 package.json 的 main 指向 lib/index.js,cordis-host/check-core.mjs 也直接导入 ./plugins/ondra-core/lib/index.js。因此全新克隆的目录里 lib/ 不存在,跳过 pnpm run build 会报「Cannot find module」。

pnpm run build 的执行顺序是 build:web → build:plugins → build:host → build:sidecar,插件先于宿主,正是为了让宿主编译时能解析到各插件的 lib/。

pnpm run release 在完成 build:app(即 tauri build,已包含完整的 build 流程)之后,把 src-tauri/target/release/bundle/ 下的产物(保留 dmg/、macos/、nsis/ 子目录结构)搬运到仓库根的 releases/。releases/ 已在 .gitignore 中,不会入库。

不直接让 Tauri 输出到 releases/ 的原因:Tauri 的 bundle 位置由 Cargo 的 target 目录决定,配置文件里改不了,只能用一步拷贝集中产物。

构建期注意事项。在启用了安全删除垫片(safe-delete shim)的环境中,pnpm 收尾阶段的临时文件清理可能被判定为批量删除而中断构建。此时可临时设置 CODEBUDDY_SAFE_DELETE_ENABLED=0 绕开。该变量只影响构建期清理,不影响产物。

7.5 官网部署

官网由 EdgeOne 自动部署,配置见 edgeone.json:

node tools/build-site.mjs

产物输出到 .site-dist/,仅含 index.html、icon.svg、favicon.ico 与 assets/。构建脚本会剔除 .DS_Store、._*、__MACOSX 三类 macOS 元数据文件,并跳过源目录为空的情况,不在产物里留下空目录壳子。


八、快速开始

8.1 开发模式

三个进程分别启动,便于单独调试:

# 终端 1:前端(热更新)
pnpm run dev:web

# 终端 2:宿主(自动拉起内核)
pnpm run dev:host

# 终端 3:壳(加载前端并管理宿主)
pnpm run tauri dev

8.2 冒烟测试

验证三层链路是否连通:

pnpm run smoke

预期输出:

宿主就绪:http://127.0.0.1:64158
  通过  健康检查
  通过  无令牌返回 401
  通过  创建会话
  通过  会话列表包含新会话
  通过  模型列表可读
  通过  工具列表可读
  通过  未知路由返回 404

8.3 核对插件装配

单独验证插件的装载结果,不依赖 HTTP 服务:

pnpm run check:core

预期输出:

=== 空谷核心装配结果 ===
声明成员数:75
启用名单:  75
成功装载:  75
跳过:      0
耗时:      200 ms

有跳过项时逐条打印原因,并以非零退出码结束,可直接用作持续集成的判据。

8.4 单独运行内核

内核可脱离宿主独立验证:

cd dsh-sidecar
node lib/index.js

然后向标准输入逐行输入 JSON-RPC 请求:

{"jsonrpc":"2.0","id":1,"method":"kernel.handshake","params":{"protocolVersion":"1.0","client":"host","clientVersion":"2.1.2"}}
{"jsonrpc":"2.0","id":2,"method":"kernel.ping"}
{"jsonrpc":"2.0","id":3,"method":"session.create","params":{"title":"测试"}}

8.5 新增一个插件

tools/prepare-plugins.mjs 负责为插件生成统一的 package.json 与 tsconfig.json。该脚本是幂等的,反复运行结果相同:

pnpm run prepare:plugins                 # 处理全部插件
pnpm run prepare:plugins -- ondra-clock  # 只处理指定插件

手工新增插件的完整步骤:

  1. 在 cordis-host/plugins/ 下建目录,放入 src/index.ts,导出 name 与 apply。
  2. 运行 pnpm run prepare:plugins -- <插件名> 生成构建配置。
  3. 在 cordis-host/plugins/ondra-core/src/index.ts 里加一行 import 和一个成员条目,并把 MIGRATED_PLUGIN_COUNT 加一。
  4. 运行 pnpm install,让 pnpm 建立新包的链接与依赖。
  5. 运行 pnpm run check:core,确认装载数增加且无跳过。

第 3 步不能省。ondra-core 把全部插件声明为 dependencies,漏掉的话 TypeScript 会直接报「找不到模块」,不会等到运行时才发现。


九、配置说明

设置项分三层:环境变量控制进程如何启动,工作区配置控制依赖如何解析,插件的配置声明控制每个功能如何工作。第三层对应 619 个字段,已在第三章逐个列出。

9.1 环境变量

变量 作用 默认值
ONDRA_HOST_ENTRY 宿主入口脚本绝对路径 cordis-host/lib/index.js
ONDRA_NODE Node 可执行文件路径 自动探测
ONDRA_SIDECAR_ENTRY 内核入口脚本绝对路径 dsh-sidecar/lib/index.js
CODEBUDDY_SAFE_DELETE_ENABLED 构建期安全删除垫片开关 未设置(即启用)

9.2 工作区配置

pnpm-workspace.yaml 中的关键设置:

  • packages:src、cordis-host、cordis-host/plugins/*(含 78 个包)、dsh-sidecar 为工作区成员。src-tauri 由 Cargo 管理,不进 pnpm 工作区。
  • overrides:@ntars/cordis 别名指向官方 cordis@4.0.0-rc.12。旧插件一律 peerDepend 到私有 scope,该 scope 未发布到公共 npm,而官方包是其唯一上游实现。

9.3 依赖装在哪里

pnpm 生成的 node_modules 分两层,理解这一点能解释「为什么依赖看起来在项目里,实际并不占项目空间」:

层 位置 内容 是否占项目体积
内容寻址存储 ~/Library/pnpm/store/ 全部包的真实文件,全局共享 否
虚拟存储 <项目>/node_modules/.pnpm/ 指向上述存储的符号链接 否

真实文件在系统级 store 里,项目内只有链接。当前仓库的 node_modules 目录 du 报 89 MB,主要是 @tauri-apps/cli 的 macOS 原生二进制(单个文件约 14 MB),它是开发期工具,不进最终安装包。

实测数据:node_modules 下 6131 个文件的硬链接数全部为 1,逻辑大小与磁盘占用均为 0 字节,说明文件内容与 store 共享、项目内不重复占用。

不能把 node_modules 完全移出项目的原因:Node 的模块解析从脚本所在目录逐级向上查找 node_modules,这是运行时约定,不是 pnpm 的选择。移动它需要 NODE_PATH 或 loader 钩子,会让 IDE 跳转、调试器断点和 tsc 的模块解析同时失真。

产物目录同样不入库:node_modules/、dist/、target/、.site-dist/、src-tauri/target/、releases/ 全部在 .gitignore 中。

9.4 壳的配置

src-tauri/tauri.conf.json 中的关键项:

  • app.windows:主窗口 main 的 width/height 与 minWidth/minHeight 统一为 820/580。除 Windows 安装界面外,所有界面都是这个尺寸,且它就是最小尺寸。
  • 启动强制尺寸:src-tauri/src/main.rs 的 setup 里对 main 窗口再次调用 set_size(820, 580) 与 set_min_size(820, 580),兜底「无论上次被拖成多大,每次启动都回到该尺寸」。
  • build.frontendDist:../dist,与 Vite 的 outDir 一致。
  • app.security.csp:default-src 'self',connect-src 仅放行 127.0.0.1 的 HTTP 与 WebSocket。
  • bundle.resources:把 cordis-host/lib 与 dsh-sidecar/lib 打进安装包。
  • tauri.windows.conf.json(Windows 平台覆盖):Tauri 会按平台读一份同名配置并与主配置做 JSON Merge Patch 合并。这里只有一件事:app.windows[0].decorations = false。原因见下一段。注意数组是整体替换,所以这份文件必须把 label/width/height/minWidth/minHeight/backgroundColor 全部重写一遍,漏一个就回退成 Tauri 默认值。

为什么 Windows 必须关掉系统装饰:titleBarStyle: "Overlay" 是 macOS 专属字段,Windows 读它等于没写,系统照旧画一条原生标题栏;而 TitleBar.tsx 在 Windows 上又渲染了一套自绘三键(WindowControls),于是窗口顶部出现两排按钮。关掉 decorations 之后自绘才真正接管:无边框窗口在 tao 里仍保留 WS_OVERLAPPEDWINDOW 的其余样式位(最大化不会盖住任务栏),边缘拖拽缩放由 WM_NCHITTEST 单独处理,整条标题栏的拖拽靠 data-tauri-drag-region。

配套的布局约束:.titlebar__controls 是 position: absolute(不占文档流),所以版本号必须自己让出宽度——layout.css 里 :root[data-platform='win32'] .titlebar__version { margin-right: 146px }(138 三键 + 8 呼吸)。少了这一条,版本号会被 × 直接压住。

  • Windows 安装界面尺寸(410×290):macOS 无需安装界面(.dmg 由系统提供)。Windows 的 NSIS 安装向导默认窗口尺寸无法在 tauri.conf.json 里直接设成任意像素;要精确做到 410×290,需提供自定义 NSIS 模板并通过 bundle.windows.nsis.template 指向它。做法:先在 Windows 上跑一次 tauri build,从生成的 target/.../bundle/nsis/*.nsi 取出 Tauri 生成的完整脚本,按 MUI 对话框尺寸改造为 410×290,再回填到配置。当前保持默认 NSIS 以保证 Windows 打包可用,该精确尺寸待 Windows 侧构建验证。

9.5 能力权限

src-tauri/capabilities/default.json 只开放主窗口的最小必要权限:核心默认能力、打开外部链接(限 http 与 https)。前端不直接持有任何本机能力,选文件、开链接一律经宿主中转。

十、API 与模块说明

10.1 本地 HTTP 接口

所有请求需带 Authorization: Bearer <token>。未授权的请求在解析请求体之前即被拒绝。

宿主共注册 46 条路由(28 个 GET、18 个 POST),下表只列最常用的几条。完整清单在 cordis-host/index.ts 的 routes.set() 调用处——路由表是显式的,没有通配兜底,因此拼错的路由在编译期就能被发现。

方法 路径 说明 响应
GET /api/health 健康检查 { ok, at }
GET /api/sessions 会话列表 SessionMeta[]
POST /api/sessions 创建会话 SessionMeta,状态 201
GET /api/sessions/:id 会话详情 SessionMeta
GET /api/models 可用模型 ModelDescriptor[]
GET /api/tools 已注册工具 ToolDescriptor[]
POST /api/proactive/check 判一次「此刻能不能主动开口」(九道闸归宿主) ProactiveVerdict
POST /api/proactive/decline 用户点了「这次不用」,宿主记一次拒绝 { ok }

10.2 内核协议方法

内核的 18 个方法通过 JSON-RPC 2.0 调用,按 NDJSON 分帧。错误码沿用 JSON-RPC 2.0 规范定义的 -32700 至 -32603,本项目另在 -32000 至 -32004 定义内核专属码。

域 方法 参数 说明
生命周期 kernel.handshake protocolVersion、client、clientVersion 版本协商,返回能力清单
kernel.ping 无 存活探测
kernel.shutdown 无 优雅关闭
会话 session.create title 创建会话
session.get id 读取会话元信息
session.list includeArchived 列出会话
session.archive id 归档,幂等
推理 inference.run sessionId、input(可省)、nudge(可省) 发起一轮对话,流式推送增量。input 省略即「她主动开口」,nudge 是不落盘的临时系统提示
inference.abort sessionId 中断当前轮次,幂等
工具 tool.list 无 列出已注册工具
tool.invoke name、arguments 调用单个工具,执行前校验参数
模型 model.list 无 列出可用模型
model.select id 切换当前模型

10.3 模块职责

路径 职责
dsh-sidecar/src/protocol.ts 协议唯一真源:方法名、事件名、错误码、类型守卫
dsh-sidecar/src/rpc/transport.ts NDJSON 分帧,按行切割消息
dsh-sidecar/src/rpc/dispatcher.ts 请求分发、错误翻译、事件推送
dsh-sidecar/src/kernel/types.ts 四类职责的公共类型
dsh-sidecar/src/kernel/sessions.ts 会话存储
dsh-sidecar/src/kernel/models.ts 模型适配器
dsh-sidecar/src/kernel/tools.ts 工具注册与参数校验
dsh-sidecar/src/kernel/index.ts 装配四类职责到协议方法
cordis-host/index.ts 宿主入口:装配插件、启动服务、报告就绪
cordis-host/check-core.mjs 核心装配核对,逐项验证成员装载
cordis-host/src/server.ts 本地 HTTP 服务与令牌鉴权
cordis-host/plugins/ondra-core/src/index.ts 空谷核心成员清单(75 行 import 与成员数组)
cordis-host/plugins/ondra-core/src/types.ts 装配契约:CoreMember、CoreLoadResult
cordis-host/plugins/dsh-client/ 内核子进程生命周期与请求配对
cordis-host/plugins/desktop-bridge/ 桌面能力桥
cordis-host/plugins/ondra-*/ 78 个包(75 个可装载插件 + 清单 + 2 个桥接层),各自独立成包
tools/prepare-plugins.mjs 为迁入插件生成统一的 package.json 与 tsconfig.json
tools/build-site.mjs 官网静态文件收集与 macOS 元数据剔除
tools/version.mjs 版本号七处同步;--minor 时提交版本文件、打 tag 并推送
tools/smoke.mjs 三层链路冒烟测试
tools/check-ui.mjs UI 规范断言:尺寸、层级、令牌定义、关闭框与错误分级
tools/check-installer.mjs Windows 安装脚本语法检查:渲染占位符后用 makensis 真编译一遍
tools/build-tray-icons.mjs 从 icon.svg 渲染托盘剪影三档(16 / 32 / 64),无底板、带 alpha
src-tauri/src/main.rs 壳入口:窗口、事件、退出清理
src-tauri/src/host.rs 宿主进程管理:启动、等待、停止
src-tauri/src/commands.rs 本机能力命令
src-tauri/src/tray.rs 托盘图标与菜单:打开、今天、静音(文案随状态在「静音 1 小时」与「取消静音」间切换)、退出
src-tauri/src/window.rs 关闭拦截:隐藏到托盘 / 退出,偏好落盘,无托盘时降级最小化
src-tauri/src/browser.rs 内置只读浏览器:官网、仓库、更新日志、许可在窗内打开,不出应用
src-tauri/installer/installer.nsi Windows 安装器脚本:三页流程、410 × 290、DPI 自适应
src/lib/host.ts 前端与宿主通信的唯一入口
src/lib/shell.ts 前端与壳通信的薄封装
src/lib/api.ts 业务接口封装

10.4 插件的配置声明约定

每个插件在自己的 src/settings.ts 里声明配置,声明是唯一真源,界面与文档都从这里取值。字段形态为一个对象,键是配置键,值包含以下成员:

成员 类型 作用
kind boolean / number / string / json / enum 数据类型,决定界面用哪种控件
default 与 kind 对应 出厂值,界面「恢复默认」取这个值
values 或 options 字符串数组 枚举候选值,两种写法等价,同一个字段只用其中一种
min / max 数值 数值型字段的上下限,界面据此限制滑杆范围
advancedOnly 布尔 是否折叠进「进阶」层
secret 布尔 是否为敏感值,界面内以掩码显示
doc 字符串 面向使用者的说明,直接渲染在字段旁

doc 与代码同源,因此不会出现文档与实际行为不一致的情况。本项目 619 个字段全部带有 doc,平均 36 字。


十一、目录结构

Ondra/
├── README.md                     中文主文档
├── README.en.md                  英文完整版
├── NOTICE                        第三方依赖与许可声明
├── docs/
│   ├── PLUGINS.md                78 个包的字段明细(中文)
│   ├── PLUGINS_EN.md             78 个包的字段明细(英文)
│   ├── TEST-REPORT-1.2.0.md      结构化测试报告
│   └── releases/                 各版本变更正文源文件(发布流程读取)
├── LICENSE                       AGPL-3.0 全文
├── package.json                  仓库元信息与脚本
├── pnpm-workspace.yaml           工作区成员与依赖覆盖
├── tsconfig.base.json            共享 TypeScript 配置
├── edgeone.json                  EdgeOne 部署配置
├── index.html                    官网首页
├── icon.svg                      应用图标唯一真源
├── favicon.ico                   站点图标
├── assets/                       品牌图像资产
├── .github/workflows/            CI:release.yml 双平台构建并发布发行版
├── tools/
│   ├── build-site.mjs            官网静态文件收集
│   ├── prepare-plugins.mjs       为迁入插件生成统一的构建配置
│   ├── collect-runtime.mjs       收集运行时文件
│   ├── bundle-release.mjs        把安装包搬运到 releases/
│   ├── version.mjs               版本号七处同步,--minor 时提交并打 tag
│   └── smoke.mjs                 三层链路冒烟测试
├── src/                          前端 UI(React 19 + Vite 8)
│   ├── index.html                单份入口,只有一棵树
│   ├── main.tsx                  装好全局前置后挂载 App,无路由
│   ├── App.tsx                   主界面根组件:装配界面区块与全局快捷键
│   ├── components/               界面组件
│   │   ├── TitleBar.tsx          自绘标题栏:人设头像与切换浮层、三档在线状态、窗口按钮
│   │   ├── Avatar.tsx            头像:默认画 icon.svg 同一份几何,自定义图裁成 96 见方
│   │   ├── TopBar.tsx            手机顶栏:汉堡开抽屉、标题、心情状态点、2px 水位线
│   │   ├── Rail.tsx              最左侧图标栏:会话 / 日志 / 扩展三个入口与底部齿轮
│   │   ├── Spine.tsx             右侧脊:一条 6 × 150 水位加三个读数,高度是精力、颜色是心情
│   │   ├── Inspector.tsx         侧写检查器:此刻 → 关系阶段 → 记得的事 → 当前活动
│   │   ├── Drawer.tsx            抽屉容器:会话 / 日志 / 扩展三种内容共用,原地替换
│   │   ├── SessionStats.tsx      会话抽屉:轮数 / 总字数 / 开始于 / 已归档四读数与「清空全部」
│   │   ├── LogDrawer.tsx         日志抽屉:会话日志 / 通道日志 / 系统日志三段
│   │   ├── ExtensionDrawer.tsx   扩展抽屉:三段式包清单(桥接 / 内核 / 能力包)与配置入口
│   │   ├── Timeline.tsx          对话阅读流:消息、引用、复制
│   │   ├── RichText.tsx          正文排版渲染:标题、列表、表格、代码、引用,全程不碰 innerHTML
│   │   ├── Composer.tsx          输入框:附件、拖放、发送方式
│   │   ├── Settings.tsx          设置面板壳:左导航 + 右内容,一个入口一个面板
│   │   ├── SettingsGeneral.tsx   通用页:主题 / 字号 / 语言 / 发送方式 / 系统通知 / 默认权限 / 音效 / 语音输入
│   │   ├── SettingsProviders.tsx 模型与 API 页:官方预设说明与自定义 OpenAI 兼容端点
│   │   ├── SettingsPersona.tsx   人物设定页:朗读 / 口吻 / 节奏 / 规矩 / 知识库
│   │   ├── SettingsData.tsx      备份与还原页:会话导出与导入
│   │   ├── SettingsBackup.tsx    备份与还原页:整库快照与回滚
│   │   ├── SettingsPlugins.tsx   扩展功能页:场面 / 模块两层插件参数(原「原始」层已删)
│   │   ├── SceneLayer.tsx        第一层:按场面填参数,预演写入
│   │   ├── ModuleLayer.tsx       第二层:按模块分组的字段表单
│   │   ├── SettingsAbout.tsx     关于页:版本 / 更新
│   │   ├── SettingsPrivacy.tsx   关于页:数据存放 / 联网范围 / 离线能力
│   │   ├── SettingsPermissions.tsx 关于页:应用权限
│   │   ├── settingsUi.tsx        设置表单原语:单选卡片 / 开关 / 行 / 说明
│   │   ├── Dialog.tsx            确认对话框,焦点收束与还原
│   │   ├── Toast.tsx             提示条:成功 / 警告 / 错误三级
│   │   ├── ScrollArea.tsx        自绘滚动容器,统一 overlay 滚动条
│   │   ├── Icon.tsx              图标唯一出口(morphicons),组件只认名字
│   │   ├── icons.ts              命名图标的重导出,数据可摇树,渲染仍归 Icon.tsx
│   │   ├── SectionFields.tsx     卡片下方原地展开整段字段,不再折叠
│   │   ├── SettingsKnowledge.tsx 人物设定·知识库页:世界书条目管理
│   │   ├── MemoryHealth.tsx      记忆健康:待补、将忘、可修三档读数
│   │   ├── DreamBlock.tsx        梦境整理:昨夜合并与遗忘的摘要块
│   │   ├── SnapshotBlock.tsx     快照块:整库备份的时间线与回滚入口
│   │   ├── QueueDock.tsx         队列坞:待发消息的排队与取消
│   │   ├── FilterCard.tsx        过滤链卡片:九步删改的开关与预览
│   │   ├── OralCard.tsx          口语化卡片:口头禅池与标点习惯
│   │   ├── RhythmCard.tsx        节奏卡片:分条数、字数上限、停顿区间
│   │   ├── VoiceCard.tsx         声音卡片:音色、语速、音量
│   │   ├── ReasonLine.tsx        主动开口的理由行:九道闸哪一道拦下的
│   │   └── Mark.tsx              品牌标记组件,图形取自 brand/mark.ts
│   ├── lib/
│   │   ├── api.ts                业务接口与共享类型
│   │   ├── host.ts               与宿主通信的唯一入口
│   │   ├── shell.ts              与壳通信的薄封装
│   │   ├── useAppState.ts        主界面状态
│   │   ├── persona.ts            人设常量与三档在线状态词
│   │   ├── usePersona.ts         人设单例:清单、当前项、热切换、在线判据
│   │   ├── preferences.ts        偏好真源:读盘、应用、跨窗口广播
│   │   ├── mute.ts               一键静音:截止时刻、到期自愈、托盘文案同步
│   │   ├── sound.ts              提示音:三种音现场合成、音量独立、受静音管
│   │   ├── absence.ts            离开时长:10 分钟阈值、人话换算、归来留一行
│   │   ├── paste.ts              粘贴:图片即附件、缩略图 48 见方、字数上限单一真源
│   │   ├── richText.ts           正文解析:Markdown 转结构体,链接白名单、递归限深
│   │   ├── settings.ts           设置读写
│   │   ├── settingTypes.ts       设置字段与分段的类型
│   │   ├── scenes.ts             场面定义与写入推导
│   │   ├── shortcuts.ts          快捷键表与按键上下文判断
│   │   ├── platform.ts           平台判断唯一真源:写 data-platform / data-input,含 linux 分支
│   │   ├── types.ts              记忆、羁绊、心情等业务类型
│   │   ├── moodColor.ts          水位色 / 心情词 / 精力比例:顶栏与右脊共用的单一真源
│   │   ├── viewport.ts           软键盘遮挡高度写进 --kb-h(读 visualViewport,不用 innerHeight)
│   │   ├── wakelock.ts           生成中保持屏幕常亮,结束即释放,回前台补申请
│   │   ├── useRestoreFocus.ts    焦点还原:关闭浮层后回到触发元素
│   │   └── useResolvedTheme.ts   把主题模式解析成 light / dark
│   ├── styles/                   全站样式
│   │   ├── tokens.css            设计令牌唯一真源(189 个)
│   │   ├── global.css            样式顺序清单,真正的 import 在 main.tsx
│   │   ├── base.css              全局基础样式:重置与不可见规则
│   │   ├── compact.css           移动端与触屏:顶栏、单列骨架、热区 44、字号分档、安全区
│   │   └── *.css                 每个组件一份,与组件同名
│   ├── assets/fonts/             界面字体 MiSans VF(含许可证)
│   ├── brand/mark.ts             品牌标记图形数据,由 icon.svg 自动生成
│   ├── package.json
│   ├── tsconfig.json
│   └── vite.config.ts
├── cordis-host/                  Cordis 宿主(Node 22 + TypeScript)
│   ├── index.ts                  启动入口
│   ├── check-core.mjs            核心装配核对
│   ├── src/
│   │   └── server.ts             本地 HTTP 服务
│   └── plugins/
│       ├── ondra-core/           空谷核心装配(成员清单在这里)
│       ├── desktop-bridge/       桥接壳能力
│       ├── dsh-client/           封装 JSON-RPC 通信
│       └── ondra-*/              78 个包:75 个成员 + ondra-core 与两个桥接层
│           ├── src/              源码(含 settings.ts 配置声明)
│           ├── lib/              编译产物
│           ├── locale/           界面文案(部分插件有)
│           ├── icon.svg          插件图标
│           └── cordis.patch.yml  旧装配声明(保留作对照,不再被读取)
├── dsh-sidecar/                  dsh 内核(Node 22 + TypeScript)
│   ├── src/
│   │   ├── index.ts              进程入口
│   │   ├── protocol.ts           协议唯一真源
│   │   ├── rpc/
│   │   │   ├── transport.ts      NDJSON 分帧
│   │   │   └── dispatcher.ts     请求分发
│   │   └── kernel/
│   │       ├── index.ts          装配
│   │       ├── types.ts          公共类型
│   │       ├── sessions.ts       会话存储
│   │       ├── models.ts         模型适配
│   │       └── tools.ts          工具注册
│   ├── package.json
│   └── tsconfig.json
└── src-tauri/                    Tauri 壳(Rust 2024)
    ├── Cargo.toml                Rust 依赖
    ├── Cargo.lock                Rust 依赖锁定
    ├── build.rs                  构建脚本
    ├── tauri.conf.json           应用配置
    ├── tauri.windows.conf.json   Windows 平台覆盖(关系统装饰)
    ├── capabilities/
    │   └── default.json          两个窗口的默认能力集
    ├── icons/                    应用图标各尺寸
    ├── installer/
    │   └── installer.nsi         Windows 安装器脚本
    └── src/
        ├── main.rs               入口:窗口、事件、退出清理
        ├── host.rs               宿主进程管理:启动、等待、停止
        ├── commands.rs           本机能力命令
        ├── browser.rs            内置只读浏览器:官网、许可、更新日志在窗内打开
        ├── tray.rs               托盘图标与菜单
        └── window.rs             关闭拦截:隐藏到托盘 / 退出

十二、性能表现

以下数据为 Apple Silicon macOS、Node 22.22.2 环境实测。

指标 数值 测量方式
前端产物 JS 365.86 KB(gzip 118.80 KB) vite build 输出,两份 chunk 合计
前端产物 CSS 70.74 KB(gzip 11.49 KB) vite build 输出
前端构建耗时 约 2.1 秒 vite build 输出的 built in
插件装载(75 项) 75/75,0 跳过,约 200 毫秒 pnpm run check:core
内核冷启动到握手可用 小于 500 毫秒 冒烟测试观察
宿主启动到上报就绪 小于 2 秒 冒烟测试观察
冒烟测试全流程 小于 5 秒 含 7 项接口验证与两次进程退出

CSS 从 6 KB 涨到 71 KB 是界面重写的必然代价,不是泄漏。 旧界面是十几个组件共用一份样式;新界面按 §5 拆成 20 个组件样式文件,还带上了完整的设计令牌层(189 个令牌)、移动端与触屏批(compact.css 459 行)、八页偏好设置与打印排版。gzip 后 11.49 KB,比旧版的原始大小还小——这条曲线只说明源文件变多,不说明传输变重。

JS 365 KB 里有 12.65 KB 是懒加载的 region chunk,不进入首屏关键路径。首屏那个 chunk 是 361.98 KB(gzip 117.09 KB)。

装载耗时为何是串行累加而非并行。 75 个插件的装载是逐个 await 的,不做并发。并发装载会让依赖方在被依赖方还没 provide 服务时就开始初始化,出现「服务不存在」的偶发失败。实测约 200 毫秒的代价换掉一整类难以复现的竞态,这个交换是划算的。

上下文消耗的两项实测约束。 工具定义与原始工具输出是上下文消耗的前两大项。ondra-organ-gate 按意图决定本轮暴露哪些工具,ondra-browser 把页面快照按 4000 字符与 80 行双重预算截断。这两处不在本机运行时体现为耗时,而体现为模型调用的 token 成本与响应质量。


十三、安全与隐私

13.1 网络边界

  • 宿主 HTTP 服务只监听 127.0.0.1,不监听 0.0.0.0,本机之外无法访问。
  • 内核不监听任何端口,只通过管道与宿主通信,本机其他进程无法连接。
  • 界面与宿主之间的 connect-src 由 CSP 限制为 127.0.0.1。
  • ondra-browser 拒绝 127.0.0.1、localhost、*.local 等私有目标,阻断服务端请求伪造的入门形态。

13.2 鉴权

  • 令牌在宿主启动时以 32 字节随机数生成,经壳传入页面,不落盘。
  • 每个请求校验 Authorization: Bearer <token>,未授权请求在解析请求体之前即被拒绝。
  • 响应头统一带 cache-control: no-store,令牌与会话数据不进入任何缓存。

13.3 能力权限

  • 前端不直接拥有本机能力。选文件、开链接一律经宿主中转。
  • 外部链接只允许 http: 与 https:,file: 与自定义协议被拒绝。
  • Tauri 能力权限按窗口配置,只开放核心默认能力与受限的外部链接打开。
  • ondra-organ-host 的能力白名单 default-deny:调用未声明的能力抛 CapabilityDeniedError,不静默忽略。

13.4 数据

  • 无遥测、无崩溃上报、无使用统计。项目内不存在任何指向第三方分析服务的代码。
  • 数据目录在创建时即收紧权限:目录 0700,文件 0600。
  • 没有账号体系,数据不跨设备同步,备份由使用者手动完成。
  • 没有社区、没有广场、没有分享按钮,对话只属于使用者与本机。
  • 存储层失败时降级到内存并明确声明「不持久化」,不静默丢弃。

13.5 高风险能力的前置约束

  • 图形界面操作、电脑只读、手机只读、adb 短信、浏览器护栏、Minecraft、视觉七项能力出厂关闭,需手动开启。
  • 出站闸门做提醒与告知,不做代理。让它代理就必须让数据流经该插件,凭据值会进入插件内存,反而制造新的风险点。

十四、兼容性说明

平台 状态 说明
① macOS 11+(Apple Silicon) 已发布 完整功能,构建与打包均在此平台验证
① macOS 11+(Intel) 支持 完整功能
② Windows 10+ 已发布 安装包可构建;文件选择对话框尚未实现
③ Linux(deb/rpm/AppImage) 未验证 代码同源,需补系统集成配置
④ iPad(iPadOS 15+) 未开始 需新增 iOS 构建
⑤ iPhone(iOS 15+) 未开始 需新增 iOS 构建
⑥ Android 8+(arm64) 已发布 单进程形态,不跑 Node;75 个插件打包进 WebView,见下
⑦ 鸿蒙 NEXT 未开始 无现成方案,需先确定 ArkTS 侧的宿主通信路径
⑧ 命令行与 Web 部分可用 有壳时可远程连接宿主;需补一项能力标记
⑨ dsh 内核 已存在 内核进程端,独立可替换,无界面形态

安卓端为什么是另一种形态。宿主是 Node 进程(约 108 MB),由壳以子进程拉起,通过 stdio 与本地 HTTP 通信。iOS 沙盒禁止应用创建子进程,也禁止 JIT;Android 虽允许子进程,但把 108 MB 运行时随包分发与「安装即用」的预期冲突。

所以安卓端走单进程形态:宿主逻辑搬进 WebView,用 IndexedDB 替代 SQLite,推理直连用户填的接口。src/lib/local/ 下承担这件事。

界面零改动:request() 与 requestStream() 是全部网络出口,在那里加一层分发即可。26 个端点走本地分发,其余返回空结构而不是 404——后者会让「扩展功能」页显示「读不到插件」,看起来像坏了。

安卓端真的装载了 75 个插件

早期版本在 src/lib/local/ 里手工复刻了情绪与记忆的纯函数(emotionPad.ts、memoryStability.ts 等,逐字从插件源码抄来)。那套做法的问题是第二份真源:能抄的只有无状态纯函数,记忆链、人设链、提示词装配那些有状态的插件根本抄不过来。

现在改成把插件本体打包进前端:

  • cordis 是纯 ESM、零 Node 依赖,在 WebView 里能直接跑
  • 30 个插件 import 了 node:*,由 src/lib/browser/ 下的替身顶上:fs(内存镜像 + IndexedDB 落盘)、path、os、crypto(自带 SHA-256 / MD5)、zlib(自带 inflate)、url、module、process
  • node:sqlite 的替身故意加载失败,让 ondra-store-sqlite 走它自己的降级链(ondra-store-json → ondra-store-memory),那条路径插件从设计之初就写好了
  • vite.config.ts 里用正则别名把 node:* 与 75 个插件包接到源码位置,只在 ONDRA_LOCAL=1 时生效

实测 75/76 个插件在安卓 WebView 里装载成功(pnpm check:mobile-plugins 守着)。唯一装不上的需要 node:net 起端口探测,在 WebView 里没有对应物。

包体切分:插件被打成独立的 plugins-*.js(1.47 MB / gzip 472 KB),只在手机端构建时产出。桌面端走真宿主,那个 chunk 永远不会被请求,桌面前端仍是 492 KB。

复刻版仍然保留,但降级为兜底:插件取不到服务时才走它。两份的一致性由 pnpm check:local-parity 逐行比对(130 + 249 行),改一边忘了改另一边就红。

检索走 2-gram 倒排:没有 FTS5 时的既定分支,插件日志里明写「本库不支持 FTS5 时必须走 2-gram 倒排分支,不得启用 BM25 分支」。

代价要说清楚:手机上没有 FTS5 的 BM25 排序,记忆检索精度略低于桌面;没有子进程,ondra-process-host 与 ondra-port-picker 装不上;ondra-tts / ondra-vision 依赖本机可执行文件,在手机上走「不可用」分支。这些是形态决定的取舍,不是待修缺陷。

为什么 iOS 仍然不做。iOS 连子进程都没有,也没有可行的 WebView 内 Node 替代方案;WebView 的 JIT 限制让纯前端推理层性能不可接受。这条路要等一个能在 iOS 上跑的计算层,目前没有。

浏览器开发态(pnpm run dev:web)下没有壳,本机能力调用会明确报错而非静默失败。

Node 版本。宿主与内核依赖 Node 22 的特性,package.json 的 engines 字段声明为 >=22。使用 Node 20 及以下会在启动时失败。

格式兼容。人设包格式为 .skill.zip,内含 meta.json 与 SKILL.md。该格式与本项目 1.x 版本兼容,旧人设包可直接使用。


十五、已知问题与限制

  1. 模型适配器仅覆盖 OpenAI 兼容协议。内核的 ChatAdapter 只实现 /chat/completions 这一套接口,供应商差异全部表达在配置里(baseUrl + model + apiKey)。出厂不预置任何供应商,首次启动只有 local/echo 离线回显模型——它不联网、不产生智能,但会把「会话落盘 → 流式推送 → 界面逐字显示」整条链路走一遍,让「装好了、链路是通的」这件事可被直接看到。真实模型需在「设置 → 模型与 API」里逐个添加。早期版本曾预置一条指向 http://127.0.0.1:3425/v1 的本地网关,那在开发机上可用、在用户机器上不存在,导致第一句话必然 fetch failed;现已移除,并带一次性迁移清掉旧配置里的残留项。
  2. 工具注册表为空。骨架未注册任何可被模型调用的工具。注意这与 75 个插件的装载是两件事:插件提供的是应用能力(记忆、情绪、表达、装配),tool.* 指的是可被模型调用的工具,后者尚未接入。
  3. 壳命令通道尚未完全接通。desktop-bridge 的 invoke 在宿主侧尚未连接到 Tauri 的自定义命令端点,调用会返回明确错误。
  4. Windows 文件选择未实现。pick_files 在非 macOS 平台返回「尚未实现」。
  5. 托盘图标与菜单已接入,但未在 Windows / Linux 真机验证。托盘由 src-tauri/src/tray.rs 创建,三档图标(16 / 32 / 64)由 tools/build-tray-icons.mjs 从 icon.svg 渲染,macOS 上按模板图染色。macOS 侧已实测:菜单四项齐全、单击唤回窗口、关闭拦截与「隐藏到托盘」生效。Windows 与 Linux 只做过代码复核。
  6. 单窗口形态。主窗口关闭即隐藏到托盘,不退出。设置是主窗口上的一张浮层(不是第二个窗口),关闭浮层即回到会话。
  7. 无自动更新。更新流程尚未实现。
  8. 插件配置已接入界面,但只覆盖三层视图。设置面板分「场景 / 模块 / 原始」三层呈现插件的 619 个字段;扩展抽屉把 78 个包按职责收成三段(桌面桥接 / 内核客户端 / 能力包),不提供逐包开关——三段都是核心组件,缺一段应用就跑不起来,开关能力只在宿主侧保留作诊断。未被任何一级菜单归类的边角字段只能在「原始」层按 JSON 直接改。
  9. 两项严格类型检查对迁入插件不生效。exactOptionalPropertyTypes 与 noUncheckedIndexedAccess 只在 cordis-host、dsh-sidecar、src 与新写的三个插件上启用。75 个迁入插件关闭了这两项,理由与代价见 4.1 与 5.8。
  10. Windows 安装界面精确 410×290 待真机验证。NSIS 默认窗口尺寸无法在配置里直接设成任意像素,自定义模板已在 src-tauri/installer/installer.nsi 实现:guiInit 里用 SetWindowPos 设框,GetDpiForWindow 按 96 DPI 基准等比换算(旧系统回退 96),check:acceptance 有静态断言守着这两个数字。缺的只是在 Windows 上真装一次,见 15.1 未验证清单。
  11. 语音输入依赖系统能力。语音走 Web Speech API,在多数桌面 WebView 中可能不可用,此时按钮给出「当前环境不支持语音输入」提示。
  12. 安装包自带运行时,但 Windows 上未做真机启动验证。build:runtime 会把 Node 二进制、宿主与内核的 node_modules 一并收进 src-tauri/runtime/,再由 bundle.resources 打进安装包;commands.rs 按平台找 Resources/node/bin/node(Windows 为同级的 node/bin/node.exe)。macOS 已实测可双击运行;Windows 的路径查找与启动只在代码层核对过,缺一次真机确认。
  13. dmg 由 hdiutil 手动生成。Tauri 2.12.4 自带的 bundle_dmg.sh 在较新 macOS 上因 grep 正则未转义方括号而解析 hdiutil 输出失败,dmg 创建中断。.app 不受影响;release 流程改为在 Tauri 打包出 .app 后用 hdiutil create -srcfolder 直接封装 dmg,绕开该脚本缺陷。
  14. 安装包未签名、未公证。macOS 是链接器 ad-hoc 签名(codesign -dv 报 flags=0x20002(adhoc,linker-signed),TeamIdentifier 未设置),Windows 没有代码签名证书,Android 是 debug 签名(keystore 走 secrets 注入, apksigner 校验通过但不构成正式分发签名)。首次打开会被系统拦下, 绕行办法写在 release 正文里。签名与公证属于正式版阶段的工作, 不在内测版的取舍范围内。
  15. 打进包里的 Node 运行时取自构建机,不是一个钉死的版本。build:runtime 直接复制 process.execPath,所以云端构建的包带 Node 24.9.0、本机构建的 带 22.22.2,两者都满足 engines: { node: ">=22" }。这意味同一个版本号 在两处构建出的包,运行时未必一致。要彻底消除需要在收集运行时那一步改成 下载固定版本,目前没做。
  16. 念想型人格未开放。第二章 2.5 节说明的三条前置条件(授权、范围声明、数据最小化)当前没有任何代码实现,因此该人格形态不对外开放。这是产品级约束,不是功能待办。

以上限制均为当前阶段的明确取舍,不代表最终形态。

15.1 未验证清单(在 macOS 上做不到的验证)

下面这些项目代码已完成、逻辑可复核,但没有在目标平台上真跑过。单独列出来,是因为「跑过」和「写得对」是两件事,混在「已完成」里会误导后续接手的人。

项目 已完成的部分 缺什么验证 风险
Windows 安装器(NSIS,410×290,3 页) 模板、页面序列、变量声明、DPI 计算 未在 Windows 上真装一次 快捷方式落点、卸载残留、高 DPI 居中
Windows 托盘图标 三档位(16 / 32 / 64)由脚本渲染并打包进 Resources/tray 未在 Windows 通知区看过 通知区尺寸与 DPI 处理可能与 macOS 菜单栏不同
cargo build(release) cargo check 通过 未跑完整 release 构建 链接期或打包期才暴露的问题
Linux 桌面集成 代码同源 未在任意发行版构建 deb/rpm 依赖、StatusNotifier 托盘缺失降级
125% / 150% 缩放下 0.5px 线 CSS 已声明降级到 1px 未在真机缩放下核过 线可能消失或变虚
安卓端插件协作机制 单进程本地实现已覆盖主线(记忆 / 情绪 / 装配 / 流式) 未在低于 Android 8 的设备上验过;未做多机型覆盖 老设备装不上是预期内的(只出 arm64)

判定标准很直接:上表任一项在对应平台上手动走一遍、结果符合本章开头那张兼容性表的「状态」列,就可以从这张表移走。


十六、更新日志

更新日志在发行版页面,本 README 不重复维护。

为什么放在发行版而不是 README:更新日志与某一次构建一一对应,下载入口、安装包大小、签名状态、已知问题都在同一处;README 描述的是项目当前状态,两者混写会让读者分不清「这是现在的样子」还是「那次改了什么」。因此 README 只讲现状,变更归发行版。

发布流程会自己带上变更正文。.github/workflows/release.yml 在生成发行版说明时,先找 docs/releases/<tag>.md;有就整段附上,没有就退回 git log 的提交标题并标注是降级输出。此前正文末尾写的是「完整变更见 README 的更新日志」,而 README 写的是「更新日志在发行版」——两边互相指,读者哪边都找不到实际变更。这条循环引用已经断掉。

版本号口径:内测线从 1.0.1-alpha 起算,末尾的 -alpha 是内测标记,不代表公测,也不代表正式版。当前为 1.2.1-alpha。


十七、后续路线图

按依赖关系排序,前一项是后一项的前提。

  1. 接通壳命令通道。让 desktop-bridge 的 invoke 真正到达 Tauri。这是文件选择、外部链接可用的前提。
  2. 流式对话界面。订阅 event.inference.delta,实现增量渲染与中断。
  3. 工具注册表。内置基础工具集(文件读写、终端、网络),并在执行前校验参数。
  4. 自动更新。签名校验、增量下载、失败回滚。
  5. 插件热装载。把扩展抽屉里「重启后生效」的开关变成真正的热插拔:现在拨一个开关只改下次启动的名单,因为运行中撤走一个已 provide 服务键的插件会留下悬空引用。
  6. 恢复两项严格类型检查。逐步修复迁入插件在 exactOptionalPropertyTypes 与 noUncheckedIndexedAccess 下的类型问题,最终全仓统一。
  7. 独立分发包。把 Node 二进制与 node_modules 一并打进安装包,摆脱对目标机 Node 环境的依赖。
  8. Linux 支持。适配系统集成部分。

十八、参与贡献方式

18.1 开发约定

  • 单位:样式一律用 px 或 rem,禁用 rpx。
  • 界面:遵循 Apple Human Interface Guidelines,曲线闭合、线条粗细一致、层级清晰。
  • 代码:全面模块化,按需加载;用最小、最直接的代码解决问题,不添加未被要求的功能。
  • 注释:说明「为什么这样做」而非「做了什么」;数据库等专业词汇用通俗语言解释。
  • 语言:说明、日志、错误信息一律使用简体中文;代码、命令、变量名、路径保留原文。
  • 插件:新增插件必须导出 name 与 apply(ctx, config),必须写好上游缺失时的降级路径,必须为每个配置项提供 doc。

18.2 提交前检查

pnpm run typecheck        # 全部工作区类型检查
pnpm run build            # 构建前端、宿主、内核
pnpm run smoke            # 三层链路冒烟测试
pnpm run check:core       # 插件装配核对
pnpm run check:gap        # 缺口链端到端(她说不知道 → 执念池 → 话由)
pnpm run check:gap-wake   # 执念唤醒资格(张力 40 的新执念不该压掉承诺)
pnpm run check:system-prompt  # 系统提示词装配(人格四段真的产得出)
pnpm run check:prompt-context # 记忆注入(插件登记的上下文真的进了提示词)
pnpm run check:kernel-system  # 内核 system 参数(真的送到供应商、且不落盘)
pnpm run check:host-persona   # 宿主端到端(HTTP 打进去,供应商收到人格正文)
pnpm run check:e2e            # 主动开口端到端(交代一件事 → 入库 → 门控给由头 → 模型收到)
pnpm run check:emotion      # 情绪读取链(写一个读数 → 六态 → 话由)
pnpm run check:feel         # 情绪写入链(一轮对话 → 信号 → 情绪内核 → 存档)
pnpm run check:diffuse      # 跨会话情绪弥散(换一场会话 → 心情按四成回落 → 写回并落盘)
pnpm run check:relation     # 关系域(一轮对话 → 互动计数 → 熟悉度 → 弥散真的收到)
pnpm run check:evolve       # 人格演化(一轮对话 → 互动计数 → 阈值真的漂到 1.0004)
pnpm run check:drift        # 阈值漂移生效(同一个读数在漂移前后判出不同的态)
pnpm run check:project      # 情感投影(六态 → 模板 → 系统提示词最后一段 → 过护栏)
pnpm run check:e2e-memory     # 记忆提炼端到端(说一句关于自己的话 → 提炼 → 事实入库)
pnpm run check:voice          # 声音链端到端(长回复 → 挑句 → 合成一次;用户层配置真的进插件;整轮情绪词结论真的传进 admit)
pnpm run check:plugins        # 扩展清单如实上报(宿主级常驻插件不能报成「未装载」)
pnpm run check:intake-switch  # 「收对话进记忆」关掉之后,库里真的一行都不收
pnpm run check:no-reason      # 没有由头时不编兜底句(正文留空 → 门控判 content-empty,连问两次也不许变 duplicate)
pnpm run check:order          # 存储顺序(取存储的插件必须排在提供方之后,备用后端不能永远取不到)
pnpm run check:filter         # 删句链保段落(分句再拼回必须逐字节等于原文;删掉一句后段落空行还在)
pnpm run check:logfmt         # 日志格式串(不许出现 %.2f 这类精度占位符——logger 不认,实参会掉到行尾)
pnpm run check:defaults       # 配置默认值(每个字段的 default 必须能通过它自己的 pattern,否则整段 schema 注册会抛错)
pnpm run check:config-used    # 配置字段有没有人读(声明了却零消费 = 假开关:设置页能改、日志照报、行为不变)
pnpm run check:stale          # 产物新鲜度(源码比 lib 新就判红:此刻跑的检查读的是旧代码)
pnpm run check:shape          # 句形档全覆盖(O-031 的六个 SentenceShape 必须都被 O-038 认得,缺一个就静默回落常句)
pnpm run check:disable        # 关掉就不干活(三插件 enable=false 之后五张表一张都不许建出来)
pnpm run check:dedupe-tally    # 判重计数五个栏位各对各的动作(灰区未写库 ≠ 复述:它才是「这条新事实没进去」)
pnpm run check:dedupe-digit    # 灰区不许吞掉新事实的取值(旧条零数字 + 新条有数字 → 必须新建,不许只加固)
pnpm run check:recall-archived # 召回侧沉底开关四个通道(配置真生效 + 请求单轮覆盖两个方向)
pnpm run check:ui         # UI 规范断言与令牌核对
pnpm run check:installer  # Windows 安装脚本语法(需要 makensis)

四十一项全部通过方可提交。check:stale 是这张清单里唯一一条不看行为、只看时间的检查,也是前面十几轮反复踩坑换来的:巡检、ondra.mjs、乃至「实机验证」读的都是产物,源码改了不重建,症状不是报错而是所有检查照旧全绿——跑的是几小时前的 lib,ondra.mjs config 里新加的配置段一个都不出现,debug.log 还是旧格式。它逐包比 src/**/*.ts 与 lib/**/*.js 的最新时间,另外单独比对打包应用里的宿主入口(落后只警告,因为出包要两分钟,不该让每小时巡检都因为「没出包」而红)。跑任何「实机验证」之前先跑它。check:logfmt 是纯静态的一条:Cordis logger 只认 %s/%d/%i/%o/%j/%%,不认精度,所以 '置信度 %.2f' 会落地成 置信度 %.2f 0.9(占位符留在行里、实参掉到行尾),全仓曾一次犯过 10 处、分布在 9 个插件,没有一处报错,只是日志从此读不出人话。check:core 有跳过项时会以非零退出码结束,可直接用作判据。check:plugins 与 check:intake-switch 是两条「开关是否真的生效」的检查:前者验扩展抽屉里 desktop-bridge / dsh-client 不能报成「未装载」(它们在主装载名单之外无条件挂载,判据只有一处 enabledPlugins 时就会谎报),后者验「收对话进记忆」关掉之后 memory_raw 真的一行不涨——判据都落在库里的行数与接口返回值上,不看服务自报的 available。check:gap 会真装载一次插件链并喂一轮对话,验的是「她答不上来的事真的进了执念池、并且真的能被取成话由」——只查代码里有没有调用,抓不到这类断路。check:gap-wake 补的是前者的背面:执念在话由优先级里排第一且排序是字典序,若不做资格判定,一条刚出生、张力只有 40 的事会压过所有承诺与待办;这个脚本用同一组输入分别走「够格」与「不够格」两条路,看 pick.kind 是否真的换人,并验 markDelivered() 的 picks / lastWakeAt 确实被写。check:system-prompt / check:kernel-system / check:host-persona 是一条链上的三段:第一段读插件自己的视图,验人格四段真的拼得出来(在此之前它拼得出、但全仓没有消费者);第二段起一个假供应商,验 inference.run 的 system 参数真的走到了 messages 最前、且不落盘;第三段从 POST /api/inference 打进去,站在供应商一侧确认人格正文真的到了——这一条是唯一不能造假的口径,因为「装配了」与「送出去了」之间那根线,两侧都不报错。check:e2e 补的是同一条链的另一半:session/event 在整仓里长期只有订阅方、没有发出方,于是 ondra-memory-intake 的轮次草稿永远是空的,memory_raw / working_todo / memory_fact / drive_gap / emotion_state 五张表从建库起一直 0 行,而所有单元级检查都绿(它们各自直接调 intake.ingest(),绕过了「谁触发」这一环)。这个脚本从 POST /api/inference 说一句「明天提醒我把合同打印出来」,然后读库文件确认落盘、读 POST /api/proactive/check 确认判据换成了「你交代过的事」、最后在假供应商一侧确认这句由头真的进了模型上下文,并反证「她自说自话那一轮不新增待办」。check:e2e-memory 补的是记忆链的后半段:修好 session/event 之后 memory_raw 有数了,memory_fact 却仍是 0——因为整条模型调用链上五个环节同时缺席且都不报错(网关没进默认名单、ctx.llm 全仓零注册、网关 sources 默认空、sources[].model 要填供应商 id 而不是模型名、提炼器没进默认名单)。任何一环断掉症状完全相同(下游恒空),所以这个脚本先用 /api/diagnostics/memory 把四层原因码并排打出来,再从 POST /api/inference 说一句「我叫小玖,住在杭州」,最后读库文件确认 memory_fact 里真有那两行,并反证「提炼的提示词与返回 JSON 一个字都没进会话历史」。check:installer 在没装 makensis 的环境退出码为 2(跳过而非失败),装了则真编译一遍安装器——安装脚本一千多行,只有 Windows 才能被 Tauri 打包器吃进去,这个检查是把它的问题提前到本机暴露的唯一手段。check:emotion 与 check:feel 是同一条链的两侧:前者验读取侧(写一个读数 → 六态跟着变 → 变成一条话由),后者验写入侧(一轮对话 → 认出信号 → 投进情绪内核 → 存档落地)。写入侧此前全仓零调用方——feel() / comfort() / setAxis() / save() 五个写接口没有任何人问过,于是 emotion_state 从建库起一直 0 行、六态恒为「平静」,而平静是合法状态,所以没有任何报错。check:feel 的判据刻意不猜语义情绪:stumble 信号只看 ondra-drive-gap 的 detect() 是否命中(判据的唯一真源),apology / gratitude 只认明确的言语行为词表,reunion 只算时间差;位移量交给上游的 0.12 单条上限与 0.08 安慰幅度,调用方一行都不重算。check:diffuse 补的是情绪链跨会话那一半:ondra-emotion-diffuse 的公式、幂等闸、间隔闸、单行标记表、参数总表全都写完了,缺的是调用方——claim() 全仓零调用方、插件也不在默认启用名单里,于是「换一场会话,上一场剩下的心情还剩多少」这件事从来没有发生过,症状是她每次都像刚开完一场没断过的对话。落笔归宿主的 src/diffuse.ts(插件刻意不把 setAxis 放进自己的接口,applied 是字面量 false),因为界面不知道那道 30 分钟闸、也拿不到标记表,重复调就会把残差乘两遍,现象是「她越聊越淡」。这个脚本分三层验:插件算术(0.2 → 0.38 → 残差再乘四成)、宿主真的写了 setAxis(再读一次情绪内核自己的视图,只看返回值会漏掉「没写却把 target 原样回传」)、以及落盘(库里那一行不是上一轮的旧值)。check:relation 补的是关系域的写入侧:ondra-emotion-stage 从七阶表、四闸、闭式衰减到存储都写完了,唯独三个写入口(touch / adjust / observe)全仓零调用方,于是 interactions 恒 0 → familiarity 恒 0 → 弥散公式里 (1 − familiarity × 0.5) 整段白写,七阶永远停在「敌意」,而三维只衰减不成长。这三件的症状都是「什么都没发生」:0 是「陌生人」这个合法取值,敌意是合法阶,所以不会有任何一处报错——本仓第七次踩同一个形态。修法分四处:touch() 成为唯一的互动计数入口(adjust() 从此不计数,两处都计会让一轮 +2,而 5000 这个分母是按一轮一次标定的);宿主 src/relation.ts 的 recordTurn() 在「用户真说了话」的轮次先 touch() 再 observe()(顺序反了第一轮会卡在二阶的互动闸上,症状是「升阶总慢一轮」);src/diffuse.ts 调 claim() 时真的传 familiarity(此前只传边界键,插件一律按 0 处理);ondra-emotion-stage 进默认启用名单。脚本分四层验:插件算术、插件写入(touch() 推得动且落盘)、宿主接线(在真实服务实例上装计数器数调用次数,因为布尔标志证明不了「那一处真的执行了」)、下游消费(用替身记下 claim() 收到的请求,避开「0.0004 的差在保留比例上看不出来」这个量级陷阱)。check:evolve 补的是人格演化的写入侧:ondra-persona-evolve 的漂移公式、三层夹紧、可回滚日志、三张表全都写完了,README 连「以前 60 就算开心,后来要 80」的手感表都列好了,缺的是四样东西,而四样全都不报错——插件不在默认启用名单(服务不注册)、noteInteraction() 全仓零调用方(interactions 恒 0、系数恒 ×1.00)、thresholdOf() 与 personaState() 也零调用方。×1.00 是合法系数、0 是合法互动数,所以症状只有一句「她永远像第一天认识你」——本仓第八次踩同一个形态。修法刻意避开第二份真源:互动次数的唯一真源是关系插件的 touch(),宿主 src/evolve.ts 的 syncEvolveInteractions() 只做差额对齐(真源是多少就把镜像补到多少),不是每轮 +1——后者两处从不同起点各自累加,历史永远补不上且迟早漂开。镜像超前时只报 drifted 不抹平,真源缺席时一律不动(拿镜像当尺子就是自证)。脚本分六层验:算术(5000 饱和点、0.6 斜率、+60% 上限、effectiveThreshold(60, 2778) ≈ 80)、宿主差额对齐(一次补 3 而不是每轮 +1)、幂等(重复对齐不再加)、落盘(库里那一行真是 3)、诊断(appliedTo 只列真的消费了的那个)、镜像超前与真源缺席两条边界。这一轮只接通了「算得出」:六态与七阶的阈值当时都是各自插件的常量表,没有任何一处接受外面传进来的值,所以 appliedTo 刻意留空——GET /api/diagnostics/emotion 的 evolve 一格把这句话按机器可读的形式回答出来,而不是靠文档里一句提醒。第二十八段把这件事做完了,见 check:drift。check:drift 补的正是那半件悬着的事——阈值漂移的判定侧。上一轮接通的是「算得出」,判定照旧读常量表,于是「她越来越难被取悦」只在自己的 README 里成立。这一轮两处都接上了:六态侧新增 thresholds.ts 把 STATE_SPECS 的 base 交给 persona-evolve.thresholdOf() 乘系数,七阶侧同样新增一份把 affection / trust 交给它;base 永远由常量表的主人给(谁的表谁给),乘法只归 O-130,两边都不存对方的数——与它 README「base 每次由调用方传进」逐字对齐。三条纪律写死在代码里:下界一律不漂(蔫 的 mood < 0.35、困 的 energy < 30 是下界,上移等于「她更容易没精神」,那是误读而非「更难取悦」);days 不漂(相处天数是客观经过的时间,乘 1.6 等于说「处得越久,心动需要越久」,循环论证);可达性护栏(mood > 0.7 乘 1.6 等于 1.12,而 mood 量程只到 1——mood > 1.12 永远不成立,「雀跃」这一态在相处 3571 次之后就被判死了;超量程的一律退回出厂值并如实上报,宁可少漂一点也不让六态变成五态)。判据不是「传参了没」,而是同一个读数在漂移前后判出不同的态:mood 0.78 在 0 次互动时判「雀跃」,推到 1667 次(系数 ×1.20)之后同一个 0.78 不再雀跃。GET /api/diagnostics/emotion 的 evolve.appliedTo 从空数组变成真实消费方列表,而它的判据是消费方自己报的 drift.applied 非空——系数 ×1.00 时阈值与出厂逐字相同,此时填上名字就是撒谎,而那正是刚装上的常态。 check:project 补的是「情绪的唯一文本出口」那条边。ondra-emotion-project 的六态模板、四条护栏词表、四道拒绝闸、配置段、启动自检全都写完了,缺的是四样东西,而四样全都不报错:插件不在默认启用名单(服务不注册)、project() 全仓零调用方(模板写好了没人问)、投影文本没有消费者(系统提示词只有人格 + 器官片段,没有一个字关于「今天该怎么开口」)、constraint 也没有消费者(§4.4 的限定句从未进过提示词)。于是她永远用同一种语气说话,而「这一轮没有情绪块」与「她本来就不该有」症状完全相同——本仓第九次踩同一个形态。落笔在宿主的 src/emotion.ts,装配点接在系统提示词的最后一段(手册 §8.5:稳定段在头、易变段追加尾部;人格跨会话不变、器官片段变得慢、投影每轮都变)。三条边界写死在代码里:不带约束就不注入(缺 §4.4 限定句时宁可这一轮没有情绪块,因为一句「你今天话可以多一点」没有「情绪不影响判断」的限定,会被模型当成行为授权);不从对话里猜「用户是不是在问数值」(插件把这件事做成 askedForNumbers 入参,揣测是它刻意避开的,宿主加一份关键词表会更难审计;代价是那个唯一例外出口目前恒不触发,诊断里 askedForNumbersWired: false 如实报出来);拒绝产出不打日志(拒绝是正常产品行为,每分钟刷一行「没有情绪块」会把真正该看的那行淹掉)。脚本分九层验:四个服务在位、六条模板逐条产出且排序 800、例外闸真的拦、情绪段真的进了装配结果且是最后一段、护栏扫成品(模板自检只扫 command + hint,而真正进提示词的是加了约束句的成品——限定句从未被自检扫过)、换状态提示词真的跟着换、幂等、诊断读数,以及六个异常路径(服务没装载 / 配置关掉 / 约束句为空 / 正文为空 / 投影抛错 / 插件拒绝)。真宿主那一段另验两条只有端到端能回答的事:它在不在默认启用名单里,以及 emotionApplied(宿主真的拼进提示词的轮数)与 project.counters.projects(插件产出过的次数)两个数是否同步——只验后者会漏掉「取到了又丢掉」。 check:prompt-context 补的是记忆注入那条边,也是本仓第六次「A 写、B 读、中间缺一个位置」。ondra-prompt-inject 从写完那天起就在做一件对的事:把八块记忆渲染成一段正文,挂进系统提示词。它调 ctx.get('systemPrompt'),拿不到就 ctx.inject(['systemPrompt'], …) 等它出现。而宿主从来没有 provide 过这个键——只是在自己的请求处理函数里直接调 assembleSystemPrompt(ctx)。于是它永远等不到,只打一行「注入装箱已就绪但无人阅读」的 warn;stats() 照常可读、界面照常显示,唯一体现是她从来不提记忆里的任何事。本轮补的是那个位置(src/prompt-context.ts + 装配里的第三段),并顺带把 ondra-memory-pool 一起装进默认名单——它是同一条链的另一侧:缺 pool,事实会全部落海马体并置 unclassified,而海马体按设计永不注入;缺 inject,归池照常有数但没人读。两侧都缺时症状完全相同,所以必须一起进名单,否则只是把空跑挪了个位置。三条纪律写死在代码里:顺序由登记方声明(同 order 按登记先后稳定排序,不稳定排序会让同一输入每轮产出不同提示词,前缀缓存全废);缺省不插值(记忆正文里一个花括号就能让宿主 renderPrompt 抛 unknown prompt variable,整次装配失败);单段抛错只让该段缺席(其余段照常进,理由与装配本身相同)。脚本分八组验:服务真被 provide、插件走的是直路(provide 早于 loadCore)、话头真透传、无话头时缺席而非空串、单段抛错被隔离、上下文排在情绪之前、撤销与幂等、装配不抛错。真宿主那一段另验三格并排:context.registered(注册了几段)、context.names(谁挂的,应含 ondra:injection)、context.applied(几轮真产出了正文)——第三格只报数不判死,因为干净库上「没有旧事可说」正是正确答案,把它也钉成 >0 会让脚本在全新安装上必红,而红会被当噪音。

check:order 补的是装载顺序那条边,也是本仓又一次「声明与事实分家」。三个存储端口按 ctx.get('ondra-store-sqlite') ?? ctx.get('ondra-store-memory') 的优先级取用,?? 右半边是 SQLite 失守时的备用后端。但 loadCore 装的是 orderMembers() 排出的序,而排序只认成员自己声明的 after:ondra-store-memory 的 after 里有 ondra-store-sqlite(它要读 SQLite 状态来判断「我在不在用」),于是它被推到 SQLite 之后,而 17 个只写了 after: ['ondra-store-sqlite'] 的消费方全排在它前面——备用后端在每一个消费方那里都是 undefined,日志还在照常打印「本后端仅作备用」。SQLite 正常时这条断线完全不显形,只在最需要冗余的那一刻出现,而且加一个新插件照抄一行 after 就会再漏一个。判据不是「声明了没」,而是把清单交给真的 orderMembers() 排一次序、再逐个成员的源码核对:凡出现 ctx.get('<存储键>'),它在排序结果里的位置就必须在该键的提供方之后。反向验证做过:把任意一个消费方的 'ondra-store-memory' 删掉,脚本精确报出那一个 id。

check:config-used 补的是另一条边,也是本仓第十次踩同一个形态:配置项声明了,但没有任何人读它。它比前九次更难发现,因为三处都说「生效了」——设置页显示已改、启动日志照实打出新值、类型定义完整无缺,只有「有没有人读这个字段」那一层是空的,而那一层没有界面。判据是把每个插件的 FIELD_SPECS 键逐个拿去包内源码里找消费者(settings.ts 自身不算:那里是声明),零消费的进入豁免清单,且豁免必须写 ≥30 字的理由并把清单本身也钉住(豁免项必须真的还被豁免,否则过期的豁免会把下一轮的人骗过去)。第 18 轮实测 624 个字段里 11 个零消费:2 个是误报(引用在 settings.ts 自己的 resolveEndpoint() 域内函数里)、4 个是待裁决、7 个是真假开关——七个插件都声明了 verbose,doc 一律写「降级时打一行日志」,却零读取。接线时守住一条纪律:warn / error 一律不受 verbose 约束,开关管的是过程噪声,不是故障信号——把故障也压掉,等于把「排查为什么她记不住」的工具收走了。同轮还修了一处 substituteInBoundaries:设置声明完整但拼装侧永远替换,修法是给 substitute() 加 scope 参数而不是新开一个方法——两个方法会让「替换」有两份实现,而这份实现的全部价值就是只有一份。check:defaults 补的是配置字段自己和自己打架那条边。ondra-config-schema 在 register() 时会拿字段的 pattern 校验它自己的 default(layers.ts 的 checkFieldSpec),所以一个「默认值是空串、而正则不收空串」的字段会让整段注册抛错——代价不是崩溃,而是那一段从此没有 schema 层:非法值不再被挡回默认值,每次启动还留一行读起来像故障的 warn。全仓曾命中 2 处(asr.language、vision.credentialRef),两处代码里都写着「留空即自动 / 留空用默认」,也就是空串本来就是合法取值,错的是正则太严。判据是逐包 import lib/settings.js、对每个带 pattern 的字符串字段跑一次 new RegExp(pattern).test(default);反向验证:把其中一处改回严格正则并 build,脚本立刻指名那个包与字段。

check:filter 补的是删句与分段打架那条边,也是本仓第一处「三个下游一致地错、却没有一处报错」的缺陷。ondra-filter-chain 九步里有七步是「删整句」,实现都是 splitSentences() → 逐句判 → joinSentences() 拼回;而 \n 是它认的句末标点之一,分句时逐句 trim() 把换行剪掉、拼回时又用空串相连——于是只要删过一句,模型写好的分段就整段被压平。症状不是报错,是气泡里两段话粘成一句(「…出门只有屏幕。雨我看得见,听不见」),而 done 的文本、界面显示、写进记忆的那条三处一致地都是错的,所以任何单点比对都发现不了。发现路径是同一轮里两个值不一样:say 打的流式原文有三段、「塑形后」只有一段。修法是把「这句后面的空白」单独存成 Sentence.gap(不塞进 text,否则 DRAFT_RULES 那些 ^…$ 锚点会全部失配),拼回时原样带上,删句时顺延给下一句。判据是两条硬的:无删改时 split → join 必须逐字节等于原文;三种删句位置之后段落空行必须还在且不多不少。反向验证:把 gap 改成恒空,脚本红 8 条,红出来的正是线上那个粘句症状。

check:shape 补的是映射表的键与它消费的联合类型不在同一处维护那条边,也是本仓第一个「两边各自合法、运行时被 ?? 吞掉」的缺陷。ondra-emotion-express 的 SentenceShape 联合类型有六个值,sleepy(困)用的是 ellipsis;而 ondra-rhythm 的 SHAPE_ADJUST 只有五个键——缺的正是 ellipsis。缺键的后果有三重静默:查表 ?? PLAIN_SHAPE 回落常句;adjusted 因系数算出来等于默认值判 false;notes 里的 { code: 'shape' } 也不产生。于是她困的时候语速确实慢下来了(tempo: 'slow' 走的是另一张有键的表),分条与单条字数完全按平常档走,而面板「句形」一格照常显示 ellipsis / 短句带省略——看着全对。判据不抄实现(不去断言表里有哪些键,那样表里加错数字也能过),而是拿实际生效的单条上限当派生量:同一段正文、同一个 maxCharsPerMessage,只换句形档,plan().maxCharsPerMessage 必须随档变化。断言写的是每档与 plain 的关系而不是「六档两两不同」——medium 与 plain 的系数都是 1(手册的「中句」与「平常」都不该改单条字数),它们相同是刻意的。反向验证:摘掉 ellipsis 键 → 恰好红 1 条,报的正是 40 < 40。

check:disable 补的是假开关那条边,一次守住三处同构缺陷。ondra-emotion-stage / ondra-emotion-diffuse / ondra-persona-evolve 三个插件都在关闭之后照旧建表(合计五张),因为 settings 已经算好了、if (!settings.enable) 就是没写。对照 ondra-emotion-core 一直是「先判关、再判存储」,说明这是漏写而非取舍。判据必须是去问存储层这张表存不存在(store.tableExists()),不能是 available === false——后者在修复前就绿,等于没测。顺带修掉 diffuse 的两处连带缺陷:原因枚举缺 schema-failed(建表失败复用 no-store,于是「磁盘写满」与「没装存储」报同一个码),以及 reason getter 让 fallbackReason 优先、会把 disabled 盖掉。evolve 另修一处:noteInteraction() 自身无开关守卫,靠宿主替它挡——任何第三个调用方都能在关闭状态下写库。反向验证:三处守卫改 if(false) → 红 5 条,与三处修复一一对应。这里踩过一个坑值得记:表名必须去源码里读,不能按目录名猜。 第一版把 emotion-stage 的表写成 ondra_relation_state(跟着目录名走),真名是 emotion_relation_state,于是那条断言永远绿、反向验证时也一直绿——一个永远不会红的断言比没有断言更糟,它让人以为那一处被守住了。

check:dedupe-tally 补的是日志把两个不同动作印成同一栏那条边,也是本仓第一处「排查工具本身在说谎」的缺陷。ondra-memory-intake 那行「判重裁决:新建 N · 合并 N · 复述 N · 丢弃 N · 冲突 N」的五个 %d 依次收 tally.add+conflict / merge / append / drop / conflict——而 append 的落库语义是只 reinforce()、不写库,它才是真正的「这条新事实没进去」;drop 才是判重术语里的「复述」。两栏标签正好换了位。代价不是措辞难看:实机 say "记一下:备用钥匙在玄关鞋柜最上层,编号 K-9",她回「记下了,我把它改过来了」,库里仍只有「第二层」那条,而日志写着「复述 1」——一句话都没进去,三处读数却完全自洽。全库实测 append 16 条、written 全为 0,其中含「用户让玖玖记住暗号」这种明确要求记住的内容。判据不是「标签含某几个字」(措辞会改),而是一对一:五个栏位、五个实参行,栏位名与它收的 tally 字段必须双向对上,且「灰区未写库」栏收 append、「复述」栏收 drop 互不串位;另有一条锁 append 分支确实不调 this.write(),防止哪天改了行为而日志变成「说过时」。反向验证:标签改回旧版 → 红 5 条,位置精确。check:dedupe-digit 补的是同一处更上游的那一刀,也是本仓第十一次踩「三个读数自洽、事实没进去」这个形态。上一轮修好了日志措辞,于是「灰区未写库 1」终于能被正确读成「这条新事实没进去」——可它为什么被划进灰区,判据里一个字都没有。实测:旧条「用户有一把自行车锁,并问密码是多少」(一个数字都没有)、新条「密码是 4712」,包含度 0.46 落在灰区 [0.40, 0.95),槽位替换不匹配 → append → 只 reinforce() 旧条、不写新条,而对话层照回「记下了」。digitsDiffer() 这个函数当时只在 conflict 分支用,灰区分支完全没用——数字是事实的值,不是修饰;旧条压根没提过任何值、新条第一次给了值,那句话不可能是「又被提起」。修法是在 append 之前加一道窄闸:旧条零数字 + 新条有数字 → 改判 add。判据刻意收窄到这一个象限——旧条已有数字而新条不同属于换值,那是 conflict 的活,放进来会抢判定、把冲突降级成新建。脚本用纯函数直打 judge()(它自己不碰 ctx、不碰库、不调模型,起宿主只会引入无关变量),六条里有一条是校准锚:把同一条的数字抹掉必须回到 append——少了它,上面那条通过可能只是「第 7 步整段被删」,那样每次复述都会多一条重复。反向验证:删掉 7.5 分支 → 恰好红 2 条(主判据 + 理由点名数字),其余 4 条仍绿。 check:recall-archived 补的是假开关那条边的一个新形态:同名键遮蔽。ondra-memory-recall 的 includeArchived 在设置页有一格(默认关),而 src/ 里真正被读的一直是 request.includeArchived——两处同名,所以「字段名在源码里出现过」这类静态扫描永远判它已接线。症状是本仓第 N 次那个形状:设置页能改、日志照实回报、重启也照做,行为一个字不变。判据不能靠读源码,只能造一个真仓库、喂真请求、看真结果:四段锁「配置 true + 请求不传 → 沉底那条进候选」「配置 false + 请求不传 → 不进」「请求显式 false 压过配置 true」「请求显式 true 压过配置 false」两个方向,外加一条锁 explain() 与 search() 用同一套判据(两处判定不一致会让「她为什么没想起这条」的解释说谎)。修复只加了一个 archivedIncluded() 私有方法,优先级是请求参数 > 配置,判据用 typeof === 'boolean' 而不是真值判断——false 是显式值,不能被配置翻回来。反向验证:把两处判定改回 request.includeArchived === true → 红 2 条,位置精确。

18.3 报告问题

请说明:复现步骤、预期结果、实际结果,以及 dsh-sidecar 与 cordis-host 的标准错误输出。涉及具体插件时请附上插件包名与相关配置项的当前值。

18.4 内容共建

18.5 联系方式

渠道 地址
QQ 群 1061684298
官网 https://ai.ntars.cn/
仓库 https://github.com/ntars/Ondra
发行版与更新日志 https://github.com/ntars/Ondra/releases
问题反馈 https://github.com/ntars/Ondra/issues
邮箱 ntars@qq.com · ntarsx@163.com

内测期间优先走 QQ 群:群里能直接问到当前版本的实际状态,也方便同步已知问题的临时绕行办法。功能建议与缺陷请走 Issues,公开可追溯。


十九、版权与许可

Copyright © 2024–2026 Ntarstudio(星玖工作室). All Rights Reserved.

本项目以 GNU Affero General Public License v3.0 授权。完整许可证原文见 LICENSE 文件。

AGPL-3.0 的核心义务:若修改本程序并通过网络提供服务,须以相同许可证公开修改后的源代码。这不影响在本机私人使用本程序。

商标。「空谷」「Ondra」「Ntarstudio」「星玖工作室」为本项目使用的名称与标识。AGPL-3.0 授予的是著作权许可,不授予商标许可。基于本项目的衍生作品不得暗示其由 Ntarstudio 出品或获得 Ntarstudio 背书。

无担保。本程序按「现状」提供,不附带任何明示或暗示的担保,包括但不限于对适销性、特定用途适用性与非侵权性的担保。完整免责条款见 LICENSE 第十五、十六条。


二十、NOTICE 开源合规声明

本项目使用的全部第三方软件、组件、素材与算法来源,已在 NOTICE 文件中逐项披露,包含名称、版本、版权方、许可证类型、官方地址、在本项目中的用途与修改说明。

披露范围。包含直接依赖与全部传递依赖:Rust crate 442 个(直接 6 个、传递 436 个),npm 包 87 个(直接 12 个、传递 75 个),合计 529 个。数量由 Cargo.lock 与 pnpm-lock.yaml 解析得出,不是估算。同一 crate 的不同版本分别计数,因为版本不同许可证可能不同;按名称去重后是 416 个 Rust crate、74 个 npm 包。

许可证分布。MIT 与 Apache-2.0 及其组合 448 个、Zlib 22 个、Unicode-3.0 19 个、MPL-2.0 17 个、BSD-3-Clause 10 个、Unlicense / 0BSD / CC0-1.0 / MIT-0 合计 8 个、ISC 3 个、LGPL-2.1-or-later 作为多选项之一 2 个,全部与 AGPL-3.0 兼容。逐类表格见 6.3。

弱著佐权义务如何处理。17 个 MPL-2.0 组件全部以未修改的发布版本引入,不产生 MPL-2.0 第 3 条的衍生作品义务,也没有公开源码的义务;若将来需要修改其中任何一个,被修改的文件须以 MPL-2.0 发布。

未使用的内容。本项目未使用任何来源不明、许可证不明或需付费授权的代码、模型权重、数据集、图片或音频素材。界面字体 MiSans VF 随前端产物一同分发(src/assets/fonts/MiSansVF.woff2,8.8 MB,由官方 TTF 子集化生成),依据小米官方免费商用授权嵌入,授权要点、子集化字符范围与注明义务见 src/assets/fonts/LICENSE-MiSans.md,并在 NOTICE 第六节逐项登记;系统字体栈仅作回退。图标的图形数据来自 lucide(ISC),渲染与形变由 morphicons(MIT)承担,两者以未修改的依赖形式引入;应用图标为自有原创设计,随源码一同以 AGPL-3.0 授权。

无模型权重。dsh-sidecar 是纯代码实现,不包含任何模型权重。推理能力由使用者在运行时自行配置的模型服务提供,相关模型的使用条款由使用者与模型提供方之间的协议约定,与本项目许可证无关。

若引用他人内容。已注明来源并确认符合其许可证要求。本项目自身版权声明与第三方版权声明在同一文件中并列保留,不互相覆盖。

若认为本项目侵犯了你的著作权、商标权或其他权利,或发现 NOTICE 存在遗漏与错误,请通过仓库议题或官网联系。核实后 7 个工作日内更正或移除相关内容。

Ntarstudio(星玖工作室)· https://www.ntars.cn/

About

空谷 Ondra —— 全插件式桌面 AI 陪伴体:75 个可装卸插件,情绪、记忆、上下文三大机制,数据全留本机 | Fully pluggable desktop AI companion: 75 loadable plugins, emotion, memory and context as core mechanisms, all data stays local.

Topics

Resources

Stars

11 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages