dsh-memu-memory 1.1.3 → 1.2.0
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 +31 -2
- package/README.zh.md +30 -2
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +1 -1
- package/docs/emotion-model-spec.md +281 -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 +395 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +1 -1
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +33 -11
- 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 +115 -0
- package/lib/cycle.js +40 -6
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +221 -19
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +335 -40
- 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 +9 -1
- package/lib/state.js +29 -12
- package/lib/tools.js +49 -2
- package/lib/vector-profile.js +27 -0
- package/package.json +2 -1
- 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/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 +212 -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 +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -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-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-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -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 +536 -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/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 +208 -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 +733 -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 +164 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// W5 probe: which layers of global curation carry content from each channel.
|
|
2
|
+
//
|
|
3
|
+
// Six synthetic channels, each with its own unique marker:
|
|
4
|
+
// 1. secretAuto secret auto-injection (plugin message in the user role, form=anchor;
|
|
5
|
+
// hypothetical shape: production secrets are not auto-injected)
|
|
6
|
+
// 2. recallSecret tool/result of an explicit memu_private_recall (secret body)
|
|
7
|
+
// 3. toolArgs tool-call arguments (assistant/message tool-call + tool/call event)
|
|
8
|
+
// 4. recallPlain ordinary private recall auto-injection (plugin message, form=recall)
|
|
9
|
+
// 5. assistant assistant text (paraphrase)
|
|
10
|
+
// 6. user user/message with source.kind === 'user'
|
|
11
|
+
//
|
|
12
|
+
// For each channel this file reports whether the marker reaches:
|
|
13
|
+
// (a) the condensed transcript,
|
|
14
|
+
// (b) the real prompt passed to subagents.startActivation,
|
|
15
|
+
// (c) the simulated curator output,
|
|
16
|
+
// (d) the committed global index.
|
|
17
|
+
//
|
|
18
|
+
// Every value below is synthetic. This file never reads or writes the real
|
|
19
|
+
// ~/.dsh/dsh-memu tree: DSH_MEMU_HOME points at an os.tmpdir() root before the
|
|
20
|
+
// plugin modules are imported and the root is removed afterwards. The clock,
|
|
21
|
+
// Math.random and crypto.randomBytes are fixed so ids and timestamps are
|
|
22
|
+
// reproducible.
|
|
23
|
+
//
|
|
24
|
+
// What it can prove: a marker placed in a given input channel does or does not
|
|
25
|
+
// reach each layer, and that the commit layer checks neither provenance nor
|
|
26
|
+
// secret visibility. What it cannot prove: that a real model would copy a
|
|
27
|
+
// secret into its output, or that a leak happened on a real machine.
|
|
28
|
+
import test from 'node:test'
|
|
29
|
+
import assert from 'node:assert/strict'
|
|
30
|
+
import fs from 'node:fs'
|
|
31
|
+
import os from 'node:os'
|
|
32
|
+
import path from 'node:path'
|
|
33
|
+
import crypto from 'node:crypto'
|
|
34
|
+
|
|
35
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-probe-'))
|
|
36
|
+
delete process.env.DSH_MEMU_MEMORY_HOME
|
|
37
|
+
process.env.DSH_MEMU_HOME = home
|
|
38
|
+
|
|
39
|
+
// Fixed clock and random sources. newMemoryId() uses Date.now() plus
|
|
40
|
+
// crypto.randomBytes(), while job ids use Date.now() plus Math.random().
|
|
41
|
+
const RealDate = Date
|
|
42
|
+
const FIXED_NOW = RealDate.parse('2026-01-02T03:04:05.000Z')
|
|
43
|
+
class FixedDate extends RealDate {
|
|
44
|
+
constructor(...args) {
|
|
45
|
+
if (args.length === 0) super(FIXED_NOW)
|
|
46
|
+
else super(...args)
|
|
47
|
+
}
|
|
48
|
+
static now() { return FIXED_NOW }
|
|
49
|
+
}
|
|
50
|
+
globalThis.Date = FixedDate
|
|
51
|
+
const realRandom = Math.random
|
|
52
|
+
let randomState = 0x5eed1234
|
|
53
|
+
Math.random = () => {
|
|
54
|
+
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
|
|
55
|
+
return randomState / 0x100000000
|
|
56
|
+
}
|
|
57
|
+
const realRandomBytes = crypto.randomBytes
|
|
58
|
+
let randomByte = 0x40
|
|
59
|
+
crypto.randomBytes = (size) => {
|
|
60
|
+
const buffer = Buffer.alloc(size)
|
|
61
|
+
for (let i = 0; i < size; i += 1) buffer[i] = (randomByte + i) & 0xff
|
|
62
|
+
randomByte = (randomByte + size) & 0xff
|
|
63
|
+
return buffer
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const {
|
|
67
|
+
startGlobalCuration, commitCuratorOutput, getCurationJob, condenseEvents,
|
|
68
|
+
} = await import('../lib/curator.js')
|
|
69
|
+
const { loadGlobalIndex, saveGlobalIndex } = await import('../lib/state.js')
|
|
70
|
+
const { dataDir } = await import('../lib/paths.js')
|
|
71
|
+
|
|
72
|
+
test.after(() => {
|
|
73
|
+
globalThis.Date = RealDate
|
|
74
|
+
Math.random = realRandom
|
|
75
|
+
crypto.randomBytes = realRandomBytes
|
|
76
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const M = {
|
|
80
|
+
secretAuto: 'W5PROBE-SECRET-AUTOINJECT-8f3a',
|
|
81
|
+
recallSecret: 'W5PROBE-RECALL-RESULT-SECRET-8f3a',
|
|
82
|
+
toolArgs: 'W5PROBE-TOOLCALL-ARGS-8f3a',
|
|
83
|
+
recallPlain: 'W5PROBE-RECALL-PLAIN-8f3a',
|
|
84
|
+
assistant: 'W5PROBE-ASSISTANT-PARAPHRASE-8f3a',
|
|
85
|
+
user: 'W5PROBE-USER-ORIGINAL-8f3a',
|
|
86
|
+
toolTail: 'W5PROBE-TOOLTAIL-8f3a',
|
|
87
|
+
existing: 'W5PROBE-EXISTING-8f3a',
|
|
88
|
+
archived: 'W5PROBE-ARCHIVED-8f3a',
|
|
89
|
+
pending: 'W5PROBE-PENDING-8f3a',
|
|
90
|
+
modelOnly: 'W5PROBE-MODEL-ONLY-8f3a',
|
|
91
|
+
invalidDomain: 'W5PROBE-COMMIT-SKIPPED-8f3a',
|
|
92
|
+
credential: 'sk-W5PROBEfakeCredential1234',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const CHANNELS = [
|
|
96
|
+
{ key: 'secretAuto', shape: 'plugin anchor message (user role)', layers: [false, false, false, false] },
|
|
97
|
+
{ key: 'recallSecret', shape: 'tool/result from memu_private_recall', layers: [true, true, true, true] },
|
|
98
|
+
{ key: 'toolArgs', shape: 'assistant tool-call arguments + tool/call event', layers: [true, true, true, true] },
|
|
99
|
+
{ key: 'recallPlain', shape: 'plugin recall message (user role)', layers: [false, false, false, false] },
|
|
100
|
+
{ key: 'assistant', shape: 'assistant text (paraphrase)', layers: [true, true, true, true] },
|
|
101
|
+
{ key: 'user', shape: 'user/message with source.kind=user', layers: [true, true, true, true] },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
const HEADER = {
|
|
105
|
+
id: 'probe-session',
|
|
106
|
+
title: 'Synthetic probe session',
|
|
107
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
108
|
+
cwd: path.join(home, 'project'),
|
|
109
|
+
agentPreset: 'coding',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The six channels share one transcript, so a single full-chain run can report
|
|
113
|
+
// all of them against the same prompt and the same commit.
|
|
114
|
+
function probeEvents() {
|
|
115
|
+
return [
|
|
116
|
+
// 1. Automatic anchor injection: a plugin message, not a user message.
|
|
117
|
+
{
|
|
118
|
+
type: 'user/message',
|
|
119
|
+
data: {
|
|
120
|
+
source: { kind: 'plugin:dsh-memu-memory', form: 'anchor' },
|
|
121
|
+
content: [{ type: 'text', text: '<memory-context scope="private">- [pmem-1] ' + M.secretAuto + ' auto-injected secret body</memory-context>' }],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
// 4. Ordinary automatic recall: same plugin-message shape, different form.
|
|
125
|
+
{
|
|
126
|
+
type: 'user/message',
|
|
127
|
+
data: {
|
|
128
|
+
source: { kind: 'plugin:dsh-memu-memory', form: 'recall' },
|
|
129
|
+
content: [{ type: 'text', text: '<memory-context scope="private">- [pmem-2] ' + M.recallPlain + ' ordinary private body</memory-context>' }],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
// 6. The user's own words.
|
|
133
|
+
{
|
|
134
|
+
type: 'user/message',
|
|
135
|
+
data: { source: { kind: 'user' }, content: [{ type: 'text', text: M.user + ' the user original words' }] },
|
|
136
|
+
},
|
|
137
|
+
// 5 + 3. Assistant paraphrase and the recall query it issued.
|
|
138
|
+
{
|
|
139
|
+
type: 'assistant/message',
|
|
140
|
+
data: {
|
|
141
|
+
message: {
|
|
142
|
+
content: [
|
|
143
|
+
{ type: 'text', text: M.assistant + ' assistant paraphrase' },
|
|
144
|
+
{ type: 'tool-call', name: 'memu_private_recall', arguments: JSON.stringify({ query: M.toolArgs, visibility: 'secret' }) },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
// 2. The result of that explicit recall (a secret entry body).
|
|
150
|
+
{
|
|
151
|
+
type: 'tool/result',
|
|
152
|
+
data: { message: { content: [{ type: 'text', text: M.recallSecret + ' explicit recall of a secret entry' }] } },
|
|
153
|
+
},
|
|
154
|
+
// 3b. The host's standalone tool/call event shape.
|
|
155
|
+
{
|
|
156
|
+
type: 'tool/call',
|
|
157
|
+
data: { name: 'memu_private_remember', arguments: JSON.stringify({ content: M.toolArgs }) },
|
|
158
|
+
},
|
|
159
|
+
// Not a channel: a long tool result is cut at 400 chars, so this tail dies.
|
|
160
|
+
{
|
|
161
|
+
type: 'tool/result',
|
|
162
|
+
data: { message: { content: [{ type: 'text', text: 'x'.repeat(500) + M.toolTail }] } },
|
|
163
|
+
},
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sessionQueryFor(events) {
|
|
168
|
+
return {
|
|
169
|
+
listSessions: async () => [{ header: HEADER, live: false }],
|
|
170
|
+
readTitleSnapshots: async () => [],
|
|
171
|
+
readSession: async (id) => {
|
|
172
|
+
assert.equal(id, HEADER.id)
|
|
173
|
+
return { session: HEADER, events, inheritedEventCount: 0 }
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ctxFor(events, onActivation) {
|
|
179
|
+
const parent = { id: 'parent', session: { header: { cwd: path.join(home, 'project') } } }
|
|
180
|
+
return {
|
|
181
|
+
get(name) {
|
|
182
|
+
if (name === 'sessionQuery') return sessionQueryFor(events)
|
|
183
|
+
if (name === 'agents') return { get: (id) => (id === 'parent' ? parent : undefined) }
|
|
184
|
+
if (name === 'subagents') return { startActivation: onActivation }
|
|
185
|
+
return undefined
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function promptInput(prompt) {
|
|
191
|
+
// The template ends with the pretty-printed JSON input.
|
|
192
|
+
const start = prompt.lastIndexOf('{\n "sessions"')
|
|
193
|
+
assert.ok(start >= 0, 'the curator prompt should embed the JSON input')
|
|
194
|
+
return JSON.parse(prompt.slice(start))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function waitJob(id) {
|
|
198
|
+
for (let i = 0; i < 200; i += 1) {
|
|
199
|
+
const job = getCurationJob(id)
|
|
200
|
+
if (job && job.status !== 'running') return job
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
202
|
+
}
|
|
203
|
+
throw new Error('curation job did not settle')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function seedGlobalIndex() {
|
|
207
|
+
saveGlobalIndex({
|
|
208
|
+
version: 1,
|
|
209
|
+
entries: {
|
|
210
|
+
'gmem-existing': {
|
|
211
|
+
track: 'memory', domain: 'general', title: 'Existing global entry',
|
|
212
|
+
content: M.existing + ' already in the global index',
|
|
213
|
+
confidence: 0.9, status: 'active',
|
|
214
|
+
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
|
215
|
+
evidence: [{ sessionId: 'older', quote: 'older' }],
|
|
216
|
+
},
|
|
217
|
+
'gmem-archived': {
|
|
218
|
+
track: 'memory', domain: 'general', title: 'Archived global entry',
|
|
219
|
+
content: M.archived + ' archived entry', confidence: 0.9, status: 'archived',
|
|
220
|
+
},
|
|
221
|
+
'gmem-pending': {
|
|
222
|
+
track: 'memory', domain: 'general', title: 'Pending global entry',
|
|
223
|
+
content: M.pending + ' pending entry', confidence: 0.5, status: 'pending_review',
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
test('probe writes only under the DSH_MEMU_HOME temp root', () => {
|
|
230
|
+
assert.equal(path.resolve(dataDir()), path.resolve(home))
|
|
231
|
+
assert.ok(path.resolve(dataDir()).startsWith(path.resolve(os.tmpdir()) + path.sep))
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('condenseEvents keeps user/assistant/tool shapes and drops every plugin message', () => {
|
|
235
|
+
const transcript = condenseEvents(probeEvents())
|
|
236
|
+
const reached = new Map(CHANNELS.map((channel) => [channel.key, transcript.includes(M[channel.key])]))
|
|
237
|
+
assert.deepEqual([...reached.values()], CHANNELS.map((channel) => channel.layers[0]))
|
|
238
|
+
assert.ok(transcript.startsWith('User: ' + M.user), 'the user original words survive')
|
|
239
|
+
assert.ok(!transcript.includes('memory-context'), 'plugin message wrappers are dropped whole')
|
|
240
|
+
assert.equal((transcript.match(new RegExp(M.toolArgs, 'g')) || []).length, 2,
|
|
241
|
+
'both tool-call argument shapes survive')
|
|
242
|
+
assert.ok(!transcript.includes(M.toolTail), 'tool results are cut at 400 chars')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('six channels, layer by layer: transcript -> prompt -> simulated output -> committed index', async (t) => {
|
|
246
|
+
seedGlobalIndex()
|
|
247
|
+
const events = probeEvents()
|
|
248
|
+
let captured = null
|
|
249
|
+
let outputText = ''
|
|
250
|
+
|
|
251
|
+
const onActivation = async (spec) => {
|
|
252
|
+
captured = spec
|
|
253
|
+
const input = promptInput(spec.request.prompt[0].text)
|
|
254
|
+
const transcript = input.sessions[0].transcript
|
|
255
|
+
const existing = JSON.stringify(input.existing_global_index)
|
|
256
|
+
// A strict copy-through model: it can only echo markers that its prompt
|
|
257
|
+
// actually contains. Anything absent from the prompt is not invented here;
|
|
258
|
+
// the provenance-blind commit path is covered by the next test.
|
|
259
|
+
const memories = []
|
|
260
|
+
for (const [key, value] of Object.entries(M)) {
|
|
261
|
+
if (key === 'credential') continue
|
|
262
|
+
if (!transcript.includes(value) && !existing.includes(value)) continue
|
|
263
|
+
memories.push({
|
|
264
|
+
op: 'create', domain: 'general', track: 'memory',
|
|
265
|
+
title: 'Copied ' + key,
|
|
266
|
+
content: 'curator copied ' + value + ' from its input',
|
|
267
|
+
confidence: 0.8,
|
|
268
|
+
evidence: [{ session_id: HEADER.id, quote: value }],
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
const output = {
|
|
272
|
+
sessions: [{ id: HEADER.id, domain: 'other', reason: 'synthetic' }],
|
|
273
|
+
memories,
|
|
274
|
+
domain_summary: { general: 'synthetic' },
|
|
275
|
+
skipped: [],
|
|
276
|
+
}
|
|
277
|
+
outputText = JSON.stringify(output)
|
|
278
|
+
return {
|
|
279
|
+
result: Promise.resolve({ stopReason: 'completed', output: [{ type: 'text', text: outputText }] }),
|
|
280
|
+
dispose: async () => {},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const queued = startGlobalCuration(ctxFor(events, onActivation), {
|
|
285
|
+
sessionIds: [HEADER.id], parentSessionId: 'parent',
|
|
286
|
+
})
|
|
287
|
+
const job = await waitJob(queued.id)
|
|
288
|
+
assert.equal(job.status, 'completed', job.error || '')
|
|
289
|
+
|
|
290
|
+
assert.ok(captured, 'startGlobalCuration should reach subagents.startActivation')
|
|
291
|
+
assert.equal(captured.provider, 'spawn')
|
|
292
|
+
assert.equal(captured.delivery, 'caller')
|
|
293
|
+
assert.equal(captured.request.prompt.length, 1)
|
|
294
|
+
assert.equal(captured.request.parent.session.header.cwd, path.join(home, 'project'))
|
|
295
|
+
|
|
296
|
+
const prompt = captured.request.prompt[0].text
|
|
297
|
+
assert.match(prompt, /全局记忆提炼任务/)
|
|
298
|
+
const input = promptInput(prompt)
|
|
299
|
+
assert.deepEqual(Object.keys(input).sort(), ['existing_global_index', 'sessions'])
|
|
300
|
+
assert.equal(input.sessions.length, 1)
|
|
301
|
+
assert.deepEqual(
|
|
302
|
+
Object.keys(input.sessions[0]).sort(),
|
|
303
|
+
['agentPreset', 'createdAt', 'cwd', 'id', 'live', 'title', 'transcript'],
|
|
304
|
+
)
|
|
305
|
+
const transcript = input.sessions[0].transcript
|
|
306
|
+
assert.equal(transcript, condenseEvents(events, 14000), 'the prompt must carry the condensed transcript itself')
|
|
307
|
+
assert.equal(input.sessions[0].live, false)
|
|
308
|
+
assert.equal(input.sessions[0].title, HEADER.title)
|
|
309
|
+
assert.equal(input.sessions[0].cwd, path.join(home, 'project'))
|
|
310
|
+
assert.equal(input.sessions[0].agentPreset, 'coding')
|
|
311
|
+
|
|
312
|
+
// existing_global_index: archived entries are filtered out, pending_review stays.
|
|
313
|
+
const existingIds = input.existing_global_index.map((entry) => entry.id)
|
|
314
|
+
assert.ok(existingIds.includes('gmem-existing'))
|
|
315
|
+
assert.ok(existingIds.includes('gmem-pending'))
|
|
316
|
+
assert.ok(!existingIds.includes('gmem-archived'))
|
|
317
|
+
const existingJson = JSON.stringify(input.existing_global_index)
|
|
318
|
+
assert.match(existingJson, new RegExp(M.existing))
|
|
319
|
+
assert.match(existingJson, new RegExp(M.pending))
|
|
320
|
+
assert.doesNotMatch(existingJson, new RegExp(M.archived))
|
|
321
|
+
assert.match(prompt, new RegExp(M.existing))
|
|
322
|
+
assert.doesNotMatch(prompt, new RegExp(M.archived))
|
|
323
|
+
|
|
324
|
+
const committedJson = JSON.stringify(loadGlobalIndex())
|
|
325
|
+
const rows = CHANNELS.map((channel) => ({
|
|
326
|
+
channel: channel.key,
|
|
327
|
+
shape: channel.shape,
|
|
328
|
+
transcript: transcript.includes(M[channel.key]),
|
|
329
|
+
prompt: prompt.includes(M[channel.key]),
|
|
330
|
+
output: outputText.includes(M[channel.key]),
|
|
331
|
+
committed: committedJson.includes(M[channel.key]),
|
|
332
|
+
}))
|
|
333
|
+
t.diagnostic('W5 channel matrix ' + JSON.stringify(rows))
|
|
334
|
+
assert.deepEqual(
|
|
335
|
+
rows.map((row) => [row.transcript, row.prompt, row.output, row.committed]),
|
|
336
|
+
CHANNELS.map((channel) => channel.layers),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
// The simulated model echoed only what reached the prompt: four transcript
|
|
340
|
+
// channels plus the two visible existing-index entries.
|
|
341
|
+
assert.equal(job.result.created, 6)
|
|
342
|
+
for (const key of ['user', 'assistant', 'toolArgs', 'recallSecret', 'existing', 'pending']) {
|
|
343
|
+
const entry = Object.values(loadGlobalIndex().entries).find((item) => item.title === 'Copied ' + key)
|
|
344
|
+
assert.ok(entry, 'expected a committed entry copied from ' + key)
|
|
345
|
+
assert.equal(entry.status, 'active')
|
|
346
|
+
assert.match(entry.content, new RegExp(M[key]))
|
|
347
|
+
}
|
|
348
|
+
const secretEntry = Object.values(loadGlobalIndex().entries).find((item) => item.title === 'Copied recallSecret')
|
|
349
|
+
assert.match(JSON.stringify(secretEntry.evidence), new RegExp(M.recallSecret))
|
|
350
|
+
for (const key of ['secretAuto', 'recallPlain', 'toolTail', 'archived', 'modelOnly', 'invalidDomain']) {
|
|
351
|
+
assert.equal(
|
|
352
|
+
Object.values(loadGlobalIndex().entries).some((item) => item.title === 'Copied ' + key),
|
|
353
|
+
false,
|
|
354
|
+
'no entry should be copied from ' + key,
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('commit accepts model output without provenance or secret filtering', async () => {
|
|
360
|
+
saveGlobalIndex({ version: 1, entries: {} })
|
|
361
|
+
const output = {
|
|
362
|
+
memories: [
|
|
363
|
+
{
|
|
364
|
+
op: 'create', domain: 'general', track: 'memory', title: 'Active from tool result',
|
|
365
|
+
content: 'copied: ' + M.recallSecret, confidence: 0.9,
|
|
366
|
+
evidence: [{ session_id: 'probe-session', quote: M.recallSecret }],
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
op: 'create', domain: 'general', track: 'memory', title: 'Held for review',
|
|
370
|
+
content: 'no evidence supplied', confidence: 0.9, evidence: [],
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
op: 'create', domain: 'not-a-domain', track: 'memory', title: 'Invalid domain',
|
|
374
|
+
content: M.invalidDomain, evidence: [{ session_id: 'probe-session', quote: 'q' }],
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
op: 'create', domain: 'general', track: 'memory', title: 'Secret flagged',
|
|
378
|
+
content: 'secret-marked but accepted: ' + M.recallSecret,
|
|
379
|
+
visibility: 'secret',
|
|
380
|
+
source: { kind: 'plugin:dsh-memu-memory', form: 'recall' },
|
|
381
|
+
confidence: 0.9,
|
|
382
|
+
evidence: [{ session_id: 'probe-session', quote: M.recallSecret }],
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
op: 'create', domain: 'general', track: 'memory', title: 'Model-only provenance',
|
|
386
|
+
content: 'never appeared in the prompt: ' + M.modelOnly,
|
|
387
|
+
confidence: 0.9,
|
|
388
|
+
evidence: [{ session_id: 'probe-session', quote: 'invented' }],
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
op: 'create', domain: 'general', track: 'memory', title: 'Credential shaped',
|
|
392
|
+
content: 'api key: ' + M.credential,
|
|
393
|
+
confidence: 0.9,
|
|
394
|
+
evidence: [{ session_id: 'probe-session', quote: 'q' }],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
}
|
|
398
|
+
const result = await commitCuratorOutput(output, { job: { step: 'committing' } })
|
|
399
|
+
assert.equal(result.created, 4)
|
|
400
|
+
assert.equal(result.pending, 1)
|
|
401
|
+
assert.equal(result.skipped.length, 1)
|
|
402
|
+
assert.match(result.skipped[0].reason, /invalid domain/)
|
|
403
|
+
|
|
404
|
+
const index = loadGlobalIndex()
|
|
405
|
+
const entries = Object.values(index.entries)
|
|
406
|
+
assert.equal(entries.length, 5)
|
|
407
|
+
const active = entries.find((entry) => entry.title === 'Active from tool result')
|
|
408
|
+
assert.equal(active.status, 'active')
|
|
409
|
+
const secret = entries.find((entry) => entry.title === 'Secret flagged')
|
|
410
|
+
assert.equal(secret.status, 'active')
|
|
411
|
+
assert.equal(Object.hasOwn(secret, 'visibility'), false)
|
|
412
|
+
const held = entries.find((entry) => entry.title === 'Held for review')
|
|
413
|
+
assert.equal(held.status, 'pending_review')
|
|
414
|
+
assert.match(held.reviewReason, /missing evidence/)
|
|
415
|
+
const modelOnly = entries.find((entry) => entry.title === 'Model-only provenance')
|
|
416
|
+
assert.match(modelOnly.content, new RegExp(M.modelOnly))
|
|
417
|
+
const credential = entries.find((entry) => entry.title === 'Credential shaped')
|
|
418
|
+
assert.match(credential.content, /\[REDACTED_KEY\]/)
|
|
419
|
+
assert.doesNotMatch(credential.content, new RegExp(M.credential))
|
|
420
|
+
assert.doesNotMatch(JSON.stringify(index), new RegExp(M.invalidDomain))
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('condense keeps private prose; redaction only covers credential-shaped strings', () => {
|
|
424
|
+
const text = condenseEvents([
|
|
425
|
+
{
|
|
426
|
+
type: 'tool/result',
|
|
427
|
+
data: { message: { content: [{ type: 'text', text: M.recallSecret + ' Authorization: Bearer abcdefghijklmnopqrstuvwxyz' }] } },
|
|
428
|
+
},
|
|
429
|
+
])
|
|
430
|
+
assert.match(text, new RegExp(M.recallSecret))
|
|
431
|
+
assert.match(text, /Bearer \[REDACTED_KEY\]/)
|
|
432
|
+
assert.doesNotMatch(text, /abcdefghijklmnopqrstuvwxyz/)
|
|
433
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
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 http from 'node:http'
|
|
7
|
+
import crypto from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-curator-'))
|
|
10
|
+
process.env.DSH_MEMU_HOME = home
|
|
11
|
+
|
|
12
|
+
const enginePython = process.env.DSH_MEMU_TEST_ENGINE_PYTHON || ''
|
|
13
|
+
if (enginePython) {
|
|
14
|
+
const engineDir = path.join(home, 'engine')
|
|
15
|
+
fs.mkdirSync(engineDir, { recursive: true })
|
|
16
|
+
fs.writeFileSync(path.join(engineDir, 'current.json'), JSON.stringify({
|
|
17
|
+
version: '0.11.0b3',
|
|
18
|
+
venv: path.dirname(path.dirname(enginePython)),
|
|
19
|
+
python: enginePython,
|
|
20
|
+
commands: ['retrieve', 'list-files', 'commit'],
|
|
21
|
+
installedAt: new Date().toISOString(),
|
|
22
|
+
}, null, 2))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function vector(text) {
|
|
26
|
+
const h = crypto.createHash('sha256').update(String(text)).digest()
|
|
27
|
+
const out = []
|
|
28
|
+
for (let i = 0; i < 8; i++) out.push((h[i] / 255) * 2 - 1)
|
|
29
|
+
const norm = Math.sqrt(out.reduce((a, b) => a + b * b, 0)) || 1
|
|
30
|
+
return out.map((x) => x / norm)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const server = http.createServer((req, res) => {
|
|
34
|
+
let body = ''
|
|
35
|
+
req.on('data', (d) => { body += d })
|
|
36
|
+
req.on('end', () => {
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(body || '{}')
|
|
39
|
+
const inputs = Array.isArray(data.input) ? data.input : [data.input]
|
|
40
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
41
|
+
res.end(JSON.stringify({
|
|
42
|
+
object: 'list',
|
|
43
|
+
data: inputs.map((text, index) => ({ object: 'embedding', index, embedding: vector(text) })),
|
|
44
|
+
model: data.model || 'test-embed',
|
|
45
|
+
usage: { prompt_tokens: inputs.length, total_tokens: inputs.length },
|
|
46
|
+
}))
|
|
47
|
+
} catch (error) {
|
|
48
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
49
|
+
res.end(JSON.stringify({ error: { message: String(error) } }))
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
54
|
+
process.env.MEMU_EMBED_PROVIDER = 'openai'
|
|
55
|
+
process.env.MEMU_EMBED_MODEL = 'test-embed'
|
|
56
|
+
process.env.MEMU_BASE_URL = `http://127.0.0.1:${server.address().port}/v1`
|
|
57
|
+
process.env.MEMU_API_KEY = 'test-key'
|
|
58
|
+
process.env.OPENAI_API_KEY = 'test-key'
|
|
59
|
+
|
|
60
|
+
const { startGlobalCuration, getCurationJob } = await import('../lib/curator.js')
|
|
61
|
+
const state = await import('../lib/state.js')
|
|
62
|
+
const memory = await import('../lib/memory.js')
|
|
63
|
+
if (enginePython) state.updateSettings({ memoryBackend: 'api' })
|
|
64
|
+
|
|
65
|
+
test.after(async () => {
|
|
66
|
+
await new Promise((resolve) => server.close(resolve))
|
|
67
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
function fakeContext() {
|
|
71
|
+
const parent = { id: 'parent', session: { header: { cwd: path.join(home, 'project') } } }
|
|
72
|
+
const curatorOutput = {
|
|
73
|
+
sessions: [{ id: 's1', domain: 'coding', reason: 'code and tool calls' }],
|
|
74
|
+
memories: [{
|
|
75
|
+
op: 'create',
|
|
76
|
+
track: 'memory',
|
|
77
|
+
domain: 'coding',
|
|
78
|
+
applies_to: ['coding'],
|
|
79
|
+
title: 'Prefers conclusion first',
|
|
80
|
+
content: 'The user prefers a conclusion before details in coding tasks.',
|
|
81
|
+
confidence: 0.9,
|
|
82
|
+
evidence: [{ session_id: 's1', quote: 'I prefer conclusions first.' }],
|
|
83
|
+
reason: 'cross-session style',
|
|
84
|
+
}],
|
|
85
|
+
domain_summary: { coding: 'coding preference' },
|
|
86
|
+
skipped: [],
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
get(name) {
|
|
90
|
+
if (name === 'agents') return { get: (id) => id === 'parent' ? parent : undefined }
|
|
91
|
+
if (name === 'subagents') {
|
|
92
|
+
return {
|
|
93
|
+
startActivation: async (spec) => {
|
|
94
|
+
assert.equal(spec.request.parent, parent)
|
|
95
|
+
return {
|
|
96
|
+
result: Promise.resolve({
|
|
97
|
+
stopReason: 'completed',
|
|
98
|
+
output: [{ type: 'text', text: JSON.stringify(curatorOutput) }],
|
|
99
|
+
}),
|
|
100
|
+
dispose: async () => {},
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (name === 'sessionQuery') {
|
|
106
|
+
return {
|
|
107
|
+
listSessions: async () => [{
|
|
108
|
+
header: { id: 's1', cwd: path.join(home, 'project'), createdAt: Date.now(), agentPreset: 'coding' },
|
|
109
|
+
live: false,
|
|
110
|
+
persisted: true,
|
|
111
|
+
}],
|
|
112
|
+
readTitleSnapshots: async () => [],
|
|
113
|
+
readSession: async () => ({
|
|
114
|
+
session: { id: 's1', cwd: path.join(home, 'project'), agentPreset: 'coding' },
|
|
115
|
+
inheritedEventCount: 0,
|
|
116
|
+
events: [
|
|
117
|
+
{ type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'I prefer conclusions first.' }] } },
|
|
118
|
+
{ type: 'assistant/message', data: { message: { content: [{ type: 'text', text: 'Understood.' }] } } },
|
|
119
|
+
],
|
|
120
|
+
}),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function waitJob(id) {
|
|
129
|
+
for (let i = 0; i < 100; i++) {
|
|
130
|
+
const job = getCurationJob(id)
|
|
131
|
+
if (job && job.status !== 'running') return job
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
133
|
+
}
|
|
134
|
+
return getCurationJob(id)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test('global curator writes a validated memory', { skip: !enginePython }, async () => {
|
|
138
|
+
const ctx = fakeContext()
|
|
139
|
+
const job = startGlobalCuration(ctx, { sessionIds: ['s1'], parentSessionId: 'parent' })
|
|
140
|
+
assert.equal(job.status, 'running')
|
|
141
|
+
const done = await waitJob(job.id)
|
|
142
|
+
assert.equal(done.status, 'completed', done.error || '')
|
|
143
|
+
const index = state.loadGlobalIndex()
|
|
144
|
+
const entries = Object.values(index.entries)
|
|
145
|
+
assert.equal(entries.length, 1)
|
|
146
|
+
assert.equal(entries[0].domain, 'coding')
|
|
147
|
+
assert.ok(entries[0].content.includes('conclusion'))
|
|
148
|
+
const listed = await memory.globalList({ exec: { agent: null } })
|
|
149
|
+
assert.ok(listed.files.some((f) => f.entry && f.entry.domain === 'coding'))
|
|
150
|
+
})
|