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/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: memories.length }
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: `整理完成(远程)· 写入 ${memories.length}`,
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 当成诊断契约(第 267 行),修改位置时同步更新该诊断映射,
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
- registrationReport = { attempted: 0, registered: 0, failures: [] }
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
- const usable = tools.filter((tool) => tool)
505
- registrationReport.registered = usable.length
506
- if (registrationReport.failures.length > 0) {
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: host schema DSL refused ${registrationReport.failures.length} of ${registrationReport.attempted} tools: ${registrationReport.failures.map((failure) => `${failure.name} (${failure.message})`).join('; ')}`)
558
+ ctx?.logger?.warn?.(`dsh-memu-memory: tool mount refused ${refused.length}/${usable.length} at register: ${refused.join('; ')}`)
509
559
  } catch {}
510
560
  }
511
- const disposers = usable.map((tool) => ctx.tools.register(tool))
512
- return () => {
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
  }
@@ -849,8 +849,13 @@ export function createVectorMigrator(options = {}) {
849
849
  ledger: readLedger,
850
850
  /**
851
851
  * 当前生效代际里"哪些 id 已经写进向量库"(按 scope key:libraryId 或 'global')。
852
- * 迁移写库时刻意不碰索引,所以索引里的老条目没有 entry.vector 戳;界面要回答
853
- * "这条在不在向量库"只能查账本。
852
+ * 两条腿合成,缺一都会数错:
853
+ * * 账本腿:迁移写库时刻意不碰索引,所以迁移写入的条目没有 entry.vector 戳,
854
+ * 只能查账本。但永久删除(forget)只删引擎行和索引行、不动账本,所以账本里的
855
+ * id 还要扣掉"索引里已经没有的"(删掉的条目必然已从索引移除)。
856
+ * * 戳腿:迁移之后的写入(remember/import/review/update/curator)在索引条目上带
857
+ * entry.vector 戳。只认当前代际的戳 —— 旧代际的戳说明它属于旧空间,是否还在
858
+ * 当前空间由账本腿回答(切代际会把当时可准入的条目全部重写一遍并记账)。
854
859
  */
855
860
  membership: () => {
856
861
  const state = vectorState()
@@ -858,10 +863,30 @@ export function createVectorMigrator(options = {}) {
858
863
  if (!active) return null
859
864
  const ledger = readLedger({ fingerprint: active.fingerprint, generation: active.generation })
860
865
  if (!ledger) return null
866
+ const generation = Number(active.generation)
861
867
  const out = {}
862
868
  for (const scope of scopesFor(active.databases)) {
863
869
  const bucket = ledger.libraries?.[scope.key]
864
- out[scope.key] = bucket?.items && typeof bucket.items === 'object' ? Object.keys(bucket.items) : []
870
+ const migrated = bucket?.items && typeof bucket.items === 'object' ? Object.keys(bucket.items) : []
871
+ let entries = null
872
+ try {
873
+ const loaded = impl.loadIndex(scope.libraryId)
874
+ // loadIndex 的约定是同步读盘;拿到 Promise(测试替身)时按读不到处理,不猜。
875
+ entries = loaded && typeof loaded.then === 'function' ? null : (loaded?.entries || {})
876
+ } catch { entries = null }
877
+ const ids = new Set()
878
+ for (const id of migrated) {
879
+ // 索引读不出来时宁可贵一点按"还在"算:损坏是暂时的,误扣会让用户以为记忆丢了。
880
+ if (!entries || Object.prototype.hasOwnProperty.call(entries, id)) ids.add(id)
881
+ }
882
+ if (entries) {
883
+ for (const [id, entry] of Object.entries(entries)) {
884
+ // remote-api 的戳描述的是远端空间,不属于本地代际;词法路径没有戳。
885
+ if (!entry || typeof entry !== 'object' || entry.vector?.route !== 'managed-local') continue
886
+ if (Number(entry.vector.generation) === generation) ids.add(id)
887
+ }
888
+ }
889
+ out[scope.key] = [...ids]
865
890
  }
866
891
  return out
867
892
  },
@@ -522,6 +522,30 @@ export function activeGeneration() {
522
522
  return state?.active?.generation ?? null
523
523
  }
524
524
 
525
+ /**
526
+ * 条目级"已入向量库"戳(entry.vector)。迁移写库故意不碰索引,所以迁移写入的条目没有
527
+ * 戳、只有账本知道;而迁移之后的每一次向量写入(remember/import/review/update/curator)
528
+ * 都必须在提交成功后把这条戳落进索引。词法路径没写库,不戳;remote-api 沿用历史口径
529
+ * (本地还留着 active 代际时照样记,界面只拿它回答"进没进向量后端")。空间归属由
530
+ * membership() 的戳腿自行过滤:只认 route === 'managed-local' 且代际匹配的戳。只读不抛错。
531
+ */
532
+ export function activeEntryStamp(route) {
533
+ if (route === 'lexical') return null
534
+ try {
535
+ const active = readVectorState({ tolerateMissing: true }).state?.active
536
+ if (!active) return null
537
+ return {
538
+ route,
539
+ model: active.profile?.catalogId || null,
540
+ generation: active.generation,
541
+ fingerprint: String(active.fingerprint || '').slice(0, 8),
542
+ at: stamp(),
543
+ }
544
+ } catch {
545
+ return null
546
+ }
547
+ }
548
+
525
549
  /** active manifest 里的 DB 集合,一次性取得(避免多库逐个换混空间)。 */
526
550
  export function activeDatabases() {
527
551
  return databaseMapFor(vectorState()?.active)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-memu-memory",
3
3
  "icon": "./assets/icon.png",
4
- "version": "1.2.0",
4
+ "version": "1.2.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",