dsh-memu-memory 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/README.md +12 -5
- package/README.zh.md +4 -2
- package/docs/contributing.md +16 -3
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -4
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +2 -2
- package/lib/client.js +1361 -146
- package/lib/consolidation-worker.js +30 -8
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1252 -91
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +51 -9
- package/lib/inbound.js +19 -0
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +282 -17
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +32 -3
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +577 -9
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +36 -18
- package/lib/tools.js +168 -10
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +1 -1
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-fork.test.mjs +14 -12
- package/tests/consolidation-snapshot.test.mjs +39 -0
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +52 -5
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +35 -2
- package/tests/graph.test.mjs +96 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +33 -4
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +188 -0
package/lib/text-similarity.js
CHANGED
|
@@ -73,34 +73,52 @@ export function lengthGap(aText, bText) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
|
87
|
+
export function similarityDetail(aText, bText) {
|
|
84
88
|
const a = String(aText || '')
|
|
85
89
|
const b = String(bText || '')
|
|
86
|
-
|
|
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)
|
|
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
|
|
97
|
-
// membership
|
|
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
|
|
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
|
-
|
|
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: '
|
|
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)))
|
package/lib/webui/standalone.css
CHANGED
|
@@ -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
|
+
}
|
package/lib/webui/standalone.js
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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('
|
|
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
|
|
870
|
-
return '
|
|
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')
|