dsh-memu-memory 1.2.0 → 1.2.2

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 (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
package/lib/graph.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
- import { indexDir, readJsonStrict, writeJson, withFileLock, stamp } from './paths.js'
3
- import { loadPrivateIndex, updatePrivateIndex, newMemoryId } from './state.js'
2
+ import { indexDir, readJsonStrict, writeJson, withFileLock, stamp, warn } from './paths.js'
3
+ import { loadPrivateIndex, updatePrivateIndex, newMemoryId, listLibraries } from './state.js'
4
4
  import { resolveLibrary, sessionIdOf, visibleToSession, isMemoryEligible, decayStrength } from './memory.js'
5
5
 
6
6
  export const LINK_KINDS = ['relation', 'commitment', 'event', 'person', 'conflict']
@@ -22,13 +22,42 @@ function allowed(entry, exec) {
22
22
  return !sessionIdOf(exec) || (isMemoryEligible(entry, { automatic: true }) && visibleToSession(entry, sessionIdOf(exec)))
23
23
  }
24
24
 
25
+ /**
26
+ * Entity listing with the derived `linked` flag (§6.4 包 D). The visibility rule
27
+ * is unchanged: without a session every entity comes back (GraphPanel calls it
28
+ * exactly that way), with a session only entities reachable from visible memory
29
+ * links are listed. What is new is the classification:
30
+ * * `linked` = referenced by an entry's links[] OR by a relationship snapshot.
31
+ * A snapshot counts — an entity with only a relationship and no memory link
32
+ * is history, not an orphan, and must not be misjudged as one.
33
+ * * With a session, a snapshot only counts when its evidenceIds include a
34
+ * memory visible in this conversation; without a session every snapshot
35
+ * counts (there is nobody to hide it from).
36
+ * `linked` is derived on the fly and never written back to the entities file —
37
+ * the startup prune (pruneOrphanEntitiesAll) stays the sole collector.
38
+ */
25
39
  export function listEntities(args) {
26
40
  const scope = scopeFor(args)
27
41
  const store = readEntities(scope.libraryId)
28
42
  const index = loadPrivateIndex(scope.libraryId)
29
- const visible = new Set(Object.values(index.entries)
30
- .filter(entry => allowed(entry, args.exec)).flatMap(entry => (entry.links || []).map(link => link.to)))
31
- const items = Object.values(store.entities).filter(entity => !sessionIdOf(args.exec) || visible.has(entity.id))
43
+ const sessionId = sessionIdOf(args.exec)
44
+ const linked = new Set()
45
+ for (const entry of Object.values(index.entries)) {
46
+ if (!allowed(entry, args.exec)) continue
47
+ for (const link of entry.links || []) linked.add(link.to)
48
+ }
49
+ for (const rel of Object.values(store.relationships || {})) {
50
+ if (sessionId) {
51
+ const evidenceVisible = (Array.isArray(rel?.evidenceIds) ? rel.evidenceIds : [])
52
+ .some(id => { const entry = index.entries[id]; return Boolean(entry) && allowed(entry, args.exec) })
53
+ if (!evidenceVisible) continue
54
+ }
55
+ if (rel?.fromId) linked.add(rel.fromId)
56
+ if (rel?.toId) linked.add(rel.toId)
57
+ }
58
+ const items = Object.values(store.entities)
59
+ .filter(entity => !sessionId || linked.has(entity.id))
60
+ .map(entity => ({ ...entity, linked: linked.has(entity.id) }))
32
61
  return { items, total: items.length }
33
62
  }
34
63
 
@@ -60,6 +89,16 @@ export function restoreGraphData(libraryId, data, memoryIdMap) {
60
89
  if (!evidenceIds.length) continue
61
90
  updateRelationship({ libraryId, fromId: entityMap[relationship.fromId], toId: entityMap[relationship.toId], state: relationship.state, evidenceIds, reason: relationship.reason || 'Imported relationship with explicit evidence.' })
62
91
  }
92
+ // A backup carries the whole entity store, but only the memories that made it
93
+ // through the export/import mapping come back, so entities nothing points at
94
+ // any more would sit in the file forever as invisible weight. Collect them
95
+ // once the remap is done; a prune failure must not fail a restore that
96
+ // already succeeded, and the startup sweep would catch it on the next launch.
97
+ try {
98
+ pruneOrphanEntities(scope.libraryId)
99
+ } catch (error) {
100
+ warn(`orphan entity prune after graph restore skipped: ${error?.message || error}`)
101
+ }
63
102
  }
64
103
 
65
104
  export function createEntity({ exec, libraryId, name, kind = 'person', aliases = [] }) {
@@ -102,30 +141,154 @@ export function renameEntity({ libraryId, entityId, name, aliases = [] }) {
102
141
  })
103
142
  }
104
143
 
105
- export function linkEntries({ exec, libraryId, fromId, toId, kind, note, confidence = 0.7 }) {
144
+ // What keeps an entity alive. Edges only live in one direction: an entry's
145
+ // links[] names the entity, the entity stores nothing back. On top of that,
146
+ // relationship snapshots (same file, keyed by the endpoint pair) describe the
147
+ // pair with history and evidence that outlive the individual entries — deleting
148
+ // an endpoint would leave a snapshot whose endpoints no longer resolve, so a
149
+ // snapshot counts as a reference too. An entity referenced by neither is an
150
+ // *orphan*: unreachable except as a graph placeholder, and safe to collect.
151
+ // Orphans come from old builds whose permanent delete dropped the memory row
152
+ // without cascading, and from partial backups.
153
+
154
+ /**
155
+ * Remove every entity in one library that no entry links to and no relationship
156
+ * snapshot references. Idempotent: on healthy data it writes nothing. Returns
157
+ * `{ removed, total }` with the removed entity ids.
158
+ */
159
+ export function pruneOrphanEntities(libraryId) {
160
+ const scope = scopeFor({ libraryId })
161
+ const file = entitiesPath(scope.libraryId)
162
+ return withFileLock(file, () => {
163
+ const store = readEntities(scope.libraryId)
164
+ const ids = Object.keys(store.entities)
165
+ if (!ids.length) return { removed: [], total: 0 }
166
+ const referenced = new Set()
167
+ for (const entry of Object.values(loadPrivateIndex(scope.libraryId).entries || {})) {
168
+ for (const link of entry.links || []) referenced.add(link.to)
169
+ }
170
+ for (const relationship of Object.values(store.relationships || {})) {
171
+ referenced.add(relationship.fromId)
172
+ referenced.add(relationship.toId)
173
+ }
174
+ const removed = ids.filter(id => !referenced.has(id))
175
+ // Write only when something actually goes, so the common case never touches
176
+ // the file (mtime-based caches, backups and concurrent readers all benefit).
177
+ if (removed.length) {
178
+ for (const id of removed) delete store.entities[id]
179
+ writeJson(file, store)
180
+ }
181
+ return { removed, total: ids.length }
182
+ })
183
+ }
184
+
185
+ /**
186
+ * The startup sweep: run pruneOrphanEntities over every active library. One
187
+ * corrupt store must not stop the rest, so per-library failures are collected
188
+ * instead of thrown; the caller decides how to log them. Archived libraries are
189
+ * deliberately left alone (frozen user data — restoring one runs the prune via
190
+ * restoreGraphData anyway). Returns `{ scanned, removed, failed }`.
191
+ */
192
+ export function pruneOrphanEntitiesAll() {
193
+ const libraries = listLibraries()
194
+ const result = { scanned: libraries.length, removed: 0, failed: [] }
195
+ for (const library of libraries) {
196
+ try {
197
+ result.removed += pruneOrphanEntities(library.id).removed.length
198
+ } catch (error) {
199
+ result.failed.push({ libraryId: library.id, error: String(error?.message || error) })
200
+ }
201
+ }
202
+ return result
203
+ }
204
+
205
+ /**
206
+ * Delete an entity. This is graph bookkeeping, not a memory write: the entity
207
+ * row goes, no memory text is touched. An entity still referenced by links or
208
+ * relationship snapshots is refused by default — silently deleting it would
209
+ * turn every one of those edges into a `[deleted]` placeholder and rewrite what
210
+ * those memories are *about*. `force` deletes through that: inbound links are
211
+ * removed from every entry (not session-filtered — a link left behind in an
212
+ * entry this conversation cannot see would dangle exactly the way this guard
213
+ * exists to prevent, and the entry text itself is untouched) and relationship
214
+ * snapshots on the endpoints are dropped with their history, which is the loss
215
+ * `force` is asking for. Index links are cleaned before the entity row so a
216
+ * crash between the two writes leaves at worst a collectable orphan, never a
217
+ * link to an entity that is already gone.
218
+ */
219
+ export function deleteEntity({ exec, libraryId, entityId, force = false }) {
220
+ const scope = scopeFor({ exec, libraryId })
221
+ if (!entityId || typeof entityId !== 'string') throw new Error('entityId is required')
222
+ const file = entitiesPath(scope.libraryId)
223
+ return withFileLock(file, () => {
224
+ const store = readEntities(scope.libraryId)
225
+ if (!store.entities[entityId]) throw new Error('unknown entity')
226
+ const index = loadPrivateIndex(scope.libraryId)
227
+ let inbound = 0
228
+ for (const entry of Object.values(index.entries || {})) {
229
+ for (const link of entry.links || []) if (link.to === entityId) inbound += 1
230
+ }
231
+ const relationshipKeys = Object.keys(store.relationships || {})
232
+ .filter(key => { const [from, to] = JSON.parse(key); return from === entityId || to === entityId })
233
+ if ((inbound || relationshipKeys.length) && !force) {
234
+ const parts = []
235
+ if (inbound) parts.push(`${inbound} link(s) point at it`)
236
+ if (relationshipKeys.length) parts.push(`${relationshipKeys.length} relationship snapshot(s) reference it`)
237
+ throw new Error(`entity is still referenced: ${parts.join(' and ')}; remove the links first or pass force to delete the entity with them`)
238
+ }
239
+ let removedLinks = 0
240
+ if (inbound) {
241
+ updatePrivateIndex(scope.libraryId, fresh => {
242
+ for (const entry of Object.values(fresh.entries)) {
243
+ if (!Array.isArray(entry.links) || !entry.links.some(link => link.to === entityId)) continue
244
+ const kept = entry.links.filter(link => link.to !== entityId)
245
+ removedLinks += entry.links.length - kept.length
246
+ entry.links = kept
247
+ }
248
+ })
249
+ }
250
+ for (const key of relationshipKeys) delete store.relationships[key]
251
+ delete store.entities[entityId]
252
+ writeJson(file, store)
253
+ return { entityId, deleted: true, removedLinks, removedRelationships: relationshipKeys.length }
254
+ })
255
+ }
256
+
257
+ /**
258
+ * Write one directed link from a memory to a memory or entity.
259
+ *
260
+ * `source` records who made the edge: 'auto' (private auto-association / entity
261
+ * hub, §5.3–5.5) or 'user'. Anything else is omitted rather than stored, so the
262
+ * field never turns into a free-form label and an old link without it keeps
263
+ * reading as a model/user link. Replacement stays idempotent: the same
264
+ * (to, kind) pair is overwritten in place instead of appended.
265
+ */
266
+ export function linkEntries({ exec, libraryId, fromId, toId, kind, note, confidence = 0.7, source }) {
106
267
  const scope = scopeFor({ exec, libraryId })
107
268
  if (!LINK_KINDS.includes(kind)) throw new Error(`link kind must be ${LINK_KINDS.join(', ')}`)
108
269
  const reason = String(note || '').trim()
109
270
  if (reason.replace(/\s/g, '').length < 8 || reason.length > 500) throw new Error('link note must contain 8-500 characters explaining the evidence')
110
271
  if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) throw new Error('link confidence must be between 0 and 1')
111
272
  if (fromId === toId) throw new Error('a memory cannot link to itself')
273
+ const linkSource = source === 'auto' || source === 'user' ? source : null
112
274
  const entities = readEntities(scope.libraryId).entities
113
275
  let result
114
276
  updatePrivateIndex(scope.libraryId, index => {
115
- const source = index.entries[fromId]
277
+ const origin = index.entries[fromId]
116
278
  const target = index.entries[toId]
117
- if (!source || !allowed(source, exec) || target && !allowed(target, exec)) throw new Error('unknown or inaccessible memory')
279
+ if (!origin || !allowed(origin, exec) || target && !allowed(target, exec)) throw new Error('unknown or inaccessible memory')
118
280
  if (!target && !entities[toId]) throw new Error('link target must be an existing memory or entity')
119
281
  if (kind === 'person' && entities[toId]?.kind !== 'person') throw new Error('person links must target a person entity')
120
- const links = Array.isArray(source.links) ? source.links : []
282
+ const links = Array.isArray(origin.links) ? origin.links : []
121
283
  result = { to: toId, kind, note: reason, confidence, createdAt: stamp() }
284
+ if (linkSource) result.source = linkSource
122
285
  const found = links.findIndex(link => link.to === toId && link.kind === kind)
123
286
  if (found >= 0) links[found] = result
124
287
  else {
125
288
  if (links.length >= 100) throw new Error('memory already has 100 links; review the existing connections first')
126
289
  links.push(result)
127
290
  }
128
- source.links = links
291
+ origin.links = links
129
292
  })
130
293
  return { from: fromId, ...result }
131
294
  }
@@ -145,7 +308,7 @@ export function unlinkEntry({ libraryId, fromId, toId, kind }) {
145
308
  return { removed }
146
309
  }
147
310
 
148
- export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 200 }) {
311
+ export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 200, includeOrphans = false }) {
149
312
  const scope = scopeFor({ exec, libraryId })
150
313
  const index = loadPrivateIndex(scope.libraryId)
151
314
  const entities = readEntities(scope.libraryId).entities
@@ -159,7 +322,9 @@ export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 2
159
322
  if (!LINK_KINDS.includes(link.kind)) continue
160
323
  // A deleted target gets a placeholder; an inaccessible target gets no hint.
161
324
  if (index.entries[link.to] && !visible.has(link.to)) continue
162
- const edge = { from, to: link.to, kind: link.kind, note: link.note, confidence: link.confidence ?? 0.7 }
325
+ // source is display metadata only ('auto' edges get a badge in the graph
326
+ // view); an older link without the field reports null instead of undefined.
327
+ const edge = { from, to: link.to, kind: link.kind, note: link.note, confidence: link.confidence ?? 0.7, source: link.source || null }
163
328
  edges.push(edge)
164
329
  for (const [a, b] of [[from, link.to], [link.to, from]]) {
165
330
  if (!adjacency.has(a)) adjacency.set(a, new Set())
@@ -181,9 +346,16 @@ export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 2
181
346
  frontier = next
182
347
  }
183
348
  } else {
349
+ // Orphan entities — no edge from any visible entry — are hidden by default.
350
+ // After the permanent-delete cascade and pruneOrphanEntities they should not
351
+ // exist on disk at all; a leftover from old data is invisible weight, and a
352
+ // session-scoped viewer already never saw them (the check below). Pass
353
+ // includeOrphans to see what an old entities.json still holds. An explicit
354
+ // focusId always wins: asking for a node by id is intent, not browsing.
184
355
  for (const id of [...visible, ...Object.keys(entities), ...adjacency.keys()]) {
185
356
  if (selected.size >= cap) break
186
357
  if (sessionIdOf(exec) && !visible.has(id) && !adjacency.has(id)) continue
358
+ if (!includeOrphans && entities[id] && !adjacency.has(id)) continue
187
359
  selected.add(id)
188
360
  }
189
361
  }
@@ -207,8 +379,12 @@ export function recallRelations(libraryId, index, id, sessionId) {
207
379
  const to = from === id ? link.to : from
208
380
  const target = index.entries[to]
209
381
  if (target && (!isMemoryEligible(target, { automatic: true }) || !visibleToSession(target, sessionId))) continue
210
- if (!target && !entities[to]) continue
211
- out.push({ id: to, title: target?.title || entities[to]?.name, kind: link.kind, note: String(link.note || '').slice(0, 80), confidence: link.confidence ?? 0.7 })
382
+ // A dangling edge (entry and entity both gone) used to be dropped here
383
+ // while getGraph rendered the same disk state as a [deleted] placeholder —
384
+ // the two views disagreed. Both now report the edge and mark it `missing`;
385
+ // the caller filters if it wants the old silence back.
386
+ const missing = !target && !entities[to]
387
+ out.push({ id: to, title: target?.title || entities[to]?.name || '[deleted]', kind: link.kind, note: String(link.note || '').slice(0, 80), confidence: link.confidence ?? 0.7, missing })
212
388
  }
213
389
  }
214
390
  return out.sort((a, b) => b.confidence - a.confidence).slice(0, 2)
package/lib/inbound.js CHANGED
@@ -155,6 +155,25 @@ export function hasPendingInbound(sessionId) {
155
155
  return Boolean(inbound?.pending?.length) && !(Date.parse(inbound.retryAt || '') > Date.now())
156
156
  }
157
157
 
158
+ /**
159
+ * 本轮真实用户消息的纯文本,直接取自持久化的 inbound journal(pending 队列)。
160
+ *
161
+ * 词典命中的唯一文本源(docs/memory-semantics.md §8.13):队列里的每一条都是
162
+ * ingestInboundBatch 校验过的显式人类消息,所以插件通知、工具结果、assistant
163
+ * 自言自语结构上进不来。调用方要在 noteNativeUserMessages 把原生消息从 pending
164
+ * 摘走**之前**取(inject.js 的顺序就是这样的)。
165
+ */
166
+ export function inboundUserTexts(binding) {
167
+ const pending = Array.isArray(binding?.inbound?.pending) ? binding.inbound.pending : []
168
+ return pending
169
+ .map(item => (Array.isArray(item?.content) ? item.content : [])
170
+ .filter(block => block?.type === 'text' && typeof block.text === 'string')
171
+ .map(block => block.text.trim())
172
+ .filter(Boolean)
173
+ .join(' '))
174
+ .filter(Boolean)
175
+ }
176
+
158
177
  export async function pendingInbound(sessionId, ctx, session) {
159
178
  const first = journal(getSessionBinding(sessionId)).pending
160
179
  // Persisted queues can recover media collection after a restart.
package/lib/index.js CHANGED
@@ -5,7 +5,8 @@ import { registerRpc } from './rpc.js'
5
5
  import { registerCycle } from './cycle.js'
6
6
  import { syncPromptDefaults } from './prompts.js'
7
7
  import { ensureEngine, engineStatus, ENGINE_VERSION } from './engine.js'
8
- import { getSettings, getMemoryBackend, backupDatabases, flushState } from './state.js'
8
+ import { getSettings, getMemoryBackend, backupDatabases, flushState, listLibraries } from './state.js'
9
+ import { pruneOrphanEntitiesAll } from './graph.js'
9
10
  import { closeEmbeddingWorkers } from './local-embedding-manager.js'
10
11
  import { bootstrapLocalVector } from './local-vector-service.js'
11
12
  import { vectorStatus } from './vector-profile.js'
@@ -62,6 +63,35 @@ function startEngineBootstrap(ctx) {
62
63
  }
63
64
  }
64
65
 
66
+ // 兜底扫描(老数据升级):旧版本的永久删除只删条目本身,别的条目 links 里指向它的
67
+ // 入边和 entities.json 里的实体节点会留下来,restoreGraphData 的部分恢复也会。这里在
68
+ // 启动后延迟跑一次全库 pruneOrphanEntitiesAll。选「每次启动延迟扫」而不是「首次
69
+ // getGraph + 持久化已清理标记」:扫描只是对小 JSON 文件的读 + 偶尔一次原子写,健康
70
+ // 数据零写入(no-op),幂等且能在任何一次删除路径将来回归时自愈;持久化标记省不下
71
+ // 多少成本,反而多一个「标记存在但数据又坏了」的状态要维护。延迟 4 秒(在 1.5s 引擎
72
+ // 引导与 2.5s 向量引导之后)让激活路径零阻塞;新安装没有库、没有实体,扫描直接 no-op。
73
+ // 单库失败只收集不抛出,整段失败只 warn —— 加载绝不能被一次清理绊倒。
74
+ function startOrphanEntitySweep(ctx) {
75
+ try {
76
+ const timer = setTimeout(() => {
77
+ try {
78
+ const result = pruneOrphanEntitiesAll()
79
+ for (const failure of result.failed) {
80
+ try { ctx.logger?.warn?.(`dsh-memu-memory: orphan entity sweep skipped ${failure.libraryId}: ${failure.error}`) } catch {}
81
+ }
82
+ if (result.removed > 0) {
83
+ try { ctx.logger?.info?.(`dsh-memu-memory: orphan entity sweep collected ${result.removed} node(s) across ${result.scanned} library(ies)`) } catch {}
84
+ }
85
+ } catch (error) {
86
+ try { ctx.logger?.warn?.(`dsh-memu-memory: orphan entity sweep failed: ${error?.message || error}`) } catch {}
87
+ }
88
+ }, 4000)
89
+ timer.unref?.()
90
+ } catch (error) {
91
+ try { ctx.logger?.warn?.(`dsh-memu-memory: orphan entity sweep skipped: ${error?.message || error}`) } catch {}
92
+ }
93
+ }
94
+
65
95
  export function apply(ctx, config) {
66
96
  // A conversation that names an agent preset composes its model-facing plugin set from
67
97
  // that preset's cordis.yml alone, and that subtree never delivers `agent/pre-step` to
@@ -121,6 +151,8 @@ export function apply(ctx, config) {
121
151
  registerRpc(ctx)
122
152
  startEngineBootstrap(ctx)
123
153
  startLocalVectorBootstrap(ctx)
154
+ // 进程级清理:preset 挂载共享同一个数据目录,只由拥有全局表面的实例扫一次。
155
+ startOrphanEntitySweep(ctx)
124
156
  }
125
157
 
126
158
  // The embedding worker is a process-global singleton, so it outlives any single