dsh-selfnote 0.14.0 → 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 +364 -24
- package/lib/explain.js +73 -0
- package/lib/index.js +73 -19
- 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
|
@@ -178,6 +178,28 @@ window.__ModuleLoader__.load({
|
|
|
178
178
|
var ruleDraftTuple = useState('')
|
|
179
179
|
var ruleDraft = ruleDraftTuple[0]
|
|
180
180
|
var setRuleDraft = ruleDraftTuple[1]
|
|
181
|
+
// 退役也要两步:退役不删数据,但退役之后它就不进注入了,误点一下等于凭空少一条
|
|
182
|
+
var pendingRetireTuple = useState(null)
|
|
183
|
+
var pendingRetire = pendingRetireTuple[0]
|
|
184
|
+
var setPendingRetire = pendingRetireTuple[1]
|
|
185
|
+
// 「已退役」默认收起:正常翻记忆时不该被一堆旧结论挡路
|
|
186
|
+
var showRetiredTuple = useState(false)
|
|
187
|
+
var showRetired = showRetiredTuple[0]
|
|
188
|
+
var setShowRetired = showRetiredTuple[1]
|
|
189
|
+
// 半衰期与画像上限:以前只能在 config.json 里手改,界面上看不见也就没人调
|
|
190
|
+
var halfLifeTuple = useState('')
|
|
191
|
+
var halfLife = halfLifeTuple[0]
|
|
192
|
+
var setHalfLife = halfLifeTuple[1]
|
|
193
|
+
var profileMaxTuple = useState('')
|
|
194
|
+
var profileMax = profileMaxTuple[0]
|
|
195
|
+
var setProfileMax = profileMaxTuple[1]
|
|
196
|
+
// 纠偏证据一页只给 30 条:后面还有就用「加载更多」翻,翻过的攒在 moreEvidence 里
|
|
197
|
+
var moreEvidenceTuple = useState([])
|
|
198
|
+
var moreEvidence = moreEvidenceTuple[0]
|
|
199
|
+
var setMoreEvidence = moreEvidenceTuple[1]
|
|
200
|
+
var moreCursorTuple = useState(null)
|
|
201
|
+
var moreCursor = moreCursorTuple[0]
|
|
202
|
+
var setMoreCursor = moreCursorTuple[1]
|
|
181
203
|
|
|
182
204
|
function apply(data) {
|
|
183
205
|
setState(data)
|
|
@@ -189,9 +211,14 @@ window.__ModuleLoader__.load({
|
|
|
189
211
|
setNoteGuide(typeof data.noteGuide === 'string' ? data.noteGuide : '')
|
|
190
212
|
setHeading(typeof data.heading === 'string' ? data.heading : '')
|
|
191
213
|
setTempTtl(String(data.tempTtlDays === undefined ? 14 : data.tempTtlDays))
|
|
214
|
+
setHalfLife(String(data.halfLifeDays === undefined ? 30 : data.halfLifeDays))
|
|
215
|
+
setProfileMax(String(data.profile && data.profile.maxChars !== undefined ? data.profile.maxChars : 110))
|
|
216
|
+
setMoreEvidence([])
|
|
217
|
+
setMoreCursor(null)
|
|
192
218
|
// 每次拿到新状态,列表下标都可能整体挪位:正在改/正等确认的那条已经没意义了
|
|
193
219
|
setEditing(null)
|
|
194
220
|
setPendingDelete(null)
|
|
221
|
+
setPendingRetire(null)
|
|
195
222
|
setFactEdit(null)
|
|
196
223
|
}
|
|
197
224
|
|
|
@@ -225,6 +252,28 @@ window.__ModuleLoader__.load({
|
|
|
225
252
|
}
|
|
226
253
|
}, [confirming])
|
|
227
254
|
|
|
255
|
+
// 「真的删」和「真的退役」跟「真的清空」一个口径:武装 5 秒,走开就自动放下。
|
|
256
|
+
// 之前只有清空带定时器,删自述是点开就永久武装 —— 点了「删」犹豫一下、切个页签回来再点同一格,就真删了。
|
|
257
|
+
useEffect(function () {
|
|
258
|
+
if (pendingDelete === null) return undefined
|
|
259
|
+
var timer = setTimeout(function () {
|
|
260
|
+
setPendingDelete(null)
|
|
261
|
+
}, 5000)
|
|
262
|
+
return function () {
|
|
263
|
+
clearTimeout(timer)
|
|
264
|
+
}
|
|
265
|
+
}, [pendingDelete])
|
|
266
|
+
|
|
267
|
+
useEffect(function () {
|
|
268
|
+
if (pendingRetire === null) return undefined
|
|
269
|
+
var timer = setTimeout(function () {
|
|
270
|
+
setPendingRetire(null)
|
|
271
|
+
}, 5000)
|
|
272
|
+
return function () {
|
|
273
|
+
clearTimeout(timer)
|
|
274
|
+
}
|
|
275
|
+
}, [pendingRetire])
|
|
276
|
+
|
|
228
277
|
function runDoctor() {
|
|
229
278
|
setBusy('doctor')
|
|
230
279
|
apiGet('/doctor')
|
|
@@ -286,6 +335,76 @@ window.__ModuleLoader__.load({
|
|
|
286
335
|
})
|
|
287
336
|
}
|
|
288
337
|
|
|
338
|
+
// 半衰期与画像上限走 /config;服务端会校验范围,越界就 400 把原因带回来
|
|
339
|
+
function saveHalfLife() {
|
|
340
|
+
setBusy('config')
|
|
341
|
+
apiPost('/config', { halfLifeDays: Number(halfLife), profileMaxChars: Number(profileMax) })
|
|
342
|
+
.then(function (data) {
|
|
343
|
+
apply(data)
|
|
344
|
+
setNotice({ tone: 'ok', text: '已保存:结论按 ' + data.halfLifeDays + ' 天衰减,画像上限 ' + (data.profile && data.profile.maxChars) + ' 字。' })
|
|
345
|
+
})
|
|
346
|
+
.catch(function (error) {
|
|
347
|
+
setNotice({ tone: 'warn', text: '保存失败:' + error.message })
|
|
348
|
+
})
|
|
349
|
+
.then(function () {
|
|
350
|
+
setBusy('')
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 索引开关:启用前服务端会自己检查「在盘上 + 没过期」,不满足就 400,把原话显示出来
|
|
355
|
+
function toggleIndex(next) {
|
|
356
|
+
setBusy('index')
|
|
357
|
+
apiPost('/index', { action: next ? 'enable' : 'disable' })
|
|
358
|
+
.then(function (data) {
|
|
359
|
+
apply(data)
|
|
360
|
+
setNotice({ tone: 'ok', text: next ? '索引已启用:下一回合起检索先走索引。' : '索引已关闭:检索退回完整扫描。' })
|
|
361
|
+
})
|
|
362
|
+
.catch(function (error) {
|
|
363
|
+
setNotice({ tone: 'warn', text: next ? '启用失败:' + error.message : '关闭失败:' + error.message })
|
|
364
|
+
})
|
|
365
|
+
.then(function () {
|
|
366
|
+
setBusy('')
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 翻页:服务端按 (at, id) 给游标,翻过的追加在后面,已勾选的不会因为重新渲染丢掉
|
|
371
|
+
function loadMoreEvidence() {
|
|
372
|
+
var cursor = moreCursor !== null ? moreCursor : state && state.corrections ? state.corrections.nextCursor : null
|
|
373
|
+
if (!cursor) return
|
|
374
|
+
setBusy('corrections')
|
|
375
|
+
apiGet('/corrections?limit=30&cursor=' + encodeURIComponent(cursor))
|
|
376
|
+
.then(function (data) {
|
|
377
|
+
setMoreEvidence(moreEvidence.concat(data.items || []))
|
|
378
|
+
setMoreCursor(data.nextCursor || '')
|
|
379
|
+
if (!data.nextCursor) setNotice({ tone: 'ok', text: '证据已经翻到底了。' })
|
|
380
|
+
})
|
|
381
|
+
.catch(function (error) {
|
|
382
|
+
setNotice({ tone: 'warn', text: '翻页失败:' + error.message })
|
|
383
|
+
})
|
|
384
|
+
.then(function () {
|
|
385
|
+
setBusy('')
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 丢弃:这些原话连规则都不是,只是记录,丢掉不该有心理负担
|
|
390
|
+
function discardPicked() {
|
|
391
|
+
var ids = pickedEvidence.slice()
|
|
392
|
+
if (ids.length === 0) return
|
|
393
|
+
setBusy('corrections')
|
|
394
|
+
apiPost('/corrections/discard', { ids: ids })
|
|
395
|
+
.then(function (data) {
|
|
396
|
+
apply(data)
|
|
397
|
+
setPickedEvidence([])
|
|
398
|
+
setNotice({ tone: 'ok', text: '已丢弃 ' + ids.length + ' 条证据(它们不会再来烦你)。' })
|
|
399
|
+
})
|
|
400
|
+
.catch(function (error) {
|
|
401
|
+
setNotice({ tone: 'warn', text: '丢弃失败:' + error.message })
|
|
402
|
+
})
|
|
403
|
+
.then(function () {
|
|
404
|
+
setBusy('')
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
289
408
|
function saveHeading() {
|
|
290
409
|
setBusy('heading')
|
|
291
410
|
apiPost('/config', { heading: heading })
|
|
@@ -364,8 +483,9 @@ window.__ModuleLoader__.load({
|
|
|
364
483
|
h('textarea', {
|
|
365
484
|
className: 'sn-input',
|
|
366
485
|
rows: rows,
|
|
367
|
-
//
|
|
368
|
-
|
|
486
|
+
// 不用 maxLength:HTML 的 maxLength 按 UTF-16 单元计,一个 emoji 算 2,
|
|
487
|
+
// 会和按码点算的预算打架(emoji 多的段落会提前两个字符被拦住)。
|
|
488
|
+
// 这里只给计数与超限提示,真正拦的是保存时那一次整份校验。
|
|
369
489
|
value: value,
|
|
370
490
|
'data-field': key,
|
|
371
491
|
onChange: function (e) { editPersona(key, e.target.value) },
|
|
@@ -591,7 +711,15 @@ window.__ModuleLoader__.load({
|
|
|
591
711
|
apiPost('/fact', { id: id, status: status })
|
|
592
712
|
.then(function (data) {
|
|
593
713
|
apply(data)
|
|
594
|
-
setNotice({
|
|
714
|
+
setNotice({
|
|
715
|
+
tone: 'ok',
|
|
716
|
+
text:
|
|
717
|
+
status === 'approved'
|
|
718
|
+
? '已采纳(原来的退役也一并恢复),下一回合进注入。'
|
|
719
|
+
: status === 'retired'
|
|
720
|
+
? '已退役:不再进注入,要它回来去「已退役」里点恢复。'
|
|
721
|
+
: '已处理。',
|
|
722
|
+
})
|
|
595
723
|
})
|
|
596
724
|
.catch(function (error) {
|
|
597
725
|
setNotice({ tone: 'warn', text: '没改成:' + error.message })
|
|
@@ -601,6 +729,17 @@ window.__ModuleLoader__.load({
|
|
|
601
729
|
})
|
|
602
730
|
}
|
|
603
731
|
|
|
732
|
+
// 退役两步:第一次只武装,第二次才发请求。数据留在 facts.jsonl 里,随时能恢复。
|
|
733
|
+
function retireFact(id) {
|
|
734
|
+
if (pendingRetire !== id) {
|
|
735
|
+
setPendingRetire(id)
|
|
736
|
+
setNotice({ tone: 'info', text: '再点一次「真的退役」——它不进注入了,但还在「已退役」里,随时能恢复。' })
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
setPendingRetire(null)
|
|
740
|
+
setFact(id, 'retired')
|
|
741
|
+
}
|
|
742
|
+
|
|
604
743
|
function setHabitState(text, status, tag) {
|
|
605
744
|
setBusy('habit')
|
|
606
745
|
apiPost('/habit', { text: text, status: status, tag: tag || '' })
|
|
@@ -617,10 +756,31 @@ window.__ModuleLoader__.load({
|
|
|
617
756
|
}
|
|
618
757
|
|
|
619
758
|
if (state === null) {
|
|
759
|
+
// 三态:正在读 / 读失败 / 刚进来还没读。读失败时把 refresh 里 catch 到的真实原因端出来,
|
|
760
|
+
// 别用「重启 DSH 试试」这种猜测盖掉它 —— 重启治不了 403(凭据),也治不了 404(路由)。
|
|
761
|
+
var loadFailed = busy !== 'load' && notice !== null
|
|
620
762
|
return h(
|
|
621
763
|
'div',
|
|
622
764
|
{ className: 'sn-root' },
|
|
623
|
-
h(
|
|
765
|
+
h(
|
|
766
|
+
'div',
|
|
767
|
+
{
|
|
768
|
+
className: 'sn-loading' + (loadFailed ? ' sn-loading-error' : ''),
|
|
769
|
+
role: loadFailed ? 'alert' : 'status',
|
|
770
|
+
},
|
|
771
|
+
loadFailed ? notice.text : '读取中…',
|
|
772
|
+
),
|
|
773
|
+
loadFailed
|
|
774
|
+
? h(
|
|
775
|
+
'div',
|
|
776
|
+
{ className: 'sn-actions' },
|
|
777
|
+
h(
|
|
778
|
+
'button',
|
|
779
|
+
{ type: 'button', className: 'sn-btn', onClick: function () { refresh() }, disabled: busy !== '' },
|
|
780
|
+
'重试',
|
|
781
|
+
),
|
|
782
|
+
)
|
|
783
|
+
: null,
|
|
624
784
|
)
|
|
625
785
|
}
|
|
626
786
|
|
|
@@ -683,7 +843,11 @@ window.__ModuleLoader__.load({
|
|
|
683
843
|
'div',
|
|
684
844
|
{ key: 'actions', className: 'sn-actions' },
|
|
685
845
|
h('button', { className: 'sn-btn', onClick: function () { setFact(fact.id, 'approved') }, disabled: busy !== '' }, '采纳'),
|
|
686
|
-
h(
|
|
846
|
+
h(
|
|
847
|
+
'button',
|
|
848
|
+
{ className: 'sn-btn sn-btn-ghost' + (pendingRetire === fact.id ? ' sn-btn-danger' : ''), onClick: function () { retireFact(fact.id) }, disabled: busy !== '' },
|
|
849
|
+
pendingRetire === fact.id ? '真的退役' : '退役',
|
|
850
|
+
),
|
|
687
851
|
h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit({ id: fact.id, text: fact.text }) }, disabled: busy !== '' }, '改'),
|
|
688
852
|
)
|
|
689
853
|
: h('div', { key: 'state', className: 'sn-fact-cite' }, '状态:' + statusLabel(fact.status)),
|
|
@@ -794,6 +958,10 @@ window.__ModuleLoader__.load({
|
|
|
794
958
|
return Math.round(hours / 24) + ' 天前'
|
|
795
959
|
}
|
|
796
960
|
|
|
961
|
+
// 没调过 apply 时(或被外部刷新打断)也显示服务端的真值,别显示空白
|
|
962
|
+
var halfLifeShown = halfLife === '' ? String(state.halfLifeDays === undefined ? 30 : state.halfLifeDays) : halfLife
|
|
963
|
+
var profileMaxShown = profileMax === '' ? String((state.profile && state.profile.maxChars) || 110) : profileMax
|
|
964
|
+
|
|
797
965
|
var settingsCard = h(
|
|
798
966
|
'div',
|
|
799
967
|
{ className: 'sn-card', key: 'settings' },
|
|
@@ -829,6 +997,7 @@ window.__ModuleLoader__.load({
|
|
|
829
997
|
h('input', {
|
|
830
998
|
type: 'text',
|
|
831
999
|
maxLength: 24,
|
|
1000
|
+
'aria-label': '注入抬头,最多 24 个字,留空就不加抬头',
|
|
832
1001
|
value: heading,
|
|
833
1002
|
placeholder: '例:AI记忆',
|
|
834
1003
|
onChange: function (e) { setHeading(e.target.value) },
|
|
@@ -849,6 +1018,38 @@ window.__ModuleLoader__.load({
|
|
|
849
1018
|
h('button', { className: 'sn-btn', onClick: saveTempTtl, disabled: busy !== '' }, '保存保质期'),
|
|
850
1019
|
),
|
|
851
1020
|
h('div', { className: 'sn-hint' }, '只对「临时」型自述生效(1–90 天,默认 14)。过了期不再自动进上下文,内容一个字都没删 —— 改成「稳定」或重写一条就回来。'),
|
|
1021
|
+
h(
|
|
1022
|
+
'div',
|
|
1023
|
+
{ className: 'sn-limits' },
|
|
1024
|
+
h(
|
|
1025
|
+
'label',
|
|
1026
|
+
{ className: 'sn-field' },
|
|
1027
|
+
h('span', null, '结论半衰期(天)'),
|
|
1028
|
+
h('input', {
|
|
1029
|
+
type: 'number',
|
|
1030
|
+
min: 1,
|
|
1031
|
+
max: 365,
|
|
1032
|
+
'aria-label': '结论半衰期,单位天,范围 1 到 365',
|
|
1033
|
+
value: halfLifeShown,
|
|
1034
|
+
onChange: function (e) { setHalfLife(e.target.value) },
|
|
1035
|
+
}),
|
|
1036
|
+
),
|
|
1037
|
+
h(
|
|
1038
|
+
'label',
|
|
1039
|
+
{ className: 'sn-field' },
|
|
1040
|
+
h('span', null, '画像上限(字)'),
|
|
1041
|
+
h('input', {
|
|
1042
|
+
type: 'number',
|
|
1043
|
+
min: 20,
|
|
1044
|
+
max: 120,
|
|
1045
|
+
'aria-label': '画像上限,单位字,范围 20 到 120',
|
|
1046
|
+
value: profileMaxShown,
|
|
1047
|
+
onChange: function (e) { setProfileMax(e.target.value) },
|
|
1048
|
+
}),
|
|
1049
|
+
),
|
|
1050
|
+
h('button', { className: 'sn-btn', onClick: saveHalfLife, disabled: busy !== '' }, '保存这两项'),
|
|
1051
|
+
),
|
|
1052
|
+
h('div', { className: 'sn-hint' }, '半衰期决定旧结论多久算「半凉」:越小越偏爱最近的。画像上限是「关于他」那段最多占多少字。'),
|
|
852
1053
|
h(
|
|
853
1054
|
'div',
|
|
854
1055
|
{ className: 'sn-field sn-field-wide' },
|
|
@@ -856,6 +1057,7 @@ window.__ModuleLoader__.load({
|
|
|
856
1057
|
h('textarea', {
|
|
857
1058
|
rows: 2,
|
|
858
1059
|
maxLength: 80,
|
|
1060
|
+
'aria-label': '什么时候该写自述,留空就用默认判据',
|
|
859
1061
|
value: noteGuide,
|
|
860
1062
|
placeholder: '例:每次改完一个插件、跑完验收之后写一条;闲聊不用写。',
|
|
861
1063
|
onChange: function (e) { setNoteGuide(e.target.value) },
|
|
@@ -923,6 +1125,7 @@ window.__ModuleLoader__.load({
|
|
|
923
1125
|
'div',
|
|
924
1126
|
{ className: 'sn-kv' },
|
|
925
1127
|
kv('索引状态', h('span', { className: 'sn-kv-tag ' + idxTone }, idxStatus)),
|
|
1128
|
+
kv('检索开关', h('span', { className: 'sn-kv-tag ' + (idx.enabled === true ? 'sn-kv-tag-ok' : 'sn-kv-tag-missing') }, idx.enabled === true ? '已启用' : '已关闭')),
|
|
926
1129
|
kv('已索引会话', String(idx.sessions ?? 0)),
|
|
927
1130
|
kv('磁盘会话', String(idx.disk ?? 0)),
|
|
928
1131
|
kv('待同步会话', String(idx.missing ?? 0)),
|
|
@@ -934,6 +1137,15 @@ window.__ModuleLoader__.load({
|
|
|
934
1137
|
'div',
|
|
935
1138
|
{ className: 'sn-actions' },
|
|
936
1139
|
h('button', { className: 'sn-btn', onClick: rebuildIndex, disabled: busy !== '' }, busy === 'index' ? '重建中…' : '重建索引'),
|
|
1140
|
+
h(
|
|
1141
|
+
'button',
|
|
1142
|
+
{
|
|
1143
|
+
className: idx.enabled === true ? 'sn-btn sn-btn-ghost' : 'sn-btn',
|
|
1144
|
+
onClick: function () { toggleIndex(idx.enabled !== true) },
|
|
1145
|
+
disabled: busy !== '' || (idx.enabled !== true && idx.present !== true),
|
|
1146
|
+
},
|
|
1147
|
+
idx.enabled === true ? '关闭索引' : '启用索引',
|
|
1148
|
+
),
|
|
937
1149
|
h('span', { className: 'sn-grow' }),
|
|
938
1150
|
h('span', { className: 'sn-hint' }, idx.present === true && idx.fresh === false ? '过期时不会用索引查,会退回完整扫描' : '重建期间会占住几秒'),
|
|
939
1151
|
),
|
|
@@ -1423,7 +1635,15 @@ window.__ModuleLoader__.load({
|
|
|
1423
1635
|
var pTotalChars = p0Chars + p1Chars + pGap
|
|
1424
1636
|
var pRestChars = Math.max(0, 320 - pTotalChars)
|
|
1425
1637
|
var pL1Max = Math.max(0, 320 - Math.min(p0Chars, 80) - (p0Chars > 0 ? 1 : 0))
|
|
1426
|
-
var
|
|
1638
|
+
var p2Chars = pinCount(personaValuesNow.l2)
|
|
1639
|
+
// 超限不靠 maxLength 拦(它按 UTF-16 计,emoji 算两个),而是停掉「保存」并说明原因;
|
|
1640
|
+
// 磁盘那关仍由服务端整份校验兜底 —— 这里只是别让人白点一次。
|
|
1641
|
+
var pOver = p0Chars > 80 || pTotalChars > 320 || p0Chars + p1Chars + p2Chars > 8000
|
|
1642
|
+
var firstPage = (state.corrections && state.corrections.items) || []
|
|
1643
|
+
// 翻过页就把后面的接上;同一 id 只留第一条(翻页期间新证据插进来时会出现重复)
|
|
1644
|
+
var correctionItems = firstPage.concat(moreEvidence).filter(function (item, at, list) {
|
|
1645
|
+
return list.findIndex(function (other) { return other.id === item.id }) === at
|
|
1646
|
+
})
|
|
1427
1647
|
var pickedCount = pickedEvidence.length
|
|
1428
1648
|
|
|
1429
1649
|
var personaCard = h(
|
|
@@ -1438,17 +1658,34 @@ window.__ModuleLoader__.load({
|
|
|
1438
1658
|
{ className: 'sn-hint' },
|
|
1439
1659
|
'L0 ' + p0Chars + ' + L1 ' + p1Chars + (pGap ? ' + 换行 1' : '') + ' = ' + pTotalChars + ' / 320 字(余 ' + pRestChars + ')',
|
|
1440
1660
|
),
|
|
1441
|
-
personaField('l2', 'L2 扩展设定', '外貌、服饰、世界观、兴趣。这一层不进每回合上下文;保存在同一个 persona.md 里,不占注入预算。', personaValuesNow.l2, Math.max(0, 8000 - pTotalChars), 6),
|
|
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,
|
|
1442
1673
|
h(
|
|
1443
1674
|
'div',
|
|
1444
1675
|
{ className: 'sn-actions' },
|
|
1445
|
-
h('button', { className: 'sn-btn', onClick: savePersona, disabled: busy !== '' }, '保存人格'),
|
|
1676
|
+
h('button', { className: 'sn-btn', onClick: savePersona, disabled: busy !== '' || pOver }, '保存人格'),
|
|
1446
1677
|
h('button', { className: 'sn-btn sn-btn-ghost', onClick: importPersona, disabled: busy !== '' }, '从 AGENT.md 导入'),
|
|
1447
1678
|
h('span', { className: 'sn-grow' }),
|
|
1448
1679
|
h('span', { className: 'sn-muted' }, '保存是整份校验:任一段不合格就一个字都不写。'),
|
|
1449
1680
|
),
|
|
1450
1681
|
|
|
1451
|
-
h(
|
|
1682
|
+
h(
|
|
1683
|
+
'div',
|
|
1684
|
+
{ className: 'sn-sub' },
|
|
1685
|
+
'纠偏候选(' + ((state.corrections && state.corrections.total) || correctionItems.length) + ' 条,这些只是证据' +
|
|
1686
|
+
(correctionItems.length < ((state.corrections && state.corrections.total) || 0) ? ',这里列了 ' + correctionItems.length + ' 条' : '') +
|
|
1687
|
+
')',
|
|
1688
|
+
),
|
|
1452
1689
|
h(
|
|
1453
1690
|
'div',
|
|
1454
1691
|
{ className: 'sn-hint' },
|
|
@@ -1482,10 +1719,19 @@ window.__ModuleLoader__.load({
|
|
|
1482
1719
|
'div',
|
|
1483
1720
|
{ className: 'sn-actions' },
|
|
1484
1721
|
h('button', { className: 'sn-btn sn-btn-ghost', onClick: draftRuleFromPicked, disabled: busy !== '' }, '用选中的 ' + pickedCount + ' 条写一条规则'),
|
|
1722
|
+
h(
|
|
1723
|
+
'button',
|
|
1724
|
+
{ className: 'sn-btn sn-btn-ghost', onClick: discardPicked, disabled: busy !== '' || pickedCount === 0 },
|
|
1725
|
+
'丢弃选中的证据',
|
|
1726
|
+
),
|
|
1727
|
+
(moreCursor !== null ? moreCursor !== '' : state.corrections && state.corrections.nextCursor)
|
|
1728
|
+
? h('button', { className: 'sn-btn sn-btn-ghost', onClick: loadMoreEvidence, disabled: busy !== '' }, busy === 'corrections' ? '翻页中…' : '加载更多')
|
|
1729
|
+
: null,
|
|
1485
1730
|
),
|
|
1486
1731
|
h('textarea', {
|
|
1487
1732
|
className: 'sn-input',
|
|
1488
1733
|
rows: 3,
|
|
1734
|
+
'aria-label': '把选中的证据改写成一条规则',
|
|
1489
1735
|
placeholder: '把上面几句改写成一条能照着做的规则,例如「回答时先举例,再给结论」。',
|
|
1490
1736
|
value: ruleDraft,
|
|
1491
1737
|
onChange: function (e) { setRuleDraft(e.target.value) },
|
|
@@ -1598,11 +1844,65 @@ window.__ModuleLoader__.load({
|
|
|
1598
1844
|
editingThis
|
|
1599
1845
|
? null
|
|
1600
1846
|
: h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit({ id: fact.id, text: fact.text }) }, disabled: busy !== '' }, '改'),
|
|
1601
|
-
h(
|
|
1847
|
+
h(
|
|
1848
|
+
'button',
|
|
1849
|
+
{ className: 'sn-btn sn-btn-ghost' + (pendingRetire === fact.id ? ' sn-btn-danger' : ''), onClick: function () { retireFact(fact.id) }, disabled: busy !== '' },
|
|
1850
|
+
pendingRetire === fact.id ? '真的退役' : '退役',
|
|
1851
|
+
),
|
|
1602
1852
|
),
|
|
1603
1853
|
)
|
|
1604
1854
|
}),
|
|
1605
1855
|
|
|
1856
|
+
retiredFacts.length > 0
|
|
1857
|
+
? h(
|
|
1858
|
+
'div',
|
|
1859
|
+
null,
|
|
1860
|
+
h('div', { className: 'sn-sub' }, '已退役(' + ((state.facts && state.facts.retiredCount) || retiredFacts.length) + ')'),
|
|
1861
|
+
h(
|
|
1862
|
+
'div',
|
|
1863
|
+
{ className: 'sn-hint' },
|
|
1864
|
+
'退役只是不再进注入,条目本身还在文件里。想让它回来,点「恢复」。' +
|
|
1865
|
+
(retiredFacts.length < ((state.facts && state.facts.retiredCount) || 0)
|
|
1866
|
+
? '(这里只列最近 ' + retiredFacts.length + ' 条)'
|
|
1867
|
+
: ''),
|
|
1868
|
+
),
|
|
1869
|
+
h(
|
|
1870
|
+
'button',
|
|
1871
|
+
{
|
|
1872
|
+
type: 'button',
|
|
1873
|
+
className: 'sn-btn sn-btn-ghost',
|
|
1874
|
+
'aria-expanded': showRetired ? 'true' : 'false',
|
|
1875
|
+
onClick: function () { setShowRetired(!showRetired) },
|
|
1876
|
+
},
|
|
1877
|
+
showRetired ? '收起' : '展开这 ' + retiredFacts.length + ' 条',
|
|
1878
|
+
),
|
|
1879
|
+
showRetired
|
|
1880
|
+
? h(
|
|
1881
|
+
'div',
|
|
1882
|
+
{ className: 'sn-retired-list' },
|
|
1883
|
+
retiredFacts.map(function (fact) {
|
|
1884
|
+
return h(
|
|
1885
|
+
'div',
|
|
1886
|
+
{ key: 'r-' + fact.id, className: 'sn-fact' },
|
|
1887
|
+
h('div', { className: 'sn-fact-text' }, fact.text),
|
|
1888
|
+
h(
|
|
1889
|
+
'div',
|
|
1890
|
+
{ className: 'sn-actions' },
|
|
1891
|
+
h('span', { className: 'sn-heat' }, '退役于 ' + (fact.retiredAt || fact.updatedAt || '—')),
|
|
1892
|
+
h('span', { className: 'sn-grow' }),
|
|
1893
|
+
h(
|
|
1894
|
+
'button',
|
|
1895
|
+
{ type: 'button', className: 'sn-btn', onClick: function () { setFact(fact.id, 'approved') }, disabled: busy !== '' },
|
|
1896
|
+
'恢复',
|
|
1897
|
+
),
|
|
1898
|
+
),
|
|
1899
|
+
)
|
|
1900
|
+
}),
|
|
1901
|
+
)
|
|
1902
|
+
: null,
|
|
1903
|
+
)
|
|
1904
|
+
: null,
|
|
1905
|
+
|
|
1606
1906
|
(state.habits || []).length > 0
|
|
1607
1907
|
? h(
|
|
1608
1908
|
'div',
|
|
@@ -1659,9 +1959,20 @@ window.__ModuleLoader__.load({
|
|
|
1659
1959
|
var injSegments = inj.segments || []
|
|
1660
1960
|
var skippedItems = []
|
|
1661
1961
|
injSegments.forEach(function (seg) {
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
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
|
+
}
|
|
1665
1976
|
})
|
|
1666
1977
|
|
|
1667
1978
|
var injectionCard = h(
|
|
@@ -1684,9 +1995,9 @@ window.__ModuleLoader__.load({
|
|
|
1684
1995
|
'div',
|
|
1685
1996
|
{ className: 'sn-seg' },
|
|
1686
1997
|
injSegments.map(function (seg) {
|
|
1687
|
-
var skipped = seg.skipped || []
|
|
1998
|
+
var skipped = Array.isArray(seg.skippedDetail) ? seg.skippedDetail : (seg.skipped || [])
|
|
1688
1999
|
var stateText = seg.chars > 0 ? '全部进入' : '暂无内容'
|
|
1689
|
-
if (skipped.length > 0) stateText = '
|
|
2000
|
+
if (skipped.length > 0) stateText = '跳过 ' + skipped.length + ' 条'
|
|
1690
2001
|
else if (seg.chars === 0 && seg.absorbed > 0) stateText = '已被画像吸收 ' + seg.absorbed + ' 条'
|
|
1691
2002
|
return h(
|
|
1692
2003
|
'div',
|
|
@@ -1700,25 +2011,27 @@ window.__ModuleLoader__.load({
|
|
|
1700
2011
|
h(
|
|
1701
2012
|
'div',
|
|
1702
2013
|
{ className: 'sn-hint' },
|
|
1703
|
-
'
|
|
2014
|
+
'七段只算记忆本身的字数;标题、边界句、画像抬头这些框架文字另计(' + (inj.overhead || 0) + ' 字),也算在总字数里。',
|
|
1704
2015
|
),
|
|
1705
2016
|
skippedItems.length === 0
|
|
1706
2017
|
? null
|
|
1707
2018
|
: h(
|
|
1708
|
-
'
|
|
2019
|
+
'details',
|
|
1709
2020
|
{ className: 'sn-skipped' },
|
|
1710
2021
|
h(
|
|
1711
|
-
'
|
|
2022
|
+
'summary',
|
|
1712
2023
|
{ className: 'sn-skipped-head' },
|
|
1713
|
-
'
|
|
2024
|
+
'被跳过的 ' + skippedItems.length + ' 条 —— 点开看每条为什么没进(每条都标了原因)',
|
|
1714
2025
|
),
|
|
1715
2026
|
skippedItems.slice(0, 6).map(function (item, i) {
|
|
1716
|
-
|
|
2027
|
+
// key 用下标:内容指纹派生 id,同段两条相同文本会撞 id(口径裁定 2)
|
|
2028
|
+
var reasonLabel = { budget: '预算不足', expired: '已过期', scope: '项目不匹配', duplicate: '重复' }
|
|
1717
2029
|
return h(
|
|
1718
2030
|
'div',
|
|
1719
2031
|
{ className: 'sn-skipped-row', key: 'sk' + i },
|
|
1720
2032
|
h('span', { className: 'sn-badge' }, item.seg),
|
|
1721
|
-
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),
|
|
1722
2035
|
)
|
|
1723
2036
|
}),
|
|
1724
2037
|
skippedItems.length > 6 ? h('div', { className: 'sn-hint' }, '还有 ' + (skippedItems.length - 6) + ' 条没列出来') : null,
|
|
@@ -1854,7 +2167,13 @@ window.__ModuleLoader__.load({
|
|
|
1854
2167
|
}),
|
|
1855
2168
|
),
|
|
1856
2169
|
|
|
1857
|
-
|
|
2170
|
+
// 提示要贴顶:记忆页有二十多张卡,人在下半屏点「采纳 / 删除」,
|
|
2171
|
+
// 反馈如果留在文档流顶部,就等于没反馈 —— 会重复点。
|
|
2172
|
+
h(
|
|
2173
|
+
'div',
|
|
2174
|
+
{ className: 'sn-notice-wrap', role: 'status', 'aria-live': 'polite' },
|
|
2175
|
+
h(Notice, { tone: notice && notice.tone, text: notice && notice.text }),
|
|
2176
|
+
),
|
|
1858
2177
|
|
|
1859
2178
|
h(
|
|
1860
2179
|
'div',
|
|
@@ -1952,6 +2271,10 @@ window.__ModuleLoader__.load({
|
|
|
1952
2271
|
'.sn-details summary{cursor:pointer;user-select:none}',
|
|
1953
2272
|
'.sn-mono{font-family:ui-monospace,Menlo,Consolas,monospace;word-break:break-all;padding-top:4px}',
|
|
1954
2273
|
'.sn-loading{padding:16px;font-size:12px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
|
|
2274
|
+
'.sn-loading-error{color:#991b1b}',
|
|
2275
|
+
'.sn-notice-wrap{position:sticky;top:0;z-index:2}',
|
|
2276
|
+
'.sn-notice-wrap>.sn-notice{box-shadow:0 1px 3px rgba(0,0,0,.08)}',
|
|
2277
|
+
'.sn-retired-list{display:flex;flex-direction:column;gap:6px;margin-top:6px}',
|
|
1955
2278
|
".sn-fact-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
1956
2279
|
".sn-badge{font-size:11px;background:#eef2ff;color:#3730a3;border-radius:999px;padding:1px 8px}",
|
|
1957
2280
|
".sn-fact-conflict{border-color:#fcd34d;background:#fffdf5}",
|
|
@@ -2026,8 +2349,9 @@ window.__ModuleLoader__.load({
|
|
|
2026
2349
|
'.sn-seg-state{margin-left:auto;font-size:11px;color:var(--dsw-alias-text-secondary,#6b7280)}',
|
|
2027
2350
|
'.sn-seg-state-warn{color:#b45309}',
|
|
2028
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}',
|
|
2029
|
-
'.sn-skipped-head{font-size:
|
|
2030
|
-
'.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}',
|
|
2031
2355
|
'.sn-skipped-text{color:var(--dsw-alias-text-secondary,#4b5563)}',
|
|
2032
2356
|
'.sn-doc-list{display:flex;flex-direction:column;gap:8px}',
|
|
2033
2357
|
'.sn-doc-row{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.6;flex-wrap:wrap}',
|
|
@@ -2037,6 +2361,22 @@ window.__ModuleLoader__.load({
|
|
|
2037
2361
|
'.sn-doc-error{background:#fef2f2;color:#b91c1c}',
|
|
2038
2362
|
'.sn-doc-label{font-weight:600;color:var(--dsw-alias-text-primary,#111827)}',
|
|
2039
2363
|
'.sn-doc-detail{flex:1;min-width:200px;color:var(--dsw-alias-text-secondary,#4b5563)}',
|
|
2364
|
+
// —— 可读性与适配(原来是 11px + #9ca3af,在浅底上几乎要贴到屏幕上看)——
|
|
2365
|
+
'.sn-hint{font-size:12px;color:var(--dsw-alias-text-secondary,#6b7280)}',
|
|
2366
|
+
'.sn-hint-warn{font-size:12px}',
|
|
2367
|
+
'.sn-date,.sn-file-meta,.sn-heat,.sn-evidence-meta,.sn-fact-cite,.sn-card-note{font-size:11.5px}',
|
|
2368
|
+
'.sn-muted{color:var(--dsw-alias-text-secondary,#6b7280)}',
|
|
2369
|
+
// 输入框以前写死 110px,抬头那种长文本被压成一条缝;宽度交给容器,只在窄屏才吃满
|
|
2370
|
+
'.sn-field{flex-wrap:wrap}',
|
|
2371
|
+
'.sn-field input,.sn-field textarea{width:auto;min-width:7em;max-width:100%}',
|
|
2372
|
+
'.sn-field input[type=text],.sn-field textarea{min-width:14em;flex:1}',
|
|
2373
|
+
// 行内操作按钮别藏到 hover 里:触屏没有 hover,键盘用户也未必摸到
|
|
2374
|
+
'.sn-row-actions{opacity:.8}',
|
|
2375
|
+
'@media (hover:none){.sn-row-actions{opacity:1}}',
|
|
2376
|
+
'.sn-expired{color:var(--dsw-alias-text-secondary,#6b7280);border-color:var(--dsw-alias-border-subtle,#d1d5db)}',
|
|
2377
|
+
'@media (max-width:520px){.sn-limits,.sn-row,.sn-actions{flex-wrap:wrap}.sn-field{width:100%}.sn-field input,.sn-field textarea{width:100%}.sn-card{padding:12px}}',
|
|
2378
|
+
// 深色底:插件原先把浅色写死了,宿主切深色时这些块会白得刺眼
|
|
2379
|
+
'@media (prefers-color-scheme:dark){.sn-notice-ok{background:#052e21;color:#a7f3d0}.sn-notice-warn{background:#3f1212;color:#fecaca}.sn-notice{background:var(--dsw-alias-bg-secondary,#1f2937)}.sn-badge,.sn-file-count,.sn-tab-on .sn-tab-badge{background:#1e1b4b;color:#c7d2fe}.sn-material,.sn-kv-tag-stale,.sn-pair-head,.sn-doc-next,.sn-skipped-head{background:#3b2a06;color:#fcd34d}.sn-kv-tag-ok{background:#052e21;color:#6ee7b7}.sn-doc-next{border-color:#92400e}.sn-fact-conflict{background:#2a2308;border-color:#92400e}.sn-doc-ok{background:#052e21;color:#6ee7b7}.sn-doc-error{background:#3f1212;color:#fecaca}.sn-doc-warn{background:#3b2a06;color:#fcd34d}}',
|
|
2040
2380
|
].join('\n')
|
|
2041
2381
|
|
|
2042
2382
|
function apply(ctx) {
|
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
|
|
@@ -1115,6 +1159,8 @@ function personaStateOf(inline = false) {
|
|
|
1115
1159
|
// L0 与 L1 之间那个换行也算字数(与注入、校验同一把尺子),
|
|
1116
1160
|
// 否则界面按「320 - L0」给出上限,填满了也存不进去。
|
|
1117
1161
|
l1Limit: Math.max(0, PERSONA_TOTAL - Math.min(l0Chars, PERSONA_L0_MAX) - (l0Chars > 0 ? 1 : 0)),
|
|
1162
|
+
// L2 的上限得跟服务端同一个尺子:界面上写 8000 而这里按 chars 算,才不会两边对不上
|
|
1163
|
+
l2Limit: Math.max(0, MAX_PERSONA_CHARS - (l0Chars + l1Chars + l2Chars)),
|
|
1118
1164
|
fileLimit: MAX_PERSONA_CHARS,
|
|
1119
1165
|
sectionless: persona.sectionless === true,
|
|
1120
1166
|
truncated: l0Chars > PERSONA_L0_MAX,
|
|
@@ -1128,15 +1174,17 @@ function correctionsStateOf() {
|
|
|
1128
1174
|
try {
|
|
1129
1175
|
const events = readEvents()
|
|
1130
1176
|
const watermark = readWatermark()
|
|
1177
|
+
const page = listCorrections({ limit: 30 })
|
|
1131
1178
|
return {
|
|
1132
1179
|
total: events.length,
|
|
1133
1180
|
unused: events.filter((entry) => !entry.used).length,
|
|
1134
1181
|
lastScannedAt: String(watermark?.lastScannedAt ?? ''),
|
|
1135
1182
|
// 界面靠这个列表渲染勾选区;只给计数的话「证据 → 规则」那条链路整段不可用
|
|
1136
|
-
items:
|
|
1183
|
+
items: page.items,
|
|
1184
|
+
nextCursor: page.nextCursor,
|
|
1137
1185
|
}
|
|
1138
1186
|
} catch {
|
|
1139
|
-
return { total: 0, unused: 0, lastScannedAt: '', items: [] }
|
|
1187
|
+
return { total: 0, unused: 0, lastScannedAt: '', items: [], nextCursor: null }
|
|
1140
1188
|
}
|
|
1141
1189
|
}
|
|
1142
1190
|
|
|
@@ -1238,7 +1286,7 @@ function doctorState(inline) {
|
|
|
1238
1286
|
}
|
|
1239
1287
|
}
|
|
1240
1288
|
if (broken > 0 || noText > 0) {
|
|
1241
|
-
push('facts', '记忆文件完整性', 'error', `${broken} 行解析不出来、${noText} 条没有正文 —— 这些记忆读不到`, '
|
|
1289
|
+
push('facts', '记忆文件完整性', 'error', `${broken} 行解析不出来、${noText} 条没有正文 —— 这些记忆读不到`, '先复制一份 facts.jsonl,再手工删掉那几行(坏行只影响它自己)')
|
|
1242
1290
|
} else {
|
|
1243
1291
|
push('facts', '记忆文件完整性', 'ok', `${lines.length} 行全部可解析`)
|
|
1244
1292
|
}
|
|
@@ -1439,6 +1487,7 @@ function buildInjection(inline, ctx) {
|
|
|
1439
1487
|
const text = composeInjection(inline, { sessionId, spotlight })
|
|
1440
1488
|
// 补偿只补一次:这一轮真贴进去了,才把标记清掉(失败的话下一轮还有机会)
|
|
1441
1489
|
if (spotlight.length > 0) compactionPending.delete(sessionId)
|
|
1490
|
+
noteInjection(text)
|
|
1442
1491
|
return text
|
|
1443
1492
|
} catch (error) {
|
|
1444
1493
|
return ''
|
|
@@ -1734,6 +1783,8 @@ function stateOfInner(inline) {
|
|
|
1734
1783
|
const builtNow = cfg.enabled ? assembleInjection(cfg) : null
|
|
1735
1784
|
// facts.jsonl 也只读一次(下面候选/已采纳/退役/归属四张卡片共用)
|
|
1736
1785
|
const factsNow = listFacts()
|
|
1786
|
+
// 计数是另一条观测口径:只读一次计数文件,不参与注入解释数据的组装
|
|
1787
|
+
const injectionStats = readState()
|
|
1737
1788
|
return {
|
|
1738
1789
|
ok: true,
|
|
1739
1790
|
enabled: cfg.enabled,
|
|
@@ -1746,6 +1797,7 @@ function stateOfInner(inline) {
|
|
|
1746
1797
|
// 每行带上「到期没有」:设置页要能把过期的临时自述标出来,而不是让人以为它这回合还在
|
|
1747
1798
|
notes: readNotes().map((entry) => ({ ...entry, expired: isExpiredNote(entry, cfg.tempTtlDays) })),
|
|
1748
1799
|
tempTtlDays: cfg.tempTtlDays,
|
|
1800
|
+
halfLifeDays: cfg.halfLifeDays,
|
|
1749
1801
|
noteGuide: cfg.noteGuide,
|
|
1750
1802
|
notesLastWrite: s.lastWrite,
|
|
1751
1803
|
notesFile: s.notesFile,
|
|
@@ -1762,6 +1814,8 @@ function stateOfInner(inline) {
|
|
|
1762
1814
|
corrections: correctionsStateOf(),
|
|
1763
1815
|
budget: budgetOf(inline),
|
|
1764
1816
|
injection: injectionState(inline, builtNow),
|
|
1817
|
+
injectionCount: Number.isFinite(Number(injectionStats.injectionCount)) ? Number(injectionStats.injectionCount) : 0,
|
|
1818
|
+
lastInjectedAt: typeof injectionStats.lastInjectedAt === 'string' ? injectionStats.lastInjectedAt : '',
|
|
1765
1819
|
attribution: attributionState(inline, builtNow, factsNow),
|
|
1766
1820
|
profile: profileState(inline),
|
|
1767
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": {
|