@a9i5k4/dsh-auto-memory 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -8
- package/README.zh-CN.md +13 -8
- package/docs/HANDBOOK.md +88 -52
- package/docs/USER-GUIDE.en.md +9 -9
- package/docs/USER-GUIDE.zh-CN.md +9 -9
- package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
- package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
- package/lib/activation-host.js +6 -1
- package/lib/client.js +819 -272
- package/lib/context-host.js +7 -1
- package/lib/episodic-store.js +90 -16
- package/lib/fact-store.js +463 -41
- package/lib/hub-io.js +217 -0
- package/lib/index.js +224 -45
- package/lib/intent-clean-safe.js +1 -1
- package/lib/memory-hub.js +37 -5
- package/lib/note-status.js +9 -1
- package/lib/procedure-store.js +252 -31
- package/lib/procedure-switch.js +38 -0
- package/lib/python-sidecar-client.js +285 -8
- package/package.json +6 -2
- package/docs/internal/ACCEPT-35-LIVE.md +0 -143
- package/docs/internal/ACCEPTANCE-20260914.md +0 -90
- package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
- package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
- package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
- package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
- package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +0 -397
- package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +0 -351
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -191
- package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +0 -181
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
- package/docs/internal/BATTLE-PLAN-20260917.md +0 -871
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
- package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
- package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
- package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
- package/docs/internal/FEATURE-INVENTORY.md +0 -531
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
- package/docs/internal/G-SERIES-EXECUTION-20260917.md +0 -248
- package/docs/internal/G3-DESIGN-20260918.md +0 -82
- package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +0 -92
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
- package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
- package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
- package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
- package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +0 -309
- package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
- package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +0 -120
- package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +0 -74
- package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +0 -175
- package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +0 -389
- package/docs/internal/ISSUE10-PLAN-20260919.md +0 -254
- package/docs/internal/ISSUE10B-FORENSICS-20260919.md +0 -468
- package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +0 -150
- package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +0 -114
- package/docs/internal/KICKOFF-P0.md +0 -254
- package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +0 -79
- package/docs/internal/MASTER-PLAN-3.0.md +0 -411
- package/docs/internal/MEMORY-GOVERNANCE-20260917.md +0 -309
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
- package/docs/internal/NEXT-VERSION-TODO.md +0 -95
- package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
- package/docs/internal/PENDING-FIXES-20260916.md +0 -289
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +0 -705
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +0 -649
- package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +0 -225
- package/docs/internal/PROGRESS-20260917.md +0 -93
- package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +0 -128
- package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +0 -163
- package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +0 -127
- package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +0 -140
- package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +0 -138
- package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +0 -218
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
- package/docs/internal/RELEASE-PROCESS.md +0 -99
- package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
- package/docs/internal/RESUME-20260918.md +0 -171
- package/docs/internal/RESUME-20260919.md +0 -104
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
- package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +0 -198
- package/docs/internal/ROADMAP-20260917-WEEK.md +0 -439
- package/docs/internal/ROADMAP.md +0 -106
- package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -185
- package/docs/internal/S10-GAP-INVENTORY-20260917.md +0 -239
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
- package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
- package/docs/internal/T6-EXECUTION-20260920.md +0 -130
- package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +0 -146
- package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +0 -89
- package/docs/internal/THESIS-OUTLINE-20260918.md +0 -147
- package/docs/internal/THREE-LAYER-CONTRACT.md +0 -219
- package/docs/internal/TODO-BACKLOG.md +0 -263
- package/docs/internal/TODO-GRAPH.html +0 -715
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
- package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +0 -297
- package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +0 -104
- package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
- package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
- package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
- package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
- package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
package/lib/hub-io.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8 记忆中枢(Memory Hub)持久化 IO —— **带健康度记账的 io 适配器**(#110,2026-09-22)。
|
|
3
|
+
*
|
|
4
|
+
* ── 背景(为什么需要这个模块)──
|
|
5
|
+
* hub 三店(episodes / facts / procedures)的 `io` 由 index.js 内联的 `hubIo()` 提供,
|
|
6
|
+
* 旧实现三个方法各自 `catch (_) {}` 把异常**吞在适配器这一层**:
|
|
7
|
+
*
|
|
8
|
+
* 1. 上层三店在 A-8 里写好的 `try { io.save(snapshot()) } catch (e) { … ok:false … }`
|
|
9
|
+
* **永远走不到 catch 分支** ⇒ store 照样返回 `{ ok:true, persisted:true }`,
|
|
10
|
+
* `persistFailures` 恒为 0、`lastPersistError` 恒为 null ⇒ 三条线全绿而磁盘没写上。
|
|
11
|
+
* 2. 用户侧表现为「记忆看着存上了,重启清零」,日志、计数、面板三处都拿不到信号。
|
|
12
|
+
*
|
|
13
|
+
* 现在把这段逻辑从 index.js 提出来,语义收紧为三条:
|
|
14
|
+
* · `save` / `clear` 失败**照原样抛出**(把「写不进去就是写不进去」交还调用方,
|
|
15
|
+
* 让 A-8 既有的 try/catch 真正生效),**同时**记一次健康度;
|
|
16
|
+
* · `load` 保持「无文件 / 损坏 → 返回 null(空启动)」的既有控制流不变,只追加可观测性;
|
|
17
|
+
* · 每次失败都写进 health(errno → 中文人话 + 累计计数 + 时间戳),由
|
|
18
|
+
* `hubIoHealthSnapshotPre()` 投影给 debugInfo / 诊断面板。
|
|
19
|
+
*
|
|
20
|
+
* 边界(与既有纪律一致):
|
|
21
|
+
* · 只做记账与转发,**不改变** 落盘格式、原子性策略(tmp + rename)与任何调用方契约;
|
|
22
|
+
* · 不引入时钟依赖以外的副作用;`onError` 回调抛错不得影响主路径(自身 try/catch 包住);
|
|
23
|
+
* · 不做 unlink/copy 回退:调用方各自保留自己的原子性策略(同 `fs-retry.js` 的边界声明)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mkdirSync, writeFileSync, renameSync, readFileSync, rmSync } from 'node:fs'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
|
|
29
|
+
/** errno → 人话。口径:暴露给前端/工具的原因必须人能看懂,不能只甩机器码。 */
|
|
30
|
+
export const HUB_IO_ERRNO_MESSAGES_V1 = Object.freeze({
|
|
31
|
+
EACCES: '权限被拒绝(目标目录不可写)',
|
|
32
|
+
EPERM: '操作被系统拒绝(权限或安全策略拦截,Windows 下也常见于文件被占用)',
|
|
33
|
+
ENOSPC: '磁盘空间不足',
|
|
34
|
+
EROFS: '目标位于只读位置',
|
|
35
|
+
EBUSY: '文件被其它进程占用',
|
|
36
|
+
ENOENT: '路径不存在(父目录缺失)',
|
|
37
|
+
EISDIR: '该名字被一个目录占着,不是文件',
|
|
38
|
+
ENOTDIR: '路径中间有一段不是目录',
|
|
39
|
+
EEXIST: '临时文件已存在',
|
|
40
|
+
ENAMETOOLONG: '路径过长',
|
|
41
|
+
EMFILE: '进程打开的文件过多',
|
|
42
|
+
EIO: '底层读写错误(磁盘或驱动)',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** 把任意异常翻成一句人话(带 errno 便于排障;无 code 时回落 message)。 */
|
|
46
|
+
export function explainHubIoErrorPre(e) {
|
|
47
|
+
const code = e && e.code ? String(e.code) : ''
|
|
48
|
+
const mapped = code ? HUB_IO_ERRNO_MESSAGES_V1[code] : ''
|
|
49
|
+
if (mapped) return mapped + '(' + code + ')'
|
|
50
|
+
if (code) return '文件系统错误 ' + code
|
|
51
|
+
return String((e && e.message) || e || '未知错误')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 新建一份健康度台账(纯内存,零 IO)。 */
|
|
55
|
+
export function createHubIoHealthPre() {
|
|
56
|
+
return { errors: 0, lastError: null, lastErrorAt: 0, saves: 0, loads: 0, clears: 0, byFile: {} }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 记一次失败,并调用 `onError(key, message)`(节流由调用方决定,本模块不持有定时器)。
|
|
61
|
+
* @returns {string} 人话原因
|
|
62
|
+
*/
|
|
63
|
+
function notePre(health, name, op, e, onError) {
|
|
64
|
+
const now = Date.now()
|
|
65
|
+
const human = explainHubIoErrorPre(e)
|
|
66
|
+
health.errors += 1
|
|
67
|
+
health.lastError = name + ' ' + op + ' 失败:' + human
|
|
68
|
+
health.lastErrorAt = now
|
|
69
|
+
const key = name + ':' + op
|
|
70
|
+
const rec = health.byFile[key] || { count: 0, lastError: null, lastErrorAt: 0 }
|
|
71
|
+
rec.count += 1
|
|
72
|
+
rec.lastError = human
|
|
73
|
+
rec.lastErrorAt = now
|
|
74
|
+
health.byFile[key] = rec
|
|
75
|
+
if (typeof onError === 'function') {
|
|
76
|
+
try { onError(key, 'M8 hub 持久化失败 —— ' + health.lastError + '(累计 ' + health.errors + ' 次)') } catch (_) { /* 记账不得影响主路径 */ }
|
|
77
|
+
}
|
|
78
|
+
return human
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 造一个 hub io 适配器工厂。
|
|
83
|
+
* @param {{dir:string, health?:object, onError?:Function, fsApi?:object}} opts
|
|
84
|
+
* `fsApi` 注入点仅供测试(故障注入),缺省用 node:fs 同步 API。
|
|
85
|
+
* @returns {(name:string) => {save:Function, load:Function, clear:Function}}
|
|
86
|
+
*/
|
|
87
|
+
export function createHubIoPre(opts = {}) {
|
|
88
|
+
const dir = String(opts.dir || '')
|
|
89
|
+
const health = opts.health || createHubIoHealthPre()
|
|
90
|
+
const onError = opts.onError
|
|
91
|
+
const api = opts.fsApi || null
|
|
92
|
+
const mk = (api && api.mkdirSync) || mkdirSync
|
|
93
|
+
const wf = (api && api.writeFileSync) || writeFileSync
|
|
94
|
+
const rn = (api && api.renameSync) || renameSync
|
|
95
|
+
const rf = (api && api.readFileSync) || readFileSync
|
|
96
|
+
const rm = (api && api.rmSync) || rmSync
|
|
97
|
+
|
|
98
|
+
// ★#110(2026-09-22):**批内合并落盘**(消除写放大)。
|
|
99
|
+
// 背景:hub 一次喂数会连续写同一份快照 N 次(每行判据 upsert 一次 ⇒ 一次整份写盘)。
|
|
100
|
+
// 做法:批内只记「最后一次的整份数据」,批末统一原子落盘。**语义无损**——每份快照都是全量,
|
|
101
|
+
// 最后一次即最终状态;store 的内存态始终最新,落盘只是它的投影。
|
|
102
|
+
// 明确声明的代价:批未落盘时进程被杀,本批持久化会丢。这批是「机器切出来的流程观察行」,
|
|
103
|
+
// 源头 judgement-shadow 文件仍在、可重放,**不涉及用户数据**。
|
|
104
|
+
// 失败**不静默**:逐文件记 health(含 errno 人话)并走 onError;返回值把 ok/written/errors 交出去。
|
|
105
|
+
let batchDepth = 0
|
|
106
|
+
const pendingWrites = new Map() // filePath → { name, data }
|
|
107
|
+
|
|
108
|
+
function atomicWrite(file, name, data) {
|
|
109
|
+
mk(dir, { recursive: true })
|
|
110
|
+
const tmp = file + '.tmp'
|
|
111
|
+
wf(tmp, JSON.stringify(data), 'utf8')
|
|
112
|
+
rn(tmp, file)
|
|
113
|
+
}
|
|
114
|
+
function flushPendingPre() {
|
|
115
|
+
let written = 0
|
|
116
|
+
let ok = true
|
|
117
|
+
for (const [file, rec] of pendingWrites) {
|
|
118
|
+
try { atomicWrite(file, rec.name, rec.data); written++ } catch (e) { ok = false }
|
|
119
|
+
}
|
|
120
|
+
pendingWrites.clear()
|
|
121
|
+
return { ok, written, errors: health.errors }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const factory = (name) => {
|
|
125
|
+
const file = path.join(dir, String(name))
|
|
126
|
+
return {
|
|
127
|
+
/**
|
|
128
|
+
* 原子写(tmp + rename)。失败**记健康度后原样抛出** —— 上层 A-8 的 try/catch 依赖这一点。
|
|
129
|
+
* 批内(beginBatch 之后 endBatch 之前)改为**只登记不落盘**,批末一次写。
|
|
130
|
+
*/
|
|
131
|
+
save(data) {
|
|
132
|
+
health.saves += 1
|
|
133
|
+
if (batchDepth > 0) { pendingWrites.set(file, { name, data }); return }
|
|
134
|
+
try {
|
|
135
|
+
atomicWrite(file, name, data)
|
|
136
|
+
} catch (e) {
|
|
137
|
+
notePre(health, name, 'save', e, onError)
|
|
138
|
+
throw e
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
/**
|
|
142
|
+
* 读取。保持既有语义:无文件 / 损坏一律返回 null(空启动,fail closed 幂等恢复);
|
|
143
|
+
* 只有「非 ENOENT 的读取失败」与「JSON 解析失败」才记健康度。
|
|
144
|
+
*/
|
|
145
|
+
load() {
|
|
146
|
+
health.loads += 1
|
|
147
|
+
let raw
|
|
148
|
+
try {
|
|
149
|
+
raw = rf(file, 'utf8')
|
|
150
|
+
} catch (e) {
|
|
151
|
+
if (!(e && e.code === 'ENOENT')) notePre(health, name, 'load', e, onError)
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(raw)
|
|
156
|
+
} catch (e) {
|
|
157
|
+
notePre(health, name, 'parse', e, onError)
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
/** 删除。失败**记健康度后原样抛出**(残留快照会在下次 load 时"复活"已清空的数据)。 */
|
|
162
|
+
clear() {
|
|
163
|
+
health.clears += 1
|
|
164
|
+
// 批内 clear 必须先取消该文件的待写,否则批末会把刚删掉的快照又写回来。
|
|
165
|
+
pendingWrites.delete(file)
|
|
166
|
+
try { rm(file, { force: true }) } catch (e) { notePre(health, name, 'clear', e, onError); throw e }
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// ── 批控制(挂在工厂函数上,调用方:`hubIo.beginBatch()` / `hubIo.endBatch()`)──
|
|
171
|
+
factory.beginBatch = () => { batchDepth += 1; return batchDepth }
|
|
172
|
+
factory.endBatch = () => {
|
|
173
|
+
if (batchDepth > 0) batchDepth -= 1
|
|
174
|
+
if (batchDepth > 0) return { ok: true, written: 0, errors: health.errors, deferred: true }
|
|
175
|
+
return flushPendingPre()
|
|
176
|
+
}
|
|
177
|
+
factory.flushBatch = () => flushPendingPre()
|
|
178
|
+
factory.batchPending = () => pendingWrites.size
|
|
179
|
+
return factory
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 健康度只读投影(供 debugInfo / 诊断面板)。
|
|
184
|
+
* 纪律:只出计数 + 人话原因 + 时间戳,**无路径、无原文**;任何异常都不得打断诊断。
|
|
185
|
+
*/
|
|
186
|
+
export function hubIoHealthSnapshotPre(health) {
|
|
187
|
+
const empty = { errors: 0, lastError: null, lastErrorAt: null, saves: 0, loads: 0, clears: 0, byFile: {}, verdict: 'ok', summary: '三层记忆已正常落盘(本轮无写入失败)' }
|
|
188
|
+
try {
|
|
189
|
+
if (!health || typeof health !== 'object') return empty
|
|
190
|
+
const byFile = {}
|
|
191
|
+
const src = (health.byFile && typeof health.byFile === 'object') ? health.byFile : {}
|
|
192
|
+
for (const k of Object.keys(src)) {
|
|
193
|
+
const r = src[k] || {}
|
|
194
|
+
byFile[k] = {
|
|
195
|
+
count: Number(r.count) || 0,
|
|
196
|
+
lastError: r.lastError ? String(r.lastError) : null,
|
|
197
|
+
lastErrorAt: Number(r.lastErrorAt) || null,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const errors = Number(health.errors) || 0
|
|
201
|
+
return {
|
|
202
|
+
errors,
|
|
203
|
+
lastError: health.lastError ? String(health.lastError) : null,
|
|
204
|
+
lastErrorAt: Number(health.lastErrorAt) || null,
|
|
205
|
+
saves: Number(health.saves) || 0,
|
|
206
|
+
loads: Number(health.loads) || 0,
|
|
207
|
+
clears: Number(health.clears) || 0,
|
|
208
|
+
byFile,
|
|
209
|
+
verdict: errors === 0 ? 'ok' : 'io-error',
|
|
210
|
+
summary: errors === 0
|
|
211
|
+
? '三层记忆已正常落盘(本轮无写入失败)'
|
|
212
|
+
: '三层记忆有 ' + errors + ' 次落盘/读取失败,最近一次:' + String(health.lastError || '') + '。记忆可能只在内存里,重启会丢。',
|
|
213
|
+
}
|
|
214
|
+
} catch (_) {
|
|
215
|
+
return empty
|
|
216
|
+
}
|
|
217
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -49,6 +49,8 @@ import { createFactStorePre } from './fact-store.js'
|
|
|
49
49
|
import { createProcedureStorePre } from './procedure-store.js'
|
|
50
50
|
import { exportSkillForPre, resolveSkillsRootPre } from './skill-export-host.js'
|
|
51
51
|
import { createMemoryHubPre } from './memory-hub.js'
|
|
52
|
+
// ★#110(2026-09-22):hub 持久化 IO 适配器与其健康度投影(原本内联在本文件里、失败被静默吞掉)。
|
|
53
|
+
import { createHubIoPre, createHubIoHealthPre, hubIoHealthSnapshotPre } from './hub-io.js'
|
|
52
54
|
import { pickConsolidationTextPre } from './intent-clean.js'
|
|
53
55
|
// T1-3(2026-09-19):hubFlushTick 写入前的**内容卫生门**复用 ⑨ 同源清洗器。
|
|
54
56
|
// 必须独占一行(本仓契约守卫以字面量断言既有 import 行,禁止为排版合并)。
|
|
@@ -542,7 +544,19 @@ const DEFAULT_CONFIG = {
|
|
|
542
544
|
pythonBackendEnabled: false,
|
|
543
545
|
/** 推理 trace 观察器(2026-08-26 裁定默认开:监听目标是模型思维链,闭源概括式 CoT 同样纳入)。 */
|
|
544
546
|
reasoningObserverEnabled: true,
|
|
545
|
-
/**
|
|
547
|
+
/** ★ B-2 修复(2026-09-22):**技能注入/唤起总闸**,语义纠正后的正确键(默认 **true**)。
|
|
548
|
+
* 取证:旧键 `procedurePromotionEnabled` 名字叫「技能固化与晋升」,但它的两个消费者
|
|
549
|
+
* 都与"晋升"无关 —— context-host.js:537(技能是否**注入上下文**)、
|
|
550
|
+
* activation-host.js:330(**主动唤起**这条臂开不开);真正的晋升由 store 门限 +
|
|
551
|
+
* 路由动作决定。旧键默认 false ⇒ 新用户装完"技能永不生效"且无任何提示。
|
|
552
|
+
* ⚠️ 解析口径统一走 `lib/procedure-switch.js` 的 `resolveProcedureInjectEnabledPre`,
|
|
553
|
+
* 不得在本文件或其它文件里各自 inline 判断(否则又是一次口径漂移)。 */
|
|
554
|
+
procedureInjectEnabled: true,
|
|
555
|
+
/** 已弃用:语义错配的旧键,保留为**兼容别名**。
|
|
556
|
+
* 新键缺省时按本键取值(老用户显式关掉的不翻面);两键都缺省才取 true。
|
|
557
|
+
* 设置页已改为操作新键,本键仅作读取回退,不再新增写入入口。
|
|
558
|
+
* 注:**不新增 `procedureAutoPromoteEnabled`** —— 全仓无任何自动晋升代码路径
|
|
559
|
+
* (`applyAutomaticTransitions` 只导出零调用,见 BUGLIST P2-4),建了就是死配置。 */
|
|
546
560
|
procedurePromotionEnabled: false,
|
|
547
561
|
/** ★T10(2026-09-20):**机械 procedure 切片**开关。
|
|
548
562
|
* 背景(用户 2026-09-20 报「白板/审批里的技能名与内容看不懂」):
|
|
@@ -563,6 +577,12 @@ const DEFAULT_CONFIG = {
|
|
|
563
577
|
episodicMinSegments: 2,
|
|
564
578
|
/** episodic: 保留的最多 episode 数(超出按时间淘汰)。默认 256。 */
|
|
565
579
|
episodicRetention: 256,
|
|
580
|
+
/** ★#110(2026-09-22,用户拍板 1000):facts 保留上限。超出按「撤销优先 → 最旧优先」淘汰,
|
|
581
|
+
* 重要项(pinned / user-memory / explicit / confidence≥0.8)最后才删。<=0 表示不限。 */
|
|
582
|
+
factRetentionMax: 1000,
|
|
583
|
+
/** ★#102(2026-09-22):工作区发现上限。旧的硬编码 30 会把第 31 个起的工作区**静默丢弃**
|
|
584
|
+
* (跨区检索/注入索引/总览一起少项)。现按「最近会话 mtime」降序截断,默认 200。 */
|
|
585
|
+
workspaceDiscoverMax: 200,
|
|
566
586
|
/** procedure: 晋升所需跨会话多样性(≥N 个独立 session)。默认 3(M-04 元代码)。 */
|
|
567
587
|
procedureMinSessions: 3,
|
|
568
588
|
/** procedure: 晋升所需成功次数(≥N)。默认 2(一次成功不足以证明可靠)。 */
|
|
@@ -2499,7 +2519,18 @@ class MemoryEngine {
|
|
|
2499
2519
|
// 保留调用方刚写入的富条目(rebuild 只是补倒排, 正文预览以已有条目为准)
|
|
2500
2520
|
fresh.entries = entries
|
|
2501
2521
|
await mkdir(sideDir, { recursive: true })
|
|
2502
|
-
|
|
2522
|
+
// ★2026-09-22:改**原子写**(tmp + 有界重试 rename)。
|
|
2523
|
+
// 旧实现是裸 `await writeFile(index.json, …)` —— 这份索引 235 KB,写入过程**不是原子的**:
|
|
2524
|
+
// 面板/工具恰在此时读,读到的要么是零字节、要么是半截 JSON ⇒ `JSON.parse` 失败 ⇒ 看板/白板
|
|
2525
|
+
// **整块空白**,写完才恢复(用户实测:「白板不见了 → 过了一会儿又出现了」)。时间戳可对:
|
|
2526
|
+
// 索引在 09-22 00:52:10 被整份重写,同一秒宿主诊断 `…-diagnose.log` 连出 6 条
|
|
2527
|
+
// `ctx-host degrade: index-not-ready(commit)` —— 就是这段「不可读窗口」。
|
|
2528
|
+
// rename 在同目录内是原子替换;Windows 上读侧句柄争用会抛 EPERM/EBUSY,
|
|
2529
|
+
// 故复用 #48 的 `retryRename`(有界退避,且不做 unlink/copy 回退以免破坏原子性)。
|
|
2530
|
+
const idxPath = path.join(sideDir, 'index.json')
|
|
2531
|
+
const tmpPath = idxPath + '.tmp'
|
|
2532
|
+
await writeFile(tmpPath, JSON.stringify(fresh, null, 2), 'utf8')
|
|
2533
|
+
await retryRename(tmpPath, idxPath)
|
|
2503
2534
|
return fresh
|
|
2504
2535
|
}
|
|
2505
2536
|
|
|
@@ -2522,7 +2553,17 @@ class MemoryEngine {
|
|
|
2522
2553
|
const sideDir = path.join(projectDir, 'handoff')
|
|
2523
2554
|
const docs = await this._collectWhiteboardDocsPre(projectDir).catch(() => [])
|
|
2524
2555
|
const index = rebuildSidecarIndexPre(this.wbWsKeyPre(projectDir), docs)
|
|
2525
|
-
try {
|
|
2556
|
+
try {
|
|
2557
|
+
await mkdir(sideDir, { recursive: true })
|
|
2558
|
+
// ★2026-09-22:与 `_writeSidecarIndexPre` 同一口径 —— **重建路径也必须原子替换**。
|
|
2559
|
+
// 这里同样是整份索引(实测 235 KB)裸写;而重建恰恰是在「索引缺失/损坏」时被触发的,
|
|
2560
|
+
// 此刻面板正等着读这份文件 ⇒ 裸写期间的读者拿到半截 JSON,白板依旧整块空白。
|
|
2561
|
+
// (本处由守卫 `smoke-test-board-index-atomic-pre.mjs` 抓出:它断言「全仓不得再有裸写 index.json」。)
|
|
2562
|
+
const rIdx = path.join(sideDir, 'index.json')
|
|
2563
|
+
const rTmp = rIdx + '.tmp'
|
|
2564
|
+
await writeFile(rTmp, JSON.stringify(index, null, 2), 'utf8')
|
|
2565
|
+
await retryRename(rTmp, rIdx)
|
|
2566
|
+
} catch (_) {}
|
|
2526
2567
|
return index
|
|
2527
2568
|
}
|
|
2528
2569
|
|
|
@@ -6603,28 +6644,44 @@ class MemoryEngine {
|
|
|
6603
6644
|
|
|
6604
6645
|
/** 扫描 ~/.dsh/sessions 下所有会话,提取去重后的工作区路径。 */
|
|
6605
6646
|
async discoverWorkspaces() {
|
|
6606
|
-
|
|
6647
|
+
// ★#102(2026-09-22):解除「硬编码 30 且静默截断」。
|
|
6648
|
+
// 旧实现:`if (out.size >= 30) return` —— 上限写死、按**目录遍历顺序**取前 30,
|
|
6649
|
+
// 第 31 个工作区起被**静默丢弃**(跨区检索 / 注入索引 / 工作区总览一起少项,且无任何信号)。
|
|
6650
|
+
// 现在:① 上限走配置 `workspaceDiscoverMax`(默认 200,非法值回落默认);
|
|
6651
|
+
// ② 以「该工作区最近一次会话文件的 mtime」为活跃度,按新→旧排序后再截断 ——
|
|
6652
|
+
// 即便真到上限,丢掉的也是**最久没用过**的,而不是碰巧排在后面的;
|
|
6653
|
+
// ③ 仅保留一个 5×上限的遍历安全阀,防止 sessions 目录异常膨胀时无限走盘。
|
|
6654
|
+
const capRaw = Number(this.config && this.config.workspaceDiscoverMax)
|
|
6655
|
+
const cap = (Number.isFinite(capRaw) && capRaw >= 1) ? Math.floor(capRaw) : 200
|
|
6656
|
+
const walkLimit = cap * 5
|
|
6657
|
+
const out = new Map() // cwd → 最近会话文件 mtimeMs
|
|
6607
6658
|
const sessionsDir = path.join(dshHome(), 'sessions')
|
|
6608
6659
|
const walk = async (dir, depth) => {
|
|
6609
6660
|
if (depth > 4) return
|
|
6610
6661
|
let entries
|
|
6611
6662
|
try { entries = await readdir(dir, { withFileTypes: true }) } catch (e) { return }
|
|
6612
6663
|
for (const en of entries) {
|
|
6613
|
-
if (out.size >=
|
|
6614
|
-
if (en.isDirectory()) await walk(path.join(dir, en.name), depth + 1)
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6664
|
+
if (out.size >= walkLimit) return
|
|
6665
|
+
if (en.isDirectory()) { await walk(path.join(dir, en.name), depth + 1); continue }
|
|
6666
|
+
if (!en.isFile() || !(en.name.endsWith('.jsonl.zstd') || en.name.endsWith('.jsonl'))) continue
|
|
6667
|
+
const full = path.join(dir, en.name)
|
|
6668
|
+
try {
|
|
6669
|
+
const first = await this.readFirstLine(full)
|
|
6670
|
+
if (!first) continue
|
|
6671
|
+
const h = JSON.parse(first)
|
|
6672
|
+
if (!h || typeof h.cwd !== 'string' || !h.cwd) continue
|
|
6673
|
+
let mt = 0
|
|
6674
|
+
try { mt = (await stat(full)).mtimeMs } catch (e) {}
|
|
6675
|
+
const prev = out.get(h.cwd) || 0
|
|
6676
|
+
if (mt > prev) out.set(h.cwd, mt)
|
|
6677
|
+
} catch (e) {}
|
|
6624
6678
|
}
|
|
6625
6679
|
}
|
|
6626
6680
|
await walk(sessionsDir, 0)
|
|
6627
|
-
return Array.from(out.
|
|
6681
|
+
return Array.from(out.entries())
|
|
6682
|
+
.sort((a, b) => b[1] - a[1]) // 活跃度降序(mtime 新→旧)
|
|
6683
|
+
.slice(0, cap)
|
|
6684
|
+
.map((e) => e[0])
|
|
6628
6685
|
}
|
|
6629
6686
|
|
|
6630
6687
|
/** 读取某工作区的近期记忆(最近5天日志 + 项目笔记头部),无 .dsh-memory 返回 null。 */
|
|
@@ -6775,6 +6832,52 @@ class MemoryEngine {
|
|
|
6775
6832
|
return Object.assign({}, snap, { verdict })
|
|
6776
6833
|
}
|
|
6777
6834
|
|
|
6835
|
+
// ---------- ★#110(2026-09-22):hub 持久化 IO 健康度视图 ----------
|
|
6836
|
+
/**
|
|
6837
|
+
* 返回三层记忆(episodes/facts/procedures)落盘 IO 的健康度投影。
|
|
6838
|
+
*
|
|
6839
|
+
* **为什么需要**:hub 三店的 `io` 由 `hubIo()` 提供,旧实现 save/clear 内 `catch (_) {}`
|
|
6840
|
+
* 把异常吞在这一层 ⇒ 上层三店(A-8 之后)的 `try { io.save() } catch` 永远不触发、
|
|
6841
|
+
* 返回值恒为 `{ok:true, persisted:true}` ⇒ 「写不进去」在**任何**出口都不可见。
|
|
6842
|
+
* 现在失败会照抛并同时记进 `_hubIoHealth`,本方法就是它的只读投影。
|
|
6843
|
+
*
|
|
6844
|
+
* 纪律:与 `_degradeViewSnapshot` / `_quotaViewSnapshot` 同款 —— 只出计数 + 人话原因 + 时间戳,
|
|
6845
|
+
* **无路径、无原文**;任何异常都不得打断 debugInfo(全程 try/catch,失败返回空计数)。
|
|
6846
|
+
*/
|
|
6847
|
+
_hubIoViewSnapshot() {
|
|
6848
|
+
return hubIoHealthSnapshotPre(this._hubIoHealth)
|
|
6849
|
+
}
|
|
6850
|
+
/**
|
|
6851
|
+
* 事实保留上限淘汰台账的只读投影(★P0-3 / 2026-09-22)。
|
|
6852
|
+
*
|
|
6853
|
+
* **为什么不能只用 stats**:`getStats()`(fact-store.js:824)返回的是累计计数,
|
|
6854
|
+
* 而 `lastPrune` 是闭包变量、只经 `getLastPrune()`(:832)单独暴露 ⇒
|
|
6855
|
+
* 「最近一次淘汰发生在什么时候、当时上限多少、超出多少」只有它能给。
|
|
6856
|
+
*
|
|
6857
|
+
* 纪律:与 `_hubIoViewSnapshot` / `_degradeViewSnapshot` 同款 —— 只出聚合数 + 时间戳,
|
|
6858
|
+
* **无事实正文、无路径**;任何异常都不得打断 debugInfo(全程 try/catch,失败返回 null)。
|
|
6859
|
+
* `protected > 0` 表示已轮到删「重要项」⇒ 上限设得过低,前端据此给告警色与指引。
|
|
6860
|
+
*/
|
|
6861
|
+
_factsPruneViewSnapshot() {
|
|
6862
|
+
try {
|
|
6863
|
+
const st = this._factStore
|
|
6864
|
+
if (!st) return null
|
|
6865
|
+
const last = typeof st.getLastPrune === 'function' ? st.getLastPrune() : null
|
|
6866
|
+
const limit = typeof st.retentionLimit === 'function' ? st.retentionLimit() : null
|
|
6867
|
+
if (!last && limit === null) return null
|
|
6868
|
+
return {
|
|
6869
|
+
pruned: Number(last && last.pruned) || 0,
|
|
6870
|
+
protected: Number(last && last.protected) || 0,
|
|
6871
|
+
limit: limit === null || limit === undefined
|
|
6872
|
+
? (last && last.limit !== undefined ? last.limit : null)
|
|
6873
|
+
: limit,
|
|
6874
|
+
over: Number(last && last.over) || 0,
|
|
6875
|
+
oldestAt: (last && last.oldestAt) || null,
|
|
6876
|
+
at: (last && last.at) || null,
|
|
6877
|
+
}
|
|
6878
|
+
} catch (_) { return null }
|
|
6879
|
+
}
|
|
6880
|
+
|
|
6778
6881
|
/** 把降级台账与配额测量**合并写入同一状态文件**(读驱动写;失败返回 false,不抛)。 */
|
|
6779
6882
|
_persistObservabilityPre(snap, quota) {
|
|
6780
6883
|
let persisted = false
|
|
@@ -6880,6 +6983,14 @@ class MemoryEngine {
|
|
|
6880
6983
|
// 回答用户那两个问题:「配额太少(内容进不来)」还是「配额多了(白花 token)」。
|
|
6881
6984
|
// 判定默认 `insufficient-data`(样本不足**不猜**),符合用户「基于长期观察」的要求。
|
|
6882
6985
|
quota: this._quotaViewSnapshot(),
|
|
6986
|
+
// ★#110(2026-09-22):hub 三层记忆持久化 IO 健康度。与既有最小投影同一出口、同一纪律
|
|
6987
|
+
// (只出计数 + 人话原因 + 时间戳,**无路径、无原文**)。回答的问题:
|
|
6988
|
+
// 「记忆到底写进磁盘了没有」——此前 save 失败被静默吞掉,面板/日志/返回值三处都看不出来。
|
|
6989
|
+
hubIo: this._hubIoViewSnapshot(),
|
|
6990
|
+
// ★P0-3(2026-09-22):事实保留上限淘汰台账 —— 与既有最小投影同一出口、同一纪律
|
|
6991
|
+
// (只出聚合计数与时间戳,**无事实正文、无路径**)。此前 getLastPrune/retentionLimit
|
|
6992
|
+
// 在双侧零调用方 ⇒「事实被悄悄淘汰了」在界面上无处可查。
|
|
6993
|
+
factsPrune: this._factsPruneViewSnapshot(),
|
|
6883
6994
|
runtimes: this.runtimes.values().map((rt) => ({
|
|
6884
6995
|
key: rt.key,
|
|
6885
6996
|
sessionId: rt.sessionId,
|
|
@@ -9135,20 +9246,33 @@ export function apply(ctx, config) {
|
|
|
9135
9246
|
// (此前 io 缺省 no-op → 重启清零)。落盘为快照整体写,数据量有界(episodes≤retention/facts/过程中)。
|
|
9136
9247
|
{
|
|
9137
9248
|
const hubDir = path.join(dshHome(), 'memory', 'hub')
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9249
|
+
// ★#110(2026-09-22):hub 持久化 IO 失败**可见化**(实现已抽到 `hub-io.js`,本处只做接线)。
|
|
9250
|
+
// 旧实现三个方法各自 `catch (_) {}` 静默吞异常,后果有两层:
|
|
9251
|
+
// ① 异常在**适配器这一层**就被吃掉 ⇒ 三店 A-8 的 `try { io.save() } catch` 永远走不到 catch 分支
|
|
9252
|
+
// ⇒ store 照样报 `{ok:true, persisted:true}`,上层统计与返回值全是绿的;
|
|
9253
|
+
// ② 用户侧表现为「记忆看着存上了,重启清零」,日志、计数、面板三处都拿不到信号。
|
|
9254
|
+
// 现在:save/clear 失败**照原样抛出**(让 A-8 的既有 try/catch 真正生效)+ 每次失败记账到
|
|
9255
|
+
// `engine._hubIoHealth`(人话原因 + 累计次数 + 时间),经 debugInfo().associativeMemory.hubIo
|
|
9256
|
+
// 与诊断日志(节流 5 分钟/键)输出;load 的「无文件/损坏 → null(空启动)」语义不变。
|
|
9257
|
+
const hubIoHealth = createHubIoHealthPre()
|
|
9258
|
+
engine._hubIoHealth = hubIoHealth
|
|
9259
|
+
const hubIo = createHubIoPre({
|
|
9260
|
+
dir: hubDir,
|
|
9261
|
+
health: hubIoHealth,
|
|
9262
|
+
onError: (key, msg) => diagThrottled('hubIo:' + key, msg),
|
|
9263
|
+
})
|
|
9264
|
+
// ★#110:把 io 工厂挂到 engine,供 hub 喂数循环做**批内合并落盘**(`beginBatch` / `endBatch`)。
|
|
9265
|
+
// 喂数循环在另一个块作用域里,拿不到本块的 `hubIo` 局部变量,只能走 engine 这条既有通道。
|
|
9266
|
+
engine._hubIoFactory = hubIo
|
|
9267
|
+
// ★P0-3(2026-09-22):事实库此前是**内联**创建在下面的 stores 字面量里、engine 上无引用。
|
|
9268
|
+
// 这里提到局部常量并单独挂一份,供 debugInfo() 的只读投影(factsPrune)使用。
|
|
9269
|
+
// 不改任何既有行为:stores.facts 仍是同一个实例,只是有了名字。
|
|
9270
|
+
const factStore = createFactStorePre({ config: { maxFacts: Number(engine.config.factRetentionMax) || 1000 }, io: hubIo('facts.json') })
|
|
9271
|
+
engine._factStore = factStore
|
|
9148
9272
|
engine._memoryHub = createMemoryHubPre({
|
|
9149
9273
|
stores: {
|
|
9150
9274
|
episodic: createEpisodicStorePre({ config: { minSegments: Number(engine.config.episodicMinSegments) || 2, retention: Number(engine.config.episodicRetention) || 256 }, io: hubIo('episodes.json') }),
|
|
9151
|
-
facts:
|
|
9275
|
+
facts: factStore,
|
|
9152
9276
|
procedures: createProcedureStorePre({
|
|
9153
9277
|
// 2026-08-30 修复:gates 用 getter 活读 engine.config——挂载(apply 同步)时
|
|
9154
9278
|
// engine.config 尚未 loadConfig,静态读会冻结 DEFAULT(3/2),设置页改门槛永不生效
|
|
@@ -9156,7 +9280,7 @@ export function apply(ctx, config) {
|
|
|
9156
9280
|
// 0 值合法(验证用);仅非有限负值回退默认——不可用 || 0 会 falsy 穿透
|
|
9157
9281
|
get minSessionDiversity() { const v = Number(engine.config.procedureMinSessions); return (Number.isFinite(v) && v >= 0) ? v : 3 },
|
|
9158
9282
|
get minSuccessCount() { const v = Number(engine.config.procedureMinSuccess); return (Number.isFinite(v) && v >= 0) ? v : 2 },
|
|
9159
|
-
get maxCorrectionRate() {
|
|
9283
|
+
get maxCorrectionRate() { const v = Number(engine.config.procedureCorrectionCap); return (Number.isFinite(v) && v >= 0) ? v : 0.3 },
|
|
9160
9284
|
get maxContradictions() { return 0 },
|
|
9161
9285
|
get highRiskRequiresApproval() { return engine.config.procedureHighRiskApproval !== false },
|
|
9162
9286
|
},
|
|
@@ -9263,20 +9387,33 @@ export function apply(ctx, config) {
|
|
|
9263
9387
|
// 异步富化+喂送(不阻塞 tick);单行失败静默
|
|
9264
9388
|
void (async () => {
|
|
9265
9389
|
let fed = 0
|
|
9266
|
-
|
|
9267
|
-
|
|
9268
|
-
|
|
9269
|
-
|
|
9270
|
-
|
|
9271
|
-
|
|
9272
|
-
|
|
9273
|
-
|
|
9274
|
-
|
|
9390
|
+
// ★#110(2026-09-22):批内合并落盘 —— 旧实现每行一次 upsert ⇒ **一次整份快照写盘**,
|
|
9391
|
+
// 一批 N 行就是 N 次写放大。这里用 hubIo 的批控制把整批收敛成「批末最多一次/文件」。
|
|
9392
|
+
// 批末一定 flush(finally),任何一行抛错也不会把已接受的改动留在内存不落盘。
|
|
9393
|
+
const ioFactory = engine._hubIoFactory
|
|
9394
|
+
try {
|
|
9395
|
+
if (ioFactory && typeof ioFactory.beginBatch === 'function') ioFactory.beginBatch()
|
|
9396
|
+
for (const row of newRows) {
|
|
9397
|
+
try {
|
|
9398
|
+
const src = Array.isArray(row.sourceIds) ? row.sourceIds[0] : null
|
|
9399
|
+
if (src) {
|
|
9400
|
+
const rec = await hubCorpusLookup(src)
|
|
9401
|
+
if (rec) {
|
|
9402
|
+
row.subject = String(rec.heading || rec.text || '').split('\n')[0].slice(0, 30)
|
|
9403
|
+
row.predicate = '记录要点'
|
|
9404
|
+
row.object = String(rec.text || '').slice(0, 120)
|
|
9405
|
+
}
|
|
9275
9406
|
}
|
|
9276
|
-
|
|
9277
|
-
|
|
9278
|
-
|
|
9279
|
-
}
|
|
9407
|
+
const r = hub.ingestJudgement(row)
|
|
9408
|
+
if (r && r.consumed) fed++
|
|
9409
|
+
} catch (_) {}
|
|
9410
|
+
}
|
|
9411
|
+
} finally {
|
|
9412
|
+
if (ioFactory && typeof ioFactory.endBatch === 'function') {
|
|
9413
|
+
const b = ioFactory.endBatch()
|
|
9414
|
+
if (b && b.written) diag('hub feed batch: ' + b.written + ' 份快照批量落盘(合并 ' + newRows.length + ' 行喂入)')
|
|
9415
|
+
if (b && b.ok === false) diag('hub feed batch: 有快照落盘失败 —— 原因见 hubIo 健康度(debugInfo().associativeMemory.hubIo)')
|
|
9416
|
+
}
|
|
9280
9417
|
}
|
|
9281
9418
|
if (fed) diag('hub feed: +' + fed + '/' + newRows.length + ' rows consumed')
|
|
9282
9419
|
})()
|
|
@@ -10150,7 +10287,13 @@ export function apply(ctx, config) {
|
|
|
10150
10287
|
else { engine.state.latestHandoffText = '# 交接账本 · ' + engine.memToday() + ' ' + nowHm() + '\n\n' + ((r && r.clean) || writeH); engine.state.loadedAt = Date.now() }
|
|
10151
10288
|
return (args.kind === 'plan'
|
|
10152
10289
|
? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path
|
|
10153
|
-
: '交接账本已写入: ' + r.path)
|
|
10290
|
+
: '交接账本已写入: ' + r.path)
|
|
10291
|
+
// ★ 2026-09-21 bugfix(A-2):handoff 此前**静默截断** —— sanitizeForWrite 超限时是
|
|
10292
|
+
// 「截断 + truncated:true」返回 ok(:8625-8628),而本分支接住了 gateH 却从不外显该标志
|
|
10293
|
+
// (对照 note 分支 :10184 会拼提示)。账本四段固定以「进度与下一步」结尾,截断恰好砍掉它,
|
|
10294
|
+
// 模型却收到「已写入」⇒ 跨窗口续命材料无声缩水。现与 note 分支对齐。
|
|
10295
|
+
+ (gateH.truncated ? '\n⚠ 账本超长已截断到 ' + writeH.length + ' 字符(上限 ' + (args.kind === 'plan' ? 200000 : 8000) + '),尾部「进度与下一步」可能已丢失 —— 请精简后重写。' : '')
|
|
10296
|
+
+ '\n请在本轮回复正文向用户转述本次更新要点。'
|
|
10154
10297
|
}
|
|
10155
10298
|
const replace = args.action === 'replace'
|
|
10156
10299
|
// 写闸门: append 单条上限 8000 字; replace(整篇重写)放行到 20 万字, 但同样经受乱码/复读/重复块质量闸门
|
|
@@ -10383,8 +10526,26 @@ export function apply(ctx, config) {
|
|
|
10383
10526
|
// —— action=activate:模型授权跳统计门 → 晋升 → 激活 → 导出 SKILL.md ——
|
|
10384
10527
|
const pr = procs.promote(pid, {}, { authorizedBy: 'model' })
|
|
10385
10528
|
if (!pr || !pr.ok || pr.decision !== 'promote') {
|
|
10529
|
+
// ★ B-1 配套(用户 2026-09-22 方针①「模型友好」+ ②「原因要人能看懂」):
|
|
10530
|
+
// 旧文案只甩 decision + reasonCodes 机器码,模型读完不知道下一步该做什么,
|
|
10531
|
+
// 遇到 high-risk 就以为"卡死了"。现在补一层**人话解释 + 可执行的下一步**,
|
|
10532
|
+
// 机器码原样保留(便于日志/前端复用),模型据此能自己决定是补 successCriteria、
|
|
10533
|
+
// 还是提示用户去「记忆中枢」点「批准」。
|
|
10534
|
+
const codes = ((pr && pr.reasonCodes) || []).map(String)
|
|
10535
|
+
const EXPLAIN = {
|
|
10536
|
+
'high-risk-awaiting-approval': '该条被标为高风险,需要**用户在「记忆中枢」页签点「批准」**后才能晋升(批准只解这一道门,其余门槛不变)。你可以告诉用户这条待批准。',
|
|
10537
|
+
'no-success-criteria': '缺少 successCriteria ⇒ 结构上无法晋升。请用 memory_procedure 补写「怎么算跑通」再调 action=activate。',
|
|
10538
|
+
'observation-only': '这条是纯 episode 观察行(没有 steps/successCriteria),设计上不参与晋升;请改为写入一条真正的技能条目。',
|
|
10539
|
+
'has-correction': '该条存在纠正记录(maxContradictions=0)⇒ 先解决矛盾,不要强推。',
|
|
10540
|
+
}
|
|
10541
|
+
const c3 = codes[0]
|
|
10542
|
+
const human = EXPLAIN[c3] || (codes.find((c) => EXPLAIN[c]) ? EXPLAIN[codes.find((c) => EXPLAIN[c])] : '')
|
|
10543
|
+
const corrCode = codes.find((c) => c.startsWith('correction-rate-'))
|
|
10386
10544
|
return out + '\n未激活:晋升未通过(decision=' + String((pr && pr.decision) || '?') +
|
|
10387
|
-
' reasonCodes=' + JSON.stringify(
|
|
10545
|
+
' reasonCodes=' + JSON.stringify(codes) + ')。条目仍在审批列表。' +
|
|
10546
|
+
(human ? '\n→ ' + human : '') +
|
|
10547
|
+
(corrCode ? '\n→ 纠正率超过当前上限(' + corrCode + '):说明该条历史执行里被纠正过,先改进流程再重试。' : '') +
|
|
10548
|
+
(codes.includes('diversity-below-3') ? '\n→ 跨会话证据不足(需要至少 3 个不同会话的成功记录):继续在别的会话里用一次,或由用户手动晋升。' : '')
|
|
10388
10549
|
}
|
|
10389
10550
|
const ar = procs.activate(pid)
|
|
10390
10551
|
if (!ar || !ar.ok) return out + '\n已晋升但激活失败:' + String((ar && ar.reason) || 'unknown')
|
|
@@ -10897,11 +11058,29 @@ export function apply(ctx, config) {
|
|
|
10897
11058
|
// 不绕过任何 gate;每次动作进 diagnose 审计。
|
|
10898
11059
|
const procs = hub.stores && hub.stores.procedures
|
|
10899
11060
|
const pid = String((body && body.procedureId) || '')
|
|
10900
|
-
|
|
11061
|
+
// ★ B-1 修复(2026-09-22):白名单补 `approve`。
|
|
11062
|
+
// 旧白名单只有 promote/activate/deprecate/pin,而高风险条目建候选时恒 `approved:false`
|
|
11063
|
+
// (procedure-store.js:235)且全仓无任何代码能置真,promote 只会返回 decision:'ask'
|
|
11064
|
+
// ⇒ 高风险条目的晋升是**死路**。现在把"人来确认"这只手补上:approve 只解一道门,
|
|
11065
|
+
// 其余五道结构门(已弃用 / 观察型 / 缺 successCriteria / 纠正率超限 / 存在纠正)照旧拦。
|
|
11066
|
+
// ★B(2026-09-22):白名单补 `force-promote`(人工**强制晋升**)。
|
|
11067
|
+
// 动机(有数据证据):只读投影 evaluatePromotion 对盘上 30 条**全部**返回 keep,
|
|
11068
|
+
// ⇒ 按 A-9 的门控,界面上一个可点的晋升按钮都没有("手动通道看起来被关掉了")。
|
|
11069
|
+
// 其中「只被 diversity/success 两道统计门拦住」的行(实际 14 条里绝大多数)
|
|
11070
|
+
// 在语义上是可以人工越过的 —— promote() 的 `if (!authorizedBy)` 正是为这场景留的口子。
|
|
11071
|
+
if (['promote', 'force-promote', 'approve', 'activate', 'deprecate', 'pin'].includes(action)) {
|
|
10901
11072
|
if (!procs) return writeJson(res, 200, { ok: false, reason: 'no-procedure-store' })
|
|
10902
11073
|
if (!pid) return writeJson(res, 400, { error: 'procedureId required' })
|
|
10903
11074
|
let r
|
|
10904
|
-
if (action === '
|
|
11075
|
+
if (action === 'approve') r = procs.approve(pid, 'user')
|
|
11076
|
+
else if (action === 'promote') r = procs.promote(pid)
|
|
11077
|
+
// ★B(2026-09-22):人工强制晋升 —— `authorizedBy:'user'`。
|
|
11078
|
+
// · 只越过 diversity / success 两道**统计门**;
|
|
11079
|
+
// · 结构门(deprecated / observationOnly / no-success-criteria / has-correction /
|
|
11080
|
+
// correction-rate / 高风险待批准)**照旧拦** —— 与 T4 冻结的"授权语义"逐字一致;
|
|
11081
|
+
// · 留痕:晋升成功会写 `p.authorizedBy = 'user'`,reasonCodes 记 `user-authorized`,
|
|
11082
|
+
// 与模型授权(`model-authorized`)在审计面可区分。
|
|
11083
|
+
else if (action === 'force-promote') r = procs.promote(pid, {}, { authorizedBy: 'user' })
|
|
10905
11084
|
else if (action === 'activate') {
|
|
10906
11085
|
r = procs.activate(pid)
|
|
10907
11086
|
// ★ ⑪-2(用户 2026-09-19 拍板):晋升为 active 后**自动导出** SKILL.md。
|
package/lib/intent-clean-safe.js
CHANGED
|
@@ -143,7 +143,7 @@ function looksEncodingCorruptedPre(trimmed) {
|
|
|
143
143
|
* 判据是**标记短语**(任意位置),不要求行首 —— 这正是它区别于 F1/F2 的地方:
|
|
144
144
|
* F1/F2 判「整行是信封」,F5 判「行内夹带信封」。
|
|
145
145
|
*/
|
|
146
|
-
const RUNTIME_RESIDUE_RE = /Current DSH file policy|Current runtime context|Approval prompts are disabled in this session|\[Retrieved memory ref|Verify against the current user request|If a reference hints at what you need|Reason:\s*fv2 lane=|Score:\s*[0-9.]+ \(rank \d+\/\d+\)/
|
|
146
|
+
const RUNTIME_RESIDUE_RE = /Current DSH file policy|Current runtime context|Approval prompts are disabled in this session|\[Retrieved memory ref|Verify against the current user request|If a reference hints at what you need|Reason:\s*fv2 lane=|Score:\s*[0-9.]+ \(rank \d+\/\d+\)|^\s*(?:Reference|Source|Reason)\s*:\s*-\s*\d{1,2}:\d{2}\s*\[kind:|^\s*\d{1,2}:\d{2}\s*\[kind:[a-z]+\]|^\s*\[kind:[a-z]+\]/im
|
|
147
147
|
|
|
148
148
|
/** F5:行内是否夹带运行时信封痕迹(不做清洗,只做判定)。 */
|
|
149
149
|
export function looksRuntimeResiduePre(text) {
|