dsh-selfnote 0.13.3 → 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/lib/client.js CHANGED
@@ -160,6 +160,46 @@ window.__ModuleLoader__.load({
160
160
  var draftTierTuple = useState('stable')
161
161
  var draftTier = draftTierTuple[0]
162
162
  var setDraftTier = draftTierTuple[1]
163
+ // 临时自述的保质期(天)。只对 temp 型生效,核心与稳定永不过期
164
+ // 同样放最后:新增 useState 只能往后加,别插在中间
165
+ var tempTtlTuple = useState('')
166
+ var tempTtl = tempTtlTuple[0]
167
+ var setTempTtl = tempTtlTuple[1]
168
+ // 人格三段的草稿:null = 直接显示磁盘上的,动过之后才是草稿。
169
+ // 依旧放最后:新增 useState 只能往后加,插在中间会让测试的调用序号错位
170
+ var personaDraftTuple = useState(null)
171
+ var personaDraft = personaDraftTuple[0]
172
+ var setPersonaDraft = personaDraftTuple[1]
173
+ // 纠偏候选里勾了哪几条:只存 id,不存整条(刷新后按 id 找得到才算数)
174
+ var pickedEvidenceTuple = useState([])
175
+ var pickedEvidence = pickedEvidenceTuple[0]
176
+ var setPickedEvidence = pickedEvidenceTuple[1]
177
+ // 用选中的证据写规则时的那段草稿:预填证据原文,要人改写成规则
178
+ var ruleDraftTuple = useState('')
179
+ var ruleDraft = ruleDraftTuple[0]
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]
163
203
 
164
204
  function apply(data) {
165
205
  setState(data)
@@ -170,9 +210,15 @@ window.__ModuleLoader__.load({
170
210
  setMaxEntries(String(data.maxEntries))
171
211
  setNoteGuide(typeof data.noteGuide === 'string' ? data.noteGuide : '')
172
212
  setHeading(typeof data.heading === 'string' ? data.heading : '')
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)
173
218
  // 每次拿到新状态,列表下标都可能整体挪位:正在改/正等确认的那条已经没意义了
174
219
  setEditing(null)
175
220
  setPendingDelete(null)
221
+ setPendingRetire(null)
176
222
  setFactEdit(null)
177
223
  }
178
224
 
@@ -206,6 +252,28 @@ window.__ModuleLoader__.load({
206
252
  }
207
253
  }, [confirming])
208
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
+
209
277
  function runDoctor() {
210
278
  setBusy('doctor')
211
279
  apiGet('/doctor')
@@ -267,6 +335,76 @@ window.__ModuleLoader__.load({
267
335
  })
268
336
  }
269
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
+
270
408
  function saveHeading() {
271
409
  setBusy('heading')
272
410
  apiPost('/config', { heading: heading })
@@ -282,6 +420,139 @@ window.__ModuleLoader__.load({
282
420
  })
283
421
  }
284
422
 
423
+ function saveTempTtl() {
424
+ var value = Number(tempTtl)
425
+ if (!Number.isFinite(value) || value < 1 || value > 90) {
426
+ setNotice({ tone: 'warn', text: '保质期要在 1–90 天之间。' })
427
+ return
428
+ }
429
+ setBusy('ttl')
430
+ apiPost('/config', { tempTtlDays: value })
431
+ .then(function (data) {
432
+ apply(data)
433
+ setNotice({ tone: 'ok', text: '保质期改成 ' + Math.round(value) + ' 天,下一回合生效。' })
434
+ })
435
+ .catch(function (error) {
436
+ setNotice({ tone: 'warn', text: '保存失败:' + error.message })
437
+ })
438
+ .then(function () {
439
+ setBusy('')
440
+ })
441
+ }
442
+
443
+ // ---- 人格三段 ----
444
+ // 字数按 Unicode 码点算,和服务端、注入预算同一把尺子。
445
+ // 注意:视觉上的一个 emoji 可能由多个码点组成(ZWJ 序列),这里如实算多个。
446
+ function pinCount(text) {
447
+ return Array.from(String(text === undefined || text === null ? '' : text)).length
448
+ }
449
+
450
+ function personaValues() { var persona = state.persona || {}
451
+ if (personaDraft === null) {
452
+ return { l0: persona.l0 || '', l1: persona.l1 || '', l2: persona.l2 || '' }
453
+ }
454
+ return personaDraft
455
+ }
456
+
457
+ function editPersona(key, value) {
458
+ var next = { l0: personaValues().l0, l1: personaValues().l1, l2: personaValues().l2 }
459
+ next[key] = value
460
+ setPersonaDraft(next)
461
+ }
462
+
463
+ function sectionlessHint(info) {
464
+ if (!info) return ''
465
+ if (info.error === 'missing') return 'persona.md 还没建,保存一次就有了。'
466
+ if (info.error === 'not-text') return 'persona.md 不像文本(或超过 1MB),先看一眼再改。'
467
+ if (info.sectionless) return '整篇没写标题,暂时全部当作 L1。'
468
+ return 'persona.md · 上次取用 ' + (info.lastReadAt ? String(info.lastReadAt).slice(0, 10) : '从没取过')
469
+ }
470
+
471
+ function personaField(key, label, hint, value, max, rows) {
472
+ var used = pinCount(value)
473
+ var over = max > 0 && used > max
474
+ return h(
475
+ 'div',
476
+ { className: 'sn-field sn-field-wide', key: key },
477
+ h(
478
+ 'span',
479
+ { className: 'sn-field-label' },
480
+ label,
481
+ h('span', { className: 'sn-count' + (over ? ' sn-count-over' : '') }, ' ' + used + ' / ' + max + ' 字'),
482
+ ),
483
+ h('textarea', {
484
+ className: 'sn-input',
485
+ rows: rows,
486
+ // 上限就是校验线的软提示;真拦住的是保存时那一次整份校验
487
+ maxLength: max > 0 ? max : undefined,
488
+ value: value,
489
+ 'data-field': key,
490
+ onChange: function (e) { editPersona(key, e.target.value) },
491
+ }),
492
+ h('div', { className: 'sn-hint' }, hint),
493
+ )
494
+ }
495
+
496
+ function savePersona() {
497
+ var values = personaValues()
498
+ setBusy('persona')
499
+ apiPost('/persona', {
500
+ l0: values.l0,
501
+ l1: values.l1,
502
+ l2: values.l2,
503
+ consume: pickedEvidence,
504
+ })
505
+ .then(function (data) {
506
+ apply(data)
507
+ setPersonaDraft(null)
508
+ setPickedEvidence([])
509
+ setRuleDraft('')
510
+ if (data.pending === true) {
511
+ setNotice({ tone: 'warn', text: '人格已保存,但证据记账没写完:' + (data.error || '') + '(下次启动会自动补)' })
512
+ } else {
513
+ setNotice({ tone: 'ok', text: '人格已保存,下一回合生效。' })
514
+ }
515
+ })
516
+ .catch(function (error) {
517
+ setNotice({ tone: 'warn', text: '保存失败:' + error.message })
518
+ })
519
+ .then(function () {
520
+ setBusy('')
521
+ })
522
+ }
523
+
524
+ function importPersona() {
525
+ setBusy('import')
526
+ apiPost('/persona/import', {})
527
+ .then(function (data) {
528
+ // 导入≠采纳:只把结果填进三个框,人自己看、自己改、自己点保存
529
+ setPersonaDraft({ l0: data.l0 || '', l1: data.l1 || '', l2: data.l2 || '' })
530
+ if (!data.exists) setNotice({ tone: 'warn', text: '没找到可导入的 AGENT.md。' })
531
+ else setNotice({ tone: 'ok', text: '已从 AGENT.md 填进来(还没保存,改完再点「保存人格」)。' })
532
+ })
533
+ .catch(function (error) {
534
+ setNotice({ tone: 'warn', text: '导入失败:' + error.message })
535
+ })
536
+ .then(function () {
537
+ setBusy('')
538
+ })
539
+ }
540
+
541
+ function toggleEvidence(id) {
542
+ var next = pickedEvidence.slice()
543
+ var at = next.indexOf(id)
544
+ if (at >= 0) next.splice(at, 1)
545
+ else next.push(id)
546
+ setPickedEvidence(next)
547
+ }
548
+
549
+ function draftRuleFromPicked() {
550
+ var picked = (correctionItems || []).filter(function (item) { return pickedEvidence.indexOf(item.id) >= 0 })
551
+ var lines = picked.map(function (item) { return '· ' + item.quote })
552
+ setRuleDraft(lines.length > 0 ? lines.join('\n') : '')
553
+ setNotice({ tone: 'ok', text: '把上面几句改写成一条规则,再点「保存人格」。' })
554
+ }
555
+
285
556
  function toggleEnabled() {
286
557
  setBusy('toggle')
287
558
  apiPost('/config', { enabled: !state.enabled })
@@ -439,7 +710,15 @@ window.__ModuleLoader__.load({
439
710
  apiPost('/fact', { id: id, status: status })
440
711
  .then(function (data) {
441
712
  apply(data)
442
- setNotice({ tone: 'ok', text: status === 'approved' ? '已采纳,下一回合进注入。' : '已处理。' })
713
+ setNotice({
714
+ tone: 'ok',
715
+ text:
716
+ status === 'approved'
717
+ ? '已采纳(原来的退役也一并恢复),下一回合进注入。'
718
+ : status === 'retired'
719
+ ? '已退役:不再进注入,要它回来去「已退役」里点恢复。'
720
+ : '已处理。',
721
+ })
443
722
  })
444
723
  .catch(function (error) {
445
724
  setNotice({ tone: 'warn', text: '没改成:' + error.message })
@@ -449,6 +728,17 @@ window.__ModuleLoader__.load({
449
728
  })
450
729
  }
451
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
+
452
742
  function setHabitState(text, status, tag) {
453
743
  setBusy('habit')
454
744
  apiPost('/habit', { text: text, status: status, tag: tag || '' })
@@ -465,10 +755,31 @@ window.__ModuleLoader__.load({
465
755
  }
466
756
 
467
757
  if (state === null) {
758
+ // 三态:正在读 / 读失败 / 刚进来还没读。读失败时把 refresh 里 catch 到的真实原因端出来,
759
+ // 别用「重启 DSH 试试」这种猜测盖掉它 —— 重启治不了 403(凭据),也治不了 404(路由)。
760
+ var loadFailed = busy !== 'load' && notice !== null
468
761
  return h(
469
762
  'div',
470
763
  { className: 'sn-root' },
471
- 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,
472
783
  )
473
784
  }
474
785
 
@@ -531,7 +842,11 @@ window.__ModuleLoader__.load({
531
842
  'div',
532
843
  { key: 'actions', className: 'sn-actions' },
533
844
  h('button', { className: 'sn-btn', onClick: function () { setFact(fact.id, 'approved') }, disabled: busy !== '' }, '采纳'),
534
- 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
+ ),
535
850
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit({ id: fact.id, text: fact.text }) }, disabled: busy !== '' }, '改'),
536
851
  )
537
852
  : h('div', { key: 'state', className: 'sn-fact-cite' }, '状态:' + statusLabel(fact.status)),
@@ -543,6 +858,7 @@ window.__ModuleLoader__.load({
543
858
  var tabs = [
544
859
  { key: 'overview', label: '概览' },
545
860
  { key: 'notes', label: '自述', badge: notes.length + ' 条' },
861
+ { key: 'persona', label: '人格', badge: ((state.persona && state.persona.chars) || 0) + ' 字' },
546
862
  { key: 'memory', label: '记忆', badge: candidates.length > 0 ? '待审 ' + candidates.length : approvedFacts.length + ' 条' },
547
863
  { key: 'profile', label: '画像', badge: (state.profile ? state.profile.chars : 0) + ' 字' },
548
864
  { key: 'injection', label: '注入', badge: ((state.injection && state.injection.chars) || 0) + ' 字' },
@@ -567,9 +883,10 @@ window.__ModuleLoader__.load({
567
883
  }
568
884
 
569
885
  var budget = state.budget || {}
570
- var limit = budget.limit || 800
571
- var caps = budget.caps || { notes: 200, profile: 130, env: 320, facts: 110, habits: 40 }
886
+ var limit = budget.limit || 1000
887
+ var caps = budget.caps || { persona: 320, notes: 200, profile: 120, env: 230, facts: 90, habits: 40 }
572
888
  var segments = [
889
+ { key: 'persona', label: '人格', value: budget.persona || 0, color: '#f97316' },
573
890
  { key: 'notes', label: '自述', value: budget.notes || 0, color: '#60a5fa' },
574
891
  { key: 'profile', label: '画像', value: budget.profile || 0, color: '#a78bfa' },
575
892
  // 环境段是最大的一块(上限 320 字),漏了它进度条就画不满:
@@ -640,6 +957,10 @@ window.__ModuleLoader__.load({
640
957
  return Math.round(hours / 24) + ' 天前'
641
958
  }
642
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
+
643
964
  var settingsCard = h(
644
965
  'div',
645
966
  { className: 'sn-card', key: 'settings' },
@@ -675,8 +996,9 @@ window.__ModuleLoader__.load({
675
996
  h('input', {
676
997
  type: 'text',
677
998
  maxLength: 24,
999
+ 'aria-label': '注入抬头,最多 24 个字,留空就不加抬头',
678
1000
  value: heading,
679
- placeholder: '例:记忆(我写的)',
1001
+ placeholder: '例:AI记忆',
680
1002
  onChange: function (e) { setHeading(e.target.value) },
681
1003
  }),
682
1004
  ),
@@ -688,6 +1010,45 @@ window.__ModuleLoader__.load({
688
1010
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: saveHeading, disabled: busy !== '' }, '保存抬头'),
689
1011
  ),
690
1012
  h('div', { className: 'sn-hint' }, '上次写入:' + lastWriteText(state.notesLastWrite)),
1013
+ h(
1014
+ 'div',
1015
+ { className: 'sn-limits' },
1016
+ h('label', { className: 'sn-field' }, h('span', null, '临时自述保质期(天)'), h('input', { type: 'number', min: 1, max: 90, value: tempTtl, onChange: function (e) { setTempTtl(e.target.value) } })),
1017
+ h('button', { className: 'sn-btn', onClick: saveTempTtl, disabled: busy !== '' }, '保存保质期'),
1018
+ ),
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' }, '半衰期决定旧结论多久算「半凉」:越小越偏爱最近的。画像上限是「关于他」那段最多占多少字。'),
691
1052
  h(
692
1053
  'div',
693
1054
  { className: 'sn-field sn-field-wide' },
@@ -695,6 +1056,7 @@ window.__ModuleLoader__.load({
695
1056
  h('textarea', {
696
1057
  rows: 2,
697
1058
  maxLength: 80,
1059
+ 'aria-label': '什么时候该写自述,留空就用默认判据',
698
1060
  value: noteGuide,
699
1061
  placeholder: '例:每次改完一个插件、跑完验收之后写一条;闲聊不用写。',
700
1062
  onChange: function (e) { setNoteGuide(e.target.value) },
@@ -762,6 +1124,7 @@ window.__ModuleLoader__.load({
762
1124
  'div',
763
1125
  { className: 'sn-kv' },
764
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 ? '已启用' : '已关闭')),
765
1128
  kv('已索引会话', String(idx.sessions ?? 0)),
766
1129
  kv('磁盘会话', String(idx.disk ?? 0)),
767
1130
  kv('待同步会话', String(idx.missing ?? 0)),
@@ -773,6 +1136,15 @@ window.__ModuleLoader__.load({
773
1136
  'div',
774
1137
  { className: 'sn-actions' },
775
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
+ ),
776
1148
  h('span', { className: 'sn-grow' }),
777
1149
  h('span', { className: 'sn-hint' }, idx.present === true && idx.fresh === false ? '过期时不会用索引查,会退回完整扫描' : '重建期间会占住几秒'),
778
1150
  ),
@@ -1076,7 +1448,7 @@ window.__ModuleLoader__.load({
1076
1448
  {
1077
1449
  className: 'sn-tier',
1078
1450
  value: draftTier,
1079
- title: '核心=人格与长期身份(先装,最多占自述段的 60%);稳定=表达习惯与工作偏好;临时=当前阶段在做什么',
1451
+ title: '核心=身份素材(不进每回合注入,只作提炼人格的原料);稳定=表达习惯与工作偏好(先装,★ 优先);临时=当前阶段在做什么',
1080
1452
  onChange: function (e) { setDraftTier(e.target.value) },
1081
1453
  },
1082
1454
  h('option', { value: 'core' }, '核心身份'),
@@ -1103,7 +1475,7 @@ window.__ModuleLoader__.load({
1103
1475
  h(
1104
1476
  'div',
1105
1477
  { className: 'sn-hint' },
1106
- '按「核心 → 稳定(★ 先装)→ 临时」的顺序装,合计 ≤ ' + caps.notes + ' 字(核心最多占 60%)。放不下的整条跳过、不截半句,注入里会留一句「另有 N 条」当门牌。改完/删完下一回合生效。',
1478
+ '只装「稳定(★ 钉住的先装)」和「临时」,合计 ≤ ' + caps.notes + ' 字。放不下的整条跳过、不截半句,注入里会留一句「另有 N 条」当门牌。改完/删完下一回合生效。',
1107
1479
  ),
1108
1480
  h(
1109
1481
  'div',
@@ -1147,6 +1519,13 @@ window.__ModuleLoader__.load({
1147
1519
  'span',
1148
1520
  { className: 'sn-text' },
1149
1521
  entry.text,
1522
+ entry.tier === 'core'
1523
+ ? h(
1524
+ 'span',
1525
+ { className: 'sn-material', title: '核心身份现在是提炼人格的素材:它不再进每回合注入。要去「人格」页把要点写进 L0 / L1 才生效' },
1526
+ '素材(不注入)',
1527
+ )
1528
+ : null,
1150
1529
  entry.expired === true
1151
1530
  ? h(
1152
1531
  'span',
@@ -1245,6 +1624,125 @@ window.__ModuleLoader__.load({
1245
1624
  : null,
1246
1625
  )
1247
1626
 
1627
+ var persona = state.persona || {}
1628
+ var personaValuesNow = personaValues()
1629
+ var p0Chars = pinCount(personaValuesNow.l0)
1630
+ var p1Chars = pinCount(personaValuesNow.l1)
1631
+ // L0 与 L1 之间那一个换行也占 320 里的字数(与保存校验同一把尺子),
1632
+ // 否则界面会绿着「320 / 320」,点保存却被拒。
1633
+ var pGap = p0Chars > 0 && p1Chars > 0 ? 1 : 0
1634
+ var pTotalChars = p0Chars + p1Chars + pGap
1635
+ var pRestChars = Math.max(0, 320 - pTotalChars)
1636
+ var pL1Max = Math.max(0, 320 - Math.min(p0Chars, 80) - (p0Chars > 0 ? 1 : 0))
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
+ })
1642
+ var pickedCount = pickedEvidence.length
1643
+
1644
+ var personaCard = h(
1645
+ 'div',
1646
+ { className: 'sn-card', key: 'persona' },
1647
+ h('div', { className: 'sn-card-title' }, '人格三段(L0 / L1 每回合进,L2 按需取)'),
1648
+ h('div', { className: 'sn-hint' }, '这里只放「要是没人提醒,我就会忘」的那些。L0 和 L1 每回合都进上下文;L2 不进,只在 self_note 去取的时候出现。'),
1649
+ personaField('l0', 'L0 核心身份', '我是谁、和谁说话、怎么称呼。最先装;超过 80 字会被截断(并在这里标出来)。', personaValuesNow.l0, 80, 2),
1650
+ personaField('l1', 'L1 行为规则', '语气、回答骨架、情境优先级、禁忌边界。一行一条;装不下的整行跳过,不截半句。', personaValuesNow.l1, pL1Max, 6),
1651
+ h(
1652
+ 'div',
1653
+ { className: 'sn-hint' },
1654
+ 'L0 ' + p0Chars + ' + L1 ' + p1Chars + (pGap ? ' + 换行 1' : '') + ' = ' + pTotalChars + ' / 320 字(余 ' + pRestChars + ')',
1655
+ ),
1656
+ personaField('l2', 'L2 扩展设定', '外貌、服饰、世界观、兴趣。这一层不进每回合上下文;保存在同一个 persona.md 里,不占注入预算。', personaValuesNow.l2, (state.persona && state.persona.l2Limit) || Math.max(0, 8000 - pTotalChars), 6),
1657
+ h(
1658
+ 'div',
1659
+ { className: 'sn-actions' },
1660
+ h('button', { className: 'sn-btn', onClick: savePersona, disabled: busy !== '' }, '保存人格'),
1661
+ h('button', { className: 'sn-btn sn-btn-ghost', onClick: importPersona, disabled: busy !== '' }, '从 AGENT.md 导入'),
1662
+ h('span', { className: 'sn-grow' }),
1663
+ h('span', { className: 'sn-muted' }, '保存是整份校验:任一段不合格就一个字都不写。'),
1664
+ ),
1665
+
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
+ ),
1673
+ h(
1674
+ 'div',
1675
+ { className: 'sn-hint' },
1676
+ '模型能读到它们,但你没有写入权限 —— 采纳必须由人在这儿点。勾几条 → 改写成一条规则 → 保存人格时才算用过这些证据。',
1677
+ ),
1678
+ correctionItems.length === 0
1679
+ ? h('div', { className: 'sn-empty' }, '还没攒到纠偏证据。你纠正我之后,这里会慢慢出现原话。')
1680
+ : h(
1681
+ 'ul',
1682
+ { className: 'sn-list' },
1683
+ correctionItems.map(function (item) {
1684
+ var picked = pickedEvidence.indexOf(item.id) >= 0
1685
+ return h(
1686
+ 'li',
1687
+ { key: item.id, className: 'sn-item' },
1688
+ h(
1689
+ 'label',
1690
+ { className: 'sn-toggle sn-evidence' },
1691
+ h('input', {
1692
+ type: 'checkbox',
1693
+ checked: picked,
1694
+ onChange: function () { toggleEvidence(item.id) },
1695
+ }),
1696
+ h('span', { className: 'sn-evidence-quote' }, item.quote),
1697
+ ),
1698
+ h('span', { className: 'sn-evidence-meta' }, (item.at || '').slice(0, 10) + ' · ' + (item.session || '')),
1699
+ )
1700
+ }),
1701
+ ),
1702
+ h(
1703
+ 'div',
1704
+ { className: 'sn-actions' },
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,
1714
+ ),
1715
+ h('textarea', {
1716
+ className: 'sn-input',
1717
+ rows: 3,
1718
+ 'aria-label': '把选中的证据改写成一条规则',
1719
+ placeholder: '把上面几句改写成一条能照着做的规则,例如「回答时先举例,再给结论」。',
1720
+ value: ruleDraft,
1721
+ onChange: function (e) { setRuleDraft(e.target.value) },
1722
+ }),
1723
+ h(
1724
+ 'div',
1725
+ { className: 'sn-actions' },
1726
+ h(
1727
+ 'button',
1728
+ {
1729
+ className: 'sn-btn',
1730
+ disabled: busy !== '' || ruleDraft.trim() === '',
1731
+ onClick: function () {
1732
+ var body = ruleDraft.trim()
1733
+ if (body === '') return
1734
+ var nextL1 = (personaValues().l1 + String.fromCharCode(10) + body).trim()
1735
+ editPersona('l1', nextL1)
1736
+ setNotice({ tone: 'ok', text: '规则已加进 L1 草稿,还没保存 —— 确认没问题再点「保存人格」。' })
1737
+ },
1738
+ },
1739
+ '加进 L1(先不进盘)',
1740
+ ),
1741
+ h('span', { className: 'sn-grow' }),
1742
+ h('span', { className: 'sn-muted' }, sectionlessHint(persona)),
1743
+ ),
1744
+ )
1745
+
1248
1746
  var memoryCard = h(
1249
1747
  'div',
1250
1748
  { className: 'sn-card', key: 'memory' },
@@ -1330,11 +1828,65 @@ window.__ModuleLoader__.load({
1330
1828
  editingThis
1331
1829
  ? null
1332
1830
  : h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { setFactEdit({ id: fact.id, text: fact.text }) }, disabled: busy !== '' }, '改'),
1333
- 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
+ ),
1334
1836
  ),
1335
1837
  )
1336
1838
  }),
1337
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
+
1338
1890
  (state.habits || []).length > 0
1339
1891
  ? h(
1340
1892
  'div',
@@ -1406,8 +1958,8 @@ window.__ModuleLoader__.load({
1406
1958
  h(
1407
1959
  'div',
1408
1960
  { className: 'sn-card-note' },
1409
- (inj.chars || 0) + ' / ' + (inj.limit || 800) + ' 字' +
1410
- (inj.overhead > 0 ? '(含抬头 ' + inj.overhead + ' 字)' : ''),
1961
+ (inj.chars || 0) + ' / ' + (inj.limit || 1000) + ' 字' +
1962
+ (inj.overhead > 0 ? '(固定开销 ' + inj.overhead + ' 字)' : ''),
1411
1963
  ),
1412
1964
  ),
1413
1965
  h('div', { className: 'sn-hint' }, '下面这段每回合都贴在用户消息前面。它只改变「我」看到的背景,不会出现在你的输入框里。'),
@@ -1523,6 +2075,7 @@ window.__ModuleLoader__.load({
1523
2075
  var panels = {
1524
2076
  overview: [statusBar, budgetBlock, statsRow, settingsCard, doctorCard, indexCard, filesBlock, transferCard],
1525
2077
  notes: [writeCard, notesList],
2078
+ persona: [personaCard],
1526
2079
  memory: [memoryCard],
1527
2080
  profile: [profileCard],
1528
2081
  injection: [injectionCard, attributionCard],
@@ -1530,6 +2083,7 @@ window.__ModuleLoader__.load({
1530
2083
  var panelHints = {
1531
2084
  overview: '注入开关、容量上限,以及索引与文件位置。',
1532
2085
  notes: 'agent 写给自己看的话,攒多了从最旧的往 archive 挪。每一行都能改、能删。',
2086
+ persona: '三段人格:L0/L1 每回合都进上下文,L2 按需取。纠偏证据在这里变成规则,但必须人来拍板。',
1533
2087
  memory: '候选、已采纳、可以升成习惯的标签;采纳只能人来点。',
1534
2088
  profile: '从已采纳记忆聚合出的「关于你」,一句一出处。',
1535
2089
  injection: '这一回合真的会贴进提示词的那段字,逐段拆开看谁进了、谁被挤掉。',
@@ -1556,7 +2110,7 @@ window.__ModuleLoader__.load({
1556
2110
  h('li', null, h('b', null, '关于你(画像)'), ' 从「已采纳」的事实里聚合,按标签每组取最新一条,每句都能点回原句;可以手改覆盖。'),
1557
2111
  h('li', null, h('b', null, '常驻结论'), ' 已采纳的事实里,画像没吸收到的那些。'),
1558
2112
  h('li', null, h('b', null, '习惯'), ' 由人点出来才生效,模型自己升不了。'),
1559
- h('li', { className: 'sn-intro-note' }, '每回合合计 ≤' + limit + ' 字:自述 ' + caps.notes + ' / 画像 ' + caps.profile + ' / 环境 ' + caps.env + ' / 结论 ' + caps.facts + ' / 习惯 ' + caps.habits + '。放不下的那一条会跳过、不截半句。这几段的上限写死在插件里(不是设置项);检索工具不受这个限制。'),
2113
+ h('li', { className: 'sn-intro-note' }, '每回合合计 ≤' + limit + ' 字:人格 ' + caps.persona + ' / 自述 ' + caps.notes + ' / 画像 ' + caps.profile + ' / 环境 ' + caps.env + ' / 结论 ' + caps.facts + ' / 习惯 ' + caps.habits + '。放不下的那一条会跳过、不截半句。这几段的上限写死在插件里(不是设置项);检索工具不受这个限制。'),
1560
2114
  ),
1561
2115
  ),
1562
2116
 
@@ -1584,7 +2138,13 @@ window.__ModuleLoader__.load({
1584
2138
  }),
1585
2139
  ),
1586
2140
 
1587
- 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
+ ),
1588
2148
 
1589
2149
  h(
1590
2150
  'div',
@@ -1660,6 +2220,13 @@ window.__ModuleLoader__.load({
1660
2220
  '.sn-pin{padding:2px 8px;line-height:1.6}',
1661
2221
  '.sn-pin-on{color:#b45309;border-color:#fcd34d}',
1662
2222
  '.sn-expired{font-size:11px;color:#9ca3af;border:1px solid #e5e7eb;border-radius:6px;padding:0 6px;margin-left:6px;white-space:nowrap}',
2223
+ '.sn-material{font-size:11px;color:#b45309;border:1px solid #fcd34d;background:#fffbeb;border-radius:6px;padding:0 6px;margin-left:6px;white-space:nowrap}',
2224
+ '.sn-field-label{display:flex;align-items:baseline;gap:4px;font-weight:600}',
2225
+ '.sn-count{font-size:12px;font-weight:400;color:var(--dsw-alias-text-quaternary,#9ca3af);font-variant-numeric:tabular-nums}',
2226
+ '.sn-count-over{color:#dc2626;font-weight:600}',
2227
+ '.sn-evidence{align-items:flex-start;gap:8px}',
2228
+ '.sn-evidence-quote{line-height:1.6}',
2229
+ '.sn-evidence-meta{font-size:11px;color:var(--dsw-alias-text-quaternary,#9ca3af);white-space:nowrap}',
1663
2230
  '.sn-fact-edit{display:flex;flex-direction:column;gap:6px}',
1664
2231
  '.sn-empty{font-size:12px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1665
2232
  '.sn-sources{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}',
@@ -1675,6 +2242,10 @@ window.__ModuleLoader__.load({
1675
2242
  '.sn-details summary{cursor:pointer;user-select:none}',
1676
2243
  '.sn-mono{font-family:ui-monospace,Menlo,Consolas,monospace;word-break:break-all;padding-top:4px}',
1677
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}',
1678
2249
  ".sn-fact-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
1679
2250
  ".sn-badge{font-size:11px;background:#eef2ff;color:#3730a3;border-radius:999px;padding:1px 8px}",
1680
2251
  ".sn-fact-conflict{border-color:#fcd34d;background:#fffdf5}",
@@ -1760,6 +2331,22 @@ window.__ModuleLoader__.load({
1760
2331
  '.sn-doc-error{background:#fef2f2;color:#b91c1c}',
1761
2332
  '.sn-doc-label{font-weight:600;color:var(--dsw-alias-text-primary,#111827)}',
1762
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}}',
1763
2350
  ].join('\n')
1764
2351
 
1765
2352
  function apply(ctx) {