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,635 @@
1
+ // Read-only binding lifecycle diagnostics (W9).
2
+ //
3
+ // A conversation's memory state lives in three layers, and only the first is on disk:
4
+ // 1. persistent binding - state.json sessions[sessionId]; survives restarts, forks and
5
+ // a plugin reload.
6
+ // 2. runtime listeners - preset-scope attachments keyed by an *agent context object*;
7
+ // they exist only inside the running plugin process and die with that context.
8
+ // 3. temporary maps - inbound per-session maps, in-flight guards, caches; cleared
9
+ // by session/disposed, turn/end or a successful pass.
10
+ //
11
+ // This module reads layer 1 through a snapshot it never writes or repairs, names layers
12
+ // 2 and 3 as unobservable from another process instead of guessing, and refuses every
13
+ // action that would delete, unbind or create anything. A candidate is a prompt to look,
14
+ // never a deletion queue. See docs/binding-lifecycle.md.
15
+
16
+ import crypto from 'node:crypto'
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import { dataDir } from './paths.js'
20
+
21
+ export const SCHEMA = 'dsh-memu-memory/binding-diagnostics@1'
22
+ // Only an inspection threshold. It never becomes an age at which something is dropped.
23
+ export const INSPECTION_DAYS = 30
24
+ const DAY_MS = 24 * 60 * 60 * 1000
25
+ const SCOPE_KEYS = ['channelId', 'characterId', 'worldId', 'branchId']
26
+
27
+ // Every report repeats what it refuses to do, so a count of candidates can never be
28
+ // mistaken for a plan to delete them.
29
+ export const REFUSALS = Object.freeze([
30
+ 'delete-session', 'delete-binding', 'unbind-session', 'delete-library',
31
+ 'delete-memory', 'create-library', 'write-state', 'rename-state',
32
+ 'gc', 'mark-injected', 'flush',
33
+ ])
34
+
35
+ // Inspection buckets. None of them is an action.
36
+ export const CANDIDATE_KINDS = Object.freeze({
37
+ SESSION_GONE: 'session-gone',
38
+ HOST_MISSING: 'host-session-missing',
39
+ LIBRARY_MISSING: 'library-missing',
40
+ LIBRARY_ARCHIVED: 'library-archived',
41
+ UNBOUND_ENABLED: 'library-enabled-but-unbound',
42
+ PENDING_INBOUND: 'pending-inbound',
43
+ SLEEP_RETRY: 'sleep-retry',
44
+ FAILED_RECALL: 'recall-degraded',
45
+ REINJECT_PENDING: 'reinject-pending',
46
+ IDLE_INSPECTION: 'idle-inspection',
47
+ })
48
+
49
+ // Runtime layers a second process cannot read. Naming them keeps "zero candidates" from
50
+ // meaning "nothing to see": these are precisely the states an in-process bug would leak.
51
+ export const RUNTIME_STATE = Object.freeze([
52
+ { layer: 'runtime-listener', file: 'lib/preset-scope.js:19', name: 'attachments', keyedBy: 'agent context object', clearedBy: 'the next attach pass when the context disappears; registerPresetAttachment disposer on unload' },
53
+ { layer: 'temporary-map', file: 'lib/inbound.js:12', name: 'mediaJobs', keyedBy: 'sessionId', clearedBy: 'settled capture promise' },
54
+ { layer: 'temporary-map', file: 'lib/inbound.js:13', name: 'calls', keyedBy: 'sessionId', clearedBy: 'tool/result or turn/end' },
55
+ { layer: 'temporary-map', file: 'lib/inbound.js:14', name: 'sessions', keyedBy: 'sessionId', clearedBy: 'session/disposed (releaseInboundSession)' },
56
+ { layer: 'temporary-map', file: 'lib/media.js:19', name: 'captures', keyedBy: 'sessionId', clearedBy: 'capture settle' },
57
+ { layer: 'temporary-map', file: 'lib/private.js:267', name: 'inFlight', keyedBy: 'sessionId', clearedBy: 'consolidation settle' },
58
+ { layer: 'temporary-map', file: 'lib/curator.js:13', name: 'jobs', keyedBy: 'job id', clearedBy: 'curation settle' },
59
+ { layer: 'cache', file: 'lib/state.js:967', name: 'indexCache', keyedBy: 'libraryId', clearedBy: 'next load / external write' },
60
+ { layer: 'cache', file: 'lib/paths.js:52', name: 'readFailures', keyedBy: 'absolute file', clearedBy: 'a later successful read' },
61
+ ])
62
+
63
+ const UNKNOWN = Object.freeze({
64
+ HOST_UNAVAILABLE: 'host-unavailable',
65
+ HOST_NOT_LOADED: 'host-not-loaded',
66
+ HOST_PARTIAL: 'host-partial',
67
+ HOST_SOURCE_UNTRUSTED: 'host-source-untrusted',
68
+ HOST_LIST_EMPTY: 'host-list-empty',
69
+ HOST_DUPLICATE_IDS: 'host-duplicate-session-ids',
70
+ RUNTIME_NOT_INSPECTABLE: 'runtime-layers-not-inspectable',
71
+ STATE_MISSING: 'state-missing',
72
+ STATE_CORRUPT: 'state-corrupt',
73
+ STATE_UNREADABLE: 'state-unreadable',
74
+ IDLE_BASIS_MISSING: 'idle-basis-missing',
75
+ BINDINGS_NOT_OBJECT: 'bindings-not-an-object',
76
+ UNBOUND_BASIS_UNKNOWN: 'unbound-basis-unknown',
77
+ })
78
+ export const UNKNOWN_CODES = Object.freeze(UNKNOWN)
79
+
80
+ function isObject(value) {
81
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
82
+ }
83
+
84
+ function numberOrNull(value) {
85
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
86
+ }
87
+
88
+ function timeOf(value) {
89
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
90
+ const parsed = Date.parse(String(value || ''))
91
+ return Number.isFinite(parsed) ? parsed : null
92
+ }
93
+
94
+ function isoAt(value) {
95
+ const at = timeOf(value)
96
+ return at === null ? null : new Date(at).toISOString()
97
+ }
98
+
99
+ function hash(value) {
100
+ return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 12)
101
+ }
102
+
103
+ function clip(value, max = 160) {
104
+ const text = String(value === undefined || value === null ? '' : value)
105
+ return text.length > max ? text.slice(0, max) + '...' : text
106
+ }
107
+
108
+ // "The switch is on but there is no library" is NOT by itself a fault. lib/inject.js runs
109
+ // ensureSession(..., { createLibrary: false }) on every turn, and lib/state.js only adopts a
110
+ // library the folder already has; a conversation in a folder without one therefore lives with
111
+ // useLibrary: true and libraryId: null for its whole life. That is the documented
112
+ // deferred-creation shape, not a lost pointer. The only snapshot evidence that a pointer once
113
+ // existed is a completed injection pass: markInjected() runs only after recall, and recall
114
+ // needs somewhere to recall from. Without that evidence the report says so as an unknown
115
+ // instead of filing a candidate — a candidate count is a to-do list, and inflating it is how a
116
+ // diagnostic stops being read.
117
+ export function unboundEvidence(facts) {
118
+ const priorInjection = Boolean(facts && facts.injection && (facts.injection.injected > 0 || facts.injection.lastTurn > 0))
119
+ return {
120
+ priorInjection,
121
+ basis: priorInjection
122
+ ? 'a completed injection pass is recorded, so this conversation had a library and the pointer is gone now'
123
+ : 'no injection has completed, so this is indistinguishable from a deliberately deferred library creation (createLibrary: false)',
124
+ }
125
+ }
126
+
127
+ export function defaultStateFile() {
128
+ // Mirrors lib/state.js STATE_FILE without loading that module: loadState() rewrites a
129
+ // missing file and quarantines a corrupt one, and a diagnostic must do neither.
130
+ return path.join(dataDir(), 'state.json')
131
+ }
132
+
133
+ /**
134
+ * Read state.json once, byte for byte, with no interpretation beyond JSON.parse.
135
+ *
136
+ * Deliberately not readJson()/loadState(): those repair the file they read (quarantine a
137
+ * corrupt one, recreate a missing one), which is exactly what a read-only report must
138
+ * not do. The sha256 and mtime are returned so a caller can prove the file did not move.
139
+ */
140
+ export function readBindingSnapshot({ stateFile = defaultStateFile() } = {}) {
141
+ const file = stateFile
142
+ let buffer
143
+ try {
144
+ buffer = fs.readFileSync(file)
145
+ } catch (error) {
146
+ const missing = error && error.code === 'ENOENT'
147
+ return {
148
+ ok: false, kind: missing ? 'missing' : 'unreadable', missing, file,
149
+ bytes: null, mtimeMs: null, sha256: null,
150
+ error: String((error && (error.code || error.message)) || error),
151
+ }
152
+ }
153
+ let stat = null
154
+ try { stat = fs.statSync(file) } catch {}
155
+ const base = {
156
+ file,
157
+ bytes: buffer.length,
158
+ mtimeMs: stat ? stat.mtimeMs : null,
159
+ sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
160
+ }
161
+ try {
162
+ return { ok: true, kind: 'ok', missing: false, value: JSON.parse(buffer.toString('utf8')), ...base }
163
+ } catch (error) {
164
+ return { ok: false, kind: 'corrupt', missing: false, error: 'invalid JSON', detail: clip(error && error.message, 200), ...base }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The facts a binding carries, without any memory text, folder path or session title.
170
+ * Paths and titles are user data; they appear as hashes or not at all.
171
+ */
172
+ export function bindingFacts(sessionId, binding, { libraries = {}, policies = {}, now = Date.now(), includeDetails = false } = {}) {
173
+ const b = isObject(binding) ? binding : {}
174
+ const scopeKey = typeof b.scopeKey === 'string' ? b.scopeKey : null
175
+ const folder = { useLibrary: true, useGlobal: true, ...(scopeKey && isObject(policies[scopeKey]) ? policies[scopeKey] : {}) }
176
+ const wantsLibrary = b.useLibrary !== false
177
+ const wantsGlobal = b.useGlobal !== false
178
+ const useLibrary = folder.useLibrary !== false && wantsLibrary
179
+ const useGlobal = folder.useGlobal !== false && wantsGlobal
180
+ const libraryId = typeof b.libraryId === 'string' && b.libraryId ? b.libraryId : null
181
+ const library = libraryId && isObject(libraries[libraryId]) ? libraries[libraryId] : null
182
+ const inbound = isObject(b.inbound) ? b.inbound : {}
183
+ const pending = Array.isArray(inbound.pending) ? inbound.pending : []
184
+ const retryAt = timeOf(inbound.retryAt)
185
+ let pendingBytes = null
186
+ try { pendingBytes = Buffer.byteLength(JSON.stringify(pending)) } catch {}
187
+ const context = isObject(b.memoryContext) ? b.memoryContext : null
188
+ const lastTurnAt = timeOf(b.lastTurnAt)
189
+ const lastInboundAt = timeOf(b.lastInboundAt)
190
+ const lastAgentIdleAt = timeOf(b.lastAgentIdleAt)
191
+ const lastActivityAt = Math.max(lastTurnAt || 0, lastInboundAt || 0, lastAgentIdleAt || 0) || null
192
+ const idleMs = lastActivityAt ? Math.max(0, now - lastActivityAt) : null
193
+ const sleepRetryAt = timeOf(b.sleepRetryAt)
194
+ const cueAt = timeOf(b.activeCue && b.activeCue.createdAt)
195
+ const injectedIds = Array.isArray(b.injectedIds) ? b.injectedIds : []
196
+ return {
197
+ sessionId,
198
+ switches: { useLibrary: wantsLibrary, useGlobal: wantsGlobal },
199
+ effective: { useLibrary, useGlobal, enabled: useLibrary || useGlobal },
200
+ folder: {
201
+ scopeKeyHash: scopeKey ? hash(scopeKey) : null,
202
+ blocksLibrary: folder.useLibrary === false,
203
+ blocksGlobal: folder.useGlobal === false,
204
+ },
205
+ domain: {
206
+ detected: typeof b.domain === 'string' ? b.domain : null,
207
+ override: typeof b.domainOverride === 'string' ? b.domainOverride : null,
208
+ },
209
+ library: {
210
+ id: libraryId,
211
+ exists: Boolean(library),
212
+ archived: Boolean(library && library.archived === true),
213
+ sameScope: Boolean(library && scopeKey && library.scopeKey === scopeKey),
214
+ },
215
+ memoryContext: {
216
+ present: Boolean(context),
217
+ scopeKeys: context ? SCOPE_KEYS.filter(key => typeof context[key] === 'string' && context[key]) : [],
218
+ },
219
+ guide: {
220
+ sent: Boolean(b.guideMessageAt),
221
+ sentAt: isoAt(b.guideMessageAt),
222
+ rulesKey: typeof b.guideRulesKey === 'string' ? b.guideRulesKey : null,
223
+ lost: b.guideLost === true,
224
+ },
225
+ inbound: {
226
+ pending: pending.length,
227
+ pendingBytes,
228
+ retrying: Boolean(retryAt && retryAt > now),
229
+ retryAt: isoAt(retryAt),
230
+ seen: Array.isArray(inbound.seen) ? inbound.seen.length : 0,
231
+ claimed: Array.isArray(inbound.claimed) ? inbound.claimed.length : 0,
232
+ clock: numberOrNull(inbound.clock),
233
+ hasError: typeof b.lastInboundError === 'string' && b.lastInboundError.length > 0,
234
+ },
235
+ sleep: {
236
+ lastSleepAt: isoAt(b.lastSleepAt),
237
+ retryAt: isoAt(sleepRetryAt),
238
+ retryDue: Boolean(sleepRetryAt && sleepRetryAt <= now),
239
+ attempts: numberOrNull(b.sleepAttempts) || 0,
240
+ hasError: typeof b.lastSleepError === 'string' && b.lastSleepError.length > 0,
241
+ sessionGone: b.sessionGone === true,
242
+ sessionGoneAt: isoAt(b.sessionGoneAt),
243
+ },
244
+ recall: {
245
+ degraded: typeof b.lastRecallError === 'string' && b.lastRecallError.length > 0,
246
+ errorLength: typeof b.lastRecallError === 'string' ? b.lastRecallError.length : 0,
247
+ },
248
+ injection: {
249
+ injected: injectedIds.length,
250
+ lastTurn: numberOrNull(b.lastInjectedTurn),
251
+ reinjectPending: b.reinjectPending === true,
252
+ cueLive: Boolean(cueAt && now - cueAt <= 30 * 60 * 1000),
253
+ },
254
+ activity: {
255
+ lastTurnAt: isoAt(lastTurnAt),
256
+ lastInboundAt: isoAt(lastInboundAt),
257
+ lastAgentIdleAt: isoAt(lastAgentIdleAt),
258
+ lastActivityAt: isoAt(lastActivityAt),
259
+ // Exact milliseconds decide the threshold; idleDays is rounded for display only.
260
+ idleMs,
261
+ idleDays: idleMs === null ? null : Math.round(idleMs / DAY_MS * 10) / 10,
262
+ },
263
+ ...(includeDetails ? {
264
+ detail: {
265
+ inboundError: clip(b.lastInboundError),
266
+ sleepError: clip(b.lastSleepError),
267
+ },
268
+ } : {}),
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Host authority. Only a full session registry may say "this session does not exist".
274
+ * The agents list is a live-agent view: it proves presence, never absence, so it is
275
+ * downgraded to partial even when the caller labels it ok.
276
+ */
277
+ export function normalizeHost(host) {
278
+ if (!isObject(host) || !Array.isArray(host.sessions)) {
279
+ return {
280
+ status: 'unavailable', source: 'unknown', complete: false, sessions: [], total: 0, duplicates: 0,
281
+ reason: isObject(host) ? 'host supplied no session array' : 'no host session list was supplied',
282
+ }
283
+ }
284
+ const source = host.source === 'session-registry' || host.source === 'agents' ? host.source : 'unknown'
285
+ const notes = []
286
+ let status = ['ok', 'partial', 'unavailable', 'not-loaded'].includes(host.status) ? host.status : 'unknown'
287
+ const byId = new Map()
288
+ let duplicates = 0
289
+ for (const raw of host.sessions) {
290
+ const record = isObject(raw) ? raw : null
291
+ if (!record || typeof record.id !== 'string' || !record.id) continue
292
+ if (byId.has(record.id)) { duplicates += 1; continue }
293
+ byId.set(record.id, {
294
+ id: record.id,
295
+ title: typeof record.title === 'string' && record.title.trim() ? record.title.trim() : null,
296
+ agentPreset: typeof record.agentPreset === 'string' ? record.agentPreset : null,
297
+ })
298
+ }
299
+ if (source === 'agents' && status === 'ok') {
300
+ status = 'partial'
301
+ notes.push('the agents list is a live-agent view, not the full session history')
302
+ }
303
+ if (source === 'unknown' && status === 'ok') {
304
+ status = 'partial'
305
+ notes.push('the session list has no declared source, so completeness cannot be trusted')
306
+ }
307
+ if (status === 'ok' && byId.size === 0) {
308
+ status = 'partial'
309
+ notes.push('an authoritative host list was empty while bindings may exist; treating it as incomplete')
310
+ }
311
+ return {
312
+ status,
313
+ source,
314
+ complete: status === 'ok',
315
+ sessions: [...byId.values()],
316
+ total: byId.size,
317
+ duplicates,
318
+ reason: typeof host.reason === 'string' && host.reason ? host.reason : (notes.join('; ') || null),
319
+ }
320
+ }
321
+
322
+ function normalizeRuntime(runtime) {
323
+ if (!isObject(runtime)) return { provided: false, layers: [], totals: null }
324
+ const layers = []
325
+ const add = (name, layer) => {
326
+ const count = runtime[name]
327
+ if (Number.isInteger(count) && count >= 0) layers.push({ name, layer, count })
328
+ }
329
+ add('presetAttachments', 'runtime-listener')
330
+ for (const name of ['inboundMediaJobs', 'inboundCalls', 'inboundSessions', 'mediaCaptures', 'privateInFlight', 'curatorJobs']) add(name, 'temporary-map')
331
+ const sum = (layer) => layers.filter(item => item.layer === layer).reduce((total, item) => total + item.count, 0)
332
+ return {
333
+ provided: true,
334
+ source: typeof runtime.source === 'string' && runtime.source ? runtime.source : 'caller',
335
+ layers,
336
+ totals: { runtimeListeners: sum('runtime-listener'), temporaryMaps: sum('temporary-map') },
337
+ }
338
+ }
339
+
340
+ function sameTitleGroups(host, factsByRaw) {
341
+ const groups = new Map()
342
+ for (const session of host.sessions) {
343
+ if (!session.title) continue
344
+ const key = hash(session.title)
345
+ const group = groups.get(key) || { key, titleLength: session.title.length, sessions: [] }
346
+ group.sessions.push(session)
347
+ groups.set(key, group)
348
+ }
349
+ return [...groups.values()].filter(group => group.sessions.length > 1).map(group => {
350
+ const ids = group.sessions.map(session => session.id)
351
+ const boundLibraries = [...new Set(group.sessions.map(session => {
352
+ const facts = factsByRaw.get(session.id)
353
+ return facts && facts.library.id ? facts.library.id : null
354
+ }))]
355
+ return {
356
+ titleHash: group.key,
357
+ titleLength: group.titleLength,
358
+ sessionIds: ids.slice().sort(),
359
+ bound: group.sessions.filter(session => factsByRaw.has(session.id)).length,
360
+ libraries: boundLibraries,
361
+ mergeable: false,
362
+ identity: 'sessionId',
363
+ reason: 'a title is a label, not an identity: a fork, a restore or a fresh chat can reuse it, and every id owns its own binding, library and inbound queue',
364
+ }
365
+ })
366
+ }
367
+
368
+ /**
369
+ * Pure analysis of an already parsed state object. Never mutates its input, never reads
370
+ * or writes a file. Host and runtime observations are optional; absent ones become
371
+ * explicit unknowns instead of assumptions.
372
+ */
373
+ export function analyzeBindings(state, options = {}) {
374
+ if (!isObject(state)) throw new TypeError('analyzeBindings requires a parsed state object')
375
+ const now = numberOrNull(options.now) === null ? Date.now() : options.now
376
+ const redact = options.redactIds === true
377
+ const includeDetails = !redact
378
+ const rawSessions = isObject(state.sessions) ? state.sessions : {}
379
+ const libraries = isObject(state.libraries) ? state.libraries : {}
380
+ const policies = isObject(state.workspacePolicies) ? state.workspacePolicies : {}
381
+ const host = normalizeHost(options.host)
382
+ const runtime = normalizeRuntime(options.runtime)
383
+ const display = new Map()
384
+ const displayId = (id) => {
385
+ if (!redact) return id
386
+ if (!display.has(id)) display.set(id, 'session-' + hash(id))
387
+ return display.get(id)
388
+ }
389
+ const factsList = Object.keys(rawSessions).map(rawId => ({
390
+ rawId,
391
+ facts: bindingFacts(displayId(rawId), rawSessions[rawId], { libraries, policies, now, includeDetails }),
392
+ }))
393
+ const factsByRaw = new Map(factsList.map(item => [item.rawId, item.facts]))
394
+ const hostIds = new Set(host.sessions.map(session => session.id))
395
+ const candidates = []
396
+ const unknownMap = new Map()
397
+ const unknown = (code, detail, extra = {}) => {
398
+ if (!unknownMap.has(code)) unknownMap.set(code, { code, detail, keeps: 'persistent bindings and their queues', ...extra })
399
+ }
400
+
401
+ if (!isObject(state.sessions)) unknown(UNKNOWN.BINDINGS_NOT_OBJECT, 'state.sessions is not an object, so no binding could be analysed')
402
+
403
+ let idleBasisMissing = 0
404
+ let unboundWithoutEvidence = 0
405
+ let unboundHostSessions = 0
406
+ for (const session of host.sessions) if (!factsByRaw.has(session.id)) unboundHostSessions += 1
407
+
408
+ for (const { rawId, facts } of factsList) {
409
+ const kinds = []
410
+ const report = (kind, reason, evidence = {}) => {
411
+ kinds.push(kind)
412
+ candidates.push({ sessionId: facts.sessionId, kind, inspectionOnly: true, action: 'inspect', reason, evidence })
413
+ }
414
+ if (facts.sleep.sessionGone) {
415
+ report(CANDIDATE_KINDS.SESSION_GONE,
416
+ 'the host reported this conversation as deleted during sleep consolidation; the binding is kept so a restored or recreated session resumes with its scope and queue',
417
+ { sessionGoneAt: facts.sleep.sessionGoneAt })
418
+ }
419
+ if (host.complete && !hostIds.has(rawId)) {
420
+ report(CANDIDATE_KINDS.HOST_MISSING,
421
+ 'the host session registry does not list this id; the binding is kept until the host confirms deletion, and a title match is not such a confirmation',
422
+ { hostSessions: host.total, lastActivityAt: facts.activity.lastActivityAt })
423
+ }
424
+ if (facts.library.id && !facts.library.exists) {
425
+ report(CANDIDATE_KINDS.LIBRARY_MISSING,
426
+ 'libraryId points at a library that is not in this state file; the pointer is reported, never rewritten, because the library may live in another profile or a backup',
427
+ { libraryId: facts.library.id })
428
+ }
429
+ if (facts.library.exists && facts.library.archived) {
430
+ report(CANDIDATE_KINDS.LIBRARY_ARCHIVED,
431
+ 'the bound library is archived; an archived library is kept on disk, so this is a pointer to inspect, not a reason to unbind',
432
+ { libraryId: facts.library.id })
433
+ }
434
+ if (facts.effective.useLibrary && !facts.library.id) {
435
+ const evidence = unboundEvidence(facts)
436
+ if (evidence.priorInjection) {
437
+ report(CANDIDATE_KINDS.UNBOUND_ENABLED, evidence.basis, {
438
+ folderBlocksLibrary: facts.folder.blocksLibrary,
439
+ injected: facts.injection.injected,
440
+ lastInjectedTurn: facts.injection.lastTurn,
441
+ })
442
+ } else {
443
+ unboundWithoutEvidence += 1
444
+ }
445
+ }
446
+ if (facts.inbound.pending > 0) {
447
+ report(CANDIDATE_KINDS.PENDING_INBOUND,
448
+ 'human messages are waiting for injection and must survive any cleanup; a pending queue is unfinished work, not garbage',
449
+ { messages: facts.inbound.pending, bytes: facts.inbound.pendingBytes, retrying: facts.inbound.retrying, retryAt: facts.inbound.retryAt })
450
+ }
451
+ if (facts.sleep.retryAt || facts.sleep.attempts > 0) {
452
+ report(CANDIDATE_KINDS.SLEEP_RETRY,
453
+ 'sleep consolidation is deferred or has failed before; the retry clock is host-independent state and must not be reset by a diagnostic',
454
+ { retryAt: facts.sleep.retryAt, retryDue: facts.sleep.retryDue, attempts: facts.sleep.attempts, hasError: facts.sleep.hasError })
455
+ }
456
+ if (facts.recall.degraded) {
457
+ report(CANDIDATE_KINDS.FAILED_RECALL,
458
+ 'the last per-turn recall recorded an error; injection stays fail-open, so this is the only trace that memory was degraded',
459
+ { errorLength: facts.recall.errorLength })
460
+ }
461
+ if (facts.injection.reinjectPending) {
462
+ report(CANDIDATE_KINDS.REINJECT_PENDING,
463
+ 'a compaction asked for the anchor to be re-sent and the flag has not been cleared yet; a live step normally clears it',
464
+ { lastTurn: facts.injection.lastTurn })
465
+ }
466
+ if (facts.activity.idleMs !== null && facts.activity.idleMs > INSPECTION_DAYS * DAY_MS) {
467
+ report(CANDIDATE_KINDS.IDLE_INSPECTION,
468
+ 'no recorded activity for more than the ' + INSPECTION_DAYS + '-day inspection threshold; the threshold only marks what to look at and never schedules a deletion',
469
+ { idleDays: facts.activity.idleDays, idleMs: facts.activity.idleMs, thresholdDays: INSPECTION_DAYS, thresholdMs: INSPECTION_DAYS * DAY_MS, lastActivityAt: facts.activity.lastActivityAt })
470
+ }
471
+ if (facts.activity.lastActivityAt === null) idleBasisMissing += 1
472
+ }
473
+
474
+ if (!runtime.provided) {
475
+ unknown(UNKNOWN.RUNTIME_NOT_INSPECTABLE,
476
+ 'preset-scope attachments and inbound per-session maps live in the plugin process; a separate process reads only the persisted layer and must not report them as zero',
477
+ { layers: RUNTIME_STATE.map(item => item.file + ' ' + item.name) })
478
+ }
479
+ if (host.status === 'unavailable') {
480
+ unknown(UNKNOWN.HOST_UNAVAILABLE, 'no host session list was supplied; every binding is kept because absence is unproven and an active-agent list is not history')
481
+ } else if (host.status === 'not-loaded') {
482
+ unknown(UNKNOWN.HOST_NOT_LOADED, 'the host session list was not loaded yet; nothing is inferred about missing sessions')
483
+ } else if (host.status === 'partial') {
484
+ unknown(UNKNOWN.HOST_PARTIAL, 'the host session list is incomplete (' + (host.reason || 'no reason given') + '); missing-session candidates are withheld and no state is touched')
485
+ } else if (host.status === 'unknown' || host.status === 'unreadable') {
486
+ unknown(UNKNOWN.HOST_SOURCE_UNTRUSTED, 'the host session list has an unrecognised status; nothing is inferred from it')
487
+ }
488
+ if (host.duplicates > 0) unknown(UNKNOWN.HOST_DUPLICATE_IDS, host.duplicates + ' duplicate host session id(s) were ignored; ids are the only identity')
489
+ if (idleBasisMissing > 0) {
490
+ unknown(UNKNOWN.IDLE_BASIS_MISSING, idleBasisMissing + ' binding(s) carry no turn, inbound or idle timestamp, so their idle age is unknown rather than zero')
491
+ }
492
+ if (unboundWithoutEvidence > 0) {
493
+ unknown(UNKNOWN.UNBOUND_BASIS_UNKNOWN, unboundWithoutEvidence + ' binding(s) want private memory (effective useLibrary: true) and have no libraryId, but record no completed injection, so the snapshot cannot separate a deferred creation from a lost pointer; they are counted, not filed as candidates')
494
+ }
495
+
496
+ const groups = host.complete ? sameTitleGroups(host, factsByRaw) : []
497
+ const byKind = {}
498
+ for (const candidate of candidates) byKind[candidate.kind] = (byKind[candidate.kind] || 0) + 1
499
+ const pendingSessions = factsList.filter(item => item.facts.inbound.pending > 0)
500
+ const summary = {
501
+ bindings: factsList.length,
502
+ host: { status: host.status, source: host.source, complete: host.complete, sessions: host.total, unboundHostSessions },
503
+ switches: {
504
+ libraryDisabled: factsList.filter(item => !item.facts.switches.useLibrary).length,
505
+ globalDisabled: factsList.filter(item => !item.facts.switches.useGlobal).length,
506
+ bothDisabled: factsList.filter(item => !item.facts.switches.useLibrary && !item.facts.switches.useGlobal).length,
507
+ folderBlockedLibrary: factsList.filter(item => item.facts.folder.blocksLibrary).length,
508
+ folderBlockedGlobal: factsList.filter(item => item.facts.folder.blocksGlobal).length,
509
+ },
510
+ libraries: {
511
+ bound: factsList.filter(item => Boolean(item.facts.library.id)).length,
512
+ enabledButUnbound: factsList.filter(item => item.facts.effective.useLibrary && !item.facts.library.id).length,
513
+ missing: factsList.filter(item => item.facts.library.id && !item.facts.library.exists).length,
514
+ archived: factsList.filter(item => item.facts.library.exists && item.facts.library.archived).length,
515
+ },
516
+ memoryContext: {
517
+ present: factsList.filter(item => item.facts.memoryContext.present).length,
518
+ absent: factsList.filter(item => !item.facts.memoryContext.present).length,
519
+ scoped: factsList.filter(item => item.facts.memoryContext.scopeKeys.length > 0).length,
520
+ },
521
+ guide: {
522
+ sent: factsList.filter(item => item.facts.guide.sent).length,
523
+ notSent: factsList.filter(item => !item.facts.guide.sent).length,
524
+ },
525
+ inbound: {
526
+ sessionsWithPending: pendingSessions.length,
527
+ pendingMessages: pendingSessions.reduce((total, item) => total + item.facts.inbound.pending, 0),
528
+ retrying: factsList.filter(item => item.facts.inbound.retrying).length,
529
+ seenTotal: factsList.reduce((total, item) => total + item.facts.inbound.seen, 0),
530
+ claimedTotal: factsList.reduce((total, item) => total + item.facts.inbound.claimed, 0),
531
+ },
532
+ sleep: {
533
+ retryScheduled: factsList.filter(item => Boolean(item.facts.sleep.retryAt)).length,
534
+ sessionGone: factsList.filter(item => item.facts.sleep.sessionGone).length,
535
+ failed: factsList.filter(item => item.facts.sleep.hasError).length,
536
+ },
537
+ unfinished: {
538
+ pendingSessions: pendingSessions.length,
539
+ pendingMessages: pendingSessions.reduce((total, item) => total + item.facts.inbound.pending, 0),
540
+ reinjectPending: factsList.filter(item => item.facts.injection.reinjectPending).length,
541
+ sleepRetryDue: factsList.filter(item => item.facts.sleep.retryDue).length,
542
+ sessionGone: factsList.filter(item => item.facts.sleep.sessionGone).length,
543
+ },
544
+ runtime: runtime.provided ? runtime.totals : null,
545
+ candidates: { total: candidates.length, byKind },
546
+ sameTitleGroups: groups.length,
547
+ }
548
+ return {
549
+ now: new Date(now).toISOString(),
550
+ idsRedacted: redact,
551
+ summary,
552
+ bindings: factsList.map(item => item.facts),
553
+ candidates,
554
+ sameTitleGroups: groups,
555
+ unknowns: [...unknownMap.values()],
556
+ runtime: runtime.provided
557
+ ? { status: 'provided', source: runtime.source, layers: runtime.layers }
558
+ : {
559
+ status: 'unknown',
560
+ reason: 'runtime layers cannot be read from this process',
561
+ layers: RUNTIME_STATE.map(item => ({ layer: item.layer, name: item.name, file: item.file, declared: true })),
562
+ },
563
+ }
564
+ }
565
+
566
+ function proofOf(file, before) {
567
+ let after = null
568
+ let mtimeMs = null
569
+ try {
570
+ const buffer = fs.readFileSync(file)
571
+ after = { sha256: crypto.createHash('sha256').update(buffer).digest('hex'), bytes: buffer.length }
572
+ } catch {}
573
+ try { mtimeMs = fs.statSync(file).mtimeMs } catch {}
574
+ const beforeSha = before && before.sha256 ? before.sha256 : null
575
+ const beforeMtime = before && typeof before.mtimeMs === 'number' ? before.mtimeMs : null
576
+ const unchanged = after && beforeSha
577
+ ? after.sha256 === beforeSha && mtimeMs === beforeMtime
578
+ : !after && !beforeSha
579
+ return {
580
+ file,
581
+ sha256Before: beforeSha,
582
+ sha256After: after ? after.sha256 : null,
583
+ bytesBefore: before && typeof before.bytes === 'number' ? before.bytes : null,
584
+ bytesAfter: after ? after.bytes : null,
585
+ mtimeMsBefore: beforeMtime,
586
+ mtimeMsAfter: mtimeMs,
587
+ unchanged: Boolean(unchanged),
588
+ verified: 'the file was re-hashed after analysis; a read-only report must leave bytes and mtime untouched',
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Read a snapshot, analyse it, then re-hash it. The report is JSON-safe and carries no
594
+ * memory text, titles or folder paths; ids are pseudonymised when redactIds is set.
595
+ */
596
+ export function diagnoseBindings(options = {}) {
597
+ const now = numberOrNull(options.now) === null ? Date.now() : options.now
598
+ const snapshot = readBindingSnapshot({ stateFile: options.stateFile })
599
+ const report = {
600
+ schema: SCHEMA,
601
+ generatedAt: new Date(now).toISOString(),
602
+ readOnly: true,
603
+ mode: 'report-only',
604
+ refusals: REFUSALS,
605
+ thresholds: { idleInspectionDays: INSPECTION_DAYS, idleIsDeletionTrigger: false },
606
+ state: {
607
+ file: snapshot.file,
608
+ status: snapshot.ok ? 'ok' : snapshot.kind,
609
+ bytes: snapshot.bytes,
610
+ mtimeMs: snapshot.mtimeMs,
611
+ sha256: snapshot.sha256,
612
+ },
613
+ analysis: null,
614
+ unknowns: [],
615
+ readOnlyProof: null,
616
+ }
617
+ if (!snapshot.ok) {
618
+ const code = snapshot.missing ? UNKNOWN.STATE_MISSING
619
+ : snapshot.kind === 'corrupt' ? UNKNOWN.STATE_CORRUPT
620
+ : UNKNOWN.STATE_UNREADABLE
621
+ report.unknowns = [{
622
+ code,
623
+ detail: snapshot.missing
624
+ ? 'no state file exists; nothing was created, because creating one is a write'
625
+ : 'the file could not be parsed; it was left exactly as found and not renamed or quarantined',
626
+ keeps: 'the file on disk',
627
+ }]
628
+ report.readOnlyProof = proofOf(snapshot.file, snapshot)
629
+ return report
630
+ }
631
+ report.analysis = analyzeBindings(snapshot.value, { now, host: options.host, runtime: options.runtime, redactIds: options.redactIds })
632
+ report.unknowns = report.analysis.unknowns
633
+ report.readOnlyProof = proofOf(snapshot.file, snapshot)
634
+ return report
635
+ }