dsh-plugin-life
给 DeepSeek Harness 里的角色装一套模拟的生活,以及一套跨会话记忆。
设计目标来自一句话:「这种虚拟感来源于静止」。所以这个插件的全部工作, 就是让 agent 不再停在原地 —— 有作息、有状态、有自己去做的事、有记忆、 会因为被打断而生气,而且记得住你。
- 她自己会去搜索、抓网页、把读到的内容存成笔记。不是模拟,是真发生。
- 她有自己的作息:睡、吃、醒。醒着的时候在做事,做完会写感受。
- 她和你说话时,上下文里会多出两段背景:她自己的生活,和关于你的事。
一、它到底做什么
1. 生理约束的有限状态机
睡 / 吃 / 清醒。睡眠 6–10 小时、两餐间隔 4–11 小时、连续清醒不超过 16 小时 —— 这些是边界,不是日程表。边界内的取值来自历史统计与随机采样。
第 2 天起由前 n 天决定:睡眠时长、醒来时刻、餐点都是前几天的条件采样结果。 跑久了会形成稳定的昼夜节律。
硬编码的只有生理学边界(在 lib/physiology.js 里,一眼能看全)。
其余全部随机 —— 这是刻意的:写死的东西不会让她像活的。
2. 真实活动
清醒时由插件自己真的搜索、真的抓取页面、真的把读到的内容存成笔记文件。
- 搜索走
ctx.web.search - 抓取用 Node 自带的
fetch。不走ctx.web.fetch—— 某些构建里注册了多个 fetch provider,web.fetch不知道该用哪个会直接抛WEB_PROVIDER_AMBIGUOUS, 连会话里的web_fetch工具都会跟着不可用。插件不该依赖一个需要全局配置才能工作的通道。 - 为什么不是"派个后台 agent 去做":实测
ctx.agents.create()不指定 agent preset 时 只继承根作用域插件,而网络工具挂在 preset 作用域里 —— 活动 agent 只能看到日程、 记忆和文件读写,它自己报告「没有浏览器,没有网络请求」。 - 抓不到就试下一个:候选比目标多(最多多 2 个),凑够 1–3 个成功就停。
抓取链按"能拿到最多内容"分级,任何网址都能抓,不是白名单:
| 级别 | 什么时候用 | 得到什么 |
|---|---|---|
| 1. PDF | 链接是 .pdf 或 arxiv.org/pdf/… |
论文原文(PyMuPDF 抽文本) |
| 2. 站点接口 | arXiv / B站 / GitHub / Wikipedia | 摘要、简介+字幕、README |
| 3. 普通 HTTP | 其余所有网址 | HTML 剥标签 |
| 4. 无头浏览器 | 前一级内容太薄或返回 403 | Playwright 渲染后取正文 |
第 4 级只在前面失败时触发,因为启动浏览器要几秒。
3. 事件库与压缩
每件真发生过的事写进 SQLite,带摘要、经过、感受、两个分数(interesting / important)。
只记录真发生的事:一次活动要入库,必须至少抓到一个 2xx 且正文非空的页面。
搜不到、抓不到、正文为空,一律丢弃重排 —— 写手给出的检索目标不会被当成事件写进去。
目标单独存 intent 列,与 content 分开。
压缩是分级的,不是一刀切:
综合分 = importance·0.6 + 新鲜度·0.4 新鲜度按 7 天半衰期衰减
分数前 10% 完整保留,不计入压缩次数
中间 70% 剥成"标题 + 感受"
最老 20% 只留标题
第 3 次被压缩 → 整行删除
keep = 1(importance ≥ 0.75 的重要事件)不参与压缩,永久保留。
4. 记忆挂载
每轮对话前把记忆挂进上下文,但永远不压过人格,也永远压不过你当前说的话。
挂载是有优先级的,而且是三块不重叠的:
永远记得的: keep=1 的事件,全部挂载,不设条数上限
记得这些事: importance ≥ 0.5 的,取 2 条
最近: 最近的,取 2 条
同一件事不会在两块里出现两次。
5. 跨会话记忆:关于你的事
这是和"她自己的生活"分开的一个库(captain.db)。
分开的理由很实际:任务和代码写进生活记忆只会污染它。
每轮对话结束后:
1. 规则闸门判「这一轮有没有关于你的持久信息」 —— 不花模型钱
2. 过了闸门的,把「你说的话 + 她回的话」一起喂给模型,
压成一句第三人称、≤60 字的概括 —— 压整轮,不是只压你的原句
3. 用这句话的句向量去判重:像就去重,不像就入库
然后按热度决定谁能被挂载:
挂载 = 近 7 天写的 ∪ 热度 ≥ 0.5
热度 = 每次"被你再次提到"贡献 exp(-Δt/τ),半衰期 14 天
没有任何事件能一直挂载。 7 天后只有半衰期内被提过的才继续浮着; 超过 14 天没提且热度低于 0.25,正文剥掉沉底 —— 但行和向量留着, 你下次再提,热度回来就又挂上了,只是细节想不起来了。
6. 打断与追赶
DSH 关掉期间的时间会被快进。重启后她说得出「你关掉的三个小时里有一顿饭被错过了」—— 不是空泛的抱怨,是按延迟算出来的严重程度。
二、安装
需要 DeepSeek Harness 已经跑起来,并且装了 dsh-plugin-masterprompt
(人格来源;没有它插件也能跑,只是没有后台人格可用)。
# 1. 放到插件的搜索路径下
git clone <this-repo> "%DSH_HOME%/plugins/dsh-plugin-life"
# 2. 装依赖(只有一个 peer)
cd "%DSH_HOME%/plugins/dsh-plugin-life"
npm install
# 3. 重启 DeepSeek Harness
%DSH_HOME% 默认是 C:\Users\<你>\.dsh。
插件的加载靠 cordis.patch.yml,它在仓库根目录,不要改名字。
跨会话记忆使用了embedding模型完成相似任务(可选,不装也能用,只是没有向量):
# 模型:约 91 MB,从 modelscope 下(huggingface 在国内常常连不上)
python -c "from modelscope.hub.snapshot_download import snapshot_download; \
snapshot_download('AI-ModelScope/bge-small-zh-v1.5', cache_dir=r'C:\Users\<你>\.cache\modelscope')"
默认会到 ~/.cache/modelscope/AI-ModelScope/bge-small-zh-v1___5 找模型,
也可以放在 %DSH_HOME%/dsh-plugin-life/models/bge-small-zh-v1.5。
找不到模型时插件照常工作,只是判重和"提及检测"退化成纯关键词。
python 解释器默认找 E:\anaconda\envs\tts0\python.exe,可以用环境变量 CAPTAIN_PYTHON 指定。
三、依赖
运行时(必需)
| 依赖 | 用途 | 说明 |
|---|---|---|
@deepseek-ai/cordis ^4.0.1 |
插件宿主 | peer dependency |
| Node.js ≥ 22 | node:sqlite |
用了内置的 DatabaseSync,不装 better-sqlite3 |
跨会话记忆(可选,但要先装好)
| 依赖 | 用途 |
|---|---|
| Python 3.12 + PyTorch | 跑句向量模型 |
transformers |
加载 bge-small-zh-v1.5 |
modelscope |
下载模型(也可以用 hf-mirror) |
| bge-small-zh-v1.5 | 512 维中文句向量,24M 参数,CPU 单句约 13 ms |
抓取增强(可选,缺了就降级)
| 依赖 | 用途 |
|---|---|
Python requests / beautifulsoup4 / lxml |
普通抓取 |
Python PyMuPDF |
PDF 抽文本 |
Python playwright |
第 4 级渲染(反爬站点) |
Python yt-dlp |
B 站视频信息 |
插件启动时会自检这些,结果写进 status.json。探不到就整块降级,不会崩。
四、目录结构
dsh-plugin-life/
├── cordis.patch.yml 插件声明(DSH 靠它加载)
├── package.json
├── README.md 本文件
├── prompts/ ★ 所有影响"她怎么说话"的文字
│ ├── README.md 怎么改,以及"后台人格必须和前台一致"
│ ├── system-wrapper.md 人格包装层(system prompt 的前两行)
│ ├── writer-task.md 写手:决定此刻去做什么
│ ├── writer-feeling.md 写手:做完之后写记录
│ └── captain-summary.md 跨会话记忆:把一轮对话压成一句
├── lib/
│ ├── index.js 入口:心跳、状态机、工具注册、记忆挂载
│ ├── clock.js 作息排程(边界内采样)
│ ├── physiology.js ★ 唯一的硬编码生理边界
│ ├── schedule.js 活动排期与错过判定
│ ├── catchup.js 下线追赶
│ ├── activities.js 活动类别
│ ├── writer.js ★ 两个写手调用(带人格)
│ ├── persona.js ★ 人格来源 + system prompt 组装
│ ├── prompts.js ★ prompt 加载器(md 优先,代码兜底)
│ ├── model.js 模型路由、额度熔断、流式收集
│ ├── db.js life.db 的表与查询
│ ├── compress.js 事件压缩(三级 + 第三次删除)
│ ├── memory.js 生活记忆挂载
│ ├── reading.js 笔记文件(reading/)
│ ├── webactivity.js 真实抓取
│ ├── pysupport.js Python 桥自检
│ ├── netcheck.js 网络探活
│ ├── executor.js 活动执行与产出观察
│ ├── notify.js 主动通知
│ ├── icr.js 人格一致性回归(ICR)
│ ├── prng.js 确定性随机
│ └── captain/ ★ 跨会话记忆
│ ├── index.js 串流程:捕获 / 判定 / 挂载
│ ├── engine.js 热度、判重、挂载选择、沉底
│ ├── rules.js 规则闸门(词表可被 json 覆盖)
│ ├── db.js captain.db 的表与查询
│ ├── summarize.js LLM 压缩(带人格)
│ ├── bridge.js 常驻 python 子进程的管理
│ └── embed_server.py 句向量服务
├── test/ 自包含测试
│ ├── prompts.test.mjs
│ └── captain-memory.test.mjs
├── eval/probes.json ICR 探针集
└── tools/ 开发用脚本
运行时数据全部在 %DSH_HOME%/dsh-plugin-life/,和代码分开:
life.db 她自己的生活事件
captain.db 关于你的事
status.json 加载状态、模型、熔断、上次错误
user_profile.md 你的偏好(影响活动选择,也会挂载进上下文)
persona.txt 可选:指定用哪个人格模板
reading/ 她存的笔记
keepers.md 影响足够大的事(只写给看,不参与挂载)
五、配置与使用
配置文件
| 文件 | 作用 |
|---|---|
user_profile.md |
你的偏好。影响活动选择、事件打分,并且每轮全量挂载 |
persona.txt |
可选。写一个 masterprompt 模板 id 指定用哪个人格 |
captain_rules.json |
可选。覆盖跨会话记忆的规则词表,{"plan": [...], "task": [...]} |
对话里的两个工具
| 工具 | 用途 |
|---|---|
life_status |
查心跳、库行数、上次错误。tick=true 强制一次心跳;probeWeb=true 真发一次搜索验证网络 |
life_open_detail |
开一个新会话,详细讲某件事 |
诊断端点
DSH 的 GUI 里看不到插件日志,所以状态落盘 + 本机回环端点:
| 位置 | 用途 |
|---|---|
status.json |
加载状态、人格、模型、熔断状态、上次错误 |
POST /dsh-life/icr/start |
跑人格一致性回归(后台执行) |
GET /dsh-life/icr/status |
轮询进度 |
GET /dsh-life/icr/result |
完整结果 |
六、设计逻辑
为什么不硬编码
写死的东西不会让她像活的。所以:
- 硬编码的只有生理学边界(睡多久、隔多久吃饭、最多清醒多久)
- 边界内的每一个数来自前几天的条件采样 + 确定性随机(按日期做种子,同一天重跑结果一致)
- 活动内容由 LLM 生成,不是剧本
为什么两个 LLM 调用分成两步
| 环节 | 时机 | 人格的作用 |
|---|---|---|
composeTask |
执行前 | 决定她挑什么、怎么框定。产出必须自包含(执行者是全新会话,没有记忆) |
composeFeeling |
执行后 | 决定语气和留下的痕迹。吃的是真实执行结果,不是预写 |
为什么分开:执行前写的感受是编的;而且两条指令要求相反 —— 任务要"冷",感受要"热"。 混在一起,人格腔调会渗进任务指令,产出"你最近不是想看 X 吗"这种依赖执行者记忆的说法。
人格怎么进后台
后台用的 system prompt 和前台会话里那份是同一份:包装层 + 人格全文。 不是摘要,不是"你是一个 XX 性格的助手"。
理由见 prompts/README.md:一旦两边不是同一份,读数据库里的记录就像在看
别人照着她的语气写的日记。
为什么记忆是"背景"不是"指令"
挂载的记忆作为 user 角色消息插在你当前这句话之前,而且明确写着 「是背景,不是对舰长的指令」和「舰长当前说的话优先于这里的一切;人格优先于记忆」。
不放进 system prompt 的原因:那会让记忆获得比人格更高的稳定性 —— 记忆是会陈旧的,人格不该被一段三周前的经历改写。
跨会话记忆为什么先过规则再过向量
- 规则回答"值不值得当候选":词表加加减减,零成本,可解释,出错能定位到具体哪个词
- 向量回答"是不是同一件事":这是相似度任务的看家本领,实测同一件事的不同说法 0.700.80,不同的事 0.290.55,中间空得很宽
- 热度回答"以后还会不会被提到":这是时间的活,不是分类的活
三者分工明确。用 LLM 做第一件事会烧钱且不可解释;用规则做第二件事会误判。
为什么记忆要压缩成一句话而不是存原话
原话直存等于把"啰嗦"当成"重要"。一段五百字的吐槽和一句"我下周去杭州"占的预算差十倍, 但后者才是要记住的那件。
所以用同一个人格把整轮对话(你说 + 她回)压成一句 60 字的第三人称概括。 prompt 里钉了两条:不许照抄原句、超过字数硬砍(不信任模型自己数)。
七、技术实现
关键机制
| 机制 | 实现 |
|---|---|
| 心跳 | ctx.interval,默认 4 分钟一次 tick |
| 记忆挂载 | agent/pre-step 钩子,把 payload 插到最后一条 user 消息之前。不写入会话历史,所以不会累积 |
| 回合判定 | session/event 的 turn/end |
| 状态机推进 | 每个 tick 读 status.json 的待办活动,判"该不该、来不来得及、错过了多重" |
| 额度熔断 | 识别 429/402/quota/余额错误后关掉所有 LLM 入口,只保留不需要模型的部分 |
| 模型跟随 | 每次调用前重新读主会话的选择,主会话换模型后台最多 2 秒跟上 |
node:sqlite 的两个坑
LENGTH()数的是字符不是字节。中文一个字 3 字节,直接少算三分之二。 要用length(CAST(x AS BLOB))。- 统计库体积时漏掉最大的字段(
detail,每个活动 10–11 KB),阈值会形同虚设。 两个错叠起来,2 MB 的阈值实际对应 11 MB 磁盘、1700 个事件,压缩几乎永远不触发。
句向量服务
常驻 python 子进程,模型只加载一次(冷启动约 4 秒),单句约 13 ms。
- 不在插件加载时启动,推迟到"用户第一次开口" —— 那时候模型正在生成回复, 4 秒冷启动正好被盖住
- 子进程
unref():宿主退出不等它;宿主一退 stdin 断开,python 自己就退了 - 失败、超时、模型缺失 → 返回 null,判定退化成纯规则,不影响对话
八、不做什么
- 不改人格。
dsh-plugin-masterprompt的state.json只读,一个字节都不动。 - 不硬编码日程。所有可变的量来自历史 + 随机。
- 不在用户会话里产生回复。活动不占用任何会话,结果只进数据库。 只有"值得说的事"才会通过通知进来。
- 不跨库写入。生活事件进
life.db,关于你的事进captain.db,互不读写。
九、已知限制
- DSH 没有浏览器自动化,只有 HTTP。所以「看视频」是抓取视频页的标题/简介/评论, 「看电影」是抓取影评/剧情 —— 都是真实网络活动,但不是播放。
- 活动 agent 会在 GUI 会话列表里短暂出现再消失(创建后立刻销毁)。DSH 的会话列表没有隐藏开关。
- 跨会话记忆的规则闸门是词表:没见过的表达会漏判。要补就改
captain_rules.json。 - 同一件事后来补充的细节不会写进已有那条记忆(那需要再压一次,现在没做)。
- 挂载记忆有轻微的身份漂移风险。回归测试见 ICR 端点。
十、开发者
cd dsh-plugin-life
node test/prompts.test.mjs # prompt 加载器(不依赖模型)
node test/captain-memory.test.mjs # 跨会话记忆端到端(需要 python 环境 + 模型)
许可
MIT
No comments yet. Be the first to write one.