@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.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 +189 -7
- package/README.zh-CN.md +189 -7
- package/docs/CONTRIBUTORS.html +471 -0
- package/docs/FRONTEND-CO-CREATION.md +191 -0
- package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
- package/docs/HANDOFF-CRITERIA.md +92 -0
- package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
- package/docs/INTEGRATION-ANALYSIS.md +350 -348
- package/docs/PROMO-PROMPT-3.0.md +100 -0
- package/docs/USER-GUIDE.en.md +58 -3
- package/docs/USER-GUIDE.zh-CN.md +59 -4
- package/docs/WHITEPAPER.md +207 -0
- package/docs/internal/ACCEPT-35-LIVE.md +143 -0
- package/docs/internal/ACCEPTANCE-20260914.md +90 -0
- package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
- package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
- package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
- package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
- package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
- package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
- package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
- package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
- package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
- package/docs/internal/FEATURE-INVENTORY.md +531 -0
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
- package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
- package/docs/internal/G3-DESIGN-20260918.md +82 -0
- package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
- package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
- package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
- package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
- package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
- package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
- package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
- package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
- package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
- package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
- package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
- package/docs/internal/KICKOFF-P0.md +254 -0
- package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
- package/docs/internal/MASTER-PLAN-3.0.md +411 -0
- package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
- package/docs/internal/PENDING-FIXES-20260916.md +289 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
- package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
- package/docs/internal/PROGRESS-20260917.md +93 -0
- package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
- package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
- package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
- package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
- package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
- package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
- package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
- package/docs/internal/RESUME-20260918.md +171 -0
- package/docs/internal/RESUME-20260919.md +104 -0
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
- package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
- package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
- package/docs/internal/ROADMAP.md +106 -0
- package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
- package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
- package/docs/internal/T6-EXECUTION-20260920.md +130 -0
- package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
- package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
- package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
- package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
- package/docs/internal/TODO-BACKLOG.md +263 -142
- package/docs/internal/TODO-GRAPH.html +715 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
- package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
- package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
- package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
- package/docs/prompts/M8-3-enable-verify.md +49 -49
- package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
- package/lib/acceptance.js +71 -0
- package/lib/activation-host.js +153 -18
- package/lib/activation-inbox.js +25 -7
- package/lib/board-mode.js +30 -0
- package/lib/client.js +1758 -90
- package/lib/config-io.js +156 -0
- package/lib/context-bridge.js +5 -2
- package/lib/context-host.js +86 -15
- package/lib/degrade.js +385 -0
- package/lib/dsh-home.js +143 -0
- package/lib/engine-identity.js +149 -0
- package/lib/engine-switch.js +247 -0
- package/lib/episodic-store.js +63 -12
- package/lib/evidence-store.js +10 -3
- package/lib/fact-store.js +22 -3
- package/lib/fs-retry.js +46 -0
- package/lib/index-sync.js +13 -1
- package/lib/index.js +3446 -263
- package/lib/intent-clean-safe.js +258 -0
- package/lib/intent-clean.js +12 -16
- package/lib/l0-extract.js +478 -149
- package/lib/l0-index-sync.js +195 -0
- package/lib/l0-index.js +349 -239
- package/lib/ledger-criteria.js +142 -0
- package/lib/m4-corpus.js +8 -2
- package/lib/m7-index-sync-host.js +73 -5
- package/lib/m7-wire.js +3 -3
- package/lib/memory-anchor.js +56 -1
- package/lib/memory-envelope.js +257 -0
- package/lib/memory-hub.js +138 -13
- package/lib/memory-index.js +4 -2
- package/lib/memory-mutation.js +246 -0
- package/lib/memory-writer.js +204 -24
- package/lib/note-status-apply.js +118 -0
- package/lib/note-status.js +196 -0
- package/lib/procedure-observation.js +48 -0
- package/lib/procedure-store.js +118 -20
- package/lib/python-setup.js +1 -1
- package/lib/python-sidecar-client.js +29 -3
- package/lib/recall-fusion.js +83 -12
- package/lib/rerank-host.js +160 -0
- package/lib/rules-edit.js +159 -0
- package/lib/rules-layer.js +261 -0
- package/lib/semantic-decide.js +41 -8
- package/lib/semantic-js.js +66 -6
- package/lib/shadow-host.js +3 -5
- package/lib/shadow-retrieval.js +3 -3
- package/lib/skill-export-host.js +153 -0
- package/lib/skill-export.js +239 -0
- package/lib/state-commit.js +245 -0
- package/lib/storage-manage.js +6 -0
- package/lib/subagent-gc.js +4 -8
- package/lib/temporal-parse.js +191 -159
- package/lib/tier-layer-inject.js +650 -0
- package/lib/tier0-catalog.js +735 -0
- package/lib/water-window.js +263 -186
- package/lib/wb-contract.js +691 -0
- package/lib/wb-sidecar.js +890 -0
- package/lib/ws-overview-rank.js +2 -2
- package/package.json +1 -1
- package/python/m7_embedding_v1.py +5 -5
- package/python/worker_semantic_v1.py +17 -6
- package/python/worker_v1.py +38 -4
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ledger-criteria-pre —— WB-GRAPH P1 判据校验中间件核心(2026-09-16, ledger_criteria_v1)。
|
|
3
|
+
*
|
|
4
|
+
* 权威依据: WB-GRAPH-INTEGRATION-PLAN.md §2.2 判据定义表 + WB-FORMAT-CONVENTION.md v1
|
|
5
|
+
* + WB-GRAPH-DECISIONS-20260914.md §E(2026-09-16 拍板: P1 全量、A6 照写+警示行、B7 跳过试点)。
|
|
6
|
+
*
|
|
7
|
+
* 设计(方案 §2.1-2.5):
|
|
8
|
+
* - 纯函数、零 IO、fail closed(仿 handoff-anchor.js 契约)。
|
|
9
|
+
* - 硬判据 = 确定性可计算(标题逐字/非空/占位符/长度), 不过 → 拒绝写入。
|
|
10
|
+
* - 软判据 = 启发式, 只标记不拦截(误报不可忽略; 水位骨架素材空时天然不满足 → 绝不阻塞接续 I4)。
|
|
11
|
+
* - 白板判据取可计算子集(零 LLM), 自然语言质量层交既有 prompt 纪律(§2.1 结论)。
|
|
12
|
+
* - 报告契约 = §2.5 ledger_criteria_v1 JSON(机器可读, P2 确认事件消费)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const LEDGER_CRITERIA_VERSION = 'ledger_criteria_v1'
|
|
16
|
+
// 兼容性再导出(2026-09-16 勘误): P0 窗口的 wb-contract.js 已实现同名判据函数(H1-H4/S1-S4),
|
|
17
|
+
// 且 index.js 两咽喉(checkMutationPre)已接线它 — 本模块勿重复造轮,统一转发保持单一真源。
|
|
18
|
+
export { checkHandoffCriteriaPre as checkHandoffCriteriaContractPre, checkPlanCriteriaPre as checkPlanCriteriaContractPre } from './wb-contract.js'
|
|
19
|
+
|
|
20
|
+
import { parseHandoffLedgerPre } from './handoff-anchor.js'
|
|
21
|
+
|
|
22
|
+
/** 交接账本四段权威标题(与 handoff-anchor.js 权重表同源)。 */
|
|
23
|
+
export const HANDOFF_SECTIONS_V1 = Object.freeze(['任务状态', '目标', '已试方案与失败原因', '进度与下一步'])
|
|
24
|
+
|
|
25
|
+
/** 占位符黑名单(借 dsh-graph CRITERIA_PLACEHOLDERS 技巧, 方案 H3)。 */
|
|
26
|
+
export const CRITERIA_PLACEHOLDERS_V1 = Object.freeze(['(待补充)', '(待补充)', 'TODO', '同上', '略', 'N/A', 'n/a'])
|
|
27
|
+
|
|
28
|
+
/** 账本硬上限(H4, 与 sanitizeForWrite 8000 同源, 先于它执行)。 */
|
|
29
|
+
export const LEDGER_MAX_CHARS_V1 = 8000
|
|
30
|
+
/** 白板硬上限(P-H2, 200000)。 */
|
|
31
|
+
export const PLAN_MAX_CHARS_V1 = 200000
|
|
32
|
+
|
|
33
|
+
function nz(s) { return String(s || '') }
|
|
34
|
+
|
|
35
|
+
function sectionBodies(parsed) {
|
|
36
|
+
// parseHandoffLedgerPre 的 title 保留 '## ' 前缀原样行 — 剥前缀与尾随空白后与权威表比对。
|
|
37
|
+
return parsed.sections.map((s) => ({ title: nz(s.title).replace(/^##\s*/, '').replace(/\s+$/, ''), body: s.body }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPlaceholderLine(line) {
|
|
41
|
+
const t = nz(line).trim()
|
|
42
|
+
if (!t) return false
|
|
43
|
+
return CRITERIA_PLACEHOLDERS_V1.some((p) => t === p || t === '-' + p || t === '*' + p)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** H2: 每段 body 非空(≥1 非空行且合计 ≥20 字符)。 */
|
|
47
|
+
function bodyStat(body) {
|
|
48
|
+
const arr = Array.isArray(body) ? body : []
|
|
49
|
+
const nonEmpty = arr.filter((l) => nz(l).trim()).length
|
|
50
|
+
const chars = arr.join('').replace(/\s/g, '').length
|
|
51
|
+
return { nonEmpty, chars }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 校验交接账本(kind=handoff)。判据 H1-H4 硬 / S1-S4 软(方案 §2.2)。
|
|
56
|
+
* @param {string} text 账本全文
|
|
57
|
+
* @returns {report} §2.5 ledger_criteria_v1(不抛错; 解析失败按 H1 fail 处理)
|
|
58
|
+
*/
|
|
59
|
+
export function checkHandoffCriteriaPre(text) {
|
|
60
|
+
const hard = []
|
|
61
|
+
const soft = []
|
|
62
|
+
let parsed = null
|
|
63
|
+
try { parsed = parseHandoffLedgerPre(text) } catch (_) { parsed = null }
|
|
64
|
+
|
|
65
|
+
// H1 四段标题齐全且逐字匹配
|
|
66
|
+
const titles = parsed ? sectionBodies(parsed).map((x) => x.title) : []
|
|
67
|
+
const missing = HANDOFF_SECTIONS_V1.filter((t) => !titles.includes(t))
|
|
68
|
+
hard.push({ id: 'H1', pass: !!parsed && missing.length === 0, missing: parsed ? missing.map((t) => '缺段:' + t) : ['解析失败(无任何 ## 段)'] })
|
|
69
|
+
|
|
70
|
+
// H2 每段非空
|
|
71
|
+
const emptySections = parsed ? sectionBodies(parsed).filter((x) => { const st = bodyStat(x.body); return st.nonEmpty < 1 || st.chars < 20 }).map((x) => x.title) : []
|
|
72
|
+
hard.push({ id: 'H2', pass: !!parsed && emptySections.length === 0, missing: emptySections.map((t) => '空段:' + t) })
|
|
73
|
+
|
|
74
|
+
// H3 无占位符行(段 body 整行即占位符)
|
|
75
|
+
const placeholderHits = []
|
|
76
|
+
if (parsed) for (const s of sectionBodies(parsed)) for (const l of s.body) if (isPlaceholderLine(l)) placeholderHits.push('[' + s.title + '] ' + l.trim())
|
|
77
|
+
hard.push({ id: 'H3', pass: placeholderHits.length === 0, missing: placeholderHits.slice(0, 5) })
|
|
78
|
+
|
|
79
|
+
// H4 总长 ≤8000
|
|
80
|
+
const len = nz(text).length
|
|
81
|
+
hard.push({ id: 'H4', pass: len <= LEDGER_MAX_CHARS_V1, missing: len > LEDGER_MAX_CHARS_V1 ? ['总长 ' + len + ' > ' + LEDGER_MAX_CHARS_V1] : [] })
|
|
82
|
+
|
|
83
|
+
// S1 每段 ≤5 行(软)
|
|
84
|
+
const overLong = parsed ? sectionBodies(parsed).filter((x) => x.body.filter((l) => l.trim()).length > 5).map((x) => x.title) : []
|
|
85
|
+
soft.push({ id: 'S1', pass: overLong.length === 0, detail: overLong.length ? '超 5 行段: ' + overLong.join('、') : '' })
|
|
86
|
+
|
|
87
|
+
// S2 失败项含报错关键词(软)
|
|
88
|
+
const failSection = parsed ? sectionBodies(parsed).find((x) => x.title === '已试方案与失败原因') : null
|
|
89
|
+
const failText = failSection ? failSection.body.join('') : ''
|
|
90
|
+
const s2pass = !failSection || /失败|报错|错误|回滚|error|fail|bug/i.test(failText)
|
|
91
|
+
soft.push({ id: 'S2', pass: s2pass, detail: s2pass ? '' : '失败项建议写成「方案→失败原因」并保留报错关键词' })
|
|
92
|
+
|
|
93
|
+
// S3 下一步含可执行特征(软)
|
|
94
|
+
const nextSection = parsed ? sectionBodies(parsed).find((x) => x.title === '进度与下一步') : null
|
|
95
|
+
const nextText = nextSection ? nextSection.body.join('') : ''
|
|
96
|
+
const s3pass = !nextSection || /[\\/]|`|::\s*(读取|运行|执行|检查|写|改|删|重建|跑)/.test(nextText)
|
|
97
|
+
soft.push({ id: 'S3', pass: s3pass, detail: s3pass ? '' : '下一步建议含可执行特征(路径/代码/命令动词)' })
|
|
98
|
+
|
|
99
|
+
const hard_pass = hard.every((x) => x.pass)
|
|
100
|
+
return { version: LEDGER_CRITERIA_VERSION, target: 'handoff', hard_pass, hard, soft }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 校验白板 PLAN(kind=plan)。判据刻意最弱(P-H1/P-H2 硬, P-S1 软, 方案 §2.2 白板表)。
|
|
105
|
+
*/
|
|
106
|
+
export function checkPlanCriteriaPre(text) {
|
|
107
|
+
const hard = []
|
|
108
|
+
const soft = []
|
|
109
|
+
const src = nz(text)
|
|
110
|
+
// P-H1 至少一个 '## ' 顶层节且非空(≥20 非空白字符)
|
|
111
|
+
const lines = src.split('\n')
|
|
112
|
+
const sections = []
|
|
113
|
+
let cur = null
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
if (/^## (.+)$/.test(line.replace(/\r$/, ''))) { if (cur) sections.push(cur); cur = { body: [] } }
|
|
116
|
+
else if (cur) cur.body.push(line)
|
|
117
|
+
}
|
|
118
|
+
if (cur) sections.push(cur)
|
|
119
|
+
const nonEmptySections = sections.filter((s) => s.body.join('').replace(/\s/g, '').length >= 20)
|
|
120
|
+
hard.push({ id: 'P-H1', pass: nonEmptySections.length >= 1, missing: nonEmptySections.length === 0 ? ['无非空 ## 顶层节(全部节被老化走或空白板)'] : [] })
|
|
121
|
+
// P-H2 长度
|
|
122
|
+
hard.push({ id: 'P-H2', pass: src.length <= PLAN_MAX_CHARS_V1, missing: src.length > PLAN_MAX_CHARS_V1 ? ['总长 ' + src.length + ' > ' + PLAN_MAX_CHARS_V1] : [] })
|
|
123
|
+
// P-S1 前瞻内容(软)
|
|
124
|
+
const s1pass = /下一步|待办|计划|todo/i.test(src)
|
|
125
|
+
soft.push({ id: 'P-S1', pass: s1pass, detail: s1pass ? '' : '建议含前瞻内容(下一步/待办/计划)' })
|
|
126
|
+
const hard_pass = hard.every((x) => x.pass)
|
|
127
|
+
return { version: LEDGER_CRITERIA_VERSION, target: 'plan', hard_pass, hard, soft }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 统一入口: 按 target 分派。未知 target fail closed(返回 hard_pass=false)。 */
|
|
131
|
+
export function checkWriteCriteriaPre(target, text) {
|
|
132
|
+
if (target === 'handoff') return checkHandoffCriteriaPre(text)
|
|
133
|
+
if (target === 'plan') return checkPlanCriteriaPre(text)
|
|
134
|
+
return { version: LEDGER_CRITERIA_VERSION, target: String(target || ''), hard_pass: false, hard: [{ id: 'INVALID', pass: false, missing: ['未知 target'] }], soft: [] }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 把 report 渲染为可执行改写指引文案(P1-4 工具层拒绝文案用)。 */
|
|
138
|
+
export function renderCriteriaRejectionPre(report) {
|
|
139
|
+
const misses = []
|
|
140
|
+
for (const h of report.hard) if (!h.pass) misses.push(...(h.missing || []))
|
|
141
|
+
return '[写入门·判据未过(' + report.target + ')] 以下硬判据不通过, 请修正后重试(结构修正即可, 无需重写全文):\n- ' + misses.join('\n- ')
|
|
142
|
+
}
|
package/lib/m4-corpus.js
CHANGED
|
@@ -94,11 +94,17 @@ export function loadCorpusSnapshot(catalog, io = {}, opts = {}) {
|
|
|
94
94
|
if (buf.length > INDEX_MAX_FILE_BYTES) { dropped.push({ stage: 'corpus', reason: 'oversized', sourceRef: source.sourceRef }); continue }
|
|
95
95
|
const fileDigest = sha256Hex(buf)
|
|
96
96
|
if (fileDigest !== sc.fileDigest) { dropped.push({ stage: 'corpus', reason: 'stale-source', sourceRef: source.sourceRef }); continue }
|
|
97
|
+
// ★ issue #66 修复(2026-09-19):**byteStart/byteEnd 是相对「剥掉 BOM 之后」的坐标系**
|
|
98
|
+
// (契约见 `memory-anchor.js` 的 `parseAnchors` 与其 C18 断言;`fileDigest` 则按含 BOM 的整 buffer 算)。
|
|
99
|
+
// 旧实现直接 `buf.subarray(...)` 用在**含 BOM 的原 buffer** 上 ⇒ BOM 文件整体偏 3 字节
|
|
100
|
+
// ⇒ `recordDigest` 恒不等 ⇒ 该文件**全部记录被判 record-stale 丢弃**,从语料/召回静默消失。
|
|
101
|
+
// 修法:消费侧对齐——有 BOM 时先剥掉再切片(无 BOM 时 body === buf,零行为变化)。
|
|
102
|
+
const body = (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) ? buf.subarray(3) : buf
|
|
97
103
|
for (const r of (sc.records || [])) {
|
|
98
|
-
if (!Number.isInteger(r.byteStart) || !Number.isInteger(r.byteEnd) || r.byteStart < 0 || r.byteEnd >
|
|
104
|
+
if (!Number.isInteger(r.byteStart) || !Number.isInteger(r.byteEnd) || r.byteStart < 0 || r.byteEnd > body.length) {
|
|
99
105
|
dropped.push({ stage: 'corpus', reason: 'record-stale', memoryId: r.memoryId, sourceRef: source.sourceRef }); continue
|
|
100
106
|
}
|
|
101
|
-
if (sha256Hex(
|
|
107
|
+
if (sha256Hex(body.subarray(r.byteStart, r.byteEnd)) !== r.recordDigest) {
|
|
102
108
|
dropped.push({ stage: 'corpus', reason: 'record-stale', memoryId: r.memoryId, sourceRef: source.sourceRef }); continue
|
|
103
109
|
}
|
|
104
110
|
records.push({
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { buildIndexSyncPlansPre, sendIndexSyncPlanPre } from './index-sync.js'
|
|
24
24
|
import { workspaceRefOf } from './evidence-store.js'
|
|
25
|
+
import { createEngineSwitchPre } from './engine-switch.js'
|
|
25
26
|
|
|
26
27
|
export const M7_INDEX_SYNC_HOST_POLICY_VERSION = 'm7_index_sync_host_v1'
|
|
27
28
|
const MAX_PATH_KEYS = 8
|
|
@@ -30,12 +31,23 @@ const MAX_DROPS = 16
|
|
|
30
31
|
export function createIndexSyncHostPre(opts = {}) {
|
|
31
32
|
const engine = opts.engine
|
|
32
33
|
if (!engine) throw new Error('index-sync-host: engine required')
|
|
33
|
-
const readyCache = new Map() // key=(wsRef,scope) -> { miv, epoch, at }
|
|
34
|
+
const readyCache = new Map() // key=(wsRef,scope) -> { miv, epoch, at, generation }
|
|
34
35
|
const inFlight = new Map() // key=(wsRef,scope) -> { controller, promise, miv }
|
|
35
36
|
const enabledKeys = new Set() // 显式 disable 的 key(同一 miv 内不再重试;miv 变化自动解除)
|
|
36
37
|
const volatileDrops = [] // ≤16 条最小投影(无文本)
|
|
37
38
|
const stats = { syncsStarted: 0, syncsOk: 0, syncsFailed: 0, skippedCached: 0,
|
|
38
|
-
epochReset: 0, mivReplaced: 0, aborted: 0, drops: 0, readyHits: 0 }
|
|
39
|
+
epochReset: 0, mivReplaced: 0, aborted: 0, drops: 0, readyHits: 0, generationResets: 0 }
|
|
40
|
+
// ★P2(T2-9a):**重建代际**。切档 = 新 generation ⇒ 旧代 readyCache 一律不得跳过重建
|
|
41
|
+
// (含 e5→BGE→e5 回到旧引擎:旧代的 ready 缓存同样作废,必须完整重跑)。
|
|
42
|
+
let generation = 0
|
|
43
|
+
// ★P2(T2-9b/c/d):切换状态机由本宿主持有(评审 §3.5 采纳条目),状态唯一所有者;
|
|
44
|
+
// semantic-status / 向导只投影它,不另立计数。
|
|
45
|
+
const switchMachine = createEngineSwitchPre({
|
|
46
|
+
now: opts.now,
|
|
47
|
+
io: opts.switchIo || null,
|
|
48
|
+
persistPath: opts.switchPersistPath || '',
|
|
49
|
+
diag: opts.diag,
|
|
50
|
+
})
|
|
39
51
|
|
|
40
52
|
function enabled() {
|
|
41
53
|
return engine.config.associativeMemoryEnabled === true &&
|
|
@@ -78,8 +90,22 @@ export function createIndexSyncHostPre(opts = {}) {
|
|
|
78
90
|
const epoch = currentEpoch()
|
|
79
91
|
const k = keyOf(wsRef, scope)
|
|
80
92
|
const cached = readyCache.get(k)
|
|
93
|
+
// ★P2(T2-9a)重建代际失效:切档后 generation 前进 ⇒ 旧代 ready 缓存**不得跳过重建**
|
|
94
|
+
// (e5→BGE→e5 回到旧引擎也一样:缓存是"旧代的",必须完整重跑)。
|
|
95
|
+
if (cached && (Number(cached.generation) || 0) !== generation) {
|
|
96
|
+
readyCache.delete(k)
|
|
97
|
+
stats.generationResets++
|
|
98
|
+
drop('generation-reset', 0, ctxKey)
|
|
99
|
+
}
|
|
81
100
|
// epoch 变化(worker 重启) → 缓存失效,必须重同步
|
|
82
|
-
|
|
101
|
+
// ★ issue #69 修复(2026-09-19):判据补上「**cached.epoch 为空 ⇒ 一律视为无效**」。
|
|
102
|
+
// 旧写法 `if (cached && epoch && cached.epoch !== epoch)` 里的 **`epoch &&` 前置**是缺陷所在:
|
|
103
|
+
// 当 `currentEpoch()` 返回 null(**client 未启动 / 已 exit 但 stdio 未排空**)时,该分支**永不成立**
|
|
104
|
+
// ⇒ 一条 `cached.epoch === null` 的旧缓存(冷启动时采样于 lazy spawn 之前,见 `:90`)会被一直保留
|
|
105
|
+
// ⇒ 对刚 spawn 出来的空索引 worker 误判 `ready=true`,该段 `context_push` 拿到零候选零激活,
|
|
106
|
+
// 且 `context-host` 的 `indexNotReady` 也不点亮(readyRes.ready 为 true)⇒ **连降级留痕都没有**。
|
|
107
|
+
// 保留旧语义的另一半(epoch 非空时要求严格相等)——worker 重启换 epoch 仍会正常失效重同步。
|
|
108
|
+
if (cached && (!cached.epoch || (epoch && cached.epoch !== epoch))) {
|
|
83
109
|
readyCache.delete(k)
|
|
84
110
|
stats.epochReset++
|
|
85
111
|
drop('epoch-reset', 0, ctxKey)
|
|
@@ -117,12 +143,15 @@ export function createIndexSyncHostPre(opts = {}) {
|
|
|
117
143
|
const promise = sendIndexSyncPlanPre(c, plan, { signal: signal2, timeoutMs: syncTimeoutMs })
|
|
118
144
|
.then((res) => {
|
|
119
145
|
if (res.ok) {
|
|
120
|
-
readyCache.set(k, { miv, epoch, at: Date.now() })
|
|
146
|
+
readyCache.set(k, { miv, epoch, at: Date.now(), generation })
|
|
121
147
|
stats.syncsOk++
|
|
148
|
+
// ★P2(T2-9c):进度**来自实际完成量** —— 只有真同步成功后才上报该 scope 单元。
|
|
149
|
+
try { switchMachine.reportDone(switchMachine.getEngineSwitchStatusPre().switchId, [k]) } catch (_) {}
|
|
122
150
|
return { ok: true, ready: true, miv, epoch }
|
|
123
151
|
}
|
|
124
152
|
stats.syncsFailed++
|
|
125
153
|
drop('sync:' + (res.reason || res.phase || 'failed'), 0, ctxKey)
|
|
154
|
+
try { switchMachine.reportFailed(switchMachine.getEngineSwitchStatusPre().switchId, 1, res.reason || res.phase || 'sync-failed') } catch (_) {}
|
|
126
155
|
return { ok: false, ready: false, reason: (res.reason || res.phase || 'sync-failed') }
|
|
127
156
|
})
|
|
128
157
|
.catch((err) => {
|
|
@@ -164,7 +193,9 @@ export function createIndexSyncHostPre(opts = {}) {
|
|
|
164
193
|
return {
|
|
165
194
|
enabled: true,
|
|
166
195
|
policyVersion: M7_INDEX_SYNC_HOST_POLICY_VERSION,
|
|
167
|
-
|
|
196
|
+
generation,
|
|
197
|
+
engineSwitch: switchMachine.getEngineSwitchStatusPre(),
|
|
198
|
+
ready: [...readyCache.entries()].map(([k, v]) => ({ key: k, miv: v.miv, epoch: v.epoch ? v.epoch.slice(0, 12) : null, generation: v.generation })),
|
|
168
199
|
inFlightCount: inFlight.size,
|
|
169
200
|
capturedPathKeys: [...enabledKeys].slice(0, MAX_PATH_KEYS),
|
|
170
201
|
stats: { ...stats },
|
|
@@ -173,6 +204,33 @@ export function createIndexSyncHostPre(opts = {}) {
|
|
|
173
204
|
}
|
|
174
205
|
}
|
|
175
206
|
|
|
207
|
+
/**
|
|
208
|
+
* ★P2(T2-9)开始一次**实际**引擎切换:新 rebuild generation + 冻结目标身份。
|
|
209
|
+
* 由调用方在**配置落盘之后**调用。返回 {ok, switchId, generation}。
|
|
210
|
+
* 副作用:generation 前进 ⇒ 所有旧代 readyCache 立即失效(下次 ensureIndexReady 必完整重跑)。
|
|
211
|
+
*/
|
|
212
|
+
function beginEngineSwitchPre(input = {}) {
|
|
213
|
+
const r = switchMachine.beginEngineSwitchPre(input)
|
|
214
|
+
if (r.ok && r.fresh) {
|
|
215
|
+
generation = Number(r.generation) || generation + 1
|
|
216
|
+
// 旧代 ready 缓存整体作废(不清盘、不删文件:缓存目录本身不就地覆盖,见回滚口径)
|
|
217
|
+
readyCache.clear()
|
|
218
|
+
for (const [, infl] of inFlight) { try { infl.controller.abort() } catch (_) {} }
|
|
219
|
+
stats.generationResets++
|
|
220
|
+
drop('engine-switch', 0, 'begin')
|
|
221
|
+
}
|
|
222
|
+
return r
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function cancelEngineSwitchPre(switchId) { return switchMachine.cancelEngineSwitchPre(switchId) }
|
|
226
|
+
function getEngineSwitchStatusPre() { return switchMachine.getEngineSwitchStatusPre() }
|
|
227
|
+
function reportEngineSwitchDone(switchId, unitKeys, opts2) { return switchMachine.reportDone(switchId, unitKeys, opts2) }
|
|
228
|
+
function setEngineSwitchTotal(switchId, total) { return switchMachine.setTotal(switchId, total) }
|
|
229
|
+
function publishEngineSwitchReady(switchId) { return switchMachine.publishReady(switchId) }
|
|
230
|
+
function failEngineSwitch(switchId, reason) { return switchMachine.failSwitch(switchId, reason) }
|
|
231
|
+
/** 当前是否处于"强制全量重建"代际(切换未完成)。 */
|
|
232
|
+
function requiresFullRebuild() { return switchMachine.requiresFullRebuild() }
|
|
233
|
+
|
|
176
234
|
function dispose(reason) {
|
|
177
235
|
for (const [, infl] of inFlight) { try { infl.controller.abort() } catch (_) {} }
|
|
178
236
|
inFlight.clear()
|
|
@@ -187,6 +245,16 @@ export function createIndexSyncHostPre(opts = {}) {
|
|
|
187
245
|
ensureWorkspaceIndexReady,
|
|
188
246
|
debugView,
|
|
189
247
|
dispose,
|
|
248
|
+
// ★P2:切档 API(评审 §3.5 采纳:状态由本宿主持有,唯一所有者)
|
|
249
|
+
beginEngineSwitchPre,
|
|
250
|
+
cancelEngineSwitchPre,
|
|
251
|
+
getEngineSwitchStatusPre,
|
|
252
|
+
reportEngineSwitchDone,
|
|
253
|
+
setEngineSwitchTotal,
|
|
254
|
+
publishEngineSwitchReady,
|
|
255
|
+
failEngineSwitch,
|
|
256
|
+
requiresFullRebuild,
|
|
257
|
+
_generationForTest: () => generation,
|
|
190
258
|
_stats: stats,
|
|
191
259
|
_readyCacheForTest: readyCache,
|
|
192
260
|
_inFlightForTest: inFlight,
|
package/lib/m7-wire.js
CHANGED
|
@@ -104,7 +104,7 @@ export function makeRequestFramePre(input) {
|
|
|
104
104
|
const sentAt = Number(input && input.sentAt)
|
|
105
105
|
const frame = {
|
|
106
106
|
protocolVersion: M7_WIRE_PROTOCOL_VERSION_V1,
|
|
107
|
-
frameId: 'frm_' + first32(sha256Str(JSON.stringify(['m7-frame-
|
|
107
|
+
frameId: 'frm_' + first32(sha256Str(JSON.stringify(['m7-frame-v1', type, requestId, sentAt]))),
|
|
108
108
|
requestId,
|
|
109
109
|
workerEpoch: String((input && input.workerEpoch) || ''),
|
|
110
110
|
type,
|
|
@@ -224,12 +224,12 @@ export function validateIndexAckPayloadPre(pl) {
|
|
|
224
224
|
|
|
225
225
|
/** 派生 chunk 身份(占位 chunking=整记录单 chunk;M7-2 tokenizer 落地前冻结此派生规则)。 */
|
|
226
226
|
export function buildChunkIdPre(memoryId, recordDigest) {
|
|
227
|
-
return 'chk_' + first32(sha256Str(JSON.stringify(['semantic-chunk-
|
|
227
|
+
return 'chk_' + first32(sha256Str(JSON.stringify(['semantic-chunk-v1', String(memoryId || ''), String(recordDigest || '')])))
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/** syncId:由 workspaceRef+scope+memoryIndexVersion+recordCount 确定(同快照重放同 id)。 */
|
|
231
231
|
export function buildSyncIdPre(workspaceRef, scope, memoryIndexVersion, recordCount) {
|
|
232
|
-
return 'syn_' + first32(sha256Str(JSON.stringify(['index-sync-
|
|
232
|
+
return 'syn_' + first32(sha256Str(JSON.stringify(['index-sync-v1', String(workspaceRef || ''), String(scope || ''), String(memoryIndexVersion || ''), Number(recordCount) | 0])))
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
/** pageDigest = sha256(canonical(records 数组))。 */
|
package/lib/memory-anchor.js
CHANGED
|
@@ -26,7 +26,12 @@ export const SIDECAR_NAMESPACE = 'dsh-auto-memory'
|
|
|
26
26
|
export const ANCHOR_PREFIX = 'memory:'
|
|
27
27
|
export const MEMORY_ID_RE = /^mem_[0-9a-f]{32}$/
|
|
28
28
|
export const MARKER_RE = /^<!-- memory:(mem_[0-9a-f]{32}) -->$/
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* 保留语法开标记(issue #54):写入侧**必须**复用本常量做前置校验。
|
|
31
|
+
* 导出而非私有 —— 过去只被本文件的读取路径(:177/:182 判定)引用,写入路径零引用,
|
|
32
|
+
* 于是"文件已有内容干净"≠"本次要写的内容干净",一次误写即让该文件此后永久拒写。
|
|
33
|
+
*/
|
|
34
|
+
export const MARKER_OPEN = '<!-- memory:'
|
|
30
35
|
const HEADING_RE = /^#{1,6}\s/
|
|
31
36
|
const HEX64_RE = /^[0-9a-f]{64}$/
|
|
32
37
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
@@ -36,6 +41,38 @@ export function newMemoryId(rnd) {
|
|
|
36
41
|
return 'mem_' + (rnd || randomUUID().replace(/-/g, ''))
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
/**
|
|
45
|
+
* issue #54:检测**待写入正文**是否含保留 marker 语法。
|
|
46
|
+
*
|
|
47
|
+
* 背景:`appendAnchoredRecord()` 等写入原语只对「文件已有内容」跑 `parseAnchors()`,
|
|
48
|
+
* 从不校验本次要写入的 `text`。含 `MARKER_OPEN` 的正文落盘后,从**下一次**写入起
|
|
49
|
+
* `parseAnchors` 判 `orphan-content` fail-closed ⇒ 该文件全部写入被永久拒绝。
|
|
50
|
+
*
|
|
51
|
+
* 本函数与 `parseAnchors` 用**同一个** `MARKER_OPEN` 常量判据,保证读写两侧口径永不漂移。
|
|
52
|
+
* 不做转义/静默改写(转义救不了:`parseAnchors` 无转义机制,`.includes()` 照样命中)。
|
|
53
|
+
*
|
|
54
|
+
* @param {string} text 待写入正文
|
|
55
|
+
* @returns {{ok:true}|{ok:false,reason:'reserved-syntax-in-content',line:number,detail:string}}
|
|
56
|
+
*/
|
|
57
|
+
export function checkReservedSyntaxInContent(text) {
|
|
58
|
+
const s = String(text == null ? '' : text)
|
|
59
|
+
if (!s.includes(MARKER_OPEN)) return { ok: true }
|
|
60
|
+
const lines = s.split(/\r?\n/)
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
if (lines[i].includes(MARKER_OPEN)) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: 'reserved-syntax-in-content',
|
|
66
|
+
// 行号按**待写入正文内**的 1-based 相对行号;调用方负责再映射为文件绝对行号。
|
|
67
|
+
line: i + 1,
|
|
68
|
+
detail: '待写入内容含保留 marker 语法 ' + MARKER_OPEN + '(第 ' + (i + 1) + ' 行): ' + lines[i].slice(0, 60),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 理论上不可达(上面已判 includes),保留兜底以便未来改判据时不静默漏过。
|
|
73
|
+
return { ok: false, reason: 'reserved-syntax-in-content', line: 1, detail: '待写入内容含保留 marker 语法' }
|
|
74
|
+
}
|
|
75
|
+
|
|
39
76
|
function sha256Hex(buf) {
|
|
40
77
|
return createHash('sha256').update(buf).digest('hex')
|
|
41
78
|
}
|
|
@@ -448,4 +485,22 @@ export function buildAnchoredIndex(sourceFile, content, prev) {
|
|
|
448
485
|
return { sourceFile, fileDigest, sourceVersion, skipped: false, records }
|
|
449
486
|
}
|
|
450
487
|
|
|
488
|
+
/**
|
|
489
|
+
* issue #54 配套:把**跨文档引用**的正文里的结构 marker 行剥掉。
|
|
490
|
+
*
|
|
491
|
+
* 背景:把 A 文档的原文(含 A 的 `<!-- memory:mem_xxx -->` 行)当正文追加进 B 文档时,
|
|
492
|
+
* 那行 marker 会变成 B 的结构锚点 —— 产生一条**幻影记录**(身份属于 A、却挂在 B 上),
|
|
493
|
+
* 且此后 `parseAnchors` 会把它当真实锚点参与 sidecar/索引,属静默结构损坏。
|
|
494
|
+
*
|
|
495
|
+
* 口径:只剥「整行恰好是合法 marker」的行(`MARKER_RE`),保留其余一切字节;
|
|
496
|
+
* 行内出现的 marker 片段不在此处理(那属 #54 写入守卫的职责)。
|
|
497
|
+
* @param {string} text 待内联的正文
|
|
498
|
+
* @returns {string} 剥掉 marker 行后的文本
|
|
499
|
+
*/
|
|
500
|
+
export function stripAnchorLines(text) {
|
|
501
|
+
const s = String(text == null ? '' : text)
|
|
502
|
+
if (!s.includes(MARKER_OPEN)) return s
|
|
503
|
+
return s.split(/\r?\n/).filter((ln) => !MARKER_RE.test(ln)).join('\n')
|
|
504
|
+
}
|
|
505
|
+
|
|
451
506
|
export { detectNewline }
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆注入信封 · 分项账本(memory_envelope_v1)—— 契约 T0-3 / 总纲 §0.5 第 6 条。
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-14 建立(P0)。**为什么需要这个模块**:
|
|
5
|
+
*
|
|
6
|
+
* 现行注入存在**两处实测缺陷**(读码确认,不是推测):
|
|
7
|
+
* ① `lib/tier-layer-inject.js:327–335`(旧行号):总长门只裁"下探段",
|
|
8
|
+
* `headParts`(目录 + 降级行)**从不动**;而且降级说明是**在裁剪之后**才追加的
|
|
9
|
+
* ⇒ 最终长度可以**超过** `maxTotalChars`。
|
|
10
|
+
* ② `lib/index.js`(`renderMemoryDynamic`):`catalogCost` 被 `Math.min(…, budget*0.35)` **封顶**,
|
|
11
|
+
* 封顶的是"**扣账成本**";实际注入用的是全文 `state.tier0LayerText`,**不参与 used 记账**。
|
|
12
|
+
* 另外 `used` 变量只累加、**从未被读取**(注水账本)。
|
|
13
|
+
*
|
|
14
|
+
* 后果:**没人能回答"这一轮到底注入了多少、分别花在哪"**。v2 的要求是把它换成
|
|
15
|
+
* **分项账本**:`chars: { rules, memoryReferences, otherDynamic, total }` +
|
|
16
|
+
* `limits: { memoryReferences, otherDynamic }`。
|
|
17
|
+
*
|
|
18
|
+
* **本模块的三条不变式**(每条都有能红的断言,见 `tests/smoke/smoke-test-t0-3-budget-ledger-pre.mjs`):
|
|
19
|
+
* E1 **总计 = 最终序列化长度**:`chars.total === text.length`(逐字节相等,不是估算)。
|
|
20
|
+
* E2 **不允许未计费尾巴**:每个字符都归属某个分项;`rules + memoryReferences + otherDynamic === total`。
|
|
21
|
+
* 标题、分隔符、降级说明**都必须作为某一段的文本被计入**(本模块不给任何"隐含前缀"留位置)。
|
|
22
|
+
* E3 **限制是硬约束**:启用了 `limits.X` 的分项,其实际字符数 **必须 ≤ 上限**;
|
|
23
|
+
* 超限时按**整段**从该分项尾部丢弃(不腰斩半段),仍超则截断最后一段并显式标注
|
|
24
|
+
* —— **裁剪永远可见**(I7:不静默)。
|
|
25
|
+
*
|
|
26
|
+
* **明确代价**(v2 要求写清,不允许用换记账写法掩盖):规则越长,注入总量越大;
|
|
27
|
+
* 本模块**不再承诺**"全部动态内容有一个与规则规模无关的固定上限"。总纲 §0.5 第 6 条已接受此代价。
|
|
28
|
+
*
|
|
29
|
+
* **设计取舍——为什么不重排段落**:分项账本只做"归类 + 计量 + 限额",**输出顺序 = 调用方给的顺序**,
|
|
30
|
+
* 每段文本逐字节照搬(段自带前导 `\n`)。这样既满足 E1/E2,又不会顺手改变注入布局
|
|
31
|
+
* (前缀缓存命中、既有套件的字节断言都不受影响)。
|
|
32
|
+
*
|
|
33
|
+
* S9 合规:零 IO、零依赖、纯函数、无网络、无 LLM、无子进程、全同步。
|
|
34
|
+
* UTF-8 无 BOM。
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export const MEMORY_ENVELOPE_VERSION = 'memory_envelope_v1'
|
|
38
|
+
|
|
39
|
+
/** 分项取值域(固定顺序仅用于**账本展示**;序列化顺序仍由调用方决定)。 */
|
|
40
|
+
export const ENVELOPE_BUCKETS_V1 = Object.freeze(['rules', 'memoryReferences', 'otherDynamic'])
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 段落优先级(★2026-09-15 新增)—— 决定"超额时先丢谁"。
|
|
44
|
+
*
|
|
45
|
+
* **为什么必须有它**:旧的丢弃是**纯位置式**(从分项尾部丢)。而尾部恰好是
|
|
46
|
+
* `project-notes` 与 `user-memory`(本机实测:这两段被整段丢弃,而它们的价值最高)。
|
|
47
|
+
* 位置顺序是**渲染顺序**,不是**价值顺序** —— 用渲染顺序决定丢谁,等于让"谁写在后面谁先死"。
|
|
48
|
+
*
|
|
49
|
+
* - `must`:**永不丢弃**(用户级硬性规则、块首尾框架行)。若连它们都超限,如实报告超额,不静默丢。
|
|
50
|
+
* - `normal`:默认档。
|
|
51
|
+
* - `low`:先丢(如最近日志——量最大、时效性最强、可从文件重读)。
|
|
52
|
+
*/
|
|
53
|
+
export const ENVELOPE_PRIORITIES_V1 = Object.freeze(['low', 'normal', 'must'])
|
|
54
|
+
const PRIORITY_RANK = Object.freeze({ low: 0, normal: 1, must: 2 })
|
|
55
|
+
const normalizePriority = (v) => (Object.prototype.hasOwnProperty.call(PRIORITY_RANK, String(v)) ? String(v) : 'normal')
|
|
56
|
+
|
|
57
|
+
/** 分项中文名(面板/日志可读;I7 精神:机器枚举之外要有人话)。 */
|
|
58
|
+
export const ENVELOPE_BUCKET_TEXT_V1 = Object.freeze({
|
|
59
|
+
rules: '规则类',
|
|
60
|
+
memoryReferences: '记忆参考类',
|
|
61
|
+
otherDynamic: '其他动态内容',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/** 账本相关的原因码 → 可读中文。 */
|
|
65
|
+
export const ENVELOPE_REASONS_V1 = Object.freeze({
|
|
66
|
+
'over-limit-dropped': '整段丢弃(超出分项上限)',
|
|
67
|
+
'over-limit-truncated': '截断(单段本身即超上限)',
|
|
68
|
+
'over-budget-total': '合计超出注入预算(分项账本如实记录,不掩盖)',
|
|
69
|
+
'unknown-bucket': '分项名非法(该段被计入 otherDynamic 并留痕)',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const clean = (v) => String(v == null ? '' : v)
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 组装注入信封(**纯函数**:同输入必定同输出)。
|
|
76
|
+
*
|
|
77
|
+
* @param {object} input
|
|
78
|
+
* @param {Array<{bucket:'rules'|'memoryReferences'|'otherDynamic', text:string, kind?:string}>} input.segments
|
|
79
|
+
* **已渲染好的段落文本**,按调用方期望的输出顺序给出。每段文本逐字节照搬
|
|
80
|
+
* (段内如需要前导换行,由调用方写进 `text`;本模块不补、不改、不重排)。
|
|
81
|
+
* @param {{memoryReferences?:number, otherDynamic?:number}} [input.limits]
|
|
82
|
+
* 分项字符上限。**未给出 = 该分项不设上限**(`rules` 在 P0/P6A 语义下**不参与裁剪**,
|
|
83
|
+
* 故即使传了也会被忽略并留痕)。
|
|
84
|
+
* @param {number} [input.budgetChars] 参考用的"总预算"(**只用于超额可见性**,不是硬门)。
|
|
85
|
+
* 给出后,若 `chars.total > budgetChars` 会产出一条 `over-budget-total` 降级项
|
|
86
|
+
* —— 这是 v2 明确要求的"不许用记账写法掩盖代价"。
|
|
87
|
+
* @returns {{text:string, chars:{rules:number,memoryReferences:number,otherDynamic:number,total:number},
|
|
88
|
+
* limits:{memoryReferences:number|null, otherDynamic:number|null, rulesIgnored?:boolean},
|
|
89
|
+
* segments:Array, dropped:Array, truncated:Array, degradations:Array, ok:boolean}}
|
|
90
|
+
*/
|
|
91
|
+
export function composeMemoryEnvelopePre(input = {}) {
|
|
92
|
+
const o = input && typeof input === 'object' ? input : {}
|
|
93
|
+
const rawSegs = Array.isArray(o.segments) ? o.segments.filter(Boolean) : []
|
|
94
|
+
const limitsIn = o && typeof o.limits === 'object' && o.limits ? o.limits : {}
|
|
95
|
+
const dropped = []
|
|
96
|
+
const truncated = []
|
|
97
|
+
const degradations = []
|
|
98
|
+
|
|
99
|
+
// ── ① 归位:分项名非法的段落**不丢**,计入 otherDynamic 并留痕(不静默改归属) ──
|
|
100
|
+
const segs = rawSegs.map((s, i) => {
|
|
101
|
+
const text = clean(s && s.text)
|
|
102
|
+
let bucket = clean(s && s.bucket)
|
|
103
|
+
if (!ENVELOPE_BUCKETS_V1.includes(bucket)) {
|
|
104
|
+
degradations.push({
|
|
105
|
+
code: 'unknown-bucket', bucket: 'otherDynamic',
|
|
106
|
+
text: '[降级] 第 ' + (i + 1) + ' 段分项名非法(' + (bucket || '(空)') + ')→ 已计入其他动态内容,未丢弃',
|
|
107
|
+
})
|
|
108
|
+
bucket = 'otherDynamic'
|
|
109
|
+
}
|
|
110
|
+
return { bucket, kind: clean(s && s.kind), text, index: i, priority: normalizePriority(s && s.priority) }
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ── ② 限额:只对 memoryReferences / otherDynamic 生效;rules 不参与裁剪(P0/P6A 语义) ──
|
|
114
|
+
// ★2026-09-15 修复:`null` / `undefined` / 空串 **必须表示"不设限"**。
|
|
115
|
+
// 旧写法 `Number(limitsIn[b])` 会把 `null` 变成 **0**(`Number(null)===0`)⇒ 上限 0 ⇒ 该分项被整段丢光。
|
|
116
|
+
// 实测病症:调用方显式传 `otherDynamic: null`(= 未配置不设限)时,注入块只剩骨架两行,
|
|
117
|
+
// `smoke-test.mjs` 立刻报 "dynamic context missing status line"。
|
|
118
|
+
// 这类"缺省语义被数值化吃掉"的坑与 P6A 的 `Number(v) || 5` 是同一族,故在此显式区分三态。
|
|
119
|
+
const limitOf = (b) => {
|
|
120
|
+
if (b === 'rules') return null
|
|
121
|
+
const raw = limitsIn[b]
|
|
122
|
+
if (raw === null || raw === undefined || raw === '') return null
|
|
123
|
+
const n = Number(raw)
|
|
124
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null
|
|
125
|
+
}
|
|
126
|
+
const limits = {
|
|
127
|
+
memoryReferences: limitOf('memoryReferences'),
|
|
128
|
+
otherDynamic: limitOf('otherDynamic'),
|
|
129
|
+
}
|
|
130
|
+
// rules 若被显式传了上限 → 明确忽略并留痕("规则不参与裁剪"是已定要求,不能被一个配置悄悄推翻)
|
|
131
|
+
const rulesLimitRaw = Number(limitsIn.rules)
|
|
132
|
+
if (limitsIn.rules !== undefined && Number.isFinite(rulesLimitRaw)) {
|
|
133
|
+
limits.rulesIgnored = true
|
|
134
|
+
degradations.push({
|
|
135
|
+
code: 'unknown-bucket', bucket: 'rules', inject: false,
|
|
136
|
+
text: '[降级] rules 分项上限(' + rulesLimitRaw + ')已被忽略:规则类不参与裁剪(分项账本仍如实体现在 chars.rules)',
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const kept = segs.slice()
|
|
141
|
+
const rankOf = (s) => PRIORITY_RANK[normalizePriority(s.priority)]
|
|
142
|
+
const bucketTotal = (bucket) => kept.filter((s) => s.bucket === bucket).reduce((a, s) => a + s.text.length, 0)
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* ★2026-09-15 修复:丢弃顺序改为**优先级感知**(旧实现是纯位置式,从尾部丢)。
|
|
146
|
+
*
|
|
147
|
+
* 规则:同一分项内,先丢**优先级最低**的段;同档内部从**文档尾部**丢(顺序稳定)。
|
|
148
|
+
* `must` **永不丢弃** —— 若丢无可丢仍超限,**如实报告**而不是悄悄丢掉最高价值的内容。
|
|
149
|
+
* 只剩最后一段可丢时**截断**它(保留部分内容优于整段消失),与旧语义一致。
|
|
150
|
+
*/
|
|
151
|
+
const enforceLimit = (bucket) => {
|
|
152
|
+
const lim = limits[bucket]
|
|
153
|
+
if (lim === null) return
|
|
154
|
+
for (;;) {
|
|
155
|
+
if (bucketTotal(bucket) <= lim) return
|
|
156
|
+
const mine = kept.filter((s) => s.bucket === bucket)
|
|
157
|
+
const droppable = mine.filter((s) => rankOf(s) < PRIORITY_RANK.must)
|
|
158
|
+
if (!droppable.length) {
|
|
159
|
+
// 全是 must ⇒ 不丢,如实报告超额(I7:可见,但不牺牲最高价值内容)
|
|
160
|
+
degradations.push({
|
|
161
|
+
code: 'over-limit-must', bucket, inject: true,
|
|
162
|
+
text: '[降级] ' + ENVELOPE_BUCKET_TEXT_V1[bucket] + '超出分项上限 ' + lim + ' 字符,但剩余 '
|
|
163
|
+
+ mine.length + ' 段均为 must 级(不可丢)⇒ 本轮**保留全部**并如实报告超额 '
|
|
164
|
+
+ (bucketTotal(bucket) - lim) + ' 字符(不静默丢弃硬性内容)',
|
|
165
|
+
})
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (droppable.length >= 2) {
|
|
169
|
+
const minRank = Math.min(...droppable.map(rankOf))
|
|
170
|
+
const group = droppable.filter((s) => rankOf(s) === minRank)
|
|
171
|
+
const victim = group[group.length - 1]
|
|
172
|
+
kept.splice(kept.indexOf(victim), 1)
|
|
173
|
+
dropped.push({ bucket, kind: victim.kind, chars: victim.text.length, priority: victim.priority, reason: 'over-limit-dropped' })
|
|
174
|
+
degradations.push({
|
|
175
|
+
code: 'over-limit-dropped', bucket, inject: true,
|
|
176
|
+
text: '[降级] ' + ENVELOPE_BUCKET_TEXT_V1[bucket] + '超出分项上限 ' + lim + ' 字符 → 丢弃优先级最低的一段'
|
|
177
|
+
+ (victim.kind ? '(' + victim.kind + ',priority=' + normalizePriority(victim.priority) + ')' : '')
|
|
178
|
+
+ ',未静默',
|
|
179
|
+
})
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
// 只剩一段可丢 ⇒ 截断(截断标记本身占字符,先预留)
|
|
183
|
+
const only = droppable[0]
|
|
184
|
+
const mark = '…(已按分项预算截断,原文 ' + only.text.length + ' 字符)'
|
|
185
|
+
if (lim <= mark.length + 1) {
|
|
186
|
+
kept.splice(kept.indexOf(only), 1)
|
|
187
|
+
dropped.push({ bucket, kind: only.kind, chars: only.text.length, priority: only.priority, reason: 'over-limit-truncated' })
|
|
188
|
+
degradations.push({
|
|
189
|
+
code: 'over-limit-truncated', bucket, inject: true,
|
|
190
|
+
text: '[降级] ' + ENVELOPE_BUCKET_TEXT_V1[bucket] + '上限 ' + lim + ' 字符过小(放不下截断标记)→ 本段本轮为空',
|
|
191
|
+
})
|
|
192
|
+
} else {
|
|
193
|
+
const before = only.text.length
|
|
194
|
+
// ★2026-09-20 移植(issue #94③ / PR #100):预算必须先扣掉同分项内 **must 段**的占用 ——
|
|
195
|
+
// 旧实现 room = lim − mark 不扣 must ⇒ 超限纯由 must 造成时,对本已放得下的段也追加
|
|
196
|
+
// 「已截断」标记(越截越长)并注入失实降级说明。
|
|
197
|
+
const mustChars = bucketTotal(bucket) - only.text.length
|
|
198
|
+
const available = Math.max(0, lim - mustChars)
|
|
199
|
+
const room = Math.max(1, available - mark.length)
|
|
200
|
+
only.text = only.text.slice(0, room) + mark
|
|
201
|
+
truncated.push({ bucket, kind: only.kind, charsBefore: before, charsAfter: only.text.length, reason: 'over-limit-truncated' })
|
|
202
|
+
degradations.push({
|
|
203
|
+
code: 'over-limit-truncated', bucket, inject: true,
|
|
204
|
+
text: '[降级] ' + ENVELOPE_BUCKET_TEXT_V1[bucket] + '只剩一段仍超上限 ' + lim + ' 字符 → 已截断(' + before + ' → ' + only.text.length + ')',
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
// 截断后复核:must 段自身超限时如实报告,不假装达标
|
|
208
|
+
if (bucketTotal(bucket) > lim) {
|
|
209
|
+
degradations.push({
|
|
210
|
+
code: 'over-limit-must', bucket, inject: true,
|
|
211
|
+
text: '[降级] ' + ENVELOPE_BUCKET_TEXT_V1[bucket] + '截断后仍超上限 ' + lim + ' 字符(剩余为 must 级不可丢)⇒ 如实报告超额 '
|
|
212
|
+
+ (bucketTotal(bucket) - lim) + ' 字符',
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
enforceLimit('memoryReferences')
|
|
219
|
+
enforceLimit('otherDynamic')
|
|
220
|
+
|
|
221
|
+
// ── ③ 序列化:顺序 = 调用方给的顺序,逐字节拼接(不补分隔符、不重排) ──
|
|
222
|
+
const text = kept.map((s) => s.text).join('')
|
|
223
|
+
|
|
224
|
+
const chars = { rules: 0, memoryReferences: 0, otherDynamic: 0, total: text.length }
|
|
225
|
+
for (const s of kept) chars[s.bucket] += s.text.length
|
|
226
|
+
|
|
227
|
+
// ── ④ 超额可见性:分项账本如实记录,**不用任何写法掩盖**(v2 明确要求) ──
|
|
228
|
+
const budgetChars = Number.isFinite(Number(o.budgetChars)) && Number(o.budgetChars) > 0 ? Math.floor(Number(o.budgetChars)) : null
|
|
229
|
+
if (budgetChars !== null && chars.total > budgetChars) {
|
|
230
|
+
const parts = ENVELOPE_BUCKETS_V1.map((b) => ENVELOPE_BUCKET_TEXT_V1[b] + ' ' + chars[b]).join(' · ')
|
|
231
|
+
degradations.push({
|
|
232
|
+
code: 'over-budget-total', bucket: null, inject: false,
|
|
233
|
+
text: '[降级] 合计 ' + chars.total + ' 字符超出注入预算 ' + budgetChars + '(分项账本如实记录: ' + parts
|
|
234
|
+
+ ')· 规则类与目录层不参与裁剪,超出的部分不会被掩盖',
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
version: MEMORY_ENVELOPE_VERSION,
|
|
240
|
+
text,
|
|
241
|
+
chars,
|
|
242
|
+
limits,
|
|
243
|
+
segments: kept.map((s) => ({ bucket: s.bucket, kind: s.kind, chars: s.text.length })),
|
|
244
|
+
dropped,
|
|
245
|
+
truncated,
|
|
246
|
+
degradations,
|
|
247
|
+
// E1/E2 自证:调用方可以据此直接断言(也让"注水账本"不可能悄悄回来)
|
|
248
|
+
ok: chars.rules + chars.memoryReferences + chars.otherDynamic === chars.total,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 分项账本 → 单行可读文本(进降级行 / 面板 / 排障都用它,口径唯一)。 */
|
|
253
|
+
export function describeEnvelopeCharsPre(envelope) {
|
|
254
|
+
const c = (envelope && envelope.chars) || { rules: 0, memoryReferences: 0, otherDynamic: 0, total: 0 }
|
|
255
|
+
return ENVELOPE_BUCKETS_V1.map((b) => ENVELOPE_BUCKET_TEXT_V1[b] + ' ' + (c[b] || 0)).join(' · ')
|
|
256
|
+
+ ' · 合计 ' + (c.total || 0)
|
|
257
|
+
}
|