dsh-selfnote 0.13.3 → 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/README.md +129 -32
- package/lib/capture-run.js +17 -1
- package/lib/client.js +285 -8
- package/lib/corrections.js +353 -0
- package/lib/index-store.js +14 -5
- package/lib/index.js +561 -75
- package/lib/persona-tx.js +308 -0
- package/lib/persona.js +248 -0
- package/lib/transfer.js +40 -2
- package/package.json +2 -2
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 保存人格的那一次跨文件写入(persona.md + corrections.jsonl + persona.evidence.json)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不能各写各的:persona 写成功、进程被杀、证据还没标 used ——
|
|
5
|
+
* 下次启动规则已经生效,证据却还挂在「未处理」里,人会以为自己没采纳过。
|
|
6
|
+
* 所以先落一份日志,日志里写的是**目标状态**(新 persona 的摘要 + 证据文件写完之后应该长什么样),
|
|
7
|
+
* 恢复时只是「把文件写成目标状态」,重放多少次结果都一样。
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
10
|
+
import { closeSync, existsSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { selfnoteRoot } from './store.js'
|
|
13
|
+
import { writeAtomic, writeJsonAtomic } from './atomic.js'
|
|
14
|
+
import { validatePersona, renderPersona, personaPath } from './persona.js'
|
|
15
|
+
import { findById, markUsed } from './corrections.js'
|
|
16
|
+
|
|
17
|
+
/** 指纹算法的版本名:换算法就让旧记录失效,而不是悄悄算出另一批指纹。 */
|
|
18
|
+
export const RULE_ALGO = 'nfkc-wp1'
|
|
19
|
+
|
|
20
|
+
const TX_PATH = join(selfnoteRoot(), 'persona.pending.json')
|
|
21
|
+
const EVIDENCE_PATH = join(selfnoteRoot(), 'persona.evidence.json')
|
|
22
|
+
const LOCK_PATH = join(selfnoteRoot(), 'persona.lock')
|
|
23
|
+
|
|
24
|
+
export class LockedError extends Error {
|
|
25
|
+
constructor(message = '另一次人格保存正在进行,稍后再试') {
|
|
26
|
+
super(message)
|
|
27
|
+
this.code = 'locked'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 规则正文的规范化:全角折半角、去空白、去标点、小写 —— 措辞没变就不该算成两条规则。 */
|
|
32
|
+
export function normalizeRule(text) {
|
|
33
|
+
return String(text ?? '')
|
|
34
|
+
.normalize('NFKC')
|
|
35
|
+
.replace(/[\p{P}\p{Z}\s]/gu, '')
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ruleFingerprint(text) {
|
|
40
|
+
return createHash('sha1').update(`${RULE_ALGO}|${normalizeRule(text)}`, 'utf8').digest('hex').slice(0, 16)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readEvidence() {
|
|
44
|
+
if (!existsSync(EVIDENCE_PATH)) return { v: 1, algo: RULE_ALGO, rules: {} }
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(EVIDENCE_PATH, 'utf8'))
|
|
47
|
+
const rules = parsed !== null && typeof parsed.rules === 'object' && parsed.rules !== null ? parsed.rules : {}
|
|
48
|
+
// 换了算法(RULE_ALGO)就等于换了一把尺子:旧指纹对新规则一文不值,宁可当空账重来
|
|
49
|
+
if (String(parsed.algo ?? RULE_ALGO) !== RULE_ALGO) return { v: 1, algo: RULE_ALGO, rules: {} }
|
|
50
|
+
return { v: 1, algo: RULE_ALGO, rules }
|
|
51
|
+
} catch {
|
|
52
|
+
return { v: 1, algo: RULE_ALGO, rules: {} }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function writeEvidenceAfter(evidence) {
|
|
57
|
+
writeJsonAtomic(EVIDENCE_PATH, evidence)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 由 base 算出「写完之后应该是什么样」。纯函数:同样的 base 算多少次都一样,
|
|
62
|
+
* 恢复时直接拿日志里的快照覆盖,不需要再算一遍(也就不会因为重算而多计一次)。
|
|
63
|
+
*/
|
|
64
|
+
export function buildEvidenceAfter(base, rules, now) {
|
|
65
|
+
const next = { v: 1, algo: RULE_ALGO, rules: { ...(base?.rules ?? {}) } }
|
|
66
|
+
for (const text of rules) {
|
|
67
|
+
const body = String(text ?? '').trim()
|
|
68
|
+
if (body === '') continue
|
|
69
|
+
const fp = ruleFingerprint(body)
|
|
70
|
+
const old = next.rules[fp]
|
|
71
|
+
next.rules[fp] = old
|
|
72
|
+
? { count: Number(old.count ?? 0) + 1, firstAt: String(old.firstAt ?? now), lastAt: now }
|
|
73
|
+
: { count: 1, firstAt: now, lastAt: now }
|
|
74
|
+
}
|
|
75
|
+
return next
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 进程内单飞:保存人格、消费证据、恢复,全排一条队 —— 交叉跑必然半新半旧。 */
|
|
79
|
+
let chain = Promise.resolve()
|
|
80
|
+
export function runSerial(job) {
|
|
81
|
+
const run = chain.then(job, job)
|
|
82
|
+
chain = run.then(
|
|
83
|
+
() => undefined,
|
|
84
|
+
() => undefined,
|
|
85
|
+
)
|
|
86
|
+
return run
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 锁最长活多久:超过这个岁数又探不到活进程,就当是上次被任务管理器杀掉留下的。 */
|
|
90
|
+
const STALE_LOCK_MS = 10 * 60 * 1000
|
|
91
|
+
|
|
92
|
+
function pidAlive(pid) {
|
|
93
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0)
|
|
96
|
+
return true
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// EPERM = 进程活着,只是不是我们能管的
|
|
99
|
+
return error?.code === 'EPERM'
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 锁里记的是 pid:进程已经没了(或者锁老得离谱)就清掉,否则用户只能手动删文件。 */
|
|
104
|
+
export function lockState(now = Date.now()) {
|
|
105
|
+
if (!existsSync(LOCK_PATH)) return { exists: false, stale: false, pid: 0, ageMs: 0 }
|
|
106
|
+
let pid = 0
|
|
107
|
+
let ageMs = 0
|
|
108
|
+
try {
|
|
109
|
+
pid = Number(readFileSync(LOCK_PATH, 'utf8').trim())
|
|
110
|
+
ageMs = now - statSync(LOCK_PATH).mtimeMs
|
|
111
|
+
} catch {
|
|
112
|
+
return { exists: true, stale: true, pid: 0, ageMs: 0 }
|
|
113
|
+
}
|
|
114
|
+
const stale = !pidAlive(pid) || !Number.isFinite(ageMs) || ageMs > STALE_LOCK_MS
|
|
115
|
+
return { exists: true, stale, pid: Number.isInteger(pid) ? pid : 0, ageMs }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clearStaleLock() {
|
|
119
|
+
const state = lockState()
|
|
120
|
+
if (!state.exists || !state.stale) return false
|
|
121
|
+
try {
|
|
122
|
+
unlinkSync(LOCK_PATH)
|
|
123
|
+
return true
|
|
124
|
+
} catch {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 跨进程互斥:O_EXCL 建锁,拿不到就是「有人正在保存」,直接报错而不是排队等死。 */
|
|
130
|
+
export async function withPersonaLock(job) {
|
|
131
|
+
let fd
|
|
132
|
+
try {
|
|
133
|
+
fd = openSync(LOCK_PATH, 'wx')
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error?.code !== 'EEXIST') throw error
|
|
136
|
+
// 上次被强杀的残留锁:清掉再试一次,否则「保存人格」会永久 409
|
|
137
|
+
if (!clearStaleLock()) throw new LockedError()
|
|
138
|
+
try {
|
|
139
|
+
fd = openSync(LOCK_PATH, 'wx')
|
|
140
|
+
} catch (retryError) {
|
|
141
|
+
if (retryError?.code === 'EEXIST') throw new LockedError()
|
|
142
|
+
throw retryError
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
try {
|
|
147
|
+
writeSync(fd, String(process.pid))
|
|
148
|
+
} catch {
|
|
149
|
+
// 写不进去不影响互斥本身
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
closeSync(fd)
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return await job()
|
|
156
|
+
} finally {
|
|
157
|
+
try {
|
|
158
|
+
unlinkSync(LOCK_PATH)
|
|
159
|
+
} catch {
|
|
160
|
+
// 已经被恢复流程清掉了
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readTxRaw() {
|
|
166
|
+
if (!existsSync(TX_PATH)) return { kind: 'missing' }
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(readFileSync(TX_PATH, 'utf8'))
|
|
169
|
+
if (parsed === null || typeof parsed !== 'object' || typeof parsed.txId !== 'string') return { kind: 'bad' }
|
|
170
|
+
return { kind: 'ok', tx: parsed }
|
|
171
|
+
} catch {
|
|
172
|
+
return { kind: 'bad' }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function readTx() {
|
|
177
|
+
const raw = readTxRaw()
|
|
178
|
+
return raw.kind === 'ok' ? raw.tx : null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function beginTx(tx) {
|
|
182
|
+
// 已有未结日志时绝不覆盖:盖掉就等于把上一次的 evidenceAfter 永久蒸发,
|
|
183
|
+
// 而那条证据的 consumedBy 已经指向旧 txId —— 计数再也补不回来,doctor 也看不到。
|
|
184
|
+
if (existsSync(TX_PATH)) throw new PendingTxError()
|
|
185
|
+
writeJsonAtomic(TX_PATH, tx)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export class PendingTxError extends Error {
|
|
189
|
+
constructor(message = '上一次人格保存还没结清(有未完成的事务日志)') {
|
|
190
|
+
super(message)
|
|
191
|
+
this.code = 'pending'
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function clearTx() {
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(TX_PATH)
|
|
198
|
+
} catch {
|
|
199
|
+
// 本来就没有
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function sha256OfCurrentPersona() {
|
|
204
|
+
const text = existsSync(personaPath()) ? readFileSync(personaPath(), 'utf8') : ''
|
|
205
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const MAX_UNUSED_DAYS = 90
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 保存人格:校验 → 写日志 → 写 persona → 消费证据 + 写证据快照 → 删日志。
|
|
212
|
+
* 第 4 步出错时 persona 已经生效了,所以只能报 pending(200),
|
|
213
|
+
* 让恢复流程下次启动把它补完 —— 谎报「保存失败」会让人以为规则没生效。
|
|
214
|
+
*/
|
|
215
|
+
export async function commitPersona({ l0 = '', l1 = '', l2 = '', consume = [] } = {}) {
|
|
216
|
+
return runSerial(async () => {
|
|
217
|
+
let locked = false
|
|
218
|
+
try {
|
|
219
|
+
return await withPersonaLock(async () => {
|
|
220
|
+
// 上一次崩在半路的事务必须先结清,否则 beginTx 会把它盖掉
|
|
221
|
+
const pending = recover()
|
|
222
|
+
const verdict = validatePersona({ l0, l1, l2 })
|
|
223
|
+
if (!verdict.ok) return { ok: false, code: verdict.code, error: verdict.error }
|
|
224
|
+
|
|
225
|
+
const ids = consume.map((id) => String(id))
|
|
226
|
+
const seen = new Set()
|
|
227
|
+
for (const id of ids) {
|
|
228
|
+
if (seen.has(id)) return { ok: false, code: 'validation', error: `证据 ${id} 重复出现在同一次提交里` }
|
|
229
|
+
seen.add(id)
|
|
230
|
+
const entry = findById(id)
|
|
231
|
+
if (entry === undefined) return { ok: false, code: 'validation', error: `证据 ${id} 不存在` }
|
|
232
|
+
if (entry.used) {
|
|
233
|
+
// used 且没有 consumedBy = 用户亲手丢弃的,同样不能再当依据
|
|
234
|
+
const why = entry.consumedBy === null ? '已被丢弃' : '已被另一次保存消费过'
|
|
235
|
+
return { ok: false, code: 'validation', error: `证据 ${id} ${why}` }
|
|
236
|
+
}
|
|
237
|
+
const at = Date.parse(entry.at)
|
|
238
|
+
if (Number.isFinite(at) && Date.now() - at > MAX_UNUSED_DAYS * 86400000) {
|
|
239
|
+
return { ok: false, code: 'validation', error: `证据 ${id} 已超过 ${MAX_UNUSED_DAYS} 天,不再作为依据` }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const now = new Date().toISOString()
|
|
244
|
+
const txId = `tx-${now.replace(/[-:.TZ]/g, '').slice(0, 15)}-${randomBytes(2).toString('hex')}`
|
|
245
|
+
const text = renderPersona({ l0, l1, l2 })
|
|
246
|
+
const rules = String(l1 ?? '')
|
|
247
|
+
.split('\n')
|
|
248
|
+
.map((line) => line.trim())
|
|
249
|
+
.filter((line) => line !== '')
|
|
250
|
+
const evidenceAfter = buildEvidenceAfter(readEvidence(), rules, now)
|
|
251
|
+
beginTx({ txId, at: now, personaSha256: createHash('sha256').update(text, 'utf8').digest('hex'), consume: ids, evidenceAfter })
|
|
252
|
+
|
|
253
|
+
writeAtomic(personaPath(), text)
|
|
254
|
+
try {
|
|
255
|
+
markUsed(ids, txId)
|
|
256
|
+
writeEvidenceAfter(evidenceAfter)
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return { ok: true, pending: true, error: `persona 已保存,但证据记账没写完:${String(error?.message ?? error)}` }
|
|
259
|
+
}
|
|
260
|
+
clearTx()
|
|
261
|
+
return { ok: true, consumed: ids }
|
|
262
|
+
})
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof LockedError) {
|
|
265
|
+
locked = true
|
|
266
|
+
return { ok: false, code: 'locked', error: error.message }
|
|
267
|
+
}
|
|
268
|
+
if (error instanceof PendingTxError) {
|
|
269
|
+
return { ok: false, code: 'pending', error: error.message }
|
|
270
|
+
}
|
|
271
|
+
throw error
|
|
272
|
+
} finally {
|
|
273
|
+
// locked 分支没有任何副作用,不需要补偿
|
|
274
|
+
void locked
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 启动恢复。四行决策:
|
|
281
|
+
* 没日志 → 什么都不做
|
|
282
|
+
* 摘要对得上 → 重放第 4 步(消费 + 写证据快照),然后删日志
|
|
283
|
+
* 摘要对不上 → persona 没写成这样:不动证据,删日志
|
|
284
|
+
* 日志本身坏了 → 留档改名,不动数据
|
|
285
|
+
*/
|
|
286
|
+
export function recover() {
|
|
287
|
+
const raw = readTxRaw()
|
|
288
|
+
if (raw.kind === 'missing') return { action: 'none' }
|
|
289
|
+
if (raw.kind === 'bad') {
|
|
290
|
+
try {
|
|
291
|
+
renameSync(TX_PATH, join(selfnoteRoot(), `persona.pending.bad-${Date.now()}.json`))
|
|
292
|
+
} catch {
|
|
293
|
+
// 改名失败也别把启动卡死
|
|
294
|
+
}
|
|
295
|
+
return { action: 'quarantined' }
|
|
296
|
+
}
|
|
297
|
+
const tx = raw.tx
|
|
298
|
+
if (sha256OfCurrentPersona() !== String(tx.personaSha256 ?? '')) {
|
|
299
|
+
clearTx()
|
|
300
|
+
return { action: 'rolled-back', detail: 'persona 与日志里的摘要不一致' }
|
|
301
|
+
}
|
|
302
|
+
const consume = Array.isArray(tx.consume) ? tx.consume.map((id) => String(id)) : []
|
|
303
|
+
markUsed(consume, String(tx.txId))
|
|
304
|
+
const evidence = tx.evidenceAfter
|
|
305
|
+
if (evidence !== null && typeof evidence === 'object') writeEvidenceAfter(evidence)
|
|
306
|
+
clearTx()
|
|
307
|
+
return { action: 'replayed' }
|
|
308
|
+
}
|
package/lib/persona.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// persona.md:三段(L0 核心身份 / L1 行为规则 / L2 扩展设定)的解析、渲染与字符计数。
|
|
2
|
+
//
|
|
3
|
+
// 计数口径是全插件唯一的:按 Unicode 码点([...text].length)。
|
|
4
|
+
// ZWJ 组合 emoji(如 👨👩👧 五个码点)会算成多个 —— 这是统一的预算单位,
|
|
5
|
+
// 不要在任何界面或文档里说成「视觉上一个 emoji 只算一个字」。
|
|
6
|
+
//
|
|
7
|
+
// 解析规则见 docs/分层人格-20261105.md §4:
|
|
8
|
+
// - 先去掉 BOM、把 CRLF 规整成 LF
|
|
9
|
+
// - `## L0` / `## L1` / `## L2`(也认「核心身份 / 行为规则 / 扩展设定」)开新段,标题行本身丢弃
|
|
10
|
+
// - 同一层出现两次 → 用换行拼接
|
|
11
|
+
// - 标题在、正文空 → 这一层当不存在(返回空串)
|
|
12
|
+
// - 全篇没有可认的标题 → 整篇进 L1,sectionless = true
|
|
13
|
+
// - 认不出的标题行原样留在正文里(不吞内容)
|
|
14
|
+
|
|
15
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { writeAtomic, writeJsonAtomic } from './atomic.js'
|
|
19
|
+
import { selfnoteRoot } from './store.js'
|
|
20
|
+
|
|
21
|
+
const TITLE_RE = /^#{1,6}\s*(.*)$/
|
|
22
|
+
|
|
23
|
+
// L0 硬上限 = 320 × 0.25;L0 + L1 硬顶 320;整份文件(含 L2)上限 8000。
|
|
24
|
+
export const PERSONA_L0_MAX = 80
|
|
25
|
+
export const PERSONA_TOTAL = 320
|
|
26
|
+
export const MAX_PERSONA_CHARS = 8000
|
|
27
|
+
export const MAX_PERSONA_BYTES = 1024 * 1024
|
|
28
|
+
|
|
29
|
+
export class PersonaValidationError extends Error {
|
|
30
|
+
constructor(message, code = 'total-too-long') {
|
|
31
|
+
super(message)
|
|
32
|
+
this.name = 'PersonaValidationError'
|
|
33
|
+
this.code = code
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ZH_LABELS = [
|
|
38
|
+
['核心身份', 'l0'],
|
|
39
|
+
['行为规则', 'l1'],
|
|
40
|
+
['扩展设定', 'l2'],
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
function tierOfLine(line) {
|
|
44
|
+
const match = TITLE_RE.exec(line)
|
|
45
|
+
if (!match) return null
|
|
46
|
+
const label = match[1].trim()
|
|
47
|
+
const head = label.toUpperCase()
|
|
48
|
+
if (/^L0\b/.test(head)) return 'l0'
|
|
49
|
+
if (/^L1\b/.test(head)) return 'l1'
|
|
50
|
+
if (/^L2\b/.test(head)) return 'l2'
|
|
51
|
+
for (const [word, tier] of ZH_LABELS) {
|
|
52
|
+
if (label.startsWith(word)) return tier
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function joinBlocks(blocks) {
|
|
58
|
+
return blocks
|
|
59
|
+
.map((lines) => lines.join('\n').replace(/^\n+|\n+$/g, ''))
|
|
60
|
+
.filter((text) => text !== '')
|
|
61
|
+
.join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function countChars(text) {
|
|
65
|
+
return [...String(text ?? '')].length
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parsePersona(raw) {
|
|
69
|
+
const text = String(raw ?? '')
|
|
70
|
+
.replace(/^\uFEFF/, '')
|
|
71
|
+
.replace(/\r\n?/g, '\n')
|
|
72
|
+
if (text.trim() === '') return { l0: '', l1: '', l2: '', sectionless: true }
|
|
73
|
+
|
|
74
|
+
const blocks = { l0: [], l1: [], l2: [] }
|
|
75
|
+
const orphan = []
|
|
76
|
+
let current = null
|
|
77
|
+
|
|
78
|
+
for (const line of text.split('\n')) {
|
|
79
|
+
const tier = tierOfLine(line)
|
|
80
|
+
if (tier !== null) {
|
|
81
|
+
current = tier
|
|
82
|
+
blocks[tier].push([])
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (current === null) {
|
|
86
|
+
orphan.push(line)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
blocks[current][blocks[current].length - 1].push(line)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sectionless = current === null
|
|
93
|
+
if (sectionless) {
|
|
94
|
+
return { l0: '', l1: text.replace(/^\n+|\n+$/g, ''), l2: '', sectionless: true }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 第一个标题之前的零散行不丢:并入 L1 的最前面
|
|
98
|
+
if (orphan.length > 0) {
|
|
99
|
+
const lead = orphan.join('\n').replace(/^\n+|\n+$/g, '')
|
|
100
|
+
if (lead !== '') blocks.l1.unshift([lead])
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
l0: joinBlocks(blocks.l0),
|
|
105
|
+
l1: joinBlocks(blocks.l1),
|
|
106
|
+
l2: joinBlocks(blocks.l2),
|
|
107
|
+
sectionless: false,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const HEADINGS = [
|
|
112
|
+
['l0', '## L0 核心身份'],
|
|
113
|
+
['l1', '## L1 行为规则'],
|
|
114
|
+
['l2', '## L2 扩展设定'],
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
export function renderPersona({ l0 = '', l1 = '', l2 = '' } = {}) {
|
|
118
|
+
const parts = []
|
|
119
|
+
for (const [key, heading] of HEADINGS) {
|
|
120
|
+
const body = String({ l0, l1, l2 }[key] ?? '').replace(/^\n+|\n+$/g, '')
|
|
121
|
+
if (body === '') continue
|
|
122
|
+
parts.push(`${heading}\n${body}`)
|
|
123
|
+
}
|
|
124
|
+
return parts.length === 0 ? '' : `${parts.join('\n\n')}\n`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function personaPath() {
|
|
128
|
+
return join(selfnoteRoot(), 'persona.md')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const nonEmptyLines = (raw) =>
|
|
132
|
+
String(raw ?? '')
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((line) => line.trim())
|
|
135
|
+
.filter((line) => line !== '')
|
|
136
|
+
|
|
137
|
+
/** 一组行拼成文本要占多少字:每行的字 + 行与行之间那一个换行。 */
|
|
138
|
+
export function costOfLines(lines) {
|
|
139
|
+
return lines.length === 0 ? 0 : lines.reduce((sum, line) => sum + countChars(line), 0) + lines.length - 1
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 人格进注入时到底占多少字 —— **校验和注入必须用同一把尺子**。
|
|
144
|
+
* 只数正文(不含行间换行、不含 L0/L1 之间那个分隔)会放过「正好 320」的人格,
|
|
145
|
+
* 而注入时按带换行的口径一算就是 321,于是最后两个字被静默换成省略号。
|
|
146
|
+
*/
|
|
147
|
+
export function personaCost({ l0 = '', l1 = '' } = {}) {
|
|
148
|
+
const a = costOfLines(nonEmptyLines(l0))
|
|
149
|
+
const b = costOfLines(nonEmptyLines(l1))
|
|
150
|
+
return { l0: a, l1: b, total: a + b + (a > 0 && b > 0 ? 1 : 0) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 逐段比,错误文案逐字对应 spec §3.2 / §8:
|
|
154
|
+
// L0 现有 92 字,超过 80 字上限
|
|
155
|
+
// L0 72 + L1 249 = 322,超了 2 字(含行间换行,与注入同一口径)
|
|
156
|
+
// 合计 8213 字,超过 8000 字上限
|
|
157
|
+
export function validatePersona({ l0 = '', l1 = '', l2 = '' } = {}) {
|
|
158
|
+
const cost = personaCost({ l0, l1 })
|
|
159
|
+
const a = cost.l0
|
|
160
|
+
const b = cost.l1
|
|
161
|
+
const c = countChars(l2)
|
|
162
|
+
if (a > PERSONA_L0_MAX) {
|
|
163
|
+
return { ok: false, code: 'l0-too-long', error: `L0 现有 ${a} 字,超过 ${PERSONA_L0_MAX} 字上限` }
|
|
164
|
+
}
|
|
165
|
+
if (cost.total > PERSONA_TOTAL) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
code: 'total-too-long',
|
|
169
|
+
// 那 1 个字是 L0 与 L1 之间的换行,不写出来使用者会以为算错了
|
|
170
|
+
error: `L0 ${a} + L1 ${b}${cost.total > a + b ? ' + 换行 1' : ''} = ${cost.total},超了 ${cost.total - PERSONA_TOTAL} 字`,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (a + b + c > MAX_PERSONA_CHARS) {
|
|
174
|
+
return { ok: false, code: 'file-too-long', error: `合计 ${a + b + c} 字,超过 ${MAX_PERSONA_CHARS} 字上限` }
|
|
175
|
+
}
|
|
176
|
+
return { ok: true }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const EMPTY_PERSONA = { l0: '', l1: '', l2: '', sectionless: true, chars: 0, truncated: false }
|
|
180
|
+
|
|
181
|
+
// 读文件不截断:运行期怎么装进 320 是注入层的事(Task 3),
|
|
182
|
+
// 这里只负责「读不出内容时别抛给上层」。
|
|
183
|
+
export function readPersona() {
|
|
184
|
+
const file = personaPath()
|
|
185
|
+
let size
|
|
186
|
+
try {
|
|
187
|
+
size = statSync(file).size
|
|
188
|
+
} catch {
|
|
189
|
+
return { ...EMPTY_PERSONA, error: 'missing' }
|
|
190
|
+
}
|
|
191
|
+
if (size > MAX_PERSONA_BYTES) return { ...EMPTY_PERSONA, error: 'not-text' }
|
|
192
|
+
let raw
|
|
193
|
+
try {
|
|
194
|
+
raw = readFileSync(file, 'utf8')
|
|
195
|
+
} catch {
|
|
196
|
+
return { ...EMPTY_PERSONA, error: 'not-text' }
|
|
197
|
+
}
|
|
198
|
+
if (raw.includes('\0')) return { ...EMPTY_PERSONA, error: 'not-text' }
|
|
199
|
+
const parsed = parsePersona(raw)
|
|
200
|
+
return {
|
|
201
|
+
...parsed,
|
|
202
|
+
chars: countChars(parsed.l0) + countChars(parsed.l1) + countChars(parsed.l2),
|
|
203
|
+
truncated: false,
|
|
204
|
+
error: '',
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function savePersonaFile({ l0 = '', l1 = '', l2 = '' } = {}) {
|
|
209
|
+
const verdict = validatePersona({ l0, l1, l2 })
|
|
210
|
+
if (!verdict.ok) throw new PersonaValidationError(verdict.error, verdict.code)
|
|
211
|
+
writeAtomic(personaPath(), renderPersona({ l0, l1, l2 }))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ---------------- 取用记账:L2 到底有没有人看 ---------------- */
|
|
215
|
+
|
|
216
|
+
const STATS_PATH = join(selfnoteRoot(), 'persona.reads.json')
|
|
217
|
+
|
|
218
|
+
export function personaStatsPath() {
|
|
219
|
+
return STATS_PATH
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 读「取用过几次」。
|
|
224
|
+
* 记这个不为统计好看:L2 是设了不看的死配置,30 天没人取就得在体检里说一声。
|
|
225
|
+
*/
|
|
226
|
+
export function readPersonaStats() {
|
|
227
|
+
try {
|
|
228
|
+
const raw = JSON.parse(readFileSync(STATS_PATH, 'utf8'))
|
|
229
|
+
return {
|
|
230
|
+
reads: Number.isFinite(Number(raw?.reads)) ? Number(raw.reads) : 0,
|
|
231
|
+
lastReadAt: typeof raw?.lastReadAt === 'string' ? raw.lastReadAt : '',
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
return { reads: 0, lastReadAt: '' }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 只有「真的取到了一段非空 L2」才计数:取了个空壳不算有人看过。 */
|
|
239
|
+
export function notePersonaRead(now = new Date()) {
|
|
240
|
+
const before = readPersonaStats()
|
|
241
|
+
const next = { reads: before.reads + 1, lastReadAt: now.toISOString() }
|
|
242
|
+
try {
|
|
243
|
+
writeJsonAtomic(STATS_PATH, next)
|
|
244
|
+
} catch {
|
|
245
|
+
/* 记不上账不该影响这次取用 */
|
|
246
|
+
}
|
|
247
|
+
return next
|
|
248
|
+
}
|
package/lib/transfer.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 索引不导出:它记的是磁盘路径与 mtime,搬过去必然过期,导入后重建一次就行。
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
export const BUNDLE_VERSION =
|
|
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.
|
|
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/notes-tier.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": {
|