dsh-memu-memory 1.1.1 → 1.1.3
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.
- package/AGENTS.md +8 -5
- package/README.md +22 -5
- package/README.zh.md +21 -4
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +41 -1
- package/docs/troubleshooting.md +395 -350
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +703 -50
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +61 -4
- package/lib/inject.js +195 -49
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +121 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
|
@@ -0,0 +1,630 @@
|
|
|
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:265', 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:950', 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: { sent: Boolean(b.guideMessageAt), sentAt: isoAt(b.guideMessageAt) },
|
|
220
|
+
inbound: {
|
|
221
|
+
pending: pending.length,
|
|
222
|
+
pendingBytes,
|
|
223
|
+
retrying: Boolean(retryAt && retryAt > now),
|
|
224
|
+
retryAt: isoAt(retryAt),
|
|
225
|
+
seen: Array.isArray(inbound.seen) ? inbound.seen.length : 0,
|
|
226
|
+
claimed: Array.isArray(inbound.claimed) ? inbound.claimed.length : 0,
|
|
227
|
+
clock: numberOrNull(inbound.clock),
|
|
228
|
+
hasError: typeof b.lastInboundError === 'string' && b.lastInboundError.length > 0,
|
|
229
|
+
},
|
|
230
|
+
sleep: {
|
|
231
|
+
lastSleepAt: isoAt(b.lastSleepAt),
|
|
232
|
+
retryAt: isoAt(sleepRetryAt),
|
|
233
|
+
retryDue: Boolean(sleepRetryAt && sleepRetryAt <= now),
|
|
234
|
+
attempts: numberOrNull(b.sleepAttempts) || 0,
|
|
235
|
+
hasError: typeof b.lastSleepError === 'string' && b.lastSleepError.length > 0,
|
|
236
|
+
sessionGone: b.sessionGone === true,
|
|
237
|
+
sessionGoneAt: isoAt(b.sessionGoneAt),
|
|
238
|
+
},
|
|
239
|
+
recall: {
|
|
240
|
+
degraded: typeof b.lastRecallError === 'string' && b.lastRecallError.length > 0,
|
|
241
|
+
errorLength: typeof b.lastRecallError === 'string' ? b.lastRecallError.length : 0,
|
|
242
|
+
},
|
|
243
|
+
injection: {
|
|
244
|
+
injected: injectedIds.length,
|
|
245
|
+
lastTurn: numberOrNull(b.lastInjectedTurn),
|
|
246
|
+
reinjectPending: b.reinjectPending === true,
|
|
247
|
+
cueLive: Boolean(cueAt && now - cueAt <= 30 * 60 * 1000),
|
|
248
|
+
},
|
|
249
|
+
activity: {
|
|
250
|
+
lastTurnAt: isoAt(lastTurnAt),
|
|
251
|
+
lastInboundAt: isoAt(lastInboundAt),
|
|
252
|
+
lastAgentIdleAt: isoAt(lastAgentIdleAt),
|
|
253
|
+
lastActivityAt: isoAt(lastActivityAt),
|
|
254
|
+
// Exact milliseconds decide the threshold; idleDays is rounded for display only.
|
|
255
|
+
idleMs,
|
|
256
|
+
idleDays: idleMs === null ? null : Math.round(idleMs / DAY_MS * 10) / 10,
|
|
257
|
+
},
|
|
258
|
+
...(includeDetails ? {
|
|
259
|
+
detail: {
|
|
260
|
+
inboundError: clip(b.lastInboundError),
|
|
261
|
+
sleepError: clip(b.lastSleepError),
|
|
262
|
+
},
|
|
263
|
+
} : {}),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Host authority. Only a full session registry may say "this session does not exist".
|
|
269
|
+
* The agents list is a live-agent view: it proves presence, never absence, so it is
|
|
270
|
+
* downgraded to partial even when the caller labels it ok.
|
|
271
|
+
*/
|
|
272
|
+
export function normalizeHost(host) {
|
|
273
|
+
if (!isObject(host) || !Array.isArray(host.sessions)) {
|
|
274
|
+
return {
|
|
275
|
+
status: 'unavailable', source: 'unknown', complete: false, sessions: [], total: 0, duplicates: 0,
|
|
276
|
+
reason: isObject(host) ? 'host supplied no session array' : 'no host session list was supplied',
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const source = host.source === 'session-registry' || host.source === 'agents' ? host.source : 'unknown'
|
|
280
|
+
const notes = []
|
|
281
|
+
let status = ['ok', 'partial', 'unavailable', 'not-loaded'].includes(host.status) ? host.status : 'unknown'
|
|
282
|
+
const byId = new Map()
|
|
283
|
+
let duplicates = 0
|
|
284
|
+
for (const raw of host.sessions) {
|
|
285
|
+
const record = isObject(raw) ? raw : null
|
|
286
|
+
if (!record || typeof record.id !== 'string' || !record.id) continue
|
|
287
|
+
if (byId.has(record.id)) { duplicates += 1; continue }
|
|
288
|
+
byId.set(record.id, {
|
|
289
|
+
id: record.id,
|
|
290
|
+
title: typeof record.title === 'string' && record.title.trim() ? record.title.trim() : null,
|
|
291
|
+
agentPreset: typeof record.agentPreset === 'string' ? record.agentPreset : null,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
if (source === 'agents' && status === 'ok') {
|
|
295
|
+
status = 'partial'
|
|
296
|
+
notes.push('the agents list is a live-agent view, not the full session history')
|
|
297
|
+
}
|
|
298
|
+
if (source === 'unknown' && status === 'ok') {
|
|
299
|
+
status = 'partial'
|
|
300
|
+
notes.push('the session list has no declared source, so completeness cannot be trusted')
|
|
301
|
+
}
|
|
302
|
+
if (status === 'ok' && byId.size === 0) {
|
|
303
|
+
status = 'partial'
|
|
304
|
+
notes.push('an authoritative host list was empty while bindings may exist; treating it as incomplete')
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
status,
|
|
308
|
+
source,
|
|
309
|
+
complete: status === 'ok',
|
|
310
|
+
sessions: [...byId.values()],
|
|
311
|
+
total: byId.size,
|
|
312
|
+
duplicates,
|
|
313
|
+
reason: typeof host.reason === 'string' && host.reason ? host.reason : (notes.join('; ') || null),
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function normalizeRuntime(runtime) {
|
|
318
|
+
if (!isObject(runtime)) return { provided: false, layers: [], totals: null }
|
|
319
|
+
const layers = []
|
|
320
|
+
const add = (name, layer) => {
|
|
321
|
+
const count = runtime[name]
|
|
322
|
+
if (Number.isInteger(count) && count >= 0) layers.push({ name, layer, count })
|
|
323
|
+
}
|
|
324
|
+
add('presetAttachments', 'runtime-listener')
|
|
325
|
+
for (const name of ['inboundMediaJobs', 'inboundCalls', 'inboundSessions', 'mediaCaptures', 'privateInFlight', 'curatorJobs']) add(name, 'temporary-map')
|
|
326
|
+
const sum = (layer) => layers.filter(item => item.layer === layer).reduce((total, item) => total + item.count, 0)
|
|
327
|
+
return {
|
|
328
|
+
provided: true,
|
|
329
|
+
source: typeof runtime.source === 'string' && runtime.source ? runtime.source : 'caller',
|
|
330
|
+
layers,
|
|
331
|
+
totals: { runtimeListeners: sum('runtime-listener'), temporaryMaps: sum('temporary-map') },
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function sameTitleGroups(host, factsByRaw) {
|
|
336
|
+
const groups = new Map()
|
|
337
|
+
for (const session of host.sessions) {
|
|
338
|
+
if (!session.title) continue
|
|
339
|
+
const key = hash(session.title)
|
|
340
|
+
const group = groups.get(key) || { key, titleLength: session.title.length, sessions: [] }
|
|
341
|
+
group.sessions.push(session)
|
|
342
|
+
groups.set(key, group)
|
|
343
|
+
}
|
|
344
|
+
return [...groups.values()].filter(group => group.sessions.length > 1).map(group => {
|
|
345
|
+
const ids = group.sessions.map(session => session.id)
|
|
346
|
+
const boundLibraries = [...new Set(group.sessions.map(session => {
|
|
347
|
+
const facts = factsByRaw.get(session.id)
|
|
348
|
+
return facts && facts.library.id ? facts.library.id : null
|
|
349
|
+
}))]
|
|
350
|
+
return {
|
|
351
|
+
titleHash: group.key,
|
|
352
|
+
titleLength: group.titleLength,
|
|
353
|
+
sessionIds: ids.slice().sort(),
|
|
354
|
+
bound: group.sessions.filter(session => factsByRaw.has(session.id)).length,
|
|
355
|
+
libraries: boundLibraries,
|
|
356
|
+
mergeable: false,
|
|
357
|
+
identity: 'sessionId',
|
|
358
|
+
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',
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Pure analysis of an already parsed state object. Never mutates its input, never reads
|
|
365
|
+
* or writes a file. Host and runtime observations are optional; absent ones become
|
|
366
|
+
* explicit unknowns instead of assumptions.
|
|
367
|
+
*/
|
|
368
|
+
export function analyzeBindings(state, options = {}) {
|
|
369
|
+
if (!isObject(state)) throw new TypeError('analyzeBindings requires a parsed state object')
|
|
370
|
+
const now = numberOrNull(options.now) === null ? Date.now() : options.now
|
|
371
|
+
const redact = options.redactIds === true
|
|
372
|
+
const includeDetails = !redact
|
|
373
|
+
const rawSessions = isObject(state.sessions) ? state.sessions : {}
|
|
374
|
+
const libraries = isObject(state.libraries) ? state.libraries : {}
|
|
375
|
+
const policies = isObject(state.workspacePolicies) ? state.workspacePolicies : {}
|
|
376
|
+
const host = normalizeHost(options.host)
|
|
377
|
+
const runtime = normalizeRuntime(options.runtime)
|
|
378
|
+
const display = new Map()
|
|
379
|
+
const displayId = (id) => {
|
|
380
|
+
if (!redact) return id
|
|
381
|
+
if (!display.has(id)) display.set(id, 'session-' + hash(id))
|
|
382
|
+
return display.get(id)
|
|
383
|
+
}
|
|
384
|
+
const factsList = Object.keys(rawSessions).map(rawId => ({
|
|
385
|
+
rawId,
|
|
386
|
+
facts: bindingFacts(displayId(rawId), rawSessions[rawId], { libraries, policies, now, includeDetails }),
|
|
387
|
+
}))
|
|
388
|
+
const factsByRaw = new Map(factsList.map(item => [item.rawId, item.facts]))
|
|
389
|
+
const hostIds = new Set(host.sessions.map(session => session.id))
|
|
390
|
+
const candidates = []
|
|
391
|
+
const unknownMap = new Map()
|
|
392
|
+
const unknown = (code, detail, extra = {}) => {
|
|
393
|
+
if (!unknownMap.has(code)) unknownMap.set(code, { code, detail, keeps: 'persistent bindings and their queues', ...extra })
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!isObject(state.sessions)) unknown(UNKNOWN.BINDINGS_NOT_OBJECT, 'state.sessions is not an object, so no binding could be analysed')
|
|
397
|
+
|
|
398
|
+
let idleBasisMissing = 0
|
|
399
|
+
let unboundWithoutEvidence = 0
|
|
400
|
+
let unboundHostSessions = 0
|
|
401
|
+
for (const session of host.sessions) if (!factsByRaw.has(session.id)) unboundHostSessions += 1
|
|
402
|
+
|
|
403
|
+
for (const { rawId, facts } of factsList) {
|
|
404
|
+
const kinds = []
|
|
405
|
+
const report = (kind, reason, evidence = {}) => {
|
|
406
|
+
kinds.push(kind)
|
|
407
|
+
candidates.push({ sessionId: facts.sessionId, kind, inspectionOnly: true, action: 'inspect', reason, evidence })
|
|
408
|
+
}
|
|
409
|
+
if (facts.sleep.sessionGone) {
|
|
410
|
+
report(CANDIDATE_KINDS.SESSION_GONE,
|
|
411
|
+
'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',
|
|
412
|
+
{ sessionGoneAt: facts.sleep.sessionGoneAt })
|
|
413
|
+
}
|
|
414
|
+
if (host.complete && !hostIds.has(rawId)) {
|
|
415
|
+
report(CANDIDATE_KINDS.HOST_MISSING,
|
|
416
|
+
'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',
|
|
417
|
+
{ hostSessions: host.total, lastActivityAt: facts.activity.lastActivityAt })
|
|
418
|
+
}
|
|
419
|
+
if (facts.library.id && !facts.library.exists) {
|
|
420
|
+
report(CANDIDATE_KINDS.LIBRARY_MISSING,
|
|
421
|
+
'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',
|
|
422
|
+
{ libraryId: facts.library.id })
|
|
423
|
+
}
|
|
424
|
+
if (facts.library.exists && facts.library.archived) {
|
|
425
|
+
report(CANDIDATE_KINDS.LIBRARY_ARCHIVED,
|
|
426
|
+
'the bound library is archived; an archived library is kept on disk, so this is a pointer to inspect, not a reason to unbind',
|
|
427
|
+
{ libraryId: facts.library.id })
|
|
428
|
+
}
|
|
429
|
+
if (facts.effective.useLibrary && !facts.library.id) {
|
|
430
|
+
const evidence = unboundEvidence(facts)
|
|
431
|
+
if (evidence.priorInjection) {
|
|
432
|
+
report(CANDIDATE_KINDS.UNBOUND_ENABLED, evidence.basis, {
|
|
433
|
+
folderBlocksLibrary: facts.folder.blocksLibrary,
|
|
434
|
+
injected: facts.injection.injected,
|
|
435
|
+
lastInjectedTurn: facts.injection.lastTurn,
|
|
436
|
+
})
|
|
437
|
+
} else {
|
|
438
|
+
unboundWithoutEvidence += 1
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (facts.inbound.pending > 0) {
|
|
442
|
+
report(CANDIDATE_KINDS.PENDING_INBOUND,
|
|
443
|
+
'human messages are waiting for injection and must survive any cleanup; a pending queue is unfinished work, not garbage',
|
|
444
|
+
{ messages: facts.inbound.pending, bytes: facts.inbound.pendingBytes, retrying: facts.inbound.retrying, retryAt: facts.inbound.retryAt })
|
|
445
|
+
}
|
|
446
|
+
if (facts.sleep.retryAt || facts.sleep.attempts > 0) {
|
|
447
|
+
report(CANDIDATE_KINDS.SLEEP_RETRY,
|
|
448
|
+
'sleep consolidation is deferred or has failed before; the retry clock is host-independent state and must not be reset by a diagnostic',
|
|
449
|
+
{ retryAt: facts.sleep.retryAt, retryDue: facts.sleep.retryDue, attempts: facts.sleep.attempts, hasError: facts.sleep.hasError })
|
|
450
|
+
}
|
|
451
|
+
if (facts.recall.degraded) {
|
|
452
|
+
report(CANDIDATE_KINDS.FAILED_RECALL,
|
|
453
|
+
'the last per-turn recall recorded an error; injection stays fail-open, so this is the only trace that memory was degraded',
|
|
454
|
+
{ errorLength: facts.recall.errorLength })
|
|
455
|
+
}
|
|
456
|
+
if (facts.injection.reinjectPending) {
|
|
457
|
+
report(CANDIDATE_KINDS.REINJECT_PENDING,
|
|
458
|
+
'a compaction asked for the anchor to be re-sent and the flag has not been cleared yet; a live step normally clears it',
|
|
459
|
+
{ lastTurn: facts.injection.lastTurn })
|
|
460
|
+
}
|
|
461
|
+
if (facts.activity.idleMs !== null && facts.activity.idleMs > INSPECTION_DAYS * DAY_MS) {
|
|
462
|
+
report(CANDIDATE_KINDS.IDLE_INSPECTION,
|
|
463
|
+
'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',
|
|
464
|
+
{ idleDays: facts.activity.idleDays, idleMs: facts.activity.idleMs, thresholdDays: INSPECTION_DAYS, thresholdMs: INSPECTION_DAYS * DAY_MS, lastActivityAt: facts.activity.lastActivityAt })
|
|
465
|
+
}
|
|
466
|
+
if (facts.activity.lastActivityAt === null) idleBasisMissing += 1
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!runtime.provided) {
|
|
470
|
+
unknown(UNKNOWN.RUNTIME_NOT_INSPECTABLE,
|
|
471
|
+
'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',
|
|
472
|
+
{ layers: RUNTIME_STATE.map(item => item.file + ' ' + item.name) })
|
|
473
|
+
}
|
|
474
|
+
if (host.status === 'unavailable') {
|
|
475
|
+
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')
|
|
476
|
+
} else if (host.status === 'not-loaded') {
|
|
477
|
+
unknown(UNKNOWN.HOST_NOT_LOADED, 'the host session list was not loaded yet; nothing is inferred about missing sessions')
|
|
478
|
+
} else if (host.status === 'partial') {
|
|
479
|
+
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')
|
|
480
|
+
} else if (host.status === 'unknown' || host.status === 'unreadable') {
|
|
481
|
+
unknown(UNKNOWN.HOST_SOURCE_UNTRUSTED, 'the host session list has an unrecognised status; nothing is inferred from it')
|
|
482
|
+
}
|
|
483
|
+
if (host.duplicates > 0) unknown(UNKNOWN.HOST_DUPLICATE_IDS, host.duplicates + ' duplicate host session id(s) were ignored; ids are the only identity')
|
|
484
|
+
if (idleBasisMissing > 0) {
|
|
485
|
+
unknown(UNKNOWN.IDLE_BASIS_MISSING, idleBasisMissing + ' binding(s) carry no turn, inbound or idle timestamp, so their idle age is unknown rather than zero')
|
|
486
|
+
}
|
|
487
|
+
if (unboundWithoutEvidence > 0) {
|
|
488
|
+
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')
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const groups = host.complete ? sameTitleGroups(host, factsByRaw) : []
|
|
492
|
+
const byKind = {}
|
|
493
|
+
for (const candidate of candidates) byKind[candidate.kind] = (byKind[candidate.kind] || 0) + 1
|
|
494
|
+
const pendingSessions = factsList.filter(item => item.facts.inbound.pending > 0)
|
|
495
|
+
const summary = {
|
|
496
|
+
bindings: factsList.length,
|
|
497
|
+
host: { status: host.status, source: host.source, complete: host.complete, sessions: host.total, unboundHostSessions },
|
|
498
|
+
switches: {
|
|
499
|
+
libraryDisabled: factsList.filter(item => !item.facts.switches.useLibrary).length,
|
|
500
|
+
globalDisabled: factsList.filter(item => !item.facts.switches.useGlobal).length,
|
|
501
|
+
bothDisabled: factsList.filter(item => !item.facts.switches.useLibrary && !item.facts.switches.useGlobal).length,
|
|
502
|
+
folderBlockedLibrary: factsList.filter(item => item.facts.folder.blocksLibrary).length,
|
|
503
|
+
folderBlockedGlobal: factsList.filter(item => item.facts.folder.blocksGlobal).length,
|
|
504
|
+
},
|
|
505
|
+
libraries: {
|
|
506
|
+
bound: factsList.filter(item => Boolean(item.facts.library.id)).length,
|
|
507
|
+
enabledButUnbound: factsList.filter(item => item.facts.effective.useLibrary && !item.facts.library.id).length,
|
|
508
|
+
missing: factsList.filter(item => item.facts.library.id && !item.facts.library.exists).length,
|
|
509
|
+
archived: factsList.filter(item => item.facts.library.exists && item.facts.library.archived).length,
|
|
510
|
+
},
|
|
511
|
+
memoryContext: {
|
|
512
|
+
present: factsList.filter(item => item.facts.memoryContext.present).length,
|
|
513
|
+
absent: factsList.filter(item => !item.facts.memoryContext.present).length,
|
|
514
|
+
scoped: factsList.filter(item => item.facts.memoryContext.scopeKeys.length > 0).length,
|
|
515
|
+
},
|
|
516
|
+
guide: {
|
|
517
|
+
sent: factsList.filter(item => item.facts.guide.sent).length,
|
|
518
|
+
notSent: factsList.filter(item => !item.facts.guide.sent).length,
|
|
519
|
+
},
|
|
520
|
+
inbound: {
|
|
521
|
+
sessionsWithPending: pendingSessions.length,
|
|
522
|
+
pendingMessages: pendingSessions.reduce((total, item) => total + item.facts.inbound.pending, 0),
|
|
523
|
+
retrying: factsList.filter(item => item.facts.inbound.retrying).length,
|
|
524
|
+
seenTotal: factsList.reduce((total, item) => total + item.facts.inbound.seen, 0),
|
|
525
|
+
claimedTotal: factsList.reduce((total, item) => total + item.facts.inbound.claimed, 0),
|
|
526
|
+
},
|
|
527
|
+
sleep: {
|
|
528
|
+
retryScheduled: factsList.filter(item => Boolean(item.facts.sleep.retryAt)).length,
|
|
529
|
+
sessionGone: factsList.filter(item => item.facts.sleep.sessionGone).length,
|
|
530
|
+
failed: factsList.filter(item => item.facts.sleep.hasError).length,
|
|
531
|
+
},
|
|
532
|
+
unfinished: {
|
|
533
|
+
pendingSessions: pendingSessions.length,
|
|
534
|
+
pendingMessages: pendingSessions.reduce((total, item) => total + item.facts.inbound.pending, 0),
|
|
535
|
+
reinjectPending: factsList.filter(item => item.facts.injection.reinjectPending).length,
|
|
536
|
+
sleepRetryDue: factsList.filter(item => item.facts.sleep.retryDue).length,
|
|
537
|
+
sessionGone: factsList.filter(item => item.facts.sleep.sessionGone).length,
|
|
538
|
+
},
|
|
539
|
+
runtime: runtime.provided ? runtime.totals : null,
|
|
540
|
+
candidates: { total: candidates.length, byKind },
|
|
541
|
+
sameTitleGroups: groups.length,
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
now: new Date(now).toISOString(),
|
|
545
|
+
idsRedacted: redact,
|
|
546
|
+
summary,
|
|
547
|
+
bindings: factsList.map(item => item.facts),
|
|
548
|
+
candidates,
|
|
549
|
+
sameTitleGroups: groups,
|
|
550
|
+
unknowns: [...unknownMap.values()],
|
|
551
|
+
runtime: runtime.provided
|
|
552
|
+
? { status: 'provided', source: runtime.source, layers: runtime.layers }
|
|
553
|
+
: {
|
|
554
|
+
status: 'unknown',
|
|
555
|
+
reason: 'runtime layers cannot be read from this process',
|
|
556
|
+
layers: RUNTIME_STATE.map(item => ({ layer: item.layer, name: item.name, file: item.file, declared: true })),
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function proofOf(file, before) {
|
|
562
|
+
let after = null
|
|
563
|
+
let mtimeMs = null
|
|
564
|
+
try {
|
|
565
|
+
const buffer = fs.readFileSync(file)
|
|
566
|
+
after = { sha256: crypto.createHash('sha256').update(buffer).digest('hex'), bytes: buffer.length }
|
|
567
|
+
} catch {}
|
|
568
|
+
try { mtimeMs = fs.statSync(file).mtimeMs } catch {}
|
|
569
|
+
const beforeSha = before && before.sha256 ? before.sha256 : null
|
|
570
|
+
const beforeMtime = before && typeof before.mtimeMs === 'number' ? before.mtimeMs : null
|
|
571
|
+
const unchanged = after && beforeSha
|
|
572
|
+
? after.sha256 === beforeSha && mtimeMs === beforeMtime
|
|
573
|
+
: !after && !beforeSha
|
|
574
|
+
return {
|
|
575
|
+
file,
|
|
576
|
+
sha256Before: beforeSha,
|
|
577
|
+
sha256After: after ? after.sha256 : null,
|
|
578
|
+
bytesBefore: before && typeof before.bytes === 'number' ? before.bytes : null,
|
|
579
|
+
bytesAfter: after ? after.bytes : null,
|
|
580
|
+
mtimeMsBefore: beforeMtime,
|
|
581
|
+
mtimeMsAfter: mtimeMs,
|
|
582
|
+
unchanged: Boolean(unchanged),
|
|
583
|
+
verified: 'the file was re-hashed after analysis; a read-only report must leave bytes and mtime untouched',
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Read a snapshot, analyse it, then re-hash it. The report is JSON-safe and carries no
|
|
589
|
+
* memory text, titles or folder paths; ids are pseudonymised when redactIds is set.
|
|
590
|
+
*/
|
|
591
|
+
export function diagnoseBindings(options = {}) {
|
|
592
|
+
const now = numberOrNull(options.now) === null ? Date.now() : options.now
|
|
593
|
+
const snapshot = readBindingSnapshot({ stateFile: options.stateFile })
|
|
594
|
+
const report = {
|
|
595
|
+
schema: SCHEMA,
|
|
596
|
+
generatedAt: new Date(now).toISOString(),
|
|
597
|
+
readOnly: true,
|
|
598
|
+
mode: 'report-only',
|
|
599
|
+
refusals: REFUSALS,
|
|
600
|
+
thresholds: { idleInspectionDays: INSPECTION_DAYS, idleIsDeletionTrigger: false },
|
|
601
|
+
state: {
|
|
602
|
+
file: snapshot.file,
|
|
603
|
+
status: snapshot.ok ? 'ok' : snapshot.kind,
|
|
604
|
+
bytes: snapshot.bytes,
|
|
605
|
+
mtimeMs: snapshot.mtimeMs,
|
|
606
|
+
sha256: snapshot.sha256,
|
|
607
|
+
},
|
|
608
|
+
analysis: null,
|
|
609
|
+
unknowns: [],
|
|
610
|
+
readOnlyProof: null,
|
|
611
|
+
}
|
|
612
|
+
if (!snapshot.ok) {
|
|
613
|
+
const code = snapshot.missing ? UNKNOWN.STATE_MISSING
|
|
614
|
+
: snapshot.kind === 'corrupt' ? UNKNOWN.STATE_CORRUPT
|
|
615
|
+
: UNKNOWN.STATE_UNREADABLE
|
|
616
|
+
report.unknowns = [{
|
|
617
|
+
code,
|
|
618
|
+
detail: snapshot.missing
|
|
619
|
+
? 'no state file exists; nothing was created, because creating one is a write'
|
|
620
|
+
: 'the file could not be parsed; it was left exactly as found and not renamed or quarantined',
|
|
621
|
+
keeps: 'the file on disk',
|
|
622
|
+
}]
|
|
623
|
+
report.readOnlyProof = proofOf(snapshot.file, snapshot)
|
|
624
|
+
return report
|
|
625
|
+
}
|
|
626
|
+
report.analysis = analyzeBindings(snapshot.value, { now, host: options.host, runtime: options.runtime, redactIds: options.redactIds })
|
|
627
|
+
report.unknowns = report.analysis.unknowns
|
|
628
|
+
report.readOnlyProof = proofOf(snapshot.file, snapshot)
|
|
629
|
+
return report
|
|
630
|
+
}
|