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,720 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import test from 'node:test'
7
+ import { fileURLToPath } from 'node:url'
8
+ import {
9
+ CANDIDATE_KINDS as KINDS,
10
+ INSPECTION_DAYS,
11
+ REFUSALS,
12
+ RUNTIME_STATE,
13
+ SCHEMA,
14
+ analyzeBindings,
15
+ bindingFacts,
16
+ diagnoseBindings,
17
+ normalizeHost,
18
+ readBindingSnapshot,
19
+ } from '../lib/binding-diagnostics.js'
20
+
21
+ const NOW = Date.parse('2026-10-29T09:00:00.000Z')
22
+ const DAY = 24 * 60 * 60 * 1000
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
24
+ const iso = (at) => new Date(at).toISOString()
25
+
26
+ function binding(overrides = {}) {
27
+ return {
28
+ scopeKey: 'C:/work/alpha',
29
+ useLibrary: true,
30
+ useGlobal: true,
31
+ domain: 'coding',
32
+ domainOverride: 'rp',
33
+ injectedIds: [],
34
+ injectedAt: {},
35
+ lastInjectedTurn: 0,
36
+ reinjectPending: false,
37
+ activeCue: null,
38
+ ...overrides,
39
+ }
40
+ }
41
+
42
+ function stateWith(sessions, { libraries = {}, workspacePolicies = {} } = {}) {
43
+ return { version: 1, settings: {}, libraries, workspacePolicies, sessions }
44
+ }
45
+
46
+ function registry(sessions) {
47
+ return {
48
+ status: 'ok',
49
+ source: 'session-registry',
50
+ sessions: sessions.map(item => (typeof item === 'string' ? { id: item } : item)),
51
+ }
52
+ }
53
+
54
+ function kindsOf(analysis, sessionId) {
55
+ return analysis.candidates.filter(candidate => candidate.sessionId === sessionId).map(candidate => candidate.kind)
56
+ }
57
+
58
+ function tempRoot() {
59
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-binding-diag-'))
60
+ }
61
+
62
+ function runCli(args) {
63
+ return spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'diagnose-bindings.mjs'), ...args], {
64
+ cwd: ROOT,
65
+ encoding: 'utf8',
66
+ })
67
+ }
68
+
69
+ test('same sessionId reconnecting reuses exactly one binding', () => {
70
+ const state = stateWith({ s1: binding({ lastTurnAt: iso(NOW - 1000) }) })
71
+ const frozen = structuredClone(state)
72
+ const first = analyzeBindings(state, { now: NOW, host: registry(['s1']) })
73
+ const second = analyzeBindings(state, { now: NOW, host: registry(['s1']) })
74
+ assert.equal(first.summary.bindings, 1)
75
+ assert.deepEqual(first.bindings.map(item => item.sessionId), ['s1'])
76
+ assert.deepEqual(first.bindings, second.bindings)
77
+ assert.deepEqual(first.candidates, second.candidates)
78
+ assert.deepEqual(state, frozen)
79
+ })
80
+
81
+ test('an agent context replacement is a runtime listener, not a binding', () => {
82
+ const state = stateWith({ s1: binding({ lastTurnAt: iso(NOW - 1000) }) })
83
+ const base = { now: NOW, host: registry(['s1']) }
84
+ const beforeSwap = analyzeBindings(state, { ...base, runtime: { source: 'plugin-process', presetAttachments: 0 } })
85
+ const afterSwap = analyzeBindings(state, { ...base, runtime: { source: 'plugin-process', presetAttachments: 1 } })
86
+ assert.deepEqual(beforeSwap.bindings, afterSwap.bindings)
87
+ assert.deepEqual(beforeSwap.candidates, afterSwap.candidates)
88
+ assert.equal(beforeSwap.summary.runtime.runtimeListeners, 0)
89
+ assert.equal(afterSwap.summary.runtime.runtimeListeners, 1)
90
+ const listener = RUNTIME_STATE.find(item => item.name === 'attachments')
91
+ assert.equal(listener.layer, 'runtime-listener')
92
+ assert.equal(listener.file, 'lib/preset-scope.js:19')
93
+ const blind = analyzeBindings(state, base)
94
+ assert.equal(blind.summary.runtime, null)
95
+ assert.ok(blind.unknowns.some(item => item.code === 'runtime-layers-not-inspectable'))
96
+ })
97
+
98
+ test('two sessions that share a title stay separate and unmergeable', () => {
99
+ const libraries = {
100
+ 'lib-a': { id: 'lib-a', scopeKey: 'C:/work/a' },
101
+ 'lib-b': { id: 'lib-b', scopeKey: 'C:/work/b' },
102
+ }
103
+ const state = stateWith({
104
+ s1: binding({ libraryId: 'lib-a', scopeKey: 'C:/work/a', lastTurnAt: iso(NOW - 1000) }),
105
+ s2: binding({ libraryId: 'lib-b', scopeKey: 'C:/work/b', lastTurnAt: iso(NOW - 2000) }),
106
+ }, { libraries })
107
+ const host = registry([
108
+ { id: 's1', title: 'night flight' },
109
+ { id: 's2', title: 'night flight' },
110
+ { id: 's3', title: 'solo' },
111
+ ])
112
+ const analysis = analyzeBindings(state, { now: NOW, host })
113
+ assert.equal(analysis.sameTitleGroups.length, 1)
114
+ const [group] = analysis.sameTitleGroups
115
+ assert.deepEqual(group.sessionIds, ['s1', 's2'])
116
+ assert.equal(group.mergeable, false)
117
+ assert.equal(group.identity, 'sessionId')
118
+ assert.equal(group.bound, 2)
119
+ assert.equal(group.titleLength, 'night flight'.length)
120
+ assert.deepEqual(analysis.bindings.map(item => item.library.id), ['lib-a', 'lib-b'])
121
+ assert.equal(JSON.stringify(analysis).includes('night flight'), false)
122
+ assert.equal(analysis.candidates.length, 0)
123
+ })
124
+
125
+ test('a preset that disappears and returns never duplicates the binding', () => {
126
+ const state = stateWith({ s1: binding({
127
+ lastTurnAt: iso(NOW - 1000),
128
+ inbound: { pending: [{ key: 'h1', text: 'hello' }], seen: [], claimed: [], clock: 1, retryAt: null },
129
+ }) })
130
+ const present = registry([{ id: 's1', title: 'kept', agentPreset: 'p1' }])
131
+ const gone = { status: 'ok', source: 'agents', sessions: [] }
132
+ const first = analyzeBindings(state, { now: NOW, host: present })
133
+ const missing = analyzeBindings(state, { now: NOW, host: gone })
134
+ const again = analyzeBindings(state, { now: NOW, host: present })
135
+ assert.equal(first.summary.bindings, 1)
136
+ assert.equal(missing.summary.bindings, 1)
137
+ assert.equal(first.summary.host.complete, true)
138
+ assert.equal(missing.summary.host.complete, false)
139
+ assert.equal(first.summary.inbound.pendingMessages, 1)
140
+ assert.equal(missing.summary.inbound.pendingMessages, 1)
141
+ assert.equal(again.summary.inbound.pendingMessages, 1)
142
+ assert.equal(missing.candidates.filter(candidate => candidate.kind === KINDS.HOST_MISSING).length, 0)
143
+ assert.ok(missing.unknowns.some(item => item.code === 'host-partial'))
144
+ assert.deepEqual(missing.candidates, first.candidates)
145
+ assert.deepEqual(again.bindings, first.bindings)
146
+ assert.deepEqual(again.candidates, first.candidates)
147
+ })
148
+
149
+ test('a deleted session keeps its scope and queue for a later restore', () => {
150
+ const libraries = { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } }
151
+ const shared = {
152
+ libraryId: 'lib-1',
153
+ scopeKey: 'C:/work/alpha',
154
+ memoryContext: { channelId: 'c1', characterId: 'ch1' },
155
+ lastTurnAt: iso(NOW - 60_000),
156
+ inbound: { pending: [{ key: 'h1', text: 'still here' }], seen: ['a'], claimed: [], clock: 4, retryAt: null },
157
+ }
158
+ const deleted = stateWith({ s1: binding({ ...shared, sessionGone: true, sessionGoneAt: iso(NOW - 10_000) }) }, { libraries })
159
+ const restored = stateWith({ s1: binding({ ...shared, sessionGone: false, sessionGoneAt: null }) }, { libraries })
160
+ const before = analyzeBindings(deleted, { now: NOW })
161
+ const after = analyzeBindings(restored, { now: NOW })
162
+ assert.deepEqual(kindsOf(before, 's1'), [KINDS.SESSION_GONE, KINDS.PENDING_INBOUND])
163
+ assert.equal(before.summary.inbound.pendingMessages, 1)
164
+ assert.equal(before.summary.unfinished.pendingSessions, 1)
165
+ assert.equal(before.bindings[0].library.id, 'lib-1')
166
+ assert.deepEqual(before.bindings[0].memoryContext.scopeKeys, ['channelId', 'characterId'])
167
+ assert.equal(kindsOf(after, 's1').includes(KINDS.SESSION_GONE), false)
168
+ assert.equal(after.summary.bindings, before.summary.bindings)
169
+ assert.equal(after.summary.inbound.pendingMessages, before.summary.inbound.pendingMessages)
170
+ assert.equal(after.bindings[0].library.id, before.bindings[0].library.id)
171
+ for (const candidate of before.candidates) {
172
+ assert.equal(candidate.inspectionOnly, true)
173
+ assert.equal(candidate.action, 'inspect')
174
+ }
175
+ })
176
+
177
+ test('a failed, partial or not-yet-loaded host query never becomes a deletion', () => {
178
+ const state = stateWith({ s1: binding({ lastTurnAt: iso(NOW - 1000) }) })
179
+ const cases = [
180
+ { label: 'absent', host: undefined, code: 'host-unavailable' },
181
+ { label: 'not-loaded', host: { status: 'not-loaded', source: 'session-registry', sessions: [] }, code: 'host-not-loaded' },
182
+ { label: 'active-agents', host: { status: 'ok', source: 'agents', sessions: [{ id: 'other' }] }, code: 'host-partial' },
183
+ { label: 'unknown-source', host: { status: 'ok', sessions: [{ id: 'other' }] }, code: 'host-partial' },
184
+ { label: 'empty-registry', host: { status: 'ok', source: 'session-registry', sessions: [] }, code: 'host-partial' },
185
+ ]
186
+ for (const item of cases) {
187
+ const analysis = analyzeBindings(state, { now: NOW, host: item.host })
188
+ assert.deepEqual(analysis.candidates.filter(candidate => candidate.kind === KINDS.HOST_MISSING), [], item.label)
189
+ assert.ok(analysis.unknowns.some(unknown => unknown.code === item.code), item.label + ' -> ' + item.code)
190
+ assert.equal(analysis.summary.host.complete, false, item.label)
191
+ assert.equal(analysis.summary.bindings, 1, item.label)
192
+ }
193
+ const authoritative = analyzeBindings(state, { now: NOW, host: registry([{ id: 'other' }]) })
194
+ assert.deepEqual(kindsOf(authoritative, 's1'), [KINDS.HOST_MISSING])
195
+ })
196
+
197
+ test('30 days is an inspection threshold, never a deletion trigger', () => {
198
+ const oldAnalysis = analyzeBindings(stateWith({ s1: binding({ lastTurnAt: iso(NOW - 31 * DAY) }) }), { now: NOW, host: registry(['s1']) })
199
+ const freshAnalysis = analyzeBindings(stateWith({ s2: binding({ lastTurnAt: iso(NOW - 29 * DAY) }) }), { now: NOW, host: registry(['s2']) })
200
+ assert.deepEqual(kindsOf(oldAnalysis, 's1'), [KINDS.IDLE_INSPECTION])
201
+ assert.deepEqual(kindsOf(freshAnalysis, 's2'), [])
202
+ const candidate = oldAnalysis.candidates[0]
203
+ assert.equal(candidate.evidence.thresholdDays, INSPECTION_DAYS)
204
+ assert.equal(candidate.evidence.idleDays, 31)
205
+ assert.equal(candidate.inspectionOnly, true)
206
+ const dir = tempRoot()
207
+ try {
208
+ const report = diagnoseBindings({ stateFile: path.join(dir, 'state.json'), now: NOW })
209
+ assert.equal(report.thresholds.idleIsDeletionTrigger, false)
210
+ assert.ok(report.refusals.includes('delete-binding'))
211
+ assert.ok(report.refusals.includes('gc'))
212
+ } finally {
213
+ fs.rmSync(dir, { recursive: true, force: true })
214
+ }
215
+ })
216
+
217
+ test('unfinished work is counted: queue, dedupe counters, retry, guide, switches', () => {
218
+ const state = stateWith({
219
+ s1: binding({
220
+ useLibrary: false,
221
+ useGlobal: false,
222
+ libraryId: null,
223
+ guideMessageAt: iso(NOW - 5000),
224
+ memoryContext: { channelId: 'c1', characterId: 'ch1', worldId: 'w1', branchId: 'b1' },
225
+ activeCue: { createdAt: iso(NOW - 60_000), text: 'cue' },
226
+ injectedIds: ['m1', 'm2'],
227
+ lastInjectedTurn: 7,
228
+ reinjectPending: true,
229
+ lastRecallError: 'engine unavailable',
230
+ inbound: {
231
+ pending: [{ key: 'k1', text: 'a' }, { key: 'k2', text: 'b' }],
232
+ seen: ['s1', 's2', 's3'],
233
+ claimed: ['c1'],
234
+ clock: 9,
235
+ retryAt: iso(NOW + 30_000),
236
+ },
237
+ sleepRetryAt: iso(NOW + 60_000),
238
+ sleepAttempts: 2,
239
+ lastSleepError: 'session gone',
240
+ }),
241
+ })
242
+ const analysis = analyzeBindings(state, { now: NOW, host: registry(['s1']) })
243
+ const kinds = kindsOf(analysis, 's1')
244
+ for (const kind of [KINDS.PENDING_INBOUND, KINDS.SLEEP_RETRY, KINDS.FAILED_RECALL, KINDS.REINJECT_PENDING]) {
245
+ assert.ok(kinds.includes(kind), kind)
246
+ }
247
+ const summary = analysis.summary
248
+ assert.equal(summary.inbound.sessionsWithPending, 1)
249
+ assert.equal(summary.inbound.pendingMessages, 2)
250
+ assert.equal(summary.inbound.seenTotal, 3)
251
+ assert.equal(summary.inbound.claimedTotal, 1)
252
+ assert.equal(summary.inbound.retrying, 1)
253
+ assert.equal(summary.memoryContext.present, 1)
254
+ assert.equal(summary.memoryContext.scoped, 1)
255
+ assert.deepEqual(analysis.bindings[0].memoryContext.scopeKeys, ['channelId', 'characterId', 'worldId', 'branchId'])
256
+ assert.equal(summary.guide.sent, 1)
257
+ assert.equal(summary.switches.bothDisabled, 1)
258
+ assert.equal(summary.sleep.retryScheduled, 1)
259
+ assert.equal(summary.sleep.failed, 1)
260
+ assert.equal(summary.unfinished.reinjectPending, 1)
261
+ assert.equal(analysis.bindings[0].injection.cueLive, true)
262
+ assert.equal(kinds.includes(KINDS.UNBOUND_ENABLED), false)
263
+ const facts = bindingFacts('s1', state.sessions.s1, { now: NOW })
264
+ assert.equal('detail' in facts, false)
265
+ })
266
+
267
+ test('a folder policy is a ceiling on both switches', () => {
268
+ const policies = { 'C:/work/alpha': { useLibrary: false, useGlobal: true } }
269
+ const libraries = { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } }
270
+ const state = stateWith({ s1: binding({ libraryId: 'lib-1', scopeKey: 'C:/work/alpha' }) }, { workspacePolicies: policies, libraries })
271
+ const analysis = analyzeBindings(state, { now: NOW, host: registry(['s1']) })
272
+ const [facts] = analysis.bindings
273
+ assert.equal(facts.switches.useLibrary, true)
274
+ assert.equal(facts.effective.useLibrary, false)
275
+ assert.equal(facts.effective.useGlobal, true)
276
+ assert.equal(facts.folder.blocksLibrary, true)
277
+ assert.equal(facts.folder.blocksGlobal, false)
278
+ assert.equal(analysis.summary.switches.folderBlockedLibrary, 1)
279
+ assert.equal(analysis.summary.switches.folderBlockedGlobal, 0)
280
+ const unbound = analyzeBindings(stateWith({ s1: binding({ scopeKey: 'C:/work/alpha', libraryId: null }) }, { workspacePolicies: policies }), { now: NOW, host: registry(['s1']) })
281
+ assert.equal(kindsOf(unbound, 's1').includes(KINDS.UNBOUND_ENABLED), false)
282
+ const disabled = analyzeBindings(stateWith({ s1: binding({ scopeKey: 'C:/work/alpha', useLibrary: false, libraryId: null }) }), { now: NOW, host: registry(['s1']) })
283
+ assert.equal(disabled.bindings[0].effective.useLibrary, false)
284
+ assert.equal(disabled.summary.switches.libraryDisabled, 1)
285
+ })
286
+
287
+ test('a dangling library pointer is reported, never rewritten', () => {
288
+ const state = stateWith({ s1: binding({ libraryId: 'lib-gone', scopeKey: 'C:/work/alpha', lastTurnAt: iso(NOW - 1000) }) })
289
+ const missing = analyzeBindings(state, { now: NOW, host: registry(['s1']) })
290
+ assert.deepEqual(kindsOf(missing, 's1'), [KINDS.LIBRARY_MISSING])
291
+ assert.equal(missing.candidates[0].evidence.libraryId, 'lib-gone')
292
+ const restored = analyzeBindings(stateWith(
293
+ { s1: binding({ libraryId: 'lib-gone', scopeKey: 'C:/work/alpha', lastTurnAt: iso(NOW - 1000) }) },
294
+ { libraries: { 'lib-gone': { id: 'lib-gone', scopeKey: 'C:/work/alpha' } } },
295
+ ), { now: NOW, host: registry(['s1']) })
296
+ assert.deepEqual(kindsOf(restored, 's1'), [])
297
+ assert.equal(restored.bindings[0].library.sameScope, true)
298
+ })
299
+
300
+ test('an archived library is inspected, not unbound', () => {
301
+ const state = stateWith(
302
+ { s1: binding({ libraryId: 'lib-1', scopeKey: 'C:/work/alpha' }) },
303
+ { libraries: { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha', archived: true } } },
304
+ )
305
+ const analysis = analyzeBindings(state, { now: NOW, host: registry(['s1']) })
306
+ assert.deepEqual(kindsOf(analysis, 's1'), [KINDS.LIBRARY_ARCHIVED])
307
+ assert.equal(analysis.summary.libraries.archived, 1)
308
+ assert.equal(analysis.bindings[0].library.exists, true)
309
+ })
310
+
311
+ test('a state read is byte-for-byte read-only', () => {
312
+ const dir = tempRoot()
313
+ try {
314
+ const file = path.join(dir, 'state.json')
315
+ const state = stateWith(
316
+ { s1: binding({ inbound: { pending: [{ key: 'h1', text: 'hi' }], seen: [], claimed: [], clock: 3, retryAt: null }, memoryContext: { channelId: 'c1' } }) },
317
+ { libraries: { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } } },
318
+ )
319
+ fs.writeFileSync(file, JSON.stringify(state, null, 2))
320
+ const beforeStat = fs.statSync(file)
321
+ const beforeBytes = fs.readFileSync(file)
322
+ const report = diagnoseBindings({ stateFile: file, now: NOW, host: registry([{ id: 's1', title: 'kept' }]) })
323
+ const afterStat = fs.statSync(file)
324
+ const afterBytes = fs.readFileSync(file)
325
+ assert.equal(report.readOnlyProof.unchanged, true)
326
+ assert.equal(report.readOnlyProof.sha256Before, report.readOnlyProof.sha256After)
327
+ assert.equal(afterStat.mtimeMs, beforeStat.mtimeMs)
328
+ assert.deepEqual(afterBytes, beforeBytes)
329
+ assert.deepEqual(fs.readdirSync(dir), ['state.json'])
330
+ assert.equal(report.analysis.summary.bindings, 1)
331
+ } finally {
332
+ fs.rmSync(dir, { recursive: true, force: true })
333
+ }
334
+ })
335
+
336
+ test('a corrupt state file is reported and left in place', () => {
337
+ const dir = tempRoot()
338
+ try {
339
+ const file = path.join(dir, 'state.json')
340
+ const broken = '{"version":1,"sessions":'
341
+ fs.writeFileSync(file, broken)
342
+ const report = diagnoseBindings({ stateFile: file, now: NOW })
343
+ assert.equal(report.state.status, 'corrupt')
344
+ assert.equal(report.analysis, null)
345
+ assert.equal(report.unknowns[0].code, 'state-corrupt')
346
+ assert.equal(report.readOnlyProof.unchanged, true)
347
+ assert.deepEqual(fs.readdirSync(dir), ['state.json'])
348
+ assert.equal(fs.readFileSync(file, 'utf8'), broken)
349
+ } finally {
350
+ fs.rmSync(dir, { recursive: true, force: true })
351
+ }
352
+ })
353
+
354
+ test('a missing state file is not created', () => {
355
+ const dir = tempRoot()
356
+ try {
357
+ const file = path.join(dir, 'state.json')
358
+ const report = diagnoseBindings({ stateFile: file, now: NOW })
359
+ assert.equal(report.state.status, 'missing')
360
+ assert.equal(report.analysis, null)
361
+ assert.equal(report.unknowns[0].code, 'state-missing')
362
+ assert.deepEqual(fs.readdirSync(dir), [])
363
+ assert.equal(fs.existsSync(file), false)
364
+ } finally {
365
+ fs.rmSync(dir, { recursive: true, force: true })
366
+ }
367
+ })
368
+
369
+ test('a redacted report omits ids, titles and folder paths', () => {
370
+ const dir = tempRoot()
371
+ try {
372
+ const file = path.join(dir, 'state.json')
373
+ const state = stateWith({ 's1-secret-id': binding({ scopeKey: 'C:/Users/someone/private/project', lastTurnAt: iso(NOW - 1000) }) })
374
+ fs.writeFileSync(file, JSON.stringify(state))
375
+ const report = diagnoseBindings({
376
+ stateFile: file,
377
+ now: NOW,
378
+ redactIds: true,
379
+ host: registry([{ id: 's1-secret-id', title: 'my private chat' }]),
380
+ })
381
+ const text = JSON.stringify(report)
382
+ assert.equal(text.includes('s1-secret-id'), false)
383
+ assert.equal(text.includes('private/project'), false)
384
+ assert.equal(text.includes('my private chat'), false)
385
+ assert.match(report.analysis.bindings[0].sessionId, /^session-[0-9a-f]{12}$/)
386
+ } finally {
387
+ fs.rmSync(dir, { recursive: true, force: true })
388
+ }
389
+ })
390
+
391
+ test('the snapshot reader parses without repairing', () => {
392
+ const dir = tempRoot()
393
+ try {
394
+ const file = path.join(dir, 'state.json')
395
+ fs.writeFileSync(file, JSON.stringify(stateWith({ s1: binding() })))
396
+ const snapshot = readBindingSnapshot({ stateFile: file })
397
+ assert.equal(snapshot.ok, true)
398
+ assert.equal(snapshot.kind, 'ok')
399
+ assert.equal(snapshot.value.sessions.s1.useLibrary, true)
400
+ assert.equal(snapshot.bytes, fs.statSync(file).size)
401
+ assert.equal(snapshot.sha256.length, 64)
402
+ } finally {
403
+ fs.rmSync(dir, { recursive: true, force: true })
404
+ }
405
+ })
406
+
407
+ test('host normalization refuses to trust a live-agent list', () => {
408
+ const agents = normalizeHost({
409
+ status: 'ok',
410
+ source: 'agents',
411
+ sessions: [{ id: 'a', title: ' t ' }, { id: 'a', title: 'dup' }, { id: '' }, null],
412
+ })
413
+ assert.equal(agents.status, 'partial')
414
+ assert.equal(agents.complete, false)
415
+ assert.equal(agents.total, 1)
416
+ assert.equal(agents.duplicates, 1)
417
+ assert.equal(agents.sessions[0].title, 't')
418
+ assert.match(agents.reason, /live-agent/)
419
+ const absent = normalizeHost(undefined)
420
+ assert.equal(absent.status, 'unavailable')
421
+ assert.equal(absent.complete, false)
422
+ })
423
+
424
+ test('the contract pins a read-only role: no gc, no deletion', () => {
425
+ for (const action of ['gc', 'delete-binding', 'delete-session', 'delete-library', 'write-state']) {
426
+ assert.ok(REFUSALS.includes(action), action)
427
+ }
428
+ assert.equal(REFUSALS.includes('inspect'), false)
429
+ assert.equal(SCHEMA, 'dsh-memu-memory/binding-diagnostics@1')
430
+ for (const item of RUNTIME_STATE) {
431
+ assert.ok(item.layer)
432
+ assert.ok(item.name)
433
+ assert.match(item.file, /^lib\/.+:\d+$/)
434
+ }
435
+ assert.ok(RUNTIME_STATE.some(item => item.file === 'lib/preset-scope.js:19' && item.layer === 'runtime-listener'))
436
+ assert.ok(RUNTIME_STATE.some(item => item.file === 'lib/inbound.js:14'))
437
+ })
438
+
439
+ test('a file-level reconnect is idempotent and leaves the snapshot untouched', () => {
440
+ const dir = tempRoot()
441
+ try {
442
+ const file = path.join(dir, 'state.json')
443
+ const state = stateWith(
444
+ { s1: binding({ libraryId: 'lib-1', lastTurnAt: iso(NOW - 1000) }) },
445
+ { libraries: { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } } },
446
+ )
447
+ fs.writeFileSync(file, JSON.stringify(state, null, 2))
448
+ const beforeBytes = fs.readFileSync(file)
449
+ const beforeMtime = fs.statSync(file).mtimeMs
450
+
451
+ const first = diagnoseBindings({ stateFile: file, now: NOW, host: registry([{ id: 's1', title: 'kept' }]) })
452
+ const second = diagnoseBindings({ stateFile: file, now: NOW, host: registry([{ id: 's1', title: 'kept' }]) })
453
+
454
+ assert.equal(first.analysis.summary.bindings, 1)
455
+ assert.deepEqual(second.analysis.bindings, first.analysis.bindings)
456
+ assert.deepEqual(second.analysis.candidates, first.analysis.candidates)
457
+ assert.equal(first.readOnlyProof.unchanged, true)
458
+ assert.equal(second.readOnlyProof.unchanged, true)
459
+ assert.equal(second.readOnlyProof.sha256Before, first.readOnlyProof.sha256After)
460
+ assert.deepEqual(fs.readdirSync(dir), ['state.json'])
461
+ assert.deepEqual(fs.readFileSync(file), beforeBytes)
462
+ assert.equal(fs.statSync(file).mtimeMs, beforeMtime)
463
+ } finally {
464
+ fs.rmSync(dir, { recursive: true, force: true })
465
+ }
466
+ })
467
+
468
+ test('a context replacement changes only the runtime layer in a file report', () => {
469
+ const dir = tempRoot()
470
+ try {
471
+ const file = path.join(dir, 'state.json')
472
+ const state = stateWith(
473
+ { s1: binding({ libraryId: 'lib-1', lastTurnAt: iso(NOW - 1000) }) },
474
+ { libraries: { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } } },
475
+ )
476
+ fs.writeFileSync(file, JSON.stringify(state))
477
+
478
+ const beforeSwap = diagnoseBindings({
479
+ stateFile: file,
480
+ now: NOW,
481
+ host: registry(['s1']),
482
+ runtime: { source: 'plugin-process', presetAttachments: 0 },
483
+ })
484
+ const afterSwap = diagnoseBindings({
485
+ stateFile: file,
486
+ now: NOW,
487
+ host: registry(['s1']),
488
+ runtime: { source: 'plugin-process', presetAttachments: 1, inboundSessions: 1 },
489
+ })
490
+
491
+ assert.deepEqual(afterSwap.analysis.bindings, beforeSwap.analysis.bindings)
492
+ assert.deepEqual(afterSwap.analysis.candidates, beforeSwap.analysis.candidates)
493
+ assert.equal(beforeSwap.analysis.summary.runtime.runtimeListeners, 0)
494
+ assert.equal(afterSwap.analysis.summary.runtime.runtimeListeners, 1)
495
+ assert.equal(afterSwap.analysis.summary.runtime.temporaryMaps, 1)
496
+ assert.equal(afterSwap.analysis.summary.bindings, 1)
497
+ assert.equal(afterSwap.analysis.runtime.status, 'provided')
498
+ assert.equal(afterSwap.analysis.runtime.layers.length, 2)
499
+ assert.equal(beforeSwap.readOnlyProof.unchanged, true)
500
+ assert.equal(afterSwap.readOnlyProof.unchanged, true)
501
+ } finally {
502
+ fs.rmSync(dir, { recursive: true, force: true })
503
+ }
504
+ })
505
+
506
+ test('a shared title with different ids keeps one bound and one unbound session', () => {
507
+ const libraries = { 'lib-a': { id: 'lib-a', scopeKey: 'C:/work/a' } }
508
+ const state = stateWith({ s1: binding({ libraryId: 'lib-a', scopeKey: 'C:/work/a', lastTurnAt: iso(NOW - 1000) }) }, { libraries })
509
+ const host = registry([
510
+ { id: 's1', title: 'night flight' },
511
+ { id: 's2', title: 'night flight' },
512
+ ])
513
+ const analysis = analyzeBindings(state, { now: NOW, host })
514
+
515
+ assert.equal(analysis.summary.bindings, 1)
516
+ assert.deepEqual(analysis.bindings.map(item => item.sessionId), ['s1'])
517
+ assert.equal(analysis.summary.host.unboundHostSessions, 1)
518
+ assert.equal(analysis.sameTitleGroups.length, 1)
519
+ const [group] = analysis.sameTitleGroups
520
+ assert.deepEqual(group.sessionIds, ['s1', 's2'])
521
+ assert.equal(group.bound, 1)
522
+ assert.deepEqual(group.libraries, ['lib-a', null])
523
+ assert.equal(group.mergeable, false)
524
+ assert.equal(group.identity, 'sessionId')
525
+ assert.equal(analysis.candidates.length, 0)
526
+ assert.equal(JSON.stringify(analysis).includes('night flight'), false)
527
+ })
528
+
529
+ test('a full delete and restore cycle keeps one binding, its scope and its queue', () => {
530
+ const libraries = { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } }
531
+ const shared = {
532
+ libraryId: 'lib-1',
533
+ scopeKey: 'C:/work/alpha',
534
+ memoryContext: { channelId: 'c1', characterId: 'ch1' },
535
+ lastTurnAt: iso(NOW - 60_000),
536
+ inbound: { pending: [{ key: 'h1', text: 'still here' }], seen: ['a'], claimed: [], clock: 4, retryAt: null },
537
+ }
538
+ const liveState = stateWith({ s1: binding(shared) }, { libraries })
539
+ const deletedState = stateWith({ s1: binding({ ...shared, sessionGone: true, sessionGoneAt: iso(NOW - 10_000) }) }, { libraries })
540
+ const restoredState = stateWith({ s1: binding({ ...shared, sessionGone: false, sessionGoneAt: null }) }, { libraries })
541
+
542
+ const live = analyzeBindings(liveState, { now: NOW, host: registry(['s1']) })
543
+ const deleted = analyzeBindings(deletedState, { now: NOW, host: registry([{ id: 'other' }]) })
544
+ const restored = analyzeBindings(restoredState, { now: NOW, host: registry(['s1']) })
545
+
546
+ assert.deepEqual(kindsOf(live, 's1'), [KINDS.PENDING_INBOUND])
547
+ assert.deepEqual(kindsOf(deleted, 's1'), [KINDS.SESSION_GONE, KINDS.HOST_MISSING, KINDS.PENDING_INBOUND])
548
+ assert.deepEqual(kindsOf(restored, 's1'), [KINDS.PENDING_INBOUND])
549
+ for (const analysis of [live, deleted, restored]) {
550
+ assert.equal(analysis.summary.bindings, 1)
551
+ assert.equal(analysis.summary.inbound.pendingMessages, 1)
552
+ assert.equal(analysis.summary.unfinished.pendingSessions, 1)
553
+ assert.equal(analysis.bindings[0].library.id, 'lib-1')
554
+ assert.deepEqual(analysis.bindings[0].memoryContext.scopeKeys, ['channelId', 'characterId'])
555
+ }
556
+ assert.equal(restored.bindings[0].sleep.sessionGone, false)
557
+ for (const candidate of deleted.candidates) {
558
+ assert.equal(candidate.inspectionOnly, true)
559
+ assert.equal(candidate.action, 'inspect')
560
+ }
561
+ })
562
+
563
+ test('persistent, runtime-listener and temporary-map counts never mix', () => {
564
+ const state = stateWith({ s1: binding({ lastTurnAt: iso(NOW - 1000) }) })
565
+ const base = { now: NOW, host: registry(['s1']) }
566
+ const persistent = analyzeBindings(state, base)
567
+ const withRuntime = analyzeBindings(state, {
568
+ ...base,
569
+ runtime: {
570
+ presetAttachments: 2,
571
+ inboundMediaJobs: 3,
572
+ inboundCalls: 4,
573
+ inboundSessions: 5,
574
+ mediaCaptures: 6,
575
+ privateInFlight: 7,
576
+ curatorJobs: 8,
577
+ },
578
+ })
579
+
580
+ assert.deepEqual(withRuntime.bindings, persistent.bindings)
581
+ assert.deepEqual(withRuntime.candidates, persistent.candidates)
582
+ assert.equal(persistent.summary.runtime, null)
583
+ assert.deepEqual(withRuntime.summary.runtime, { runtimeListeners: 2, temporaryMaps: 33 })
584
+ assert.equal(withRuntime.summary.bindings, 1)
585
+ assert.equal(withRuntime.summary.candidates.total, 0)
586
+ assert.equal('runtime' in withRuntime.bindings[0], false)
587
+ assert.equal(RUNTIME_STATE.filter(item => item.layer === 'runtime-listener').length, 1)
588
+ assert.equal(RUNTIME_STATE.filter(item => item.layer === 'temporary-map').length, 6)
589
+ assert.equal(RUNTIME_STATE.filter(item => item.layer === 'cache').length, 2)
590
+
591
+ // The declared file:line positions are part of the diagnostic contract. If a source
592
+ // edit moves a map without updating RUNTIME_STATE, this guard fails instead of silently
593
+ // printing a stale map location.
594
+ for (const item of RUNTIME_STATE) {
595
+ const match = /^(lib\/[^:]+):(\d+)$/.exec(item.file)
596
+ assert.ok(match, item.file)
597
+ const source = fs.readFileSync(path.join(ROOT, match[1]), 'utf8').split(/\r?\n/)
598
+ const line = source[Number(match[2]) - 1]
599
+ assert.ok(line && line.includes(item.name), item.file + ' should declare ' + item.name)
600
+ }
601
+ })
602
+
603
+ test('a failed host query keeps the queue, scope and library pointer', () => {
604
+ const libraries = { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } }
605
+ const state = stateWith({
606
+ s1: binding({
607
+ libraryId: 'lib-1',
608
+ scopeKey: 'C:/work/alpha',
609
+ memoryContext: { channelId: 'c1', characterId: 'ch1' },
610
+ lastTurnAt: iso(NOW - 1000),
611
+ inbound: { pending: [{ key: 'h1', text: 'hello' }], seen: [], claimed: [], clock: 2, retryAt: null },
612
+ }),
613
+ }, { libraries })
614
+ const frozen = structuredClone(state)
615
+ const cases = [
616
+ { label: 'absent', host: undefined, code: 'host-unavailable' },
617
+ { label: 'not-loaded', host: { status: 'not-loaded', source: 'session-registry', sessions: [] }, code: 'host-not-loaded' },
618
+ { label: 'partial', host: { status: 'partial', source: 'session-registry', reason: 'query failed', sessions: [] }, code: 'host-partial' },
619
+ { label: 'active-agents', host: { status: 'ok', source: 'agents', sessions: [] }, code: 'host-partial' },
620
+ ]
621
+ for (const item of cases) {
622
+ const analysis = analyzeBindings(state, { now: NOW, host: item.host })
623
+ assert.equal(analysis.summary.bindings, 1, item.label)
624
+ assert.equal(analysis.bindings[0].library.id, 'lib-1', item.label)
625
+ assert.deepEqual(analysis.bindings[0].memoryContext.scopeKeys, ['channelId', 'characterId'], item.label)
626
+ assert.equal(analysis.summary.inbound.pendingMessages, 1, item.label)
627
+ assert.equal(analysis.summary.unfinished.pendingSessions, 1, item.label)
628
+ assert.ok(analysis.unknowns.some(unknown => unknown.code === item.code), item.label + ' -> ' + item.code)
629
+ assert.equal(analysis.candidates.filter(candidate => candidate.kind === KINDS.HOST_MISSING).length, 0, item.label)
630
+ assert.deepEqual(state, frozen, item.label)
631
+ }
632
+ })
633
+
634
+ test('exactly 30 days is not reported; one millisecond over is', () => {
635
+ const libraries = { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } }
636
+ const exactly = analyzeBindings(
637
+ stateWith({ s1: binding({ libraryId: 'lib-1', lastTurnAt: iso(NOW - 30 * DAY) }) }, { libraries }),
638
+ { now: NOW, host: registry(['s1']) },
639
+ )
640
+ assert.equal(exactly.bindings[0].activity.idleMs, 30 * DAY)
641
+ assert.equal(exactly.bindings[0].activity.idleDays, 30)
642
+ assert.deepEqual(kindsOf(exactly, 's1'), [])
643
+
644
+ const justOver = analyzeBindings(
645
+ stateWith({ s1: binding({ libraryId: 'lib-1', lastTurnAt: iso(NOW - 30 * DAY - 1) }) }, { libraries }),
646
+ { now: NOW, host: registry(['s1']) },
647
+ )
648
+ assert.equal(justOver.bindings[0].activity.idleMs, 30 * DAY + 1)
649
+ assert.deepEqual(kindsOf(justOver, 's1'), [KINDS.IDLE_INSPECTION])
650
+ assert.equal(justOver.candidates[0].evidence.thresholdDays, INSPECTION_DAYS)
651
+ assert.equal(justOver.candidates[0].evidence.idleMs, 30 * DAY + 1)
652
+ })
653
+
654
+ test('the CLI returns 0/1/2/3 and never creates a directory or writes state', () => {
655
+ const dir = tempRoot()
656
+ try {
657
+ const cleanFile = path.join(dir, 'clean.json')
658
+ const cleanState = stateWith(
659
+ { s1: binding({ libraryId: 'lib-1', lastTurnAt: iso(NOW - 1000) }) },
660
+ { libraries: { 'lib-1': { id: 'lib-1', scopeKey: 'C:/work/alpha' } } },
661
+ )
662
+ fs.writeFileSync(cleanFile, JSON.stringify(cleanState))
663
+ const cleanBytes = fs.readFileSync(cleanFile)
664
+ const cleanMtime = fs.statSync(cleanFile).mtimeMs
665
+
666
+ const clean = runCli(['--state', cleanFile, '--now', iso(NOW)])
667
+ assert.equal(clean.status, 0, clean.stderr)
668
+ assert.match(clean.stdout, /bindings: 1/)
669
+ assert.match(clean.stdout, /read-only proof: bytes and mtime identical/)
670
+ assert.match(clean.stdout, /runtime layers not readable from this process: 9 declared \(unknown\)/)
671
+
672
+ const candidateFile = path.join(dir, 'candidate.json')
673
+ const candidateState = stateWith({
674
+ s1: binding({
675
+ lastTurnAt: iso(NOW - 1000),
676
+ inbound: { pending: [{ key: 'h1', text: 'hello' }], seen: [], claimed: [], clock: 1, retryAt: null },
677
+ }),
678
+ })
679
+ fs.writeFileSync(candidateFile, JSON.stringify(candidateState))
680
+ const candidateBytes = fs.readFileSync(candidateFile)
681
+ const candidateMtime = fs.statSync(candidateFile).mtimeMs
682
+
683
+ const candidate = runCli(['--state', candidateFile, '--now', iso(NOW)])
684
+ assert.equal(candidate.status, 1, candidate.stderr)
685
+ assert.match(candidate.stdout, /pending-inbound/)
686
+ assert.match(candidate.stdout, /inspection candidates/)
687
+
688
+ const candidateJson = runCli(['--state', candidateFile, '--now', iso(NOW), '--json'])
689
+ assert.equal(candidateJson.status, 1, candidateJson.stderr)
690
+ const parsed = JSON.parse(candidateJson.stdout)
691
+ assert.equal(parsed.analysis.summary.candidates.total, 1)
692
+
693
+ for (const args of [['--nope'], ['--now', 'not-a-date']]) {
694
+ const usage = runCli(args)
695
+ assert.equal(usage.status, 2, args.join(' '))
696
+ assert.match(usage.stderr, /error:/)
697
+ }
698
+ const help = runCli(['--help'])
699
+ assert.equal(help.status, 0)
700
+
701
+ const missingDir = path.join(dir, 'nested')
702
+ const missing = runCli(['--state', path.join(missingDir, 'state.json'), '--now', iso(NOW)])
703
+ assert.equal(missing.status, 3, missing.stderr)
704
+ assert.equal(fs.existsSync(missingDir), false)
705
+
706
+ assert.deepEqual(fs.readdirSync(dir).sort(), ['candidate.json', 'clean.json'])
707
+ assert.deepEqual(fs.readFileSync(cleanFile), cleanBytes)
708
+ assert.equal(fs.statSync(cleanFile).mtimeMs, cleanMtime)
709
+ assert.deepEqual(fs.readFileSync(candidateFile), candidateBytes)
710
+ assert.equal(fs.statSync(candidateFile).mtimeMs, candidateMtime)
711
+ } finally {
712
+ fs.rmSync(dir, { recursive: true, force: true })
713
+ }
714
+ })
715
+
716
+ test('analysis rejects a state that is not an object', () => {
717
+ for (const value of [null, [], 'x', 3]) {
718
+ assert.throws(() => analyzeBindings(value), /parsed state object/)
719
+ }
720
+ })