dsh-selfnote 0.13.2 → 0.13.3

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
@@ -155,6 +155,11 @@ window.__ModuleLoader__.load({
155
155
  var headingTuple = useState('')
156
156
  var heading = headingTuple[0]
157
157
  var setHeading = headingTuple[1]
158
+ // 新写的那条算哪一型:核心先装、稳定默认、临时只活当下
159
+ // 放在最后:上面那些 useState 的调用序号被测试依赖,别插在中间
160
+ var draftTierTuple = useState('stable')
161
+ var draftTier = draftTierTuple[0]
162
+ var setDraftTier = draftTierTuple[1]
158
163
 
159
164
  function apply(data) {
160
165
  setState(data)
@@ -299,11 +304,12 @@ window.__ModuleLoader__.load({
299
304
  return
300
305
  }
301
306
  setBusy('note')
302
- apiPost('/note', { text: text })
307
+ apiPost('/note', { text: text, tier: draftTier })
303
308
  .then(function (data) {
304
309
  apply(data)
305
310
  setDraft('')
306
- setNotice({ tone: 'ok', text: '写好了。' })
311
+ var add = data.noteAdd || {}
312
+ setNotice(add.warning ? { tone: 'warn', text: '写好了,但' + add.warning } : { tone: 'ok', text: '写好了。' })
307
313
  })
308
314
  .catch(function (error) {
309
315
  setNotice({ tone: 'warn', text: '写入失败:' + error.message })
@@ -351,6 +357,22 @@ window.__ModuleLoader__.load({
351
357
  })
352
358
  }
353
359
 
360
+ // 改类型 / pin:正文一个字不动,只换这条排在哪一队
361
+ function setNoteTier(index, patch) {
362
+ setBusy('note-meta')
363
+ apiPost('/note/meta', { index: index, tier: patch.tier, pin: patch.pin })
364
+ .then(function (data) {
365
+ apply(data)
366
+ setNotice({ tone: 'ok', text: '改好了,下一回合按新的位置装。' })
367
+ })
368
+ .catch(function (error) {
369
+ setNotice({ tone: 'warn', text: '没改成:' + error.message })
370
+ })
371
+ .then(function () {
372
+ setBusy('')
373
+ })
374
+ }
375
+
354
376
  function removeNote(index) {
355
377
  if (pendingDelete !== index) {
356
378
  setPendingDelete(index)
@@ -1049,12 +1071,31 @@ window.__ModuleLoader__.load({
1049
1071
  h(
1050
1072
  'div',
1051
1073
  { className: 'sn-actions' },
1074
+ h(
1075
+ 'select',
1076
+ {
1077
+ className: 'sn-tier',
1078
+ value: draftTier,
1079
+ title: '核心=人格与长期身份(先装,最多占自述段的 60%);稳定=表达习惯与工作偏好;临时=当前阶段在做什么',
1080
+ onChange: function (e) { setDraftTier(e.target.value) },
1081
+ },
1082
+ h('option', { value: 'core' }, '核心身份'),
1083
+ h('option', { value: 'stable' }, '稳定偏好'),
1084
+ h('option', { value: 'temp' }, '临时自述'),
1085
+ ),
1052
1086
  h('button', { className: 'sn-btn', onClick: addNote, disabled: busy !== '' }, '写进去'),
1053
1087
  h('span', { className: 'sn-grow' }),
1054
1088
  h('button', { className: 'sn-btn sn-btn-danger', onClick: clearAll, disabled: busy !== '' }, confirming ? '真的清空' : '清空当前(进归档)'),
1055
1089
  ),
1056
1090
  )
1057
1091
 
1092
+ var tierCounts = { core: 0, stable: 0, temp: 0, expired: 0 }
1093
+ notes.forEach(function (entry) {
1094
+ var key = entry.tier === 'core' || entry.tier === 'temp' ? entry.tier : 'stable'
1095
+ tierCounts[key] += 1
1096
+ if (entry.expired === true) tierCounts.expired += 1
1097
+ })
1098
+
1058
1099
  var notesList = h(
1059
1100
  'div',
1060
1101
  { className: 'sn-card', key: 'notes' },
@@ -1062,7 +1103,13 @@ window.__ModuleLoader__.load({
1062
1103
  h(
1063
1104
  'div',
1064
1105
  { className: 'sn-hint' },
1065
- '每一行都会进注入:最新的优先,合计 ≤ ' + caps.notes + ' 字,放不下的整条跳过(不截半句)。改完/删完下一回合生效。',
1106
+ '按「核心 → 稳定(★ 先装)→ 临时」的顺序装,合计 ≤ ' + caps.notes + ' 字(核心最多占 60%)。放不下的整条跳过、不截半句,注入里会留一句「另有 N 条」当门牌。改完/删完下一回合生效。',
1107
+ ),
1108
+ h(
1109
+ 'div',
1110
+ { className: 'sn-hint' },
1111
+ '核心 ' + tierCounts.core + ' 条 稳定 ' + tierCounts.stable + ' 条 临时 ' + tierCounts.temp + ' 条' +
1112
+ (tierCounts.expired > 0 ? '(其中 ' + tierCounts.expired + ' 条已过期,不再自动进场)' : ''),
1066
1113
  ),
1067
1114
  reversed.length === 0
1068
1115
  ? h('div', { className: 'sn-empty' }, '还没有任何自述。模型攒到第一条时会出现在这里。')
@@ -1096,12 +1143,46 @@ window.__ModuleLoader__.load({
1096
1143
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: cancelEditNote, disabled: busy !== '' }, '取消'),
1097
1144
  ),
1098
1145
  )
1099
- : h('span', { className: 'sn-text' }, entry.text),
1146
+ : h(
1147
+ 'span',
1148
+ { className: 'sn-text' },
1149
+ entry.text,
1150
+ entry.expired === true
1151
+ ? h(
1152
+ 'span',
1153
+ { className: 'sn-expired', title: '临时自述过了保质期,不再自动进上下文;内容还在,改成「稳定」或重写一条就回来' },
1154
+ '已过期',
1155
+ )
1156
+ : null,
1157
+ ),
1100
1158
  isEditing
1101
1159
  ? null
1102
1160
  : h(
1103
1161
  'span',
1104
1162
  { className: 'sn-row-actions' },
1163
+ h(
1164
+ 'select',
1165
+ {
1166
+ className: 'sn-tier',
1167
+ value: entry.tier || 'stable',
1168
+ title: '这条属于哪一型:核心先装(人格、长期身份)、稳定按 pin 排、临时最后',
1169
+ onChange: function (e) { setNoteTier(stored, { tier: e.target.value }) },
1170
+ disabled: busy !== '',
1171
+ },
1172
+ h('option', { value: 'core' }, '核心'),
1173
+ h('option', { value: 'stable' }, '稳定'),
1174
+ h('option', { value: 'temp' }, '临时'),
1175
+ ),
1176
+ h(
1177
+ 'button',
1178
+ {
1179
+ className: 'sn-btn sn-btn-ghost sn-pin' + (entry.pin === true ? ' sn-pin-on' : ''),
1180
+ title: entry.pin === true ? '已钉住:同一型里优先装进去(再点取消)' : '钉住:同一型里优先装进去',
1181
+ onClick: function () { setNoteTier(stored, { pin: entry.pin !== true }) },
1182
+ disabled: busy !== '',
1183
+ },
1184
+ entry.pin === true ? '★' : '☆',
1185
+ ),
1105
1186
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: function () { startEditNote(stored, entry.text) }, disabled: busy !== '' }, '改'),
1106
1187
  h('button', { className: 'sn-btn sn-btn-danger', onClick: function () { removeNote(stored) }, disabled: busy !== '' }, pendingDelete === stored ? '真的删' : '删'),
1107
1188
  ),
@@ -1575,6 +1656,10 @@ window.__ModuleLoader__.load({
1575
1656
  '.sn-edit{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}',
1576
1657
  '.sn-row-actions{display:flex;gap:6px;margin-left:auto;flex:none;opacity:.55;transition:opacity .12s ease}',
1577
1658
  '.sn-item:hover .sn-row-actions,.sn-item:focus-within .sn-row-actions{opacity:1}',
1659
+ '.sn-tier{font-size:11px;font-family:inherit;padding:2px 4px;border-radius:6px;border:1px solid var(--dsw-alias-border-subtle,#d1d5db);background:var(--dsw-alias-bg-primary,#fff);color:inherit;cursor:pointer}',
1660
+ '.sn-pin{padding:2px 8px;line-height:1.6}',
1661
+ '.sn-pin-on{color:#b45309;border-color:#fcd34d}',
1662
+ '.sn-expired{font-size:11px;color:#9ca3af;border:1px solid #e5e7eb;border-radius:6px;padding:0 6px;margin-left:6px;white-space:nowrap}',
1578
1663
  '.sn-fact-edit{display:flex;flex-direction:column;gap:6px}',
1579
1664
  '.sn-empty{font-size:12px;color:var(--dsw-alias-text-secondary,#9ca3af)}',
1580
1665
  '.sn-sources{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}',
package/lib/index.js CHANGED
@@ -16,11 +16,14 @@ import {
16
16
  clearNotes,
17
17
  configPath,
18
18
  deleteNote,
19
+ isExpiredNote,
19
20
  notesPath,
21
+ normalizeTier,
20
22
  readAuthToken,
21
23
  readConfig,
22
24
  readNotes,
23
25
  renderNotes,
26
+ setNoteMeta,
24
27
  trim,
25
28
  selfnoteRoot,
26
29
  stats,
@@ -30,6 +33,7 @@ import {
30
33
  writeNotes,
31
34
  DEFAULT_MAX_CHARS,
32
35
  DEFAULT_MAX_ENTRIES,
36
+ DEFAULT_TEMP_TTL_DAYS,
33
37
  MAX_ENTRY_CHARS,
34
38
  } from './store.js'
35
39
 
@@ -155,6 +159,8 @@ const DEFAULT_CONFIG = {
155
159
  halfLifeDays: DEFAULT_HALF_LIFE_DAYS,
156
160
  /** 自定义「写自述的时机」;留空就用工具说明里的默认判据。 */
157
161
  noteGuide: '',
162
+ /** 临时自述的保质期(天):过了就不自动进场,只留在库里。核心与稳定型不受影响。 */
163
+ tempTtlDays: DEFAULT_TEMP_TTL_DAYS,
158
164
  }
159
165
 
160
166
  const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的事,是记我自己的。
@@ -168,6 +174,7 @@ const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的
168
174
  写法:
169
175
  - 第一人称,一句话到三句话;写「我」的做法和判断,不复述用户的原话。
170
176
  - 一条写一件事。空话、套话、复述人格设定都不值得写。
177
+ - 写的时候用 tier 标明这是哪一型:core=核心身份(人格、长期身份、基本原则)、stable=稳定偏好(语言、表达习惯、工作偏好)、temp=临时自述(当前阶段的目标、近期变化)。不写按 stable 算。
171
178
  - 只追加,不能改写历史。写满后最旧的条目自动进归档,不会丢,只是不再每回合注入。
172
179
  - 这是明文保存在本机、并且每回合都会进上下文的文件:密码、密钥、验证码这类凭据别写。
173
180
  - 记用户本人(名字、习惯、处境、在意的点)是有用的,但那些更适合进事实库:用 memory_stage。
@@ -188,6 +195,15 @@ const PARAMETERS = {
188
195
  type: 'string',
189
196
  description: 'action=write 时的正文,一句话到三句话,第一人称。最多 600 字,超出截断。',
190
197
  },
198
+ tier: {
199
+ type: 'string',
200
+ enum: ['core', 'stable', 'temp'],
201
+ description: '这条属于哪一型:core=核心身份(人格、长期身份、基本原则,先装);stable=稳定偏好(表达习惯、工作偏好,默认);temp=临时自述(当前阶段的目标、近期变化,最后装)。',
202
+ },
203
+ pin: {
204
+ type: 'boolean',
205
+ description: '钉住:同一型里优先装进上下文。只给「绝不能忘」的那一两条用,钉多了等于没钉。',
206
+ },
191
207
  },
192
208
  required: ['action'],
193
209
  }
@@ -215,6 +231,9 @@ function currentConfig(inline) {
215
231
  // 存的正好是老默认 → 跟着换代;用户自己改过的(不等于老默认)一个字都不动。
216
232
  heading: merged.heading === LEGACY_HEADING ? DEFAULT_CONFIG.heading : merged.heading,
217
233
  noteGuide: typeof merged.noteGuide === 'string' ? merged.noteGuide.trim().slice(0, GUIDE_LIMIT) : '',
234
+ tempTtlDays: Number.isFinite(Number(merged.tempTtlDays))
235
+ ? Math.max(1, Math.min(Math.round(Number(merged.tempTtlDays)), 90))
236
+ : DEFAULT_TEMP_TTL_DAYS,
218
237
  }
219
238
  }
220
239
 
@@ -233,6 +252,14 @@ function limitsOf(cfg) {
233
252
  const BUDGET = { notes: 200, profile: 130, env: 320, facts: 110, habits: 40 }
234
253
  /** 五段合计,别在别处再手写一个数字。 */
235
254
  export const TOTAL_BUDGET = BUDGET.notes + BUDGET.profile + BUDGET.env + BUDGET.facts + BUDGET.habits
255
+
256
+ /**
257
+ * 自述段的内部分账。核心身份(人格、长期身份)先装,最多吃 60%:
258
+ * 人格优先,但不能一条人格把整个自述段占死,后面的人味就全没了。
259
+ */
260
+ const CORE_SHARE = 0.6
261
+ /** 临时自述的保底比例:别让长期偏好把「当前阶段在做什么」全部挤掉。 */
262
+ const TEMP_SHARE = 0.25
236
263
  /** 自定义「写自述的时机」那行的上限(字)。 */
237
264
  export const GUIDE_LIMIT = 80
238
265
  /** 注入抬头那一行的上限(字)。它是每回合固定开销,不设限等于让人用一句话吃掉预算。 */
@@ -356,6 +383,129 @@ function fit(items, limit) {
356
383
  return fitDetailed(items, limit).text
357
384
  }
358
385
 
386
+ /**
387
+ * 自述段的三型分账。顺序就是优先级:
388
+ * 核心(旧→新,人格底子先在场)→ 稳定(pin 先,再新→旧)→ 临时(新→旧)。
389
+ * 稳定段有上限(给临时留 1/4 保底),临时段拿走剩下的全部 —— 临时条目都很短,通常吃不完。
390
+ * 为什么要分:混着装的时候,一条 90 字的人格自述和五条「今晚提醒」抢同一个 200 字,
391
+ * 抢输的总是人格那条,而且没人看得出来谁被挤掉了。
392
+ * 临时型还会过期:过了 tempTtlDays 就不再自动进场(数据一个字没删,只是不进上下文)。
393
+ */
394
+ function packNotes(entries, limit, ttl) {
395
+ const ttlDays = Number.isFinite(Number(ttl)) ? Number(ttl) : DEFAULT_TEMP_TTL_DAYS
396
+ const core = []
397
+ const stable = []
398
+ const temp = []
399
+ const expired = []
400
+ for (const entry of entries) {
401
+ const tier = normalizeTier(entry?.tier)
402
+ if (tier === 'core') core.push(entry)
403
+ else if (tier === 'temp') {
404
+ if (isExpiredNote(entry, ttlDays)) expired.push(entry)
405
+ else temp.push(entry)
406
+ } else stable.push(entry)
407
+ }
408
+ const coreList = core.map((entry) => entry.text)
409
+ const stableList = stable
410
+ .slice()
411
+ .reverse()
412
+ .sort((a, b) => (b.pin === true ? 1 : 0) - (a.pin === true ? 1 : 0))
413
+ .map((entry) => entry.text)
414
+ const tempList = temp.slice().reverse().map((entry) => entry.text)
415
+
416
+ const coreLimit = Math.min(limit, Math.round(limit * CORE_SHARE))
417
+ const coreFit = fitDetailed(coreList, coreLimit)
418
+ let used = coreFit.text === '' ? 0 : coreFit.text.length + 1
419
+ const tail = Math.max(0, limit - used)
420
+ const tempFloor = tempList.length === 0 ? 0 : Math.min(tail, Math.max(24, Math.round(tail * TEMP_SHARE)))
421
+ const stableLimit = Math.max(0, tail - tempFloor)
422
+ const stableFit = fitDetailed(stableList, stableLimit)
423
+ used += stableFit.text === '' ? 0 : stableFit.text.length + 1
424
+ const tempLimit = Math.max(0, limit - used)
425
+ const tempFit = fitDetailed(tempList, tempLimit)
426
+
427
+ const parts = [coreFit.text, stableFit.text, tempFit.text].filter((part) => part !== '')
428
+ // 过期的也算「没进上下文」:它们还在库里,模型得靠门牌知道有这么几条,
429
+ // 所以并进 skipped 一起报数 —— 少报就是骗人。
430
+ const expiredTexts = expired.map((entry) => entry.text)
431
+ return {
432
+ text: parts.join('\n'),
433
+ kept: [...coreFit.kept, ...stableFit.kept, ...tempFit.kept],
434
+ skipped: [...coreFit.skipped, ...stableFit.skipped, ...tempFit.skipped, ...expiredTexts],
435
+ // 每型的账:设置页要能指着说「核心 3 条全进了,临时 2 条被挤掉」
436
+ tiers: {
437
+ core: { total: coreList.length, kept: coreFit.kept.length, skipped: coreFit.skipped.length, limit: coreLimit },
438
+ stable: { total: stableList.length, kept: stableFit.kept.length, skipped: stableFit.skipped.length, limit: stableLimit },
439
+ temp: {
440
+ total: tempList.length + expired.length,
441
+ kept: tempFit.kept.length,
442
+ skipped: tempFit.skipped.length,
443
+ expired: expired.length,
444
+ limit: tempLimit,
445
+ },
446
+ },
447
+ }
448
+ }
449
+
450
+ /**
451
+ * 自述段 + 门牌。
452
+ * 门牌必须收敛:它自己占字,加进去可能又挤掉一条,条数一变门牌上的数字就是错的。
453
+ * 所以两轮:先按全长排,用剩下的条数算门牌,再按「减掉门牌」的长度重排,直到条数不再变。
454
+ */
455
+ function notesSegment(entries, limit, ttl) {
456
+ const packed = packNotes(entries, limit, ttl)
457
+ if (packed.skipped.length === 0) return { ...packed, pointer: '' }
458
+ let current = packed
459
+ let pointer = ''
460
+ for (let pass = 0; pass < 3; pass += 1) {
461
+ const next = `另有 ${current.skipped.length} 条自述(self_note read)`
462
+ if (next.length + 1 >= limit) break
463
+ const refit = packNotes(entries, Math.max(0, limit - next.length - 1), ttl)
464
+ const converged = refit.skipped.length === current.skipped.length
465
+ pointer = next
466
+ current = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
467
+ if (converged) break
468
+ }
469
+ if (pointer !== '') current.text = current.text.includes(pointer) ? current.text : `${current.text} ${pointer}`.trim()
470
+ return { ...current, pointer }
471
+ }
472
+
473
+ /** 结论段打包:要连 id 一起留下(归属统计靠它),所以不能直接用 fitDetailed。 */
474
+ function packFacts(ranked, limit) {
475
+ const kept = []
476
+ const skipped = []
477
+ let used = 0
478
+ for (const item of ranked) {
479
+ const text = String(item?.text ?? '').trim()
480
+ if (text === '') continue
481
+ if (used + text.length > limit) {
482
+ skipped.push(text)
483
+ continue
484
+ }
485
+ kept.push(item)
486
+ used += text.length + 1
487
+ }
488
+ return { text: kept.map((item) => item.text).join('\n'), kept, skipped, used }
489
+ }
490
+
491
+ /** 结论段 + 门牌:和自述同一套收敛逻辑,否则「另有 N 条」会少报。 */
492
+ function factsSegment(ranked, limit) {
493
+ const packed = packFacts(ranked, limit)
494
+ if (packed.skipped.length === 0) return { ...packed, pointer: '' }
495
+ let current = packed
496
+ let pointer = ''
497
+ for (let pass = 0; pass < 3; pass += 1) {
498
+ const next = `另有 ${current.skipped.length} 条结论(memory_recall 查)`
499
+ if (next.length + 1 >= limit) break
500
+ const refit = packFacts(ranked, Math.max(0, limit - next.length - 1))
501
+ const converged = refit.skipped.length === current.skipped.length
502
+ pointer = next
503
+ current = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
504
+ if (converged) break
505
+ }
506
+ return { ...current, pointer }
507
+ }
508
+
359
509
  let injectCache = { key: '', text: '' }
360
510
 
361
511
  /** 注入文本的缓存键:三份数据只要有一份变了,就重算。 */
@@ -371,8 +521,11 @@ function injectionKey(cfg) {
371
521
  }
372
522
  })
373
523
  const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, cfg.profileOverride.length, cfg.profileOverride].join('~')
524
+ // 临时自述会自己到期:文件一个字没改,跨过保质期那天注入也得重算。
525
+ // 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
526
+ const dayKey = 'd' + Math.floor(Date.now() / 86400000)
374
527
  // 半衰期也影响排序(facts / env 段按热度排),不改文件只改它时也得重算
375
- return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays
528
+ return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays + '|ttl' + cfg.tempTtlDays + '|' + dayKey
376
529
  }
377
530
 
378
531
  /**
@@ -384,14 +537,18 @@ function assembleInjection(cfg, options = {}) {
384
537
  const segments = []
385
538
  const parts = []
386
539
 
387
- const noteFit = fitDetailed(
388
- readNotes()
389
- .slice()
390
- .reverse()
391
- .map((entry) => entry.text),
392
- BUDGET.notes,
393
- )
394
- segments.push({ key: 'notes', label: '自述', limit: BUDGET.notes, chars: noteFit.text.length, kept: noteFit.kept, skipped: noteFit.skipped })
540
+ // 自述:三型分账 + 门牌。装不下的那几条,模型必须知道存在,否则它会把「没看见」当成「没有」。
541
+ const noteFit = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays)
542
+ segments.push({
543
+ key: 'notes',
544
+ label: '自述',
545
+ limit: BUDGET.notes,
546
+ chars: noteFit.text.length,
547
+ kept: noteFit.kept,
548
+ skipped: noteFit.skipped,
549
+ tiers: noteFit.tiers,
550
+ pointer: noteFit.pointer ?? '',
551
+ })
395
552
  if (noteFit.text !== '') parts.push(noteFit.text)
396
553
 
397
554
  // 自定义「写自述的时机」:工具说明在注册时就定型了,改一句要重启;
@@ -453,10 +610,8 @@ function assembleInjection(cfg, options = {}) {
453
610
  if (envLine !== '') parts.push(envLine)
454
611
 
455
612
  const ranked = factsForInjection(cfg)
456
- const factFit = fitDetailed(
457
- ranked.map((item) => item.text),
458
- BUDGET.facts,
459
- )
613
+ // 结论段也带门牌:装不下的不只是「没用」,是「查得到但现在不在场」
614
+ const factFit = factsSegment(ranked, BUDGET.facts)
460
615
  // 只有真的进了上下文才算「用到」;被检索到但没进上下文的不涨热度。
461
616
  // 画像段也是真贴进提示词的一行 —— 被画像吸收的条目同样要记账,
462
617
  // 否则「关于他:…」里天天在用的那句会一直显示「0 次 / 从没进过」。
@@ -466,10 +621,7 @@ function assembleInjection(cfg, options = {}) {
466
621
  const placements = {}
467
622
  for (const id of profile.sources ?? []) placements[id] = 'profile'
468
623
  for (const id of env.ids) placements[id] = 'env'
469
- let factChars = 0
470
- for (const item of ranked) {
471
- if (factChars + item.text.length > BUDGET.facts) continue
472
- factChars += item.text.length + 1
624
+ for (const item of factFit.kept) {
473
625
  factIds.push(item.id)
474
626
  placements[item.id] = 'facts'
475
627
  }
@@ -479,8 +631,9 @@ function assembleInjection(cfg, options = {}) {
479
631
  label: '结论',
480
632
  limit: BUDGET.facts,
481
633
  chars: factFit.text.length,
482
- kept: factFit.kept,
634
+ kept: factFit.kept.map((item) => item.text),
483
635
  skipped: factFit.skipped,
636
+ pointer: factFit.pointer ?? '',
484
637
  absorbed: factFit.text === '' ? (profile.sources ?? []).length : 0,
485
638
  })
486
639
  if (factFit.text !== '') parts.push(factFit.text)
@@ -1235,7 +1388,9 @@ function stateOfInner(inline) {
1235
1388
  entries: s.entries,
1236
1389
  chars: s.chars,
1237
1390
  archiveFiles: s.archiveFiles,
1238
- notes: readNotes(),
1391
+ // 每行带上「到期没有」:设置页要能把过期的临时自述标出来,而不是让人以为它这回合还在
1392
+ notes: readNotes().map((entry) => ({ ...entry, expired: isExpiredNote(entry, cfg.tempTtlDays) })),
1393
+ tempTtlDays: cfg.tempTtlDays,
1239
1394
  noteGuide: cfg.noteGuide,
1240
1395
  notesLastWrite: s.lastWrite,
1241
1396
  notesFile: s.notesFile,
@@ -1318,14 +1473,11 @@ function currentProfileText(cfg) {
1318
1473
  function budgetOf(inline) {
1319
1474
  const cfg = currentConfig(inline)
1320
1475
  if (!cfg.enabled) return { total: 0, limit: TOTAL_BUDGET, notes: 0, profile: 0, env: 0, facts: 0, habits: 0, enabled: false, caps: { ...BUDGET } }
1321
- const notes = fit(readNotes().slice().reverse().map((entry) => entry.text), BUDGET.notes)
1476
+ // 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上(门牌也要算进去)
1477
+ const notes = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays).text
1322
1478
  const profile = currentProfile(cfg)
1323
- // 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上
1324
1479
  const env = packEnvFair(envForInjection(cfg), BUDGET.env).text
1325
- const facts = fit(
1326
- factsForInjection(cfg).map((item) => item.text),
1327
- BUDGET.facts,
1328
- )
1480
+ const facts = factsSegment(factsForInjection(cfg), BUDGET.facts).text
1329
1481
  const habits = fit(readHabits().filter((habit) => habit.status === 'active').map((habit) => '· ' + habit.text), BUDGET.habits)
1330
1482
  return {
1331
1483
  enabled: true,
@@ -1341,11 +1493,34 @@ function budgetOf(inline) {
1341
1493
  }
1342
1494
  }
1343
1495
 
1496
+ const TIER_WORD = { core: '核心身份', stable: '稳定偏好', temp: '临时自述' }
1497
+
1498
+ /**
1499
+ * 写完的那条到底进不进得了上下文 —— 提前说清楚。
1500
+ * 超过整段预算的条目,保存会成功、注入会静默跳过,人只会觉得「我写了怎么没生效」。
1501
+ */
1502
+ function noteCapacityWarning(tier, text, inline) {
1503
+ const cfg = currentConfig(inline)
1504
+ if (cfg.enabled === false) return '注入已关闭,这条暂时不会进上下文。'
1505
+ const key = normalizeTier(tier)
1506
+ const body = String(text ?? '')
1507
+ const caps = { core: Math.round(BUDGET.notes * CORE_SHARE), stable: BUDGET.notes, temp: BUDGET.notes }
1508
+ if (body.length > caps[key]) {
1509
+ return `这条 ${body.length} 字,超过${TIER_WORD[key]}段的上限(约 ${caps[key]} 字),模型每回合都看不到它 —— 要么写短,要么拆成几条。`
1510
+ }
1511
+ const info = packNotes(readNotes(), BUDGET.notes, cfg.tempTtlDays).tiers[key]
1512
+ if (info.skipped > 0) return `${TIER_WORD[key]}现在共 ${info.total} 条,这轮有 ${info.skipped} 条被挤掉(排在前面的才进上下文)。`
1513
+ return ''
1514
+ }
1515
+
1344
1516
  function renderText(args, value) {
1345
1517
  if (value?.error) return `⚠️ ${value.error}`
1346
1518
  if (value.action === 'write') {
1347
1519
  const tail = value.archived > 0 ? `,最旧的 ${value.archived} 条已归档到 ${value.archivedFile}` : ''
1348
- return `已写下。现在生效 ${value.entries} 条 / ${value.chars} 字${tail}。`
1520
+ const kind = TIER_WORD[value.tier ?? 'stable'] ?? TIER_WORD.stable
1521
+ const pin = value.pin === true ? '、已 pin' : ''
1522
+ const warn = value.warning ? `\n注意:${value.warning}` : ''
1523
+ return `已写下(${kind}${pin})。现在生效 ${value.entries} 条 / ${value.chars} 字${tail}。${warn}`
1349
1524
  }
1350
1525
  if (value.action === 'read') {
1351
1526
  if (value.entries === 0) return '还没有任何自述。'
@@ -1815,15 +1990,18 @@ export function apply(ctx, config) {
1815
1990
  if (action === 'write') {
1816
1991
  const text = String(args?.text ?? '').trim()
1817
1992
  if (text === '') return { action, error: 'text 不能为空' }
1818
- const result = appendNote(text, limits)
1993
+ const result = appendNote(text, limits, { tier: args?.tier, pin: args?.pin })
1819
1994
  const after = stats()
1820
1995
  return {
1821
1996
  action,
1822
1997
  text,
1998
+ tier: result.tier,
1999
+ pin: result.pin,
1823
2000
  entries: after.entries,
1824
2001
  chars: after.chars,
1825
2002
  archived: result.archived,
1826
2003
  archivedFile: result.archivedFile || '',
2004
+ warning: noteCapacityWarning(result.tier, text, inline),
1827
2005
  }
1828
2006
  }
1829
2007
  if (action === 'read') {
@@ -2054,6 +2232,13 @@ export function apply(ctx, config) {
2054
2232
  }
2055
2233
  next.profileMaxChars = Math.round(value)
2056
2234
  }
2235
+ if (body.tempTtlDays !== undefined) {
2236
+ const value = Number(body.tempTtlDays)
2237
+ if (!Number.isFinite(value) || value < 1 || value > 90) {
2238
+ return sendJson(res, 400, { ok: false, error: 'tempTtlDays 要在 1–90 天之间' })
2239
+ }
2240
+ next.tempTtlDays = Math.round(value)
2241
+ }
2057
2242
  writeConfig(next)
2058
2243
  return sendJson(res, 200, stateOf(inline))
2059
2244
  }
@@ -2061,15 +2246,30 @@ export function apply(ctx, config) {
2061
2246
  const body = await readBody(req)
2062
2247
  const text = typeof body?.text === 'string' ? body.text.trim() : ''
2063
2248
  if (text === '') return sendJson(res, 400, { ok: false, error: '内容不能为空' })
2064
- appendNote(text, limitsOf(currentConfig(inline)))
2065
- return sendJson(res, 200, stateOf(inline))
2249
+ const result = appendNote(text, limitsOf(currentConfig(inline)), { tier: body?.tier, pin: body?.pin })
2250
+ auditAction('note-add', { tier: result.tier, pin: result.pin, chars: text.length }, req)
2251
+ return sendJson(res, 200, {
2252
+ ...stateOf(inline),
2253
+ noteAdd: { tier: result.tier, pin: result.pin, warning: noteCapacityWarning(result.tier, text, inline) },
2254
+ })
2255
+ }
2256
+ // 只改类型 / pin:把一条人格自述标成「核心身份」,就是这个接口
2257
+ if (req.method === 'POST' && route === '/note/meta') {
2258
+ const body = await readBody(req)
2259
+ try {
2260
+ const result = setNoteMeta(body?.index, { tier: body?.tier, pin: body?.pin })
2261
+ auditAction('note-meta', { index: body?.index, tier: result.tier, pin: result.pin }, req)
2262
+ return sendJson(res, 200, { ...stateOf(inline), noteMeta: result })
2263
+ } catch (error) {
2264
+ return sendJson(res, 400, { ok: false, error: error?.message ?? String(error) })
2265
+ }
2066
2266
  }
2067
2267
  // 改一条 / 删一条。0.11.x 之前只有「追加」和「整份清空」:
2068
2268
  // 注入里出现一句话说错了,只能整份清掉重写 —— 这就是「看得见、改不掉」。
2069
2269
  if (req.method === 'POST' && route === '/note/update') {
2070
2270
  const body = await readBody(req)
2071
2271
  try {
2072
- const result = updateNote(body?.index, body?.text, limitsOf(currentConfig(inline)))
2272
+ const result = updateNote(body?.index, body?.text, limitsOf(currentConfig(inline)), { tier: body?.tier, pin: body?.pin })
2073
2273
  auditAction('note-update', { index: body?.index, archived: result.archived }, req)
2074
2274
  return sendJson(res, 200, { ...stateOf(inline), noteUpdate: result })
2075
2275
  } catch (error) {
package/lib/store.js CHANGED
@@ -21,6 +21,49 @@ export const DEFAULT_MAX_ENTRIES = 40
21
21
  /** 单条自述的硬上限,防止一次写入撑爆。 */
22
22
  export const MAX_ENTRY_CHARS = 600
23
23
 
24
+ /**
25
+ * 自述分三型。混在一起装的时候,人格和「今晚推个提醒」抢同一份预算,
26
+ * 抢输的永远是人味那一条 —— 所以分开排:
27
+ * core 核心身份(人格、长期身份、基本原则),先装,独立配额
28
+ * stable 稳定偏好(语言、表达习惯、工作偏好),按 pin → 时间
29
+ * temp 临时自述(当前阶段的目标、近期变化),最后装,时间倒序
30
+ */
31
+ export const NOTE_TIERS = ['core', 'stable', 'temp']
32
+ /** 类型在 notes.md 里写成中文:人打开文件看得懂、改得动,不用查代码。 */
33
+ export const TIER_TAG = { core: '核心', stable: '稳定', temp: '临时' }
34
+ const TAG_TO_TIER = { 核心: 'core', 稳定: 'stable', 临时: 'temp' }
35
+ /** 没打标记的按「稳定」算:存量条目零迁移,行为跟改版前一模一样。 */
36
+ export const DEFAULT_TIER = 'stable'
37
+
38
+ /**
39
+ * 临时自述的保质期(天)。过了就不再每回合自动在场 —— 只留在库里,等人自己翻,
40
+ * 这样「这周在弄什么」不会一直跟人格抢预算。核心与稳定型永不到期。
41
+ */
42
+ export const DEFAULT_TEMP_TTL_DAYS = 14
43
+
44
+ /**
45
+ * 一条自述到期没有。
46
+ * date 只有「年月日」(没有时刻),按当天零点算;正好第 N 天还在保质期内,
47
+ * 免得写出「今天刚写、明天就过期」这种一日游。
48
+ */
49
+ export function isExpiredNote(entry, ttlDays = DEFAULT_TEMP_TTL_DAYS, now = Date.now()) {
50
+ if (normalizeTier(entry?.tier) !== 'temp') return false
51
+ const raw = typeof entry?.date === 'string' ? entry.date.trim() : ''
52
+ const at = Date.parse(`${raw}T00:00:00`)
53
+ if (!Number.isFinite(at)) return false
54
+ const days = Number.isFinite(Number(ttlDays)) ? Math.max(1, Math.round(Number(ttlDays))) : DEFAULT_TEMP_TTL_DAYS
55
+ return now - at > days * 86400000
56
+ }
57
+
58
+ /** 把外部传进来的 tier 收成合法值,认不出就是默认型(不抛错 —— 别为了个标记把写自述弄失败)。 */
59
+ export function normalizeTier(value) {
60
+ if (typeof value !== 'string') return DEFAULT_TIER
61
+ const raw = value.trim().toLowerCase()
62
+ if (NOTE_TIERS.includes(raw)) return raw
63
+ // 也认中文写法,方便人直接对着接口手搓
64
+ return TAG_TO_TIER[raw] ?? TAG_TO_TIER[value.trim()] ?? DEFAULT_TIER
65
+ }
66
+
24
67
  function dshHome() {
25
68
  const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
26
69
  return fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh')
@@ -49,17 +92,38 @@ export function today() {
49
92
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
50
93
  }
51
94
 
52
- /** 把 notes.md 解析成 [{ date, text }],忽略空行与注释。 */
95
+ /** 正文归一:回车换行压成空格、去掉首尾空白、截到硬上限。 */
96
+ function cleanEntryText(text) {
97
+ return String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_ENTRY_CHARS)
98
+ }
99
+
100
+ /**
101
+ * 把 notes.md 解析成 [{ date, text, tier, pin }],忽略空行与注释。
102
+ * 行格式:`- 2026-11-04 [核心*] 正文` —— 日期可省、标记可省、`*` 表示 pin。
103
+ * 标记认不出就原样留在正文里:宁可多显示一个怪括号,也不能吞掉人写的内容。
104
+ */
53
105
  export function parseNotes(raw) {
54
106
  const out = []
55
107
  for (const line of String(raw ?? '').split(/\r?\n/)) {
56
108
  const trimmed = line.trim()
57
109
  if (trimmed === '' || trimmed.startsWith('#')) continue
58
- const m = trimmed.match(/^[-*]\s+(\d{4}-\d{2}-\d{2})?\s*(.*)$/)
110
+ const m = trimmed.match(/^[-*]\s+(\d{4}-\d{2}-\d{2})?\s*(?:\[([^\]]{1,8})\]\s*)?(.*)$/)
59
111
  if (!m) continue
60
- const text = (m[2] ?? '').trim()
112
+ let text = (m[3] ?? '').trim()
113
+ let tier = DEFAULT_TIER
114
+ let pin = false
115
+ const tag = (m[2] ?? '').trim()
116
+ if (tag !== '') {
117
+ const hit = tag.match(/^(核心|稳定|临时)\s*(\*)?$/)
118
+ if (hit) {
119
+ tier = TAG_TO_TIER[hit[1]]
120
+ pin = hit[2] === '*'
121
+ } else {
122
+ text = `[${tag}] ${text}`.trim()
123
+ }
124
+ }
61
125
  if (text === '') continue
62
- out.push({ date: m[1] ?? '', text })
126
+ out.push({ date: m[1] ?? '', text, tier, pin })
63
127
  }
64
128
  return out
65
129
  }
@@ -71,9 +135,20 @@ export function readNotes() {
71
135
  return parseNotes(readFileSync(file, 'utf8'))
72
136
  }
73
137
 
138
+ /**
139
+ * 标记只在「非默认」时才写出来:稳定型且没 pin 的条目不带前缀,
140
+ * 存量 notes.md 不会因为升级平白多出一堆 `[稳定]`。
141
+ */
142
+ function tagOf(entry) {
143
+ const tier = normalizeTier(entry?.tier)
144
+ const pin = entry?.pin === true
145
+ if (tier === DEFAULT_TIER && !pin) return ''
146
+ return `[${TIER_TAG[tier]}${pin ? '*' : ''}] `
147
+ }
148
+
74
149
  /** 渲染成注入/展示用的文本(最新在后)。 */
75
150
  export function renderNotes(entries) {
76
- return entries.map((entry) => `- ${entry.date ? `${entry.date} ` : ''}${entry.text}`).join('\n')
151
+ return entries.map((entry) => `- ${entry.date ? `${entry.date} ` : ''}${tagOf(entry)}${entry.text}`).join('\n')
77
152
  }
78
153
 
79
154
  /** 写回当前生效的自述(整份重写,内容不超过上限)。 */
@@ -114,18 +189,19 @@ export function archive(entries) {
114
189
  }
115
190
 
116
191
  /** 追加一条自述并裁剪归档。返回 { kept, archived, notesFile }。 */
117
- export function appendNote(text, limits) {
118
- const clean = String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_ENTRY_CHARS)
192
+ export function appendNote(text, limits, meta = {}) {
193
+ const clean = cleanEntryText(text)
119
194
  if (clean === '') throw new Error('自述内容不能为空')
120
195
  const entries = readNotes()
121
- entries.push({ date: today(), text: clean })
196
+ const entry = { date: today(), text: clean, tier: normalizeTier(meta.tier), pin: meta.pin === true }
197
+ entries.push(entry)
122
198
  const { kept, overflow } = trim(entries, limits)
123
199
  // 先归档、再改 notes.md:反过来的话,归档一抛错(磁盘满、被占用),
124
200
  // 被挤出预算的那几条既已从 notes.md 消失、又没进归档,等于静默丢掉。
125
201
  // 万一 notes.md 写失败,最坏是归档里有重复,还能人工删 —— 丢内容没法补。
126
202
  const archivedFile = archive(overflow)
127
203
  writeNotes(kept)
128
- return { kept: kept.length, archived: overflow.length, archivedFile, notesFile: notesPath() }
204
+ return { kept: kept.length, archived: overflow.length, archivedFile, notesFile: notesPath(), tier: entry.tier, pin: entry.pin }
129
205
  }
130
206
 
131
207
  /**
@@ -133,21 +209,53 @@ export function appendNote(text, limits) {
133
209
  * 日期保留原样:改的是措辞,不是「今天又写了一条」。
134
210
  * 改长了照样按容量裁剪,被挤出去的进归档 —— 和追加走同一条规则。
135
211
  */
136
- export function updateNote(index, text, limits) {
212
+ export function updateNote(index, text, limits, meta = {}) {
137
213
  const entries = readNotes()
138
214
  const at = Number(index)
139
215
  if (!Number.isInteger(at) || at < 0 || at >= entries.length) {
140
216
  // 列表可能是几秒前拉的:给一句能照着做的话,而不是「非法参数」
141
217
  throw new Error('没有这一条了(可能刚被改过),刷新一下再试')
142
218
  }
143
- const clean = String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_ENTRY_CHARS)
219
+ const clean = cleanEntryText(text)
144
220
  if (clean === '') throw new Error('自述内容不能为空')
145
- if (clean === entries[at].text) return { kept: entries.length, archived: 0, unchanged: true, notesFile: notesPath() }
146
- entries[at] = { date: entries[at].date || today(), text: clean }
221
+ const prev = entries[at]
222
+ const next = {
223
+ date: prev.date || today(),
224
+ text: clean,
225
+ // meta 没传 = 保留原来那个型,别偷偷把一条核心身份降级成临时
226
+ tier: meta.tier === undefined ? normalizeTier(prev.tier) : normalizeTier(meta.tier),
227
+ pin: meta.pin === undefined ? prev.pin === true : meta.pin === true,
228
+ }
229
+ if (next.text === prev.text && next.tier === normalizeTier(prev.tier) && next.pin === (prev.pin === true)) {
230
+ return { kept: entries.length, archived: 0, unchanged: true, notesFile: notesPath() }
231
+ }
232
+ entries[at] = next
147
233
  const { kept, overflow } = trim(entries, limits)
148
234
  const archivedFile = archive(overflow)
149
235
  writeNotes(kept)
150
- return { kept: kept.length, archived: overflow.length, archivedFile, unchanged: false, notesFile: notesPath() }
236
+ return { kept: kept.length, archived: overflow.length, archivedFile, unchanged: false, notesFile: notesPath(), tier: next.tier, pin: next.pin }
237
+ }
238
+
239
+ /**
240
+ * 只改一条的类型 / pin,不动正文。设置页上「把这条设成核心身份」就是这个接口。
241
+ * 不走 trim:标记只多几个字,为这点长度把一条旧自述挤进归档不值当。
242
+ */
243
+ export function setNoteMeta(index, meta = {}) {
244
+ const entries = readNotes()
245
+ const at = Number(index)
246
+ if (!Number.isInteger(at) || at < 0 || at >= entries.length) {
247
+ throw new Error('没有这一条了(可能刚被改过),刷新一下再试')
248
+ }
249
+ const prev = entries[at]
250
+ const next = {
251
+ date: prev.date,
252
+ text: prev.text,
253
+ tier: meta.tier === undefined ? normalizeTier(prev.tier) : normalizeTier(meta.tier),
254
+ pin: meta.pin === undefined ? prev.pin === true : meta.pin === true,
255
+ }
256
+ entries[at] = next
257
+ writeNotes(entries)
258
+ return { kept: entries.length, tier: next.tier, pin: next.pin, notesFile: notesPath() }
151
259
  }
152
260
 
153
261
  /** 删一条:先归档再摘除,删掉的东西仍然找得回来(archive/YYYY-MM.md)。 */
@@ -163,6 +271,13 @@ export function deleteNote(index) {
163
271
  return { kept: entries.length, removed: dropped, archivedFile, notesFile: notesPath() }
164
272
  }
165
273
 
274
+ /** 三型各几条。 */
275
+ export function countTiers(entries) {
276
+ const out = { core: 0, stable: 0, temp: 0 }
277
+ for (const entry of entries) out[normalizeTier(entry.tier)] += 1
278
+ return out
279
+ }
280
+
166
281
  /** 统计:生效条数、字符数、归档文件数。 */
167
282
  export function stats() {
168
283
  const entries = readNotes()
@@ -176,6 +291,9 @@ export function stats() {
176
291
  return {
177
292
  entries: entries.length,
178
293
  chars: renderNotes(entries).length,
294
+ // 三型各几条:设置页要能解释「为什么我这条没进上下文」
295
+ tiers: countTiers(entries),
296
+ pinned: entries.filter((entry) => entry.pin === true).length,
179
297
  archiveFiles,
180
298
  notesFile: notesPath(),
181
299
  // 上次真正写进 notes.md 的时刻:条目里只存日期(YYYY-MM-DD),看不出「几小时前」,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-selfnote",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
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/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/notes-tier.mjs && node tests/client-state.mjs && node tests/client.mjs",
31
31
  "test:accept": "node tests/acceptance-runtime.mjs"
32
32
  },
33
33
  "engines": {