dsh-memu-memory 1.2.0 → 1.2.1

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/lib/curator.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  globalDbPath, loadGlobalIndex, updateGlobalIndex, newMemoryId, getSettings, effectiveVectorRoute,
3
3
  } from './state.js'
4
- import { commit } from './memu.js'
4
+ import { commit, forget } from './memu.js'
5
5
  import { loadPrompt } from './prompts.js'
6
6
  import { retryStoreWrite, redactSecrets, warn } from './paths.js'
7
7
  import { record } from './activity-log.js'
8
- import { assertVectorWriteAllowed, hasActiveVectorGeneration, readVectorState, withVectorWrite } from './vector-profile.js'
8
+ import { activeEntryStamp, assertVectorWriteAllowed, hasActiveVectorGeneration, readVectorState, withVectorWrite } from './vector-profile.js'
9
9
 
10
10
  const MAX_SESSIONS = 10
11
11
  const MAX_TOTAL_CHARS = 90_000
@@ -214,6 +214,43 @@ export function cancelCurationJob(id) {
214
214
  return true
215
215
  }
216
216
 
217
+ // 孤儿窗口补偿(这是补偿设计,不是事务):curator 是 commit-before-index —— 批量提交
218
+ // 成功而全局索引写失败时,引擎库里会留下索引不认识的孤儿行。这里对刚提交的每个 name
219
+ // 逐个 forget,尽力撤销;这是尽力而为,不是回滚保证:删不掉的必须可见。补偿结果(包括
220
+ // 哪些补上了、哪些没补上)逐条记录进活动日志 kind:'curate',绝不吞掉。
221
+ async function compensateCuratorCommit(originalError, { files, route }) {
222
+ const db = globalDbPath()
223
+ const compensated = []
224
+ const failed = []
225
+ for (const file of files) {
226
+ try {
227
+ const result = await forget({ db, name: file.name })
228
+ if (!result || typeof result !== 'object' || result.ok === false || result.error || result.parseWarning
229
+ || !Number.isSafeInteger(result.deleted) || result.deleted < 0) {
230
+ throw new Error('backend compensation delete was not acknowledged')
231
+ }
232
+ compensated.push(file.name)
233
+ } catch (compensationError) {
234
+ failed.push({ name: file.name, error: String(compensationError?.message || compensationError) })
235
+ }
236
+ }
237
+ if (failed.length) {
238
+ record({
239
+ kind: 'curate', scope: 'global', ok: false, level: 'error',
240
+ message: `全局提炼索引写失败后的补偿不完整:${compensated.length}/${files.length} 条已从向量库撤销,${failed.length} 条撤销失败(引擎库残留孤儿行,需手动清理)`,
241
+ detail: { route, compensated, failed: failed.slice(0, 20), originalError: String(originalError?.message || originalError) },
242
+ refs: files.map((file) => file.name).slice(0, 20),
243
+ })
244
+ } else {
245
+ record({
246
+ kind: 'curate', scope: 'global', ok: true, level: 'warn',
247
+ message: `全局提炼索引写失败,已从向量库回滚刚提交的 ${compensated.length} 条记忆(补偿成功;索引未变,重跑提炼即可)`,
248
+ detail: { route, compensated, failed: [], originalError: String(originalError?.message || originalError) },
249
+ refs: files.map((file) => file.name).slice(0, 20),
250
+ })
251
+ }
252
+ }
253
+
217
254
  // Exported for the audit regression suite: merging the commit decision logic
218
255
  // into startGlobalCuration would require standing up subagents to test it.
219
256
  export async function commitCuratorOutput(output, { job }) {
@@ -302,36 +339,54 @@ export async function commitCuratorOutput(output, { job }) {
302
339
  // Managed agent mode still commits the exact same files to the active local
303
340
  // vector database; only pending_review entries remain index-only.
304
341
  const route = engineRoute()
305
- if (route !== 'lexical') {
306
- await commit({
307
- payload: { recall_files: files, resource: [], user: {} },
308
- route, db: globalDbPath(),
309
- })
310
- }
311
-
312
- if (job?.abort?.signal?.aborted) throw new Error('cancelled by user')
313
- await retryStoreWrite(() => updateGlobalIndex(fresh => {
314
- for (const { id, entry } of pending) fresh.entries[id] = entry
315
- for (const { id, entry, op, targetId, previous } of updates) {
316
- if (previous && JSON.stringify(fresh.entries[targetId]) !== JSON.stringify(previous)) {
317
- const heldId = newMemoryId('gmem')
318
- fresh.entries[heldId] = { ...entry, status: 'pending_review', conflictingWith: targetId, reviewReason: 'target changed while curator was running' }
319
- continue
320
- }
321
- if (op === 'merge' && previous) {
322
- entry.content = entry.content || previous.content
323
- entry.title = entry.title || previous.title
324
- entry.evidence = [...(previous.evidence || []), ...(entry.evidence || [])].slice(-40)
325
- }
326
- fresh.entries[id] = entry
327
- if ((op === 'supersede' || op === 'merge') && targetId && fresh.entries[targetId]) {
328
- fresh.entries[targetId].status = 'superseded'
329
- fresh.entries[targetId].supersededBy = id
330
- fresh.entries[targetId].validTo = now
331
- fresh.entries[targetId].updatedAt = now
332
- }
342
+ let committedToBackend = false
343
+ try {
344
+ if (route !== 'lexical') {
345
+ await commit({
346
+ payload: { recall_files: files, resource: [], user: {} },
347
+ route, db: globalDbPath(),
348
+ })
349
+ committedToBackend = true
350
+ // 整理写入与单条写入同口径:提交成功就在条目上落"已入向量库"戳,否则这批
351
+ // 全局记忆在向量计数(membership 的戳腿)里永远缺失。pending 不提交,不戳。
352
+ const vector = activeEntryStamp(route)
353
+ if (vector) for (const update of updates) update.entry.vector = vector
333
354
  }
334
- }))
355
+
356
+ if (job?.abort?.signal?.aborted) throw new Error('cancelled by user')
357
+ await retryStoreWrite(() => updateGlobalIndex(fresh => {
358
+ for (const { id, entry } of pending) fresh.entries[id] = entry
359
+ for (const { id, entry, op, targetId, previous } of updates) {
360
+ if (previous && JSON.stringify(fresh.entries[targetId]) !== JSON.stringify(previous)) {
361
+ const heldId = newMemoryId('gmem')
362
+ // 提交成功的只有 `id` 这一行,held 克隆从未进过向量库——它绝不能继承
363
+ // "提交成功即落戳"的 entry.vector:向量库里的行名是 `id` 而不是 heldId,
364
+ // 带戳会让 membership 把一条不存在的行计进来,而真实存在的 `id` 行反而
365
+ // 无处归属(它根本不会按这个克隆写进索引)。
366
+ const held = { ...entry, status: 'pending_review', conflictingWith: targetId, reviewReason: 'target changed while curator was running' }
367
+ delete held.vector
368
+ fresh.entries[heldId] = held
369
+ continue
370
+ }
371
+ if (op === 'merge' && previous) {
372
+ entry.content = entry.content || previous.content
373
+ entry.title = entry.title || previous.title
374
+ entry.evidence = [...(previous.evidence || []), ...(entry.evidence || [])].slice(-40)
375
+ }
376
+ fresh.entries[id] = entry
377
+ if ((op === 'supersede' || op === 'merge') && targetId && fresh.entries[targetId]) {
378
+ fresh.entries[targetId].status = 'superseded'
379
+ fresh.entries[targetId].supersededBy = id
380
+ fresh.entries[targetId].validTo = now
381
+ fresh.entries[targetId].updatedAt = now
382
+ }
383
+ }
384
+ }))
385
+ } catch (error) {
386
+ // 提交成功之后(abort 检查 / 索引写)的任何失败都会留下孤儿行:尽力撤销。
387
+ if (committedToBackend) await compensateCuratorCommit(error, { files, route })
388
+ throw error
389
+ }
335
390
  })
336
391
  const summary = {
337
392
  files,
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']
@@ -60,6 +60,16 @@ export function restoreGraphData(libraryId, data, memoryIdMap) {
60
60
  if (!evidenceIds.length) continue
61
61
  updateRelationship({ libraryId, fromId: entityMap[relationship.fromId], toId: entityMap[relationship.toId], state: relationship.state, evidenceIds, reason: relationship.reason || 'Imported relationship with explicit evidence.' })
62
62
  }
63
+ // A backup carries the whole entity store, but only the memories that made it
64
+ // through the export/import mapping come back, so entities nothing points at
65
+ // any more would sit in the file forever as invisible weight. Collect them
66
+ // once the remap is done; a prune failure must not fail a restore that
67
+ // already succeeded, and the startup sweep would catch it on the next launch.
68
+ try {
69
+ pruneOrphanEntities(scope.libraryId)
70
+ } catch (error) {
71
+ warn(`orphan entity prune after graph restore skipped: ${error?.message || error}`)
72
+ }
63
73
  }
64
74
 
65
75
  export function createEntity({ exec, libraryId, name, kind = 'person', aliases = [] }) {
@@ -102,6 +112,119 @@ export function renameEntity({ libraryId, entityId, name, aliases = [] }) {
102
112
  })
103
113
  }
104
114
 
115
+ // What keeps an entity alive. Edges only live in one direction: an entry's
116
+ // links[] names the entity, the entity stores nothing back. On top of that,
117
+ // relationship snapshots (same file, keyed by the endpoint pair) describe the
118
+ // pair with history and evidence that outlive the individual entries — deleting
119
+ // an endpoint would leave a snapshot whose endpoints no longer resolve, so a
120
+ // snapshot counts as a reference too. An entity referenced by neither is an
121
+ // *orphan*: unreachable except as a graph placeholder, and safe to collect.
122
+ // Orphans come from old builds whose permanent delete dropped the memory row
123
+ // without cascading, and from partial backups.
124
+
125
+ /**
126
+ * Remove every entity in one library that no entry links to and no relationship
127
+ * snapshot references. Idempotent: on healthy data it writes nothing. Returns
128
+ * `{ removed, total }` with the removed entity ids.
129
+ */
130
+ export function pruneOrphanEntities(libraryId) {
131
+ const scope = scopeFor({ libraryId })
132
+ const file = entitiesPath(scope.libraryId)
133
+ return withFileLock(file, () => {
134
+ const store = readEntities(scope.libraryId)
135
+ const ids = Object.keys(store.entities)
136
+ if (!ids.length) return { removed: [], total: 0 }
137
+ const referenced = new Set()
138
+ for (const entry of Object.values(loadPrivateIndex(scope.libraryId).entries || {})) {
139
+ for (const link of entry.links || []) referenced.add(link.to)
140
+ }
141
+ for (const relationship of Object.values(store.relationships || {})) {
142
+ referenced.add(relationship.fromId)
143
+ referenced.add(relationship.toId)
144
+ }
145
+ const removed = ids.filter(id => !referenced.has(id))
146
+ // Write only when something actually goes, so the common case never touches
147
+ // the file (mtime-based caches, backups and concurrent readers all benefit).
148
+ if (removed.length) {
149
+ for (const id of removed) delete store.entities[id]
150
+ writeJson(file, store)
151
+ }
152
+ return { removed, total: ids.length }
153
+ })
154
+ }
155
+
156
+ /**
157
+ * The startup sweep: run pruneOrphanEntities over every active library. One
158
+ * corrupt store must not stop the rest, so per-library failures are collected
159
+ * instead of thrown; the caller decides how to log them. Archived libraries are
160
+ * deliberately left alone (frozen user data — restoring one runs the prune via
161
+ * restoreGraphData anyway). Returns `{ scanned, removed, failed }`.
162
+ */
163
+ export function pruneOrphanEntitiesAll() {
164
+ const libraries = listLibraries()
165
+ const result = { scanned: libraries.length, removed: 0, failed: [] }
166
+ for (const library of libraries) {
167
+ try {
168
+ result.removed += pruneOrphanEntities(library.id).removed.length
169
+ } catch (error) {
170
+ result.failed.push({ libraryId: library.id, error: String(error?.message || error) })
171
+ }
172
+ }
173
+ return result
174
+ }
175
+
176
+ /**
177
+ * Delete an entity. This is graph bookkeeping, not a memory write: the entity
178
+ * row goes, no memory text is touched. An entity still referenced by links or
179
+ * relationship snapshots is refused by default — silently deleting it would
180
+ * turn every one of those edges into a `[deleted]` placeholder and rewrite what
181
+ * those memories are *about*. `force` deletes through that: inbound links are
182
+ * removed from every entry (not session-filtered — a link left behind in an
183
+ * entry this conversation cannot see would dangle exactly the way this guard
184
+ * exists to prevent, and the entry text itself is untouched) and relationship
185
+ * snapshots on the endpoints are dropped with their history, which is the loss
186
+ * `force` is asking for. Index links are cleaned before the entity row so a
187
+ * crash between the two writes leaves at worst a collectable orphan, never a
188
+ * link to an entity that is already gone.
189
+ */
190
+ export function deleteEntity({ exec, libraryId, entityId, force = false }) {
191
+ const scope = scopeFor({ exec, libraryId })
192
+ if (!entityId || typeof entityId !== 'string') throw new Error('entityId is required')
193
+ const file = entitiesPath(scope.libraryId)
194
+ return withFileLock(file, () => {
195
+ const store = readEntities(scope.libraryId)
196
+ if (!store.entities[entityId]) throw new Error('unknown entity')
197
+ const index = loadPrivateIndex(scope.libraryId)
198
+ let inbound = 0
199
+ for (const entry of Object.values(index.entries || {})) {
200
+ for (const link of entry.links || []) if (link.to === entityId) inbound += 1
201
+ }
202
+ const relationshipKeys = Object.keys(store.relationships || {})
203
+ .filter(key => { const [from, to] = JSON.parse(key); return from === entityId || to === entityId })
204
+ if ((inbound || relationshipKeys.length) && !force) {
205
+ const parts = []
206
+ if (inbound) parts.push(`${inbound} link(s) point at it`)
207
+ if (relationshipKeys.length) parts.push(`${relationshipKeys.length} relationship snapshot(s) reference it`)
208
+ throw new Error(`entity is still referenced: ${parts.join(' and ')}; remove the links first or pass force to delete the entity with them`)
209
+ }
210
+ let removedLinks = 0
211
+ if (inbound) {
212
+ updatePrivateIndex(scope.libraryId, fresh => {
213
+ for (const entry of Object.values(fresh.entries)) {
214
+ if (!Array.isArray(entry.links) || !entry.links.some(link => link.to === entityId)) continue
215
+ const kept = entry.links.filter(link => link.to !== entityId)
216
+ removedLinks += entry.links.length - kept.length
217
+ entry.links = kept
218
+ }
219
+ })
220
+ }
221
+ for (const key of relationshipKeys) delete store.relationships[key]
222
+ delete store.entities[entityId]
223
+ writeJson(file, store)
224
+ return { entityId, deleted: true, removedLinks, removedRelationships: relationshipKeys.length }
225
+ })
226
+ }
227
+
105
228
  export function linkEntries({ exec, libraryId, fromId, toId, kind, note, confidence = 0.7 }) {
106
229
  const scope = scopeFor({ exec, libraryId })
107
230
  if (!LINK_KINDS.includes(kind)) throw new Error(`link kind must be ${LINK_KINDS.join(', ')}`)
@@ -145,7 +268,7 @@ export function unlinkEntry({ libraryId, fromId, toId, kind }) {
145
268
  return { removed }
146
269
  }
147
270
 
148
- export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 200 }) {
271
+ export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 200, includeOrphans = false }) {
149
272
  const scope = scopeFor({ exec, libraryId })
150
273
  const index = loadPrivateIndex(scope.libraryId)
151
274
  const entities = readEntities(scope.libraryId).entities
@@ -181,9 +304,16 @@ export function getGraph({ exec, libraryId, focusId = null, depth = 1, limit = 2
181
304
  frontier = next
182
305
  }
183
306
  } else {
307
+ // Orphan entities — no edge from any visible entry — are hidden by default.
308
+ // After the permanent-delete cascade and pruneOrphanEntities they should not
309
+ // exist on disk at all; a leftover from old data is invisible weight, and a
310
+ // session-scoped viewer already never saw them (the check below). Pass
311
+ // includeOrphans to see what an old entities.json still holds. An explicit
312
+ // focusId always wins: asking for a node by id is intent, not browsing.
184
313
  for (const id of [...visible, ...Object.keys(entities), ...adjacency.keys()]) {
185
314
  if (selected.size >= cap) break
186
315
  if (sessionIdOf(exec) && !visible.has(id) && !adjacency.has(id)) continue
316
+ if (!includeOrphans && entities[id] && !adjacency.has(id)) continue
187
317
  selected.add(id)
188
318
  }
189
319
  }
@@ -207,8 +337,12 @@ export function recallRelations(libraryId, index, id, sessionId) {
207
337
  const to = from === id ? link.to : from
208
338
  const target = index.entries[to]
209
339
  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 })
340
+ // A dangling edge (entry and entity both gone) used to be dropped here
341
+ // while getGraph rendered the same disk state as a [deleted] placeholder —
342
+ // the two views disagreed. Both now report the edge and mark it `missing`;
343
+ // the caller filters if it wants the old silence back.
344
+ const missing = !target && !entities[to]
345
+ 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
346
  }
213
347
  }
214
348
  return out.sort((a, b) => b.confidence - a.confidence).slice(0, 2)
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