@max-null/dsh-memory 0.6.1 → 0.7.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
@@ -8,22 +8,22 @@ This plugin belongs to the **`@max-null/*` family** — a set of plugins that to
8
8
 
9
9
  ## 设计原则
10
10
 
11
- 1. **人是所有者**:模型只能写入 `suggested` 状态的记忆,绝不自我提升;只有人工确认(`setStatus`)才能让记忆生效。
11
+ 1. **写入即生效,人是例外干预者**:模型写入的记忆直接生效(`approved`),不再逐条等人放行;人保留随时查看、改写、删除、钉住或回滚的能力——人不在场不等于失控。
12
12
  2. **可观测先于精准**:每条记忆是明文,`memory_list` 随时可见、`memory_forget` 随时删除——不存在"静默暗礁"。
13
- 3. **明文是人机共享的审计窗口**:记忆是可读文本,模型可自检其是否过期或出错(规划中的 v2)。
13
+ 3. **明文是人机共享的审计窗口**:记忆是可读文本,模型可自检其是否过期(有效性锚点会在所绑环境值变化后标记 `stale`),人可随时查看与改写。
14
14
  4. **确定性且缓存安全**:BM25 关键词检索是存储的纯函数、无 LLM 调用;固定指引进 system-prompt section,`approved + injected` 记忆进 recall context(global 全量 + 当前会话工作区),逐条为单行摘要并按注入预算截断(超预算按最近使用优先,省略数在面板可见)。
15
15
 
16
- ## 截图
17
-
18
- 装完后在设置里多出「记忆」一项,可查看与管理跨会话记忆:
19
-
20
- **入口:** 设置 → 记忆
21
-
22
- | 设置入口与面板 |
23
- |---|
24
- | ![设置 → 记忆](docs/shots/memory-settings-1.png) |
25
-
26
-
16
+ ## 截图
17
+
18
+ 装完后在设置里多出「记忆」一项,可查看与管理跨会话记忆:
19
+
20
+ **入口:** 设置 → 记忆
21
+
22
+ | 设置入口与面板 |
23
+ |---|
24
+ | ![设置 → 记忆](docs/shots/memory-settings-1.png) |
25
+
26
+
27
27
  ## 用法
28
28
 
29
29
  ```bash
@@ -55,7 +55,7 @@ npm install @max-null/dsh-memory
55
55
 
56
56
  两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace` 过滤时会同时查两层。旧版双重前缀文件名(`memory_project_memory_project_<hash>.json`)在打开时自动迁移为规范名。
57
57
 
58
- ## 使用流程(人工确认闸门)
58
+ ## 使用流程(写入即生效,人为例外干预)
59
59
 
60
60
  提示词模板库(0.6.0):`prompt_search / prompt_get / prompt_list / prompt_add` 四个工具管理**模板库**——
61
61
  md 文件是唯一事实源(`~/.dsh/prompt-library/*.md` 为 global;`<workspace>/.dsh/prompt-library/` 随工作区分享),
@@ -63,14 +63,19 @@ md 文件是唯一事实源(`~/.dsh/prompt-library/*.md` 为 global;`<worksp
63
63
  **永不注入 system prompt**。
64
64
 
65
65
  ```
66
- 模型 memory_save → status: suggested(只是建议,未生效)
67
- 人 memory_confirm → status: approved(已审核;是否常驻注入由独立开关 injected 决定)
68
- 人(面板/开关) → injected: true(每轮注入:global + 当前会话工作区,摘要化 + 预算截断)
69
- memory_search → 关键词/语义召回任意状态的记忆(命中标记 lastUsedAt 冷热追踪)
70
- memory_forget → 随时删除
66
+ 模型 memory_save → status: approved,立即生效;命中密钥/凭据规则则隔离(不进检索也不进注入)
67
+ memory_search → 关键词/语义召回(只搜记忆;模板走 prompt_search 通道,不会混进候选池)
68
+ 命中累计 2 次 → injected: true(自动打开常驻:global + 当前会话工作区,摘要化 + 预算截断)
69
+ 30 天未再命中 → 自动撤下常驻(只撤自动开的;人工动过的开关双向豁免)
70
+ 人(面板 / 开关) → 钉住 / 删除 / 放行隔离记录 / 回滚
71
+ memory_forget → 随时删除(删除始终是人的动作)
71
72
  ```
72
73
 
73
- 模型**永远不能自我提升**一条记忆——`memory_save` 只写 `suggested`,只有人在明确要求时(`memory_confirm`)才能让它生效。这保证了记忆不是黑盒:人随时能看、能改、能删。
74
+ 两条自动规则撑起淘汰机制:**反复被检索命中**是它值得每轮付费的证据,**长期不再被命中**则自动退出常驻。两者都不删除任何内容——记忆不会无限累积,也不会被系统自行清空。
75
+
76
+ 记忆还可声明**有效性锚点**(环境变量 / 工具清单 / 插件版本):所绑的值变化后,这条记忆被标记 `stale` 并撤下常驻,检索结果里也带出失效提示——环境变了,旧结论就不再被当成仍然正确。
77
+
78
+ 模型写入不需要人点头,但**人始终能看见并推翻**:面板按「隔离区 / 常驻 / 冷数据」分组,被隔离的记录必须由人放行才会重新进入检索。
74
79
 
75
80
  ## 为什么明文 + BM25,而不是向量检索
76
81
 
package/client.js CHANGED
@@ -175,26 +175,34 @@ var STRINGS = {
175
175
  promptFilterAll: "\u5168\u90E8",
176
176
  memorySearch: "\u641C\u7D22\u8BB0\u5FC6\u2026",
177
177
  empty: "\u9ED1\u6697\u4E2D\u672A\u89C1\u7075\u5149",
178
- confirm: "\u786E\u8BA4",
178
+ confirm: "\u653E\u884C",
179
179
  forget: "\u5220\u9664",
180
- groupPending: "\u5F85\u5BA1\u6838",
181
- groupOnDemand: "\u5DF2\u5BA1\u6838 \xB7 \u6309\u9700",
180
+ groupQuarantine: "\u9694\u79BB\u533A",
181
+ groupQuarantineHint: "\u547D\u4E2D\u5371\u9669\u5185\u5BB9\u89C4\u5219\uFF08\u5BC6\u94A5/\u51ED\u636E\u7C7B\uFF09\u2192 \u4E0D\u8FDB\u6CE8\u5165\u3001\u4E0D\u8FDB\u68C0\u7D22\u3002\u653E\u884C\u540E\u6309\u666E\u901A\u8BB0\u5FC6\u5904\u7406\u3002",
182
+ quarantineReason: "\u89C4\u5219\uFF1A{reason}",
183
+ release: "\u653E\u884C",
184
+ blockedWhileQuarantined: "\u9694\u79BB\u4E2D \xB7 \u653E\u884C\u540E\u53EF\u5E38\u9A7B\u6CE8\u5165",
185
+ groupPending: "\u5F85\u653E\u884C",
186
+ groupOnDemand: "\u6309\u9700\u68C0\u7D22",
182
187
  groupInjected: "\u5E38\u9A7B\u6CE8\u5165",
188
+ groupCold: "\u51B7\u6570\u636E\xB7\u6298\u53E0\uFF08{days} \u5929\u672A\u68C0\u7D22\uFF09",
183
189
  injectSwitch: "\u5E38\u9A7B\u6CE8\u5165",
184
- approveFirst: "\u5BA1\u6838\u901A\u8FC7\u540E\u53EF\u5E38\u9A7B\u6CE8\u5165",
190
+ approveFirst: "\u5148\u653E\u884C\u9694\u79BB\u624D\u80FD\u5E38\u9A7B\u6CE8\u5165",
185
191
  allNamespaces: "\u5168\u90E8",
186
192
  nsGlobal: "\u5168\u5C40",
187
193
  nsWorkspace: "\u5DE5\u4F5C\u533A",
188
194
  noWorkspace: "\u672A\u9009\u62E9\u5DE5\u4F5C\u533A",
189
195
  organizeMemory: "\u6574\u7406\u8BB0\u5FC6",
190
- confirmAll: "\u5168\u90E8\u786E\u8BA4",
196
+ confirmAll: "\u5168\u90E8\u653E\u884C",
191
197
  injectPreview: "\u6CE8\u5165\u9884\u89C8",
192
198
  contextUsage: "\u4E0A\u4E0B\u6587\u5360\u7528",
193
199
  keywordsLabel: "\u5173\u952E\u8BCD",
194
- suggested: "\u5F85\u5BA1\u6838",
195
- approved: "\u5DF2\u5BA1\u6838",
200
+ suggested: "\u9694\u79BB",
201
+ approved: "\u5DF2\u751F\u6548",
196
202
  refresh: "\u5237\u65B0",
197
- coldHint: "\u51B7\u6570\u636E \xB7 \u5EFA\u8BAE\u6309\u9700\u5316"
203
+ coldHint: "\u51B7\u6570\u636E",
204
+ lastUsedAt: "\u4E0A\u641C",
205
+ hitCount: "\u547D\u4E2D {count} \u6B21"
198
206
  },
199
207
  en: {
200
208
  tabMemory: "Memory",
@@ -220,26 +228,34 @@ var STRINGS = {
220
228
  promptFilterAll: "All",
221
229
  memorySearch: "Search memory\u2026",
222
230
  empty: "No spark in the dark",
223
- confirm: "Confirm",
231
+ confirm: "Release",
224
232
  forget: "Forget",
225
- groupPending: "Pending review",
226
- groupOnDemand: "Approved \xB7 on demand",
233
+ groupQuarantine: "Quarantine",
234
+ groupQuarantineHint: "Flagged by a dangerous-content rule (secrets/credentials) \u2192 kept out of injection and recall. Releasing it makes it an ordinary memory.",
235
+ quarantineReason: "Rule: {reason}",
236
+ release: "Release",
237
+ blockedWhileQuarantined: "Quarantined \xB7 release to allow injection",
238
+ groupPending: "Pending release",
239
+ groupOnDemand: "On demand",
227
240
  groupInjected: "Always injected",
241
+ groupCold: "Cold \xB7 collapsed ({days} days untouched)",
228
242
  injectSwitch: "Inject every turn",
229
- approveFirst: "Approve to enable injection",
243
+ approveFirst: "Release the quarantine first",
230
244
  allNamespaces: "All",
231
245
  nsGlobal: "Global",
232
246
  nsWorkspace: "Workspace",
233
247
  noWorkspace: "No workspace selected",
234
248
  organizeMemory: "Organize memory",
235
- confirmAll: "Approve all",
249
+ confirmAll: "Release all",
236
250
  injectPreview: "Injection preview",
237
251
  contextUsage: "Context usage",
238
252
  keywordsLabel: "Keywords",
239
- suggested: "Suggested",
240
- approved: "Approved",
253
+ suggested: "Quarantined",
254
+ approved: "Active",
241
255
  refresh: "Refresh",
242
- coldHint: "Cold \xB7 consider on-demand"
256
+ coldHint: "Cold",
257
+ lastUsedAt: "Recalled",
258
+ hitCount: "{count} hits"
243
259
  }
244
260
  };
245
261
  var localeId = "zh";
@@ -356,7 +372,7 @@ var memoryApi = {
356
372
  return api("prompt.refresh", { ...cwd === void 0 ? {} : { cwd } });
357
373
  }
358
374
  };
359
- var ORGANIZE_PROMPT = "\u8BF7\u6574\u7406\u6211\u7684\u8BB0\u5FC6\u5E93\uFF1A\u7528 memory_list \u67E5\u770B\u5168\u90E8\u8BB0\u5FC6\u3002\u6574\u7406\u89C4\u5219\uFF08\u5FC5\u987B\u6267\u884C\uFF09\uFF1A\u2460\u540C\u7C7B\u6761\u76EE\u5408\u5E76\u2014\u2014\u5DE5\u4F5C\u4E60\u60EF/\u7EA6\u5B9A\u7C7B\u591A\u6761\u5408\u5E76\u4E3A\u4E00\u6761\uFF08\u5185\u5BB9\u7528 \u2460\u2461\u2462 \u5E76\u5217\uFF0C\u907F\u514D\u788E\u7247\u5316\uFF09\uFF1B\u2461\u4E0E\u300C[\u8BB0\u5FC6\u7CFB\u7EDF\u81EA\u8FF0]\u300D\uFF08\u4F60\u4E0A\u4E0B\u6587\u4E2D\u7684\u8BB0\u5FC6\u673A\u5236\u8BF4\u660E\uFF09\u5185\u5BB9\u91CD\u590D\u7684\u63D2\u4EF6\u4ECB\u7ECD\u6761\u76EE\uFF08\u5982 dsh-memory \u63D2\u4EF6\u53D1\u5E03\u4FE1\u606F\uFF09\u5E94\u5220\u9664\u2014\u2014\u673A\u5236\u8BF4\u660E\u5DF2\u7531\u7CFB\u7EDF\u5E38\u9A7B\u63D0\u4F9B\uFF0C\u65E0\u9700\u7528\u6237\u5B58\u50A8\uFF1B\u2462\u5BF9\u8FC7\u65F6\u3001\u9519\u8BEF\u6216\u5DF2\u53D8\u5316\u7684\u5185\u5BB9\u7528 memory_update \u4FEE\u6B63\uFF08\u4F1A\u91CD\u7F6E\u4E3A\u5F85\u5BA1\u6838\uFF09\uFF1B\u2463\u7CBE\u7B80\u5197\u957F\u5185\u5BB9\uFF0C\u4E3A\u6BCF\u6761\u8865\u5145\u6216\u4FEE\u6B63 keywords\uFF1B\u2464\u9700\u8981\u5220\u9664\u7684\u7528 memory_forget\uFF0C\u9700\u8981\u65B0\u589E\u7684\u7528 memory_save\u3002\u5224\u65AD\u5185\u5BB9\u662F\u5426\u8FC7\u65F6\u7684\u65B9\u6CD5\uFF1A\u628A\u8BB0\u5FC6\u91CC\u63D0\u5230\u7684\u5DE5\u5177\u540D/\u6570\u91CF\u4E0E\u4F60\u5F53\u524D\u5B9E\u9645\u53EF\u7528\u7684\u8BB0\u5FC6\u5DE5\u5177\u5BF9\u7167\u2014\u2014\u4F60\u5F53\u524D\u53EF\u7528\uFF1Amemory_save / memory_list / memory_search / memory_confirm / memory_forget / memory_update\uFF08\u5171 6 \u4E2A\uFF09\uFF1B\u82E5\u8BB0\u5FC6\u4E2D\u7684\u5DE5\u5177\u5217\u8868\u3001\u6570\u91CF\u3001\u6D41\u7A0B\u4E0E\u6B64\u4E0D\u7B26\u5373\u4E3A\u8FC7\u65F6\uFF0C\u7528 memory_update \u4FEE\u6B63\u3002\u6539\u52A8\u5168\u90E8\u843D\u5728 suggested \u7B49\u5F85\u5BA1\u6838\uFF08\u4E0D\u8981\u8C03\u7528 memory_confirm\uFF09\uFF0C\u5B8C\u6210\u540E\u7528\u4E00\u53E5\u8BDD\u6C47\u62A5\u6574\u7406\u7ED3\u679C\u3002";
375
+ var ORGANIZE_PROMPT = "\u8BF7\u6574\u7406\u6211\u7684\u8BB0\u5FC6\u5E93\uFF1A\u7528 memory_list \u67E5\u770B\u5168\u90E8\u8BB0\u5FC6\u3002\u6574\u7406\u89C4\u5219\uFF08\u5FC5\u987B\u6267\u884C\uFF09\uFF1A\u2460\u540C\u7C7B\u6761\u76EE\u5408\u5E76\u2014\u2014\u5DE5\u4F5C\u4E60\u60EF/\u7EA6\u5B9A\u7C7B\u591A\u6761\u5408\u5E76\u4E3A\u4E00\u6761\uFF08\u5185\u5BB9\u7528 \u2460\u2461\u2462 \u5E76\u5217\uFF0C\u907F\u514D\u788E\u7247\u5316\uFF09\uFF1B\u2461\u4E0E\u300C[\u8BB0\u5FC6\u7CFB\u7EDF\u81EA\u8FF0]\u300D\uFF08\u4F60\u4E0A\u4E0B\u6587\u4E2D\u7684\u8BB0\u5FC6\u673A\u5236\u8BF4\u660E\uFF09\u5185\u5BB9\u91CD\u590D\u7684\u63D2\u4EF6\u4ECB\u7ECD\u6761\u76EE\uFF08\u5982 dsh-memory \u63D2\u4EF6\u53D1\u5E03\u4FE1\u606F\uFF09\u5E94\u5220\u9664\u2014\u2014\u673A\u5236\u8BF4\u660E\u5DF2\u7531\u7CFB\u7EDF\u5E38\u9A7B\u63D0\u4F9B\uFF0C\u65E0\u9700\u7528\u6237\u5B58\u50A8\uFF1B\u2462\u5BF9\u8FC7\u65F6\u3001\u9519\u8BEF\u6216\u5DF2\u53D8\u5316\u7684\u5185\u5BB9\u7528 memory_update \u4FEE\u6B63\uFF08\u6539\u52A8\u751F\u6548\u3001\u6CE8\u5165\u5F00\u5173\u4FDD\u7559\uFF09\uFF1B\u2463\u7CBE\u7B80\u5197\u957F\u5185\u5BB9\uFF0C\u4E3A\u6BCF\u6761\u8865\u5145\u6216\u4FEE\u6B63 keywords\uFF1B\u2464\u9700\u8981\u5220\u9664\u7684\u7528 memory_forget\uFF0C\u9700\u8981\u65B0\u589E\u7684\u7528 memory_save\u3002\u5224\u65AD\u5185\u5BB9\u662F\u5426\u8FC7\u65F6\u7684\u65B9\u6CD5\uFF1A\u628A\u8BB0\u5FC6\u91CC\u63D0\u5230\u7684\u5DE5\u5177\u540D/\u6570\u91CF\u4E0E\u4F60\u5F53\u524D\u5B9E\u9645\u53EF\u7528\u7684\u8BB0\u5FC6\u5DE5\u5177\u5BF9\u7167\u2014\u2014\u4F60\u5F53\u524D\u53EF\u7528\uFF1Amemory_save / memory_list / memory_search / memory_confirm / memory_forget / memory_update\uFF08\u5171 6 \u4E2A\uFF09\uFF1B\u82E5\u8BB0\u5FC6\u4E2D\u7684\u5DE5\u5177\u5217\u8868\u3001\u6570\u91CF\u3001\u6D41\u7A0B\u4E0E\u6B64\u4E0D\u7B26\u5373\u4E3A\u8FC7\u65F6\uFF0C\u7528 memory_update \u4FEE\u6B63\u3002\u6539\u52A8\u76F4\u63A5\u751F\u6548\uFF08\u4E0D\u8981\u8C03\u7528 memory_confirm\uFF09\uFF0C\u5B8C\u6210\u540E\u7528\u4E00\u53E5\u8BDD\u6C47\u62A5\u6574\u7406\u7ED3\u679C\u3002";
360
376
  function insertIntoInput(ctx, text) {
361
377
  const sessions = ctx.get?.("sessions");
362
378
  const conversation = ctx.get?.("conversation");
@@ -585,6 +601,9 @@ function MemoryView(props) {
585
601
  const t = useT();
586
602
  const [mode, setMode] = (0, import_react.useState)("mem");
587
603
  const [records, setRecords] = (0, import_react.useState)([]);
604
+ const [quarantined, setQuarantined] = (0, import_react.useState)([]);
605
+ const [coldOpen, setColdOpen] = (0, import_react.useState)(false);
606
+ const [quarantineOpen, setQuarantineOpen] = (0, import_react.useState)(true);
588
607
  const [query, setQuery] = (0, import_react.useState)("");
589
608
  const [namespace, setNamespace] = (0, import_react.useState)(null);
590
609
  const [refreshing, setRefreshing] = (0, import_react.useState)(false);
@@ -608,11 +627,16 @@ function MemoryView(props) {
608
627
  setRecords(await memoryApi.list({}, props.cwd));
609
628
  } catch {
610
629
  }
630
+ try {
631
+ setQuarantined(await memoryApi.list({ quarantined: true }, props.cwd));
632
+ } catch {
633
+ }
611
634
  };
612
635
  const refreshFromDisk = async () => {
613
636
  setRefreshing(true);
614
637
  try {
615
638
  setRecords(await memoryApi.reload(props.cwd));
639
+ setQuarantined(await memoryApi.list({ quarantined: true }, props.cwd));
616
640
  } catch {
617
641
  await reload();
618
642
  } finally {
@@ -623,15 +647,22 @@ function MemoryView(props) {
623
647
  if (props.visible) void reload();
624
648
  }, [props.visible]);
625
649
  const toggleInjected = async (record) => {
626
- if (record.status !== "approved") return;
650
+ if (record.status !== "approved" || record.quarantined) return;
627
651
  try {
628
652
  await memoryApi.setInjected(record.id, !record.injected, props.cwd);
629
653
  } catch {
630
654
  }
631
655
  await reload();
632
656
  };
657
+ const release = async (record) => {
658
+ try {
659
+ await memoryApi.confirm(record.id, props.cwd);
660
+ } catch {
661
+ }
662
+ await reload();
663
+ };
633
664
  const confirmAll = async () => {
634
- const pending = records.filter((record) => record.status === "suggested");
665
+ const pending = records.filter((record) => record.status === "suggested" && !record.quarantined);
635
666
  if (pending.length === 0) return;
636
667
  await Promise.all(pending.map((record) => memoryApi.confirm(record.id, props.cwd).catch(() => null)));
637
668
  await reload();
@@ -671,16 +702,180 @@ function MemoryView(props) {
671
702
  const q = query.trim().toLowerCase();
672
703
  const byNs = namespace === null ? records : namespace === "workspace" ? records.filter((record) => record.namespace === "project") : records.filter((record) => record.namespace === "global");
673
704
  const filtered = byNs.filter((record) => q === "" || record.content.toLowerCase().includes(q));
705
+ const quarantinedScoped = (namespace === null ? quarantined : namespace === "workspace" ? quarantined.filter((record) => record.namespace === "project") : quarantined.filter((record) => record.namespace === "global")).filter((record) => q === "" || record.content.toLowerCase().includes(q));
674
706
  const nsCounts = {
675
- all: records.length,
676
- global: records.filter((record) => record.namespace === "global").length,
677
- workspace: records.filter((record) => record.namespace === "project").length
707
+ all: records.length + quarantined.length,
708
+ global: records.filter((record) => record.namespace === "global").length + quarantined.filter((record) => record.namespace === "global").length,
709
+ workspace: records.filter((record) => record.namespace === "project").length + quarantined.filter((record) => record.namespace === "project").length
678
710
  };
711
+ const coldNow = Date.now();
712
+ const settled = (excludeCold) => excludeCold ? filtered.filter((record) => !isCold(record.lastUsedAt, coldNow)) : filtered;
679
713
  const groups = [
680
- { key: "pending", label: t("groupPending"), items: filtered.filter((record) => record.status === "suggested") },
681
- { key: "ondemand", label: t("groupOnDemand"), items: filtered.filter((record) => record.status === "approved" && !record.injected) },
682
- { key: "injected", label: t("groupInjected"), items: filtered.filter((record) => record.status === "approved" && record.injected) }
714
+ { key: "pending", label: t("groupPending"), items: settled(false).filter((record) => record.status === "suggested") },
715
+ { key: "injected", label: t("groupInjected"), items: settled(true).filter((record) => record.status === "approved" && record.injected) },
716
+ { key: "ondemand", label: t("groupOnDemand"), items: settled(true).filter((record) => record.status === "approved" && !record.injected) }
683
717
  ].filter((group) => group.items.length > 0);
718
+ const coldItems = settled(false).filter((record) => record.status === "approved" && isCold(record.lastUsedAt, coldNow));
719
+ const memoryCard = (record, options = {}) => {
720
+ const isQuarantined = options.quarantined === true || record.quarantined;
721
+ const injectable = record.status === "approved" && !isQuarantined;
722
+ return (0, import_react.createElement)(
723
+ "div",
724
+ {
725
+ key: record.id,
726
+ style: {
727
+ ...ssid.card,
728
+ // 隔离区显眼但不吓人:左边一条警示色,不换底色(安全网,不是错误)
729
+ ...isQuarantined ? { borderLeft: "3px solid var(--dsw-alias-state-warning-primary, #f7c94f)" } : {}
730
+ }
731
+ },
732
+ (0, import_react.createElement)("div", { style: ssid.text }, ContentWithRefs({ text: record.content })),
733
+ // 隔离原因:命中的规则名(安全网,只陈述事实,不做道德评判)
734
+ isQuarantined ? (0, import_react.createElement)(
735
+ "div",
736
+ { style: { ...ssid.muted, marginTop: 6, display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" } },
737
+ (0, import_react.createElement)("span", {
738
+ style: {
739
+ fontSize: 10,
740
+ padding: "1px 7px",
741
+ borderRadius: 8,
742
+ fontWeight: 600,
743
+ background: "var(--dsw-alias-state-warning-tertiary, rgba(247,201,79,.16))",
744
+ color: "var(--dsw-alias-state-warning-primary, #f7c94f)"
745
+ }
746
+ }, t("groupQuarantine")),
747
+ (0, import_react.createElement)("span", null, record.quarantineReason === void 0 || record.quarantineReason === "" ? t("quarantineReason", { reason: "\u2014" }) : t("quarantineReason", { reason: record.quarantineReason }))
748
+ ) : null,
749
+ // keywords 展示(0.3.5:设计约定 UI 显示 keywords)
750
+ record.keywords !== void 0 && record.keywords.length > 0 ? (0, import_react.createElement)(
751
+ "div",
752
+ { style: { display: "flex", flexWrap: "wrap", gap: 4, marginTop: 6 } },
753
+ record.keywords.map((keyword) => (0, import_react.createElement)("span", {
754
+ key: keyword,
755
+ style: {
756
+ fontSize: 10,
757
+ padding: "1px 7px",
758
+ borderRadius: 8,
759
+ background: "var(--dsw-alias-bg-module-platform, rgba(128,148,168,.14))",
760
+ color: "var(--dsw-alias-label-secondary, #67748a)"
761
+ }
762
+ }, keyword))
763
+ ) : null,
764
+ (0, import_react.createElement)(
765
+ "div",
766
+ { style: { ...ssid.muted, marginTop: 6 } },
767
+ `${record.namespace} \xB7 ${record.status === "approved" ? t("approved") : t("suggested")}${record.injected ? ` \xB7 ${t("groupInjected")}` : ""}${record.lastUsedAt !== void 0 ? ` \xB7 ${t("lastUsedAt")} ${fmtDate(record.lastUsedAt)}` : ""}${isCold(record.lastUsedAt, coldNow) ? ` \xB7 ${t("coldHint")}` : ""}${record.hitCount > 0 ? ` \xB7 ${t("hitCount", { count: record.hitCount })}` : ""}`
768
+ ),
769
+ (0, import_react.createElement)(
770
+ "div",
771
+ { style: { display: "flex", gap: 6, marginTop: 8, alignItems: "center" } },
772
+ (0, import_react.createElement)("button", {
773
+ type: "button",
774
+ title: isQuarantined ? t("blockedWhileQuarantined") : record.status === "approved" ? t("injectSwitch") : t("approveFirst"),
775
+ disabled: !injectable,
776
+ onClick: () => {
777
+ void toggleInjected(record);
778
+ },
779
+ style: {
780
+ ...ssid.btn,
781
+ ...record.injected ? { color: ssid.accent, borderColor: ssid.accent } : {},
782
+ opacity: injectable ? 1 : 0.4,
783
+ cursor: injectable ? "pointer" : "not-allowed"
784
+ }
785
+ }, record.injected ? `\u2713 ${t("injectSwitch")}` : t("injectSwitch")),
786
+ isQuarantined ? (0, import_react.createElement)("button", {
787
+ style: { ...ssid.btn, color: ssid.accent, borderColor: ssid.accent },
788
+ onClick: () => {
789
+ void release(record);
790
+ }
791
+ }, t("release")) : null,
792
+ record.status === "suggested" && !isQuarantined ? (0, import_react.createElement)("button", {
793
+ style: ssid.btn,
794
+ onClick: () => {
795
+ void memoryApi.confirm(record.id, props.cwd).then(() => reload());
796
+ }
797
+ }, t("confirm")) : null,
798
+ (0, import_react.createElement)("button", {
799
+ style: ssid.btn,
800
+ onClick: () => {
801
+ void memoryApi.forget(record.id, props.cwd).then(() => reload());
802
+ }
803
+ }, t("forget"))
804
+ )
805
+ );
806
+ };
807
+ const foldHeader = (label, count, open, toggle) => (0, import_react.createElement)(
808
+ "div",
809
+ {
810
+ style: { ...ssid.title, cursor: "pointer", marginBottom: 0 },
811
+ onClick: toggle,
812
+ onKeyDown: (event) => {
813
+ if (event.key === "Enter" || event.key === " ") toggle();
814
+ },
815
+ role: "button",
816
+ tabIndex: 0
817
+ },
818
+ (0, import_react.createElement)("span", null, `${open ? "\u25BE" : "\u25B8"} ${label}`),
819
+ (0, import_react.createElement)("span", { style: ssid.count }, String(count))
820
+ );
821
+ const quarantineSection = quarantinedScoped.length === 0 ? null : (0, import_react.createElement)(
822
+ "div",
823
+ { key: "quarantine", style: { display: "flex", flexDirection: "column", gap: 6 } },
824
+ foldHeader(t("groupQuarantine"), quarantinedScoped.length, quarantineOpen, () => {
825
+ setQuarantineOpen((open) => !open);
826
+ }),
827
+ (0, import_react.createElement)("div", { style: { ...ssid.muted, fontSize: 10.5 } }, t("groupQuarantineHint")),
828
+ quarantineOpen ? (0, import_react.createElement)(
829
+ "div",
830
+ { style: { display: "flex", flexDirection: "column", gap: 6 } },
831
+ quarantinedScoped.map((record) => memoryCard(record, { quarantined: true }))
832
+ ) : null
833
+ );
834
+ const coldSection = coldItems.length === 0 ? null : (0, import_react.createElement)(
835
+ "div",
836
+ { key: "cold", style: { display: "flex", flexDirection: "column", gap: 6 } },
837
+ foldHeader(t("groupCold", { days: COLD_DAYS }), coldItems.length, coldOpen, () => {
838
+ setColdOpen((open) => !open);
839
+ }),
840
+ coldOpen ? (0, import_react.createElement)(
841
+ "div",
842
+ { style: { display: "flex", flexDirection: "column", gap: 6 } },
843
+ coldItems.map((record) => memoryCard(record))
844
+ ) : null
845
+ );
846
+ const groupSections = groups.map((group) => (0, import_react.createElement)(
847
+ "div",
848
+ {
849
+ key: group.key,
850
+ style: { display: "flex", flexDirection: "column", gap: 6 }
851
+ },
852
+ (0, import_react.createElement)(
853
+ "div",
854
+ { style: ssid.title },
855
+ (0, import_react.createElement)("span", null, group.label),
856
+ (0, import_react.createElement)(
857
+ "div",
858
+ { style: { display: "flex", alignItems: "center", gap: 6 } },
859
+ group.key === "pending" && group.items.length > 0 ? (0, import_react.createElement)("button", {
860
+ type: "button",
861
+ title: t("confirmAll"),
862
+ onClick: () => {
863
+ void confirmAll();
864
+ },
865
+ style: { ...ssid.btn, padding: "1px 8px", fontSize: 10.5 }
866
+ }, t("confirmAll")) : null,
867
+ (0, import_react.createElement)("span", { style: ssid.count }, String(group.items.length))
868
+ )
869
+ ),
870
+ group.items.map((record) => memoryCard(record))
871
+ ));
872
+ const listSection = quarantineSection === null && groupSections.length === 0 && coldSection === null ? (0, import_react.createElement)("div", { key: "empty", style: ssid.empty }, t("empty")) : (0, import_react.createElement)(
873
+ "div",
874
+ { key: "groups", style: { display: "flex", flexDirection: "column", gap: 12 } },
875
+ quarantineSection,
876
+ groupSections,
877
+ coldSection
878
+ );
684
879
  const modeTabs = (0, import_react.createElement)(
685
880
  "div",
686
881
  { style: { display: "flex", gap: 4 } },
@@ -799,83 +994,7 @@ function MemoryView(props) {
799
994
  preview.lines.length === 0 ? (0, import_react.createElement)("div", { style: ssid.muted }, t("empty")) : preview.lines.map((line, index) => (0, import_react.createElement)("div", { key: index, style: { ...ssid.muted, fontSize: 11 } }, line))
800
995
  ) : null,
801
996
  // 未选择工作区:工作区视图显示占位(0.3.4 工作区路由语义)
802
- namespace === "workspace" && (props.cwd === void 0 || props.cwd === "") ? (0, import_react.createElement)("div", { style: ssid.empty }, t("noWorkspace")) : groups.length === 0 ? (0, import_react.createElement)("div", { style: ssid.empty }, t("empty")) : groups.map((group) => (0, import_react.createElement)(
803
- "div",
804
- { key: group.key, style: { display: "flex", flexDirection: "column", gap: 6 } },
805
- (0, import_react.createElement)(
806
- "div",
807
- { style: ssid.title },
808
- (0, import_react.createElement)("span", null, group.label),
809
- (0, import_react.createElement)(
810
- "div",
811
- { style: { display: "flex", alignItems: "center", gap: 6 } },
812
- group.key === "pending" && group.items.length > 0 ? (0, import_react.createElement)("button", {
813
- type: "button",
814
- title: t("confirmAll"),
815
- onClick: () => {
816
- void confirmAll();
817
- },
818
- style: { ...ssid.btn, padding: "1px 8px", fontSize: 10.5 }
819
- }, t("confirmAll")) : null,
820
- (0, import_react.createElement)("span", { style: ssid.count }, String(group.items.length))
821
- )
822
- ),
823
- group.items.map((record) => (0, import_react.createElement)(
824
- "div",
825
- { key: record.id, style: ssid.card },
826
- (0, import_react.createElement)("div", { style: ssid.text }, ContentWithRefs({ text: record.content })),
827
- // keywords 展示(0.3.5:设计约定 UI 显示 keywords)
828
- record.keywords !== void 0 && record.keywords.length > 0 ? (0, import_react.createElement)(
829
- "div",
830
- { style: { display: "flex", flexWrap: "wrap", gap: 4, marginTop: 6 } },
831
- record.keywords.map((keyword) => (0, import_react.createElement)("span", {
832
- key: keyword,
833
- style: {
834
- fontSize: 10,
835
- padding: "1px 7px",
836
- borderRadius: 8,
837
- background: "var(--dsw-alias-bg-module-platform, rgba(128,148,168,.14))",
838
- color: "var(--dsw-alias-label-secondary, #67748a)"
839
- }
840
- }, keyword))
841
- ) : null,
842
- (0, import_react.createElement)(
843
- "div",
844
- { style: { ...ssid.muted, marginTop: 6 } },
845
- `${record.namespace} \xB7 ${record.status === "approved" ? t("approved") : t("suggested")}${record.injected ? ` \xB7 ${t("groupInjected")}` : ""}${record.lastUsedAt !== void 0 ? ` \xB7 \u4E0A\u641C ${fmtDate(record.lastUsedAt)}` : ""}${isCold(record.lastUsedAt) ? ` \xB7 ${t("coldHint")}` : ""}`
846
- ),
847
- (0, import_react.createElement)(
848
- "div",
849
- { style: { display: "flex", gap: 6, marginTop: 8, alignItems: "center" } },
850
- (0, import_react.createElement)("button", {
851
- type: "button",
852
- title: record.status === "approved" ? t("injectSwitch") : t("approveFirst"),
853
- disabled: record.status !== "approved",
854
- onClick: () => {
855
- void toggleInjected(record);
856
- },
857
- style: {
858
- ...ssid.btn,
859
- ...record.injected ? { color: ssid.accent, borderColor: ssid.accent } : {},
860
- opacity: record.status !== "approved" ? 0.4 : 1,
861
- cursor: record.status !== "approved" ? "not-allowed" : "pointer"
862
- }
863
- }, record.injected ? `\u2713 ${t("injectSwitch")}` : t("injectSwitch")),
864
- record.status === "suggested" ? (0, import_react.createElement)("button", {
865
- style: ssid.btn,
866
- onClick: () => {
867
- void memoryApi.confirm(record.id, props.cwd).then(() => reload());
868
- }
869
- }, t("confirm")) : null,
870
- (0, import_react.createElement)("button", {
871
- style: ssid.btn,
872
- onClick: () => {
873
- void memoryApi.forget(record.id, props.cwd).then(() => reload());
874
- }
875
- }, t("forget"))
876
- )
877
- ))
878
- ))
997
+ namespace === "workspace" && (props.cwd === void 0 || props.cwd === "") ? (0, import_react.createElement)("div", { style: ssid.empty }, t("noWorkspace")) : listSection
879
998
  )
880
999
  );
881
1000
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 有效性锚点(2026-09-15 静默记忆机制):把一条记忆绑定到某个**可探测的环境值**上,
3
+ * 值变了就自动失效——解决「版本特性类记忆随环境升级静默变错」的问题。
4
+ *
5
+ * 第一版只做**进程内可探测**的类型:校验跑在会话启动的路径上,探测必须零 IO。
6
+ * 需要跑子进程才能拿到的(如 PowerShell 版本)留待后续——那要先解决「插件直接
7
+ * spawn 会绕过 DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子。
8
+ */
9
+ /** 受控词汇表:写入时只能从这三种里选,不能自创(自创的锚点无法机械校验)。 */
10
+ export type AnchorKind = 'env' | 'tool-list' | 'self-version';
11
+ /** 一条记忆声明的锚点。 */
12
+ export interface MemoryAnchor {
13
+ kind: AnchorKind;
14
+ /** `env` 类型时的变量名;其余类型省略。 */
15
+ name?: string;
16
+ /** 写入时探测到的值。 */
17
+ value: string;
18
+ }
19
+ /** 探测上下文:由调用方提供,便于测试注入与缓存。 */
20
+ export interface AnchorProbes {
21
+ /**
22
+ * 当前可用工具的清单;**拿不到时必须是 `undefined` 而不是空数组**——空数组会被判成
23
+ * 「工具全没了」,让所有 `tool-list` 锚点误失效。
24
+ */
25
+ toolNames?: readonly string[];
26
+ /** dsh-memory 自身版本。 */
27
+ selfVersion: string;
28
+ /** 环境变量读取器;缺省 `process.env`。 */
29
+ env?: (name: string) => string | undefined;
30
+ }
31
+ /**
32
+ * 探测某个锚点当前的取值。
33
+ *
34
+ * @param anchor - 记忆声明的锚点。
35
+ * @param probes - 当前环境的探测上下文。
36
+ * @returns 当前值;**无法探测时返回 `undefined`**(调用方按「未校验」处理,不能判失效)。
37
+ */
38
+ export declare function probeAnchor(anchor: MemoryAnchor, probes: AnchorProbes): string | undefined;
39
+ /**
40
+ * 锚点是否仍然成立。
41
+ *
42
+ * @returns `true` 成立;`false` 失效;`undefined` **未校验**(探测不到,不判失效——探测
43
+ * 失败与「值变了」是两件事,混起来会误伤)。
44
+ */
45
+ export declare function anchorHolds(anchor: MemoryAnchor, probes: AnchorProbes): boolean | undefined;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 有效性锚点(2026-09-15 静默记忆机制):把一条记忆绑定到某个**可探测的环境值**上,
3
+ * 值变了就自动失效——解决「版本特性类记忆随环境升级静默变错」的问题。
4
+ *
5
+ * 第一版只做**进程内可探测**的类型:校验跑在会话启动的路径上,探测必须零 IO。
6
+ * 需要跑子进程才能拿到的(如 PowerShell 版本)留待后续——那要先解决「插件直接
7
+ * spawn 会绕过 DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子。
8
+ */
9
+ /**
10
+ * 探测某个锚点当前的取值。
11
+ *
12
+ * @param anchor - 记忆声明的锚点。
13
+ * @param probes - 当前环境的探测上下文。
14
+ * @returns 当前值;**无法探测时返回 `undefined`**(调用方按「未校验」处理,不能判失效)。
15
+ */
16
+ export function probeAnchor(anchor, probes) {
17
+ switch (anchor.kind) {
18
+ case 'env': {
19
+ if (anchor.name === undefined || anchor.name === '')
20
+ return undefined;
21
+ const read = probes.env ?? ((name) => process.env[name]);
22
+ return read(anchor.name);
23
+ }
24
+ case 'tool-list':
25
+ if (probes.toolNames === undefined)
26
+ return undefined;
27
+ return [...probes.toolNames].sort().join(',');
28
+ case 'self-version':
29
+ return probes.selfVersion;
30
+ default:
31
+ return undefined;
32
+ }
33
+ }
34
+ /**
35
+ * 锚点是否仍然成立。
36
+ *
37
+ * @returns `true` 成立;`false` 失效;`undefined` **未校验**(探测不到,不判失效——探测
38
+ * 失败与「值变了」是两件事,混起来会误伤)。
39
+ */
40
+ export function anchorHolds(anchor, probes) {
41
+ const current = probeAnchor(anchor, probes);
42
+ if (current === undefined)
43
+ return undefined;
44
+ return current === anchor.value;
45
+ }