@max-null/dsh-memory 0.11.1 → 0.12.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/dist/anchors.d.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  * 有效性锚点(2026-09-15 静默记忆机制):把一条记忆绑定到某个**可探测的环境值**上,
3
3
  * 值变了就自动失效——解决「版本特性类记忆随环境升级静默变错」的问题。
4
4
  *
5
- * 第一版只做**进程内可探测**的类型:校验跑在会话启动的路径上,探测必须零 IO。
6
- * 需要跑子进程才能拿到的(如 PowerShell 版本)留待后续——那要先解决「插件直接
7
- * spawn 会绕过 DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子。
5
+ * 探测跑在会话启动的校验路径上,因此**禁止起子进程**(那要先解决「插件直接 spawn 会绕过
6
+ * DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子)。**文件探测不在此列**:`path-exists`
7
+ * 只有一次 `existsSync`,与读环境变量同量级。此前这里写的是「探测必须零 IO」,那句话把
8
+ * 「禁止 spawn」误写成了「禁止一切系统调用」——0.12.0 加 `path-exists` 时改正。
8
9
  */
9
- /** 受控词汇表:写入时只能从这三种里选,不能自创(自创的锚点无法机械校验)。 */
10
- export type AnchorKind = 'env' | 'tool-list' | 'self-version';
10
+ /** 受控词汇表:写入时只能从这四种里选,不能自创(自创的锚点无法机械校验)。 */
11
+ export type AnchorKind = 'env' | 'tool-list' | 'self-version' | 'path-exists';
11
12
  /** 一条记忆声明的锚点。 */
12
13
  export interface MemoryAnchor {
13
14
  kind: AnchorKind;
@@ -27,6 +28,13 @@ export interface AnchorProbes {
27
28
  selfVersion: string;
28
29
  /** 环境变量读取器;缺省 `process.env`。 */
29
30
  env?: (name: string) => string | undefined;
31
+ /**
32
+ * 会话工作区 cwd:`path-exists` 用它把**相对路径**解析成绝对路径(0.12.0)。
33
+ *
34
+ * 缺省时相对路径判为「**未校验**」而不是「不存在」——定位不到与文件真的不在是两件事,
35
+ * 混起来会让所有相对路径锚点在拿不到 cwd 的路径上集体误失效。
36
+ */
37
+ workspaceCwd?: string;
30
38
  }
31
39
  /**
32
40
  * 探测某个锚点当前的取值。
package/dist/anchors.js CHANGED
@@ -2,10 +2,13 @@
2
2
  * 有效性锚点(2026-09-15 静默记忆机制):把一条记忆绑定到某个**可探测的环境值**上,
3
3
  * 值变了就自动失效——解决「版本特性类记忆随环境升级静默变错」的问题。
4
4
  *
5
- * 第一版只做**进程内可探测**的类型:校验跑在会话启动的路径上,探测必须零 IO。
6
- * 需要跑子进程才能拿到的(如 PowerShell 版本)留待后续——那要先解决「插件直接
7
- * spawn 会绕过 DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子。
5
+ * 探测跑在会话启动的校验路径上,因此**禁止起子进程**(那要先解决「插件直接 spawn 会绕过
6
+ * DSH 的 shell 沙箱」的合规问题,不能顺手开这个口子)。**文件探测不在此列**:`path-exists`
7
+ * 只有一次 `existsSync`,与读环境变量同量级。此前这里写的是「探测必须零 IO」,那句话把
8
+ * 「禁止 spawn」误写成了「禁止一切系统调用」——0.12.0 加 `path-exists` 时改正。
8
9
  */
10
+ import { existsSync } from 'node:fs';
11
+ import { isAbsolute, resolve } from 'node:path';
9
12
  /**
10
13
  * 探测某个锚点当前的取值。
11
14
  *
@@ -27,6 +30,16 @@ export function probeAnchor(anchor, probes) {
27
30
  return [...probes.toolNames].sort().join(',');
28
31
  case 'self-version':
29
32
  return probes.selfVersion;
33
+ case 'path-exists': {
34
+ if (anchor.name === undefined || anchor.name === '')
35
+ return undefined;
36
+ const relative = !isAbsolute(anchor.name);
37
+ // 相对路径但没有工作区:定位不到 → 未校验(不能判失效)
38
+ if (relative && (probes.workspaceCwd === undefined || probes.workspaceCwd === ''))
39
+ return undefined;
40
+ const target = relative ? resolve(probes.workspaceCwd, anchor.name) : anchor.name;
41
+ return existsSync(target) ? 'present' : 'absent';
42
+ }
30
43
  default:
31
44
  return undefined;
32
45
  }
package/dist/engine.d.ts CHANGED
@@ -114,6 +114,14 @@ export interface MemoryFilter {
114
114
  quarantined?: boolean;
115
115
  /** 记录类别过滤(0.6.0);缺省不过滤(工具层显式传 kind='fact' 保持旧行为)。 */
116
116
  kind?: MemoryKind;
117
+ /**
118
+ * 更新时间下界 / 上界(0.12.0;epoch ms,含端点)。
119
+ *
120
+ * **只有 `list` 读这两个字段**——给 `search` 加时间过滤要动候选集(过滤与打分、`markUsed`
121
+ * 记账的交互:被过滤掉的命中还算不算「用过」),那是另一件要单独论证的事,本版不做。
122
+ */
123
+ after?: number;
124
+ before?: number;
117
125
  }
118
126
  /** {@link MemoryEngine.update} 的补丁:省略的字段保持原值(`injected` 省略时连 `injectedAuto` 一起保持)。 */
119
127
  export interface MemoryPatch {
@@ -184,6 +192,36 @@ export type MemoryChange = {
184
192
  from: string;
185
193
  to: string;
186
194
  };
195
+ /** 体检报告的单条记录:摘要长度与注入行同量级,便于直接念给人听或写进诊断行。 */
196
+ export interface SweepItem {
197
+ id: string;
198
+ summary: string;
199
+ }
200
+ /**
201
+ * 只读体检的产出({@link MemoryEngine.sweep})。四类各有明确的消费者,不做「算了没人读」的采样
202
+ * ——候选与新失效会进注入诊断行(模型每轮可见),久未命中与全量留给按需调用。
203
+ */
204
+ export interface SweepReport {
205
+ /** 当前失效的记录,带原因(锚点不符 / 被取代 / 被撤回)。 */
206
+ stale: Array<SweepItem & {
207
+ reason: string | undefined;
208
+ }>;
209
+ /** 久未被检索命中的**非常驻**记录——常驻的「没被查过」是正常的,它本来就每轮在场。 */
210
+ longUnused: Array<SweepItem & {
211
+ lastUsed: number;
212
+ }>;
213
+ /** 候选:被反复检索命中但没有被钉成常驻(见 `candidates`)。 */
214
+ candidates: Array<SweepItem & {
215
+ hitCount: number;
216
+ }>;
217
+ /** 常驻与注入预算的占用:总数 / 实际进注入 / 预算(无限制时不出现)/ 被挤掉几条。 */
218
+ resident: {
219
+ total: number;
220
+ injected: number;
221
+ budget: number | undefined;
222
+ omitted: number;
223
+ };
224
+ }
187
225
  declare module '@deepseek-ai/cordis' {
188
226
  interface Context {
189
227
  memory: MemoryEngine;
@@ -260,6 +298,13 @@ export declare class MemoryEngine extends Service {
260
298
  private writeChain;
261
299
  /** 会话启动维护只跑一次的标记(2026-09-15)。 */
262
300
  private maintenanceDone;
301
+ /** 最近一次体检的结果(进程内,不落盘):供注入侧报「本次校验发现的新失效」。 */
302
+ private lastSweep;
303
+ /**
304
+ * 最近一次体检结果(只读)。未跑过时为 `undefined`——注入侧据此决定要不要报那一行,
305
+ * 它只在真有新失效时出现,稀有所以不构成噪音。
306
+ */
307
+ get lastSweepReport(): SweepReport | undefined;
263
308
  constructor(ctx: import('@deepseek-ai/cordis').Context, config?: MemoryConfig);
264
309
  protected [Service.init](): Promise<void>;
265
310
  /** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
@@ -363,6 +408,30 @@ export declare class MemoryEngine extends Service {
363
408
  * 未打开的层级本轮不计入,影响可忽略(链上祖先在检索路径打开后即缓存)。
364
409
  */
365
410
  indexCandidates(projectCwd?: string): MemoryRecord[];
411
+ /**
412
+ * 只读体检(0.12.0):把「该退场的」和「钉了却没进来的」摆成一张清单,**不修改任何状态**
413
+ * ——连时间戳都不写。
414
+ *
415
+ * 为什么坚持只读:一只读的工具,最坏情况是报告不准,判断者按错的清单做了坏决定也能追溯;
416
+ * 一个会改状态的工具,最坏情况是错了都看不见。这决定了它不需要「每日一次」的守卫,也不
417
+ * 需要定期读者——有人问它就活着,没人问它不产生任何成本(不占预算、不落盘、不写状态)。
418
+ */
419
+ sweep(projectCwd?: string, options?: {
420
+ days?: number;
421
+ now?: number;
422
+ }): Promise<SweepReport>;
423
+ /**
424
+ * 候选(0.12.0):被反复检索命中、但没有被钉成常驻的记录。**只提示,不改状态。**
425
+ *
426
+ * 筛选条件与原自动升级判据逐条对应(approved、未隔离、未被人工否决、非模板、未常驻)
427
+ * ——判据没变,变的只是它的作用:从「自动钉住」改为「列出来等一个判断」。`hitCount`
428
+ * 只增不减,所以候选会一直留着,直到被钉住、被删或记录本身消失——这不是缺陷,是提示
429
+ * 该有的持续性:它只在被消费之后才消失。
430
+ *
431
+ * 与 {@link indexCandidates} 的分工:那个取**全部**记录(供索引行统计规模与主题),
432
+ * 这个只取满足候选条件的(供体检报告与诊断行)。
433
+ */
434
+ candidates(projectCwd?: string): MemoryRecord[];
366
435
  /**
367
436
  * 按 id 定位记录所在表:global 优先,其次工作区链(自身 → 祖先,由近及远)。
368
437
  * **异步版**——写路径必须用它:`writeScope` 可能在本次进入前刚 `reload()`,
@@ -425,6 +494,9 @@ export declare class MemoryEngine extends Service {
425
494
  /**
426
495
  * 锚点探测上下文。`toolNames` 拿不到时返回 `undefined`——**不能给空数组**:那会被判成
427
496
  * 「工具全没了」,让所有 `tool-list` 锚点误失效。
497
+ *
498
+ * `workspaceCwd`(0.12.0)供 `path-exists` 把相对路径解析成绝对路径;缺省时相对路径
499
+ * 判为「未校验」而不是「不存在」。
428
500
  */
429
501
  private anchorProbes;
430
502
  /** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
@@ -434,9 +506,16 @@ export declare class MemoryEngine extends Service {
434
506
  /** {@link markUsed} 的实现体;必须经 `writeScope` 进入(`recordUse` 在此之内)。 */
435
507
  private markUsedInner;
436
508
  /**
437
- * 命中记账 + 自动升级(2026-09-15 静默记忆机制):命中次数达阈值即自动打开常驻
438
- * 注入——「被反复检索命中」是它值得每轮付费的唯一客观证据。统计性字段不 emit,
439
- * 只有真的改变了注入状态才发 `memory/changed`。
509
+ * 命中记账(2026-09-15 静默记忆机制;0.12.0 取消自动升级)。
510
+ *
511
+ * 只记 `hitCount` 与 `lastUsedAt`,**不再据此改变注入状态**。原判据是「被反复检索
512
+ * 命中」= 值得每轮付费的唯一客观证据,但这条证据测错了东西:命中次数度量的是
513
+ * **它被想起来了**,而常驻的语义是**它不能被忘**——能被检索命中的恰恰是检索已经
514
+ * 够用的那些,真正该常驻的(想不起来去查的)命中次数永远是 0。达到
515
+ * {@link CANDIDATE_HITS} 的记录改由 {@link candidates} 列出,进体检报告与诊断行
516
+ * 提示,改不改由判断者决定(判据详见设计文档 §二事实四)。
517
+ *
518
+ * 统计性字段不 emit:本函数现在不改变任何被订阅的状态。
440
519
  */
441
520
  private recordUse;
442
521
  /** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
@@ -563,6 +642,33 @@ export declare class MemoryEngine extends Service {
563
642
  setInjected(id: MemoryId, injected: boolean, projectCwd?: string): Promise<MemoryRecord>;
564
643
  /** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
565
644
  private setInjectedInner;
645
+ /**
646
+ * 语义作废(0.12.0):把 `targetId` 标成**被 `id` 取代**。
647
+ *
648
+ * 与「改内容」的区别:`update` 会让旧内容直接消失,「取代」把痕迹留下来——检索时看到
649
+ * 「这条已作废,原因是 X」对被主动查的人是信息而不是干扰。`id` 只用来写原因,本身不被
650
+ * 改动;两条都必须定位得到,否则拒绝(写一个指向不存在记忆的原因没有意义)。
651
+ *
652
+ * 下游复用既有的 `stale` 行为:撤常驻、不排除检索、带标注等核对回写。
653
+ */
654
+ supersede(id: MemoryId, targetId: MemoryId, projectCwd?: string): Promise<MemoryRecord>;
655
+ /** {@link supersede} 的实现体;必须经 `writeScope` 进入。返回**被标记的那条**。 */
656
+ private supersedeInner;
657
+ /**
658
+ * 撤回(0.12.0):把本条标成作废并写明原因。**内容保留**——不删除、也不排除检索,
659
+ * 与锚点失效后「带标注等核对」是同一条路径。
660
+ */
661
+ retract(id: MemoryId, reason: string, projectCwd?: string): Promise<MemoryRecord>;
662
+ /** {@link retract} 的实现体;必须经 `writeScope` 进入。 */
663
+ private retractInner;
664
+ /**
665
+ * 标失效的共用落点(0.12.0):写 `stale` + 原因、撤掉常驻、发 `memory/changed`。
666
+ *
667
+ * 与 `verifyAnchorsInner` 的失效分支保持**同一组字段**(不动 `status`、不删内容、不排除
668
+ * 检索)——「被取代」「被撤回」「锚点不符」在下游是同一种状态,只在 `staleReason` 里区分
669
+ * 来源。失效是一个维度、多种来源,这是那条判据的落点。
670
+ */
671
+ private markStale;
566
672
  /**
567
673
  * 修改一条记忆的内容/关键词/注入位(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
568
674
  * 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态。2026-09-18
package/dist/engine.js CHANGED
@@ -18,26 +18,42 @@ import { parsePromptFile, scanPromptDir, writePromptFile } from "./prompt-files.
18
18
  import { detectSensitive } from "./quarantine.js";
19
19
  import { SELF_VERSION } from "./self.js";
20
20
  import { anchorHolds } from "./anchors.js";
21
+ import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, deriveSummary, renderInjection, } from "./injection.js";
21
22
  import { QueryLog, rankGapCandidates, summarizeQueryLog, summarizeSources } from "./query-log.js";
22
23
  /** Brand a string as a {@link MemoryId} (compile-time only). */
23
24
  export function MemoryId(id) {
24
25
  return id;
25
26
  }
26
- /** 自动升常驻所需的命中次数(2026-09-15):一次偶然命中不足以证明「每轮都值得付费」。 */
27
- const AUTO_INJECT_HITS = 2;
27
+ /**
28
+ * 候选提示所需的命中次数(2026-09-15 起是自动升常驻的阈值;0.12.0 改为只提示)。
29
+ *
30
+ * 达到它的记录进 {@link MemoryEngine.candidates},由体检报告与诊断行列出——**不再据此
31
+ * 改变注入状态**。阈值本身没动:取消自动升之后,它影响的是提示清单的宽度,不再是状态变更。
32
+ * 一次偶然命中不足以说明任何事,所以起点仍是 2。
33
+ */
34
+ const CANDIDATE_HITS = 2;
28
35
  /**
29
36
  * 一次检索里最多把前多少条记为「被使用」(2026-09-15)。
30
37
  *
31
38
  * 不设上限时,命中计数退化成「和查询有任意字符重叠」:BM25 对常见 2-gram 会给全库打分,
32
- * 一句「实机验证标记」在本机就能命中 81 条,于是**两次检索把所有 approved 记忆都升成常驻**,
33
- * 注入预算当场被历史条目占满——「反复被命中才值得每轮付费」这个判据也就失效了。
34
- * 取前 K 名接近「模型真的会看的那几条」,且与检索返回值解耦(返回值不动)。
39
+ * 一句「实机验证标记」在本机就能命中 81 条。0.12.0 之前这会让**两次检索把所有 approved
40
+ * 记忆都升成常驻**、注入预算当场被历史条目占满;取消自动升之后危害降级为「候选清单被灌满
41
+ * 噪音」,但上限仍然必要——它保证 `hitCount` 度量的是「模型真的会看的那几条」。
42
+ * 与检索返回值解耦(返回值不动)。
35
43
  */
36
44
  const HIT_MARK_LIMIT = 5;
37
45
  /** 反查候选的正文摘要长度(字符)。 */
38
46
  const GAP_EXCERPT_CHARS = 160;
39
47
  /** 自动降级的天数阈值(2026-09-15):与面板的冷数据判定(COLD_DAYS=30)对齐。 */
40
48
  const AUTO_DEMOTE_DAYS = 30;
49
+ /**
50
+ * 体检里「久未命中」的默认天数阈值(0.12.0)。
51
+ *
52
+ * 比自动降级的 30 天大得多,因为两者判的不是一类东西:30 天判「自动升上来的常驻是否还有
53
+ * 价值」,而久未命中的对象**本来就不是常驻**——非常驻的记忆几十天不用是常态,阈值定得太低
54
+ * 会把正常记录报成待清理。
55
+ */
56
+ const DEFAULT_SWEEP_DAYS = 90;
41
57
  const blockSchema = z.object({
42
58
  namespace: z.enum(['global', 'project']),
43
59
  status: z.enum(['suggested', 'approved', 'auto', 'suggest']),
@@ -183,6 +199,15 @@ export class MemoryEngine extends Service {
183
199
  writeChain = Promise.resolve();
184
200
  /** 会话启动维护只跑一次的标记(2026-09-15)。 */
185
201
  maintenanceDone = false;
202
+ /** 最近一次体检的结果(进程内,不落盘):供注入侧报「本次校验发现的新失效」。 */
203
+ lastSweep;
204
+ /**
205
+ * 最近一次体检结果(只读)。未跑过时为 `undefined`——注入侧据此决定要不要报那一行,
206
+ * 它只在真有新失效时出现,稀有所以不构成噪音。
207
+ */
208
+ get lastSweepReport() {
209
+ return this.lastSweep;
210
+ }
186
211
  constructor(ctx, config = {}) {
187
212
  super(ctx, 'memory');
188
213
  this.config = config;
@@ -491,6 +516,78 @@ export class MemoryEngine extends Service {
491
516
  }
492
517
  return candidates;
493
518
  }
519
+ /**
520
+ * 只读体检(0.12.0):把「该退场的」和「钉了却没进来的」摆成一张清单,**不修改任何状态**
521
+ * ——连时间戳都不写。
522
+ *
523
+ * 为什么坚持只读:一只读的工具,最坏情况是报告不准,判断者按错的清单做了坏决定也能追溯;
524
+ * 一个会改状态的工具,最坏情况是错了都看不见。这决定了它不需要「每日一次」的守卫,也不
525
+ * 需要定期读者——有人问它就活着,没人问它不产生任何成本(不占预算、不落盘、不写状态)。
526
+ */
527
+ async sweep(projectCwd, options) {
528
+ const now = options?.now ?? Date.now();
529
+ const days = options?.days ?? DEFAULT_SWEEP_DAYS;
530
+ const threshold = now - days * 24 * 60 * 60 * 1000;
531
+ const summaryChars = this.config.summaryChars ?? DEFAULT_SUMMARY_CHARS;
532
+ const brief = (record) => deriveSummary(record.content, summaryChars);
533
+ const records = await this.allRecords(undefined, projectCwd);
534
+ const stale = records
535
+ .filter(record => record.stale === true)
536
+ .map(record => ({ id: String(record.id), summary: brief(record), reason: record.staleReason }));
537
+ // 只看非常驻:常驻的「没被检索过」是正常的(它本来就每轮在场,不需要被查)
538
+ const longUnused = records
539
+ .filter(record => record.injected !== true && (record.lastUsedAt ?? record.updatedAt) < threshold)
540
+ .map(record => ({
541
+ id: String(record.id),
542
+ summary: brief(record),
543
+ lastUsed: record.lastUsedAt ?? record.updatedAt,
544
+ }));
545
+ const candidates = this.candidates(projectCwd)
546
+ .map(record => ({ id: String(record.id), summary: brief(record), hitCount: record.hitCount ?? 0 }));
547
+ const resident = this.recallRecords(projectCwd);
548
+ const rendered = renderInjection(resident, this.config.injectionBudget ?? DEFAULT_INJECTION_BUDGET, summaryChars);
549
+ return {
550
+ stale,
551
+ longUnused,
552
+ candidates,
553
+ resident: {
554
+ total: resident.length,
555
+ injected: rendered.lines.length,
556
+ // `renderInjection` 用 `null` 表示「预算无限制」,而输出 schema 用「字段缺席」表达
557
+ // 同一件事(可选字段推导出 `number | undefined`)——在这里归一,避免两套空值语义。
558
+ budget: rendered.budget ?? undefined,
559
+ omitted: rendered.omitted,
560
+ },
561
+ };
562
+ }
563
+ /**
564
+ * 候选(0.12.0):被反复检索命中、但没有被钉成常驻的记录。**只提示,不改状态。**
565
+ *
566
+ * 筛选条件与原自动升级判据逐条对应(approved、未隔离、未被人工否决、非模板、未常驻)
567
+ * ——判据没变,变的只是它的作用:从「自动钉住」改为「列出来等一个判断」。`hitCount`
568
+ * 只增不减,所以候选会一直留着,直到被钉住、被删或记录本身消失——这不是缺陷,是提示
569
+ * 该有的持续性:它只在被消费之后才消失。
570
+ *
571
+ * 与 {@link indexCandidates} 的分工:那个取**全部**记录(供索引行统计规模与主题),
572
+ * 这个只取满足候选条件的(供体检报告与诊断行)。
573
+ */
574
+ candidates(projectCwd) {
575
+ const picked = (table) => this.recordsOf(table).filter(record => record.status === 'approved'
576
+ && record.quarantined !== true
577
+ && record.injected !== true
578
+ && record.injectedAuto !== false
579
+ && (record.kind ?? 'fact') !== 'prompt'
580
+ && (record.hitCount ?? 0) >= CANDIDATE_HITS);
581
+ const found = picked(this.requireTable('global'));
582
+ if (projectCwd === undefined || projectCwd === '')
583
+ return found;
584
+ for (const { key } of this.projectChain(projectCwd)) {
585
+ const table = this.projectTables.get(key);
586
+ if (table !== undefined)
587
+ found.push(...picked(table));
588
+ }
589
+ return found;
590
+ }
494
591
  /**
495
592
  * 按 id 定位记录所在表:global 优先,其次工作区链(自身 → 祖先,由近及远)。
496
593
  * **异步版**——写路径必须用它:`writeScope` 可能在本次进入前刚 `reload()`,
@@ -612,6 +709,9 @@ export class MemoryEngine extends Service {
612
709
  (filter?.quarantined === undefined ? record.quarantined !== true : record.quarantined === filter.quarantined)
613
710
  && (filter?.status === undefined || record.status === filter.status)
614
711
  && (filter?.injected === undefined || record.injected === filter.injected)
712
+ // 0.12.0:按更新时间筛——与注入排序、注入行的日期标记同一个依据,不制造第二个「新」的定义
713
+ && (filter?.after === undefined || record.updatedAt >= filter.after)
714
+ && (filter?.before === undefined || record.updatedAt <= filter.before)
615
715
  && (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind));
616
716
  }
617
717
  /**
@@ -739,9 +839,12 @@ export class MemoryEngine extends Service {
739
839
  const demoted = await this.demoteStale(projectCwd);
740
840
  if (demoted > 0)
741
841
  this.ctx.logger?.info?.(`dsh-memory: 长期未命中,撤下常驻 ${demoted} 条`);
742
- const staled = await this.verifyAnchors(this.anchorProbes(), projectCwd);
842
+ const staled = await this.verifyAnchors(this.anchorProbes(projectCwd), projectCwd);
743
843
  if (staled > 0)
744
844
  this.ctx.logger?.info?.(`dsh-memory: 锚点校验更新 ${staled} 条`);
845
+ // 体检(0.12.0):只读,产物存进实例字段,供注入侧报「本次校验发现的新失效」。
846
+ // 它在 try 内——报告没有记忆本身重要,失败由下面的 catch 兜住、不阻断启动。
847
+ this.lastSweep = await this.sweep(projectCwd);
745
848
  }
746
849
  catch (error) {
747
850
  this.ctx.logger?.warn?.(`dsh-memory: 启动维护失败(不影响使用):${String(error)}`);
@@ -750,8 +853,11 @@ export class MemoryEngine extends Service {
750
853
  /**
751
854
  * 锚点探测上下文。`toolNames` 拿不到时返回 `undefined`——**不能给空数组**:那会被判成
752
855
  * 「工具全没了」,让所有 `tool-list` 锚点误失效。
856
+ *
857
+ * `workspaceCwd`(0.12.0)供 `path-exists` 把相对路径解析成绝对路径;缺省时相对路径
858
+ * 判为「未校验」而不是「不存在」。
753
859
  */
754
- anchorProbes() {
860
+ anchorProbes(workspaceCwd) {
755
861
  let toolNames;
756
862
  try {
757
863
  const tools = this.ctx.get('tools');
@@ -765,7 +871,11 @@ export class MemoryEngine extends Service {
765
871
  catch {
766
872
  toolNames = undefined;
767
873
  }
768
- return { ...(toolNames === undefined ? {} : { toolNames }), selfVersion: SELF_VERSION };
874
+ return {
875
+ ...(toolNames === undefined ? {} : { toolNames }),
876
+ selfVersion: SELF_VERSION,
877
+ ...(workspaceCwd === undefined || workspaceCwd === '' ? {} : { workspaceCwd }),
878
+ };
769
879
  }
770
880
  /** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
771
881
  tableOf(id, projectCwd) {
@@ -812,33 +922,24 @@ export class MemoryEngine extends Service {
812
922
  }
813
923
  }
814
924
  /**
815
- * 命中记账 + 自动升级(2026-09-15 静默记忆机制):命中次数达阈值即自动打开常驻
816
- * 注入——「被反复检索命中」是它值得每轮付费的唯一客观证据。统计性字段不 emit,
817
- * 只有真的改变了注入状态才发 `memory/changed`。
925
+ * 命中记账(2026-09-15 静默记忆机制;0.12.0 取消自动升级)。
926
+ *
927
+ * 只记 `hitCount` 与 `lastUsedAt`,**不再据此改变注入状态**。原判据是「被反复检索
928
+ * 命中」= 值得每轮付费的唯一客观证据,但这条证据测错了东西:命中次数度量的是
929
+ * **它被想起来了**,而常驻的语义是**它不能被忘**——能被检索命中的恰恰是检索已经
930
+ * 够用的那些,真正该常驻的(想不起来去查的)命中次数永远是 0。达到
931
+ * {@link CANDIDATE_HITS} 的记录改由 {@link candidates} 列出,进体检报告与诊断行
932
+ * 提示,改不改由判断者决定(判据详见设计文档 §二事实四)。
933
+ *
934
+ * 统计性字段不 emit:本函数现在不改变任何被订阅的状态。
818
935
  */
819
936
  async recordUse(table, id, block, now) {
820
- const hitCount = (block.hitCount ?? 0) + 1;
821
- const normalized = normalizeBlock(block);
822
- // 人工设置过的(injectedAuto === false)双向豁免:既不被降级,也不被升级。
823
- // 少了这一条,人手动关掉的记忆会在下次命中时被系统重新打开——用户唯一能
824
- // 表达「不要这条常驻」的动作就失效了,而这与降级侧「人工决定优先」不对称。
825
- const promote = normalized.status === 'approved'
826
- && block.quarantined !== true
827
- && block.injectedAuto !== false
828
- // 模板永不注入(0.6.0):prompt 记录连自动升级的资格都没有。少了这一条,
829
- // 模板会因「经常被搜到」而升成常驻,把注入预算花在从不该进上下文的东西上。
830
- && (block.kind ?? 'fact') !== 'prompt'
831
- && !normalized.injected
832
- && hitCount >= AUTO_INJECT_HITS;
833
937
  const updated = {
834
938
  ...block,
835
- hitCount,
939
+ hitCount: (block.hitCount ?? 0) + 1,
836
940
  lastUsedAt: now,
837
- ...promote ? { injected: true, injectedAuto: true } : {},
838
941
  };
839
942
  await table.put(id, updated);
840
- if (promote)
841
- this.ctx.emit('memory/changed', { operation: 'injected', id, injected: true });
842
943
  }
843
944
  // ── 查询日志与影响力统计(⑥ 2026-09-15)────────────────────────────────
844
945
  /** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
@@ -1271,6 +1372,64 @@ export class MemoryEngine extends Service {
1271
1372
  this.ctx.emit('memory/changed', { operation: 'injected', id, injected });
1272
1373
  return record;
1273
1374
  }
1375
+ /**
1376
+ * 语义作废(0.12.0):把 `targetId` 标成**被 `id` 取代**。
1377
+ *
1378
+ * 与「改内容」的区别:`update` 会让旧内容直接消失,「取代」把痕迹留下来——检索时看到
1379
+ * 「这条已作废,原因是 X」对被主动查的人是信息而不是干扰。`id` 只用来写原因,本身不被
1380
+ * 改动;两条都必须定位得到,否则拒绝(写一个指向不存在记忆的原因没有意义)。
1381
+ *
1382
+ * 下游复用既有的 `stale` 行为:撤常驻、不排除检索、带标注等核对回写。
1383
+ */
1384
+ async supersede(id, targetId, projectCwd) {
1385
+ return this.writeScope(projectCwd, () => this.supersedeInner(id, targetId, projectCwd));
1386
+ }
1387
+ /** {@link supersede} 的实现体;必须经 `writeScope` 进入。返回**被标记的那条**。 */
1388
+ async supersedeInner(id, targetId, projectCwd) {
1389
+ if (id === targetId)
1390
+ throw new Error(`cannot supersede a memory with itself ('${id}')`);
1391
+ if (await this.locateRecord(id, projectCwd) === undefined) {
1392
+ throw new Error(`cannot supersede from unknown memory '${id}'`);
1393
+ }
1394
+ const target = await this.locateRecord(targetId, projectCwd);
1395
+ if (target === undefined)
1396
+ throw new Error(`cannot supersede unknown memory '${targetId}'`);
1397
+ return toRecord(targetId, await this.markStale(targetId, target, `被 ${String(id).slice(0, 8)} 取代`));
1398
+ }
1399
+ /**
1400
+ * 撤回(0.12.0):把本条标成作废并写明原因。**内容保留**——不删除、也不排除检索,
1401
+ * 与锚点失效后「带标注等核对」是同一条路径。
1402
+ */
1403
+ async retract(id, reason, projectCwd) {
1404
+ return this.writeScope(projectCwd, () => this.retractInner(id, reason, projectCwd));
1405
+ }
1406
+ /** {@link retract} 的实现体;必须经 `writeScope` 进入。 */
1407
+ async retractInner(id, reason, projectCwd) {
1408
+ const found = await this.locateRecord(id, projectCwd);
1409
+ if (found === undefined)
1410
+ throw new Error(`cannot retract unknown memory '${id}'`);
1411
+ return toRecord(id, await this.markStale(id, found, `已撤回:${reason}`));
1412
+ }
1413
+ /**
1414
+ * 标失效的共用落点(0.12.0):写 `stale` + 原因、撤掉常驻、发 `memory/changed`。
1415
+ *
1416
+ * 与 `verifyAnchorsInner` 的失效分支保持**同一组字段**(不动 `status`、不删内容、不排除
1417
+ * 检索)——「被取代」「被撤回」「锚点不符」在下游是同一种状态,只在 `staleReason` 里区分
1418
+ * 来源。失效是一个维度、多种来源,这是那条判据的落点。
1419
+ */
1420
+ async markStale(id, found, reason) {
1421
+ const updated = {
1422
+ ...found.block,
1423
+ stale: true,
1424
+ staleReason: reason,
1425
+ injected: false,
1426
+ injectedAuto: false,
1427
+ updatedAt: Date.now(),
1428
+ };
1429
+ await found.table.put(id, updated);
1430
+ this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
1431
+ return updated;
1432
+ }
1274
1433
  /**
1275
1434
  * 修改一条记忆的内容/关键词/注入位(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
1276
1435
  * 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态。2026-09-18
package/dist/index.js CHANGED
@@ -9,12 +9,38 @@ import { MemoryEngine } from "./engine.js";
9
9
  import { MemoryGateway } from "./remote.js";
10
10
  import { mountMemoryApi } from "./routes.js";
11
11
  import { SELF_DESCRIPTION } from "./self.js";
12
- import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, indexNotice, neighborNotice, neutralizeBraces, omittedNotice, renderInjection } from "./injection.js";
12
+ import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, candidateNotice, indexNotice, neighborNotice, neutralizeBraces, omittedNotice, renderInjection, staleNotice } from "./injection.js";
13
13
  export { MemoryEngine } from "./engine.js";
14
14
  export { MemoryId } from "./engine.js";
15
15
  export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from "./bm25.js";
16
16
  export const name = 'dsh-memory';
17
17
  export const inject = ['storage', 'systemPrompt', 'tools', 'webServer', 'webRuntime'];
18
+ /**
19
+ * 解析时间过滤参数(0.12.0):`7d` / `30d` 这类相对天数,或 `YYYY-MM-DD` 绝对日期。
20
+ *
21
+ * **为什么用字符串而不是数字**:工具参数里目前没有 number 类型的先例(`type: 'number'` 只
22
+ * 出现在输出 schema),不拿一个未验证的参数类型去赌;字符串也天然容纳 `7d` 这种相对写法。
23
+ * 解析不出(空串 / 格式不对)一律返回 `undefined` = 不过滤,**不报错**——过滤条件是辅助
24
+ * 信息,让它把一次查询整体打断得不偿失。
25
+ *
26
+ * 绝对日期按**本地时区**解释(`.dsh` 的存储时间戳是 epoch,用户在本地日期里思考),
27
+ * 相对天数以 `now` 为基准往前推。
28
+ */
29
+ function parseTimeFilter(input, now) {
30
+ if (input === undefined)
31
+ return undefined;
32
+ const text = input.trim();
33
+ if (text === '')
34
+ return undefined;
35
+ const relative = /^(\d+)d$/.exec(text);
36
+ if (relative !== null)
37
+ return now - Number(relative[1]) * 24 * 60 * 60 * 1000;
38
+ const absolute = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(text);
39
+ if (absolute !== null) {
40
+ return new Date(Number(absolute[1]), Number(absolute[2]) - 1, Number(absolute[3])).getTime();
41
+ }
42
+ return undefined;
43
+ }
18
44
  const RECORD_SCHEMA = {
19
45
  type: 'object',
20
46
  additionalProperties: false,
@@ -43,6 +69,70 @@ const HIT_SCHEMA = {
43
69
  score: { type: 'number', required: true },
44
70
  },
45
71
  };
72
+ /**
73
+ * 体检报告的输出(0.12.0):四类清单一律只读。`summary` 与注入行同量级,直接念给人听
74
+ * 不失真——这个工具的主要用法就是「你问起时我念给你看」。
75
+ */
76
+ const SWEEP_SCHEMA = {
77
+ type: 'object',
78
+ additionalProperties: false,
79
+ properties: {
80
+ stale: {
81
+ type: 'array',
82
+ required: true,
83
+ description: '当前失效的记录(锚点不符 / 被取代 / 已撤回),带原因。',
84
+ items: {
85
+ type: 'object',
86
+ additionalProperties: false,
87
+ properties: {
88
+ id: { type: 'string', required: true },
89
+ summary: { type: 'string', required: true },
90
+ reason: { type: 'string' },
91
+ },
92
+ },
93
+ },
94
+ longUnused: {
95
+ type: 'array',
96
+ required: true,
97
+ description: '久未被检索命中的非常驻记录——清理或归档的候选材料。',
98
+ items: {
99
+ type: 'object',
100
+ additionalProperties: false,
101
+ properties: {
102
+ id: { type: 'string', required: true },
103
+ summary: { type: 'string', required: true },
104
+ lastUsed: { type: 'number', required: true },
105
+ },
106
+ },
107
+ },
108
+ candidates: {
109
+ type: 'array',
110
+ required: true,
111
+ description: '被反复检索命中但没有被钉成常驻:要不要钉的待办。',
112
+ items: {
113
+ type: 'object',
114
+ additionalProperties: false,
115
+ properties: {
116
+ id: { type: 'string', required: true },
117
+ summary: { type: 'string', required: true },
118
+ hitCount: { type: 'number', required: true },
119
+ },
120
+ },
121
+ },
122
+ resident: {
123
+ type: 'object',
124
+ required: true,
125
+ additionalProperties: false,
126
+ description: '常驻与注入预算的占用;`omitted > 0` 表示「钉了却没进来」。',
127
+ properties: {
128
+ total: { type: 'number', required: true },
129
+ injected: { type: 'number', required: true },
130
+ budget: { type: 'number', description: '生效预算(字符);无限制时不出现。' },
131
+ omitted: { type: 'number', required: true },
132
+ },
133
+ },
134
+ },
135
+ };
46
136
  /** 模板记录(0.6.0):不暴露本地文件路径(meta.path 引擎内部使用)。 */
47
137
  const PROMPT_RECORD_SCHEMA = {
48
138
  type: 'object',
@@ -115,19 +205,24 @@ const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and pr
115
205
  + '记结论时一并记下**它是怎么来的**——它为什么成立,以及是什么场景把它逼出来的。没有出处的结论像一把'
116
206
  + '没有来历的尺子:看起来天经地义,而天经地义的东西最容易被机械套用。'
117
207
  + 'Pass `injected: true` on memory_save / memory_update to pin a memory resident in every turn\'s context: '
118
- + 'the same switch the settings panel exposes, exempting the record from both automatic rules (promote on '
119
- + 'repeated hits, demote after long idle). Reserve it for rules, standing agreements and judgement criteria '
208
+ + 'the same switch the settings panel exposes, exempting the record from the remaining automatic rule '
209
+ + '(demote after long idle; repeated hits no longer promote anything — they only feed the candidate hint). '
210
+ + 'Reserve it for rules, standing agreements and judgement criteria '
120
211
  + '— the kind that must apply even when nobody thinks to search for them; facts, references and case notes '
121
212
  + 'stay search-only. '
122
213
  + '给 memory_save / memory_update 传 `injected: true` 即把这条钉成每轮常驻——与设置面板是同一个开关,'
123
- + '此后不受两条自动规则(反复命中自动开启 / 长期未命中自动撤下)影响。只用于规则、长期约定与判据这类'
214
+ + '此后不受剩下那条自动规则(长期未命中自动撤下)影响;反复命中已不再自动开启任何东西,只喂候选提示。'
215
+ + '只用于规则、长期约定与判据这类'
124
216
  + '「没人想起来搜也必须生效」的记忆;事实、参考与案例留作按需检索。'
125
217
  + 'When earlier context may be relevant, call memory_search to recall it — reviewable memories '
126
218
  + '相关历史上下文可用 memory_search 检索;被隔离的记录不进检索。'
127
219
  + 'Every memory is plaintext and inspectable with memory_list; memory_forget removes one. '
128
220
  + '所有记忆均为明文,可用 memory_list 查看;memory_forget 删除一条。'
129
221
  + 'Approved + injected memories of the CURRENT session workspace are every-turn injected too. '
130
- + '已审核 + 常驻注入开关打开的工作区记忆也会每轮注入(按当前会话工作区路由)。';
222
+ + '已审核 + 常驻注入开关打开的工作区记忆也会每轮注入(按当前会话工作区路由)。'
223
+ + 'When the library grows, call memory_sweep for a read-only health check (stale records with reasons, '
224
+ + 'long-unused ones, candidates that keep being retrieved without being pinned, and budget usage). '
225
+ + '库变大之后用 memory_sweep 做只读体检——失效记录(带原因)、久未命中、被反复检索却未常驻的候选、以及预算占用。';
131
226
  // 0.5.1:`memory:recall` provider 可经 AssembleContext.agent 拿到当前会话
132
227
  // 的 header.cwd——注入 = global + 当前会话工作区的 approved+injected
133
228
  // (0.3.4 起工作区记忆就按会话 cwd 路由;provider 是同步回调,只读已
@@ -147,6 +242,14 @@ function recallText(memory, projectCwd, budget, summaryChars) {
147
242
  const notice = omittedNotice(rendered);
148
243
  if (notice !== '')
149
244
  parts.push(notice);
245
+ // 0.12.0:新失效(稀有,出现即值得看一眼)与候选(该不该钉的待办)。
246
+ // 两者都不占预算;候选必须走到眼前,因为判断者就是读这段上下文的模型自己。
247
+ const stale = staleNotice(memory.lastSweepReport?.stale ?? []);
248
+ if (stale !== '')
249
+ parts.push(stale);
250
+ const candidates = candidateNotice(memory.candidates(projectCwd));
251
+ if (candidates !== '')
252
+ parts.push(candidates);
150
253
  // ① 索引行:给出「库里还有什么、按什么去搜」(不占预算、长度常数级)
151
254
  const index = indexNotice(memory.indexCandidates(projectCwd));
152
255
  if (index !== '')
@@ -261,7 +364,7 @@ export async function apply(ctx, config) {
261
364
  additionalProperties: false,
262
365
  description: 'Optional validity anchor: bind this memory to a probeable environment value so it auto-expires when that value changes. Use only for facts that depend on the environment (tool behaviour under a specific version, config decided by an env var). 可选锚点:把记忆绑到可探测的环境值,值变了自动失效。只用于「随环境变化的事实」。',
263
366
  properties: {
264
- kind: { type: 'string', required: true, enum: ['env', 'tool-list', 'self-version'], description: 'env = a named environment variable; tool-list = the available tool set; self-version = this plugin version.' },
367
+ kind: { type: 'string', required: true, enum: ['env', 'tool-list', 'self-version', 'path-exists'], description: 'env = a named environment variable; tool-list = the available tool set; self-version = this plugin version; path-exists = a path that should still exist (`name` = the path, `value` = `present` or `absent`).' },
265
368
  name: { type: 'string', description: 'For kind=env: the variable name.' },
266
369
  value: { type: 'string', required: true, description: 'The value probed at write time.' },
267
370
  },
@@ -287,21 +390,28 @@ export async function apply(ctx, config) {
287
390
  }));
288
391
  ctx.tools.register(defineTool({
289
392
  name: 'memory_list',
290
- description: 'List every stored memory, optionally filtered by namespace, status, or injected switch. Every memory is plaintext and inspectable. 列出全部记忆,可按 namespace/status/injected 过滤;均为明文可查。',
393
+ description: 'List every stored memory, optionally filtered by namespace, status, injected switch, or update time. Every memory is plaintext and inspectable. 列出全部记忆,可按 namespace/status/injected/更新时间过滤;均为明文可查。',
291
394
  parameters: {
292
395
  namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
293
396
  status: { type: 'string', enum: ['suggested', 'approved'], description: 'Restrict to one review status. 限定审核状态(suggested=待审核 / approved=已审核).' },
294
397
  injected: { type: 'boolean', description: 'Restrict by the persistent-injection switch. 按常驻注入开关过滤.' },
398
+ after: { type: 'string', description: 'Only memories updated at/after this time: `YYYY-MM-DD`, or a relative form like `7d` / `30d`. 只返回此后更新过的记忆(`YYYY-MM-DD`,或相对形式 `7d` / `30d`).' },
399
+ before: { type: 'string', description: 'Only memories updated at/before this time (same forms as `after`). 只返回此前更新过的记忆(写法同 after).' },
295
400
  },
296
401
  output: {
297
402
  schema: { type: 'array', items: RECORD_SCHEMA },
298
403
  render: (_args, value) => renderJson(value),
299
404
  },
300
405
  execute(args, exec) {
406
+ const now = Date.now();
407
+ const after = parseTimeFilter(args.after, now);
408
+ const before = parseTimeFilter(args.before, now);
301
409
  return memory.list({
302
410
  ...args.namespace === undefined ? {} : { namespace: args.namespace },
303
411
  ...args.status === undefined ? {} : { status: args.status },
304
412
  ...args.injected === undefined ? {} : { injected: args.injected },
413
+ ...after === undefined ? {} : { after },
414
+ ...before === undefined ? {} : { before },
305
415
  }, execProjectCwd(exec)).then(records => records.map(recordValue));
306
416
  },
307
417
  presentCall: () => present('List memories', 'read'),
@@ -505,18 +615,28 @@ export async function apply(ctx, config) {
505
615
  id: { type: 'string', required: true, description: 'Exact memory id from memory_list. 记忆 id(来自 memory_list).' },
506
616
  content: { type: 'string', description: 'New content; omit to keep current. 新内容;省略则保留现有内容.' },
507
617
  keywords: { type: 'array', items: { type: 'string' }, description: 'New keywords; omit to keep current. 新关键词;省略则保留现有.' },
508
- injected: { type: 'boolean', description: 'Set or clear the persistent-injection switch; omit to keep the current value. Setting it records a deliberate decision — the memory stops being auto-promoted or auto-demoted. See memory_save for what deserves residency. 设置或清除常驻注入开关;省略则保持原值。给值即视为有意决定——此后不受自动升降影响。何时该常驻见 memory_save。' },
618
+ supersedes: { type: 'string', description: 'Mark the memory with this id as superseded by the current one (the current record is unchanged; only the other gets the void mark). 把该 id 指向的记忆标成「被本条取代」——本条不变,只写对方的作废标记。与 content / retract 互斥。' },
619
+ retract: { type: 'string', description: 'Retract this memory: give a reason and it is marked void (content kept, no longer injected, still searchable). 撤回本条:给一个原因即标记作废——内容保留、不再进注入,检索仍可检回。与 content / supersedes 互斥。' },
620
+ injected: { type: 'boolean', description: 'Set or clear the persistent-injection switch; omit to keep the current value. Setting it records a deliberate decision — the memory stops being auto-demoted (repeated hits no longer promote anything). See memory_save for what deserves residency. 设置或清除常驻注入开关;省略则保持原值。给值即视为有意决定——此后不受自动降级影响(反复命中已不再自动开启任何东西)。何时该常驻见 memory_save。' },
509
621
  },
510
622
  output: {
511
623
  schema: RECORD_SCHEMA,
512
624
  render: (_args, value) => renderJson(value),
513
625
  },
514
626
  execute(args, exec) {
515
- return memory.update(args.id, {
627
+ const cwd = execProjectCwd(exec);
628
+ const id = args.id;
629
+ // 三种动作互斥,按 retract > supersedes > update 的次序取第一个(与工具描述一致):
630
+ // 前两个是「标状态」,第三个才是「改内容」。同时给多个时以更重的动作为准。
631
+ if (args.retract !== undefined)
632
+ return memory.retract(id, args.retract, cwd).then(recordValue);
633
+ if (args.supersedes !== undefined)
634
+ return memory.supersede(id, args.supersedes, cwd).then(recordValue);
635
+ return memory.update(id, {
516
636
  ...args.content === undefined ? {} : { content: args.content },
517
637
  ...args.keywords === undefined ? {} : { keywords: args.keywords },
518
638
  ...args.injected === undefined ? {} : { injected: args.injected },
519
- }, execProjectCwd(exec)).then(recordValue);
639
+ }, cwd).then(recordValue);
520
640
  },
521
641
  presentCall: args => present('Update memory', 'other', args.id),
522
642
  }));
@@ -535,4 +655,17 @@ export async function apply(ctx, config) {
535
655
  },
536
656
  presentCall: args => present('Confirm memory', 'other', args.id),
537
657
  }));
658
+ ctx.tools.register(defineTool({
659
+ name: 'memory_sweep',
660
+ description: 'Read-only health check: lists stale memories (with the reason), long-unused non-resident ones, candidates that keep being retrieved without being pinned, and how much of the injection budget the resident set takes. Changes nothing. 只读体检:列出失效的记忆(带原因)、久未被检索命中的非常驻记录、被反复检索却未常驻的候选,以及常驻占用了多少注入预算。不修改任何状态——最坏情况是报告不准,而不是误改内容。',
661
+ parameters: {},
662
+ output: {
663
+ schema: SWEEP_SCHEMA,
664
+ render: (_args, value) => renderJson(value),
665
+ },
666
+ execute(_args, exec) {
667
+ return memory.sweep(execProjectCwd(exec));
668
+ },
669
+ presentCall: () => present('Sweep memory', 'other', 'read-only'),
670
+ }));
538
671
  }
@@ -32,6 +32,14 @@ export declare function deriveSummary(content: string, maxChars: number): string
32
32
  */
33
33
  export declare function neutralizeBraces(text: string): string;
34
34
  /** 一条注入行的渲染文本(与面板预览完全一致)。 */
35
+ /**
36
+ * 日期标记(0.12.0):`YYYY-MM-DD`,按**本地时区**取,输入是 epoch ms。
37
+ *
38
+ * 用绝对日期而不是「18 天前」:相对时间每轮都在变,会把一个稳定的注入副本变成每轮漂移的
39
+ * 文本——`memory:recall` 本来就是动态 context,但不必要的漂移仍应避免。**减少变化的维度
40
+ * 是廉价的稳定性。**
41
+ */
42
+ export declare function formatDay(at: number): string;
35
43
  export declare function injectionLine(record: MemoryRecord, summaryChars: number): string;
36
44
  export interface InjectionRender {
37
45
  /** 实际注入行(已按预算截断、按更新时间降序)。 */
@@ -54,9 +62,12 @@ export interface InjectionRender {
54
62
  chars: number;
55
63
  }
56
64
  /**
57
- * 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
58
- * updatedAt——最近使用/更新的记忆最先),逐条累加开销直到预算用尽;
59
- * 预算为 null 或非正数时全量注入(不过滤)。
65
+ * 由候选记录渲染注入行:按**更新时间**降序(最近更新的最先),逐条累加开销直到预算
66
+ * 用尽;预算为 null 或非正数时全量注入(不过滤)。
67
+ *
68
+ * 0.12.0 起不再看 `lastUsedAt`:那是**检索**留下的痕迹,而检索是「需要时才用」的行为
69
+ * ——拿它决定「谁每轮在场」,等于让「查得多的」挤掉「钉得牢的」,而且一次 `memory_search`
70
+ * 会当场改变下一轮的注入内容(`markUsed` 就在检索路径上)。排序只该由内容本身的变更驱动。
60
71
  */
61
72
  export declare function renderInjection(records: readonly MemoryRecord[], budget: number | null, summaryChars: number): InjectionRender;
62
73
  /**
@@ -67,6 +78,31 @@ export declare function renderInjection(records: readonly MemoryRecord[], budget
67
78
  * {@link OMITTED_NOTICE_LIMIT} 只列前 N 个。
68
79
  */
69
80
  export declare function omittedNotice(rendered: InjectionRender): string;
81
+ /** 注入侧报「新失效」的条数上限——在稀有与有用之间取舍,只报几条就够指向问题。 */
82
+ export declare const STALE_NOTICE_LIMIT = 3;
83
+ /** 候选提示列出的条数上限。 */
84
+ export declare const CANDIDATE_NOTICE_LIMIT = 3;
85
+ /** 候选提示里每条摘要的长度:比注入行更短,这一行本身不该占太多。 */
86
+ export declare const CANDIDATE_SUMMARY_CHARS = 24;
87
+ /**
88
+ * 新失效提示行(0.12.0):报「本次启动校验发现几条记忆失效」。
89
+ *
90
+ * **它稀有,所以不构成噪音**:一条记忆失效意味着「照它行动会出错」,值得每轮看见,直到被
91
+ * 核对回写或删除。与 {@link omittedNotice} 一样**不占注入预算**(调用方追加在注入行之后)。
92
+ */
93
+ export declare function staleNotice(stale: ReadonlyArray<{
94
+ summary: string;
95
+ }>): string;
96
+ /**
97
+ * 候选提示行(0.12.0):报「有几条被反复检索命中、但没有被钉成常驻」。
98
+ *
99
+ * **判断者是模型自己**——一份不会主动去调的体检报告等于不存在,所以候选必须自己走到它眼前。
100
+ * `hitCount` 只增不减,候选会一直留着直到被消费(钉住或删掉):这不是缺陷,是提示该有的
101
+ * 持续性。**不占注入预算**。
102
+ */
103
+ export declare function candidateNotice(candidates: ReadonlyArray<{
104
+ content: string;
105
+ }>): string;
70
106
  /** 子项目索引行列出的条数上限(④-B):超出以 `…` 结尾,防这行本身失控。 */
71
107
  export declare const NEIGHBOR_NOTICE_LIMIT = 5;
72
108
  /**
package/dist/injection.js CHANGED
@@ -47,18 +47,34 @@ export function neutralizeBraces(text) {
47
47
  return result;
48
48
  }
49
49
  /** 一条注入行的渲染文本(与面板预览完全一致)。 */
50
+ /**
51
+ * 日期标记(0.12.0):`YYYY-MM-DD`,按**本地时区**取,输入是 epoch ms。
52
+ *
53
+ * 用绝对日期而不是「18 天前」:相对时间每轮都在变,会把一个稳定的注入副本变成每轮漂移的
54
+ * 文本——`memory:recall` 本来就是动态 context,但不必要的漂移仍应避免。**减少变化的维度
55
+ * 是廉价的稳定性。**
56
+ */
57
+ export function formatDay(at) {
58
+ const date = new Date(at);
59
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
60
+ }
50
61
  export function injectionLine(record, summaryChars) {
51
62
  // ④-A:跨工作区召回时带来源标记(`..` / `../..`);当前工作区不带,保持既有格式
52
63
  const scope = record.scope === undefined ? '' : `@${record.scope}`;
53
- return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}${scope}] ${deriveSummary(record.content, summaryChars)}`;
64
+ // 0.12.0:日期固定在 scope 之前——日期是数字、scope 是点号,顺序固定即无歧义。
65
+ // 「这条是什么时候记的」直接决定要不要先核对再照它做,尤其对随环境失效的那类记忆。
66
+ return `- [memory:${String(record.id).slice(0, 8)}:${record.namespace}@${formatDay(record.updatedAt)}${scope}] ${deriveSummary(record.content, summaryChars)}`;
54
67
  }
55
68
  /**
56
- * 由候选记录渲染注入行:按使用序降序(lastUsedAt 优先、缺省回退
57
- * updatedAt——最近使用/更新的记忆最先),逐条累加开销直到预算用尽;
58
- * 预算为 null 或非正数时全量注入(不过滤)。
69
+ * 由候选记录渲染注入行:按**更新时间**降序(最近更新的最先),逐条累加开销直到预算
70
+ * 用尽;预算为 null 或非正数时全量注入(不过滤)。
71
+ *
72
+ * 0.12.0 起不再看 `lastUsedAt`:那是**检索**留下的痕迹,而检索是「需要时才用」的行为
73
+ * ——拿它决定「谁每轮在场」,等于让「查得多的」挤掉「钉得牢的」,而且一次 `memory_search`
74
+ * 会当场改变下一轮的注入内容(`markUsed` 就在检索路径上)。排序只该由内容本身的变更驱动。
59
75
  */
60
76
  export function renderInjection(records, budget, summaryChars) {
61
- const ordered = [...records].sort((left, right) => (right.lastUsedAt ?? right.updatedAt) - (left.lastUsedAt ?? left.updatedAt));
77
+ const ordered = [...records].sort((left, right) => right.updatedAt - left.updatedAt);
62
78
  const lines = [];
63
79
  const omittedRecords = [];
64
80
  let chars = 0;
@@ -95,6 +111,40 @@ export function omittedNotice(rendered) {
95
111
  const truncated = rendered.omittedRecords.length > shown.length ? '…' : '';
96
112
  return `(另有 ${rendered.omitted} 条常驻因预算未注入:${labels.join('、')}${truncated})`;
97
113
  }
114
+ /** 注入侧报「新失效」的条数上限——在稀有与有用之间取舍,只报几条就够指向问题。 */
115
+ export const STALE_NOTICE_LIMIT = 3;
116
+ /** 候选提示列出的条数上限。 */
117
+ export const CANDIDATE_NOTICE_LIMIT = 3;
118
+ /** 候选提示里每条摘要的长度:比注入行更短,这一行本身不该占太多。 */
119
+ export const CANDIDATE_SUMMARY_CHARS = 24;
120
+ /**
121
+ * 新失效提示行(0.12.0):报「本次启动校验发现几条记忆失效」。
122
+ *
123
+ * **它稀有,所以不构成噪音**:一条记忆失效意味着「照它行动会出错」,值得每轮看见,直到被
124
+ * 核对回写或删除。与 {@link omittedNotice} 一样**不占注入预算**(调用方追加在注入行之后)。
125
+ */
126
+ export function staleNotice(stale) {
127
+ if (stale.length === 0)
128
+ return '';
129
+ const shown = stale.slice(0, STALE_NOTICE_LIMIT);
130
+ const truncated = stale.length > shown.length ? '…' : '';
131
+ return `(本次校验发现 ${stale.length} 条记忆失效:${shown.map(item => item.summary).join('、')}${truncated})`;
132
+ }
133
+ /**
134
+ * 候选提示行(0.12.0):报「有几条被反复检索命中、但没有被钉成常驻」。
135
+ *
136
+ * **判断者是模型自己**——一份不会主动去调的体检报告等于不存在,所以候选必须自己走到它眼前。
137
+ * `hitCount` 只增不减,候选会一直留着直到被消费(钉住或删掉):这不是缺陷,是提示该有的
138
+ * 持续性。**不占注入预算**。
139
+ */
140
+ export function candidateNotice(candidates) {
141
+ if (candidates.length === 0)
142
+ return '';
143
+ const shown = candidates.slice(0, CANDIDATE_NOTICE_LIMIT);
144
+ const labels = shown.map(record => deriveSummary(record.content, CANDIDATE_SUMMARY_CHARS));
145
+ const truncated = candidates.length > shown.length ? '…' : '';
146
+ return `(另有 ${candidates.length} 条被反复检索但未常驻:${labels.join('、')}${truncated})`;
147
+ }
98
148
  /** 子项目索引行列出的条数上限(④-B):超出以 `…` 结尾,防这行本身失控。 */
99
149
  export const NEIGHBOR_NOTICE_LIMIT = 5;
100
150
  /**
package/dist/self.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * 0.9.2:补齐常驻注入机制——`injected` 参数、注入预算上限、预算诊断行的含义。
6
6
  * 0.10.0:修正工具清单(6 → 10,补提示词模板工具)与两层记忆说明——原清单漏了
7
7
  * `prompt_*` 系列,照它读会以为本插件只有记忆工具。
8
+ * 0.12.0:工具清单 10 → 11(补 `memory_sweep`);删掉「命中累计 2 次自动开启常驻」
9
+ * (该规则已取消,命中只喂候选提示);注入取舍依据由「最近使用」改为「最近更新」。
8
10
  */
9
11
  import { createRequire } from 'node:module';
10
12
  export const SELF_VERSION = (() => {
@@ -19,7 +21,7 @@ export const SELF_VERSION = (() => {
19
21
  }
20
22
  })();
21
23
  export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
22
- + '你有 10 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget(`memory_confirm` 仅在用户明确要求放行隔离记录时用);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索)。'
24
+ + '你有 11 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget / memory_sweep(`memory_confirm` 仅在用户明确要求放行隔离记录时用;`memory_sweep` 是只读体检——失效清单、久未命中、候选与预算占用,不修改任何状态);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索)。'
23
25
  + '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
24
26
  + '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
25
27
  + '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
@@ -30,9 +32,9 @@ export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话
30
32
  + '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale,不会再被当成仍然正确。'
31
33
  + '写入即生效(`approved`),人工只在例外时介入;命中密钥/凭据规则的写入会被隔离(不进注入与检索)。'
32
34
  + '**常驻注入**:每轮注入「global + 当前会话工作区」里 approved 且开着常驻开关的记忆单行摘要(形如 `- [memory:id:namespace] 摘要`);全文要靠 memory_search 取。'
33
- + '开关有两条自动规则:命中累计 2 次自动开启、30 天未命中自动撤下;**给 memory_save / memory_update 传 `injected` 即显式接管**,此后这两条都不再适用。'
35
+ + '开关只有一条自动规则:**自动升上来的**常驻在 30 天未命中后撤下;命中次数不再自动改变注入状态(它只喂体检报告的候选提示)。**给 memory_save / memory_update 传 `injected` 即显式接管**,此后这条规则也不再适用。'
34
36
  + '**钉的判据是「失效可察觉性」**:这条记忆若没被想起来,你会不会根本意识不到自己漏了它?会 → 钉常驻(规则 / 委托 / 判据);不会 → 留检索(事实 / 参考 / 案例)。'
35
- + '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按最近使用优先取舍——所以「钉了常驻」只等于「有资格排队」。'
37
+ + '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按**最近更新**优先取舍(不是最近被检索——检索是「需要时才用」的行为,拿它决定「谁每轮在场」会让查得多的挤掉钉得牢的)——所以「钉了常驻」只等于「有资格排队」。'
36
38
  + '装不下时注入末尾会出现一行 `(另有 N 条常驻因预算未注入:…短摘要…)`——**那是「有常驻记忆并不在场」的唯一信号**,看到它就该逐条评估撤下。'
37
39
  + '发现某条记忆与实际不符时用 memory_update 修正(保持生效);确认没用的用 memory_forget。'
38
40
  + '(English: dsh-memory provides cross-session memory. Writes take effect immediately; save durable preferences, corrections and reusable conclusions — not transient state, guesses, or facts readable from the repo (record where to look instead). Always add multi-angle keywords. Pass `injected: true` to keep one resident every turn — reserve that for rules and standing agreements; facts stay search-only. Resident injection runs on a budget, so a pinned memory is only queued, never guaranteed to be in context.)';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-memory",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, writes take effect immediately, signal-driven injection, no embeddings",
5
5
  "license": "MIT",
6
6
  "publishConfig": {