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.
- package/AGENTS.md +1 -1
- package/README.md +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +324 -0
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +191 -2
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { registerHooks } from 'node:module'
|
|
7
|
+
|
|
8
|
+
// Package F2 (spec §4.11 / §6.7): the pendingAbsorb review path and undo.
|
|
9
|
+
//
|
|
10
|
+
// Covered here:
|
|
11
|
+
// 1. listReviews carries reviewRisk / reviewRiskReasons / pendingAbsorb;
|
|
12
|
+
// 2. accept: resolution must be replace (coexist throws), the duplicate gate
|
|
13
|
+
// excludes target+sources, the snapshot check catches a concurrently changed
|
|
14
|
+
// source ('review changed; reload and retry'), the vector store receives ONLY
|
|
15
|
+
// the proposal id (never the sources), and the single index transaction
|
|
16
|
+
// supersedes the target, archives the sources, rewires links and writes the
|
|
17
|
+
// snapshot while clearing pendingAbsorb/reviewReason/conflictingWith;
|
|
18
|
+
// 3. reject: the proposal is archived and target/sources stay untouched;
|
|
19
|
+
// 4. undo-absorb through the RPC: restores target/sources/rewired links in one
|
|
20
|
+
// transaction, archives the merged entry, refuses a second undo, refuses a
|
|
21
|
+
// libraryId, refuses an entry without a snapshot, and refuses when a member
|
|
22
|
+
// was edited after absorption.
|
|
23
|
+
//
|
|
24
|
+
// Same hermetic pattern as tests/curator-commit.test.mjs. A managed vector
|
|
25
|
+
// generation is active so the accept path's commit lands in the stub backend
|
|
26
|
+
// and can be counted.
|
|
27
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-absorb-review-'))
|
|
28
|
+
process.env.DSH_MEMU_HOME = home
|
|
29
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
30
|
+
|
|
31
|
+
const backend = {
|
|
32
|
+
commits: [],
|
|
33
|
+
forgets: [],
|
|
34
|
+
}
|
|
35
|
+
globalThis.__curatorAbsorbReviewTest = {
|
|
36
|
+
async commit(args) {
|
|
37
|
+
const names = (args.payload?.recall_files || []).map((file) => file.name)
|
|
38
|
+
backend.commits.push({ names, route: args.route, db: args.db })
|
|
39
|
+
return { ok: true }
|
|
40
|
+
},
|
|
41
|
+
async forget(args) {
|
|
42
|
+
backend.forgets.push({ name: args.name, db: args.db })
|
|
43
|
+
return { deleted: 1, segments: 1 }
|
|
44
|
+
},
|
|
45
|
+
async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
|
|
46
|
+
async listFiles() { return { recall_files: [], resources: [], raw: null } },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const memuSource = 'const bridge = globalThis.__curatorAbsorbReviewTest;'
|
|
50
|
+
+ 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
|
|
51
|
+
+ 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
|
|
52
|
+
const hooks = registerHooks({
|
|
53
|
+
resolve(specifier, context, nextResolve) {
|
|
54
|
+
if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
|
|
55
|
+
return nextResolve(specifier, context)
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const curator = await import('../lib/curator.js')
|
|
60
|
+
const memory = await import('../lib/memory.js')
|
|
61
|
+
const rpc = await import('../lib/rpc.js')
|
|
62
|
+
const state = await import('../lib/state.js')
|
|
63
|
+
const vm = await import('../lib/vector-migration.js')
|
|
64
|
+
const vp = await import('../lib/vector-profile.js')
|
|
65
|
+
const catalog = await import('../lib/embedding-catalog.js')
|
|
66
|
+
|
|
67
|
+
const BGE = 'bge-small-zh-v1.5-int8'
|
|
68
|
+
const baseEntry = catalog.getEmbeddingModel(BGE)
|
|
69
|
+
const altEntry = JSON.parse(JSON.stringify(baseEntry))
|
|
70
|
+
altEntry.id = 'bge-small-zh-v1.5-int8-alt'
|
|
71
|
+
altEntry.displayName = 'BGE alternative revision (absorb review fixture)'
|
|
72
|
+
altEntry.sources.modelscope.revision = 'c'.repeat(40)
|
|
73
|
+
altEntry.profileFingerprint = catalog.profileFingerprint(altEntry)
|
|
74
|
+
const models = { [BGE]: baseEntry, [altEntry.id]: altEntry }
|
|
75
|
+
vp.setCatalogEntryResolver((id) => models[id] ?? null)
|
|
76
|
+
|
|
77
|
+
state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
|
|
78
|
+
|
|
79
|
+
test.after(async () => {
|
|
80
|
+
try { hooks.unregister?.() } catch {}
|
|
81
|
+
vp.resetCatalogEntryResolver()
|
|
82
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
function seedGlobalIndex(entries = {}) {
|
|
86
|
+
state.saveGlobalIndex({ version: 1, entries: structuredClone(entries) })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function activateVectorGeneration() {
|
|
90
|
+
const dbs = new Map()
|
|
91
|
+
const rowsOf = (db) => {
|
|
92
|
+
if (!dbs.has(db)) dbs.set(db, new Map())
|
|
93
|
+
return dbs.get(db)
|
|
94
|
+
}
|
|
95
|
+
const migrator = vm.createVectorMigrator({
|
|
96
|
+
async listLibraries() { return [] },
|
|
97
|
+
loadIndex(libraryId) {
|
|
98
|
+
return libraryId ? state.loadPrivateIndex(libraryId) : state.loadGlobalIndex()
|
|
99
|
+
},
|
|
100
|
+
async commit({ db, item }) { rowsOf(db).set(item.id, item.text) },
|
|
101
|
+
async forget({ db, itemId }) { rowsOf(db).delete(itemId) },
|
|
102
|
+
async listFiles({ db }) { return [...rowsOf(db).keys()] },
|
|
103
|
+
async smoke() { return { ok: true, checks: ['dimension=512'] } },
|
|
104
|
+
})
|
|
105
|
+
const switched = await migrator.switchProfile({ profile: BGE })
|
|
106
|
+
assert.ok(['switched', 'already-active'].includes(switched.status), 'an active generation must be available')
|
|
107
|
+
return { migrator, generation: switched.generation }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const CORE = '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'
|
|
111
|
+
const AT = '2026-01-01T00:00:00.000Z'
|
|
112
|
+
const A = 'gmem-review-a'
|
|
113
|
+
const B = 'gmem-review-b'
|
|
114
|
+
const C = 'gmem-review-c'
|
|
115
|
+
const D = 'gmem-review-d'
|
|
116
|
+
const E = 'gmem-review-e'
|
|
117
|
+
|
|
118
|
+
function baseEntryOf(id, title, content, extra = {}) {
|
|
119
|
+
return {
|
|
120
|
+
track: 'memory', domain: 'general', title, content,
|
|
121
|
+
confidence: 0.9, status: 'active',
|
|
122
|
+
createdAt: AT, updatedAt: AT,
|
|
123
|
+
evidence: [{ sessionId: 's0', quote: '部署窗口只在周四' }],
|
|
124
|
+
...extra,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function absorbEvidence(ids) {
|
|
129
|
+
return ids.map((id, index) => ({ session_id: `s-rev-${index}`, quote: `来源 ${id} 的落地引用内容`, source_id: id }))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function seedCluster({ withLinks = false } = {}) {
|
|
133
|
+
const entries = {
|
|
134
|
+
[A]: baseEntryOf(A, '部署窗口约定', CORE + '全量发布必须在监控确认通过之后才能执行。'),
|
|
135
|
+
[B]: baseEntryOf(B, '部署窗口细节', CORE + '灰度发布按服务分组逐步放量,异常时立即回滚。'),
|
|
136
|
+
[C]: baseEntryOf(C, '部署窗口约束', CORE + '窗口外禁止任何生产变更。'),
|
|
137
|
+
}
|
|
138
|
+
if (withLinks) {
|
|
139
|
+
entries[D] = baseEntryOf(D, '簇外条目', '另一个话题的内容,与吸收无关。', {
|
|
140
|
+
links: [{ id: 'lnk-in-9', opId: 'op9', to: A, kind: 'related', note: '这条入边在吸收后要改指合并稿。', confidence: 0.7, evidence: [], createdAt: AT }],
|
|
141
|
+
})
|
|
142
|
+
entries[A].links = [{ id: 'lnk-out-9', opId: 'op8', to: E, kind: 'refines', note: 'target 指向簇外的边要并入合并稿。', confidence: 0.7, evidence: [], createdAt: AT }]
|
|
143
|
+
entries[E] = baseEntryOf(E, '簇外条目2', '被 target 指向的条目。')
|
|
144
|
+
}
|
|
145
|
+
seedGlobalIndex(entries)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// review-all 让 absorb 计划一律落 pendingAbsorb 提案(spec §4.10 表格第三列)。
|
|
149
|
+
state.updateSettings({ curatorReviewMode: 'review-all' })
|
|
150
|
+
|
|
151
|
+
const ABSORB_PLAN = {
|
|
152
|
+
memories: [{
|
|
153
|
+
op: 'absorb', target_id: A, source_ids: [B, C],
|
|
154
|
+
confidence: 0.9,
|
|
155
|
+
evidence: absorbEvidence([A, B, C]),
|
|
156
|
+
}],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function pendingProposalOf() {
|
|
160
|
+
const queue = await rpc.dispatch({}, 'listReviews', {})
|
|
161
|
+
const item = queue.items.find((row) => row.pendingAbsorb)
|
|
162
|
+
assert.ok(item, 'the absorb proposal must be in the review queue')
|
|
163
|
+
return item
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test('listReviews exposes reviewRisk, reviewRiskReasons and pendingAbsorb for the queue UI', async () => {
|
|
167
|
+
seedCluster()
|
|
168
|
+
await activateVectorGeneration()
|
|
169
|
+
const result = await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
170
|
+
assert.equal(result.pending, 1)
|
|
171
|
+
const item = await pendingProposalOf()
|
|
172
|
+
assert.equal(item.pendingAbsorb.targetId, A)
|
|
173
|
+
assert.deepEqual([...item.pendingAbsorb.sourceIds].sort(), [B, C].sort())
|
|
174
|
+
assert.ok(item.pendingAbsorb.opId)
|
|
175
|
+
assert.equal(item.pendingAbsorb.expectedUpdatedAt[A], AT)
|
|
176
|
+
assert.equal(typeof item.reviewRisk, 'number', 'reviewRisk rides along on every queue row')
|
|
177
|
+
assert.ok(Array.isArray(item.reviewRiskReasons))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('accept turns the proposal active, archives the sources, commits only the proposal id to vectors and rewires links', async () => {
|
|
181
|
+
seedCluster({ withLinks: true })
|
|
182
|
+
await activateVectorGeneration()
|
|
183
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
184
|
+
const item = await pendingProposalOf()
|
|
185
|
+
const proposalId = item.id
|
|
186
|
+
|
|
187
|
+
const resolved = await rpc.dispatch({}, 'resolveReview', { id: proposalId, action: 'accept' })
|
|
188
|
+
assert.equal(resolved.status, 'active')
|
|
189
|
+
|
|
190
|
+
const index = state.loadGlobalIndex()
|
|
191
|
+
const merged = index.entries[proposalId]
|
|
192
|
+
assert.equal(merged.status, 'active')
|
|
193
|
+
// §3.3/§4.11:accept 写 absorbedFrom/absorbedAt/absorbOpId/representativeId,
|
|
194
|
+
// 并清 pendingAbsorb/reviewReason/conflictingWith。
|
|
195
|
+
assert.deepEqual(merged.absorbedFrom, [A, B, C].sort())
|
|
196
|
+
assert.ok(merged.absorbOpId)
|
|
197
|
+
assert.ok(merged.representativeId)
|
|
198
|
+
assert.ok(merged.absorbAt || merged.updatedAt)
|
|
199
|
+
assert.equal(merged.auditTrail?.at(-1)?.op, 'absorb')
|
|
200
|
+
assert.equal(index.entries[A].auditTrail?.at(-1)?.op, 'absorb')
|
|
201
|
+
assert.equal(index.entries[B].auditTrail?.at(-1)?.op, 'absorb')
|
|
202
|
+
assert.equal(merged.pendingAbsorb, undefined)
|
|
203
|
+
assert.equal(merged.reviewReason, undefined)
|
|
204
|
+
assert.equal(merged.conflictingWith, undefined)
|
|
205
|
+
assert.ok(merged.absorbSnapshot, 'the accept transaction writes the undo snapshot')
|
|
206
|
+
assert.ok(merged.vector, 'the accepted entry carries the vector stamp')
|
|
207
|
+
|
|
208
|
+
// 向量库只收到提案 id 这一行;sources 一行都没有。
|
|
209
|
+
const lastCommit = backend.commits.at(-1)
|
|
210
|
+
assert.deepEqual(lastCommit.names, [proposalId])
|
|
211
|
+
|
|
212
|
+
// target superseded;sources archived。
|
|
213
|
+
assert.equal(index.entries[A].status, 'superseded')
|
|
214
|
+
assert.equal(index.entries[A].absorbedInto, proposalId)
|
|
215
|
+
assert.equal(index.entries[B].status, 'archived')
|
|
216
|
+
assert.equal(index.entries[B].absorbedBy, proposalId)
|
|
217
|
+
assert.equal(index.entries[C].status, 'archived')
|
|
218
|
+
// links:入边改指 merged,出边并入 merged。
|
|
219
|
+
assert.equal(index.entries[D].links[0].to, proposalId)
|
|
220
|
+
assert.equal(merged.links[0].to, E)
|
|
221
|
+
// 快照保留吸收前状态。
|
|
222
|
+
assert.deepEqual(merged.absorbSnapshot.rewired.map((row) => row.id), [D])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('reject archives the proposal and leaves target/sources untouched', async () => {
|
|
226
|
+
seedCluster()
|
|
227
|
+
await activateVectorGeneration()
|
|
228
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
229
|
+
const item = await pendingProposalOf()
|
|
230
|
+
await rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'reject' })
|
|
231
|
+
const index = state.loadGlobalIndex()
|
|
232
|
+
assert.equal(index.entries[item.id].status, 'archived')
|
|
233
|
+
assert.equal(index.entries[A].status, 'active', 'the target stays active on reject')
|
|
234
|
+
assert.equal(index.entries[B].status, 'active')
|
|
235
|
+
assert.equal(index.entries[C].status, 'active')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('accept with a coexist resolution is refused: a merged entry cannot coexist with its own sources', async () => {
|
|
239
|
+
seedCluster()
|
|
240
|
+
await activateVectorGeneration()
|
|
241
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
242
|
+
const item = await pendingProposalOf()
|
|
243
|
+
await assert.rejects(
|
|
244
|
+
() => rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'accept', resolution: 'coexist', reason: '条件不同的并存说明文字' }),
|
|
245
|
+
/replace/,
|
|
246
|
+
)
|
|
247
|
+
assert.equal(state.loadGlobalIndex().entries[item.id].status, 'pending_review', 'the proposal stays pending')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('accept with a concurrently changed source throws "review changed" and keeps the proposal pending', async () => {
|
|
251
|
+
seedCluster()
|
|
252
|
+
await activateVectorGeneration()
|
|
253
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
254
|
+
const item = await pendingProposalOf()
|
|
255
|
+
// 并发写:source 的 updatedAt 变了,快照校验必须拦下这次接受。
|
|
256
|
+
state.updateGlobalIndex((fresh) => {
|
|
257
|
+
fresh.entries[B] = { ...fresh.entries[B], content: '来源被并发修改。', updatedAt: '2026-03-03T00:00:00.000Z' }
|
|
258
|
+
})
|
|
259
|
+
await assert.rejects(
|
|
260
|
+
() => rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'accept' }),
|
|
261
|
+
/review changed; reload and retry/,
|
|
262
|
+
)
|
|
263
|
+
const index = state.loadGlobalIndex()
|
|
264
|
+
assert.equal(index.entries[item.id].status, 'pending_review', 'the proposal stays pending')
|
|
265
|
+
assert.equal(index.entries[A].status, 'active', 'the target is not superseded by a failed accept')
|
|
266
|
+
assert.equal(index.entries[B].status, 'active')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('undo-absorb restores target, sources and rewired links, archives the merged entry, and refuses a second undo', async () => {
|
|
270
|
+
seedCluster({ withLinks: true })
|
|
271
|
+
await activateVectorGeneration()
|
|
272
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
273
|
+
const item = await pendingProposalOf()
|
|
274
|
+
await rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'accept' })
|
|
275
|
+
const mergedId = item.id
|
|
276
|
+
|
|
277
|
+
const undone = await rpc.dispatch({}, 'resolveReview', { id: mergedId, action: 'undo-absorb' })
|
|
278
|
+
assert.equal(undone.status, 'archived')
|
|
279
|
+
|
|
280
|
+
const index = state.loadGlobalIndex()
|
|
281
|
+
const merged = index.entries[mergedId]
|
|
282
|
+
assert.equal(merged.status, 'archived', 'the merged entry is archived, not deleted')
|
|
283
|
+
assert.ok(merged.absorbUndoneAt)
|
|
284
|
+
assert.ok(merged.content, 'the merged body is kept')
|
|
285
|
+
assert.equal(merged.auditTrail.at(-1).op, 'undo-absorb')
|
|
286
|
+
assert.equal(index.entries[A].auditTrail.at(-1).op, 'undo-absorb')
|
|
287
|
+
assert.equal(index.entries[B].auditTrail.at(-1).op, 'undo-absorb')
|
|
288
|
+
// target 恢复。
|
|
289
|
+
const target = index.entries[A]
|
|
290
|
+
assert.equal(target.status, 'active')
|
|
291
|
+
assert.equal(target.supersededBy, undefined)
|
|
292
|
+
assert.equal(target.absorbedInto, undefined)
|
|
293
|
+
assert.equal(target.validTo, undefined)
|
|
294
|
+
// sources 恢复。
|
|
295
|
+
for (const id of [B, C]) {
|
|
296
|
+
const source = index.entries[id]
|
|
297
|
+
assert.equal(source.status, 'active')
|
|
298
|
+
assert.equal(source.absorbedBy, undefined)
|
|
299
|
+
assert.equal(source.absorbedAt, undefined)
|
|
300
|
+
assert.equal(source.validTo, undefined)
|
|
301
|
+
}
|
|
302
|
+
// rewired 边恢复原状。
|
|
303
|
+
assert.equal(index.entries[D].links[0].to, A)
|
|
304
|
+
|
|
305
|
+
// 二次撤销:merged 已不是 active,直接报错。
|
|
306
|
+
await assert.rejects(
|
|
307
|
+
() => rpc.dispatch({}, 'resolveReview', { id: mergedId, action: 'undo-absorb' }),
|
|
308
|
+
/active merged entry with an absorb snapshot/,
|
|
309
|
+
)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('undo-absorb refuses a libraryId payload, an entry without a snapshot, and members edited after absorption', async () => {
|
|
313
|
+
seedCluster({ withLinks: true })
|
|
314
|
+
await activateVectorGeneration()
|
|
315
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
316
|
+
const item = await pendingProposalOf()
|
|
317
|
+
await rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'accept' })
|
|
318
|
+
const mergedId = item.id
|
|
319
|
+
|
|
320
|
+
// 带 libraryId 的 undo 是私有复核语义错位,必须报错(spec §6.7 RPC)。
|
|
321
|
+
await assert.rejects(
|
|
322
|
+
() => rpc.dispatch({}, 'resolveReview', { id: mergedId, action: 'undo-absorb', libraryId: 'some-lib' }),
|
|
323
|
+
/libraryId/,
|
|
324
|
+
)
|
|
325
|
+
// 没有 absorbSnapshot 的条目不能 undo。
|
|
326
|
+
await assert.rejects(
|
|
327
|
+
() => rpc.dispatch({}, 'resolveReview', { id: A, action: 'undo-absorb' }),
|
|
328
|
+
/absorb snapshot/,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
// 吸收后 target 又被人工改过(updatedAt > absorbedAt)→ 拒绝整体撤销。
|
|
332
|
+
// (absorbedAt 是真实的当前时间,人工改动的时间必须落在它之后。)
|
|
333
|
+
state.updateGlobalIndex((fresh) => {
|
|
334
|
+
fresh.entries[A] = { ...fresh.entries[A], content: '人工修正后的部署窗口约定。', updatedAt: '2030-01-01T00:00:00.000Z' }
|
|
335
|
+
})
|
|
336
|
+
await assert.rejects(
|
|
337
|
+
() => rpc.dispatch({}, 'resolveReview', { id: mergedId, action: 'undo-absorb' }),
|
|
338
|
+
/absorb undo refused/,
|
|
339
|
+
)
|
|
340
|
+
const index = state.loadGlobalIndex()
|
|
341
|
+
assert.equal(index.entries[mergedId].status, 'active', 'the refused undo leaves the merge in place')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('the review wrapper still records the review in the activity log', async () => {
|
|
345
|
+
seedCluster()
|
|
346
|
+
await activateVectorGeneration()
|
|
347
|
+
await curator.commitCuratorPlans(structuredClone(ABSORB_PLAN), { job: { step: '' } })
|
|
348
|
+
const item = await pendingProposalOf()
|
|
349
|
+
await rpc.dispatch({}, 'resolveReview', { id: item.id, action: 'reject' })
|
|
350
|
+
const event = [...await import('../lib/activity-log.js').then((m) => m.listEvents({ kind: 'write' }))]
|
|
351
|
+
.find((row) => row.detail?.id === item.id && row.message.includes('复核'))
|
|
352
|
+
assert.ok(event, 'the resolveReview wrapper logs the decision')
|
|
353
|
+
})
|
|
@@ -149,11 +149,14 @@ test('maybeCue falls back to a fixed sentence when no memory qualifies', async (
|
|
|
149
149
|
|
|
150
150
|
test('a tense room pulls charged memories further ahead than a quiet one', async () => {
|
|
151
151
|
const { sessionId, exec } = setup('arousal')
|
|
152
|
+
// 两条正文词面不同(dice 0.512,低于 TOPIC_DICE):事实级去重上线后,同文改标题的
|
|
153
|
+
// 旧写法会被写入侧打标/召回侧折叠,唤醒度因子就测不到了。语义并行 + 显式 arousal
|
|
154
|
+
// 仍然隔离这个因子。
|
|
152
155
|
const calm = await memory.privateRemember({
|
|
153
156
|
exec, content: 'The hallway smelled of rain on warm stone.', title: 'hallway calm', arousal: 0.1, moodValence: 0.05,
|
|
154
157
|
})
|
|
155
158
|
const loud = await memory.privateRemember({
|
|
156
|
-
exec, content: '
|
|
159
|
+
exec, content: 'Rain hammered the hallway stones while voices echoed off the walls.', title: 'hallway loud', arousal: 0.95, moodValence: 0.05,
|
|
157
160
|
})
|
|
158
161
|
|
|
159
162
|
async function gapAt(arousal) {
|
|
@@ -189,8 +192,9 @@ test('arousal cannot outrank a clearly better semantic hit', async () => {
|
|
|
189
192
|
|
|
190
193
|
test('a mood that is tense but neither good nor bad still counts as a mood', async () => {
|
|
191
194
|
const { sessionId, exec } = setup('tense')
|
|
195
|
+
// 正文词面不同(dice 0.514):同文会被事实级去重拒绝,这里改写保持语义并行。
|
|
192
196
|
const flat = await memory.privateRemember({ exec, content: 'The invoice is due on the fourteenth.', title: 'invoice', arousal: 0.1, moodValence: 0 })
|
|
193
|
-
const edge = await memory.privateRemember({ exec, content: 'The invoice
|
|
197
|
+
const edge = await memory.privateRemember({ exec, content: 'The invoice payment must arrive by the fourteenth without fail.', title: 'invoice edge', arousal: 0.9, moodValence: 0 })
|
|
194
198
|
// valence is exactly neutral here, so without the arousal gate this room would be
|
|
195
199
|
// treated as "no mood at all" and arousal would never reach recallScore.
|
|
196
200
|
state.setSessionMood(sessionId, { valence: 0, arousal: 0.9 })
|
|
@@ -70,7 +70,7 @@ test('tip-of-tongue is about reachability, not importance', () => {
|
|
|
70
70
|
assert.equal(memory.isTipOfTongue({ layer: 'long_term', confidence: 0.9, strength: 1, createdAt: daysAgo(1) }), false)
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
-
test('
|
|
73
|
+
test('recall shows the title of a half-remembered memory, never its wording', async () => {
|
|
74
74
|
const { sessionId, binding, exec } = setup('tongue')
|
|
75
75
|
const content = '周四晚上在厨房,他一边擦杯子一边说那句关于不要再改需求的话,说完就把杯子放回架子上,转身去阳台抽了一支烟,很久都没有再开口,客厅的电视一直在放同一档节目。'
|
|
76
76
|
const saved = await memory.privateRemember({ exec, content, title: '厨房里那句话', confidence: 0.3 })
|
|
@@ -79,7 +79,7 @@ test('injection gives the gist, not the wording, when a memory is half-remembere
|
|
|
79
79
|
state.savePrivateIndex(binding.libraryId, index)
|
|
80
80
|
|
|
81
81
|
const out = await inject.buildRecall(binding, '厨房 那句话', 1, { ...state.getSettings(), privateTopK: 3, relevanceThreshold: 0 }, exec, sessionId)
|
|
82
|
-
assert.ok(out.text.includes('
|
|
82
|
+
assert.ok(out.text.includes('厨房里那句话'), 'the title is the directory entry')
|
|
83
83
|
assert.ok(!out.text.includes('客厅的电视一直在放同一档节目'), 'the exact wording must not be handed over as if quoted')
|
|
84
84
|
assert.ok(out.text.includes(saved.id), 'the id and the tool are how the original is still reachable')
|
|
85
85
|
})
|