dsh-memu-memory 1.2.0 → 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/README.md +8 -4
- package/README.zh.md +9 -5
- package/docs/contributing.md +2 -2
- package/docs/env-and-commands.md +8 -0
- package/docs/troubleshooting.md +4 -3
- package/lib/activity-log.js +22 -5
- package/lib/binding-diagnostics.js +1 -1
- package/lib/client.js +302 -24
- package/lib/consolidation-worker.js +43 -14
- package/lib/curator.js +86 -31
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/memory.js +314 -70
- package/lib/private.js +98 -6
- package/lib/rpc.js +45 -4
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +64 -8
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/package.json +3 -2
- package/scripts/replay.mjs +573 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +142 -10
- package/tests/consolidation-snapshot.test.mjs +43 -4
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph.test.mjs +17 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui.test.mjs +3 -2
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// dsh-memu-memory 链路回放巡检 — 不走测试框架,按生产代码路径端到端重放五条链路。
|
|
2
|
+
//
|
|
3
|
+
// npm run replay
|
|
4
|
+
//
|
|
5
|
+
// 与 npm test 的分工:测试钉住单元与契约,这里回答"五条真实链路在当前代码下还能不能
|
|
6
|
+
// 一口气走通"。只有 Python 引擎子进程换成内存桩(证明链路通路,不证明 embedding 本身),
|
|
7
|
+
// 其余全是生产模块:真实工具层、真实迁移账本落盘、真实活动日志、真实 React 渲染。
|
|
8
|
+
// 退出码 0 = 全部检查点通过;失败会列出每个 FAIL 的检查点名。
|
|
9
|
+
import { registerHooks } from 'node:module'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
const REPO_URL = new URL('../', import.meta.url)
|
|
15
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-replay-'))
|
|
16
|
+
process.env.DSH_MEMU_HOME = home
|
|
17
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
18
|
+
|
|
19
|
+
const results = []
|
|
20
|
+
function check(name, cond, extra = '') {
|
|
21
|
+
results.push({ name, ok: Boolean(cond) })
|
|
22
|
+
console.log((cond ? ' PASS ' : ' FAIL ') + name + (cond ? '' : ' << ' + extra))
|
|
23
|
+
}
|
|
24
|
+
async function phase(title, fn) {
|
|
25
|
+
console.log('\n== ' + title + ' ==')
|
|
26
|
+
try { await fn() } catch (error) { check(title + '(阶段异常)', false, error?.stack || String(error)) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------- 引擎桩
|
|
30
|
+
// memu.js 的 Python 子进程整只换成内存行存:commit/forget/listFiles 都记账,
|
|
31
|
+
// 向量回放用它证明"引擎写入真发生了"。
|
|
32
|
+
const memuRows = new Map()
|
|
33
|
+
const memuCalls = { commit: [], forget: [], listFiles: 0 }
|
|
34
|
+
globalThis.__memuRows = memuRows
|
|
35
|
+
globalThis.__memuCalls = memuCalls
|
|
36
|
+
const engineSource = `
|
|
37
|
+
const rows = globalThis.__memuRows, calls = globalThis.__memuCalls
|
|
38
|
+
export const commit = async ({ payload, db }) => {
|
|
39
|
+
calls.commit.push({ db, names: payload.recall_files.map(r => r.name) })
|
|
40
|
+
for (const row of payload.recall_files) rows.set(db + '|' + row.name, row)
|
|
41
|
+
return { ok: true }
|
|
42
|
+
}
|
|
43
|
+
export const retrieve = async () => ({ files: [] })
|
|
44
|
+
export const forget = async ({ db, name }) => {
|
|
45
|
+
calls.forget.push({ db, name })
|
|
46
|
+
rows.delete(db + '|' + name)
|
|
47
|
+
return { deleted: 1, segments: 1 }
|
|
48
|
+
}
|
|
49
|
+
export const listFiles = async ({ db }) => {
|
|
50
|
+
calls.listFiles += 1
|
|
51
|
+
return [...rows.keys()].filter(k => k.startsWith(db + '|')).map(k => k.split('|')[1])
|
|
52
|
+
}
|
|
53
|
+
`
|
|
54
|
+
registerHooks({
|
|
55
|
+
resolve(specifier, context, nextResolve) {
|
|
56
|
+
if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(engineSource), shortCircuit: true }
|
|
57
|
+
return nextResolve(specifier, context)
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const state = (await import(new URL('lib/state.js', REPO_URL)))
|
|
62
|
+
const memory = (await import(new URL('lib/memory.js', REPO_URL)))
|
|
63
|
+
const tools = await import(new URL('lib/tools.js', REPO_URL))
|
|
64
|
+
const privateMod = await import(new URL('lib/private.js', REPO_URL))
|
|
65
|
+
const rpcMod = await import(new URL('lib/rpc.js', REPO_URL))
|
|
66
|
+
const activity = await import(new URL('lib/activity-log.js', REPO_URL))
|
|
67
|
+
const vm = await import(new URL('lib/vector-migration.js', REPO_URL))
|
|
68
|
+
const vp = await import(new URL('lib/vector-profile.js', REPO_URL))
|
|
69
|
+
const { resetActivityLogCache, listEvents } = activity
|
|
70
|
+
const { dispatch } = rpcMod
|
|
71
|
+
|
|
72
|
+
const execFor = (sessionId, preset = 'standard') => {
|
|
73
|
+
const b = state.getSessionBinding(sessionId)
|
|
74
|
+
return { agent: { session: { header: { id: sessionId, cwd: b?.scopeKey || '', agentPreset: preset } } } }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------- 通用:抓真实工具面
|
|
78
|
+
function captureTools() {
|
|
79
|
+
const map = new Map()
|
|
80
|
+
const dispose = tools.registerTools({ tools: { register: (tool) => { map.set(tool.name, tool); return () => {} } }, logger: { warn: () => {} } })
|
|
81
|
+
return { map, dispose }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ================================================================ 回放 1:dedup
|
|
85
|
+
await phase('回放 1 · 事实级去重:真实工具层写入 → 拒绝 → 逃生口 → 召回折叠', async () => {
|
|
86
|
+
resetActivityLogCache()
|
|
87
|
+
state.updateSettings({ memoryBackend: 'agent', vectorSource: 'local' })
|
|
88
|
+
state.ensureSession('dup-session', { cwd: path.join(home, 'proj-dup'), agentPreset: 'standard' })
|
|
89
|
+
const exec = execFor('dup-session')
|
|
90
|
+
const { map } = captureTools()
|
|
91
|
+
const remember = map.get('memu_private_remember')
|
|
92
|
+
const libId = state.getSessionBinding('dup-session').libraryId
|
|
93
|
+
|
|
94
|
+
const first = await remember.execute({ content: '我不吃香菜,闻到就难受。', title: '口味' }, exec)
|
|
95
|
+
const firstId = /已写入私有记忆:(mem-\S+)/.exec(String(first))?.[1]
|
|
96
|
+
check('第一次写入成功并返回 id', Boolean(firstId), String(first).slice(0, 120))
|
|
97
|
+
|
|
98
|
+
const second = await remember.execute({ content: '我不吃香菜,闻到就难受。', title: '口味' }, exec)
|
|
99
|
+
check('第二次几乎相同 → 工具层明确拒绝并给 existingId', /未写入/.test(String(second)) && String(second).includes(firstId), String(second).slice(0, 160))
|
|
100
|
+
check('库内条数仍是 1(没有写进重复条目)', Object.keys(state.loadPrivateIndex(libId).entries).length === 1)
|
|
101
|
+
const dupRow = listEvents({ kind: 'write', limit: 3 })[0]
|
|
102
|
+
check('活动日志留了"拒绝重复写入"信号', /拒绝重复写入/.test(dupRow?.message || ''))
|
|
103
|
+
|
|
104
|
+
const third = await remember.execute({ content: '我不吃香菜,闻到就难受。', title: '口味', allow_duplicate: true }, exec)
|
|
105
|
+
const thirdId = /已写入私有记忆:(mem-\S+)/.exec(String(third))?.[1]
|
|
106
|
+
check('allow_duplicate 逃生口:确属独立观测时放行', Boolean(thirdId) && thirdId !== firstId, String(third).slice(0, 120))
|
|
107
|
+
check('库内条数 2', Object.keys(state.loadPrivateIndex(libId).entries).length === 2)
|
|
108
|
+
|
|
109
|
+
const recalled = await memory.privateRecall({ exec, query: '香菜 过敏 难受', topK: 5, relevanceThreshold: 0 })
|
|
110
|
+
const ids = (recalled.files || []).map(f => f.name)
|
|
111
|
+
check('召回侧按簇折叠:两条同事实只出 1 条', ids.length === 1 && (ids[0] === firstId || ids[0] === thirdId), 'ids=' + JSON.stringify(ids))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// ================================================================ 回放 2/3:PTC 整理
|
|
115
|
+
const childCtxs = new Map()
|
|
116
|
+
function ptcHost({ workerReceipt = true } = {}) {
|
|
117
|
+
const rec = { created: [], registered: [], disposed: [], toolCalls: [] }
|
|
118
|
+
const ctx = {
|
|
119
|
+
rec,
|
|
120
|
+
on: (name, handler) => { if (name !== 'session/event') return () => {}; (rec.handlers ??= []).push(handler); return () => {} },
|
|
121
|
+
get: (name) => {
|
|
122
|
+
if (name === 'agents') {
|
|
123
|
+
return {
|
|
124
|
+
get: (id) => {
|
|
125
|
+
if (childCtxs.has(id)) return { ctx: childCtxs.get(id) }
|
|
126
|
+
return { session: { requestHeader: () => ({ config: { provider: 'p', model: 'm' }, tools: [{ name: 'run_code' }] }) } }
|
|
127
|
+
},
|
|
128
|
+
create: async (spec) => {
|
|
129
|
+
rec.created.push(spec)
|
|
130
|
+
// 子会话 ctx:worker 的工具就挂在这里 —— 用一个真注册表接住 attachWorkerTools
|
|
131
|
+
// 的注册,"run_code 程序"从里面按名字取工具执行(= 宿主 SDK 绑定的行为)。
|
|
132
|
+
const registry = new Map()
|
|
133
|
+
childCtxs.set(spec.sessionId, {
|
|
134
|
+
tools: { register: (tool) => { registry.set(tool.name, tool); rec.registered.push(tool.name); return () => { rec.disposed.push(tool.name) } } },
|
|
135
|
+
})
|
|
136
|
+
const childSession = { id: spec.sessionId }
|
|
137
|
+
// followup 只入队(生产契约),whenIdle 才等真正跑完:work promise 承载 worker 的异步过程。
|
|
138
|
+
let work = Promise.resolve()
|
|
139
|
+
return {
|
|
140
|
+
agent: {
|
|
141
|
+
session: childSession,
|
|
142
|
+
followup: (message) => {
|
|
143
|
+
work = (async () => {
|
|
144
|
+
// —— 模拟 PTC worker:在 run_code 里调 tools.memu_private_remember ——
|
|
145
|
+
const remember = registry.get('memu_private_remember')
|
|
146
|
+
const childBinding = state.getSessionBinding(spec.sessionId)
|
|
147
|
+
let createdId = null
|
|
148
|
+
if (remember) {
|
|
149
|
+
const text = await remember.execute({ content: 'PTC 会话整理出的记忆:部署密钥放在保险柜。', title: 'ptc-整理' }, { agent: { session: { header: { id: spec.sessionId, cwd: childBinding.scopeKey, agentPreset: 'yuike-ptc' } } } })
|
|
150
|
+
createdId = /已写入私有记忆:(mem-\S+)/.exec(String(text))?.[1] || null
|
|
151
|
+
rec.toolCalls.push({ tool: 'memu_private_remember', text: String(text).slice(0, 60) })
|
|
152
|
+
}
|
|
153
|
+
if (workerReceipt && createdId) {
|
|
154
|
+
const fence = ['```memu-consolidation-receipt', JSON.stringify({ version: 1, jobId: childBinding.jobId, snapshotHash: childBinding.snapshotHash, status: 'completed', outcome: 'created', checkedRange: { fromSeq: 0, throughSeq: 4 }, createdIds: [createdId], updatedIds: [], heldIds: [], reasonCode: null, retryable: false }), '```'].join('\n')
|
|
155
|
+
for (const handler of rec.handlers ?? []) handler(childSession, { type: 'assistant/message', seq: 90, data: { message: { content: [{ type: 'text', text: fence }] } } })
|
|
156
|
+
}
|
|
157
|
+
for (const handler of rec.handlers ?? []) handler(childSession, { type: 'turn/end', seq: 91, data: { turn: 1, reason: { kind: 'completed' } } })
|
|
158
|
+
})()
|
|
159
|
+
},
|
|
160
|
+
whenIdle: () => work,
|
|
161
|
+
},
|
|
162
|
+
dispose: async () => { rec.disposedChild = true },
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (name === 'sessionQuery') {
|
|
168
|
+
return {
|
|
169
|
+
readSession: async () => ({
|
|
170
|
+
events: [
|
|
171
|
+
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '帮我记一下密钥放哪了' }] } },
|
|
172
|
+
{ seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '好的' }] } } },
|
|
173
|
+
{ seq: 2, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
174
|
+
{ seq: 3, type: 'tool/ptc-dispatch', data: { rootCallId: 'r1', parentCallId: 'r1', subCallId: 'r1:ptc:1', name: 'read', arguments: {}, isError: false, content: [] } },
|
|
175
|
+
{ seq: 4, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'run_code' }] } } },
|
|
176
|
+
],
|
|
177
|
+
session: { cwd: path.join(home, 'proj-ptc'), agentPreset: 'yuike-ptc' },
|
|
178
|
+
}),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return undefined
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
return ctx
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await phase('回放 2 · PTC 整理成功链路:probe → fork → 挂载 → worker 真调工具 → 回执凭证', async () => {
|
|
188
|
+
resetActivityLogCache()
|
|
189
|
+
state.updateSettings({ memoryBackend: 'agent', vectorSource: 'local' })
|
|
190
|
+
state.ensureSession('ptc-live', { cwd: path.join(home, 'proj-ptc'), agentPreset: 'yuike-ptc' })
|
|
191
|
+
const ctx = ptcHost({})
|
|
192
|
+
const result = await privateMod.requestPrivateConsolidation(ctx, 'ptc-live', { force: true })
|
|
193
|
+
check('run_code-only header 不再被拒(旧版报 worker-tools-ptc-only)', ctx.rec.created.length === 1, result.error || '')
|
|
194
|
+
check('worker 面只挂读写四件', JSON.stringify([...ctx.rec.registered].sort()) === JSON.stringify(['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update']), JSON.stringify(ctx.rec.registered))
|
|
195
|
+
check('worker 真的通过挂载的工具写入了', ctx.rec.toolCalls.length === 1 && /已写入/.test(ctx.rec.toolCalls[0].text), JSON.stringify(ctx.rec.toolCalls))
|
|
196
|
+
check('回执被对账为完成(settled,写入 1 条)', result.ok === true && result.settled === true && result.wrote === 1, JSON.stringify({ ok: result.ok, reasonCode: result.reasonCode, error: result.error }))
|
|
197
|
+
check('整理游标推进', (state.getSessionBinding('ptc-live')?.lastSleepSeq ?? 0) === 4)
|
|
198
|
+
const started = listEvents({ kind: 'consolidate', limit: 5 }).find(r => (r.detail || {}).phase === 'started')
|
|
199
|
+
check('开始日志带 probe 证据(ptc-header)', started?.detail?.probe?.source === 'ptc-header' && started?.detail?.probe?.ptc === true, JSON.stringify(started?.detail?.probe))
|
|
200
|
+
check('开始日志带挂载清单', Array.isArray(started?.detail?.workerToolsAttached) && started.detail.workerToolsAttached.length === 4)
|
|
201
|
+
check('整理结束挂载已卸载(不留常驻注册)', ctx.rec.disposed.length === 4)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
await phase('回放 3 · PTC 整理失败链路:无回执 → 诚实失败 → 失败可观测', async () => {
|
|
205
|
+
state.ensureSession('ptc-fail', { cwd: path.join(home, 'proj-ptc-fail'), agentPreset: 'yuike-ptc' })
|
|
206
|
+
const ctx = ptcHost({ workerReceipt: false })
|
|
207
|
+
const result = await privateMod.requestPrivateConsolidation(ctx, 'ptc-fail', { force: true })
|
|
208
|
+
check('fork 照常创建(不因 PTC 拒绝)', ctx.rec.created.length === 1)
|
|
209
|
+
check('无回执 = 诚实的 receipt-missing 失败', result.ok === false && result.reasonCode === 'receipt-missing', JSON.stringify({ reasonCode: result.reasonCode }))
|
|
210
|
+
const errRow = listEvents({ kind: 'consolidate', level: 'error', limit: 5 })[0]
|
|
211
|
+
check('失败进了活动日志(error 级)', errRow?.ok === false && /receipt-missing/.test(errRow?.message || ''))
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// ================================================================ 回放 4:提醒 + 日志页
|
|
215
|
+
const titledCtx = {
|
|
216
|
+
get: (name) => (name === 'sessionQuery'
|
|
217
|
+
? { readTitleSnapshots: async (ids) => ids.map((id) => ({
|
|
218
|
+
sessionId: id,
|
|
219
|
+
status: 'fulfilled',
|
|
220
|
+
value: { title: { title: id === 'ptc-live' ? '公主的深夜备份会话' : 'PTC 失败的会话' } },
|
|
221
|
+
})) }
|
|
222
|
+
: undefined),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await phase('回放 4 · 整理提醒 + 日志页数据链路:失败/成功分列、标题与库名、瘦身', async () => {
|
|
226
|
+
const feed = await dispatch(titledCtx, 'getConsolidationFeed', { limit: 6 })
|
|
227
|
+
const failRow = (feed.failures || []).find(r => r.sessionId === 'ptc-fail')
|
|
228
|
+
const okRow = (feed.successes || []).find(r => r.sessionId === 'ptc-live')
|
|
229
|
+
check('失败列里有 ptc-fail,且带会话标题', failRow && failRow.sessionTitle === 'PTC 失败的会话', JSON.stringify(feed.failures?.map(r => [r.sessionId, r.sessionTitle])))
|
|
230
|
+
check('失败行带库名与 reasonCode(跳转与提示的数据都在)', Boolean(failRow?.libraryName) && failRow.reasonCode === 'receipt-missing')
|
|
231
|
+
check('成功列里有 ptc-live(与失败分列)', Boolean(okRow) && okRow.ok !== false)
|
|
232
|
+
check('回执 detail 不整包透传(行已瘦身)', failRow && failRow.detail === undefined)
|
|
233
|
+
const logs = await dispatch(titledCtx, 'getActivityLog', { kind: 'consolidate', limit: 20 })
|
|
234
|
+
check('日志页行带会话标题与库名', logs.events.every(r => typeof r.sessionTitle === 'string' || r.sessionTitle === null) && logs.events.some(r => r.libraryName))
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// ---- 客户端真实渲染回放(与 tests/client-render 同款最小 React 运行时)----
|
|
238
|
+
const registrations = []
|
|
239
|
+
const jumpTargets = []
|
|
240
|
+
let localeDict = null
|
|
241
|
+
let reactHooks = []
|
|
242
|
+
let forced = null
|
|
243
|
+
let cursor = 0
|
|
244
|
+
let effects = []
|
|
245
|
+
const React = {
|
|
246
|
+
createElement: (type, props, ...children) => ({ type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }),
|
|
247
|
+
Fragment: Symbol('Fragment'),
|
|
248
|
+
useState(initial) {
|
|
249
|
+
const i = cursor++
|
|
250
|
+
if (forced && forced.index === i) reactHooks[i] = forced.value
|
|
251
|
+
else if (reactHooks[i] === undefined) reactHooks[i] = typeof initial === 'function' ? initial() : initial
|
|
252
|
+
return [reactHooks[i], (next) => { reactHooks[i] = typeof next === 'function' ? next(reactHooks[i]) : next }]
|
|
253
|
+
},
|
|
254
|
+
useCallback: (fn) => fn,
|
|
255
|
+
useMemo: (fn) => fn(),
|
|
256
|
+
useEffect(fn) { effects.push(fn) },
|
|
257
|
+
useLayoutEffect() {},
|
|
258
|
+
useRef: (v) => ({ current: v }),
|
|
259
|
+
useSyncExternalStore: (_s, snap) => snap(),
|
|
260
|
+
}
|
|
261
|
+
globalThis.window = {
|
|
262
|
+
__ModuleLoader__: { load({ factory }) { globalThis.__dshMemuExports = factory((name) => { if (name === 'react') return React; throw new Error('unexpected require: ' + name) }) } },
|
|
263
|
+
addEventListener() {}, removeEventListener() {},
|
|
264
|
+
open: (url) => { jumpTargets.push(url); return { focus() {}, closed: false } },
|
|
265
|
+
location: { href: 'http://127.0.0.1:8787/' },
|
|
266
|
+
}
|
|
267
|
+
globalThis.document = {
|
|
268
|
+
visibilityState: 'visible',
|
|
269
|
+
createElement: (tag) => ({ tag, style: {}, attrs: {}, children: [], textContent: '', setAttribute(n, v) { this.attrs[n] = v }, addEventListener() {}, appendChild(c) { this.children.push(c); return c }, remove() {} }),
|
|
270
|
+
querySelector: () => null, addEventListener() {}, removeEventListener() {},
|
|
271
|
+
head: { appendChild() {} }, body: { appendChild() {} },
|
|
272
|
+
}
|
|
273
|
+
const realSetInterval = globalThis.setInterval
|
|
274
|
+
globalThis.setInterval = (fn, ms) => ({ unref() {} })
|
|
275
|
+
globalThis.clearInterval = () => {}
|
|
276
|
+
await import(new URL('lib/client.js', REPO_URL))
|
|
277
|
+
globalThis.setInterval = realSetInterval
|
|
278
|
+
|
|
279
|
+
function treeWalk(node, fn, depth = 0) {
|
|
280
|
+
if (node === null || node === undefined || depth > 500) return
|
|
281
|
+
fn(node)
|
|
282
|
+
if (Array.isArray(node)) { for (const c of node) treeWalk(c, fn, depth + 1); return }
|
|
283
|
+
if (typeof node === 'object') {
|
|
284
|
+
if (typeof node.type === 'function') { treeWalk(node.type(node.props), fn, depth + 1); return }
|
|
285
|
+
treeWalk(node.props?.children, fn, depth + 1)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await phase('回放 5 · 侧边栏提醒块真实渲染:默认收起一条 → 展开 → 清除失败 → 跳转深链', async () => {
|
|
290
|
+
const fakeCtx = {
|
|
291
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
292
|
+
// register(NS, {zh, en}) 把真实词典交给 bind —— t 返回真文案,不是 key。
|
|
293
|
+
locale: { register: (_ns, dict) => { localeDict = dict?.zh || null; return () => {} }, bind: () => (key) => (localeDict?.[key] || key), get: () => 'zh' },
|
|
294
|
+
connection: { rpc: { call: async (_p, endpoint) => {
|
|
295
|
+
// 客户端 rpc() 的契约是信封 { ok, value }:套一层再回。
|
|
296
|
+
let value
|
|
297
|
+
if (endpoint === 'getConsolidationFeed') value = await dispatch(titledCtx, 'getConsolidationFeed', { limit: 6 })
|
|
298
|
+
else if (endpoint === 'clearConsolidationFailures') value = await dispatch(titledCtx, 'clearConsolidationFailures', { limit: 6 })
|
|
299
|
+
else if (endpoint === 'getActivityLog') value = await dispatch(titledCtx, 'getActivityLog', { sessionId: 'ptc-live', limit: 12 })
|
|
300
|
+
else if (endpoint === 'getSidebarState') value = {
|
|
301
|
+
revision: 1, stamp: 's', domain: 'general',
|
|
302
|
+
// 提醒块挂在 bindingBlock 的"已绑定"分支(记忆设置卡之下),fake 要给真绑定。
|
|
303
|
+
binding: { sessionId: 'ptc-live', libraryId: 'lib-replay', useLibrary: true, useGlobal: true, scopeKey: '/proj/ptc', domain: 'general' },
|
|
304
|
+
folder: { scopeKey: '/proj/ptc', useLibrary: true, useGlobal: true },
|
|
305
|
+
effective: { useLibrary: true, useGlobal: true, enabled: true, folder: { scopeKey: '/proj/ptc', useLibrary: true, useGlobal: true } },
|
|
306
|
+
private: { items: [], active: 0, pinned: 0, total: 0 },
|
|
307
|
+
global: { items: [], active: 0, total: 0 },
|
|
308
|
+
domains: [],
|
|
309
|
+
}
|
|
310
|
+
else if (endpoint === 'listSessions') value = { sessions: [] }
|
|
311
|
+
else value = {}
|
|
312
|
+
return { ok: true, value }
|
|
313
|
+
} } },
|
|
314
|
+
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
|
|
315
|
+
get: () => undefined,
|
|
316
|
+
slots: { inject: (_n, cb) => cb({ effect: (fn) => fn(), register: (spec, component) => { registrations.push({ slot: spec.name, component }) } }), register: (spec, component) => { registrations.push({ slot: spec.name, component }); return () => {} } },
|
|
317
|
+
inject: (_d, cb) => cb({ effect: (fn) => { try { fn() } catch {} }, sidebarRightTabs: { register: () => () => {} }, get: () => undefined }),
|
|
318
|
+
}
|
|
319
|
+
globalThis.__dshMemuExports.apply(fakeCtx)
|
|
320
|
+
const tab = registrations.find(r => r.slot === 'sidebar.right.pane.tab')
|
|
321
|
+
check('侧边栏 tab 已注册', Boolean(tab?.component))
|
|
322
|
+
|
|
323
|
+
// tab.component() 只生成元素壳;函数组件是在 treeWalk 展开时才真正执行的,
|
|
324
|
+
// 所以时序必须是:展开(注册 effects)→ 跑 effects → 等异步落地 → 再展开收集。
|
|
325
|
+
async function paint() {
|
|
326
|
+
effects = []; cursor = 0
|
|
327
|
+
const shell = tab.component({ ctx: fakeCtx, t: (k) => k, sessionId: 'ptc-live' })
|
|
328
|
+
treeWalk(shell, () => {})
|
|
329
|
+
for (const effect of effects) { try { await effect() } catch {} }
|
|
330
|
+
effects = []; cursor = 0
|
|
331
|
+
for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
332
|
+
return tab.component({ ctx: fakeCtx, t: (k) => k, sessionId: 'ptc-live' })
|
|
333
|
+
}
|
|
334
|
+
function inspect(tree) {
|
|
335
|
+
let text = ''
|
|
336
|
+
let jumpButton = null
|
|
337
|
+
let toggle = null
|
|
338
|
+
let clearButton = null
|
|
339
|
+
treeWalk(tree, (node) => {
|
|
340
|
+
if (typeof node === 'string' || typeof node === 'number') { text += String(node) + ' '; return }
|
|
341
|
+
if (typeof node !== 'object' || !node.props) return
|
|
342
|
+
if (node.props.children === '↗') jumpButton = node
|
|
343
|
+
if (node.props['aria-expanded'] !== undefined) toggle = node
|
|
344
|
+
if (node.props.children === '清除失败记录') clearButton = node
|
|
345
|
+
})
|
|
346
|
+
return { text, jumpButton, toggle, clearButton }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// —— 默认收起:只有一条,双列不可见 ——
|
|
350
|
+
const collapsed = inspect(await paint())
|
|
351
|
+
check('提醒块默认收起:只有最新一条(失败优先)', collapsed.jumpButton !== null && !collapsed.clearButton, collapsed.text.slice(0, 200))
|
|
352
|
+
check('收起态不显示成功列', !/成功/.test(collapsed.text), collapsed.text.slice(0, 160))
|
|
353
|
+
check('收起的那条挂的是会话标题(不是裸 id)', /PTC 失败的会话/.test(collapsed.text) && !/ptc-fail/.test(collapsed.text))
|
|
354
|
+
if (collapsed.jumpButton?.props?.onClick) collapsed.jumpButton.props.onClick()
|
|
355
|
+
check('收起态也能跳转深链(?libraryId=…#/settings/browser)', jumpTargets.some(u => /\/dsh-memu-memory\/ui\?libraryId=[^#]+#\/settings\/browser/.test(u)), JSON.stringify(jumpTargets))
|
|
356
|
+
|
|
357
|
+
// —— 点击头部展开:失败/成功两列 + 清除按钮 ——
|
|
358
|
+
collapsed.toggle?.props?.onClick?.()
|
|
359
|
+
const opened = inspect(await paint())
|
|
360
|
+
check('展开后双列可见(失败 + 成功)', /失败/.test(opened.text) && /成功/.test(opened.text) && /公主的深夜备份会话/.test(opened.text), opened.text.slice(0, 240))
|
|
361
|
+
check('展开后挂清除失败记录按钮', Boolean(opened.clearButton))
|
|
362
|
+
|
|
363
|
+
// —— 清除:只删失败行,成功流水与其它日志不动 ——
|
|
364
|
+
const beforeFeed = await dispatch(titledCtx, 'getConsolidationFeed', { limit: 6 })
|
|
365
|
+
const beforeOthers = listEvents({}).length
|
|
366
|
+
opened.clearButton?.props?.onClick?.()
|
|
367
|
+
for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
368
|
+
const cleared = inspect(await paint())
|
|
369
|
+
const afterFeed = await dispatch(titledCtx, 'getConsolidationFeed', { limit: 6 })
|
|
370
|
+
check('清除后失败列变空', (afterFeed.failures || []).length === 0 && (beforeFeed.failures || []).length > 0)
|
|
371
|
+
check('成功流水与其它日志保留', (afterFeed.successes || []).length === (beforeFeed.successes || []).length
|
|
372
|
+
&& listEvents({}).length === beforeOthers - (beforeFeed.failures || []).length)
|
|
373
|
+
// —— 清除后收起:展开态保持(组件语义),收起回落为最新成功,失败痕迹全部消失 ——
|
|
374
|
+
// 注意:成功行的会话标题本身可以叫"…失败的会话",所以断言打在失败计数标记上。
|
|
375
|
+
cleared.toggle?.props?.onClick?.()
|
|
376
|
+
const recollapsed = inspect(await paint())
|
|
377
|
+
check('清除后收起:无失败计数、无清除按钮、只剩成功流水', !/失败 \d/.test(recollapsed.text) && !recollapsed.clearButton && /完成/.test(recollapsed.text) && /整理提醒/.test(recollapsed.text), recollapsed.text.slice(0, 200))
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// ================================================================ 回放 7:MemU 记忆页分组/多选/查看跳转
|
|
381
|
+
await phase('回放 7 · MemU 记忆页:私有/全局分组 → 多选批量 → 全局"查看此记忆"跳转', async () => {
|
|
382
|
+
const rpcOps = [] // updateMemory / deleteMemory 的审计记录
|
|
383
|
+
const wallItems = [
|
|
384
|
+
{ id: 'mem-a', libraryId: 'lib-r', libraryName: '回放库', title: '私有A', preview: 'pa', layer: 'long_term', status: 'active', source: 'inferred', decay: 0.5, tags: [] },
|
|
385
|
+
{ id: 'mem-b', libraryId: 'lib-r', libraryName: '回放库', title: '私有B', preview: 'pb', layer: 'long_term', status: 'archived', source: 'inferred', decay: 0.4, tags: [] },
|
|
386
|
+
{ id: 'gmem-x', libraryId: null, libraryName: 'global', title: '全局X', preview: 'gx', layer: 'long_term', status: 'active', source: 'inferred', decay: 0.3, tags: [] },
|
|
387
|
+
]
|
|
388
|
+
const wallCtx = {
|
|
389
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
390
|
+
locale: { register: (_ns, dict) => { localeDict = dict?.zh || null; return () => {} }, bind: () => (key) => (localeDict?.[key] || key), get: () => 'zh' },
|
|
391
|
+
connection: { rpc: { call: async (_p, endpoint, payload) => {
|
|
392
|
+
let value
|
|
393
|
+
if (endpoint === 'getState') value = {
|
|
394
|
+
engine: { status: 'ready', currentVersion: '0.11.0b3', recommendedVersion: '0.11.0b3', installed: true, versions: [], state: {} },
|
|
395
|
+
settings: { autoInject: 'smart', mustRememberCap: 16, unpinBudgetPerSession: 2 },
|
|
396
|
+
libraries: [{ id: 'lib-r', name: '回放库', scopeKey: '/proj/r' }], archivedLibraries: [],
|
|
397
|
+
memuConfig: { provider: 'openai' }, summaryConfig: {}, binding: null,
|
|
398
|
+
private: { items: [], active: 2, pinned: 0, total: 2 }, global: { items: [], active: 1, total: 1 }, domains: [],
|
|
399
|
+
}
|
|
400
|
+
else if (endpoint === 'browseWall') value = { items: wallItems, total: wallItems.length, libraries: [{ id: 'lib-r', name: '回放库', scopeKey: '/proj/r' }], errors: [] }
|
|
401
|
+
else if (endpoint === 'getMemory') value = { id: payload?.id, libraryId: payload?.libraryId, entry: { title: 'x', content: 'c', layer: 'long_term', status: payload?.id === 'mem-b' ? 'archived' : 'active' }, attachments: [] }
|
|
402
|
+
else if (endpoint === 'updateMemory') {
|
|
403
|
+
rpcOps.push({ op: 'updateMemory', ...payload })
|
|
404
|
+
// 与真实后端同口径:patch.status 真的改变条目状态,池归属随之变化。
|
|
405
|
+
const it = wallItems.find(item => item.id === payload?.id)
|
|
406
|
+
if (it && payload?.patch?.status) it.status = payload.patch.status
|
|
407
|
+
value = { ok: true }
|
|
408
|
+
}
|
|
409
|
+
else if (endpoint === 'deleteMemory') { rpcOps.push({ op: 'deleteMemory', ...payload }); value = { ok: true } }
|
|
410
|
+
else if (endpoint === 'listGlobal') value = { files: [{ id: 'gmem-x', title: '全局X', content: 'gx', domain: 'general' }] }
|
|
411
|
+
else if (endpoint === 'getPrompts') value = { prompts: [] }
|
|
412
|
+
else if (endpoint === 'listVersions') value = { ok: true, versions: [] }
|
|
413
|
+
else if (endpoint === 'listBackups') value = { backups: [] }
|
|
414
|
+
else if (endpoint === 'listFolders') value = { folders: [] }
|
|
415
|
+
else if (endpoint === 'listLibraries') value = { libraries: [{ id: 'lib-r', name: '回放库', scopeKey: '/proj/r' }] }
|
|
416
|
+
else value = {}
|
|
417
|
+
return { ok: true, value }
|
|
418
|
+
} } },
|
|
419
|
+
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
|
|
420
|
+
get: () => undefined,
|
|
421
|
+
slots: { inject: (_n, cb) => cb({ effect: (fn) => fn(), register: (spec, component) => { registrations.push({ slot: spec.name, component }) } }), register: (spec, component) => { registrations.push({ slot: spec.name, component }); return () => {} } },
|
|
422
|
+
inject: (_d, cb) => cb({ effect: (fn) => { try { fn() } catch {} }, sidebarRightTabs: { register: () => () => {} }, get: () => undefined }),
|
|
423
|
+
}
|
|
424
|
+
// 必须用本阶段自己的 ctx apply:settings.section 若被前一个回放注册过,find 会拿到
|
|
425
|
+
// 旧组件(RPC 词表不对)——所以无条件 apply 并取最后一次注册。
|
|
426
|
+
globalThis.__dshMemuExports.apply(wallCtx)
|
|
427
|
+
const settings = [...registrations].reverse().find(r => r.slot === 'settings.section')
|
|
428
|
+
check('设置页组件已注册', Boolean(settings?.component))
|
|
429
|
+
|
|
430
|
+
// 渲染 → 展开(注册 effects)→ 跑 effects → 找到唯一的数字 0 hook(page 索引)→ 强制换页。
|
|
431
|
+
let pageHookIndex = -1
|
|
432
|
+
let lastPaintedPage = undefined
|
|
433
|
+
async function paintPage(pageValue = undefined) {
|
|
434
|
+
console.log('PAINT page=' + pageValue + ' pageHookIndex=' + pageHookIndex + ' hooks=' + reactHooks.length + ' state0=' + JSON.stringify(reactHooks[0] && typeof reactHooks[0] === 'object' ? Object.keys(reactHooks[0]).slice(0, 3) : reactHooks[0]) + ' globalFiles=' + JSON.stringify(reactHooks[3] ?? null))
|
|
435
|
+
effects = []; cursor = 0
|
|
436
|
+
forced = pageValue === undefined ? null : { index: pageHookIndex, value: pageValue }
|
|
437
|
+
// 单数组 shim 的错位防护:SettingsPage 自身的 hook(含 wallFocus/page)位置稳定,
|
|
438
|
+
// 嵌套组件(墙)的 hook 槽位随页面形状变化 —— 换页时清掉 page hook 之后的陈旧槽位,
|
|
439
|
+
// 组件按 undefined 重新初始化;同页内的交互状态(多选/展开)照常保留。
|
|
440
|
+
if (pageHookIndex !== -1 && pageValue !== undefined && pageValue !== lastPaintedPage) {
|
|
441
|
+
reactHooks = reactHooks.slice(0, pageHookIndex + 1)
|
|
442
|
+
}
|
|
443
|
+
if (pageValue !== undefined) lastPaintedPage = pageValue
|
|
444
|
+
const shell = settings.component({ ctx: wallCtx, t: (k) => k })
|
|
445
|
+
treeWalk(shell, () => {})
|
|
446
|
+
if (pageHookIndex === -1) {
|
|
447
|
+
const zeroes = reactHooks.map((v, i) => (v === 0 ? i : -1)).filter(i => i >= 0)
|
|
448
|
+
if (zeroes.length === 1) pageHookIndex = zeroes[0]
|
|
449
|
+
}
|
|
450
|
+
for (const effect of effects) { try { await effect() } catch {} }
|
|
451
|
+
effects = []; cursor = 0
|
|
452
|
+
for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
453
|
+
try { return settings.component({ ctx: wallCtx, t: (k) => (localeDict?.[k] || k) }) } catch (e) { console.log('PAINT-CRASH page=' + pageValue + ': ' + (e && e.stack || e).slice(0, 80)); throw e }
|
|
454
|
+
}
|
|
455
|
+
function inspect(tree) {
|
|
456
|
+
let text = ''
|
|
457
|
+
const buttons = []
|
|
458
|
+
treeWalk(tree, (node) => {
|
|
459
|
+
if (typeof node === 'string' || typeof node === 'number') { text += String(node) + ' '; return }
|
|
460
|
+
if (typeof node === 'object' && node.type === 'button') buttons.push(node)
|
|
461
|
+
})
|
|
462
|
+
const byText = (needle) => buttons.find(b => {
|
|
463
|
+
let t2 = ''; treeWalk(b, n => { if (typeof n === 'string' || typeof n === 'number') t2 += String(n) })
|
|
464
|
+
return t2.includes(needle)
|
|
465
|
+
})
|
|
466
|
+
return { text, buttons, byText }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 清场:shim 的 hooks 数组还留着回放 5 MemoryTab 的状态,直接渲染 SettingsPage 会
|
|
470
|
+
// 整体错位。先全清,再暖启动一轮 page 0 让 zero-scan 探测到 page hook。
|
|
471
|
+
reactHooks = []
|
|
472
|
+
await paintPage()
|
|
473
|
+
|
|
474
|
+
// —— 默认私有块靠前且生效:全局条目不可见 ——
|
|
475
|
+
let view = inspect(await paintPage(9))
|
|
476
|
+
check('分组默认私有:私有 tab 在前且选中', /私有 \(/.test(view.text) && /全局 \(/.test(view.text)
|
|
477
|
+
&& view.buttons.findIndex(b => { let t2 = ''; treeWalk(b, n => { if (typeof n === 'string') t2 += String(n) }); return /私有 \(/.test(t2) && b.props['aria-selected'] === true })
|
|
478
|
+
< view.buttons.findIndex(b => { let t2 = ''; treeWalk(b, n => { if (typeof n === 'string') t2 += String(n) }); return /全局 \(/.test(t2) }),
|
|
479
|
+
view.text.slice(0, 200))
|
|
480
|
+
check('私有块不显示全局条目', /私有A/.test(view.text) && !/全局X/.test(view.text), view.text.slice(0, 200))
|
|
481
|
+
|
|
482
|
+
// —— 切到全局块:全局条目可见、多选按钮不存在(全局只读)——
|
|
483
|
+
view = inspect(await paintPage(9)); view.byText('全局 (')?.props?.onClick?.()
|
|
484
|
+
view = inspect(await paintPage(9))
|
|
485
|
+
check('全局块显示全局条目、隐藏私有条目', /全局X/.test(view.text) && !/私有A/.test(view.text), view.text.slice(0, 200))
|
|
486
|
+
check('全局块没有多选入口(只读)', !view.byText('多选'))
|
|
487
|
+
|
|
488
|
+
// —— 回到私有块,多选 → 批量归档 ——
|
|
489
|
+
view.byText('私有 (')?.props?.onClick?.()
|
|
490
|
+
view = inspect(await paintPage(9))
|
|
491
|
+
view.byText('多选')?.props?.onClick?.()
|
|
492
|
+
view = inspect(await paintPage(9))
|
|
493
|
+
view.byText('私有A')?.props?.onClick?.() // 多选模式下点卡片 = 勾选
|
|
494
|
+
view = inspect(await paintPage(9))
|
|
495
|
+
check('多选工具条出现且计数正确', /已选 1 条/.test(view.text) && /归档 \(1\)/.test(view.text), view.text.slice(0, 160))
|
|
496
|
+
view.byText('归档 (1)')?.props?.onClick?.()
|
|
497
|
+
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
498
|
+
const archiveOp = rpcOps.filter(op => op.op === 'updateMemory').at(-1)
|
|
499
|
+
check('批量归档走 updateMemory 且 status=archived', archiveOp && archiveOp.id === 'mem-a' && archiveOp.patch?.status === 'archived', JSON.stringify(archiveOp))
|
|
500
|
+
|
|
501
|
+
// —— 已归档池:多选恢复 + 两段式删除 ——
|
|
502
|
+
view = inspect(await paintPage(9))
|
|
503
|
+
view.byText('已归档 (2)')?.props?.onClick?.()
|
|
504
|
+
view = inspect(await paintPage(9))
|
|
505
|
+
view.byText('私有A')?.props?.onClick?.()
|
|
506
|
+
view = inspect(await paintPage(9))
|
|
507
|
+
check('恢复按钮按选中计数出现', /恢复 \(1\)/.test(view.text), view.text.slice(0, 160))
|
|
508
|
+
view.byText('恢复 (1)')?.props?.onClick?.()
|
|
509
|
+
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
510
|
+
check('批量恢复走 updateMemory 且 status=active', rpcOps.some(op => op.op === 'updateMemory' && op.id === 'mem-a' && op.patch?.status === 'active'))
|
|
511
|
+
view = inspect(await paintPage(9))
|
|
512
|
+
view.byText('私有B')?.props?.onClick?.()
|
|
513
|
+
view = inspect(await paintPage(9))
|
|
514
|
+
const armDelete = view.byText('彻底删除 (1)')
|
|
515
|
+
check('已归档条目出现永久删除入口(两段式)', Boolean(armDelete))
|
|
516
|
+
armDelete?.props?.onClick?.()
|
|
517
|
+
view = inspect(await paintPage(9))
|
|
518
|
+
view.byText('确认删除 (1)')?.props?.onClick?.()
|
|
519
|
+
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
520
|
+
check('批量删除走 deleteMemory', rpcOps.some(op => op.op === 'deleteMemory' && op.id === 'mem-b'))
|
|
521
|
+
|
|
522
|
+
// —— 全局页"查看此记忆":接力棒切到记忆页并直接开抽屉 ——
|
|
523
|
+
view = inspect(await paintPage(6))
|
|
524
|
+
check('全局记忆页每条有查看按钮', Boolean(view.byText('查看此记忆')), view.text.slice(0, 120))
|
|
525
|
+
view.byText('查看此记忆')?.props?.onClick?.()
|
|
526
|
+
// setPage(9) 已写进 page hook:强制值跟随,走 effects 让 focus 打开抽屉。
|
|
527
|
+
view = inspect(await paintPage(reactHooks[pageHookIndex]))
|
|
528
|
+
check('查看跳到 MemU 记忆页并打开对应记忆抽屉', view.text.includes('全局X') && view.text.includes('全局记忆 ·'), view.text.slice(0, 200))
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
// ================================================================ 回放 6:向量计数链路
|
|
532
|
+
await phase('回放 6 · 向量计数链路:迁移账本 + 写入戳 → 计数随真实增减走', async () => {
|
|
533
|
+
resetActivityLogCache()
|
|
534
|
+
state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
|
|
535
|
+
state.ensureSession('vec-session', { cwd: path.join(home, 'proj-vec'), agentPreset: 'standard' })
|
|
536
|
+
const libId = state.getSessionBinding('vec-session').libraryId
|
|
537
|
+
const exec = execFor('vec-session')
|
|
538
|
+
await memory.privateRemember({ exec, content: '迁移时就有的记忆一', title: 'm1' })
|
|
539
|
+
await memory.privateRemember({ exec, content: '迁移时就有的记忆二', title: 'm2' })
|
|
540
|
+
|
|
541
|
+
const dbs = new Map()
|
|
542
|
+
const migrator = vm.createVectorMigrator({
|
|
543
|
+
listLibraries: () => state.listLibraries().map(l => l.id),
|
|
544
|
+
loadIndex: (id) => (id == null ? state.loadGlobalIndex() : state.loadPrivateIndex(id)),
|
|
545
|
+
commit: async ({ db, item }) => { dbs.set(db + '|' + item.id, item.text) },
|
|
546
|
+
forget: async ({ db, itemId }) => { dbs.delete(db + '|' + itemId) },
|
|
547
|
+
listFiles: async ({ db }) => [...dbs.keys()].filter(k => k.startsWith(db + '|')).map(k => k.split('|')[1]),
|
|
548
|
+
smoke: async () => ({ ok: true }),
|
|
549
|
+
})
|
|
550
|
+
const sw = await migrator.switchProfile({ profile: vp.profileFromCatalog('bge-small-zh-v1.5-int8') })
|
|
551
|
+
const idsOf = (scope) => ((migrator.membership() || {})[scope] || []).sort()
|
|
552
|
+
const migrated = idsOf(libId)
|
|
553
|
+
check('切换时写入 2/2:计数从迁移账本出发', migrated.length === 2, JSON.stringify(migrated))
|
|
554
|
+
|
|
555
|
+
const w = await memory.privateRemember({ exec, content: '切换之后新写的记忆', title: 'post' })
|
|
556
|
+
check('切换后写入带当前代际戳', state.loadPrivateIndex(libId).entries[w.id]?.vector?.generation === sw.generation, JSON.stringify(state.loadPrivateIndex(libId).entries[w.id]?.vector))
|
|
557
|
+
check('计数跟随真实写入增长(旧 bug:冻在 2)', idsOf(libId).length === 3 && idsOf(libId).includes(w.id), JSON.stringify(idsOf(libId)))
|
|
558
|
+
check('引擎写入真发生了(memu commit 被调)', memuCalls.commit.some(c => c.names.includes(w.id)))
|
|
559
|
+
|
|
560
|
+
await memory.privateArchive({ exec, id: migrated[0] })
|
|
561
|
+
await memory.privateDelete({ exec, id: migrated[0] })
|
|
562
|
+
const after = idsOf(libId)
|
|
563
|
+
check('永久删除后计数扣掉该条(账本腿的删除检查)', after.length === 2 && !after.includes(migrated[0]), JSON.stringify(after))
|
|
564
|
+
check('回滚代际的旧戳不泄漏:计数值与新代际一致', sw.generation === 1 && after.length + 1 === 3)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// ================================================================ 汇总
|
|
568
|
+
const failed = results.filter(r => !r.ok)
|
|
569
|
+
console.log('\n======== 回放汇总 ========')
|
|
570
|
+
console.log('检查点 ' + results.length + ' 个,通过 ' + (results.length - failed.length) + ',失败 ' + failed.length)
|
|
571
|
+
for (const f of failed) console.log(' FAIL: ' + f.name)
|
|
572
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
573
|
+
process.exit(failed.length ? 1 : 0)
|
|
@@ -149,11 +149,14 @@ test('maybeCue falls back to a fixed sentence when no memory qualifies', async (
|
|
|
149
149
|
|
|
150
150
|
test('a tense room pulls charged memories further ahead than a quiet one', async () => {
|
|
151
151
|
const { sessionId, exec } = setup('arousal')
|
|
152
|
+
// 两条正文词面不同(dice 0.512,低于 TOPIC_DICE):事实级去重上线后,同文改标题的
|
|
153
|
+
// 旧写法会被写入侧打标/召回侧折叠,唤醒度因子就测不到了。语义并行 + 显式 arousal
|
|
154
|
+
// 仍然隔离这个因子。
|
|
152
155
|
const calm = await memory.privateRemember({
|
|
153
156
|
exec, content: 'The hallway smelled of rain on warm stone.', title: 'hallway calm', arousal: 0.1, moodValence: 0.05,
|
|
154
157
|
})
|
|
155
158
|
const loud = await memory.privateRemember({
|
|
156
|
-
exec, content: '
|
|
159
|
+
exec, content: 'Rain hammered the hallway stones while voices echoed off the walls.', title: 'hallway loud', arousal: 0.95, moodValence: 0.05,
|
|
157
160
|
})
|
|
158
161
|
|
|
159
162
|
async function gapAt(arousal) {
|
|
@@ -189,8 +192,9 @@ test('arousal cannot outrank a clearly better semantic hit', async () => {
|
|
|
189
192
|
|
|
190
193
|
test('a mood that is tense but neither good nor bad still counts as a mood', async () => {
|
|
191
194
|
const { sessionId, exec } = setup('tense')
|
|
195
|
+
// 正文词面不同(dice 0.514):同文会被事实级去重拒绝,这里改写保持语义并行。
|
|
192
196
|
const flat = await memory.privateRemember({ exec, content: 'The invoice is due on the fourteenth.', title: 'invoice', arousal: 0.1, moodValence: 0 })
|
|
193
|
-
const edge = await memory.privateRemember({ exec, content: 'The invoice
|
|
197
|
+
const edge = await memory.privateRemember({ exec, content: 'The invoice payment must arrive by the fourteenth without fail.', title: 'invoice edge', arousal: 0.9, moodValence: 0 })
|
|
194
198
|
// valence is exactly neutral here, so without the arousal gate this room would be
|
|
195
199
|
// treated as "no mood at all" and arousal would never reach recallScore.
|
|
196
200
|
state.setSessionMood(sessionId, { valence: 0, arousal: 0.9 })
|
|
@@ -208,7 +208,12 @@ test('a tool surface without memory tools is unavailable, and nothing is forked'
|
|
|
208
208
|
}
|
|
209
209
|
})
|
|
210
210
|
|
|
211
|
-
test('a PTC-only tool surface is
|
|
211
|
+
test('a PTC-only tool surface is not a refusal: the fork proceeds and only an honest receipt failure is reported', async () => {
|
|
212
|
+
// 2026-10-31 口径变更:PTC 会话的 header 只有 run_code(或任意 PTC transport 名),
|
|
213
|
+
// 其余工具走 run_code 程序里的 SDK 绑定 —— header 名单不承载"能不能写"。
|
|
214
|
+
// 所以 PTC 不再产生 unavailable:fork 照常创建,写入能力由 scope 挂载与回执裁决。
|
|
215
|
+
// 这个 fake 的 worker 不写回执,所以结果是一次诚实的 receipt-missing 失败,
|
|
216
|
+
// 而不是"从不尝试"。
|
|
212
217
|
const sessionId = 'cred-tools-ptc'
|
|
213
218
|
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-ptc'), agentPreset: 'standard' })
|
|
214
219
|
const ctx = host({
|
|
@@ -216,15 +221,21 @@ test('a PTC-only tool surface is reported as PTC and not as a generic miss', asy
|
|
|
216
221
|
events: [
|
|
217
222
|
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '用 PTC 跑一下' }] } },
|
|
218
223
|
{ seq: 1, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
219
|
-
{ seq: 2, type: 'tool/ptc-dispatch', data: {} },
|
|
224
|
+
{ seq: 2, type: 'tool/ptc-dispatch', data: { name: 'ptc_exec' } },
|
|
220
225
|
{ seq: 3, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'ptc_exec' }] } } },
|
|
221
226
|
],
|
|
222
227
|
})
|
|
223
228
|
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
229
|
+
assert.equal(ctx.rec.created.length, 1, 'a PTC surface no longer blocks the fork')
|
|
224
230
|
assert.equal(result.ok, false)
|
|
225
|
-
assert.equal(result.reasonCode, '
|
|
226
|
-
|
|
227
|
-
|
|
231
|
+
assert.equal(result.reasonCode, 'receipt-missing', 'the fake worker gave no receipt: an honest run failure, not a refusal')
|
|
232
|
+
// 任务书按 PTC 口径写:点名 run_code transport 与 SDK 调用形态。
|
|
233
|
+
const followup = ctx.rec.followedUp[0]
|
|
234
|
+
const brief = Array.isArray(followup?.content)
|
|
235
|
+
? followup.content.map((block) => block?.text || '').join('\n')
|
|
236
|
+
: String(followup || '')
|
|
237
|
+
assert.match(brief, /run_code/)
|
|
238
|
+
assert.match(brief, /tools\.memu_private_remember/)
|
|
228
239
|
})
|
|
229
240
|
|
|
230
241
|
test('a parent that changes its library mid-run voids the credential', async () => {
|