@a9i5k4/dsh-auto-memory 2.2.5 → 2.2.7

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.
Files changed (68) hide show
  1. package/README.md +20 -10
  2. package/README.zh-CN.md +22 -10
  3. package/docs/CONTINUITY-FLOW.md +222 -0
  4. package/docs/HANDBOOK.md +354 -0
  5. package/docs/INTEGRATION-ANALYSIS.md +348 -0
  6. package/docs/M-CM7-HANDOFF-LAYERED-RETRIEVAL.md +311 -0
  7. package/docs/M8-MEMORY-HUB.md +1 -1
  8. package/docs/PROMPT-PACK-LAYERED-RECALL.md +474 -0
  9. package/docs/PROMPT-SET-STRICT.md +389 -0
  10. package/docs/RELEASE-GO-NOGO.md +82 -0
  11. package/docs/ROADMAP.md +162 -0
  12. package/docs/STATUS-BOARD.md +147 -0
  13. package/docs/USER-GUIDE.en.md +382 -0
  14. package/docs/USER-GUIDE.zh-CN.md +382 -289
  15. package/docs/prompts/EXEC-ORDER.md +77 -0
  16. package/docs/prompts/FEEDING-SCRIPT.md +174 -0
  17. package/docs/prompts/FEEDING-SEQUENCE.md +61 -0
  18. package/docs/prompts/FIX-AGENT-M8-2b.md +119 -0
  19. package/docs/prompts/FIX-AGENT-P11.md +97 -0
  20. package/docs/prompts/FIX-AGENT-P12-FULL-REGRESSION.md +135 -0
  21. package/docs/prompts/FIX-AGENT-P12.md +113 -0
  22. package/docs/prompts/FIX-AGENT-P13-PYTHON-RANK.md +100 -0
  23. package/docs/prompts/FIX-AGENT-P8.md +120 -0
  24. package/docs/prompts/FIX-AGENT-P9.md +110 -0
  25. package/docs/prompts/FIX-AGENT-P9a.md +94 -0
  26. package/docs/prompts/FIX-AGENT-P9d.md +114 -0
  27. package/docs/prompts/FIX-AGENT-TEMPORAL-ARM.md +148 -0
  28. package/docs/prompts/LIVE-VERIFY-ZCODE.md +105 -0
  29. package/docs/prompts/M8-1-fact-metadata.md +45 -0
  30. package/docs/prompts/M8-2-ADJUDICATION.md +98 -0
  31. package/docs/prompts/M8-2-importance-wiring.md +42 -0
  32. package/docs/prompts/M8-2b-evidence-pipeline.md +52 -0
  33. package/docs/prompts/M8-3-enable-verify.md +49 -0
  34. package/docs/prompts/M8-R-REPORT.md +156 -0
  35. package/docs/prompts/M8-R-research.md +67 -0
  36. package/docs/prompts/P1-l0-index.md +30 -0
  37. package/docs/prompts/P10-importance-calibration.md +45 -0
  38. package/docs/prompts/P11-silent-catch-observability.md +43 -0
  39. package/docs/prompts/P2-semantic-recall.md +30 -0
  40. package/docs/prompts/P3-fusion.md +28 -0
  41. package/docs/prompts/P4-l0-response.md +28 -0
  42. package/docs/prompts/P5-handoff-anchor.md +28 -0
  43. package/docs/prompts/P6-ledger-weight.md +27 -0
  44. package/docs/prompts/P7-write-fix.md +26 -0
  45. package/docs/prompts/P8-rrf-wiring.md +47 -0
  46. package/docs/prompts/P9-REVIEW-DECISION.md +95 -0
  47. package/docs/prompts/P9-evidence-write-coverage.md +113 -0
  48. package/docs/prompts/README.md +105 -0
  49. package/docs/prompts/ZCODE-DROPIN.md +229 -0
  50. package/docs/prompts/_COMMON.md +88 -0
  51. package/lib/client.js +95 -88
  52. package/lib/context-host.js +77 -2
  53. package/lib/evidence-agg.js +81 -0
  54. package/lib/fact-store.js +32 -0
  55. package/lib/handoff-anchor.js +114 -0
  56. package/lib/index.js +545 -38
  57. package/lib/l0-extract.js +149 -0
  58. package/lib/l0-index.js +239 -0
  59. package/lib/m7-wire.js +4 -3
  60. package/lib/memory-importance.js +70 -0
  61. package/lib/python-setup.js +16 -4
  62. package/lib/recall-fusion.js +99 -0
  63. package/lib/shadow-retrieval.js +2 -2
  64. package/lib/storage-manage.js +17 -0
  65. package/lib/subagent-gc.js +8 -1
  66. package/lib/temporal-parse.js +159 -0
  67. package/package.json +1 -1
  68. package/python/worker_semantic_v1.py +28 -1
@@ -19,6 +19,7 @@ import {
19
19
  buildContextPushEnvelopePre, buildAuthorizedMemoryRefFromRecord, createAccessEvidencePre,
20
20
  createCiteEvidencesFromText, createCorrectionEvidencesFromText, computeReadCoverage,
21
21
  createContextPushBridge, createNullContextSinkPre, createFakeContextSinkPre,
22
+ CORRECTION_LEXICON_V1,
22
23
  CONTEXT_BRIDGE_BUDGET_V1, CONTEXT_BRIDGE_POLICY_VERSION, EVIDENCE_POLICY_VERSION,
23
24
  } from './context-bridge.js'
24
25
  import { EvidenceEventStore, rebuildAggregates, workspaceRefOf } from './evidence-store.js'
@@ -41,6 +42,49 @@ function diagCtx(msg) {
41
42
  } catch (e) {}
42
43
  }
43
44
 
45
+ /**
46
+ * P9a correction 归因选择器(纯函数,零 IO;precision-first):
47
+ * 从 evidence store 事件(loadEvents 投影形态)里选「最近一条被 cite/read 的记忆」作为
48
+ * correction 归因对象。用户纠正时几乎不可能手打完整 memoryId,故归因到最近被引用/读取
49
+ * 的记忆而非用户消息内新找的 id(原 createCorrectionEvidencesFromText 路径保留不动)。
50
+ * - 只认 kind∈{cite,read} 且带 memoryId;时间窗口 [now-windowMs, +∞),默认 5 分钟;
51
+ * - 按 ts 降序取第 1 条;ts 平局按 memoryId 升序保证确定性;
52
+ * - 窗口内已有 correction 的 memoryId 跳过(同一记忆一轮最多一条,防连续段重复惩罚);
53
+ * - ts 取 event.ts(投影形态);兼容内存形态顶层 ts/createdAt;
54
+ * - 任何异常/非法输入返回 null(调用方静默不发)。
55
+ */
56
+ export function selectCorrectionAttributionPre(input) {
57
+ try {
58
+ const events = Array.isArray(input && input.events) ? input.events : []
59
+ const now = Number.isFinite(input && input.now) ? input.now : Date.now()
60
+ const windowMs = Number.isFinite(input && input.windowMs) && input.windowMs > 0 ? input.windowMs : 300000
61
+ const cutoff = now - windowMs
62
+ const correctedRecently = new Set()
63
+ let best = null
64
+ let bestTs = -1
65
+ for (const e of events) {
66
+ if (!e || typeof e !== 'object') continue
67
+ if (e.kind === 'correction' && e.memoryId) {
68
+ const cts = Number(e.event && e.event.ts) || Number(e.ts) || Number(e.createdAt) || 0
69
+ if (cts >= cutoff) correctedRecently.add(String(e.memoryId))
70
+ }
71
+ }
72
+ for (const e of events) {
73
+ if (!e || typeof e !== 'object') continue
74
+ if (e.kind !== 'cite' && e.kind !== 'read') continue
75
+ if (!e.memoryId) continue
76
+ if (correctedRecently.has(String(e.memoryId))) continue
77
+ const ets = Number(e.event && e.event.ts) || Number(e.ts) || Number(e.createdAt) || 0
78
+ if (ets < cutoff) continue
79
+ if (!best || ets > bestTs || (ets === bestTs && String(e.memoryId) < String(best.memoryId))) {
80
+ best = e
81
+ bestTs = ets
82
+ }
83
+ }
84
+ return best
85
+ } catch (_) { return null }
86
+ }
87
+
44
88
  export function createContextHost(opts = {}) {
45
89
  const engine = opts.engine
46
90
  if (!engine) throw new Error('context-host: engine required')
@@ -497,7 +541,36 @@ export function createContextHost(opts = {}) {
497
541
  const corrections = seg.kind === 'user'
498
542
  ? createCorrectionEvidencesFromText({ text: seg.text, knownRecords: corpusSnap.records, coords })
499
543
  : []
500
- await persistEvidence([...cites, ...corrections])
544
+ // P9a:用户段命中纠正词典但文本不含完整 memoryId 时(现实常态),把 correction 归因到
545
+ // 最近一条被 cite/read 的记忆(5 分钟窗口,同记忆一轮最多一条);无命中静默不发。
546
+ // 原 cite/correction 产出零改动;store 读取仅在词典命中时发生;fail-soft + diag(不记用户原文)。
547
+ const attributed = []
548
+ if (seg.kind === 'user') {
549
+ let lexHits = 0
550
+ let attributedPrefix = 'none'
551
+ try {
552
+ const norm = String(seg.text == null ? '' : seg.text).normalize('NFKC').replace(/[A-Z]/g, (c) => c.toLowerCase())
553
+ lexHits = CORRECTION_LEXICON_V1.filter((w) => norm.includes(w)).length
554
+ if (lexHits > 0) {
555
+ const st = storeFor()
556
+ const recent = selectCorrectionAttributionPre({ events: st.loadEvents().events, now: Date.now() })
557
+ if (recent) {
558
+ const r = createAccessEvidencePre({
559
+ ...coords, kind: 'correction', memoryId: recent.memoryId, anchorId: recent.anchorId, scope: recent.scope,
560
+ sourceRef: recent.source && recent.source.sourceRef, sourceEpoch: recent.source && recent.source.sourceEpoch,
561
+ sourceVersion: recent.source && recent.source.sourceVersion,
562
+ fileDigest: recent.source && recent.source.fileDigest, recordDigest: recent.source && recent.source.recordDigest,
563
+ })
564
+ if (r.ok) { attributed.push(r.evidence); attributedPrefix = String(recent.memoryId).slice(0, 12) }
565
+ }
566
+ }
567
+ } catch (eP9a) {
568
+ diagCtx('p9a correction attribution error: ' + String(eP9a && eP9a.message || eP9a).slice(0, 100))
569
+ }
570
+ // 隐私:diag 只记词典词计数 + 归因 memoryId 前 12 位,绝不记录用户原文。
571
+ diagCtx('p9a correction attribution: lexHits=' + lexHits + ' attributed=' + attributedPrefix)
572
+ }
573
+ await persistEvidence([...cites, ...corrections, ...attributed])
501
574
  }
502
575
 
503
576
  /**
@@ -696,7 +769,9 @@ export function createContextHost(opts = {}) {
696
769
  const cutoff = Date.now() - (Number(windowMs) || 300000)
697
770
  const out = new Map()
698
771
  for (const e of events) {
699
- const ets = e.ts || e.createdAt || 0
772
+ // P9d:store 投影事件 ts 在 event.ts(顶层无 ts/createdAt),与 selectCorrectionAttributionPre 同口径;
773
+ // 旧写法 e.ts || e.createdAt || 0 恒为 0 → 窗口全跳过 → success 证据链结构性断裂。
774
+ const ets = Number(e.event && e.event.ts) || Number(e.ts) || Number(e.createdAt) || 0
700
775
  if (ets < cutoff) continue
701
776
  if (e.kind !== 'read' && e.kind !== 'cite') continue
702
777
  if (!e.memoryId) continue
@@ -0,0 +1,81 @@
1
+ /**
2
+ * evidence-agg-pre —— evidence 事件 → 聚合 → importance 输入契约(M8-2b, 2026-09-09)。
3
+ *
4
+ * 管道:evidence/events/*.jsonl(写入侧 context-bridge-pre,只读)→ 有界扫描 →
5
+ * 按 memoryId 聚合六类计数 + distinctSessions → 交给 memory-importance-pre.computeImportancePre
6
+ * → 作为 recall() L0 融合的加权因子之一(P8 融合入口)。
7
+ *
8
+ * 契约:
9
+ * - 纯函数 + IO 注入(io = { listFiles(), readFile(name) }),模块零内置 IO、零写入。
10
+ * - 有界读取:文件名日期在窗口内(默认近 7 天)且每文件只取末 N 行(默认 400),绝不全量扫描历史。
11
+ * 文件名约定 YYYY-MM-DD.jsonl(EvidenceEventStore 落盘惯例,实测样本确认);无法解析日期的文件跳过(确定性)。
12
+ * - 事件行结构(实测 2026-09-09.jsonl 确认):kind/memoryId 在顶层,会话= event.sessionRef,时间= event.ts。
13
+ * - 聚合输出形状 = memory-importance-pre.computeImportancePre 的输入契约(以其源码为准,不另立)。
14
+ * - fail-soft:行损坏跳过;io 抛错由调用方处理(模块内不吞 IO 异常——io 是注入的,调用方决定降级)。
15
+ */
16
+
17
+ export const EVIDENCE_AGG_VERSION = 'evidence_agg_v1'
18
+
19
+ /** 有界读取默认值。 */
20
+ export const EVIDENCE_AGG_DEFAULTS_V1 = Object.freeze({
21
+ maxAgeDays: 7, // 文件名日期距 now 的最大天数
22
+ maxLinesPerFile: 400, // 每文件末 N 行(沿用 index.js 证据读取范式 slice(-400))
23
+ })
24
+
25
+ const KINDS = ['seen', 'read', 'cite', 'reuse', 'success', 'correction']
26
+
27
+ /**
28
+ * 有界扫描 evidence 事件(只读)。io 注入;文件按名字日期过滤 + 每文件末 N 行。
29
+ * @param {{listFiles:Function, readFile:Function}} io listFiles()→文件名数组;readFile(name)→全文
30
+ * @param {{maxAgeDays?:number, maxLinesPerFile?:number, now?:Function}} opts
31
+ * @returns {Array<object>} 解析后的事件对象(损坏行跳过;任何字段缺失由聚合层兜底)
32
+ */
33
+ export function scanEvidenceEventsPre(io, opts = {}) {
34
+ const d = Object.assign({}, EVIDENCE_AGG_DEFAULTS_V1, opts)
35
+ const now = d.now || Date.now
36
+ const files = (io.listFiles() || []).slice().sort()
37
+ const cutoff = now() - d.maxAgeDays * 86400000
38
+ const out = []
39
+ for (const name of files) {
40
+ const m = /^(\d{4})-(\d{2})-(\d{2})\.jsonl$/.exec(String(name))
41
+ if (!m) continue // 非日期命名 → 跳过(确定性;不做全量兜底)
42
+ const fileTs = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 23, 59, 59)
43
+ if (fileTs < cutoff) continue
44
+ const lines = String(io.readFile(name) || '').split('\n').filter(Boolean)
45
+ for (const ln of lines.slice(-d.maxLinesPerFile)) {
46
+ try { out.push(JSON.parse(ln)) } catch (_) {}
47
+ }
48
+ }
49
+ return out
50
+ }
51
+
52
+ /**
53
+ * 按 memoryId 聚合六类计数与去重会话数。输出形状 = computeImportancePre 的输入契约。
54
+ * @param {Array<{kind?:string, memoryId?:string, event?:{sessionRef?:string}, sessionRef?:string}>} events
55
+ * @returns {Map<string, {distinctSessions:number, seen:number, read:number, cite:number, reuse:number, success:number, correction:number}>}
56
+ */
57
+ export function aggregateEvidenceEventsPre(events) {
58
+ const list = Array.isArray(events) ? events : []
59
+ const byId = new Map()
60
+ for (const e of list) {
61
+ if (!e || typeof e.memoryId !== 'string' || !e.memoryId) continue
62
+ const kind = e.kind
63
+ if (!KINDS.includes(kind)) continue // 非六类事件不计数(与 evidenceFor 口径一致)
64
+ let agg = byId.get(e.memoryId)
65
+ if (!agg) {
66
+ agg = { distinctSessions: 0, seen: 0, read: 0, cite: 0, reuse: 0, success: 0, correction: 0 }
67
+ byId.set(e.memoryId, agg)
68
+ }
69
+ agg[kind]++
70
+ const sessionRef = (e.event && e.event.sessionRef) || e.sessionRef
71
+ if (sessionRef) {
72
+ if (!agg._sessions) agg._sessions = new Set()
73
+ agg._sessions.add(sessionRef)
74
+ }
75
+ }
76
+ for (const agg of byId.values()) {
77
+ agg.distinctSessions = agg._sessions ? agg._sessions.size : 0
78
+ delete agg._sessions
79
+ }
80
+ return byId
81
+ }
package/lib/fact-store.js CHANGED
@@ -37,6 +37,12 @@ export const FACT_ID_RE = /^fact_[0-9a-f]{32}$/
37
37
  /** scope 枚举: 与 M5/M6 的 scope 对齐(User=跨工作区, Workspace=当前工作区)。 */
38
38
  export const FACT_SCOPES_V1 = Object.freeze(['User', 'Workspace'])
39
39
 
40
+ /** M8-1 认识论状态枚举: fact=证据/不可推导, observation=推断/可重算, directive=行为指令。 */
41
+ export const FACT_EPISTEMIC_STATUSES_V1 = Object.freeze(['fact', 'observation', 'directive'])
42
+
43
+ /** M8-1 趋势枚举: 相对上次确认的生命周期走向。 */
44
+ export const FACT_TRENDS_V1 = Object.freeze(['new', 'strengthening', 'stable', 'weakening', 'stale'])
45
+
40
46
  /** sourceClass 枚举: 与 m4-corpus-pre 的 sourceClass 对齐。 */
41
47
  export const FACT_SOURCE_CLASSES_V1 = Object.freeze([
42
48
  'user-memory', 'workspace-notes', 'workspace-log', 'semantic-candidate', 'profile-candidate',
@@ -69,6 +75,12 @@ export function validateFactCandidatePre(cand) {
69
75
  if (cand.provenance !== undefined && (!Array.isArray(cand.provenance) || cand.provenance.some((s) => typeof s !== 'string'))) p.push('provenance')
70
76
  if (cand.confidence !== undefined && (typeof cand.confidence !== 'number' || !Number.isFinite(cand.confidence) || cand.confidence < 0 || cand.confidence > 1)) p.push('confidence')
71
77
  if (cand.ttl !== undefined && (typeof cand.ttl !== 'number' || !Number.isFinite(cand.ttl) || cand.ttl < 0)) p.push('ttl')
78
+ // M8-1 时间三价 + 认识论状态 + 趋势(全部可选;缺省放行 → 旧结构/旧调用方不受影响)
79
+ for (const k of ['occurredAt', 'mentionedAt', 'ingestedAt']) {
80
+ if (cand[k] !== undefined && (typeof cand[k] !== 'number' || !Number.isFinite(cand[k]) || cand[k] < 0)) p.push(k)
81
+ }
82
+ if (cand.epistemicStatus !== undefined && !FACT_EPISTEMIC_STATUSES_V1.includes(cand.epistemicStatus)) p.push('epistemicStatus')
83
+ if (cand.trend !== undefined && !FACT_TRENDS_V1.includes(cand.trend)) p.push('trend')
72
84
  if (p.length) return { ok: false, reason: 'invalid:' + p.join(',') }
73
85
  return { ok: true, candidate: cand }
74
86
  }
@@ -89,6 +101,12 @@ export function validateFactPre(fact) {
89
101
  if (typeof fact.confirmedAt !== 'number' || !Number.isFinite(fact.confirmedAt)) p.push('confirmedAt')
90
102
  if (fact.ttl !== undefined && (typeof fact.ttl !== 'number' || !Number.isFinite(fact.ttl))) p.push('ttl')
91
103
  if (fact.revoked !== undefined && typeof fact.revoked !== 'boolean') p.push('revoked')
104
+ // M8-1 新字段(全部可选;缺字段=旧数据,放行 → 既有 facts.json 可读,不报错不丢弃)
105
+ for (const k of ['occurredAt', 'mentionedAt', 'ingestedAt']) {
106
+ if (fact[k] !== undefined && (typeof fact[k] !== 'number' || !Number.isFinite(fact[k]))) p.push(k)
107
+ }
108
+ if (fact.epistemicStatus !== undefined && !FACT_EPISTEMIC_STATUSES_V1.includes(fact.epistemicStatus)) p.push('epistemicStatus')
109
+ if (fact.trend !== undefined && !FACT_TRENDS_V1.includes(fact.trend)) p.push('trend')
92
110
  if (p.length) return { ok: false, reason: 'invalid:' + p.join(',') }
93
111
  return { ok: true, fact }
94
112
  }
@@ -190,6 +208,12 @@ export function createFactStorePre(opts = {}) {
190
208
  confidence: c.confidence !== undefined ? c.confidence : null,
191
209
  confirmedAt: now, ttl: c.ttl !== undefined ? c.ttl : FACT_TTL_DEFAULT_V1,
192
210
  revoked: false,
211
+ // M8-1 时间三价:入库时间必填(=本次确认);发生/陈述时间候选提供则透传,缺省 undefined(落盘时省略)
212
+ ingestedAt: now,
213
+ occurredAt: c.occurredAt,
214
+ mentionedAt: c.mentionedAt,
215
+ ...(c.epistemicStatus !== undefined ? { epistemicStatus: c.epistemicStatus } : {}),
216
+ ...(c.trend !== undefined ? { trend: c.trend } : {}),
193
217
  }
194
218
  const fv = validateFactPre(fact)
195
219
  if (!fv.ok) return { ok: false, reason: 'fact-invalid:' + fv.reason, outcome: 'invalid' }
@@ -210,7 +234,15 @@ export function createFactStorePre(opts = {}) {
210
234
  for (const s of c.provenance) if (!seen.has(s)) prev.provenance.push(s)
211
235
  }
212
236
  if (c.confidence !== undefined && c.confidence !== null) prev.confidence = c.confidence
237
+ // M8-1 向后兼容回填(须在 confirmedAt 更新前执行):旧记录(无 ingestedAt)首次合并时补齐,
238
+ // 取其原始确认时间(=首次入库);不改变其余合并语义
239
+ if (prev.ingestedAt === undefined && Number.isFinite(prev.confirmedAt)) prev.ingestedAt = prev.confirmedAt
213
240
  prev.confirmedAt = now // 合并视为重新确认
241
+ // M8-1 可选元数据透传:重新陈述时更新发生/陈述时间与认识论状态/趋势(候选提供才写,加法性)
242
+ if (c.occurredAt !== undefined) prev.occurredAt = c.occurredAt
243
+ if (c.mentionedAt !== undefined) prev.mentionedAt = c.mentionedAt
244
+ if (c.epistemicStatus !== undefined) prev.epistemicStatus = c.epistemicStatus
245
+ if (c.trend !== undefined) prev.trend = c.trend
214
246
  if (c.ttl !== undefined) prev.ttl = c.ttl
215
247
  stats.merged++
216
248
  void persist()
@@ -0,0 +1,114 @@
1
+ /**
2
+ * P6(2026-09-09) 账本权重化截断纯核心(handoff_ledger_weight_v1)。
3
+ *
4
+ * 背景:buildContinueCarry 的 `ledger.slice(0, 8000)` 是位置截断,而账本段内异质——
5
+ * 位置截可能把高价值段整体截掉(M-CM7 §C/§G4)。本模块按账本自身四段标题赋权:
6
+ * 已试方案与失败原因 .35 > 进度与下一步 .30 > 目标 .20 > 任务状态 .15
7
+ * 预算不足时从最低权重段开始截(保留段标题行),预算充足时逐字节原样返回。
8
+ *
9
+ * 契约:
10
+ * - 标题字符串来自账本文件本身(写入侧骨架逐字一致),不做内容语义标注——禁止把"成功解法"误标为"失败原因"。
11
+ * - 纯函数、零 IO、零依赖;同输入逐字节确定;非法输入 fail closed(返回 null,调用方回落位置截断)。
12
+ * - 只影响注入,不动文件原文。
13
+ */
14
+
15
+ export const HANDOFF_LEDGER_WEIGHT_VERSION = 'handoff_ledger_weight_v1'
16
+
17
+ /** 四段标题 → 权重(降序)。标题为账本中的原样标题行(半角 '## '+单空格,不含行尾 \r)。 */
18
+ export const HANDOFF_LEDGER_SECTION_WEIGHTS_V1 = Object.freeze([
19
+ Object.freeze({ title: '已试方案与失败原因', weight: 0.35 }),
20
+ Object.freeze({ title: '进度与下一步', weight: 0.30 }),
21
+ Object.freeze({ title: '目标', weight: 0.20 }),
22
+ Object.freeze({ title: '任务状态', weight: 0.15 }),
23
+ ])
24
+
25
+ const TITLE_WEIGHT = new Map(HANDOFF_LEDGER_SECTION_WEIGHTS_V1.map((x) => [x.title, x.weight]))
26
+ /** 未知 '## ' 段:权重最低(最先被截),但仍保留标题与内容直到轮到它。 */
27
+ const UNKNOWN_WEIGHT = 0.05
28
+ const TRIM_MARK = '…(已按预算截断,全文见 handoff/ 最新账本)'
29
+
30
+ function weightOf(titleText) {
31
+ const name = String(titleText || '').replace(/^##\s*/, '').replace(/\s+$/, '')
32
+ return TITLE_WEIGHT.has(name) ? TITLE_WEIGHT.get(name) : UNKNOWN_WEIGHT
33
+ }
34
+
35
+ /**
36
+ * 解析账本四段。行级切分:遇到 `## ` 开头行即开新段;首个标题行之前的内容为 preamble(如 `# 交接账本 · …` 大标题)。
37
+ * title 保留原样行(含 '## ' 前缀,容忍尾随 \r);body 为两标题行之间的原始行(不做 trim,保证原样回装)。
38
+ * @returns {{preamble: string[], sections: Array<{title: string, body: string[], weight: number}>} | null}
39
+ * 无任何 '## ' 段或输入非法 → null(fail closed)。
40
+ */
41
+ export function parseHandoffLedgerPre(text) {
42
+ const src = typeof text === 'string' ? text : ''
43
+ if (!src.trim()) return null
44
+ const lines = src.split('\n')
45
+ const preamble = []
46
+ const sections = []
47
+ let cur = null
48
+ for (const line of lines) {
49
+ if (/^## (.+)$/.test(line.replace(/\r$/, ''))) {
50
+ if (cur) sections.push(cur)
51
+ cur = { title: line, body: [], weight: weightOf(line) }
52
+ } else if (cur) cur.body.push(line)
53
+ else preamble.push(line)
54
+ }
55
+ if (cur) sections.push(cur)
56
+ if (!sections.length) return null
57
+ return { preamble, sections }
58
+ }
59
+
60
+ /** 回装:preamble + 各段(title 行 + body 行)以 \n 连接;对无 \r 输入逐字节还原原文本。 */
61
+ function assemble(preamble, sections) {
62
+ const parts = []
63
+ if (preamble.length) parts.push(preamble.join('\n'))
64
+ for (const s of sections) parts.push(s.body.length ? s.title + '\n' + s.body.join('\n') : s.title)
65
+ return parts.join('\n')
66
+ }
67
+
68
+ /** 段内按行收缩:从 body 尾部丢行(保头部,与 slice(0,N) 语义一致),返回新 body(≤keepChars 字符)。 */
69
+ function shrinkBody(body, keepChars) {
70
+ if (keepChars <= 0) return []
71
+ const out = []
72
+ let used = 0
73
+ for (let i = 0; i < body.length; i++) {
74
+ const cost = body[i].length + (i > 0 ? 1 : 0)
75
+ if (used + cost > keepChars) break
76
+ out.push(body[i])
77
+ used += cost
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * 权重化截断。预算充足(原文 ≤budget)→ 返回与原文逐字节相同的字符串;
84
+ * 不足 → 从最低权重段开始截 body(段标题保留,截点带 TRIM_MARK),直到进入预算;
85
+ * 全部段收缩后仍超预算(如 preamble/标题行本身超限)→ 最后回落 `slice(0, budget)`。
86
+ * 解析失败(非法输入/无已知段结构)→ 返回 null,调用方自行回落。
87
+ * @param {string} text 账本全文
88
+ * @param {number} budget 注入预算(字符)
89
+ * @returns {string | null}
90
+ */
91
+ export function weightedTrimHandoffLedgerPre(text, budget) {
92
+ const b = Number(budget)
93
+ if (!Number.isFinite(b) || b <= 0) return null
94
+ const parsed = parseHandoffLedgerPre(text)
95
+ if (!parsed) return null
96
+ const original = assemble(parsed.preamble, parsed.sections)
97
+ if (original.length <= b) return original
98
+
99
+ // 截断顺序:权重升序(稳定:同权重保持文内出现顺序)
100
+ const order = parsed.sections.map((s, i) => ({ s, i })).sort((x, y) => x.s.weight - y.s.weight || x.i - y.i)
101
+ const markCost = TRIM_MARK.length + 1 // 截点换行 + marker 本身,预留避免逐段收敛震荡
102
+ for (const { s } of order) {
103
+ const current = assemble(parsed.preamble, parsed.sections)
104
+ if (current.length <= b) break
105
+ const bodyLen = s.body.join('\n').length
106
+ if (bodyLen <= 0) continue
107
+ const excess = current.length - b
108
+ const keep = Math.max(0, bodyLen - excess - markCost)
109
+ s.body = shrinkBody(s.body, keep)
110
+ s.body = s.body.length ? s.body.concat([TRIM_MARK]) : [TRIM_MARK]
111
+ }
112
+ const out = assemble(parsed.preamble, parsed.sections)
113
+ return out.length <= b ? out : out.slice(0, b)
114
+ }