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 +10 -8
- package/lib/client.js +46 -16
- package/lib/explain.js +73 -0
- package/lib/index.js +66 -17
- package/lib/persona.js +7 -1
- package/package.json +2 -2
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.
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 #
|
|
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
|
-
|
|
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
|
-
|
|
1947
|
-
|
|
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 = '
|
|
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
|
-
'
|
|
2014
|
+
'七段只算记忆本身的字数;标题、边界句、画像抬头这些框架文字另计(' + (inj.overhead || 0) + ' 字),也算在总字数里。',
|
|
1988
2015
|
),
|
|
1989
2016
|
skippedItems.length === 0
|
|
1990
2017
|
? null
|
|
1991
2018
|
: h(
|
|
1992
|
-
'
|
|
2019
|
+
'details',
|
|
1993
2020
|
{ className: 'sn-skipped' },
|
|
1994
2021
|
h(
|
|
1995
|
-
'
|
|
2022
|
+
'summary',
|
|
1996
2023
|
{ className: 'sn-skipped-head' },
|
|
1997
|
-
'
|
|
2024
|
+
'被跳过的 ' + skippedItems.length + ' 条 —— 点开看每条为什么没进(每条都标了原因)',
|
|
1998
2025
|
),
|
|
1999
2026
|
skippedItems.slice(0, 6).map(function (item, i) {
|
|
2000
|
-
|
|
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-
|
|
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:
|
|
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: [...
|
|
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(
|
|
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} 条没有正文 —— 这些记忆读不到`, '
|
|
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
|
-
|
|
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.
|
|
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": {
|