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/README.md +24 -17
- package/lib/atomic.js +38 -1
- package/lib/capture-run.js +7 -4
- package/lib/capture.js +2 -1
- package/lib/client.js +63 -38
- package/lib/corrections.js +15 -1
- package/lib/explain.js +77 -0
- package/lib/index-store.js +4 -4
- package/lib/index.js +125 -50
- package/lib/memory.js +10 -8
- package/lib/persona-tx.js +5 -14
- package/lib/persona.js +7 -1
- package/lib/profile.js +5 -3
- package/lib/store.js +14 -4
- package/lib/transfer.js +26 -7
- package/lib/warm.js +16 -2
- package/package.json +2 -2
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
|
+
}
|
package/lib/index-store.js
CHANGED
|
@@ -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
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
303
|
+
if (used + countChars(body) > limit) {
|
|
303
304
|
skipped.push(body)
|
|
304
305
|
continue
|
|
305
306
|
}
|
|
306
|
-
used += body
|
|
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()
|
|
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: [...
|
|
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
|
|
495
|
-
const refit = packNotes(entries, Math.max(0, room - next
|
|
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
|
|
660
|
-
const refit = packFacts(ranked, Math.max(0, limit - next
|
|
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
|
|
692
|
+
const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, countChars(cfg.profileOverride), cfg.profileOverride].join('~')
|
|
684
693
|
// 临时自述会自己到期:文件一个字没改,跨过保质期那天注入也得重算。
|
|
685
694
|
// 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
|
|
686
|
-
const
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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} 条没有正文 —— 这些记忆读不到`, '
|
|
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
|
|
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
|
|
1871
|
-
|
|
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(
|
|
1880
|
-
if (probe.kept.includes(body))
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|