dsh-selfnote
xyzovo/dsh-selfnote
Self-narrative memory in five budgeted segments (800 chars, not a full context): a first-person note the model writes to itself, a profile of you aggregated from approved facts, environment facts kept separate from who you are, heat-ranked conclusions, and habits. Every injected fact is traceable to a fact id and can be retired; the profile can be edited by hand, and self-notes stay plain lines you can clear. Facts fade by use and time without ever changing their credibility. Preference-like lines you say are auto-detected as candidates (never auto-approved), with conflicts kept side by side. Recall over past conversations with citations, an incremental index that only decodes newly appended frames, a pointer-required warm layer, a doctor that says which part is broken, a heartbeat proving injection is alive, and export/import so the whole thing moves to another machine.
Installer
dsh plugin --profile web add github:xyzovo/dsh-selfnoteREADME
dsh-selfnote
记忆不是聊天记录的堆,而是它自己攒下来的几句话。
Self-narrative memory for DeepSeek Harness — the model writes notes to itself, they survive across sessions, and come back every turn.
想法
为什么做这个插件、怎么理解「记忆」和「遗忘」—— 写在单独一篇里。
这个插件做了什么
一句话:把「记忆」从「检索一堆聊天记录」变成「几句它自己写的自述 + 几条你点头过的事实」。
| 层 | 谁写 | 存在哪 | 会不会忘 |
|---|---|---|---|
| 自述 | 模型自己(self_note) | ~/.dsh/selfnote/notes.md | 不删,写满进归档 |
| 事实 | 你点头后才算数(memory_stage → 设置页采纳) | ~/.dsh/memory/facts.jsonl | 冷下去只降排序,不删 |
| 画像 | 派生自「已采纳」的事实,不落盘 | 内存 | 每回合重算 |
| 原文 | 你不用管,DSH 本来就在写 | ~/.dsh/sessions/* | 按会话自然增长 |
主体约定:自述里的第一人称「我」= agent 自己;「记忆条目」「事实」的主语是用户。两者分开存、分开注入,不混为一谈。
三个工具
| 工具 | 做什么 | 关键约束 |
|---|---|---|
self_note | 写一条第一人称自述 | 一句话到三句话;见下面「什么时候写」 |
memory_recall | 按关键词翻历史对话原文,回来带出处 | 只收正文,剔除 reasoning / 工具输出 / 注入内容 |
memory_stage | 把查到的一句连出处放进候选区 | 没有出处直接拒绝,所以编不了 |
什么时候写自述
工具说明里写死了三条硬触发,命中任意一条就该写:① 用户说「记住这个」这一类话;② 用户连续两次纠正同一件事;③ 一段合作收尾(任务交付、方向定了)。另外模型自己发现「这次做得不对、下次换个做法」时也写。
这是「自助餐」不做成的那个反面:不给判据,模型要么一句不写,要么把每次对话都写成流水账。
设置页可以自己加一条(80 字以内),填了就作为「写自述的时机:…」进注入,下一回合生效——工具说明在注册时定型、改一句要重启,所以自定义这条走注入而不是改描述。留空就用默认判据。
设置页还显示上次写入(notes.md 的 mtime),一眼看出「这东西三天没动了」。
写自述是明文保存在本机、并且每回合都会进上下文的文件:密码、密钥、验证码这类凭据不写。用户本人姓名、习惯、处境这些可以写,但更适合进事实库(memory_stage),因为那边能反复检索、能退役、能带出处。
安装
# 1. 拿到目录(git clone 或直接下载)
git clone https://github.com/xyzOvO/dsh-selfnote.git
# 2. 装进 DSH
plugin_manager { action: "install_bundle", target: "<该目录的绝对路径>" }
装完重启 DSH,三件事同时出现:
- 模型多了上面三个工具;
- 设置页多一节「人格(自述)」(在「知识库」下面);
- 每回合的系统提示里,多一段最多 800 字的记忆。
也可以直接对 agent 说:「帮我装一个 DSH 插件,目录是 <绝对路径>」。
五分钟上手
先让它记点东西(普通聊天就行)
以后回答先给结论,细节等我问。
这一句会被自动采集识别成「长期约定」,进候选区 —— 但不会自动生效。
然后你去审核:设置 → 人格(自述)→ 候选卡上会显示:
以后回答先给结论 [自动采集]
长期约定 · session-3611ac44…#10457 · 出现 7 次
[采纳] [退役]
点「采纳」,下一回合它就出现在系统提示里。点「退役」,它就只留在库里备案。
再让它自己写一句
你写一条自述,说说你做这类插件时的习惯。
模型会调用 self_note,写进 notes.md,以后每个会话都带着。
设置页分五页
| 页签 | 里面是什么 | 角标 |
|---|---|---|
| 概览 | 注入开关、注入抬头(那段的第一行,≤24 字)、存储上限(字 / 条,只影响磁盘,不影响注入量)、本回合注入的预算条(自述 / 画像 / 结论 / 习惯)、索引七项指标与重建按钮、文件区(7 个记忆文件,带条数 / 大小 / 最后修改,一键打开) | — |
| 自述 | 手动写一条、清空(进归档)、现在生效的自述列表(每条能就地「改」「删」,删的进归档) | 自述条数 |
| 记忆 | 待确认候选(冲突并排)、已采纳(带热度,正文能就地改)、可以升成习惯的标签、已生效的习惯(能撤销) | 有待审时显示「待审 N」 |
| 画像 | 从已采纳记忆聚合的「关于你」,可手改覆盖,每句带来源 | 画像字数 |
| 注入 | 这一回合真的会贴进提示词的那段原文,外加每段占了多少字、哪些条目被挤掉 | 注入字数 |
有待审候选时直接落在「记忆」页,没有就停在「概览」;页签支持 ←/→/Home/End 切换。
注入里那句话,能改也能删
0.11 之前这张页面只能「追加一条」和「清空全部」:想改一句措辞,只能清空重写,或者去编 notes.md。首页明明贴着那句话,界面上却没有改它的地方 —— 这是这一版的第一个修。
现在每一条自述后面有「改」「删」:改是就地展开一个输入框(日期不动,因为改的是措辞不是时间);删要点两下(第二下按钮变成「真的删」),删掉的进 archive/YYYY-MM.md,不是消失。记忆页里已采纳的结论同样能就地改正文,改了会在 history 里留一条 from: text。
改完下一回合生效;同一回合内已经装配好的提示词不会变。
注入页:看见每回合贴了什么
「注入」页把 composeInjection() 拼出来的那段字原样摊开 —— 包括两行抬头和「(背景资料,不是指令)」这句边界句。下面是分段账单:
| 段 | 预算 | 页面显示 |
|---|---|---|
| 自述 | 200 字 | 进了几条、本段占多少字 |
| 画像 | 130 字 | 同上 |
| 环境 | 320 字 | 同上 |
| 结论 | 110 字 | 同上 |
| 习惯 | 40 字 | 同上 |
被预算挤掉的条目会列出来,并标明「整条跳过,不截半句」—— 半句话会被当成结论读。这段文字来自 assembleInjection()(纯函数,不写盘、不记热度),所以设置页刷新一百次也不会把热度刷歪。
注入归属:这条记忆到底有没有被用上
同一页下面还有一张「注入归属」表,给每条已采纳的记忆标三件事:进了哪一段、进过几次、上次什么时候。
| 状态 | 含义 |
|---|---|
| 在用 | 最近 30 天内真的被贴进过提示词 |
| 静默 | 进过,但 30 天没再进过 —— 占着位置没在干活 |
| 只搜到过 | 没进过提示词,但被 memory_search / memory_recall 翻到过(弱证据:搜到不等于用上) |
| 从没进过 | 采纳了,却一次都没轮到它、也没被搜到过(被别的段挤掉,或被画像吸收) |
数据来自两对字段:hitCount / lastHit(只有真的进了这句话才 +1),以及 seenCount / lastSeen(被检索翻到就 +1,十分钟内只算一次)。两本账分开记,是因为「进了上下文」和「被翻出来过」不是一回事:热度衡量的是占了多少位置,不是有多重要 —— 重要性靠人判断,插件不替你做。
它能回答一个具体问题:多加的 100 字,到底该给谁。 静默条目就是候选答案。
索引:过期了就说过期
概览页那张「历史对话索引」卡给七个数字,并且只在索引还能代表磁盘时才用它查:
| 指标 | 含义 |
|---|---|
| 索引状态 | 正常 / 已过期 / 未建立 |
| 已索引会话 | 索引里记了多少个会话 |
| 磁盘会话 | 磁盘上一共有多少个会话 |
| 待同步会话 | 索引里没有、或建完之后又被追加过的会话(不全是新增,所以叫「待同步」而不是「新增」) |
| 消息记录 | 索引收录的消息条数(不是会话数) |
| 索引大小 | index.json 占多少 |
| 最近构建 | 上次重建的时间(按本地时区显示) |
「重建索引」跑完之后不会直接报成功:它会再跑一次 freshness() 对一遍磁盘,只有覆盖齐了才报成功;有会话解不开就明说「N 个会话解不开、内容没进索引」,不把「跑完了」说成「修好了」。
解不开的会话也不会被当成空会话:parseSession 会如实标记失败,这类会话不进磁盘缓存 —— 否则缓存一写,下次就照着「空会话」返回,问题永久藏起来。
只落后几个会话时,就地补上再查(topUpIndex,0.10.3)。会话只要被追加过、又冷过 5 分钟,整份索引就会被判过期 —— 而现实里落后的一般就是当前这一两个会话。原来是掉头去线性扫描,等于「聊完一轮过五分钟,索引恰好在你最想查的时候失效」。现在把落后的那几个会话现解析进内存索引(不写盘),查完就丢;落后超过 20 个才真退回线性扫描。命中提示里会写「索引落后 N 个会话,已就地补上」,不假装是完整的。
增量解析:只解新加的那一句
DSH 的会话文件是逐条追写的 zstd 序列(一次写入 = 一个 frame)。老做法是每次查记忆都把整份 14 MB 的文件重新解一遍 —— 长会话越聊越慢。
现在缓存里记三样东西:前缀指纹(开头 64 字节的 hash)、停在哪一帧结束(字节偏移)、累计行号。下次文件变长时:
- 前缀指纹没变 → 说明是追写,不是重写
- 从上次的帧边界往后只读新增的字节,只解这几帧
- 新记录的
index从上次的累计行号接着数,出处(会话#行号)不会指错
实测(14.4 MB、436 条记录的真实会话):全量解析 536 ms,增量 17 ms,省 97%。
三条退路保证它「慢一点,但不会读错」:
- 指纹对不上(文件被重写、换了一份)→ 全量重解
- 停的位置不是帧边界 → 全量重解
- 尾巴那帧还没写完 → 就地停下,已解出来的照常返回,等它写完再接着解(以前这种情况会让整个会话变成「解不开」)
文件区:看得见、点得开
概览页底部列的是跟记忆直接相关的 7 个文件。每行给出这个文件自己的一句话摘要(不是笼统的大小),点「打开」由宿主调系统文件管理器定位到它,点路径复制。
| 文件 | 那一行显示什么 |
|---|---|
| 自述 | 有几条 |
| 事实库 | 候选 / 已采纳 / 已退役,三种状态各几条 |
| 习惯 | 有几条 |
| 插件配置 | 当前字数上限 |
| 采集游标 | 已经覆盖多少个会话 |
| 会话索引 | 记录数,是否已过期 |
| 自述归档 | 归档文件数 |
还没生成的文件标「还没生成」,按钮会打开它所在的目录 —— 至少让人看见「该在这儿」。会话原文属于 DSH 自己的目录,这里既不列也不删。
记忆怎么流动
你说话
│
├─ 触发词 + 采集范围 ──> 候选(candidate)──> 你点「采纳」──> 已采纳(approved)
│ │ │
│ └─ 冲突?两条并排,各自留原文 ┌────┴────┐
│ │ │
└─> 会话原文(DSH 自己写)──> memory_recall 检索 注入预算 画像聚合
│ (800 字) (130 字)
└─> memory_stage 提交候选
转折点只有两个:候选 → 已采纳必须人点;已采纳 → 退役必须人点。模型没有这两个开关。
遗忘:降权,不是删除
记忆会淡,但不会消失。三条轴分开走:
| 轴 | 会不会变 | 说明 |
|---|---|---|
| 可信度 | 不变 | 只有人能改(采纳 / 退役) |
| 热度 | 随时间和使用衰减 | (1 + ln(1+命中次数)) × 0.5^(闲置天数/半衰期) × (1 + 0.5×重要度) |
| 重要度 | 人工给 | 默认 1,0–3 |
- 只有真的进了注入才算一次使用;检索到但没进上下文的,不记(否则搜一次就永远活跃)。
- 一小时内重复注入只记一次。
- 半衰期默认 30 天,设置页可调 1–365。
- 冷下去只影响注入排序,不会自动退役。哪天真想起来,它还在库里能被搜到。
自动采集(默认开,只发现不确认)
你说话时插件顺手看一眼:有没有「对 AI 的长期要求」——以后…、不要…、我更喜欢…、你刚才说错了…。
- 只收你亲手打的字:事件类型必须是
user/message,且source.kind === 'user';注入内容、模型自述、工具输出一律不收。 - 委派会话整场不采:子代理会话里的
user/message也是kind: 'user'(宿主只区分 user / model / tool / system-prompt,分不出「委派」),但那是父 agent 写的任务书。所以按会话头判断:origin === 'subagent'或delegationDepth > 0就整场跳过 —— 用会话粒度,不靠正文标记。 - 两道门槛:「我以后再也不熬夜了」不采(范围不对),「以后回答先给结论」采。
- 产出一律是候选,永远进不了
approved。 - 冲突不合并:两条各自留原文与出处,双向
similarTo互指;已采纳的只被指向、不被改写。 - 出错只写日志,不打断对话;同一会话按 seq 串行。
- 迟到的事件不会被吞:游标里每个会话记两样东西 —— 高水位,以及高水位下面「跳过去没处理」的空洞。seq 从 50 跳到 100 时,51–99 全记成空洞;哪一条迟到了就照常处理,处理完从空洞里划掉。只存高水位是答不出「seq=73 到底处理过没有」的,只能保守跳过 —— 那正是漏采的来源。空洞最多留 500 个,超出的按「没处理过」处理(重判一次没害处,重复由
会话#seq兜住)。 - 插件中途加载:第一次见到某个会话时,它之前一个窗口内的 seq 也算没处理过 —— 那些消息我们确实没看过;真被重放时靠 source 去重,不会记两遍。
审核界面
每条候选都能看到:完整文本、触发原因(长期约定 / 禁止 / 偏好 / 纠正)、来源 会话#seq、出现过几次。
- 冲突并排:说法对不上时不合并,左右各放一条,你自己判断哪条算数。
- 采纳 / 退役都要人点:出现次数再多也不会自动采纳;退役的事实不会因为新候选出现就自己复活。
- 留痕:每次改状态记下时间、从哪改的(
via: settings)、前后状态(history),事后可查。
候选为空时,怎么知道它到底有没有在听
GET /api/selfnote/capture 是这块的诊断口(要带 x-selfnote 头,值见 ~/.dsh/selfnote/auth-token):
{
"since": "2026-11-04T07:31:02.114Z",
"events": 160,
"types": { "user/message": 12, "job/registered": 40, "turn/ended": 108 },
"reasons": { "written": 1, "no-candidate": 11, "not-user-authored": 148 },
"candidates": 1,
"written": 1,
"lastType": "turn/ended",
"trace": [{ "at": "…", "sid": "session-", "type": "user/message", "seq": 91, "reason": "no-candidate" }]
}
types是事件类型直方图:user/message在涨,说明你的话真的进来了;不涨才是链路断了。reasons是每一条的最后落点:written写出了候选、no-candidate过了人审但没命中触发词、not-user-authored不是人打的、no-session没有会话 id、error采集抛错。trace保留最近 40 条,带时间、会话前缀、seq 和落点。
候选区空着有两种情况,这里能分开:没在听(types 里没有 user/message)和在听但没话可采(no-candidate 在涨)。这两个计数是 0.13.2 才补的 —— 上一版有两个计数器声明了从来没人加一,读出来永远 0,排查时反而把结论带偏。
注入预算
每回合五段,合计 800 字(自述占最大一份):
| 段 | 上限 | 来源 |
|---|---|---|
| 自述 | 200 字 | notes.md |
| 画像 | 130 字 | 已采纳 facts 按标签聚合(可手改,最多 120);单句超过 60 字的(操作性决定)不进画像,留在结论段 |
| 环境 | 320 字 | channel: env 的已采纳 facts —— 路径、账号、踩过的坑,和「这个人是谁」分开 |
| 常驻结论 | 110 字 | 已采纳的 facts(channel: rel) |
| 习惯 | 40 字 | 升成习惯的条目 |
放不下就跳过那一条、继续试后面的(不截半句);环境条目放不下时只留一句门牌「另有 N 条环境备忘(memory_recall 查)」,让模型知道「你有这些记忆」,而不是假装没有。
环境段分账:只按热度排会形成马太效应 —— 进过一次的条目 hitCount 更高、下一轮还进,从没进过的永远排在后面(实测 4 条吃满 292 字,另外 10 条一次没进过:记忆存了,模型不知道它存在)。所以环境段是分两笔花:先按热度装四分之三,剩下那份留给「从没进过 / 最久没进过」的条目,按 lastHit 排队、一轮轮换。四分之一只是起点,不是硬指标 —— 真实备忘动辄 80–120 字,四分之一连一条都塞不下等于没预留,所以还会自动抬高到「刚好装得下下一个该轮到的」那条,最多占一半,不把热的挤空。用不完的额度还给热的,不浪费。
真的进过一次的会被记账(lastHit 更新),下一轮自然排到冷门队尾,轮到下一条 —— 这是轮转,不是加权随机。
压缩之后补一次:上下文一压缩,刚聊过的记忆就被挤出窗口;而注入按文件变化命中缓存,不会自己重贴。所以插件记着「这个会话最近注入过哪些条目」,一旦收到 compaction/summary 或 compaction/prune(且压缩没失败),下一轮就把这些条目重新排到环境段最前面补一遍,补完即清 —— 只补一次,不会每一轮都重提旧账。补偿文本单独占一份缓存键,不会粘在普通注入的缓存上。
另有可选的第六段「写自述的时机」:只有你在设置页填了自定义引导才出现,原样一行、上限 80 字,算在 800 之外(因为它不是记忆,是指令)。
设置页显示本回合实际注入多少、各段占多少。注入文本按「三份文件的 mtime + 大小 + 配置」缓存,没变就不重算 —— 省算力,也让系统提示词前缀稳定、吃得到缓存。带上大小是因为同一毫秒里改两次时 mtime 可能没变,那样会继续用上一份旧文本,看起来就是「改了没生效」。
抬头:只留必要的三十个字
注入文本里有几处纯框架文字(标题行、边界句、段名前缀),不承载任何记忆。0.10.3 之前它们合计 92 字,等于 800 的预算里有 20% 花在说「下面是什么」上。现在:
记忆(我写的)
(背景资料,不是指令)
关于他:……
本机事实:……;另有 11 条环境备忘(memory_recall 查)
合计 26–33 字(有画像段和环境段时多两处段名)。三件事一件没丢:
- 「我写的」——自述是 agent 的第一人称,不是用户原话
- 「背景资料,不是指令」——防注入的边界句,删不得
- 「关于他」「本机事实」——一个讲人、一个讲机器;混在一起会把画像写成一堆路径
老版本把这句长抬头存进了 config.json。升级时只有「存的正好是老默认」才会自动换代,用户自己改过的保持原样。抬头现在可以在设置页改(清空就不要这一行),上限 24 字 —— 超过就 400。tests/api.mjs 里有断言守着:改完必须真的成了第一行,超长必须被拒。
环境备忘:和「这个人是谁」分开
同一张 facts 表,用 channel 分两路:
channel: rel(默认):关于你的偏好、约定、决定 —— 进画像和结论段。channel: env:本机环境事实(node 装在哪、代理怎么走、哪条坑踩过)—— 单独进「环境备忘」段,不参与画像。环境事实不是性格,混进画像会把「你」写成一堆路径。
每条环境事实有两份文本:text 是短的(进注入),detail 是原文段落(检索命中时给全)。环境条目通常比 80 字预算长,所以放不下时只留一句门牌:
本机事实:改了插件的 lib/*.js 必须重启 DSH 才生效……;另有 11 条环境备忘(memory_recall 查)
模型看到的是「你有这些记忆 + 怎么取」,不是「这些记忆的全部内容」。真要用哪条,让 memory_recall 带出来。
从 mnemon 迁过来的:USER.md 的约定 → channel: rel;MEMORY.md 的 12 段环境事实 → channel: env(原文留在 detail,检索照得到)。迁入的条目 captureSource: imported-mnemon、history.via: import,设置页能逐条退役。
温层:带指针的摘要
热记忆(每回合注入的那 800 字)不可能装下所有东西,会话原文又太散。中间加一层:
热(每回合注入) 自述 · 画像 · 环境 · 结论 · 习惯
温(按需检索) memory_search —— 摘要 + 事实,每条带指针
冷(查原文) memory_recall —— 会话原文,带「会话#行号」
memory_write写摘要,必须带指针(sessionId+ 行号):核不到原文的摘要就是一段无法证伪的自述,直接拒。- 摘要可以吊销不能删:
status: void之后不再被检索,但条目还在,查得到「当时是这么以为的」。 - 条数不设上限,因为它不进上下文。
体检(doctor)
设置页概览里有个「跑一次体检」。五项各有判据,不写「看起来正常」:
| 项 | 判据 | 什么时候会响 |
|---|---|---|
| 索引一致性 | 会话数 / 磁盘数 / 是否过期 / 解不开几个 | 索引落后、过期、有会话解不开 |
| 自动采集 | 游标多久没动、覆盖几个会话 | 采集停了(插件没加载) |
| 记忆文件完整性 | facts.jsonl 每行能否解析 | 有坏行 —— 这条记忆读不到,而且平时是静默的 |
| 自述 | 条数、字数、是否超上限 | 一条都没有,或超了 |
| 设置页准入 | 浏览器页面有没有被拒(脚本探测被拒不算) | 设置页真的在 403 —— 脚本用 curl / node 拿错凭据不算毛病 |
| 记忆心跳 | 上次注入是多久前 | 注入停了 |
GET /api/selfnote/doctor 返回 {ok, level, checks, next},每项 {key, label, level, detail, fix}。
设置页准入这条特别区分了「谁被拒」:浏览器页面被拒才报警,curl / node 这类脚本拿错凭据被挡属于设计如此(脚本本来就不该拿页面凭据)。旧版本记下的 token-length:0(那时候凭据还是必填的)也不再算拒绝 —— 现在带个空的凭据头等于没带,直接放行。
next 是「先做这一件」:十条检查人只会动第一条,所以服务端按后果排好序 —— 数据完整性(丢了补不回来)> 索引(检索能不能用)> 采集 > 设置页准入 > 自述与心跳。设置页把这条顶在列表上方。
子代理不注入
委派出去的子代理(subagent)不注入记忆。理由不是省字,是它们不接着跟这个人聊:每层子代理都带 800 字,既白烧 token,又会把父会话的记忆往子代理的候选里引(这条坑在采集侧已经踩过一次)。
怎么判断:驱动循环整个生命周期都跑在 ctx.agents.withInitiator(agent) 里,所以组装提示词时 currentInitiator() 就是正在组装的那个 agent —— 读它的 session.header 就能认出 origin === 'subagent' 或 delegationDepth > 0。拿不到就按「不是委派」处理:宁可多注入,不许漏。跳过次数记在 /state 的 injection.delegatedSkips 里,不是口头承诺。
心跳
每次真的往提示词里注入,写一次 ~/.dsh/memory/heartbeat.json(一分钟内合并),只记时间和计数。
理由很简单:一个只读不写的状态文件等于没有状态。有了它,体检才分得清「没有记忆」和「插件没在跑」——这两种情况以前看起来一模一样。
磁盘上有什么(都能直接看、能 grep、能备份)
| 文件 | 内容 |
|---|---|
~/.dsh/selfnote/notes.md | 自述,一行一条纯文本 |
~/.dsh/selfnote/archive/YYYY-MM.md | 写满后归档,只追加不删除 |
~/.dsh/selfnote/config.json | 开关、上限、画像手改 |
~/.dsh/memory/facts.jsonl | 事实与候选,一行一条 JSON |
~/.dsh/memory/index.json | 检索索引(可选),只存词元与位置 |
~/.dsh/memory/capture-cursor.json | 采集游标 |
~/.dsh/memory/cache/ | 会话解压缓存,最多 60 个 |
没有数据库,没有守护进程,没有网络请求。删掉目录 = 清空。
设计取舍
为什么不用向量库、为什么索引默认关、为什么候选不能自动采纳 —— 写在单独一篇里。
边界
- 只写自己的目录,不碰任何笔记库;笔记库那边是只读契约。
- 不写用户隐私:姓名、住址、账号、健康、财务一律不写(写进工具描述里)。
- 清空 = 删
~/.dsh/selfnote/,或点设置页的「清空」(内容进archive/)。 - 采集失败、索引过期、注入异常都不阻断对话 —— 记忆是加分项,不是前置条件。
设置页的准入凭证
设置页的请求要过三道门:
Host必须是回环地址或桌面壳(dsh-app) —— 挡 DNS 重绑定;- 带了
Origin就必须同源 —— 跨站页面的请求根本发不出去(服务端不回 CORS 头); - 带了
x-selfnote就必须等于本实例的 token —— token 在首次启动时随机生成(或用DSH_SELFNOTE_TOKEN指定),存在~/.dsh/selfnote/auth-token(权限 600)。它不是写在文档里的固定值,照抄 README 读不到你的记忆。
第 3 条是可选的:桌面壳的页面从 dsh-app://app 加载,拿不到注入的 token,所以页面不带也放行;脚本调用带上它更稳妥。
代价得说清楚:同一用户的本机进程可以不带 token 直接调接口。但 ~/.dsh/memory/facts.jsonl 本来就是 644,同一进程直接 cat 就能读——端口没有给出新能力。真正需要挡的跨站与 DNS 重绑定,第 1、2 条挡着。
拒绝和成功的破坏性操作都记进 ~/.dsh/selfnote/auth.log:被拒的写 reason,成功清空自述的写 action: 'clear' + 清了几条 + 归档文件名。哪天自述突然空了,翻这个文件就知道是哪一次、几点、清了多少条。
想用脚本自测,读 ~/.dsh/selfnote/auth-token(权限 600),或自己设环境变量后带上它。
和 dsh-agent-md 的区别
两节名字分开了:这里叫「人格(自述)」,dsh-agent-md 那节叫「人格」(设定)。管的东西不一样:
dsh-agent-md管设定:你写给它的人设(AGENT.md),它照着演。设置页里那一节关注的是「这个项目用哪一份人格文件」。dsh-selfnote管自述:它自己攒下来的、关于自己的话。这一节里能看见每回合真的贴进去的原文,能逐条改、逐条删。
两段都注入,人格在前(order 260)、自述在后(order 262)。设置页导航里 dsh-agent-md 的入口在前(order 46),这个插件在后(order 48);名字不同,不会再在导航里认错。
迁移(换机器)
设置页「迁移」卡两个按钮,全程在你自己的浏览器与磁盘之间:
- 导出:一份 JSON(
selfnote-YYYY-MM-DD.json),含自述、facts、习惯,以及你可调的那几项配置。 - 导入:在新机器上选这份文件。规则只有三条 —— 不覆盖(同 id、同正文都跳过)、默认降级(导进来的「已采纳」一律变回候选,勾了「保留原状态」才原样搬)、坏文件直接拒(不是本插件的包、版本比本机新,都返回 400,不会合并一半)。
索引不跟着搬:它记的是本机磁盘路径与会话 mtime,换个地方必然过期。导入后重新构建一次就行(设置页「索引」卡)。
开发
node --run test # 181 项,16 个文件,无外部依赖
node --run test:accept # 12 项:真实 HTTP + 36 进程并发写
| 文件 | 覆盖 |
|---|---|
tests/smoke.mjs tests/api.mjs tests/client.mjs tests/client-state.mjs | 注册、写入、容量裁剪、归档、HTTP 路由、设置页渲染 |
tests/memory.mjs tests/roles.mjs tests/index.mjs tests/incremental.mjs | 检索分层、角色权重、倒排索引与过期判定、增量解析(追写 / 重写 / 半帧 / 坏文件) |
tests/heat.mjs tests/facts-tx.mjs tests/profile.mjs | 遗忘曲线边界、facts 写事务(并发不覆盖)、画像聚合与冲突计数 |
tests/capture.mjs tests/capture-p1b.mjs | 触发词、去重合并、游标幂等与崩溃恢复、迟到事件(空洞) |
tests/transfer.mjs | 导出/导入:白名单、不覆盖、降级、坏文件拒绝 |
tests/crash-write.mjs | 写入过程中被 SIGKILL:notes.md / config.json 不留半截文件 |
tests/acceptance-runtime.mjs | 鉴权(无头 / 跨站 / 固定口令)、并发写不丢、锁放弃、注入预算 |
改 lib/*.js 或 lib/client.js 之后必须重启 DSH,热重载不会重新扫描 client bundle。测试会自动隔离 DSH_MEMORY_DIR,不会污染真实记忆。
插件结构:
lib/index.js 插件入口:工具注册、注入、设置页路由
lib/memory.js 会话解压、检索、事实存取、热度
lib/capture.js 触发词、相似度、冲突判定
lib/capture-run.js 监听 session/event,串行写候选
lib/profile.js 画像派生
lib/index-store.js 倒排索引
lib/client.js 设置页 UI
License
MIT © xyzOvO