dsh-memu-memory 1.2.0 → 1.2.1
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/README.md +8 -4
- package/README.zh.md +9 -5
- package/docs/contributing.md +2 -2
- package/docs/env-and-commands.md +8 -0
- package/docs/troubleshooting.md +4 -3
- package/lib/activity-log.js +22 -5
- package/lib/binding-diagnostics.js +1 -1
- package/lib/client.js +302 -24
- package/lib/consolidation-worker.js +43 -14
- package/lib/curator.js +86 -31
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/memory.js +314 -70
- package/lib/private.js +98 -6
- package/lib/rpc.js +45 -4
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +64 -8
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/package.json +3 -2
- package/scripts/replay.mjs +573 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +142 -10
- package/tests/consolidation-snapshot.test.mjs +43 -4
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph.test.mjs +17 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui.test.mjs +3 -2
package/lib/private.js
CHANGED
|
@@ -181,8 +181,14 @@ export async function requestRemoteConsolidation(ctx, sessionId, { signal, timeo
|
|
|
181
181
|
signal,
|
|
182
182
|
timeoutMs,
|
|
183
183
|
})
|
|
184
|
+
// 写入计数必须说真话:`memories` 是模型给的清单,逐条 privateRemember 可能被
|
|
185
|
+
// 事实级去重拒绝(deduped,不落库)或因矛盾暂扣(pending_review,仅入索引)。
|
|
186
|
+
// count 只数真正落库的;被拒/暂扣分列进 detail,否则"写入 N 条"就是误报。
|
|
187
|
+
let written = 0
|
|
188
|
+
let deduped = 0
|
|
189
|
+
let held = 0
|
|
184
190
|
for (const memory of memories) {
|
|
185
|
-
await privateRemember({
|
|
191
|
+
const outcome = await privateRemember({
|
|
186
192
|
...memory,
|
|
187
193
|
exec: { agent: { session: { header: { id: sessionId } } } },
|
|
188
194
|
content: memory.content,
|
|
@@ -192,12 +198,17 @@ export async function requestRemoteConsolidation(ctx, sessionId, { signal, timeo
|
|
|
192
198
|
confidence: memory.confidence,
|
|
193
199
|
source: memory.source || 'inferred',
|
|
194
200
|
})
|
|
201
|
+
if (outcome?.deduped === true) deduped += 1
|
|
202
|
+
else if (outcome?.entry?.status === 'pending_review') held += 1
|
|
203
|
+
else written += 1
|
|
195
204
|
}
|
|
196
205
|
setSessionBinding(sessionId, { lastSleepSeq: events[events.length - 1]?.seq })
|
|
197
|
-
const result = { ok: true, sessionId, backend: 'api', count:
|
|
206
|
+
const result = { ok: true, sessionId, backend: 'api', count: written, deduped, held }
|
|
198
207
|
record({
|
|
199
208
|
kind: 'consolidate', scope: 'private', sessionId, libraryId: binding.libraryId || null, ok: true,
|
|
200
|
-
message: `整理完成(远程)· 写入 ${
|
|
209
|
+
message: `整理完成(远程)· 写入 ${written}`
|
|
210
|
+
+ (deduped ? ` · 去重拦下 ${deduped}` : '')
|
|
211
|
+
+ (held ? ` · 待复核 ${held}` : ''),
|
|
201
212
|
detail: { ...result, forced: force },
|
|
202
213
|
})
|
|
203
214
|
return result
|
|
@@ -264,10 +275,79 @@ function turnEndFailure(event) {
|
|
|
264
275
|
return 'the fork turn ended ' + (reason.kind || 'without a reason') + (message ? ': ' + message : '')
|
|
265
276
|
}
|
|
266
277
|
|
|
278
|
+
// PTC 兼容(2026-10-31 现场 worker-tools-ptc-only):PTC 会话的 header 工具表只有
|
|
279
|
+
// run_code,其余工具走 run_code 程序里的 SDK 绑定(宿主 ptc.ts 用
|
|
280
|
+
// tools.schemas(agent, toolSet) 枚举"调用 agent 的可见面")。preset 组合没列出本插件
|
|
281
|
+
// 时,fork 的注册表里根本没有 memu_*,worker 既不能原生调用也没有 SDK 绑定,回执只能
|
|
282
|
+
// 是 tools-unavailable。修法与 preset-scope.js 的注入挂载同构:把读写四件挂进 fork
|
|
283
|
+
// 自己的 ctx scope —— 注册表按 scope 链取可见面,所以只对这个 worker 可见,不扩散到
|
|
284
|
+
// 其它会话;挂载点必须在 followup 之前(宿主在 step 开始时 resolve 一次 admitted set,
|
|
285
|
+
// 之后注册的工具不进本步)。失败不阻断整理:worker 会按任务书如实回执 tools-unavailable。
|
|
286
|
+
async function attachWorkerTools(agents, childId, parentId) {
|
|
287
|
+
const wanted = [...WORKER_WRITE_TOOLS, ...WORKER_READ_TOOLS]
|
|
288
|
+
const ctxOf = (record) => {
|
|
289
|
+
try { return record?.ctx || record?.agent?.ctx || null } catch { return null }
|
|
290
|
+
}
|
|
291
|
+
let parentHasCtx = null
|
|
292
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
293
|
+
let record = null
|
|
294
|
+
try { record = agents.get?.(childId) || null } catch { record = null }
|
|
295
|
+
if (record) {
|
|
296
|
+
const actx = ctxOf(record)
|
|
297
|
+
// 有记录但没有 ctx:这版宿主不暴露 agent context(preset-scope.js 的同款限制),
|
|
298
|
+
// 重试不会变出来。ctx 在但没有 tools 服务:组合里就没有。两者都直接放弃。
|
|
299
|
+
if (!actx || !(actx.tools && typeof actx.tools.register === 'function')) break
|
|
300
|
+
try {
|
|
301
|
+
const dispose = registerTools(actx, { only: wanted })
|
|
302
|
+
// tools.js 在 disposer 上附挂实际注册成功的名单(编译 + register 都过的那部分)。
|
|
303
|
+
// 读它,不再把 wanted 全量当成"已挂载"——部分挂载必须结构化可见。
|
|
304
|
+
const attached = Array.isArray(dispose?.attached) ? [...dispose.attached] : null
|
|
305
|
+
return {
|
|
306
|
+
dispose,
|
|
307
|
+
attached,
|
|
308
|
+
requested: wanted,
|
|
309
|
+
// 名单缺失 = 无法证明挂全:partial 照实标 true,attached 落 null(与失败同口径)。
|
|
310
|
+
partial: attached ? attached.length < wanted.length : true,
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
warn('consolidation worker tool attach failed: ' + String(error?.message || error))
|
|
314
|
+
return null
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// 子记录还没出现:只在这个宿主确实会暴露 ctx(父记录带 ctx)时才值得短退避等它。
|
|
318
|
+
if (parentHasCtx === null) parentHasCtx = Boolean(ctxOf(agents.get?.(parentId)))
|
|
319
|
+
if (!parentHasCtx) break
|
|
320
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
321
|
+
}
|
|
322
|
+
warn('consolidation worker has no tools service on its own context; the worker may be unable to write memories')
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 活动日志"整理已开始"条目的挂载元数据口径(workerTools* 三件):
|
|
327
|
+
// workerToolsAttached 实际注册成功的工具名数组;null = 挂载没有完成或无法确认
|
|
328
|
+
// (fork ctx 无 tools 服务、register 抛错、宿主没回名单),
|
|
329
|
+
// 与"挂全了"严格区分——这正是旧字段把 wanted 全量误报成
|
|
330
|
+
// 已挂载的教训。
|
|
331
|
+
// workerToolsRequested 请求挂载的完整 wanted 名单;与 attached 全等才是全量挂载。
|
|
332
|
+
// workerToolsPartial true = 未证明 worker 拿到完整 wanted 集(子集、空集、或挂载
|
|
333
|
+
// 整体失败)。刻意把"一件都没挂上"也标 true:若失败标 false,
|
|
334
|
+
// false 会被一眼读成"全量挂载",元数据又不准了。
|
|
335
|
+
function workerToolsMeta(workerTools) {
|
|
336
|
+
const wanted = [...WORKER_WRITE_TOOLS, ...WORKER_READ_TOOLS]
|
|
337
|
+
if (!workerTools) {
|
|
338
|
+
return { workerToolsAttached: null, workerToolsRequested: wanted, workerToolsPartial: true }
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
workerToolsAttached: Array.isArray(workerTools.attached) ? workerTools.attached : null,
|
|
342
|
+
workerToolsRequested: Array.isArray(workerTools.requested) ? workerTools.requested : wanted,
|
|
343
|
+
workerToolsPartial: workerTools.partial === true,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
267
347
|
const inFlight = new Map()
|
|
268
348
|
|
|
269
349
|
// 这里的 import 刻意写在语句之后:lib/binding-diagnostics.js 的 RUNTIME_STATE 把 `inFlight`
|
|
270
|
-
// 的 file:line
|
|
350
|
+
// 的 file:line 当成诊断契约(当前 lib/private.js:336),修改位置时同步更新该诊断映射,
|
|
271
351
|
// 而那是别人的文件、本包不改。ESM 的 import 声明是提升的,写在语句之后合法。
|
|
272
352
|
import { warn } from './paths.js'
|
|
273
353
|
import { captureSessionSnapshot } from './consolidation-snapshot.js'
|
|
@@ -276,7 +356,8 @@ import {
|
|
|
276
356
|
recoverStaleJobs, recordOperation, scopeGenerationOf,
|
|
277
357
|
} from './consolidation-ledger.js'
|
|
278
358
|
import { decideCompletion, receiptContractText } from './consolidation-receipt.js'
|
|
279
|
-
import { buildWorkerBrief, probeToolSurface } from './consolidation-worker.js'
|
|
359
|
+
import { buildWorkerBrief, probeToolSurface, WORKER_READ_TOOLS, WORKER_WRITE_TOOLS } from './consolidation-worker.js'
|
|
360
|
+
import { registerTools } from './tools.js'
|
|
280
361
|
|
|
281
362
|
// 账本异常必须可见;调用方只有拿到明确的持久租约才可启动 worker。
|
|
282
363
|
// 其余读写失败返回保守结果,不得据此推进已确认边界。
|
|
@@ -325,6 +406,7 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
|
|
|
325
406
|
let turnEnd = null
|
|
326
407
|
let jobId = null
|
|
327
408
|
let scopeChanged = null
|
|
409
|
+
let workerTools = null
|
|
328
410
|
const assistantTexts = []
|
|
329
411
|
let childId = null
|
|
330
412
|
// 每次整理都要在活动日志里留一条:谁触发(手动/空闲)、fork 有没有建、结果是完成、
|
|
@@ -522,6 +604,13 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
|
|
|
522
604
|
signal: abort.signal,
|
|
523
605
|
})
|
|
524
606
|
|
|
607
|
+
// header 无法证明 worker 有写工具时(PTC 会话名单只有 run_code / 旧会话没有名单),
|
|
608
|
+
// 把读写四件挂进 fork 自己的 scope,保证 SDK 面(PTC)与原生面(普通会话)都真能写入。
|
|
609
|
+
// 标准 preset 的 fork 名单里已有 memu_*(probe.hasWrite === true),不重复挂。
|
|
610
|
+
if (probe.hasWrite !== true) {
|
|
611
|
+
workerTools = await attachWorkerTools(agents, childId, sessionId)
|
|
612
|
+
}
|
|
613
|
+
|
|
525
614
|
// 先记"已开始"再等结果:一个大会话的 fork 可能跑好几分钟,没有这条记录,
|
|
526
615
|
// 侧边栏和日志页在等待期间只会显示上一次的结果,用户会以为按钮没生效。
|
|
527
616
|
record({
|
|
@@ -531,7 +620,9 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
|
|
|
531
620
|
libraryId: binding.libraryId || null,
|
|
532
621
|
ok: true,
|
|
533
622
|
message: '整理已开始 · ' + (force ? '手动' : '空闲') + ' · fork ' + childId.slice(-8) + ' · ' + events.length + ' 条事件 · 快照 ' + snapshot.hash,
|
|
534
|
-
detail: { childId, jobId, force, events: events.length, phase: 'started', snapshot: snapshotMeta
|
|
623
|
+
detail: { childId, jobId, force, events: events.length, phase: 'started', snapshot: snapshotMeta,
|
|
624
|
+
probe: { source: probe.source, hasWrite: probe.hasWrite, ptc: probe.ptcObserved === true },
|
|
625
|
+
...workerToolsMeta(workerTools) },
|
|
535
626
|
})
|
|
536
627
|
|
|
537
628
|
// turn/end 是这一轮的唯一权威结果;whenIdle 只说明 driver 停了(失败的回合也停)。
|
|
@@ -731,6 +822,7 @@ export async function requestHistoricalConsolidation(ctx, sessionId, { maxWaitMs
|
|
|
731
822
|
inFlight.delete(sessionId)
|
|
732
823
|
abort.abort()
|
|
733
824
|
try { offSession?.() } catch {}
|
|
825
|
+
try { workerTools?.dispose?.() } catch {}
|
|
734
826
|
try { await handle?.dispose?.() } catch {}
|
|
735
827
|
try { if (childId) deleteSessionBinding(childId) } catch {}
|
|
736
828
|
}
|
package/lib/rpc.js
CHANGED
|
@@ -18,7 +18,7 @@ import { statsView } from './stats.js'
|
|
|
18
18
|
import { getMemuConfig, saveMemuConfig, setEmbeddingSource, testMemuConfig, getSummaryConfig, getVisionConfig } from './memu-config.js'
|
|
19
19
|
import { getLocalVectorState, downloadLocalVectorModel, installLocalVectorRuntime, selectLocalVectorModel, switchLocalVectorProfile, rollbackLocalVectorProfile, abortLocalVectorMigration, testLocalVector } from './local-vector-service.js'
|
|
20
20
|
import { webUiHtml, WEBUI_PATH, standaloneHtml, standaloneAsset, SETTINGS_PATH } from './webui.js'
|
|
21
|
-
import { getGraph, listEntities, createEntity, renameEntity, linkEntries, unlinkEntry, getRelationship, updateRelationship, graphExport, restoreGraphData } from './graph.js'
|
|
21
|
+
import { getGraph, listEntities, createEntity, renameEntity, deleteEntity, linkEntries, unlinkEntry, getRelationship, updateRelationship, graphExport, restoreGraphData } from './graph.js'
|
|
22
22
|
import { getAttachment, attachmentThumbnail, attachmentPreview, exportMediaBundle, importMediaBundle, streamMediaExport, importMediaStream } from './media.js'
|
|
23
23
|
import { listEvents, logStats, clearEvents } from './activity-log.js'
|
|
24
24
|
|
|
@@ -255,13 +255,44 @@ async function readSessionTitles(sessionQuery, ids) {
|
|
|
255
255
|
for (const r of await sessionQuery.readTitleSnapshots(ids) || []) {
|
|
256
256
|
if (r?.status !== 'fulfilled' || !r.sessionId) continue
|
|
257
257
|
const title = r.value?.title?.title || r.value?.session?.title
|
|
258
|
-
if (title) titles.set(r.sessionId, title)
|
|
258
|
+
if (title && String(title).trim()) titles.set(r.sessionId, String(title).trim())
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
} catch {}
|
|
262
262
|
return titles
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// 日志行原本只有 sessionId/libraryId 裸 id:日志页和侧边栏提醒要回答"这是哪个对话、
|
|
266
|
+
// 哪个文件夹",得在服务端把标题与库名拼上。标题走宿主 readTitleSnapshots(批量一次),
|
|
267
|
+
// 查不到的行保留 null,客户端回退到短 id。
|
|
268
|
+
async function decorateLogRows(ctx, rows) {
|
|
269
|
+
const ids = [...new Set(rows.map(row => row?.sessionId).filter(Boolean))]
|
|
270
|
+
const titles = await readSessionTitles(ctx?.get?.('sessionQuery'), ids)
|
|
271
|
+
return rows.map(row => ({
|
|
272
|
+
...row,
|
|
273
|
+
sessionTitle: row?.sessionId ? (titles.get(row.sessionId) || null) : null,
|
|
274
|
+
libraryName: row?.libraryId ? (getLibrary(row.libraryId)?.name || null) : null,
|
|
275
|
+
}))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 侧边栏"整理提醒"的数据源:跨会话(不按当前对话过滤),失败/成功两列分开,
|
|
279
|
+
// 每列只取最新 limit 条。失败的行带会话标题与库名,客户端用它挂跳转按钮。
|
|
280
|
+
async function consolidationFeed(ctx, payload = {}) {
|
|
281
|
+
const perColumn = Math.max(1, Math.min(20, Number(payload?.limit) || 6))
|
|
282
|
+
const rows = await decorateLogRows(ctx, listEvents({ kind: 'consolidate', limit: 80 }))
|
|
283
|
+
const slim = ({ detail, ...row }) => ({
|
|
284
|
+
...row,
|
|
285
|
+
reasonCode: detail?.reasonCode || detail?.code || null,
|
|
286
|
+
timedOut: detail?.timedOut === true,
|
|
287
|
+
count: detail?.count ?? null,
|
|
288
|
+
force: detail?.force === true,
|
|
289
|
+
})
|
|
290
|
+
return {
|
|
291
|
+
failures: rows.filter(row => row.ok === false).slice(0, perColumn).map(slim),
|
|
292
|
+
successes: rows.filter(row => row.ok !== false).slice(0, perColumn).map(slim),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
265
296
|
// The host's session corpus is the authority for "this conversation still
|
|
266
297
|
// exists". A binding in state.json is deliberately kept after a conversation is
|
|
267
298
|
// deleted so its memory library survives; it must not keep the dead row visible.
|
|
@@ -882,6 +913,8 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
882
913
|
return createEntity(payload || {})
|
|
883
914
|
case 'renameEntity':
|
|
884
915
|
return renameEntity(payload || {})
|
|
916
|
+
case 'deleteEntity':
|
|
917
|
+
return deleteEntity(payload || {})
|
|
885
918
|
case 'linkEntries':
|
|
886
919
|
return linkEntries(payload || {})
|
|
887
920
|
case 'unlinkEntry':
|
|
@@ -1140,7 +1173,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
1140
1173
|
return engineView()
|
|
1141
1174
|
case 'getActivityLog':
|
|
1142
1175
|
return {
|
|
1143
|
-
events: listEvents({
|
|
1176
|
+
events: await decorateLogRows(ctx, listEvents({
|
|
1144
1177
|
limit: payload?.limit,
|
|
1145
1178
|
kind: payload?.kind,
|
|
1146
1179
|
level: payload?.level,
|
|
@@ -1148,9 +1181,17 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
1148
1181
|
sessionId: payload?.sessionId,
|
|
1149
1182
|
since: payload?.since,
|
|
1150
1183
|
q: payload?.q,
|
|
1151
|
-
}),
|
|
1184
|
+
})),
|
|
1152
1185
|
stats: logStats(),
|
|
1153
1186
|
}
|
|
1187
|
+
case 'getConsolidationFeed':
|
|
1188
|
+
return await consolidationFeed(ctx, payload)
|
|
1189
|
+
case 'clearConsolidationFailures': {
|
|
1190
|
+
// 侧边栏失败提醒的"清除":只删 consolidate 且 ok=false 的行。返回删除条数和
|
|
1191
|
+
// 清除后的新 feed,客户端直接回填,不用二次拉取。
|
|
1192
|
+
const cleared = clearEvents({ kind: 'consolidate', ok: false })
|
|
1193
|
+
return { cleared: cleared.removed, feed: await consolidationFeed(ctx, payload) }
|
|
1194
|
+
}
|
|
1154
1195
|
case 'clearActivityLog':
|
|
1155
1196
|
return clearEvents()
|
|
1156
1197
|
default:
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
* 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.
|
|
82
|
+
*/
|
|
83
|
+
export function similarTexts(aText, bText) {
|
|
84
|
+
const a = String(aText || '')
|
|
85
|
+
const b = String(bText || '')
|
|
86
|
+
if (lengthGap(a, b) > LENGTH_PRUNE_RATIO) {
|
|
87
|
+
const aClean = clean(a)
|
|
88
|
+
const bClean = clean(b)
|
|
89
|
+
if (!aClean || !bClean) return 0
|
|
90
|
+
const shortClean = aClean.length <= bClean.length ? aClean : bClean
|
|
91
|
+
const longClean = aClean.length <= bClean.length ? bClean : aClean
|
|
92
|
+
if (shortClean.length < PROBE_MIN_CHARS || shortClean.length > PROBE_MAX_CHARS) return 0
|
|
93
|
+
// Probe 1: verbatim containment of the short text inside the long one.
|
|
94
|
+
if (longClean.includes(shortClean)) return CONTAINMENT_FLOOR
|
|
95
|
+
// 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.
|
|
100
|
+
for (let i = 0; i < shortClean.length - 1; i += 1) {
|
|
101
|
+
if (!longClean.includes(shortClean.slice(i, i + 2))) return 0
|
|
102
|
+
}
|
|
103
|
+
return CORE_COVER_FLOOR
|
|
104
|
+
}
|
|
105
|
+
return dice(bigrams(a), bigrams(b))
|
|
106
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -162,9 +162,13 @@ function formatGlobalItem(item, index, options = {}) {
|
|
|
162
162
|
return lines.join('\n')
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
export function registerTools(ctx) {
|
|
165
|
+
export function registerTools(ctx, { only = null } = {}) {
|
|
166
166
|
const tools = []
|
|
167
|
-
|
|
167
|
+
// 全量注册(宿主装载)才更新全局 registrationReport:整理 worker 的按需挂载
|
|
168
|
+
// (private.js attachWorkerTools,only=读写四件)不能把 memu_status 的注册清单
|
|
169
|
+
// 覆盖成 4/4,否则其它会话看到的是 worker 的子集,可观测性就断了。
|
|
170
|
+
const reportSaved = only ? registrationReport : null
|
|
171
|
+
if (!only) registrationReport = { attempted: 0, registered: 0, failures: [] }
|
|
168
172
|
tools.push(toolBase(
|
|
169
173
|
'memu_private_link',
|
|
170
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.',
|
|
@@ -223,6 +227,7 @@ export function registerTools(ctx) {
|
|
|
223
227
|
},
|
|
224
228
|
dominance: { type: 'number', description: '谁在掌控:-1 完全失控 … +1 完全掌控。省略=继承当前会话。' },
|
|
225
229
|
peak: { type: 'boolean', description: 'A high point or low point of the arc — peaks and endings stay easier to recall.' },
|
|
230
|
+
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
231
|
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
232
|
track: { type: 'string', description: 'memory (default) or habit for an aggregated expression/sticker habit; do not write one habit per occurrence.' },
|
|
228
233
|
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 +264,16 @@ export function registerTools(ctx) {
|
|
|
259
264
|
dominance: args.dominance ?? null,
|
|
260
265
|
peak: args.peak === true,
|
|
261
266
|
salientDate: args.salient_date ?? null,
|
|
267
|
+
allowDuplicate: args.allow_duplicate === true,
|
|
262
268
|
})
|
|
269
|
+
// 事实级去重:几乎相同的重写被拒,返回的是
|
|
270
|
+
// { deduped, existingId, hint },没有 entry——必须先接住,否则 result.entry.layer 抛错。
|
|
271
|
+
if (result.deduped === true) {
|
|
272
|
+
return [
|
|
273
|
+
`未写入:库内已有几乎相同的记忆 [${result.existingId}](相似度 ${(Number(result.similarity) || 0).toFixed(2)})。`,
|
|
274
|
+
`要补充内容请改用 memu_private_update 传该 id 合并;若确属两次独立观测,带 allow_duplicate=true 重写。`,
|
|
275
|
+
].join('\n')
|
|
276
|
+
}
|
|
263
277
|
const entry = result.entry
|
|
264
278
|
if (result.conflict) {
|
|
265
279
|
// The entry is held (pending_review): it is stored, but excluded from
|
|
@@ -501,17 +515,59 @@ export function registerTools(ctx) {
|
|
|
501
515
|
{ isConcurrencySafe: () => true },
|
|
502
516
|
))
|
|
503
517
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
518
|
+
// 整理 worker 的挂载面(private.js 的 fork 兼容):只挂读写四件,不把
|
|
519
|
+
// archive/delete/unpin 这些破坏性工具暴露给一个后台子会话。
|
|
520
|
+
const wanted = Array.isArray(only) && only.length > 0 ? only : null
|
|
521
|
+
const usable = tools.filter((tool) => tool && (!wanted || wanted.includes(tool.name)))
|
|
522
|
+
if (only) {
|
|
523
|
+
registrationReport = reportSaved
|
|
524
|
+
if (usable.length !== wanted.length) {
|
|
525
|
+
try {
|
|
526
|
+
ctx?.logger?.warn?.(`dsh-memu-memory: worker tool mount compiled ${usable.length}/${wanted.length}: ${wanted.join(', ')}`)
|
|
527
|
+
} catch {}
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
registrationReport.registered = usable.length
|
|
531
|
+
if (registrationReport.failures.length > 0) {
|
|
532
|
+
try {
|
|
533
|
+
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('; ')}`)
|
|
534
|
+
} catch {}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// register 这一步也逐件 fail-open(与 toolBase 的编译 fail-open 同一哲学):宿主
|
|
538
|
+
// 注册表拒一件(重名、scope 锁定)不能掀翻整组挂载——原来的 `.map()` 一件抛错就连
|
|
539
|
+
// 前面已注册成功的 disposer 一起丢掉,在 worker scope 上留下卸不掉的常驻注册。
|
|
540
|
+
const disposers = []
|
|
541
|
+
const attached = []
|
|
542
|
+
const refused = []
|
|
543
|
+
for (const tool of usable) {
|
|
544
|
+
try {
|
|
545
|
+
const dispose = ctx.tools.register(tool)
|
|
546
|
+
disposers.push(typeof dispose === 'function' ? dispose : null)
|
|
547
|
+
attached.push(tool.name)
|
|
548
|
+
} catch (error) {
|
|
549
|
+
refused.push(`${tool.name} (${String(error?.message || error)})`)
|
|
550
|
+
// 全量装载(宿主)才进 registrationReport:memu_status 的 refused 清单要如实反映
|
|
551
|
+
// 编译 + register 两道关都被拒了谁;worker 的按需挂载照旧不碰全局报告。
|
|
552
|
+
if (!only) registrationReport.failures.push({ name: tool.name, message: String(error?.message || error) })
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (refused.length > 0) {
|
|
556
|
+
if (!only) registrationReport.registered = attached.length
|
|
507
557
|
try {
|
|
508
|
-
ctx?.logger?.warn?.(`dsh-memu-memory:
|
|
558
|
+
ctx?.logger?.warn?.(`dsh-memu-memory: tool mount refused ${refused.length}/${usable.length} at register: ${refused.join('; ')}`)
|
|
509
559
|
} catch {}
|
|
510
560
|
}
|
|
511
|
-
|
|
512
|
-
|
|
561
|
+
// 返回值仍是可直接调用的 disposer(private.js attachWorkerTools 与宿主装载都这么用),
|
|
562
|
+
// 但附挂结构化名单:attached = 编译 + register 都真正成功的工具名;requested = 请求
|
|
563
|
+
// 名单(宿主全量装载没有 only,为 null)。部分编译/注册失败时调用方据此写活动日志,
|
|
564
|
+
// 不再把 wanted 全量误报成已挂载。
|
|
565
|
+
const disposeAll = () => {
|
|
513
566
|
for (const dispose of disposers) {
|
|
514
567
|
try { dispose?.() } catch {}
|
|
515
568
|
}
|
|
516
569
|
}
|
|
570
|
+
disposeAll.attached = attached
|
|
571
|
+
disposeAll.requested = wanted
|
|
572
|
+
return disposeAll
|
|
517
573
|
}
|
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/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.1",
|
|
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",
|