dsh-selfnote 0.13.1 → 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/README.md CHANGED
@@ -252,6 +252,29 @@ DSH 的会话文件是**逐条追写**的 zstd 序列(一次写入 = 一个 fr
252
252
  - **采纳 / 退役都要人点**:出现次数再多也不会自动采纳;退役的事实不会因为新候选出现就自己复活。
253
253
  - **留痕**:每次改状态记下时间、从哪改的(`via: settings`)、前后状态(`history`),事后可查。
254
254
 
255
+ ### 候选为空时,怎么知道它到底有没有在听
256
+
257
+ `GET /api/selfnote/capture` 是这块的诊断口(要带 `x-selfnote` 头,值见 `~/.dsh/selfnote/auth-token`):
258
+
259
+ ```json
260
+ {
261
+ "since": "2026-11-04T07:31:02.114Z",
262
+ "events": 160,
263
+ "types": { "user/message": 12, "job/registered": 40, "turn/ended": 108 },
264
+ "reasons": { "written": 1, "no-candidate": 11, "not-user-authored": 148 },
265
+ "candidates": 1,
266
+ "written": 1,
267
+ "lastType": "turn/ended",
268
+ "trace": [{ "at": "…", "sid": "session-", "type": "user/message", "seq": 91, "reason": "no-candidate" }]
269
+ }
270
+ ```
271
+
272
+ - `types` 是事件类型直方图:`user/message` 在涨,说明**你的话真的进来了**;不涨才是链路断了。
273
+ - `reasons` 是每一条的最后落点:`written` 写出了候选、`no-candidate` 过了人审但没命中触发词、`not-user-authored` 不是人打的、`no-session` 没有会话 id、`error` 采集抛错。
274
+ - `trace` 保留最近 40 条,带时间、会话前缀、seq 和落点。
275
+
276
+ 候选区空着有两种情况,这里能分开:**没在听**(`types` 里没有 `user/message`)和**在听但没话可采**(`no-candidate` 在涨)。这两个计数是 0.13.2 才补的 —— 上一版有两个计数器声明了从来没人加一,读出来永远 0,排查时反而把结论带偏。
277
+
255
278
  ## 注入预算
256
279
 
257
280
  每回合五段,合计 800 字(自述占最大一份):
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
 
@@ -81,16 +85,62 @@ export const name = 'selfnote'
81
85
  /** 声明式依赖:systemPrompt 注入,tools 写入,webServer 提供设置页 API。 */
82
86
  export const inject = ['systemPrompt', 'tools', 'webServer']
83
87
 
84
- /** 自动采集的现场计数器:只用于排查「事件到底有没有到」,不参与业务。 */
88
+ /**
89
+ * 自动采集的现场情况:只用于排查「事件到底有没有到、卡在哪一步」,不参与业务。
90
+ *
91
+ * 以前这里有两个 userAuthored / noSeq 计数器,声明了从来没人加一,读出来永远是 0 ——
92
+ * 排查时反而误导(看起来像「消息一条都没进来」)。现在换成两条真账:
93
+ * types(按事件类型计数)与 reasons(按落点计数),外加最近若干条轨迹。
94
+ */
95
+ const CAPTURE_TRACE_MAX = 40
96
+
85
97
  const captureDebug = {
98
+ since: null,
86
99
  events: 0,
87
- userAuthored: 0,
88
- noSeq: 0,
100
+ types: {},
101
+ reasons: {},
89
102
  candidates: 0,
90
103
  written: 0,
91
104
  lastType: null,
92
105
  lastKeys: null,
93
106
  lastError: null,
107
+ trace: [],
108
+ }
109
+
110
+ function bumpCounter(bucket, key) {
111
+ if (typeof key !== 'string' || key === '') return
112
+ bucket[key] = (bucket[key] ?? 0) + 1
113
+ }
114
+
115
+ /** 事件一进来先记账:类型直方图 + 最近几条。返回类型,供后面的落点记录复用。 */
116
+ function noteCaptureEvent(sessionId, event) {
117
+ captureDebug.events += 1
118
+ const type = typeof event?.type === 'string' && event.type !== '' ? event.type : 'unknown'
119
+ bumpCounter(captureDebug.types, type)
120
+ captureDebug.lastType = event?.type ?? null
121
+ captureDebug.lastKeys = event !== null && typeof event === 'object' ? Object.keys(event).slice(0, 12) : null
122
+ uploadTrace({
123
+ at: new Date().toISOString(),
124
+ sid: typeof sessionId === 'string' ? sessionId.slice(0, 8) : '',
125
+ type,
126
+ seq: Number.isSafeInteger(event?.seq) ? event.seq : null,
127
+ reason: null,
128
+ })
129
+ return type
130
+ }
131
+
132
+ /** 这一轮采集最后落在哪:写进去了 / 没候选 / 不是人说的 / 没 seq …… */
133
+ function noteCaptureOutcome(reason) {
134
+ bumpCounter(captureDebug.reasons, reason)
135
+ const last = captureDebug.trace[captureDebug.trace.length - 1]
136
+ if (last !== undefined) last.reason = reason
137
+ }
138
+
139
+ function uploadTrace(entry) {
140
+ captureDebug.trace.push(entry)
141
+ if (captureDebug.trace.length > CAPTURE_TRACE_MAX) {
142
+ captureDebug.trace.splice(0, captureDebug.trace.length - CAPTURE_TRACE_MAX)
143
+ }
94
144
  }
95
145
 
96
146
  const API_PREFIX = '/api/selfnote'
@@ -109,6 +159,8 @@ const DEFAULT_CONFIG = {
109
159
  halfLifeDays: DEFAULT_HALF_LIFE_DAYS,
110
160
  /** 自定义「写自述的时机」;留空就用工具说明里的默认判据。 */
111
161
  noteGuide: '',
162
+ /** 临时自述的保质期(天):过了就不自动进场,只留在库里。核心与稳定型不受影响。 */
163
+ tempTtlDays: DEFAULT_TEMP_TTL_DAYS,
112
164
  }
113
165
 
114
166
  const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的事,是记我自己的。
@@ -122,6 +174,7 @@ const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的
122
174
  写法:
123
175
  - 第一人称,一句话到三句话;写「我」的做法和判断,不复述用户的原话。
124
176
  - 一条写一件事。空话、套话、复述人格设定都不值得写。
177
+ - 写的时候用 tier 标明这是哪一型:core=核心身份(人格、长期身份、基本原则)、stable=稳定偏好(语言、表达习惯、工作偏好)、temp=临时自述(当前阶段的目标、近期变化)。不写按 stable 算。
125
178
  - 只追加,不能改写历史。写满后最旧的条目自动进归档,不会丢,只是不再每回合注入。
126
179
  - 这是明文保存在本机、并且每回合都会进上下文的文件:密码、密钥、验证码这类凭据别写。
127
180
  - 记用户本人(名字、习惯、处境、在意的点)是有用的,但那些更适合进事实库:用 memory_stage。
@@ -142,6 +195,15 @@ const PARAMETERS = {
142
195
  type: 'string',
143
196
  description: 'action=write 时的正文,一句话到三句话,第一人称。最多 600 字,超出截断。',
144
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
+ },
145
207
  },
146
208
  required: ['action'],
147
209
  }
@@ -169,6 +231,9 @@ function currentConfig(inline) {
169
231
  // 存的正好是老默认 → 跟着换代;用户自己改过的(不等于老默认)一个字都不动。
170
232
  heading: merged.heading === LEGACY_HEADING ? DEFAULT_CONFIG.heading : merged.heading,
171
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,
172
237
  }
173
238
  }
174
239
 
@@ -187,6 +252,14 @@ function limitsOf(cfg) {
187
252
  const BUDGET = { notes: 200, profile: 130, env: 320, facts: 110, habits: 40 }
188
253
  /** 五段合计,别在别处再手写一个数字。 */
189
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
190
263
  /** 自定义「写自述的时机」那行的上限(字)。 */
191
264
  export const GUIDE_LIMIT = 80
192
265
  /** 注入抬头那一行的上限(字)。它是每回合固定开销,不设限等于让人用一句话吃掉预算。 */
@@ -310,6 +383,129 @@ function fit(items, limit) {
310
383
  return fitDetailed(items, limit).text
311
384
  }
312
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
+
313
509
  let injectCache = { key: '', text: '' }
314
510
 
315
511
  /** 注入文本的缓存键:三份数据只要有一份变了,就重算。 */
@@ -325,8 +521,11 @@ function injectionKey(cfg) {
325
521
  }
326
522
  })
327
523
  const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, cfg.profileOverride.length, cfg.profileOverride].join('~')
524
+ // 临时自述会自己到期:文件一个字没改,跨过保质期那天注入也得重算。
525
+ // 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
526
+ const dayKey = 'd' + Math.floor(Date.now() / 86400000)
328
527
  // 半衰期也影响排序(facts / env 段按热度排),不改文件只改它时也得重算
329
- 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
330
529
  }
331
530
 
332
531
  /**
@@ -338,14 +537,18 @@ function assembleInjection(cfg, options = {}) {
338
537
  const segments = []
339
538
  const parts = []
340
539
 
341
- const noteFit = fitDetailed(
342
- readNotes()
343
- .slice()
344
- .reverse()
345
- .map((entry) => entry.text),
346
- BUDGET.notes,
347
- )
348
- 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
+ })
349
552
  if (noteFit.text !== '') parts.push(noteFit.text)
350
553
 
351
554
  // 自定义「写自述的时机」:工具说明在注册时就定型了,改一句要重启;
@@ -407,10 +610,8 @@ function assembleInjection(cfg, options = {}) {
407
610
  if (envLine !== '') parts.push(envLine)
408
611
 
409
612
  const ranked = factsForInjection(cfg)
410
- const factFit = fitDetailed(
411
- ranked.map((item) => item.text),
412
- BUDGET.facts,
413
- )
613
+ // 结论段也带门牌:装不下的不只是「没用」,是「查得到但现在不在场」
614
+ const factFit = factsSegment(ranked, BUDGET.facts)
414
615
  // 只有真的进了上下文才算「用到」;被检索到但没进上下文的不涨热度。
415
616
  // 画像段也是真贴进提示词的一行 —— 被画像吸收的条目同样要记账,
416
617
  // 否则「关于他:…」里天天在用的那句会一直显示「0 次 / 从没进过」。
@@ -420,10 +621,7 @@ function assembleInjection(cfg, options = {}) {
420
621
  const placements = {}
421
622
  for (const id of profile.sources ?? []) placements[id] = 'profile'
422
623
  for (const id of env.ids) placements[id] = 'env'
423
- let factChars = 0
424
- for (const item of ranked) {
425
- if (factChars + item.text.length > BUDGET.facts) continue
426
- factChars += item.text.length + 1
624
+ for (const item of factFit.kept) {
427
625
  factIds.push(item.id)
428
626
  placements[item.id] = 'facts'
429
627
  }
@@ -433,8 +631,9 @@ function assembleInjection(cfg, options = {}) {
433
631
  label: '结论',
434
632
  limit: BUDGET.facts,
435
633
  chars: factFit.text.length,
436
- kept: factFit.kept,
634
+ kept: factFit.kept.map((item) => item.text),
437
635
  skipped: factFit.skipped,
636
+ pointer: factFit.pointer ?? '',
438
637
  absorbed: factFit.text === '' ? (profile.sources ?? []).length : 0,
439
638
  })
440
639
  if (factFit.text !== '') parts.push(factFit.text)
@@ -1189,7 +1388,9 @@ function stateOfInner(inline) {
1189
1388
  entries: s.entries,
1190
1389
  chars: s.chars,
1191
1390
  archiveFiles: s.archiveFiles,
1192
- notes: readNotes(),
1391
+ // 每行带上「到期没有」:设置页要能把过期的临时自述标出来,而不是让人以为它这回合还在
1392
+ notes: readNotes().map((entry) => ({ ...entry, expired: isExpiredNote(entry, cfg.tempTtlDays) })),
1393
+ tempTtlDays: cfg.tempTtlDays,
1193
1394
  noteGuide: cfg.noteGuide,
1194
1395
  notesLastWrite: s.lastWrite,
1195
1396
  notesFile: s.notesFile,
@@ -1272,14 +1473,11 @@ function currentProfileText(cfg) {
1272
1473
  function budgetOf(inline) {
1273
1474
  const cfg = currentConfig(inline)
1274
1475
  if (!cfg.enabled) return { total: 0, limit: TOTAL_BUDGET, notes: 0, profile: 0, env: 0, facts: 0, habits: 0, enabled: false, caps: { ...BUDGET } }
1275
- const notes = fit(readNotes().slice().reverse().map((entry) => entry.text), BUDGET.notes)
1476
+ // 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上(门牌也要算进去)
1477
+ const notes = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays).text
1276
1478
  const profile = currentProfile(cfg)
1277
- // 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上
1278
1479
  const env = packEnvFair(envForInjection(cfg), BUDGET.env).text
1279
- const facts = fit(
1280
- factsForInjection(cfg).map((item) => item.text),
1281
- BUDGET.facts,
1282
- )
1480
+ const facts = factsSegment(factsForInjection(cfg), BUDGET.facts).text
1283
1481
  const habits = fit(readHabits().filter((habit) => habit.status === 'active').map((habit) => '· ' + habit.text), BUDGET.habits)
1284
1482
  return {
1285
1483
  enabled: true,
@@ -1295,11 +1493,34 @@ function budgetOf(inline) {
1295
1493
  }
1296
1494
  }
1297
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
+
1298
1516
  function renderText(args, value) {
1299
1517
  if (value?.error) return `⚠️ ${value.error}`
1300
1518
  if (value.action === 'write') {
1301
1519
  const tail = value.archived > 0 ? `,最旧的 ${value.archived} 条已归档到 ${value.archivedFile}` : ''
1302
- 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}`
1303
1524
  }
1304
1525
  if (value.action === 'read') {
1305
1526
  if (value.entries === 0) return '还没有任何自述。'
@@ -1769,15 +1990,18 @@ export function apply(ctx, config) {
1769
1990
  if (action === 'write') {
1770
1991
  const text = String(args?.text ?? '').trim()
1771
1992
  if (text === '') return { action, error: 'text 不能为空' }
1772
- const result = appendNote(text, limits)
1993
+ const result = appendNote(text, limits, { tier: args?.tier, pin: args?.pin })
1773
1994
  const after = stats()
1774
1995
  return {
1775
1996
  action,
1776
1997
  text,
1998
+ tier: result.tier,
1999
+ pin: result.pin,
1777
2000
  entries: after.entries,
1778
2001
  chars: after.chars,
1779
2002
  archived: result.archived,
1780
2003
  archivedFile: result.archivedFile || '',
2004
+ warning: noteCapacityWarning(result.tier, text, inline),
1781
2005
  }
1782
2006
  }
1783
2007
  if (action === 'read') {
@@ -2008,6 +2232,13 @@ export function apply(ctx, config) {
2008
2232
  }
2009
2233
  next.profileMaxChars = Math.round(value)
2010
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
+ }
2011
2242
  writeConfig(next)
2012
2243
  return sendJson(res, 200, stateOf(inline))
2013
2244
  }
@@ -2015,15 +2246,30 @@ export function apply(ctx, config) {
2015
2246
  const body = await readBody(req)
2016
2247
  const text = typeof body?.text === 'string' ? body.text.trim() : ''
2017
2248
  if (text === '') return sendJson(res, 400, { ok: false, error: '内容不能为空' })
2018
- appendNote(text, limitsOf(currentConfig(inline)))
2019
- 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
+ }
2020
2266
  }
2021
2267
  // 改一条 / 删一条。0.11.x 之前只有「追加」和「整份清空」:
2022
2268
  // 注入里出现一句话说错了,只能整份清掉重写 —— 这就是「看得见、改不掉」。
2023
2269
  if (req.method === 'POST' && route === '/note/update') {
2024
2270
  const body = await readBody(req)
2025
2271
  try {
2026
- 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 })
2027
2273
  auditAction('note-update', { index: body?.index, archived: result.archived }, req)
2028
2274
  return sendJson(res, 200, { ...stateOf(inline), noteUpdate: result })
2029
2275
  } catch (error) {
@@ -2209,12 +2455,14 @@ export function apply(ctx, config) {
2209
2455
  /* ---- 自动采集(P1-B):只发现,不确认 ---- */
2210
2456
  // session/event 是 fire-and-forget:这里绝不能把异常抛回宿主,也不能阻塞对话
2211
2457
  if (typeof ctx?.on === 'function') {
2458
+ captureDebug.since = new Date().toISOString()
2212
2459
  const offCapture = ctx.on('session/event', (session, event) => {
2213
- captureDebug.events += 1
2214
- captureDebug.lastType = event?.type ?? null
2215
- captureDebug.lastKeys = event !== null && typeof event === 'object' ? Object.keys(event).slice(0, 12) : null
2460
+ const type = noteCaptureEvent(session?.id, event)
2216
2461
  const sessionId = session?.id
2217
- if (typeof sessionId !== 'string' || sessionId === '') return
2462
+ if (typeof sessionId !== 'string' || sessionId === '') {
2463
+ noteCaptureOutcome('no-session')
2464
+ return
2465
+ }
2218
2466
  // 压缩发生了就记一笔:下一轮把这个会话用过的记忆再贴一遍
2219
2467
  noteCompaction(session, event)
2220
2468
  // 同一会话按到达顺序串行,避免 seq=11 先完成把游标推到 11、把 seq=10 挤掉
@@ -2229,17 +2477,20 @@ export function apply(ctx, config) {
2229
2477
  )
2230
2478
  .then((result) => {
2231
2479
  captureDebug.candidates += result?.plan?.created?.length ?? 0
2232
- if (result?.handled === true) {
2480
+ const written = result?.handled === true
2481
+ if (written) {
2233
2482
  captureDebug.written += 1
2234
2483
  const plan = result.plan ?? {}
2235
2484
  ctx.logger?.info?.(
2236
2485
  `selfnote: 采集到 ${plan.created?.length ?? 0} 条候选(合并 ${plan.merged?.length ?? 0},冲突 ${plan.conflicts?.length ?? 0})`,
2237
2486
  )
2238
2487
  }
2488
+ noteCaptureOutcome(written ? 'written' : String(result?.reason ?? type + ':unknown'))
2239
2489
  })
2240
2490
  .catch((error) => {
2241
2491
  // 采集坏掉不许影响对话
2242
2492
  captureDebug.lastError = String(error?.message ?? error)
2493
+ noteCaptureOutcome('error')
2243
2494
  ctx.logger?.warn?.('selfnote: 自动采集失败 ' + String(error?.message ?? error))
2244
2495
  })
2245
2496
  })
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.1",
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/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": {