dsh-auto-memory 0.5.1 → 0.6.0

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 CHANGED
@@ -25,6 +25,26 @@ open a brand-new session tomorrow, ask *"what do you know about me?"*, and it
25
25
 
26
26
  ---
27
27
 
28
+ ## What's new in 0.6.0 (P2)
29
+
30
+ - **Harness-sessions evaluation datasets** (`evals/datasets/`): synthetic
31
+ multi-session developer transcripts with gold-label facts — durable /
32
+ ephemeral-trap / updated labels, evidence sessions, abstention probes.
33
+ The coding-agent memory scenario no public benchmark covers.
34
+ - **L1 injection recall** (zero-API runner): 100% on both datasets at the
35
+ perfect-consolidation ceiling — the injection side loses nothing.
36
+ - **L3 consolidation quality** (one LLM call per session, same code path as
37
+ the plugin): **mini recall 100% / update-correctness 100% / pollution 0%;
38
+ standard recall 83.3% / precision 84.6% / update-correctness 100% /
39
+ pollution 0%** (deepseek-chat, temperature 0). Ephemeral traps were
40
+ correctly refused in every run.
41
+ - **Product fix surfaced by the eval**: consolidation now writes
42
+ title/description in the transcript's language (Chinese transcripts used
43
+ to produce English memories — measured, then fixed).
44
+ - Community: soft-mute (third state between keep and delete, #10), panel
45
+ current-workspace fix (#9), CJK bigram echo-dedup (#5), editor busy/error
46
+ UX (#8), GB11643 checksum before id-card redaction (#7).
47
+
28
48
  ## What's new in 0.5.0 (P2)
29
49
 
30
50
  - **Three-factor ranking** (Generative Agents formula, embedding-free):
@@ -220,7 +240,6 @@ Override in your profile's `cordis.patch.yml` (config replaces wholesale):
220
240
  resume); everything flows through standard `tool/call` / `tool/result`.
221
241
 
222
242
  Deep dives: [design decisions](docs/design.md) ·
223
- [dsh source-level research](docs/api-reports.md) ·
224
243
  [postmortem: shipping a PR to awesome-dsh-plugin](docs/postmortem-pr-5696.md)
225
244
 
226
245
  ## Roadmap
package/README.zh.md CHANGED
@@ -24,6 +24,23 @@ dsh plugin --profile demo add dsh-auto-memory
24
24
 
25
25
  ---
26
26
 
27
+ ## 0.6.0 新增(P2)
28
+
29
+ - **harness-sessions 评测数据集**(`evals/datasets/`):合成多会话开发者剧本
30
+ + 金标事实(durable / ephemeral 陷阱 / updated 推翻标签、evidence 会话、
31
+ 拒答题)——公开基准都不覆盖的 coding-agent 记忆场景。
32
+ - **L1 注入召回**(零 API runner):两个数据集在完美固化上界下均 100%——
33
+ 注入侧零损耗。
34
+ - **L3 固化质量**(每会话一次 LLM 调用,与插件同一条固化代码路径):
35
+ **mini 召回 100% / 更新正确率 100% / 污染 0%;standard 召回 83.3% /
36
+ 精确率 84.6% / 更新正确率 100% / 污染 0%**(deepseek-chat,temperature 0)。
37
+ ephemeral 陷阱在每轮运行中都被正确拒绝。
38
+ - **评测反哺的产品修复**:固化描述现在跟随会话语言(中文会话曾固化出
39
+ 英文记忆——先测出,后修复)。
40
+ - 社区贡献:软停用(保留与删除之间的第三档,#10)、面板当前工作区修复
41
+ (#9)、中文 bigram 回声去重(#5)、编辑器 busy/错误 UX(#8)、身份证
42
+ 校验位前置验证(#7)。
43
+
27
44
  ## 0.5.0 新增(P2)
28
45
 
29
46
  - **三因子排序**(Generative Agents 公式的免 embedding 版):注入索引与
@@ -149,7 +166,7 @@ node scripts/demo.mjs # 不要 API key、不开浏览器:看 写入 → 索引
149
166
  评测层已抓到过真实 bug:字节预算曾遗漏指导文本、整段超支 ~800 字节
150
167
  (已修复并带回归)。
151
168
 
152
- **78 项测试(含确定性评测层)。运行时依赖仅 `yaml`。安装体积 15 kB。**
169
+ **93 项测试 + 三层评测(L0 确定性/L1 召回/L3 固化质量)。运行时依赖仅 `yaml`。**
153
170
 
154
171
  ## 安装
155
172
 
@@ -187,7 +204,6 @@ dsh --profile demo # 重启 profile 生效
187
204
  一切走标准 `tool/call` / `tool/result`。
188
205
 
189
206
  深度内容:[设计决策](docs/design.md) ·
190
- [dsh 源码级调研](docs/api-reports.md) ·
191
207
  [复盘:向 awesome-dsh-plugin 提 PR](docs/postmortem-pr-5696.md)
192
208
 
193
209
  ## 路线图
package/lib/client.js CHANGED
@@ -51,6 +51,7 @@ window.__ModuleLoader__.load({
51
51
  const [groups, setGroups] = (0, react.useState)(null);
52
52
  const [maxBytes, setMaxBytes] = (0, react.useState)(4096);
53
53
  const [error, setError] = (0, react.useState)(false);
54
+ const [actionError, setActionError] = (0, react.useState)(null);
54
55
  const [editing, setEditing] = (0, react.useState)(null);
55
56
  const [busy, setBusy] = (0, react.useState)(false);
56
57
  const load = (0, react.useCallback)(async () => {
@@ -68,12 +69,15 @@ window.__ModuleLoader__.load({
68
69
  }, [load]);
69
70
  const act = async (fn) => {
70
71
  setBusy(true);
72
+ setActionError(null);
71
73
  try {
72
74
  const response = await fn();
73
- if (!response.ok) console.warn("[auto-memory] action failed", response.status);
74
- await load();
75
+ if (!response.ok) setActionError(response.status === 404 ? t("deleteFailed") : t("saveFailed"));
76
+ } catch {
77
+ setActionError(t("saveFailed"));
75
78
  } finally {
76
79
  setBusy(false);
80
+ await load();
77
81
  }
78
82
  };
79
83
  const cwd = (group) => group.scope === "project" && group.current === true ? currentCwd() : void 0;
@@ -120,13 +124,19 @@ window.__ModuleLoader__.load({
120
124
  style: { opacity: .75 },
121
125
  children: t("intro")
122
126
  }),
123
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
127
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
124
128
  onClick: () => {
125
129
  load();
126
130
  },
127
131
  disabled: busy,
128
132
  children: t("refresh")
129
- }) }),
133
+ }), actionError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
134
+ style: {
135
+ marginLeft: "12px",
136
+ color: "#d97706"
137
+ },
138
+ children: actionError
139
+ })] }),
130
140
  total === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("empty") }),
131
141
  groups.map((group) => {
132
142
  const usage = group.indexBytes / maxBytes;
@@ -209,6 +219,10 @@ window.__ModuleLoader__.load({
209
219
  title: t("pinned"),
210
220
  children: " 📌"
211
221
  }),
222
+ memory.disabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
223
+ title: t("muted"),
224
+ children: " 🔇"
225
+ }),
212
226
  " ",
213
227
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("code", {
214
228
  style: {
@@ -282,6 +296,36 @@ window.__ModuleLoader__.load({
282
296
  children: memory.pinned ? t("unpinAction") : t("pinAction")
283
297
  }),
284
298
  " ",
299
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
300
+ disabled: busy,
301
+ onClick: () => {
302
+ (async () => {
303
+ try {
304
+ const detail = await read({
305
+ name: memory.name,
306
+ scope: group.scope,
307
+ cwd: cwd(group)
308
+ });
309
+ await act(() => write({
310
+ cwd: cwd(group),
311
+ scope: group.scope,
312
+ name: memory.name,
313
+ title: memory.title,
314
+ description: memory.description,
315
+ type: memory.type,
316
+ body: detail.body,
317
+ pinned: memory.pinned,
318
+ disabled: !memory.disabled
319
+ }));
320
+ } catch (error) {
321
+ console.warn("[auto-memory] read failed, mute aborted", error);
322
+ await load();
323
+ }
324
+ })();
325
+ },
326
+ children: memory.disabled ? t("unmuteAction") : t("muteAction")
327
+ }),
328
+ " ",
285
329
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
286
330
  disabled: busy,
287
331
  onClick: () => {
@@ -303,6 +347,7 @@ window.__ModuleLoader__.load({
303
347
  t,
304
348
  initial: editing.memory,
305
349
  initialBody: editing.body,
350
+ busy,
306
351
  onCancel: () => {
307
352
  setEditing(null);
308
353
  },
@@ -319,7 +364,7 @@ window.__ModuleLoader__.load({
319
364
  });
320
365
  }
321
366
  /** 单条记忆编辑器(标题/摘要/正文,保存即重建索引、下次注入生效)。 */
322
- function MemoryEditor({ t, initial, initialBody, onSave, onCancel }) {
367
+ function MemoryEditor({ t, initial, initialBody, busy, onSave, onCancel }) {
323
368
  const [title, setTitle] = (0, react.useState)(initial.title ?? "");
324
369
  const [description, setDescription] = (0, react.useState)(initial.description);
325
370
  const [body, setBody] = (0, react.useState)(initialBody);
@@ -383,6 +428,7 @@ window.__ModuleLoader__.load({
383
428
  style: { margin: "8px 0 0" },
384
429
  children: [
385
430
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
431
+ disabled: busy,
386
432
  onClick: () => {
387
433
  onSave({
388
434
  name: initial.name,
@@ -397,6 +443,7 @@ window.__ModuleLoader__.load({
397
443
  }),
398
444
  " ",
399
445
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
446
+ disabled: busy,
400
447
  onClick: onCancel,
401
448
  children: t("cancel")
402
449
  })
@@ -436,6 +483,9 @@ window.__ModuleLoader__.load({
436
483
  userScope: "用户级(所有工作区)",
437
484
  projectScope: "项目级",
438
485
  pinned: "置顶",
486
+ muted: "已停用(不注入)",
487
+ muteAction: "停用",
488
+ unmuteAction: "恢复",
439
489
  reads: "次读取",
440
490
  updated: "更新于",
441
491
  injectedPreview: "下一次会话将注入的索引(点开预览)",
@@ -470,6 +520,9 @@ window.__ModuleLoader__.load({
470
520
  userScope: "User scope (all workspaces)",
471
521
  projectScope: "Project scope",
472
522
  pinned: "pinned",
523
+ muted: "muted (not injected)",
524
+ muteAction: "Mute",
525
+ unmuteAction: "Unmute",
473
526
  reads: "reads",
474
527
  updated: "updated",
475
528
  injectedPreview: "Index injected into the next session (click to preview)",
@@ -537,11 +590,19 @@ window.__ModuleLoader__.load({
537
590
  currentCwd: () => currentCwd(ctx)
538
591
  };
539
592
  }
540
- /** 从 sessions 快照取一个可用的工作区 cwd(官方 ui-reference 同款读取面)。 */
593
+ /** 从 sessions 快照解析当前工作区 cwd(#9:多工作区并存时不再取首个)。 */
541
594
  function currentCwd(ctx) {
542
595
  const byId = ctx.get("sessions")?.list?.getSnapshot?.()?.byId;
543
596
  if (byId === void 0) return void 0;
544
- for (const session of Object.values(byId)) if (session.cwd !== void 0 && session.cwd.length > 0) return session.cwd;
597
+ const rows = Object.values(byId).filter((session) => session.cwd !== void 0 && session.cwd.length > 0);
598
+ if (rows.length === 0) return void 0;
599
+ const running = rows.filter((session) => session.running === true);
600
+ const pool = running.length > 0 ? running : rows;
601
+ pool.sort((a, b) => {
602
+ const delta = (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
603
+ return delta !== 0 ? delta : a.cwd < b.cwd ? -1 : 1;
604
+ });
605
+ return pool[0]?.cwd;
545
606
  }
546
607
  /** 注册侧栏入口与记忆管理面板。 */
547
608
  function apply(ctx) {
package/lib/index.js CHANGED
@@ -14,7 +14,7 @@ const USER_SCOPE_DIR = "_user";
14
14
  /**
15
15
  * 记忆存储层:类型化记忆文件 CRUD + MEMORY.md 索引维护。
16
16
  *
17
- * 关键设计决策(依据源码调研与对抗性审查,见 docs/api-reports.md):
17
+ * 关键设计决策(依据源码调研与对抗性审查,见 internal research notes):
18
18
  * 1. **必须用 node:fs 而非 ctx.fs**——默认部署的 fs-sandbox 可写根不含 $DSH_HOME,
19
19
  * 走 ctx.fs 会抛 FS_SANDBOX_DENIED;官方先例 skill-filesystem 访问 $DSH_HOME
20
20
  * 下的文件同样直接用 node:fs。
@@ -114,7 +114,7 @@ function parseMemory(raw, scope) {
114
114
  try {
115
115
  const fm = parseFrontmatter(raw);
116
116
  if (!fm) return null;
117
- const { name, description, type, title, created, updated, lastRead, reads, pinned, importance } = fm.data;
117
+ const { name, description, type, title, created, updated, lastRead, reads, pinned, importance, disabled } = fm.data;
118
118
  if (typeof name !== "string" || typeof description !== "string" || description.trim().length === 0) return null;
119
119
  const parsedType = type === void 0 ? "reference" : asMemoryType(String(type));
120
120
  const asMs = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
@@ -128,6 +128,7 @@ function parseMemory(raw, scope) {
128
128
  body: fm.body.trim(),
129
129
  scope,
130
130
  ...pinned === true ? { pinned: true } : {},
131
+ ...disabled === true ? { disabled: true } : {},
131
132
  ...asImportance(importance) !== void 0 ? { importance: asImportance(importance) } : {},
132
133
  createdMs: asMs(created),
133
134
  updatedMs: asMs(updated),
@@ -146,6 +147,7 @@ function serializeMemory(record) {
146
147
  description: record.description,
147
148
  type: record.type,
148
149
  ...record.pinned === true ? { pinned: true } : {},
150
+ ...record.disabled === true ? { disabled: true } : {},
149
151
  ...record.importance !== void 0 ? { importance: record.importance } : {},
150
152
  ...record.createdMs !== void 0 ? { created: record.createdMs } : {},
151
153
  ...record.updatedMs !== void 0 ? { updated: record.updatedMs } : {},
@@ -171,7 +173,7 @@ function mergeLifecycleMeta(existing, now) {
171
173
  */
172
174
  function renderIndexBody(records) {
173
175
  const now = Date.now();
174
- const lines = [...records].sort((a, b) => {
176
+ const lines = [...records.filter((record) => record.disabled !== true)].sort((a, b) => {
175
177
  const pa = a.pinned === true ? 0 : 1;
176
178
  const pb = b.pinned === true ? 0 : 1;
177
179
  if (pa !== pb) return pa - pb;
@@ -239,12 +241,54 @@ const SECRET_PATTERNS = [
239
241
  }
240
242
  ];
241
243
  /**
244
+ * GB11643 身份证校验位验证:18 位号码仅当末位与加权模 11 校验相符才算真身份证。
245
+ * 纯长度匹配会把订单号/雪花 ID 等任意 18 位数字一并误杀(#7)。
246
+ */
247
+ function isValidIdCard(candidate) {
248
+ if (!/^\d{17}[\dXx]$/.test(candidate)) return false;
249
+ const WEIGHTS = [
250
+ 7,
251
+ 9,
252
+ 10,
253
+ 5,
254
+ 8,
255
+ 4,
256
+ 2,
257
+ 1,
258
+ 6,
259
+ 3,
260
+ 7,
261
+ 9,
262
+ 10,
263
+ 5,
264
+ 8,
265
+ 4,
266
+ 2
267
+ ];
268
+ const CHECKS = [
269
+ "1",
270
+ "0",
271
+ "X",
272
+ "9",
273
+ "8",
274
+ "7",
275
+ "6",
276
+ "5",
277
+ "4",
278
+ "3",
279
+ "2"
280
+ ];
281
+ let sum = 0;
282
+ for (let i = 0; i < 17; i++) sum += Number(candidate[i]) * WEIGHTS[i];
283
+ return candidate[17].toUpperCase() === CHECKS[sum % 11];
284
+ }
285
+ /**
242
286
  * 写入前脱敏(纯函数,可测):把高置信密钥/手机号/身份证替换为 [REDACTED:kind]。
243
- * 应用于 description 与 body——所有写路径(工具/固化/面板)都经 store.write,单点拦截。
287
+ * 应用于 description/body——所有写路径(工具/固化/面板)都经 store.write,单点拦截。
244
288
  */
245
289
  function redactSecrets(text) {
246
290
  let out = text;
247
- for (const { kind, re } of SECRET_PATTERNS) out = out.replace(re, `[REDACTED:${kind}]`);
291
+ for (const { kind, re } of SECRET_PATTERNS) out = out.replace(re, (match) => kind === "id-card" && !isValidIdCard(match) ? match : `[REDACTED:${kind}]`);
248
292
  return out;
249
293
  }
250
294
  /**
@@ -280,9 +324,19 @@ function memoryScore(record, nowMs) {
280
324
  /**
281
325
  * 描述相似度(Jaccard 词集,纯函数,可测):固化防回声的查重依据——
282
326
  * "既有记忆的复述不是新信息",相似度过高的候选跳过而非堆积。
327
+ * CJK 感知:连续中文段按字符二元组(bigram)切分(检索领域标准做法)——
328
+ * 整段单 token 会让中文近重复仅得 ~0.4,防回声对中文完全失效(#5)。
283
329
  */
284
330
  function descriptionSimilarity(a, b) {
285
- const words = (text) => new Set(text.toLowerCase().split(/[^a-z0-9一-鿿]+/).filter((w) => w.length > 0));
331
+ const words = (text) => {
332
+ const tokens = text.toLowerCase().split(/[^a-z0-9一-鿿]+/).filter((w) => w.length > 0);
333
+ const out = /* @__PURE__ */ new Set();
334
+ for (const token of tokens) if (/^[一-鿿]+$/.test(token)) {
335
+ if (token.length === 1) out.add(token);
336
+ else for (let i = 0; i + 1 < token.length; i++) out.add(token.slice(i, i + 2));
337
+ } else out.add(token);
338
+ return out;
339
+ };
286
340
  const wa = words(a);
287
341
  const wb = words(b);
288
342
  if (wa.size === 0 || wb.size === 0) return 0;
@@ -364,11 +418,13 @@ var MemoryStore = class {
364
418
  await this.withLockRecovery(join(dir, INDEX_FILENAME), async () => {
365
419
  const existing = parseMemory(await promises.readFile(file, "utf8").catch(() => ""), scope);
366
420
  const pinned = record.pinned ?? existing?.pinned;
421
+ const disabled = record.disabled ?? existing?.disabled;
367
422
  const importance = record.importance ?? existing?.importance;
368
423
  const title = record.title ?? existing?.title;
369
424
  written = {
370
425
  ...record,
371
426
  ...pinned === true ? { pinned: true } : {},
427
+ ...disabled === true ? { disabled: true } : {},
372
428
  ...importance !== void 0 ? { importance } : {},
373
429
  ...title !== void 0 ? { title: sanitizeContent(title) } : {},
374
430
  description: sanitizeContent(record.description),
@@ -615,6 +671,10 @@ function registerMemoryTools(ctx, store, enableUserScope) {
615
671
  type: "boolean",
616
672
  description: "Pin this memory: it sorts first in the index, survives budget truncation, and is never hidden by staleness eviction. Use when the user explicitly says to keep something forever; unpin by passing false"
617
673
  },
674
+ disabled: {
675
+ type: "boolean",
676
+ description: "Soft-mute: keep the file but exclude it from the injected index (the memory stops reaching the model without being deleted). Use when the user says \"stop bringing this up\" or similar; unmute by passing false"
677
+ },
618
678
  scope: {
619
679
  type: "string",
620
680
  enum: ["project", "user"],
@@ -661,6 +721,7 @@ function registerMemoryTools(ctx, store, enableUserScope) {
661
721
  name,
662
722
  title: args.title !== void 0 && args.title.trim().length > 0 ? args.title.trim().slice(0, 80) : void 0,
663
723
  ...args.pinned !== void 0 ? { pinned: args.pinned } : {},
724
+ ...args.disabled !== void 0 ? { disabled: args.disabled } : {},
664
725
  description: args.description.trim().slice(0, 160),
665
726
  type: args.type,
666
727
  body: args.body.slice(0, 2e3)
@@ -1252,6 +1313,9 @@ sessions for this user, following these rules:
1252
1313
  Return ONLY a JSON array, each element exactly:
1253
1314
  {"name":"kebab-case-id","title":"short human heading","description":"one line <=160 chars","type":"user|feedback|project|reference","body":"the fact in markdown; for feedback include **Why:** and **How to apply:** lines","scope":"project","importance":5}
1254
1315
  Use scope "user" only for user-global preferences; default "project".
1316
+ Write "name" in kebab-case English; write title/description/body in the SAME
1317
+ language as the transcript (a Chinese transcript gets Chinese memories —
1318
+ the user will read them).
1255
1319
 
1256
1320
  <transcript>
1257
1321
  ${transcript}
@@ -1399,6 +1463,7 @@ async function readGroup(dir, scope, slug) {
1399
1463
  description: record.description,
1400
1464
  type: record.type,
1401
1465
  pinned: record.pinned === true,
1466
+ disabled: record.disabled === true,
1402
1467
  reads: record.reads ?? 0,
1403
1468
  ...record.updatedMs !== void 0 ? { updatedMs: record.updatedMs } : {},
1404
1469
  bytes: Buffer.byteLength(raw, "utf8")
@@ -1470,7 +1535,8 @@ function registerMemoryApi(ctx, store, rootDir, config) {
1470
1535
  description: record.description,
1471
1536
  type: record.type,
1472
1537
  body: record.body,
1473
- pinned: record.pinned === true
1538
+ pinned: record.pinned === true,
1539
+ disabled: record.disabled === true
1474
1540
  });
1475
1541
  } catch (error) {
1476
1542
  return json({ error: String(error) }, 400);
@@ -1493,6 +1559,7 @@ function registerMemoryApi(ctx, store, rootDir, config) {
1493
1559
  name,
1494
1560
  title: typeof body.title === "string" && body.title.trim().length > 0 ? body.title.trim().slice(0, 80) : void 0,
1495
1561
  ...body.pinned !== void 0 ? { pinned: body.pinned } : {},
1562
+ ...body.disabled === true ? { disabled: true } : {},
1496
1563
  description: body.description.trim().slice(0, 160),
1497
1564
  type: body.type,
1498
1565
  body: typeof body.body === "string" ? body.body.slice(0, 2e3) : ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-auto-memory",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Claude Code style auto-memory plugin for DeepSeek Harness: typed memory files + MEMORY.md index auto-injected into the system prompt",
5
5
  "type": "module",
6
6
  "license": "MIT",