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,125 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-storage-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+
12
+ const indexDir = path.join(home, 'index')
13
+ const stateFile = path.join(home, 'state.json')
14
+
15
+ test.after(() => {
16
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
17
+ })
18
+
19
+ function quarantinedOf(base) {
20
+ try {
21
+ return fs.readdirSync(indexDir).filter((name) => name.startsWith(`${base}.corrupt-`))
22
+ } catch {
23
+ return []
24
+ }
25
+ }
26
+
27
+ test('a missing index is not a failure and does not block the first write', () => {
28
+ const libId = 'lib_missing'
29
+ assert.deepEqual(state.loadPrivateIndex(libId).entries, {})
30
+ state.savePrivateIndex(libId, { version: 1, entries: { 'mem-1': { id: 'mem-1', content: 'x' } } })
31
+ assert.equal(state.loadPrivateIndex(libId).entries['mem-1'].content, 'x')
32
+ assert.deepEqual(quarantinedOf(`${libId}.json`), [])
33
+ assert.equal(state.storageHealth().issues.length, 0)
34
+ })
35
+
36
+ test('a corrupt index keeps its raw bytes and is not written over', () => {
37
+ const libId = 'lib_corrupt'
38
+ const file = path.join(indexDir, `${libId}.json`)
39
+ const broken = '{ "entries": { "mem-9": { "content": "别丢" '
40
+ fs.writeFileSync(file, broken, 'utf8')
41
+
42
+ assert.deepEqual(state.loadPrivateIndex(libId).entries, {})
43
+
44
+ const kept = quarantinedOf(`${libId}.json`)
45
+ assert.equal(kept.length, 1)
46
+ assert.equal(fs.readFileSync(path.join(indexDir, kept[0]), 'utf8'), broken)
47
+ assert.equal(fs.existsSync(file), false)
48
+
49
+ assert.throws(() => state.savePrivateIndex(libId, { entries: {} }), /earlier read failed/)
50
+ // Recovery is explicit: restore a valid file, then the successful read lifts the barrier.
51
+ fs.writeFileSync(file, JSON.stringify({ entries: { 'mem-recovered': { content: 'restored' } } }))
52
+ assert.equal(state.loadPrivateIndex(libId).entries['mem-recovered'].content, 'restored')
53
+ state.savePrivateIndex(libId, { version: 1, entries: { 'mem-2': { id: 'mem-2' } } })
54
+ assert.equal(state.loadPrivateIndex(libId).entries['mem-2'].id, 'mem-2')
55
+ assert.equal(fs.readFileSync(path.join(indexDir, kept[0]), 'utf8'), broken)
56
+ })
57
+
58
+ test('an unreadable index blocks writes until it reads cleanly again', () => {
59
+ const libId = 'lib_busy'
60
+ const file = path.join(indexDir, `${libId}.json`)
61
+ // A directory where a file is expected makes readFileSync fail with EISDIR —
62
+ // the same class as EBUSY/EMFILE: the bytes are fine, we just cannot see them.
63
+ fs.mkdirSync(file, { recursive: true })
64
+
65
+ assert.deepEqual(state.loadPrivateIndex(libId).entries, {})
66
+ assert.deepEqual(quarantinedOf(`${libId}.json`), [], 'an I/O failure must not move the file aside')
67
+ assert.equal(fs.existsSync(file), true)
68
+
69
+ assert.throws(
70
+ () => state.savePrivateIndex(libId, { version: 1, entries: {} }),
71
+ (error) => error.code === 'dsh-memu-memory/read-failed',
72
+ )
73
+ assert.ok(state.storageHealth().issues.some((issue) => issue.file.includes(libId)))
74
+
75
+ // Obstruction gone -> the next successful read lifts the barrier.
76
+ fs.rmdirSync(file)
77
+ state.loadPrivateIndex(libId)
78
+ state.savePrivateIndex(libId, { version: 1, entries: { 'mem-3': { id: 'mem-3' } } })
79
+ assert.equal(state.loadPrivateIndex(libId).entries['mem-3'].id, 'mem-3')
80
+ })
81
+
82
+ test('a corrupt state.json is quarantined instead of silently emptying the plugin', () => {
83
+ state.setWorkspacePolicy(path.join(home, 'proj'), { useLibrary: false })
84
+ assert.ok(Object.keys(state.loadState().workspacePolicies).length > 0)
85
+ // Flush first: with local changes pending, the cache is authoritative by design
86
+ // and an external rewrite would just be overwritten by our own flush.
87
+ state.flushState()
88
+
89
+ const broken = '{ "version": 1, "libraries": { '
90
+ fs.writeFileSync(stateFile, broken, 'utf8')
91
+
92
+ const recovered = state.loadState()
93
+ assert.deepEqual(recovered.libraries, {})
94
+ const kept = fs.readdirSync(home).filter((name) => name.startsWith('state.json.corrupt-'))
95
+ assert.equal(kept.length, 1)
96
+ assert.equal(fs.readFileSync(path.join(home, kept[0]), 'utf8'), broken)
97
+
98
+ state.setWorkspacePolicy(path.join(home, 'proj'), { useGlobal: false })
99
+ assert.equal(state.getWorkspacePolicy(path.join(home, 'proj')).useGlobal, false)
100
+ assert.throws(() => state.flushState(), /earlier read failed/)
101
+ assert.equal(fs.readFileSync(path.join(home, kept[0]), 'utf8'), broken)
102
+ fs.writeFileSync(stateFile, JSON.stringify({ version: 1, libraries: {}, sessions: {}, workspacePolicies: {} }))
103
+ state.flushState()
104
+ })
105
+
106
+ test('an unreadable state.json blocks writes rather than replacing it with defaults', () => {
107
+ state.flushState()
108
+ const saved = fs.readFileSync(stateFile, 'utf8')
109
+ fs.rmSync(stateFile)
110
+ fs.mkdirSync(stateFile)
111
+
112
+ assert.deepEqual(state.loadState().libraries, {})
113
+ assert.equal(state.storageHealth().issues[0].file, 'state.json')
114
+
115
+ // Bookkeeping writes are deferred now, so the refusal surfaces on flush.
116
+ state.setWorkspacePolicy(path.join(home, 'other'), { useLibrary: false })
117
+ assert.throws(() => state.flushState(), (error) => error.code === 'dsh-memu-memory/read-failed')
118
+ assert.ok(fs.statSync(stateFile).isDirectory(), 'the unreadable path was left untouched')
119
+
120
+ fs.rmdirSync(stateFile)
121
+ fs.writeFileSync(stateFile, saved, 'utf8')
122
+ assert.equal(state.loadState().workspacePolicies != null, true)
123
+ state.flushState()
124
+ assert.equal(state.storageHealth().issues.length, 0)
125
+ })
@@ -0,0 +1,5 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ export function createUserMessage(input) {
4
+ return { id: crypto.randomUUID(), role: 'user', ...input }
5
+ }
@@ -0,0 +1,7 @@
1
+ // The host exports buildForkSeed from @deepseek-ai/dsh-session. Consolidation
2
+ // forks a seeded copy of the transcript, so the fork builder is on the critical
3
+ // path of every agent-backend consolidation — without a stub here those tests
4
+ // could only ever exercise the "fork builder unavailable" failure branch.
5
+ export function buildForkSeed(events, lastSeq) {
6
+ return { seeded: true, events, lastSeq }
7
+ }
@@ -0,0 +1,101 @@
1
+ // A faithful stand-in for the host's `defineTool`: it compiles tool schemas with
2
+ // the same value-schema DSL rules as @deepseek-ai/dsh-tools, so a schema the real
3
+ // host rejects fails here too. The previous stub was `return spec`, which meant
4
+ // 320 green tests could not see that the host refused every one of the ten tools.
5
+ //
6
+ // Rules mirrored from packages/core/tools/src/schema.ts (host):
7
+ // - annotations are description/title/default/examples;
8
+ // - an object node needs an explicit boolean `additionalProperties`;
9
+ // - an array node accepts only type/items (annotations allowed), and its items are
10
+ // compiled WITHOUT `required` — nested required must be inline `required: true`;
11
+ // - `required` on a property must be the literal `true`;
12
+ // - any other key is rejected.
13
+
14
+ const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples']
15
+
16
+ function assertAuthorKeys(source, path, allowed) {
17
+ for (const key of Object.keys(source)) {
18
+ if (!allowed.includes(key)) {
19
+ throw new Error(`${path}.${key} is not supported by the value schema DSL`)
20
+ }
21
+ }
22
+ }
23
+
24
+ function compileValueSchema(source, path, { allowRequired = false } = {}) {
25
+ if (source === null || typeof source !== 'object' || Array.isArray(source)) {
26
+ throw new Error(`${path} must be a value schema object`)
27
+ }
28
+ const type = source.type
29
+ switch (type) {
30
+ case 'object': {
31
+ assertAuthorKeys(source, path, [
32
+ ...ANNOTATION_KEYS,
33
+ ...(allowRequired ? ['required'] : []),
34
+ 'type', 'properties', 'additionalProperties',
35
+ ])
36
+ if (allowRequired && Object.hasOwn(source, 'required') && source.required !== true) {
37
+ throw new Error(`${path}.required must be true when present`)
38
+ }
39
+ if (typeof source.additionalProperties !== 'boolean') {
40
+ throw new Error(`${path}.additionalProperties must be explicitly true or false`)
41
+ }
42
+ const properties = source.properties ?? {}
43
+ if (properties === null || typeof properties !== 'object' || Array.isArray(properties)) {
44
+ throw new Error(`${path}.properties must be a property map`)
45
+ }
46
+ for (const [key, value] of Object.entries(properties)) {
47
+ compileValueSchema(value, `${path}.properties.${key}`, { allowRequired: true })
48
+ }
49
+ return { type: 'object', additionalProperties: source.additionalProperties, properties }
50
+ }
51
+ case 'array': {
52
+ assertAuthorKeys(source, path, [...ANNOTATION_KEYS, 'type', 'items'])
53
+ if (source.items === undefined) throw new Error(`${path}.items must be a value schema`)
54
+ compileValueSchema(source.items, `${path}.items`, { allowRequired: false })
55
+ return { type: 'array', items: source.items }
56
+ }
57
+ case 'string':
58
+ case 'number':
59
+ case 'integer':
60
+ case 'boolean':
61
+ case 'null': {
62
+ assertAuthorKeys(source, path, [
63
+ ...ANNOTATION_KEYS,
64
+ ...(allowRequired ? ['required'] : []),
65
+ 'type', 'enum', 'const',
66
+ ])
67
+ if (allowRequired && Object.hasOwn(source, 'required') && source.required !== true) {
68
+ throw new Error(`${path}.required must be true when present`)
69
+ }
70
+ return { type }
71
+ }
72
+ default:
73
+ throw new Error(`${path}.type ${JSON.stringify(type)} is not supported by the value schema DSL`)
74
+ }
75
+ }
76
+
77
+ function compileParameterSchema(parameters, path = 'parameters') {
78
+ if (parameters === null || typeof parameters !== 'object' || Array.isArray(parameters)) {
79
+ throw new Error(`${path} must be a property map`)
80
+ }
81
+ // The host compiles the implicit open parameter object: extra keys are allowed.
82
+ for (const [key, value] of Object.entries(parameters)) {
83
+ compileValueSchema(value, `${path}.${key}`, { allowRequired: true })
84
+ }
85
+ return parameters
86
+ }
87
+
88
+ export function defineTool(options) {
89
+ if (options === null || typeof options !== 'object') throw new Error('defineTool requires an options object')
90
+ if (typeof options.name !== 'string' || !options.name) throw new Error('defineTool requires a non-empty name')
91
+ if (typeof options.description !== 'string') throw new Error(`${options.name}: defineTool requires a description`)
92
+ if (typeof options.timeoutMs !== 'number' || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
93
+ throw new Error(`${options.name}: timeoutMs must be a positive finite number`)
94
+ }
95
+ if (typeof options.execute !== 'function') throw new Error(`${options.name}: defineTool requires an execute function`)
96
+ compileParameterSchema(options.parameters)
97
+ const outputSchema = options.output?.schema
98
+ if (outputSchema === undefined) throw new Error(`${options.name}: defineTool requires output.schema`)
99
+ compileValueSchema(outputSchema, `${options.name}.output.schema`, { allowRequired: false })
100
+ return options
101
+ }
@@ -0,0 +1,73 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { defineTool } from './stubs/dsh-tools.js'
5
+
6
+ // The stub compiles tool schemas with the host's value-schema DSL rules. These
7
+ // cases pin the two shapes that the real host refused in production — the reason
8
+ // all ten memu tools were missing from the tool surface while every test passed:
9
+ // the old stub was `return spec`, so nothing was ever compiled.
10
+
11
+ const good = (parameters) => ({
12
+ name: 'probe',
13
+ description: 'probe',
14
+ parameters,
15
+ output: { schema: { type: 'string' } },
16
+ timeoutMs: 1000,
17
+ execute: async () => 'ok',
18
+ })
19
+
20
+ test('an object node without an explicit additionalProperties is refused', () => {
21
+ assert.throws(
22
+ () => defineTool(good({
23
+ entity: { type: 'object', properties: { name: { type: 'string' } } },
24
+ })),
25
+ /parameters\.entity\.additionalProperties must be explicitly true or false/,
26
+ )
27
+ })
28
+
29
+ test('array-form required inside array items is refused', () => {
30
+ assert.throws(
31
+ () => defineTool(good({
32
+ attachments: {
33
+ type: 'array',
34
+ items: { type: 'object', properties: { sha256: { type: 'string' } }, required: ['sha256'], additionalProperties: true },
35
+ },
36
+ })),
37
+ /parameters\.attachments\.items\.required is not supported by the value schema DSL/,
38
+ )
39
+ })
40
+
41
+ test('nested required must be the literal true', () => {
42
+ assert.throws(
43
+ () => defineTool(good({
44
+ attachments: {
45
+ type: 'array',
46
+ items: { type: 'object', additionalProperties: true, properties: { sha256: { type: 'string', required: false } } },
47
+ },
48
+ })),
49
+ /required must be true when present/,
50
+ )
51
+ const tool = defineTool(good({
52
+ attachments: {
53
+ type: 'array',
54
+ items: { type: 'object', additionalProperties: true, properties: { sha256: { type: 'string', required: true } } },
55
+ },
56
+ }))
57
+ assert.equal(tool.name, 'probe')
58
+ })
59
+
60
+ test('a non-positive timeout is refused', () => {
61
+ assert.throws(() => defineTool({ ...good({}), timeoutMs: 0 }), /timeoutMs/)
62
+ })
63
+
64
+ test('the shipped tool surface compiles under the host rules', async () => {
65
+ const { registerTools } = await import('../lib/tools.js')
66
+ const registered = []
67
+ const dispose = registerTools({
68
+ tools: { register: (tool) => { registered.push(tool.name); return () => {} } },
69
+ logger: { warn: (message) => { throw new Error(`unexpected registration warning: ${message}`) } },
70
+ })
71
+ assert.equal(registered.length, 10, `registered ${registered.join(', ')}`)
72
+ dispose()
73
+ })
@@ -0,0 +1,184 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-tools-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const { registerTools } = await import('../lib/tools.js')
11
+ const state = await import('../lib/state.js')
12
+ const { updateSettings } = state
13
+
14
+ test.after(() => {
15
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
16
+ })
17
+
18
+ function fakeCtx() {
19
+ const tools = []
20
+ const ctx = {
21
+ tools: {
22
+ register(tool) {
23
+ tools.push(tool)
24
+ return () => {}
25
+ },
26
+ },
27
+ logger: { warn() {} },
28
+ }
29
+ const dispose = registerTools(ctx)
30
+ return { ctx, tools, dispose }
31
+ }
32
+
33
+ const exec = {
34
+ agent: { session: { header: { id: 'tools-session', cwd: path.join(home, 'project-tools'), agentPreset: 'coding' } } },
35
+ }
36
+
37
+ test('all v1 tools register', () => {
38
+ const { tools, dispose } = fakeCtx()
39
+ const names = tools.map((t) => t.name)
40
+ for (const name of [
41
+ 'memu_private_link',
42
+ 'memu_private_remember',
43
+ 'memu_private_recall',
44
+ 'memu_private_list',
45
+ 'memu_private_update',
46
+ 'memu_private_archive',
47
+ 'memu_private_unpin',
48
+ 'memu_global_recall',
49
+ 'memu_global_list',
50
+ 'memu_status',
51
+ ]) {
52
+ assert.ok(names.includes(name), `missing ${name}`)
53
+ }
54
+ // Exactly ten, not "at least these nine": a schema the host DSL refuses used to
55
+ // abort registerTools() before any register() ran, so counting is the assertion
56
+ // that actually catches a total registration failure.
57
+ assert.equal(names.length, 10, `expected 10 tools, got ${names.length}: ${names.join(', ')}`)
58
+ dispose()
59
+ })
60
+
61
+ test('status works without an engine', async () => {
62
+ const { tools, dispose } = fakeCtx()
63
+ const status = await tools.find((t) => t.name === 'memu_status').execute({}, exec)
64
+ assert.match(status, /engine:/)
65
+ assert.match(status, /library:/)
66
+ // The registration count is the visible signal for a refused schema: without it
67
+ // a host that rejects the tool and a plugin that never had it look identical.
68
+ assert.match(status, /tools: 10\/10 registered/)
69
+ dispose()
70
+ })
71
+
72
+ test('private list stays usable without an active vector profile and says why', async () => {
73
+ const { tools, dispose } = fakeCtx()
74
+ updateSettings({ memoryBackend: 'api' })
75
+ try {
76
+ // The conversation must actually be bound: an unbound chat gets the
77
+ // "no library" answer instead, and never creates one just by listing.
78
+ state.ensureSession('tools-session', { cwd: path.join(home, 'project-tools') })
79
+ const text = await tools.find((t) => t.name === 'memu_private_list').execute({}, exec)
80
+ // No active generation means MemU cannot answer; the index still can. "Empty"
81
+ // on its own reads as lost memories, so the reason travels with the result.
82
+ assert.match(text, /私有记忆库 .* 为空。/)
83
+ assert.match(text, /本地向量未激活/)
84
+ } finally {
85
+ updateSettings({ memoryBackend: 'agent' })
86
+ dispose()
87
+ }
88
+ })
89
+
90
+ test('private list on an unbound conversation neither creates a library nor errors', async () => {
91
+ const { tools, dispose } = fakeCtx()
92
+ const before = state.listLibraries().length
93
+ const text = await tools.find((t) => t.name === 'memu_private_list').execute({}, {
94
+ agent: { session: { header: { id: 'tools-unbound', cwd: path.join(home, 'project-unbound'), agentPreset: 'coding' } } },
95
+ })
96
+ assert.match(text, /没有绑定记忆库/)
97
+ assert.equal(state.listLibraries().length, before, 'listing must not materialize a library')
98
+ dispose()
99
+ })
100
+
101
+ test('private list rows carry the same date label recall prints', async () => {
102
+ const { tools, dispose } = fakeCtx()
103
+ const cwd = path.join(home, 'project-dates')
104
+ const execDates = { agent: { session: { header: { id: 'tools-list-dates', cwd, agentPreset: 'coding' } } } }
105
+ state.ensureSession('tools-list-dates', { cwd })
106
+ const remember = tools.find((t) => t.name === 'memu_private_remember')
107
+ // Two forms of the same field: an appointment keeps its year, a birthday keeps MM-DD.
108
+ await remember.execute({ content: '明天要去考驾照。', title: '明天要考驾照', salient_date: '2026-10-29' }, execDates)
109
+ await remember.execute({ content: '生日是 10 月 28 日。', title: '生日', salient_date: '10-28' }, execDates)
110
+ const list = await tools.find((t) => t.name === 'memu_private_list').execute({}, execDates)
111
+ // A date only recall renders reads as "no date" in the list, so the two surfaces
112
+ // must agree on the spelling: date= for a one-off, anniversary= for a yearly one.
113
+ assert.match(list, /date=2026-10-29/)
114
+ assert.match(list, /anniversary=10-28/)
115
+ dispose()
116
+ })
117
+
118
+ test('must_remember requires a pin reason', async () => {
119
+ const { tools, dispose } = fakeCtx()
120
+ const text = await tools.find((t) => t.name === 'memu_private_remember').execute({
121
+ content: 'something worth keeping',
122
+ layer: 'must_remember',
123
+ }, exec)
124
+ assert.match(text, /pin_reason is required/)
125
+ dispose()
126
+ })
127
+
128
+ test('status separates "nobody polled" from "nothing was written"', async () => {
129
+ const { tools, dispose } = fakeCtx()
130
+ const { dispatch } = await import('../lib/rpc.js')
131
+ // A pane polling is what records a beat; a save is what records a write event.
132
+ await dispatch({}, 'getSidebarState', { revisionOnly: true })
133
+ state.savePrivateIndex('lib-sync', { version: 1, entries: {} })
134
+ const unbound = { agent: { session: { header: { id: 'tools-sync', cwd: path.join(home, 'project-sync') } } } }
135
+ const text = await tools.find((t) => t.name === 'memu_status').execute({}, unbound)
136
+ assert.match(text, /sync: heartbeat \d+s ago, last write private@lib-sync \d+s ago/)
137
+ dispose()
138
+ })
139
+
140
+ test('unpin is the model\'s call, guarded by reason, quota and cooldown', async () => {
141
+ const { tools, dispose } = fakeCtx()
142
+ const binding = state.ensureSession('tools-session', { cwd: path.join(home, 'project-tools'), agentPreset: 'coding' })
143
+ const index = state.loadPrivateIndex(binding.libraryId)
144
+ for (const id of ['mem-unpin', 'mem-unpin-2', 'mem-unpin-3']) {
145
+ index.entries[id] = {
146
+ track: 'memory',
147
+ layer: 'must_remember',
148
+ title: `A pinned memory ${id}`,
149
+ content: 'Something that used to matter.',
150
+ status: 'active',
151
+ pinSource: 'user_explicit',
152
+ pinReason: 'keep this',
153
+ createdAt: new Date().toISOString(),
154
+ updatedAt: new Date().toISOString(),
155
+ }
156
+ }
157
+ state.savePrivateIndex(binding.libraryId, index)
158
+
159
+ const exec = { agent: { session: { header: { id: 'tools-session' } } } }
160
+ const unpin = (id, reason) => tools.find((t) => t.name === 'memu_private_unpin').execute({ id, reason }, exec)
161
+
162
+ const tooShort = await unpin('mem-unpin', 'let it go')
163
+ assert.match(tooShort, /at least 20 characters/)
164
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries['mem-unpin'].layer, 'must_remember')
165
+
166
+ const accepted = await unpin('mem-unpin', '那件事已经过去了,我现在能把它当成一件旧事来讲,而不是每次想起都疼。')
167
+ assert.match(accepted, /long_term|marginalized|边缘化/)
168
+ const after = state.loadPrivateIndex(binding.libraryId).entries['mem-unpin']
169
+ assert.equal(after.layer, 'long_term')
170
+ assert.equal(after.marginalized, true)
171
+ assert.equal(after.unpinnedBy, 'model')
172
+ assert.ok(after.originalPinSource !== undefined)
173
+
174
+ // Cooldown: a second unpin right after is refused even though quota remains.
175
+ const cooldown = await unpin('mem-unpin-2', '这次也一样,我已经能平静地说完了,不再需要一直记着。')
176
+ assert.match(cooldown, /cooldown/)
177
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries['mem-unpin-2'].layer, 'must_remember')
178
+
179
+ // Quota: once the conversation has spent it, memory stays pinned.
180
+ state.setSessionBinding('tools-session', { lastUnpinAt: null, unpinCount: 2 })
181
+ const quota = await unpin('mem-unpin-3', '我已经想清楚了,这件事可以放下来,不必再一直提醒自己。')
182
+ assert.match(quota, /quota/)
183
+ dispose()
184
+ })
@@ -0,0 +1,57 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ // L3 audit item: the settings page in lib/client.js renders a hard-coded
8
+ // fallback for every numeric field (`settings.x ?? N`). The browser bundle
9
+ // cannot import lib/state.js, so those literals are the only client-side copy
10
+ // of DEFAULT_SETTINGS. They had drifted (privateTopK 4 vs 5, globalTopK 3 vs 5,
11
+ // dedupeTurns 8 vs 16, ...), and because the fallback paints before the first
12
+ // getState reply, saving in that window persisted the wrong value.
13
+ // This gate keeps the two lists equal.
14
+
15
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
16
+ const REPO = path.resolve(HERE, '..')
17
+ const state = await import(new URL('../lib/state.js', import.meta.url).href)
18
+ const clientSource = fs.readFileSync(path.join(REPO, 'lib', 'client.js'), 'utf8')
19
+
20
+ function clientFallbacks() {
21
+ const found = new Map()
22
+ for (const match of clientSource.matchAll(/settings\.([A-Za-z0-9_]+)\s*\?\?\s*(-?\d+(?:\.\d+)?)/g)) {
23
+ const key = match[1]
24
+ const value = Number(match[2])
25
+ assert.ok(!found.has(key), 'settings.' + key + ' has more than one fallback literal in lib/client.js')
26
+ found.set(key, value)
27
+ }
28
+ return found
29
+ }
30
+
31
+ test('every client-side settings fallback equals its DEFAULT_SETTINGS value', () => {
32
+ const fallbacks = clientFallbacks()
33
+ assert.ok(fallbacks.size >= 10, 'expected the settings grid fallbacks to be discoverable, found ' + fallbacks.size)
34
+ const drift = []
35
+ for (const [key, value] of fallbacks) {
36
+ const expected = state.DEFAULT_SETTINGS[key]
37
+ if (typeof expected !== 'number') continue
38
+ if (expected !== value) drift.push('settings.' + key + ' ?? ' + value + ' but DEFAULT_SETTINGS.' + key + ' is ' + expected)
39
+ }
40
+ assert.deepEqual(drift, [], 'client fallbacks drifted from DEFAULT_SETTINGS: ' + drift.join('; '))
41
+ })
42
+
43
+ test('the audited numeric fields are covered by that gate', () => {
44
+ const fallbacks = clientFallbacks()
45
+ for (const [key, expected] of [
46
+ ['privateTopK', 5],
47
+ ['globalTopK', 5],
48
+ ['mustRememberCap', 12],
49
+ ['dedupeTurns', 16],
50
+ ['globalEveryNTurns', 6],
51
+ ['randomCueChance', 0.02],
52
+ ['sleepAfterIdleMinutes', 3],
53
+ ]) {
54
+ assert.equal(fallbacks.get(key), expected, 'settings.' + key + ' fallback')
55
+ assert.equal(state.DEFAULT_SETTINGS[key], expected, 'DEFAULT_SETTINGS.' + key)
56
+ }
57
+ })