dsh-memu-memory 1.2.1 → 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 +12 -5
- package/README.zh.md +4 -2
- package/docs/contributing.md +16 -3
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -4
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +2 -2
- package/lib/client.js +1361 -146
- package/lib/consolidation-worker.js +30 -8
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1252 -91
- 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 +51 -9
- package/lib/inbound.js +19 -0
- 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 +282 -17
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +32 -3
- 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 +577 -9
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +36 -18
- package/lib/tools.js +168 -10
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +1 -1
- 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/tests/absorb-review.test.mjs +353 -0
- 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-fork.test.mjs +14 -12
- package/tests/consolidation-snapshot.test.mjs +39 -0
- 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 +52 -5
- 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 +35 -2
- package/tests/graph.test.mjs +96 -0
- package/tests/help-page.test.mjs +415 -0
- 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-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 +33 -4
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +188 -0
|
@@ -70,7 +70,7 @@ test('tip-of-tongue is about reachability, not importance', () => {
|
|
|
70
70
|
assert.equal(memory.isTipOfTongue({ layer: 'long_term', confidence: 0.9, strength: 1, createdAt: daysAgo(1) }), false)
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
-
test('
|
|
73
|
+
test('recall shows the title of a half-remembered memory, never its wording', async () => {
|
|
74
74
|
const { sessionId, binding, exec } = setup('tongue')
|
|
75
75
|
const content = '周四晚上在厨房,他一边擦杯子一边说那句关于不要再改需求的话,说完就把杯子放回架子上,转身去阳台抽了一支烟,很久都没有再开口,客厅的电视一直在放同一档节目。'
|
|
76
76
|
const saved = await memory.privateRemember({ exec, content, title: '厨房里那句话', confidence: 0.3 })
|
|
@@ -79,7 +79,7 @@ test('injection gives the gist, not the wording, when a memory is half-remembere
|
|
|
79
79
|
state.savePrivateIndex(binding.libraryId, index)
|
|
80
80
|
|
|
81
81
|
const out = await inject.buildRecall(binding, '厨房 那句话', 1, { ...state.getSettings(), privateTopK: 3, relevanceThreshold: 0 }, exec, sessionId)
|
|
82
|
-
assert.ok(out.text.includes('
|
|
82
|
+
assert.ok(out.text.includes('厨房里那句话'), 'the title is the directory entry')
|
|
83
83
|
assert.ok(!out.text.includes('客厅的电视一直在放同一档节目'), 'the exact wording must not be handed over as if quoted')
|
|
84
84
|
assert.ok(out.text.includes(saved.id), 'the id and the tool are how the original is still reachable')
|
|
85
85
|
})
|
|
@@ -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
|