dsh-selfnote 0.13.3 → 0.14.1
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 +600 -13
- package/lib/corrections.js +353 -0
- package/lib/index-store.js +14 -5
- package/lib/index.js +566 -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
package/lib/index.js
CHANGED
|
@@ -37,6 +37,22 @@ import {
|
|
|
37
37
|
MAX_ENTRY_CHARS,
|
|
38
38
|
} from './store.js'
|
|
39
39
|
|
|
40
|
+
import {
|
|
41
|
+
countChars,
|
|
42
|
+
MAX_PERSONA_CHARS,
|
|
43
|
+
notePersonaRead,
|
|
44
|
+
PERSONA_L0_MAX,
|
|
45
|
+
PERSONA_TOTAL,
|
|
46
|
+
personaPath,
|
|
47
|
+
readPersona,
|
|
48
|
+
readPersonaStats,
|
|
49
|
+
renderPersona,
|
|
50
|
+
savePersonaFile,
|
|
51
|
+
validatePersona,
|
|
52
|
+
} from './persona.js'
|
|
53
|
+
import { discardByIds, listCorrections, readEvents, readWatermark } from './corrections.js'
|
|
54
|
+
import { commitPersona, lockState, recover, runSerial } from './persona-tx.js'
|
|
55
|
+
|
|
40
56
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
41
57
|
|
|
42
58
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
@@ -77,7 +93,7 @@ import { buildProfile } from './profile.js'
|
|
|
77
93
|
import { writeAtomic } from './atomic.js'
|
|
78
94
|
import { cursorPath, enqueue, isDelegatedSession, readCursor } from './capture.js'
|
|
79
95
|
import { searchWarm, warmStats, writeSummary } from './warm.js'
|
|
80
|
-
import { buildBundle, mergeBundle, validateBundle } from './transfer.js'
|
|
96
|
+
import { buildBundle, mergeBundle, mergePersona, validateBundle } from './transfer.js'
|
|
81
97
|
import { handleEvent } from './capture-run.js'
|
|
82
98
|
|
|
83
99
|
export const name = 'selfnote'
|
|
@@ -153,6 +169,8 @@ const DEFAULT_CONFIG = {
|
|
|
153
169
|
heading: '记忆(我写的)',
|
|
154
170
|
/** 画像:从 approved facts 聚合出的「关于你」,可关、可手改。 */
|
|
155
171
|
profileEnabled: true,
|
|
172
|
+
/** 人格段(L0 + L1)是否每回合注入;关掉只是不注入,persona.md 一个字节都不动。 */
|
|
173
|
+
personaEnabled: true,
|
|
156
174
|
profileOverride: '',
|
|
157
175
|
profileMaxChars: 110,
|
|
158
176
|
/** 热度半衰期(天):越久没用,激活优先级越低,但事实本身不动。 */
|
|
@@ -174,14 +192,14 @@ const TOOL_DESCRIPTION = `维护「我自己的叙述」——不是记用户的
|
|
|
174
192
|
写法:
|
|
175
193
|
- 第一人称,一句话到三句话;写「我」的做法和判断,不复述用户的原话。
|
|
176
194
|
- 一条写一件事。空话、套话、复述人格设定都不值得写。
|
|
177
|
-
- 写的时候用 tier 标明这是哪一型:core
|
|
195
|
+
- 写的时候用 tier 标明这是哪一型:core=身份素材(人格、长期身份、基本原则 —— 不参与注入,只作提炼人格的原料)、stable=稳定偏好(语言、表达习惯、工作偏好)、temp=临时自述(当前阶段的目标、近期变化)。不写按 stable 算。
|
|
178
196
|
- 只追加,不能改写历史。写满后最旧的条目自动进归档,不会丢,只是不再每回合注入。
|
|
179
197
|
- 这是明文保存在本机、并且每回合都会进上下文的文件:密码、密钥、验证码这类凭据别写。
|
|
180
198
|
- 记用户本人(名字、习惯、处境、在意的点)是有用的,但那些更适合进事实库:用 memory_stage。
|
|
181
199
|
|
|
182
200
|
回看:action="read" 看生效的,action="archive" 看归档,action="stats" 看容量。`
|
|
183
201
|
|
|
184
|
-
const ACTIONS = ['write', 'read', 'archive', 'stats']
|
|
202
|
+
const ACTIONS = ['write', 'read', 'archive', 'stats', 'persona', 'corrections']
|
|
185
203
|
|
|
186
204
|
const PARAMETERS = {
|
|
187
205
|
type: 'object',
|
|
@@ -198,7 +216,7 @@ const PARAMETERS = {
|
|
|
198
216
|
tier: {
|
|
199
217
|
type: 'string',
|
|
200
218
|
enum: ['core', 'stable', 'temp'],
|
|
201
|
-
description: '这条属于哪一型:core
|
|
219
|
+
description: '这条属于哪一型:core=身份素材(人格、长期身份,不参与注入,只作提炼人格的原料);stable=稳定偏好(表达习惯、工作偏好,默认);temp=临时自述(当前阶段的目标、近期变化,最后装)。',
|
|
202
220
|
},
|
|
203
221
|
pin: {
|
|
204
222
|
type: 'boolean',
|
|
@@ -220,6 +238,7 @@ function currentConfig(inline) {
|
|
|
220
238
|
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.min(maxChars, 8000) : DEFAULT_MAX_CHARS,
|
|
221
239
|
maxEntries: Number.isFinite(maxEntries) && maxEntries > 0 ? Math.min(maxEntries, 200) : DEFAULT_MAX_ENTRIES,
|
|
222
240
|
profileEnabled: merged.profileEnabled !== false,
|
|
241
|
+
personaEnabled: merged.personaEnabled !== false,
|
|
223
242
|
profileOverride: typeof merged.profileOverride === 'string' ? merged.profileOverride.trim().slice(0, 240) : '',
|
|
224
243
|
profileMaxChars: Number.isFinite(Number(merged.profileMaxChars))
|
|
225
244
|
? Math.max(20, Math.min(Number(merged.profileMaxChars), 120))
|
|
@@ -245,19 +264,21 @@ function limitsOf(cfg) {
|
|
|
245
264
|
}
|
|
246
265
|
|
|
247
266
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
267
|
+
* 注入预算:六段合计 1000 字,放不下就跳过那一条(不是把整段截断)。
|
|
268
|
+
* 人格(L0 + L1)占最大一份 —— 记忆的主体是「我是谁」,其余是它需要知道的背景。
|
|
269
|
+
* 320/200/120/230/90/40 = 1000:相比五段版的 800,多出来的 200 全给了人格,
|
|
270
|
+
* 自述、画像、环境、结论各让一点(环境 320→230 是唯一有实际损失的段落)。
|
|
251
271
|
*/
|
|
252
|
-
const BUDGET = { notes: 200, profile:
|
|
253
|
-
/**
|
|
254
|
-
export const TOTAL_BUDGET =
|
|
272
|
+
const BUDGET = { persona: 320, notes: 200, profile: 120, env: 230, facts: 90, habits: 40 }
|
|
273
|
+
/** 六段合计,别在别处再手写一个数字。 */
|
|
274
|
+
export const TOTAL_BUDGET =
|
|
275
|
+
BUDGET.persona + BUDGET.notes + BUDGET.profile + BUDGET.env + BUDGET.facts + BUDGET.habits
|
|
276
|
+
|
|
277
|
+
/** 人格框架句:让它知道「下面是你的样子」而不是「有人在给你下命令」。 */
|
|
278
|
+
export const PERSONA_FRAME = '以下是你自己的样子,照这个说话。'
|
|
279
|
+
/** L2 的指路句:不进 chars(和自述段门牌同一套先例)。 */
|
|
280
|
+
export const PERSONA_POINTER = '外貌与世界观细节见 self_note action:"persona"'
|
|
255
281
|
|
|
256
|
-
/**
|
|
257
|
-
* 自述段的内部分账。核心身份(人格、长期身份)先装,最多吃 60%:
|
|
258
|
-
* 人格优先,但不能一条人格把整个自述段占死,后面的人味就全没了。
|
|
259
|
-
*/
|
|
260
|
-
const CORE_SHARE = 0.6
|
|
261
282
|
/** 临时自述的保底比例:别让长期偏好把「当前阶段在做什么」全部挤掉。 */
|
|
262
283
|
const TEMP_SHARE = 0.25
|
|
263
284
|
/** 自定义「写自述的时机」那行的上限(字)。 */
|
|
@@ -369,12 +390,13 @@ function fitDetailed(items, limit) {
|
|
|
369
390
|
for (const item of items) {
|
|
370
391
|
const text = String(item ?? '').trim()
|
|
371
392
|
if (text === '') continue
|
|
372
|
-
|
|
393
|
+
const width = countChars(text)
|
|
394
|
+
if (used + width > limit) {
|
|
373
395
|
skipped.push(text)
|
|
374
396
|
continue
|
|
375
397
|
}
|
|
376
398
|
kept.push(text)
|
|
377
|
-
used +=
|
|
399
|
+
used += width + 1
|
|
378
400
|
}
|
|
379
401
|
return { text: kept.join('\n'), kept, skipped }
|
|
380
402
|
}
|
|
@@ -384,12 +406,15 @@ function fit(items, limit) {
|
|
|
384
406
|
}
|
|
385
407
|
|
|
386
408
|
/**
|
|
387
|
-
*
|
|
388
|
-
* 核心(旧→新,人格底子先在场)→ 稳定(pin 先,再新→旧)→ 临时(新→旧)。
|
|
409
|
+
* 自述段的账。顺序就是优先级:稳定(pin 先,再新→旧)→ 临时(新→旧)。
|
|
389
410
|
* 稳定段有上限(给临时留 1/4 保底),临时段拿走剩下的全部 —— 临时条目都很短,通常吃不完。
|
|
390
411
|
* 为什么要分:混着装的时候,一条 90 字的人格自述和五条「今晚提醒」抢同一个 200 字,
|
|
391
412
|
* 抢输的总是人格那条,而且没人看得出来谁被挤掉了。
|
|
392
413
|
* 临时型还会过期:过了 tempTtlDays 就不再自动进场(数据一个字没删,只是不进上下文)。
|
|
414
|
+
*
|
|
415
|
+
* 核心型(core)不再参与注入:它是**身份素材**,只当提炼进 persona.md 的原料。
|
|
416
|
+
* 身份该由人格段(L0/L1)说,两处都说一遍等于让模型看两版自己。
|
|
417
|
+
* 存量 core 一条不删,只是不进上下文;条数靠门牌告诉模型「有这么些素材」。
|
|
393
418
|
*/
|
|
394
419
|
function packNotes(entries, limit, ttl) {
|
|
395
420
|
const ttlDays = Number.isFinite(Number(ttl)) ? Number(ttl) : DEFAULT_TEMP_TTL_DAYS
|
|
@@ -405,7 +430,6 @@ function packNotes(entries, limit, ttl) {
|
|
|
405
430
|
else temp.push(entry)
|
|
406
431
|
} else stable.push(entry)
|
|
407
432
|
}
|
|
408
|
-
const coreList = core.map((entry) => entry.text)
|
|
409
433
|
const stableList = stable
|
|
410
434
|
.slice()
|
|
411
435
|
.reverse()
|
|
@@ -413,28 +437,26 @@ function packNotes(entries, limit, ttl) {
|
|
|
413
437
|
.map((entry) => entry.text)
|
|
414
438
|
const tempList = temp.slice().reverse().map((entry) => entry.text)
|
|
415
439
|
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
const tail = Math.max(0, limit - used)
|
|
420
|
-
const tempFloor = tempList.length === 0 ? 0 : Math.min(tail, Math.max(24, Math.round(tail * TEMP_SHARE)))
|
|
421
|
-
const stableLimit = Math.max(0, tail - tempFloor)
|
|
440
|
+
// 临时先占保底份额,稳定吃剩下的 —— 顺序反过来会让临时永远抢不到位置
|
|
441
|
+
const tempFloor = tempList.length === 0 ? 0 : Math.min(limit, Math.max(24, Math.round(limit * TEMP_SHARE)))
|
|
442
|
+
const stableLimit = Math.max(0, limit - tempFloor)
|
|
422
443
|
const stableFit = fitDetailed(stableList, stableLimit)
|
|
423
|
-
used
|
|
444
|
+
const used = stableFit.text === '' ? 0 : countChars(stableFit.text) + 1
|
|
424
445
|
const tempLimit = Math.max(0, limit - used)
|
|
425
446
|
const tempFit = fitDetailed(tempList, tempLimit)
|
|
426
447
|
|
|
427
|
-
const parts = [
|
|
448
|
+
const parts = [stableFit.text, tempFit.text].filter((part) => part !== '')
|
|
428
449
|
// 过期的也算「没进上下文」:它们还在库里,模型得靠门牌知道有这么几条,
|
|
429
450
|
// 所以并进 skipped 一起报数 —— 少报就是骗人。
|
|
430
451
|
const expiredTexts = expired.map((entry) => entry.text)
|
|
431
452
|
return {
|
|
432
453
|
text: parts.join('\n'),
|
|
433
|
-
kept: [...
|
|
434
|
-
skipped: [...
|
|
435
|
-
//
|
|
454
|
+
kept: [...stableFit.kept, ...tempFit.kept],
|
|
455
|
+
skipped: [...stableFit.skipped, ...tempFit.skipped, ...expiredTexts],
|
|
456
|
+
// 每型的账:设置页要能指着说「稳定 3 条全进了,临时 2 条被挤掉」
|
|
436
457
|
tiers: {
|
|
437
|
-
|
|
458
|
+
// 核心不进注入,所以 kept 恒 0、limit 恒 0:读数上「0 / 0」比「3 / 0」更不容易被误读
|
|
459
|
+
core: { total: core.length, kept: 0, skipped: core.length, limit: 0 },
|
|
438
460
|
stable: { total: stableList.length, kept: stableFit.kept.length, skipped: stableFit.skipped.length, limit: stableLimit },
|
|
439
461
|
temp: {
|
|
440
462
|
total: tempList.length + expired.length,
|
|
@@ -453,21 +475,158 @@ function packNotes(entries, limit, ttl) {
|
|
|
453
475
|
* 所以两轮:先按全长排,用剩下的条数算门牌,再按「减掉门牌」的长度重排,直到条数不再变。
|
|
454
476
|
*/
|
|
455
477
|
function notesSegment(entries, limit, ttl) {
|
|
456
|
-
|
|
457
|
-
|
|
478
|
+
// 核心素材的门牌和自述门牌是两件事:前者是「有素材,没给你」,后者是「有自述装不下」。
|
|
479
|
+
// 素材那条也得占字数(它是要贴进上下文的字),所以先把它从额度里扣掉再打包。
|
|
480
|
+
const material = entries.filter((entry) => normalizeTier(entry?.tier) === 'core').length
|
|
481
|
+
const materialNote = material > 0 ? `另有 ${material} 条身份素材没装进来(self_note read 可看)` : ''
|
|
482
|
+
const room = materialNote === '' ? limit : Math.max(0, limit - countChars(materialNote) - 1)
|
|
483
|
+
const withMaterial = (segment) => {
|
|
484
|
+
if (materialNote === '') return segment
|
|
485
|
+
const text = segment.text === '' ? materialNote : `${segment.text} ${materialNote}`
|
|
486
|
+
return { ...segment, text }
|
|
487
|
+
}
|
|
488
|
+
const packed = packNotes(entries, room, ttl)
|
|
489
|
+
if (packed.skipped.length === 0) return { ...withMaterial(packed), pointer: '' }
|
|
458
490
|
let current = packed
|
|
459
491
|
let pointer = ''
|
|
460
492
|
for (let pass = 0; pass < 3; pass += 1) {
|
|
461
|
-
const next = `另有 ${current.skipped.length}
|
|
462
|
-
if (next.length + 1 >=
|
|
463
|
-
const refit = packNotes(entries, Math.max(0,
|
|
493
|
+
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)
|
|
464
496
|
const converged = refit.skipped.length === current.skipped.length
|
|
465
497
|
pointer = next
|
|
466
|
-
|
|
498
|
+
const joined = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
|
|
499
|
+
current = joined
|
|
467
500
|
if (converged) break
|
|
468
501
|
}
|
|
469
|
-
|
|
470
|
-
|
|
502
|
+
return { ...withMaterial(current), pointer }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 人格段:L0 + L1 常驻,L2 一个字节都不进(只留一句指路句)。
|
|
507
|
+
*
|
|
508
|
+
* 装的时候按**非空行**装、整行跳过 —— 一条规则被拦腰截断会变成另一条规则,
|
|
509
|
+
* 比没装进去更糟。唯一例外:L1 第一行就超预算时截断补 `…`,
|
|
510
|
+
* 因为「半句规则」仍强过「完全没有人格」,但必须留痕(truncated + 门牌 + doctor)。
|
|
511
|
+
*
|
|
512
|
+
* chars 只算进了上下文的 L0/L1 正文;指路句是框架文字,走 overhead
|
|
513
|
+
*(和自述段的门牌同一套口径,否则「320 / 320」这种读数会看不出是被谁撑满的)。
|
|
514
|
+
*/
|
|
515
|
+
export function personaSegment(persona, limit) {
|
|
516
|
+
const empty = {
|
|
517
|
+
text: '',
|
|
518
|
+
chars: 0,
|
|
519
|
+
kept: [],
|
|
520
|
+
skipped: [],
|
|
521
|
+
truncated: false,
|
|
522
|
+
pointer: '',
|
|
523
|
+
l0: { chars: 0, kept: 0, skipped: 0 },
|
|
524
|
+
l1: { chars: 0, kept: 0, skipped: 0 },
|
|
525
|
+
}
|
|
526
|
+
if (!persona || persona.error === 'not-text') return empty
|
|
527
|
+
|
|
528
|
+
const linesOf = (raw) =>
|
|
529
|
+
String(raw ?? '')
|
|
530
|
+
.split('\n')
|
|
531
|
+
.map((line) => line.trim())
|
|
532
|
+
.filter((line) => line !== '')
|
|
533
|
+
const rawL0 = linesOf(persona.l0)
|
|
534
|
+
const rawL1 = linesOf(persona.l1)
|
|
535
|
+
// 只写了 L2 也要指路:否则「我有一份设定」这件事模型永远不知道,L2 等于没写。
|
|
536
|
+
// 指路句不计字数,所以这里只改指针,不动预算。
|
|
537
|
+
if (rawL0.length === 0 && rawL1.length === 0) {
|
|
538
|
+
const l2Only = String(persona?.l2 ?? '').trim() !== ''
|
|
539
|
+
return { ...empty, pointer: l2Only ? PERSONA_POINTER : '' }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 一段行拼成文本要占多少字:每行的字 + 行与行之间那一个换行。
|
|
543
|
+
// 校验(persona.js 的 personaCost)用的是同一个尺子 —— 两把尺子会让人格
|
|
544
|
+
// 「保存时说 320 正好,注入时却吃掉最后两个字」。
|
|
545
|
+
const costOf = (lines) => (lines.length === 0 ? 0 : lines.reduce((sum, line) => sum + countChars(line), 0) + lines.length - 1)
|
|
546
|
+
|
|
547
|
+
let truncated = false
|
|
548
|
+
// L0 硬上限 80:文件被外部改超了,运行期截到 80 并留痕,不整段丢。
|
|
549
|
+
// 口径同 personaCost —— 含行间换行,否则「校验 80 / 注入 81」又能差一个字。
|
|
550
|
+
const l0 = []
|
|
551
|
+
let l0Chars = 0
|
|
552
|
+
for (const line of rawL0) {
|
|
553
|
+
const width = countChars(line)
|
|
554
|
+
const need = l0.length === 0 ? width : l0Chars + 1 + width
|
|
555
|
+
if (need <= PERSONA_L0_MAX) {
|
|
556
|
+
l0.push(line)
|
|
557
|
+
l0Chars = need
|
|
558
|
+
continue
|
|
559
|
+
}
|
|
560
|
+
const room = l0.length === 0 ? PERSONA_L0_MAX : PERSONA_L0_MAX - l0Chars - 1
|
|
561
|
+
if (room > 1) l0.push([...line].slice(0, room - 1).join('') + '…')
|
|
562
|
+
l0Chars = PERSONA_L0_MAX
|
|
563
|
+
truncated = true
|
|
564
|
+
break
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const packLines = (lines, startLines, startCost, budget) => {
|
|
568
|
+
const kept = [...startLines]
|
|
569
|
+
const skipped = []
|
|
570
|
+
let used = startCost
|
|
571
|
+
for (const line of lines) {
|
|
572
|
+
const width = countChars(line)
|
|
573
|
+
// 接在后面就要一个换行,所以是 used + 1 + width;第一行前面没有换行
|
|
574
|
+
const need = kept.length === startLines.length && startLines.length === 0 ? width : used + 1 + width
|
|
575
|
+
if (need <= budget) { kept.push(line); used = need; continue }
|
|
576
|
+
if (kept.length === startLines.length) {
|
|
577
|
+
// 第一行就超预算:截断补 …,别让人格整段消失
|
|
578
|
+
const room = Math.max(0, budget - used - 1)
|
|
579
|
+
if (room > 1) kept.push([...line].slice(0, room - 1).join('') + '…')
|
|
580
|
+
used = budget
|
|
581
|
+
truncated = true
|
|
582
|
+
continue
|
|
583
|
+
}
|
|
584
|
+
skipped.push(line)
|
|
585
|
+
}
|
|
586
|
+
return { kept, used, skipped }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const l0Cost = costOf(l0)
|
|
590
|
+
const packed = packLines(rawL1, l0, l0Cost, limit)
|
|
591
|
+
let kept = packed.kept
|
|
592
|
+
let skipped = packed.skipped
|
|
593
|
+
|
|
594
|
+
// 门牌也要收敛:它自己占字,加进去可能又挤掉一行,行数一变数字就错
|
|
595
|
+
let skippedNote = ''
|
|
596
|
+
for (let pass = 0; pass < 3 && skipped.length > 0; pass += 1) {
|
|
597
|
+
const nextNote = `另有 ${skipped.length} 行人格规则没装进来`
|
|
598
|
+
if (countChars(nextNote) + 1 >= limit) break
|
|
599
|
+
const refit = packLines(rawL1, l0, l0Cost, limit - countChars(nextNote) - 1)
|
|
600
|
+
const converged = refit.skipped.length === skipped.length
|
|
601
|
+
kept = refit.kept
|
|
602
|
+
skipped = refit.skipped
|
|
603
|
+
if (converged) { skippedNote = nextNote; break }
|
|
604
|
+
skippedNote = nextNote
|
|
605
|
+
}
|
|
606
|
+
// 收敛不了就别印门牌:印一个和实际跳过数对不上的数字比不印更糟
|
|
607
|
+
if (skippedNote !== '' && !new RegExp(`另有 ${skipped.length} 行`).test(skippedNote)) skippedNote = ''
|
|
608
|
+
|
|
609
|
+
// 按「谁属于哪一段」切:packLines 是把 L0 原样前置的,前 l0.length 行一定是 L0。
|
|
610
|
+
//(原来按文字相等去数,L1 里有一行和 L0 同文就会数成两行,能印出「另有 -1 行」。)
|
|
611
|
+
const l0Kept = Math.min(l0.length, kept.length)
|
|
612
|
+
const l1Kept = kept.slice(l0Kept)
|
|
613
|
+
const body = kept.join('\n')
|
|
614
|
+
const text = skippedNote === '' ? body : `${body} ${skippedNote}`
|
|
615
|
+
const l0CharsOut = costOf(l0.slice(0, l0Kept))
|
|
616
|
+
const l1CharsOut = costOf(l1Kept)
|
|
617
|
+
const sep = l0Kept > 0 && l1Kept.length > 0 ? 1 : 0
|
|
618
|
+
const noteCost = skippedNote === '' ? 0 : 1 + countChars(skippedNote)
|
|
619
|
+
const hasL2 = String(persona?.l2 ?? '').trim() !== ''
|
|
620
|
+
return {
|
|
621
|
+
text,
|
|
622
|
+
chars: l0CharsOut + sep + l1CharsOut + noteCost,
|
|
623
|
+
kept,
|
|
624
|
+
skipped,
|
|
625
|
+
truncated,
|
|
626
|
+
pointer: hasL2 ? PERSONA_POINTER : '',
|
|
627
|
+
l0: { chars: l0CharsOut, kept: l0Kept, skipped: rawL0.length - l0Kept },
|
|
628
|
+
l1: { chars: l1CharsOut, kept: l1Kept.length, skipped: skipped.length },
|
|
629
|
+
}
|
|
471
630
|
}
|
|
472
631
|
|
|
473
632
|
/** 结论段打包:要连 id 一起留下(归属统计靠它),所以不能直接用 fitDetailed。 */
|
|
@@ -478,12 +637,13 @@ function packFacts(ranked, limit) {
|
|
|
478
637
|
for (const item of ranked) {
|
|
479
638
|
const text = String(item?.text ?? '').trim()
|
|
480
639
|
if (text === '') continue
|
|
481
|
-
|
|
640
|
+
const width = countChars(text)
|
|
641
|
+
if (used + width > limit) {
|
|
482
642
|
skipped.push(text)
|
|
483
643
|
continue
|
|
484
644
|
}
|
|
485
645
|
kept.push(item)
|
|
486
|
-
used +=
|
|
646
|
+
used += width + 1
|
|
487
647
|
}
|
|
488
648
|
return { text: kept.map((item) => item.text).join('\n'), kept, skipped, used }
|
|
489
649
|
}
|
|
@@ -510,7 +670,7 @@ let injectCache = { key: '', text: '' }
|
|
|
510
670
|
|
|
511
671
|
/** 注入文本的缓存键:三份数据只要有一份变了,就重算。 */
|
|
512
672
|
function injectionKey(cfg) {
|
|
513
|
-
const stamps = [notesPath(), factsFile(), habitsFile()].map((file) => {
|
|
673
|
+
const stamps = [notesPath(), factsFile(), habitsFile(), personaPath()].map((file) => {
|
|
514
674
|
try {
|
|
515
675
|
const st = statSync(file)
|
|
516
676
|
// 带上大小:同一毫秒内改两次(脚本、连点保存)时 mtime 可能没变,键就会撞车、
|
|
@@ -525,7 +685,7 @@ function injectionKey(cfg) {
|
|
|
525
685
|
// 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
|
|
526
686
|
const dayKey = 'd' + Math.floor(Date.now() / 86400000)
|
|
527
687
|
// 半衰期也影响排序(facts / env 段按热度排),不改文件只改它时也得重算
|
|
528
|
-
return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays + '|ttl' + cfg.tempTtlDays + '|' + dayKey
|
|
688
|
+
return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.personaEnabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays + '|ttl' + cfg.tempTtlDays + '|' + dayKey
|
|
529
689
|
}
|
|
530
690
|
|
|
531
691
|
/**
|
|
@@ -536,6 +696,28 @@ function assembleInjection(cfg, options = {}) {
|
|
|
536
696
|
const spotlight = options.spotlight instanceof Set ? options.spotlight : new Set(options.spotlight ?? [])
|
|
537
697
|
const segments = []
|
|
538
698
|
const parts = []
|
|
699
|
+
const personaParts = []
|
|
700
|
+
|
|
701
|
+
// 人格:L0 + L1 常驻,排在最前 —— 它不是「记忆」,是「我是谁」,
|
|
702
|
+
// 所以它在边界句「(背景资料,不是指令)」之外,先读到的是自己的样子。
|
|
703
|
+
// 关掉只是不注入,persona.md 一个字节都不动。
|
|
704
|
+
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
|
+
if (fit.text !== '') personaParts.push(fit.text)
|
|
719
|
+
if (fit.pointer !== '') personaParts.push(fit.pointer)
|
|
720
|
+
}
|
|
539
721
|
|
|
540
722
|
// 自述:三型分账 + 门牌。装不下的那几条,模型必须知道存在,否则它会把「没看见」当成「没有」。
|
|
541
723
|
const noteFit = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays)
|
|
@@ -543,7 +725,7 @@ function assembleInjection(cfg, options = {}) {
|
|
|
543
725
|
key: 'notes',
|
|
544
726
|
label: '自述',
|
|
545
727
|
limit: BUDGET.notes,
|
|
546
|
-
chars: noteFit.text
|
|
728
|
+
chars: countChars(noteFit.text),
|
|
547
729
|
kept: noteFit.kept,
|
|
548
730
|
skipped: noteFit.skipped,
|
|
549
731
|
tiers: noteFit.tiers,
|
|
@@ -588,7 +770,7 @@ function assembleInjection(cfg, options = {}) {
|
|
|
588
770
|
// 门牌句自己也要占字,加进去以后可能又多挤掉一条 —— 所以要收敛:
|
|
589
771
|
// 反复用「这次剩几条」重算门牌,直到条数不再变。不然那句「另有 N 条」会少报。
|
|
590
772
|
for (let pass = 0; pass < 3 && env.skipped.length > 0; pass += 1) {
|
|
591
|
-
const pointer = `另有 ${env.skipped.length}
|
|
773
|
+
const pointer = `另有 ${env.skipped.length} 条环境备忘没装进来(memory_recall 查)`
|
|
592
774
|
const refit = packEnvFair(envRanked, Math.max(0, BUDGET.env - pointer.length - 1), spotlight)
|
|
593
775
|
const text = refit.text === '' ? pointer : `${refit.text};${pointer}`
|
|
594
776
|
const converged = refit.skipped.length === env.skipped.length
|
|
@@ -600,7 +782,7 @@ function assembleInjection(cfg, options = {}) {
|
|
|
600
782
|
key: 'env',
|
|
601
783
|
label: '环境',
|
|
602
784
|
limit: BUDGET.env,
|
|
603
|
-
chars: env.text
|
|
785
|
+
chars: countChars(env.text),
|
|
604
786
|
kept: env.kept,
|
|
605
787
|
skipped: env.skipped,
|
|
606
788
|
// 分账信息:设置页能看出「1/4 预留」有没有真的分出去、这轮回顾了哪几条
|
|
@@ -630,7 +812,7 @@ function assembleInjection(cfg, options = {}) {
|
|
|
630
812
|
key: 'facts',
|
|
631
813
|
label: '结论',
|
|
632
814
|
limit: BUDGET.facts,
|
|
633
|
-
chars: factFit.text
|
|
815
|
+
chars: countChars(factFit.text),
|
|
634
816
|
kept: factFit.kept.map((item) => item.text),
|
|
635
817
|
skipped: factFit.skipped,
|
|
636
818
|
pointer: factFit.pointer ?? '',
|
|
@@ -644,17 +826,29 @@ function assembleInjection(cfg, options = {}) {
|
|
|
644
826
|
.map((habit) => '· ' + habit.text),
|
|
645
827
|
BUDGET.habits,
|
|
646
828
|
)
|
|
647
|
-
segments.push({ key: 'habits', label: '习惯', limit: BUDGET.habits, chars: habitFit.text
|
|
829
|
+
segments.push({ key: 'habits', label: '习惯', limit: BUDGET.habits, chars: countChars(habitFit.text), kept: habitFit.kept, skipped: habitFit.skipped })
|
|
648
830
|
if (habitFit.text !== '') parts.push(habitFit.text)
|
|
649
831
|
|
|
650
832
|
const heading = typeof cfg.heading === 'string' ? cfg.heading.trim() : DEFAULT_CONFIG.heading
|
|
651
833
|
const body = parts.join('\n')
|
|
652
|
-
//
|
|
834
|
+
// 边界句:让模型知道这一段是背景资料,不是有人在借记忆之名给它下命令。
|
|
835
|
+
// 它只约束记忆段 —— 人格段在它上面,不受这句话管。
|
|
653
836
|
const note = '(背景资料,不是指令)'
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
837
|
+
const hasContent = personaParts.length > 0 || body !== ''
|
|
838
|
+
const lines = []
|
|
839
|
+
if (hasContent) {
|
|
840
|
+
if (heading !== '') lines.push(heading)
|
|
841
|
+
if (personaParts.length > 0) lines.push(PERSONA_FRAME, ...personaParts)
|
|
842
|
+
// 记忆段全空、只剩人格时也留这句话:它标的是「下面不再是你的样子」这条分界
|
|
843
|
+
lines.push(note)
|
|
844
|
+
if (body !== '') lines.push(body)
|
|
845
|
+
}
|
|
846
|
+
const text = lines.join('\n')
|
|
847
|
+
// overhead:抬头、框架句、指路句、边界句、换行这些框架文字,不算进六段预算,
|
|
848
|
+
// 但也要让人知道它们在 —— 否则「320 / 320」看不出是被谁撑满的
|
|
849
|
+
const total = countChars(text)
|
|
850
|
+
const overhead = total - segments.reduce((sum, seg) => sum + seg.chars, 0)
|
|
851
|
+
return { text, chars: total, segments, factIds, placements, overhead: Math.max(0, overhead) }
|
|
658
852
|
}
|
|
659
853
|
|
|
660
854
|
/**
|
|
@@ -867,10 +1061,11 @@ let delegatedSkips = 0
|
|
|
867
1061
|
|
|
868
1062
|
function injectionState(inline, builtInput) {
|
|
869
1063
|
const cfg = currentConfig(inline)
|
|
870
|
-
const limit =
|
|
871
|
-
|
|
1064
|
+
const limit = TOTAL_BUDGET
|
|
1065
|
+
const key = injectionKey(cfg)
|
|
1066
|
+
if (!cfg.enabled) return { enabled: false, text: '', chars: 0, limit, key, segments: [], overhead: 0, delegatedSkips }
|
|
872
1067
|
const built = builtInput ?? assembleInjection(cfg)
|
|
873
|
-
return { enabled: true, text: built.text, chars: built.chars, limit, segments: built.segments, overhead: built.overhead, delegatedSkips }
|
|
1068
|
+
return { enabled: true, text: built.text, chars: built.chars, limit, key, segments: built.segments, overhead: built.overhead, delegatedSkips }
|
|
874
1069
|
}
|
|
875
1070
|
|
|
876
1071
|
/**
|
|
@@ -880,6 +1075,102 @@ function injectionState(inline, builtInput) {
|
|
|
880
1075
|
* 鉴权在拒人 —— 这些以前都只是某个数字看着不对,没人会为它停手。
|
|
881
1076
|
* 每项给一个明确判据,不写「看起来正常」这种话。
|
|
882
1077
|
*/
|
|
1078
|
+
/* ---------------- 人格与纠偏:设置页/工具/体检共用的一份读数 ---------------- */
|
|
1079
|
+
|
|
1080
|
+
const CORRECTIONS_HINT =
|
|
1081
|
+
'你只能读:证据由插件从对话里自动采集,规则改写成什么样由人确认后保存 —— 你没有写入权限。' +
|
|
1082
|
+
'要把某几条变成行为规则,得在设置页「人格」页由人确认。'
|
|
1083
|
+
|
|
1084
|
+
const pendingTxPath = () => join(selfnoteRoot(), 'persona.pending.json')
|
|
1085
|
+
|
|
1086
|
+
/** L2 设了不看 = 死配置:30 天没人取过就该在体检里说一句。 */
|
|
1087
|
+
function staleRead(info, now = Date.now()) {
|
|
1088
|
+
if (info.reads === 0) return true
|
|
1089
|
+
const at = Date.parse(info.lastReadAt)
|
|
1090
|
+
if (!Number.isFinite(at)) return true
|
|
1091
|
+
return now - at > 30 * 86400000
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** 人格的三段字数与取用记录:每次现读文件,不缓存 —— 人格是外面也能改的文件。 */
|
|
1095
|
+
function personaStateOf(inline = false) {
|
|
1096
|
+
const cfg = currentConfig(inline)
|
|
1097
|
+
const persona = readPersona()
|
|
1098
|
+
const l0 = String(persona.l0 ?? '')
|
|
1099
|
+
const l1 = String(persona.l1 ?? '')
|
|
1100
|
+
const l2 = String(persona.l2 ?? '')
|
|
1101
|
+
const l0Chars = countChars(l0)
|
|
1102
|
+
const l1Chars = countChars(l1)
|
|
1103
|
+
const l2Chars = countChars(l2)
|
|
1104
|
+
const stats = readPersonaStats()
|
|
1105
|
+
return {
|
|
1106
|
+
enabled: cfg.personaEnabled !== false,
|
|
1107
|
+
l0,
|
|
1108
|
+
l1,
|
|
1109
|
+
l2,
|
|
1110
|
+
chars: l0Chars + l1Chars + l2Chars,
|
|
1111
|
+
l0Chars,
|
|
1112
|
+
l1Chars,
|
|
1113
|
+
l2Chars,
|
|
1114
|
+
l0Limit: PERSONA_L0_MAX,
|
|
1115
|
+
// L0 与 L1 之间那个换行也算字数(与注入、校验同一把尺子),
|
|
1116
|
+
// 否则界面按「320 - L0」给出上限,填满了也存不进去。
|
|
1117
|
+
l1Limit: Math.max(0, PERSONA_TOTAL - Math.min(l0Chars, PERSONA_L0_MAX) - (l0Chars > 0 ? 1 : 0)),
|
|
1118
|
+
// L2 的上限得跟服务端同一个尺子:界面上写 8000 而这里按 chars 算,才不会两边对不上
|
|
1119
|
+
l2Limit: Math.max(0, MAX_PERSONA_CHARS - (l0Chars + l1Chars + l2Chars)),
|
|
1120
|
+
fileLimit: MAX_PERSONA_CHARS,
|
|
1121
|
+
sectionless: persona.sectionless === true,
|
|
1122
|
+
truncated: l0Chars > PERSONA_L0_MAX,
|
|
1123
|
+
error: persona.error ?? '',
|
|
1124
|
+
reads: stats.reads,
|
|
1125
|
+
lastReadAt: stats.lastReadAt,
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function correctionsStateOf() {
|
|
1130
|
+
try {
|
|
1131
|
+
const events = readEvents()
|
|
1132
|
+
const watermark = readWatermark()
|
|
1133
|
+
const page = listCorrections({ limit: 30 })
|
|
1134
|
+
return {
|
|
1135
|
+
total: events.length,
|
|
1136
|
+
unused: events.filter((entry) => !entry.used).length,
|
|
1137
|
+
lastScannedAt: String(watermark?.lastScannedAt ?? ''),
|
|
1138
|
+
// 界面靠这个列表渲染勾选区;只给计数的话「证据 → 规则」那条链路整段不可用
|
|
1139
|
+
items: page.items,
|
|
1140
|
+
nextCursor: page.nextCursor,
|
|
1141
|
+
}
|
|
1142
|
+
} catch {
|
|
1143
|
+
return { total: 0, unused: 0, lastScannedAt: '', items: [], nextCursor: null }
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* 从 ~/.dsh/AGENT.md 读一篇旧人格,按标题初分类成三段。
|
|
1149
|
+
*
|
|
1150
|
+
* 只读、只返回,**不保存** —— 导入不等于采纳:哪些是行为规则、哪些只是背景,
|
|
1151
|
+
* 得人自己过一遍。全程不调模型,纯关键词。
|
|
1152
|
+
*/
|
|
1153
|
+
function importPersonaFromAgentMd() {
|
|
1154
|
+
const file = join(dirname(memoryRoot()), 'AGENT.md')
|
|
1155
|
+
// 文件不在也给三段空串:界面三个框照常渲染,形状固定,前端不用分支
|
|
1156
|
+
if (!existsSync(file)) return { ok: false, exists: false, error: `没找到 ${file}`, source: file, l0: '', l1: '', l2: '' }
|
|
1157
|
+
const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
|
|
1158
|
+
const buckets = { l0: [], l1: [], l2: [] }
|
|
1159
|
+
let current = 'l1'
|
|
1160
|
+
const sections = raw.split(/^(#{1,3}\s+.*)$/m)
|
|
1161
|
+
const head = sections.shift() ?? ''
|
|
1162
|
+
if (head.trim() !== '') buckets.l1.push(head.trim())
|
|
1163
|
+
for (let i = 0; i < sections.length; i += 2) {
|
|
1164
|
+
const title = (sections[i] ?? '').replace(/^#{1,3}\s+/, '').trim()
|
|
1165
|
+
const body = (sections[i + 1] ?? '').trim()
|
|
1166
|
+
if (/核心身份|身份|我是谁|称呼|名字/.test(title)) current = 'l0'
|
|
1167
|
+
else if (/外貌|服饰|外观|长相|世界观|背景|兴趣|爱好|细节|设定/.test(title)) current = 'l2'
|
|
1168
|
+
else current = 'l1'
|
|
1169
|
+
if (body !== '') buckets[current].push(body)
|
|
1170
|
+
}
|
|
1171
|
+
return { ok: true, exists: true, source: file, l0: buckets.l0.join('\n\n'), l1: buckets.l1.join('\n\n'), l2: buckets.l2.join('\n\n') }
|
|
1172
|
+
}
|
|
1173
|
+
|
|
883
1174
|
function doctorState(inline) {
|
|
884
1175
|
const checks = []
|
|
885
1176
|
const push = (key, label, level, detail, fix) => checks.push({ key, label, level, detail, fix: fix ?? '' })
|
|
@@ -898,8 +1189,12 @@ function doctorState(inline) {
|
|
|
898
1189
|
const info = indexStats(sessions)
|
|
899
1190
|
if (!info.present) {
|
|
900
1191
|
push('index', '索引一致性', 'warn', '还没建索引,检索只能线性扫描(慢,而且会标不完整)', '重建索引')
|
|
901
|
-
} else if (info.
|
|
902
|
-
|
|
1192
|
+
} else if (info.absent > 0 || info.stale > 0) {
|
|
1193
|
+
// 分开说:「没进去」和「建完之后又改过」是两件事,合成一个数会跟上面那组 N/M 对不上
|
|
1194
|
+
const parts = []
|
|
1195
|
+
if (info.absent > 0) parts.push(`${info.absent} 个没进去`)
|
|
1196
|
+
if (info.stale > 0) parts.push(`${info.stale} 个建完之后又改过`)
|
|
1197
|
+
push('index', '索引一致性', 'warn', `${info.sessions}/${info.disk} 个会话在索引里,${parts.join('、')}`, '重建索引')
|
|
903
1198
|
} else if (!info.fresh) {
|
|
904
1199
|
push('index', '索引一致性', 'warn', `索引已过期(${info.sessions} 个会话,磁盘 ${info.disk} 个)`, '重建索引')
|
|
905
1200
|
} else {
|
|
@@ -1022,7 +1317,56 @@ function doctorState(inline) {
|
|
|
1022
1317
|
push('auth', '设置页准入', 'error', `auth.log 读不了:${error?.message ?? error}`)
|
|
1023
1318
|
}
|
|
1024
1319
|
|
|
1025
|
-
// 6.
|
|
1320
|
+
// 6. 人格文件:没写 = 没有身份;写坏了 = 每回合都在贴一份截断的
|
|
1321
|
+
try {
|
|
1322
|
+
const info = personaStateOf(inline)
|
|
1323
|
+
const pending = existsSync(pendingTxPath())
|
|
1324
|
+
const lock = lockState()
|
|
1325
|
+
if (info.error === 'missing') {
|
|
1326
|
+
push('persona', '人格', 'warn', '人格文件还没写(persona.md)—— 注入里没有身份与行为规则', '在「人格」页写三段')
|
|
1327
|
+
} else if (info.error === 'not-text') {
|
|
1328
|
+
push('persona', '人格', 'warn', 'persona.md 读不了(太大或含二进制)', '换回纯文本再试')
|
|
1329
|
+
} else if (info.chars > MAX_PERSONA_CHARS) {
|
|
1330
|
+
push('persona', '人格', 'warn', `persona.md 合计 ${info.chars} 字,超过 ${MAX_PERSONA_CHARS} 字上限(只在读 L2 时截断)`, '把 L2 写短或拆到别处')
|
|
1331
|
+
} else if (lock.stale) {
|
|
1332
|
+
push('persona', '人格', 'warn', `persona.lock 是上次异常退出留下的(pid ${lock.pid || '未知'})—— 下次保存时会自动清掉`, '清不掉就手动删它')
|
|
1333
|
+
} else if (pending) {
|
|
1334
|
+
push('persona', '人格', 'warn', '上次保存没走完(persona.pending.json 还在)—— 下次启动会自动补或回滚', '重启 DSH')
|
|
1335
|
+
} else if (info.sectionless) {
|
|
1336
|
+
push('persona', '人格', 'info', '整篇被当成 L1(没找到 L0/L1/L2 标题)—— 建议分段', '按标题改成三段')
|
|
1337
|
+
} else if (info.truncated) {
|
|
1338
|
+
push('persona', '人格', 'warn', `L0 有 ${info.l0Chars} 字,超过 ${PERSONA_L0_MAX} 字,注入时只给前 ${PERSONA_L0_MAX} 字`, '把 L0 写短')
|
|
1339
|
+
} else if (info.l0Chars === 0 && info.l1Chars === 0) {
|
|
1340
|
+
const material = readNotes().filter((entry) => normalizeTier(entry?.tier) === 'core').length
|
|
1341
|
+
if (material > 0) {
|
|
1342
|
+
push('persona', '人格', 'warn', `有 ${material} 条身份素材没提炼进人格 —— 它们一个都不进上下文`, '从自述里提炼到 L0/L1')
|
|
1343
|
+
} else {
|
|
1344
|
+
push('persona', '人格', 'warn', 'L0 和 L1 都是空的 —— 模型每回合都不知道自己是谁', '写 L0/L1')
|
|
1345
|
+
}
|
|
1346
|
+
} else if (info.l2Chars > 0 && staleRead(info)) {
|
|
1347
|
+
push('persona', '人格', 'info', `L2 有 ${info.l2Chars} 字,但 30 天没人取过 —— 可能是白写的设定`, '要么删掉,要么真去用')
|
|
1348
|
+
} else {
|
|
1349
|
+
push('persona', '人格', 'ok', `L0 ${info.l0Chars} / L1 ${info.l1Chars} / L2 ${info.l2Chars} 字`)
|
|
1350
|
+
}
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
push('persona', '人格', 'error', `persona.md 读不了:${error?.message ?? error}`)
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// 7. 纠偏证据:攒了没人看,等于没攒
|
|
1356
|
+
try {
|
|
1357
|
+
const info = correctionsStateOf()
|
|
1358
|
+
if (info.total === 0) {
|
|
1359
|
+
push('corrections', '纠偏', 'ok', '还没攒到纠偏证据(从启用那一刻开始攒,不回填历史)')
|
|
1360
|
+
} else if (info.unused >= 20) {
|
|
1361
|
+
push('corrections', '纠偏', 'info', `${info.total} 条证据里有 ${info.unused} 条没处理 —— 该看一看了`, '打开「人格」页的候选区')
|
|
1362
|
+
} else {
|
|
1363
|
+
push('corrections', '纠偏', 'ok', `收集 ${info.total} 条,未处理 ${info.unused} 条`)
|
|
1364
|
+
}
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
push('corrections', '纠偏', 'error', `纠偏证据读不了:${error?.message ?? error}`)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// 8. 心跳:注入到底有没有在跑 —— 这是唯一能证明「插件活着」的证据
|
|
1026
1370
|
try {
|
|
1027
1371
|
const beatState = readHeartbeat()
|
|
1028
1372
|
if (beatState === null) {
|
|
@@ -1047,7 +1391,7 @@ function doctorState(inline) {
|
|
|
1047
1391
|
// 「先做这一件」:体检列十条,人只会动第一条 —— 那就替他把顺序排好。
|
|
1048
1392
|
// 判据:数据完整性最优先(丢了补不回来),其次检索能不能用,再次采集,
|
|
1049
1393
|
// 鉴权只影响设置页、自述和心跳是装饰,排在后面。
|
|
1050
|
-
const PRIORITY = { facts: 60, index: 50, 'index-failed': 45, capture: 40, auth: 30, notes: 20, beat: 10 }
|
|
1394
|
+
const PRIORITY = { facts: 60, index: 50, 'index-failed': 45, capture: 40, auth: 30, persona: 35, notes: 20, corrections: 15, beat: 10 }
|
|
1051
1395
|
const pending = checks.filter((item) => item.level !== 'ok')
|
|
1052
1396
|
pending.sort((a, b) => (PRIORITY[b.key] ?? 0) - (PRIORITY[a.key] ?? 0))
|
|
1053
1397
|
const first = pending[0]
|
|
@@ -1161,7 +1505,22 @@ function memoryFiles(sessions) {
|
|
|
1161
1505
|
}
|
|
1162
1506
|
const index = indexStats()
|
|
1163
1507
|
|
|
1508
|
+
const personaInfo = readPersona()
|
|
1509
|
+
const personaChars =
|
|
1510
|
+
personaInfo.error === 'missing'
|
|
1511
|
+
? '还没写'
|
|
1512
|
+
: personaInfo.error === 'not-text'
|
|
1513
|
+
? '读不了'
|
|
1514
|
+
: countChars(personaInfo.l0) + countChars(personaInfo.l1) + countChars(personaInfo.l2) + ' 字'
|
|
1515
|
+
|
|
1164
1516
|
const specs = [
|
|
1517
|
+
{
|
|
1518
|
+
key: 'persona',
|
|
1519
|
+
label: '人格',
|
|
1520
|
+
detail: 'L0 / L1 每回合进,L2 按需取;整份原子保存',
|
|
1521
|
+
path: personaPath(),
|
|
1522
|
+
count: personaChars,
|
|
1523
|
+
},
|
|
1165
1524
|
{
|
|
1166
1525
|
key: 'notes',
|
|
1167
1526
|
label: '自述',
|
|
@@ -1391,6 +1750,7 @@ function stateOfInner(inline) {
|
|
|
1391
1750
|
// 每行带上「到期没有」:设置页要能把过期的临时自述标出来,而不是让人以为它这回合还在
|
|
1392
1751
|
notes: readNotes().map((entry) => ({ ...entry, expired: isExpiredNote(entry, cfg.tempTtlDays) })),
|
|
1393
1752
|
tempTtlDays: cfg.tempTtlDays,
|
|
1753
|
+
halfLifeDays: cfg.halfLifeDays,
|
|
1394
1754
|
noteGuide: cfg.noteGuide,
|
|
1395
1755
|
notesLastWrite: s.lastWrite,
|
|
1396
1756
|
notesFile: s.notesFile,
|
|
@@ -1403,6 +1763,8 @@ function stateOfInner(inline) {
|
|
|
1403
1763
|
runtime: runtimeInfo(),
|
|
1404
1764
|
files: memoryFiles(),
|
|
1405
1765
|
index: indexState(),
|
|
1766
|
+
persona: personaStateOf(inline),
|
|
1767
|
+
corrections: correctionsStateOf(),
|
|
1406
1768
|
budget: budgetOf(inline),
|
|
1407
1769
|
injection: injectionState(inline, builtNow),
|
|
1408
1770
|
attribution: attributionState(inline, builtNow, factsNow),
|
|
@@ -1472,8 +1834,10 @@ function currentProfileText(cfg) {
|
|
|
1472
1834
|
/** 本次注入实际占了多少字,按段拆开给设置页看。 */
|
|
1473
1835
|
function budgetOf(inline) {
|
|
1474
1836
|
const cfg = currentConfig(inline)
|
|
1475
|
-
|
|
1837
|
+
const limit = TOTAL_BUDGET
|
|
1838
|
+
if (!cfg.enabled) return { total: 0, limit, persona: 0, notes: 0, profile: 0, env: 0, facts: 0, habits: 0, enabled: false, caps: { ...BUDGET } }
|
|
1476
1839
|
// 和 assembleInjection 用同一套分账打包,否则设置页的读数跟真注入对不上(门牌也要算进去)
|
|
1840
|
+
const persona = cfg.personaEnabled === false ? 0 : personaSegment(readPersona(), BUDGET.persona).chars
|
|
1477
1841
|
const notes = notesSegment(readNotes(), BUDGET.notes, cfg.tempTtlDays).text
|
|
1478
1842
|
const profile = currentProfile(cfg)
|
|
1479
1843
|
const env = packEnvFair(envForInjection(cfg), BUDGET.env).text
|
|
@@ -1481,13 +1845,14 @@ function budgetOf(inline) {
|
|
|
1481
1845
|
const habits = fit(readHabits().filter((habit) => habit.status === 'active').map((habit) => '· ' + habit.text), BUDGET.habits)
|
|
1482
1846
|
return {
|
|
1483
1847
|
enabled: true,
|
|
1484
|
-
limit
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1848
|
+
limit,
|
|
1849
|
+
persona,
|
|
1850
|
+
notes: countChars(notes),
|
|
1851
|
+
profile: countChars(profile.text),
|
|
1852
|
+
env: countChars(env),
|
|
1853
|
+
facts: countChars(facts),
|
|
1854
|
+
habits: countChars(habits),
|
|
1855
|
+
total: persona + countChars(notes) + countChars(profile.text) + countChars(env) + countChars(facts) + countChars(habits),
|
|
1491
1856
|
// 每段的上限也交出去:设置页要能说清「这是写死的,不是你能调的」,而不是让人对着余量瞎调
|
|
1492
1857
|
caps: { ...BUDGET },
|
|
1493
1858
|
}
|
|
@@ -1504,13 +1869,20 @@ function noteCapacityWarning(tier, text, inline) {
|
|
|
1504
1869
|
if (cfg.enabled === false) return '注入已关闭,这条暂时不会进上下文。'
|
|
1505
1870
|
const key = normalizeTier(tier)
|
|
1506
1871
|
const body = String(text ?? '')
|
|
1507
|
-
|
|
1508
|
-
if (
|
|
1509
|
-
return
|
|
1872
|
+
// 核心是素材,不进注入:这时候说「超了额度」是骗人,得直接说清楚它去哪儿了
|
|
1873
|
+
if (key === 'core') {
|
|
1874
|
+
return '核心身份不再参与注入(它是提炼人格的素材)—— 想让它每回合生效,写进人格段:self_note action:"persona"。'
|
|
1875
|
+
}
|
|
1876
|
+
// 拿真实额度试装一次:把这条当成已经写进去,看它自己进不进得来。
|
|
1877
|
+
// 光比「整段 200 字」不够 —— 稳定先占一份、临时只留 1/4 保底,
|
|
1878
|
+
// 一条 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} 字,别的型先占了位),模型每回合都看不到它 —— 要么写短,要么拆成几条。`
|
|
1510
1884
|
}
|
|
1511
|
-
|
|
1512
|
-
if (info.skipped > 0) return `${TIER_WORD[key]}现在共 ${info.total} 条,这轮有 ${info.skipped} 条被挤掉(排在前面的才进上下文)。`
|
|
1513
|
-
return ''
|
|
1885
|
+
return `${TIER_WORD[key]}现在共 ${info.total} 条,这轮有 ${info.skipped} 条被挤掉(排在前面的才进上下文)。`
|
|
1514
1886
|
}
|
|
1515
1887
|
|
|
1516
1888
|
function renderText(args, value) {
|
|
@@ -1958,6 +2330,19 @@ export function apply(ctx, config) {
|
|
|
1958
2330
|
|
|
1959
2331
|
initAuthToken()
|
|
1960
2332
|
|
|
2333
|
+
// 上次保存人格如果崩在中途,这里补完:事务日志存的是「目标状态」不是增量,
|
|
2334
|
+
// 所以重放只是把文件写成该有的样子,天然幂等。补不上就让它留在原地,体检会报。
|
|
2335
|
+
try {
|
|
2336
|
+
const recovery = recover()
|
|
2337
|
+
if (recovery.action === 'replayed') {
|
|
2338
|
+
ctx?.logger?.info?.('[selfnote] 上次人格保存已补完(' + (recovery.txId ?? '无日志号') + ')')
|
|
2339
|
+
} else if (recovery.action === 'quarantined') {
|
|
2340
|
+
ctx?.logger?.warn?.('[selfnote] 人格事务日志解不开,已改名留档:' + (recovery.file ?? 'persona.pending.bad-*.json'))
|
|
2341
|
+
}
|
|
2342
|
+
} catch (err) {
|
|
2343
|
+
ctx?.logger?.warn?.('[selfnote] 人格事务恢复失败:' + (err && err.message ? err.message : err))
|
|
2344
|
+
}
|
|
2345
|
+
|
|
1961
2346
|
// 把 token 交给页面:只有同源的设置页读得到这段脚本,跨站页面读不到 HTML。
|
|
1962
2347
|
if (typeof ctx?.webServer?.tapIndex === 'function') {
|
|
1963
2348
|
const disposeTap = ctx.webServer.tapIndex(injectTokenScript)
|
|
@@ -2027,6 +2412,37 @@ export function apply(ctx, config) {
|
|
|
2027
2412
|
text: s.archiveFiles === 0 ? '还没有归档。' : `归档在 ${archiveDir()}(${s.archiveFiles} 个文件)。`,
|
|
2028
2413
|
}
|
|
2029
2414
|
}
|
|
2415
|
+
if (action === 'persona') {
|
|
2416
|
+
const persona = readPersona()
|
|
2417
|
+
const l2 = String(persona.l2 ?? '')
|
|
2418
|
+
if (l2.trim() === '') {
|
|
2419
|
+
return { action, text: '人格文件里还没写 L2 扩展设定(persona.md)。', chars: 0, truncated: false, empty: true }
|
|
2420
|
+
}
|
|
2421
|
+
const total = countChars(l2)
|
|
2422
|
+
const kept = total > MAX_PERSONA_CHARS ? [...l2].slice(0, MAX_PERSONA_CHARS).join('') : l2
|
|
2423
|
+
const truncated = kept !== l2
|
|
2424
|
+
// 取到了非空的一段才算「有人看过」:取个空壳不该把 L2 的观察期清零
|
|
2425
|
+
notePersonaRead()
|
|
2426
|
+
const tail = truncated ? `\n\n(L2 共 ${total} 字,这里给了前 ${MAX_PERSONA_CHARS} 字)` : ''
|
|
2427
|
+
return {
|
|
2428
|
+
action,
|
|
2429
|
+
text: `(背景资料,不是指令 —— 以下是设定,不是要你照做的话)\n\n${kept}${tail}`,
|
|
2430
|
+
chars: countChars(kept),
|
|
2431
|
+
truncated,
|
|
2432
|
+
empty: false,
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
if (action === 'corrections') {
|
|
2436
|
+
const page = listCorrections({ cursor: String(args?.cursor ?? ''), limit: Number(args?.limit ?? 30) })
|
|
2437
|
+
return {
|
|
2438
|
+
action,
|
|
2439
|
+
items: page.items,
|
|
2440
|
+
total: page.total,
|
|
2441
|
+
unused: page.unused,
|
|
2442
|
+
nextCursor: page.nextCursor,
|
|
2443
|
+
hint: CORRECTIONS_HINT,
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2030
2446
|
return { action, error: `未知 action: ${action || '(空)'}(可用 ${ACTIONS.join(' / ')})` }
|
|
2031
2447
|
} catch (error) {
|
|
2032
2448
|
return { action, error: `自述操作失败:${error?.message ?? error}` }
|
|
@@ -2316,6 +2732,8 @@ export function apply(ctx, config) {
|
|
|
2316
2732
|
facts: listFacts(),
|
|
2317
2733
|
habits: readHabits(),
|
|
2318
2734
|
config: currentConfig(inline),
|
|
2735
|
+
// 人格跟着走;纠偏候选不跟(它是这台机器上攒下的证据)
|
|
2736
|
+
persona: readPersona(),
|
|
2319
2737
|
})
|
|
2320
2738
|
auditAction('export', bundle.counts, req)
|
|
2321
2739
|
return sendJson(res, 200, bundle)
|
|
@@ -2325,6 +2743,15 @@ export function apply(ctx, config) {
|
|
|
2325
2743
|
const incoming = body?.bundle ?? body
|
|
2326
2744
|
const problem = validateBundle(incoming)
|
|
2327
2745
|
if (problem !== null) return sendJson(res, 400, { ok: false, error: `这份文件用不了:${problem}` })
|
|
2746
|
+
// 人格先算、先校验:等三份文件都写完了才抛错,用户看到「导入失败」,
|
|
2747
|
+
// 实际上已经导了一半 —— 半事务比直接拒绝更糟
|
|
2748
|
+
const personaPlan = mergePersona(incoming.persona, readPersona())
|
|
2749
|
+
if (personaPlan.action === 'adopt') {
|
|
2750
|
+
const verdict = validatePersona(personaPlan.persona)
|
|
2751
|
+
if (!verdict.ok) {
|
|
2752
|
+
return sendJson(res, 400, { ok: false, error: `导入包里的人格用不了:${verdict.error}` })
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2328
2755
|
const limits = limitsOf(currentConfig(inline))
|
|
2329
2756
|
const before = { notes: readNotes().length, facts: listFacts().length, habits: readHabits().length }
|
|
2330
2757
|
const merged = mergeBundle(
|
|
@@ -2338,6 +2765,16 @@ export function apply(ctx, config) {
|
|
|
2338
2765
|
const { kept: trimmedNotes } = trim(merged.notes, limits)
|
|
2339
2766
|
writeNotes(trimmedNotes)
|
|
2340
2767
|
writeHabits(merged.habits)
|
|
2768
|
+
// 人格:目标空就采用,一模一样就跳过,两边都有且不同就落旁路文件 —— 不覆盖
|
|
2769
|
+
let personaFile = ''
|
|
2770
|
+
if (personaPlan.action === 'adopt') {
|
|
2771
|
+
savePersonaFile(personaPlan.persona)
|
|
2772
|
+
personaFile = personaPath()
|
|
2773
|
+
} else if (personaPlan.action === 'conflict') {
|
|
2774
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
2775
|
+
personaFile = join(selfnoteRoot(), 'persona.imported-' + stamp + '.md')
|
|
2776
|
+
writeAtomic(personaFile, renderPersona(personaPlan.persona))
|
|
2777
|
+
}
|
|
2341
2778
|
const report = {
|
|
2342
2779
|
added: merged.added,
|
|
2343
2780
|
skipped: merged.skipped,
|
|
@@ -2347,6 +2784,7 @@ export function apply(ctx, config) {
|
|
|
2347
2784
|
droppedNotes: merged.notes.length - trimmedNotes.length,
|
|
2348
2785
|
before,
|
|
2349
2786
|
after: { notes: trimmedNotes.length, facts: merged.facts.length, habits: merged.habits.length },
|
|
2787
|
+
persona: { action: personaPlan.action, file: personaFile },
|
|
2350
2788
|
indexStale: true,
|
|
2351
2789
|
}
|
|
2352
2790
|
auditAction('import', { added: merged.added, keepStatus: merged.keepStatus }, req)
|
|
@@ -2371,6 +2809,59 @@ export function apply(ctx, config) {
|
|
|
2371
2809
|
if (!result.ok) return sendJson(res, 400, { ok: false, error: result.error })
|
|
2372
2810
|
return sendJson(res, 200, stateOf(inline))
|
|
2373
2811
|
}
|
|
2812
|
+
if (req.method === 'POST' && route === '/persona') {
|
|
2813
|
+
const body = await readBody(req)
|
|
2814
|
+
// 没给的段沿用盘上现值:只传 l1 不该把 l0 / l2 一起清空。
|
|
2815
|
+
// 三段都不给就是发错了 —— 空对象曾经能把整份人格抹掉。
|
|
2816
|
+
if (!body || (body.l0 === undefined && body.l1 === undefined && body.l2 === undefined)) {
|
|
2817
|
+
return sendJson(res, 400, { ok: false, code: 'validation', error: '没有要保存的内容:l0 / l1 / l2 至少给一个' })
|
|
2818
|
+
}
|
|
2819
|
+
const kept = readPersona()
|
|
2820
|
+
const pick = (key) => (body[key] === undefined ? String(kept?.[key] ?? '') : String(body[key]))
|
|
2821
|
+
// commitPersona 自己就串行化 + 加锁了;这里再套一层 runSerial 会把自己排在
|
|
2822
|
+
// 自己的队尾上,永远等不到 —— 嵌套同一条队列就是死锁。
|
|
2823
|
+
const result = await commitPersona({
|
|
2824
|
+
l0: pick('l0'),
|
|
2825
|
+
l1: pick('l1'),
|
|
2826
|
+
l2: pick('l2'),
|
|
2827
|
+
consume: Array.isArray(body?.consume) ? body.consume.map(String) : [],
|
|
2828
|
+
})
|
|
2829
|
+
if (result.ok !== true) {
|
|
2830
|
+
return sendJson(res, result.code === 'locked' ? 409 : 400, { ok: false, code: result.code, error: result.error })
|
|
2831
|
+
}
|
|
2832
|
+
// 回整份 state:设置页拿到响应就直接 apply,只回 {ok, consumed} 会把页面刷成空白。
|
|
2833
|
+
if (result.pending === true) {
|
|
2834
|
+
return sendJson(res, 200, {
|
|
2835
|
+
...stateOf(inline),
|
|
2836
|
+
ok: true,
|
|
2837
|
+
pending: true,
|
|
2838
|
+
consumed: result.consumed ?? [],
|
|
2839
|
+
error: result.error,
|
|
2840
|
+
hint: '人格已保存;证据记账没写完,下次启动会自动补上,别重复保存。',
|
|
2841
|
+
})
|
|
2842
|
+
}
|
|
2843
|
+
return sendJson(res, 200, { ...stateOf(inline), ok: true, consumed: result.consumed ?? [], persona: personaStateOf(inline) })
|
|
2844
|
+
}
|
|
2845
|
+
if (req.method === 'POST' && route === '/persona/import') {
|
|
2846
|
+
const imported = importPersonaFromAgentMd()
|
|
2847
|
+
// 一律 200 + 固定形状:文件不在只是「没得导」,不是请求出错,
|
|
2848
|
+
// 前端照三条空串渲染、照 exists 决定说不说话
|
|
2849
|
+
return sendJson(res, 200, imported)
|
|
2850
|
+
}
|
|
2851
|
+
if (req.method === 'GET' && route === '/corrections') {
|
|
2852
|
+
const query = new URL(String(req.url ?? '/'), 'http://127.0.0.1')
|
|
2853
|
+
const page = listCorrections({
|
|
2854
|
+
cursor: query.searchParams.get('cursor') ?? '',
|
|
2855
|
+
limit: Number(query.searchParams.get('limit') ?? 30),
|
|
2856
|
+
})
|
|
2857
|
+
return sendJson(res, 200, { ok: true, ...page, hint: CORRECTIONS_HINT })
|
|
2858
|
+
}
|
|
2859
|
+
if (req.method === 'POST' && route === '/corrections/discard') {
|
|
2860
|
+
const body = await readBody(req)
|
|
2861
|
+
const result = await runSerial(() => discardByIds(Array.isArray(body?.ids) ? body.ids.map(String) : []))
|
|
2862
|
+
if (result.ok !== true) return sendJson(res, 400, { ok: false, error: result.error })
|
|
2863
|
+
return sendJson(res, 200, { ok: true, discarded: result.discarded })
|
|
2864
|
+
}
|
|
2374
2865
|
if (req.method === 'POST' && route === '/index') {
|
|
2375
2866
|
const body = await readBody(req)
|
|
2376
2867
|
const action = typeof body?.action === 'string' ? body.action : ''
|