dsh-memu-memory 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
@@ -0,0 +1,124 @@
1
+ // One ruler for "do these two memories say the same thing".
2
+ //
3
+ // 5: the recall side (candidate folding, MMR hard skip) and the
4
+ // write side (the duplicate gate) must measure with the same formula, or recall
5
+ // folds what write kept and vice versa. Everything here is deliberately
6
+ // dependency-free — two bigram sets and the Dice coefficient — and the text
7
+ // preprocessing is the one the contradiction check always used (lowercase, strip
8
+ // whitespace/punctuation/symbols), so findConflict's behaviour is unchanged by
9
+ // this extraction.
10
+
11
+ // Same fact: a recall cluster keeps one candidate, MMR hard-skips the rest.
12
+ export const DUP_DICE = 0.82
13
+ // Write-side reject: a new memory this close to an existing one is refused.
14
+ export const DUP_REJECT_DICE = 0.90
15
+ // Same topic, different fact: soft-penalty band on recall, duplicateOf tag on write.
16
+ export const TOPIC_DICE = 0.55
17
+ // Write-side pruning: skip the bigram pass when two texts differ in length by more
18
+ // than this fraction of the longer one. At this ratio Dice cannot reach the reject
19
+ // band even with zero unique bigrams on the short side.
20
+ export const LENGTH_PRUNE_RATIO = 0.5
21
+
22
+ // Length-disparate pairs (gap > LENGTH_PRUNE_RATIO) do not go through Dice — the
23
+ // ratio math makes a reject-band Dice impossible, which is exactly the hole a
24
+ // "rewrite it much longer" evader walks through. Two cheap containment probes
25
+ // close the hole without paying the full bigram pass:
26
+ // * verbatim containment — the short cleaned text is a substring of the long one;
27
+ // * core coverage — every character bigram of the short text occurs somewhere in
28
+ // the long one ("核心词全含"), probed with String.includes so the long side is
29
+ // never materialised into a bigram set and a miss fails fast on the first
30
+ // absent gram.
31
+ // Both are strictly stronger signals than Dice ≥ DUP_REJECT_DICE (a verbatim or
32
+ // all-grams-contained overlap cannot be an accident), so they report a floor in
33
+ // the reject band instead of a made-up Dice value. Partial coverage deliberately
34
+ // reports 0: with a >0.5 length gap there is no honest way to say "same topic",
35
+ // and a false reject on the write side is worse than a rare paraphrase bypass —
36
+ // the residual risk (heavily reworded padding) is documented in
37
+ export const CONTAINMENT_FLOOR = 0.92
38
+ export const CORE_COVER_FLOOR = DUP_REJECT_DICE
39
+ // Below this many cleaned characters the probe has too little signal (a 4-char
40
+ // string and its bigrams occur in unrelated texts all the time); above the max the
41
+ // probe budget is exceeded and both sides are substantial enough to trust the
42
+ // length prune again. Bounds keep the write-side gate O(|short|·|long|) native
43
+ // scans per pruned pair instead of O(N²) bigram-set construction.
44
+ const PROBE_MIN_CHARS = 6
45
+ const PROBE_MAX_CHARS = 240
46
+
47
+ /** The one text preprocessing every measurement here shares. */
48
+ export function clean(text) {
49
+ return String(text || '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
50
+ }
51
+
52
+ /** Lowercase, strip whitespace/punctuation/symbols, then collect character bigrams. */
53
+ export function bigrams(text) {
54
+ const c = clean(text)
55
+ const out = new Set()
56
+ for (let i = 0; i < c.length - 1; i += 1) out.add(c.slice(i, i + 2))
57
+ return out
58
+ }
59
+
60
+ /** Dice coefficient over two bigram sets; 0 when either side is empty. */
61
+ export function dice(a, b) {
62
+ if (a.size === 0 || b.size === 0) return 0
63
+ let shared = 0
64
+ for (const gram of a) if (b.has(gram)) shared += 1
65
+ return (2 * shared) / (a.size + b.size)
66
+ }
67
+
68
+ /** |len(A)-len(B)| / max(len); 0 when both sides are empty. */
69
+ export function lengthGap(aText, bText) {
70
+ const a = String(aText || '').length
71
+ const b = String(bText || '').length
72
+ return Math.abs(a - b) / Math.max(a, b, 1)
73
+ }
74
+
75
+ /**
76
+ * The full probe result behind similarTexts. `dice` is the plain bigram Dice
77
+ * when the pair is length-comparable; when the length prune fires, `dice` is 0
78
+ * and `containment` carries whichever probe (verbatim CONTAINMENT_FLOOR or
79
+ * core-coverage CORE_COVER_FLOOR) closed the hole, else 0. `lengthGap` is the
80
+ * raw length ratio the prune decision was made on.
81
+ *
82
+ * similarTexts stays `max(dice, containment)` — call sites that need to know
83
+ * *why* two texts matched read the fields instead of reverse-engineering the
84
+ * max (the curator pair features keep "verbatim containment" apart from "high
85
+ * Dice" so the §4.8 conflict carve-outs can tell duplication from rewriting).
86
+ */
87
+ export function similarityDetail(aText, bText) {
88
+ const a = String(aText || '')
89
+ const b = String(bText || '')
90
+ const gap = lengthGap(a, b)
91
+ if (gap > LENGTH_PRUNE_RATIO) {
92
+ const aClean = clean(a)
93
+ const bClean = clean(b)
94
+ if (!aClean || !bClean) return { dice: 0, containment: 0, lengthGap: gap }
95
+ const shortClean = aClean.length <= bClean.length ? aClean : bClean
96
+ const longClean = aClean.length <= bClean.length ? bClean : aClean
97
+ if (shortClean.length < PROBE_MIN_CHARS || shortClean.length > PROBE_MAX_CHARS) {
98
+ return { dice: 0, containment: 0, lengthGap: gap }
99
+ }
100
+ // Probe 1: verbatim containment of the short text inside the long one.
101
+ if (longClean.includes(shortClean)) return { dice: 0, containment: CONTAINMENT_FLOOR, lengthGap: gap }
102
+ // Probe 2: core coverage — every bigram of the short text occurs in the long
103
+ // one; includes() over the cleaned long text is exactly long-bigram-set
104
+ // membership, so no long-side set is ever built.
105
+ for (let i = 0; i < shortClean.length - 1; i += 1) {
106
+ if (!longClean.includes(shortClean.slice(i, i + 2))) return { dice: 0, containment: 0, lengthGap: gap }
107
+ }
108
+ return { dice: 0, containment: CORE_COVER_FLOOR, lengthGap: gap }
109
+ }
110
+ return { dice: dice(bigrams(a), bigrams(b)), containment: 0, lengthGap: gap }
111
+ }
112
+
113
+ /**
114
+ * Dice over raw texts with the write-side length pruning applied: pairs whose
115
+ * lengths differ too much return 0 without paying for the bigram build (the
116
+ * gate runs over the whole library on every write). 0 can never win a
117
+ * max-similarity scan, so pruned pairs simply never become the best match —
118
+ * unless they hit one of the two containment probes below, which are the only
119
+ * way a length-disparate pair reaches the reject band.
120
+ */
121
+ export function similarTexts(aText, bText) {
122
+ const detail = similarityDetail(aText, bText)
123
+ return Math.max(detail.dice, detail.containment)
124
+ }
package/lib/tools.js CHANGED
@@ -7,7 +7,9 @@ import {
7
7
  import { engineStatus, ENGINE_VERSION } from './engine.js'
8
8
  import { centeredSnippet } from './snippet.js'
9
9
  import { redactSecrets } from './paths.js'
10
- import { linkEntries, createEntity, updateRelationship } from './graph.js'
10
+ import { linkEntries, createEntity, listEntities, updateRelationship } from './graph.js'
11
+ import { normalizeEntityName } from './private-links.js'
12
+ import { autoLinkPrivateMemories } from './private-links.js'
11
13
  import { getSettings, listLibraries, getLibrary, loadPrivateIndex, loadGlobalIndex, storageHealth, syncActivity } from './state.js'
12
14
 
13
15
  function text(value) {
@@ -33,6 +35,19 @@ function errorText(error) {
33
35
  return `dsh-memu-memory error: ${message}`
34
36
  }
35
37
 
38
+ // 词典核心(lib/lexicon.js)按需加载:工具注册面不能被一个
39
+ // 尚未落地的模块卡死;集成落地后这里可以改回静态 import。
40
+ let lexiconModulePromise = null
41
+ function lexiconApi() {
42
+ if (!lexiconModulePromise) {
43
+ lexiconModulePromise = import('./lexicon.js').catch((error) => {
44
+ lexiconModulePromise = null
45
+ throw error
46
+ })
47
+ }
48
+ return lexiconModulePromise
49
+ }
50
+
36
51
  // Registration is per-tool fail-open. defineTool compiles the schema the moment it
37
52
  // is called, and the first throw used to abort registerTools() before a single
38
53
  // register() ran — so one schema the host DSL rejects silently removed all ten
@@ -162,19 +177,23 @@ function formatGlobalItem(item, index, options = {}) {
162
177
  return lines.join('\n')
163
178
  }
164
179
 
165
- export function registerTools(ctx) {
180
+ export function registerTools(ctx, { only = null } = {}) {
166
181
  const tools = []
167
- registrationReport = { attempted: 0, registered: 0, failures: [] }
182
+ // 全量注册(宿主装载)才更新全局 registrationReport:整理 worker 的按需挂载
183
+ // (private.js attachWorkerTools,only=worker 读写面)不能把 memu_status 的注册清单
184
+ // 覆盖成 4/4,否则其它会话看到的是 worker 的子集,可观测性就断了。
185
+ const reportSaved = only ? registrationReport : null
186
+ if (!only) registrationReport = { attempted: 0, registered: 0, failures: [] }
168
187
  tools.push(toolBase(
169
188
  'memu_private_link',
170
- 'Connect two existing memories or a memory and an existing entity in the bound library. kind is relation, commitment, event, person or conflict. Requires an evidence note of 8-500 characters; confidence defaults to 0.7. Cannot delete links or cross conversation visibility boundaries.',
189
+ 'Connect two existing memories or a memory and an existing entity in the bound library. kind is relation, commitment, event, person or conflict. Requires an evidence note of 8-500 characters; confidence defaults to 0.7. This is also the adjudication tool for memu_private_link_suggest candidates: link a pair only when the note cites concrete evidence of the same entity or event, and pass entity {name, kind} to create or reuse that named entity. Cannot delete links or cross conversation visibility boundaries.',
171
190
  {
172
191
  from: { type: 'string', description: 'Exact source memory id; required unless updating a relationship snapshot.' },
173
192
  to: { type: 'string', description: 'Exact target memory or entity id; required unless entity or relationship is supplied.' },
174
193
  kind: { type: 'string', description: 'relation, commitment, event, person or conflict; required when linking.' },
175
- note: { type: 'string', required: true, description: '8-500 character reason grounded in the conversation, not a guess.' },
194
+ note: { type: 'string', required: true, description: '8-500 character reason grounded in the conversation, not a guess. For a memu_private_link_suggest candidate, quote the transcript line that proves the same entity or event.' },
176
195
  confidence: { type: 'number', description: '0-1 confidence, default 0.7.' },
177
- entity: { type: 'object', properties: { name: { type: 'string' }, kind: { type: 'string' } }, additionalProperties: true, description: 'Optional named entity to create and link to instead of to. Use only an explicit identity, never infer a person from an ambiguous pronoun.' },
196
+ entity: { type: 'object', properties: { name: { type: 'string' }, kind: { type: 'string' } }, additionalProperties: true, description: 'Optional named entity to create and link to instead of to; an entity with the same normalized name is reused (case/width-insensitive). Use only an explicit identity, never infer a person from an ambiguous pronoun.' },
178
197
  relationship: { type: 'object', properties: {
179
198
  fromEntity: { type: 'string' }, toEntity: { type: 'string' },
180
199
  evidenceIds: { type: 'array', items: { type: 'string' } },
@@ -187,17 +206,64 @@ export function registerTools(ctx) {
187
206
  const relation = args.relationship
188
207
  return JSON.stringify(updateRelationship({ exec, fromId: relation.fromEntity, toId: relation.toEntity, state: relation.state, evidenceIds: relation.evidenceIds, reason: args.note }))
189
208
  }
190
- const toId = args.entity ? createEntity({ exec, name: args.entity.name, kind: args.entity.kind || 'person' }).id : args.to
209
+ let toId = args.to
210
+ if (args.entity) {
211
+ const name = String(args.entity.name || '').trim()
212
+ const kind = args.entity.kind || 'person'
213
+ const normalized = normalizeEntityName(name)
214
+ const existing = listEntities({ exec }).items.find((item) => normalizeEntityName(item.name) === normalized
215
+ || (Array.isArray(item.aliases) && item.aliases.some((alias) => normalizeEntityName(alias) === normalized)))
216
+ if (existing) {
217
+ if (existing.kind !== kind) throw new Error(`entity "${name}" already exists as ${existing.kind}; use that kind instead of ${kind}`)
218
+ toId = existing.id
219
+ } else {
220
+ toId = createEntity({ exec, name, kind }).id
221
+ }
222
+ }
191
223
  return JSON.stringify(linkEntries({ exec, fromId: args.from, toId, kind: args.kind, note: args.note, confidence: args.confidence }))
192
224
  },
193
225
  ))
194
226
 
227
+ tools.push(toolBase(
228
+ 'memu_private_link_suggest',
229
+ 'Local algorithm pass over the memories this consolidation just wrote. It applies the high-confidence memory↔memory links and entity hubs itself and returns ONLY the uncertain candidate pairs for you to adjudicate with memu_private_link. Call it once after writing, with the ids you created or updated; it needs no extra transcript read and never returns full memory bodies.',
230
+ {
231
+ ids: {
232
+ type: 'array',
233
+ items: { type: 'string' },
234
+ // 宿主 schema DSL 不允许在数组节点上写 required(与 tags/aliases 同),
235
+ // 所以"必填"由描述 + 运行时校验表达,schema 面只能标成可选。
236
+ description: 'Required: ids created or updated in this consolidation, 1-20. Only the entries this run touched need to be listed.',
237
+ },
238
+ },
239
+ async (args, exec) => {
240
+ // Read-only for the binding itself: an unbound chat gets an answer, not a library.
241
+ const binding = peekBinding(exec)
242
+ if (!binding?.libraryId) return '当前对话没有绑定记忆库,无法计算私有自动关联候选。'
243
+ const ids = (Array.isArray(args.ids) ? args.ids : [])
244
+ .map((id) => String(id ?? '').trim())
245
+ .filter(Boolean)
246
+ .slice(0, 20)
247
+ if (!ids.length) return '没有可关联的 id:请传本轮创建/更新的记忆 id(1-20 个)。'
248
+ const result = await autoLinkPrivateMemories({
249
+ libraryId: binding.libraryId,
250
+ sessionId: sessionIdOf(exec),
251
+ ids,
252
+ })
253
+ // Compact contract: what was already linked, then the uncertain pairs. A run
254
+ // with no candidates still reports autoLinked so "nothing applied" and
255
+ // "nothing needed" stay distinguishable.
256
+ return JSON.stringify({ autoLinked: result.autoLinked || [], candidates: result.candidates || [] })
257
+ },
258
+ { isConcurrencySafe: () => false, timeoutMs: 120_000 },
259
+ ))
260
+
195
261
  tools.push(toolBase(
196
262
  'memu_private_remember',
197
263
  'Write one useful private memory in the bound library. Match the domain: engineering facts, logic and verification in coding; grounded in-character experiences in RP; neutral facts and preferences in general. Do not copy a transcript or invent evidence. layer=must_remember requires pin_reason and pin_source (user_explicit, side_event, self).',
198
264
  {
199
265
  content: { type: 'string', required: true, description: 'Self-contained content appropriate to the domain. Coding: conclusion, scope/symbol, logic/conditions and verification; preserve necessary minimal code exactly. RP: an evidenced moment and its effect on later interaction, without invented facts.' },
200
- title: { type: 'string', description: 'Short title; defaults to the content.' },
266
+ title: { type: 'string', description: '8-40 characters. This title is a permanent index: the anchor and turn recall show it WITHOUT the body, so it must name the concrete fact — people/objects/events or the conclusion keywords ("Yuike 名字来自小时候的白猫", "Postgres 连接池上限改为 40"). Never an empty label like "一些事" / "那段对话" / "关于XX的事"; the details belong in content. Defaults to the content when omitted.' },
201
267
  layer: { type: 'string', description: 'long_term (default), short_term, or must_remember. short_term is a draft with a deadline: it expires after shortTermTtlHours unless promoted to long_term before then. Use it for what only has to survive this stretch of conversation.' },
202
268
  pin_reason: { type: 'string', description: 'Required for must_remember; first-person reason.' },
203
269
  pin_source: { type: 'string', description: 'user_explicit, side_event, or self.' },
@@ -223,6 +289,7 @@ export function registerTools(ctx) {
223
289
  },
224
290
  dominance: { type: 'number', description: '谁在掌控:-1 完全失控 … +1 完全掌控。省略=继承当前会话。' },
225
291
  peak: { type: 'boolean', description: 'A high point or low point of the arc — peaks and endings stay easier to recall.' },
292
+ allow_duplicate: { type: 'boolean', description: 'Only for two genuinely independent observations of the same fact (e.g. the same event recorded twice in one day). The default false refuses a near-identical rewrite and returns the existing memory id; passing true states you looked at that existing memory and still want a separate entry.' },
226
293
  salient_date: { type: 'string', description: 'MM-DD for something that comes back every year (a birthday, an anniversary of an event); it resurfaces on that day each year. YYYY-MM-DD for a one-off dated commitment or appointment: it is raised the day before and on the day itself, and never again after it has passed. Use the annual form only when the day really does recur. A relative day (今天/明天/后天/today/tomorrow) must be resolved to the absolute date it points at: sending a bare MM-DD turns one appointment into a yearly reminder. If the content itself names a date, pass it here too: when this field is empty the plugin only falls back to a single unambiguous YYYY-MM-DD found in the text.' },
227
294
  track: { type: 'string', description: 'memory (default) or habit for an aggregated expression/sticker habit; do not write one habit per occurrence.' },
228
295
  attachments: { type: 'array', items: { type: 'object', properties: { sha256: { type: 'string', required: true } }, additionalProperties: true }, description: 'At most 8 sha256 references received in this conversation. Never supply paths or bytes. Ordinary image memories default to short_term if layer is omitted; choose long_term explicitly only when important.' },
@@ -259,7 +326,16 @@ export function registerTools(ctx) {
259
326
  dominance: args.dominance ?? null,
260
327
  peak: args.peak === true,
261
328
  salientDate: args.salient_date ?? null,
329
+ allowDuplicate: args.allow_duplicate === true,
262
330
  })
331
+ // 事实级去重:几乎相同的重写被拒,返回的是
332
+ // { deduped, existingId, hint },没有 entry——必须先接住,否则 result.entry.layer 抛错。
333
+ if (result.deduped === true) {
334
+ return [
335
+ `未写入:库内已有几乎相同的记忆 [${result.existingId}](相似度 ${(Number(result.similarity) || 0).toFixed(2)})。`,
336
+ `要补充内容请改用 memu_private_update 传该 id 合并;若确属两次独立观测,带 allow_duplicate=true 重写。`,
337
+ ].join('\n')
338
+ }
263
339
  const entry = result.entry
264
340
  if (result.conflict) {
265
341
  // The entry is held (pending_review): it is stored, but excluded from
@@ -326,11 +402,11 @@ export function registerTools(ctx) {
326
402
 
327
403
  tools.push(toolBase(
328
404
  'memu_private_update',
329
- 'Update a private memory in the bound library. Changing content rewrites the memory in MemU; metadata-only changes stay local.',
405
+ 'Update a private memory in the bound library. Changing content rewrites the memory in MemU; metadata-only changes stay local. If the new content covers more than the old title says, rewrite the title under the same rule.',
330
406
  {
331
407
  id: { type: 'string', required: true, description: 'Memory id.' },
332
408
  content: { type: 'string', description: 'New body.' },
333
- title: { type: 'string' },
409
+ title: { type: 'string', description: 'New title under the same rule as memu_private_remember.title: 8-40 characters that name the concrete fact (people/objects/events or conclusion keywords), because the anchor shows only titles. Never an empty label like "关于XX的事"; details stay in content. Omit to keep the current title.' },
334
410
  tags: { type: 'array', items: { type: 'string' } },
335
411
  status: { type: 'string', description: 'active, archived, fragment, suppressed, or expired.' },
336
412
  layer: { type: 'string', description: 'long_term to promote a short_term draft once it has proven worth keeping, or short_term to re-arm the deadline. must_remember needs a pin_reason and is only writable through memu_private_remember.' },
@@ -396,6 +472,101 @@ export function registerTools(ctx) {
396
472
  },
397
473
  ))
398
474
 
475
+ tools.push(toolBase(
476
+ 'memu_lexicon_search',
477
+ 'Search the ACTIVE lexicon (user-coined slang, memes, triggers and terms) of the library bound to this conversation; archived entries are excluded. Read-only. When a user-coined word or phrase appears and you are not sure what it means, look it up here before asking or guessing; returns term → meaning/usage with short evidence.',
478
+ {
479
+ query: { type: 'string', required: true, description: 'The word or phrase to look up. Exact term/alias match first with a bounded fuzzy fallback; short queries work best.' },
480
+ },
481
+ async (args, exec) => {
482
+ // Read-only: never materialize a binding or a library from a lexicon query,
483
+ // same rule as memu_private_recall.
484
+ const binding = peekBinding(exec)
485
+ if (!binding?.libraryId) return '当前对话没有绑定记忆库,无法查询词典。请先初始化或绑定记忆库。'
486
+ const lexicon = await lexiconApi()
487
+ const hits = await lexicon.searchLexicon(
488
+ { libraryId: binding.libraryId, query: String(args.query || ''), memoryContext: binding.memoryContext || {} },
489
+ { includeArchived: false },
490
+ )
491
+ const rows = (Array.isArray(hits) ? hits : []).map((hit) => (hit?.entry && typeof hit.entry === 'object' ? hit.entry : hit))
492
+ if (!rows.length) return `词典(library=${binding.libraryId})没有命中「${truncate(args.query, 80)}」。`
493
+ const lines = [`词典命中(library=${binding.libraryId},${rows.length} 条)`]
494
+ for (const [index, e] of rows.entries()) {
495
+ const evidence = (Array.isArray(e.evidence) ? e.evidence : []).slice(0, 2)
496
+ .map((item) => String(item?.quote || item?.id || '')).filter(Boolean).join(' / ')
497
+ lines.push([
498
+ `${index + 1}. [${e.term || '?'}] kind=${e.kind || 'term'} status=${e.status || 'active'}`,
499
+ ` meaning=${truncate(e.meaning, 300)}`,
500
+ e.usage ? ` usage=${truncate(e.usage, 200)}` : '',
501
+ evidence ? ` evidence=${truncate(evidence, 200)}` : '',
502
+ ].filter(Boolean).join('\n'))
503
+ }
504
+ return lines.join('\n')
505
+ },
506
+ { isConcurrencySafe: () => true },
507
+ ))
508
+
509
+ tools.push(toolBase(
510
+ 'memu_lexicon_propose',
511
+ 'Write one lexicon entry (slang / meme / term) for the library bound to this conversation. The entry becomes ACTIVE immediately: there is no pending state and no human review, and it is injected from the next turn. term is the index key, meaning is the explanation. Search first with memu_lexicon_search and propose only what this conversation evidences, one entry per term. Sensitive triggers cannot be created or updated by the model — those stay user-managed. There is no lexicon delete from the model side.',
512
+ {
513
+ term: { type: 'string', required: true, description: 'The index key / title of the entry: short, stable, spelled the way the user actually writes it (normally 1-12 characters, at most a short phrase). Not a sentence and not the explanation — that goes in meaning.' },
514
+ kind: { type: 'string', required: true, description: 'slang, meme, trigger, or term. trigger is rejected here: sensitive triggers are user-managed.' },
515
+ meaning: { type: 'string', required: true, description: 'The explanation of term: what it means in this library, in one or two sentences.' },
516
+ usage: { type: 'string', description: 'When or how the word is used.' },
517
+ aliases: { type: 'array', items: { type: 'string' }, description: 'Alternative spellings or short forms, at most 20.' },
518
+ examples: { type: 'array', items: { type: 'string' }, description: 'Short real quotes from this conversation showing the usage, at most 8. Never invent quotes.' },
519
+ evidence: { type: 'array', items: { type: 'object', properties: { kind: { type: 'string' }, id: { type: 'string' }, quote: { type: 'string' } }, additionalProperties: true }, description: 'Where the meaning came from: short quotes with stable ids only, no full transcripts.' },
520
+ confidence: { type: 'number', description: '0-1; how sure you are of the meaning. Use it honestly — it sets the entry display strength.' },
521
+ },
522
+ async (args, exec) => {
523
+ const binding = peekBinding(exec)
524
+ if (!binding?.libraryId) return '当前对话没有绑定记忆库,无法写入词条。请先初始化或绑定记忆库。'
525
+ const lexicon = await lexiconApi()
526
+ let result
527
+ try {
528
+ result = await lexicon.upsertLexiconTerm({
529
+ libraryId: binding.libraryId,
530
+ term: args.term,
531
+ kind: args.kind,
532
+ meaning: args.meaning,
533
+ usage: args.usage,
534
+ aliases: args.aliases,
535
+ examples: args.examples,
536
+ // 词条继承当前会话的场景身份;没有场景就是空 context(全场景可见)。
537
+ context: binding.memoryContext || {},
538
+ evidence: args.evidence,
539
+ confidence: args.confidence,
540
+ // 来源固定 model-proposal;普通词条直接 active(§2.1),sensitive trigger 由
541
+ // 核心层 typed 拒绝。
542
+ source: 'model-proposal',
543
+ })
544
+ } catch (error) {
545
+ // typed rejection 翻译:sensitive trigger 不允许模型写入是核心层的硬规则,
546
+ // 原始错误码对模型没有行动价值,翻成"该让用户做什么"。
547
+ const code = String(error?.code || '')
548
+ const message = String(error?.message || error)
549
+ if (/sensitive/i.test(code) || (/sensitive/i.test(message) && /trigger/i.test(message))) {
550
+ return '未写入:sensitive trigger 不能由模型写入或修改。这类触发词请让用户在词典页手动添加。'
551
+ }
552
+ throw error
553
+ }
554
+ // 核心返回 { term: 词条对象(含 termId), created, notOwned? }。
555
+ const termLabel = result?.term && typeof result.term === 'object' ? String(result.term.term || args.term) : String(result?.term || args.term)
556
+ if (result?.created === false) {
557
+ // 没新建:要么词条归用户(归档/墓碑/草稿),要么同名 active 词条被原地更新。
558
+ if (result?.notOwned === true) {
559
+ return `未改动:词条「${termLabel}」已归档或归用户管理,模型不能覆盖它。如需恢复或编辑请让用户在词典页操作。`
560
+ }
561
+ return `已更新现有生效词条「${termLabel}」(status=active);若字段与原来一致则等于没变。sensitive trigger 只能由用户手动管理。`
562
+ }
563
+ return [
564
+ `已写入词条「${termLabel}」(status=active,下一回合起参与注入)。`,
565
+ '词条已自动生效,无需用户审核;sensitive trigger 只能由用户手动添加。',
566
+ ].join('\n')
567
+ },
568
+ ))
569
+
399
570
  tools.push(toolBase(
400
571
  'memu_global_recall',
401
572
  'Retrieve cross-session global memories applicable to the current domain. Global memory is read-only for the main agent.',
@@ -501,17 +672,60 @@ export function registerTools(ctx) {
501
672
  { isConcurrencySafe: () => true },
502
673
  ))
503
674
 
504
- const usable = tools.filter((tool) => tool)
505
- registrationReport.registered = usable.length
506
- if (registrationReport.failures.length > 0) {
675
+ // 整理 worker 的挂载面(private.js 的 fork 兼容):只挂 WORKER_WRITE_TOOLS +
676
+ // WORKER_READ_TOOLS(记忆读写 + 关联裁决/候选 + 词典检索/写入),不把
677
+ // archive/delete/unpin 这些破坏性工具暴露给一个后台子会话。
678
+ const wanted = Array.isArray(only) && only.length > 0 ? only : null
679
+ const usable = tools.filter((tool) => tool && (!wanted || wanted.includes(tool.name)))
680
+ if (only) {
681
+ registrationReport = reportSaved
682
+ if (usable.length !== wanted.length) {
683
+ try {
684
+ ctx?.logger?.warn?.(`dsh-memu-memory: worker tool mount compiled ${usable.length}/${wanted.length}: ${wanted.join(', ')}`)
685
+ } catch {}
686
+ }
687
+ } else {
688
+ registrationReport.registered = usable.length
689
+ if (registrationReport.failures.length > 0) {
690
+ try {
691
+ ctx?.logger?.warn?.(`dsh-memu-memory: host schema DSL refused ${registrationReport.failures.length} of ${registrationReport.attempted} tools: ${registrationReport.failures.map((failure) => `${failure.name} (${failure.message})`).join('; ')}`)
692
+ } catch {}
693
+ }
694
+ }
695
+ // register 这一步也逐件 fail-open(与 toolBase 的编译 fail-open 同一哲学):宿主
696
+ // 注册表拒一件(重名、scope 锁定)不能掀翻整组挂载——原来的 `.map()` 一件抛错就连
697
+ // 前面已注册成功的 disposer 一起丢掉,在 worker scope 上留下卸不掉的常驻注册。
698
+ const disposers = []
699
+ const attached = []
700
+ const refused = []
701
+ for (const tool of usable) {
702
+ try {
703
+ const dispose = ctx.tools.register(tool)
704
+ disposers.push(typeof dispose === 'function' ? dispose : null)
705
+ attached.push(tool.name)
706
+ } catch (error) {
707
+ refused.push(`${tool.name} (${String(error?.message || error)})`)
708
+ // 全量装载(宿主)才进 registrationReport:memu_status 的 refused 清单要如实反映
709
+ // 编译 + register 两道关都被拒了谁;worker 的按需挂载照旧不碰全局报告。
710
+ if (!only) registrationReport.failures.push({ name: tool.name, message: String(error?.message || error) })
711
+ }
712
+ }
713
+ if (refused.length > 0) {
714
+ if (!only) registrationReport.registered = attached.length
507
715
  try {
508
- ctx?.logger?.warn?.(`dsh-memu-memory: host schema DSL refused ${registrationReport.failures.length} of ${registrationReport.attempted} tools: ${registrationReport.failures.map((failure) => `${failure.name} (${failure.message})`).join('; ')}`)
716
+ ctx?.logger?.warn?.(`dsh-memu-memory: tool mount refused ${refused.length}/${usable.length} at register: ${refused.join('; ')}`)
509
717
  } catch {}
510
718
  }
511
- const disposers = usable.map((tool) => ctx.tools.register(tool))
512
- return () => {
719
+ // 返回值仍是可直接调用的 disposer(private.js attachWorkerTools 与宿主装载都这么用),
720
+ // 但附挂结构化名单:attached = 编译 + register 都真正成功的工具名;requested = 请求
721
+ // 名单(宿主全量装载没有 only,为 null)。部分编译/注册失败时调用方据此写活动日志,
722
+ // 不再把 wanted 全量误报成已挂载。
723
+ const disposeAll = () => {
513
724
  for (const dispose of disposers) {
514
725
  try { dispose?.() } catch {}
515
726
  }
516
727
  }
728
+ disposeAll.attached = attached
729
+ disposeAll.requested = wanted
730
+ return disposeAll
517
731
  }
@@ -849,8 +849,13 @@ export function createVectorMigrator(options = {}) {
849
849
  ledger: readLedger,
850
850
  /**
851
851
  * 当前生效代际里"哪些 id 已经写进向量库"(按 scope key:libraryId 或 'global')。
852
- * 迁移写库时刻意不碰索引,所以索引里的老条目没有 entry.vector 戳;界面要回答
853
- * "这条在不在向量库"只能查账本。
852
+ * 两条腿合成,缺一都会数错:
853
+ * * 账本腿:迁移写库时刻意不碰索引,所以迁移写入的条目没有 entry.vector 戳,
854
+ * 只能查账本。但永久删除(forget)只删引擎行和索引行、不动账本,所以账本里的
855
+ * id 还要扣掉"索引里已经没有的"(删掉的条目必然已从索引移除)。
856
+ * * 戳腿:迁移之后的写入(remember/import/review/update/curator)在索引条目上带
857
+ * entry.vector 戳。只认当前代际的戳 —— 旧代际的戳说明它属于旧空间,是否还在
858
+ * 当前空间由账本腿回答(切代际会把当时可准入的条目全部重写一遍并记账)。
854
859
  */
855
860
  membership: () => {
856
861
  const state = vectorState()
@@ -858,10 +863,30 @@ export function createVectorMigrator(options = {}) {
858
863
  if (!active) return null
859
864
  const ledger = readLedger({ fingerprint: active.fingerprint, generation: active.generation })
860
865
  if (!ledger) return null
866
+ const generation = Number(active.generation)
861
867
  const out = {}
862
868
  for (const scope of scopesFor(active.databases)) {
863
869
  const bucket = ledger.libraries?.[scope.key]
864
- out[scope.key] = bucket?.items && typeof bucket.items === 'object' ? Object.keys(bucket.items) : []
870
+ const migrated = bucket?.items && typeof bucket.items === 'object' ? Object.keys(bucket.items) : []
871
+ let entries = null
872
+ try {
873
+ const loaded = impl.loadIndex(scope.libraryId)
874
+ // loadIndex 的约定是同步读盘;拿到 Promise(测试替身)时按读不到处理,不猜。
875
+ entries = loaded && typeof loaded.then === 'function' ? null : (loaded?.entries || {})
876
+ } catch { entries = null }
877
+ const ids = new Set()
878
+ for (const id of migrated) {
879
+ // 索引读不出来时宁可贵一点按"还在"算:损坏是暂时的,误扣会让用户以为记忆丢了。
880
+ if (!entries || Object.prototype.hasOwnProperty.call(entries, id)) ids.add(id)
881
+ }
882
+ if (entries) {
883
+ for (const [id, entry] of Object.entries(entries)) {
884
+ // remote-api 的戳描述的是远端空间,不属于本地代际;词法路径没有戳。
885
+ if (!entry || typeof entry !== 'object' || entry.vector?.route !== 'managed-local') continue
886
+ if (Number(entry.vector.generation) === generation) ids.add(id)
887
+ }
888
+ }
889
+ out[scope.key] = [...ids]
865
890
  }
866
891
  return out
867
892
  },
@@ -522,6 +522,30 @@ export function activeGeneration() {
522
522
  return state?.active?.generation ?? null
523
523
  }
524
524
 
525
+ /**
526
+ * 条目级"已入向量库"戳(entry.vector)。迁移写库故意不碰索引,所以迁移写入的条目没有
527
+ * 戳、只有账本知道;而迁移之后的每一次向量写入(remember/import/review/update/curator)
528
+ * 都必须在提交成功后把这条戳落进索引。词法路径没写库,不戳;remote-api 沿用历史口径
529
+ * (本地还留着 active 代际时照样记,界面只拿它回答"进没进向量后端")。空间归属由
530
+ * membership() 的戳腿自行过滤:只认 route === 'managed-local' 且代际匹配的戳。只读不抛错。
531
+ */
532
+ export function activeEntryStamp(route) {
533
+ if (route === 'lexical') return null
534
+ try {
535
+ const active = readVectorState({ tolerateMissing: true }).state?.active
536
+ if (!active) return null
537
+ return {
538
+ route,
539
+ model: active.profile?.catalogId || null,
540
+ generation: active.generation,
541
+ fingerprint: String(active.fingerprint || '').slice(0, 8),
542
+ at: stamp(),
543
+ }
544
+ } catch {
545
+ return null
546
+ }
547
+ }
548
+
525
549
  /** active manifest 里的 DB 集合,一次性取得(避免多库逐个换混空间)。 */
526
550
  export function activeDatabases() {
527
551
  return databaseMapFor(vectorState()?.active)
@@ -1146,3 +1146,20 @@ html[data-standalone] .dshm-wall-graph {
1146
1146
  }
1147
1147
 
1148
1148
  html[data-standalone] .dshm-relation-field textarea{border-radius:12px;border-color:var(--dshm-line-strong);background:var(--dshm-field);padding:9px 11px;font-size:13px;color:inherit}
1149
+
1150
+ /* The standalone field rules set `background` (a shorthand) with !important, so
1151
+ the generic chevron in client.js was being erased. Keep the theme fill and add
1152
+ the chevron in one place for every select in this shell. */
1153
+ html[data-standalone] .dshm-root select {
1154
+ padding-right: 36px !important;
1155
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23808a99' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
1156
+ background-repeat: no-repeat !important;
1157
+ background-position: right 14px center !important;
1158
+ background-size: 10px 6px !important;
1159
+ }
1160
+ html[data-standalone] .dshm-root select:focus {
1161
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23808a99' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
1162
+ background-repeat: no-repeat !important;
1163
+ background-position: right 14px center !important;
1164
+ background-size: 10px 6px !important;
1165
+ }
@@ -23,10 +23,12 @@
23
23
  zh: {
24
24
  engine: '引擎与连接', settings: '记忆设置', folders: '文件夹', libraries: '记忆库',
25
25
  browser: '记忆浏览', global: '全局记忆', prompts: '提示词', stats: '统计', memories: '记忆总览',
26
+ lexicon: '词典', help: '帮助',
26
27
  },
27
28
  en: {
28
29
  engine: 'Engine & connection', settings: 'Memory settings', folders: 'Folders', libraries: 'Libraries',
29
30
  browser: 'Memory browser', global: 'Global memory', prompts: 'Prompts', stats: 'Stats', memories: 'Memories',
31
+ lexicon: 'Lexicon', help: 'Help',
30
32
  },
31
33
  }
32
34
  var copy = COPY[locale]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-memu-memory",
3
3
  "icon": "./assets/icon.png",
4
- "version": "1.2.0",
4
+ "version": "1.2.2",
5
5
  "description": "面向长篇 RP 与陪伴对话的 DeepSeek Harness 记忆插件:三层记忆、逐轮召回、来源标注、关系图、图片附件与独立 WebUI。",
6
6
  "keywords": [
7
7
  "deepseek-harness",
@@ -61,7 +61,8 @@
61
61
  "check": "node scripts/check-all.mjs",
62
62
  "setup": "node scripts/setup.mjs",
63
63
  "test": "node --import ./tests/loader-register.mjs --test tests/*.test.mjs",
64
- "test:chain": "node scripts/chain-matrix.mjs"
64
+ "test:chain": "node scripts/chain-matrix.mjs",
65
+ "replay": "node --import ./tests/loader-register.mjs scripts/replay.mjs"
65
66
  },
66
67
  "engines": {
67
68
  "node": ">=20.9.0",
@@ -294,7 +294,10 @@ await auto('B-4', 'a Chinese anchor lands inside its token budget', async () =>
294
294
  const anchor = inject.buildAnchor(binding, settings, null)
295
295
  const tokens = budget.estTokens(anchor.text)
296
296
  const target = Number(settings.anchorTokens || 700)
297
- const low = Math.floor(target * 0.85)
297
+ // Title cards spend less than the old full-text cards: the invariant is that the
298
+ // anchor never exceeds its budget and still renders a useful block, not that it
299
+ // fills the budget to 85%.
300
+ const low = Math.min(120, target)
298
301
  const high = target
299
302
  assert.ok(tokens >= low && tokens <= high,
300
303
  'estimated ' + tokens + ' tokens, wanted ' + low + '..' + high + ' for a ' + target + ' budget')
@@ -858,16 +861,16 @@ await auto('H-4', 'a contradiction is held back until someone settles it', async
858
861
  return 'same-title conflict held, accepted, and rejected'
859
862
  })
860
863
 
861
- await auto('H-5', 'a memory on the tip of the tongue arrives without its body', async () => {
864
+ await auto('H-5', 'a memory on the tip of the tongue arrives as a title without its body', async () => {
862
865
  const lib = makeLibrary('h5')
863
866
  const long = '那年夏天我们在旧办公室最后一晚把所有抽屉清空,有人把钥匙挂在门框上,谁也没说要走,但所有人都知道第二周不会再来。'
864
867
  // Not must_remember: a pinned memory is never on the tip of the tongue by design.
865
868
  seed(lib.id, 'mem-h5a', { layer: 'long_term', title: 'the last night', content: long, confidence: 0.4 })
866
869
  const anchor = inject.buildAnchor({ libraryId: lib.id }, state.getSettings(), null)
867
- assert.ok(anchor.text.includes('只记得大概'), 'it says up front that only the gist is left')
870
+ assert.ok(anchor.text.includes('the last night'), 'the title is the directory entry')
868
871
  assert.ok(anchor.text.includes('mem-h5a'), 'and points at the id so a recall can finish the job')
869
- assert.ok(!anchor.text.includes('第二周不会再来'), 'while withholding the tail of the text')
870
- return 'gist only, id included'
872
+ assert.ok(!anchor.text.includes('第二周不会再来'), 'while withholding the body')
873
+ return 'title only, id included'
871
874
  })
872
875
  await auto('H-6', 'a held claim is visible in the browser, with its library named', async () => {
873
876
  const lib = makeLibrary('h6')