dsh-selfnote 0.13.2 → 0.14.0

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/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/lib/transfer.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * 索引不导出:它记的是磁盘路径与 mtime,搬过去必然过期,导入后重建一次就行。
10
10
  */
11
11
 
12
- export const BUNDLE_VERSION = 1
12
+ export const BUNDLE_VERSION = 2
13
13
  export const BUNDLE_APP = 'dsh-selfnote'
14
14
 
15
15
  const CONFIG_FIELDS = [
@@ -22,6 +22,10 @@ const CONFIG_FIELDS = [
22
22
  'profileMaxChars',
23
23
  'halfLifeDays',
24
24
  'noteGuide',
25
+ // v2 加的两个:人格开关与临时自述保质期。它们决定「搬过去之后样子一样不一样」,
26
+ // 不带的话,同一份导出在另一台机器上会长得不一样
27
+ 'personaEnabled',
28
+ 'tempTtlDays',
25
29
  ]
26
30
 
27
31
  function isObject(value) {
@@ -32,8 +36,16 @@ function textOf(value) {
32
36
  return typeof value === 'string' ? value.trim() : ''
33
37
  }
34
38
 
39
+ /** 人格三段:只有非空的才算「带了人格」,全空等同于没带。 */
40
+ function normalizePersona(value) {
41
+ if (!isObject(value)) return null
42
+ const out = { l0: textOf(value.l0), l1: textOf(value.l1), l2: textOf(value.l2) }
43
+ if (out.l0 === '' && out.l1 === '' && out.l2 === '') return null
44
+ return out
45
+ }
46
+
35
47
  /** 导出:只挑能带走的东西,配置只挑用户可调的那几项。 */
36
- export function buildBundle({ notes = [], facts = [], habits = [], config = {}, now = new Date() } = {}) {
48
+ export function buildBundle({ notes = [], facts = [], habits = [], config = {}, persona = null, now = new Date() } = {}) {
37
49
  const safeConfig = {}
38
50
  for (const field of CONFIG_FIELDS) {
39
51
  if (config[field] !== undefined) safeConfig[field] = config[field]
@@ -46,6 +58,8 @@ export function buildBundle({ notes = [], facts = [], habits = [], config = {},
46
58
  notes: notes.map((entry) => ({ date: entry.date ?? '', text: entry.text ?? '' })),
47
59
  facts: facts.map((fact) => ({ ...fact })),
48
60
  habits: habits.map((habit) => ({ ...habit })),
61
+ // 人格跟着走(v2);纠偏候选不跟 —— 它是「这台机器上攒下的证据」,搬过去就失了上下文
62
+ persona: normalizePersona(persona),
49
63
  config: safeConfig,
50
64
  }
51
65
  }
@@ -60,9 +74,33 @@ export function validateBundle(bundle) {
60
74
  for (const key of ['notes', 'facts', 'habits']) {
61
75
  if (bundle[key] !== undefined && !Array.isArray(bundle[key])) return `${key} 不是数组`
62
76
  }
77
+ if (bundle.persona !== undefined && bundle.persona !== null) {
78
+ if (!isObject(bundle.persona)) return 'persona 不是对象'
79
+ for (const field of ['l0', 'l1', 'l2']) {
80
+ const value = bundle.persona[field]
81
+ if (value !== undefined && typeof value !== 'string') return `persona.${field} 不是字符串`
82
+ }
83
+ }
63
84
  return null
64
85
  }
65
86
 
87
+ /**
88
+ * 人格怎么合并:**永远不覆盖**。
89
+ * - 包里没带、或者全空 → none
90
+ * - 本机还没有人格 → adopt(用导入的)
91
+ * - 本机有且一模一样 → same(不写盘,省一次无意义的原子写)
92
+ * - 本机有但不一样 → conflict,调用方把它落到 persona.imported-<ts>.md,
93
+ * 绝不悄悄盖掉本机那份 —— 人格是人一条条确认出来的,不该被一次导入换掉。
94
+ */
95
+ export function mergePersona(incoming, current) {
96
+ const next = normalizePersona(incoming)
97
+ if (next === null) return { action: 'none' }
98
+ const now = normalizePersona(current)
99
+ if (now === null) return { action: 'adopt', persona: next }
100
+ if (now.l0 === next.l0 && now.l1 === next.l1 && now.l2 === next.l2) return { action: 'same' }
101
+ return { action: 'conflict', persona: next }
102
+ }
103
+
66
104
  /**
67
105
  * 合并:返回「合并后的三份数据 + 账目」。
68
106
  * 纯函数,不写盘 —— 写盘由调用方做,失败才好回滚。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-selfnote",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
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/persona.mjs && node tests/corrections.mjs && node tests/client-state.mjs && node tests/client.mjs",
31
31
  "test:accept": "node tests/acceptance-runtime.mjs"
32
32
  },
33
33
  "engines": {