dsh-memu-memory 1.1.3 → 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/AGENTS.md +1 -1
- package/README.md +39 -6
- package/README.zh.md +39 -7
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +3 -3
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +396 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +23 -6
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +335 -35
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +144 -0
- package/lib/curator.js +86 -31
- package/lib/cycle.js +40 -6
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +534 -88
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +430 -43
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +29 -12
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +113 -10
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +51 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/scripts/replay.mjs +573 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +216 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +331 -0
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +489 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +360 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +94 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +122 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +591 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +212 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +790 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +165 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,366 @@
|
|
|
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
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-browser-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const { createLibrary, ensureSession, loadPrivateIndex, savePrivateIndex, listLibraries, getSessionBinding } = await import('../lib/state.js')
|
|
11
|
+
const { privateRemember, privateRecall, privateUpdate, privateArchive, globalList, globalRecall } = await import('../lib/memory.js')
|
|
12
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
13
|
+
|
|
14
|
+
test.after(() => {
|
|
15
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function seed(libraryId, id, entry) {
|
|
19
|
+
const index = loadPrivateIndex(libraryId)
|
|
20
|
+
index.entries[id] = {
|
|
21
|
+
layer: 'long_term', title: id, content: `body of ${id}`, status: 'active',
|
|
22
|
+
createdAt: '2026-10-01T00:00:00.000Z', ...entry,
|
|
23
|
+
}
|
|
24
|
+
savePrivateIndex(libraryId, index)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('library-scoped archive works with no session bound', async () => {
|
|
28
|
+
const lib = createLibrary({ name: 'browser-a', cwd: path.join(home, 'browser-a') })
|
|
29
|
+
seed(lib.id, 'mem-1', {})
|
|
30
|
+
const result = await privateArchive({ libraryId: lib.id, id: 'mem-1' })
|
|
31
|
+
assert.equal(result.entry.status, 'archived')
|
|
32
|
+
assert.equal(loadPrivateIndex(lib.id).entries['mem-1'].status, 'archived')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('library-scoped update touches only the index when content is unchanged', async () => {
|
|
36
|
+
const lib = createLibrary({ name: 'browser-b', cwd: path.join(home, 'browser-b') })
|
|
37
|
+
seed(lib.id, 'mem-2', { tags: ['old'] })
|
|
38
|
+
const result = await privateUpdate({ libraryId: lib.id, id: 'mem-2', title: 'renamed', tags: ['new'] })
|
|
39
|
+
assert.equal(result.entry.title, 'renamed')
|
|
40
|
+
assert.deepEqual(result.entry.tags, ['new'])
|
|
41
|
+
assert.equal(result.entry.content, 'body of mem-2')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('an unknown library id is rejected instead of falling back to a binding', () => {
|
|
45
|
+
assert.throws(
|
|
46
|
+
() => privateArchive({ libraryId: 'lib-nope', id: 'mem-x' }),
|
|
47
|
+
/unknown library/,
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('browseLibrary degrades to the index while the engine is missing', async () => {
|
|
52
|
+
const lib = createLibrary({ name: 'browser-c', cwd: path.join(home, 'browser-c') })
|
|
53
|
+
seed(lib.id, 'mem-3', { layer: 'must_remember', marginalized: true, tags: ['keep'] })
|
|
54
|
+
const view = await dispatch({}, 'browseLibrary', { libraryId: lib.id })
|
|
55
|
+
assert.equal(view.source, 'index')
|
|
56
|
+
assert.equal(view.libraryId, lib.id)
|
|
57
|
+
assert.equal(view.files.length, 1)
|
|
58
|
+
const [item] = view.files
|
|
59
|
+
assert.equal(item.id, 'mem-3')
|
|
60
|
+
assert.equal(item.layer, 'must_remember')
|
|
61
|
+
assert.equal(item.marginalized, true)
|
|
62
|
+
assert.deepEqual(item.tags, ['keep'])
|
|
63
|
+
assert.match(item.content, /body of mem-3/)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('local agent backend writes and recalls memory without an engine', async () => {
|
|
67
|
+
const cwd = path.join(home, 'browser-local')
|
|
68
|
+
const lib = createLibrary({ name: 'browser-local', cwd })
|
|
69
|
+
ensureSession('browser-local-session', { cwd, libraryId: lib.id })
|
|
70
|
+
const exec = { agent: { session: { header: { id: 'browser-local-session', cwd, agentPreset: 'coding' } } } }
|
|
71
|
+
const saved = await privateRemember({
|
|
72
|
+
exec, content: '用户喜欢在周五下午整理下周计划', title: '周五整理计划', tags: ['习惯'],
|
|
73
|
+
})
|
|
74
|
+
assert.equal(saved.result.backend, 'lexical')
|
|
75
|
+
assert.ok(saved.id)
|
|
76
|
+
const recalled = await privateRecall({ exec, query: '周五 计划', topK: 3 })
|
|
77
|
+
assert.equal(recalled.files.length, 1)
|
|
78
|
+
assert.match(recalled.files[0].content, /周五下午/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('exportLibrary emits a re-importable payload', async () => {
|
|
82
|
+
const lib = createLibrary({ name: 'browser-d', cwd: path.join(home, 'browser-d') })
|
|
83
|
+
seed(lib.id, 'mem-4', {})
|
|
84
|
+
const out = await dispatch({}, 'exportLibrary', { libraryId: lib.id })
|
|
85
|
+
assert.equal(out.format, 'dsh-memu-memory-library@1')
|
|
86
|
+
assert.equal(out.library.id, lib.id)
|
|
87
|
+
assert.equal(out.memories.length, 1)
|
|
88
|
+
assert.equal(out.memories[0].id, 'mem-4')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('reading global memory never creates a library for an unbound session', async () => {
|
|
92
|
+
const before = listLibraries().length
|
|
93
|
+
const exec = { agent: { session: { header: { id: 'never-seen-session', cwd: '', agentPreset: null } } } }
|
|
94
|
+
// Local agent mode reads the JSON index directly, so this must succeed without
|
|
95
|
+
// an engine; what matters is that neither read leaves a placeholder library.
|
|
96
|
+
const listed = await globalList({ exec })
|
|
97
|
+
assert.equal(listed.source, 'index')
|
|
98
|
+
const recalled = await globalRecall({ exec, query: 'anything', topK: 3 })
|
|
99
|
+
assert.equal(recalled.domain, 'general')
|
|
100
|
+
assert.equal(listLibraries().length, before, 'reads must not materialize a placeholder library')
|
|
101
|
+
assert.equal(getSessionBinding('never-seen-session'), null, 'reads must not create a binding')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('initializing a session with no workspace folder is refused, not bucketed', async () => {
|
|
105
|
+
const before = listLibraries().length
|
|
106
|
+
await assert.rejects(() => dispatch({}, 'initSession', { sessionId: 'no-folder-session' }), /no workspace folder/)
|
|
107
|
+
await assert.rejects(() => dispatch({}, 'initSession', { sessionId: 'no-folder-session', cwd: '__default__' }), /no workspace folder/)
|
|
108
|
+
assert.equal(listLibraries().length, before, 'a refused init must not create a library')
|
|
109
|
+
assert.equal(getSessionBinding('no-folder-session'), null)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('the new-session prompt check is read-only', async () => {
|
|
113
|
+
const before = listLibraries().length
|
|
114
|
+
const out = await dispatch({}, 'getSessionPromptState', { sessionId: 'prompt-read-only' })
|
|
115
|
+
assert.equal(out.binding, null)
|
|
116
|
+
assert.ok(Array.isArray(out.libraries))
|
|
117
|
+
assert.deepEqual(out.domains, ['rp', 'coding', 'general', 'mixed'], 'the prompt must offer the memory domains setDomain accepts')
|
|
118
|
+
assert.equal(listLibraries().length, before)
|
|
119
|
+
assert.equal(getSessionBinding('prompt-read-only'), null)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('setDomain writes Coding and rejects an unknown domain', async () => {
|
|
123
|
+
const cwd = path.join(home, 'browser-domain')
|
|
124
|
+
await dispatch({}, 'initSession', { sessionId: 'browser-domain-session', cwd, createLibrary: false })
|
|
125
|
+
const out = await dispatch({}, 'setDomain', { sessionId: 'browser-domain-session', domain: 'coding' })
|
|
126
|
+
assert.equal(out.domainOverride, 'coding')
|
|
127
|
+
assert.equal(getSessionBinding('browser-domain-session').domainOverride, 'coding')
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
() => dispatch({}, 'setDomain', { sessionId: 'browser-domain-session', domain: 'not-a-domain' }),
|
|
130
|
+
/invalid domain/,
|
|
131
|
+
)
|
|
132
|
+
assert.equal(getSessionBinding('browser-domain-session').domainOverride, 'coding', 'a rejected value must not overwrite the stored domain')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('initSession with createLibrary:false leaves no library behind', async () => {
|
|
136
|
+
const cwd = path.join(home, 'browser-opt-out')
|
|
137
|
+
const before = listLibraries().length
|
|
138
|
+
const out = await dispatch({}, 'initSession', { sessionId: 'browser-opt-out', cwd, createLibrary: false })
|
|
139
|
+
assert.equal(out.binding.libraryId, null)
|
|
140
|
+
assert.equal(listLibraries().length, before)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('switching to a version that is not installed is refused', async () => {
|
|
144
|
+
const { switchEngineVersion, listEngineVersions } = await import('../lib/engine.js')
|
|
145
|
+
const out = await switchEngineVersion('9.9.9-nope')
|
|
146
|
+
assert.equal(out.ok, false)
|
|
147
|
+
assert.match(out.error, /not installed/)
|
|
148
|
+
// Nothing may claim to be in use when no runtime exists.
|
|
149
|
+
assert.ok(listEngineVersions().every((v) => !v.current || v.installed))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('an install without history.json is backfilled from current.json', async () => {
|
|
153
|
+
const engineHome = path.join(home, 'engine')
|
|
154
|
+
const version = '9.9.9-backfill'
|
|
155
|
+
const venv = path.join(engineHome, version, 'venv')
|
|
156
|
+
fs.mkdirSync(venv, { recursive: true })
|
|
157
|
+
fs.writeFileSync(path.join(engineHome, 'current.json'), JSON.stringify({
|
|
158
|
+
version,
|
|
159
|
+
venv,
|
|
160
|
+
python: path.join(venv, 'Scripts', 'python.exe'),
|
|
161
|
+
commands: ['list-files'],
|
|
162
|
+
mirror: 'https://example.invalid/simple',
|
|
163
|
+
installedAt: '2026-01-02T03:04:05.000Z',
|
|
164
|
+
}), 'utf8')
|
|
165
|
+
const { listEngineVersions } = await import('../lib/engine.js')
|
|
166
|
+
const row = listEngineVersions().find((v) => v.version === version)
|
|
167
|
+
assert.ok(row, 'the installed version must appear in the history list')
|
|
168
|
+
assert.equal(row.installedAt, '2026-01-02T03:04:05.000Z')
|
|
169
|
+
assert.equal(row.mirror, 'https://example.invalid/simple')
|
|
170
|
+
const history = JSON.parse(fs.readFileSync(path.join(engineHome, 'history.json'), 'utf8'))
|
|
171
|
+
assert.equal(history[version].installedAt, '2026-01-02T03:04:05.000Z')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('updateMemory and archiveMemory route by libraryId', async () => {
|
|
175
|
+
const lib = createLibrary({ name: 'browser-e', cwd: path.join(home, 'browser-e') })
|
|
176
|
+
seed(lib.id, 'mem-5', {})
|
|
177
|
+
await dispatch({}, 'updateMemory', { libraryId: lib.id, id: 'mem-5', patch: { title: 'edited', status: 'suppressed' } })
|
|
178
|
+
assert.equal(loadPrivateIndex(lib.id).entries['mem-5'].title, 'edited')
|
|
179
|
+
assert.equal(loadPrivateIndex(lib.id).entries['mem-5'].status, 'suppressed')
|
|
180
|
+
await dispatch({}, 'archiveMemory', { libraryId: lib.id, id: 'mem-5' })
|
|
181
|
+
assert.equal(loadPrivateIndex(lib.id).entries['mem-5'].status, 'archived')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// The documented set is enforced, not filtered. A key outside it used to be dropped
|
|
185
|
+
// in silence, so a browser that tried to pin a memory got a cheerful success and an
|
|
186
|
+
// unchanged entry; refusing the whole patch also guarantees nothing half-applied.
|
|
187
|
+
test('updateMemory edits the documented set and refuses anything else', async () => {
|
|
188
|
+
const lib = createLibrary({ name: 'browser-f', cwd: path.join(home, 'browser-f') })
|
|
189
|
+
seed(lib.id, 'mem-6', { layer: 'long_term' })
|
|
190
|
+
const before = { ...loadPrivateIndex(lib.id).entries['mem-6'] }
|
|
191
|
+
await assert.rejects(
|
|
192
|
+
() => dispatch({}, 'updateMemory', {
|
|
193
|
+
libraryId: lib.id,
|
|
194
|
+
id: 'mem-6',
|
|
195
|
+
patch: {
|
|
196
|
+
title: 'ok',
|
|
197
|
+
pinReason: 'sneaky',
|
|
198
|
+
pinSource: 'user_explicit',
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
/unknown memory field: pinReason, pinSource/,
|
|
202
|
+
'pinReason and pinSource decide the anchor rank, so they stay server-side',
|
|
203
|
+
)
|
|
204
|
+
assert.deepEqual(
|
|
205
|
+
{ ...loadPrivateIndex(lib.id).entries['mem-6'] },
|
|
206
|
+
before,
|
|
207
|
+
'a refused patch leaves the entry exactly as it was',
|
|
208
|
+
)
|
|
209
|
+
await dispatch({}, 'updateMemory', {
|
|
210
|
+
libraryId: lib.id,
|
|
211
|
+
id: 'mem-6',
|
|
212
|
+
patch: { title: 'ok', layer: 'short_term', owner: 'about_user', visibility: 'secret' },
|
|
213
|
+
})
|
|
214
|
+
const entry = loadPrivateIndex(lib.id).entries['mem-6']
|
|
215
|
+
assert.equal(entry.title, 'ok')
|
|
216
|
+
// The browser may demote a memory to a draft instead of only promoting it, and it
|
|
217
|
+
// may say whose fact it is and how far it may travel.
|
|
218
|
+
assert.equal(entry.layer, 'short_term')
|
|
219
|
+
assert.equal(entry.owner, 'about_user')
|
|
220
|
+
assert.equal(entry.visibility, 'secret')
|
|
221
|
+
assert.ok(!entry.pinReason, 'pinReason must not be writable from the browser')
|
|
222
|
+
assert.ok(!entry.pinSource, 'pinSource decides the anchor rank, so it stays server-side')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('folder sessions use host titles and hide archived or deleted conversations', async () => {
|
|
226
|
+
const cwd = path.join(home, 'browser-folder-list')
|
|
227
|
+
ensureSession('folder-visible', { cwd })
|
|
228
|
+
ensureSession('folder-archived', { cwd })
|
|
229
|
+
ensureSession('folder-deleted', { cwd })
|
|
230
|
+
const ctx = {
|
|
231
|
+
get(name) {
|
|
232
|
+
if (name === 'sessionQuery') {
|
|
233
|
+
return {
|
|
234
|
+
listSessions: async () => [
|
|
235
|
+
{ header: { id: 'folder-visible', cwd }, live: true, persisted: true },
|
|
236
|
+
{ header: { id: 'folder-archived', cwd }, live: false, persisted: true },
|
|
237
|
+
{ header: { id: 'folder-deleted', cwd }, live: false, persisted: true },
|
|
238
|
+
],
|
|
239
|
+
readTitleSnapshots: async (ids) => ids.map((sessionId) => ({
|
|
240
|
+
sessionId,
|
|
241
|
+
status: 'fulfilled',
|
|
242
|
+
value: { title: { title: sessionId === 'folder-visible' ? '可见对话' : '' } },
|
|
243
|
+
})),
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (name === 'workspaceRegistry') {
|
|
247
|
+
return {
|
|
248
|
+
archivedSessionIds: ['folder-archived'],
|
|
249
|
+
list: () => [{ path: cwd, sessionIds: ['folder-visible', 'folder-archived'] }],
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return null
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
const out = await dispatch(ctx, 'listFolderSessions', { scopeKey: cwd })
|
|
256
|
+
assert.deepEqual(out.sessions.map((s) => s.sessionId), ['folder-visible'])
|
|
257
|
+
assert.equal(out.sessions[0].title, '可见对话')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// ---- provenance in the browser, and the private review queue ----
|
|
261
|
+
|
|
262
|
+
test('the browser shows where a memory came from, and lets it be corrected', async () => {
|
|
263
|
+
const lib = createLibrary({ name: 'browser-src', cwd: path.join(home, 'browser-src') })
|
|
264
|
+
seed(lib.id, 'mem-src', { source: 'inferred', confidence: 0.8 })
|
|
265
|
+
const view = await dispatch({}, 'browseLibrary', { libraryId: lib.id })
|
|
266
|
+
assert.equal(view.files[0].source, 'inferred', 'provenance is visible without opening the editor')
|
|
267
|
+
assert.equal(view.files[0].accuracy, null, 'an entry written before accuracy existed reads as unknown, not zero')
|
|
268
|
+
|
|
269
|
+
await dispatch({}, 'updateMemory', { libraryId: lib.id, id: 'mem-src', patch: { source: 'user_said' } })
|
|
270
|
+
assert.equal(loadPrivateIndex(lib.id).entries['mem-src'].source, 'user_said')
|
|
271
|
+
await assert.rejects(
|
|
272
|
+
() => dispatch({}, 'updateMemory', { libraryId: lib.id, id: 'mem-src', patch: { source: 'they-said' } }),
|
|
273
|
+
/source must be/,
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('a private contradiction waits in the same queue as the global one', async () => {
|
|
278
|
+
const lib = createLibrary({ name: 'browser-review', cwd: path.join(home, 'browser-review') })
|
|
279
|
+
seed(lib.id, 'mem-a', {})
|
|
280
|
+
seed(lib.id, 'mem-b', {
|
|
281
|
+
status: 'pending_review',
|
|
282
|
+
reviewReason: '与已有记忆「mem-a」同题但内容不同',
|
|
283
|
+
conflictingWith: 'mem-a',
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const queue = await dispatch({}, 'listReviews', { libraryId: lib.id })
|
|
287
|
+
assert.equal(queue.scope, 'private')
|
|
288
|
+
assert.equal(queue.libraryId, lib.id)
|
|
289
|
+
assert.equal(queue.items.length, 1)
|
|
290
|
+
assert.equal(queue.items[0].id, 'mem-b')
|
|
291
|
+
assert.equal(queue.items[0].libraryId, lib.id, 'each row knows which library to resolve against')
|
|
292
|
+
assert.match(queue.items[0].reviewReason, /同题/)
|
|
293
|
+
|
|
294
|
+
await dispatch({}, 'resolveReview', { libraryId: lib.id, id: 'mem-b', action: 'accept' })
|
|
295
|
+
const entry = loadPrivateIndex(lib.id).entries['mem-b']
|
|
296
|
+
assert.equal(entry.status, 'active')
|
|
297
|
+
assert.equal(entry.reviewReason, undefined, 'the reason goes away once the question is answered')
|
|
298
|
+
assert.ok(entry.reviewedAt)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('rejecting a private review archives it instead of deleting it', async () => {
|
|
302
|
+
const lib = createLibrary({ name: 'browser-reject', cwd: path.join(home, 'browser-reject') })
|
|
303
|
+
seed(lib.id, 'mem-c', { status: 'pending_review', reviewReason: '说法相反', conflictingWith: 'mem-a' })
|
|
304
|
+
await dispatch({}, 'resolveReview', { libraryId: lib.id, id: 'mem-c', action: 'reject' })
|
|
305
|
+
const entry = loadPrivateIndex(lib.id).entries['mem-c']
|
|
306
|
+
assert.equal(entry.status, 'archived', 'a discarded claim is kept on record, not erased')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('the global queue is still the default, and an unknown library is refused', async () => {
|
|
310
|
+
const out = await dispatch({}, 'listReviews', {})
|
|
311
|
+
assert.equal(out.scope, 'global')
|
|
312
|
+
assert.equal(out.libraryId, null)
|
|
313
|
+
await assert.rejects(() => dispatch({}, 'listReviews', { libraryId: 'lib-nope' }), /library not found/)
|
|
314
|
+
await assert.rejects(
|
|
315
|
+
() => dispatch({}, 'resolveReview', { libraryId: 'lib-nope', id: 'x', action: 'accept' }),
|
|
316
|
+
/library not found/,
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// ---- permanent deletion of archived memories ----
|
|
321
|
+
|
|
322
|
+
test('an archived memory can be deleted for good, and leaves the browser', async () => {
|
|
323
|
+
const lib = createLibrary({ name: 'browser-del', cwd: path.join(home, 'browser-del') })
|
|
324
|
+
seed(lib.id, 'mem-live', {})
|
|
325
|
+
seed(lib.id, 'mem-gone', { status: 'archived' })
|
|
326
|
+
const before = await dispatch({}, 'browseLibrary', { libraryId: lib.id })
|
|
327
|
+
assert.equal(before.files.length, 2)
|
|
328
|
+
|
|
329
|
+
const out = await dispatch({}, 'deleteMemory', { libraryId: lib.id, id: 'mem-gone' })
|
|
330
|
+
assert.equal(out.deleted, true)
|
|
331
|
+
assert.equal(out.libraryId, lib.id)
|
|
332
|
+
|
|
333
|
+
const index = loadPrivateIndex(lib.id)
|
|
334
|
+
assert.equal(index.entries['mem-gone'], undefined, 'the entry is gone from the index, not just relabelled')
|
|
335
|
+
assert.ok(index.entries['mem-live'], 'the live neighbour is untouched')
|
|
336
|
+
const after = await dispatch({}, 'browseLibrary', { libraryId: lib.id })
|
|
337
|
+
assert.deepEqual(after.files.map((f) => f.id), ['mem-live'])
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('a live memory cannot be deleted without archiving it first', async () => {
|
|
341
|
+
const lib = createLibrary({ name: 'browser-del-live', cwd: path.join(home, 'browser-del-live') })
|
|
342
|
+
seed(lib.id, 'mem-safe', {})
|
|
343
|
+
await assert.rejects(
|
|
344
|
+
() => dispatch({}, 'deleteMemory', { libraryId: lib.id, id: 'mem-safe' }),
|
|
345
|
+
/archive it first/,
|
|
346
|
+
)
|
|
347
|
+
assert.ok(loadPrivateIndex(lib.id).entries['mem-safe'], 'the refusal leaves the entry in place')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('deleting needs a library id, and an unknown one is refused', async () => {
|
|
351
|
+
const lib = createLibrary({ name: 'browser-del-args', cwd: path.join(home, 'browser-del-args') })
|
|
352
|
+
seed(lib.id, 'mem-x', { status: 'archived' })
|
|
353
|
+
await assert.rejects(() => dispatch({}, 'deleteMemory', { id: 'mem-x' }), /libraryId is required/)
|
|
354
|
+
await assert.rejects(() => dispatch({}, 'deleteMemory', { libraryId: 'lib-nope', id: 'mem-x' }), /library not found/)
|
|
355
|
+
await assert.rejects(() => dispatch({}, 'deleteMemory', { libraryId: lib.id }), /memory id is required/)
|
|
356
|
+
assert.ok(loadPrivateIndex(lib.id).entries['mem-x'], 'all three refusals are no-ops')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('forgetting a name the engine never had is not an error', async () => {
|
|
360
|
+
const { forget } = await import('../lib/memu.js')
|
|
361
|
+
const missingDb = path.join(home, 'no-such-engine', 'library.sqlite3')
|
|
362
|
+
const out = await forget({ db: missingDb, name: 'mem-never' })
|
|
363
|
+
assert.equal(out.deleted, 0)
|
|
364
|
+
assert.equal(out.missing, true, 'a missing db is reported instead of being created empty')
|
|
365
|
+
assert.equal(fs.existsSync(missingDb), false)
|
|
366
|
+
})
|