dsh-selfnote 0.14.1 → 0.14.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/explain.js ADDED
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { normalizeTier } from './store.js'
3
+
4
+ export const SKIP_REASONS = Object.freeze(['budget', 'expired', 'scope', 'duplicate'])
5
+ export const SKIP_PRIORITY = Object.freeze(['scope', 'expired', 'duplicate', 'budget'])
6
+ export const CORE_PREVIEW = '素材层,不注入'
7
+ export const PREVIEW_MAX_CHARS = 60
8
+
9
+ // 六步规范(m05194):纯函数,不含 Date / 随机 / locale。
10
+ // ① 非字符串一律转字符串 ② NFKC 兼容等价 + 全角半角 ③ CRLF / 裸 CR → LF
11
+ // ④ 去首尾空白(连带 BOM) ⑤ 连续空白折叠为一个空格 ⑥ 返回稳定文本
12
+ export function normalizeForId(text) {
13
+ const s1 = String(text ?? '')
14
+ const s2 = s1.normalize('NFKC')
15
+ const s3 = s2.replace(/\r\n?/g, '\n')
16
+ const s4 = s3.trim()
17
+ const s5 = s4.replace(/\s+/gu, ' ')
18
+ return s5
19
+ }
20
+
21
+ export function contentId(text) {
22
+ return createHash('sha1').update(normalizeForId(text), 'utf8').digest('hex').slice(0, 12)
23
+ }
24
+
25
+ export function pickReason(flags = {}) {
26
+ for (const reason of SKIP_PRIORITY) {
27
+ if (flags[reason] === true) return reason
28
+ }
29
+ return null
30
+ }
31
+
32
+ export function skippedId(segmentKey, text) {
33
+ return String(segmentKey) + ':' + contentId(text)
34
+ }
35
+
36
+ // 只用于「展示预览」:按字素簇截,不切 surrogate pair / 组合字符 / ZWJ 序列。
37
+ // 与六段预算的 countChars()(码点)是两把尺子,不许互相回灌。
38
+ const SEGMENTER = new Intl.Segmenter('und', { granularity: 'grapheme' })
39
+
40
+ export function previewOf(text, maxGraphemes = PREVIEW_MAX_CHARS) {
41
+ const s = String(text ?? '')
42
+ const parts = Array.from(SEGMENTER.segment(s), (part) => part.segment)
43
+ if (parts.length <= maxGraphemes) return s
44
+ return parts.slice(0, maxGraphemes).join('') + '…'
45
+ }
46
+
47
+ // 只读取已有打包结果做「事后分类」:绝不重新打包、不改候选集合 / 排序 / 预算 / 文本。
48
+ // sources 与 skipped 同源、等长、逐位平行(由 packNotes() 在构造 skipped 的同一次调用里产出)。
49
+ // 旧版这里恒给 budget:true,pickReason() 就永远有结果、下面的 if 永远进不去 else ——
50
+ // 一个不可达分支。现在按来源如实展开:认不出的(含缺失)才回落 budget。
51
+ function flagsFor(source) {
52
+ return {
53
+ scope: source === 'scope',
54
+ expired: source === 'expired',
55
+ duplicate: source === 'duplicate',
56
+ budget: source === 'budget' || !SKIP_REASONS.includes(source),
57
+ }
58
+ }
59
+
60
+ export function describeSkipped({ key, skipped = [], sources = [], notes = [] } = {}) {
61
+ const list = Array.isArray(skipped) ? skipped : []
62
+ // 来源数组必须同源等长;缺一项就整批保守降级,宁可少报原因也不报错原因。
63
+ // 降级在这里显式做,调用方不必靠「故意传空数组触发长度不齐」这种暗号。
64
+ const src = Array.isArray(sources) && sources.length === list.length ? sources : null
65
+ const out = list.map((raw, i) => {
66
+ const text = String(raw ?? '')
67
+ return { id: skippedId(key, text), preview: previewOf(text), reason: pickReason(flagsFor(src === null ? null : (src[i] ?? null))) }
68
+ })
69
+ if (key === 'notes') {
70
+ const coreNotes = Array.isArray(notes) ? notes : []
71
+ for (const entry of coreNotes.filter((e) => normalizeTier(e?.tier) === 'core')) {
72
+ const text = String(entry?.text ?? '')
73
+ out.push({ id: skippedId(key, text), preview: CORE_PREVIEW, reason: 'budget' })
74
+ }
75
+ }
76
+ return out
77
+ }
@@ -326,9 +326,9 @@ export function ensureIndex(options = {}) {
326
326
  export function queryIndex(index, terms) {
327
327
  const buckets = []
328
328
  for (const term of terms) {
329
- const hits = index.postings?.[term]
330
- if (hits === undefined) buckets.push([])
331
- else buckets.push(hits)
329
+ const postings = index.postings ?? {}
330
+ const hits = Object.hasOwn(postings, term) && Array.isArray(postings[term]) ? postings[term] : []
331
+ buckets.push(hits)
332
332
  }
333
333
  if (buckets.length === 0) return { ids: [], degraded: false }
334
334
 
@@ -365,7 +365,7 @@ export function indexStats(sessions = listSessions()) {
365
365
  const disk = sessions.length
366
366
  // 优先走门牌文件:几十 KB,免得为了报数字去解析 10 MB 索引
367
367
  const meta = readIndexMeta()
368
- if (meta !== null) {
368
+ if (meta !== null && existsSync(indexPath())) {
369
369
  const state = freshnessOfSessions(meta.sessions ?? {}, sessions)
370
370
  return {
371
371
  present: true,
package/lib/index.js CHANGED
@@ -54,6 +54,7 @@ import { discardByIds, listCorrections, readEvents, readWatermark } from './corr
54
54
  import { commitPersona, lockState, recover, runSerial } from './persona-tx.js'
55
55
 
56
56
  import { randomBytes, timingSafeEqual } from 'node:crypto'
57
+ import { describeSkipped } from './explain.js'
57
58
 
58
59
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
59
60
  import { spawn } from 'node:child_process'
@@ -93,7 +94,7 @@ import { buildProfile } from './profile.js'
93
94
  import { writeAtomic } from './atomic.js'
94
95
  import { cursorPath, enqueue, isDelegatedSession, readCursor } from './capture.js'
95
96
  import { searchWarm, warmStats, writeSummary } from './warm.js'
96
- import { buildBundle, mergeBundle, mergePersona, validateBundle } from './transfer.js'
97
+ import { buildBundle, mergeBundle, mergeConfig, mergePersona, validateBundle } from './transfer.js'
97
98
  import { handleEvent } from './capture-run.js'
98
99
 
99
100
  export const name = 'selfnote'
@@ -239,7 +240,7 @@ function currentConfig(inline) {
239
240
  maxEntries: Number.isFinite(maxEntries) && maxEntries > 0 ? Math.min(maxEntries, 200) : DEFAULT_MAX_ENTRIES,
240
241
  profileEnabled: merged.profileEnabled !== false,
241
242
  personaEnabled: merged.personaEnabled !== false,
242
- profileOverride: typeof merged.profileOverride === 'string' ? merged.profileOverride.trim().slice(0, 240) : '',
243
+ profileOverride: typeof merged.profileOverride === 'string' ? [...merged.profileOverride.trim()].slice(0, 240).join('') : '',
243
244
  profileMaxChars: Number.isFinite(Number(merged.profileMaxChars))
244
245
  ? Math.max(20, Math.min(Number(merged.profileMaxChars), 120))
245
246
  : DEFAULT_CONFIG.profileMaxChars,
@@ -249,7 +250,7 @@ function currentConfig(inline) {
249
250
  // 老版本把这句 28 字的抬头写进了 config.json。它只干一件事,现在默认 6 字。
250
251
  // 存的正好是老默认 → 跟着换代;用户自己改过的(不等于老默认)一个字都不动。
251
252
  heading: merged.heading === LEGACY_HEADING ? DEFAULT_CONFIG.heading : merged.heading,
252
- noteGuide: typeof merged.noteGuide === 'string' ? merged.noteGuide.trim().slice(0, GUIDE_LIMIT) : '',
253
+ noteGuide: typeof merged.noteGuide === 'string' ? [...merged.noteGuide.trim()].slice(0, GUIDE_LIMIT).join('') : '',
253
254
  tempTtlDays: Number.isFinite(Number(merged.tempTtlDays))
254
255
  ? Math.max(1, Math.min(Math.round(Number(merged.tempTtlDays)), 90))
255
256
  : DEFAULT_TEMP_TTL_DAYS,
@@ -299,11 +300,11 @@ function packEnv(items, limit) {
299
300
  for (const item of items) {
300
301
  const body = String(item.text ?? '').trim()
301
302
  if (body === '') continue
302
- if (used + body.length > limit) {
303
+ if (used + countChars(body) > limit) {
303
304
  skipped.push(body)
304
305
  continue
305
306
  }
306
- used += body.length + 1
307
+ used += countChars(body) + 1
307
308
  kept.push(body)
308
309
  ids.push(item.id)
309
310
  text = text === '' ? body : `${text};${body}`
@@ -354,7 +355,7 @@ function packEnvFair(items, limit, spotlight = new Set()) {
354
355
  let plan = arrange(reserve)
355
356
  const head = plan.warm[0] ?? plan.cold[0]
356
357
  if (head !== undefined) {
357
- const floor = String(head.text ?? '').trim().length + 1
358
+ const floor = countChars(String(head.text ?? '').trim()) + 1
358
359
  if (floor > reserve) {
359
360
  reserve = Math.min(Math.floor(cap / 2), floor)
360
361
  plan = arrange(reserve)
@@ -416,7 +417,7 @@ function fit(items, limit) {
416
417
  * 身份该由人格段(L0/L1)说,两处都说一遍等于让模型看两版自己。
417
418
  * 存量 core 一条不删,只是不进上下文;条数靠门牌告诉模型「有这么些素材」。
418
419
  */
419
- function packNotes(entries, limit, ttl) {
420
+ export function packNotes(entries, limit, ttl) {
420
421
  const ttlDays = Number.isFinite(Number(ttl)) ? Number(ttl) : DEFAULT_TEMP_TTL_DAYS
421
422
  const core = []
422
423
  const stable = []
@@ -449,10 +450,18 @@ function packNotes(entries, limit, ttl) {
449
450
  // 过期的也算「没进上下文」:它们还在库里,模型得靠门牌知道有这么几条,
450
451
  // 所以并进 skipped 一起报数 —— 少报就是骗人。
451
452
  const expiredTexts = expired.map((entry) => entry.text)
453
+ const stableSkipped = stableFit.skipped
454
+ const tempSkipped = tempFit.skipped
452
455
  return {
453
456
  text: parts.join('\n'),
454
457
  kept: [...stableFit.kept, ...tempFit.kept],
455
- skipped: [...stableFit.skipped, ...tempFit.skipped, ...expiredTexts],
458
+ skipped: [...stableSkipped, ...tempSkipped, ...expiredTexts],
459
+ // 与 skipped 同一次构造、逐位平行:只给解释层读,不参与筛选 / 排序 / 预算 / 文本
460
+ skippedSources: [
461
+ ...stableSkipped.map(() => 'budget'),
462
+ ...tempSkipped.map(() => 'budget'),
463
+ ...expiredTexts.map(() => 'expired'),
464
+ ],
456
465
  // 每型的账:设置页要能指着说「稳定 3 条全进了,临时 2 条被挤掉」
457
466
  tiers: {
458
467
  // 核心不进注入,所以 kept 恒 0、limit 恒 0:读数上「0 / 0」比「3 / 0」更不容易被误读
@@ -491,8 +500,8 @@ function notesSegment(entries, limit, ttl) {
491
500
  let pointer = ''
492
501
  for (let pass = 0; pass < 3; pass += 1) {
493
502
  const next = `另有 ${current.skipped.length} 条自述没装进来(self_note read)`
494
- if (next.length + 1 >= room) break
495
- const refit = packNotes(entries, Math.max(0, room - next.length - 1), ttl)
503
+ if (countChars(next) + 1 >= room) break
504
+ const refit = packNotes(entries, Math.max(0, room - countChars(next) - 1), ttl)
496
505
  const converged = refit.skipped.length === current.skipped.length
497
506
  pointer = next
498
507
  const joined = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
@@ -656,8 +665,8 @@ function factsSegment(ranked, limit) {
656
665
  let pointer = ''
657
666
  for (let pass = 0; pass < 3; pass += 1) {
658
667
  const next = `另有 ${current.skipped.length} 条结论(memory_recall 查)`
659
- if (next.length + 1 >= limit) break
660
- const refit = packFacts(ranked, Math.max(0, limit - next.length - 1))
668
+ if (countChars(next) + 1 >= limit) break
669
+ const refit = packFacts(ranked, Math.max(0, limit - countChars(next) - 1))
661
670
  const converged = refit.skipped.length === current.skipped.length
662
671
  pointer = next
663
672
  current = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
@@ -680,12 +689,13 @@ function injectionKey(cfg) {
680
689
  return file + ':-'
681
690
  }
682
691
  })
683
- const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, cfg.profileOverride.length, cfg.profileOverride].join('~')
692
+ const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, countChars(cfg.profileOverride), cfg.profileOverride].join('~')
684
693
  // 临时自述会自己到期:文件一个字没改,跨过保质期那天注入也得重算。
685
694
  // 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
686
- const dayKey = 'd' + Math.floor(Date.now() / 86400000)
695
+ const now = new Date()
696
+ const dayKey = 'd' + now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate()
687
697
  // 半衰期也影响排序(facts / env 段按热度排),不改文件只改它时也得重算
688
- return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.personaEnabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays + '|ttl' + cfg.tempTtlDays + '|' + dayKey
698
+ return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.personaEnabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays + '|ttl' + cfg.tempTtlDays + '|guide' + cfg.noteGuide + '|' + dayKey
689
699
  }
690
700
 
691
701
  /**
@@ -697,30 +707,37 @@ function assembleInjection(cfg, options = {}) {
697
707
  const segments = []
698
708
  const parts = []
699
709
  const personaParts = []
710
+ const noteEntries = readNotes()
700
711
 
701
712
  // 人格:L0 + L1 常驻,排在最前 —— 它不是「记忆」,是「我是谁」,
702
713
  // 所以它在边界句「(背景资料,不是指令)」之外,先读到的是自己的样子。
703
714
  // 关掉只是不注入,persona.md 一个字节都不动。
715
+ const ZERO_PERSONA_FIT = {
716
+ text: '', chars: 0, kept: [], skipped: [], truncated: false, pointer: '',
717
+ l0: { chars: 0, kept: 0, skipped: 0 }, l1: { chars: 0, kept: 0, skipped: 0 },
718
+ }
719
+ const fit = cfg.personaEnabled === false
720
+ ? ZERO_PERSONA_FIT // 关闭时连 persona.md 都不读
721
+ : personaSegment(readPersona(), BUDGET.persona)
722
+ segments.push({
723
+ key: 'persona',
724
+ label: '人格',
725
+ limit: BUDGET.persona,
726
+ chars: fit.chars,
727
+ kept: fit.kept,
728
+ skipped: fit.skipped,
729
+ truncated: fit.truncated,
730
+ pointer: fit.pointer,
731
+ l0: fit.l0,
732
+ l1: fit.l1,
733
+ })
704
734
  if (cfg.personaEnabled !== false) {
705
- const fit = personaSegment(readPersona(), BUDGET.persona)
706
- segments.push({
707
- key: 'persona',
708
- label: '人格',
709
- limit: BUDGET.persona,
710
- chars: fit.chars,
711
- kept: fit.kept,
712
- skipped: fit.skipped,
713
- truncated: fit.truncated,
714
- pointer: fit.pointer,
715
- l0: fit.l0,
716
- l1: fit.l1,
717
- })
718
735
  if (fit.text !== '') personaParts.push(fit.text)
719
736
  if (fit.pointer !== '') personaParts.push(fit.pointer)
720
737
  }
721
738
 
722
739
  // 自述:三型分账 + 门牌。装不下的那几条,模型必须知道存在,否则它会把「没看见」当成「没有」。
723
- const noteFit = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays)
740
+ const noteFit = notesSegment(noteEntries, BUDGET.notes, cfg.tempTtlDays)
724
741
  segments.push({
725
742
  key: 'notes',
726
743
  label: '自述',
@@ -740,7 +757,7 @@ function assembleInjection(cfg, options = {}) {
740
757
  key: 'guide',
741
758
  label: '写自述的时机',
742
759
  limit: GUIDE_LIMIT,
743
- chars: guideText.length,
760
+ chars: countChars(guideText),
744
761
  kept: guideText === '' ? [] : [guideText],
745
762
  skipped: [],
746
763
  })
@@ -755,7 +772,7 @@ function assembleInjection(cfg, options = {}) {
755
772
  key: 'profile',
756
773
  label: '画像',
757
774
  limit: BUDGET.profile,
758
- chars: profileText.length,
775
+ chars: countChars(profileText),
759
776
  kept: profileText === '' ? [] : [profileText],
760
777
  skipped: [],
761
778
  })
@@ -771,7 +788,7 @@ function assembleInjection(cfg, options = {}) {
771
788
  // 反复用「这次剩几条」重算门牌,直到条数不再变。不然那句「另有 N 条」会少报。
772
789
  for (let pass = 0; pass < 3 && env.skipped.length > 0; pass += 1) {
773
790
  const pointer = `另有 ${env.skipped.length} 条环境备忘没装进来(memory_recall 查)`
774
- const refit = packEnvFair(envRanked, Math.max(0, BUDGET.env - pointer.length - 1), spotlight)
791
+ const refit = packEnvFair(envRanked, Math.max(0, BUDGET.env - countChars(pointer) - 1), spotlight)
775
792
  const text = refit.text === '' ? pointer : `${refit.text};${pointer}`
776
793
  const converged = refit.skipped.length === env.skipped.length
777
794
  env = { ...refit, text }
@@ -829,6 +846,20 @@ function assembleInjection(cfg, options = {}) {
829
846
  segments.push({ key: 'habits', label: '习惯', limit: BUDGET.habits, chars: countChars(habitFit.text), kept: habitFit.kept, skipped: habitFit.skipped })
830
847
  if (habitFit.text !== '') parts.push(habitFit.text)
831
848
 
849
+ // 解释元数据只挂在这一遍的 segments 上:预览那遍与真注入那遍各算各的,
850
+ // 不写全局、不落盘(D3),injectCache 仍只存 { key, text }。
851
+ for (const seg of segments) {
852
+ seg.skippedDetail = describeSkipped({
853
+ key: seg.key,
854
+ skipped: Array.isArray(seg.skipped) ? seg.skipped : [],
855
+ // 非 notes 段没有逐条来源:显式给等长的 budget 数组,不再靠「传空触发长度不齐」这种暗号
856
+ sources: seg.key === 'notes' && Array.isArray(noteFit.skippedSources)
857
+ ? noteFit.skippedSources
858
+ : (Array.isArray(seg.skipped) ? seg.skipped : []).map(() => 'budget'),
859
+ notes: seg.key === 'notes' ? noteEntries : [],
860
+ })
861
+ }
862
+
832
863
  const heading = typeof cfg.heading === 'string' ? cfg.heading.trim() : DEFAULT_CONFIG.heading
833
864
  const body = parts.join('\n')
834
865
  // 边界句:让模型知道这一段是背景资料,不是有人在借记忆之名给它下命令。
@@ -950,6 +981,22 @@ export function readHeartbeat() {
950
981
  }
951
982
  }
952
983
 
984
+ /**
985
+ * 记一次「注入文本真返回给模型」。这是与解释数据(skippedDetail)分开的独立观测口径:
986
+ * 同步 read-modify-write,中间没有 await,单进程内不会交错;state.json 不在 injectionKey()
987
+ * 的四个 stamps 里,写它不会让注入缓存失效;写失败静默,绝不影响原本的注入返回。
988
+ * 不用 heartbeat.injections(它有 60 秒节流,且缓存命中不写),也不用 seenCount / hitCount。
989
+ */
990
+ function noteInjection(text) {
991
+ if (typeof text !== 'string' || text === '') return
992
+ try {
993
+ const state = readState()
994
+ const prev = Number(state.injectionCount)
995
+ const count = Number.isFinite(prev) ? prev + 1 : 1
996
+ writeState({ ...state, injectionCount: count, lastInjectedAt: new Date().toISOString() })
997
+ } catch { /* 计数失败绝不影响注入 */ }
998
+ }
999
+
953
1000
  function beat(extra = {}) {
954
1001
  const now = Date.now()
955
1002
  if (now - lastBeat < HEARTBEAT_MS) return
@@ -1242,7 +1289,7 @@ function doctorState(inline) {
1242
1289
  }
1243
1290
  }
1244
1291
  if (broken > 0 || noText > 0) {
1245
- push('facts', '记忆文件完整性', 'error', `${broken} 行解析不出来、${noText} 条没有正文 —— 这些记忆读不到`, '从 .bak 恢复或手工修')
1292
+ push('facts', '记忆文件完整性', 'error', `${broken} 行解析不出来、${noText} 条没有正文 —— 这些记忆读不到`, '先复制一份 facts.jsonl,再手工删掉那几行(坏行只影响它自己)')
1246
1293
  } else {
1247
1294
  push('facts', '记忆文件完整性', 'ok', `${lines.length} 行全部可解析`)
1248
1295
  }
@@ -1443,6 +1490,7 @@ function buildInjection(inline, ctx) {
1443
1490
  const text = composeInjection(inline, { sessionId, spotlight })
1444
1491
  // 补偿只补一次:这一轮真贴进去了,才把标记清掉(失败的话下一轮还有机会)
1445
1492
  if (spotlight.length > 0) compactionPending.delete(sessionId)
1493
+ noteInjection(text)
1446
1494
  return text
1447
1495
  } catch (error) {
1448
1496
  return ''
@@ -1738,6 +1786,8 @@ function stateOfInner(inline) {
1738
1786
  const builtNow = cfg.enabled ? assembleInjection(cfg) : null
1739
1787
  // facts.jsonl 也只读一次(下面候选/已采纳/退役/归属四张卡片共用)
1740
1788
  const factsNow = listFacts()
1789
+ // 计数是另一条观测口径:只读一次计数文件,不参与注入解释数据的组装
1790
+ const injectionStats = readState()
1741
1791
  return {
1742
1792
  ok: true,
1743
1793
  enabled: cfg.enabled,
@@ -1767,6 +1817,8 @@ function stateOfInner(inline) {
1767
1817
  corrections: correctionsStateOf(),
1768
1818
  budget: budgetOf(inline),
1769
1819
  injection: injectionState(inline, builtNow),
1820
+ injectionCount: Number.isFinite(Number(injectionStats.injectionCount)) ? Number(injectionStats.injectionCount) : 0,
1821
+ lastInjectedAt: typeof injectionStats.lastInjectedAt === 'string' ? injectionStats.lastInjectedAt : '',
1770
1822
  attribution: attributionState(inline, builtNow, factsNow),
1771
1823
  profile: profileState(inline),
1772
1824
  facts: {
@@ -1800,7 +1852,7 @@ function currentProfile(cfg) {
1800
1852
  const config = cfg ?? currentConfig()
1801
1853
  if (config.profileEnabled !== true) return { text: '', parts: [], conflicts: [], sources: [], chars: 0, manual: false }
1802
1854
  if (config.profileOverride !== '') {
1803
- return { text: config.profileOverride, parts: [], conflicts: [], sources: [], chars: config.profileOverride.length, manual: true }
1855
+ return { text: config.profileOverride, parts: [], conflicts: [], sources: [], chars: countChars(config.profileOverride), manual: true }
1804
1856
  }
1805
1857
  const built = buildProfile(listFacts(), { maxChars: config.profileMaxChars })
1806
1858
  return { ...built, manual: false }
@@ -1867,22 +1919,42 @@ const TIER_WORD = { core: '核心身份', stable: '稳定偏好', temp: '临时
1867
1919
  function noteCapacityWarning(tier, text, inline) {
1868
1920
  const cfg = currentConfig(inline)
1869
1921
  if (cfg.enabled === false) return '注入已关闭,这条暂时不会进上下文。'
1870
- const key = normalizeTier(tier)
1871
- const body = String(text ?? '')
1922
+ const entries = readNotes()
1923
+ // 调用点在 appendNote 之后:最后一条就是刚写的。旧版在这里又凭空补一条,
1924
+ // 等于把新条试装了两遍、额度算得偏紧;现在直接用落盘后的真实列表。
1925
+ const last = entries.at(-1)
1926
+ const key = normalizeTier(last?.tier ?? tier)
1927
+ const body = String(last?.text ?? text ?? '')
1872
1928
  // 核心是素材,不进注入:这时候说「超了额度」是骗人,得直接说清楚它去哪儿了
1873
1929
  if (key === 'core') {
1874
1930
  return '核心身份不再参与注入(它是提炼人格的素材)—— 想让它每回合生效,写进人格段:self_note action:"persona"。'
1875
1931
  }
1876
- // 拿真实额度试装一次:把这条当成已经写进去,看它自己进不进得来。
1932
+ // 拿真实额度试装一次:看这条自己进不进得来。
1877
1933
  // 光比「整段 200 字」不够 —— 稳定先占一份、临时只留 1/4 保底,
1878
1934
  // 一条 110 字的临时自述会顺利通过 200 字的检查,然后每回合被静默跳过。
1879
- const probe = packNotes([...readNotes(), { tier: key, text: body }], BUDGET.notes, cfg.tempTtlDays)
1880
- if (probe.kept.includes(body)) return ''
1881
- const info = probe.tiers[key]
1882
- if (body.length > info.limit) {
1883
- return `这条 ${body.length} 字,超过${TIER_WORD[key]}段这轮实际能用的 ${info.limit} 字(整段 ${BUDGET.notes} 字,别的型先占了位),模型每回合都看不到它 —— 要么写短,要么拆成几条。`
1935
+ const probe = packNotes(entries, BUDGET.notes, cfg.tempTtlDays)
1936
+ if (!probe.kept.includes(body)) {
1937
+ const info = probe.tiers[key]
1938
+ if (countChars(body) > info.limit) {
1939
+ return `这条 ${countChars(body)} 字,超过${TIER_WORD[key]}段这轮实际能用的 ${info.limit} 字(整段 ${BUDGET.notes} 字,别的型先占了位),模型每回合都看不到它 —— 要么写短,要么拆成几条。`
1940
+ }
1941
+ return `${TIER_WORD[key]}现在共 ${info.total} 条,这轮有 ${info.skipped} 条被挤掉(排在前面的才进上下文)。`
1942
+ }
1943
+ // 自己进来了,不等于没出事:线上翻过一次 —— 一条 95 字长条自己进得来,
1944
+ // 却把原本全进的短规则挤了出去;旧版只问「这条进不进得来」,于是一声不吭。
1945
+ // 拿「写入前」的列表打包做基线,按文本多重集比少了哪些条(同文重复也算得清)。
1946
+ const before = packNotes(entries.slice(0, -1), BUDGET.notes, cfg.tempTtlDays)
1947
+ const pool = new Map()
1948
+ for (const kept of probe.kept) pool.set(kept, (pool.get(kept) ?? 0) + 1)
1949
+ pool.set(body, (pool.get(body) ?? 0) - 1) // 新写的那一份本来就不在基线里
1950
+ let displaced = 0
1951
+ for (const kept of before.kept) {
1952
+ const left = pool.get(kept) ?? 0
1953
+ if (left > 0) pool.set(kept, left - 1)
1954
+ else displaced += 1
1884
1955
  }
1885
- return `${TIER_WORD[key]}现在共 ${info.total} 条,这轮有 ${info.skipped} 条被挤掉(排在前面的才进上下文)。`
1956
+ if (displaced === 0) return ''
1957
+ return `这条 ${countChars(body)} 字自己进了上下文,但把 ${displaced} 条自述挤掉了(排在前面的才进上下文)—— 写短些,或先把旧规则整理归档。`
1886
1958
  }
1887
1959
 
1888
1960
  function renderText(args, value) {
@@ -2169,7 +2241,7 @@ export function currentAuthInjection() {
2169
2241
  function injectTokenScript(html) {
2170
2242
  if (authToken === '') return html
2171
2243
  if (html.includes('__DSH_SELFNOTE_TOKEN__')) return html
2172
- const script = `<script>window.__DSH_SELFNOTE_TOKEN__=${JSON.stringify(authToken)}</script>`
2244
+ const script = `<script>window.__DSH_SELFNOTE_TOKEN__=${JSON.stringify(authToken).replace(/</g, '\\u003c')}</script>`
2173
2245
  if (html.includes('</head>')) {
2174
2246
  authInjected = 'head'
2175
2247
  return html.replace('</head>', `${script}</head>`)
@@ -2394,7 +2466,7 @@ export function apply(ctx, config) {
2394
2466
  return {
2395
2467
  action,
2396
2468
  entries: entries.length,
2397
- chars: renderNotes(entries).length,
2469
+ chars: countChars(renderNotes(entries)),
2398
2470
  maxChars: limits.maxChars,
2399
2471
  text: renderNotes(entries),
2400
2472
  }
@@ -2613,7 +2685,7 @@ export function apply(ctx, config) {
2613
2685
  if (typeof body.heading === 'string') {
2614
2686
  const value = body.heading.trim()
2615
2687
  // 抬头是注入的第一行,长度不设限等于让人用一句话吃掉整个预算
2616
- if (value.length > HEADING_LIMIT) {
2688
+ if (countChars(value) > HEADING_LIMIT) {
2617
2689
  return sendJson(res, 400, { ok: false, error: `抬头不能超过 ${HEADING_LIMIT} 字` })
2618
2690
  }
2619
2691
  next.heading = value
@@ -2622,7 +2694,7 @@ export function apply(ctx, config) {
2622
2694
  if (typeof body.noteGuide !== 'string') {
2623
2695
  return sendJson(res, 400, { ok: false, error: 'noteGuide 要是字符串' })
2624
2696
  }
2625
- if (body.noteGuide.trim().length > GUIDE_LIMIT) {
2697
+ if (countChars(body.noteGuide.trim()) > GUIDE_LIMIT) {
2626
2698
  return sendJson(res, 400, { ok: false, error: `noteGuide 不能超过 ${GUIDE_LIMIT} 字` })
2627
2699
  }
2628
2700
  next.noteGuide = body.noteGuide.trim()
@@ -2632,7 +2704,7 @@ export function apply(ctx, config) {
2632
2704
  if (typeof body.profileOverride !== 'string') {
2633
2705
  return sendJson(res, 400, { ok: false, error: 'profileOverride 要是字符串' })
2634
2706
  }
2635
- next.profileOverride = body.profileOverride.slice(0, 240)
2707
+ next.profileOverride = [...body.profileOverride].slice(0, 240).join('')
2636
2708
  }
2637
2709
  if (body.halfLifeDays !== undefined) {
2638
2710
  const value = Number(body.halfLifeDays)
@@ -2663,7 +2735,7 @@ export function apply(ctx, config) {
2663
2735
  const text = typeof body?.text === 'string' ? body.text.trim() : ''
2664
2736
  if (text === '') return sendJson(res, 400, { ok: false, error: '内容不能为空' })
2665
2737
  const result = appendNote(text, limitsOf(currentConfig(inline)), { tier: body?.tier, pin: body?.pin })
2666
- auditAction('note-add', { tier: result.tier, pin: result.pin, chars: text.length }, req)
2738
+ auditAction('note-add', { tier: result.tier, pin: result.pin, chars: countChars(text) }, req)
2667
2739
  return sendJson(res, 200, {
2668
2740
  ...stateOf(inline),
2669
2741
  noteAdd: { tier: result.tier, pin: result.pin, warning: noteCapacityWarning(result.tier, text, inline) },
@@ -2752,7 +2824,8 @@ export function apply(ctx, config) {
2752
2824
  return sendJson(res, 400, { ok: false, error: `导入包里的人格用不了:${verdict.error}` })
2753
2825
  }
2754
2826
  }
2755
- const limits = limitsOf(currentConfig(inline))
2827
+ const configPlan = mergeConfig(incoming, readConfig())
2828
+ const limits = limitsOf({ ...currentConfig(inline), ...configPlan.config })
2756
2829
  const before = { notes: readNotes().length, facts: listFacts().length, habits: readHabits().length }
2757
2830
  const merged = mergeBundle(
2758
2831
  incoming,
@@ -2765,6 +2838,7 @@ export function apply(ctx, config) {
2765
2838
  const { kept: trimmedNotes } = trim(merged.notes, limits)
2766
2839
  writeNotes(trimmedNotes)
2767
2840
  writeHabits(merged.habits)
2841
+ if (configPlan.applied.length > 0) writeConfig({ ...readConfig(), ...configPlan.config })
2768
2842
  // 人格:目标空就采用,一模一样就跳过,两边都有且不同就落旁路文件 —— 不覆盖
2769
2843
  let personaFile = ''
2770
2844
  if (personaPlan.action === 'adopt') {
@@ -2786,6 +2860,7 @@ export function apply(ctx, config) {
2786
2860
  after: { notes: trimmedNotes.length, facts: merged.facts.length, habits: merged.habits.length },
2787
2861
  persona: { action: personaPlan.action, file: personaFile },
2788
2862
  indexStale: true,
2863
+ configApplied: configPlan.applied.length,
2789
2864
  }
2790
2865
  auditAction('import', { added: merged.added, keepStatus: merged.keepStatus }, req)
2791
2866
  return sendJson(res, 200, { ...stateOf(inline), import: report })
package/lib/memory.js CHANGED
@@ -25,6 +25,7 @@ import { join } from 'node:path'
25
25
  import { zstdDecompressSync } from 'node:zlib'
26
26
 
27
27
  import { writeAtomic, writeJsonAtomic } from './atomic.js'
28
+ import { countChars } from './store.js'
28
29
 
29
30
  const SESSION_FILE = 'session.v4.jsonl.zstd'
30
31
  /** 缓存格式版本:解析规则变了就 +1,旧缓存自动作废。 */
@@ -759,7 +760,7 @@ export function mutateFacts(mutate, attempts = 5, deadlineMs = 150) {
759
760
  export function stageFact(input = {}) {
760
761
  const text = String(input.text ?? '').trim()
761
762
  if (text === '') return { ok: false, error: 'text 不能为空' }
762
- if (text.length > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
763
+ if (countChars(text) > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
763
764
  const check = checkCitation(input.citation)
764
765
  if (!check.ok) return { ok: false, error: check.error }
765
766
 
@@ -798,7 +799,7 @@ export function stageFact(input = {}) {
798
799
  export function setFactText(id, text, meta = {}) {
799
800
  const clean = String(text ?? '').replace(/\s+/g, ' ').trim()
800
801
  if (clean === '') return { ok: false, error: '内容不能为空' }
801
- if (clean.length > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
802
+ if (countChars(clean) > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
802
803
  let before = ''
803
804
  let updated = null
804
805
  const outcome = mutateFacts((facts) => {
@@ -885,8 +886,9 @@ export function touchFacts(ids, now = Date.now()) {
885
886
  // 而 'YYYY-MM-DD' 解析出来是当天 00:00 —— 过了午夜那一小时窗口就永远失效,
886
887
  // 每装配一次都会 +1(实测 25 分钟能连涨 3 次,把「进过几次」记成「算过几次」)。
887
888
  const stamp = new Date(now).toISOString()
888
- // 热度是软数据:抢不到锁就这回合先不记(lastHit 没变,下一回合还会再试),
889
- // 别为了记一次点击把事件循环卡住 —— 所以给它的重试窗口比别的写入短。
889
+ // 热度是软数据:抢不到锁就这回合先不记(lastHit 没变,下一回合还会再试)。
890
+ // 机会比硬写少(2 次 vs 5 次),但每次等锁的窗口给足:20ms 在 16 并发下几乎必输,
891
+ // test:accept 曾因此偶发「热度没落下:t2(返回 0)」。
890
892
  const outcome = mutateFacts((facts) => {
891
893
  let changed = 0
892
894
  for (const fact of facts) {
@@ -898,7 +900,7 @@ export function touchFacts(ids, now = Date.now()) {
898
900
  changed += 1
899
901
  }
900
902
  return { changed: changed > 0, count: changed }
901
- }, 2, 20)
903
+ }, 2, 150)
902
904
  return outcome.count ?? 0
903
905
  }
904
906
 
@@ -915,7 +917,7 @@ export function seenFacts(ids, now = Date.now()) {
915
917
  const wanted = new Set(ids ?? [])
916
918
  if (wanted.size === 0) return 0
917
919
  const stamp = new Date(now).toISOString()
918
- // 和 touchFacts 一样是软写入:抢不到锁就这次先不记,别为一次记账卡住事件循环。
920
+ // 和 touchFacts 一样是软写入:2 次机会、每次等锁 150ms,抢不到就这次先不记。
919
921
  const outcome = mutateFacts((facts) => {
920
922
  let changed = 0
921
923
  for (const fact of facts) {
@@ -927,7 +929,7 @@ export function seenFacts(ids, now = Date.now()) {
927
929
  changed += 1
928
930
  }
929
931
  return { changed: changed > 0, count: changed }
930
- }, 2, 20)
932
+ }, 2, 150)
931
933
  return outcome.count ?? 0
932
934
  }
933
935
 
@@ -978,7 +980,7 @@ export function setHabit(text, status, tag) {
978
980
  const wanted = String(status ?? '')
979
981
  const clean = String(text ?? '').trim()
980
982
  if (clean === '') return { ok: false, error: 'text 不能为空' }
981
- if (clean.length > 80) return { ok: false, error: '一条习惯最多 80 字' }
983
+ if (countChars(clean) > 80) return { ok: false, error: '一条习惯最多 80 字' }
982
984
  if (!['active', 'retired'].includes(wanted)) return { ok: false, error: 'status 只能是 active / retired' }
983
985
 
984
986
  const habits = readHabits()
package/lib/persona-tx.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 恢复时只是「把文件写成目标状态」,重放多少次结果都一样。
8
8
  */
9
9
  import { createHash, randomBytes } from 'node:crypto'
10
- import { closeSync, existsSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs'
10
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
11
11
  import { join } from 'node:path'
12
12
  import { selfnoteRoot } from './store.js'
13
13
  import { writeAtomic, writeJsonAtomic } from './atomic.js'
@@ -111,7 +111,8 @@ export function lockState(now = Date.now()) {
111
111
  } catch {
112
112
  return { exists: true, stale: true, pid: 0, ageMs: 0 }
113
113
  }
114
- const stale = !pidAlive(pid) || !Number.isFinite(ageMs) || ageMs > STALE_LOCK_MS
114
+ const empty = (() => { try { return readFileSync(LOCK_PATH, 'utf8').trim() === '' } catch { return false } })()
115
+ const stale = !empty && (!pidAlive(pid) || !Number.isFinite(ageMs) || ageMs > STALE_LOCK_MS)
115
116
  return { exists: true, stale, pid: Number.isInteger(pid) ? pid : 0, ageMs }
116
117
  }
117
118
 
@@ -128,29 +129,19 @@ function clearStaleLock() {
128
129
 
129
130
  /** 跨进程互斥:O_EXCL 建锁,拿不到就是「有人正在保存」,直接报错而不是排队等死。 */
130
131
  export async function withPersonaLock(job) {
131
- let fd
132
132
  try {
133
- fd = openSync(LOCK_PATH, 'wx')
133
+ writeFileSync(LOCK_PATH, String(process.pid), { flag: 'wx' })
134
134
  } catch (error) {
135
135
  if (error?.code !== 'EEXIST') throw error
136
136
  // 上次被强杀的残留锁:清掉再试一次,否则「保存人格」会永久 409
137
137
  if (!clearStaleLock()) throw new LockedError()
138
138
  try {
139
- fd = openSync(LOCK_PATH, 'wx')
139
+ writeFileSync(LOCK_PATH, String(process.pid), { flag: 'wx' })
140
140
  } catch (retryError) {
141
141
  if (retryError?.code === 'EEXIST') throw new LockedError()
142
142
  throw retryError
143
143
  }
144
144
  }
145
- try {
146
- try {
147
- writeSync(fd, String(process.pid))
148
- } catch {
149
- // 写不进去不影响互斥本身
150
- }
151
- } finally {
152
- closeSync(fd)
153
- }
154
145
  try {
155
146
  return await job()
156
147
  } finally {
package/lib/persona.js CHANGED
@@ -73,11 +73,17 @@ export function parsePersona(raw) {
73
73
 
74
74
  const blocks = { l0: [], l1: [], l2: [] }
75
75
  const orphan = []
76
+ const seen = new Set()
76
77
  let current = null
77
78
 
78
79
  for (const line of text.split('\n')) {
79
80
  const tier = tierOfLine(line)
80
- if (tier !== null) {
81
+ // 分节规则:某一层第一次出现才算换节;同一层再出现且当前就在这一层,仍按合段处理
82
+ // (手写文件里把规则拆成两段 L1 是常见写法)。但若某一层已经出现、当前却在别的层,
83
+ // 那这一行是正文 —— 否则正文里一句「## L0 …」会把上一段整段抢走,round-trip 就丢层级了。
84
+ const isHeading = tier !== null && (!seen.has(tier) || tier === current)
85
+ if (isHeading) {
86
+ seen.add(tier)
81
87
  current = tier
82
88
  blocks[tier].push([])
83
89
  continue