dsh-memu-memory 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +12 -5
  3. package/README.zh.md +4 -2
  4. package/docs/contributing.md +16 -3
  5. package/docs/host-api.md +3 -1
  6. package/docs/memory-semantics.md +21 -2
  7. package/docs/troubleshooting.md +42 -4
  8. package/lib/backup.js +24 -2
  9. package/lib/binding-diagnostics.js +2 -2
  10. package/lib/client.js +1361 -146
  11. package/lib/consolidation-worker.js +30 -8
  12. package/lib/curator-evidence.js +131 -0
  13. package/lib/curator-policy.js +81 -0
  14. package/lib/curator.js +1252 -91
  15. package/lib/cycle.js +3 -0
  16. package/lib/embedding-download.js +39 -4
  17. package/lib/engine.js +6 -3
  18. package/lib/global-graph.js +282 -0
  19. package/lib/graph.js +51 -9
  20. package/lib/inbound.js +19 -0
  21. package/lib/inject.js +277 -17
  22. package/lib/lexicon.js +1115 -0
  23. package/lib/local-vector-service.js +8 -6
  24. package/lib/managed-runtime.js +33 -2
  25. package/lib/memory.js +282 -17
  26. package/lib/memu.js +2 -2
  27. package/lib/migrate.js +83 -2
  28. package/lib/private-links.js +587 -0
  29. package/lib/private.js +32 -3
  30. package/lib/prompts/global-curator.md +76 -19
  31. package/lib/prompts/private-consolidation.coding.md +4 -1
  32. package/lib/prompts/private-consolidation.md +3 -1
  33. package/lib/prompts/private-consolidation.mixed.md +3 -1
  34. package/lib/prompts/private-consolidation.rp.md +3 -1
  35. package/lib/prompts.js +1 -1
  36. package/lib/rpc.js +577 -9
  37. package/lib/state.js +4 -1
  38. package/lib/text-retrieval.js +661 -0
  39. package/lib/text-similarity.js +36 -18
  40. package/lib/tools.js +168 -10
  41. package/lib/webui/standalone.css +17 -0
  42. package/lib/webui/standalone.js +2 -0
  43. package/package.json +1 -1
  44. package/scripts/acceptance-local.mjs +8 -5
  45. package/scripts/curator-pair-calibrate.mjs +259 -0
  46. package/scripts/lexicon-calibrate.mjs +230 -0
  47. package/scripts/lexicon-dedupe.mjs +318 -0
  48. package/tests/absorb-review.test.mjs +353 -0
  49. package/tests/appendix-b2.test.mjs +2 -2
  50. package/tests/client-export.test.mjs +520 -0
  51. package/tests/client-render.test.mjs +19 -3
  52. package/tests/consolidation-fork.test.mjs +14 -12
  53. package/tests/consolidation-snapshot.test.mjs +39 -0
  54. package/tests/curator-absorb.test.mjs +443 -0
  55. package/tests/curator-calibrate.test.mjs +173 -0
  56. package/tests/curator-clusters.test.mjs +281 -0
  57. package/tests/curator-commit.test.mjs +52 -5
  58. package/tests/curator-evidence.test.mjs +102 -0
  59. package/tests/curator-link.test.mjs +316 -0
  60. package/tests/curator-pairing.test.mjs +252 -0
  61. package/tests/curator-probe.test.mjs +15 -6
  62. package/tests/curator-review-mode.test.mjs +314 -0
  63. package/tests/disclosure.test.mjs +2 -1
  64. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  65. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  66. package/tests/fixtures/lexicon-stub.mjs +284 -0
  67. package/tests/graph-cascade.test.mjs +35 -2
  68. package/tests/graph.test.mjs +96 -0
  69. package/tests/help-page.test.mjs +415 -0
  70. package/tests/inbound.test.mjs +6 -3
  71. package/tests/inject-guide.test.mjs +28 -0
  72. package/tests/inject.test.mjs +157 -5
  73. package/tests/lexicon-calibrate.test.mjs +48 -0
  74. package/tests/lexicon-dedupe.test.mjs +171 -0
  75. package/tests/lexicon-frequency.test.mjs +179 -0
  76. package/tests/lexicon-inject.test.mjs +410 -0
  77. package/tests/lexicon-perf.test.mjs +436 -0
  78. package/tests/lexicon-ui.test.mjs +547 -0
  79. package/tests/lexicon.test.mjs +922 -0
  80. package/tests/managed-runtime.test.mjs +32 -0
  81. package/tests/private-links.test.mjs +445 -0
  82. package/tests/prompts.test.mjs +35 -0
  83. package/tests/recall-dedup.test.mjs +33 -4
  84. package/tests/tool-schema.test.mjs +1 -1
  85. package/tests/tools.test.mjs +112 -3
  86. package/tests/webui-auth.test.mjs +348 -2
  87. package/tests/webui.test.mjs +188 -0
@@ -73,34 +73,52 @@ export function lengthGap(aText, bText) {
73
73
  }
74
74
 
75
75
  /**
76
- * Dice over raw texts with the write-side length pruning applied: pairs whose
77
- * lengths differ too much return 0 without paying for the bigram build (the
78
- * gate runs over the whole library on every write). 0 can never win a
79
- * max-similarity scan, so pruned pairs simply never become the best match —
80
- * unless they hit one of the two containment probes below, which are the only
81
- * way a length-disparate pair reaches the reject band.
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).
82
86
  */
83
- export function similarTexts(aText, bText) {
87
+ export function similarityDetail(aText, bText) {
84
88
  const a = String(aText || '')
85
89
  const b = String(bText || '')
86
- if (lengthGap(a, b) > LENGTH_PRUNE_RATIO) {
90
+ const gap = lengthGap(a, b)
91
+ if (gap > LENGTH_PRUNE_RATIO) {
87
92
  const aClean = clean(a)
88
93
  const bClean = clean(b)
89
- if (!aClean || !bClean) return 0
94
+ if (!aClean || !bClean) return { dice: 0, containment: 0, lengthGap: gap }
90
95
  const shortClean = aClean.length <= bClean.length ? aClean : bClean
91
96
  const longClean = aClean.length <= bClean.length ? bClean : aClean
92
- if (shortClean.length < PROBE_MIN_CHARS || shortClean.length > PROBE_MAX_CHARS) return 0
97
+ if (shortClean.length < PROBE_MIN_CHARS || shortClean.length > PROBE_MAX_CHARS) {
98
+ return { dice: 0, containment: 0, lengthGap: gap }
99
+ }
93
100
  // Probe 1: verbatim containment of the short text inside the long one.
94
- if (longClean.includes(shortClean)) return CONTAINMENT_FLOOR
101
+ if (longClean.includes(shortClean)) return { dice: 0, containment: CONTAINMENT_FLOOR, lengthGap: gap }
95
102
  // Probe 2: core coverage — every bigram of the short text occurs in the long
96
- // one. includes() over the cleaned long text is exactly long-bigram-set
97
- // membership (a hit means the two characters are adjacent somewhere in the
98
- // long text), so no long-side set is ever built; the first absent gram ends
99
- // the probe.
103
+ // one; includes() over the cleaned long text is exactly long-bigram-set
104
+ // membership, so no long-side set is ever built.
100
105
  for (let i = 0; i < shortClean.length - 1; i += 1) {
101
- if (!longClean.includes(shortClean.slice(i, i + 2))) return 0
106
+ if (!longClean.includes(shortClean.slice(i, i + 2))) return { dice: 0, containment: 0, lengthGap: gap }
102
107
  }
103
- return CORE_COVER_FLOOR
108
+ return { dice: 0, containment: CORE_COVER_FLOOR, lengthGap: gap }
104
109
  }
105
- return dice(bigrams(a), bigrams(b))
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)
106
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
@@ -165,20 +180,20 @@ function formatGlobalItem(item, index, options = {}) {
165
180
  export function registerTools(ctx, { only = null } = {}) {
166
181
  const tools = []
167
182
  // 全量注册(宿主装载)才更新全局 registrationReport:整理 worker 的按需挂载
168
- // (private.js attachWorkerTools,only=读写四件)不能把 memu_status 的注册清单
183
+ // (private.js attachWorkerTools,only=worker 读写面)不能把 memu_status 的注册清单
169
184
  // 覆盖成 4/4,否则其它会话看到的是 worker 的子集,可观测性就断了。
170
185
  const reportSaved = only ? registrationReport : null
171
186
  if (!only) registrationReport = { attempted: 0, registered: 0, failures: [] }
172
187
  tools.push(toolBase(
173
188
  'memu_private_link',
174
- '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.',
175
190
  {
176
191
  from: { type: 'string', description: 'Exact source memory id; required unless updating a relationship snapshot.' },
177
192
  to: { type: 'string', description: 'Exact target memory or entity id; required unless entity or relationship is supplied.' },
178
193
  kind: { type: 'string', description: 'relation, commitment, event, person or conflict; required when linking.' },
179
- 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.' },
180
195
  confidence: { type: 'number', description: '0-1 confidence, default 0.7.' },
181
- 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.' },
182
197
  relationship: { type: 'object', properties: {
183
198
  fromEntity: { type: 'string' }, toEntity: { type: 'string' },
184
199
  evidenceIds: { type: 'array', items: { type: 'string' } },
@@ -191,17 +206,64 @@ export function registerTools(ctx, { only = null } = {}) {
191
206
  const relation = args.relationship
192
207
  return JSON.stringify(updateRelationship({ exec, fromId: relation.fromEntity, toId: relation.toEntity, state: relation.state, evidenceIds: relation.evidenceIds, reason: args.note }))
193
208
  }
194
- 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
+ }
195
223
  return JSON.stringify(linkEntries({ exec, fromId: args.from, toId, kind: args.kind, note: args.note, confidence: args.confidence }))
196
224
  },
197
225
  ))
198
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
+
199
261
  tools.push(toolBase(
200
262
  'memu_private_remember',
201
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).',
202
264
  {
203
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.' },
204
- 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.' },
205
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.' },
206
268
  pin_reason: { type: 'string', description: 'Required for must_remember; first-person reason.' },
207
269
  pin_source: { type: 'string', description: 'user_explicit, side_event, or self.' },
@@ -340,11 +402,11 @@ export function registerTools(ctx, { only = null } = {}) {
340
402
 
341
403
  tools.push(toolBase(
342
404
  'memu_private_update',
343
- '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.',
344
406
  {
345
407
  id: { type: 'string', required: true, description: 'Memory id.' },
346
408
  content: { type: 'string', description: 'New body.' },
347
- 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.' },
348
410
  tags: { type: 'array', items: { type: 'string' } },
349
411
  status: { type: 'string', description: 'active, archived, fragment, suppressed, or expired.' },
350
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.' },
@@ -410,6 +472,101 @@ export function registerTools(ctx, { only = null } = {}) {
410
472
  },
411
473
  ))
412
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
+
413
570
  tools.push(toolBase(
414
571
  'memu_global_recall',
415
572
  'Retrieve cross-session global memories applicable to the current domain. Global memory is read-only for the main agent.',
@@ -515,7 +672,8 @@ export function registerTools(ctx, { only = null } = {}) {
515
672
  { isConcurrencySafe: () => true },
516
673
  ))
517
674
 
518
- // 整理 worker 的挂载面(private.js 的 fork 兼容):只挂读写四件,不把
675
+ // 整理 worker 的挂载面(private.js 的 fork 兼容):只挂 WORKER_WRITE_TOOLS +
676
+ // WORKER_READ_TOOLS(记忆读写 + 关联裁决/候选 + 词典检索/写入),不把
519
677
  // archive/delete/unpin 这些破坏性工具暴露给一个后台子会话。
520
678
  const wanted = Array.isArray(only) && only.length > 0 ? only : null
521
679
  const usable = tools.filter((tool) => tool && (!wanted || wanted.includes(tool.name)))
@@ -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.1",
4
+ "version": "1.2.2",
5
5
  "description": "面向长篇 RP 与陪伴对话的 DeepSeek Harness 记忆插件:三层记忆、逐轮召回、来源标注、关系图、图片附件与独立 WebUI。",
6
6
  "keywords": [
7
7
  "deepseek-harness",
@@ -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')