dsh-memu-memory 1.1.3 → 1.2.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.
Files changed (173) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +31 -2
  3. package/README.zh.md +30 -2
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +1 -1
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/host-api.md +2 -2
  9. package/docs/memory-semantics.md +12 -10
  10. package/docs/recall-benchmark.md +238 -0
  11. package/docs/troubleshooting.md +395 -395
  12. package/lib/active-memory.js +165 -0
  13. package/lib/activity-log.js +1 -1
  14. package/lib/binding-diagnostics.js +8 -3
  15. package/lib/client.js +33 -11
  16. package/lib/consolidation-ledger.js +347 -0
  17. package/lib/consolidation-receipt.js +284 -0
  18. package/lib/consolidation-snapshot.js +274 -0
  19. package/lib/consolidation-worker.js +115 -0
  20. package/lib/cycle.js +40 -6
  21. package/lib/inject.js +104 -76
  22. package/lib/local-embedding-manager.js +24 -6
  23. package/lib/memory.js +221 -19
  24. package/lib/mood.js +268 -39
  25. package/lib/preset-scope.js +1 -1
  26. package/lib/private.js +335 -40
  27. package/lib/prompts/private-consolidation.coding.md +1 -0
  28. package/lib/prompts/private-consolidation.md +1 -0
  29. package/lib/prompts/private-consolidation.mixed.md +1 -0
  30. package/lib/prompts/private-consolidation.rp.md +1 -0
  31. package/lib/prompts.js +4 -4
  32. package/lib/remote-summary.js +51 -25
  33. package/lib/rpc.js +9 -1
  34. package/lib/state.js +29 -12
  35. package/lib/tools.js +49 -2
  36. package/lib/vector-profile.js +27 -0
  37. package/package.json +2 -1
  38. package/scripts/acceptance-local.mjs +2 -1
  39. package/scripts/active-memory-ab-live.mjs +655 -0
  40. package/scripts/active-memory-ab.mjs +1302 -0
  41. package/scripts/chain-matrix.mjs +11 -1
  42. package/scripts/check-all.mjs +10 -1
  43. package/tests/activation.test.mjs +89 -0
  44. package/tests/active-memory-ab.test.mjs +745 -0
  45. package/tests/active-memory.test.mjs +129 -0
  46. package/tests/activity-log.test.mjs +126 -0
  47. package/tests/activity-wiring.test.mjs +103 -0
  48. package/tests/appendix-b.test.mjs +212 -0
  49. package/tests/appendix-b2.test.mjs +166 -0
  50. package/tests/audit-regression.test.mjs +272 -0
  51. package/tests/audit-safety.test.mjs +227 -0
  52. package/tests/backend-compensation.test.mjs +272 -0
  53. package/tests/backup.test.mjs +793 -0
  54. package/tests/binding-diagnostics.test.mjs +720 -0
  55. package/tests/browser.test.mjs +366 -0
  56. package/tests/client-render.test.mjs +892 -0
  57. package/tests/coalesce.test.mjs +113 -0
  58. package/tests/config.test.mjs +159 -0
  59. package/tests/consolidation-credential.test.mjs +320 -0
  60. package/tests/consolidation-fork.test.mjs +357 -0
  61. package/tests/consolidation-ledger.test.mjs +298 -0
  62. package/tests/consolidation-snapshot.test.mjs +321 -0
  63. package/tests/consolidation-value.test.mjs +89 -0
  64. package/tests/context-scope.test.mjs +40 -0
  65. package/tests/cue.test.mjs +44 -0
  66. package/tests/curator-probe.test.mjs +433 -0
  67. package/tests/curator.test.mjs +150 -0
  68. package/tests/cycle.test.mjs +411 -0
  69. package/tests/disclosure.test.mjs +231 -0
  70. package/tests/doc-drift.test.mjs +222 -0
  71. package/tests/embedding-catalog.test.mjs +266 -0
  72. package/tests/embedding-download.test.mjs +391 -0
  73. package/tests/embedding-runtime.test.mjs +389 -0
  74. package/tests/emotion-chain.test.mjs +84 -0
  75. package/tests/emotion-contract.test.mjs +445 -0
  76. package/tests/engine.test.mjs +35 -0
  77. package/tests/fixtures/active-memory-ab/README.md +56 -0
  78. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  79. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  80. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  81. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  82. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  83. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  84. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  85. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  86. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  87. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  88. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  89. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  90. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  92. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  93. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  94. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  95. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  96. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  97. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  98. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  99. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  100. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  101. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  102. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  103. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  104. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  105. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  106. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  107. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  108. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  109. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  110. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  111. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  112. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  113. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  114. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  115. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  116. package/tests/graph-view.test.mjs +187 -0
  117. package/tests/graph.test.mjs +77 -0
  118. package/tests/hook-order.test.mjs +72 -0
  119. package/tests/humanlike.test.mjs +232 -0
  120. package/tests/hybrid-recall.test.mjs +92 -0
  121. package/tests/inbound.test.mjs +393 -0
  122. package/tests/inject-guide.test.mjs +364 -0
  123. package/tests/inject.test.mjs +222 -0
  124. package/tests/injection-budget-settings.test.mjs +35 -0
  125. package/tests/injection-budget.test.mjs +64 -0
  126. package/tests/injection-chain.test.mjs +191 -0
  127. package/tests/loader-register.mjs +3 -0
  128. package/tests/loader.mjs +12 -0
  129. package/tests/local-embedding-entry.test.mjs +95 -0
  130. package/tests/local-embedding-manager.test.mjs +476 -0
  131. package/tests/local-embedding-protocol.test.mjs +95 -0
  132. package/tests/local-embedding-worker.test.mjs +346 -0
  133. package/tests/managed-memory.test.mjs +536 -0
  134. package/tests/managed-runtime.test.mjs +40 -0
  135. package/tests/media.test.mjs +132 -0
  136. package/tests/migration-payload.test.mjs +42 -0
  137. package/tests/mood.test.mjs +109 -0
  138. package/tests/multiprocess.test.mjs +29 -0
  139. package/tests/new-session-gate.test.mjs +361 -0
  140. package/tests/package-shipping.test.mjs +84 -0
  141. package/tests/preset-scope.test.mjs +212 -0
  142. package/tests/prompts.test.mjs +250 -0
  143. package/tests/react-snapshot.test.mjs +23 -0
  144. package/tests/recall-bench.test.mjs +306 -0
  145. package/tests/remote.test.mjs +140 -0
  146. package/tests/rename.test.mjs +30 -0
  147. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  148. package/tests/repetition-eval-live.test.mjs +177 -0
  149. package/tests/repetition-eval.test.mjs +373 -0
  150. package/tests/salient-date.test.mjs +142 -0
  151. package/tests/settings-beat.test.mjs +221 -0
  152. package/tests/short-term.test.mjs +166 -0
  153. package/tests/sidebar-beat.test.mjs +208 -0
  154. package/tests/sidebar-refresh.test.mjs +123 -0
  155. package/tests/sidebar.test.mjs +70 -0
  156. package/tests/snippet.test.mjs +47 -0
  157. package/tests/source-kind.test.mjs +52 -0
  158. package/tests/state.test.mjs +150 -0
  159. package/tests/stats.test.mjs +112 -0
  160. package/tests/storage.test.mjs +125 -0
  161. package/tests/stubs/dsh-llm.js +5 -0
  162. package/tests/stubs/dsh-session.js +7 -0
  163. package/tests/stubs/dsh-tools.js +101 -0
  164. package/tests/tool-schema.test.mjs +73 -0
  165. package/tests/tools.test.mjs +184 -0
  166. package/tests/ui-defaults.test.mjs +57 -0
  167. package/tests/update-fields.test.mjs +138 -0
  168. package/tests/vector-migration.test.mjs +733 -0
  169. package/tests/vector-profile.test.mjs +425 -0
  170. package/tests/visibility-migration.test.mjs +152 -0
  171. package/tests/webui-auth.test.mjs +454 -0
  172. package/tests/webui.test.mjs +164 -0
  173. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,347 @@
1
+ import path from 'node:path'
2
+ import { dataDir, readJson, writeJson, withFileLock, stamp, shortHash } from './paths.js'
3
+
4
+ // C 包:整理的持久账本。
5
+ //
6
+ // 旧的整理只有一条 lastSleepSeq:它同时承担了「收到多少证据」「检查到哪」「这次跑没跑完」
7
+ // 三种含义,于是任何一种失败都会把三种含义一起推错——失败被记成已整理,或者完成被记成没做。
8
+ // 这里把它们分开:
9
+ // pending 收到但还没检查完的边界(有新证据就前进,失败不会丢)
10
+ // checked 已经有凭证、确认覆盖过的边界(只有 completed/带 reasonCode 的 skipped 才前进)
11
+ // inflight 进行中的快照 + 租约(jobId、scopeGeneration、leaseUntil),重启后按租约过期回收
12
+ // 账本是插件自己的最小状态,写在数据目录里(state.json 由 state.js 管,本包不越界改它):
13
+ // 一个 JSON 文件、每次改动都走 withFileLock + tmp/rename,读到坏文件时禁止覆盖。
14
+
15
+ export const CONSOLIDATION_LEDGER_VERSION = 1
16
+ export const DEFAULT_LEASE_MS = 15 * 60_000
17
+ export const HISTORY_LIMIT = 12
18
+ export const OPERATION_LIMIT = 200
19
+ const LEDGER_FILE = 'consolidation.json'
20
+
21
+ export function ledgerPath() {
22
+ return path.join(dataDir(), LEDGER_FILE)
23
+ }
24
+
25
+ function emptyLedger() {
26
+ return { version: CONSOLIDATION_LEDGER_VERSION, sessions: {} }
27
+ }
28
+
29
+ export function loadLedger() {
30
+ const raw = readJson(ledgerPath(), null)
31
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return emptyLedger()
32
+ const sessions = raw.sessions && typeof raw.sessions === 'object' && !Array.isArray(raw.sessions) ? raw.sessions : {}
33
+ return { version: CONSOLIDATION_LEDGER_VERSION, sessions }
34
+ }
35
+
36
+ function writeLedger(ledger) {
37
+ writeJson(ledgerPath(), ledger)
38
+ return ledger
39
+ }
40
+
41
+ // 读-改-写必须在锁里完成:两个会话同时整理会各写一份完整文件,
42
+ // 后写的会把前一个的 inflight 抹掉,于是「同一段转录被 fork 两次」。
43
+ function mutateLedger(fn) {
44
+ const box = { result: null }
45
+ withFileLock(ledgerPath(), () => {
46
+ const ledger = loadLedger()
47
+ box.result = fn(ledger)
48
+ writeLedger(ledger)
49
+ })
50
+ return box.result
51
+ }
52
+
53
+ function ensureEntry(ledger, sessionId) {
54
+ const key = String(sessionId || '')
55
+ const existing = ledger.sessions[key]
56
+ const entry = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}
57
+ entry.pending = entry.pending || null
58
+ entry.checked = entry.checked || null
59
+ entry.inflight = entry.inflight || null
60
+ entry.history = Array.isArray(entry.history) ? entry.history : []
61
+ entry.operations = entry.operations && typeof entry.operations === 'object' && !Array.isArray(entry.operations) ? entry.operations : {}
62
+ entry.attempts = Number(entry.attempts) || 0
63
+ ledger.sessions[key] = entry
64
+ return entry
65
+ }
66
+
67
+ function clampText(value, max = 300) {
68
+ const text = String(value || '')
69
+ return text.length > max ? text.slice(0, max) : text
70
+ }
71
+
72
+ /**
73
+ * 父授权指纹:库、文件夹、领域、开关、记忆上下文、记忆归属会话。
74
+ * 任何一项变了,进行中的任务就不再代表父会话当下的授权,必须作废。
75
+ */
76
+ export function scopeGenerationOf(binding) {
77
+ if (!binding) return null
78
+ const parts = [
79
+ binding.scopeKey || '', binding.libraryId || '', binding.domainOverride || binding.domain || '',
80
+ binding.useLibrary === false ? '0' : '1', binding.useGlobal === true ? 'g1' : 'g0',
81
+ binding.memoryOwnerSessionId || '', JSON.stringify(binding.memoryContext || {}),
82
+ ]
83
+ return shortHash(parts.join('|'))
84
+ }
85
+
86
+ function pushHistory(entry, record) {
87
+ entry.history.push(record)
88
+ if (entry.history.length > HISTORY_LIMIT) entry.history = entry.history.slice(-HISTORY_LIMIT)
89
+ }
90
+
91
+ function retryAtFrom(now, attempts) {
92
+ const minutes = Math.min(60, 2 ** Math.min(Math.max(1, attempts), 6))
93
+ return new Date(now + minutes * 60_000).toISOString()
94
+ }
95
+
96
+ /** 只读视图:调用方拿不到可变对象,避免绕过锁改账本。 */
97
+ export function sessionState(sessionId, ledger = loadLedger()) {
98
+ const entry = ledger.sessions?.[String(sessionId || '')]
99
+ if (!entry) {
100
+ return { sessionId: String(sessionId || ''), pending: null, checked: null, inflight: null, attempts: 0, retryAt: null, lastError: null, lastReasonCode: null, history: [] }
101
+ }
102
+ return structuredClone({
103
+ sessionId: String(sessionId || ''),
104
+ pending: entry.pending || null,
105
+ checked: entry.checked || null,
106
+ inflight: entry.inflight || null,
107
+ attempts: Number(entry.attempts) || 0,
108
+ retryAt: entry.retryAt || null,
109
+ lastError: entry.lastError || null,
110
+ lastReasonCode: entry.lastReasonCode || null,
111
+ history: Array.isArray(entry.history) ? entry.history : [],
112
+ })
113
+ }
114
+
115
+ /** 有新的事件批次到达:只前进 received/pending 边界,不碰 checked。 */
116
+ export function noteEvidence({ sessionId, throughSeq, fromSeq = null, at = Date.now() }) {
117
+ const seq = Number(throughSeq)
118
+ if (!Number.isSafeInteger(seq) || seq < 0) return null
119
+ // 每次空闲扫描都会经过这里;边界没动就不要写盘(一个会话一次多余写盘在扫描里没有意义)。
120
+ const seen = Number(loadLedger().sessions?.[String(sessionId || '')]?.pending?.throughSeq)
121
+ if (Number.isSafeInteger(seen) && seen >= seq) {
122
+ return { changed: false, pending: loadLedger().sessions?.[String(sessionId || '')]?.pending || null }
123
+ }
124
+ return mutateLedger((ledger) => {
125
+ const entry = ensureEntry(ledger, sessionId)
126
+ const checkedThrough = Number(entry.checked?.throughSeq)
127
+ const floor = Number.isSafeInteger(checkedThrough) && checkedThrough >= 0 ? checkedThrough + 1 : 0
128
+ const start = Number.isSafeInteger(Number(fromSeq)) && Number(fromSeq) >= 0 ? Math.max(Number(fromSeq), floor) : floor
129
+ const previous = Number(entry.pending?.throughSeq)
130
+ if (Number.isSafeInteger(previous) && previous >= seq) return { changed: false, pending: entry.pending }
131
+ entry.pending = { fromSeq: start, throughSeq: seq, at: new Date(at).toISOString() }
132
+ entry.updatedAt = stamp()
133
+ return { changed: true, pending: entry.pending }
134
+ })
135
+ }
136
+
137
+ /**
138
+ * 开始一个任务。租约没到期就拒绝:这是「同一会话只有一个进行中任务」的持久化版本,
139
+ * 进程重启后仍然有效(in-memory 的 inFlight Map 重启就没了)。
140
+ */
141
+ export function beginJob({ sessionId, jobId, snapshot, binding, now = Date.now(), leaseMs = DEFAULT_LEASE_MS }) {
142
+ return mutateLedger((ledger) => {
143
+ const entry = ensureEntry(ledger, sessionId)
144
+ const leaseUntil = Number(entry.inflight?.leaseUntil) || 0
145
+ if (entry.inflight && leaseUntil > now && entry.inflight.jobId !== jobId) {
146
+ return { ok: false, reasonCode: 'lease-held', jobId: entry.inflight.jobId, leaseUntil }
147
+ }
148
+ const generation = scopeGenerationOf(binding)
149
+ entry.inflight = {
150
+ jobId,
151
+ snapshot: snapshot || null,
152
+ startedAt: now,
153
+ leaseUntil: now + leaseMs,
154
+ scopeGeneration: generation,
155
+ checkedVersion: JSON.stringify(entry.checked),
156
+ libraryId: binding?.libraryId || null,
157
+ scopeKey: binding?.scopeKey || null,
158
+ }
159
+ entry.updatedAt = stamp()
160
+ return { ok: true, job: { ...entry.inflight } }
161
+ })
162
+ }
163
+
164
+ export function renewLease({ sessionId, jobId, now = Date.now(), leaseMs = DEFAULT_LEASE_MS }) {
165
+ return mutateLedger((ledger) => {
166
+ const entry = ensureEntry(ledger, sessionId)
167
+ if (!entry.inflight || entry.inflight.jobId !== jobId) return false
168
+ entry.inflight.leaseUntil = now + leaseMs
169
+ entry.updatedAt = stamp()
170
+ return true
171
+ })
172
+ }
173
+
174
+ function pendingFromSnapshot(snapshot, checked, pending) {
175
+ const through = Number(snapshot?.throughSeq)
176
+ if (!Number.isSafeInteger(through) || through < 0) return pending || null
177
+ const checkedThrough = Number(checked?.throughSeq)
178
+ const floor = Number.isSafeInteger(checkedThrough) && checkedThrough >= 0 ? checkedThrough + 1 : 0
179
+ const from = Number.isSafeInteger(Number(snapshot?.fromSeq)) ? Math.max(Number(snapshot.fromSeq), floor) : floor
180
+ const pendingThrough = Number(pending?.throughSeq)
181
+ const finalThrough = Number.isSafeInteger(pendingThrough) ? Math.max(through, pendingThrough) : through
182
+ if (from > finalThrough) return null
183
+ return { fromSeq: from, throughSeq: finalThrough, at: stamp() }
184
+ }
185
+
186
+ function coveredRangeOf(covered, snapshot) {
187
+ if (!covered || !snapshot || !Number.isSafeInteger(covered.fromSeq) || !Number.isSafeInteger(covered.throughSeq)) return null
188
+ if (covered.fromSeq !== snapshot.fromSeq || covered.throughSeq !== snapshot.throughSeq) return null
189
+ return { fromSeq: covered.fromSeq, throughSeq: covered.throughSeq }
190
+ }
191
+
192
+ /**
193
+ * 结束一个任务。只有带凭证的 completed / 带 reasonCode 的 skipped 才推进 checked;
194
+ * 其余(failed / deferred / 格式错误 / 工具不可用)保留 pending,等下一次。
195
+ */
196
+ export function completeJob({
197
+ sessionId, jobId, snapshot, covered = null, status, outcome = null,
198
+ reasonCode = null, retryable = false, counts = null, receipt = null, now = Date.now(),
199
+ }) {
200
+ return mutateLedger((ledger) => {
201
+ const entry = ensureEntry(ledger, sessionId)
202
+ const job = entry.inflight && entry.inflight.jobId === jobId ? entry.inflight : null
203
+ if (!job || job.leaseUntil <= now || job.checkedVersion !== JSON.stringify(entry.checked)
204
+ || (snapshot && (snapshot.hash !== job.snapshot?.hash || snapshot.fromSeq !== job.snapshot?.fromSeq || snapshot.throughSeq !== job.snapshot?.throughSeq))) {
205
+ return { advanced: false, reasonCode: 'stale-job', checked: entry.checked, pending: entry.pending }
206
+ }
207
+ const jobSnapshot = job.snapshot
208
+ const terminal = status === 'completed' || (status === 'skipped' && Boolean(reasonCode))
209
+ if (job) entry.inflight = null
210
+ entry.lastReasonCode = reasonCode || null
211
+ if (terminal) {
212
+ const range = coveredRangeOf(covered, jobSnapshot)
213
+ const snapshotFrom = Number(jobSnapshot?.fromSeq)
214
+ const snapshotThrough = Number(jobSnapshot?.throughSeq)
215
+ const fullCoverage = range && Number.isSafeInteger(snapshotFrom) && Number.isSafeInteger(snapshotThrough)
216
+ && range.fromSeq === snapshotFrom && range.throughSeq === snapshotThrough
217
+ if (!fullCoverage) {
218
+ entry.pending = pendingFromSnapshot(jobSnapshot, entry.checked, entry.pending)
219
+ entry.lastError = 'a terminal receipt without a covered range cannot advance the checked cursor'
220
+ pushHistory(entry, { jobId, status: 'failed', outcome, reasonCode: 'no-covered-range', at: stamp(), counts })
221
+ entry.updatedAt = stamp()
222
+ return { advanced: false, checked: entry.checked, pending: entry.pending }
223
+ }
224
+ entry.checked = {
225
+ fromSeq: range.fromSeq,
226
+ throughSeq: range.throughSeq,
227
+ at: stamp(),
228
+ status,
229
+ outcome: outcome || 'no_value',
230
+ jobId,
231
+ reasonCode: reasonCode || null,
232
+ snapshotHash: jobSnapshot?.hash || null,
233
+ eventCount: jobSnapshot?.eventCount ?? null,
234
+ receipt: receipt ? { status: receipt.status, outcome: receipt.outcome, reasonCode: receipt.reasonCode || null } : null,
235
+ }
236
+ const pendingThrough = Number(entry.pending?.throughSeq)
237
+ if (entry.pending && (!Number.isSafeInteger(pendingThrough) || pendingThrough <= range.throughSeq)) entry.pending = null
238
+ else if (entry.pending) entry.pending = { fromSeq: range.throughSeq + 1, throughSeq: pendingThrough, at: entry.pending.at }
239
+ entry.attempts = 0
240
+ entry.retryAt = null
241
+ entry.lastError = null
242
+ pushHistory(entry, { jobId, status, outcome: outcome || 'no_value', reasonCode: reasonCode || null, at: stamp(), counts })
243
+ entry.updatedAt = stamp()
244
+ return { advanced: true, checked: entry.checked, pending: entry.pending }
245
+ }
246
+ entry.pending = pendingFromSnapshot(jobSnapshot, entry.checked, entry.pending)
247
+ entry.attempts = Number(entry.attempts) || 0
248
+ if (retryable) {
249
+ entry.attempts += 1
250
+ entry.retryAt = retryAtFrom(now, entry.attempts)
251
+ }
252
+ entry.lastError = clampText(reasonCode || 'not advanced')
253
+ pushHistory(entry, { jobId, status: status || 'failed', outcome, reasonCode, at: stamp(), counts })
254
+ entry.updatedAt = stamp()
255
+ return { advanced: false, checked: entry.checked, pending: entry.pending, retryAt: entry.retryAt || null }
256
+ })
257
+ }
258
+
259
+ /** 失败:清掉 inflight、把范围退回 pending、按尝试次数退避。 */
260
+ export function failJob({ sessionId, jobId, reasonCode = 'failed', retryable = true, error = null, now = Date.now() }) {
261
+ return mutateLedger((ledger) => {
262
+ const entry = ensureEntry(ledger, sessionId)
263
+ const job = entry.inflight && entry.inflight.jobId === jobId ? entry.inflight : null
264
+ if (!job) return { stale: true, reasonCode: 'stale-job', pending: entry.pending, retryAt: entry.retryAt || null, attempts: entry.attempts }
265
+ entry.inflight = null
266
+ entry.pending = pendingFromSnapshot(job.snapshot, entry.checked, entry.pending)
267
+ if (retryable) {
268
+ entry.attempts = (Number(entry.attempts) || 0) + 1
269
+ entry.retryAt = retryAtFrom(now, entry.attempts)
270
+ } else {
271
+ // 重试也不会变好(父授权变了、工具面不存在):记下来但不要每小时再试一次。
272
+ entry.retryAt = null
273
+ }
274
+ entry.lastReasonCode = reasonCode
275
+ entry.lastError = clampText(error || reasonCode)
276
+ pushHistory(entry, { jobId, status: 'failed', reasonCode, at: stamp() })
277
+ entry.updatedAt = stamp()
278
+ return { pending: entry.pending, retryAt: entry.retryAt || null, attempts: entry.attempts }
279
+ })
280
+ }
281
+
282
+ /**
283
+ * 僵尸清理:进程被杀之后 inflight 会留在账本里。租约到期就当成失败回收,
284
+ * 把快照范围退回 pending,绝不假装那一段已经整理过。
285
+ */
286
+ export function recoverStaleJobs({ now = Date.now(), leaseMs = DEFAULT_LEASE_MS } = {}) {
287
+ return mutateLedger((ledger) => {
288
+ const recovered = []
289
+ for (const [sessionId, entry] of Object.entries(ledger.sessions || {})) {
290
+ if (!entry || typeof entry !== 'object') continue
291
+ const leaseUntil = Number(entry.inflight?.leaseUntil)
292
+ if (!entry.inflight) continue
293
+ if (Number.isSafeInteger(leaseUntil) && leaseUntil > now) continue
294
+ const job = entry.inflight
295
+ entry.inflight = null
296
+ entry.pending = pendingFromSnapshot(job?.snapshot || null, entry.checked, entry.pending)
297
+ entry.attempts = (Number(entry.attempts) || 0) + 1
298
+ entry.retryAt = retryAtFrom(now, entry.attempts)
299
+ entry.lastReasonCode = 'lease-expired'
300
+ entry.lastError = 'a consolidation job was interrupted before it finished; its range is pending again'
301
+ pushHistory(entry, { jobId: job?.jobId || null, status: 'failed', reasonCode: 'lease-expired', at: stamp() })
302
+ entry.updatedAt = stamp()
303
+ recovered.push({ sessionId, jobId: job?.jobId || null, leaseUntil: leaseUntil > 0 ? leaseUntil : null })
304
+ }
305
+ return recovered
306
+ })
307
+ }
308
+
309
+ /**
310
+ * 幂等键:同一份固定快照在同一库里只应当被结算一次。
311
+ * 失败的部分写入靠这个键判同再试,而不是靠「同一个主题就跳过」。
312
+ */
313
+ export function operationKey({ sessionId, libraryId = null, snapshotHash, scopeGeneration = '', kind = 'consolidate' }) {
314
+ return shortHash([sessionId, libraryId || '', snapshotHash || '', scopeGeneration, kind].join('|'))
315
+ }
316
+
317
+ export function findOperation(sessionId, key, ledger = loadLedger()) {
318
+ const entry = ledger.sessions?.[String(sessionId || '')]
319
+ const found = entry?.operations?.[String(key || '')]
320
+ return found && typeof found === 'object' ? structuredClone(found) : null
321
+ }
322
+
323
+ export function recordOperation({ sessionId, key, outcome, ids = [], at = Date.now() }) {
324
+ return mutateLedger((ledger) => {
325
+ const entry = ensureEntry(ledger, sessionId)
326
+ entry.operations[String(key)] = { outcome, ids: Array.isArray(ids) ? ids.slice(0, 50) : [], at: new Date(at).toISOString() }
327
+ const keys = Object.keys(entry.operations)
328
+ if (keys.length > OPERATION_LIMIT) {
329
+ for (const stale of keys.slice(0, keys.length - OPERATION_LIMIT)) delete entry.operations[stale]
330
+ }
331
+ entry.updatedAt = stamp()
332
+ return entry.operations[String(key)]
333
+ })
334
+ }
335
+
336
+ export function clearSessionState(sessionId) {
337
+ return mutateLedger((ledger) => {
338
+ delete ledger.sessions[String(sessionId || '')]
339
+ return true
340
+ })
341
+ }
342
+
343
+ export default {
344
+ CONSOLIDATION_LEDGER_VERSION, DEFAULT_LEASE_MS, ledgerPath, loadLedger, sessionState, scopeGenerationOf,
345
+ noteEvidence, beginJob, renewLease, completeJob, failJob, recoverStaleJobs, operationKey, findOperation,
346
+ recordOperation, clearSessionState,
347
+ }
@@ -0,0 +1,284 @@
1
+ // C 包:结构化完成凭证。
2
+ //
3
+ // whenIdle() 只说明 driver 停了,它不代表这一轮整理真的完成了什么——失败的回合同样会
4
+ // 让 whenIdle 解析。所以完成不再是「没报错」,而是一份可以核验的回执:状态、覆盖范围、
5
+ // 写入了哪些 id。回执是 worker 最后一条消息里的一个围栏块(围栏标记由代码拼出来,
6
+ // 目标文件里不出现字面反引号,避免和别的模板混在一起),解析失败就是失败,绝不猜测。
7
+ export const RECEIPT_CONTRACT_VERSION = 1
8
+
9
+ const TICK = String.fromCharCode(96)
10
+ const FENCE = TICK + TICK + TICK
11
+ export const RECEIPT_TAG = 'memu-consolidation-receipt'
12
+ export const RECEIPT_FENCE = FENCE + RECEIPT_TAG
13
+ export const RECEIPT_STATUSES = Object.freeze(['completed', 'skipped', 'deferred', 'failed'])
14
+ export const RECEIPT_OUTCOMES = Object.freeze(['created', 'updated', 'mixed', 'no_value'])
15
+ const ID_RE = /^[A-Za-z0-9._-]{1,128}$/
16
+ const MAX_IDS = 50
17
+
18
+ function stableJson(value) {
19
+ if (Array.isArray(value)) return '[' + value.map(stableJson).join(',') + ']'
20
+ if (value && typeof value === 'object') {
21
+ return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stableJson(value[key])).join(',') + '}'
22
+ }
23
+ return JSON.stringify(value)
24
+ }
25
+
26
+ export function entryFingerprint(entry) {
27
+ return stableJson(entry)
28
+ }
29
+
30
+ const OPEN_RE = new RegExp(RECEIPT_FENCE + '[^\\n]*\\r?\\n([\\s\\S]*?)' + FENCE, 'g')
31
+ const TAG_RE = new RegExp('<' + RECEIPT_TAG + '>([\\s\\S]*?)</' + RECEIPT_TAG + '>', 'g')
32
+
33
+ // 回执契约正文:给 worker 的英文/中文混合说明,字段名固定。
34
+ export function receiptContractText({ jobId = null, snapshotHash = null } = {}) {
35
+ return [
36
+ '[完成回执 · 必须给出]',
37
+ '最后一条消息里原样输出一个围栏块,块内只有一个 JSON 对象:',
38
+ RECEIPT_FENCE,
39
+ '{',
40
+ ' "version": 1,',
41
+ ' "jobId": ' + JSON.stringify(jobId || '复制本任务 jobId') + ',',
42
+ ' "snapshotHash": ' + JSON.stringify(snapshotHash || '复制本任务 snapshotHash') + ',',
43
+ ' "status": "completed | skipped | deferred | failed",',
44
+ ' "outcome": "created | updated | mixed | no_value",',
45
+ ' "checkedRange": { "fromSeq": 数字, "throughSeq": 数字 },',
46
+ ' "createdIds": ["新增的 id"], "updatedIds": ["合并过的 id"], "heldIds": ["进待复核的 id"],',
47
+ ' "reasonCode": "没有正常完成时必填,如 tools-unavailable / no-value / timeout",',
48
+ ' "retryable": true',
49
+ '}',
50
+ FENCE,
51
+ '规则:id 必须是你这次真的写入/更新/暂扣的 id,编造 id 会让整次整理判为无效。',
52
+ 'status=completed 且 outcome=no_value 表示「看过了,没有值得留的」,是正常结果;',
53
+ '做不到(工具缺失、范围读不到、被中断)就用 failed/deferred 并写清 reasonCode,不要假装完成。',
54
+ ].join('\n')
55
+ }
56
+
57
+ function lastMatch(re, text) {
58
+ let found = null
59
+ re.lastIndex = 0
60
+ for (let match = re.exec(text); match !== null; match = re.exec(text)) found = match[1]
61
+ return found
62
+ }
63
+
64
+ /** 从一段模型正文里取出回执 JSON 文本;没有就返回 null。 */
65
+ export function extractReceiptBody(text) {
66
+ const body = String(text || '')
67
+ if (!body) return null
68
+ const fenced = lastMatch(OPEN_RE, body)
69
+ if (fenced !== null) return fenced.trim()
70
+ const tagged = lastMatch(TAG_RE, body)
71
+ return tagged !== null ? tagged.trim() : null
72
+ }
73
+
74
+ function idList(value, field, problems) {
75
+ if (value === undefined || value === null) return []
76
+ if (!Array.isArray(value)) {
77
+ problems.push(field + ' must be an array of memory ids')
78
+ return []
79
+ }
80
+ const out = []
81
+ for (const entry of value.slice(0, MAX_IDS)) {
82
+ if (typeof entry !== 'string' || !ID_RE.test(entry.trim())) {
83
+ problems.push(field + ' contains a value that is not a memory id: ' + String(entry).slice(0, 40))
84
+ continue
85
+ }
86
+ out.push(entry.trim())
87
+ }
88
+ return out
89
+ }
90
+
91
+ function seqOf(value) {
92
+ const seq = Number(value)
93
+ return Number.isSafeInteger(seq) && seq >= 0 ? seq : null
94
+ }
95
+
96
+ /** 结构校验:任何一条不符都整份退回,不做「尽量接受」。 */
97
+ export function validateReceipt(value) {
98
+ const problems = []
99
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
100
+ return { ok: false, problems: ['the receipt must be a single JSON object'] }
101
+ }
102
+ const jobId = value.jobId ?? null
103
+ const snapshotHash = value.snapshotHash ?? null
104
+ if (jobId !== null && (typeof jobId !== 'string' || !ID_RE.test(jobId))) problems.push('jobId must be a task identifier')
105
+ if (snapshotHash !== null && (typeof snapshotHash !== 'string' || !/^[a-f0-9]{64}$/.test(snapshotHash))) problems.push('snapshotHash must be a SHA-256 digest')
106
+ const status = typeof value.status === 'string' ? value.status.trim() : ''
107
+ if (!RECEIPT_STATUSES.includes(status)) problems.push('status must be one of ' + RECEIPT_STATUSES.join(', '))
108
+ const outcomeRaw = typeof value.outcome === 'string' ? value.outcome.trim() : ''
109
+ const outcome = RECEIPT_OUTCOMES.includes(outcomeRaw) ? outcomeRaw : ''
110
+ if (!outcome && (status === 'completed' || status === 'skipped')) problems.push('outcome must be one of ' + RECEIPT_OUTCOMES.join(', '))
111
+ const createdIds = idList(value.createdIds, 'createdIds', problems)
112
+ const updatedIds = idList(value.updatedIds, 'updatedIds', problems)
113
+ const heldIds = idList(value.heldIds, 'heldIds', problems)
114
+ let range = null
115
+ if (value.checkedRange !== undefined && value.checkedRange !== null) {
116
+ const fromSeq = seqOf(value.checkedRange?.fromSeq)
117
+ const throughSeq = seqOf(value.checkedRange?.throughSeq)
118
+ if (fromSeq === null || throughSeq === null) problems.push('checkedRange needs non-negative integer fromSeq and throughSeq')
119
+ else if (fromSeq > throughSeq) problems.push('checkedRange.fromSeq must not exceed throughSeq')
120
+ else range = { fromSeq, throughSeq }
121
+ }
122
+ if ((status === 'completed' || status === 'skipped') && !range) problems.push('terminal receipt needs checkedRange')
123
+ const reasonCode = typeof value.reasonCode === 'string' && value.reasonCode.trim() ? value.reasonCode.trim().slice(0, 80) : null
124
+ if (!reasonCode && status !== 'completed') problems.push('reasonCode is required for status ' + (status || '(missing)'))
125
+ if (outcome === 'created' && createdIds.length === 0) problems.push('outcome created needs at least one createdIds entry')
126
+ if (outcome === 'updated' && updatedIds.length === 0) problems.push('outcome updated needs at least one updatedIds entry')
127
+ if (outcome === 'mixed' && (createdIds.length === 0 || updatedIds.length === 0)) problems.push('outcome mixed needs createdIds and updatedIds')
128
+ if (outcome === 'no_value' && (createdIds.length > 0 || updatedIds.length > 0)) problems.push('outcome no_value cannot carry createdIds or updatedIds')
129
+ if (problems.length > 0) return { ok: false, problems }
130
+ return {
131
+ ok: true,
132
+ receipt: {
133
+ version: RECEIPT_CONTRACT_VERSION,
134
+ jobId,
135
+ snapshotHash,
136
+ status,
137
+ outcome: outcome || null,
138
+ checkedRange: range,
139
+ createdIds,
140
+ updatedIds,
141
+ heldIds,
142
+ reasonCode,
143
+ retryable: value.retryable === true,
144
+ },
145
+ problems: [],
146
+ }
147
+ }
148
+
149
+ /** 解析正文里的回执。没有回执 / JSON 坏 / 结构不符各有自己的 reasonCode。 */
150
+ export function parseReceipt(text) {
151
+ const body = extractReceiptBody(text)
152
+ if (body === null) return { ok: false, reasonCode: 'receipt-missing', detail: 'the worker produced no completion receipt' }
153
+ let value = null
154
+ try {
155
+ value = JSON.parse(body)
156
+ } catch (error) {
157
+ return { ok: false, reasonCode: 'receipt-malformed', detail: String(error?.message || error) }
158
+ }
159
+ const validated = validateReceipt(value)
160
+ if (!validated.ok) return { ok: false, reasonCode: 'receipt-invalid', detail: validated.problems.join('; ') }
161
+ return { ok: true, receipt: validated.receipt }
162
+ }
163
+
164
+ /**
165
+ * 回执与库对账:id 必须真的存在,新增的必须真的是新的,暂扣的必须真的在待复核。
166
+ * 这样「模型说写了」不算数,「库里确实有」才算数。
167
+ */
168
+ export function reconcileReceipt({ receipt, index = null, beforeIds = null, beforeEntries = null } = {}) {
169
+ const problems = []
170
+ const entries = index?.entries || {}
171
+ const known = beforeIds instanceof Set ? beforeIds : null
172
+ const prior = beforeEntries && typeof beforeEntries === 'object' ? beforeEntries : null
173
+ const createdIds = []
174
+ const duplicateIds = []
175
+ const updatedIds = []
176
+ const heldIds = []
177
+ const missing = []
178
+ const changed = (id) => prior && Object.prototype.hasOwnProperty.call(prior, id) && prior[id] !== entryFingerprint(entries[id])
179
+ for (const id of receipt?.createdIds || []) {
180
+ if (!entries[id]) { missing.push(id); continue }
181
+ if (known && known.has(id)) {
182
+ duplicateIds.push(id)
183
+ if (!changed(id)) problems.push('created id ' + id + ' already existed without a verified update')
184
+ else updatedIds.push(id)
185
+ continue
186
+ }
187
+ createdIds.push(id)
188
+ }
189
+ for (const id of receipt?.updatedIds || []) {
190
+ if (!entries[id]) { missing.push(id); continue }
191
+ if (!known || !known.has(id) || !changed(id)) {
192
+ problems.push('updated id ' + id + ' has no verified post-worker change')
193
+ continue
194
+ }
195
+ updatedIds.push(id)
196
+ }
197
+ for (const id of receipt?.heldIds || []) {
198
+ const entry = entries[id]
199
+ if (!entry) { missing.push(id); continue }
200
+ if (entry.status !== 'pending_review') { problems.push('held id ' + id + ' is not pending_review'); continue }
201
+ heldIds.push(id)
202
+ }
203
+ if (missing.length > 0) problems.push('receipt cites ids that are not in the library: ' + missing.slice(0, 8).join(', '))
204
+ // 重复写回已有 id 不算新增:记下来,但按更新计入,免得「整理成功」把没发生的事算成新记忆。
205
+ let outcome = receipt?.outcome || 'no_value'
206
+ if (createdIds.length > 0 && updatedIds.length > 0) outcome = 'mixed'
207
+ else if (createdIds.length > 0) outcome = 'created'
208
+ else if (updatedIds.length > 0) outcome = 'updated'
209
+ else outcome = 'no_value'
210
+ if (problems.length > 0) return { ok: false, problems, createdIds, updatedIds, heldIds, duplicateIds, outcome }
211
+ return { ok: true, problems: [], createdIds, updatedIds, heldIds, duplicateIds, outcome }
212
+ }
213
+
214
+ /**
215
+ * 把「worker 说了什么 + 库里真的变了什么」折成一个可以结算的结论。
216
+ * advance=true 才允许推进 checked 游标(完整覆盖 + 有凭证 / 有对账证据)。
217
+ */
218
+ export function decideCompletion({ text = '', wrote = 0, index = null, beforeIds = null, beforeEntries = null, expectedJobId = null, expectedSnapshotHash = null } = {}) {
219
+ const body = String(text || '').trim()
220
+ if (!body) {
221
+ return {
222
+ status: 'failed', outcome: null, advance: false, settled: false, retryable: true,
223
+ reasonCode: 'receipt-missing',
224
+ error: wrote > 0
225
+ ? 'the worker wrote ' + wrote + ' new memories but returned no receipt; an unattributed write cannot settle a snapshot'
226
+ : 'the consolidation worker produced no completion receipt; index differences alone cannot settle the snapshot',
227
+ receipt: null,
228
+ }
229
+ }
230
+ const parsed = parseReceipt(body)
231
+ if (!parsed.ok) {
232
+ return {
233
+ status: 'failed',
234
+ outcome: null,
235
+ advance: false,
236
+ settled: false,
237
+ retryable: true,
238
+ reasonCode: parsed.reasonCode,
239
+ error: 'the consolidation worker did not return a usable receipt: ' + parsed.detail,
240
+ receipt: null,
241
+ }
242
+ }
243
+ const receipt = parsed.receipt
244
+ if ((expectedJobId !== null && receipt.jobId !== expectedJobId) ||
245
+ (expectedSnapshotHash !== null && receipt.snapshotHash !== expectedSnapshotHash)) {
246
+ return { status: 'failed', outcome: null, advance: false, settled: false, retryable: true,
247
+ reasonCode: 'receipt-identity-mismatch',
248
+ error: 'the completion receipt does not identify the current worker job and snapshot', receipt }
249
+ }
250
+ const reconciled = reconcileReceipt({ receipt, index, beforeIds, beforeEntries })
251
+ if (!reconciled.ok) {
252
+ return {
253
+ status: 'failed',
254
+ outcome: null,
255
+ advance: false,
256
+ settled: false,
257
+ retryable: true,
258
+ reasonCode: 'receipt-unverified',
259
+ error: 'the receipt does not match the library: ' + reconciled.problems.join('; '),
260
+ receipt,
261
+ }
262
+ }
263
+ const advance = receipt.status === 'completed' || (receipt.status === 'skipped' && Boolean(receipt.reasonCode))
264
+ return {
265
+ status: receipt.status,
266
+ outcome: reconciled.outcome,
267
+ advance,
268
+ settled: advance,
269
+ retryable: !advance && receipt.status !== 'skipped',
270
+ reasonCode: receipt.reasonCode || null,
271
+ receipt,
272
+ counted: { created: reconciled.createdIds.length, updated: reconciled.updatedIds.length, held: reconciled.heldIds.length },
273
+ createdIds: reconciled.createdIds,
274
+ updatedIds: reconciled.updatedIds,
275
+ heldIds: reconciled.heldIds,
276
+ duplicateIds: reconciled.duplicateIds,
277
+ covered: receipt.checkedRange,
278
+ }
279
+ }
280
+
281
+ export default {
282
+ RECEIPT_CONTRACT_VERSION, RECEIPT_TAG, RECEIPT_FENCE, RECEIPT_STATUSES, RECEIPT_OUTCOMES,
283
+ receiptContractText, extractReceiptBody, validateReceipt, parseReceipt, reconcileReceipt, decideCompletion,
284
+ }