dsh-memu-memory 1.2.0 → 1.2.2
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 +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +324 -0
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +191 -2
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// Package B (spec §6.2): export/import contract + success notices.
|
|
2
|
+
//
|
|
3
|
+
// The export contract is behavioral: exportLibraryNow/importLibraryNow take an
|
|
4
|
+
// options object whose libraryId defaults to the browsed library but must be
|
|
5
|
+
// the id that reaches the exportLibrary payload, the fallback download name
|
|
6
|
+
// and the ZIP upload URL. The notices are the other half: every browser row
|
|
7
|
+
// action answers with its own success message through the page's notice slot
|
|
8
|
+
// while failures keep using setError.
|
|
9
|
+
//
|
|
10
|
+
// Same fake-React harness as tests/client-render.test.mjs, plus two seams the
|
|
11
|
+
// old harness never needed: captured createElement nodes (the file input and
|
|
12
|
+
// the download anchors) and stubbed fetch/URL.createObjectURL.
|
|
13
|
+
import test from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
|
|
16
|
+
const createdNodes = []
|
|
17
|
+
const fetchCalls = []
|
|
18
|
+
const realFetch = globalThis.fetch
|
|
19
|
+
const realCreateObjectURL = URL.createObjectURL
|
|
20
|
+
const realRevokeObjectURL = URL.revokeObjectURL
|
|
21
|
+
|
|
22
|
+
const registrations = []
|
|
23
|
+
|
|
24
|
+
function makeElement(type, props, ...children) {
|
|
25
|
+
return { type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let hooks = []
|
|
29
|
+
let cursor = 0
|
|
30
|
+
let effects = []
|
|
31
|
+
let dirty = false
|
|
32
|
+
let forced = null
|
|
33
|
+
let generation = 0
|
|
34
|
+
|
|
35
|
+
const React = {
|
|
36
|
+
createElement: makeElement,
|
|
37
|
+
Fragment: Symbol('Fragment'),
|
|
38
|
+
useState(initial) {
|
|
39
|
+
const index = cursor++
|
|
40
|
+
const generation_ = generation
|
|
41
|
+
if (hooks[index] === undefined) hooks[index] = typeof initial === 'function' ? initial() : initial
|
|
42
|
+
if (forced && forced.index === index) hooks[index] = forced.value
|
|
43
|
+
return [hooks[index], (next) => {
|
|
44
|
+
if (generation_ !== generation) return
|
|
45
|
+
hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
|
|
46
|
+
dirty = true
|
|
47
|
+
}]
|
|
48
|
+
},
|
|
49
|
+
useCallback: (fn) => fn,
|
|
50
|
+
useMemo: (fn) => fn(),
|
|
51
|
+
useEffect(fn, deps) { effects.push({ fn, deps, index: cursor++ }) },
|
|
52
|
+
useLayoutEffect() {},
|
|
53
|
+
useRef: (value) => ({ current: value }),
|
|
54
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
globalThis.window = {
|
|
58
|
+
__ModuleLoader__: {
|
|
59
|
+
load({ factory }) {
|
|
60
|
+
globalThis.__dshMemuExports = factory((name) => {
|
|
61
|
+
if (name === 'react') return React
|
|
62
|
+
throw new Error(`unexpected require: ${name}`)
|
|
63
|
+
})
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
addEventListener() {},
|
|
67
|
+
removeEventListener() {},
|
|
68
|
+
}
|
|
69
|
+
globalThis.document = {
|
|
70
|
+
createElement: (tag) => {
|
|
71
|
+
const node = {
|
|
72
|
+
tag,
|
|
73
|
+
style: {},
|
|
74
|
+
attrs: {},
|
|
75
|
+
children: [],
|
|
76
|
+
href: '',
|
|
77
|
+
download: '',
|
|
78
|
+
textContent: '',
|
|
79
|
+
setAttribute(name, value) { node.attrs[name] = value },
|
|
80
|
+
addEventListener() {},
|
|
81
|
+
appendChild(child) { node.children.push(child); return child },
|
|
82
|
+
remove() { node.removed = true },
|
|
83
|
+
click() { node.clicked = true },
|
|
84
|
+
}
|
|
85
|
+
createdNodes.push(node)
|
|
86
|
+
return node
|
|
87
|
+
},
|
|
88
|
+
querySelector: () => null,
|
|
89
|
+
addEventListener() {},
|
|
90
|
+
removeEventListener() {},
|
|
91
|
+
head: { appendChild() {} },
|
|
92
|
+
body: { appendChild() {} },
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The import ZIP path POSTs to a host route; record instead of hitting nothing.
|
|
96
|
+
globalThis.fetch = async (url, options) => {
|
|
97
|
+
fetchCalls.push({ url: String(url), options })
|
|
98
|
+
return { json: async () => ({ ok: true, value: { imported: 2, counts: {} } }) }
|
|
99
|
+
}
|
|
100
|
+
URL.createObjectURL = () => 'blob:fixture'
|
|
101
|
+
URL.revokeObjectURL = () => {}
|
|
102
|
+
|
|
103
|
+
await import('../lib/client.js')
|
|
104
|
+
|
|
105
|
+
const EMPTY_STATE = {
|
|
106
|
+
engine: { status: 'ready', versions: [], currentVersion: '0.11.0b3', recommendedVersion: '0.11.0b3', installed: true, state: {} },
|
|
107
|
+
settings: { autoInject: 'smart', mustRememberCap: 16, unpinBudgetPerSession: 2 },
|
|
108
|
+
libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/proj/demo', privateCount: 2 }],
|
|
109
|
+
archivedLibraries: [],
|
|
110
|
+
memuConfig: { provider: 'openai' },
|
|
111
|
+
summaryConfig: {},
|
|
112
|
+
binding: null,
|
|
113
|
+
privateCount: 2,
|
|
114
|
+
pinnedCount: 0,
|
|
115
|
+
globalCount: 0,
|
|
116
|
+
jobs: [],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const BROWSE_FILES = [
|
|
120
|
+
{ id: 'mem-live', title: 'live entry', content: 'an active body', layer: 'long_term', status: 'active', decay: 0.9 },
|
|
121
|
+
{ id: 'mem-gone', title: 'archived entry', content: 'an archived body', layer: 'long_term', status: 'archived', decay: 0.4 },
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
const WALL_ITEM = {
|
|
125
|
+
id: 'mem-wall', libraryId: 'lib-1', title: 'walled entry', preview: 'a body',
|
|
126
|
+
layer: 'long_term', status: 'active', decay: 0.9,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeAnswers(over = {}) {
|
|
130
|
+
return {
|
|
131
|
+
getState: EMPTY_STATE,
|
|
132
|
+
getSettingsState: EMPTY_STATE,
|
|
133
|
+
listLibraries: { libraries: EMPTY_STATE.libraries },
|
|
134
|
+
listSessions: { sessions: [] },
|
|
135
|
+
listVersions: { ok: true, versions: [] },
|
|
136
|
+
listBackups: { backups: [] },
|
|
137
|
+
listFolders: { folders: [] },
|
|
138
|
+
getSidebarState: {
|
|
139
|
+
binding: null, domain: 'general', folder: null,
|
|
140
|
+
effective: { useLibrary: false, useGlobal: false, enabled: false },
|
|
141
|
+
private: { items: [], active: 0, pinned: 0, total: 0 },
|
|
142
|
+
global: { items: [], active: 0, total: 0 },
|
|
143
|
+
domains: [],
|
|
144
|
+
},
|
|
145
|
+
jobStatus: null,
|
|
146
|
+
browseLibrary: { source: 'engine', files: structuredClone(BROWSE_FILES) },
|
|
147
|
+
browseWall: { items: [structuredClone(WALL_ITEM)], total: 1, errors: [] },
|
|
148
|
+
getMemory: { id: 'mem-wall', libraryId: 'lib-1', entry: { title: 'walled entry', content: 'a body', layer: 'long_term', status: 'active', confidence: 0.8, tags: [] }, attachments: [] },
|
|
149
|
+
...over,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function fakeCtx({ answers = {}, rpcCalls = [] } = {}) {
|
|
154
|
+
return {
|
|
155
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
156
|
+
locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
|
|
157
|
+
connection: {
|
|
158
|
+
rpc: {
|
|
159
|
+
call: async (path, endpoint, payload) => {
|
|
160
|
+
rpcCalls.push({ path, endpoint, payload })
|
|
161
|
+
if (typeof answers[endpoint] === 'function') return answers[endpoint](payload)
|
|
162
|
+
return { ok: true, value: answers[endpoint] ?? {} }
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
|
|
167
|
+
get: (name) => (name === 'uiSession'
|
|
168
|
+
? { adapter: { current: { getSnapshot: () => ({ key: 'probe-session' }) } } }
|
|
169
|
+
: undefined),
|
|
170
|
+
slots: {
|
|
171
|
+
inject: (_name, callback) => callback(scope),
|
|
172
|
+
register: (spec, component) => { registrations.push({ slot: spec.name, id: spec.id, component }); return () => {} },
|
|
173
|
+
},
|
|
174
|
+
inject: (_deps, callback) => callback({
|
|
175
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
176
|
+
sidebarRightTabs: { register: () => () => {} },
|
|
177
|
+
get: () => undefined,
|
|
178
|
+
}),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const scope = {
|
|
183
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
184
|
+
register: (spec, component) => { registrations.push({ slot: spec.name, id: spec.id, component }); return () => {} },
|
|
185
|
+
inject: (_deps, callback) => callback(scope),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function renderPage(component, props, pageValue, { remount = false } = {}) {
|
|
189
|
+
if (remount) { hooks = []; generation++ }
|
|
190
|
+
effects = []
|
|
191
|
+
cursor = 0
|
|
192
|
+
forced = pageValue === undefined ? null : { index: pageHookIndex, value: pageValue }
|
|
193
|
+
const tree = component(props)
|
|
194
|
+
assert.ok(tree, 'component rendered nothing')
|
|
195
|
+
return { tree, effects }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let pageHookIndex = -1
|
|
199
|
+
|
|
200
|
+
function collectPageHook() {
|
|
201
|
+
const zeroes = hooks.map((value, index) => (value === 0 ? index : -1)).filter((index) => index >= 0)
|
|
202
|
+
assert.equal(zeroes.length, 1, 'expected exactly one numeric-zero state (the page index)')
|
|
203
|
+
pageHookIndex = zeroes[0]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function treeText(node, depth = 0) {
|
|
207
|
+
if (node === null || node === undefined || depth > 400) return ''
|
|
208
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
|
209
|
+
if (Array.isArray(node)) return node.map((child) => treeText(child, depth + 1)).join(' ')
|
|
210
|
+
if (typeof node.type === 'function') return treeText(node.type(node.props), depth + 1)
|
|
211
|
+
return `${node.props?.className || ''} ${treeText(node.props?.children, depth + 1)}`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function walkNodes(node, visit, depth = 0) {
|
|
215
|
+
if (node === null || node === undefined || depth > 400) return
|
|
216
|
+
if (Array.isArray(node)) {
|
|
217
|
+
for (const child of node) walkNodes(child, visit, depth + 1)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (typeof node !== 'object') return
|
|
221
|
+
if (visit(node, depth) === false) return
|
|
222
|
+
if (typeof node.type === 'function') {
|
|
223
|
+
walkNodes(node.type(node.props), visit, depth + 1)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
walkNodes(node.props?.children, visit, depth + 1)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
230
|
+
|
|
231
|
+
// Renders the settings page, waits for the mount fetches, jumps to the browser
|
|
232
|
+
// page (5), selects lib-1 in the picker and (optionally) presses reload so the
|
|
233
|
+
// rows are painted. Returns render/rerender helpers plus the collected calls.
|
|
234
|
+
async function openBrowserPage(t, { reload = true, answers = {} } = {}) {
|
|
235
|
+
const rpcCalls = []
|
|
236
|
+
const ctx = fakeCtx({ answers: makeAnswers(answers), rpcCalls })
|
|
237
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
238
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
239
|
+
assert.ok(settings?.component, 'settings.section was not registered')
|
|
240
|
+
const props = { ctx, t: (key) => key }
|
|
241
|
+
const first = await renderPage(settings.component, props, undefined, { remount: true })
|
|
242
|
+
collectPageHook()
|
|
243
|
+
const cleanups = []
|
|
244
|
+
t.after(() => { for (const cleanup of cleanups.reverse()) cleanup() })
|
|
245
|
+
for (const effect of first.effects) {
|
|
246
|
+
const cleanup = effect.fn()
|
|
247
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
248
|
+
}
|
|
249
|
+
await settle()
|
|
250
|
+
await settle()
|
|
251
|
+
|
|
252
|
+
let tree = (await renderPage(settings.component, props, 5)).tree
|
|
253
|
+
let picker = []
|
|
254
|
+
walkNodes(tree, (node) => {
|
|
255
|
+
const options = Array.isArray(node.props?.children) ? node.props.children : []
|
|
256
|
+
if (node.type === 'select' && options.some((option) => option?.props?.value === 'lib-1')) picker.push(node)
|
|
257
|
+
})
|
|
258
|
+
assert.equal(picker.length, 1, 'the library picker did not render')
|
|
259
|
+
picker[0].props.onChange({ target: { value: 'lib-1' } })
|
|
260
|
+
|
|
261
|
+
const rerender = async () => {
|
|
262
|
+
const { tree, effects } = await renderPage(settings.component, props, 5)
|
|
263
|
+
const nodes = []
|
|
264
|
+
walkNodes(tree, (node) => { nodes.push(node) })
|
|
265
|
+
return { tree, nodes, text: treeText(tree), effects }
|
|
266
|
+
}
|
|
267
|
+
let view = await rerender()
|
|
268
|
+
|
|
269
|
+
if (reload) {
|
|
270
|
+
const reloadButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'reload')
|
|
271
|
+
assert.equal(reloadButtons.length, 1, 'the reload button did not render')
|
|
272
|
+
reloadButtons[0].props.onClick()
|
|
273
|
+
await settle()
|
|
274
|
+
await settle()
|
|
275
|
+
view = await rerender()
|
|
276
|
+
}
|
|
277
|
+
const findButtons = (label) => view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === label)
|
|
278
|
+
return { ctx, rpcCalls, rerender, view, findButtons }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
test.after(() => {
|
|
282
|
+
globalThis.fetch = realFetch
|
|
283
|
+
URL.createObjectURL = realCreateObjectURL
|
|
284
|
+
URL.revokeObjectURL = realRevokeObjectURL
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('the JSON export sends the browsed libraryId and reports exportStarted with the filename', async (t) => {
|
|
288
|
+
createdNodes.length = 0
|
|
289
|
+
const { rpcCalls, rerender, findButtons } = await openBrowserPage(t, {
|
|
290
|
+
reload: false,
|
|
291
|
+
answers: { exportLibrary: { filename: 'lib-1.json', base64: Buffer.from('hello').toString('base64') } },
|
|
292
|
+
})
|
|
293
|
+
const exportButtons = findButtons('export')
|
|
294
|
+
assert.equal(exportButtons.length, 1, 'the JSON export button did not render')
|
|
295
|
+
exportButtons[0].props.onClick()
|
|
296
|
+
await settle()
|
|
297
|
+
await settle()
|
|
298
|
+
|
|
299
|
+
const call = rpcCalls.find((entry) => entry.endpoint === 'exportLibrary')
|
|
300
|
+
assert.ok(call, 'exportLibrary was not called')
|
|
301
|
+
assert.equal(call.payload.libraryId, 'lib-1', 'the payload must carry the selected library id')
|
|
302
|
+
assert.equal(call.payload.includeAttachments, false)
|
|
303
|
+
assert.equal(call.payload.delivery, undefined)
|
|
304
|
+
const anchor = createdNodes.find((node) => node.tag === 'a' && node.clicked)
|
|
305
|
+
assert.ok(anchor, 'the download anchor was created and clicked')
|
|
306
|
+
assert.equal(anchor.href, 'blob:fixture')
|
|
307
|
+
assert.equal(anchor.download, 'lib-1.json', 'the fallback download name uses the served filename')
|
|
308
|
+
const { text } = await rerender()
|
|
309
|
+
assert.ok(text.includes('exportStarted'), 'the export notice did not appear')
|
|
310
|
+
assert.ok(!text.includes('exportImages'), 'the renamed copy key leaked back in')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('the ZIP export passes includeAttachments with delivery download and offers the served URL', async (t) => {
|
|
314
|
+
createdNodes.length = 0
|
|
315
|
+
const { rpcCalls, rerender, findButtons } = await openBrowserPage(t, {
|
|
316
|
+
reload: false,
|
|
317
|
+
answers: { exportLibrary: { filename: 'lib-1-with-attachments.zip', downloadUrl: 'http://host/exports/lib-1-with-attachments.zip' } },
|
|
318
|
+
})
|
|
319
|
+
const zipButtons = findButtons('exportZip')
|
|
320
|
+
assert.equal(zipButtons.length, 1, 'the ZIP export button did not render')
|
|
321
|
+
zipButtons[0].props.onClick()
|
|
322
|
+
await settle()
|
|
323
|
+
await settle()
|
|
324
|
+
|
|
325
|
+
const call = rpcCalls.find((entry) => entry.endpoint === 'exportLibrary')
|
|
326
|
+
assert.equal(call.payload.includeAttachments, true, 'the ZIP button must request attachments')
|
|
327
|
+
assert.equal(call.payload.delivery, 'download')
|
|
328
|
+
assert.equal(call.payload.libraryId, 'lib-1')
|
|
329
|
+
const anchor = createdNodes.find((node) => node.tag === 'a' && node.clicked)
|
|
330
|
+
assert.ok(anchor, 'the downloadUrl branch still downloads through an anchor')
|
|
331
|
+
assert.equal(anchor.href, 'http://host/exports/lib-1-with-attachments.zip')
|
|
332
|
+
const { text } = await rerender()
|
|
333
|
+
assert.ok(text.includes('exportStarted'), 'a served URL export also reports exportStarted')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('the ZIP import posts to the selected library id and lands back in that library', async (t) => {
|
|
337
|
+
createdNodes.length = 0
|
|
338
|
+
fetchCalls.length = 0
|
|
339
|
+
const { rpcCalls, rerender, findButtons } = await openBrowserPage(t, { reload: false })
|
|
340
|
+
const importButtons = findButtons('importJson')
|
|
341
|
+
assert.equal(importButtons.length, 1, 'the import button did not render')
|
|
342
|
+
importButtons[0].props.onClick()
|
|
343
|
+
const input = createdNodes.find((node) => node.tag === 'input' && node.type === 'file')
|
|
344
|
+
assert.ok(input, 'the import file picker was created')
|
|
345
|
+
input.files = [{ name: 'lib-1.zip' }]
|
|
346
|
+
await input.onchange()
|
|
347
|
+
await settle()
|
|
348
|
+
await settle()
|
|
349
|
+
|
|
350
|
+
assert.equal(fetchCalls.length, 1, 'the ZIP import must go through the host route')
|
|
351
|
+
assert.ok(
|
|
352
|
+
fetchCalls[0].url.startsWith('/dsh-memu-memory/import/lib-1.zip'),
|
|
353
|
+
`the ZIP URL must use the selected library id, got ${fetchCalls[0].url}`,
|
|
354
|
+
)
|
|
355
|
+
const { text } = await rerender()
|
|
356
|
+
assert.ok(text.includes('importDone'), 'the import success notice did not appear')
|
|
357
|
+
// After a successful import the browser reloads the same library.
|
|
358
|
+
const browsed = rpcCalls.filter((entry) => entry.endpoint === 'browseLibrary')
|
|
359
|
+
assert.ok(browsed.some((entry) => entry.payload.libraryId === 'lib-1'), 'the browser reloaded the imported library')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('each browser row action answers with its own success notice', async (t) => {
|
|
363
|
+
createdNodes.length = 0
|
|
364
|
+
// A server with state: the row actions mutate the served inventory, so the
|
|
365
|
+
// archived tab really gains/loses rows as the test drives it. Function
|
|
366
|
+
// answers must hand back the full RPC envelope (ok/value) themselves.
|
|
367
|
+
const files = structuredClone(BROWSE_FILES)
|
|
368
|
+
const { rpcCalls, rerender, view, findButtons } = await openBrowserPage(t, {
|
|
369
|
+
reload: true,
|
|
370
|
+
answers: {
|
|
371
|
+
browseLibrary: () => ({ ok: true, value: { source: 'engine', files: structuredClone(files) } }),
|
|
372
|
+
addMemory: async () => ({ ok: true, value: { id: 'mem-added' } }),
|
|
373
|
+
updateMemory: async (payload) => {
|
|
374
|
+
const row = files.find((file) => file.id === payload.id)
|
|
375
|
+
if (row) Object.assign(row, payload.patch)
|
|
376
|
+
return { ok: true, value: {} }
|
|
377
|
+
},
|
|
378
|
+
archiveMemory: async (payload) => {
|
|
379
|
+
const row = files.find((file) => file.id === payload.id)
|
|
380
|
+
if (row) row.status = 'archived'
|
|
381
|
+
return { ok: true, value: {} }
|
|
382
|
+
},
|
|
383
|
+
deleteMemory: async (payload) => {
|
|
384
|
+
const at = files.findIndex((file) => file.id === payload.id)
|
|
385
|
+
if (at >= 0) files.splice(at, 1)
|
|
386
|
+
return { ok: true, value: {} }
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
// addMemory → memorySaved
|
|
392
|
+
const contentBox = view.nodes.find((node) => node.type === 'textarea' && node.props?.placeholder === 'memoryContent')
|
|
393
|
+
assert.ok(contentBox, 'the new-memory form did not render')
|
|
394
|
+
contentBox.props.onChange({ target: { value: 'a fresh fact worth keeping' } })
|
|
395
|
+
let next = await rerender()
|
|
396
|
+
const addButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'add')
|
|
397
|
+
assert.equal(addButtons.length, 1)
|
|
398
|
+
addButtons[0].props.onClick()
|
|
399
|
+
await settle(); await settle()
|
|
400
|
+
next = await rerender()
|
|
401
|
+
assert.ok(next.text.includes('memorySaved'), 'addMemory did not report memorySaved')
|
|
402
|
+
assert.ok(rpcCalls.some((call) => call.endpoint === 'addMemory' && call.payload.libraryId === 'lib-1'))
|
|
403
|
+
|
|
404
|
+
// saveMemory → memorySaved (the row editor needs a non-empty draft first)
|
|
405
|
+
const titleInput = next.nodes.find((node) => node.type === 'input' && node.props?.placeholder === 'name')
|
|
406
|
+
assert.ok(titleInput, 'the row editor did not render')
|
|
407
|
+
titleInput.props.onChange({ target: { value: 'renamed entry' } })
|
|
408
|
+
next = await rerender()
|
|
409
|
+
const saveButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'save')
|
|
410
|
+
assert.equal(saveButtons.length, 1, 'the row save button did not render')
|
|
411
|
+
saveButtons[0].props.onClick()
|
|
412
|
+
await settle(); await settle()
|
|
413
|
+
next = await rerender()
|
|
414
|
+
assert.ok(next.text.includes('memorySaved'), 'saveMemory did not report memorySaved')
|
|
415
|
+
assert.ok(rpcCalls.some((call) => call.endpoint === 'updateMemory' && call.payload.id === 'mem-live'))
|
|
416
|
+
|
|
417
|
+
// archiveMemory → memoryArchived (the active row's archive button)
|
|
418
|
+
const archiveButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'archive')
|
|
419
|
+
assert.equal(archiveButtons.length, 1, 'the active row archive button did not render')
|
|
420
|
+
archiveButtons[0].props.onClick()
|
|
421
|
+
await settle(); await settle()
|
|
422
|
+
next = await rerender()
|
|
423
|
+
assert.ok(next.text.includes('memoryArchived'), 'archiveMemory did not report memoryArchived')
|
|
424
|
+
assert.ok(rpcCalls.some((call) => call.endpoint === 'archiveMemory' && call.payload.id === 'mem-live'))
|
|
425
|
+
|
|
426
|
+
// The archived tab now holds both rows; restore → memoryRestored.
|
|
427
|
+
const archivedTab = next.nodes.filter((node) => node.props?.role === 'tab'
|
|
428
|
+
&& String(node.props?.children || '').startsWith('browseArchivedTab'))
|
|
429
|
+
assert.equal(archivedTab.length, 1)
|
|
430
|
+
archivedTab[0].props.onClick()
|
|
431
|
+
next = await rerender()
|
|
432
|
+
const restoreButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'restore')
|
|
433
|
+
assert.equal(restoreButtons.length, 2, 'both archived rows offer restore')
|
|
434
|
+
restoreButtons[1].props.onClick()
|
|
435
|
+
await settle(); await settle()
|
|
436
|
+
next = await rerender()
|
|
437
|
+
assert.ok(next.text.includes('memoryRestored'), 'restoreMemory did not report memoryRestored')
|
|
438
|
+
assert.ok(rpcCalls.some((call) => call.endpoint === 'updateMemory' && call.payload.id === 'mem-gone' && call.payload.patch.status === 'active'))
|
|
439
|
+
|
|
440
|
+
// deleteMemoryForever → memoryDeleted (two-step confirm on the last archived row)
|
|
441
|
+
const armButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'deleteForever')
|
|
442
|
+
assert.ok(armButtons.length >= 1, 'the archived delete button did not render')
|
|
443
|
+
armButtons[0].props.onClick()
|
|
444
|
+
next = await rerender()
|
|
445
|
+
const confirmButtons = next.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'confirmDelete')
|
|
446
|
+
assert.equal(confirmButtons.length, 1, 'the armed confirm did not render')
|
|
447
|
+
confirmButtons[0].props.onClick()
|
|
448
|
+
await settle(); await settle()
|
|
449
|
+
next = await rerender()
|
|
450
|
+
assert.ok(next.text.includes('memoryDeleted'), 'deleteMemoryForever did not report memoryDeleted')
|
|
451
|
+
assert.ok(rpcCalls.some((call) => call.endpoint === 'deleteMemory' && call.payload.id === 'mem-live'))
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('the memory wall reports save, archive and bulk outcomes through onNotice', async (t) => {
|
|
455
|
+
createdNodes.length = 0
|
|
456
|
+
const rpcCalls = []
|
|
457
|
+
const ctx = fakeCtx({ answers: makeAnswers(), rpcCalls })
|
|
458
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
459
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
460
|
+
const props = { ctx, t: (key) => key }
|
|
461
|
+
const first = await renderPage(settings.component, props, undefined, { remount: true })
|
|
462
|
+
collectPageHook()
|
|
463
|
+
for (const effect of first.effects) effect.fn()
|
|
464
|
+
await settle(); await settle()
|
|
465
|
+
|
|
466
|
+
const rerenderAt = async (page) => {
|
|
467
|
+
const { tree, effects } = await renderPage(settings.component, props, page)
|
|
468
|
+
const nodes = []
|
|
469
|
+
walkNodes(tree, (node) => { nodes.push(node) })
|
|
470
|
+
return { tree, nodes, text: treeText(tree), effects }
|
|
471
|
+
}
|
|
472
|
+
// Jump to the wall page and let its own effects fetch; browseWall is
|
|
473
|
+
// effect-driven, unlike the browser page's handler-driven reload.
|
|
474
|
+
let view = await rerenderAt(9)
|
|
475
|
+
for (const effect of view.effects) effect.fn()
|
|
476
|
+
await settle(); await settle()
|
|
477
|
+
view = await rerenderAt(9)
|
|
478
|
+
const wallItems = view.nodes.filter((node) => node.type === 'button' && String(node.props?.className || '').includes('dshm-wall-item'))
|
|
479
|
+
assert.equal(wallItems.length, 1, 'the wall item did not render')
|
|
480
|
+
wallItems[0].props.onClick()
|
|
481
|
+
await settle(); await settle()
|
|
482
|
+
view = await rerenderAt(9)
|
|
483
|
+
|
|
484
|
+
// The drawer is open and editable: save → memorySaved.
|
|
485
|
+
const saveButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'save')
|
|
486
|
+
assert.equal(saveButtons.length, 1, 'the drawer save button did not render')
|
|
487
|
+
saveButtons[0].props.onClick()
|
|
488
|
+
await settle(); await settle()
|
|
489
|
+
view = await rerenderAt(9)
|
|
490
|
+
assert.ok(view.text.includes('memorySaved'), 'the wall save did not reach the page notice')
|
|
491
|
+
|
|
492
|
+
// Archive from the drawer → memoryArchived.
|
|
493
|
+
const archiveButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'archive')
|
|
494
|
+
assert.equal(archiveButtons.length, 1, 'the drawer archive button did not render')
|
|
495
|
+
archiveButtons[0].props.onClick()
|
|
496
|
+
await settle(); await settle()
|
|
497
|
+
view = await rerenderAt(9)
|
|
498
|
+
assert.ok(view.text.includes('memoryArchived'), 'the wall archive did not reach the page notice')
|
|
499
|
+
|
|
500
|
+
// Close the drawer, enter multi-select, select all, bulk archive → bulkDone.
|
|
501
|
+
const closeButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'close')
|
|
502
|
+
assert.ok(closeButtons.length >= 1)
|
|
503
|
+
closeButtons[closeButtons.length - 1].props.onClick()
|
|
504
|
+
view = await rerenderAt(9)
|
|
505
|
+
const multiButtons = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'multiSelect')
|
|
506
|
+
assert.equal(multiButtons.length, 1, 'the multi-select entry did not render')
|
|
507
|
+
multiButtons[0].props.onClick()
|
|
508
|
+
view = await rerenderAt(9)
|
|
509
|
+
const selectAll = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'wallSelectAll')
|
|
510
|
+
assert.equal(selectAll.length, 1)
|
|
511
|
+
selectAll[0].props.onClick()
|
|
512
|
+
view = await rerenderAt(9)
|
|
513
|
+
const bulkArchive = view.nodes.filter((node) => typeof node.type === 'function' && node.props?.children === 'archive (1)')
|
|
514
|
+
assert.equal(bulkArchive.length, 1, 'the bulk archive button did not render')
|
|
515
|
+
bulkArchive[0].props.onClick()
|
|
516
|
+
await settle(); await settle()
|
|
517
|
+
view = await rerenderAt(9)
|
|
518
|
+
assert.ok(view.text.includes('bulkDone'), 'the bulk success notice did not appear')
|
|
519
|
+
assert.ok(rpcCalls.some((call) => call.endpoint === 'updateMemory' && call.payload.id === 'mem-wall' && call.payload.patch.status === 'archived'))
|
|
520
|
+
})
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// only runs when its page is selected.
|
|
7
7
|
import test from 'node:test'
|
|
8
8
|
import assert from 'node:assert/strict'
|
|
9
|
+
import fs from 'node:fs'
|
|
9
10
|
|
|
10
11
|
const registrations = []
|
|
11
12
|
|
|
@@ -211,14 +212,14 @@ test('the settings page renders every page instead of a blank panel', async () =
|
|
|
211
212
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
212
213
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
213
214
|
|
|
214
|
-
for (let page = 0; page <
|
|
215
|
+
for (let page = 0; page < 12; page++) {
|
|
215
216
|
const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, page)
|
|
216
217
|
const text = treeText(tree)
|
|
217
218
|
assert.match(String(tree.props?.className), /dshm-root/, `page ${page} did not render the shell`)
|
|
218
219
|
// The rail is the nav: every page label has to be there. Counting the shared
|
|
219
220
|
// dshm-nav-item class instead would also pick up the in-page tab strips (memory
|
|
220
221
|
// backend, prompt scope, browser view), which use the same widget on purpose.
|
|
221
|
-
const NAV = ['pageOverview', 'pageConnection', 'pageSettings', 'pageFolders', 'pageLibraries', 'pageBrowser', 'pageGlobal', 'pagePrompts', 'pageStats', 'pageMemories', 'pageLogs']
|
|
222
|
+
const NAV = ['pageOverview', 'pageConnection', 'pageSettings', 'pageFolders', 'pageLibraries', 'pageBrowser', 'pageGlobal', 'pagePrompts', 'pageStats', 'pageMemories', 'pageLogs', 'pageLexicon']
|
|
222
223
|
assert.ok(NAV.every((key) => text.includes(key)), `page ${page} lost a nav entry`)
|
|
223
224
|
assert.equal((text.match(/dshm-nav-ind/g) || []).length, 1, `page ${page} lost or duplicated its rail indicator`)
|
|
224
225
|
assert.match(text, /dshm-page/, `page ${page} lost the content pane`)
|
|
@@ -258,6 +259,21 @@ test('the sidebar tab renders with the two-layer header', async () => {
|
|
|
258
259
|
assert.match(text, /dmCoding/, 'domain picker did not render its options')
|
|
259
260
|
})
|
|
260
261
|
|
|
262
|
+
test('the sidebar context-scope card cannot be flex-shrunk and keeps a two-column field grid', () => {
|
|
263
|
+
const src = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
264
|
+
assert.match(src, /className: 'dshm-tab-body'/, 'the sidebar body needs its non-shrinking class')
|
|
265
|
+
assert.match(src, /\.dshm-tab-body>\*\{flex-shrink:0\}/, 'sidebar sections must scroll instead of shrinking')
|
|
266
|
+
assert.match(src, /\.dshm-scope\{width:100%;margin:0 0 10px;[^}]*flex-shrink:0/, 'the scope card must span the sidebar content width and not collapse')
|
|
267
|
+
assert.match(src, /\.dshm-scope-form\{[^}]*grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/, 'four scope fields must wrap into two readable columns')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('the sidebar run log keeps five rows and links its header to the WebUI logs page', () => {
|
|
271
|
+
const src = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
272
|
+
assert.match(src, /runLog\.slice\(0, 5\)/, 'the sidebar shows the five newest runs, not an unbounded list')
|
|
273
|
+
assert.match(src, /openWebUiPage\('logs'\)/, 'the run-log header must open the WebUI logs page')
|
|
274
|
+
assert.match(src, /runLogOpen/, 'the jump needs a visible label')
|
|
275
|
+
})
|
|
276
|
+
|
|
261
277
|
// The browser row is the only place a human can see that a memory is marked
|
|
262
278
|
// "never say this out loud" or "secret" without opening the JSON. The badges are
|
|
263
279
|
// driven by fields that used to be stored and ignored, so a regression here is
|
|
@@ -484,7 +500,7 @@ test('every pick-one-of-these control uses the same tab strip', async () => {
|
|
|
484
500
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
485
501
|
|
|
486
502
|
const orphans = []
|
|
487
|
-
for (let page = 0; page <
|
|
503
|
+
for (let page = 0; page < 12; page++) {
|
|
488
504
|
const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, page)
|
|
489
505
|
walkNodes(tree, (node) => {
|
|
490
506
|
if (node.type !== 'button') return
|
|
@@ -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 () => {
|