dsh-memu-memory 1.1.3 → 1.2.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.
Files changed (184) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +39 -6
  3. package/README.zh.md +39 -7
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +3 -3
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +8 -0
  9. package/docs/host-api.md +2 -2
  10. package/docs/memory-semantics.md +12 -10
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +396 -395
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +23 -6
  15. package/lib/binding-diagnostics.js +8 -3
  16. package/lib/client.js +335 -35
  17. package/lib/consolidation-ledger.js +347 -0
  18. package/lib/consolidation-receipt.js +284 -0
  19. package/lib/consolidation-snapshot.js +274 -0
  20. package/lib/consolidation-worker.js +144 -0
  21. package/lib/curator.js +86 -31
  22. package/lib/cycle.js +40 -6
  23. package/lib/graph.js +139 -5
  24. package/lib/index.js +33 -1
  25. package/lib/inject.js +104 -76
  26. package/lib/local-embedding-manager.js +24 -6
  27. package/lib/memory.js +534 -88
  28. package/lib/mood.js +268 -39
  29. package/lib/preset-scope.js +1 -1
  30. package/lib/private.js +430 -43
  31. package/lib/prompts/private-consolidation.coding.md +1 -0
  32. package/lib/prompts/private-consolidation.md +1 -0
  33. package/lib/prompts/private-consolidation.mixed.md +1 -0
  34. package/lib/prompts/private-consolidation.rp.md +1 -0
  35. package/lib/prompts.js +4 -4
  36. package/lib/remote-summary.js +51 -25
  37. package/lib/rpc.js +54 -5
  38. package/lib/state.js +29 -12
  39. package/lib/text-similarity.js +106 -0
  40. package/lib/tools.js +113 -10
  41. package/lib/vector-migration.js +28 -3
  42. package/lib/vector-profile.js +51 -0
  43. package/package.json +4 -2
  44. package/scripts/acceptance-local.mjs +2 -1
  45. package/scripts/active-memory-ab-live.mjs +655 -0
  46. package/scripts/active-memory-ab.mjs +1302 -0
  47. package/scripts/chain-matrix.mjs +11 -1
  48. package/scripts/check-all.mjs +10 -1
  49. package/scripts/replay.mjs +573 -0
  50. package/tests/activation.test.mjs +89 -0
  51. package/tests/active-memory-ab.test.mjs +745 -0
  52. package/tests/active-memory.test.mjs +129 -0
  53. package/tests/activity-log.test.mjs +126 -0
  54. package/tests/activity-wiring.test.mjs +103 -0
  55. package/tests/appendix-b.test.mjs +216 -0
  56. package/tests/appendix-b2.test.mjs +166 -0
  57. package/tests/audit-regression.test.mjs +272 -0
  58. package/tests/audit-safety.test.mjs +227 -0
  59. package/tests/backend-compensation.test.mjs +272 -0
  60. package/tests/backup.test.mjs +793 -0
  61. package/tests/binding-diagnostics.test.mjs +720 -0
  62. package/tests/browser.test.mjs +366 -0
  63. package/tests/client-render.test.mjs +892 -0
  64. package/tests/coalesce.test.mjs +113 -0
  65. package/tests/config.test.mjs +159 -0
  66. package/tests/consolidation-credential.test.mjs +331 -0
  67. package/tests/consolidation-feed.test.mjs +96 -0
  68. package/tests/consolidation-fork.test.mjs +489 -0
  69. package/tests/consolidation-ledger.test.mjs +298 -0
  70. package/tests/consolidation-snapshot.test.mjs +360 -0
  71. package/tests/consolidation-value.test.mjs +89 -0
  72. package/tests/context-scope.test.mjs +40 -0
  73. package/tests/cue.test.mjs +44 -0
  74. package/tests/curator-commit.test.mjs +277 -0
  75. package/tests/curator-probe.test.mjs +433 -0
  76. package/tests/curator.test.mjs +150 -0
  77. package/tests/cycle.test.mjs +411 -0
  78. package/tests/disclosure.test.mjs +231 -0
  79. package/tests/doc-drift.test.mjs +222 -0
  80. package/tests/embedding-catalog.test.mjs +266 -0
  81. package/tests/embedding-download.test.mjs +391 -0
  82. package/tests/embedding-runtime.test.mjs +389 -0
  83. package/tests/emotion-chain.test.mjs +84 -0
  84. package/tests/emotion-contract.test.mjs +445 -0
  85. package/tests/engine.test.mjs +35 -0
  86. package/tests/fixtures/active-memory-ab/README.md +56 -0
  87. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  88. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  89. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  90. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  92. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  93. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  94. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  95. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  96. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  97. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  98. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  99. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  100. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  101. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  102. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  103. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  104. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  105. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  106. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  107. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  108. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  109. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  110. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  111. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  112. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  113. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  114. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  115. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  116. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  117. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  118. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  119. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  120. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  121. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  122. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  123. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  124. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  125. package/tests/graph-cascade.test.mjs +190 -0
  126. package/tests/graph-view.test.mjs +187 -0
  127. package/tests/graph.test.mjs +94 -0
  128. package/tests/hook-order.test.mjs +72 -0
  129. package/tests/humanlike.test.mjs +232 -0
  130. package/tests/hybrid-recall.test.mjs +122 -0
  131. package/tests/inbound.test.mjs +393 -0
  132. package/tests/inject-guide.test.mjs +364 -0
  133. package/tests/inject.test.mjs +222 -0
  134. package/tests/injection-budget-settings.test.mjs +35 -0
  135. package/tests/injection-budget.test.mjs +64 -0
  136. package/tests/injection-chain.test.mjs +191 -0
  137. package/tests/loader-register.mjs +3 -0
  138. package/tests/loader.mjs +12 -0
  139. package/tests/local-embedding-entry.test.mjs +95 -0
  140. package/tests/local-embedding-manager.test.mjs +476 -0
  141. package/tests/local-embedding-protocol.test.mjs +95 -0
  142. package/tests/local-embedding-worker.test.mjs +346 -0
  143. package/tests/managed-memory.test.mjs +591 -0
  144. package/tests/managed-runtime.test.mjs +40 -0
  145. package/tests/media.test.mjs +132 -0
  146. package/tests/migration-payload.test.mjs +42 -0
  147. package/tests/mood.test.mjs +109 -0
  148. package/tests/multiprocess.test.mjs +29 -0
  149. package/tests/new-session-gate.test.mjs +361 -0
  150. package/tests/package-shipping.test.mjs +84 -0
  151. package/tests/preset-scope.test.mjs +212 -0
  152. package/tests/prompts.test.mjs +250 -0
  153. package/tests/react-snapshot.test.mjs +23 -0
  154. package/tests/recall-bench.test.mjs +306 -0
  155. package/tests/recall-dedup.test.mjs +417 -0
  156. package/tests/remote.test.mjs +140 -0
  157. package/tests/rename.test.mjs +30 -0
  158. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  159. package/tests/repetition-eval-live.test.mjs +177 -0
  160. package/tests/repetition-eval.test.mjs +373 -0
  161. package/tests/salient-date.test.mjs +142 -0
  162. package/tests/settings-beat.test.mjs +221 -0
  163. package/tests/short-term.test.mjs +166 -0
  164. package/tests/sidebar-beat.test.mjs +212 -0
  165. package/tests/sidebar-refresh.test.mjs +123 -0
  166. package/tests/sidebar.test.mjs +70 -0
  167. package/tests/snippet.test.mjs +47 -0
  168. package/tests/source-kind.test.mjs +52 -0
  169. package/tests/state.test.mjs +150 -0
  170. package/tests/stats.test.mjs +112 -0
  171. package/tests/storage.test.mjs +125 -0
  172. package/tests/stubs/dsh-llm.js +5 -0
  173. package/tests/stubs/dsh-session.js +7 -0
  174. package/tests/stubs/dsh-tools.js +101 -0
  175. package/tests/tool-schema.test.mjs +73 -0
  176. package/tests/tools.test.mjs +184 -0
  177. package/tests/ui-defaults.test.mjs +57 -0
  178. package/tests/update-fields.test.mjs +138 -0
  179. package/tests/vector-migration.test.mjs +790 -0
  180. package/tests/vector-profile.test.mjs +425 -0
  181. package/tests/visibility-migration.test.mjs +152 -0
  182. package/tests/webui-auth.test.mjs +454 -0
  183. package/tests/webui.test.mjs +165 -0
  184. package/tests/windows-env.test.mjs +89 -0
package/lib/private.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
2
2
  import crypto from 'node:crypto'
3
- import { getSessionBinding, getLibrary, getSettings, setSessionBinding, loadPrivateIndex, effectiveScope } from './state.js'
3
+ import { getSessionBinding, getLibrary, getSettings, setSessionBinding, deleteSessionBinding, loadPrivateIndex, effectiveScope } from './state.js'
4
4
  import { domainOf, privateRemember, isMemoryEligible, visibleToSession } from './memory.js'
5
5
  import { loadPromptForDomain, CONSOLIDATION_CONTRACTS } from './prompts.js'
6
6
  import { summarizeRemote } from './remote-summary.js'
7
7
  import { condenseEvents, readSessionSnapshot } from './curator.js'
8
8
  import { record } from './activity-log.js'
9
+ import { ACTIVE_MEMORY_POLICY, assessWake, collectEvidence } from './active-memory.js'
10
+ import { entryFingerprint } from './consolidation-receipt.js'
9
11
 
10
12
  // Short-term entries expire on a clock, so the idle pass is the last moment anyone
11
13
  // can decide what they were worth. The list is generated in code rather than added
@@ -179,8 +181,14 @@ export async function requestRemoteConsolidation(ctx, sessionId, { signal, timeo
179
181
  signal,
180
182
  timeoutMs,
181
183
  })
184
+ // 写入计数必须说真话:`memories` 是模型给的清单,逐条 privateRemember 可能被
185
+ // 事实级去重拒绝(deduped,不落库)或因矛盾暂扣(pending_review,仅入索引)。
186
+ // count 只数真正落库的;被拒/暂扣分列进 detail,否则"写入 N 条"就是误报。
187
+ let written = 0
188
+ let deduped = 0
189
+ let held = 0
182
190
  for (const memory of memories) {
183
- await privateRemember({
191
+ const outcome = await privateRemember({
184
192
  ...memory,
185
193
  exec: { agent: { session: { header: { id: sessionId } } } },
186
194
  content: memory.content,
@@ -190,12 +198,17 @@ export async function requestRemoteConsolidation(ctx, sessionId, { signal, timeo
190
198
  confidence: memory.confidence,
191
199
  source: memory.source || 'inferred',
192
200
  })
201
+ if (outcome?.deduped === true) deduped += 1
202
+ else if (outcome?.entry?.status === 'pending_review') held += 1
203
+ else written += 1
193
204
  }
194
205
  setSessionBinding(sessionId, { lastSleepSeq: events[events.length - 1]?.seq })
195
- const result = { ok: true, sessionId, backend: 'api', count: memories.length }
206
+ const result = { ok: true, sessionId, backend: 'api', count: written, deduped, held }
196
207
  record({
197
208
  kind: 'consolidate', scope: 'private', sessionId, libraryId: binding.libraryId || null, ok: true,
198
- message: `整理完成(远程)· 写入 ${memories.length}`,
209
+ message: `整理完成(远程)· 写入 ${written}`
210
+ + (deduped ? ` · 去重拦下 ${deduped}` : '')
211
+ + (held ? ` · 待复核 ${held}` : ''),
199
212
  detail: { ...result, forced: force },
200
213
  })
201
214
  return result
@@ -262,8 +275,115 @@ function turnEndFailure(event) {
262
275
  return 'the fork turn ended ' + (reason.kind || 'without a reason') + (message ? ': ' + message : '')
263
276
  }
264
277
 
278
+ // PTC 兼容(2026-10-31 现场 worker-tools-ptc-only):PTC 会话的 header 工具表只有
279
+ // run_code,其余工具走 run_code 程序里的 SDK 绑定(宿主 ptc.ts 用
280
+ // tools.schemas(agent, toolSet) 枚举"调用 agent 的可见面")。preset 组合没列出本插件
281
+ // 时,fork 的注册表里根本没有 memu_*,worker 既不能原生调用也没有 SDK 绑定,回执只能
282
+ // 是 tools-unavailable。修法与 preset-scope.js 的注入挂载同构:把读写四件挂进 fork
283
+ // 自己的 ctx scope —— 注册表按 scope 链取可见面,所以只对这个 worker 可见,不扩散到
284
+ // 其它会话;挂载点必须在 followup 之前(宿主在 step 开始时 resolve 一次 admitted set,
285
+ // 之后注册的工具不进本步)。失败不阻断整理:worker 会按任务书如实回执 tools-unavailable。
286
+ async function attachWorkerTools(agents, childId, parentId) {
287
+ const wanted = [...WORKER_WRITE_TOOLS, ...WORKER_READ_TOOLS]
288
+ const ctxOf = (record) => {
289
+ try { return record?.ctx || record?.agent?.ctx || null } catch { return null }
290
+ }
291
+ let parentHasCtx = null
292
+ for (let attempt = 0; attempt < 6; attempt += 1) {
293
+ let record = null
294
+ try { record = agents.get?.(childId) || null } catch { record = null }
295
+ if (record) {
296
+ const actx = ctxOf(record)
297
+ // 有记录但没有 ctx:这版宿主不暴露 agent context(preset-scope.js 的同款限制),
298
+ // 重试不会变出来。ctx 在但没有 tools 服务:组合里就没有。两者都直接放弃。
299
+ if (!actx || !(actx.tools && typeof actx.tools.register === 'function')) break
300
+ try {
301
+ const dispose = registerTools(actx, { only: wanted })
302
+ // tools.js 在 disposer 上附挂实际注册成功的名单(编译 + register 都过的那部分)。
303
+ // 读它,不再把 wanted 全量当成"已挂载"——部分挂载必须结构化可见。
304
+ const attached = Array.isArray(dispose?.attached) ? [...dispose.attached] : null
305
+ return {
306
+ dispose,
307
+ attached,
308
+ requested: wanted,
309
+ // 名单缺失 = 无法证明挂全:partial 照实标 true,attached 落 null(与失败同口径)。
310
+ partial: attached ? attached.length < wanted.length : true,
311
+ }
312
+ } catch (error) {
313
+ warn('consolidation worker tool attach failed: ' + String(error?.message || error))
314
+ return null
315
+ }
316
+ }
317
+ // 子记录还没出现:只在这个宿主确实会暴露 ctx(父记录带 ctx)时才值得短退避等它。
318
+ if (parentHasCtx === null) parentHasCtx = Boolean(ctxOf(agents.get?.(parentId)))
319
+ if (!parentHasCtx) break
320
+ await new Promise(resolve => setTimeout(resolve, 100))
321
+ }
322
+ warn('consolidation worker has no tools service on its own context; the worker may be unable to write memories')
323
+ return null
324
+ }
325
+
326
+ // 活动日志"整理已开始"条目的挂载元数据口径(workerTools* 三件):
327
+ // workerToolsAttached 实际注册成功的工具名数组;null = 挂载没有完成或无法确认
328
+ // (fork ctx 无 tools 服务、register 抛错、宿主没回名单),
329
+ // 与"挂全了"严格区分——这正是旧字段把 wanted 全量误报成
330
+ // 已挂载的教训。
331
+ // workerToolsRequested 请求挂载的完整 wanted 名单;与 attached 全等才是全量挂载。
332
+ // workerToolsPartial true = 未证明 worker 拿到完整 wanted 集(子集、空集、或挂载
333
+ // 整体失败)。刻意把"一件都没挂上"也标 true:若失败标 false,
334
+ // false 会被一眼读成"全量挂载",元数据又不准了。
335
+ function workerToolsMeta(workerTools) {
336
+ const wanted = [...WORKER_WRITE_TOOLS, ...WORKER_READ_TOOLS]
337
+ if (!workerTools) {
338
+ return { workerToolsAttached: null, workerToolsRequested: wanted, workerToolsPartial: true }
339
+ }
340
+ return {
341
+ workerToolsAttached: Array.isArray(workerTools.attached) ? workerTools.attached : null,
342
+ workerToolsRequested: Array.isArray(workerTools.requested) ? workerTools.requested : wanted,
343
+ workerToolsPartial: workerTools.partial === true,
344
+ }
345
+ }
346
+
265
347
  const inFlight = new Map()
266
348
 
349
+ // 这里的 import 刻意写在语句之后:lib/binding-diagnostics.js 的 RUNTIME_STATE 把 `inFlight`
350
+ // 的 file:line 当成诊断契约(当前 lib/private.js:336),修改位置时同步更新该诊断映射,
351
+ // 而那是别人的文件、本包不改。ESM 的 import 声明是提升的,写在语句之后合法。
352
+ import { warn } from './paths.js'
353
+ import { captureSessionSnapshot } from './consolidation-snapshot.js'
354
+ import {
355
+ beginJob, completeJob, failJob, findOperation, noteEvidence, operationKey,
356
+ recoverStaleJobs, recordOperation, scopeGenerationOf,
357
+ } from './consolidation-ledger.js'
358
+ import { decideCompletion, receiptContractText } from './consolidation-receipt.js'
359
+ import { buildWorkerBrief, probeToolSurface, WORKER_READ_TOOLS, WORKER_WRITE_TOOLS } from './consolidation-worker.js'
360
+ import { registerTools } from './tools.js'
361
+
362
+ // 账本异常必须可见;调用方只有拿到明确的持久租约才可启动 worker。
363
+ // 其余读写失败返回保守结果,不得据此推进已确认边界。
364
+ function safeLedger(action, fallback = null) {
365
+ try {
366
+ return action()
367
+ } catch (error) {
368
+ try { warn('consolidation ledger: ' + String(error?.message || error)) } catch {}
369
+ return fallback
370
+ }
371
+ }
372
+
373
+ // 交给结果日志的快照身份:有 hash、有边界、没有正文。
374
+ function snapshotMetaOf(snapshot) {
375
+ if (!snapshot) return null
376
+ return {
377
+ hash: snapshot.hash,
378
+ source: snapshot.source,
379
+ fromSeq: snapshot.fromSeq,
380
+ throughSeq: snapshot.throughSeq,
381
+ eventCount: snapshot.eventCount,
382
+ droppedEvents: snapshot.droppedEvents,
383
+ capturedAt: snapshot.capturedAt,
384
+ }
385
+ }
386
+
267
387
  // Every agent-backend consolidation runs here, live sessions included: the same
268
388
  // main agent, on a seeded copy of the transcript, writing into the library the
269
389
  // original used. In-place consolidation would append a housekeeping turn to the
@@ -284,6 +404,11 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
284
404
  let handle = null
285
405
  let offSession = null
286
406
  let turnEnd = null
407
+ let jobId = null
408
+ let scopeChanged = null
409
+ let workerTools = null
410
+ const assistantTexts = []
411
+ let childId = null
287
412
  // 每次整理都要在活动日志里留一条:谁触发(手动/空闲)、fork 有没有建、结果是完成、
288
413
  // 超时还是失败。否则"点了没反应"只能靠翻会话文件才知道发生了什么。
289
414
  const finish = (result) => {
@@ -295,49 +420,173 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
295
420
  sessionId,
296
421
  libraryId: binding.libraryId || null,
297
422
  ok,
298
- level: ok ? (skipped ? 'info' : 'info') : (result?.timedOut ? 'warn' : 'error'),
423
+ level: ok ? 'info' : (result?.timedOut ? 'warn' : 'error'),
299
424
  message: '整理' + (ok ? (skipped ? '跳过(没有值得记的内容)' : '完成') : (result?.timedOut ? '超时' : '失败'))
300
425
  + (force ? ' · 手动' : ' · 空闲')
301
426
  + (result?.childId ? ' · fork ' + result.childId.slice(-8) : '')
427
+ + (result?.reasonCode ? ' · ' + result.reasonCode : '')
302
428
  + (result?.error ? ':' + String(result.error) : '')
303
- + (result?.count !== undefined ? ' · 写入 ' + result.count : ''),
304
- detail: { force, ...result },
429
+ + (result?.count !== undefined ? ' · 写入 ' + result.count : '')
430
+ + (result?.wrote !== null && result?.wrote !== undefined ? ' · 索引 +' + result.wrote : '')
431
+ + (result?.scanned !== undefined ? ' · 扫描 ' + result.scanned + ' 事件 / 新 ' + (result.fresh ?? '?') : ''),
432
+ detail: { force, jobId, ...result },
305
433
  refs: [],
306
434
  })
307
435
  return result
308
436
  }
309
437
  try {
310
- const sessionQuery = ctx.get?.('sessionQuery')
438
+ // 僵尸租约先回收:进程被杀之后 inflight 会留在账本里,不能让一条死记录永久挡住这个会话。
439
+ safeLedger(() => {
440
+ const recovered = recoverStaleJobs()
441
+ if (recovered.length > 0) {
442
+ record({ kind: 'check', scope: 'private', ok: true, message: '回收 ' + recovered.length + ' 个过期整理租约', detail: { recovered } })
443
+ }
444
+ return recovered
445
+ })
446
+
447
+ // 读不到会话就什么都不用谈,所以先确认快照能力,再确认 fork 能力——
448
+ // 失败信息要说清缺的是哪一个(readSession/observeSession 还是 agents.create)。
449
+ const sessionQuery = (() => { try { return ctx.get?.('sessionQuery') || null } catch { return null } })()
450
+ if (!sessionQuery || (typeof sessionQuery.readSession !== 'function' && typeof sessionQuery.observeSession !== 'function')) {
451
+ return finish({
452
+ ok: false, sessionId, code: 'unavailable', reasonCode: 'no-session-query', settled: false,
453
+ error: 'host has no sessionQuery.readSession / observeSession; historical consolidation is unavailable',
454
+ })
455
+ }
311
456
  const agents = ctx.get?.('agents')
312
- if (!sessionQuery?.readSession || typeof agents?.create !== 'function') {
313
- return finish({ ok: false, error: 'host has no readSession / agents.create; historical consolidation is unavailable' })
457
+ if (typeof agents?.create !== 'function') {
458
+ return finish({ ok: false, sessionId, code: 'unavailable', reasonCode: 'no-agents-service', settled: false, error: 'host has no agents.create; historical consolidation is unavailable' })
314
459
  }
315
460
  const buildForkSeed = await loadBuildForkSeed()
316
- if (!buildForkSeed) return finish({ ok: false, error: 'dsh-session buildForkSeed is unavailable' })
461
+ if (!buildForkSeed) {
462
+ return finish({ ok: false, sessionId, code: 'unavailable', reasonCode: 'no-fork-seed', settled: false, error: 'dsh-session buildForkSeed is unavailable' })
463
+ }
464
+
465
+ // 安全快照:cut 固定、身份可核验。未闭合的回合、没配对的工具调用、半截 step 在这里就被挡住,
466
+ // 而不是被当成内容喂给 worker(C 包第 1、2 条)。拿不到就 deferred,不收编。
467
+ const captured = await captureSessionSnapshot(ctx, sessionId, { signal: abort.signal })
468
+ if (!captured.ok) {
469
+ if (captured.code === 'deferred') {
470
+ return finish({ ok: true, sessionId, skipped: captured.reasonCode, settled: false, deferred: true, reasonCode: captured.reasonCode, detail: captured.error })
471
+ }
472
+ return finish({
473
+ ok: false, sessionId, code: captured.code, reasonCode: captured.reasonCode, settled: false,
474
+ retryable: captured.retryable === true, error: captured.error,
475
+ })
476
+ }
477
+ const snapshot = captured.snapshot
478
+ const events = captured.events
479
+ const snapshotMeta = snapshotMetaOf(snapshot)
480
+ const generationAtStart = scopeGenerationOf(binding)
481
+ // 待检查边界与已确认边界分开记:证据到达就记一次,失败不回退。
482
+ safeLedger(() => noteEvidence({ sessionId, fromSeq: snapshot.fromSeq, throughSeq: snapshot.throughSeq }))
483
+ if (events.length === 0) return finish({ ok: false, sessionId, reasonCode: 'empty-snapshot', settled: false, error: 'this session has no content to consolidate' })
317
484
 
318
- const childId = `memu-consolidate-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`
319
- const snapshot = await sessionQuery.readSession(sessionId)
320
- const events = Array.isArray(snapshot?.events) ? snapshot.events : []
321
- const lastSeq = events[events.length - 1]?.seq
322
- if (events.length === 0 || lastSeq === undefined) return finish({ ok: false, error: 'this session has no content to consolidate' })
323
485
  // The scheduler asks "is this conversation idle", which is not the same as
324
486
  // "did anything happen that deserves a memory". Answer the second question
325
487
  // before paying for a fork; a manual consolidation passes force and skips it.
326
488
  if (!force) {
327
- const value = assessConsolidationValue(unconsolidatedEvents(binding, events))
328
- if (!value.worth) return finish({ ok: true, sessionId, skipped: value.reason, settled: true, assessment: value })
489
+ const fresh = unconsolidatedEvents(binding, events)
490
+ const value = assessConsolidationValue(fresh)
491
+ // 这一批里没有真人回合、或只有一句「好的继续」:这一段确实没什么可整理的,
492
+ // 是终态而不是 deferral——否则空闲扫描会为同一句寒暄一直重扫。
493
+ // 没有真人回合、或只有一句「谢谢」:这一段确实没有可整理的证据,是终态而不是 deferral。
494
+ // 写成 deferred 会让空闲扫描为同一句寒暄每轮重扫(tests/cycle.test.mjs 的寒暄用例钉住这一点)。
495
+ if (!value.worth) return finish({ ok: true, sessionId, skipped: value.reason, settled: false, deferred: true, reasonCode: value.reason, assessment: value, snapshot: snapshotMeta })
496
+ // 加权判定:短空闲要求分数达标,长空闲(饥饿阈值)无条件放行;
497
+ // 阈值与权重集中在 lib/active-memory.js,可根据回放标定。
498
+ const settings = getSettings()
499
+ const idleMinutes = binding.lastTurnAt ? (Date.now() - (Date.parse(binding.lastTurnAt) || Date.now())) / 60_000 : Infinity
500
+ const starved = idleMinutes >= Number(settings.activeMemoryStarvationMinutes ?? ACTIVE_MEMORY_POLICY.starvationMinutes)
501
+ const evidence = collectEvidence(fresh)
502
+ const wake = assessWake({
503
+ evidence,
504
+ starved,
505
+ transcriptEvents: events.length,
506
+ // 「待整理区间有多大」是守卫唯一该看的规模信号:守卫要躲的是为一句轻话重种
507
+ // 整段转录,而不是把一个从没整理过的大会话永远挡在门外。
508
+ pendingEvents: fresh.length,
509
+ policy: { ...ACTIVE_MEMORY_POLICY, minScore: Number(settings.activeMemoryMinScore ?? ACTIVE_MEMORY_POLICY.minScore) },
510
+ })
511
+ record({
512
+ kind: 'check',
513
+ scope: 'private',
514
+ sessionId,
515
+ libraryId: binding.libraryId || null,
516
+ ok: wake.wake,
517
+ message: (wake.wake ? '主动检查触发' : '主动检查跳过')
518
+ + ' · score ' + wake.score
519
+ + ' · ' + wake.reason
520
+ + ' · 空闲 ' + (Number.isFinite(idleMinutes) ? Math.round(idleMinutes) + ' 分钟' : '未知')
521
+ + ' · 会话 ' + events.length + ' 事件'
522
+ + ' · 快照 ' + snapshot.hash,
523
+ detail: { trigger: 'auto', reason: wake.reason, score: wake.score, parts: wake.parts, starved, transcriptEvents: events.length, snapshot: snapshotMeta },
524
+ })
525
+ if (!wake.wake) return finish({ ok: true, sessionId, skipped: 'low-score', settled: false, assessment: value, score: wake.score, parts: wake.parts, snapshot: snapshotMeta })
329
526
  }
330
- const header = snapshot?.session || {}
527
+
528
+ // 幂等:同一份固定快照已经结算过就不再 fork 一次(失败的部分写入靠操作键判同再试,
529
+ // 而不是靠"同一个主题就跳过")。
530
+ const opKey = operationKey({ sessionId, libraryId: binding.libraryId, snapshotHash: snapshot.hash, scopeGeneration: generationAtStart })
531
+ const previous = safeLedger(() => findOperation(sessionId, opKey))
532
+ if (previous && (previous.outcome === 'completed' || previous.outcome === 'skipped')) {
533
+ return finish({ ok: true, sessionId, skipped: 'already-consolidated', settled: true, snapshot: snapshotMeta, credential: previous })
534
+ }
535
+
536
+ // 工具面必须可验证:父会话这一轮真实发给模型的工具表里若没有记忆写工具,
537
+ // fork 同样不会有——那就不 fork,明确 unavailable(C 包第 3 条)。
538
+ const probe = probeToolSurface({ ctx, sessionId, events })
539
+ if (!probe.ok) {
540
+ return finish({
541
+ ok: false, sessionId, code: 'unavailable', reasonCode: probe.reasonCode, settled: false, retryable: false,
542
+ toolSurface: probe.tools || null, ptc: probe.ptcObserved === true,
543
+ error: 'this conversation exposes no memory write tool to the model (' + String(probe.source || 'unknown') + '), so a consolidation worker could not write anything; consolidation is unavailable until the session has memu_private_remember / memu_private_update',
544
+ })
545
+ }
546
+
547
+ jobId = 'cons-' + Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex')
548
+ const begun = safeLedger(() => beginJob({ sessionId, jobId, snapshot, binding }))
549
+ if (!begun) {
550
+ return finish({
551
+ ok: false, sessionId, settled: false, retryable: true, reasonCode: 'ledger-unavailable',
552
+ error: 'the consolidation lease could not be persisted; no worker was created',
553
+ })
554
+ }
555
+ if (begun.ok !== true) {
556
+ return finish({
557
+ ok: false, sessionId, running: true, settled: false, reasonCode: 'lease-held',
558
+ error: 'this session already holds a consolidation lease (' + String(begun.jobId) + ' until ' + new Date(begun.leaseUntil).toISOString() + '); the previous run is still unaccounted for',
559
+ })
560
+ }
561
+
562
+ const header = captured.header || {}
563
+ // 每次整理都留"看了多少、新的多少、真正写了几条",否则"0 记忆"无法定位是
564
+ // 没触发、被门挡住还是提取空转。
565
+ const freshCount = unconsolidatedEvents(binding, events).length
566
+ let beforeIds = null
567
+ let beforeEntries = null
568
+ try {
569
+ const beforeIndex = loadPrivateIndex(binding.libraryId)
570
+ beforeIds = new Set(Object.keys(beforeIndex.entries))
571
+ beforeEntries = Object.fromEntries(Object.entries(beforeIndex.entries).map(([id, entry]) => [id, entryFingerprint(entry)]))
572
+ } catch { beforeIds = null; beforeEntries = null }
331
573
 
332
574
  const agentOptions = inheritedAgentOptions(ctx, sessionId, events)
333
- if (!agentOptions) return finish({ ok: false, error: 'cannot inherit the parent model/provider; consolidation fork was not created' })
575
+ if (!agentOptions) {
576
+ safeLedger(() => failJob({ sessionId, jobId, reasonCode: 'no-parent-route', retryable: true, error: 'cannot inherit the parent model/provider' }))
577
+ return finish({ ok: false, sessionId, reasonCode: 'no-parent-route', settled: false, error: 'cannot inherit the parent model/provider; consolidation fork was not created' })
578
+ }
334
579
 
580
+ childId = 'memu-consolidate-' + Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex')
335
581
  // `inheritedEventCount` is a TOP-LEVEL CreateAgentOptions field, NOT session
336
582
  // metadata: SessionStore.prepare() rebuilds the header from a fixed meta
337
583
  // whitelist (version, id, createdAt, cwd, parentSession, isSeeded, origin,
338
584
  // delegationDepth, agentPreset), so a count placed in `meta` is silently
339
585
  // dropped and Session's constructor then refuses the seeded session with
340
586
  // "seeded session requires an inherited event count". Every fork died there.
587
+ //
588
+ // 种子只种到快照的 throughSeq:worker 看到的世界就是它要对账的那一段,
589
+ // 快照之后新发生的事件留给下一批。
341
590
  handle = await agents.create({
342
591
  sessionId: childId,
343
592
  meta: {
@@ -349,13 +598,19 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
349
598
  origin: 'subagent',
350
599
  agentPreset: header.agentPreset,
351
600
  },
352
- // The fork is the parent's transcript, so it inherits every event through lastSeq.
353
- inheritedEventCount: lastSeq + 1,
354
- seed: buildForkSeed(events, lastSeq),
601
+ inheritedEventCount: snapshot.throughSeq + 1,
602
+ seed: buildForkSeed(events, snapshot.throughSeq),
355
603
  agentOptions,
356
604
  signal: abort.signal,
357
605
  })
358
606
 
607
+ // header 无法证明 worker 有写工具时(PTC 会话名单只有 run_code / 旧会话没有名单),
608
+ // 把读写四件挂进 fork 自己的 scope,保证 SDK 面(PTC)与原生面(普通会话)都真能写入。
609
+ // 标准 preset 的 fork 名单里已有 memu_*(probe.hasWrite === true),不重复挂。
610
+ if (probe.hasWrite !== true) {
611
+ workerTools = await attachWorkerTools(agents, childId, sessionId)
612
+ }
613
+
359
614
  // 先记"已开始"再等结果:一个大会话的 fork 可能跑好几分钟,没有这条记录,
360
615
  // 侧边栏和日志页在等待期间只会显示上一次的结果,用户会以为按钮没生效。
361
616
  record({
@@ -364,21 +619,54 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
364
619
  sessionId,
365
620
  libraryId: binding.libraryId || null,
366
621
  ok: true,
367
- message: '整理已开始 · ' + (force ? '手动' : '空闲') + ' · fork ' + childId.slice(-8) + ' · ' + events.length + ' 条事件',
368
- detail: { childId, force, events: events.length, phase: 'started' },
622
+ message: '整理已开始 · ' + (force ? '手动' : '空闲') + ' · fork ' + childId.slice(-8) + ' · ' + events.length + ' 条事件 · 快照 ' + snapshot.hash,
623
+ detail: { childId, jobId, force, events: events.length, phase: 'started', snapshot: snapshotMeta,
624
+ probe: { source: probe.source, hasWrite: probe.hasWrite, ptc: probe.ptcObserved === true },
625
+ ...workerToolsMeta(workerTools) },
369
626
  })
370
627
 
371
- // turn/end is the authoritative outcome of the fork's first turn. whenIdle()
372
- // only means the driver stopped: an error turn also resolves it, so without
373
- // this check a failed consolidation would advance lastSleepSeq and look like
374
- // success to the idle sweep. The listener is public and session-scoped; we
375
- // register before followup so the event cannot race the wait.
628
+ // turn/end 是这一轮的唯一权威结果;whenIdle 只说明 driver 停了(失败的回合也停)。
629
+ // 顺带从同一个公共事件流里收三样东西:worker 的正文(回执在里面)、
630
+ // 它的工具动作(用来在写入途中重验父授权),以及父会话自己是否动过。
376
631
  const childSession = handle?.agent?.session
632
+ const parentAgent = (() => { try { return agents.get?.(sessionId) || null } catch { return null } })()
633
+ const parentLive = parentAgent?.session || null
634
+ const isParentSession = (session) => session === parentLive || (session && parentLive === null && session?.id === sessionId)
635
+ const revalidate = (source) => {
636
+ if (scopeChanged) return
637
+ const now = scopeGenerationOf(getSessionBinding(sessionId))
638
+ if (!generationAtStart || now === generationAtStart) return
639
+ scopeChanged = { source, expected: generationAtStart, actual: now }
640
+ record({
641
+ kind: 'consolidate', scope: 'private', sessionId, libraryId: binding.libraryId || null, ok: false, level: 'warn',
642
+ message: '整理作废:父会话授权变化(' + source + ')',
643
+ detail: { jobId, childId, ...scopeChanged },
644
+ })
645
+ try { abort.abort() } catch {}
646
+ }
377
647
  if (typeof ctx.on === 'function' && childSession) {
378
648
  try {
379
649
  offSession = ctx.on('session/event', (session, event) => {
380
- if (session !== childSession || event?.type !== 'turn/end') return
381
- turnEnd = event
650
+ if (session === childSession) {
651
+ if (event?.type === 'turn/end') { turnEnd = event; return }
652
+ if (event?.type === 'assistant/message') {
653
+ const blocks = event?.data?.message?.content
654
+ if (Array.isArray(blocks)) {
655
+ for (const block of blocks) {
656
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) assistantTexts.push(block.text)
657
+ }
658
+ }
659
+ return
660
+ }
661
+ // 工具动作是写入的落点:每出现一次就重验一次父授权,变了立刻停掉这条 fork。
662
+ if (event?.type === 'tool/call' || event?.type === 'tool/result' || event?.type === 'tool/ptc-dispatch' || event?.type === 'tool/ptc-dispatch-start') {
663
+ revalidate('worker-write')
664
+ return
665
+ }
666
+ return
667
+ }
668
+ // 用户又说话了:父会话的库/域/上下文可能已经被切换,重新比一次指纹。
669
+ if (isParentSession(session) && (event?.type === 'turn/start' || event?.type === 'user/message')) revalidate('parent-activity')
382
670
  })
383
671
  } catch {}
384
672
  }
@@ -387,6 +675,15 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
387
675
  // freshly minted session id.
388
676
  setSessionBinding(childId, {
389
677
  libraryId: binding.libraryId,
678
+ consolidationFork: true,
679
+ // 整理发生在几小时后,父会话此刻的心境不是这段转录里那件事的情绪,所以 fork
680
+ // 不继承 mood。显式写 null 而不是留空:缺字段和「这一刻是中性」在写入时都走
681
+ // 同一条兜底,但只有 null 能把「后台 worker 没有当下心境」说清楚。
682
+ mood: null,
683
+ parentSessionId: sessionId,
684
+ parentScopeGeneration: generationAtStart,
685
+ snapshotHash: snapshot.hash,
686
+ jobId,
390
687
  scopeKey: binding.scopeKey,
391
688
  memoryOwnerSessionId: binding.memoryOwnerSessionId || sessionId,
392
689
  memoryContext: binding.memoryContext || {},
@@ -396,7 +693,18 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
396
693
  domainOverride: binding.domainOverride || null,
397
694
  })
398
695
 
399
- handle.agent.followup(noticeMessage(buildPrivateConsolidationPrompt(sessionId), '整理这场对话的私有记忆'))
696
+ // 写入前最后一次重验:这次的授权还成立吗?不成立就别把 prompt 发出去。
697
+ revalidate('pre-dispatch')
698
+ if (scopeChanged) {
699
+ safeLedger(() => failJob({ sessionId, jobId, reasonCode: 'parent-scope-changed', retryable: false, error: 'the parent conversation changed scope before dispatch' }))
700
+ return finish({
701
+ ok: false, sessionId, childId, settled: false, retryable: false, reasonCode: 'parent-scope-changed',
702
+ error: 'the parent conversation switched library, scope or memory context before the worker was dispatched; nothing was credited',
703
+ })
704
+ }
705
+
706
+ const brief = buildWorkerBrief({ snapshot, probe, contract: receiptContractText({ jobId, snapshotHash: snapshot.hash }), reason: force ? 'manual' : 'auto', jobId })
707
+ handle.agent.followup(noticeMessage([buildPrivateConsolidationPrompt(sessionId), '', brief].join('\n'), '整理这场对话的私有记忆'))
400
708
  // followup only queues. whenIdle says the driver stopped; turn/end says
401
709
  // whether that turn actually succeeded, so both are checked below.
402
710
  const idle = typeof handle.agent.whenIdle === 'function' ? handle.agent.whenIdle() : Promise.resolve()
@@ -406,40 +714,119 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
406
714
  const wait = new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), maxWaitMs) })
407
715
  outcome = await Promise.race([idle.then(() => 'done'), wait])
408
716
  } finally { clearTimeout(timer) }
409
- // A timeout is not success: the fork may still be mid-write. Report it as
410
- // unsettled so the scheduler can retry, instead of silently dropping the
411
- // consolidation.
412
- // On success, the next assessment measures from here, not from this transcript top.
717
+
718
+ let wrote = null
719
+ let decision = null
720
+ let completion = null
413
721
  if (outcome === 'done') {
414
722
  const failure = turnEndFailure(turnEnd)
415
- if (failure) return finish({ ok: false, error: 'consolidation fork failed: ' + failure, sessionId, childId, settled: false, timedOut: false })
416
- setSessionBinding(sessionId, { lastSleepSeq: lastSeq })
723
+ if (failure) {
724
+ safeLedger(() => failJob({ sessionId, jobId, reasonCode: 'fork-turn-failed', error: failure }))
725
+ return finish({ ok: false, error: 'consolidation fork failed: ' + failure, sessionId, childId, settled: false, timedOut: false })
726
+ }
727
+ // 父授权重验:写入可能已经落了一部分,但凭证只有在授权还成立时才算数。
728
+ const generationNow = scopeGenerationOf(getSessionBinding(sessionId))
729
+ if (scopeChanged || (generationAtStart && generationNow !== generationAtStart)) {
730
+ safeLedger(() => failJob({ sessionId, jobId, reasonCode: 'parent-scope-changed', retryable: false, error: 'the parent conversation changed scope during consolidation' }))
731
+ return finish({
732
+ ok: false, sessionId, childId, settled: false, retryable: false, reasonCode: 'parent-scope-changed',
733
+ error: 'the parent conversation switched library, scope or memory context while the worker was running; the result was not credited (writes that already landed stay in the library, the range stays pending)',
734
+ })
735
+ }
736
+ // 兼容字段 lastSleepSeq 只能跟着凭证走:没有对账过的结果就不算整理过,
737
+ // 否则旧游标会把「失败」记成「已整理」,正是 C 包要消灭的那类谎。
738
+ if (beforeIds) {
739
+ try {
740
+ const afterIds = Object.keys(loadPrivateIndex(binding.libraryId).entries)
741
+ wrote = afterIds.filter((id) => !beforeIds.has(id)).length
742
+ } catch { wrote = null }
743
+ }
744
+ let index = null
745
+ try { index = loadPrivateIndex(binding.libraryId) } catch { index = null }
746
+ // 完成不是"没报错":worker 的回执 + 库里的真实变化,两边对上才结算。
747
+ const workerText = assistantTexts.join('\n')
748
+ decision = decideCompletion({ text: workerText, wrote, index, beforeIds, beforeEntries, expectedJobId: jobId, expectedSnapshotHash: snapshot.hash })
749
+
750
+ if (!decision.advance) {
751
+ safeLedger(() => failJob({
752
+ sessionId, jobId, reasonCode: decision.reasonCode || 'not-settled',
753
+ retryable: decision.retryable !== false, error: decision.error || null,
754
+ }))
755
+ return finish({
756
+ ok: false, sessionId, childId, settled: false, retryable: decision.retryable !== false,
757
+ status: decision.status, outcome: decision.outcome, reasonCode: decision.reasonCode,
758
+ counts: decision.counted || null, snapshot: snapshotMeta,
759
+ error: decision.error || ('consolidation result was not verified: ' + String(decision.reasonCode || 'unknown')),
760
+ })
761
+ }
762
+ completion = safeLedger(() => completeJob({
763
+ sessionId, jobId, snapshot,
764
+ covered: decision.covered,
765
+ status: decision.status,
766
+ outcome: decision.outcome,
767
+ reasonCode: decision.reasonCode,
768
+ retryable: decision.retryable === true,
769
+ counts: decision.counted,
770
+ receipt: decision.receipt,
771
+ }))
772
+ if (!completion?.advanced) {
773
+ return finish({ ok: false, sessionId, childId, settled: false, retryable: true,
774
+ reasonCode: 'ledger-not-advanced', snapshot: snapshotMeta,
775
+ error: 'the consolidation ledger could not verify the covered snapshot range' })
776
+ }
777
+ setSessionBinding(sessionId, { lastSleepSeq: snapshot.throughSeq })
778
+ safeLedger(() => recordOperation({ sessionId, key: opKey, outcome: decision.status, ids: decision.createdIds || [] }))
417
779
  }
418
780
  // A timeout is not success: the fork may still be mid-write. It has to come
419
781
  // back with a message, not just `timedOut` — the settings button renders
420
782
  // `result.error` and an empty string there is indistinguishable from a click
421
783
  // that did nothing at all (observed: two clicks, no feedback, no memories).
422
784
  if (outcome === 'timeout') {
785
+ safeLedger(() => failJob({ sessionId, jobId, reasonCode: 'timeout', error: 'timed out before a receipt' }))
423
786
  return finish({
424
787
  ok: false,
425
788
  sessionId,
426
789
  childId,
427
790
  settled: false,
428
791
  timedOut: true,
429
- error: `consolidation timed out after ${Math.round(maxWaitMs / 1000)}s; the fork was stopped (memories written before the timeout are kept). Retry, or consolidate a shorter conversation.`,
792
+ reasonCode: 'timeout',
793
+ error: 'consolidation timed out after ' + Math.round(maxWaitMs / 1000) + 's; the fork was stopped (memories written before the timeout are kept). Retry, or consolidate a shorter conversation.',
430
794
  })
431
795
  }
432
- return finish({ ok: outcome === 'done', sessionId, childId, settled: outcome === 'done', timedOut: false })
796
+ return finish({
797
+ ok: outcome === 'done',
798
+ sessionId,
799
+ childId,
800
+ settled: outcome === 'done',
801
+ timedOut: false,
802
+ status: decision?.status || null,
803
+ outcome: decision?.outcome || null,
804
+ reasonCode: decision?.reasonCode || null,
805
+ counts: decision?.counted || null,
806
+ createdIds: decision?.createdIds || [],
807
+ updatedIds: decision?.updatedIds || [],
808
+ heldIds: decision?.heldIds || [],
809
+ receipt: decision?.receipt
810
+ ? { status: decision.receipt.status, outcome: decision.receipt.outcome, reasonCode: decision.receipt.reasonCode }
811
+ : null,
812
+ credential: completion?.checked ? { checked: completion.checked, pending: completion.pending } : null,
813
+ snapshot: snapshotMeta,
814
+ scanned: events.length,
815
+ fresh: freshCount,
816
+ wrote,
817
+ })
433
818
  } catch (error) {
434
- return finish({ ok: false, error: String(error?.message || error) })
819
+ if (jobId) safeLedger(() => failJob({ sessionId, jobId, reasonCode: 'exception', error: String(error?.message || error) }))
820
+ return finish({ ok: false, sessionId, settled: false, reasonCode: 'exception', error: String(error?.message || error) })
435
821
  } finally {
436
822
  inFlight.delete(sessionId)
437
823
  abort.abort()
438
824
  try { offSession?.() } catch {}
825
+ try { workerTools?.dispose?.() } catch {}
439
826
  try { await handle?.dispose?.() } catch {}
827
+ try { if (childId) deleteSessionBinding(childId) } catch {}
440
828
  }
441
829
  }
442
-
443
830
  export function isConsolidating(sessionId) {
444
831
  return inFlight.has(sessionId)
445
832
  }
@@ -28,6 +28,7 @@
28
28
  - open_loop:只标外部阻塞(等对方决定、等发布、等一个跑不完的验证),不标「还没想清楚」。
29
29
  - confidence:没完全确认就写低一点。
30
30
  - 工程事实用 owner=about_project、mood_valence=0、arousal=0.35、peak=false;工作偏好才用 owner=about_user,不把排障焦虑当作事实准确性。
31
+ - 情绪按事件所在回合还原:emotion 描述的是那条记忆里的那次事件,不是整理时的心情;工程事实不写角色情绪,owner=about_project 固定中性。dominance 只在写人物侧条目时填(自己在推动为正、被推着走为负),工程事实省略。同一轮的多条条目各填各的,不把一次争吵的情绪串到所有条目。
31
32
 
32
33
  永久记忆只留给「永远不能违反的约束」;普通决定不要 pin。
33
34
  不要写全局记忆;全局记忆只由 curator 写。
@@ -22,6 +22,7 @@
22
22
  留成 `MM-DD` 会让它变成每年都响的周年。
23
23
  - open_loop:真实未完的承诺或明确阻塞才标 true;普通犹豫、沉默与未知不自动形成待办。
24
24
  - confidence:依据不足就标明未知并写低一点,不能用低置信度许可编造。
25
+ - 情绪按事件所在回合还原:用那一轮对话里的情绪填 emotion,不要用整理时的心情。同一轮的多条条目各填各的,不把一次争吵的情绪串到所有条目。工程事实(owner=about_project)固定中性。dominance 跟着 emotion 一起填:自己在推动为正、被推着走为负,没把握就省略。
25
26
 
26
27
  永久记忆只留明确永久要求、有依据的核心约束,角色身份或侧事件理由仅适用于实际叙事;不要为了显得深刻而 pin。具体能否写永久、如何更新,以随后输出协议为准。
27
28
  不要写全局记忆;全局记忆只由 curator 写。
@@ -26,6 +26,7 @@
26
26
  - open_loop:仅明确的外部阻塞、未完成承诺或影响后续的已铺垫悬念;普通犹豫或未知不自动标 true。
27
27
  - confidence:不确定就低一点。
28
28
  - 工程事实用 owner=about_project、mood_valence=0、arousal=0.35、peak=false;人物经历的感受与事实置信分开,不让聊天情绪污染技术结论。
29
+ - 情绪按事件所在回合还原:人物侧填那一轮对话里的情绪(emotion/arousal/dominance),技术侧不写角色情绪;同一轮拆开的多条条目各填各的,一次争吵的情绪不串到所有条目。工程事实(owner=about_project)固定中性。dominance 只跟人物侧的 emotion 一起填,技术侧省略。
29
30
 
30
31
  永久记忆只留给"忘了就不再是我"或绝不能违反的约束;普通决定不要 pin。
31
32
  不要写全局记忆;全局记忆只由 curator 写。