dsh-selfnote 0.14.1 → 0.14.2

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
@@ -10,10 +10,10 @@
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- <img src="https://img.shields.io/badge/version-0.14.1-4f46e5" alt="version">
13
+ <img src="https://img.shields.io/badge/version-0.14.2-4f46e5" alt="version">
14
14
  <img src="https://img.shields.io/badge/license-MIT-16a34a" alt="license">
15
15
  <img src="https://img.shields.io/badge/node-%3E%3D20.13-339933" alt="node">
16
- <img src="https://img.shields.io/badge/tests-290%20passed-0ea5e9" alt="tests">
16
+ <img src="https://img.shields.io/badge/tests-280%20passed-0ea5e9" alt="tests">
17
17
  </p>
18
18
 
19
19
  ---
@@ -109,7 +109,7 @@ plugin_manager { action: "install_bundle", target: "<该目录的绝对路径>"
109
109
  | **人格** | 三段式人格(L0 核心身份 / L1 行为规则 / L2 扩展设定),带「还能写多少」的实时字数;「从 AGENT.md 导入」只填框、不保存;下面是纠偏证据列表 —— 勾几条、改写成一条规则、加进 L1 草稿,再点「保存人格」 | 人格字数 |
110
110
  | **记忆** | 待确认候选(冲突并排)、已采纳(带热度,**正文能就地改**)、可以升成习惯的标签、已生效的习惯(能撤销) | 有待审时显示「待审 N」 |
111
111
  | **画像** | 从已采纳记忆聚合的「关于你」,可手改覆盖,每句带来源 | 画像字数 |
112
- | **注入** | **这一回合真的会贴进提示词的那段原文**,外加每段占了多少字、哪些条目被挤掉 | 注入字数 |
112
+ | **注入** | **这一回合真的会贴进提示词的那段原文**,外加每段占了多少字、哪些条目被跳过、每条为什么没进 | 注入字数 |
113
113
 
114
114
  有待审候选时直接落在「记忆」页,没有就停在「概览」;页签支持 ←/→/Home/End 切换。
115
115
 
@@ -140,7 +140,7 @@ plugin_manager { action: "install_bundle", target: "<该目录的绝对路径>"
140
140
  写的时候用 `tier` 标明是哪一型(`core` / `stable` / `temp`,不写按 `stable` 算)。落盘长这样:
141
141
 
142
142
  ```
143
- - 2026-11-04 [核心*] 我叫鲸鲸。得意时会多说两句,但正事一律认真。
143
+ - 2026-11-04 [核心*] 默认用中文回答,专业词顺手带一句白话解释。
144
144
  - 2026-11-04 [稳定] 回答先给结论,细节等他问。
145
145
  - 2026-11-04 [临时] 这周在查 DSH 远程主机连不上的问题。
146
146
  ```
@@ -170,7 +170,9 @@ plugin_manager { action: "install_bundle", target: "<该目录的绝对路径>"
170
170
  | 结论 | 90 字 | 同上 |
171
171
  | 习惯 | 40 字 | 同上 |
172
172
 
173
- 被预算挤掉的条目会**列出来**,并标明「整条跳过,不截半句」—— 半句话会被当成结论读。这段文字来自 `assembleInjection()`(纯函数,不写盘、不记热度),所以设置页刷新一百次也不会把热度刷歪。
173
+ 被预算挤掉或已过期的条目会**列出来**:每段都给 `skippedDetail`,点开就能看见每条没进上下文的原因(`budget` / `expired`);核心素材单列「素材层,不注入」,不会被误当成普通的预算竞争失败。这是**当前这一轮**的实时解释,不保存历史跳过记录 —— 实时可解释性不等于历史可追溯性。原文仍是「整条跳过,不截半句」—— 半句话会被当成结论读。这段文字来自 `assembleInjection()`(纯函数,不写盘、不记热度),所以设置页刷新一百次也不会把热度刷歪。
174
+
175
+ 另外,`/state` 顶层的 `injectionCount` / `lastInjectedAt` 记的是「注入文本真的返回给模型」的次数与最后时刻(缓存命中也算),与上面的 `skippedDetail` 是两条独立观测链路:一个回答「哪些没进、为什么」,一个回答「实际返回了几次」,不用 `seenCount`、预览次数或心跳计数代替。
174
176
 
175
177
  ### 注入归属:这条记忆到底有没有被用上
176
178
 
@@ -497,7 +499,7 @@ L2 为空时那句指路句也不发(不能指一条空路)。L2 有内容
497
499
 
498
500
  1. **`Host` 必须是回环地址或桌面壳(`dsh-app`)** —— 挡 DNS 重绑定;
499
501
  2. **带了 `Origin` 就必须同源** —— 跨站页面的请求根本发不出去(服务端不回 CORS 头);
500
- 3. **带了 `x-selfnote` 就必须等于本实例的 token** —— token 在首次启动时随机生成(或用 `DSH_SELFNOTE_TOKEN` 指定),存在 `~/.dsh/selfnote/auth-token`(权限 600)。**它不是写在文档里的固定值**,照抄 README 读不到你的记忆。
502
+ 3. **带了 `x-selfnote` 就必须等于本实例的 token** —— token 在首次启动时随机生成(或用 `DSH_SELFNOTE_TOKEN` 指定),存在 `~/.dsh/selfnote/auth-token`(POSIX 上 600;Windows 上 chmod 是空操作,靠用户目录自身的 ACL)。**它不是写在文档里的固定值**,照抄 README 读不到你的记忆。
501
503
 
502
504
  第 3 条是**可选**的:桌面壳的页面从 `dsh-app://app` 加载,拿不到注入的 token,所以页面不带也放行;脚本调用带上它更稳妥。
503
505
 
@@ -505,7 +507,7 @@ L2 为空时那句指路句也不发(不能指一条空路)。L2 有内容
505
507
 
506
508
  **拒绝和成功的破坏性操作都记进 `~/.dsh/selfnote/auth.log`**:被拒的写 `reason`,成功清空自述的写 `action: 'clear'` + 清了几条 + 归档文件名。哪天自述突然空了,翻这个文件就知道是哪一次、几点、清了多少条。
507
509
 
508
- 想用脚本自测,读 `~/.dsh/selfnote/auth-token`(权限 600),或自己设环境变量后带上它。
510
+ 想用脚本自测,读 `~/.dsh/selfnote/auth-token`(POSIX 600,Windows 靠用户目录 ACL),或自己设环境变量后带上它。
509
511
 
510
512
  ## 和 `dsh-agent-md` 的区别
511
513
 
@@ -528,7 +530,7 @@ L2 为空时那句指路句也不发(不能指一条空路)。L2 有内容
528
530
  ## 开发
529
531
 
530
532
  ```bash
531
- node --run test # 290 项,21 个文件,无外部依赖
533
+ node --run test # 280 项,24 个文件,无外部依赖
532
534
  node --run test:accept # 12 项:真实 HTTP + 36 进程并发写
533
535
  ```
534
536
 
package/lib/client.js CHANGED
@@ -483,8 +483,9 @@ window.__ModuleLoader__.load({
483
483
  h('textarea', {
484
484
  className: 'sn-input',
485
485
  rows: rows,
486
- // 上限就是校验线的软提示;真拦住的是保存时那一次整份校验
487
- maxLength: max > 0 ? max : undefined,
486
+ // 不用 maxLength:HTML 的 maxLength 按 UTF-16 单元计,一个 emoji 算 2,
487
+ // 会和按码点算的预算打架(emoji 多的段落会提前两个字符被拦住)。
488
+ // 这里只给计数与超限提示,真正拦的是保存时那一次整份校验。
488
489
  value: value,
489
490
  'data-field': key,
490
491
  onChange: function (e) { editPersona(key, e.target.value) },
@@ -1634,6 +1635,10 @@ window.__ModuleLoader__.load({
1634
1635
  var pTotalChars = p0Chars + p1Chars + pGap
1635
1636
  var pRestChars = Math.max(0, 320 - pTotalChars)
1636
1637
  var pL1Max = Math.max(0, 320 - Math.min(p0Chars, 80) - (p0Chars > 0 ? 1 : 0))
1638
+ var p2Chars = pinCount(personaValuesNow.l2)
1639
+ // 超限不靠 maxLength 拦(它按 UTF-16 计,emoji 算两个),而是停掉「保存」并说明原因;
1640
+ // 磁盘那关仍由服务端整份校验兜底 —— 这里只是别让人白点一次。
1641
+ var pOver = p0Chars > 80 || pTotalChars > 320 || p0Chars + p1Chars + p2Chars > 8000
1637
1642
  var firstPage = (state.corrections && state.corrections.items) || []
1638
1643
  // 翻过页就把后面的接上;同一 id 只留第一条(翻页期间新证据插进来时会出现重复)
1639
1644
  var correctionItems = firstPage.concat(moreEvidence).filter(function (item, at, list) {
@@ -1654,10 +1659,21 @@ window.__ModuleLoader__.load({
1654
1659
  'L0 ' + p0Chars + ' + L1 ' + p1Chars + (pGap ? ' + 换行 1' : '') + ' = ' + pTotalChars + ' / 320 字(余 ' + pRestChars + ')',
1655
1660
  ),
1656
1661
  personaField('l2', 'L2 扩展设定', '外貌、服饰、世界观、兴趣。这一层不进每回合上下文;保存在同一个 persona.md 里,不占注入预算。', personaValuesNow.l2, (state.persona && state.persona.l2Limit) || Math.max(0, 8000 - pTotalChars), 6),
1662
+ pOver
1663
+ ? h(
1664
+ 'div',
1665
+ { className: 'sn-hint-warn' },
1666
+ '超了:'
1667
+ + (p0Chars > 80 ? 'L0 超过 80 字;' : '')
1668
+ + (pTotalChars > 320 ? 'L0 + L1 超过 320 字;' : '')
1669
+ + (p0Chars + p1Chars + p2Chars > 8000 ? '整份超过 8000 字;' : '')
1670
+ + '减到上限以内再保存,否则整份会被拒绝、一个字都不写。',
1671
+ )
1672
+ : null,
1657
1673
  h(
1658
1674
  'div',
1659
1675
  { className: 'sn-actions' },
1660
- h('button', { className: 'sn-btn', onClick: savePersona, disabled: busy !== '' }, '保存人格'),
1676
+ h('button', { className: 'sn-btn', onClick: savePersona, disabled: busy !== '' || pOver }, '保存人格'),
1661
1677
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: importPersona, disabled: busy !== '' }, '从 AGENT.md 导入'),
1662
1678
  h('span', { className: 'sn-grow' }),
1663
1679
  h('span', { className: 'sn-muted' }, '保存是整份校验:任一段不合格就一个字都不写。'),
@@ -1943,9 +1959,20 @@ window.__ModuleLoader__.load({
1943
1959
  var injSegments = inj.segments || []
1944
1960
  var skippedItems = []
1945
1961
  injSegments.forEach(function (seg) {
1946
- ;(seg.skipped || []).forEach(function (text) {
1947
- skippedItems.push({ seg: seg.label, text: text })
1948
- })
1962
+ if (Array.isArray(seg.skippedDetail)) {
1963
+ seg.skippedDetail.forEach(function (item) {
1964
+ skippedItems.push({
1965
+ seg: seg.label,
1966
+ text: String((item && item.preview) ?? ''),
1967
+ reason: String((item && item.reason) ?? ''),
1968
+ })
1969
+ })
1970
+ } else {
1971
+ // 兼容旧 /state:只有 skipped 字符串,没有原因,也不许白板
1972
+ ;(seg.skipped || []).forEach(function (text) {
1973
+ skippedItems.push({ seg: seg.label, text: String(text ?? ''), reason: '' })
1974
+ })
1975
+ }
1949
1976
  })
1950
1977
 
1951
1978
  var injectionCard = h(
@@ -1968,9 +1995,9 @@ window.__ModuleLoader__.load({
1968
1995
  'div',
1969
1996
  { className: 'sn-seg' },
1970
1997
  injSegments.map(function (seg) {
1971
- var skipped = seg.skipped || []
1998
+ var skipped = Array.isArray(seg.skippedDetail) ? seg.skippedDetail : (seg.skipped || [])
1972
1999
  var stateText = seg.chars > 0 ? '全部进入' : '暂无内容'
1973
- if (skipped.length > 0) stateText = '挤掉 ' + skipped.length + ' 条'
2000
+ if (skipped.length > 0) stateText = '跳过 ' + skipped.length + ' 条'
1974
2001
  else if (seg.chars === 0 && seg.absorbed > 0) stateText = '已被画像吸收 ' + seg.absorbed + ' 条'
1975
2002
  return h(
1976
2003
  'div',
@@ -1984,25 +2011,27 @@ window.__ModuleLoader__.load({
1984
2011
  h(
1985
2012
  'div',
1986
2013
  { className: 'sn-hint' },
1987
- '四段只算记忆本身的字数;标题、边界句、画像抬头这些框架文字另计(' + (inj.overhead || 0) + ' 字),也算在总字数里。',
2014
+ '七段只算记忆本身的字数;标题、边界句、画像抬头这些框架文字另计(' + (inj.overhead || 0) + ' 字),也算在总字数里。',
1988
2015
  ),
1989
2016
  skippedItems.length === 0
1990
2017
  ? null
1991
2018
  : h(
1992
- 'div',
2019
+ 'details',
1993
2020
  { className: 'sn-skipped' },
1994
2021
  h(
1995
- 'div',
2022
+ 'summary',
1996
2023
  { className: 'sn-skipped-head' },
1997
- '被挤掉的 ' + skippedItems.length + ' 条 —— 超出本段预算就整条跳过,不截半句(半句话会变成错结论)',
2024
+ '被跳过的 ' + skippedItems.length + ' 条 —— 点开看每条为什么没进(每条都标了原因)',
1998
2025
  ),
1999
2026
  skippedItems.slice(0, 6).map(function (item, i) {
2000
- var text = item.text.length > 60 ? item.text.slice(0, 60) + '…' : item.text
2027
+ // key 用下标:内容指纹派生 id,同段两条相同文本会撞 id(口径裁定 2)
2028
+ var reasonLabel = { budget: '预算不足', expired: '已过期', scope: '项目不匹配', duplicate: '重复' }
2001
2029
  return h(
2002
2030
  'div',
2003
2031
  { className: 'sn-skipped-row', key: 'sk' + i },
2004
2032
  h('span', { className: 'sn-badge' }, item.seg),
2005
- h('span', { className: 'sn-skipped-text' }, text),
2033
+ h('span', { className: 'sn-skipped-reason' }, reasonLabel[item.reason] || item.reason || '未说明'),
2034
+ h('span', { className: 'sn-skipped-text' }, item.text),
2006
2035
  )
2007
2036
  }),
2008
2037
  skippedItems.length > 6 ? h('div', { className: 'sn-hint' }, '还有 ' + (skippedItems.length - 6) + ' 条没列出来') : null,
@@ -2320,8 +2349,9 @@ window.__ModuleLoader__.load({
2320
2349
  '.sn-seg-state{margin-left:auto;font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280)}',
2321
2350
  '.sn-seg-state-warn{color:#b45309}',
2322
2351
  '.sn-skipped{display:flex;flex-direction:column;gap:6px;padding:8px 10px;border:1px dashed var(--dsw-alias-border-subtle,#e5e7eb);border-radius:8px}',
2323
- '.sn-skipped-head{font-size:11px;color:#92400e;line-height:1.6}',
2324
- '.sn-skipped-row{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.6}',
2352
+ '.sn-skipped-head{font-size:11.5px;color:#92400e;line-height:1.6;cursor:pointer}',
2353
+ '.sn-skipped-row{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.6;flex-wrap:wrap}',
2354
+ '.sn-skipped-reason{font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280);border:1px solid var(--dsw-alias-border-subtle,#e5e7eb);border-radius:999px;padding:0 6px;white-space:nowrap}',
2325
2355
  '.sn-skipped-text{color:var(--dsw-alias-text-secondary,#4b5563)}',
2326
2356
  '.sn-doc-list{display:flex;flex-direction:column;gap:8px}',
2327
2357
  '.sn-doc-row{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.6;flex-wrap:wrap}',
package/lib/explain.js ADDED
@@ -0,0 +1,73 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { normalizeTier } from './store.js'
3
+
4
+ export const SKIP_REASONS = Object.freeze(['budget', 'expired', 'scope', 'duplicate'])
5
+ export const SKIP_PRIORITY = Object.freeze(['scope', 'expired', 'duplicate', 'budget'])
6
+ export const CORE_PREVIEW = '素材层,不注入'
7
+ export const PREVIEW_MAX_CHARS = 60
8
+
9
+ // 六步规范(m05194):纯函数,不含 Date / 随机 / locale。
10
+ // ① 非字符串一律转字符串 ② NFKC 兼容等价 + 全角半角 ③ CRLF / 裸 CR → LF
11
+ // ④ 去首尾空白(连带 BOM) ⑤ 连续空白折叠为一个空格 ⑥ 返回稳定文本
12
+ export function normalizeForId(text) {
13
+ const s1 = String(text ?? '')
14
+ const s2 = s1.normalize('NFKC')
15
+ const s3 = s2.replace(/\r\n?/g, '\n')
16
+ const s4 = s3.trim()
17
+ const s5 = s4.replace(/\s+/gu, ' ')
18
+ return s5
19
+ }
20
+
21
+ export function contentId(text) {
22
+ return createHash('sha1').update(normalizeForId(text), 'utf8').digest('hex').slice(0, 12)
23
+ }
24
+
25
+ export function pickReason(flags = {}) {
26
+ for (const reason of SKIP_PRIORITY) {
27
+ if (flags[reason] === true) return reason
28
+ }
29
+ return null
30
+ }
31
+
32
+ export function skippedId(segmentKey, text) {
33
+ return String(segmentKey) + ':' + contentId(text)
34
+ }
35
+
36
+ // 只用于「展示预览」:按字素簇截,不切 surrogate pair / 组合字符 / ZWJ 序列。
37
+ // 与六段预算的 countChars()(码点)是两把尺子,不许互相回灌。
38
+ const SEGMENTER = new Intl.Segmenter('und', { granularity: 'grapheme' })
39
+
40
+ export function previewOf(text, maxGraphemes = PREVIEW_MAX_CHARS) {
41
+ const s = String(text ?? '')
42
+ const parts = Array.from(SEGMENTER.segment(s), (part) => part.segment)
43
+ if (parts.length <= maxGraphemes) return s
44
+ return parts.slice(0, maxGraphemes).join('') + '…'
45
+ }
46
+
47
+ // 只读取已有打包结果做「事后分类」:绝不重新打包、不改候选集合 / 排序 / 预算 / 文本。
48
+ // sources 与 skipped 同源、等长、逐位平行(由 packNotes() 在构造 skipped 的同一次调用里产出)。
49
+ function flagsFor(source) {
50
+ return { scope: false, expired: source === 'expired', duplicate: false, budget: true }
51
+ }
52
+
53
+ export function describeSkipped({ key, skipped = [], sources = [], notes = [] } = {}) {
54
+ const list = Array.isArray(skipped) ? skipped : []
55
+ // 来源数组必须同源等长;缺一项就整批保守降级,宁可少报原因也不报错原因。
56
+ const src = Array.isArray(sources) && sources.length === list.length ? sources : []
57
+ const out = []
58
+ list.forEach((raw, i) => {
59
+ const text = String(raw ?? '')
60
+ const reason = pickReason(flagsFor(src[i] ?? null))
61
+ if (reason !== null) {
62
+ out.push({ id: skippedId(key, text), preview: previewOf(text), reason })
63
+ }
64
+ })
65
+ if (key === 'notes') {
66
+ const coreNotes = Array.isArray(notes) ? notes : []
67
+ for (const entry of coreNotes.filter((e) => normalizeTier(e?.tier) === 'core')) {
68
+ const text = String(entry?.text ?? '')
69
+ out.push({ id: skippedId(key, text), preview: CORE_PREVIEW, reason: 'budget' })
70
+ }
71
+ }
72
+ return out
73
+ }
package/lib/index.js CHANGED
@@ -54,6 +54,7 @@ import { discardByIds, listCorrections, readEvents, readWatermark } from './corr
54
54
  import { commitPersona, lockState, recover, runSerial } from './persona-tx.js'
55
55
 
56
56
  import { randomBytes, timingSafeEqual } from 'node:crypto'
57
+ import { describeSkipped } from './explain.js'
57
58
 
58
59
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
59
60
  import { spawn } from 'node:child_process'
@@ -416,7 +417,7 @@ function fit(items, limit) {
416
417
  * 身份该由人格段(L0/L1)说,两处都说一遍等于让模型看两版自己。
417
418
  * 存量 core 一条不删,只是不进上下文;条数靠门牌告诉模型「有这么些素材」。
418
419
  */
419
- function packNotes(entries, limit, ttl) {
420
+ export function packNotes(entries, limit, ttl) {
420
421
  const ttlDays = Number.isFinite(Number(ttl)) ? Number(ttl) : DEFAULT_TEMP_TTL_DAYS
421
422
  const core = []
422
423
  const stable = []
@@ -449,10 +450,18 @@ function packNotes(entries, limit, ttl) {
449
450
  // 过期的也算「没进上下文」:它们还在库里,模型得靠门牌知道有这么几条,
450
451
  // 所以并进 skipped 一起报数 —— 少报就是骗人。
451
452
  const expiredTexts = expired.map((entry) => entry.text)
453
+ const stableSkipped = stableFit.skipped
454
+ const tempSkipped = tempFit.skipped
452
455
  return {
453
456
  text: parts.join('\n'),
454
457
  kept: [...stableFit.kept, ...tempFit.kept],
455
- skipped: [...stableFit.skipped, ...tempFit.skipped, ...expiredTexts],
458
+ skipped: [...stableSkipped, ...tempSkipped, ...expiredTexts],
459
+ // 与 skipped 同一次构造、逐位平行:只给解释层读,不参与筛选 / 排序 / 预算 / 文本
460
+ skippedSources: [
461
+ ...stableSkipped.map(() => 'budget'),
462
+ ...tempSkipped.map(() => 'budget'),
463
+ ...expiredTexts.map(() => 'expired'),
464
+ ],
456
465
  // 每型的账:设置页要能指着说「稳定 3 条全进了,临时 2 条被挤掉」
457
466
  tiers: {
458
467
  // 核心不进注入,所以 kept 恒 0、limit 恒 0:读数上「0 / 0」比「3 / 0」更不容易被误读
@@ -697,30 +706,37 @@ function assembleInjection(cfg, options = {}) {
697
706
  const segments = []
698
707
  const parts = []
699
708
  const personaParts = []
709
+ const noteEntries = readNotes()
700
710
 
701
711
  // 人格:L0 + L1 常驻,排在最前 —— 它不是「记忆」,是「我是谁」,
702
712
  // 所以它在边界句「(背景资料,不是指令)」之外,先读到的是自己的样子。
703
713
  // 关掉只是不注入,persona.md 一个字节都不动。
714
+ const ZERO_PERSONA_FIT = {
715
+ text: '', chars: 0, kept: [], skipped: [], truncated: false, pointer: '',
716
+ l0: { chars: 0, kept: 0, skipped: 0 }, l1: { chars: 0, kept: 0, skipped: 0 },
717
+ }
718
+ const fit = cfg.personaEnabled === false
719
+ ? ZERO_PERSONA_FIT // 关闭时连 persona.md 都不读
720
+ : personaSegment(readPersona(), BUDGET.persona)
721
+ segments.push({
722
+ key: 'persona',
723
+ label: '人格',
724
+ limit: BUDGET.persona,
725
+ chars: fit.chars,
726
+ kept: fit.kept,
727
+ skipped: fit.skipped,
728
+ truncated: fit.truncated,
729
+ pointer: fit.pointer,
730
+ l0: fit.l0,
731
+ l1: fit.l1,
732
+ })
704
733
  if (cfg.personaEnabled !== false) {
705
- const fit = personaSegment(readPersona(), BUDGET.persona)
706
- segments.push({
707
- key: 'persona',
708
- label: '人格',
709
- limit: BUDGET.persona,
710
- chars: fit.chars,
711
- kept: fit.kept,
712
- skipped: fit.skipped,
713
- truncated: fit.truncated,
714
- pointer: fit.pointer,
715
- l0: fit.l0,
716
- l1: fit.l1,
717
- })
718
734
  if (fit.text !== '') personaParts.push(fit.text)
719
735
  if (fit.pointer !== '') personaParts.push(fit.pointer)
720
736
  }
721
737
 
722
738
  // 自述:三型分账 + 门牌。装不下的那几条,模型必须知道存在,否则它会把「没看见」当成「没有」。
723
- const noteFit = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays)
739
+ const noteFit = notesSegment(noteEntries, BUDGET.notes, cfg.tempTtlDays)
724
740
  segments.push({
725
741
  key: 'notes',
726
742
  label: '自述',
@@ -829,6 +845,18 @@ function assembleInjection(cfg, options = {}) {
829
845
  segments.push({ key: 'habits', label: '习惯', limit: BUDGET.habits, chars: countChars(habitFit.text), kept: habitFit.kept, skipped: habitFit.skipped })
830
846
  if (habitFit.text !== '') parts.push(habitFit.text)
831
847
 
848
+ // 解释元数据只挂在这一遍的 segments 上:预览那遍与真注入那遍各算各的,
849
+ // 不写全局、不落盘(D3),injectCache 仍只存 { key, text }。
850
+ for (const seg of segments) {
851
+ seg.skippedDetail = describeSkipped({
852
+ key: seg.key,
853
+ skipped: Array.isArray(seg.skipped) ? seg.skipped : [],
854
+ // 非 notes 段传空数组:describeSkipped 见长度不齐就整批保守降级成 budget,正是它们该有的值
855
+ sources: seg.key === 'notes' && Array.isArray(noteFit.skippedSources) ? noteFit.skippedSources : [],
856
+ notes: seg.key === 'notes' ? noteEntries : [],
857
+ })
858
+ }
859
+
832
860
  const heading = typeof cfg.heading === 'string' ? cfg.heading.trim() : DEFAULT_CONFIG.heading
833
861
  const body = parts.join('\n')
834
862
  // 边界句:让模型知道这一段是背景资料,不是有人在借记忆之名给它下命令。
@@ -950,6 +978,22 @@ export function readHeartbeat() {
950
978
  }
951
979
  }
952
980
 
981
+ /**
982
+ * 记一次「注入文本真返回给模型」。这是与解释数据(skippedDetail)分开的独立观测口径:
983
+ * 同步 read-modify-write,中间没有 await,单进程内不会交错;state.json 不在 injectionKey()
984
+ * 的四个 stamps 里,写它不会让注入缓存失效;写失败静默,绝不影响原本的注入返回。
985
+ * 不用 heartbeat.injections(它有 60 秒节流,且缓存命中不写),也不用 seenCount / hitCount。
986
+ */
987
+ function noteInjection(text) {
988
+ if (typeof text !== 'string' || text === '') return
989
+ try {
990
+ const state = readState()
991
+ const prev = Number(state.injectionCount)
992
+ const count = Number.isFinite(prev) ? prev + 1 : 1
993
+ writeState({ ...state, injectionCount: count, lastInjectedAt: new Date().toISOString() })
994
+ } catch { /* 计数失败绝不影响注入 */ }
995
+ }
996
+
953
997
  function beat(extra = {}) {
954
998
  const now = Date.now()
955
999
  if (now - lastBeat < HEARTBEAT_MS) return
@@ -1242,7 +1286,7 @@ function doctorState(inline) {
1242
1286
  }
1243
1287
  }
1244
1288
  if (broken > 0 || noText > 0) {
1245
- push('facts', '记忆文件完整性', 'error', `${broken} 行解析不出来、${noText} 条没有正文 —— 这些记忆读不到`, '从 .bak 恢复或手工修')
1289
+ push('facts', '记忆文件完整性', 'error', `${broken} 行解析不出来、${noText} 条没有正文 —— 这些记忆读不到`, '先复制一份 facts.jsonl,再手工删掉那几行(坏行只影响它自己)')
1246
1290
  } else {
1247
1291
  push('facts', '记忆文件完整性', 'ok', `${lines.length} 行全部可解析`)
1248
1292
  }
@@ -1443,6 +1487,7 @@ function buildInjection(inline, ctx) {
1443
1487
  const text = composeInjection(inline, { sessionId, spotlight })
1444
1488
  // 补偿只补一次:这一轮真贴进去了,才把标记清掉(失败的话下一轮还有机会)
1445
1489
  if (spotlight.length > 0) compactionPending.delete(sessionId)
1490
+ noteInjection(text)
1446
1491
  return text
1447
1492
  } catch (error) {
1448
1493
  return ''
@@ -1738,6 +1783,8 @@ function stateOfInner(inline) {
1738
1783
  const builtNow = cfg.enabled ? assembleInjection(cfg) : null
1739
1784
  // facts.jsonl 也只读一次(下面候选/已采纳/退役/归属四张卡片共用)
1740
1785
  const factsNow = listFacts()
1786
+ // 计数是另一条观测口径:只读一次计数文件,不参与注入解释数据的组装
1787
+ const injectionStats = readState()
1741
1788
  return {
1742
1789
  ok: true,
1743
1790
  enabled: cfg.enabled,
@@ -1767,6 +1814,8 @@ function stateOfInner(inline) {
1767
1814
  corrections: correctionsStateOf(),
1768
1815
  budget: budgetOf(inline),
1769
1816
  injection: injectionState(inline, builtNow),
1817
+ injectionCount: Number.isFinite(Number(injectionStats.injectionCount)) ? Number(injectionStats.injectionCount) : 0,
1818
+ lastInjectedAt: typeof injectionStats.lastInjectedAt === 'string' ? injectionStats.lastInjectedAt : '',
1770
1819
  attribution: attributionState(inline, builtNow, factsNow),
1771
1820
  profile: profileState(inline),
1772
1821
  facts: {
package/lib/persona.js CHANGED
@@ -73,11 +73,17 @@ export function parsePersona(raw) {
73
73
 
74
74
  const blocks = { l0: [], l1: [], l2: [] }
75
75
  const orphan = []
76
+ const seen = new Set()
76
77
  let current = null
77
78
 
78
79
  for (const line of text.split('\n')) {
79
80
  const tier = tierOfLine(line)
80
- if (tier !== null) {
81
+ // 分节规则:某一层第一次出现才算换节;同一层再出现且当前就在这一层,仍按合段处理
82
+ // (手写文件里把规则拆成两段 L1 是常见写法)。但若某一层已经出现、当前却在别的层,
83
+ // 那这一行是正文 —— 否则正文里一句「## L0 …」会把上一段整段抢走,round-trip 就丢层级了。
84
+ const isHeading = tier !== null && (!seen.has(tier) || tier === current)
85
+ if (isHeading) {
86
+ seen.add(tier)
81
87
  current = tier
82
88
  blocks[tier].push([])
83
89
  continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-selfnote",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
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/fair.mjs && node tests/capture-debug.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/notes-tier.mjs && node tests/persona.mjs && node tests/corrections.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/capture-debug.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/explain.mjs && node tests/skipped.mjs && node tests/injection-count.mjs && node tests/notes-tier.mjs && node tests/persona.mjs && node tests/corrections.mjs && node tests/client-state.mjs && node tests/client.mjs",
31
31
  "test:accept": "node tests/acceptance-runtime.mjs"
32
32
  },
33
33
  "engines": {