dsh-memu-memory 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +31 -2
  3. package/README.zh.md +30 -2
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +1 -1
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/host-api.md +2 -2
  9. package/docs/memory-semantics.md +12 -10
  10. package/docs/recall-benchmark.md +238 -0
  11. package/docs/troubleshooting.md +395 -395
  12. package/lib/active-memory.js +165 -0
  13. package/lib/activity-log.js +1 -1
  14. package/lib/binding-diagnostics.js +8 -3
  15. package/lib/client.js +33 -11
  16. package/lib/consolidation-ledger.js +347 -0
  17. package/lib/consolidation-receipt.js +284 -0
  18. package/lib/consolidation-snapshot.js +274 -0
  19. package/lib/consolidation-worker.js +115 -0
  20. package/lib/cycle.js +40 -6
  21. package/lib/inject.js +104 -76
  22. package/lib/local-embedding-manager.js +24 -6
  23. package/lib/memory.js +221 -19
  24. package/lib/mood.js +268 -39
  25. package/lib/preset-scope.js +1 -1
  26. package/lib/private.js +335 -40
  27. package/lib/prompts/private-consolidation.coding.md +1 -0
  28. package/lib/prompts/private-consolidation.md +1 -0
  29. package/lib/prompts/private-consolidation.mixed.md +1 -0
  30. package/lib/prompts/private-consolidation.rp.md +1 -0
  31. package/lib/prompts.js +4 -4
  32. package/lib/remote-summary.js +51 -25
  33. package/lib/rpc.js +9 -1
  34. package/lib/state.js +29 -12
  35. package/lib/tools.js +49 -2
  36. package/lib/vector-profile.js +27 -0
  37. package/package.json +2 -1
  38. package/scripts/acceptance-local.mjs +2 -1
  39. package/scripts/active-memory-ab-live.mjs +655 -0
  40. package/scripts/active-memory-ab.mjs +1302 -0
  41. package/scripts/chain-matrix.mjs +11 -1
  42. package/scripts/check-all.mjs +10 -1
  43. package/tests/activation.test.mjs +89 -0
  44. package/tests/active-memory-ab.test.mjs +745 -0
  45. package/tests/active-memory.test.mjs +129 -0
  46. package/tests/activity-log.test.mjs +126 -0
  47. package/tests/activity-wiring.test.mjs +103 -0
  48. package/tests/appendix-b.test.mjs +212 -0
  49. package/tests/appendix-b2.test.mjs +166 -0
  50. package/tests/audit-regression.test.mjs +272 -0
  51. package/tests/audit-safety.test.mjs +227 -0
  52. package/tests/backend-compensation.test.mjs +272 -0
  53. package/tests/backup.test.mjs +793 -0
  54. package/tests/binding-diagnostics.test.mjs +720 -0
  55. package/tests/browser.test.mjs +366 -0
  56. package/tests/client-render.test.mjs +892 -0
  57. package/tests/coalesce.test.mjs +113 -0
  58. package/tests/config.test.mjs +159 -0
  59. package/tests/consolidation-credential.test.mjs +320 -0
  60. package/tests/consolidation-fork.test.mjs +357 -0
  61. package/tests/consolidation-ledger.test.mjs +298 -0
  62. package/tests/consolidation-snapshot.test.mjs +321 -0
  63. package/tests/consolidation-value.test.mjs +89 -0
  64. package/tests/context-scope.test.mjs +40 -0
  65. package/tests/cue.test.mjs +44 -0
  66. package/tests/curator-probe.test.mjs +433 -0
  67. package/tests/curator.test.mjs +150 -0
  68. package/tests/cycle.test.mjs +411 -0
  69. package/tests/disclosure.test.mjs +231 -0
  70. package/tests/doc-drift.test.mjs +222 -0
  71. package/tests/embedding-catalog.test.mjs +266 -0
  72. package/tests/embedding-download.test.mjs +391 -0
  73. package/tests/embedding-runtime.test.mjs +389 -0
  74. package/tests/emotion-chain.test.mjs +84 -0
  75. package/tests/emotion-contract.test.mjs +445 -0
  76. package/tests/engine.test.mjs +35 -0
  77. package/tests/fixtures/active-memory-ab/README.md +56 -0
  78. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  79. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  80. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  81. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  82. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  83. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  84. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  85. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  86. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  87. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  88. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  89. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  90. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  92. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  93. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  94. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  95. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  96. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  97. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  98. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  99. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  100. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  101. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  102. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  103. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  104. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  105. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  106. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  107. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  108. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  109. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  110. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  111. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  112. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  113. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  114. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  115. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  116. package/tests/graph-view.test.mjs +187 -0
  117. package/tests/graph.test.mjs +77 -0
  118. package/tests/hook-order.test.mjs +72 -0
  119. package/tests/humanlike.test.mjs +232 -0
  120. package/tests/hybrid-recall.test.mjs +92 -0
  121. package/tests/inbound.test.mjs +393 -0
  122. package/tests/inject-guide.test.mjs +364 -0
  123. package/tests/inject.test.mjs +222 -0
  124. package/tests/injection-budget-settings.test.mjs +35 -0
  125. package/tests/injection-budget.test.mjs +64 -0
  126. package/tests/injection-chain.test.mjs +191 -0
  127. package/tests/loader-register.mjs +3 -0
  128. package/tests/loader.mjs +12 -0
  129. package/tests/local-embedding-entry.test.mjs +95 -0
  130. package/tests/local-embedding-manager.test.mjs +476 -0
  131. package/tests/local-embedding-protocol.test.mjs +95 -0
  132. package/tests/local-embedding-worker.test.mjs +346 -0
  133. package/tests/managed-memory.test.mjs +536 -0
  134. package/tests/managed-runtime.test.mjs +40 -0
  135. package/tests/media.test.mjs +132 -0
  136. package/tests/migration-payload.test.mjs +42 -0
  137. package/tests/mood.test.mjs +109 -0
  138. package/tests/multiprocess.test.mjs +29 -0
  139. package/tests/new-session-gate.test.mjs +361 -0
  140. package/tests/package-shipping.test.mjs +84 -0
  141. package/tests/preset-scope.test.mjs +212 -0
  142. package/tests/prompts.test.mjs +250 -0
  143. package/tests/react-snapshot.test.mjs +23 -0
  144. package/tests/recall-bench.test.mjs +306 -0
  145. package/tests/remote.test.mjs +140 -0
  146. package/tests/rename.test.mjs +30 -0
  147. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  148. package/tests/repetition-eval-live.test.mjs +177 -0
  149. package/tests/repetition-eval.test.mjs +373 -0
  150. package/tests/salient-date.test.mjs +142 -0
  151. package/tests/settings-beat.test.mjs +221 -0
  152. package/tests/short-term.test.mjs +166 -0
  153. package/tests/sidebar-beat.test.mjs +208 -0
  154. package/tests/sidebar-refresh.test.mjs +123 -0
  155. package/tests/sidebar.test.mjs +70 -0
  156. package/tests/snippet.test.mjs +47 -0
  157. package/tests/source-kind.test.mjs +52 -0
  158. package/tests/state.test.mjs +150 -0
  159. package/tests/stats.test.mjs +112 -0
  160. package/tests/storage.test.mjs +125 -0
  161. package/tests/stubs/dsh-llm.js +5 -0
  162. package/tests/stubs/dsh-session.js +7 -0
  163. package/tests/stubs/dsh-tools.js +101 -0
  164. package/tests/tool-schema.test.mjs +73 -0
  165. package/tests/tools.test.mjs +184 -0
  166. package/tests/ui-defaults.test.mjs +57 -0
  167. package/tests/update-fields.test.mjs +138 -0
  168. package/tests/vector-migration.test.mjs +733 -0
  169. package/tests/vector-profile.test.mjs +425 -0
  170. package/tests/visibility-migration.test.mjs +152 -0
  171. package/tests/webui-auth.test.mjs +454 -0
  172. package/tests/webui.test.mjs +164 -0
  173. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,361 @@
1
+ // The new-session prompt is mounted through ReactDOM.createRoot, which the settings
2
+ // render harness leaves stubbed out — so its cards were never exercised by any test.
3
+ // It is also the one screen where a wrong default silently decides whether a whole
4
+ // conversation remembers anything, so it gets its own harness.
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ const roots = []
9
+
10
+ function makeElement(type, props, ...children) {
11
+ return { type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }
12
+ }
13
+
14
+ let hooks = []
15
+ let cursor = 0
16
+ let effects = []
17
+
18
+ const React = {
19
+ createElement: makeElement,
20
+ Fragment: Symbol('Fragment'),
21
+ useState(initial) {
22
+ const index = cursor++
23
+ if (hooks[index] === undefined) hooks[index] = typeof initial === 'function' ? initial() : initial
24
+ return [hooks[index], (next) => { hooks[index] = typeof next === 'function' ? next(hooks[index]) : next }]
25
+ },
26
+ useCallback: (fn) => fn,
27
+ useMemo: (fn) => fn(),
28
+ useEffect(fn, deps) { effects.push({ fn, deps, index: cursor++ }) },
29
+ useLayoutEffect() {},
30
+ useRef: (value) => ({ current: value }),
31
+ useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
32
+ }
33
+
34
+ const reactDom = {
35
+ createRoot(container) {
36
+ const root = { container, element: null }
37
+ root.render = (element) => { root.element = element; roots.push(root) }
38
+ root.unmount = () => {}
39
+ return root
40
+ },
41
+ }
42
+
43
+ globalThis.window = {
44
+ __ModuleLoader__: {
45
+ load({ factory }) {
46
+ globalThis.__dshMemuExports = factory((name) => {
47
+ if (name === 'react') return React
48
+ if (name === 'react-dom/client') return reactDom
49
+ throw new Error('unexpected require: ' + name)
50
+ })
51
+ },
52
+ },
53
+ addEventListener() {},
54
+ removeEventListener() {},
55
+ }
56
+ globalThis.document = {
57
+ createElement: () => ({ style: {}, attrs: {}, setAttribute(n, v) { this.attrs[n] = v }, appendChild() {}, remove() {} }),
58
+ querySelector: () => null,
59
+ addEventListener() {},
60
+ removeEventListener() {},
61
+ head: { appendChild() {} },
62
+ body: { append(node) { return node } },
63
+ }
64
+
65
+ await import('../lib/client.js')
66
+ // A folder that keeps both layers, with a library already sitting in it.
67
+ const OPEN_FOLDER = {
68
+ binding: null,
69
+ shouldPrompt: true,
70
+ libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/ws/demo' }],
71
+ domains: ['rp', 'coding', 'general', 'mixed'],
72
+ folder: { scopeKey: '/ws/demo', useLibrary: true, useGlobal: true },
73
+ settings: { newSessionPromptDismissed: false, newSessionDefault: 'both' },
74
+ }
75
+
76
+ function buildCtx(promptState) {
77
+ const snapshot = { byId: { 'sess-1': { cwd: '/ws/demo', blank: true, origin: 'user' } }, ids: ['sess-1'] }
78
+ const connection = {
79
+ rpc: {
80
+ call: async (_path, endpoint, payload) => {
81
+ if (endpoint === 'getSessionPromptState') return { ok: true, value: promptState }
82
+ if (endpoint === 'initSession' || endpoint === 'setSessionScope' || endpoint === 'setDomain') {
83
+ calls.push({ endpoint, payload })
84
+ return { ok: true, value: {} }
85
+ }
86
+ throw new Error('unexpected rpc: ' + endpoint)
87
+ },
88
+ },
89
+ }
90
+ // This is the scope the gate is mounted with; the host passes an injected
91
+ // sub-scope that can still reach the session and the RPC connection.
92
+ const gateScope = {
93
+ get: (name) => (name === 'uiSession'
94
+ ? { adapter: { current: { subscribe: () => () => {}, getSnapshot: () => ({ key: 'sess-1' }) } } }
95
+ : undefined),
96
+ sessions: { list: { subscribe: () => () => {}, getSnapshot: () => snapshot } },
97
+ connection,
98
+ effect: (fn) => { try { fn() } catch {} },
99
+ }
100
+ return {
101
+ effect: (fn) => { try { fn() } catch {} },
102
+ locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
103
+ connection,
104
+ sessions: gateScope.sessions,
105
+ get: gateScope.get,
106
+ slots: {
107
+ inject: (_name, callback) => { callback && callback(gateScope) },
108
+ register: () => () => {},
109
+ },
110
+ inject: (deps, callback) => { if (Array.isArray(deps) && deps.includes('sessions')) callback(gateScope) },
111
+ }
112
+ }
113
+
114
+ const calls = []
115
+
116
+ function renderGate(promptState) {
117
+ hooks = []
118
+ calls.length = 0
119
+ cursor = 0
120
+ effects = []
121
+ roots.length = 0
122
+ globalThis.__dshMemuExports.apply(buildCtx(promptState))
123
+ assert.equal(roots.length, 1, 'the new-session gate was not mounted at all')
124
+ return roots[0].element
125
+ }
126
+
127
+ // First pass renders nothing (no prompt yet), the mount effect fetches the state,
128
+ // the second pass renders the dialog with whatever the effect decided.
129
+ async function settle(element) {
130
+ effects = []
131
+ cursor = 0
132
+ const first = element.type(element.props)
133
+ for (const effect of effects) effect.fn()
134
+ await new Promise((resolve) => setTimeout(resolve, 0))
135
+ await new Promise((resolve) => setTimeout(resolve, 0))
136
+ effects = []
137
+ cursor = 0
138
+ return { first, tree: element.type(element.props) }
139
+ }
140
+
141
+ function treeText(node, depth = 0) {
142
+ if (node === null || node === undefined || depth > 400) return ''
143
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
144
+ if (Array.isArray(node)) return node.map((child) => treeText(child, depth + 1)).join(' ')
145
+ if (typeof node.type === 'function') return treeText(node.type(node.props), depth + 1)
146
+ return treeText(node.props?.children, depth + 1)
147
+ }
148
+
149
+ function walk(node, visit, depth = 0) {
150
+ if (node === null || node === undefined || depth > 400) return
151
+ if (Array.isArray(node)) { for (const child of node) walk(child, visit, depth + 1); return }
152
+ if (typeof node !== 'object') return
153
+ visit(node)
154
+ if (typeof node.type === 'function') { walk(node.type(node.props), visit, depth + 1); return }
155
+ walk(node.props?.children, visit, depth + 1)
156
+ }
157
+
158
+ function cardsOf(tree) {
159
+ const cards = []
160
+ walk(tree, (node) => { if (node.props?.role === 'radio') cards.push(node) })
161
+ return cards
162
+ }
163
+ test('the new-session prompt is four cards with consequences, and waits before showing', async () => {
164
+ const element = renderGate(OPEN_FOLDER)
165
+ const { first, tree } = await settle(element)
166
+ // Nothing is known yet on the first pass, so there must be no empty dialog
167
+ // flashing on every new conversation.
168
+ assert.equal(first, null, 'the gate painted before the session state arrived')
169
+
170
+ const cards = cardsOf(tree)
171
+ assert.equal(cards.length, 4, 'the prompt is four cards, not four checkboxes')
172
+ const titles = cards.map((card) => treeText(card.props.children).trim())
173
+ assert.ok(titles[0].includes('memOffTitle'), 'the no-memory card lost its title')
174
+ assert.ok(titles[1].includes('memPrivateTitle'), 'the private card lost its title')
175
+ assert.ok(titles[2].includes('memGlobalTitle'), 'the global card lost its title')
176
+ assert.ok(titles[3].includes('memBothTitle'), 'the both card lost its title')
177
+ // Every card carries what happens next, not just a label.
178
+ assert.ok(titles[0].includes('memOffDesc') && titles[1].includes('memPrivateDesc'))
179
+ assert.ok(titles[2].includes('memGlobalDesc') && titles[3].includes('memBothDesc'))
180
+ assert.ok(!cards.some((card) => card.props.disabled), 'a permissive folder disabled a card')
181
+
182
+ let dialog = null
183
+ walk(tree, (node) => { if (node.props?.role === 'dialog') dialog = node })
184
+ assert.ok(dialog, 'the prompt has no dialog semantics')
185
+ assert.equal(dialog.props['aria-modal'], 'true')
186
+
187
+ const text = treeText(tree)
188
+ for (const key of ['memoryPromptBody', 'domain', 'domainPromptHint', 'advanced', 'chooseLibrary', 'createLibrary', 'rememberChoice', 'later', 'confirm']) {
189
+ assert.ok(text.includes(key), 'the prompt lost ' + key)
190
+ }
191
+ // The dismissed flag is not set yet, so nothing was committed behind the user.
192
+ assert.equal(calls.length, 0, 'the prompt wrote a decision before the user answered')
193
+ })
194
+
195
+ test('a folder that switched a layer off greys the cards that need it', async () => {
196
+ const blocked = {
197
+ ...OPEN_FOLDER,
198
+ folder: { scopeKey: '/ws/demo', useLibrary: false, useGlobal: false },
199
+ }
200
+ const { tree } = await settle(renderGate(blocked))
201
+ const cards = cardsOf(tree)
202
+ assert.equal(cards.length, 4)
203
+ assert.ok(!cards[0].props.disabled, 'the no-memory card was disabled by the folder')
204
+ for (const card of cards.slice(1)) {
205
+ assert.equal(card.props.disabled, true, 'a blocked layer stayed clickable')
206
+ assert.ok(treeText(card.props.children).includes('memBlockedByFolder'), 'a disabled card did not say why')
207
+ }
208
+ })
209
+
210
+ test('a folder with memory switched off binds silently instead of asking', async () => {
211
+ const off = {
212
+ ...OPEN_FOLDER,
213
+ shouldPrompt: false,
214
+ folder: { scopeKey: '/ws/demo', useLibrary: false, useGlobal: false },
215
+ }
216
+ const { tree } = await settle(renderGate(off))
217
+ assert.equal(tree, null, 'the prompt appeared for a folder whose memory is off')
218
+ assert.deepEqual(calls.map((call) => call.endpoint), ['initSession', 'setSessionScope'])
219
+ assert.equal(calls[0].payload.createLibrary, false, 'a library was created for a folder with memory off')
220
+ const scope = calls[1].payload
221
+ assert.equal(scope.useLibrary, false)
222
+ assert.equal(scope.useGlobal, false)
223
+ })
224
+ // ---- the card is the answer; the sidebar just draws it -----------------------
225
+ // A card names a pair of switches, and those two switches are exactly what the
226
+ // sidebar renders afterwards. Writing only one of them (or, as the confirm
227
+ // branch once did, only the library) let ensureSession fill the other from the
228
+ // folder policy — which is how "private memory only" came back with both boxes
229
+ // ticked. These cases hold the three ideas together: card, binding, sidebar.
230
+
231
+ const CARD_ANSWERS = [
232
+ { key: 'memOffTitle', layers: { useLibrary: false, useGlobal: false }, createLibrary: false },
233
+ { key: 'memPrivateTitle', layers: { useLibrary: true, useGlobal: false }, createLibrary: true },
234
+ { key: 'memGlobalTitle', layers: { useLibrary: false, useGlobal: true }, createLibrary: false },
235
+ { key: 'memBothTitle', layers: { useLibrary: true, useGlobal: true }, createLibrary: true },
236
+ ]
237
+
238
+ // Re-run the component with the current hook state but WITHOUT replaying the
239
+ // mount effect: the effect re-fetches the state and would reset the card the
240
+ // test just clicked.
241
+ function paint(element) {
242
+ cursor = 0
243
+ effects = []
244
+ return element.type(element.props)
245
+ }
246
+
247
+ function findNode(tree, predicate) {
248
+ let hit = null
249
+ walk(tree, (node) => { if (!hit && predicate(node)) hit = node })
250
+ return hit
251
+ }
252
+
253
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
254
+
255
+ async function answerCard(cardKey) {
256
+ const element = renderGate(OPEN_FOLDER)
257
+ const { tree } = await settle(element)
258
+ const card = cardsOf(tree).find((node) => treeText(node.props.children).includes(cardKey))
259
+ assert.ok(card, 'the prompt has no card ' + cardKey)
260
+ card.props.onClick()
261
+ const painted = paint(element)
262
+ const confirm = findNode(painted, (node) => node.props?.key === 'ok' && typeof node.props.onClick === 'function')
263
+ assert.ok(confirm, 'the prompt has no confirm button')
264
+ assert.equal(confirm.props.disabled, false, 'confirm was disabled for ' + cardKey)
265
+ await confirm.props.onClick()
266
+ await tick()
267
+ await tick()
268
+ }
269
+
270
+ for (const { key, layers, createLibrary } of CARD_ANSWERS) {
271
+ test('answering ' + key + ' writes both switches, not just the library', async () => {
272
+ await answerCard(key)
273
+ assert.deepEqual(
274
+ calls.map((call) => call.endpoint),
275
+ ['initSession', 'setSessionScope', 'setDomain'],
276
+ key + ' did not bind first, scope second, and domain last',
277
+ )
278
+ const init = calls[0].payload
279
+ assert.equal(init.createLibrary, createLibrary, key + ' created the wrong library')
280
+ const scope = calls[1].payload
281
+ assert.equal(scope.useLibrary, layers.useLibrary, key + ' left the private switch to the folder policy')
282
+ assert.equal(scope.useGlobal, layers.useGlobal, key + ' left the global switch to the folder policy')
283
+ assert.equal(calls[2].payload.domain, 'rp', key + ' did not write the default domain')
284
+ })
285
+ }
286
+
287
+ // The remembered answer has to mean the same thing as the card did. This branch
288
+ // used to write only the library (or only the layers), so the answer the user
289
+ // gave and the two boxes the sidebar drew could differ.
290
+ const REMEMBERED = [
291
+ ['off', { useLibrary: false, useGlobal: false }, false],
292
+ ['auto', { useLibrary: true, useGlobal: false }, true],
293
+ ['global', { useLibrary: false, useGlobal: true }, false],
294
+ ['both', { useLibrary: true, useGlobal: true }, true],
295
+ ]
296
+
297
+ for (const [preference, layers, createLibrary] of REMEMBERED) {
298
+ test('a remembered "' + preference + '" answer is applied as both switches', async () => {
299
+ const dismissed = {
300
+ ...OPEN_FOLDER,
301
+ settings: { newSessionPromptDismissed: true, newSessionDefault: preference },
302
+ }
303
+ const { tree } = await settle(renderGate(dismissed))
304
+ assert.equal(tree, null, 'a remembered answer still opened the prompt')
305
+ assert.deepEqual(calls.map((call) => call.endpoint), ['initSession', 'setSessionScope'])
306
+ assert.equal(calls[0].payload.createLibrary, createLibrary, preference + ' created the wrong library')
307
+ assert.equal(calls[1].payload.useLibrary, layers.useLibrary, preference + ' lost its private switch')
308
+ assert.equal(calls[1].payload.useGlobal, layers.useGlobal, preference + ' lost its global switch')
309
+ })
310
+ }
311
+
312
+ test('the advanced library pick changes where memory lives, not which layers', async () => {
313
+ const element = renderGate(OPEN_FOLDER)
314
+ const { tree } = await settle(element)
315
+ const card = cardsOf(tree).find((node) => treeText(node.props.children).includes('memPrivateTitle'))
316
+ card.props.onClick()
317
+ let painted = paint(element)
318
+ const radios = []
319
+ walk(painted, (node) => {
320
+ if (node.props?.type === 'radio' && node.props?.name === 'memu-library') radios.push(node)
321
+ })
322
+ assert.equal(radios.length, 3, 'the advanced block lost its library radios')
323
+ // "create a new library" with an empty name cannot be confirmed yet.
324
+ radios[2].props.onChange({ target: { checked: true } })
325
+ painted = paint(element)
326
+ const blocked = findNode(painted, (node) => node.props?.key === 'ok')
327
+ assert.equal(blocked.props.disabled, true, 'an empty library name was confirmable')
328
+ // Pointing the private layer at the folder's existing library must keep the
329
+ // card's answer intact — this is the click that used to erase it.
330
+ radios[1].props.onChange({ target: { checked: true } })
331
+ painted = paint(element)
332
+ const confirm = findNode(painted, (node) => node.props?.key === 'ok')
333
+ assert.equal(confirm.props.disabled, false, 'a chosen existing library could not be confirmed')
334
+ await confirm.props.onClick()
335
+ await tick()
336
+ await tick()
337
+ assert.equal(calls[0].payload.libraryId, 'lib-1', 'the picked library was not bound')
338
+ assert.equal('createLibrary' in calls[0].payload, false, 'picking a library created another one')
339
+ assert.equal(calls[1].payload.useLibrary, true, 'the pick overwrote the private switch')
340
+ assert.equal(calls[1].payload.useGlobal, false, 'the pick turned the global layer back on')
341
+ assert.equal(calls[2].payload.domain, 'rp', 'the library pick lost the domain')
342
+ })
343
+
344
+ // The domain selector is the point of this prompt: an RP workspace used for a
345
+ // coding session must not silently keep RP prompts and RP memory filtering.
346
+ test('the prompt defaults the domain to RP and can switch it to Coding', async () => {
347
+ const element = renderGate(OPEN_FOLDER)
348
+ const { tree } = await settle(element)
349
+ const select = findNode(tree, (node) => node.type === 'select')
350
+ assert.ok(select, 'the prompt has no domain selector')
351
+ assert.equal(select.props.value, 'rp', 'the domain did not default to the binding default')
352
+ assert.deepEqual(select.props.children.map((option) => option.props.value), ['rp', 'coding', 'general', 'mixed'])
353
+ select.props.onChange({ target: { value: 'coding' } })
354
+ const painted = paint(element)
355
+ const confirm = findNode(painted, (node) => node.props?.key === 'ok')
356
+ await confirm.props.onClick()
357
+ await tick()
358
+ await tick()
359
+ assert.deepEqual(calls.map((call) => call.endpoint), ['initSession', 'setSessionScope', 'setDomain'])
360
+ assert.equal(calls[2].payload.domain, 'coding', 'the selected Coding domain was not written')
361
+ })
@@ -0,0 +1,84 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { spawnSync } from 'node:child_process'
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ // H1 audit item: the published tarball used to carry no tests/ at all, so
10
+ // `npm test` inside an installed package reported "tests 0 / pass 0" and exited
11
+ // 0 while `npm run check` died with ENOENT on the missing tests/ directory.
12
+ // These assertions pin both halves of the fix: tests/ ships, and check-all.mjs
13
+ // treats a legitimately absent directory as skip instead of a crash.
14
+
15
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
16
+ const REPO = path.resolve(HERE, '..')
17
+ const pkg = JSON.parse(fs.readFileSync(path.join(REPO, 'package.json'), 'utf8'))
18
+
19
+ // A directory may only be skipped when the file that would have been read is
20
+ // really absent; every shipped directory must still be syntax-checked.
21
+ function fakeInstall({ dirs }) {
22
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'package-shipping-'))
23
+ fs.mkdirSync(path.join(root, 'scripts'), { recursive: true })
24
+ for (const name of ['check-all.mjs', 'check-doc-drift.mjs', 'check-i18n.mjs']) {
25
+ fs.copyFileSync(path.join(REPO, 'scripts', name), path.join(root, 'scripts', name))
26
+ }
27
+ for (const [rel, body] of Object.entries(dirs)) {
28
+ const abs = path.join(root, rel)
29
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
30
+ fs.writeFileSync(abs, body)
31
+ }
32
+ // Bounds check reads these two before it reaches the i18n stage; they must
33
+ // exist for the run to get past the syntax loop at all.
34
+ fs.writeFileSync(path.join(root, 'AGENTS.md'), '# index\n')
35
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fake-install', version: '0.0.0' }))
36
+ return root
37
+ }
38
+
39
+ function runCheckAll(root) {
40
+ return spawnSync(process.execPath, [path.join('scripts', 'check-all.mjs')], { cwd: root, encoding: 'utf8' })
41
+ }
42
+
43
+ test('package.json ships tests/ so the packaged suite is not silently empty', () => {
44
+ assert.ok(Array.isArray(pkg.files), 'package.json needs a files whitelist')
45
+ assert.ok(pkg.files.includes('tests'), 'files must include "tests" or npm test finds 0 test files')
46
+ // The suite is only runnable if its harness (loader + stubs) travels with it.
47
+ for (const rel of ['loader-register.mjs', 'loader.mjs', 'stubs/dsh-llm.js']) {
48
+ assert.ok(fs.existsSync(path.join(REPO, 'tests', rel)), 'tests/' + rel + ' must ship with the package')
49
+ }
50
+ })
51
+
52
+ test('npm test glob still points at the shipped test files', () => {
53
+ assert.match(String(pkg.scripts?.test || ''), /tests\/\*\.test\.mjs/, 'test script must target tests/*.test.mjs')
54
+ })
55
+
56
+ test('check-all.mjs skips a missing tests/ directory instead of crashing', () => {
57
+ const root = fakeInstall({ dirs: { 'lib/probe.js': 'export const ok = 1\n' } })
58
+ try {
59
+ const result = runCheckAll(root)
60
+ const stdout = String(result.stdout || '')
61
+ const stderr = String(result.stderr || '')
62
+ assert.ok(!/ENOENT/.test(stderr), 'a missing tests/ directory must not surface as ENOENT: ' + stderr.slice(0, 400))
63
+ assert.ok(!/at file:\/\//.test(stderr), 'check-all.mjs must not die with a stack trace: ' + stderr.slice(0, 400))
64
+ assert.match(stdout, /skip tests\//, 'the absent directory must be reported as skipped')
65
+ assert.match(stdout, /ok {3}lib\/probe\.js/, 'syntax checking must still run for the directories that exist')
66
+ // Reaching a later stage proves the loop did not abort the whole script.
67
+ assert.match(stdout, /i18n/)
68
+ } finally {
69
+ fs.rmSync(root, { recursive: true, force: true })
70
+ }
71
+ })
72
+
73
+ test('check-all.mjs still checks lib/ and scripts/ when tests/ is present', () => {
74
+ const root = fakeInstall({ dirs: { 'lib/probe.js': 'export const ok = 1\n', 'tests/probe.test.mjs': '// x\n' } })
75
+ try {
76
+ const result = runCheckAll(root)
77
+ const stdout = String(result.stdout || '')
78
+ assert.match(stdout, /ok {3}lib\/probe\.js/)
79
+ assert.match(stdout, /ok {3}scripts\/check-all\.mjs/)
80
+ assert.doesNotMatch(stdout, /skip tests\//)
81
+ } finally {
82
+ fs.rmSync(root, { recursive: true, force: true })
83
+ }
84
+ })
@@ -0,0 +1,212 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ // Attach tests use fake agents/fiber and never touch the real host. The temp home is
8
+ // still set so any incidental state read/write from the attached listeners stays in
9
+ // os.tmpdir(), matching the rest of the suite.
10
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-preset-'))
11
+ process.env.DSH_MEMU_HOME = home
12
+
13
+ test.after(() => {
14
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
15
+ })
16
+
17
+ import { attachPresetAgents, registerPresetAttachment, sameScopeTree } from '../lib/preset-scope.js'
18
+
19
+ // CORDIS FIBERS
20
+ // =============
21
+ // The host instance of this plugin registers its pre-step listener on the host
22
+ // composition's context. A conversation whose preset lists its own plugins lives in a
23
+ // separate fiber tree, so that listener never fires and the conversation gets no memory.
24
+ // `sameScopeTree` is the whole guard against the opposite mistake — attaching a second
25
+ // listener to a conversation the host one already covers, which would inject twice.
26
+
27
+ function fiber(parent = null) {
28
+ const node = { parent: parent ? { fiber: parent } : null }
29
+ node.parent = parent ? { fiber: parent } : { fiber: node }
30
+ return node
31
+ }
32
+
33
+ function chain(...nodes) {
34
+ for (let i = 0; i < nodes.length - 1; i += 1) nodes[i].parent = { fiber: nodes[i + 1] }
35
+ const last = nodes[nodes.length - 1]
36
+ last.parent = { fiber: last }
37
+ return nodes[0]
38
+ }
39
+
40
+ function fakeCtx({ agents = null, fiber: ownFiber = null, logger = null } = {}) {
41
+ const handlers = []
42
+ const injected = []
43
+ return {
44
+ fiber: ownFiber,
45
+ logger,
46
+ injected,
47
+ inject(...args) {
48
+ injected.push(args)
49
+ },
50
+ get(name) {
51
+ if (name === 'agents') return agents
52
+ return undefined
53
+ },
54
+ on(event, fn) {
55
+ handlers.push({ event, fn })
56
+ return () => {
57
+ const index = handlers.findIndex((entry) => entry.fn === fn)
58
+ if (index >= 0) handlers.splice(index, 1)
59
+ }
60
+ },
61
+ handlers,
62
+ }
63
+ }
64
+
65
+ function fakeAgents(list) {
66
+ return { list: () => list.slice() }
67
+ }
68
+
69
+ test('an agent inside our own fiber chain is already covered', () => {
70
+ const host = fiber()
71
+ const child = fiber(host)
72
+ assert.equal(sameScopeTree({ fiber: child }, { fiber: host }), true)
73
+ })
74
+
75
+ test('an agent in a sibling tree is not covered', () => {
76
+ const host = fiber()
77
+ const presetRoot = fiber()
78
+ assert.equal(sameScopeTree({ fiber: presetRoot }, { fiber: host }), false)
79
+ })
80
+
81
+ test('a missing fiber on either side is "unknown", never "covered"', () => {
82
+ assert.equal(sameScopeTree({}, { fiber: fiber() }), false)
83
+ assert.equal(sameScopeTree({ fiber: fiber() }, {}), false)
84
+ assert.equal(sameScopeTree(undefined, undefined), false)
85
+ })
86
+
87
+ test('a deep chain terminates and finds the host fiber', () => {
88
+ const host = fiber()
89
+ const deep = chain(fiber(), fiber(), fiber(), host)
90
+ assert.equal(sameScopeTree({ fiber: deep }, { fiber: host }), true)
91
+ })
92
+
93
+ test('only preset conversations get attached, and only once', () => {
94
+ const host = fiber()
95
+ const presetAgent = fakeCtx({ fiber: fiber() })
96
+ const normalAgent = fakeCtx({ fiber: fiber(host) })
97
+ const logs = []
98
+ const agents = fakeAgents([
99
+ { ctx: presetAgent, session: { header: { id: 'preset-1', origin: 'main' } } },
100
+ { ctx: normalAgent, session: { header: { id: 'normal-1', origin: 'main' } } },
101
+ ])
102
+ const ctx = fakeCtx({ agents, fiber: host, logger: { warn: (m) => logs.push(m) } })
103
+
104
+ assert.equal(attachPresetAgents(ctx, { log: (m) => logs.push(m) }), 1)
105
+ assert.equal(presetAgent.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
106
+ assert.equal(normalAgent.handlers.length, 0)
107
+
108
+ // Second pass over the same registry must not stack a second listener.
109
+ assert.equal(attachPresetAgents(ctx, { log: (m) => logs.push(m) }), 0)
110
+ assert.equal(presetAgent.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
111
+
112
+ assert.ok(logs.some((line) => line.includes('attached memory injection to preset conversation preset-1')))
113
+ })
114
+
115
+ test('subagents and agents without a context are left alone, with a signal', () => {
116
+ const host = fiber()
117
+ const subAgent = fakeCtx({ fiber: fiber() })
118
+ const logs = []
119
+ const agents = fakeAgents([
120
+ { ctx: subAgent, session: { header: { id: 'sub-1', origin: 'subagent' } } },
121
+ { session: { header: { id: 'no-ctx', origin: 'main' } } },
122
+ ])
123
+ const ctx = fakeCtx({ agents, fiber: host, logger: { warn: (line) => logs.push(String(line)) } })
124
+ assert.equal(attachPresetAgents(ctx, { log: (line) => logs.push(String(line)) }), 0)
125
+ assert.equal(subAgent.handlers.length, 0)
126
+ // Without this line the adaptation fails exactly like "nothing happened".
127
+ assert.ok(logs.some((line) => line.includes('agents.list() exposed no agent context')))
128
+ })
129
+
130
+ test('a conversation that goes away is detached', () => {
131
+ const host = fiber()
132
+ const presetAgent = fakeCtx({ fiber: fiber() })
133
+ const live = [{ ctx: presetAgent, session: { header: { id: 'preset-1', origin: 'main' } } }]
134
+ const ctx = fakeCtx({ agents: fakeAgents(live), fiber: host })
135
+ assert.equal(attachPresetAgents(ctx), 1)
136
+ assert.equal(presetAgent.handlers.length, 4)
137
+
138
+ live.length = 0
139
+ assert.equal(attachPresetAgents(ctx), 0)
140
+ assert.equal(presetAgent.handlers.length, 0)
141
+ })
142
+
143
+ test('a host without an agents service is not an error', () => {
144
+ const ctx = fakeCtx({ agents: null, fiber: fiber() })
145
+ assert.equal(attachPresetAgents(ctx), 0)
146
+ })
147
+
148
+ test('the attach loop runs immediately and can be stopped', () => {
149
+ const host = fiber()
150
+ const presetAgent = fakeCtx({ fiber: fiber() })
151
+ const ctx = fakeCtx({ agents: fakeAgents([{ ctx: presetAgent, session: { header: { id: 'p', origin: 'main' } } }]), fiber: host })
152
+ const stop = registerPresetAttachment(ctx, { intervalMs: 60_000 })
153
+ assert.equal(presetAgent.handlers.length, 4)
154
+ stop()
155
+ assert.equal(presetAgent.handlers.length, 0)
156
+ })
157
+
158
+ // agents.list() is documented in two shapes on the host: the registry record and a
159
+ // bare agent. A bare entry may still have the model-facing context on the record that
160
+ // agents.get() returns. All three shapes must attach to the same agent-owned context.
161
+ test('records, bare agents and agents.get() fallbacks all provide the attach context', () => {
162
+ const host = fiber()
163
+ const recordCtx = fakeCtx({ fiber: fiber() })
164
+ const bareCtx = fakeCtx({ fiber: fiber() })
165
+ const fallbackCtx = fakeCtx({ fiber: fiber() })
166
+ const live = [
167
+ { ctx: recordCtx, session: { header: { id: 'record-1', origin: 'main' } } },
168
+ { ctx: bareCtx, session: { header: { id: 'bare-1', origin: 'main' } } },
169
+ { session: { header: { id: 'fallback-1', origin: 'main' } } },
170
+ ]
171
+ const agents = {
172
+ list: () => live.slice(),
173
+ get: (id) => (id === 'fallback-1' ? { ctx: fallbackCtx } : undefined),
174
+ }
175
+ const ctx = fakeCtx({ agents, fiber: host })
176
+ assert.equal(attachPresetAgents(ctx), 3)
177
+ for (const agentCtx of [recordCtx, bareCtx, fallbackCtx]) {
178
+ assert.equal(agentCtx.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
179
+ assert.deepEqual(agentCtx.injected, [], 'a preset gets the message path, not a system prompt section')
180
+ }
181
+ })
182
+
183
+ // The plugin must not pretend that attaching an injection listener mounts tools. The
184
+ // tool surface is owned by the preset bundle: an explicitly listed preset has memu_*
185
+ // tools, an unlisted one has none. Both get the listener (the guide text tells the
186
+ // unlisted model to ignore the rules), and neither tool list is edited here.
187
+ test('the tool surface follows the preset bundle, while injection attaches to both', () => {
188
+ const host = fiber()
189
+ const listedCtx = fakeCtx({ fiber: fiber() })
190
+ const unlistedCtx = fakeCtx({ fiber: fiber() })
191
+ const listedTools = ['memu_private_recall', 'memu_private_remember', 'memu_status']
192
+ const unlistedTools = ['bash', 'read_file']
193
+ const live = [
194
+ { ctx: listedCtx, session: { header: { id: 'listed-1', origin: 'main' } }, toolNames: listedTools.slice() },
195
+ { ctx: unlistedCtx, session: { header: { id: 'unlisted-1', origin: 'main' } }, toolNames: unlistedTools.slice() },
196
+ ]
197
+ const ctx = fakeCtx({ agents: fakeAgents(live), fiber: host })
198
+ assert.equal(attachPresetAgents(ctx), 2)
199
+ assert.equal(listedCtx.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
200
+ assert.equal(unlistedCtx.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
201
+ assert.deepEqual(live[0].toolNames, listedTools, 'listing the plugin is what makes memu_* tools exist')
202
+ assert.deepEqual(live[1].toolNames, unlistedTools, 'injection must not fabricate memu_* tools')
203
+ assert.equal(live[1].toolNames.some((name) => name.startsWith('memu_')), false)
204
+ })
205
+
206
+ test('a throwing agents service is contained', () => {
207
+ const ctx = fakeCtx({
208
+ agents: { list() { throw new Error('registry unavailable') } },
209
+ fiber: fiber(),
210
+ })
211
+ assert.equal(attachPresetAgents(ctx), 0)
212
+ })