dsh-daughter 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  > 记忆跟着角色走,不跟着会话走:换对话框、换工作区,关系与经历都还在。
6
6
 
7
+ > ⚠️ **用途边界**:本插件是给 **DSH 做人格演变**用的。它的记忆与情绪会**跨窗口贯通、并带时间感**(在 1 号窗口说一句,切到 2 号窗口她会接上一句)。**不要在正式/工作对话里开启**,否则私人聊天里的记忆会串进你的工作上下文,污染正常问答。
8
+
7
9
  ## 对接对象(先看这张表)
8
10
 
9
11
  | 项 | 具体是什么 |
@@ -41,6 +43,8 @@ npx dsh-daughter-token --write-to "<VPet 安装目录>\mod\DshDaughter\plugin"
41
43
 
42
44
  也可以手动把 `brain/brainstem/connector_token` 的内容复制成 `plugin/token.txt`,或设置环境变量 `DSH_DAUGHTER_TOKEN`。令牌缺失时 MOD 仍会启动,但请求会被 401 拒绝,日志里会写明原因。
43
45
 
46
+ > 如果宿主把 `storage.data_root` 改到了别处,取令牌时要一起指定:`npx dsh-daughter-token --data-root <数据目录>`(或设 `DSH_DAUGHTER_DATA_ROOT`)。不指定的话,它会在插件目录里新建一个令牌,和插件实际用的那个对不上,连接器就会一直 401。
47
+
44
48
  **怎么编译**(MOD 源码在 `connectors/vpet/mod-src/`,目标框架 `net10.0-windows`):
45
49
 
46
50
  ```powershell
@@ -118,6 +122,9 @@ https://fastly.jsdelivr.net/gh/han0han0le0ni/dsh-daughter@main/market/index.json
118
122
 
119
123
  之后在「发现」里搜 `daughter` 安装即可。索引里每个版本都钉死了 commit,装到的是可复现的版本。
120
124
 
125
+ > jsDelivr 对 `@main` 有分支级缓存,刚发版时索引可能还停在旧版本。要立刻拿到最新索引,用固定 commit 的地址(每次发版后把 `<sha>` 换成当次发布提交):
126
+ > `https://fastly.jsdelivr.net/gh/han0han0le0ni/dsh-daughter@<sha>/market/index.json`
127
+
121
128
  > 社区目录(`dsh-plugin-catalog`,也就是「发现」里默认那份)收录的是 npm 上的包,本插件发布到 npm 之后会被抓取;在那之前,请用上面的自带索引源。
122
129
 
123
130
  **2. 直接从 git 装**
@@ -11,6 +11,8 @@
11
11
  * npx dsh-daughter-token --rotate
12
12
  *
13
13
  * 参数:
14
+ * --data-root <目录> 指定数据根目录(宿主把 storage.data_root 改到别处时必须传,
15
+ * 否则会在这里新造一个令牌,和插件实际用的那个对不上)
14
16
  * --write-to <目录> 同时写入 <目录>/token.txt(MOD / 壳直接读)
15
17
  * --rotate 删掉旧令牌重新生成(所有连接器都要换新值)
16
18
  * --json 机器可读
@@ -35,7 +37,11 @@ const value = (name, fallback) => {
35
37
  const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
36
38
 
37
39
  const config = loadConfig(pluginRoot, silentLogger);
38
- const dataRoot = resolveDataRoot(pluginRoot, config);
40
+
41
+ // 宿主可能用 storage.data_root 覆盖 config.yaml 里的值;这里允许显式指定,
42
+ // 避免在包目录里新造一个令牌,而插件实际读的是另一个位置
43
+ const dataRootOverride = String(value('data-root', process.env.DSH_DAUGHTER_DATA_ROOT ?? '')).trim();
44
+ const dataRoot = dataRootOverride ? resolve(pluginRoot, dataRootOverride) : resolveDataRoot(pluginRoot, config);
39
45
  const tokenPath = join(dataRoot, CONNECTOR_TOKEN_FILE);
40
46
 
41
47
  if (has('rotate')) {
package/config.yaml CHANGED
@@ -3,7 +3,7 @@
3
3
  # 这里是一份"开箱即用"的保守默认值,不含任何个人偏好:
4
4
  # - 所有数值都是代码内置默认值,改这里等价于改默认行为
5
5
  # - 想调整请直接改本文件;升级插件会覆盖本文件,个人配置建议另存备份
6
- # - 未实现的配置项(desktop / backend / shared_across_*)会在启动时告警并忽略
6
+ # - 未实现的配置项(backend)会在启动时告警并忽略;desktop 默认关闭,需显式打开
7
7
  version: 1
8
8
  character_id: daughter-default
9
9
 
@@ -42,6 +42,46 @@ association:
42
42
  # 换对话框后的第一句也靠它续接"上一个对话的结尾"。关掉即完全沉默。
43
43
  fallback_when_no_match: true
44
44
 
45
+ # 窗口只是"工位",不是记忆分区:换到另一个对话框,刚在别处说的话仍会被想起(§8.2)
46
+ continuity:
47
+ enabled: true
48
+ # 别的窗口里多久以内的话算"刚刚",会补进当前窗口(分钟)。隔太久就不再当"刚才"提
49
+ cross_window_minutes: 45
50
+ # 每轮最多补几条"别处刚说的话",避免把整个对话历史都塞进来
51
+ cross_window_max: 2
52
+
53
+ # "局"(进行中的活动):同一件事跨窗口、跨轮会被切成一堆事件,这里归并成一个 thread,
54
+ # 注入时给前情摘要而不是单条碎片(§8.2)。纯规则、不调模型:Dice 相似度 + 时间间隔。
55
+ threading:
56
+ enabled: true
57
+ # 同一局两次发言的最大间隔(分钟),超过就算新的一局
58
+ gap_minutes: 45
59
+ # 标题 Dice 相似度门槛(0–1),达到才算同一局
60
+ similarity: 0.25
61
+ # 一局多久没动静就算结束(分钟)。超过这个时长就算"已结束(一直没动静)",
62
+ # 不再当进行中的事往下推,但仍然留在寄存器里当落脚处
63
+ activity_ttl_minutes: 180
64
+ # 已结束的局再留多久才彻底出寄存器(分钟)。默认 24 小时:
65
+ # "昨晚那局"要还能看见,但别让旧账越攒越多
66
+ closed_retention_minutes: 1440
67
+ # 收尾话术:听到这些就算这一局结了(标"已结束"再留一会儿),不必等它自己凉。
68
+ # 判错只是少挂一局,不会丢记忆;想更宽松就往里加词
69
+ closing_phrases:
70
+ - 先这样
71
+ - 不玩了
72
+ - 不聊了
73
+ - 下次再说
74
+ - 回头再说
75
+ - 就到这儿
76
+ - 到此为止
77
+ - 收工
78
+ - 晚安
79
+ - 睡了
80
+ # 前情摘要最多带几轮
81
+ max_summary_turns: 4
82
+ # 每轮最多注入几个"进行中的局"
83
+ max_injected: 2
84
+
45
85
  skills:
46
86
  max_main: 1
47
87
  max_associated: 2
@@ -165,6 +205,10 @@ budget:
165
205
  persona: 300
166
206
  state_hint: 150
167
207
  event_digest: 1400
208
+ # 跨窗口块独立于当前话题检索:预算裁剪先动事件块,也动不到它
209
+ cross_window: 200
210
+ # 进行中的局:两个窗口共享同一局的前情摘要,优先级最高
211
+ thread: 200
168
212
  skill_digest: 500
169
213
  association: 300
170
214
  constraint: 120
@@ -182,7 +226,11 @@ safety:
182
226
 
183
227
  storage:
184
228
  scope: character # 仅支持 character;其它取值会告警并按角色级运行
229
+ # 跨会话是否接力「此刻」的 L5(情绪/压力/疲劳/安全感)。事件库与人格始终是角色级。
230
+ # true = 换对话框后接着上一段,按闲置时长自然回落(默认,像人:换房间不清空情绪)
231
+ # false = 每个会话从基线重新出生(旧行为,彻底隔离)
185
232
  shared_across_sessions: true
233
+ # false = L5 只在同一 workspace 内接力,换项目/目录不串味(工作窗口与私人窗口互不污染)
186
234
  shared_across_workspaces: true
187
235
  data_root: . # 相对于插件根目录;运行时数据都写在这里
188
236
  backend: sqlite
package/dist/config.js CHANGED
@@ -33,6 +33,32 @@ export const defaultConfig = {
33
33
  hop_decay: 0.6,
34
34
  fallback_when_no_match: true,
35
35
  },
36
+ continuity: {
37
+ enabled: true,
38
+ cross_window_minutes: 45,
39
+ cross_window_max: 2,
40
+ },
41
+ threading: {
42
+ enabled: true,
43
+ gap_minutes: 45,
44
+ similarity: 0.25,
45
+ activity_ttl_minutes: 180,
46
+ closed_retention_minutes: 1440,
47
+ closing_phrases: [
48
+ '先这样',
49
+ '不玩了',
50
+ '不聊了',
51
+ '下次再说',
52
+ '回头再说',
53
+ '就到这儿',
54
+ '到此为止',
55
+ '收工',
56
+ '晚安',
57
+ '睡了',
58
+ ],
59
+ max_summary_turns: 4,
60
+ max_injected: 2,
61
+ },
36
62
  skills: {
37
63
  max_main: 1,
38
64
  max_associated: 2,
@@ -113,6 +139,8 @@ export const defaultConfig = {
113
139
  persona: 300,
114
140
  state_hint: 150,
115
141
  event_digest: 1400,
142
+ cross_window: 200,
143
+ thread: 200,
116
144
  skill_digest: 500,
117
145
  association: 300,
118
146
  constraint: 120,
@@ -256,8 +284,12 @@ function warnUnsupported(config, logger) {
256
284
  scope: config.storage.scope,
257
285
  });
258
286
  }
259
- if (!config.storage.shared_across_sessions || !config.storage.shared_across_workspaces) {
260
- logger.warn('dsh-daughter: 跨会话/跨工作区共享固定开启,shared_across_* 目前不生效');
287
+ // shared_across_* 现在真的生效了:只作用于「此刻」的 L5 接力,事件库与人格仍是角色级
288
+ if (!config.storage.shared_across_sessions) {
289
+ logger.info('dsh-daughter: shared_across_sessions=false,每个会话从基线重新出生(L5 不接力)');
290
+ }
291
+ else if (!config.storage.shared_across_workspaces) {
292
+ logger.info('dsh-daughter: shared_across_workspaces=false,L5 只在同一工作区内接力');
261
293
  }
262
294
  if (!config.storage.single_writer) {
263
295
  logger.warn('dsh-daughter: single_writer=false,已关闭写锁,请确认没有并发写者');
@@ -291,6 +323,26 @@ function mergeDeep(base, override) {
291
323
  return result;
292
324
  }
293
325
  function normalizeConfig(config) {
326
+ // 隔离开关:只有明确写 false 才关,其它写法(缺省、字符串、undefined)都按默认开启
327
+ config.storage.shared_across_sessions = config.storage.shared_across_sessions !== false;
328
+ config.storage.shared_across_workspaces = config.storage.shared_across_workspaces !== false;
329
+ // 跨窗口时间感:分钟取整且至少 1,条数夹在 0–8
330
+ config.continuity.enabled = config.continuity.enabled !== false;
331
+ config.continuity.cross_window_minutes = Math.max(1, Math.round(num(config.continuity.cross_window_minutes, 45)));
332
+ config.continuity.cross_window_max = Math.max(0, Math.min(8, Math.round(num(config.continuity.cross_window_max, 2))));
333
+ // 局的归并:相似度夹在 0–1,时间窗口与条数取整
334
+ config.threading.enabled = config.threading.enabled !== false;
335
+ config.threading.similarity = clampUnit(config.threading.similarity, 0.25);
336
+ config.threading.gap_minutes = Math.max(1, Math.round(num(config.threading.gap_minutes, 45)));
337
+ config.threading.activity_ttl_minutes = Math.max(1, Math.round(num(config.threading.activity_ttl_minutes, 180)));
338
+ config.threading.closed_retention_minutes = Math.max(1, Math.round(num(config.threading.closed_retention_minutes, 1440)));
339
+ // 收尾话术:只留非空字符串;整组被写成空时退回默认,否则"结了没"永远判不出来
340
+ const closing = Array.isArray(config.threading.closing_phrases)
341
+ ? config.threading.closing_phrases.filter((phrase) => typeof phrase === 'string' && phrase.trim().length > 0)
342
+ : [];
343
+ config.threading.closing_phrases = closing.length > 0 ? closing : [...defaultConfig.threading.closing_phrases];
344
+ config.threading.max_summary_turns = Math.max(2, Math.min(12, Math.round(num(config.threading.max_summary_turns, 4))));
345
+ config.threading.max_injected = Math.max(0, Math.min(6, Math.round(num(config.threading.max_injected, 2))));
294
346
  config.retrieval.top_k = Math.max(1, Math.round(num(config.retrieval.top_k, 5)));
295
347
  config.retrieval.min_semantic = clampUnit(num(config.retrieval.min_semantic, 0.01), 0.01);
296
348
  // 回看感受的触发距离:0 会让每次召回都写一条,1 等于永不写,两端都无意义
package/dist/index.js CHANGED
@@ -2,24 +2,25 @@ import { loadConfig, resolveDataRoot } from './config.js';
2
2
  import { performance } from 'node:perf_hooks';
3
3
  import { Store } from './store.js';
4
4
  import { Observability } from './runtime/observability.js';
5
- import { createRng, relativeTime, silentLogger, truncate } from './util.js';
5
+ import { createRng, relativeTime, safeFileName, silentLogger, truncate } from './util.js';
6
6
  import { applyBodySignal, pickMotionByPad } from './runtime/body-link.js';
7
7
  import { decideIngest } from './runtime/filter.js';
8
- import { createInitialL5, selectSubpersona, updateL5 } from './runtime/state.js';
8
+ import { carryL5, createInitialL5, selectSubpersona, updateL5 } from './runtime/state.js';
9
+ import { assignThread, isDialogueSource, pruneOpenThreads } from './runtime/threading.js';
9
10
  import { evaluateSpeak, localDayKey } from './runtime/speak.js';
10
11
  import { readForeground } from './runtime/desktop-observe.js';
11
12
  import { containsDiagnosisLabel, evaluateSafety } from './runtime/safety.js';
12
13
  import { applySurprise, associateEvents, extractEvent, linkNewEvent, pickAssociationSeed, recallDrift, reconstructOnRecall, retrieveEvents, } from './runtime/memory.js';
13
- import { NO_MEMORY_CONSTRAINT, activateSkills, buildBlocks, buildEventDigest, buildPersonaText, buildRelationText, buildSkillDigest, buildStateHint, clipBlocks, matchReflex, WORKING_STYLE, } from './runtime/content.js';
14
+ import { NO_MEMORY_CONSTRAINT, activateSkills, buildBlocks, buildCrossWindowDigest, buildEventDigest, buildPersonaText, buildThreadDigest, buildRelationText, buildSkillDigest, buildStateHint, clipBlocks, matchReflex, WORKING_STYLE, } from './runtime/content.js';
14
15
  import { applyPersonalitySignal } from './runtime/personality.js';
15
16
  import { applyPersonaDrift, buildPersonProfiles, buildStarMap, buildThingProfiles, bumpSkill, learnDraftSkill, maybeFreezeSkill, runTick, } from './runtime/maintenance.js';
16
17
  /** §6.1 时延预算:超时只告警与记日志,不做硬中断(SQLite 同步查询无法中途取消) */
17
18
  const RETRIEVE_BUDGET_MS = 150;
18
19
  const BEFORE_PROMPT_BUDGET_MS = 100;
19
- /** 换对话框后的第一句:接上"上一个对话的结尾"最多带几条、往前看多久 */
20
- const CONTINUATION_LIMIT = 2;
21
- const CONTINUATION_WINDOW_MS = 30 * 86_400_000;
22
- /** 已见过首轮的会话集合上限,超了整体清空(只影响"是否首轮",丢一次判断无副作用) */
20
+ /** 换对话框后的第一句:接上"上一段对话的结尾"最多带几条、往前看多久 */
21
+ const CONTINUATION_LIMIT = 3;
22
+ const CONTINUATION_WINDOW_MS = 7 * 86_400_000;
23
+ /** 已见过首轮的会话集合上限(超出按最久未用淘汰,不再整体清空) */
23
24
  const SEEN_SESSIONS_CAP = 512;
24
25
  export function createDaughterPlugin(options) {
25
26
  return new Daughter(options);
@@ -39,6 +40,8 @@ class Daughter {
39
40
  handledTurns = new Map();
40
41
  /** 已经构建过 prompt 的会话:只有第一次才允许"续接上一个对话" */
41
42
  seenSessions = new Set();
43
+ /** 每个窗口已经贴过的"别处刚说的话",跨窗口轨道靠它去重,避免同一件事反复提醒 */
44
+ injectedCross = new Map();
42
45
  rng;
43
46
  onMotion;
44
47
  /** 连接器令牌:主动开口探测桌面桥时带上(§12.2) */
@@ -67,8 +70,7 @@ class Daughter {
67
70
  const overdraftDelta = l5.stress > 0.7 ? 0.03 : -0.01;
68
71
  l5.emotionalOverdraft = Math.min(1, Math.max(0, l5.emotionalOverdraft + overdraftDelta));
69
72
  store.saveOverdraft(l5.emotionalOverdraft);
70
- store.saveL5(l5);
71
- this.sessions.set(sessionKey(ctx), l5);
73
+ this.commitL5(store, ctx, l5);
72
74
  this.logger.debug('on_user_message', {
73
75
  sessionId: ctx.sessionId,
74
76
  level: verdict.level,
@@ -118,17 +120,54 @@ class Daughter {
118
120
  // 换对话框后的第一句:先接上"上一个对话的结尾"。
119
121
  // 换窗口时"刚才"比"情绪像"更该被想起,所以它排在一跳联想之前——
120
122
  // 这条路径只在该会话第一次构建 prompt 时走一次。
121
- const sessionId = sessionKey(ctx);
122
- const firstTurn = !this.seenSessions.has(sessionId);
123
- this.seenSessions.add(sessionId);
124
- if (this.seenSessions.size > SEEN_SESSIONS_CAP)
125
- this.seenSessions.clear();
123
+ // 缓存键(角色::会话)与库里的 session_id 是两回事:查库必须用原样的会话 id,
124
+ // 否则"排除本窗口"这条永远不成立,本窗口刚说的话会被当成别处说的贴回来。
125
+ const cacheKey = sessionKey(ctx);
126
+ const firstTurn = !this.seenSessions.has(cacheKey);
127
+ this.markSeen(cacheKey);
128
+ // 窗口名字:按第一次出现的顺序发「窗1」「窗2」并落库,重启后还是同一个名
129
+ const namer = this.windowNamer(ctx.characterId);
126
130
  const nowMs = Date.parse(now);
127
- const continued = firstTurn && this.config.association.fallback_when_no_match && withSurprise.length === 0
131
+ // 续接是个固定槽位,不再给字面命中让位:早期要求"这一句没撞上任何旧事"才续接,
132
+ // 于是换窗口后正好提到某件旧事时,"刚才说到哪儿"反而被顶掉了——那正是最该接上的时候。
133
+ const hitIds = new Set(withSurprise.map((hit) => hit.event.id));
134
+ const continued = firstTurn && this.config.association.fallback_when_no_match
135
+ ? store
136
+ .listRecentEvents(new Date(Number.isFinite(nowMs) ? nowMs - CONTINUATION_WINDOW_MS : 0).toISOString(), CONTINUATION_LIMIT + hitIds.size)
137
+ .filter((event) => !hitIds.has(event.id))
138
+ .slice(0, CONTINUATION_LIMIT)
139
+ .map((event) => ({ event, score: event.weight, reason: '续接(上一段对话的结尾)' }))
140
+ : [];
141
+ const continuedIds = new Set(continued.map((hit) => hit.event.id));
142
+ // 窗口只是工位,不是记忆分区:别的窗口里刚说的话,每轮都补进来,
143
+ // 这样"在 1 里说要去跑步,到 2 里说你好"才会接上一句"不是跑了吗"。
144
+ const injectedCross = this.injectedCross.get(cacheKey) ?? new Set();
145
+ const contentKey = (event) => `${event.title}\u0000${event.summary}`;
146
+ const crossCandidates = this.config.continuity.enabled
128
147
  ? store
129
- .listRecentEvents(new Date(Number.isFinite(nowMs) ? nowMs - CONTINUATION_WINDOW_MS : 0).toISOString(), CONTINUATION_LIMIT)
130
- .map((event) => ({ event, score: event.weight, reason: '续接(上一个对话的结尾)' }))
148
+ .listRecentElsewhere(new Date(Number.isFinite(nowMs) ? nowMs - this.config.continuity.cross_window_minutes * 60_000 : 0).toISOString(), this.config.continuity.cross_window_max + injectedCross.size + hitIds.size + continuedIds.size,
149
+ // 原样会话 id:库里存的就是它,换成缓存键会让"排除本窗口"失效
150
+ ctx.sessionId)
151
+ .filter((event) => !hitIds.has(event.id) && !continuedIds.has(event.id) && !injectedCross.has(event.id))
152
+ .map((event) => ({
153
+ event,
154
+ score: event.weight,
155
+ reason: '跨窗口(刚在别处说的)',
156
+ window: namer(event.sessionId),
157
+ }))
131
158
  : [];
159
+ // 同一个东西被记过好几次(同一个视频刷到五次),这一块只留最新的一条
160
+ const seenCross = new Set();
161
+ const crossWindow = crossCandidates.filter((hit) => {
162
+ const key = contentKey(hit.event);
163
+ if (seenCross.has(key))
164
+ return false;
165
+ seenCross.add(key);
166
+ return true;
167
+ }).slice(0, this.config.continuity.cross_window_max);
168
+ for (const hit of crossWindow)
169
+ injectedCross.add(hit.event.id);
170
+ this.injectedCross.set(cacheKey, injectedCross);
132
171
  // 一条字面命中都没有时,仍然可以从记忆网里往外摸:
133
172
  // 人想不起直接对应的事,不会因此停止联想,而是从"当下情绪最像、时间最近"的那段记忆顺下去。
134
173
  let seedIds = withSurprise.map((hit) => hit.event.id);
@@ -161,6 +200,8 @@ class Daughter {
161
200
  .filter((item) => verdict.level < 2 || Math.abs(item.event.emotionAtTime.pleasure) <= 0.2);
162
201
  // §10.1 降级收紧主动联想:档位 1 砍半,档位 2 起整个联想块不再注入
163
202
  const associationLimit = verdict.level >= 2 ? 0 : verdict.level >= 1 ? Math.ceil(associatedViews.length / 2) : associatedViews.length;
203
+ // 联想块保留内容而不是只给 eventId:事件块可能被预算裁掉,联想块是它被裁后的备份;
204
+ // 同时带上"由哪条想到",这是事件块给不出的路径信息
164
205
  const associationText = associationLimit > 0
165
206
  ? `[联想]\n${associatedViews
166
207
  .slice(0, associationLimit)
@@ -190,19 +231,45 @@ class Daughter {
190
231
  // 续接条目不算命中:不进 promptCache.hits,也就不写 recallCount / weight,
191
232
  // 否则每开一个新窗口它都被强化一次,最后永远排在最前面。
192
233
  const memoryHit = withSurprise.length > 0 || associationDigest.length > 0;
193
- const injected = continued.length > 0 ? [...injectedHits, ...continued] : injectedHits;
234
+ // 跨窗口不进事件块:单独一块,独立预算,不受当前话题检索与预算裁剪影响
235
+ // 同一条只贴一个地方:跨窗口块已经给过的内容,事件块不再重复贴一遍;
236
+ // 事件块内部也按内容去重(同一个视频刷到五次,也只占一行)
237
+ const crossKeys = new Set(crossWindow.map((hit) => contentKey(hit.event)));
238
+ const seenContent = new Set();
239
+ const injected = [...injectedHits, ...continued].filter((hit) => {
240
+ const key = contentKey(hit.event);
241
+ if (crossKeys.has(key) || seenContent.has(key))
242
+ return false;
243
+ seenContent.add(key);
244
+ return true;
245
+ });
194
246
  // §4.4 全文层:只在命中且全文确实比摘要更长时取摘录,由事件块的 cap 兜底
195
247
  const excerpts = this.config.retrieval.expand_fulltext
196
248
  ? this.collectExcerpts(store, injected)
197
249
  : undefined;
250
+ // 进行中的局:角色级共享,两个窗口看到同一局的前情摘要(§8.2)
251
+ const prunedThreads = this.config.threading.enabled
252
+ ? pruneOpenThreads(store.loadOpenThreads(), now, this.config.threading.activity_ttl_minutes, 6, this.config.threading.closed_retention_minutes)
253
+ : [];
254
+ // 开着的最多 max_injected 局;另外固定带一条最近收尾的——
255
+ // "结了没"得有个交代,昨晚那局也要留着落脚处,不能整段砍掉
256
+ const openHeads = prunedThreads
257
+ .filter((head) => head.status !== 'closed')
258
+ .slice(0, this.config.threading.max_injected);
259
+ const closedHead = prunedThreads.find((head) => head.status === 'closed');
260
+ const openThreads = closedHead ? [...openHeads, closedHead] : openHeads;
261
+ // 带上本窗口的名字与 id:局的最后一句要标清"这儿/别处",不然自己的话会被当成对方的
262
+ const threadText = buildThreadDigest(openThreads, now, ctx.sessionId, namer);
198
263
  const { blocks, usedTokens } = buildBlocks({
199
264
  personaText: [buildPersonaText(persona, sub), relationText, WORKING_STYLE].filter(Boolean).join('\n'),
200
265
  stateHint: buildStateHint(l5, verdict),
201
- eventDigest: buildEventDigest(injected, excerpts, now),
266
+ eventDigest: buildEventDigest(injected, excerpts, now, ctx.sessionId, namer),
267
+ crossWindowText: buildCrossWindowDigest(crossWindow, now, ctx.sessionId, namer),
268
+ threadText,
202
269
  skillDigest: buildSkillDigest(activation),
203
270
  associationText,
204
- // 有内容就不能说"没有任何记录":续接条目不算命中,但块里确实列了往事
205
- constraint: injected.length > 0 ? null : NO_MEMORY_CONSTRAINT,
271
+ // 有内容就不能说"没有任何记录":续接/跨窗口不算命中,但块里确实列了往事
272
+ constraint: injected.length > 0 || crossWindow.length > 0 || openThreads.length > 0 ? null : NO_MEMORY_CONSTRAINT,
206
273
  config: this.config,
207
274
  });
208
275
  const budget = Math.min(input.tokenBudget, this.config.budget.max_total_tokens);
@@ -264,10 +331,22 @@ class Daughter {
264
331
  const l5 = this.getL5(ctx, persona);
265
332
  const toolsUsed = input.toolsUsed ?? [];
266
333
  const event = extractEvent(ctx, userText, assistantText, l5, this.rng);
334
+ // "局"归并:时间相邻 + 话题相似的事件归成一个 thread(跨窗口也算同一局)
335
+ let openThreads = null;
336
+ if (this.config.threading.enabled && isDialogueSource(event.source)) {
337
+ // 原话一起交给归并:摘要只记话题,"卡在哪"要靠原话才接得上
338
+ const assigned = assignThread(event, store.loadOpenThreads(), this.config, userText);
339
+ event.threadId = assigned.threadId;
340
+ openThreads = assigned.heads;
341
+ }
267
342
  // 全文层写完整的问 + 答,摘要层只留压缩版(§4.4)
268
343
  const ingested = this.ingest(store, event, `${userText.trim()}\n→ ${assistantText.trim()}`.trim());
269
- if (ingested.action === 'keep')
344
+ if (ingested.action === 'keep') {
345
+ if (openThreads) {
346
+ store.saveOpenThreads(pruneOpenThreads(openThreads, now, this.config.threading.activity_ttl_minutes, 6, this.config.threading.closed_retention_minutes));
347
+ }
270
348
  linkNewEvent(store, event);
349
+ }
271
350
  const cache = this.promptCache.get(sessionKey(ctx));
272
351
  const skillsUpdated = [];
273
352
  const evolutionIds = [];
@@ -460,7 +539,7 @@ class Daughter {
460
539
  else
461
540
  next.emotion[key] = Math.min(1, Math.max(-1, next.emotion[key] + value));
462
541
  }
463
- store.saveL5(next);
542
+ this.commitL5(store, input.ctx, next);
464
543
  }
465
544
  let ok = true;
466
545
  if (matched.action.kind === 'motion') {
@@ -674,6 +753,7 @@ class Daughter {
674
753
  this.promptCache.clear();
675
754
  this.userTextCache.clear();
676
755
  this.sourceCache.clear();
756
+ this.injectedCross.clear();
677
757
  this.observers.clear();
678
758
  this.bodyCache.clear();
679
759
  }
@@ -757,6 +837,32 @@ class Daughter {
757
837
  }
758
838
  return excerpts;
759
839
  }
840
+ /**
841
+ * 窗口名字:按第一次出现的顺序发「窗1」「窗2」并落库。
842
+ * 早先用会话 id 的哈希("窗SX"),谁也看不出那是什么意思;序号才是"哪个窗"。
843
+ * 名字一旦发出去就不再变,换个窗口才拿新号。
844
+ */
845
+ windowNamer(characterId) {
846
+ const store = this.store(characterId);
847
+ const names = store.loadWindowNames();
848
+ const used = new Set(Object.values(names)
849
+ .map((name) => Number(name.replace(/\D/g, '')))
850
+ .filter((value) => Number.isFinite(value) && value > 0));
851
+ return (sessionId) => {
852
+ if (!sessionId)
853
+ return '旧窗口';
854
+ const known = names[sessionId];
855
+ if (known)
856
+ return known;
857
+ let next = 1;
858
+ while (used.has(next))
859
+ next += 1;
860
+ used.add(next);
861
+ names[sessionId] = `窗${next}`;
862
+ store.saveWindowNames(names);
863
+ return names[sessionId];
864
+ };
865
+ }
760
866
  store(characterId) {
761
867
  let store = this.stores.get(characterId);
762
868
  if (!store) {
@@ -773,11 +879,59 @@ class Daughter {
773
879
  const cached = this.sessions.get(sessionKey(ctx));
774
880
  if (cached)
775
881
  return cached;
776
- const loaded = this.store(ctx.characterId).loadL5(ctx.sessionId);
777
- const l5 = loaded ?? createInitialL5(ctx.characterId, ctx.sessionId, ctx.now(), persona, this.store(ctx.characterId).loadOverdraft());
882
+ const store = this.store(ctx.characterId);
883
+ const loaded = store.loadL5(ctx.sessionId);
884
+ // 新会话没有自己的快照时,接着上一段走(受 storage.shared_across_* 控制)
885
+ const carried = loaded ? null : this.carriedL5(store, ctx, persona);
886
+ const l5 = loaded
887
+ ?? carried
888
+ ?? createInitialL5(ctx.characterId, ctx.sessionId, ctx.now(), persona, store.loadOverdraft());
889
+ l5.emotionalOverdraft = store.loadOverdraft();
778
890
  this.sessions.set(sessionKey(ctx), l5);
779
891
  return l5;
780
892
  }
893
+ /**
894
+ * 接力上一段「此刻」:间隔按 emotion_relax 复利回落,所以隔五分钟原样、隔一夜平复但不归零。
895
+ * 隔离范围由配置决定:session(不接力)/ workspace(只在同一工作区内接力)/ character(默认)。
896
+ */
897
+ carriedL5(store, ctx, persona) {
898
+ const scope = this.carryScope(ctx);
899
+ if (!scope)
900
+ return null;
901
+ // character 范围允许回退到"最近一次会话":旧存档没有接力快照,升级当次也要能接上。
902
+ // workspace 范围不回退——旧快照里没有 workspace 字段,猜一个等于把工作窗口串到私人窗口。
903
+ const previous = store.loadL5Carry(scope) ?? (scope === 'character' ? store.latestL5() : null);
904
+ if (!previous || previous.sessionId === ctx.sessionId)
905
+ return null;
906
+ return carryL5(previous, ctx.sessionId, ctx.now(), persona, this.config);
907
+ }
908
+ /** null = 不接力(每个会话从基线重新出生) */
909
+ carryScope(ctx) {
910
+ if (!this.config.storage.shared_across_sessions)
911
+ return null;
912
+ if (this.config.storage.shared_across_workspaces)
913
+ return 'character';
914
+ return `ws.${safeFileName(ctx.workspaceId)}`;
915
+ }
916
+ /** 写回 L5 的唯一出口:落盘 + 刷新接力快照,避免两条写入路径有一处忘了同步 */
917
+ commitL5(store, ctx, l5) {
918
+ store.saveL5(l5);
919
+ const scope = this.carryScope(ctx);
920
+ if (scope)
921
+ store.saveL5Carry(scope, l5);
922
+ this.sessions.set(sessionKey(ctx), l5);
923
+ }
924
+ /** 记「这个会话已经走过首轮」。超上限按最久未用淘汰;早年是整体 clear(),会把老会话全打回首轮 */
925
+ markSeen(sessionId) {
926
+ this.seenSessions.delete(sessionId);
927
+ this.seenSessions.add(sessionId);
928
+ while (this.seenSessions.size > SEEN_SESSIONS_CAP) {
929
+ const oldest = this.seenSessions.values().next().value;
930
+ if (oldest === undefined)
931
+ break;
932
+ this.seenSessions.delete(oldest);
933
+ }
934
+ }
781
935
  pickSubpersona(persona, audience, currentId) {
782
936
  if (audience) {
783
937
  const found = persona.l4.subPersonas.find((item) => item.id === audience);
@@ -1,12 +1,21 @@
1
- import type { DaughterConfig, EventRecord, L5State, MoralOutlook, PersonaBundle, PromptBlock, ReflexAction, ReflexRule, RetrievalHit, SafetyVerdict, SkillActivation, SkillCard, SubPersona } from '../types.js';
1
+ import type { DaughterConfig, EventRecord, L5State, MoralOutlook, PersonaBundle, PromptBlock, ReflexAction, ReflexRule, RetrievalHit, SafetyVerdict, SkillActivation, SkillCard, SubPersona, WindowNamer } from '../types.js';
2
2
  import type { PersonProfile, ThingProfile } from './maintenance.js';
3
+ import type { ThreadHead } from './threading.js';
3
4
  import { type MotivationContext } from './motivation.js';
5
+ import { type WeightedLabel } from './weights.js';
4
6
  /** 做任务时的说话节奏:不让模型在连续工具调用中保持沉默。 */
5
7
  export declare const WORKING_STYLE: string;
8
+ /** 性格权重:各维独立强度(0.00–1.00,两位小数),取最强三档,彼此不归一 */
9
+ export declare function traitWeights(bigFive: PersonaBundle['l1']['bigFive']): WeightedLabel[];
10
+ /** 关系:主档按已存阶段(回退有迟滞),括号里给权重,顺带说在往哪一档走 */
11
+ export declare function stageLine(l2: PersonaBundle['l2']): string;
6
12
  export declare function buildPersonaText(persona: PersonaBundle, sub: SubPersona): string;
7
13
  /**
8
- * 关系与偏好注入:把人物画像(亲密度/关系变化)、事物喜好、善恶观压缩成三行。
14
+ * 关系与偏好注入:把人物画像(亲密度/关系变化)、事物喜好、善恶观压缩成几行。
9
15
  * 星图与 prompt 共用同一套计算,避免两处口径不一致。
16
+ *
17
+ * 偏好行不再静默消失:没有明确喜恶时退成"最近常聊",把她攒下来的相处量说出来——
18
+ * 主档叫"中性"不等于这段关系没内容,只是还没长出喜恶。
10
19
  */
11
20
  export declare function buildRelationText(input: {
12
21
  person?: PersonProfile;
@@ -36,13 +45,31 @@ export declare function normalizeReflexRule(raw: unknown): ReflexRule | null;
36
45
  * 每行带一个相对时间("3 小时前"/"昨天"):没有这个锚,模型分不清是刚才还是上个月,
37
46
  * `now` 缺省时不显示,便于只关心内容的调用方与测试。
38
47
  */
39
- export declare function buildEventDigest(hits: RetrievalHit[], excerpts?: Map<string, string>, now?: string): string;
48
+ export declare function buildEventDigest(hits: RetrievalHit[], excerpts?: Map<string, string>, now?: string, currentSessionId?: string,
49
+ /** 窗口名字的来源:生产环境传落库的编号(窗1/窗2),缺省用稳定短哈希 */
50
+ namer?: WindowNamer): string;
51
+ /**
52
+ * 跨窗口轨道:单独一块、独立预算,不跟"当前话题检索"混在同一块里。
53
+ * 这样它不受当前窗口上下文影响——无论这边聊什么、检索出多少,这一块都在。
54
+ *
55
+ * 每行都标"别处·窗XX",块头写清这边是哪个窗:不给名字时,本窗口刚说的话
56
+ * 会被当成别处的,模型就开始对着自己的回声往下推。
57
+ */
58
+ export declare function buildCrossWindowDigest(hits: RetrievalHit[], now?: string, currentSessionId?: string, namer?: WindowNamer): string;
59
+ /**
60
+ * "局"前情摘要:把同一局跨窗口、跨轮的几条事件合成一行,让两个窗口看到的是同一局,
61
+ * 而不是各捡一条残迹。只给结构(几轮、跨几个窗口、最后一句原话)与开合状态,
62
+ * 不塞整局全文——"卡在我看不见,等她给线索"这种原话才是下次能接上的地方。
63
+ */
64
+ export declare function buildThreadDigest(heads: ThreadHead[], now?: string, currentSessionId?: string, namer?: WindowNamer): string;
40
65
  export declare function buildSkillDigest(activation: SkillActivation): string;
41
66
  export declare const NO_MEMORY_CONSTRAINT: string;
42
67
  export declare function buildBlocks(input: {
43
68
  personaText: string;
44
69
  stateHint: string;
45
70
  eventDigest: string;
71
+ crossWindowText: string;
72
+ threadText: string;
46
73
  skillDigest: string;
47
74
  associationText: string;
48
75
  constraint: string | null;