@max-null/dsh-memory 0.11.1 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/anchors.d.ts +13 -5
- package/dist/anchors.js +16 -3
- package/dist/engine.d.ts +127 -3
- package/dist/engine.js +238 -31
- package/dist/index.js +174 -11
- package/dist/injection.d.ts +39 -3
- package/dist/injection.js +55 -5
- package/dist/self.js +8 -4
- package/package.json +1 -1
package/dist/anchors.d.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* 有效性锚点(2026-09-15 静默记忆机制):把一条记忆绑定到某个**可探测的环境值**上,
|
|
3
3
|
* 值变了就自动失效——解决「版本特性类记忆随环境升级静默变错」的问题。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
@@ -124,6 +132,14 @@ export interface MemoryPatch {
|
|
|
124
132
|
* `injectedAuto: false`,此后不受自动升降影响);省略则保持原值。
|
|
125
133
|
*/
|
|
126
134
|
injected?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* 有效性锚点(0.12.1):省略保持原值,给值改写,给 `null` 清除。
|
|
137
|
+
*
|
|
138
|
+
* 给值时**当场校验一次**并同步 `stale`(成立即复活、不成立即标失效、探测不到则不动)——
|
|
139
|
+
* 改写锚点的意图就是「让它重新被判定」,等到下次会话维护再判定会留下一个说不清的空窗
|
|
140
|
+
* (改了锚点却仍显示失效)。清除锚点时连「因该锚点而标的失效」一起清。
|
|
141
|
+
*/
|
|
142
|
+
anchor?: MemoryAnchor | null;
|
|
127
143
|
}
|
|
128
144
|
export interface MemoryHit {
|
|
129
145
|
record: MemoryRecord;
|
|
@@ -184,6 +200,36 @@ export type MemoryChange = {
|
|
|
184
200
|
from: string;
|
|
185
201
|
to: string;
|
|
186
202
|
};
|
|
203
|
+
/** 体检报告的单条记录:摘要长度与注入行同量级,便于直接念给人听或写进诊断行。 */
|
|
204
|
+
export interface SweepItem {
|
|
205
|
+
id: string;
|
|
206
|
+
summary: string;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* 只读体检的产出({@link MemoryEngine.sweep})。四类各有明确的消费者,不做「算了没人读」的采样
|
|
210
|
+
* ——候选与新失效会进注入诊断行(模型每轮可见),久未命中与全量留给按需调用。
|
|
211
|
+
*/
|
|
212
|
+
export interface SweepReport {
|
|
213
|
+
/** 当前失效的记录,带原因(锚点不符 / 被取代 / 被撤回)。 */
|
|
214
|
+
stale: Array<SweepItem & {
|
|
215
|
+
reason: string | undefined;
|
|
216
|
+
}>;
|
|
217
|
+
/** 久未被检索命中的**非常驻**记录——常驻的「没被查过」是正常的,它本来就每轮在场。 */
|
|
218
|
+
longUnused: Array<SweepItem & {
|
|
219
|
+
lastUsed: number;
|
|
220
|
+
}>;
|
|
221
|
+
/** 候选:被反复检索命中但没有被钉成常驻(见 `candidates`)。 */
|
|
222
|
+
candidates: Array<SweepItem & {
|
|
223
|
+
hitCount: number;
|
|
224
|
+
}>;
|
|
225
|
+
/** 常驻与注入预算的占用:总数 / 实际进注入 / 预算(无限制时不出现)/ 被挤掉几条。 */
|
|
226
|
+
resident: {
|
|
227
|
+
total: number;
|
|
228
|
+
injected: number;
|
|
229
|
+
budget: number | undefined;
|
|
230
|
+
omitted: number;
|
|
231
|
+
};
|
|
232
|
+
}
|
|
187
233
|
declare module '@deepseek-ai/cordis' {
|
|
188
234
|
interface Context {
|
|
189
235
|
memory: MemoryEngine;
|
|
@@ -260,6 +306,16 @@ export declare class MemoryEngine extends Service {
|
|
|
260
306
|
private writeChain;
|
|
261
307
|
/** 会话启动维护只跑一次的标记(2026-09-15)。 */
|
|
262
308
|
private maintenanceDone;
|
|
309
|
+
/** 最近一次体检的结果(进程内,不落盘):供注入侧报「本次校验发现的新失效」。 */
|
|
310
|
+
private lastSweep;
|
|
311
|
+
/**
|
|
312
|
+
* 最近一次体检结果(只读)。未跑过时为 `undefined`——注入侧据此决定要不要报那一行,
|
|
313
|
+
* 它只在真有新失效时出现,稀有所以不构成噪音。
|
|
314
|
+
*
|
|
315
|
+
* **每次 `sweep()`(含工具调用)都刷新它**(0.12.1)。此前只在会话启动维护里赋值,于是
|
|
316
|
+
* 那句提示在整个会话内不再变化,可能一直挂着早已被删掉的记录。
|
|
317
|
+
*/
|
|
318
|
+
get lastSweepReport(): SweepReport | undefined;
|
|
263
319
|
constructor(ctx: import('@deepseek-ai/cordis').Context, config?: MemoryConfig);
|
|
264
320
|
protected [Service.init](): Promise<void>;
|
|
265
321
|
/** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
|
|
@@ -363,6 +419,30 @@ export declare class MemoryEngine extends Service {
|
|
|
363
419
|
* 未打开的层级本轮不计入,影响可忽略(链上祖先在检索路径打开后即缓存)。
|
|
364
420
|
*/
|
|
365
421
|
indexCandidates(projectCwd?: string): MemoryRecord[];
|
|
422
|
+
/**
|
|
423
|
+
* 只读体检(0.12.0):把「该退场的」和「钉了却没进来的」摆成一张清单,**不修改任何状态**
|
|
424
|
+
* ——连时间戳都不写。
|
|
425
|
+
*
|
|
426
|
+
* 为什么坚持只读:一只读的工具,最坏情况是报告不准,判断者按错的清单做了坏决定也能追溯;
|
|
427
|
+
* 一个会改状态的工具,最坏情况是错了都看不见。这决定了它不需要「每日一次」的守卫,也不
|
|
428
|
+
* 需要定期读者——有人问它就活着,没人问它不产生任何成本(不占预算、不落盘、不写状态)。
|
|
429
|
+
*/
|
|
430
|
+
sweep(projectCwd?: string, options?: {
|
|
431
|
+
days?: number;
|
|
432
|
+
now?: number;
|
|
433
|
+
}): Promise<SweepReport>;
|
|
434
|
+
/**
|
|
435
|
+
* 候选(0.12.0):被反复检索命中、但没有被钉成常驻的记录。**只提示,不改状态。**
|
|
436
|
+
*
|
|
437
|
+
* 筛选条件与原自动升级判据逐条对应(approved、未隔离、未被人工否决、非模板、未常驻)
|
|
438
|
+
* ——判据没变,变的只是它的作用:从「自动钉住」改为「列出来等一个判断」。`hitCount`
|
|
439
|
+
* 只增不减,所以候选会一直留着,直到被钉住、被删或记录本身消失——这不是缺陷,是提示
|
|
440
|
+
* 该有的持续性:它只在被消费之后才消失。
|
|
441
|
+
*
|
|
442
|
+
* 与 {@link indexCandidates} 的分工:那个取**全部**记录(供索引行统计规模与主题),
|
|
443
|
+
* 这个只取满足候选条件的(供体检报告与诊断行)。
|
|
444
|
+
*/
|
|
445
|
+
candidates(projectCwd?: string): MemoryRecord[];
|
|
366
446
|
/**
|
|
367
447
|
* 按 id 定位记录所在表:global 优先,其次工作区链(自身 → 祖先,由近及远)。
|
|
368
448
|
* **异步版**——写路径必须用它:`writeScope` 可能在本次进入前刚 `reload()`,
|
|
@@ -425,6 +505,9 @@ export declare class MemoryEngine extends Service {
|
|
|
425
505
|
/**
|
|
426
506
|
* 锚点探测上下文。`toolNames` 拿不到时返回 `undefined`——**不能给空数组**:那会被判成
|
|
427
507
|
* 「工具全没了」,让所有 `tool-list` 锚点误失效。
|
|
508
|
+
*
|
|
509
|
+
* `workspaceCwd`(0.12.0)供 `path-exists` 把相对路径解析成绝对路径;缺省时相对路径
|
|
510
|
+
* 判为「未校验」而不是「不存在」。
|
|
428
511
|
*/
|
|
429
512
|
private anchorProbes;
|
|
430
513
|
/** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
|
|
@@ -434,9 +517,16 @@ export declare class MemoryEngine extends Service {
|
|
|
434
517
|
/** {@link markUsed} 的实现体;必须经 `writeScope` 进入(`recordUse` 在此之内)。 */
|
|
435
518
|
private markUsedInner;
|
|
436
519
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
520
|
+
* 命中记账(2026-09-15 静默记忆机制;0.12.0 取消自动升级)。
|
|
521
|
+
*
|
|
522
|
+
* 只记 `hitCount` 与 `lastUsedAt`,**不再据此改变注入状态**。原判据是「被反复检索
|
|
523
|
+
* 命中」= 值得每轮付费的唯一客观证据,但这条证据测错了东西:命中次数度量的是
|
|
524
|
+
* **它被想起来了**,而常驻的语义是**它不能被忘**——能被检索命中的恰恰是检索已经
|
|
525
|
+
* 够用的那些,真正该常驻的(想不起来去查的)命中次数永远是 0。达到
|
|
526
|
+
* {@link CANDIDATE_HITS} 的记录改由 {@link candidates} 列出,进体检报告与诊断行
|
|
527
|
+
* 提示,改不改由判断者决定(判据详见设计文档 §二事实四)。
|
|
528
|
+
*
|
|
529
|
+
* 统计性字段不 emit:本函数现在不改变任何被订阅的状态。
|
|
440
530
|
*/
|
|
441
531
|
private recordUse;
|
|
442
532
|
/** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
|
|
@@ -495,6 +585,13 @@ export declare class MemoryEngine extends Service {
|
|
|
495
585
|
* @returns 本次状态发生变化的条数。
|
|
496
586
|
*/
|
|
497
587
|
verifyAnchors(probes: AnchorProbes, projectCwd?: string): Promise<number>;
|
|
588
|
+
/**
|
|
589
|
+
* 锚点失效的原因文案:`verifyAnchorsInner` 与 `update` 的锚点改写共用一处。
|
|
590
|
+
*
|
|
591
|
+
* 共用不是为省几行——「改写或清除锚点时顺带清失效」要认得出「这条失效是本锚点标的」,
|
|
592
|
+
* 两处生成的文案必须逐字相同;认错就会把撤回、被取代标的失效一起抹掉。
|
|
593
|
+
*/
|
|
594
|
+
private static anchorStaleReason;
|
|
498
595
|
/** {@link verifyAnchors} 的实现体;必须经 `writeScope` 进入。 */
|
|
499
596
|
private verifyAnchorsInner;
|
|
500
597
|
/**
|
|
@@ -563,6 +660,33 @@ export declare class MemoryEngine extends Service {
|
|
|
563
660
|
setInjected(id: MemoryId, injected: boolean, projectCwd?: string): Promise<MemoryRecord>;
|
|
564
661
|
/** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
|
|
565
662
|
private setInjectedInner;
|
|
663
|
+
/**
|
|
664
|
+
* 语义作废(0.12.0):把 `targetId` 标成**被 `id` 取代**。
|
|
665
|
+
*
|
|
666
|
+
* 与「改内容」的区别:`update` 会让旧内容直接消失,「取代」把痕迹留下来——检索时看到
|
|
667
|
+
* 「这条已作废,原因是 X」对被主动查的人是信息而不是干扰。`id` 只用来写原因,本身不被
|
|
668
|
+
* 改动;两条都必须定位得到,否则拒绝(写一个指向不存在记忆的原因没有意义)。
|
|
669
|
+
*
|
|
670
|
+
* 下游复用既有的 `stale` 行为:撤常驻、不排除检索、带标注等核对回写。
|
|
671
|
+
*/
|
|
672
|
+
supersede(id: MemoryId, targetId: MemoryId, projectCwd?: string): Promise<MemoryRecord>;
|
|
673
|
+
/** {@link supersede} 的实现体;必须经 `writeScope` 进入。返回**被标记的那条**。 */
|
|
674
|
+
private supersedeInner;
|
|
675
|
+
/**
|
|
676
|
+
* 撤回(0.12.0):把本条标成作废并写明原因。**内容保留**——不删除、也不排除检索,
|
|
677
|
+
* 与锚点失效后「带标注等核对」是同一条路径。
|
|
678
|
+
*/
|
|
679
|
+
retract(id: MemoryId, reason: string, projectCwd?: string): Promise<MemoryRecord>;
|
|
680
|
+
/** {@link retract} 的实现体;必须经 `writeScope` 进入。 */
|
|
681
|
+
private retractInner;
|
|
682
|
+
/**
|
|
683
|
+
* 标失效的共用落点(0.12.0):写 `stale` + 原因、撤掉常驻、发 `memory/changed`。
|
|
684
|
+
*
|
|
685
|
+
* 与 `verifyAnchorsInner` 的失效分支保持**同一组字段**(不动 `status`、不删内容、不排除
|
|
686
|
+
* 检索)——「被取代」「被撤回」「锚点不符」在下游是同一种状态,只在 `staleReason` 里区分
|
|
687
|
+
* 来源。失效是一个维度、多种来源,这是那条判据的落点。
|
|
688
|
+
*/
|
|
689
|
+
private markStale;
|
|
566
690
|
/**
|
|
567
691
|
* 修改一条记忆的内容/关键词/注入位(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
|
|
568
692
|
* 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态。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
|
-
/**
|
|
27
|
-
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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,18 @@ 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
|
+
* **每次 `sweep()`(含工具调用)都刷新它**(0.12.1)。此前只在会话启动维护里赋值,于是
|
|
209
|
+
* 那句提示在整个会话内不再变化,可能一直挂着早已被删掉的记录。
|
|
210
|
+
*/
|
|
211
|
+
get lastSweepReport() {
|
|
212
|
+
return this.lastSweep;
|
|
213
|
+
}
|
|
186
214
|
constructor(ctx, config = {}) {
|
|
187
215
|
super(ctx, 'memory');
|
|
188
216
|
this.config = config;
|
|
@@ -491,6 +519,83 @@ export class MemoryEngine extends Service {
|
|
|
491
519
|
}
|
|
492
520
|
return candidates;
|
|
493
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* 只读体检(0.12.0):把「该退场的」和「钉了却没进来的」摆成一张清单,**不修改任何状态**
|
|
524
|
+
* ——连时间戳都不写。
|
|
525
|
+
*
|
|
526
|
+
* 为什么坚持只读:一只读的工具,最坏情况是报告不准,判断者按错的清单做了坏决定也能追溯;
|
|
527
|
+
* 一个会改状态的工具,最坏情况是错了都看不见。这决定了它不需要「每日一次」的守卫,也不
|
|
528
|
+
* 需要定期读者——有人问它就活着,没人问它不产生任何成本(不占预算、不落盘、不写状态)。
|
|
529
|
+
*/
|
|
530
|
+
async sweep(projectCwd, options) {
|
|
531
|
+
const now = options?.now ?? Date.now();
|
|
532
|
+
const days = options?.days ?? DEFAULT_SWEEP_DAYS;
|
|
533
|
+
const threshold = now - days * 24 * 60 * 60 * 1000;
|
|
534
|
+
const summaryChars = this.config.summaryChars ?? DEFAULT_SUMMARY_CHARS;
|
|
535
|
+
const brief = (record) => deriveSummary(record.content, summaryChars);
|
|
536
|
+
const records = await this.allRecords(undefined, projectCwd);
|
|
537
|
+
const stale = records
|
|
538
|
+
.filter(record => record.stale === true)
|
|
539
|
+
.map(record => ({ id: String(record.id), summary: brief(record), reason: record.staleReason }));
|
|
540
|
+
// 只看非常驻:常驻的「没被检索过」是正常的(它本来就每轮在场,不需要被查)
|
|
541
|
+
const longUnused = records
|
|
542
|
+
.filter(record => record.injected !== true && (record.lastUsedAt ?? record.updatedAt) < threshold)
|
|
543
|
+
.map(record => ({
|
|
544
|
+
id: String(record.id),
|
|
545
|
+
summary: brief(record),
|
|
546
|
+
lastUsed: record.lastUsedAt ?? record.updatedAt,
|
|
547
|
+
}));
|
|
548
|
+
const candidates = this.candidates(projectCwd)
|
|
549
|
+
.map(record => ({ id: String(record.id), summary: brief(record), hitCount: record.hitCount ?? 0 }));
|
|
550
|
+
const resident = this.recallRecords(projectCwd);
|
|
551
|
+
const rendered = renderInjection(resident, this.config.injectionBudget ?? DEFAULT_INJECTION_BUDGET, summaryChars);
|
|
552
|
+
const report = {
|
|
553
|
+
stale,
|
|
554
|
+
longUnused,
|
|
555
|
+
candidates,
|
|
556
|
+
resident: {
|
|
557
|
+
total: resident.length,
|
|
558
|
+
injected: rendered.lines.length,
|
|
559
|
+
// `renderInjection` 用 `null` 表示「预算无限制」,而输出 schema 用「字段缺席」表达
|
|
560
|
+
// 同一件事(可选字段推导出 `number | undefined`)——在这里归一,避免两套空值语义。
|
|
561
|
+
budget: rendered.budget ?? undefined,
|
|
562
|
+
omitted: rendered.omitted,
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
// 每次体检都写回最近一次结果(0.12.1):注入侧那句「本次校验发现 N 条失效」读的就是它。
|
|
566
|
+
// 原先只有会话启动维护会赋值,于是调用 `memory_sweep` 刷新不了它——报告里挂着早已被删掉
|
|
567
|
+
// 的记录,整个会话内不再变化。(这条只写实例字段,不影响「只读」的承诺:不落盘、不改记忆。)
|
|
568
|
+
this.lastSweep = report;
|
|
569
|
+
return report;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* 候选(0.12.0):被反复检索命中、但没有被钉成常驻的记录。**只提示,不改状态。**
|
|
573
|
+
*
|
|
574
|
+
* 筛选条件与原自动升级判据逐条对应(approved、未隔离、未被人工否决、非模板、未常驻)
|
|
575
|
+
* ——判据没变,变的只是它的作用:从「自动钉住」改为「列出来等一个判断」。`hitCount`
|
|
576
|
+
* 只增不减,所以候选会一直留着,直到被钉住、被删或记录本身消失——这不是缺陷,是提示
|
|
577
|
+
* 该有的持续性:它只在被消费之后才消失。
|
|
578
|
+
*
|
|
579
|
+
* 与 {@link indexCandidates} 的分工:那个取**全部**记录(供索引行统计规模与主题),
|
|
580
|
+
* 这个只取满足候选条件的(供体检报告与诊断行)。
|
|
581
|
+
*/
|
|
582
|
+
candidates(projectCwd) {
|
|
583
|
+
const picked = (table) => this.recordsOf(table).filter(record => record.status === 'approved'
|
|
584
|
+
&& record.quarantined !== true
|
|
585
|
+
&& record.injected !== true
|
|
586
|
+
&& record.injectedAuto !== false
|
|
587
|
+
&& (record.kind ?? 'fact') !== 'prompt'
|
|
588
|
+
&& (record.hitCount ?? 0) >= CANDIDATE_HITS);
|
|
589
|
+
const found = picked(this.requireTable('global'));
|
|
590
|
+
if (projectCwd === undefined || projectCwd === '')
|
|
591
|
+
return found;
|
|
592
|
+
for (const { key } of this.projectChain(projectCwd)) {
|
|
593
|
+
const table = this.projectTables.get(key);
|
|
594
|
+
if (table !== undefined)
|
|
595
|
+
found.push(...picked(table));
|
|
596
|
+
}
|
|
597
|
+
return found;
|
|
598
|
+
}
|
|
494
599
|
/**
|
|
495
600
|
* 按 id 定位记录所在表:global 优先,其次工作区链(自身 → 祖先,由近及远)。
|
|
496
601
|
* **异步版**——写路径必须用它:`writeScope` 可能在本次进入前刚 `reload()`,
|
|
@@ -612,6 +717,9 @@ export class MemoryEngine extends Service {
|
|
|
612
717
|
(filter?.quarantined === undefined ? record.quarantined !== true : record.quarantined === filter.quarantined)
|
|
613
718
|
&& (filter?.status === undefined || record.status === filter.status)
|
|
614
719
|
&& (filter?.injected === undefined || record.injected === filter.injected)
|
|
720
|
+
// 0.12.0:按更新时间筛——与注入排序、注入行的日期标记同一个依据,不制造第二个「新」的定义
|
|
721
|
+
&& (filter?.after === undefined || record.updatedAt >= filter.after)
|
|
722
|
+
&& (filter?.before === undefined || record.updatedAt <= filter.before)
|
|
615
723
|
&& (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind));
|
|
616
724
|
}
|
|
617
725
|
/**
|
|
@@ -739,9 +847,14 @@ export class MemoryEngine extends Service {
|
|
|
739
847
|
const demoted = await this.demoteStale(projectCwd);
|
|
740
848
|
if (demoted > 0)
|
|
741
849
|
this.ctx.logger?.info?.(`dsh-memory: 长期未命中,撤下常驻 ${demoted} 条`);
|
|
742
|
-
const staled = await this.verifyAnchors(this.anchorProbes(), projectCwd);
|
|
850
|
+
const staled = await this.verifyAnchors(this.anchorProbes(projectCwd), projectCwd);
|
|
743
851
|
if (staled > 0)
|
|
744
852
|
this.ctx.logger?.info?.(`dsh-memory: 锚点校验更新 ${staled} 条`);
|
|
853
|
+
// 体检(0.12.0):只读,产物存进实例字段,供注入侧报「本次校验发现的新失效」。
|
|
854
|
+
// 它在 try 内——报告没有记忆本身重要,失败由下面的 catch 兜住、不阻断启动。
|
|
855
|
+
// 写回由 `sweep()` 自己做(0.12.1),这里不再赋值——于是手工调用 `memory_sweep`
|
|
856
|
+
// 也能刷新注入里那句提示。
|
|
857
|
+
await this.sweep(projectCwd);
|
|
745
858
|
}
|
|
746
859
|
catch (error) {
|
|
747
860
|
this.ctx.logger?.warn?.(`dsh-memory: 启动维护失败(不影响使用):${String(error)}`);
|
|
@@ -750,8 +863,11 @@ export class MemoryEngine extends Service {
|
|
|
750
863
|
/**
|
|
751
864
|
* 锚点探测上下文。`toolNames` 拿不到时返回 `undefined`——**不能给空数组**:那会被判成
|
|
752
865
|
* 「工具全没了」,让所有 `tool-list` 锚点误失效。
|
|
866
|
+
*
|
|
867
|
+
* `workspaceCwd`(0.12.0)供 `path-exists` 把相对路径解析成绝对路径;缺省时相对路径
|
|
868
|
+
* 判为「未校验」而不是「不存在」。
|
|
753
869
|
*/
|
|
754
|
-
anchorProbes() {
|
|
870
|
+
anchorProbes(workspaceCwd) {
|
|
755
871
|
let toolNames;
|
|
756
872
|
try {
|
|
757
873
|
const tools = this.ctx.get('tools');
|
|
@@ -765,7 +881,11 @@ export class MemoryEngine extends Service {
|
|
|
765
881
|
catch {
|
|
766
882
|
toolNames = undefined;
|
|
767
883
|
}
|
|
768
|
-
return {
|
|
884
|
+
return {
|
|
885
|
+
...(toolNames === undefined ? {} : { toolNames }),
|
|
886
|
+
selfVersion: SELF_VERSION,
|
|
887
|
+
...(workspaceCwd === undefined || workspaceCwd === '' ? {} : { workspaceCwd }),
|
|
888
|
+
};
|
|
769
889
|
}
|
|
770
890
|
/** 同步版定位:只查**已打开**的表(global + 链上已缓存的工作区表)。 */
|
|
771
891
|
tableOf(id, projectCwd) {
|
|
@@ -812,33 +932,24 @@ export class MemoryEngine extends Service {
|
|
|
812
932
|
}
|
|
813
933
|
}
|
|
814
934
|
/**
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
935
|
+
* 命中记账(2026-09-15 静默记忆机制;0.12.0 取消自动升级)。
|
|
936
|
+
*
|
|
937
|
+
* 只记 `hitCount` 与 `lastUsedAt`,**不再据此改变注入状态**。原判据是「被反复检索
|
|
938
|
+
* 命中」= 值得每轮付费的唯一客观证据,但这条证据测错了东西:命中次数度量的是
|
|
939
|
+
* **它被想起来了**,而常驻的语义是**它不能被忘**——能被检索命中的恰恰是检索已经
|
|
940
|
+
* 够用的那些,真正该常驻的(想不起来去查的)命中次数永远是 0。达到
|
|
941
|
+
* {@link CANDIDATE_HITS} 的记录改由 {@link candidates} 列出,进体检报告与诊断行
|
|
942
|
+
* 提示,改不改由判断者决定(判据详见设计文档 §二事实四)。
|
|
943
|
+
*
|
|
944
|
+
* 统计性字段不 emit:本函数现在不改变任何被订阅的状态。
|
|
818
945
|
*/
|
|
819
946
|
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
947
|
const updated = {
|
|
834
948
|
...block,
|
|
835
|
-
hitCount,
|
|
949
|
+
hitCount: (block.hitCount ?? 0) + 1,
|
|
836
950
|
lastUsedAt: now,
|
|
837
|
-
...promote ? { injected: true, injectedAuto: true } : {},
|
|
838
951
|
};
|
|
839
952
|
await table.put(id, updated);
|
|
840
|
-
if (promote)
|
|
841
|
-
this.ctx.emit('memory/changed', { operation: 'injected', id, injected: true });
|
|
842
953
|
}
|
|
843
954
|
// ── 查询日志与影响力统计(⑥ 2026-09-15)────────────────────────────────
|
|
844
955
|
/** 查询日志(惰性创建;位置见 {@link queryLogPath})。 */
|
|
@@ -982,6 +1093,16 @@ export class MemoryEngine extends Service {
|
|
|
982
1093
|
async verifyAnchors(probes, projectCwd) {
|
|
983
1094
|
return this.writeScope(projectCwd, () => this.verifyAnchorsInner(probes, projectCwd));
|
|
984
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* 锚点失效的原因文案:`verifyAnchorsInner` 与 `update` 的锚点改写共用一处。
|
|
1098
|
+
*
|
|
1099
|
+
* 共用不是为省几行——「改写或清除锚点时顺带清失效」要认得出「这条失效是本锚点标的」,
|
|
1100
|
+
* 两处生成的文案必须逐字相同;认错就会把撤回、被取代标的失效一起抹掉。
|
|
1101
|
+
*/
|
|
1102
|
+
static anchorStaleReason(anchor) {
|
|
1103
|
+
const label = `${anchor.kind}${anchor.name === undefined ? '' : `:${anchor.name}`}`;
|
|
1104
|
+
return `${label} 声明为 ${anchor.value},当前不符`;
|
|
1105
|
+
}
|
|
985
1106
|
/** {@link verifyAnchors} 的实现体;必须经 `writeScope` 进入。 */
|
|
986
1107
|
async verifyAnchorsInner(probes, projectCwd) {
|
|
987
1108
|
const tables = [this.requireTable('global')];
|
|
@@ -1000,13 +1121,12 @@ export class MemoryEngine extends Service {
|
|
|
1000
1121
|
continue; // 未校验:探测不到就不动
|
|
1001
1122
|
if ((block.stale === true) === !holds)
|
|
1002
1123
|
continue; // 状态没变
|
|
1003
|
-
const label = `${block.anchor.kind}${block.anchor.name === undefined ? '' : `:${block.anchor.name}`}`;
|
|
1004
1124
|
const updated = holds
|
|
1005
1125
|
? { ...block, stale: false, staleReason: undefined }
|
|
1006
1126
|
: {
|
|
1007
1127
|
...block,
|
|
1008
1128
|
stale: true,
|
|
1009
|
-
staleReason:
|
|
1129
|
+
staleReason: MemoryEngine.anchorStaleReason(block.anchor),
|
|
1010
1130
|
injected: false,
|
|
1011
1131
|
injectedAuto: false,
|
|
1012
1132
|
};
|
|
@@ -1271,6 +1391,64 @@ export class MemoryEngine extends Service {
|
|
|
1271
1391
|
this.ctx.emit('memory/changed', { operation: 'injected', id, injected });
|
|
1272
1392
|
return record;
|
|
1273
1393
|
}
|
|
1394
|
+
/**
|
|
1395
|
+
* 语义作废(0.12.0):把 `targetId` 标成**被 `id` 取代**。
|
|
1396
|
+
*
|
|
1397
|
+
* 与「改内容」的区别:`update` 会让旧内容直接消失,「取代」把痕迹留下来——检索时看到
|
|
1398
|
+
* 「这条已作废,原因是 X」对被主动查的人是信息而不是干扰。`id` 只用来写原因,本身不被
|
|
1399
|
+
* 改动;两条都必须定位得到,否则拒绝(写一个指向不存在记忆的原因没有意义)。
|
|
1400
|
+
*
|
|
1401
|
+
* 下游复用既有的 `stale` 行为:撤常驻、不排除检索、带标注等核对回写。
|
|
1402
|
+
*/
|
|
1403
|
+
async supersede(id, targetId, projectCwd) {
|
|
1404
|
+
return this.writeScope(projectCwd, () => this.supersedeInner(id, targetId, projectCwd));
|
|
1405
|
+
}
|
|
1406
|
+
/** {@link supersede} 的实现体;必须经 `writeScope` 进入。返回**被标记的那条**。 */
|
|
1407
|
+
async supersedeInner(id, targetId, projectCwd) {
|
|
1408
|
+
if (id === targetId)
|
|
1409
|
+
throw new Error(`cannot supersede a memory with itself ('${id}')`);
|
|
1410
|
+
if (await this.locateRecord(id, projectCwd) === undefined) {
|
|
1411
|
+
throw new Error(`cannot supersede from unknown memory '${id}'`);
|
|
1412
|
+
}
|
|
1413
|
+
const target = await this.locateRecord(targetId, projectCwd);
|
|
1414
|
+
if (target === undefined)
|
|
1415
|
+
throw new Error(`cannot supersede unknown memory '${targetId}'`);
|
|
1416
|
+
return toRecord(targetId, await this.markStale(targetId, target, `被 ${String(id).slice(0, 8)} 取代`));
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* 撤回(0.12.0):把本条标成作废并写明原因。**内容保留**——不删除、也不排除检索,
|
|
1420
|
+
* 与锚点失效后「带标注等核对」是同一条路径。
|
|
1421
|
+
*/
|
|
1422
|
+
async retract(id, reason, projectCwd) {
|
|
1423
|
+
return this.writeScope(projectCwd, () => this.retractInner(id, reason, projectCwd));
|
|
1424
|
+
}
|
|
1425
|
+
/** {@link retract} 的实现体;必须经 `writeScope` 进入。 */
|
|
1426
|
+
async retractInner(id, reason, projectCwd) {
|
|
1427
|
+
const found = await this.locateRecord(id, projectCwd);
|
|
1428
|
+
if (found === undefined)
|
|
1429
|
+
throw new Error(`cannot retract unknown memory '${id}'`);
|
|
1430
|
+
return toRecord(id, await this.markStale(id, found, `已撤回:${reason}`));
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* 标失效的共用落点(0.12.0):写 `stale` + 原因、撤掉常驻、发 `memory/changed`。
|
|
1434
|
+
*
|
|
1435
|
+
* 与 `verifyAnchorsInner` 的失效分支保持**同一组字段**(不动 `status`、不删内容、不排除
|
|
1436
|
+
* 检索)——「被取代」「被撤回」「锚点不符」在下游是同一种状态,只在 `staleReason` 里区分
|
|
1437
|
+
* 来源。失效是一个维度、多种来源,这是那条判据的落点。
|
|
1438
|
+
*/
|
|
1439
|
+
async markStale(id, found, reason) {
|
|
1440
|
+
const updated = {
|
|
1441
|
+
...found.block,
|
|
1442
|
+
stale: true,
|
|
1443
|
+
staleReason: reason,
|
|
1444
|
+
injected: false,
|
|
1445
|
+
injectedAuto: false,
|
|
1446
|
+
updatedAt: Date.now(),
|
|
1447
|
+
};
|
|
1448
|
+
await found.table.put(id, updated);
|
|
1449
|
+
this.ctx.emit('memory/changed', { operation: 'status', id, status: normalizeBlock(updated).status });
|
|
1450
|
+
return updated;
|
|
1451
|
+
}
|
|
1274
1452
|
/**
|
|
1275
1453
|
* 修改一条记忆的内容/关键词/注入位(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
|
|
1276
1454
|
* 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态。2026-09-18
|
|
@@ -1293,7 +1471,7 @@ export class MemoryEngine extends Service {
|
|
|
1293
1471
|
const status = verdict.quarantined ? 'suggested' : normalized.status;
|
|
1294
1472
|
// 常驻注入(2026-09-18):显式给值即接管;未审核或已隔离一律不注入(与自动升级同一门槛)
|
|
1295
1473
|
const injected = status === 'approved' ? patch.injected ?? normalized.injected : false;
|
|
1296
|
-
|
|
1474
|
+
const patched = {
|
|
1297
1475
|
...block,
|
|
1298
1476
|
status,
|
|
1299
1477
|
injected,
|
|
@@ -1306,6 +1484,35 @@ export class MemoryEngine extends Service {
|
|
|
1306
1484
|
...(patch.keywords === undefined ? {} : { keywords }),
|
|
1307
1485
|
updatedAt: Date.now(),
|
|
1308
1486
|
};
|
|
1487
|
+
// 锚点(0.12.1):省略 → 连 stale 一起保持原值
|
|
1488
|
+
if (patch.anchor === undefined)
|
|
1489
|
+
return patched;
|
|
1490
|
+
// 给 `null` → 清除锚点,并连「因它而标的失效」一起清。别的来源(撤回、被取代)原因文案
|
|
1491
|
+
// 不同,不动——「解除约束」与「撤销作废」是两件事,不能顺手一起抹掉。
|
|
1492
|
+
if (patch.anchor === null) {
|
|
1493
|
+
const fromAnchor = block.anchor !== undefined
|
|
1494
|
+
&& block.staleReason === MemoryEngine.anchorStaleReason(block.anchor);
|
|
1495
|
+
return {
|
|
1496
|
+
...patched,
|
|
1497
|
+
anchor: undefined,
|
|
1498
|
+
...fromAnchor ? { stale: false, staleReason: undefined } : {},
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
// 给值 → 当场校验一次并同步 stale;下面的字段组与 `verifyAnchorsInner` 的失效分支一致
|
|
1502
|
+
const holds = anchorHolds(patch.anchor, this.anchorProbes(projectCwd));
|
|
1503
|
+
if (holds === true)
|
|
1504
|
+
return { ...patched, anchor: patch.anchor, stale: false, staleReason: undefined };
|
|
1505
|
+
if (holds === false) {
|
|
1506
|
+
return {
|
|
1507
|
+
...patched,
|
|
1508
|
+
anchor: patch.anchor,
|
|
1509
|
+
stale: true,
|
|
1510
|
+
staleReason: MemoryEngine.anchorStaleReason(patch.anchor),
|
|
1511
|
+
injected: false,
|
|
1512
|
+
injectedAuto: false,
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
return { ...patched, anchor: patch.anchor }; // 探测不到:不动 stale(未校验 ≠ 失效)
|
|
1309
1516
|
};
|
|
1310
1517
|
// 注入位变化单独发一条事件:客户端与面板据此刷新「常驻」列,只发 status 会漏掉它
|
|
1311
1518
|
const publish = (previous, updated) => {
|
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,
|
|
@@ -33,6 +59,16 @@ const RECORD_SCHEMA = {
|
|
|
33
59
|
hitCount: { type: 'number' },
|
|
34
60
|
quarantined: { type: 'boolean' },
|
|
35
61
|
scope: { type: 'string', description: '来源工作区(④-A):缺省 = 当前会话工作区;`..` / `../..` = 上级工作区。' },
|
|
62
|
+
anchor: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
description: '有效性锚点:这个值一变,这条记忆就自动失效。`memory_update` 传新值即改写(当场重新校验),传 `null` 即清除。',
|
|
66
|
+
properties: {
|
|
67
|
+
kind: { type: 'string', required: true, enum: ['env', 'tool-list', 'self-version', 'path-exists'] },
|
|
68
|
+
name: { type: 'string' },
|
|
69
|
+
value: { type: 'string', required: true },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
36
72
|
},
|
|
37
73
|
};
|
|
38
74
|
const HIT_SCHEMA = {
|
|
@@ -43,6 +79,70 @@ const HIT_SCHEMA = {
|
|
|
43
79
|
score: { type: 'number', required: true },
|
|
44
80
|
},
|
|
45
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* 体检报告的输出(0.12.0):四类清单一律只读。`summary` 与注入行同量级,直接念给人听
|
|
84
|
+
* 不失真——这个工具的主要用法就是「你问起时我念给你看」。
|
|
85
|
+
*/
|
|
86
|
+
const SWEEP_SCHEMA = {
|
|
87
|
+
type: 'object',
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
properties: {
|
|
90
|
+
stale: {
|
|
91
|
+
type: 'array',
|
|
92
|
+
required: true,
|
|
93
|
+
description: '当前失效的记录(锚点不符 / 被取代 / 已撤回),带原因。',
|
|
94
|
+
items: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
properties: {
|
|
98
|
+
id: { type: 'string', required: true },
|
|
99
|
+
summary: { type: 'string', required: true },
|
|
100
|
+
reason: { type: 'string' },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
longUnused: {
|
|
105
|
+
type: 'array',
|
|
106
|
+
required: true,
|
|
107
|
+
description: '久未被检索命中的非常驻记录——清理或归档的候选材料。',
|
|
108
|
+
items: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
properties: {
|
|
112
|
+
id: { type: 'string', required: true },
|
|
113
|
+
summary: { type: 'string', required: true },
|
|
114
|
+
lastUsed: { type: 'number', required: true },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
candidates: {
|
|
119
|
+
type: 'array',
|
|
120
|
+
required: true,
|
|
121
|
+
description: '被反复检索命中但没有被钉成常驻:要不要钉的待办。',
|
|
122
|
+
items: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
properties: {
|
|
126
|
+
id: { type: 'string', required: true },
|
|
127
|
+
summary: { type: 'string', required: true },
|
|
128
|
+
hitCount: { type: 'number', required: true },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
resident: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
required: true,
|
|
135
|
+
additionalProperties: false,
|
|
136
|
+
description: '常驻与注入预算的占用;`omitted > 0` 表示「钉了却没进来」。',
|
|
137
|
+
properties: {
|
|
138
|
+
total: { type: 'number', required: true },
|
|
139
|
+
injected: { type: 'number', required: true },
|
|
140
|
+
budget: { type: 'number', description: '生效预算(字符);无限制时不出现。' },
|
|
141
|
+
omitted: { type: 'number', required: true },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
46
146
|
/** 模板记录(0.6.0):不暴露本地文件路径(meta.path 引擎内部使用)。 */
|
|
47
147
|
const PROMPT_RECORD_SCHEMA = {
|
|
48
148
|
type: 'object',
|
|
@@ -74,6 +174,7 @@ function recordValue(record) {
|
|
|
74
174
|
hitCount: record.hitCount,
|
|
75
175
|
...record.quarantined === true ? { quarantined: true } : {},
|
|
76
176
|
...record.scope === undefined ? {} : { scope: record.scope },
|
|
177
|
+
...record.anchor === undefined ? {} : { anchor: record.anchor },
|
|
77
178
|
};
|
|
78
179
|
}
|
|
79
180
|
function hitValue(hit) {
|
|
@@ -115,19 +216,24 @@ const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and pr
|
|
|
115
216
|
+ '记结论时一并记下**它是怎么来的**——它为什么成立,以及是什么场景把它逼出来的。没有出处的结论像一把'
|
|
116
217
|
+ '没有来历的尺子:看起来天经地义,而天经地义的东西最容易被机械套用。'
|
|
117
218
|
+ '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
|
|
119
|
-
+ '
|
|
219
|
+
+ 'the same switch the settings panel exposes, exempting the record from the remaining automatic rule '
|
|
220
|
+
+ '(demote after long idle; repeated hits no longer promote anything — they only feed the candidate hint). '
|
|
221
|
+
+ 'Reserve it for rules, standing agreements and judgement criteria '
|
|
120
222
|
+ '— the kind that must apply even when nobody thinks to search for them; facts, references and case notes '
|
|
121
223
|
+ 'stay search-only. '
|
|
122
224
|
+ '给 memory_save / memory_update 传 `injected: true` 即把这条钉成每轮常驻——与设置面板是同一个开关,'
|
|
123
|
-
+ '
|
|
225
|
+
+ '此后不受剩下那条自动规则(长期未命中自动撤下)影响;反复命中已不再自动开启任何东西,只喂候选提示。'
|
|
226
|
+
+ '只用于规则、长期约定与判据这类'
|
|
124
227
|
+ '「没人想起来搜也必须生效」的记忆;事实、参考与案例留作按需检索。'
|
|
125
228
|
+ 'When earlier context may be relevant, call memory_search to recall it — reviewable memories '
|
|
126
229
|
+ '相关历史上下文可用 memory_search 检索;被隔离的记录不进检索。'
|
|
127
230
|
+ 'Every memory is plaintext and inspectable with memory_list; memory_forget removes one. '
|
|
128
231
|
+ '所有记忆均为明文,可用 memory_list 查看;memory_forget 删除一条。'
|
|
129
232
|
+ 'Approved + injected memories of the CURRENT session workspace are every-turn injected too. '
|
|
130
|
-
+ '已审核 + 常驻注入开关打开的工作区记忆也会每轮注入(按当前会话工作区路由)。'
|
|
233
|
+
+ '已审核 + 常驻注入开关打开的工作区记忆也会每轮注入(按当前会话工作区路由)。'
|
|
234
|
+
+ 'When the library grows, call memory_sweep for a read-only health check (stale records with reasons, '
|
|
235
|
+
+ 'long-unused ones, candidates that keep being retrieved without being pinned, and budget usage). '
|
|
236
|
+
+ '库变大之后用 memory_sweep 做只读体检——失效记录(带原因)、久未命中、被反复检索却未常驻的候选、以及预算占用。';
|
|
131
237
|
// 0.5.1:`memory:recall` provider 可经 AssembleContext.agent 拿到当前会话
|
|
132
238
|
// 的 header.cwd——注入 = global + 当前会话工作区的 approved+injected
|
|
133
239
|
// (0.3.4 起工作区记忆就按会话 cwd 路由;provider 是同步回调,只读已
|
|
@@ -147,6 +253,14 @@ function recallText(memory, projectCwd, budget, summaryChars) {
|
|
|
147
253
|
const notice = omittedNotice(rendered);
|
|
148
254
|
if (notice !== '')
|
|
149
255
|
parts.push(notice);
|
|
256
|
+
// 0.12.0:新失效(稀有,出现即值得看一眼)与候选(该不该钉的待办)。
|
|
257
|
+
// 两者都不占预算;候选必须走到眼前,因为判断者就是读这段上下文的模型自己。
|
|
258
|
+
const stale = staleNotice(memory.lastSweepReport?.stale ?? []);
|
|
259
|
+
if (stale !== '')
|
|
260
|
+
parts.push(stale);
|
|
261
|
+
const candidates = candidateNotice(memory.candidates(projectCwd));
|
|
262
|
+
if (candidates !== '')
|
|
263
|
+
parts.push(candidates);
|
|
150
264
|
// ① 索引行:给出「库里还有什么、按什么去搜」(不占预算、长度常数级)
|
|
151
265
|
const index = indexNotice(memory.indexCandidates(projectCwd));
|
|
152
266
|
if (index !== '')
|
|
@@ -261,7 +375,7 @@ export async function apply(ctx, config) {
|
|
|
261
375
|
additionalProperties: false,
|
|
262
376
|
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
377
|
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.' },
|
|
378
|
+
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
379
|
name: { type: 'string', description: 'For kind=env: the variable name.' },
|
|
266
380
|
value: { type: 'string', required: true, description: 'The value probed at write time.' },
|
|
267
381
|
},
|
|
@@ -287,21 +401,28 @@ export async function apply(ctx, config) {
|
|
|
287
401
|
}));
|
|
288
402
|
ctx.tools.register(defineTool({
|
|
289
403
|
name: 'memory_list',
|
|
290
|
-
description: 'List every stored memory, optionally filtered by namespace, status, or
|
|
404
|
+
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
405
|
parameters: {
|
|
292
406
|
namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
|
|
293
407
|
status: { type: 'string', enum: ['suggested', 'approved'], description: 'Restrict to one review status. 限定审核状态(suggested=待审核 / approved=已审核).' },
|
|
294
408
|
injected: { type: 'boolean', description: 'Restrict by the persistent-injection switch. 按常驻注入开关过滤.' },
|
|
409
|
+
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`).' },
|
|
410
|
+
before: { type: 'string', description: 'Only memories updated at/before this time (same forms as `after`). 只返回此前更新过的记忆(写法同 after).' },
|
|
295
411
|
},
|
|
296
412
|
output: {
|
|
297
413
|
schema: { type: 'array', items: RECORD_SCHEMA },
|
|
298
414
|
render: (_args, value) => renderJson(value),
|
|
299
415
|
},
|
|
300
416
|
execute(args, exec) {
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
const after = parseTimeFilter(args.after, now);
|
|
419
|
+
const before = parseTimeFilter(args.before, now);
|
|
301
420
|
return memory.list({
|
|
302
421
|
...args.namespace === undefined ? {} : { namespace: args.namespace },
|
|
303
422
|
...args.status === undefined ? {} : { status: args.status },
|
|
304
423
|
...args.injected === undefined ? {} : { injected: args.injected },
|
|
424
|
+
...after === undefined ? {} : { after },
|
|
425
|
+
...before === undefined ? {} : { before },
|
|
305
426
|
}, execProjectCwd(exec)).then(records => records.map(recordValue));
|
|
306
427
|
},
|
|
307
428
|
presentCall: () => present('List memories', 'read'),
|
|
@@ -500,23 +621,52 @@ export async function apply(ctx, config) {
|
|
|
500
621
|
}));
|
|
501
622
|
ctx.tools.register(defineTool({
|
|
502
623
|
name: 'memory_update',
|
|
503
|
-
description: 'Update the content, keywords
|
|
624
|
+
description: 'Update the content, keywords, persistent-injection switch or validity anchor of one stored memory (e.g. correcting stale facts, or re-anchoring a memory whose anchor no longer holds). The record keeps its review status; content that looks like a credential quarantines it instead. 修改一条记忆的内容、关键词、常驻注入开关或有效性锚点(如修正过时信息、把失效的锚点改对)。改动后记忆保持原有审核状态;若新内容命中危险规则则转为隔离。',
|
|
504
625
|
parameters: {
|
|
505
626
|
id: { type: 'string', required: true, description: 'Exact memory id from memory_list. 记忆 id(来自 memory_list).' },
|
|
506
627
|
content: { type: 'string', description: 'New content; omit to keep current. 新内容;省略则保留现有内容.' },
|
|
507
628
|
keywords: { type: 'array', items: { type: 'string' }, description: 'New keywords; omit to keep current. 新关键词;省略则保留现有.' },
|
|
508
|
-
|
|
629
|
+
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 互斥。' },
|
|
630
|
+
retract: { type: 'string', description: 'Retract this memory: give a reason and it is marked void (content kept, no longer injected, still searchable). 撤回本条:给一个原因即标记作废——内容保留、不再进注入,检索仍可检回。与 content / supersedes 互斥。' },
|
|
631
|
+
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。' },
|
|
632
|
+
anchor: {
|
|
633
|
+
description: 'Set, replace, or clear the validity anchor; omit to keep the current one. A new value is re-probed immediately: if it holds, a stale mark left by the old anchor is dropped; if it fails, the memory is marked stale and unpinned at once. `null` clears the anchor together with the stale mark that anchor caused. 设置 / 改写 / 清除有效性锚点;省略则保持原值。给值**当场重新探测**——新锚点成立就撤掉旧锚点留下的失效标记,不成立就立刻标失效并撤下常驻;给 `null` 清除锚点,并连同它带来的失效标记一起清。',
|
|
634
|
+
oneOf: [
|
|
635
|
+
{
|
|
636
|
+
type: 'object',
|
|
637
|
+
additionalProperties: false,
|
|
638
|
+
properties: {
|
|
639
|
+
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`).' },
|
|
640
|
+
name: { type: 'string', description: 'For kind=env: the variable name. For kind=path-exists: the path (relative paths resolve against the session workspace).' },
|
|
641
|
+
value: { type: 'string', required: true, description: 'The value probed at write time.' },
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
{ type: 'null' },
|
|
645
|
+
],
|
|
646
|
+
},
|
|
509
647
|
},
|
|
510
648
|
output: {
|
|
511
649
|
schema: RECORD_SCHEMA,
|
|
512
650
|
render: (_args, value) => renderJson(value),
|
|
513
651
|
},
|
|
514
652
|
execute(args, exec) {
|
|
515
|
-
|
|
653
|
+
const cwd = execProjectCwd(exec);
|
|
654
|
+
const id = args.id;
|
|
655
|
+
// 三种动作互斥,按 retract > supersedes > update 的次序取第一个(与工具描述一致):
|
|
656
|
+
// 前两个是「标状态」,第三个才是「改内容」。同时给多个时以更重的动作为准。
|
|
657
|
+
if (args.retract !== undefined)
|
|
658
|
+
return memory.retract(id, args.retract, cwd).then(recordValue);
|
|
659
|
+
if (args.supersedes !== undefined)
|
|
660
|
+
return memory.supersede(id, args.supersedes, cwd).then(recordValue);
|
|
661
|
+
return memory.update(id, {
|
|
516
662
|
...args.content === undefined ? {} : { content: args.content },
|
|
517
663
|
...args.keywords === undefined ? {} : { keywords: args.keywords },
|
|
518
664
|
...args.injected === undefined ? {} : { injected: args.injected },
|
|
519
|
-
|
|
665
|
+
// 参数已由 tool schema 校验(对象或 null),这里的窄化只为过类型
|
|
666
|
+
...args.anchor === undefined
|
|
667
|
+
? {}
|
|
668
|
+
: { anchor: args.anchor },
|
|
669
|
+
}, cwd).then(recordValue);
|
|
520
670
|
},
|
|
521
671
|
presentCall: args => present('Update memory', 'other', args.id),
|
|
522
672
|
}));
|
|
@@ -535,4 +685,17 @@ export async function apply(ctx, config) {
|
|
|
535
685
|
},
|
|
536
686
|
presentCall: args => present('Confirm memory', 'other', args.id),
|
|
537
687
|
}));
|
|
688
|
+
ctx.tools.register(defineTool({
|
|
689
|
+
name: 'memory_sweep',
|
|
690
|
+
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. 只读体检:列出失效的记忆(带原因)、久未被检索命中的非常驻记录、被反复检索却未常驻的候选,以及常驻占用了多少注入预算。不修改任何状态——最坏情况是报告不准,而不是误改内容。',
|
|
691
|
+
parameters: {},
|
|
692
|
+
output: {
|
|
693
|
+
schema: SWEEP_SCHEMA,
|
|
694
|
+
render: (_args, value) => renderJson(value),
|
|
695
|
+
},
|
|
696
|
+
execute(_args, exec) {
|
|
697
|
+
return memory.sweep(execProjectCwd(exec));
|
|
698
|
+
},
|
|
699
|
+
presentCall: () => present('Sweep memory', 'other', 'read-only'),
|
|
700
|
+
}));
|
|
538
701
|
}
|
package/dist/injection.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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) =>
|
|
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,10 @@
|
|
|
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
|
+
* (该规则已取消,命中只喂候选提示);注入取舍依据由「最近使用」改为「最近更新」。
|
|
10
|
+
* 0.12.1:锚点那句补上「可以用 memory_update 改写或清除」——此前模型读得到锚点的作用,
|
|
11
|
+
* 却没有任何改它的手段,读起来像一条只能写死一次的东西。
|
|
8
12
|
*/
|
|
9
13
|
import { createRequire } from 'node:module';
|
|
10
14
|
export const SELF_VERSION = (() => {
|
|
@@ -19,7 +23,7 @@ export const SELF_VERSION = (() => {
|
|
|
19
23
|
}
|
|
20
24
|
})();
|
|
21
25
|
export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
|
|
22
|
-
+ '你有
|
|
26
|
+
+ '你有 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
27
|
+ '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
|
|
24
28
|
+ '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
|
|
25
29
|
+ '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
|
|
@@ -27,12 +31,12 @@ export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话
|
|
|
27
31
|
+ '③ 得出跨会话仍然有用的结论、路径或坑,而非本次任务的临时细节。'
|
|
28
32
|
+ '**不要记**:临时状态、未经验证的推测、能从代码或文档直接读到的事实(那类记「去哪查」即可)。'
|
|
29
33
|
+ '**怎么写**:一条一件事,并给多角度关键词(同义词、缩写、中英),否则将来检索不到它。'
|
|
30
|
-
+ '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale
|
|
34
|
+
+ '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale,不会再被当成仍然正确;绑错了用 memory_update 改(当场重新判定,成立即复活),不要了就传 `anchor: null` 清除。当前锚点随记录一起返回。'
|
|
31
35
|
+ '写入即生效(`approved`),人工只在例外时介入;命中密钥/凭据规则的写入会被隔离(不进注入与检索)。'
|
|
32
36
|
+ '**常驻注入**:每轮注入「global + 当前会话工作区」里 approved 且开着常驻开关的记忆单行摘要(形如 `- [memory:id:namespace] 摘要`);全文要靠 memory_search 取。'
|
|
33
|
-
+ '
|
|
37
|
+
+ '开关只有一条自动规则:**自动升上来的**常驻在 30 天未命中后撤下;命中次数不再自动改变注入状态(它只喂体检报告的候选提示)。**给 memory_save / memory_update 传 `injected` 即显式接管**,此后这条规则也不再适用。'
|
|
34
38
|
+ '**钉的判据是「失效可察觉性」**:这条记忆若没被想起来,你会不会根本意识不到自己漏了它?会 → 钉常驻(规则 / 委托 / 判据);不会 → 留检索(事实 / 参考 / 案例)。'
|
|
35
|
-
+ '**注入有预算**(默认 1500 字符,约装 11
|
|
39
|
+
+ '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按**最近更新**优先取舍(不是最近被检索——检索是「需要时才用」的行为,拿它决定「谁每轮在场」会让查得多的挤掉钉得牢的)——所以「钉了常驻」只等于「有资格排队」。'
|
|
36
40
|
+ '装不下时注入末尾会出现一行 `(另有 N 条常驻因预算未注入:…短摘要…)`——**那是「有常驻记忆并不在场」的唯一信号**,看到它就该逐条评估撤下。'
|
|
37
41
|
+ '发现某条记忆与实际不符时用 memory_update 修正(保持生效);确认没用的用 memory_forget。'
|
|
38
42
|
+ '(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.
|
|
3
|
+
"version": "0.12.1",
|
|
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": {
|