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.
- package/AGENTS.md +1 -1
- package/README.md +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- 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 +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- 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 +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- 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 +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- 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/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- 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-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- 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 +324 -0
- 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 +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- 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-memory.test.mjs +55 -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 +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- 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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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:
|
|
716
|
+
ctx?.logger?.warn?.(`dsh-memu-memory: tool mount refused ${refused.length}/${usable.length} at register: ${refused.join('; ')}`)
|
|
509
717
|
} catch {}
|
|
510
718
|
}
|
|
511
|
-
|
|
512
|
-
|
|
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
|
}
|
package/lib/vector-migration.js
CHANGED
|
@@ -849,8 +849,13 @@ export function createVectorMigrator(options = {}) {
|
|
|
849
849
|
ledger: readLedger,
|
|
850
850
|
/**
|
|
851
851
|
* 当前生效代际里"哪些 id 已经写进向量库"(按 scope key:libraryId 或 'global')。
|
|
852
|
-
*
|
|
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
|
-
|
|
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
|
},
|
package/lib/vector-profile.js
CHANGED
|
@@ -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)
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-memu-memory",
|
|
3
3
|
"icon": "./assets/icon.png",
|
|
4
|
-
"version": "1.2.
|
|
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
|
-
|
|
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')
|