dsh-memu-memory 1.1.2 → 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 (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,411 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-cycle-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const cycle = await import('../lib/cycle.js')
12
+
13
+ test.after(() => {
14
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
15
+ })
16
+
17
+ // A ctx stand-in for a host that can read a transcript and fork it. Consolidation
18
+ // never asks `agents.get` for anything any more, so the recorder counts both sides:
19
+ // followups handed to the user's own live agent (must stay 0) and followups handed
20
+ // to a scratch fork (must be 1 per consolidated session).
21
+ function fakeCtx({ live = [], forkable = null, gone = [], transcript = null, parentOptions = { provider: 'test-provider', model: 'test-model' }, turnReason = { kind: 'completed' } } = {}) {
22
+ const liveSet = new Set(live)
23
+ const forkSet = new Set(forkable || live)
24
+ const goneSet = new Set(gone)
25
+ const created = []
26
+ const disposed = []
27
+ const liveFollowups = []
28
+ const forkFollowups = []
29
+ const seeds = []
30
+ const sessionHandlers = []
31
+ // A transcript that actually contains a person: the value gate reads it before a
32
+ // fork exists, so a fixture with bare `{seq}` markers would be judged empty.
33
+ const events = transcript || [
34
+ { seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '帮我看一下这个插件为什么空闲整理从来没跑过,日志里只有 skip 一行' }] } },
35
+ { seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'tool-call', name: 'read', arguments: '{}' }] } } },
36
+ ]
37
+ const ctx = {
38
+ created, disposed, liveFollowups, forkFollowups, seeds,
39
+ on: (name, handler) => {
40
+ if (name !== 'session/event') return () => {}
41
+ sessionHandlers.push(handler)
42
+ return () => {
43
+ const index = sessionHandlers.indexOf(handler)
44
+ if (index >= 0) sessionHandlers.splice(index, 1)
45
+ }
46
+ },
47
+ get: (name) => {
48
+ if (name === 'agents') {
49
+ return {
50
+ get: (id) => (liveSet.has(id)
51
+ ? { followup: (message) => liveFollowups.push({ id, message }), options: parentOptions }
52
+ : undefined),
53
+ create: async (spec) => {
54
+ created.push(spec)
55
+ seeds.push(spec.seed)
56
+ const childSession = { id: spec.sessionId }
57
+ return {
58
+ agent: {
59
+ session: childSession,
60
+ followup: (message) => {
61
+ forkFollowups.push({ child: spec.sessionId, message })
62
+ const proof = { version: 1, jobId: state.getSessionBinding(spec.sessionId).jobId, snapshotHash: state.getSessionBinding(spec.sessionId).snapshotHash, status: 'completed', outcome: 'no_value', checkedRange: { fromSeq: 0, throughSeq: events.at(-1).seq }, createdIds: [], updatedIds: [], heldIds: [] }
63
+ const tick = String.fromCharCode(96).repeat(3)
64
+ for (const handler of sessionHandlers) {
65
+ handler(childSession, { type: 'assistant/message', seq: 99, data: { message: { content: [{ type: 'text', text: tick + 'memu-consolidation-receipt\n' + JSON.stringify(proof) + '\n' + tick }] } } })
66
+ handler(childSession, { type: 'turn/end', seq: 100, data: { turn: 1, reason: turnReason } })
67
+ }
68
+ },
69
+ whenIdle: async () => {},
70
+ },
71
+ dispose: async () => { disposed.push(spec.sessionId) },
72
+ }
73
+ },
74
+ }
75
+ }
76
+ if (name === 'sessionQuery') {
77
+ return {
78
+ readSession: async (id) => {
79
+ if (goneSet.has(id)) throw new Error(`session "${id}" not found`)
80
+ return forkSet.has(id) ? { events, session: { cwd: path.join(home, 'p'), agentPreset: 'rp' } } : { events: [] }
81
+ },
82
+ }
83
+ }
84
+ return undefined
85
+ },
86
+ }
87
+ return ctx
88
+ }
89
+
90
+ function touchAgo(sessionId, minutesAgo) {
91
+ state.setSessionBinding(sessionId, { lastTurnAt: new Date(Date.now() - minutesAgo * 60_000).toISOString() })
92
+ }
93
+
94
+ test('sleep pass does nothing unless it is turned on', async () => {
95
+ // Fresh installs now default this on, so turn it off explicitly to test the
96
+ // disabled branch without depending on the default.
97
+ state.updateSettings({ sleepConsolidation: false })
98
+ const binding = state.ensureSession('cycle-off', { cwd: path.join(home, 'p-off'), agentPreset: 'rp' })
99
+ touchAgo('cycle-off', 120)
100
+ const out = await cycle.runSleepPass(fakeCtx({ live: ['cycle-off'] }))
101
+ assert.equal(out.skipped, 'disabled')
102
+ assert.equal(state.getLastSleepAt('cycle-off'), null)
103
+ })
104
+
105
+ test('an idle live session is consolidated in a fork, never in its own chat', async () => {
106
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 10 })
107
+ const binding = state.ensureSession('cycle-idle', { cwd: path.join(home, 'p-idle'), agentPreset: 'rp' })
108
+ touchAgo('cycle-idle', 30)
109
+
110
+ const ctx = fakeCtx({ live: ['cycle-idle'] })
111
+ const first = await cycle.runSleepPass(ctx)
112
+ assert.equal(first.ran, 1)
113
+ assert.ok(state.getLastSleepAt('cycle-idle'))
114
+
115
+ // The reason this whole path exists: a consolidation prompt queued on the chat
116
+ // the user is looking at shows up as a pending draft above the composer and then
117
+ // lives in that transcript forever. The live agent must never be handed one.
118
+ assert.equal(ctx.liveFollowups.length, 0, 'the live session must not get a queued draft')
119
+ assert.equal(ctx.created.length, 1, 'exactly one scratch fork')
120
+ assert.equal(ctx.forkFollowups.length, 1, 'the fork is what carries the work')
121
+ assert.equal(ctx.disposed.length, 1, 'the fork is thrown away afterwards')
122
+ const child = ctx.created[0].sessionId
123
+ assert.match(child, /^memu-consolidate-/)
124
+ assert.equal(ctx.created[0].meta.origin, 'subagent', 'a subagent fork stays out of the window list and the injection path')
125
+ assert.equal(ctx.created[0].meta.parentSession, 'cycle-idle')
126
+ assert.equal(ctx.seeds[0].lastSeq, 1, 'the seed is the parent transcript')
127
+ assert.equal(state.getSessionBinding(child), null, 'worker binding is removed after disposal')
128
+
129
+ const second = await cycle.runSleepPass(fakeCtx({ live: ['cycle-idle'] }))
130
+ assert.equal(second.ran, 0)
131
+ assert.equal(second.skipped, undefined)
132
+ })
133
+
134
+ test('a session that was not idle long enough is left alone', async () => {
135
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 30 })
136
+ state.ensureSession('cycle-recent', { cwd: path.join(home, 'p-recent'), agentPreset: 'rp' })
137
+ touchAgo('cycle-recent', 2)
138
+ const out = await cycle.runSleepPass(fakeCtx({ live: ['cycle-recent'] }))
139
+ assert.equal(out.ran, 0)
140
+ assert.equal(state.getLastSleepAt('cycle-recent'), null)
141
+ })
142
+
143
+ test('an unreadable transcript backs off without claiming a completed sleep', async () => {
144
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 5 })
145
+ state.ensureSession('cycle-dead', { cwd: path.join(home, 'p-dead'), agentPreset: 'rp' })
146
+ touchAgo('cycle-dead', 60)
147
+ const logs = []
148
+ await cycle.runSleepPass({ get: () => undefined }, { log: (m) => logs.push(m) })
149
+ assert.equal(state.getLastSleepAt('cycle-dead'), null)
150
+ const failed = state.getSessionBinding('cycle-dead')
151
+ assert.ok(Date.parse(failed.sleepRetryAt) > Date.now())
152
+ assert.equal(failed.sleepAttempts, 1)
153
+ await cycle.runSleepPass({ get: () => undefined })
154
+ assert.equal(state.getSessionBinding('cycle-dead').sleepAttempts, 1, 'backoff suppresses immediate retries')
155
+ assert.ok(logs.some((m) => /consolidation skipped for cycle-dead/.test(m)), 'skip logged: ' + logs.join(' | '))
156
+ state.setSessionBinding('cycle-dead', { sleepRetryAt: new Date(0).toISOString() })
157
+ await cycle.runSleepPass(fakeCtx({ live: ['cycle-dead'] }))
158
+ assert.ok(state.getLastSleepAt('cycle-dead'), 'a recovered host retries without another user turn')
159
+ assert.equal(state.getSessionBinding('cycle-dead').lastSleepError, null)
160
+ })
161
+
162
+ test('a fork that cannot be created is logged, not dropped silently', async () => {
163
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 5 })
164
+ state.ensureSession('cycle-hist', { cwd: path.join(home, 'p-hist'), agentPreset: 'rp' })
165
+ touchAgo('cycle-hist', 60)
166
+ const logs = []
167
+ const ctx = {
168
+ get: (name) => {
169
+ if (name === 'sessionQuery') return { readSession: async () => ({ events: [
170
+ { seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '这个 fork 一直建不起来,帮我看下为什么,日志里只有 skipped 一行' }] } },
171
+ ], session: {} }) }
172
+ if (name === 'agents') return {
173
+ get: () => ({ options: { provider: 'test-provider', model: 'test-model' } }),
174
+ create: async () => { throw new Error('no fork in test env') },
175
+ }
176
+ return undefined
177
+ },
178
+ }
179
+ await cycle.runSleepPass(ctx, { log: (m) => logs.push(m) })
180
+ assert.ok(logs.some((m) => /consolidation skipped for cycle-hist: no fork in test env/.test(m)), 'attempt logged: ' + logs.join(' | '))
181
+ assert.equal(state.getLastSleepAt('cycle-hist'), null)
182
+ assert.match(state.getSessionBinding('cycle-hist').lastSleepError, /no fork/)
183
+ })
184
+
185
+ test('a fork whose turn errors is backed off, not marked asleep', async () => {
186
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 5 })
187
+ state.ensureSession('cycle-fork-error', { cwd: path.join(home, 'p-fork-error'), agentPreset: 'rp' })
188
+ touchAgo('cycle-fork-error', 60)
189
+ const out = await cycle.runSleepPass(fakeCtx({
190
+ live: ['cycle-fork-error'],
191
+ turnReason: { kind: 'error', error: { message: 'prompt variable "{{model}}" has no value' } },
192
+ }))
193
+ assert.equal(out.ran, 0)
194
+ assert.equal(state.getLastSleepAt('cycle-fork-error'), null, 'a failed turn is still unslept')
195
+ const failed = state.getSessionBinding('cycle-fork-error')
196
+ assert.equal(failed.sleepAttempts, 1)
197
+ assert.match(failed.lastSleepError, /fork turn ended error/)
198
+ assert.ok(Date.parse(failed.sleepRetryAt) > Date.now(), 'the scheduler backs off instead of losing the work')
199
+ })
200
+
201
+ test('a turn newer than the last sleep makes the session eligible again', async () => {
202
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 5 })
203
+ state.ensureSession('cycle-again', { cwd: path.join(home, 'p-again'), agentPreset: 'rp' })
204
+ // Slept 40 minutes ago, then the conversation continued 15 minutes ago: the
205
+ // earlier sleep is stale and a new one is due.
206
+ state.setLastSleepAt('cycle-again', new Date(Date.now() - 40 * 60_000).toISOString())
207
+ touchAgo('cycle-again', 15)
208
+
209
+ const out = await cycle.runSleepPass(fakeCtx({ live: ['cycle-again'] }))
210
+ assert.equal(out.ran, 1)
211
+ assert.ok(Date.parse(state.getLastSleepAt('cycle-again')) > Date.parse(new Date(Date.now() - 15 * 60_000).toISOString()) - 60_000)
212
+ })
213
+
214
+ test('a conversation the host says is gone is marked once and never retried', async () => {
215
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 5 })
216
+ state.ensureSession('cycle-ghost', { cwd: path.join(home, 'p-ghost'), agentPreset: 'rp' })
217
+ touchAgo('cycle-ghost', 120)
218
+ const logs = []
219
+ const out = await cycle.runSleepPass(fakeCtx({ live: ['cycle-ghost'], gone: ['cycle-ghost'] }), { log: (m) => logs.push(m) })
220
+ assert.equal(out.ran, 0)
221
+ const binding = state.getSessionBinding('cycle-ghost')
222
+ assert.equal(binding.sessionGone, true, 'the mark is what stops the hourly retry')
223
+ assert.equal(binding.sleepAttempts, 0, 'a deleted session is not a transient failure')
224
+ assert.equal(binding.sleepRetryAt, null, 'nothing to retry later')
225
+ assert.ok(logs.some((m) => /no longer exists/.test(m)), 'explained once: ' + logs.join(' | '))
226
+ const quiet = await cycle.runSleepPass(fakeCtx({ live: ['cycle-ghost'] }))
227
+ assert.equal(quiet.considered, 0, 'the next sweep does not even read the transcript')
228
+ })
229
+
230
+ test('a policy greeting skip does not claim a completed worker check', async () => {
231
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 5 })
232
+ state.ensureSession('cycle-smalltalk', { cwd: path.join(home, 'p-small'), agentPreset: 'rp' })
233
+ touchAgo('cycle-smalltalk', 20)
234
+ const ctx = fakeCtx({ live: ['cycle-smalltalk'], transcript: [
235
+ { seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '谢谢' }] } },
236
+ { seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '不客气' }] } } },
237
+ ] })
238
+ const out = await cycle.runSleepPass(ctx)
239
+ assert.equal(out.ran, 0, 'a policy skip is not a verified worker receipt')
240
+ assert.equal(ctx.created.length, 0, 'no fork for small talk')
241
+ assert.equal(state.getLastSleepAt('cycle-smalltalk'), null)
242
+ })
243
+
244
+ test('auto curation stays off unless an interval is set', async () => {
245
+ const out = await cycle.runCurationPass(fakeCtx({ live: ['cycle-idle'] }))
246
+ assert.equal(out.skipped, 'disabled')
247
+ })
248
+
249
+ test('auto curation waits for a live parent and does not advance the schedule', async () => {
250
+ state.updateSettings({ autoCurateEveryHours: 1, autoCurateSessionLimit: 5 })
251
+ state.setCycleState({ lastCurateAt: null })
252
+ state.ensureSession('cycle-curate', { cwd: path.join(home, 'p-curate'), agentPreset: 'rp' })
253
+ touchAgo('cycle-curate', 5)
254
+
255
+ const out = await cycle.runCurationPass(fakeCtx({ live: [] }))
256
+ assert.equal(out.skipped, 'no-live-parent')
257
+ assert.equal(out.ran, 0)
258
+ assert.ok(!state.getCycleState().lastCurateAt, 'a run that never started must not reschedule')
259
+ })
260
+
261
+ function registryCtx(paths) {
262
+ return {
263
+ get: (name) => (name === 'workspaceRegistry' ? { list: () => paths.map((p) => ({ path: p })) } : undefined),
264
+ }
265
+ }
266
+
267
+ test('a library is archived only when its workspace leaves the DSH registry', () => {
268
+ const lib = state.createLibrary({ name: 'cycle-ws', cwd: '/work/cycle-ws' })
269
+ assert.ok(state.listLibraries().some((l) => l.id === lib.id), 'starts active')
270
+
271
+ // Registered: this library must not be touched (other leftovers from earlier
272
+ // tests may legitimately archive in the same sweep, so assert only about ours).
273
+ const stillThere = cycle.runLibraryHousekeeping(registryCtx(['/work/cycle-ws', '/work/other']))
274
+ assert.ok(!stillThere.archived.includes(lib.id))
275
+
276
+ // Unregistered (but still on disk): archived.
277
+ const removed = cycle.runLibraryHousekeeping(registryCtx(['/work/other']))
278
+ assert.ok(removed.archived.includes(lib.id), 'library should archive when its workspace is unregistered')
279
+ assert.ok(state.listArchivedLibraries().map((l) => l.id).includes(lib.id))
280
+ assert.ok(state.listLibraries().every((l) => l.id !== lib.id), 'archived libraries leave the active list')
281
+ })
282
+
283
+ test('an absent or empty registry archives nothing (fail-open)', () => {
284
+ const lib = state.createLibrary({ name: 'cycle-safe', cwd: '/work/cycle-safe' })
285
+ // No workspaceRegistry service at all.
286
+ const noService = cycle.runLibraryHousekeeping({ get: () => undefined })
287
+ assert.deepEqual(noService.archived, [])
288
+ assert.deepEqual(noService.revived, [])
289
+ // Registry present but empty — a fresh install must not wipe the library list.
290
+ const empty = cycle.runLibraryHousekeeping(registryCtx([]))
291
+ assert.deepEqual(empty.archived, [])
292
+ assert.ok(state.getLibrary(lib.id))
293
+ })
294
+
295
+ test('a workspace coming back revives its library', () => {
296
+ const lib = state.createLibrary({ name: 'cycle-revive', cwd: '/work/cycle-revive' })
297
+ cycle.runLibraryHousekeeping(registryCtx(['/work/other']))
298
+ assert.ok(state.listArchivedLibraries().some((l) => l.id === lib.id), 'should have been archived')
299
+ const back = cycle.runLibraryHousekeeping(registryCtx(['/work/cycle-revive']))
300
+ assert.ok(back.revived.includes(lib.id))
301
+ assert.ok(state.listLibraries().some((l) => l.id === lib.id), 'should be active again')
302
+ })
303
+
304
+ test('archived libraries are kept forever unless an age is set', () => {
305
+ const lib = state.createLibrary({ name: 'cycle-keep', cwd: '/work/cycle-keep' })
306
+ state.archiveLibrary(lib.id)
307
+ state.updateSettings({ purgeArchivedAfterDays: 0 })
308
+ const manual = cycle.runLibraryHousekeeping(registryCtx(['/work/somewhere-else']))
309
+ assert.deepEqual(manual.purged, [])
310
+ assert.ok(state.getLibrary(lib.id), 'manual mode must not delete')
311
+
312
+ // Now make it old and turn on purging.
313
+ const st = state.loadState()
314
+ st.libraries[lib.id].archivedAt = new Date(Date.now() - 40 * 86_400_000).toISOString()
315
+ state.saveState(st)
316
+ state.updateSettings({ purgeArchivedAfterDays: 30 })
317
+ const purged = cycle.runLibraryHousekeeping(registryCtx(['/work/somewhere-else']))
318
+ assert.deepEqual(purged.purged, [])
319
+ assert.ok(purged.purgeCandidates.includes(lib.id))
320
+ assert.ok(state.getLibrary(lib.id), 'age produces a review candidate, never an automatic library deletion')
321
+ })
322
+
323
+ test('deleting an un-archived library is refused', () => {
324
+ const lib = state.createLibrary({ name: 'cycle-nodelete', cwd: '/work/cycle-nodelete' })
325
+ const result = state.deleteLibrary(lib.id)
326
+ assert.equal(result.ok, false)
327
+ assert.match(result.error, /archive the library before deleting/)
328
+ assert.ok(state.getLibrary(lib.id))
329
+ })
330
+
331
+ test('auto curation is not due yet', async () => {
332
+ state.updateSettings({ autoCurateEveryHours: 6 })
333
+ state.setCycleState({ lastCurateAt: new Date().toISOString() })
334
+ const out = await cycle.runCurationPass(fakeCtx({ live: ['cycle-curate'] }))
335
+ assert.equal(out.skipped, 'not-due')
336
+ })
337
+ test('a thin turn shortly after a consolidation waits for real evidence', async () => {
338
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 3 })
339
+ state.ensureSession('cycle-cooldown', { cwd: path.join(home, 'p-cooldown'), agentPreset: 'rp' })
340
+ // 刚整理过(20 分钟前),5 分钟前只补了一句很短的话:再 fork 一次要把整段转录
341
+ // 重新种进去,纯粹浪费 token。评分门槛会把它压到饥饿阈值之后。
342
+ state.setSessionBinding('cycle-cooldown', { lastTurnAt: new Date(Date.now() - 5 * 60_000).toISOString() })
343
+ state.setLastSleepAt('cycle-cooldown', new Date(Date.now() - 20 * 60_000).toISOString())
344
+ const ctx = fakeCtx({ live: ['cycle-cooldown'] })
345
+ const out = await cycle.runSleepPass(ctx)
346
+ assert.equal(out.ran, 0)
347
+ assert.equal(ctx.created.length, 0, 'a thin follow-up must not buy another full-transcript fork')
348
+ // 收尾:把它标记为已睡,避免候选泄漏到后续用例。
349
+ state.setLastSleepAt('cycle-cooldown', new Date().toISOString())
350
+ })
351
+
352
+ test('a session that just injected memory is left alone by the idle sweep', async () => {
353
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 3 })
354
+ state.ensureSession('cycle-recall', { cwd: path.join(home, 'p-recall'), agentPreset: 'rp' })
355
+ state.setSessionBinding('cycle-recall', {
356
+ // 空闲已过饥饿阈值,按评分本会 fork;但 5 分钟前刚注入了记忆,抑制优先。
357
+ lastTurnAt: new Date(Date.now() - 20 * 60_000).toISOString(),
358
+ lastInjectedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
359
+ })
360
+ const ctx = fakeCtx({ live: ['cycle-recall'] })
361
+ const out = await cycle.runSleepPass(ctx)
362
+ assert.equal(out.ran, 0)
363
+ assert.equal(ctx.created.length, 0, 'a session with fresh memory context does not need a fork')
364
+ state.setLastSleepAt('cycle-recall', new Date().toISOString())
365
+ })
366
+
367
+ // H5: a missing file is not the host saying the conversation was deleted.
368
+ // captureSessionSnapshot marks a failed read retryable on purpose ("读失败,可重试"),
369
+ // but the bare word ENOENT in isGoneSessionError used to turn that into sessionGone —
370
+ // and the sweep skips a marked session until some later human message clears it.
371
+ test('a transcript that cannot be read is retried, not marked as a deleted conversation', async () => {
372
+ state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 5 })
373
+ state.ensureSession('cycle-enoent', { cwd: path.join(home, 'p-enoent'), agentPreset: 'rp' })
374
+ // 6 minutes: still idle under the 5-minute threshold, but the sweep takes the two
375
+ // newest candidates and this file shares one state directory across its tests.
376
+ touchAgo('cycle-enoent', 6)
377
+ const logs = []
378
+ const ctx = {
379
+ get: (name) => {
380
+ if (name === 'sessionQuery') {
381
+ return {
382
+ readSession: async () => {
383
+ const error = new Error("ENOENT: no such file or directory, open 'D:/home/sessions/cycle-enoent/log.jsonl'")
384
+ error.code = 'ENOENT'
385
+ throw error
386
+ },
387
+ }
388
+ }
389
+ if (name === 'agents') {
390
+ return { get: () => ({}), create: async () => { throw new Error('a session that cannot be read must not be forked') } }
391
+ }
392
+ return undefined
393
+ },
394
+ }
395
+ await cycle.runSleepPass(ctx, { log: (message) => logs.push(message), limit: 10 })
396
+ const binding = state.getSessionBinding('cycle-enoent')
397
+ assert.ok(!binding.sessionGone, 'a transient read failure is not the host reporting a deletion')
398
+ assert.equal(binding.sleepAttempts, 1, 'it backs off like any other failure')
399
+ assert.ok(Date.parse(binding.sleepRetryAt) > Date.now(), 'it is retried later')
400
+ assert.match(binding.lastSleepError, /ENOENT/)
401
+ assert.ok(logs.some((message) => /consolidation skipped for cycle-enoent/.test(message)), 'stated once: ' + logs.join(' | '))
402
+ assert.equal(state.getLastSleepAt('cycle-enoent'), null, 'nothing was consolidated, so nothing is marked asleep')
403
+ })
404
+
405
+ test('only the host saying the conversation is gone counts as deleted', () => {
406
+ assert.equal(cycle.isGoneSessionError('session "abc" not found'), true)
407
+ assert.equal(cycle.isGoneSessionError('no such session: abc'), true)
408
+ assert.equal(cycle.isGoneSessionError('this session does not exist any more'), true)
409
+ assert.equal(cycle.isGoneSessionError("ENOENT: no such file or directory, open 'D:/x/log.jsonl'"), false)
410
+ assert.equal(cycle.isGoneSessionError('EACCES: permission denied, open D:/x'), false)
411
+ })
@@ -0,0 +1,231 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-tone-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const memory = await import('../lib/memory.js')
12
+ const inject = await import('../lib/inject.js')
13
+
14
+ test.after(() => {
15
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
16
+ })
17
+
18
+ function execFor(sessionId, cwd) {
19
+ return { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
20
+ }
21
+
22
+ function seedGlobal(id, patch) {
23
+ const index = state.loadGlobalIndex()
24
+ index.entries[id] = {
25
+ track: 'memory',
26
+ layer: 'long_term',
27
+ domain: 'coding',
28
+ status: 'active',
29
+ confidence: 0.9,
30
+ createdAt: new Date().toISOString(),
31
+ updatedAt: new Date().toISOString(),
32
+ ...patch,
33
+ }
34
+ state.saveGlobalIndex(index)
35
+ }
36
+
37
+ test('disclosure changes the wording of the injected line, not whether it arrives', async () => {
38
+ const cwd = path.join(home, 'proj-tone')
39
+ const sessionId = 'tone-suffix'
40
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
41
+ const exec = execFor(sessionId, cwd)
42
+ const settings = { ...state.getSettings(), relevanceThreshold: 0, privateTopK: 5 }
43
+
44
+ await memory.privateRemember({ exec, content: 'The ledger file lives in docs/ledger.md.', title: 'ledger file', disclosure: 'can_tell' })
45
+ await memory.privateRemember({ exec, content: 'The ledger file is generated by the old importer.', title: 'ledger importer', disclosure: 'only_if_asked' })
46
+ await memory.privateRemember({ exec, content: 'The ledger file was named after his father.', title: 'ledger name', disclosure: 'never_say' })
47
+
48
+ const recall = await inject.buildRecall(state.getSessionBinding(sessionId), 'ledger file', 1, settings, exec, sessionId)
49
+ assert.deepEqual(recall.errors, [])
50
+ assert.ok(recall.text.includes('ledger file'), 'a can_tell memory arrives as before')
51
+ assert.equal(recall.text.split('对方不问就别主动提').length - 1, 1, 'only_if_asked carries its suffix')
52
+ assert.equal(recall.text.split('记得,但别直接说出口').length - 1, 1, 'never_say carries its suffix')
53
+ // The default case must stay silent: adding a suffix to every line would be noise.
54
+ const canTellLine = recall.text.split('\n').find((line) => line.includes('ledger file ·') || line.includes('ledger file'))
55
+ assert.ok(canTellLine && !canTellLine.includes('对方不问') && !canTellLine.includes('别直接说出口'))
56
+ })
57
+
58
+ test('a secret stays out of the anchor but is still reachable through recall', async () => {
59
+ const cwd = path.join(home, 'proj-secret')
60
+ const sessionId = 'tone-secret'
61
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
62
+ const exec = execFor(sessionId, cwd)
63
+ const settings = { ...state.getSettings(), anchorTokens: 700, relevanceThreshold: 0 }
64
+
65
+ const { id: secretId } = await memory.privateRemember({
66
+ exec,
67
+ content: 'SECRET-BODY he is planning to leave the company in spring.',
68
+ title: 'leaving in spring',
69
+ visibility: 'secret',
70
+ })
71
+ const { id: pinnedSecretId } = await memory.privateRemember({
72
+ exec,
73
+ content: 'PINNED-SECRET-BODY the diagnosis he told nobody else.',
74
+ title: 'the diagnosis',
75
+ layer: 'must_remember',
76
+ pinReason: 'he said it once and never again',
77
+ pinSource: 'user_explicit',
78
+ visibility: 'secret',
79
+ })
80
+ const { id: sharedId } = await memory.privateRemember({
81
+ exec,
82
+ content: 'SHARED-BODY the deploy window is Thursday night.',
83
+ title: 'deploy window',
84
+ })
85
+
86
+ const anchor = inject.buildAnchor(binding, settings)
87
+ // The anchor is the most conspicuous list the model sees every turn; a secret that
88
+ // sits there is a secret waiting to be said at the wrong moment.
89
+ assert.equal(anchor.text.includes('SECRET-BODY'), false)
90
+ assert.equal(anchor.text.includes('PINNED-SECRET-BODY'), false, 'pinning does not override secrecy')
91
+ assert.equal(anchor.text.includes(secretId), false)
92
+ assert.equal(anchor.text.includes(pinnedSecretId), false)
93
+ assert.ok(anchor.text.includes('SHARED-BODY'), 'ordinary memories still show up')
94
+
95
+ const recalled = await memory.privateRecall({ exec, query: 'leave company diagnosis deploy window', topK: 5, relevanceThreshold: 0 })
96
+ assert.ok(recalled.files.some((file) => file.name === secretId), 'a secret is a recall target, not a hidden row')
97
+ assert.ok(recalled.files.some((file) => file.name === sharedId))
98
+ })
99
+
100
+ test('the short-term pointer card carries the disclosure suffix too', async () => {
101
+ const cwd = path.join(home, 'proj-pointer')
102
+ const sessionId = 'tone-pointer'
103
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
104
+ const exec = execFor(sessionId, cwd)
105
+
106
+ const { id } = await memory.privateRemember({
107
+ exec,
108
+ content: 'BODY he was still deciding about the surgery.',
109
+ title: 'the surgery question',
110
+ layer: 'short_term',
111
+ disclosure: 'never_say',
112
+ })
113
+ const anchor = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 700 })
114
+ const line = anchor.text.split('\n').find((row) => row.includes(id))
115
+ assert.ok(line, 'the pointer row exists')
116
+ assert.ok(line.includes('小时后过期'))
117
+ assert.ok(line.includes('记得,但别直接说出口'), 'a draft can be just as unsayable as a durable memory')
118
+ assert.equal(anchor.text.includes('BODY he was still'), false)
119
+ })
120
+
121
+ test('owner prefixes only about_user, and the prefix is cheap', async () => {
122
+ const cwd = path.join(home, 'proj-owner')
123
+ const sessionId = 'tone-owner'
124
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
125
+ const exec = execFor(sessionId, cwd)
126
+ const settings = { ...state.getSettings(), anchorTokens: 700 }
127
+
128
+ await memory.privateRemember({ exec, content: 'He cannot eat shellfish.', title: 'shellfish', owner: 'about_user' })
129
+ await memory.privateRemember({ exec, content: 'I keep forgetting to water the plant.', title: 'water the plant', owner: 'me' })
130
+
131
+ const anchor = inject.buildAnchor(binding, settings)
132
+ const aboutThem = anchor.text.split('\n').find((row) => row.includes('shellfish'))
133
+ const aboutMe = anchor.text.split('\n').find((row) => row.includes('water the plant'))
134
+ assert.ok(aboutThem.includes('[关于他] '), 'whose fact it is changes how it may be said')
135
+ assert.equal(aboutMe.includes('[关于他] '), false)
136
+ })
137
+
138
+ test('the three fields are validated instead of silently stored', async () => {
139
+ const cwd = path.join(home, 'proj-validate')
140
+ const sessionId = 'tone-validate'
141
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
142
+ const exec = execFor(sessionId, cwd)
143
+
144
+ await assert.rejects(() => memory.privateRemember({ exec, content: 'x', disclosure: 'maybe' }), /disclosure must be/)
145
+ await assert.rejects(() => memory.privateRemember({ exec, content: 'x', owner: 'somebody' }), /owner must be/)
146
+ await assert.rejects(() => memory.privateRemember({ exec, content: 'x', visibility: 'friends' }), /visibility must be/)
147
+
148
+ const { id } = await memory.privateRemember({ exec, content: 'A note about the attic.', title: 'attic' })
149
+ await assert.rejects(() => memory.privateUpdate({ exec, id, disclosure: 'nope' }), /disclosure must be/)
150
+ const updated = await memory.privateUpdate({ exec, id, owner: 'about_user', visibility: 'secret' })
151
+ assert.equal(updated.entry.owner, 'about_user')
152
+ assert.equal(updated.entry.visibility, 'secret')
153
+ })
154
+
155
+ test('relevanceThreshold is relative to the best hit, and 0 switches it off', async () => {
156
+ const cwd = path.join(home, 'proj-threshold')
157
+ const sessionId = 'tone-threshold'
158
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
159
+ const exec = execFor(sessionId, cwd)
160
+
161
+ await memory.privateRemember({ exec, content: 'The billing page copy is owned by marketing.', title: 'billing copy' })
162
+ await memory.privateRemember({ exec, content: 'Billing exports run nightly.', title: 'billing export' })
163
+ await memory.privateRemember({ exec, content: 'A billing incident in March came from a rounding bug.', title: 'billing incident' })
164
+
165
+ state.updateSettings({ relevanceThreshold: 0 })
166
+ const all = await memory.privateRecall({ exec, query: 'billing page', topK: 5 })
167
+ assert.equal(all.files.length, 3, 'threshold 0 keeps everything the matcher found')
168
+
169
+ state.updateSettings({ relevanceThreshold: 0.9 })
170
+ try {
171
+ const top = await memory.privateRecall({ exec, query: 'billing page', topK: 5 })
172
+ assert.equal(top.files.length, 1, 'a high threshold keeps only what scores close to the best hit')
173
+ } finally {
174
+ state.updateSettings({ relevanceThreshold: 0.4 })
175
+ }
176
+ })
177
+
178
+ test('being recalled counts as being used, in one index write', async () => {
179
+ const cwd = path.join(home, 'proj-touch')
180
+ const sessionId = 'tone-touch'
181
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
182
+ const exec = execFor(sessionId, cwd)
183
+
184
+ const { id } = await memory.privateRemember({ exec, content: 'The release checklist is in docs/release.md.', title: 'release checklist' })
185
+ const before = memory.decayStrength(state.loadPrivateIndex(binding.libraryId).entries[id])
186
+
187
+ await memory.privateRecall({ exec, query: 'release checklist', topK: 5 })
188
+ const entry = state.loadPrivateIndex(binding.libraryId).entries[id]
189
+ assert.equal(entry.accessCount, 1, 'a recall is an access')
190
+ assert.ok(entry.lastAccessAt, 'and it is stamped, so the browser can show it')
191
+ assert.ok(memory.decayStrength(entry) > before, 'use slows the fade instead of being dead weight')
192
+
193
+ const touched = memory.touchAccessMany({ libraryId: binding.libraryId, ids: [id, 'nope'] })
194
+ assert.equal(touched, 1, 'unknown ids are skipped rather than thrown')
195
+ })
196
+
197
+ test('a subject change reaches the global layer without waiting out the cadence', async () => {
198
+ const cwd = path.join(home, 'proj-topic')
199
+ const sessionId = 'tone-topic'
200
+ state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
201
+ state.setSessionBinding(sessionId, { domainOverride: 'coding' })
202
+ const binding = state.getSessionBinding(sessionId)
203
+ seedGlobal('mem-global-rollback', {
204
+ title: 'Rollback drill',
205
+ content: 'The rollback drill runs on the last Friday of the month.',
206
+ })
207
+ const exec = execFor(sessionId, cwd)
208
+ const settings = { ...state.getSettings(), autoInject: 'smart', globalEveryNTurns: 6 }
209
+
210
+ // No history yet: turn 2 must stay on the cadence, otherwise every early turn
211
+ // drags cross-conversation memory in.
212
+ // Deliberately shares no vocabulary with the running history, but does match the
213
+ // global entry: retrieval is lexical, so a topic change can only reach memory it can name.
214
+ const coldQuery = 'rollback drill friday schedule'
215
+ const cold = await inject.buildRecall(binding, coldQuery, 2, settings, exec, sessionId)
216
+ assert.ok(!cold.ids.includes('mem-global-rollback'), 'a conversation with no history has no topic to change from')
217
+
218
+ state.setSessionBinding(sessionId, {
219
+ recentQueries: ['回滚演练怎么跑', '部署脚本放在哪个目录', 'staging 环境的数据库怎么连', '迁移顺序有没有文档'],
220
+ })
221
+ const warm = state.getSessionBinding(sessionId)
222
+ const switched = await inject.buildRecall(warm, coldQuery, 2, settings, exec, sessionId)
223
+ assert.ok(switched.ids.includes('mem-global-rollback'), 'an unrelated subject should hit the global layer now')
224
+
225
+ // The running sample is updated as turns go by, and it is a sample, not a transcript.
226
+ const after = state.getSessionBinding(sessionId)
227
+ assert.equal(after.recentQueries.length, 5)
228
+ assert.equal(after.recentQueries[4].text, coldQuery)
229
+ const onTopic = await inject.buildRecall(after, '回滚演练还是周五吗', 3, settings, exec, sessionId)
230
+ assert.ok(!onTopic.ids.includes('mem-global-rollback'), 'and staying on topic keeps the cadence instead')
231
+ })