dsh-selfnote 0.12.1 → 0.13.0

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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/lib/index.js +154 -15
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -266,6 +266,12 @@ DSH 的会话文件是**逐条追写**的 zstd 序列(一次写入 = 一个 fr
266
266
 
267
267
  放不下就跳过那一条、继续试后面的(不截半句);环境条目放不下时只留一句门牌「另有 N 条环境备忘(memory_recall 查)」,让模型知道「你有这些记忆」,而不是假装没有。
268
268
 
269
+ **环境段分账**:只按热度排会形成马太效应 —— 进过一次的条目 hitCount 更高、下一轮还进,从没进过的永远排在后面(实测 4 条吃满 292 字,另外 10 条一次没进过:记忆存了,模型不知道它存在)。所以环境段是分两笔花:先按热度装四分之三,剩下那份留给「从没进过 / 最久没进过」的条目,按 lastHit 排队、一轮轮换。四分之一只是起点,不是硬指标 —— 真实备忘动辄 80–120 字,四分之一连一条都塞不下等于没预留,所以还会自动抬高到「刚好装得下下一个该轮到的」那条,最多占一半,不把热的挤空。用不完的额度还给热的,不浪费。
270
+
271
+ 真的进过一次的会被记账(lastHit 更新),下一轮自然排到冷门队尾,轮到下一条 —— 这是轮转,不是加权随机。
272
+
273
+ **压缩之后补一次**:上下文一压缩,刚聊过的记忆就被挤出窗口;而注入按文件变化命中缓存,不会自己重贴。所以插件记着「这个会话最近注入过哪些条目」,一旦收到 `compaction/summary` 或 `compaction/prune`(且压缩没失败),下一轮就把这些条目重新排到环境段最前面补一遍,补完即清 —— 只补一次,不会每一轮都重提旧账。补偿文本单独占一份缓存键,不会粘在普通注入的缓存上。
274
+
269
275
  另有可选的第六段「写自述的时机」:只有你在设置页填了自定义引导才出现,原样一行、上限 80 字,**算在 800 之外**(因为它不是记忆,是指令)。
270
276
 
271
277
  设置页显示本回合实际注入多少、各段占多少。注入文本按「三份文件的 mtime + 大小 + 配置」缓存,没变就不重算 —— 省算力,也让系统提示词前缀稳定、吃得到缓存。带上大小是因为同一毫秒里改两次时 mtime 可能没变,那样会继续用上一份旧文本,看起来就是「改了没生效」。
package/lib/index.js CHANGED
@@ -214,7 +214,74 @@ function packEnv(items, limit) {
214
214
  ids.push(item.id)
215
215
  text = text === '' ? body : `${text};${body}`
216
216
  }
217
- return { text, kept, skipped, ids }
217
+ return { text, kept, skipped, ids, used }
218
+ }
219
+
220
+ /**
221
+ * 冷门条目在环境段里预留多少份额。
222
+ *
223
+ * 只按热度排会形成马太效应:进过一次的 hitCount 就 +1、热度更高、下一轮还进;
224
+ * 一次没进过的永远排在后面。实测 4 条吃满了 292 字,另外 10 条一次没进过 ——
225
+ * 记忆存进去了,模型却不知道它存在。所以环境段不让「热的」吃满:
226
+ * 先按热度装 limit 的 3/4,剩下 1/4 留给「从没进过 / 最久没进过」的。
227
+ */
228
+ export const ENV_RESERVE_RATIO = 0.25
229
+
230
+ function lastHitOf(item) {
231
+ const raw = item?.lastHit
232
+ const parsed = raw === null || raw === undefined ? NaN : Date.parse(raw)
233
+ // 从没进过就用 0 当排名(比任何真实时间戳都早)。
234
+ // 不能用 -Infinity:两个 -Infinity 相减得 NaN,比较器一乱,冷门队列就退化成热度顺序了。
235
+ return Number.isFinite(parsed) ? parsed : 0
236
+ }
237
+
238
+ /**
239
+ * 环境段分账打包:热的先占 3/4,冷门的用剩下的(从最久没进过的开始排队)。
240
+ * spotlight 是这一轮要重点回顾的 id(压缩之后用),它们排在冷门队伍最前面。
241
+ * 冷门那份用不完就还给热的 —— 不浪费预算。
242
+ *
243
+ * 1/4 只是起点,不是硬指标:真实的备忘动辄 80–120 字,1/4 连一条都塞不下,
244
+ * 「预留了」却什么都没发生(实测对着真数据跑,9 条从没进过的一条也进不来)。
245
+ * 所以再加一条下限 —— 保证**下一个该轮到的**那条进得来(不是「最短的那条」:
246
+ * 打包是按队列顺序试的,队首太长就轮不到后面那些短的)。最多占到一半,别把热的挤空。
247
+ */
248
+ function packEnvFair(items, limit, spotlight = new Set()) {
249
+ const cap = Math.max(0, limit)
250
+ const arrange = (reserve) => {
251
+ const base = packEnv(items, Math.max(0, cap - reserve))
252
+ const rest = items.filter((item) => !base.ids.includes(item.id))
253
+ const warm = rest.filter((item) => spotlight.has(item.id))
254
+ const cold = rest.filter((item) => !spotlight.has(item.id))
255
+ // 从没进过的排最前,然后是最久没进过的:进过一次就会被记账,下轮自然排到队尾
256
+ cold.sort((a, b) => lastHitOf(a) - lastHitOf(b) || a.heat - b.heat)
257
+ return { base, warm, cold }
258
+ }
259
+ let reserve = Math.floor(cap * ENV_RESERVE_RATIO)
260
+ let plan = arrange(reserve)
261
+ const head = plan.warm[0] ?? plan.cold[0]
262
+ if (head !== undefined) {
263
+ const floor = String(head.text ?? '').trim().length + 1
264
+ if (floor > reserve) {
265
+ reserve = Math.min(Math.floor(cap / 2), floor)
266
+ plan = arrange(reserve)
267
+ }
268
+ }
269
+ const { base, warm, cold } = plan
270
+ const extra = packEnv([...warm, ...cold], Math.max(0, cap - base.used))
271
+ const ids = [...base.ids, ...extra.ids]
272
+ const inText = new Set(ids)
273
+ const skipped = items
274
+ .filter((item) => !inText.has(item.id))
275
+ .map((item) => String(item.text ?? '').trim())
276
+ .filter((body) => body !== '')
277
+ return {
278
+ text: [base.text, extra.text].filter((part) => part !== '').join(';'),
279
+ kept: [...base.kept, ...extra.kept],
280
+ skipped,
281
+ ids,
282
+ reserve,
283
+ spotlit: warm.filter((item) => extra.ids.includes(item.id)).map((item) => item.id),
284
+ }
218
285
  }
219
286
 
220
287
  /**
@@ -266,7 +333,8 @@ function injectionKey(cfg) {
266
333
  * 组装这一回合要注入的整段文本,同时报出每段进了什么、挤掉了什么。
267
334
  * 纯函数:不写盘、不记热度 —— 设置页可以随时拿它照镜子,不会副作用。
268
335
  */
269
- function assembleInjection(cfg) {
336
+ function assembleInjection(cfg, options = {}) {
337
+ const spotlight = options.spotlight instanceof Set ? options.spotlight : new Set(options.spotlight ?? [])
270
338
  const segments = []
271
339
  const parts = []
272
340
 
@@ -312,12 +380,13 @@ function assembleInjection(cfg) {
312
380
  const envRanked = envForInjection(cfg)
313
381
  // 环境条目往往比预算长,全塞进来会把自述挤掉。所以:热的短的进上下文,
314
382
  // 剩下的只留一句门牌「另有 N 条」——告诉模型「你有这些记忆,去查」,而不是假装没有。
315
- let env = packEnv(envRanked, BUDGET.env)
383
+ // 打分账打包而不是纯热度:热的吃 3/4,冷的吃 1/4,否则冷门条目一辈子轮不到。
384
+ let env = packEnvFair(envRanked, BUDGET.env, spotlight)
316
385
  // 门牌句自己也要占字,加进去以后可能又多挤掉一条 —— 所以要收敛:
317
386
  // 反复用「这次剩几条」重算门牌,直到条数不再变。不然那句「另有 N 条」会少报。
318
387
  for (let pass = 0; pass < 3 && env.skipped.length > 0; pass += 1) {
319
388
  const pointer = `另有 ${env.skipped.length} 条环境备忘(memory_recall 查)`
320
- const refit = packEnv(envRanked, Math.max(0, BUDGET.env - pointer.length - 1))
389
+ const refit = packEnvFair(envRanked, Math.max(0, BUDGET.env - pointer.length - 1), spotlight)
321
390
  const text = refit.text === '' ? pointer : `${refit.text};${pointer}`
322
391
  const converged = refit.skipped.length === env.skipped.length
323
392
  env = { ...refit, text }
@@ -331,6 +400,9 @@ function assembleInjection(cfg) {
331
400
  chars: env.text.length,
332
401
  kept: env.kept,
333
402
  skipped: env.skipped,
403
+ // 分账信息:设置页能看出「1/4 预留」有没有真的分出去、这轮回顾了哪几条
404
+ reserve: env.reserve,
405
+ spotlit: env.spotlit,
334
406
  })
335
407
  if (envLine !== '') parts.push(envLine)
336
408
 
@@ -504,14 +576,59 @@ function beat(extra = {}) {
504
576
  }
505
577
  }
506
578
 
507
- /** 注入文本:模型每次装配提示词都会调用。命中缓存时不读文件、不计算。 */
508
- function composeInjection(inline) {
579
+ /**
580
+ * 压缩之后要补的话。
581
+ *
582
+ * 上下文一压缩,刚聊过的记忆就被挤出了窗口 —— 而注入按「哪几个文件变了」命中缓存,
583
+ * 该重新贴的事实没人提醒。所以记两样:每个会话最近一次注入过哪些条目、哪些会话刚压缩过;
584
+ * 压缩后的下一轮把用过的条目再提一遍(spotlight),提完就清,只补一次。
585
+ */
586
+ const lastInjectedBySession = new Map()
587
+ const compactionPending = new Map()
588
+ const COMPACTION_EVENTS = new Set(['compaction/summary', 'compaction/prune', 'compaction/end'])
589
+ const MAX_SESSION_TRACKED = 200
590
+
591
+ function trimSessionMap(map) {
592
+ while (map.size > MAX_SESSION_TRACKED) map.delete(map.keys().next().value)
593
+ }
594
+
595
+ /** 压缩事件到达时按会话打个标记:下一轮照这个会话用过的东西补一遍。 */
596
+ function noteCompaction(sessionId, event) {
597
+ const type = String(event?.type ?? '')
598
+ if (!COMPACTION_EVENTS.has(type)) return
599
+ // 压缩失败/中止时窗口里还有原文,补了是重复
600
+ const data = event?.data
601
+ if (data !== null && typeof data === 'object' && 'error' in data && data.error !== undefined) return
602
+ compactionPending.set(sessionId, { at: Date.now(), ids: [...(lastInjectedBySession.get(sessionId) ?? [])] })
603
+ trimSessionMap(compactionPending)
604
+ }
605
+
606
+ /** 这轮要回顾哪些条目:先看一眼,真注入成功了再清(只补一次)。 */
607
+ function peekSpotlight(sessionId) {
608
+ const pending = sessionId === '' ? undefined : compactionPending.get(sessionId)
609
+ return pending === undefined ? [] : [...pending.ids]
610
+ }
611
+
612
+ /**
613
+ * 注入文本:模型每次装配提示词都会调用。命中缓存时不读文件、不计算。
614
+ */
615
+ function composeInjection(inline, options = {}) {
509
616
  const cfg = currentConfig(inline)
510
617
  if (!cfg.enabled) return ''
511
- const key = injectionKey(cfg)
618
+ const sessionId = typeof options.sessionId === 'string' ? options.sessionId : ''
619
+ const spotlight = Array.isArray(options.spotlight) ? options.spotlight : []
620
+ // 压缩补偿只对那一个会话、那一轮有效:带上会话和条目才能跟普通注入分开缓存。
621
+ // 这一段必须挂在写完之后的键上一起存(见下),否则补偿文本会被存成普通键,
622
+ // 一直粘在缓存里 —— 补一次就变成补一辈子。
623
+ const suffix = spotlight.length > 0 ? '|sn' + sessionId + ':' + spotlight.join(',') : ''
624
+ const key = injectionKey(cfg) + suffix
512
625
  if (injectCache.key === key) return injectCache.text
513
626
 
514
- const built = assembleInjection(cfg)
627
+ const built = assembleInjection(cfg, { spotlight: new Set(spotlight) })
628
+ if (sessionId !== '') {
629
+ lastInjectedBySession.set(sessionId, [...built.factIds])
630
+ trimSessionMap(lastInjectedBySession)
631
+ }
515
632
  // 真注入过一次才打心跳:这是「插件在跑」唯一的物证
516
633
  beat({ chars: built.chars, facts: built.factIds.length })
517
634
  if (built.text === '') {
@@ -527,7 +644,7 @@ function composeInjection(inline) {
527
644
  if (built.factIds.length > 0) {
528
645
  try {
529
646
  touchFacts(built.factIds)
530
- keyAfter = injectionKey(cfg)
647
+ keyAfter = injectionKey(cfg) + suffix
531
648
  } catch {
532
649
  /* 记账失败不影响注入 */
533
650
  }
@@ -749,6 +866,17 @@ function injectionAgentIsDelegated(ctx) {
749
866
  }
750
867
  }
751
868
 
869
+ /** 当前这一轮是哪个会话在问 —— 压缩补偿要按会话算。 */
870
+ function currentSessionId(ctx) {
871
+ try {
872
+ const agent = ctx?.agents?.currentInitiator?.()
873
+ const id = agent?.session?.header?.id ?? agent?.session?.id
874
+ return typeof id === 'string' ? id : ''
875
+ } catch {
876
+ return ''
877
+ }
878
+ }
879
+
752
880
  /** 故障隔离:记忆坏了也不许拖垮对话,注入失败就当没有记忆。 */
753
881
  function buildInjection(inline, ctx) {
754
882
  try {
@@ -758,7 +886,12 @@ function buildInjection(inline, ctx) {
758
886
  delegatedSkips += 1
759
887
  return ''
760
888
  }
761
- return composeInjection(inline)
889
+ const sessionId = currentSessionId(ctx)
890
+ const spotlight = peekSpotlight(sessionId)
891
+ const text = composeInjection(inline, { sessionId, spotlight })
892
+ // 补偿只补一次:这一轮真贴进去了,才把标记清掉(失败的话下一轮还有机会)
893
+ if (spotlight.length > 0) compactionPending.delete(sessionId)
894
+ return text
762
895
  } catch (error) {
763
896
  return ''
764
897
  }
@@ -1113,7 +1246,13 @@ function factsForInjection(cfg) {
1113
1246
  /** 环境备忘:机器路径、账号、踩过的坑 —— 与「这个人是谁」分开注入,不占画像。 */
1114
1247
  function envForInjection(cfg) {
1115
1248
  const pool = listFacts('approved').filter((fact) => fact.channel === 'env')
1116
- return rankByHeat(pool, { halfLifeDays: cfg.halfLifeDays }).map(({ fact, heat }) => ({ id: fact.id, text: fact.text, heat }))
1249
+ // 带上 lastHit:冷门那 1/4 按「谁最久没进过」排队,只有热度是不够的
1250
+ return rankByHeat(pool, { halfLifeDays: cfg.halfLifeDays }).map(({ fact, heat }) => ({
1251
+ id: fact.id,
1252
+ text: fact.text,
1253
+ heat,
1254
+ lastHit: fact.lastHit ?? null,
1255
+ }))
1117
1256
  }
1118
1257
 
1119
1258
  function currentProfileText(cfg) {
@@ -1126,10 +1265,8 @@ function budgetOf(inline) {
1126
1265
  if (!cfg.enabled) return { total: 0, limit: TOTAL_BUDGET, notes: 0, profile: 0, env: 0, facts: 0, habits: 0, enabled: false, caps: { ...BUDGET } }
1127
1266
  const notes = fit(readNotes().slice().reverse().map((entry) => entry.text), BUDGET.notes)
1128
1267
  const profile = currentProfile(cfg)
1129
- const env = fit(
1130
- envForInjection(cfg).map((item) => item.text),
1131
- BUDGET.env,
1132
- )
1268
+ // 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上
1269
+ const env = packEnvFair(envForInjection(cfg), BUDGET.env).text
1133
1270
  const facts = fit(
1134
1271
  factsForInjection(cfg).map((item) => item.text),
1135
1272
  BUDGET.facts,
@@ -2069,6 +2206,8 @@ export function apply(ctx, config) {
2069
2206
  captureDebug.lastKeys = event !== null && typeof event === 'object' ? Object.keys(event).slice(0, 12) : null
2070
2207
  const sessionId = session?.id
2071
2208
  if (typeof sessionId !== 'string' || sessionId === '') return
2209
+ // 压缩发生了就记一笔:下一轮把这个会话用过的记忆再贴一遍
2210
+ noteCompaction(sessionId, event)
2072
2211
  // 同一会话按到达顺序串行,避免 seq=11 先完成把游标推到 11、把 seq=10 挤掉
2073
2212
  enqueue(sessionId, () =>
2074
2213
  handleEvent(event, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-selfnote",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Self-narrative memory for DeepSeek Harness: the model writes notes to itself, which persist across sessions and are injected back. | 人格库:模型写给自己看的话,跨会话保留并每回合注入。",
6
6
  "keywords": [
@@ -27,7 +27,7 @@
27
27
  "assets"
28
28
  ],
29
29
  "scripts": {
30
- "test": "node tests/smoke.mjs && node tests/memory.mjs && node tests/roles.mjs && node tests/heat.mjs && node tests/facts-tx.mjs && node tests/crash-write.mjs && node tests/capture.mjs && node tests/capture-p1b.mjs && node tests/index.mjs && node tests/incremental.mjs && node tests/profile.mjs && node tests/warm.mjs && node tests/transfer.mjs && node tests/api.mjs && node tests/client-state.mjs && node tests/client.mjs",
30
+ "test": "node tests/smoke.mjs && node tests/memory.mjs && node tests/roles.mjs && node tests/heat.mjs && node tests/fair.mjs && node tests/facts-tx.mjs && node tests/crash-write.mjs && node tests/capture.mjs && node tests/capture-p1b.mjs && node tests/index.mjs && node tests/incremental.mjs && node tests/profile.mjs && node tests/warm.mjs && node tests/transfer.mjs && node tests/api.mjs && node tests/client-state.mjs && node tests/client.mjs",
31
31
  "test:accept": "node tests/acceptance-runtime.mjs"
32
32
  },
33
33
  "engines": {