dsh-memu-memory 1.2.1 → 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 (87) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +12 -5
  3. package/README.zh.md +4 -2
  4. package/docs/contributing.md +16 -3
  5. package/docs/host-api.md +3 -1
  6. package/docs/memory-semantics.md +21 -2
  7. package/docs/troubleshooting.md +42 -4
  8. package/lib/backup.js +24 -2
  9. package/lib/binding-diagnostics.js +2 -2
  10. package/lib/client.js +1361 -146
  11. package/lib/consolidation-worker.js +30 -8
  12. package/lib/curator-evidence.js +131 -0
  13. package/lib/curator-policy.js +81 -0
  14. package/lib/curator.js +1252 -91
  15. package/lib/cycle.js +3 -0
  16. package/lib/embedding-download.js +39 -4
  17. package/lib/engine.js +6 -3
  18. package/lib/global-graph.js +282 -0
  19. package/lib/graph.js +51 -9
  20. package/lib/inbound.js +19 -0
  21. package/lib/inject.js +277 -17
  22. package/lib/lexicon.js +1115 -0
  23. package/lib/local-vector-service.js +8 -6
  24. package/lib/managed-runtime.js +33 -2
  25. package/lib/memory.js +282 -17
  26. package/lib/memu.js +2 -2
  27. package/lib/migrate.js +83 -2
  28. package/lib/private-links.js +587 -0
  29. package/lib/private.js +32 -3
  30. package/lib/prompts/global-curator.md +76 -19
  31. package/lib/prompts/private-consolidation.coding.md +4 -1
  32. package/lib/prompts/private-consolidation.md +3 -1
  33. package/lib/prompts/private-consolidation.mixed.md +3 -1
  34. package/lib/prompts/private-consolidation.rp.md +3 -1
  35. package/lib/prompts.js +1 -1
  36. package/lib/rpc.js +577 -9
  37. package/lib/state.js +4 -1
  38. package/lib/text-retrieval.js +661 -0
  39. package/lib/text-similarity.js +36 -18
  40. package/lib/tools.js +168 -10
  41. package/lib/webui/standalone.css +17 -0
  42. package/lib/webui/standalone.js +2 -0
  43. package/package.json +1 -1
  44. package/scripts/acceptance-local.mjs +8 -5
  45. package/scripts/curator-pair-calibrate.mjs +259 -0
  46. package/scripts/lexicon-calibrate.mjs +230 -0
  47. package/scripts/lexicon-dedupe.mjs +318 -0
  48. package/tests/absorb-review.test.mjs +353 -0
  49. package/tests/appendix-b2.test.mjs +2 -2
  50. package/tests/client-export.test.mjs +520 -0
  51. package/tests/client-render.test.mjs +19 -3
  52. package/tests/consolidation-fork.test.mjs +14 -12
  53. package/tests/consolidation-snapshot.test.mjs +39 -0
  54. package/tests/curator-absorb.test.mjs +443 -0
  55. package/tests/curator-calibrate.test.mjs +173 -0
  56. package/tests/curator-clusters.test.mjs +281 -0
  57. package/tests/curator-commit.test.mjs +52 -5
  58. package/tests/curator-evidence.test.mjs +102 -0
  59. package/tests/curator-link.test.mjs +316 -0
  60. package/tests/curator-pairing.test.mjs +252 -0
  61. package/tests/curator-probe.test.mjs +15 -6
  62. package/tests/curator-review-mode.test.mjs +314 -0
  63. package/tests/disclosure.test.mjs +2 -1
  64. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  65. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  66. package/tests/fixtures/lexicon-stub.mjs +284 -0
  67. package/tests/graph-cascade.test.mjs +35 -2
  68. package/tests/graph.test.mjs +96 -0
  69. package/tests/help-page.test.mjs +415 -0
  70. package/tests/inbound.test.mjs +6 -3
  71. package/tests/inject-guide.test.mjs +28 -0
  72. package/tests/inject.test.mjs +157 -5
  73. package/tests/lexicon-calibrate.test.mjs +48 -0
  74. package/tests/lexicon-dedupe.test.mjs +171 -0
  75. package/tests/lexicon-frequency.test.mjs +179 -0
  76. package/tests/lexicon-inject.test.mjs +410 -0
  77. package/tests/lexicon-perf.test.mjs +436 -0
  78. package/tests/lexicon-ui.test.mjs +547 -0
  79. package/tests/lexicon.test.mjs +922 -0
  80. package/tests/managed-runtime.test.mjs +32 -0
  81. package/tests/private-links.test.mjs +445 -0
  82. package/tests/prompts.test.mjs +35 -0
  83. package/tests/recall-dedup.test.mjs +33 -4
  84. package/tests/tool-schema.test.mjs +1 -1
  85. package/tests/tools.test.mjs +112 -3
  86. package/tests/webui-auth.test.mjs +348 -2
  87. package/tests/webui.test.mjs +188 -0
@@ -0,0 +1,284 @@
1
+ // 集成回归时移除:lib/lexicon.js 真身落地后,删除本文件、lexicon-stub-hooks.mjs 以及
2
+ // tests/lexicon-ui.test.mjs 顶部的 register 调用。
3
+ //
4
+ // 轻量替身:只覆盖 P3 消费的冻结签名与四条语义——非用户来源的普通词条直接 active、
5
+ // sensitive trigger 的模型/curator 提案 typed rejection、非用户改敏感字段 typed
6
+ // rejection、active/archived 生命周期(archive/restore)。不承诺与真核心一致的锁、
7
+ // CAS、原子写或容量上限;真实文件落在 dataDir()/lexicon/ 下,因此备份清单与 restore
8
+ // allowlist 测试仍然走真实磁盘。
9
+ import path from 'node:path'
10
+ import { dataDir, ensureDir, readJson, writeJson } from '../../lib/paths.js'
11
+
12
+ const KINDS = ['slang', 'meme', 'trigger', 'term']
13
+ // 公开生命周期(§2.1):pending/rejected 只作为 legacy/墓碑数据存在,新写不产出。
14
+ const PUBLIC_STATUSES = ['active', 'archived']
15
+ const SENSITIVE_FIELDS = ['term', 'kind', 'sensitivity', 'context', 'meaning']
16
+ // 与设计 §1.2 同向的近似规范化:NFKC、大小写折叠、空白折叠、去标点/符号。
17
+ const normalizeLexeme = (value) => String(value ?? '')
18
+ .normalize('NFKC')
19
+ .toLowerCase()
20
+ .replace(/\s+/g, ' ')
21
+ .replace(/[\p{P}\p{S}]+/gu, '')
22
+ .trim()
23
+
24
+ const fileFor = (libraryId) => path.join(dataDir(), 'lexicon', `${libraryId}.json`)
25
+ const emptyStore = (libraryId) => ({ version: 1, revision: 0, libraryId, terms: {}, rejected: {} })
26
+
27
+ export function loadLexicon(libraryId) {
28
+ const raw = readJson(fileFor(libraryId), null)
29
+ if (!raw || typeof raw !== 'object' || raw.terms == null) return emptyStore(libraryId)
30
+ return {
31
+ version: 1,
32
+ revision: Number(raw.revision) || 0,
33
+ libraryId,
34
+ terms: raw.terms && typeof raw.terms === 'object' ? raw.terms : {},
35
+ rejected: raw.rejected && typeof raw.rejected === 'object' ? raw.rejected : {},
36
+ }
37
+ }
38
+
39
+ function saveLexicon(store) {
40
+ ensureDir(path.dirname(fileFor(store.libraryId)))
41
+ writeJson(fileFor(store.libraryId), {
42
+ version: store.version,
43
+ revision: store.revision,
44
+ libraryId: store.libraryId,
45
+ terms: store.terms,
46
+ rejected: store.rejected || {},
47
+ })
48
+ }
49
+
50
+ function fail(code, message) {
51
+ const error = new Error(message)
52
+ error.code = code
53
+ return error
54
+ }
55
+
56
+ // 与 lib/context.js 的 contextVisible 同语义的精简版:P3 只依赖"越界条目搜不到"。
57
+ function contextVisible(context, current) {
58
+ if (!context) return true
59
+ for (const key of ['channelId', 'characterId', 'worldId', 'branchId']) {
60
+ if (context[key] && context[key] !== current?.[key]) return false
61
+ }
62
+ if (Array.isArray(context.knownBy) && context.knownBy.length && !context.knownBy.includes(current?.characterId)) return false
63
+ return true
64
+ }
65
+
66
+ let seq = 0
67
+ const nextId = () => {
68
+ seq += 1
69
+ return `lex-stub-${String(seq).padStart(4, '0')}`
70
+ }
71
+
72
+ function sensitiveDelta(existing, next) {
73
+ return SENSITIVE_FIELDS.filter((field) => {
74
+ const before = field === 'context' ? JSON.stringify(existing.context || {}) : String(existing[field] ?? '')
75
+ const after = field === 'context' ? JSON.stringify(next.context || {}) : String(next[field] ?? '')
76
+ return before !== after
77
+ })
78
+ }
79
+
80
+ /**
81
+ * 写入/更新:与真核心同一套公开语义(§2.1)。created:false + notOwned:true 表示
82
+ * 撞上了一条非用户来源不能动的词条(archived/rejected/legacy 修订草稿)。
83
+ */
84
+ export function upsertLexiconTerm(options = {}) {
85
+ const libraryId = String(options.libraryId || '')
86
+ if (!libraryId) throw fail('LEXICON_LIBRARY_REQUIRED', 'libraryId is required')
87
+ const term = String(options.term || '').trim()
88
+ const kind = String(options.kind || 'term')
89
+ const meaning = String(options.meaning || '').trim()
90
+ if (!term) throw fail('LEXICON_TERM_REQUIRED', 'term is required')
91
+ if (!meaning) throw fail('LEXICON_MEANING_REQUIRED', 'meaning is required')
92
+ if (!KINDS.includes(kind)) throw fail('LEXICON_KIND_INVALID', `unknown lexicon kind: ${kind}`)
93
+ const source = String(options.source || 'user')
94
+ const isUser = source === 'user'
95
+ // trigger 默认 sensitive(§1.2)。
96
+ const sensitivity = kind === 'trigger' && options.sensitivity == null ? 'sensitive' : String(options.sensitivity || 'normal')
97
+ // §2.1:非用户来源不能建 sensitive trigger —— typed rejection。
98
+ if (!isUser && kind === 'trigger' && sensitivity === 'sensitive') {
99
+ throw fail('LEXICON_SENSITIVE_FORBIDDEN', 'sensitive triggers are user-managed; model/curator proposals are rejected')
100
+ }
101
+ // pending/rejected 不可由新调用方设置:统一收口成 active(非用户来源也不可设 archived)。
102
+ const requested = PUBLIC_STATUSES.includes(String(options.status)) ? String(options.status) : 'active'
103
+ const wantedStatus = isUser ? requested : 'active'
104
+
105
+ const store = loadLexicon(libraryId)
106
+ const normalizedTerm = normalizeLexeme(term)
107
+ const aliases = Array.isArray(options.aliases) ? options.aliases.map((a) => String(a)) : []
108
+ const normalizedAliases = aliases.map(normalizeLexeme).filter(Boolean)
109
+ const context = options.context && typeof options.context === 'object' ? { ...options.context } : {}
110
+ const now = new Date().toISOString()
111
+ // 冲突:同一规范词(term 或 alias)已存在时更新既有词条,不新建(§1.2 唯一性)。
112
+ // 归档条目按 term 等值参与(includeArchived 口径)。
113
+ let targetId = null
114
+ for (const [id, e] of Object.entries(store.terms)) {
115
+ const status = e?.status || 'active'
116
+ const normalizedEntryTerm = normalizeLexeme(e?.term)
117
+ if (status === 'archived') {
118
+ if (normalizedEntryTerm === normalizedTerm) { targetId = id; break }
119
+ continue
120
+ }
121
+ const keys = new Set([normalizedEntryTerm, ...(Array.isArray(e?.normalizedAliases) ? e.normalizedAliases : []).map((a) => String(a).toLowerCase())])
122
+ if (keys.has(normalizedTerm) || normalizedAliases.some((a) => keys.has(a))) { targetId = id; break }
123
+ }
124
+ const created = targetId === null
125
+ let revived = false
126
+ if (!created) {
127
+ const e = store.terms[targetId]
128
+ const status = e.status || 'active'
129
+ // archived / rejected / legacy 修订草稿:非用户来源不复活、不改写。
130
+ const immutable = status === 'archived' || status === 'rejected' || (status === 'pending' && Boolean(e.revisionOf))
131
+ if (immutable && !isUser) return { term: e, termId: targetId, created: false, notOwned: true }
132
+ if (!isUser) {
133
+ if (e.kind === 'trigger' && e.sensitivity === 'sensitive') {
134
+ throw fail('LEXICON_SENSITIVE_FIELDS_FORBIDDEN', 'only the user manages sensitive triggers')
135
+ }
136
+ const changed = sensitiveDelta(e, { ...e, term, kind, sensitivity, context, meaning })
137
+ if (changed.length) throw fail('LEXICON_SENSITIVE_FIELDS_FORBIDDEN', `non-user writes cannot change ${changed.join(', ')}`)
138
+ }
139
+ const revivedEntry = status !== 'active'
140
+ e.term = term
141
+ e.normalizedTerm = normalizedTerm
142
+ e.kind = kind
143
+ e.meaning = meaning
144
+ if (options.usage != null) e.usage = String(options.usage)
145
+ e.aliases = aliases
146
+ e.normalizedAliases = normalizedAliases
147
+ if (options.context !== undefined) e.context = context
148
+ if (options.knownBy !== undefined) e.knownBy = Array.isArray(options.knownBy) ? options.knownBy.map(String) : []
149
+ if (options.visibility != null) e.visibility = String(options.visibility)
150
+ e.sensitivity = sensitivity
151
+ if (typeof options.strength === 'number') e.strength = options.strength
152
+ if (typeof options.confidence === 'number') e.confidence = options.confidence
153
+ e.status = isUser ? wantedStatus : 'active'
154
+ e.source = source
155
+ if (options.evidence !== undefined) e.evidence = Array.isArray(options.evidence) ? options.evidence : []
156
+ e.updatedAt = now
157
+ revived = revivedEntry
158
+ if (revivedEntry) {
159
+ delete e.revisionOf
160
+ delete e.previousRevision
161
+ e.reviewedAt = now
162
+ e.reviewedBy = source
163
+ e.reviewReason = null
164
+ if (status === 'rejected' && e.fingerprint) delete store.rejected[e.fingerprint]
165
+ }
166
+ } else {
167
+ targetId = nextId()
168
+ store.terms[targetId] = {
169
+ term,
170
+ normalizedTerm,
171
+ kind,
172
+ meaning,
173
+ usage: options.usage == null ? '' : String(options.usage),
174
+ examples: Array.isArray(options.examples) ? options.examples.map(String) : [],
175
+ aliases,
176
+ normalizedAliases,
177
+ context,
178
+ knownBy: Array.isArray(options.knownBy) ? options.knownBy.map(String) : [],
179
+ visibility: String(options.visibility || 'shared'),
180
+ sensitivity,
181
+ strength: typeof options.strength === 'number' ? options.strength : 0.5,
182
+ confidence: typeof options.confidence === 'number' ? options.confidence : null,
183
+ status: wantedStatus,
184
+ source,
185
+ evidence: Array.isArray(options.evidence) ? options.evidence : [],
186
+ sourceMemoryId: options.sourceMemoryId || null,
187
+ createdAt: now,
188
+ updatedAt: now,
189
+ reviewedAt: null,
190
+ reviewedBy: null,
191
+ reviewReason: null,
192
+ hitCounts: { candidate: 0, injection: 0, query: 0 },
193
+ lastInjectedAt: null,
194
+ }
195
+ }
196
+ store.revision += 1
197
+ saveLexicon(store)
198
+ return { term: store.terms[targetId], termId: targetId, created, ...(revived ? { revived: true } : {}) }
199
+ }
200
+
201
+ /** 归档(§2.1):只有 active → archived;不写 rejected 墓碑。 */
202
+ export function archiveLexiconTerm({ libraryId, termId } = {}) {
203
+ const store = loadLexicon(libraryId)
204
+ const e = store.terms[termId]
205
+ if (!e) throw fail('LEXICON_NOT_FOUND', 'unknown lexicon term')
206
+ const status = e.status || 'active'
207
+ if (status !== 'active') throw fail('LEXICON_INVALID_STATUS', `only active terms can be archived; ${termId} is ${status}`)
208
+ e.status = 'archived'
209
+ e.updatedAt = new Date().toISOString()
210
+ store.revision += 1
211
+ saveLexicon(store)
212
+ return { term: e, termId, action: 'archive' }
213
+ }
214
+
215
+ /** 恢复(§2.1):只有 archived → active;legacy 修订草稿拒绝独立恢复。 */
216
+ export function restoreLexiconTerm({ libraryId, termId } = {}) {
217
+ const store = loadLexicon(libraryId)
218
+ const e = store.terms[termId]
219
+ if (!e) throw fail('LEXICON_NOT_FOUND', 'unknown lexicon term')
220
+ const status = e.status || 'active'
221
+ if (status !== 'archived') throw fail('LEXICON_INVALID_STATUS', `only archived terms can be restored; ${termId} is ${status}`)
222
+ if (e.revisionOf && (store.terms[e.revisionOf]?.status || 'active') === 'active') {
223
+ throw fail('LEXICON_REVISION_IMMUTABLE', 'a legacy revision draft cannot be restored on its own')
224
+ }
225
+ const normalizedTerm = normalizeLexeme(e.term)
226
+ const aliases = Array.isArray(e.normalizedAliases) ? e.normalizedAliases : []
227
+ for (const [id, other] of Object.entries(store.terms)) {
228
+ if (id === termId) continue
229
+ const otherStatus = other.status || 'active'
230
+ if (otherStatus !== 'active' && otherStatus !== 'pending') continue
231
+ const otherAliases = Array.isArray(other.normalizedAliases) ? other.normalizedAliases : []
232
+ if (other.normalizedTerm === normalizedTerm || otherAliases.includes(normalizedTerm)
233
+ || aliases.includes(other.normalizedTerm) || otherAliases.some((alias) => aliases.includes(alias))) {
234
+ throw fail('LEXICON_ALIAS_CONFLICT', `restoring ${termId} would collide with ${id}`)
235
+ }
236
+ }
237
+ e.status = 'active'
238
+ e.updatedAt = new Date().toISOString()
239
+ store.revision += 1
240
+ saveLexicon(store)
241
+ return { term: e, termId, action: 'restore' }
242
+ }
243
+
244
+ export function deleteLexiconTerm({ libraryId, termId } = {}) {
245
+ const store = loadLexicon(libraryId)
246
+ if (!store.terms[termId]) throw fail('LEXICON_TERM_NOT_FOUND', 'unknown lexicon term')
247
+ delete store.terms[termId]
248
+ store.revision += 1
249
+ saveLexicon(store)
250
+ return { termId, deleted: true }
251
+ }
252
+
253
+ export function searchLexicon(options = {}, opts = {}) {
254
+ const libraryId = String(options.libraryId || '')
255
+ const includeArchived = opts?.includeArchived === true || options.includeArchived === true
256
+ const store = loadLexicon(libraryId)
257
+ const query = normalizeLexeme(options.query)
258
+ if (!query) return []
259
+ const memoryContext = options.memoryContext || {}
260
+ const hits = []
261
+ for (const [termId, e] of Object.entries(store.terms)) {
262
+ const status = e.status || 'active'
263
+ if (status !== 'active' && !(includeArchived && status === 'archived')) continue
264
+ if (!contextVisible(e.context, memoryContext)) continue
265
+ const haystack = [normalizeLexeme(e.term), ...(Array.isArray(e.normalizedAliases) ? e.normalizedAliases : []).map(normalizeLexeme), normalizeLexeme(e.meaning)].filter(Boolean)
266
+ if (!haystack.some((value) => value.includes(query) || query.includes(value))) continue
267
+ hits.push({ id: termId, ...e, matchedBy: 'exact' })
268
+ if (hits.length >= 8) break
269
+ }
270
+ return hits
271
+ }
272
+
273
+ /** 可见词条:active 默认;includeArchived 追加 archived(§2.1)。pending/rejected 永不返回。 */
274
+ export function visibleLexiconTerms(libraryId, memoryContext = {}, opts = {}) {
275
+ const includeArchived = opts?.includeArchived === true
276
+ const store = loadLexicon(libraryId)
277
+ return Object.entries(store.terms)
278
+ .filter(([, e]) => {
279
+ const status = e.status || 'active'
280
+ return status === 'active' || (includeArchived && status === 'archived')
281
+ })
282
+ .filter(([, e]) => contextVisible(e.context, memoryContext))
283
+ .map(([id, e]) => ({ termId: id, id, ...e }))
284
+ }
@@ -165,8 +165,7 @@ test('getGraph hides orphan entities by default and reveals them on includeOrpha
165
165
  graph.deleteEntity({ libraryId, entityId: lonely.id })
166
166
  })
167
167
 
168
- test('apply() schedules the sweep late, and a corrupt store only warns instead of blocking load', async () => {
169
- const badLib = state.ensureSession('sweep-bad', { cwd: path.join(home, 'ws-bad'), agentPreset: 'coding' }).libraryId
168
+ test('apply() schedules the sweep late, and a corrupt store only warns instead of blocking load', async () => { const badLib = state.ensureSession('sweep-bad', { cwd: path.join(home, 'ws-bad'), agentPreset: 'coding' }).libraryId
170
169
  fs.writeFileSync(path.join(indexDir(), `${badLib}.entities.json`), '{ still not json')
171
170
  const info = []
172
171
  const warn = []
@@ -188,3 +187,37 @@ test('apply() schedules the sweep late, and a corrupt store only warns instead o
188
187
  assert.ok(warn.some(message => message.includes(`orphan entity sweep skipped ${badLib}`)), 'the sweep reports the corrupt library')
189
188
  assert.equal(warn.some(message => message.includes('orphan entity sweep failed')), false, 'per-library failures stay contained')
190
189
  })
190
+
191
+ // ---- §6.4 包 D:linked 派生字段与删除路径的一致性 ----
192
+ // 只有 relationship、没有 memory link 的实体是历史,不是孤儿——linked 必须为
193
+ // true,启动 prune 也必须放行它;判定口径与 pruneOrphanEntities 完全一致。
194
+
195
+ test('a relationship-only entity is linked, not an orphan, and prune agrees', () => {
196
+ const soloA = graph.createEntity({ libraryId, name: 'RelOnlyA' })
197
+ const soloB = graph.createEntity({ libraryId, name: 'RelOnlyB' })
198
+ graph.updateRelationship({ libraryId, fromId: soloA.id, toId: soloB.id, state: { familiarity: 0.4 }, evidenceIds: ['mem-free'], reason: 'The free scene is explicit evidence.' })
199
+ const rows = graph.listEntities({ libraryId }).items
200
+ const rowA = rows.find(entity => entity.id === soloA.id)
201
+ const rowB = rows.find(entity => entity.id === soloB.id)
202
+ assert.ok(rowA && rowB, 'no session means the pair comes back with everyone else')
203
+ assert.equal(rowA.linked, true, 'a snapshot endpoint is a reference even without any memory link')
204
+ assert.equal(rowB.linked, true)
205
+ const pruned = graph.pruneOrphanEntities(libraryId)
206
+ assert.ok(!pruned.removed.includes(soloA.id) && !pruned.removed.includes(soloB.id),
207
+ 'the prune must share the linked judgement, never collect snapshot-referenced entities')
208
+ })
209
+
210
+ test('the linked flag tracks a refused delete through force, and cleanup drops the row', () => {
211
+ const kept = graph.createEntity({ libraryId, name: 'FlagKept' })
212
+ graph.linkEntries({ libraryId, fromId: 'mem-free', toId: kept.id, kind: 'person', note: 'FlagKept is the person this scene is about.' })
213
+ let row = graph.listEntities({ libraryId }).items.find(entity => entity.id === kept.id)
214
+ assert.equal(row.linked, true, 'an inbound link shows up as linked before any delete')
215
+ assert.throws(() => graph.deleteEntity({ libraryId, entityId: kept.id }), /still referenced/, 'the refusal keeps both the row and its link')
216
+ assert.ok(graph.listEntities({ libraryId }).items.some(entity => entity.id === kept.id))
217
+ const forced = graph.deleteEntity({ libraryId, entityId: kept.id, force: true })
218
+ assert.equal(forced.deleted, true)
219
+ assert.equal(forced.removedLinks, 1, 'force sweeps the inbound link')
220
+ assert.ok(state.loadPrivateIndex(libraryId).entries['mem-free'].links.every(link => link.to !== kept.id), 'the forced delete sweeps the inbound link of this entity')
221
+ row = graph.listEntities({ libraryId }).items.find(entity => entity.id === kept.id)
222
+ assert.equal(row, undefined, 'after the force delete the entity is gone from the listing too')
223
+ })
@@ -49,6 +49,44 @@ test('invalid kinds, empty reasons and out-of-scope private links are rejected',
49
49
  assert.ok(!JSON.stringify(graph.getGraph({ exec })).includes('private title'))
50
50
  })
51
51
 
52
+ test('an explicit link source persists on the record and surfaces on graph edges', () => {
53
+ state.updatePrivateIndex(libraryId, index => {
54
+ index.entries['mem-src-a'] = { title: 'alpha note', content: 'alpha body', status: 'active', visibility: 'shared' }
55
+ index.entries['mem-src-b'] = { title: 'beta note', content: 'beta body', status: 'active', visibility: 'shared' }
56
+ index.entries['mem-src-c'] = { title: 'gamma note', content: 'gamma body', status: 'active', visibility: 'shared' }
57
+ })
58
+ const linked = graph.linkEntries({
59
+ libraryId, fromId: 'mem-src-a', toId: 'mem-src-b', kind: 'relation',
60
+ note: 'Auto-linked because both memories share the stable name Alpha.', source: 'auto',
61
+ })
62
+ assert.equal(linked.source, 'auto')
63
+ const stored = () => state.loadPrivateIndex(libraryId).entries['mem-src-a'].links
64
+ assert.equal(stored().find(link => link.to === 'mem-src-b').source, 'auto', 'auto is persisted, not only returned')
65
+
66
+ // Replacement stays idempotent and the source survives the rewrite.
67
+ graph.linkEntries({
68
+ libraryId, fromId: 'mem-src-a', toId: 'mem-src-b', kind: 'relation',
69
+ note: 'Re-linked after a rerun with the same evidence.', source: 'auto',
70
+ })
71
+ const replaced = stored().filter(link => link.to === 'mem-src-b')
72
+ assert.equal(replaced.length, 1, 'the same (to, kind) pair is replaced in place')
73
+ assert.equal(replaced[0].source, 'auto')
74
+
75
+ // Only auto/user are meaningful; anything else is omitted rather than stored.
76
+ graph.linkEntries({
77
+ libraryId, fromId: 'mem-src-b', toId: 'mem-src-c', kind: 'event',
78
+ note: 'The same event seen from another side.', source: 'robot',
79
+ })
80
+ assert.equal(state.loadPrivateIndex(libraryId).entries['mem-src-b'].links[0].source, undefined)
81
+
82
+ const view = graph.getGraph({ libraryId, focusId: 'mem-src-b', depth: 1 })
83
+ const autoEdge = view.edges.find(edge => edge.from === 'mem-src-a' && edge.to === 'mem-src-b')
84
+ assert.equal(autoEdge.source, 'auto', 'the UI can tag an auto link')
85
+ assert.equal(autoEdge.kind, 'relation', 'link semantics are unchanged')
86
+ const plainEdge = view.edges.find(edge => edge.from === 'mem-src-b')
87
+ assert.equal(plainEdge.source, null, 'a link without a source reports null, not undefined')
88
+ })
89
+
52
90
  test('graphs cap nodes at 200 and the recall carries at most two pointers', async () => {
53
91
  state.updatePrivateIndex(libraryId, index => {
54
92
  for (let i = 10; i < 400; i++) index.entries[`mem-${i}`] = { title: `node ${i}`, status: 'active', content: 'node' }
@@ -92,3 +130,61 @@ test('a dangling edge is reported as missing, matching the graph view placeholde
92
130
  const healthy = graph.recallRelations(libraryId, state.loadPrivateIndex(libraryId), 'mem-2', null)
93
131
  assert.equal(healthy.find(item => item.id === zhou.id).missing, false)
94
132
  })
133
+
134
+ // ---- §6.4 包 D:listEntities 的 linked 派生字段与孤立判定 ----
135
+ // 前提(合并稿 0.2 纠正):无 session 本来就返回全部实体——不"放开过滤",
136
+ // 只加 linked 标记;有 session 才按可见引用收窄。
137
+
138
+ test('listEntities: without a session every entity returns, with a correct derived linked flag', () => {
139
+ const binding = state.ensureSession('vis-all', { cwd: path.join(home, 'ws-vis-all'), agentPreset: 'rp' })
140
+ const lib = binding.libraryId
141
+ const anchored = graph.createEntity({ libraryId: lib, name: 'Anchored' })
142
+ const drifting = graph.createEntity({ libraryId: lib, name: 'Drifting' })
143
+ state.savePrivateIndex(lib, { entries: {
144
+ 'mem-anchored': { title: 'anchored', content: 'scene', layer: 'long_term', status: 'active', visibility: 'shared',
145
+ links: [{ to: anchored.id, kind: 'person', note: 'Anchored is the person this scene is about.', confidence: 0.9 }] },
146
+ } })
147
+ const all = graph.listEntities({ libraryId: lib })
148
+ assert.equal(all.items.length, 2, 'without a session the full entity list comes back')
149
+ assert.equal(all.total, 2)
150
+ const anchoredRow = all.items.find(entity => entity.id === anchored.id)
151
+ const driftingRow = all.items.find(entity => entity.id === drifting.id)
152
+ assert.equal(anchoredRow.linked, true, 'a link target is linked')
153
+ assert.equal(driftingRow.linked, false, 'an entity nothing points at is unlinked')
154
+ // linked 是运行时派生字段:实体文件里绝不出现它,启动 prune 的判定不受污染。
155
+ assert.equal(graph.graphExport(lib).entities[drifting.id].linked, undefined, 'the derived flag is never written back')
156
+ })
157
+
158
+ test('listEntities: with a session only visible references count, and relationship evidence is session-filtered too', () => {
159
+ const binding = state.ensureSession('vis-scoped', { cwd: path.join(home, 'ws-vis-scoped'), agentPreset: 'rp' })
160
+ const lib = binding.libraryId
161
+ const exec = { agent: { session: { header: { id: 'vis-scoped' } } } }
162
+ const linked = graph.createEntity({ libraryId: lib, name: 'Linked' })
163
+ const secretTarget = graph.createEntity({ libraryId: lib, name: 'SecretTarget' })
164
+ const relVisible = graph.createEntity({ libraryId: lib, name: 'RelVisible' })
165
+ const relHidden = graph.createEntity({ libraryId: lib, name: 'RelHidden' })
166
+ const lonely = graph.createEntity({ libraryId: lib, name: 'Lonely' })
167
+ state.savePrivateIndex(lib, { entries: {
168
+ 'mem-open': { title: 'open', content: 'open scene', layer: 'long_term', status: 'active', visibility: 'shared',
169
+ links: [{ to: linked.id, kind: 'person', note: 'Linked is the person this scene is about.', confidence: 0.9 }] },
170
+ 'mem-hidden': { title: 'hidden', content: 'secret scene', layer: 'long_term', status: 'active', visibility: 'secret', context: { characterId: 'other' },
171
+ links: [{ to: secretTarget.id, kind: 'person', note: 'Secret is the person this scene is about.', confidence: 0.9 }] },
172
+ } })
173
+ // 关系快照也算引用:evidence 指向本会话可见记忆的算,指不可见记忆的不算。
174
+ graph.updateRelationship({ libraryId: lib, fromId: linked.id, toId: relVisible.id, state: { trust: 0.5 }, evidenceIds: ['mem-open'], reason: 'The open scene is explicit evidence.' })
175
+ graph.updateRelationship({ libraryId: lib, fromId: secretTarget.id, toId: relHidden.id, state: { trust: 0.5 }, evidenceIds: ['mem-hidden'], reason: 'The hidden scene is the only evidence here.' })
176
+ const scoped = graph.listEntities({ exec, libraryId: lib })
177
+ const ids = scoped.items.map(entity => entity.id).sort()
178
+ assert.deepEqual(ids, [linked.id, relVisible.id].sort(),
179
+ 'a visible entry link and a relationship with visible evidence both count; secret-referenced and orphan entities do not')
180
+ assert.ok(scoped.items.every(entity => entity.linked === true), 'every session-visible row is linked by construction')
181
+ // 无 session:全量计入(两条快照都算引用),Linked/RelVisible/SecretTarget/RelHidden
182
+ // 都是 linked=true,只有 Lonely 是孤立。
183
+ const all = graph.listEntities({ libraryId: lib })
184
+ assert.equal(all.items.length, 5, 'no session means every entity, referenced or not')
185
+ const linkedMap = Object.fromEntries(all.items.map(entity => [entity.id, entity.linked]))
186
+ for (const entity of [linked, relVisible, secretTarget, relHidden]) {
187
+ assert.equal(linkedMap[entity.id], true, `${entity.name} is referenced and must not be misjudged as an orphan`)
188
+ }
189
+ assert.equal(linkedMap[lonely.id], false, 'the untouched entity stays unlinked')
190
+ })