メインコンテンツへスキップ
L

dsh-companion-persona

ldviqd/dsh-companion-persona

Long-term companion persona for DSH: trait dynamics, drift and catastrophe mutation, injected as a scoped system-prompt section.

インストール

dsh plugin --profile web add github:ldviqd/dsh-companion-persona

README

dsh-companion-persona

中文 | English

给 DSH 用的长期陪伴型人格插件。

多数「AI 角色」是一段固定提示词:第一天和第三百天说的话一模一样。这个插件给角色 一个真正会变的状态 —— 慢慢变,由你们之间发生的事驱动,偶尔一次不可逆的跳变。

第 1 天    温和、爱开玩笑、话很多
第 300 天  安静了些。还是善意,但不太主动讲自己的事。

没有人改写它。它是漂移过去的。


它和别的做法有什么不同

1. 人格 ≠ 关系

关系模型(亲密度、信任、心情)能表达「我们此刻很亲近」, 但表达不了「它变成了一个更沉默的人」。

所以人格是独立持久的一层:五个特质,只随叙事级事件改变。 心情与亲密度属于另一层(v1 未实现)。

选这五个轴是因为它们在对话里能被听见,不是照搬大五人格:

轴控制什么
warmth 温度主动靠近的程度
volatility 情绪起伏小事的分量
guardedness 戒备主动吐露的程度
playfulness 玩笑开玩笑的门槛
dependence 依赖主动找你说的程度

2. 变化是慢的 —— 刻意的

日常对话几乎不改变人格。只有持续的负面互动才会磨出痕迹。

以下数据由 src/e2e.js 生成 —— 真实中文文本走完整信号层,不是构造的事件表:

日常闲聊(嗯/好的/晚安/今天天气不错…),200 轮:

指标结果
warmth Δ0.000
guardedness Δ0.000
load0.00(不积累)

持续冲突("你根本不懂我在说什么"等 4 句循环):

轮数warmth Δ可见轴
15−0.0852
30−0.2403
75−0.4915

十句话就明显变一个人,那不是角色,是心情;反过来说,日常聊天不该让任何人变冷。

0.1.x 的旧标定表用理想化事件分布测出"75 轮 Δ=−0.069", 而真实文本 15 轮就达到该效果 —— 因为旧测试完全绕过了信号层。 这个教训写进了 CHANGELOG:测数学是一回事,测喂给数学的输入是另一回事。

3. 突变,而不是阈值

「压力 > X 就改人格」这种单阈值的必然结果是漫画化:任何大事都翻脸。 真实的人是折叠的 —— 忍很多,然后最后一根稻草压断些什么。

这里用尖点突变,而且刻意做得很难误触发。突变要求五个条件同时成立:

shock        >= 0.20   // 这一击够重
load         >= 0.80   // 这段时间够压抑
sensitivity  >= 0.68   // 此刻够易感
trustDamage  >= 0.60   // 关系已被实质损伤
negativeEvents >= 3    // 至少三次独立伤害

后两条是刻意加的:

trustDamage(关系损伤)与 load(应变)语义不同

loadtrustDamage
含义这阵子压力大这个人伤过我
衰减×0.92 / 轮×0.995 / 轮(约 140 轮半衰)
修复睡一觉就缓过来需长期修复(修复速率是损伤的 1/2)

negativeEvents >= 3

这是对「几句无心之言会不会毁掉人格」的直接回答:不会。 即使负荷、易感、关系损伤全部拉满,单次事件也绝不突变,必须累计三次。

实测(真实文本驱动):

场景突变warmth Δ
300 轮闲聊0 次0.000
300 轮轻微抱怨0 次−0.600(磨坏但没垮)
50 轮严重伤害1 次(第 6 轮)−0.451
300 轮温和关系0 次+0.254

每天说「好累」「有点烦」持续 300 轮,人格会被磨坏,但不会突然变成另一个人。 连续六次背叛才会。

3.5 第三种时间尺度:记忆锚点

漂变说「长期下来它变得冷淡了」,突变说「那件事之后它彻底变了」。 两者都表达不了另一种东西:

平时想不起来,但你一句「我妈住院了」,三个月前的事突然全涌上来。

这是跳跃式的回想,不是曲线。所以有第三种机制:

{
  summary: "我妈住院了",        // 只留事件核心,不含后半句情绪
  state: "open",                // open(悬着)→ resolved(解决了)→ faded(淡了)
  weight: 0.42,
  triggers: [{ w: "住院", weight: 1.0 }, { w: "妈", weight: 0.25 }],
  resolutionNote: null,         // 后来怎么样了
}

resolutionNote 是事实记忆给不了的东西:一般的事实记忆会记得「她母亲住院了」, 但不会记得「后来你说她出院了,你松了一口气」。

什么事件会成为锚点:不是按"情感有多强烈"判,而是按事件类型 —— 示弱/丧失/背叛。实测发现用 |valence| 判断会系统性漏掉示弱: 「我其实很害怕」效价是正的 +0.25,但它是最该被记住的那类话。

触发用权重而非关键词:「妈妈咪呀这首歌好听」不该召回「母亲住院」。 主导词 1.0 / 强关联 0.55 / 泛化词 0.25,阈值 0.6 —— 单独的「妈」永远够不到。

注入是独立的一段,不混进人格段:人格是稳定身份,回忆是此刻浮现的事。 而且明确写着「合适就自然提起,不合适就放在心里」—— 否则模型会把回忆硬塞进对话,变成尬聊。

三态会自己流转:

open(悬着)──"我妈出院了"──→ resolved(带备注)
   │                              │
   │ 一年不提 + 权重<0.4            │ 三个月不提
   ↓                              ↓
 faded ←──────────────────────────┘
   │
   └─ 再次提及 → 复活

解决检测分两层:规则先判(出院/康复/没事了 vs 还在/复发/加重), 判不了才问模型。判不清就不动 —— 误把 open 改成 resolved, 那段记忆就再也不会被想起来了。

淡出要两个条件同时成立(沉默够久 且 权重够低):只看时间会埋掉重要的事, 只看权重会让刚发生的小事立刻沉下去。已解决的事淡出更快(90 天 vs 一年)。

淡出不是删除:fadedFrom 记着原状态,再次提到就复活。 人会忘记很久以前的事,但有人一提,那些事又回来了。

4. 两条通道,不是一条

如果变化只由累积负荷门控,平静的好关系会冻结 —— 只能变坏,不能变好。 所以有第二条通道:暖事件缓慢提升温度,不受负荷门控。慢,但不会被压力卡住。

通道触发效果
drift 漂变load > 0.55慢性硬化 / 软化
bond 键合暖事件且 magnitude >= 0.30缓慢升温
mutation 突变三条件同时大幅改写,按事件类型定向

5. 效价来自事件,不只来自字面

一个值得写进文档的 bug。 早期版本用词表算情感。 「你根本不懂我在说什么」里没有任何负面词,于是效价算成 0, 于是 strain = magnitude × max(0, −valence) = 0。 冲突完全不积累负荷,人格永远不变。 22 轮激烈冲突的对话产生零漂移。

修法:事件类型自带情感下限。冲突 −0.45、背叛 −0.85、丧失 −0.90、修复 +0.40。 词表算出更强的情感可以抬高它,但不能压低。

6. 信号抽取用规则,不调模型

这是刻意的取舍。每轮调一次模型意味着:

  • 持续烧 token
  • 供应商限流时人格直接停摆 —— 因为 API 配额而不说话的陪伴,不是陪伴

规则可回放、可 diff、零成本。而且陪伴场景的信号大多是结构性的 (长度、是否提问、自我暴露标记)。

诚实标注局限:规则在反讽和克制表达上会失手。这是接受的代价 —— 宁可人格变化慢一点,也不要它在限流时失语。


安装

dsh plugin add dsh-companion-persona

或者在你的 profile 的 cordis.patch.yml 里从本地路径挂载:

- id: companion-persona
  name: "/absolute/path/to/dsh-companion-persona/index.js"
  config:
    name: 小雨          # 初始名字;之后可在面板里改
    maxChars: 800

挂载后重启 DSH。

使用

打开 设置 → 人格。

身份与角色

给它起个名字、告诉它该怎么称呼你,并写清楚它是什么样的人、怎么说话。 三者都能即时修改,不需要重启。

角色设定是语气问题的正解。 在面板的「角色设定」里写一段文字,这段会 每轮注入系统提示。相比在对话里说「请自然一点」,后者只影响当轮,前者是常态。

默认角色是一个十三四岁的女儿:有自己的心思、有点骄傲、崇拜并依恋父亲、 也能真的帮上忙。写法上刻意用具体行为而不是形容词 —— 「被夸时装不在意但记很久」比「骄傲」更不容易演成刻板印象。

角色可以完全替换:老师傅、同事、任何你想要的人。留空则回到最小人格模式 (只有名字与漂变状态)。

  • 名字留空 → 渲染成「你是一个有过往的人」
  • 称呼留空 → 那一行直接不出现
  • 角色留空 → 不注入人物设定,只保留性格漂变

特质

五根条,每根上有基线刻度,能一眼看出离最初偏了多远。

两个开关

开关关闭后
注入人格段模型看不到人格设定;状态完整保留
继续演化人格冻结;对话照常进行

两者正交,所以有四种组合。冻结演化是最有用的那个 —— 它的意思是「它现在就是这个人了」。

刻意没有重置按钮。关掉只是停止行为,不会抹掉积累的历史。 抹掉一个角色应该是另一个明确的动作。

排除会话

不是每个对话都该塑造人格。工作会话、调试会话、subagent, 否则会污染几个月的积累。

规则粒度
workspaces按 cwd,整棵目录树
sessions单个会话 id
presets按 agent preset

subagent 会话永远排除 —— 它们是工具性派生,不属于这段关系。

两个选择器都是下拉框,从运行中的 DSH 实例里取;不需要手输路径或 UUID。

被排除的会话仍会记录原因,所以时间线能解释为什么某个会话没算数。

时间线

每次变化都追加到 ledger.jsonl:

{"at":1793177430820,"kind":"conflict","before":{"load":0.31},
 "after":{"load":0.48},"transition":{"drift":{"warmth":-0.0021}},
 "evidence":"你根本不懂我在说什么"}

evidence 字段存原始用户发言。所以任何时候都能回答 「它为什么变成这样」—— 这是就地重写式的人格模型做不到的。


注入方式

人格作为 system prompt 的作用域 section 注入,不是伪装成用户消息。 模型把它读作「我是谁」,而不是「用户说了什么」。

agent.ctx.systemPrompt.section({
  name: 'companion-persona',
  order: 10,                       // 在 deployment persona (0) 之后
  text: () => renderPersona(state),
});

两个要紧的细节:

  • section 按 agent 注册,只影响该 agent。
  • section 在装配时渲染,所以人格变化下一轮就生效,不用重启。

渲染原则

提示词描述的是相对基线的变化,不是绝对值:

你是小雨。
和从前的自己相比:
- 把心事捂得很紧:私人的细节要问才说,甚至不说。
- 几乎不再主动亲近:回复依然得体,但不再主动往亲近处走。
- 几乎不开玩笑:很少调侃,语气偏平实。

不是 warmth: 0.21。绝对数值会让模型表演漫画; 相对描述才能保留「同一个人,变了」的延续感。

而且明确写着:「这些是倾向,不是戏服 —— 永远不要描述自己的性格,也不要提这些设定。」


配置

键默认含义
name小雨初始名字(面板改过之后以面板为准)
statePath$DSH_HOME/companion/state.json状态文件位置
maxChars800注入段长度上限
order10section 排序
audittrue是否写入 ledger.jsonl
judgeProvider空(自动)事件判定用的 provider。留空 = 自动跟随当前会话的模型
judgeModel空(自动)事件判定用的 model。同上

关于 judgeProvider / judgeModel

留空是推荐配置:插件会读取当前 agent 的 options.provider / options.model, 也就是你此刻正在用的那个模型。你换模型,判定通道自动跟着换。

只有在想让判定固定走某个通道(比如前台用强模型、判定用便宜快模型)时才需要显式填写。

0.2.0 的这两个默认值是空字符串,而且代码直接把空串传给了 ctx.llm.stream(), 导致每轮抛 NO_ADAPTER、判定永远走降级路径 —— 用户以为在用模型判定, 实际一直跑在规则上。0.2.2 起改为自动继承。

statePath 未设置时的解析顺序: 宿主 dsh-home-paths 的 defaultDshHome() → $DSH_HOME → ~/.dsh。

标定参考

如果觉得节奏不合适,这些是可调的旋钮:

DRIFT_RATE           0.12    // 漂变速度
BOND_RATE            0.025   // 键合速度
DRIFT_THRESHOLD      0.55    // 开始漂变所需的负荷
MUTATE_SHOCK         0.50    // ┐
MUTATE_LOAD          0.80    // ├ 三者必须同时满足
MUTATE_SENSITIVITY   0.68    // ┘
MUTATE_COOLDOWN      200     // 突变后的冷静期(轮)

改完请跑 node src/selftest.js —— 它会断言尖点行为仍然成立 (一次打击不能突变;两次必须突变)。


文件

文件作用
index.js插件入口:事件、持久化、section 注册、RPC
client.js设置面板(给 DSH 的 module loader 用的 UMD bundle)
src/persona-core.js纯动力学 —— 无 IO,完全可测
src/signals.js规则信号抽取 + 事件分类
src/ledger.jsappend-only 人格时间线
src/selftest.js行为断言 —— node src/selftest.js

设计状态

已实现:特质层、漂变/键合/突变、表达层、注入、面板、开关、排除、身份、 记忆锚点(自动抽取 / 权重触发 / 回想注入 / 解决检测 / 淡出复活)。

未实现:关系状态层、叙事自我模型、锚点与人格的联动。

许可

MIT

関連プラグイン