跳过主要内容
X

dsh-selfnote

xyzovo/dsh-selfnote

五段预算制的自我叙述记忆(共 800 字,不是整段上下文):模型写给自己看的第一人称自述、从已采纳记忆聚合的「关于你」画像、与本机环境事实分开的画像、按热度排序的结论、以及习惯。每条注入的事实都能回溯到具体事实 id,可一键退役;画像可手改覆盖,自述则是可清空的纯文本行。事实按使用与时间衰减,但可信度不随时间变。你说过的长期要求会被自动识别成候选(永不自动采纳),互相冲突的两条并排留着。另外带出处的历史检索、只解新增帧的增量索引、必须有指针的温层摘要、能说清哪里坏了的体检,证明注入还活着的心跳,以及能整体搬到另一台机器的导出/导入。

安装

dsh plugin --profile web add github:xyzovo/dsh-selfnote

README

dsh-selfnote

dsh-selfnote

记忆不是聊天记录的堆,而是它自己攒下来的几句话。
Self-narrative memory for DeepSeek Harness — the model writes notes to itself, they survive across sessions, and come back every turn.

version license node tests


想法

为什么做这个插件、怎么理解「记忆」和「遗忘」—— 写在单独一篇里。

这个插件做了什么

一句话:把「记忆」从「检索一堆聊天记录」变成「几句它自己写的自述 + 几条你点头过的事实」。

层谁写存在哪会不会忘
自述模型自己(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,三件事同时出现:

  1. 模型多了上面三个工具;
  2. 设置页多一节「人格(自述)」(在「知识库」下面);
  3. 每回合的系统提示里,多一段最多 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)、停在哪一帧结束(字节偏移)、累计行号。下次文件变长时:

  1. 前缀指纹没变 → 说明是追写,不是重写
  2. 从上次的帧边界往后只读新增的字节,只解这几帧
  3. 新记录的 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/)。
  • 采集失败、索引过期、注入异常都不阻断对话 —— 记忆是加分项,不是前置条件。

设置页的准入凭证

设置页的请求要过三道门:

  1. Host 必须是回环地址或桌面壳(dsh-app) —— 挡 DNS 重绑定;
  2. 带了 Origin 就必须同源 —— 跨站页面的请求根本发不出去(服务端不回 CORS 头);
  3. 带了 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

相关插件