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,132 @@
|
|
|
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 crypto from 'node:crypto'
|
|
7
|
+
import { Readable, Writable } from 'node:stream'
|
|
8
|
+
import sharp from 'sharp'
|
|
9
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-media-'))
|
|
10
|
+
process.env.DSH_MEMU_HOME = home
|
|
11
|
+
const state = await import('../lib/state.js')
|
|
12
|
+
const media = await import('../lib/media.js')
|
|
13
|
+
const memory = await import('../lib/memory.js')
|
|
14
|
+
const inject = await import('../lib/inject.js')
|
|
15
|
+
const rpc = await import('../lib/rpc.js')
|
|
16
|
+
const { registerTools } = await import('../lib/tools.js')
|
|
17
|
+
test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
|
|
18
|
+
const bytes = await sharp({ create: { width: 800, height: 600, channels: 3, background: '#339966' } }).png().toBuffer()
|
|
19
|
+
const sha = crypto.createHash('sha256').update(bytes).digest('hex')
|
|
20
|
+
const ref = { attachmentId: sha, mediaType: 'image/png', bytes: bytes.length, width: 800, height: 600, name: 'birthday.png' }
|
|
21
|
+
const binding = state.ensureSession('images', { cwd: path.join(home, 'rp'), agentPreset: 'rp' })
|
|
22
|
+
const libraryId = binding.libraryId
|
|
23
|
+
const session = { header: { id: 'images', cwd: path.join(home, 'rp') } }
|
|
24
|
+
const exec = { agent: { session } }
|
|
25
|
+
const ctx = { get: name => name === 'attachments' ? { readImage: async () => ({ ref, data: bytes }) } : undefined }
|
|
26
|
+
const event = (seq, text = '') => ({ type: 'user/message', seq, data: { source: { kind: 'user' }, content: [...(text ? [{ type: 'text', text }] : []), { type: 'image', attachment: ref }] } })
|
|
27
|
+
|
|
28
|
+
test('three image events store one verified blob and hint at a habit, not an automatic memory', async () => {
|
|
29
|
+
for (let i = 0; i < 3; i++) await media.captureMediaEvent(ctx, session, event(i, '这是上次生日留下的照片。'))
|
|
30
|
+
const pool = JSON.parse(fs.readFileSync(path.join(home, 'index', `${libraryId}.media.json`)))
|
|
31
|
+
assert.equal(pool.totals.count, 1)
|
|
32
|
+
assert.equal(pool.blobs[sha].useCount, 3)
|
|
33
|
+
assert.equal(fs.readdirSync(path.join(home, 'attachments', libraryId)).length, 1)
|
|
34
|
+
assert.equal(state.getSessionBinding('images').stickerHint.sha256, sha)
|
|
35
|
+
assert.equal(Object.keys(state.loadPrivateIndex(libraryId).entries).length, 0)
|
|
36
|
+
await media.captureMediaEvent(ctx, session, event(2))
|
|
37
|
+
assert.equal(media.attachmentRefs(libraryId, [{ sha256: sha }])[0].useCount, 3, 'event replay is idempotent')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('image-only turns retrieve a saved image and expose the image count in status', async () => {
|
|
41
|
+
const saved = await memory.privateRemember({ exec, title: '生日照片', content: '上次生日的一张照片。', attachments: [{ sha256: sha }], layer: 'long_term' })
|
|
42
|
+
const message = { role: 'user', ...event(4).data }
|
|
43
|
+
const decision = await inject.injectForStep({ agent: exec.agent, decision: { kind: 'enter', messages: [message] }, turn: 2, step: 1 })
|
|
44
|
+
assert.ok(decision.messages.length > 1)
|
|
45
|
+
assert.ok(decision.messages[0].content[0].text.includes(saved.id))
|
|
46
|
+
assert.equal(state.getSessionBinding('images').recentQueries.at(-1).imageCount, 1)
|
|
47
|
+
const tools = []
|
|
48
|
+
registerTools({ tools: { register: tool => { tools.push(tool); return () => {} } } })
|
|
49
|
+
assert.match(await tools.find(tool => tool.name === 'memu_status').execute({}, exec), /image_turn: 1/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('ordinary images are short-lived drafts, important ones retain separate accuracy and habits aggregate', async () => {
|
|
53
|
+
const draft = await memory.privateRemember({ exec, title: '普通图', content: '今天收到的图。', attachments: [{ sha256: sha }] })
|
|
54
|
+
assert.equal(draft.entry.layer, 'short_term')
|
|
55
|
+
assert.ok(Math.abs(Date.parse(draft.entry.expiresAt) - Date.now() - 72 * 3_600_000) < 5000)
|
|
56
|
+
const important = await memory.privateRemember({ exec, title: '重要瞬间', content: '这是生日纪念。', attachments: [{ sha256: sha }], layer: 'long_term', salientDate: '10-28', arousal: .95, confidence: .9 })
|
|
57
|
+
assert.ok(important.entry.accuracy < important.entry.confidence)
|
|
58
|
+
const habit = await memory.privateRemember({ exec, title: '表达习惯', content: '他无语时习惯发这张图。', attachments: [{ sha256: sha }], track: 'habit' })
|
|
59
|
+
assert.equal(habit.entry.track, 'habit')
|
|
60
|
+
assert.equal(habit.entry.layer, 'long_term')
|
|
61
|
+
const aggregate = await memory.privateRemember({ exec, title: '表达习惯', content: '他无语时经常发这张图,不必逐次复述。', attachments: [{ sha256: sha }], track: 'habit' })
|
|
62
|
+
assert.equal(aggregate.id, habit.id, 'an agent write aggregates the same image habit instead of appending another')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('thumbnail authorization rejects secrets, session mismatches and disabled scopes; captions respect never_say', async () => {
|
|
66
|
+
const privateSession = state.ensureSession('private-image', { cwd: path.join(home, 'private') })
|
|
67
|
+
await media.captureMediaEvent(ctx, { header: { id: 'private-image' } }, event(0, 'hidden caption'))
|
|
68
|
+
const secret = await memory.privateRemember({ exec: { agent: { session: { header: { id: 'private-image' } } } }, content: 'secret body', title: 'secret', visibility: 'secret', attachments: [{ sha256: sha }], layer: 'long_term' })
|
|
69
|
+
assert.throws(() => media.getAttachment({ libraryId: privateSession.libraryId, sha256: sha, sessionId: 'private-image' }), error => error.status === 403)
|
|
70
|
+
await memory.privateUpdate({ libraryId: privateSession.libraryId, id: secret.id, visibility: 'shared', disclosure: 'never_say' })
|
|
71
|
+
assert.equal(media.getAttachment({ libraryId: privateSession.libraryId, sha256: sha, sessionId: 'private-image' }).caption, null)
|
|
72
|
+
assert.throws(() => media.getAttachment({ libraryId, sha256: sha, sessionId: 'unknown' }), error => error.status === 403)
|
|
73
|
+
state.setSessionBinding('private-image', { useLibrary: false })
|
|
74
|
+
assert.throws(() => media.getAttachment({ libraryId: privateSession.libraryId, sha256: sha, sessionId: 'private-image' }), error => error.status === 403)
|
|
75
|
+
const thumb = await media.attachmentThumbnail({ libraryId, sha256: sha, sessionId: 'images' })
|
|
76
|
+
const metadata = await sharp(thumb.data).metadata()
|
|
77
|
+
assert.equal(metadata.width, 512)
|
|
78
|
+
assert.equal(metadata.height, 384)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('the size guard never reads an oversized image and still allows a missing-image memory', async () => {
|
|
82
|
+
const refTooLarge = { ...ref, attachmentId: 'f'.repeat(64), bytes: 9 * 1024 * 1024 }
|
|
83
|
+
let reads = 0
|
|
84
|
+
const out = await media.captureMediaEvent({ get: () => ({ readImage: async () => { reads++; throw new Error('must not read') } }) }, session,
|
|
85
|
+
{ ...event(10), data: { source: { kind: 'user' }, content: [{ type: 'image', attachment: refTooLarge }] } })
|
|
86
|
+
assert.equal(reads, 0)
|
|
87
|
+
assert.equal(out[0].missing, true)
|
|
88
|
+
const saved = await memory.privateRemember({ exec, content: '图太大但记得收到过。', title: '大图', attachments: [{ sha256: refTooLarge.attachmentId }] })
|
|
89
|
+
assert.equal(saved.entry.attachments[0].missing, true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('an attachment archive round-trips bytes and missing files do not break memory recall', async () => {
|
|
93
|
+
const exported = await rpc.dispatch({}, 'exportLibrary', { libraryId, includeAttachments: true })
|
|
94
|
+
const other = state.createLibrary({ name: 'imported', cwd: path.join(home, 'imported') })
|
|
95
|
+
const result = await rpc.dispatch({}, 'importMemories', { libraryId: other.id, archiveBase64: exported.base64 })
|
|
96
|
+
assert.ok(result.imported > 0)
|
|
97
|
+
assert.ok(fs.existsSync(path.join(home, 'attachments', other.id, `${sha}.png`)))
|
|
98
|
+
fs.unlinkSync(path.join(home, 'attachments', libraryId, `${sha}.png`))
|
|
99
|
+
assert.equal(media.getAttachment({ libraryId, sha256: sha, sessionId: 'images' }).missing, true)
|
|
100
|
+
assert.ok((await memory.privateRecall({ exec, query: '生日', relevanceThreshold: 0 })).files.length)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('GC keeps referenced images and deletes only unused month-old bytes', () => {
|
|
104
|
+
const file = path.join(home, 'index', `${libraryId}.media.json`)
|
|
105
|
+
const pool = JSON.parse(fs.readFileSync(file))
|
|
106
|
+
const orphan = 'e'.repeat(64)
|
|
107
|
+
fs.writeFileSync(path.join(home, 'attachments', libraryId, `${orphan}.png`), bytes)
|
|
108
|
+
pool.blobs[orphan] = { ...pool.blobs[sha], refs: [], lastUsedAt: '2026-01-01T00:00:00Z', sha256: orphan }
|
|
109
|
+
fs.writeFileSync(file, JSON.stringify(pool))
|
|
110
|
+
media.reconcileMedia(libraryId, state.loadPrivateIndex(libraryId), { now: Date.parse('2026-10-28T00:00:00Z') })
|
|
111
|
+
assert.ok(!fs.existsSync(path.join(home, 'attachments', libraryId, `${orphan}.png`)))
|
|
112
|
+
assert.ok(Object.keys(state.loadPrivateIndex(libraryId).entries).length > 0)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('streaming archives round-trip without base64 and reject zip-slip paths', async () => {
|
|
116
|
+
const { zipSync } = await import('fflate')
|
|
117
|
+
const source = state.createLibrary({ name: 'stream-source', cwd: path.join(home, 'stream-source') })
|
|
118
|
+
state.ensureSession('stream-session', { cwd: path.join(home, 'stream-source') })
|
|
119
|
+
await media.captureMediaEvent(ctx, { header: { id: 'stream-session' } }, event(0, 'verified source image'))
|
|
120
|
+
await memory.privateRemember({ exec: { agent: { session: { header: { id: 'stream-session' } } } }, title: 'stream', content: 'streamed image evidence', attachments: [{ sha256: sha }], layer: 'long_term' })
|
|
121
|
+
const payload = await rpc.dispatch({}, 'exportLibrary', { libraryId: source.id })
|
|
122
|
+
const chunks = []
|
|
123
|
+
const output = new Writable({ write(chunk, _encoding, done) { chunks.push(Buffer.from(chunk)); done() } })
|
|
124
|
+
output.writeHead = () => {}
|
|
125
|
+
await media.streamMediaExport(source.id, payload, output)
|
|
126
|
+
const dest = state.createLibrary({ name: 'stream-dest', cwd: path.join(home, 'stream-dest') })
|
|
127
|
+
const archive = await media.importMediaStream(dest.id, Readable.from([Buffer.concat(chunks)]))
|
|
128
|
+
assert.equal(archive.memories.length, 1)
|
|
129
|
+
assert.ok(fs.existsSync(path.join(home, 'attachments', dest.id, `${sha}.png`)))
|
|
130
|
+
const malicious = zipSync({ '../outside.json': Buffer.from('{}') })
|
|
131
|
+
await assert.rejects(media.importMediaStream(dest.id, Readable.from([malicious])), /unsupported/)
|
|
132
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
const { migrationCommitPayload } = await import('../lib/local-vector-service.js')
|
|
5
|
+
|
|
6
|
+
// 现场事故(2026-10-29):重建时每条记忆都报 VECTOR_READBACK_FAILED,影子库只有 61KB
|
|
7
|
+
// 空 schema。原因不是 embedding,而是迁移器把 `{ name, description, ...entry }` 裸对象
|
|
8
|
+
// 当成 commit 载荷 —— MemU 只认 `{ recall_files, resource, user }`,于是"提交成功"却一行
|
|
9
|
+
// 没写。这组用例钉住信封形状。
|
|
10
|
+
|
|
11
|
+
test('the migration commit uses the recall_files envelope MemU expects', () => {
|
|
12
|
+
const payload = migrationCommitPayload({
|
|
13
|
+
id: 'mem-abc',
|
|
14
|
+
text: '标题 正文',
|
|
15
|
+
entry: { title: '标题', content: '正文' },
|
|
16
|
+
})
|
|
17
|
+
assert.deepEqual(payload, {
|
|
18
|
+
recall_files: [{ name: 'mem-abc', track: 'memory', description: '标题', content: '正文' }],
|
|
19
|
+
resource: [],
|
|
20
|
+
user: {},
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('a snapshot without an entry falls back to the snapshot text', () => {
|
|
25
|
+
const payload = migrationCommitPayload({ id: 'mem-1', text: 'fallback text' })
|
|
26
|
+
assert.equal(payload.recall_files[0].name, 'mem-1')
|
|
27
|
+
assert.equal(payload.recall_files[0].description, 'fallback text')
|
|
28
|
+
assert.equal(payload.recall_files[0].content, 'fallback text')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('the payload is never the bare object that silently commits nothing', () => {
|
|
32
|
+
const payload = migrationCommitPayload({ id: 'mem-2', entry: {} })
|
|
33
|
+
assert.ok(Array.isArray(payload.recall_files), 'MemU needs a recall_files array')
|
|
34
|
+
assert.equal(payload.name, undefined, 'a top-level name is the old broken shape')
|
|
35
|
+
assert.equal(payload.recall_files[0].content, '')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('long titles are clamped to the description budget', () => {
|
|
39
|
+
const payload = migrationCommitPayload({ id: 'mem-3', entry: { title: 'x'.repeat(400), content: 'body' } })
|
|
40
|
+
assert.equal(payload.recall_files[0].description.length, 180)
|
|
41
|
+
assert.equal(payload.recall_files[0].content, 'body')
|
|
42
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
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-mood-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const state = await import('../lib/state.js')
|
|
11
|
+
const mood = await import('../lib/mood.js')
|
|
12
|
+
const { normalizeSalientDate } = await import('../lib/memory.js')
|
|
13
|
+
const inject = await import('../lib/inject.js')
|
|
14
|
+
|
|
15
|
+
test.after(() => {
|
|
16
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('readMood signs the present state without reading meaning', () => {
|
|
20
|
+
assert.ok(mood.readMood('今天太开心了,测试全部通过').valence > 0)
|
|
21
|
+
assert.ok(mood.readMood('我很难受,一点都跑不起来').valence < 0)
|
|
22
|
+
assert.equal(mood.readMood('把 index.js 里的 import 改成相对路径').valence, 0)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('mood congruence pulls same-signed memories up and pushes opposites down', () => {
|
|
26
|
+
assert.ok(mood.moodCongruence(-0.8, -0.6) > 0)
|
|
27
|
+
assert.ok(mood.moodCongruence(0.8, -0.6) < 0)
|
|
28
|
+
assert.equal(mood.moodCongruence(0.8, 0), 0)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('mood fades to neutral instead of holding all day', () => {
|
|
32
|
+
const now = Date.now()
|
|
33
|
+
const fresh = mood.moodDecay({ valence: -0.8, arousal: 0.9, at: now }, now)
|
|
34
|
+
const later = mood.moodDecay({ valence: -0.8, arousal: 0.9, at: now }, now + 6 * 60 * 60 * 1000)
|
|
35
|
+
assert.ok(Math.abs(later.valence) < Math.abs(fresh.valence))
|
|
36
|
+
assert.ok(later.arousal < fresh.arousal && later.arousal >= 0.35)
|
|
37
|
+
assert.equal(mood.moodDecay(null, now).valence, 0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('salient dates keep the year when one is given and refuse anything unparseable', () => {
|
|
41
|
+
assert.equal(normalizeSalientDate('3-05'), '03-05')
|
|
42
|
+
// A full date keeps its year: it is one day, not an anniversary that recurs.
|
|
43
|
+
assert.equal(normalizeSalientDate('2026-03-05T00:00:00.000Z'), '2026-03-05')
|
|
44
|
+
assert.equal(normalizeSalientDate('下次再说'), null)
|
|
45
|
+
assert.equal(normalizeSalientDate('13-40'), null)
|
|
46
|
+
assert.equal(normalizeSalientDate(null), null)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('an anniversary fires once per day and not again', () => {
|
|
50
|
+
const binding = state.ensureSession('mood-anniv', { cwd: path.join(home, 'p-anniv'), agentPreset: 'rp' })
|
|
51
|
+
const now = new Date()
|
|
52
|
+
const md = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
53
|
+
const index = state.loadPrivateIndex(binding.libraryId)
|
|
54
|
+
index.entries['mem-anniv'] = {
|
|
55
|
+
track: 'memory', layer: 'long_term', title: '第一次一起看海', content: '那天下着小雨。',
|
|
56
|
+
status: 'active', salientDate: md, createdAt: now.toISOString(), updatedAt: now.toISOString(),
|
|
57
|
+
}
|
|
58
|
+
state.savePrivateIndex(binding.libraryId, index)
|
|
59
|
+
|
|
60
|
+
const first = inject.buildAnniversary('mood-anniv', binding, now)
|
|
61
|
+
assert.match(first.text, /memory-anniversary/)
|
|
62
|
+
assert.match(first.text, /第一次一起看海/)
|
|
63
|
+
assert.deepEqual(first.ids, ['mem-anniv'])
|
|
64
|
+
|
|
65
|
+
state.markAnniversaryShown('mood-anniv', 'mem-anniv', first.dayKey)
|
|
66
|
+
const second = inject.buildAnniversary('mood-anniv', binding, now)
|
|
67
|
+
assert.equal(second.text, '')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('an anniversary on a different day stays silent', () => {
|
|
71
|
+
const binding = state.ensureSession('mood-anniv2', { cwd: path.join(home, 'p-anniv2'), agentPreset: 'rp' })
|
|
72
|
+
const index = state.loadPrivateIndex(binding.libraryId)
|
|
73
|
+
index.entries['mem-someday'] = {
|
|
74
|
+
track: 'memory', layer: 'long_term', title: '某个九月', content: '', status: 'active',
|
|
75
|
+
salientDate: '09-21', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
76
|
+
}
|
|
77
|
+
state.savePrivateIndex(binding.libraryId, index)
|
|
78
|
+
const out = inject.buildAnniversary('mood-anniv2', binding, new Date('2026-03-05T12:00:00'))
|
|
79
|
+
assert.equal(out.text, '')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('low-confidence memory is injected as a hedge, not a fact', () => {
|
|
83
|
+
const binding = state.ensureSession('mood-fuzzy', { cwd: path.join(home, 'p-fuzzy'), agentPreset: 'rp' })
|
|
84
|
+
const index = state.loadPrivateIndex(binding.libraryId)
|
|
85
|
+
index.entries['mem-fuzzy'] = {
|
|
86
|
+
track: 'memory', layer: 'long_term', title: '也许养过一只猫', content: '记不清了。',
|
|
87
|
+
status: 'active', confidence: 0.3, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
88
|
+
}
|
|
89
|
+
index.entries['mem-solid'] = {
|
|
90
|
+
track: 'memory', layer: 'must_remember', title: '对花生过敏', content: '确定。',
|
|
91
|
+
status: 'active', confidence: 0.95, pinReason: '安全相关', pinSource: 'user_explicit',
|
|
92
|
+
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
93
|
+
}
|
|
94
|
+
state.savePrivateIndex(binding.libraryId, index)
|
|
95
|
+
const anchor = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 1200 })
|
|
96
|
+
const fuzzyLine = anchor.text.split('\n').find((l) => l.includes('mem-fuzzy'))
|
|
97
|
+
const solidLine = anchor.text.split('\n').find((l) => l.includes('mem-solid'))
|
|
98
|
+
assert.match(fuzzyLine, /记忆模糊/)
|
|
99
|
+
assert.doesNotMatch(solidLine, /记忆模糊/)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('session mood is stored on the binding and read back', () => {
|
|
103
|
+
state.ensureSession('mood-store', { cwd: path.join(home, 'p-store'), agentPreset: 'rp' })
|
|
104
|
+
state.setSessionMood('mood-store', { valence: -0.6, arousal: 0.8 })
|
|
105
|
+
const stored = state.getSessionMood('mood-store')
|
|
106
|
+
assert.equal(stored.valence, -0.6)
|
|
107
|
+
assert.equal(stored.arousal, 0.8)
|
|
108
|
+
assert.ok(typeof stored.at === 'number')
|
|
109
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
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 { spawn } from 'node:child_process'
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-multi-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
const state = await import('../lib/state.js')
|
|
10
|
+
test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
|
|
11
|
+
const library = state.createLibrary({ name: 'parallel', cwd: path.join(home, 'parallel') })
|
|
12
|
+
const moduleUrl = new URL('../lib/state.js', import.meta.url).href
|
|
13
|
+
function worker(prefix) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const pathsUrl = new URL('../lib/paths.js', import.meta.url).href
|
|
16
|
+
const code = `const state = await import(${JSON.stringify(moduleUrl)}); const {retryStoreWrite}=await import(${JSON.stringify(pathsUrl)}); for(let i=0;i<15;i++){await retryStoreWrite(()=>state.updatePrivateIndex(${JSON.stringify(library.id)}, index=>{index.entries[${JSON.stringify(prefix)}+i]={content:'retained '+i}}));state.setSessionBinding(${JSON.stringify(prefix)}+i,{useGlobal:false});await retryStoreWrite(()=>state.flushState())}`
|
|
17
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', code], { env: { ...process.env, DSH_MEMU_HOME: home }, windowsHide: true })
|
|
18
|
+
let errors = ''
|
|
19
|
+
child.stderr.on('data', data => { errors += data })
|
|
20
|
+
child.on('error', reject)
|
|
21
|
+
child.on('close', code => code === 0 ? resolve() : reject(new Error(errors)))
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
test('independent processes keep every disjoint memory and session binding', async () => {
|
|
25
|
+
await Promise.all([worker('a'), worker('b'), worker('c')])
|
|
26
|
+
state.clearCaches()
|
|
27
|
+
assert.equal(Object.keys(state.loadPrivateIndex(library.id).entries).length, 45)
|
|
28
|
+
assert.equal(Object.keys(state.loadState().sessions).length, 45)
|
|
29
|
+
})
|