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,321 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
const { captureSessionSnapshot, safePrefix, toolPairing, lastClosedTurnSeq, snapshotHash } = await import('../lib/consolidation-snapshot.js')
|
|
5
|
+
const { probeToolSurface, lastHeaderTools, buildWorkerBrief } = await import('../lib/consolidation-worker.js')
|
|
6
|
+
|
|
7
|
+
// C 包第 1、2 条:安全前缀必须可解释。这里逐条钉住「什么算完整」,
|
|
8
|
+
// 因为把半步喂给 worker 的代价是它对没发生的事编出结论。
|
|
9
|
+
|
|
10
|
+
function turn(turnNo, step, extra = {}) {
|
|
11
|
+
return [
|
|
12
|
+
{ seq: 0, type: 'turn/start', data: { turn: turnNo } },
|
|
13
|
+
{ seq: 1, type: 'step/start', data: { turn: turnNo, step } },
|
|
14
|
+
{ seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '我把 API key 放到 1password 了' }] } },
|
|
15
|
+
{ seq: 3, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '收到' }] } } },
|
|
16
|
+
{ seq: 4, type: 'step/end', data: { turn: turnNo, step } },
|
|
17
|
+
{ seq: 5, type: 'turn/end', data: { turn: turnNo, reason: { kind: 'completed' } } },
|
|
18
|
+
].map((event) => ({ ...event, ...extra }))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function closeTurn(events, turnNo, base) {
|
|
22
|
+
return [
|
|
23
|
+
...events,
|
|
24
|
+
{ seq: base, type: 'turn/start', data: { turn: turnNo } },
|
|
25
|
+
{ seq: base + 1, type: 'step/start', data: { turn: turnNo, step: 1 } },
|
|
26
|
+
{ seq: base + 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第二轮:别删那条记忆' }] } },
|
|
27
|
+
{ seq: base + 3, type: 'step/end', data: { turn: turnNo, step: 1 } },
|
|
28
|
+
{ seq: base + 4, type: 'turn/end', data: { turn: turnNo, reason: { kind: 'completed' } } },
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('a live lease is cut at the last closed turn', () => {
|
|
33
|
+
const events = [
|
|
34
|
+
...turn(1, 1),
|
|
35
|
+
// 第二轮只开了头:turn/start + step/start + 一条真人消息,没有 turn/end。
|
|
36
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
37
|
+
{ seq: 7, type: 'step/start', data: { turn: 2, step: 1 } },
|
|
38
|
+
{ seq: 8, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '这句话还没答完' }] } },
|
|
39
|
+
]
|
|
40
|
+
const safe = safePrefix(events, { requireClosedTurn: true })
|
|
41
|
+
assert.equal(safe.ok, true)
|
|
42
|
+
assert.equal(safe.throughSeq, 5, 'the open second turn is not part of the snapshot')
|
|
43
|
+
assert.equal(safe.eventCount, 6)
|
|
44
|
+
assert.equal(safe.dropped, 3)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('a live transcript with no closed turn is deferred, not consolidated', () => {
|
|
48
|
+
const events = [
|
|
49
|
+
{ seq: 0, type: 'turn/start', data: { turn: 1 } },
|
|
50
|
+
{ seq: 1, type: 'step/start', data: { turn: 1, step: 1 } },
|
|
51
|
+
]
|
|
52
|
+
const safe = safePrefix(events, { requireClosedTurn: true })
|
|
53
|
+
assert.equal(safe.ok, false)
|
|
54
|
+
assert.equal(safe.reasonCode, 'no-closed-turn')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a tool call without its result is a half step and is trimmed', () => {
|
|
58
|
+
const events = [
|
|
59
|
+
...turn(1, 1),
|
|
60
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
61
|
+
{ seq: 7, type: 'step/start', data: { turn: 2, step: 1 } },
|
|
62
|
+
{ seq: 8, type: 'assistant/message', data: { message: { content: [{ type: 'tool-call', id: 'call-1', name: 'read' }] } } },
|
|
63
|
+
{ seq: 9, type: 'tool/call', data: { turn: 2, step: 1, callId: 'call-1', name: 'read', arguments: '{}' } },
|
|
64
|
+
{ seq: 10, type: 'step/end', data: { turn: 2, step: 1 } },
|
|
65
|
+
{ seq: 11, type: 'turn/end', data: { turn: 2, reason: { kind: 'aborted' } } },
|
|
66
|
+
]
|
|
67
|
+
const pairing = toolPairing(events)
|
|
68
|
+
assert.equal(pairing.unpaired.length, 1)
|
|
69
|
+
assert.equal(pairing.firstUnpairedSeq, 8)
|
|
70
|
+
const safe = safePrefix(events, { requireClosedTurn: true })
|
|
71
|
+
assert.equal(safe.ok, true)
|
|
72
|
+
assert.equal(safe.throughSeq, 5, 'the turn whose call never resolved is dropped whole')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('a paired tool call keeps its turn', () => {
|
|
76
|
+
const events = [
|
|
77
|
+
...turn(1, 1),
|
|
78
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
79
|
+
{ seq: 7, type: 'step/start', data: { turn: 2, step: 1 } },
|
|
80
|
+
{ seq: 8, type: 'tool/call', data: { turn: 2, step: 1, callId: 'call-1', name: 'read', arguments: '{}' } },
|
|
81
|
+
{ seq: 9, type: 'tool/result', data: { turn: 2, step: 1, message: { toolCallId: 'call-1', source: { kind: 'tool', callId: 'call-1' }, content: [{ type: 'text', text: 'ok' }], isError: false } } },
|
|
82
|
+
{ seq: 10, type: 'step/end', data: { turn: 2, step: 1 } },
|
|
83
|
+
{ seq: 11, type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } },
|
|
84
|
+
]
|
|
85
|
+
const safe = safePrefix(events, { requireClosedTurn: true })
|
|
86
|
+
assert.equal(safe.ok, true)
|
|
87
|
+
assert.equal(safe.throughSeq, 11)
|
|
88
|
+
assert.equal(safe.pairing.unpaired, 0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('host tool result identities pair in both live and validated snapshot modes', async () => {
|
|
92
|
+
for (const message of [
|
|
93
|
+
{ toolCallId: 'host-call', source: { kind: 'tool', callId: 'host-call' } },
|
|
94
|
+
{ source: { kind: 'tool', callId: 'host-call' } },
|
|
95
|
+
{ callId: 'host-call' },
|
|
96
|
+
]) {
|
|
97
|
+
const events = [
|
|
98
|
+
{ seq: 0, type: 'turn/start', data: { turn: 1 } },
|
|
99
|
+
{ seq: 1, type: 'step/start', data: { turn: 1, step: 1 } },
|
|
100
|
+
{ seq: 2, type: 'assistant/message', data: { message: { content: [{ type: 'tool-call', id: 'host-call', name: 'read' }] } } },
|
|
101
|
+
{ seq: 3, type: 'tool/call', data: { callId: 'host-call', name: 'read' } },
|
|
102
|
+
{ seq: 4, type: 'tool/result', data: { message: { ...message, content: [{ type: 'text', text: 'ok' }] } } },
|
|
103
|
+
{ seq: 5, type: 'step/end', data: { turn: 1, step: 1 } },
|
|
104
|
+
{ seq: 6, type: 'turn/end', data: { turn: 1 } },
|
|
105
|
+
]
|
|
106
|
+
assert.equal(toolPairing(events).unpaired.length, 0)
|
|
107
|
+
for (const requireClosedTurn of [true, false]) {
|
|
108
|
+
const safe = safePrefix(events, { requireClosedTurn })
|
|
109
|
+
assert.equal(safe.ok, true)
|
|
110
|
+
assert.equal(safe.throughSeq, 6)
|
|
111
|
+
}
|
|
112
|
+
const captured = await captureSessionSnapshot(leaseHost({ events }), 'host-pair')
|
|
113
|
+
assert.equal(captured.ok, true)
|
|
114
|
+
assert.equal(captured.snapshot.throughSeq, 6)
|
|
115
|
+
const unmatched = events.filter(event => event.type !== 'tool/result')
|
|
116
|
+
assert.equal(toolPairing(unmatched).firstUnpairedSeq, 2)
|
|
117
|
+
assert.equal(safePrefix(unmatched, { requireClosedTurn: true }).ok, false)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a validated log keeps its full range but still drops a trailing step/start', () => {
|
|
122
|
+
const events = [
|
|
123
|
+
...turn(1, 1),
|
|
124
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
125
|
+
{ seq: 7, type: 'step/start', data: { turn: 2, step: 1 } },
|
|
126
|
+
]
|
|
127
|
+
const safe = safePrefix(events, { requireClosedTurn: false })
|
|
128
|
+
assert.equal(safe.ok, true)
|
|
129
|
+
assert.equal(safe.throughSeq, 5, 'an open step at the tail is not part of the prefix')
|
|
130
|
+
assert.equal(lastClosedTurnSeq(events), 5)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('an empty transcript has nothing to snapshot', () => {
|
|
134
|
+
assert.equal(safePrefix([], { requireClosedTurn: true }).reasonCode, 'no-events')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('the snapshot hash is stable for the same range and changes with content', () => {
|
|
138
|
+
const events = turn(1, 1)
|
|
139
|
+
const a = snapshotHash(events, 0, 5)
|
|
140
|
+
const b = snapshotHash(events, 0, 5)
|
|
141
|
+
const c = snapshotHash([...events.slice(0, 5), { seq: 5, type: 'turn/end', data: { turn: 1, reason: { kind: 'error' } } }], 0, 5)
|
|
142
|
+
const equalLength = snapshotHash(events.map((event) => event.type === 'user/message' ? { ...event, data: { ...event.data, content: [{ type: 'text', text: '同样长度' }] } } : event), 0, 5)
|
|
143
|
+
assert.equal(a, b)
|
|
144
|
+
assert.notEqual(a, c, 'a different last event means a different snapshot identity')
|
|
145
|
+
assert.notEqual(a, equalLength, 'different equal-length event bodies must have different identities')
|
|
146
|
+
assert.equal(a.length, 64, 'content-addressed sha256 of the canonical events')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// ---- captureSessionSnapshot:只用公开 API,缺能力就明确失败 ----
|
|
150
|
+
|
|
151
|
+
function leaseHost({ events, source = 'live', cursor = null, dispose = null, onObserve = null }) {
|
|
152
|
+
const rec = { disposed: 0, projectionMode: null, readSessionCalls: 0 }
|
|
153
|
+
return {
|
|
154
|
+
rec,
|
|
155
|
+
on: () => () => {},
|
|
156
|
+
get: (name) => {
|
|
157
|
+
if (name !== 'sessionQuery') return undefined
|
|
158
|
+
return {
|
|
159
|
+
observeSession: async (id, options) => {
|
|
160
|
+
rec.projectionMode = options?.projectionMode ?? null
|
|
161
|
+
if (onObserve) onObserve(id, options)
|
|
162
|
+
return {
|
|
163
|
+
source,
|
|
164
|
+
header: { cwd: 'D:/proj', agentPreset: 'standard' },
|
|
165
|
+
inheritedEventCount: 0,
|
|
166
|
+
events,
|
|
167
|
+
cursor: cursor === null ? (events.at(-1)?.seq ?? -1) : cursor,
|
|
168
|
+
retain: () => ({ [Symbol.dispose]: () => {} }),
|
|
169
|
+
[Symbol.dispose]: () => { rec.disposed += 1; dispose?.() },
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
readSession: async () => { rec.readSessionCalls += 1; return { events, session: { cwd: 'D:/proj' } } },
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
test('captureSessionSnapshot uses the public lease, pins the cut and disposes the lease', async () => {
|
|
179
|
+
const events = turn(1, 1)
|
|
180
|
+
const ctx = leaseHost({ events })
|
|
181
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-lease')
|
|
182
|
+
assert.equal(captured.ok, true)
|
|
183
|
+
assert.equal(captured.snapshot.source, 'lease-live')
|
|
184
|
+
assert.equal(captured.snapshot.throughSeq, 5)
|
|
185
|
+
assert.equal(captured.snapshot.eventCount, 6)
|
|
186
|
+
assert.equal(captured.snapshot.cursor, 5)
|
|
187
|
+
assert.equal(ctx.rec.projectionMode, 'none', 'raw events, not the model-visible projection')
|
|
188
|
+
assert.equal(ctx.rec.disposed, 1, 'the lease is always released')
|
|
189
|
+
assert.equal(ctx.rec.readSessionCalls, 0, 'a lease read must not fall back to a full log read')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('a prepared lease is a balanced log, so it does not need a closed turn', async () => {
|
|
193
|
+
const events = [
|
|
194
|
+
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '我更喜欢简短回答' }] } },
|
|
195
|
+
{ seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '好' }] } } },
|
|
196
|
+
]
|
|
197
|
+
const ctx = leaseHost({ events, source: 'prepared' })
|
|
198
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-prepared')
|
|
199
|
+
assert.equal(captured.ok, true)
|
|
200
|
+
assert.equal(captured.snapshot.source, 'lease-prepared')
|
|
201
|
+
assert.equal(captured.snapshot.throughSeq, 1)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('a live lease with an open turn is deferred instead of consolidated', async () => {
|
|
205
|
+
const ctx = leaseHost({ events: [{ seq: 0, type: 'turn/start', data: { turn: 1 } }] })
|
|
206
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-open')
|
|
207
|
+
assert.equal(captured.ok, false)
|
|
208
|
+
assert.equal(captured.code, 'deferred')
|
|
209
|
+
assert.equal(captured.reasonCode, 'no-closed-turn')
|
|
210
|
+
assert.equal(ctx.rec.disposed, 1, 'a deferred read still releases the lease')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('a host with readSession only is used as a validated log', async () => {
|
|
214
|
+
const ctx = { on: () => () => {}, get: (name) => (name === 'sessionQuery'
|
|
215
|
+
? { readSession: async () => ({ events: turn(1, 1), session: { cwd: 'D:/p' }, inheritedEventCount: 0 }) }
|
|
216
|
+
: undefined) }
|
|
217
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-legacy')
|
|
218
|
+
assert.equal(captured.ok, true)
|
|
219
|
+
assert.equal(captured.snapshot.source, 'validated-log')
|
|
220
|
+
assert.equal(captured.snapshot.throughSeq, 5)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('a host without any snapshot API is unavailable, not an empty success', async () => {
|
|
224
|
+
const none = await captureSessionSnapshot({ on: () => () => {}, get: () => undefined }, 'sess-none')
|
|
225
|
+
assert.equal(none.ok, false)
|
|
226
|
+
assert.equal(none.code, 'unavailable')
|
|
227
|
+
assert.equal(none.reasonCode, 'no-session-query')
|
|
228
|
+
const partial = await captureSessionSnapshot({ on: () => () => {}, get: () => ({}) }, 'sess-partial')
|
|
229
|
+
assert.equal(partial.reasonCode, 'no-snapshot-api')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('a failing observeSession is a retryable failure with the host message', async () => {
|
|
233
|
+
const ctx = { on: () => () => {}, get: () => ({ observeSession: async () => { throw new Error('SESSION_QUERY_PERSISTENCE_FAILED: disk gone') } }) }
|
|
234
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-boom')
|
|
235
|
+
assert.equal(captured.ok, false)
|
|
236
|
+
assert.equal(captured.code, 'failed')
|
|
237
|
+
assert.match(captured.error, /disk gone/)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// ---- 工具面:标准 / PTC / 未知 三种都要能分辨 ----
|
|
241
|
+
|
|
242
|
+
const MEMU_TOOLS = [
|
|
243
|
+
{ name: 'memu_private_remember' },
|
|
244
|
+
{ name: 'memu_private_update' },
|
|
245
|
+
{ name: 'memu_private_recall' },
|
|
246
|
+
{ name: 'read' },
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
test('a standard header that lists memu_* tools proves the worker can write', () => {
|
|
250
|
+
const events = [{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: MEMU_TOOLS } } }]
|
|
251
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
252
|
+
assert.equal(probe.ok, true)
|
|
253
|
+
assert.equal(probe.hasWrite, true)
|
|
254
|
+
assert.equal(probe.source, 'header-event')
|
|
255
|
+
assert.deepEqual(probe.tools, ['memu_private_remember', 'memu_private_update', 'memu_private_recall', 'read'])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('a header without memory tools is unavailable rather than a silent wasted fork', () => {
|
|
259
|
+
const events = [{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'read' }, { name: 'write' }] } } }]
|
|
260
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
261
|
+
assert.equal(probe.ok, false)
|
|
262
|
+
assert.equal(probe.reasonCode, 'worker-tools-missing')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('a PTC-only turn is reported as PTC, not as a generic missing tool', () => {
|
|
266
|
+
const events = [
|
|
267
|
+
{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'ptc_exec' }] } } },
|
|
268
|
+
{ seq: 1, type: 'tool/ptc-dispatch', data: {} },
|
|
269
|
+
]
|
|
270
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
271
|
+
assert.equal(probe.ok, false)
|
|
272
|
+
assert.equal(probe.reasonCode, 'worker-tools-ptc-only')
|
|
273
|
+
assert.equal(probe.ptcObserved, true)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('a missing tool list is unknown, not a refusal', () => {
|
|
277
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events: [{ seq: 0, type: 'request/header', data: { header: { config: {} } } }] })
|
|
278
|
+
assert.equal(probe.ok, true)
|
|
279
|
+
assert.equal(probe.source, 'unknown-tools')
|
|
280
|
+
assert.equal(probe.tools, null)
|
|
281
|
+
assert.equal(probeToolSurface({ ctx: null, sessionId: 's', events: [] }).source, 'unknown')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('the live request header wins over an older logged one', () => {
|
|
285
|
+
const ctx = {
|
|
286
|
+
get: (name) => (name === 'agents' ? { get: () => ({ session: { requestHeader: () => ({ config: {}, tools: MEMU_TOOLS }) } }) } : undefined),
|
|
287
|
+
}
|
|
288
|
+
const events = [{ seq: 9, type: 'request/header', data: { header: { config: {}, tools: [{ name: 'read' }] } } }]
|
|
289
|
+
const probe = probeToolSurface({ ctx, sessionId: 'live', events })
|
|
290
|
+
assert.equal(probe.ok, true)
|
|
291
|
+
assert.equal(probe.source, 'live-header')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('lastHeaderTools folds to the latest header only', () => {
|
|
295
|
+
const events = [
|
|
296
|
+
{ seq: 0, type: 'request/header', data: { header: { tools: [{ name: 'old' }] } } },
|
|
297
|
+
{ seq: 1, type: 'request/header', data: { header: { tools: MEMU_TOOLS } } },
|
|
298
|
+
]
|
|
299
|
+
const folded = lastHeaderTools(events)
|
|
300
|
+
assert.equal(folded.found, true)
|
|
301
|
+
assert.ok(folded.tools.includes('memu_private_remember'))
|
|
302
|
+
assert.equal(folded.tools.includes('old'), false)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('the worker brief carries the range, the tools and the receipt contract', () => {
|
|
306
|
+
const brief = buildWorkerBrief({
|
|
307
|
+
snapshot: { fromSeq: 0, throughSeq: 41, eventCount: 42, hash: 'abc123' },
|
|
308
|
+
probe: { tools: ['memu_private_remember', 'memu_private_recall'] },
|
|
309
|
+
contract: 'RECEIPT-CONTRACT-MARKER',
|
|
310
|
+
reason: 'manual',
|
|
311
|
+
jobId: 'cons-1',
|
|
312
|
+
})
|
|
313
|
+
assert.match(brief, /不是这场对话里的角色/)
|
|
314
|
+
assert.match(brief, /第 0 到第 41 条事件/)
|
|
315
|
+
assert.match(brief, /快照 abc123/)
|
|
316
|
+
assert.match(brief, /memu_private_remember/)
|
|
317
|
+
assert.match(brief, /不写全局记忆/)
|
|
318
|
+
assert.match(brief, /RECEIPT-CONTRACT-MARKER/)
|
|
319
|
+
assert.match(brief, /手动/)
|
|
320
|
+
assert.match(brief, /tools-unavailable/)
|
|
321
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
const { assessConsolidationValue, unconsolidatedEvents } = await import('../lib/private.js')
|
|
5
|
+
|
|
6
|
+
function human(seq, text) {
|
|
7
|
+
return { seq, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function notice(seq, text) {
|
|
11
|
+
return { seq, type: 'user/message', data: { source: { kind: 'plugin:dsh-memu-memory' }, content: [{ type: 'text', text }] } }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assistant(seq, blocks) {
|
|
15
|
+
return { seq, type: 'assistant/message', data: { message: { content: blocks } } }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('an empty tail is not worth a fork', () => {
|
|
19
|
+
const value = assessConsolidationValue([])
|
|
20
|
+
assert.equal(value.worth, false)
|
|
21
|
+
assert.equal(value.reason, 'no-human-turn')
|
|
22
|
+
assert.equal(value.humanTurns, 0)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('plugin notices and tool chatter are not human turns', () => {
|
|
26
|
+
const value = assessConsolidationValue([
|
|
27
|
+
notice(0, '整理这场对话的私有记忆'),
|
|
28
|
+
{ seq: 1, type: 'tool/result', data: { message: { content: [{ type: 'text', text: 'ok' }] } } },
|
|
29
|
+
notice(2, 'short_term · 快到期了'),
|
|
30
|
+
])
|
|
31
|
+
assert.equal(value.worth, false)
|
|
32
|
+
assert.equal(value.humanTurns, 0)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a bare thank-you is small talk', () => {
|
|
36
|
+
const value = assessConsolidationValue([human(0, '谢谢'), assistant(1, [{ type: 'text', text: '不客气' }])])
|
|
37
|
+
assert.equal(value.worth, false)
|
|
38
|
+
assert.equal(value.reason, 'small-talk')
|
|
39
|
+
assert.equal(value.humanTurns, 1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('one substantive message is enough', () => {
|
|
43
|
+
const value = assessConsolidationValue([human(0, '以后发版前不要动 AGENTS.md 里那行预算,它一超就被静默截断,模型永远看不到后半段。')])
|
|
44
|
+
assert.equal(value.worth, true)
|
|
45
|
+
assert.equal(value.reason, 'substantive')
|
|
46
|
+
assert.ok(value.humanChars >= 12)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('tool work counts even when the person types little', () => {
|
|
50
|
+
const value = assessConsolidationValue([
|
|
51
|
+
human(0, '跑一下'),
|
|
52
|
+
assistant(1, [{ type: 'tool-call', name: 'bash', arguments: '{"command":"npm test"}' }]),
|
|
53
|
+
{ seq: 2, type: 'tool/call', data: { name: 'bash', arguments: '{}' } },
|
|
54
|
+
])
|
|
55
|
+
assert.equal(value.worth, true)
|
|
56
|
+
assert.equal(value.toolCalls, 2, 'both shapes of tool call are counted')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('a short message can be the whole point', () => {
|
|
60
|
+
// Length is not substance: five characters are a durable preference.
|
|
61
|
+
const value = assessConsolidationValue([human(0, '我不吃香菜')])
|
|
62
|
+
assert.equal(value.worth, true)
|
|
63
|
+
assert.equal(value.humanTurns, 1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('an acknowledgement is refused, a short question is not', () => {
|
|
67
|
+
assert.equal(assessConsolidationValue([human(0, '好的,继续')]).worth, false)
|
|
68
|
+
assert.equal(assessConsolidationValue([human(0, '这个怎么配')]).worth, true, 'a question is worth reading even when it is short')
|
|
69
|
+
assert.equal(assessConsolidationValue([human(0, '这个怎么配'), human(1, '我按文档试了还是不行')]).worth, true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('only the events after the last consolidation are judged', () => {
|
|
73
|
+
const events = [human(0, '很久以前的一次长对话,已经整理过了,内容足够长足够长足够长足够长。'), human(1, '谢谢')]
|
|
74
|
+
// No marker recorded yet: the whole transcript is the tail, so the old material
|
|
75
|
+
// still counts once. That is deliberate — the first pass after an upgrade should
|
|
76
|
+
// not decide "small talk" from a transcript it has never read.
|
|
77
|
+
assert.equal(unconsolidatedEvents({}, events).length, 2)
|
|
78
|
+
assert.equal(assessConsolidationValue(unconsolidatedEvents({}, events)).worth, true)
|
|
79
|
+
// Once a marker exists, the tail is only what came after it.
|
|
80
|
+
const tail = unconsolidatedEvents({ lastSleepSeq: 0 }, events)
|
|
81
|
+
assert.equal(tail.length, 1)
|
|
82
|
+
assert.equal(assessConsolidationValue(tail).worth, false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a zero marker is honoured (seq 0 is a real position)', () => {
|
|
86
|
+
const events = [human(0, '旧消息'), human(1, '新的一次决定,内容足够长足够长足够长足够长足够长足够长。')]
|
|
87
|
+
const tail = unconsolidatedEvents({ lastSleepSeq: 0 }, events)
|
|
88
|
+
assert.deepEqual(tail.map((event) => event.seq), [1])
|
|
89
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-scope-'))
|
|
7
|
+
process.env.DSH_MEMU_HOME = home
|
|
8
|
+
const state = await import('../lib/state.js')
|
|
9
|
+
const memory = await import('../lib/memory.js')
|
|
10
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
11
|
+
test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
|
|
12
|
+
function exec(id) { return { agent: { session: { header: { id } } } } }
|
|
13
|
+
for (const id of ['a', 'b']) state.ensureSession(id, { cwd: path.join(home, 'shared-rp'), agentPreset: 'rp' })
|
|
14
|
+
|
|
15
|
+
test('same-title claims from distinct speakers do not become contradictions', async () => {
|
|
16
|
+
const a = await memory.privateRemember({ exec: exec('a'), title: '饮品偏好', content: '小甲喜欢红茶。', context: { speakerId: 'user-a' } })
|
|
17
|
+
const b = await memory.privateRemember({ exec: exec('a'), title: '饮品偏好', content: '小乙不喜欢红茶。', context: { speakerId: 'user-b' } })
|
|
18
|
+
assert.equal(a.entry.status, 'active')
|
|
19
|
+
assert.equal(b.entry.status, 'active')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('world, branch and channel isolate every recall and list surface', async () => {
|
|
23
|
+
await dispatch({}, 'setMemoryContext', { sessionId: 'a', context: { channelId: 'group-1', characterId: 'hero', worldId: 'world-1', branchId: 'west' } })
|
|
24
|
+
await dispatch({}, 'setMemoryContext', { sessionId: 'b', context: { channelId: 'group-2', characterId: 'hero', worldId: 'world-1', branchId: 'west' } })
|
|
25
|
+
const secret = await memory.privateRemember({ exec: exec('a'), title: '剧场钥匙', content: '剧场钥匙在台灯旁。', context: { knownBy: ['hero'] } })
|
|
26
|
+
assert.ok((await memory.privateRecall({ exec: exec('a'), query: secret.id })).files.length)
|
|
27
|
+
assert.equal((await memory.privateRecall({ exec: exec('b'), query: secret.id })).files.length, 0)
|
|
28
|
+
assert.ok(!(await memory.privateList({ exec: exec('b') })).files.some(file => file.name === secret.id))
|
|
29
|
+
await dispatch({}, 'setMemoryContext', { sessionId: 'b', context: { channelId: 'group-1', characterId: 'hero', worldId: 'world-1', branchId: 'east' } })
|
|
30
|
+
assert.equal((await memory.privateRecall({ exec: exec('b'), query: secret.id })).files.length, 0)
|
|
31
|
+
await assert.rejects(memory.privateRemember({ exec: exec('b'), content: 'scope bypass', context: { branchId: 'west' } }), /cannot widen/)
|
|
32
|
+
await assert.rejects(memory.privateRemember({ exec: exec('b'), content: 'scope widening', context: { branchId: '' } }), /cannot widen/)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('knownBy is a character knowledge boundary, not a relationship score', async () => {
|
|
36
|
+
await dispatch({}, 'setMemoryContext', { sessionId: 'a', context: { characterId: 'hero' } })
|
|
37
|
+
const record = await memory.privateRemember({ exec: exec('a'), title: '反派的计划', content: '叙事者记录了反派的计划,主角不知道。', context: { knownBy: ['villain'] } })
|
|
38
|
+
assert.equal((await memory.privateRecall({ exec: exec('a'), query: record.id })).files.length, 0)
|
|
39
|
+
assert.ok(!(await memory.privateList({ exec: exec('a') })).files.some(file => file.name === record.id))
|
|
40
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
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-cue-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const state = await import('../lib/state.js')
|
|
11
|
+
const inject = await import('../lib/inject.js')
|
|
12
|
+
|
|
13
|
+
test.after(() => {
|
|
14
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('random cue is injected once and armed', async () => {
|
|
18
|
+
state.updateSettings({ randomCueChance: 1 })
|
|
19
|
+
const cwd = path.join(home, 'project-cue')
|
|
20
|
+
state.ensureSession('cue-session', { cwd, agentPreset: 'rp' })
|
|
21
|
+
const user = {
|
|
22
|
+
id: 'u-cue', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '今天发生了什么,你还记得吗?' }],
|
|
23
|
+
}
|
|
24
|
+
const decision = { kind: 'enter', messages: [user] }
|
|
25
|
+
const agent = { session: { header: { id: 'cue-session', cwd, agentPreset: 'rp' } } }
|
|
26
|
+
const out = await inject.injectForStep({ agent, decision, turn: 2, step: 1, signal: { aborted: false } })
|
|
27
|
+
const text = out.messages[0]?.content?.[0]?.text || ''
|
|
28
|
+
assert.ok(text.includes('<memory-cue id="'))
|
|
29
|
+
const active = state.getActiveCue('cue-session')
|
|
30
|
+
assert.ok(active && active.id)
|
|
31
|
+
|
|
32
|
+
const again = await inject.injectForStep({ agent, decision, turn: 3, step: 1, signal: { aborted: false } })
|
|
33
|
+
const textAgain = again.messages?.[0]?.content?.[0]?.text || ''
|
|
34
|
+
assert.ok(!textAgain.includes('<memory-cue id="'))
|
|
35
|
+
assert.equal(state.consumeCue('cue-session', active.id), true)
|
|
36
|
+
assert.equal(state.getActiveCue('cue-session'), null)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('makeCue returns a stable shape', () => {
|
|
40
|
+
const cue = inject.makeCue()
|
|
41
|
+
assert.match(cue.id, /^cue-/)
|
|
42
|
+
assert.ok(cue.text.length > 0)
|
|
43
|
+
assert.ok(inject.CUE_TEXTS.includes(cue.text))
|
|
44
|
+
})
|