dsh-selfnote 0.14.0 → 0.14.1

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.0-4f46e5" alt="version">
13
+ <img src="https://img.shields.io/badge/version-0.14.1-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-285%20passed-0ea5e9" alt="tests">
16
+ <img src="https://img.shields.io/badge/tests-290%20passed-0ea5e9" alt="tests">
17
17
  </p>
18
18
 
19
19
  ---
@@ -528,7 +528,7 @@ L2 为空时那句指路句也不发(不能指一条空路)。L2 有内容
528
528
  ## 开发
529
529
 
530
530
  ```bash
531
- node --run test # 285 项,21 个文件,无外部依赖
531
+ node --run test # 290 项,21 个文件,无外部依赖
532
532
  node --run test:accept # 12 项:真实 HTTP + 36 进程并发写
533
533
  ```
534
534
 
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 })
@@ -591,7 +710,15 @@ window.__ModuleLoader__.load({
591
710
  apiPost('/fact', { id: id, status: status })
592
711
  .then(function (data) {
593
712
  apply(data)
594
- setNotice({ tone: 'ok', text: status === 'approved' ? '已采纳,下一回合进注入。' : '已处理。' })
713
+ setNotice({
714
+ tone: 'ok',
715
+ text:
716
+ status === 'approved'
717
+ ? '已采纳(原来的退役也一并恢复),下一回合进注入。'
718
+ : status === 'retired'
719
+ ? '已退役:不再进注入,要它回来去「已退役」里点恢复。'
720
+ : '已处理。',
721
+ })
595
722
  })
596
723
  .catch(function (error) {
597
724
  setNotice({ tone: 'warn', text: '没改成:' + error.message })
@@ -601,6 +728,17 @@ window.__ModuleLoader__.load({
601
728
  })
602
729
  }
603
730
 
731
+ // 退役两步:第一次只武装,第二次才发请求。数据留在 facts.jsonl 里,随时能恢复。
732
+ function retireFact(id) {
733
+ if (pendingRetire !== id) {
734
+ setPendingRetire(id)
735
+ setNotice({ tone: 'info', text: '再点一次「真的退役」——它不进注入了,但还在「已退役」里,随时能恢复。' })
736
+ return
737
+ }
738
+ setPendingRetire(null)
739
+ setFact(id, 'retired')
740
+ }
741
+
604
742
  function setHabitState(text, status, tag) {
605
743
  setBusy('habit')
606
744
  apiPost('/habit', { text: text, status: status, tag: tag || '' })
@@ -617,10 +755,31 @@ window.__ModuleLoader__.load({
617
755
  }
618
756
 
619
757
  if (state === null) {
758
+ // 三态:正在读 / 读失败 / 刚进来还没读。读失败时把 refresh 里 catch 到的真实原因端出来,
759
+ // 别用「重启 DSH 试试」这种猜测盖掉它 —— 重启治不了 403(凭据),也治不了 404(路由)。
760
+ var loadFailed = busy !== 'load' && notice !== null
620
761
  return h(
621
762
  'div',
622
763
  { className: 'sn-root' },
623
- h('div', { className: 'sn-loading' }, busy === 'load' ? '读取中…' : '还没有连上宿主(重启 DSH 试试)'),
764
+ h(
765
+ 'div',
766
+ {
767
+ className: 'sn-loading' + (loadFailed ? ' sn-loading-error' : ''),
768
+ role: loadFailed ? 'alert' : 'status',
769
+ },
770
+ loadFailed ? notice.text : '读取中…',
771
+ ),
772
+ loadFailed
773
+ ? h(
774
+ 'div',
775
+ { className: 'sn-actions' },
776
+ h(
777
+ 'button',
778
+ { type: 'button', className: 'sn-btn', onClick: function () { refresh() }, disabled: busy !== '' },
779
+ '重试',
780
+ ),
781
+ )
782
+ : null,
624
783
  )
625
784
  }
626
785
 
@@ -683,7 +842,11 @@ window.__ModuleLoader__.load({
683
842
  'div',
684
843
  { key: 'actions', className: 'sn-actions' },
685
844
  h('button', { className: 'sn-btn', onClick: function () { setFact(fact.id, 'approved') }, disabled: busy !== '' }, '采纳'),
686
- h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFact(fact.id, 'retired') }, disabled: busy !== '' }, '退役'),
845
+ h(
846
+ 'button',
847
+ { className: 'sn-btn sn-btn-ghost' + (pendingRetire === fact.id ? ' sn-btn-danger' : ''), onClick: function () { retireFact(fact.id) }, disabled: busy !== '' },
848
+ pendingRetire === fact.id ? '真的退役' : '退役',
849
+ ),
687
850
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit({ id: fact.id, text: fact.text }) }, disabled: busy !== '' }, '改'),
688
851
  )
689
852
  : h('div', { key: 'state', className: 'sn-fact-cite' }, '状态:' + statusLabel(fact.status)),
@@ -794,6 +957,10 @@ window.__ModuleLoader__.load({
794
957
  return Math.round(hours / 24) + ' 天前'
795
958
  }
796
959
 
960
+ // 没调过 apply 时(或被外部刷新打断)也显示服务端的真值,别显示空白
961
+ var halfLifeShown = halfLife === '' ? String(state.halfLifeDays === undefined ? 30 : state.halfLifeDays) : halfLife
962
+ var profileMaxShown = profileMax === '' ? String((state.profile && state.profile.maxChars) || 110) : profileMax
963
+
797
964
  var settingsCard = h(
798
965
  'div',
799
966
  { className: 'sn-card', key: 'settings' },
@@ -829,6 +996,7 @@ window.__ModuleLoader__.load({
829
996
  h('input', {
830
997
  type: 'text',
831
998
  maxLength: 24,
999
+ 'aria-label': '注入抬头,最多 24 个字,留空就不加抬头',
832
1000
  value: heading,
833
1001
  placeholder: '例:AI记忆',
834
1002
  onChange: function (e) { setHeading(e.target.value) },
@@ -849,6 +1017,38 @@ window.__ModuleLoader__.load({
849
1017
  h('button', { className: 'sn-btn', onClick: saveTempTtl, disabled: busy !== '' }, '保存保质期'),
850
1018
  ),
851
1019
  h('div', { className: 'sn-hint' }, '只对「临时」型自述生效(1–90 天,默认 14)。过了期不再自动进上下文,内容一个字都没删 —— 改成「稳定」或重写一条就回来。'),
1020
+ h(
1021
+ 'div',
1022
+ { className: 'sn-limits' },
1023
+ h(
1024
+ 'label',
1025
+ { className: 'sn-field' },
1026
+ h('span', null, '结论半衰期(天)'),
1027
+ h('input', {
1028
+ type: 'number',
1029
+ min: 1,
1030
+ max: 365,
1031
+ 'aria-label': '结论半衰期,单位天,范围 1 到 365',
1032
+ value: halfLifeShown,
1033
+ onChange: function (e) { setHalfLife(e.target.value) },
1034
+ }),
1035
+ ),
1036
+ h(
1037
+ 'label',
1038
+ { className: 'sn-field' },
1039
+ h('span', null, '画像上限(字)'),
1040
+ h('input', {
1041
+ type: 'number',
1042
+ min: 20,
1043
+ max: 120,
1044
+ 'aria-label': '画像上限,单位字,范围 20 到 120',
1045
+ value: profileMaxShown,
1046
+ onChange: function (e) { setProfileMax(e.target.value) },
1047
+ }),
1048
+ ),
1049
+ h('button', { className: 'sn-btn', onClick: saveHalfLife, disabled: busy !== '' }, '保存这两项'),
1050
+ ),
1051
+ h('div', { className: 'sn-hint' }, '半衰期决定旧结论多久算「半凉」:越小越偏爱最近的。画像上限是「关于他」那段最多占多少字。'),
852
1052
  h(
853
1053
  'div',
854
1054
  { className: 'sn-field sn-field-wide' },
@@ -856,6 +1056,7 @@ window.__ModuleLoader__.load({
856
1056
  h('textarea', {
857
1057
  rows: 2,
858
1058
  maxLength: 80,
1059
+ 'aria-label': '什么时候该写自述,留空就用默认判据',
859
1060
  value: noteGuide,
860
1061
  placeholder: '例:每次改完一个插件、跑完验收之后写一条;闲聊不用写。',
861
1062
  onChange: function (e) { setNoteGuide(e.target.value) },
@@ -923,6 +1124,7 @@ window.__ModuleLoader__.load({
923
1124
  'div',
924
1125
  { className: 'sn-kv' },
925
1126
  kv('索引状态', h('span', { className: 'sn-kv-tag ' + idxTone }, idxStatus)),
1127
+ kv('检索开关', h('span', { className: 'sn-kv-tag ' + (idx.enabled === true ? 'sn-kv-tag-ok' : 'sn-kv-tag-missing') }, idx.enabled === true ? '已启用' : '已关闭')),
926
1128
  kv('已索引会话', String(idx.sessions ?? 0)),
927
1129
  kv('磁盘会话', String(idx.disk ?? 0)),
928
1130
  kv('待同步会话', String(idx.missing ?? 0)),
@@ -934,6 +1136,15 @@ window.__ModuleLoader__.load({
934
1136
  'div',
935
1137
  { className: 'sn-actions' },
936
1138
  h('button', { className: 'sn-btn', onClick: rebuildIndex, disabled: busy !== '' }, busy === 'index' ? '重建中…' : '重建索引'),
1139
+ h(
1140
+ 'button',
1141
+ {
1142
+ className: idx.enabled === true ? 'sn-btn sn-btn-ghost' : 'sn-btn',
1143
+ onClick: function () { toggleIndex(idx.enabled !== true) },
1144
+ disabled: busy !== '' || (idx.enabled !== true && idx.present !== true),
1145
+ },
1146
+ idx.enabled === true ? '关闭索引' : '启用索引',
1147
+ ),
937
1148
  h('span', { className: 'sn-grow' }),
938
1149
  h('span', { className: 'sn-hint' }, idx.present === true && idx.fresh === false ? '过期时不会用索引查,会退回完整扫描' : '重建期间会占住几秒'),
939
1150
  ),
@@ -1423,7 +1634,11 @@ window.__ModuleLoader__.load({
1423
1634
  var pTotalChars = p0Chars + p1Chars + pGap
1424
1635
  var pRestChars = Math.max(0, 320 - pTotalChars)
1425
1636
  var pL1Max = Math.max(0, 320 - Math.min(p0Chars, 80) - (p0Chars > 0 ? 1 : 0))
1426
- var correctionItems = (state.corrections && state.corrections.items) || []
1637
+ var firstPage = (state.corrections && state.corrections.items) || []
1638
+ // 翻过页就把后面的接上;同一 id 只留第一条(翻页期间新证据插进来时会出现重复)
1639
+ var correctionItems = firstPage.concat(moreEvidence).filter(function (item, at, list) {
1640
+ return list.findIndex(function (other) { return other.id === item.id }) === at
1641
+ })
1427
1642
  var pickedCount = pickedEvidence.length
1428
1643
 
1429
1644
  var personaCard = h(
@@ -1438,7 +1653,7 @@ window.__ModuleLoader__.load({
1438
1653
  { className: 'sn-hint' },
1439
1654
  'L0 ' + p0Chars + ' + L1 ' + p1Chars + (pGap ? ' + 换行 1' : '') + ' = ' + pTotalChars + ' / 320 字(余 ' + pRestChars + ')',
1440
1655
  ),
1441
- personaField('l2', 'L2 扩展设定', '外貌、服饰、世界观、兴趣。这一层不进每回合上下文;保存在同一个 persona.md 里,不占注入预算。', personaValuesNow.l2, Math.max(0, 8000 - pTotalChars), 6),
1656
+ personaField('l2', 'L2 扩展设定', '外貌、服饰、世界观、兴趣。这一层不进每回合上下文;保存在同一个 persona.md 里,不占注入预算。', personaValuesNow.l2, (state.persona && state.persona.l2Limit) || Math.max(0, 8000 - pTotalChars), 6),
1442
1657
  h(
1443
1658
  'div',
1444
1659
  { className: 'sn-actions' },
@@ -1448,7 +1663,13 @@ window.__ModuleLoader__.load({
1448
1663
  h('span', { className: 'sn-muted' }, '保存是整份校验:任一段不合格就一个字都不写。'),
1449
1664
  ),
1450
1665
 
1451
- h('div', { className: 'sn-sub' }, '纠偏候选(' + correctionItems.length + ' 条,这些只是证据)'),
1666
+ h(
1667
+ 'div',
1668
+ { className: 'sn-sub' },
1669
+ '纠偏候选(' + ((state.corrections && state.corrections.total) || correctionItems.length) + ' 条,这些只是证据' +
1670
+ (correctionItems.length < ((state.corrections && state.corrections.total) || 0) ? ',这里列了 ' + correctionItems.length + ' 条' : '') +
1671
+ ')',
1672
+ ),
1452
1673
  h(
1453
1674
  'div',
1454
1675
  { className: 'sn-hint' },
@@ -1482,10 +1703,19 @@ window.__ModuleLoader__.load({
1482
1703
  'div',
1483
1704
  { className: 'sn-actions' },
1484
1705
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: draftRuleFromPicked, disabled: busy !== '' }, '用选中的 ' + pickedCount + ' 条写一条规则'),
1706
+ h(
1707
+ 'button',
1708
+ { className: 'sn-btn sn-btn-ghost', onClick: discardPicked, disabled: busy !== '' || pickedCount === 0 },
1709
+ '丢弃选中的证据',
1710
+ ),
1711
+ (moreCursor !== null ? moreCursor !== '' : state.corrections && state.corrections.nextCursor)
1712
+ ? h('button', { className: 'sn-btn sn-btn-ghost', onClick: loadMoreEvidence, disabled: busy !== '' }, busy === 'corrections' ? '翻页中…' : '加载更多')
1713
+ : null,
1485
1714
  ),
1486
1715
  h('textarea', {
1487
1716
  className: 'sn-input',
1488
1717
  rows: 3,
1718
+ 'aria-label': '把选中的证据改写成一条规则',
1489
1719
  placeholder: '把上面几句改写成一条能照着做的规则,例如「回答时先举例,再给结论」。',
1490
1720
  value: ruleDraft,
1491
1721
  onChange: function (e) { setRuleDraft(e.target.value) },
@@ -1598,11 +1828,65 @@ window.__ModuleLoader__.load({
1598
1828
  editingThis
1599
1829
  ? null
1600
1830
  : h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit({ id: fact.id, text: fact.text }) }, disabled: busy !== '' }, '改'),
1601
- h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFact(fact.id, 'retired') }, disabled: busy !== '' }, '退役'),
1831
+ h(
1832
+ 'button',
1833
+ { className: 'sn-btn sn-btn-ghost' + (pendingRetire === fact.id ? ' sn-btn-danger' : ''), onClick: function () { retireFact(fact.id) }, disabled: busy !== '' },
1834
+ pendingRetire === fact.id ? '真的退役' : '退役',
1835
+ ),
1602
1836
  ),
1603
1837
  )
1604
1838
  }),
1605
1839
 
1840
+ retiredFacts.length > 0
1841
+ ? h(
1842
+ 'div',
1843
+ null,
1844
+ h('div', { className: 'sn-sub' }, '已退役(' + ((state.facts && state.facts.retiredCount) || retiredFacts.length) + ')'),
1845
+ h(
1846
+ 'div',
1847
+ { className: 'sn-hint' },
1848
+ '退役只是不再进注入,条目本身还在文件里。想让它回来,点「恢复」。' +
1849
+ (retiredFacts.length < ((state.facts && state.facts.retiredCount) || 0)
1850
+ ? '(这里只列最近 ' + retiredFacts.length + ' 条)'
1851
+ : ''),
1852
+ ),
1853
+ h(
1854
+ 'button',
1855
+ {
1856
+ type: 'button',
1857
+ className: 'sn-btn sn-btn-ghost',
1858
+ 'aria-expanded': showRetired ? 'true' : 'false',
1859
+ onClick: function () { setShowRetired(!showRetired) },
1860
+ },
1861
+ showRetired ? '收起' : '展开这 ' + retiredFacts.length + ' 条',
1862
+ ),
1863
+ showRetired
1864
+ ? h(
1865
+ 'div',
1866
+ { className: 'sn-retired-list' },
1867
+ retiredFacts.map(function (fact) {
1868
+ return h(
1869
+ 'div',
1870
+ { key: 'r-' + fact.id, className: 'sn-fact' },
1871
+ h('div', { className: 'sn-fact-text' }, fact.text),
1872
+ h(
1873
+ 'div',
1874
+ { className: 'sn-actions' },
1875
+ h('span', { className: 'sn-heat' }, '退役于 ' + (fact.retiredAt || fact.updatedAt || '—')),
1876
+ h('span', { className: 'sn-grow' }),
1877
+ h(
1878
+ 'button',
1879
+ { type: 'button', className: 'sn-btn', onClick: function () { setFact(fact.id, 'approved') }, disabled: busy !== '' },
1880
+ '恢复',
1881
+ ),
1882
+ ),
1883
+ )
1884
+ }),
1885
+ )
1886
+ : null,
1887
+ )
1888
+ : null,
1889
+
1606
1890
  (state.habits || []).length > 0
1607
1891
  ? h(
1608
1892
  'div',
@@ -1854,7 +2138,13 @@ window.__ModuleLoader__.load({
1854
2138
  }),
1855
2139
  ),
1856
2140
 
1857
- h(Notice, { tone: notice && notice.tone, text: notice && notice.text }),
2141
+ // 提示要贴顶:记忆页有二十多张卡,人在下半屏点「采纳 / 删除」,
2142
+ // 反馈如果留在文档流顶部,就等于没反馈 —— 会重复点。
2143
+ h(
2144
+ 'div',
2145
+ { className: 'sn-notice-wrap', role: 'status', 'aria-live': 'polite' },
2146
+ h(Notice, { tone: notice && notice.tone, text: notice && notice.text }),
2147
+ ),
1858
2148
 
1859
2149
  h(
1860
2150
  'div',
@@ -1952,6 +2242,10 @@ window.__ModuleLoader__.load({
1952
2242
  '.sn-details summary{cursor:pointer;user-select:none}',
1953
2243
  '.sn-mono{font-family:ui-monospace,Menlo,Consolas,monospace;word-break:break-all;padding-top:4px}',
1954
2244
  '.sn-loading{padding:16px;font-size:12px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
2245
+ '.sn-loading-error{color:#991b1b}',
2246
+ '.sn-notice-wrap{position:sticky;top:0;z-index:2}',
2247
+ '.sn-notice-wrap>.sn-notice{box-shadow:0 1px 3px rgba(0,0,0,.08)}',
2248
+ '.sn-retired-list{display:flex;flex-direction:column;gap:6px;margin-top:6px}',
1955
2249
  ".sn-fact-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
1956
2250
  ".sn-badge{font-size:11px;background:#eef2ff;color:#3730a3;border-radius:999px;padding:1px 8px}",
1957
2251
  ".sn-fact-conflict{border-color:#fcd34d;background:#fffdf5}",
@@ -2037,6 +2331,22 @@ window.__ModuleLoader__.load({
2037
2331
  '.sn-doc-error{background:#fef2f2;color:#b91c1c}',
2038
2332
  '.sn-doc-label{font-weight:600;color:var(--dsw-alias-text-primary,#111827)}',
2039
2333
  '.sn-doc-detail{flex:1;min-width:200px;color:var(--dsw-alias-text-secondary,#4b5563)}',
2334
+ // —— 可读性与适配(原来是 11px + #9ca3af,在浅底上几乎要贴到屏幕上看)——
2335
+ '.sn-hint{font-size:12px;color:var(--dsw-alias-text-secondary,#6b7280)}',
2336
+ '.sn-hint-warn{font-size:12px}',
2337
+ '.sn-date,.sn-file-meta,.sn-heat,.sn-evidence-meta,.sn-fact-cite,.sn-card-note{font-size:11.5px}',
2338
+ '.sn-muted{color:var(--dsw-alias-text-secondary,#6b7280)}',
2339
+ // 输入框以前写死 110px,抬头那种长文本被压成一条缝;宽度交给容器,只在窄屏才吃满
2340
+ '.sn-field{flex-wrap:wrap}',
2341
+ '.sn-field input,.sn-field textarea{width:auto;min-width:7em;max-width:100%}',
2342
+ '.sn-field input[type=text],.sn-field textarea{min-width:14em;flex:1}',
2343
+ // 行内操作按钮别藏到 hover 里:触屏没有 hover,键盘用户也未必摸到
2344
+ '.sn-row-actions{opacity:.8}',
2345
+ '@media (hover:none){.sn-row-actions{opacity:1}}',
2346
+ '.sn-expired{color:var(--dsw-alias-text-secondary,#6b7280);border-color:var(--dsw-alias-border-subtle,#d1d5db)}',
2347
+ '@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}}',
2348
+ // 深色底:插件原先把浅色写死了,宿主切深色时这些块会白得刺眼
2349
+ '@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
2350
  ].join('\n')
2041
2351
 
2042
2352
  function apply(ctx) {
package/lib/index.js CHANGED
@@ -1115,6 +1115,8 @@ function personaStateOf(inline = false) {
1115
1115
  // L0 与 L1 之间那个换行也算字数(与注入、校验同一把尺子),
1116
1116
  // 否则界面按「320 - L0」给出上限,填满了也存不进去。
1117
1117
  l1Limit: Math.max(0, PERSONA_TOTAL - Math.min(l0Chars, PERSONA_L0_MAX) - (l0Chars > 0 ? 1 : 0)),
1118
+ // L2 的上限得跟服务端同一个尺子:界面上写 8000 而这里按 chars 算,才不会两边对不上
1119
+ l2Limit: Math.max(0, MAX_PERSONA_CHARS - (l0Chars + l1Chars + l2Chars)),
1118
1120
  fileLimit: MAX_PERSONA_CHARS,
1119
1121
  sectionless: persona.sectionless === true,
1120
1122
  truncated: l0Chars > PERSONA_L0_MAX,
@@ -1128,15 +1130,17 @@ function correctionsStateOf() {
1128
1130
  try {
1129
1131
  const events = readEvents()
1130
1132
  const watermark = readWatermark()
1133
+ const page = listCorrections({ limit: 30 })
1131
1134
  return {
1132
1135
  total: events.length,
1133
1136
  unused: events.filter((entry) => !entry.used).length,
1134
1137
  lastScannedAt: String(watermark?.lastScannedAt ?? ''),
1135
1138
  // 界面靠这个列表渲染勾选区;只给计数的话「证据 → 规则」那条链路整段不可用
1136
- items: listCorrections({ limit: 30 }).items,
1139
+ items: page.items,
1140
+ nextCursor: page.nextCursor,
1137
1141
  }
1138
1142
  } catch {
1139
- return { total: 0, unused: 0, lastScannedAt: '', items: [] }
1143
+ return { total: 0, unused: 0, lastScannedAt: '', items: [], nextCursor: null }
1140
1144
  }
1141
1145
  }
1142
1146
 
@@ -1746,6 +1750,7 @@ function stateOfInner(inline) {
1746
1750
  // 每行带上「到期没有」:设置页要能把过期的临时自述标出来,而不是让人以为它这回合还在
1747
1751
  notes: readNotes().map((entry) => ({ ...entry, expired: isExpiredNote(entry, cfg.tempTtlDays) })),
1748
1752
  tempTtlDays: cfg.tempTtlDays,
1753
+ halfLifeDays: cfg.halfLifeDays,
1749
1754
  noteGuide: cfg.noteGuide,
1750
1755
  notesLastWrite: s.lastWrite,
1751
1756
  notesFile: s.notesFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-selfnote",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
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": [