@modusensus/dsh-mneme 0.6.5 → 0.6.7

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
@@ -5,7 +5,11 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-628%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-716%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
+ [![CI](https://img.shields.io/github/actions/workflow/status/modusensus/dsh-mneme/test.yml)](https://github.com/modusensus/dsh-mneme/actions)
10
+ [![node](https://img.shields.io/badge/node-24%2B-blue)](https://nodejs.org)
11
+ [![npm downloads](https://img.shields.io/npm/dm/@modusensus/dsh-mneme?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
12
+ [![coverage](https://img.shields.io/codecov/c/github/modusensus/dsh-mneme/main)](https://codecov.io/gh/modusensus/dsh-mneme)
9
13
 
10
14
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
15
 
@@ -226,6 +230,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
226
230
 
227
231
  | 版本 | 亮点 |
228
232
  |------|------|
233
+ | **v0.6.7** | 记忆面板前端增强:记忆删除端点 + autoTag 手动开关 + 目录 VS Code 式文件树 + 面板卡片式布局(分类栏 + search/tree/detail 三卡);716 测试全绿 |
234
+ | **v0.6.6** | kimi-k3 复验 4 项修复:autoTag 跳过已打标记忆并合并、tag: 搜索召回统计门控(避免零召回拖垮 TopK);710 测试全绿 |
229
235
  | **v0.6.5** | 整合 v0.6.2-0.6.4:Tag 系统 + 目录视图 + Tag 加权召回(全部 opt-in);709 测试全绿 |
230
236
  | **v0.6.4** | Tag 加权召回:query/session tag 交集 boost(`tagBoostEnabled` 默认关) |
231
237
  | **v0.6.3** | 目录视图:Tag 文件夹 + 无标签兜底 + 点击跳详情 |
@@ -425,7 +431,7 @@ src/
425
431
  lib/
426
432
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
427
433
  └── *.js # src 的同步分发产物
428
- test/ # 628 个 node:test 测试(含审计与三轴线压测不变量)
434
+ test/ # 716 个 node:test 测试(含审计与三轴线压测不变量)
429
435
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · benchmark-recall.js 召回基准
430
436
  ```
431
437
 
@@ -434,7 +440,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
434
440
  ```bash
435
441
  cd dsh-mneme
436
442
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
437
- npm test # 运行 628 个测试
443
+ npm test # 运行 716 个测试
438
444
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
439
445
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
440
446
  ```
package/lib/api.js CHANGED
@@ -218,6 +218,96 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
218
218
  }
219
219
  });
220
220
 
221
+ // --- app config read/write (partial: pass only the fields to change) ---
222
+ register({
223
+ kind: "exact",
224
+ path: "/api/dsh-mneme/config",
225
+ handler: async (req, res) => {
226
+ try {
227
+ if (!requireAuth(req, res, apiToken)) return;
228
+ if (req.method === "PUT" || req.method === "POST") {
229
+ const body = parseBody(await readBody(req));
230
+ const patch = {};
231
+ const setIfBoolean = (key) => {
232
+ if (Object.prototype.hasOwnProperty.call(body, key)) {
233
+ if (typeof body[key] !== "boolean") {
234
+ sendJson(res, 400, { error: `${key} must be a boolean` });
235
+ return false;
236
+ }
237
+ patch[key] = body[key];
238
+ }
239
+ return true;
240
+ };
241
+ if (!setIfBoolean("autoTagEnabled") || !setIfBoolean("manualTagEnabled")) return;
242
+ if (Object.keys(patch).length === 0) {
243
+ sendJson(res, 400, { error: "no valid config field provided" });
244
+ return;
245
+ }
246
+ const config = settings.setAutoTagConfig(patch);
247
+ sendJson(res, 200, { config });
248
+ return;
249
+ }
250
+ sendJson(res, 200, { config: settings.getAutoTagConfig() });
251
+ } catch {
252
+ sendJson(res, 500, { error: "internal" });
253
+ }
254
+ }
255
+ });
256
+
257
+ // --- delete a memory by exact id or best query match (mirrors memory_delete tool) ---
258
+ register({
259
+ kind: "exact",
260
+ path: "/api/dsh-mneme/memories",
261
+ handler: async (req, res) => {
262
+ try {
263
+ if (!requireAuth(req, res, apiToken)) return;
264
+ if (req.method !== "DELETE") {
265
+ sendJson(res, 405, { error: "method not allowed" });
266
+ return;
267
+ }
268
+ const body = parseBody(await readBody(req));
269
+ const hasId = Object.prototype.hasOwnProperty.call(body, "id");
270
+ const hasQuery = Object.prototype.hasOwnProperty.call(body, "query");
271
+ if ((hasId && hasQuery) || (!hasId && !hasQuery)) {
272
+ sendJson(res, 400, { error: "provide exactly one of id or query" });
273
+ return;
274
+ }
275
+ if (hasId) {
276
+ if (typeof body.id !== "string" || !body.id.trim()) {
277
+ sendJson(res, 400, { error: "invalid id" });
278
+ return;
279
+ }
280
+ const mem = service.getById(body.id);
281
+ if (!mem) {
282
+ sendJson(res, 200, { deleted: false });
283
+ return;
284
+ }
285
+ service.remove(body.id);
286
+ sendJson(res, 200, { deleted: true, id: body.id });
287
+ return;
288
+ }
289
+ if (typeof body.query !== "string" || !body.query.trim()) {
290
+ sendJson(res, 400, { error: "invalid query" });
291
+ return;
292
+ }
293
+ const results = await service.searchMemories(body.query, { mode: "auto", topK: 1, useRerank: true });
294
+ if (!Array.isArray(results) || results.length === 0) {
295
+ sendJson(res, 200, { deleted: false });
296
+ return;
297
+ }
298
+ const mem = results[0];
299
+ if (!mem || !mem.id) {
300
+ sendJson(res, 200, { deleted: false });
301
+ return;
302
+ }
303
+ service.remove(mem.id);
304
+ sendJson(res, 200, { deleted: true, id: mem.id });
305
+ } catch {
306
+ sendJson(res, 500, { error: "internal" });
307
+ }
308
+ }
309
+ });
310
+
221
311
  // --- vector re-index (backfill embeddings for rows missing them) ---
222
312
  register({
223
313
  kind: "exact",
package/lib/client.js CHANGED
@@ -72,6 +72,11 @@ window.__ModuleLoader__.load({
72
72
  "memory.settings.profileSaved": "画像已保存",
73
73
  "memory.settings.rules": "规则",
74
74
  "memory.settings.rulesHint": "Agent 必须遵守的行为规则,每轮注入",
75
+ "memory.settings.autoTagTitle": "自动打标签",
76
+ "memory.settings.autoTagHint": "开启后,新记忆由轻量模型自动打标签,目录页按标签自动分类(opt-in,默认关)",
77
+ "memory.settings.autoTagEnabled": "自动打标签",
78
+ "memory.settings.autoTagSave": "保存",
79
+ "memory.settings.autoTagSaved": "已保存",
75
80
  "memory.settings.ruleAdd": "添加规则",
76
81
  "memory.settings.rulePlaceholder": "例如:回答时总是先给结论",
77
82
  "memory.settings.commands": "自定义指令",
@@ -115,6 +120,8 @@ window.__ModuleLoader__.load({
115
120
  "memory.directory.untagged": "无标签",
116
121
  "memory.directory.loading": "加载中…",
117
122
  "memory.directory.empty": "暂无记忆条目",
123
+ "directory.editToggle": "编辑模式(显示删除)",
124
+ "directory.deleteConfirm": "确定删除这条记忆吗?",
118
125
  "memory.card.open": "在主区记忆库中查看全文",
119
126
  "memory.time.now": "刚刚",
120
127
  "memory.time.seconds": "{n}秒前",
@@ -172,6 +179,11 @@ window.__ModuleLoader__.load({
172
179
  "memory.settings.profileSaved": "Profile saved",
173
180
  "memory.settings.rules": "Rules",
174
181
  "memory.settings.rulesHint": "Behavior rules the agent must follow every turn",
182
+ "memory.settings.autoTagTitle": "Auto-Tag",
183
+ "memory.settings.autoTagHint": "When on, new memories are auto-tagged by a light model and the directory auto-grouped by tag (opt-in, off by default)",
184
+ "memory.settings.autoTagEnabled": "Auto-tagging",
185
+ "memory.settings.autoTagSave": "Save",
186
+ "memory.settings.autoTagSaved": "Saved",
175
187
  "memory.settings.ruleAdd": "Add Rule",
176
188
  "memory.settings.rulePlaceholder": "e.g. Always lead with a conclusion",
177
189
  "memory.settings.commands": "Custom Commands",
@@ -215,6 +227,8 @@ window.__ModuleLoader__.load({
215
227
  "memory.directory.untagged": "Untagged",
216
228
  "memory.directory.loading": "Loading…",
217
229
  "memory.directory.empty": "No memories yet",
230
+ "directory.editToggle": "Edit mode",
231
+ "directory.deleteConfirm": "Delete this memory?",
218
232
  "memory.card.open": "Open full text in the Memory tab",
219
233
  "memory.time.now": "just now",
220
234
  "memory.time.seconds": "{n}s ago",
@@ -365,6 +379,13 @@ window.__ModuleLoader__.load({
365
379
  // --- detail column ---
366
380
  ".mneme-xdetail{flex:none;max-height:44%;min-height:0;overflow-y:auto;padding:14px 20px 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
367
381
  ".mneme-xtree{flex:1;min-height:0;overflow-y:auto;padding:8px 10px 28px}",
382
+ ".mneme-xmain{display:flex;flex-direction:column}",
383
+ ".mneme-xfilter-bar{flex:none;height:48px;display:flex;align-items:center;gap:8px;padding:0 12px;border-bottom:1px solid var(--dsw-alias-border-l2,#ddd)}",
384
+ ".mneme-xcards{flex:1;display:flex;flex-direction:row;gap:12px;padding:12px;overflow:hidden}",
385
+ ".mneme-card{background:var(--dsw-alias-bg-base,#fafafa);border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.06);padding:12px;display:flex;flex-direction:column;overflow:auto}",
386
+ ".mneme-card--search{width:236px;flex:none}",
387
+ ".mneme-card--tree{width:260px;flex:none}",
388
+ ".mneme-card--detail{flex:1;min-width:0}",
368
389
  ".mneme-xdinner{max-width:720px}",
369
390
  ".mneme-xdtitle{font-size:16px;font-weight:600;line-height:24px;color:var(--dsw-alias-label-primary);margin-bottom:10px;word-break:break-word}",
370
391
  ".mneme-xdmeta{display:flex;flex-wrap:wrap;gap:4px 14px;margin-bottom:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
@@ -408,6 +429,16 @@ window.__ModuleLoader__.load({
408
429
  ".mneme-gs-link:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
409
430
  // --- directory sub-view (v0.6.3): tag folders + memory entries ---
410
431
  ".mneme-directory{flex:1;min-height:0;overflow-y:auto;padding:8px 10px 28px}",
432
+ ".mneme-edit-toggle{margin:6px 10px;display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;user-select:none}",
433
+ ".mneme-dir-item{position:relative;display:flex;align-items:center;gap:6px;padding:2px 6px;border-radius:4px}",
434
+ ".mneme-dir-item:hover{background:rgba(127,127,127,.18)}",
435
+ ".mneme-tree-line{width:14px;height:1px;border-top:1px dashed rgba(127,127,127,.5);flex:none}",
436
+ ".mneme-dir-item .mneme-dir-del{opacity:0;transition:opacity .12s}",
437
+ ".mneme-dir-item:hover .mneme-dir-del{opacity:1}",
438
+ ".mneme-dir-del{margin-left:auto;background:none;border:none;color:#e5484d;cursor:pointer;font-size:13px;line-height:1;padding:2px 4px}",
439
+ ".mneme-dir-del:hover{filter:brightness(1.25)}",
440
+ ".mneme-edit-on .mneme-dir-del{opacity:1}",
441
+ ".mneme-dir-ititle{background:none;border:none;padding:0;font:inherit;color:inherit;cursor:pointer;flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
411
442
  ".mneme-dir-folder{margin-bottom:4px}",
412
443
  ".mneme-dir-folderhead{display:flex;align-items:center;gap:6px;width:100%;padding:6px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit;font-size:13px;font-weight:500;line-height:18px;text-align:left}",
413
444
  ".mneme-dir-folderhead:hover{background:var(--dsw-alias-interactive-bg-hover)}",
@@ -832,6 +863,8 @@ window.__ModuleLoader__.load({
832
863
  (typeof window !== "undefined" && window.localStorage) ? window.localStorage.getItem("dsh-mneme-api-token") || "" : ""
833
864
  );
834
865
  const [apiTokenSaved, setApiTokenSaved] = react.useState(false);
866
+ const [autoTag, setAutoTag] = react.useState(false);
867
+ const [autoTagSaved, setAutoTagSaved] = react.useState(false);
835
868
 
836
869
  const load = react.useCallback(async () => {
837
870
  try {
@@ -845,6 +878,11 @@ window.__ModuleLoader__.load({
845
878
  setRules(Array.isArray(r.rules) ? r.rules : []);
846
879
  setCommands(Array.isArray(c.commands) ? c.commands : []);
847
880
  setVector(v.config || { enabled: false, baseUrl: "", apiKey: "", model: "" });
881
+ // autoTag loads independently so a config failure can't cascade-block the rest.
882
+ try {
883
+ const g = await apiFetch("/api/dsh-mneme/config").then((res) => res.json());
884
+ setAutoTag(g.config?.autoTagEnabled === true);
885
+ } catch { /* keep default off */ }
848
886
  } catch { /* ignore */ }
849
887
  }, []);
850
888
 
@@ -941,6 +979,18 @@ window.__ModuleLoader__.load({
941
979
  } catch { /* ignore */ }
942
980
  }
943
981
 
982
+ async function saveAutoTag() {
983
+ try {
984
+ await apiFetch("/api/dsh-mneme/config", {
985
+ method: "PUT",
986
+ headers: { "Content-Type": "application/json" },
987
+ body: JSON.stringify({ autoTagEnabled: autoTag })
988
+ });
989
+ setAutoTagSaved(true);
990
+ setTimeout(() => setAutoTagSaved(false), 1500);
991
+ } catch { /* ignore */ }
992
+ }
993
+
944
994
  const inputStyle = { boxSizing: "border-box", width: "100%", height: 34, padding: "0 12px", borderRadius: 10, border: "1px solid var(--dsw-alias-border-l2, #ddd)", background: "var(--dsw-alias-bg-base, transparent)", color: "var(--dsw-alias-label-primary)", fontFamily: "inherit", fontSize: 13, outline: "none", marginBottom: 8 };
945
995
  const labelStyle = { fontSize: 12, fontWeight: 600, margin: "12px 0 4px", color: "var(--dsw-alias-label-primary)" };
946
996
  const hintStyle = { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)", marginBottom: 8 };
@@ -1007,6 +1057,17 @@ window.__ModuleLoader__.load({
1007
1057
  h("button", { style: styles.footerButton, onClick: addCommand }, t("memory.settings.cmdAdd")),
1008
1058
  cmdError && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error, #c33)" } }, cmdError)
1009
1059
  ),
1060
+ // auto-tag switch
1061
+ h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.autoTagTitle")),
1062
+ h("div", { style: hintStyle }, t("memory.settings.autoTagHint")),
1063
+ h("label", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 8, fontSize: 13 } },
1064
+ h("input", { type: "checkbox", checked: autoTag, onChange: (e) => setAutoTag(e.target.checked) }),
1065
+ h("span", null, t("memory.settings.autoTagEnabled"))
1066
+ ),
1067
+ h("div", { style: { display: "flex", alignItems: "center", gap: 6 } },
1068
+ h("button", { style: styles.footerButton, onClick: saveAutoTag }, t("memory.settings.autoTagSave")),
1069
+ autoTagSaved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.autoTagSaved"))
1070
+ ),
1010
1071
  // vector search
1011
1072
  h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.vectorTitle")),
1012
1073
  h("div", { style: hintStyle }, t("memory.settings.vectorHint")),
@@ -1120,6 +1181,7 @@ window.__ModuleLoader__.load({
1120
1181
  function DirectoryPanel({ t, onJump, collapsed, setCollapsed }) {
1121
1182
  const [dir, setDir] = useState(null); // { groups: [...], untagged: [...] }
1122
1183
  const [status, setStatus] = useState("loading"); // loading | ready | error
1184
+ const [editMode, setEditMode] = useState(false); // manual tag/delete UI gate
1123
1185
 
1124
1186
  useEffect(() => {
1125
1187
  let cancelled = false;
@@ -1133,23 +1195,52 @@ window.__ModuleLoader__.load({
1133
1195
  setStatus("ready");
1134
1196
  })
1135
1197
  .catch(() => { if (!cancelled) { setDir(null); setStatus("error"); } });
1198
+ apiFetch("/api/dsh-mneme/config")
1199
+ .then((res) => (res.ok ? res.json() : null))
1200
+ .then((d) => { if (!cancelled && d?.config && typeof d.config.manualTagEnabled === "boolean") setEditMode(d.config.manualTagEnabled); })
1201
+ .catch(() => {});
1136
1202
  return () => { cancelled = true; };
1137
1203
  }, []);
1138
1204
 
1139
1205
  const toggle = (key) => setCollapsed((c) => ({ ...c, [key]: !c[key] }));
1140
1206
 
1207
+ const toggleEdit = () => {
1208
+ const next = !editMode;
1209
+ apiFetch("/api/dsh-mneme/config", {
1210
+ method: "PUT",
1211
+ headers: { "Content-Type": "application/json" },
1212
+ body: JSON.stringify({ manualTagEnabled: next })
1213
+ }).then((res) => { if (res.ok) setEditMode(next); }).catch(() => {});
1214
+ };
1215
+
1216
+ const handleDelete = (memId, e) => {
1217
+ e?.stopPropagation?.();
1218
+ if (typeof window === "undefined" || !window.confirm(t("directory.deleteConfirm"))) return;
1219
+ apiFetch("/api/dsh-mneme/memories", {
1220
+ method: "DELETE",
1221
+ headers: { "Content-Type": "application/json" },
1222
+ body: JSON.stringify({ id: memId })
1223
+ })
1224
+ .then((res) => (res.ok ? res.json() : null))
1225
+ .then((d) => {
1226
+ if (!d || d.deleted !== true) return;
1227
+ setDir((prev) => ({
1228
+ groups: prev.groups.map((g) => ({ ...g, memories: g.memories.filter((m) => m.id !== memId) })),
1229
+ untagged: prev.untagged.filter((m) => m.id !== memId)
1230
+ }));
1231
+ })
1232
+ .catch(() => {});
1233
+ };
1234
+
1141
1235
  const renderFolder = (name, memories, extraKey, emptyLabel) => {
1142
1236
  const folderKey = extraKey || name;
1143
1237
  const open = !collapsed[folderKey];
1144
1238
  const rows = memories.map((m) =>
1145
- h("button", {
1146
- key: m.id,
1147
- type: "button",
1148
- className: "mneme-dir-item",
1149
- onClick: () => onJump(m)
1150
- },
1151
- h("span", { className: "mneme-dir-ititle" }, m.title || m.content?.slice(0, 40)),
1152
- h("span", { className: "mneme-dir-itime" }, formatDate(m.updated_at || m.created_at))
1239
+ h("div", { key: m.id, className: "mneme-dir-item" },
1240
+ h("span", { className: "mneme-tree-line" }),
1241
+ h("button", { type: "button", className: "mneme-dir-ititle", onClick: () => onJump(m) }, m.title || m.content?.slice(0, 40)),
1242
+ h("span", { className: "mneme-dir-itime" }, formatDate(m.updated_at || m.created_at)),
1243
+ editMode && h("button", { type: "button", className: "mneme-dir-del", "aria-label": "delete", onClick: (e) => handleDelete(m.id, e) }, "✕")
1153
1244
  )
1154
1245
  );
1155
1246
  return h("div", {
@@ -1164,7 +1255,7 @@ window.__ModuleLoader__.load({
1164
1255
  onClick: () => toggle(folderKey)
1165
1256
  },
1166
1257
  h("span", { className: "mneme-dir-caret" }, open ? "▾" : "▸"),
1167
- h("span", { className: "mneme-dir-fname" }, name),
1258
+ h("span", { className: "mneme-dir-fname" }, `📁 ${name}`),
1168
1259
  h("span", { className: "mneme-dir-fcount" }, String(memories.length))
1169
1260
  ),
1170
1261
  open && h("div", { className: "mneme-dir-fbody" },
@@ -1180,7 +1271,11 @@ window.__ModuleLoader__.load({
1180
1271
  if (status === "error" || !hasAny) {
1181
1272
  return h("div", { className: "mneme-directory" }, h("div", { className: "mneme-dir-empty" }, t("memory.directory.empty")));
1182
1273
  }
1183
- return h("div", { className: "mneme-directory" },
1274
+ return h("div", { className: editMode ? "mneme-directory mneme-edit-on" : "mneme-directory" },
1275
+ h("label", { className: "mneme-edit-toggle" },
1276
+ h("input", { type: "checkbox", checked: editMode, onChange: toggleEdit }),
1277
+ t("directory.editToggle")
1278
+ ),
1184
1279
  dir.groups.map((g) => renderFolder(g.tag, g.memories)),
1185
1280
  dir.untagged.length > 0 && renderFolder(t("memory.directory.untagged"), dir.untagged, "mneme-untagged")
1186
1281
  );
@@ -1426,38 +1521,8 @@ window.__ModuleLoader__.load({
1426
1521
  ),
1427
1522
  ),
1428
1523
  view === "memory" && h("div", { className: "mneme-xmain" },
1429
- h("div", { className: "mneme-xside" },
1430
- h("div", { className: "mneme-xcolhead" }, t("memory.explorer.searchTitle")),
1431
- h("input", {
1432
- className: "mneme-search mneme-xsearch",
1433
- placeholder: t("memory.explorer.search"),
1434
- value: query,
1435
- onChange: (e) => setQuery(e.target.value)
1436
- }),
1437
- entityQuery && h("button", {
1438
- className: "mneme-entitychip",
1439
- style: { textAlign: "left", justifyContent: "flex-start" },
1440
- onClick: () => openGraphFor(entityQuery)
1441
- }, `${t("memory.graph.viewInGraph")} “${entityQuery}”`),
1442
- h("div", { className: "mneme-xrow" },
1443
- vecEnabled && h("button", {
1444
- className: semantic ? "mneme-chip mneme-active" : "mneme-chip",
1445
- title: t("memory.settings.vectorTitle"),
1446
- onClick: () => setSemantic(!semantic)
1447
- }, t("memory.panel.semantic")),
1448
- h("select", {
1449
- className: "mneme-select mneme-xselect",
1450
- value: searchTopK,
1451
- onChange: (e) => setSearchTopK(Number(e.target.value)),
1452
- title: t("memory.explorer.topK")
1453
- }, [5, 10, 20, 50].map((n) => h("option", { key: n, value: n }, t("memory.explorer.topKOption").replace("{n}", String(n)))))
1454
- ),
1455
- h("div", { className: "mneme-xrow" },
1456
- h("span", { className: "mneme-xcount" }, t("memory.explorer.count").replace("{n}", String(visible.length))),
1457
- h("button", { className: "mneme-footbtn", onClick: () => setReloadKey((k) => k + 1) }, t("memory.explorer.refresh"))
1458
- )
1459
- ),
1460
- h("div", { className: "mneme-xside mneme-xside--filter" },
1524
+ // 1. 中间分类栏
1525
+ h("div", { className: "mneme-xfilter-bar" },
1461
1526
  h("div", { className: "mneme-xcolhead" }, t("memory.explorer.types")),
1462
1527
  h("button", {
1463
1528
  className: type === "all" ? "mneme-xtype mneme-active" : "mneme-xtype",
@@ -1473,8 +1538,88 @@ window.__ModuleLoader__.load({
1473
1538
  h("span", { className: "mneme-xcount2" }, String(counts[key]))
1474
1539
  ))
1475
1540
  ),
1476
- h("div", { className: "mneme-xbrowse" },
1477
- h("div", { className: "mneme-xdetail" },
1541
+ // 2. 底部三卡片
1542
+ h("div", { className: "mneme-xcards" },
1543
+ // 左卡:搜索
1544
+ h("div", { className: "mneme-card mneme-card--search" },
1545
+ h("div", { className: "mneme-xcolhead" }, t("memory.explorer.searchTitle")),
1546
+ h("input", {
1547
+ className: "mneme-search mneme-xsearch",
1548
+ placeholder: t("memory.explorer.search"),
1549
+ value: query,
1550
+ onChange: (e) => setQuery(e.target.value)
1551
+ }),
1552
+ entityQuery && h("button", {
1553
+ className: "mneme-entitychip",
1554
+ style: { textAlign: "left", justifyContent: "flex-start" },
1555
+ onClick: () => openGraphFor(entityQuery)
1556
+ }, `${t("memory.graph.viewInGraph")} “${entityQuery}”`),
1557
+ h("div", { className: "mneme-xrow" },
1558
+ vecEnabled && h("button", {
1559
+ className: semantic ? "mneme-chip mneme-active" : "mneme-chip",
1560
+ title: t("memory.settings.vectorTitle"),
1561
+ onClick: () => setSemantic(!semantic)
1562
+ }, t("memory.panel.semantic")),
1563
+ h("select", {
1564
+ className: "mneme-select mneme-xselect",
1565
+ value: searchTopK,
1566
+ onChange: (e) => setSearchTopK(Number(e.target.value)),
1567
+ title: t("memory.explorer.topK")
1568
+ }, [5, 10, 20, 50].map((n) => h("option", { key: n, value: n }, t("memory.explorer.topKOption").replace("{n}", String(n)))))
1569
+ ),
1570
+ h("div", { className: "mneme-xrow" },
1571
+ h("span", { className: "mneme-xcount" }, t("memory.explorer.count").replace("{n}", String(visible.length))),
1572
+ h("button", { className: "mneme-footbtn", onClick: () => setReloadKey((k) => k + 1) }, t("memory.explorer.refresh"))
1573
+ )
1574
+ ),
1575
+ // 中卡:时间树
1576
+ h("div", { className: "mneme-card mneme-card--tree" },
1577
+ h("div", { className: "mneme-xcolhead" }, t("memory.explorer.timeline")),
1578
+ loading
1579
+ ? h("div", { className: "mneme-xempty" }, "…")
1580
+ : months.length === 0
1581
+ ? h("div", { className: "mneme-xempty" }, t("memory.explorer.empty"))
1582
+ : months.map((month) =>
1583
+ h("div", { key: month.key },
1584
+ h("button", {
1585
+ className: "mneme-xmonth",
1586
+ "aria-expanded": String(!collapsed[month.key]),
1587
+ onClick: () => setCollapsed((c) => ({ ...c, [month.key]: !c[month.key] }))
1588
+ },
1589
+ h("span", { className: "mneme-xcaret" }, collapsed[month.key] ? "▸" : "▾"),
1590
+ month.label
1591
+ ),
1592
+ !collapsed[month.key] && month.days.map((day) =>
1593
+ h("div", { key: day.key },
1594
+ h("button", {
1595
+ className: "mneme-xday",
1596
+ "aria-expanded": String(!collapsed[day.key]),
1597
+ onClick: () => setCollapsed((c) => ({ ...c, [day.key]: !c[day.key] }))
1598
+ },
1599
+ h("span", { className: "mneme-xcaret" }, collapsed[day.key] ? "▸" : "▾"),
1600
+ day.label
1601
+ ),
1602
+ !collapsed[day.key] && day.items.map((m) => {
1603
+ const d = new Date(m.updated_at || m.created_at || 0);
1604
+ const time = Number.isNaN(d.getTime())
1605
+ ? ""
1606
+ : d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
1607
+ return h("button", {
1608
+ key: m.id,
1609
+ ref: (el) => { if (el) itemRefs.current.set(m.id, el); else itemRefs.current.delete(m.id); },
1610
+ className: m.id === selectedId ? "mneme-xitem mneme-active" : "mneme-xitem",
1611
+ onClick: () => setSelectedId(m.id)
1612
+ },
1613
+ h("span", { className: "mneme-xtime" }, time),
1614
+ h("span", { className: "mneme-xname" }, m.title || m.content?.slice(0, 40))
1615
+ );
1616
+ })
1617
+ )
1618
+ )
1619
+ )
1620
+ )),
1621
+ // 右卡:详情
1622
+ h("div", { className: "mneme-card mneme-card--detail" },
1478
1623
  selected
1479
1624
  ? h(react.Fragment, { key: selected.id },
1480
1625
  h("div", { className: "mneme-xdinner" },
@@ -1535,50 +1680,6 @@ window.__ModuleLoader__.load({
1535
1680
  )
1536
1681
  )
1537
1682
  : h("div", { className: "mneme-xempty" }, t("memory.explorer.emptyDetail"))
1538
- ),
1539
- h("div", { className: "mneme-xtree" },
1540
- h("div", { className: "mneme-xcolhead" }, t("memory.explorer.timeline")),
1541
- loading
1542
- ? h("div", { className: "mneme-xempty" }, "…")
1543
- : months.length === 0
1544
- ? h("div", { className: "mneme-xempty" }, t("memory.explorer.empty"))
1545
- : months.map((month) =>
1546
- h("div", { key: month.key },
1547
- h("button", {
1548
- className: "mneme-xmonth",
1549
- "aria-expanded": String(!collapsed[month.key]),
1550
- onClick: () => setCollapsed((c) => ({ ...c, [month.key]: !c[month.key] }))
1551
- },
1552
- h("span", { className: "mneme-xcaret" }, collapsed[month.key] ? "▸" : "▾"),
1553
- month.label
1554
- ),
1555
- !collapsed[month.key] && month.days.map((day) =>
1556
- h("div", { key: day.key },
1557
- h("button", {
1558
- className: "mneme-xday",
1559
- "aria-expanded": String(!collapsed[day.key]),
1560
- onClick: () => setCollapsed((c) => ({ ...c, [day.key]: !c[day.key] }))
1561
- },
1562
- h("span", { className: "mneme-xcaret" }, collapsed[day.key] ? "▸" : "▾"),
1563
- day.label
1564
- ),
1565
- !collapsed[day.key] && day.items.map((m) => {
1566
- const d = new Date(m.updated_at || m.created_at || 0);
1567
- const time = Number.isNaN(d.getTime())
1568
- ? ""
1569
- : d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
1570
- return h("button", {
1571
- key: m.id,
1572
- ref: (el) => { if (el) itemRefs.current.set(m.id, el); else itemRefs.current.delete(m.id); },
1573
- className: m.id === selectedId ? "mneme-xitem mneme-active" : "mneme-xitem",
1574
- onClick: () => setSelectedId(m.id)
1575
- },
1576
- h("span", { className: "mneme-xtime" }, time),
1577
- h("span", { className: "mneme-xname" }, m.title || m.content?.slice(0, 40))
1578
- );
1579
- })
1580
- ))
1581
- ))
1582
1683
  )
1583
1684
  )
1584
1685
  ),
@@ -78,8 +78,12 @@ export async function runAutoTag({ ctx, service, config, route }) {
78
78
  ? config.autoTagMaxPerRun
79
79
  : 10;
80
80
  // Retained = post-consolidation active memories, newest first, capped.
81
+ // Skip memories that already carry live tags — otherwise every run re-picks
82
+ // the same "newest" batch (tagging doesn't bump updated_at) and older
83
+ // memories starve; manual tags are also left untouched this way.
81
84
  const memories = service.all()
82
85
  .filter((m) => !m.forgotten && !m.archived && !m.session_disposed_at && m.type !== "summary")
86
+ .filter((m) => (service.getMemoryTags?.(m.id) ?? []).length === 0)
83
87
  .sort((a, b) => {
84
88
  const ta = String(a.updated_at ?? "");
85
89
  const tb = String(b.updated_at ?? "");
@@ -137,7 +141,10 @@ export async function runAutoTag({ ctx, service, config, route }) {
137
141
  try {
138
142
  service.transaction(() => {
139
143
  for (const { id, tags } of toWrite) {
140
- service.applyMemoryTags(id, tags);
144
+ // Merge with existing live tags (never clobber manual tags that landed
145
+ // between selection and write).
146
+ const existing = service.getMemoryTags?.(id) ?? [];
147
+ service.applyMemoryTags(id, [...new Set([...existing, ...tags])]);
141
148
  tagged++;
142
149
  }
143
150
  });
package/lib/service.js CHANGED
@@ -373,7 +373,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
373
373
  .sort((a, b) => b.score - a.score)
374
374
  : hits.map((m) => ({ ...m, source: "tag" }));
375
375
  const result = hits.slice(0, topK);
376
- touchRecalled(result);
376
+ // Only count toward recall stats/forgetting when the caller asked for
377
+ // recording — the panel's `tag:` search must not pollute the curve.
378
+ if (options.recordRecall) touchRecalled(result);
377
379
  return result;
378
380
  }
379
381
 
package/lib/settings.js CHANGED
@@ -137,6 +137,36 @@ export function createSettings(db) {
137
137
  };
138
138
  setSetting("vector", JSON.stringify(cfg));
139
139
  return cfg;
140
+ },
141
+ /** Tagging switches (autoTag opt-in LLM pass + manual tag editing gate). */
142
+ getAutoTagConfig() {
143
+ const raw = getSetting("autoTag");
144
+ let stored = {};
145
+ if (raw) {
146
+ try {
147
+ const j = JSON.parse(raw);
148
+ if (j && typeof j === "object") stored = j;
149
+ } catch { /* fall through to defaults */ }
150
+ }
151
+ return {
152
+ autoTagEnabled: stored.autoTagEnabled === true,
153
+ manualTagEnabled: stored.manualTagEnabled === true
154
+ };
155
+ },
156
+ setAutoTagConfig(partial) {
157
+ const cur = (() => {
158
+ const raw = getSetting("autoTag");
159
+ try {
160
+ const j = JSON.parse(raw);
161
+ return j && typeof j === "object" ? j : {};
162
+ } catch { return {}; }
163
+ })();
164
+ const cfg = {
165
+ autoTagEnabled: partial.autoTagEnabled === undefined ? cur.autoTagEnabled === true : partial.autoTagEnabled === true,
166
+ manualTagEnabled: partial.manualTagEnabled === undefined ? cur.manualTagEnabled === true : partial.manualTagEnabled === true
167
+ };
168
+ setSetting("autoTag", JSON.stringify(cfg));
169
+ return cfg;
140
170
  }
141
171
  };
142
172
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.6.5",
4
+ "version": "0.6.7",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -57,12 +57,14 @@
57
57
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
58
58
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
59
59
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
60
- "@deepseek-ai/schemastery": "^3.18.1"
60
+ "@deepseek-ai/schemastery": "^3.18.1",
61
+ "c8": "^12.0.0"
61
62
  },
62
63
  "scripts": {
63
64
  "sync": "node scripts/sync-lib.js",
64
65
  "prepack": "npm run sync",
65
66
  "test": "node --test test/*.test.js",
67
+ "test:coverage": "c8 node --test test/*.test.js",
66
68
  "e2e": "node scripts/e2e-dsh.js",
67
69
  "stress": "node scripts/stress-dsh.js"
68
70
  },
@@ -71,5 +73,8 @@
71
73
  },
72
74
  "overrides": {
73
75
  "adm-zip": "0.6.0"
76
+ },
77
+ "c8": {
78
+ "reporter": ["text", "lcov"]
74
79
  }
75
80
  }
package/src/api.js CHANGED
@@ -218,6 +218,96 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
218
218
  }
219
219
  });
220
220
 
221
+ // --- app config read/write (partial: pass only the fields to change) ---
222
+ register({
223
+ kind: "exact",
224
+ path: "/api/dsh-mneme/config",
225
+ handler: async (req, res) => {
226
+ try {
227
+ if (!requireAuth(req, res, apiToken)) return;
228
+ if (req.method === "PUT" || req.method === "POST") {
229
+ const body = parseBody(await readBody(req));
230
+ const patch = {};
231
+ const setIfBoolean = (key) => {
232
+ if (Object.prototype.hasOwnProperty.call(body, key)) {
233
+ if (typeof body[key] !== "boolean") {
234
+ sendJson(res, 400, { error: `${key} must be a boolean` });
235
+ return false;
236
+ }
237
+ patch[key] = body[key];
238
+ }
239
+ return true;
240
+ };
241
+ if (!setIfBoolean("autoTagEnabled") || !setIfBoolean("manualTagEnabled")) return;
242
+ if (Object.keys(patch).length === 0) {
243
+ sendJson(res, 400, { error: "no valid config field provided" });
244
+ return;
245
+ }
246
+ const config = settings.setAutoTagConfig(patch);
247
+ sendJson(res, 200, { config });
248
+ return;
249
+ }
250
+ sendJson(res, 200, { config: settings.getAutoTagConfig() });
251
+ } catch {
252
+ sendJson(res, 500, { error: "internal" });
253
+ }
254
+ }
255
+ });
256
+
257
+ // --- delete a memory by exact id or best query match (mirrors memory_delete tool) ---
258
+ register({
259
+ kind: "exact",
260
+ path: "/api/dsh-mneme/memories",
261
+ handler: async (req, res) => {
262
+ try {
263
+ if (!requireAuth(req, res, apiToken)) return;
264
+ if (req.method !== "DELETE") {
265
+ sendJson(res, 405, { error: "method not allowed" });
266
+ return;
267
+ }
268
+ const body = parseBody(await readBody(req));
269
+ const hasId = Object.prototype.hasOwnProperty.call(body, "id");
270
+ const hasQuery = Object.prototype.hasOwnProperty.call(body, "query");
271
+ if ((hasId && hasQuery) || (!hasId && !hasQuery)) {
272
+ sendJson(res, 400, { error: "provide exactly one of id or query" });
273
+ return;
274
+ }
275
+ if (hasId) {
276
+ if (typeof body.id !== "string" || !body.id.trim()) {
277
+ sendJson(res, 400, { error: "invalid id" });
278
+ return;
279
+ }
280
+ const mem = service.getById(body.id);
281
+ if (!mem) {
282
+ sendJson(res, 200, { deleted: false });
283
+ return;
284
+ }
285
+ service.remove(body.id);
286
+ sendJson(res, 200, { deleted: true, id: body.id });
287
+ return;
288
+ }
289
+ if (typeof body.query !== "string" || !body.query.trim()) {
290
+ sendJson(res, 400, { error: "invalid query" });
291
+ return;
292
+ }
293
+ const results = await service.searchMemories(body.query, { mode: "auto", topK: 1, useRerank: true });
294
+ if (!Array.isArray(results) || results.length === 0) {
295
+ sendJson(res, 200, { deleted: false });
296
+ return;
297
+ }
298
+ const mem = results[0];
299
+ if (!mem || !mem.id) {
300
+ sendJson(res, 200, { deleted: false });
301
+ return;
302
+ }
303
+ service.remove(mem.id);
304
+ sendJson(res, 200, { deleted: true, id: mem.id });
305
+ } catch {
306
+ sendJson(res, 500, { error: "internal" });
307
+ }
308
+ }
309
+ });
310
+
221
311
  // --- vector re-index (backfill embeddings for rows missing them) ---
222
312
  register({
223
313
  kind: "exact",
@@ -78,8 +78,12 @@ export async function runAutoTag({ ctx, service, config, route }) {
78
78
  ? config.autoTagMaxPerRun
79
79
  : 10;
80
80
  // Retained = post-consolidation active memories, newest first, capped.
81
+ // Skip memories that already carry live tags — otherwise every run re-picks
82
+ // the same "newest" batch (tagging doesn't bump updated_at) and older
83
+ // memories starve; manual tags are also left untouched this way.
81
84
  const memories = service.all()
82
85
  .filter((m) => !m.forgotten && !m.archived && !m.session_disposed_at && m.type !== "summary")
86
+ .filter((m) => (service.getMemoryTags?.(m.id) ?? []).length === 0)
83
87
  .sort((a, b) => {
84
88
  const ta = String(a.updated_at ?? "");
85
89
  const tb = String(b.updated_at ?? "");
@@ -137,7 +141,10 @@ export async function runAutoTag({ ctx, service, config, route }) {
137
141
  try {
138
142
  service.transaction(() => {
139
143
  for (const { id, tags } of toWrite) {
140
- service.applyMemoryTags(id, tags);
144
+ // Merge with existing live tags (never clobber manual tags that landed
145
+ // between selection and write).
146
+ const existing = service.getMemoryTags?.(id) ?? [];
147
+ service.applyMemoryTags(id, [...new Set([...existing, ...tags])]);
141
148
  tagged++;
142
149
  }
143
150
  });
package/src/service.js CHANGED
@@ -373,7 +373,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
373
373
  .sort((a, b) => b.score - a.score)
374
374
  : hits.map((m) => ({ ...m, source: "tag" }));
375
375
  const result = hits.slice(0, topK);
376
- touchRecalled(result);
376
+ // Only count toward recall stats/forgetting when the caller asked for
377
+ // recording — the panel's `tag:` search must not pollute the curve.
378
+ if (options.recordRecall) touchRecalled(result);
377
379
  return result;
378
380
  }
379
381
 
package/src/settings.js CHANGED
@@ -137,6 +137,36 @@ export function createSettings(db) {
137
137
  };
138
138
  setSetting("vector", JSON.stringify(cfg));
139
139
  return cfg;
140
+ },
141
+ /** Tagging switches (autoTag opt-in LLM pass + manual tag editing gate). */
142
+ getAutoTagConfig() {
143
+ const raw = getSetting("autoTag");
144
+ let stored = {};
145
+ if (raw) {
146
+ try {
147
+ const j = JSON.parse(raw);
148
+ if (j && typeof j === "object") stored = j;
149
+ } catch { /* fall through to defaults */ }
150
+ }
151
+ return {
152
+ autoTagEnabled: stored.autoTagEnabled === true,
153
+ manualTagEnabled: stored.manualTagEnabled === true
154
+ };
155
+ },
156
+ setAutoTagConfig(partial) {
157
+ const cur = (() => {
158
+ const raw = getSetting("autoTag");
159
+ try {
160
+ const j = JSON.parse(raw);
161
+ return j && typeof j === "object" ? j : {};
162
+ } catch { return {}; }
163
+ })();
164
+ const cfg = {
165
+ autoTagEnabled: partial.autoTagEnabled === undefined ? cur.autoTagEnabled === true : partial.autoTagEnabled === true,
166
+ manualTagEnabled: partial.manualTagEnabled === undefined ? cur.manualTagEnabled === true : partial.manualTagEnabled === true
167
+ };
168
+ setSetting("autoTag", JSON.stringify(cfg));
169
+ return cfg;
140
170
  }
141
171
  };
142
172
  }
package/test/api.test.js CHANGED
@@ -547,3 +547,48 @@ test("GET /api/dsh-mneme/directory reports untagged memories", async () => {
547
547
  const data = JSON.parse(res.body);
548
548
  assert.deepEqual(data.untagged.map((m) => m.id), [bare.id]);
549
549
  });
550
+
551
+ test("GET/PUT /api/dsh-mneme/config round-trips autoTag switches", async () => {
552
+ const { routes } = setup();
553
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/config");
554
+ const res1 = new FakeRes();
555
+ await route.handler(req("/api/dsh-mneme/config"), res1);
556
+ assert.equal(res1.statusCode, 200);
557
+ assert.deepEqual(JSON.parse(res1.body).config, { autoTagEnabled: false, manualTagEnabled: false });
558
+ const res2 = new FakeRes();
559
+ await route.handler(req("/api/dsh-mneme/config", "PUT", { autoTagEnabled: true, manualTagEnabled: false }), res2);
560
+ assert.equal(res2.statusCode, 200);
561
+ assert.deepEqual(JSON.parse(res2.body).config, { autoTagEnabled: true, manualTagEnabled: false });
562
+ });
563
+
564
+ test("DELETE /api/dsh-mneme/memories removes by id and reports misses", async () => {
565
+ const { routes, service } = setup();
566
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
567
+ const target = service.list().find((m) => m.title === "语言");
568
+ const id = target.id;
569
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memories");
570
+ const res = new FakeRes();
571
+ await route.handler(req("/api/dsh-mneme/memories", "DELETE", { id }), res);
572
+ assert.equal(res.statusCode, 200);
573
+ assert.equal(JSON.parse(res.body).deleted, true);
574
+ const res2 = new FakeRes();
575
+ await route.handler(req("/api/dsh-mneme/memories", "DELETE", { id }), res2);
576
+ assert.equal(JSON.parse(res2.body).deleted, false);
577
+ });
578
+
579
+ test("DELETE /api/dsh-mneme/memories rejects id+query together", async () => {
580
+ const { routes } = setup();
581
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memories");
582
+ const res = new FakeRes();
583
+ await route.handler(req("/api/dsh-mneme/memories", "DELETE", { id: "x", query: "y" }), res);
584
+ assert.equal(res.statusCode, 400);
585
+ });
586
+
587
+ test("PUT /api/dsh-mneme/config supports partial update", async () => {
588
+ const { routes } = setup();
589
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/config");
590
+ const res = new FakeRes();
591
+ await route.handler(req("/api/dsh-mneme/config", "PUT", { autoTagEnabled: true }), res);
592
+ assert.equal(res.statusCode, 200);
593
+ assert.deepEqual(JSON.parse(res.body).config, { autoTagEnabled: true, manualTagEnabled: false });
594
+ });
@@ -99,3 +99,20 @@ test("vector config disabled value is stored as false", () => {
99
99
  assert.equal(settings.getVectorConfig().enabled, false);
100
100
  store.close();
101
101
  });
102
+
103
+ test("autoTag config defaults to disabled and round-trips", () => {
104
+ const { store, settings } = setup();
105
+ assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: false, manualTagEnabled: false });
106
+ settings.setAutoTagConfig({ autoTagEnabled: true, manualTagEnabled: true });
107
+ assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: true, manualTagEnabled: true });
108
+ settings.setAutoTagConfig({ autoTagEnabled: false });
109
+ assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: false, manualTagEnabled: true });
110
+ store.close();
111
+ });
112
+
113
+ test("autoTag config coerces non-boolean to false", () => {
114
+ const { store, settings } = setup();
115
+ settings.setAutoTagConfig({ autoTagEnabled: "yes", manualTagEnabled: 1 });
116
+ assert.deepEqual(settings.getAutoTagConfig(), { autoTagEnabled: false, manualTagEnabled: false });
117
+ store.close();
118
+ });
package/test/tag.test.js CHANGED
@@ -186,6 +186,24 @@ test("runAutoTag writes validated tags into entity_attrs", async () => {
186
186
  store.close();
187
187
  });
188
188
 
189
+ test("runAutoTag: skips already-tagged memories, merges into fresh ones", async () => {
190
+ const { store, service } = makeService();
191
+ const a = seed(service, "Linux", "内核");
192
+ const b = seed(service, "考研", "公共管理");
193
+ store.setMemoryTags(a.id, ["已有"]); // manual tag
194
+ const text = JSON.stringify([
195
+ { id: a.id, tags: ["linux"] },
196
+ { id: b.id, tags: ["考研"] }
197
+ ]);
198
+ const ctx = makeCtx([text]);
199
+ const result = await runAutoTag({ ctx, service, config: { dreamProvider: "d", dreamModel: "m" } });
200
+ assert.equal(result.ok, true);
201
+ assert.equal(result.tagged, 1, "only the untagged memory is tagged");
202
+ assert.deepEqual(store.getMemoryTags(a.id), ["已有"], "manual tags left untouched");
203
+ assert.deepEqual(store.getMemoryTags(b.id), ["考研"]);
204
+ store.close();
205
+ });
206
+
189
207
  test("runAutoTag fail-safe: garbage / aborted LLM output never throws and writes nothing", async () => {
190
208
  const { store, service } = makeService();
191
209
  const a = seed(service, "Alpha", "正文");