@max-null/dsh-memory 0.6.1 → 0.7.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/index.js CHANGED
@@ -28,6 +28,10 @@ const RECORD_SCHEMA = {
28
28
  createdAt: { type: 'number', required: true },
29
29
  updatedAt: { type: 'number', required: true },
30
30
  lastUsedAt: { type: 'number' },
31
+ stale: { type: 'boolean', description: '内容可能已过时(锚点失效):据此行动前先核对现状,核对后用 memory_update 修正或刷新。' },
32
+ staleReason: { type: 'string' },
33
+ hitCount: { type: 'number' },
34
+ quarantined: { type: 'boolean' },
31
35
  },
32
36
  };
33
37
  const HIT_SCHEMA = {
@@ -64,6 +68,10 @@ function recordValue(record) {
64
68
  createdAt: record.createdAt,
65
69
  updatedAt: record.updatedAt,
66
70
  ...record.lastUsedAt === undefined ? {} : { lastUsedAt: record.lastUsedAt },
71
+ ...record.stale === undefined ? {} : { stale: record.stale },
72
+ ...record.staleReason === undefined ? {} : { staleReason: record.staleReason },
73
+ hitCount: record.hitCount,
74
+ ...record.quarantined === true ? { quarantined: true } : {},
67
75
  };
68
76
  }
69
77
  function hitValue(hit) {
@@ -86,13 +94,15 @@ function promptValue(record) {
86
94
  // 采用中英双语都写、模型自取——设计文档「风险与注意」)。
87
95
  const GUIDANCE = 'Use memory tools for cross-session preferences, habits, and project conventions. '
88
96
  + '记忆工具用于跨会话的偏好、习惯与项目约定。'
89
- + 'memory_save always records a suggestion (`suggested`) and never makes it effective itself — '
90
- + 'a human approves it. memory_save 永远只写入建议(`suggested`),不会自行生效——需人工审核通过。'
97
+ + 'memory_save writes a memory that takes effect immediately (`approved`) — memories are maintained '
98
+ + 'by the system and the model, and humans intervene only as exceptions. Credential-like content is '
99
+ + 'quarantined instead. memory_save 写入即生效(`approved`)——记忆由系统与模型自行维护,人工只在例外时介入;'
100
+ + '命中危险内容规则(密钥/凭据)的写入会被隔离。'
91
101
  + 'Approval only marks the content reviewed; whether it is injected every turn is a separate '
92
102
  + 'human-controlled switch (`injected`). 审核通过只代表内容被认可;是否每轮常驻注入由独立的'
93
103
  + '人工开关(`injected`)控制。'
94
104
  + 'When earlier context may be relevant, call memory_search to recall it — reviewable memories '
95
- + '(`suggested`) are searchable too. 相关历史上下文可用 memory_search 检索——待审核的记忆也可检索。'
105
+ + '相关历史上下文可用 memory_search 检索;被隔离的记录不进检索。'
96
106
  + 'Every memory is plaintext and inspectable with memory_list; memory_forget removes one. '
97
107
  + '所有记忆均为明文,可用 memory_list 查看;memory_forget 删除一条。'
98
108
  + 'Approved + injected memories of the CURRENT session workspace are every-turn injected too. '
@@ -173,11 +183,21 @@ export async function apply(ctx, config) {
173
183
  });
174
184
  ctx.tools.register(defineTool({
175
185
  name: 'memory_save',
176
- description: 'Record one cross-session memory as a suggestion. It never becomes effective until a human confirms it; the model must not present a suggestion as confirmed.',
186
+ description: 'Save one cross-session memory. It takes effect immediately; credential-like content is quarantined instead. Record durable preferences, corrections and reusable conclusions — not transient state, guesses, or facts readable from the repo (record where to look instead). 保存一条跨会话记忆:写入即生效;命中密钥/凭据规则的写入会被隔离。只记长期偏好、纠正与可复用结论——不记临时状态、推测、或能从仓库读到的事实(那类记「去哪查」)。',
177
187
  parameters: {
178
188
  content: { type: 'string', required: true, description: 'Plaintext memory content.' },
179
189
  namespace: { type: 'string', enum: ['global', 'project'], description: 'Where it applies; defaults to global.' },
180
- keywords: { type: 'array', items: { type: 'string' }, description: 'Explicit searchable anchors for memory_search.' },
190
+ keywords: { type: 'array', items: { type: 'string' }, description: 'Explicit searchable anchors for memory_search. Give several angles (synonyms, abbreviations, zh/en) or it will not be found later. 多角度关键词(同义词、缩写、中英),否则将来检索不到它。' },
191
+ anchor: {
192
+ type: 'object',
193
+ additionalProperties: false,
194
+ 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). 可选锚点:把记忆绑到可探测的环境值,值变了自动失效。只用于「随环境变化的事实」。',
195
+ properties: {
196
+ 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.' },
197
+ name: { type: 'string', description: 'For kind=env: the variable name.' },
198
+ value: { type: 'string', required: true, description: 'The value probed at write time.' },
199
+ },
200
+ },
181
201
  },
182
202
  output: {
183
203
  schema: RECORD_SCHEMA,
@@ -188,6 +208,10 @@ export async function apply(ctx, config) {
188
208
  content: args.content,
189
209
  ...args.namespace === undefined ? {} : { namespace: args.namespace },
190
210
  ...args.keywords === undefined ? {} : { keywords: args.keywords },
211
+ // 参数已由 tool schema 校验,这里的窄化只为过类型
212
+ ...args.anchor === undefined
213
+ ? {}
214
+ : { anchor: args.anchor },
191
215
  }, execProjectCwd(exec)).then(recordValue);
192
216
  },
193
217
  presentCall: args => present('Save memory', 'other', args.content),
@@ -215,7 +239,7 @@ export async function apply(ctx, config) {
215
239
  }));
216
240
  ctx.tools.register(defineTool({
217
241
  name: 'memory_search',
218
- description: 'Recall stored memories by keyword. Deterministic literal matching — a miss means no stored term matched the query. 按关键词检索记忆(中文 2-gram + 关键词加权;配置嵌入时含语义融合;含待审核条目)。',
242
+ description: 'Recall stored memories by keyword. Deterministic literal matching — a miss means no stored term matched the query. 按关键词检索记忆(中文 2-gram + 关键词加权;配置嵌入时含语义融合;含待审核条目,被隔离的除外)。',
219
243
  parameters: {
220
244
  query: { type: 'string', required: true, description: 'Keyword query. 关键词查询.' },
221
245
  namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
@@ -382,7 +406,7 @@ export async function apply(ctx, config) {
382
406
  }));
383
407
  ctx.tools.register(defineTool({
384
408
  name: 'memory_update',
385
- description: 'Update the content or keywords of one stored memory (e.g. correcting stale facts). The record is re-marked `suggested` — the human must review it again before it is approved/injected again. 修改一条记忆的内容或关键词(如修正过时信息)。改动后该记忆重置为待审核(suggested),需人工再次审核;常驻注入开关保留原值(审核通过后恢复)。',
409
+ description: 'Update the content or keywords of one stored memory (e.g. correcting stale facts). The record keeps its review status and injection switch; content that looks like a credential quarantines it instead. 修改一条记忆的内容或关键词(如修正过时信息)。改动后记忆保持原有审核状态与注入开关;若新内容命中危险规则则转为隔离。',
386
410
  parameters: {
387
411
  id: { type: 'string', required: true, description: 'Exact memory id from memory_list. 记忆 id(来自 memory_list).' },
388
412
  content: { type: 'string', description: 'New content; omit to keep current. 新内容;省略则保留现有内容.' },
@@ -402,7 +426,7 @@ export async function apply(ctx, config) {
402
426
  }));
403
427
  ctx.tools.register(defineTool({
404
428
  name: 'memory_confirm',
405
- description: 'Approve a suggested memory so it is marked human-reviewed (`approved`). Approval does NOT enable persistent injection — whether a memory is injected every turn is a separate human-controlled switch (`injected`). Only call this when the human explicitly asks to approve a memory; never self-promote a suggestion. 将待审核记忆标记为已审核(approved)。审核通过不改变注入状态——是否每轮常驻注入由独立的人工开关(injected)控制。仅在用户明确要求审核某条记忆时调用;模型不得自我提升。',
429
+ description: 'Release a quarantined memory: marks it human-reviewed (`approved`) and clears its quarantine. Under the silent mechanism ordinary writes already take effect by themselves, so call this only when the human explicitly asks to release a quarantined memory. 放行一条被隔离的记忆:标记为已审核并解除隔离。静默机制下普通写入已自行生效,因此仅在用户明确要求放行隔离记忆时调用。',
406
430
  parameters: {
407
431
  id: { type: 'string', required: true, description: 'Exact memory id from memory_list. 记忆 id(来自 memory_list).' },
408
432
  },
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 危险内容硬拦(2026-09-15 静默记忆机制第一版):写入路径上的确定性规则检测。
3
+ *
4
+ * 这一类判断不交给模型:凭据一旦进了记忆库,检索就会把它交给模型,而模型无法
5
+ * 可靠判断「这段是不是密钥」。所以用固定规则拦下,命中即隔离(不注入、不检索),
6
+ * 由人显式放行或删除——这是设计文档里唯一不由 LLM 承担的判断。
7
+ *
8
+ * 误报的代价(一条正常记忆被隔离)小于漏报的代价(密钥进入每轮上下文),
9
+ * 因此规则偏保守:宁可多拦,且放行只需一次人工动作。
10
+ */
11
+ /** 隔离判定的结果。 */
12
+ export interface QuarantineVerdict {
13
+ /** 是否应隔离。 */
14
+ quarantined: boolean;
15
+ /** 命中的规则名(诊断与面板展示用);未命中时为 undefined。 */
16
+ reason?: string;
17
+ }
18
+ /**
19
+ * 判定一段文本是否应被隔离。
20
+ *
21
+ * @param parts - 参与判定的文本片段(正文与关键词都算:关键词里塞密钥同样会外泄)。
22
+ * @returns 命中任意规则即 `quarantined: true`,并给出规则名。
23
+ */
24
+ export declare function detectSensitive(...parts: readonly string[]): QuarantineVerdict;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 危险内容硬拦(2026-09-15 静默记忆机制第一版):写入路径上的确定性规则检测。
3
+ *
4
+ * 这一类判断不交给模型:凭据一旦进了记忆库,检索就会把它交给模型,而模型无法
5
+ * 可靠判断「这段是不是密钥」。所以用固定规则拦下,命中即隔离(不注入、不检索),
6
+ * 由人显式放行或删除——这是设计文档里唯一不由 LLM 承担的判断。
7
+ *
8
+ * 误报的代价(一条正常记忆被隔离)小于漏报的代价(密钥进入每轮上下文),
9
+ * 因此规则偏保守:宁可多拦,且放行只需一次人工动作。
10
+ */
11
+ /** 单条规则:名字进诊断,正则做判定。命中任意一条即隔离。 */
12
+ const RULES = [
13
+ // 常见平台的密钥前缀
14
+ { name: 'api-key-prefix', pattern: /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/ },
15
+ { name: 'aws-access-key', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
16
+ { name: 'github-token', pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
17
+ { name: 'slack-token', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
18
+ // 私钥块
19
+ { name: 'private-key-block', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
20
+ // 赋值式秘密:password/token/api_key = 值
21
+ { name: 'assigned-secret', pattern: /\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)\b\s*[:=]\s*\S{6,}/i },
22
+ // 认证头
23
+ { name: 'authorization-header', pattern: /\bBearer\s+[A-Za-z0-9._-]{20,}/ },
24
+ // 带口令的连接串(scheme://user:pass@host)
25
+ { name: 'credential-url', pattern: /[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]+@/i },
26
+ ];
27
+ /**
28
+ * 判定一段文本是否应被隔离。
29
+ *
30
+ * @param parts - 参与判定的文本片段(正文与关键词都算:关键词里塞密钥同样会外泄)。
31
+ * @returns 命中任意规则即 `quarantined: true`,并给出规则名。
32
+ */
33
+ export function detectSensitive(...parts) {
34
+ const text = parts.filter(part => part !== '').join('\n');
35
+ if (text === '')
36
+ return { quarantined: false };
37
+ for (const rule of RULES) {
38
+ // 规则都是无状态正则(无 /g),test 不会受 lastIndex 影响
39
+ if (rule.pattern.test(text))
40
+ return { quarantined: true, reason: rule.name };
41
+ }
42
+ return { quarantined: false };
43
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * 查询日志与影响力统计(2026-09-15 静默记忆机制,设计文档 §三.5 / §六.6)。
3
+ *
4
+ * 漏检不产生任何事件:「该用却搜不到」与「真没用了」在旧信号里长得一样(反例 4),
5
+ * 照使用信号沉底会误杀。所以插件必须自己观察自己——记录每次检索的查询与命中数
6
+ * (有界保留),事后低频反查「这些查询本该命中谁」;同一份数据反过来按**来源**聚合
7
+ * 影响力(写入量 / 命中数),作为「某来源写入多、命中 0」这类关闭信号的判据(反例 5)。
8
+ *
9
+ * 纯文件读写 + 纯函数,不引入依赖。日志是检索路径上的**旁路**:写失败绝不能影响
10
+ * 检索结果,因此文件读写各自容错,抛错只由调用方处理(引擎侧一律 try/catch)。
11
+ */
12
+ /** 有界保留条数(设计文档 §六.6「有界保留」,建议最近 200 条)。 */
13
+ export declare const QUERY_LOG_LIMIT = 200;
14
+ /** 单条查询文本的截断上限(字符)——日志只用于反查,不需要全文。 */
15
+ export declare const QUERY_TEXT_LIMIT = 120;
16
+ /** 单次反查报告的查询个数上限(低频反查:一次看一小批就够)。 */
17
+ export declare const GAP_QUERY_LIMIT = 10;
18
+ /** 每个查询给出的候选上限。 */
19
+ export declare const GAP_CANDIDATE_LIMIT = 5;
20
+ /** 记忆来源:'agent' = 主模型写入;'human' = 人工写入。 */
21
+ export type MemorySource = 'agent' | 'human';
22
+ /** 聚合时无来源字段的旧记录归入此组(不猜测,保留「未知历史来源」)。 */
23
+ export declare const UNKNOWN_SOURCE = "unknown";
24
+ /** One recorded `memory_search` call: the query text, how many records it hit, and when. */
25
+ export interface QueryLogEntry {
26
+ /** 查询文本(已折叠空白并截断)。 */
27
+ query: string;
28
+ /** 本次检索的命中条数;0 = 空命中,正是反查的入口。 */
29
+ hits: number;
30
+ /** 记录时间(epoch ms)。 */
31
+ at: number;
32
+ }
33
+ /** 一次查询日志写入(`at` 缺省 = 现在)。 */
34
+ export interface QueryLogInput {
35
+ query: string;
36
+ hits: number;
37
+ at?: number;
38
+ }
39
+ /** 日志文件位置与有界条数。 */
40
+ export interface QueryLogOptions {
41
+ /** 日志文件绝对路径(与 memory.json 同目录)。 */
42
+ path: string;
43
+ /** 有界保留条数;缺省 {@link QUERY_LOG_LIMIT}。 */
44
+ limit?: number;
45
+ /** 时钟(测试注入用);缺省 Date.now。 */
46
+ now?: () => number;
47
+ }
48
+ /** 一个空命中查询在日志里的累计形态(反查的输入)。 */
49
+ export interface QueryGapSummary {
50
+ query: string;
51
+ /** 该查询空命中的次数。 */
52
+ count: number;
53
+ /** 最近一次出现的时间。 */
54
+ lastAt: number;
55
+ }
56
+ /** 查询日志的聚合摘要(纯统计,不读文件)。 */
57
+ export interface QueryLogSummary {
58
+ /** 日志条数。 */
59
+ total: number;
60
+ /** 空命中(hits === 0)的条数。 */
61
+ misses: number;
62
+ /** 去重后的空命中查询,最近出现的在前(最多 `limit` 个)。 */
63
+ gaps: QueryGapSummary[];
64
+ /** 最近一条日志的时间(0 = 空日志)。 */
65
+ lastAt: number;
66
+ }
67
+ /** 反查候选:一条可能与查询相关、但检索当时没有命中的记忆。 */
68
+ export interface GapCandidate {
69
+ /** 记录 id(字符串形态)。 */
70
+ id: string;
71
+ /** 记录正文(调用方按需截断)。 */
72
+ content: string;
73
+ /** 精确词元命中数:> 0 表示「现在重搜就该命中」。 */
74
+ exact: number;
75
+ /** 前缀近似命中的查询词元(如 vue3 ↔ vue)。 */
76
+ relaxed: string[];
77
+ }
78
+ /** 影响力统计的输入:一条现存记录的来源与影响面。 */
79
+ export interface SourceTallyInput {
80
+ /** 来源;缺省 = 旧记录(无来源字段)。 */
81
+ source?: MemorySource;
82
+ /** 被检索命中的累计次数。 */
83
+ hitCount: number;
84
+ /** 是否常驻注入(真正改变每轮决策的那部分)。 */
85
+ injected: boolean;
86
+ }
87
+ /** 按来源聚合的影响力(设计文档 §六.6 的三个数里的前两个 + 注入面)。 */
88
+ export interface SourceStat {
89
+ source: MemorySource | typeof UNKNOWN_SOURCE;
90
+ /** 写入量:现存的该来源记录数。 */
91
+ writes: number;
92
+ /** 被检索命中数:现存记录 `hitCount` 合计。 */
93
+ hits: number;
94
+ /** 常驻注入条数。 */
95
+ injected: number;
96
+ }
97
+ /** 查询文本规范化(纯函数):折叠空白 + 截断。 */
98
+ export declare function normalizeQuery(query: string, limit?: number): string;
99
+ /**
100
+ * 容错解析(纯函数):坏 JSON / 坏结构 → 空日志(绝不抛)。
101
+ * 接受两种形态:裸数组,以及 `{ queries: [...] }`(本模块写出的形态)。
102
+ */
103
+ export declare function parseQueryLog(text: string): QueryLogEntry[];
104
+ /** 序列化(纯函数):2 空格缩进 + 尾换行,与仓库其他存储文件一致。 */
105
+ export declare function serializeQueryLog(log: readonly QueryLogEntry[]): string;
106
+ /** 纯函数:追加一条并截到上限(最新的在末尾;返回新数组,不改入参)。 */
107
+ export declare function appendQueryEntry(log: readonly QueryLogEntry[], input: QueryLogInput, limit?: number): QueryLogEntry[];
108
+ /** 聚合摘要(纯函数):空命中计数 + 去重查询列表(反查入口)。 */
109
+ export declare function summarizeQueryLog(log: readonly QueryLogEntry[], limit?: number): QueryLogSummary;
110
+ /**
111
+ * 反查排序(纯函数):把一个空命中的查询与当前记忆库对起来,找出「本应命中」的候选。
112
+ *
113
+ * 两档匹配,都只看字面,因此结论可解释:
114
+ * ①**精确词元**(与检索同一套分词):命中即代表现在重搜会命中——日志里却是 0,
115
+ * 说明那条记忆是查询之后才写入的(或当时被过滤挡掉了);
116
+ * ②**前缀近似**(长度 ≥ {@link RELAXED_MIN_CHARS} 且互为前缀):覆盖「vue3 ↔ vue」
117
+ * 这类标识符/版本号变体——检索按整词比对必然漏,而它往往正是该补 keywords 的那条。
118
+ *
119
+ * 两档都无候选 = 知识缺口(这类知识库里确实还没有),本身也是有用信息。
120
+ */
121
+ export declare function rankGapCandidates(query: string, docs: readonly {
122
+ id: string;
123
+ body: string;
124
+ tags?: string;
125
+ }[], limit?: number): GapCandidate[];
126
+ /**
127
+ * 按来源聚合影响力(纯函数,设计文档 §六.6 / 反例 5):写入量与被检索命中数。
128
+ *
129
+ * **第三个数的取舍**:设计文档要的「被人工删除数」在这里没有,原因是它无法归属——
130
+ * 来源字段长在记录上,`memory_forget` 删除记录时来源一起消失,查询日志也不携带来源,
131
+ * 于是删除量只能数出总数、数不出是谁写的。按「日志里曾经的 id 现已不存在」做差异法
132
+ * 同样归不到来源(能归的只有还活着的记录,活着的没被删)。要拿到它需要在 `forget()`
133
+ * 里追加带来源的删除墓碑,第一版不做:先攒前两个数,等真需要判 habit / guardian 时再补。
134
+ */
135
+ export declare function summarizeSources(records: readonly SourceTallyInput[]): SourceStat[];
136
+ /**
137
+ * 有界查询日志(文件 + 上面的纯函数)。每次追加都是读-改-写整个文件:单进程内由
138
+ * 事件循环串行化,多进程并发(两个 DSH 实例)可能丢条目——日志是旁路证据,可接受。
139
+ */
140
+ export declare class QueryLog {
141
+ private readonly options;
142
+ private readonly limit;
143
+ private readonly now;
144
+ constructor(options: QueryLogOptions);
145
+ /** 读日志;文件缺失或损坏 → 空数组(绝不抛)。 */
146
+ readLog(): QueryLogEntry[];
147
+ /**
148
+ * 追加一条(有界:越界丢最旧)。**写失败会抛**——由调用方兜住:
149
+ * 检索路径上的日志失败绝不能改变检索结果。
150
+ * @returns 写入后的完整日志(调用方可直接用,不必重读)。
151
+ */
152
+ appendQuery(input: QueryLogInput): QueryLogEntry[];
153
+ /** 聚合摘要(空命中与去重查询列表)。 */
154
+ summarize(limit?: number): QueryLogSummary;
155
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * 查询日志与影响力统计(2026-09-15 静默记忆机制,设计文档 §三.5 / §六.6)。
3
+ *
4
+ * 漏检不产生任何事件:「该用却搜不到」与「真没用了」在旧信号里长得一样(反例 4),
5
+ * 照使用信号沉底会误杀。所以插件必须自己观察自己——记录每次检索的查询与命中数
6
+ * (有界保留),事后低频反查「这些查询本该命中谁」;同一份数据反过来按**来源**聚合
7
+ * 影响力(写入量 / 命中数),作为「某来源写入多、命中 0」这类关闭信号的判据(反例 5)。
8
+ *
9
+ * 纯文件读写 + 纯函数,不引入依赖。日志是检索路径上的**旁路**:写失败绝不能影响
10
+ * 检索结果,因此文件读写各自容错,抛错只由调用方处理(引擎侧一律 try/catch)。
11
+ */
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ import { tokenize } from "./bm25.js";
15
+ /** 有界保留条数(设计文档 §六.6「有界保留」,建议最近 200 条)。 */
16
+ export const QUERY_LOG_LIMIT = 200;
17
+ /** 单条查询文本的截断上限(字符)——日志只用于反查,不需要全文。 */
18
+ export const QUERY_TEXT_LIMIT = 120;
19
+ /** 单次反查报告的查询个数上限(低频反查:一次看一小批就够)。 */
20
+ export const GAP_QUERY_LIMIT = 10;
21
+ /** 每个查询给出的候选上限。 */
22
+ export const GAP_CANDIDATE_LIMIT = 5;
23
+ /**
24
+ * 前缀近似匹配的最短词元长度:太短会连出一片噪声(CJK 单字/双字元长度都不足 3,
25
+ * 因此这一档实质上只对英文与标识符生效)。
26
+ */
27
+ const RELAXED_MIN_CHARS = 3;
28
+ /** 聚合时无来源字段的旧记录归入此组(不猜测,保留「未知历史来源」)。 */
29
+ export const UNKNOWN_SOURCE = 'unknown';
30
+ /** 查询文本规范化(纯函数):折叠空白 + 截断。 */
31
+ export function normalizeQuery(query, limit = QUERY_TEXT_LIMIT) {
32
+ const collapsed = query.replace(/\s+/g, ' ').trim();
33
+ return collapsed.length <= limit ? collapsed : collapsed.slice(0, limit);
34
+ }
35
+ /**
36
+ * 逐条校验(纯函数):结构不对的条目丢弃——日志是辅助证据,
37
+ * 一条坏记录不该让整份日志失效。
38
+ */
39
+ function toEntry(value) {
40
+ if (typeof value !== 'object' || value === null)
41
+ return undefined;
42
+ const entry = value;
43
+ if (typeof entry.query !== 'string' || entry.query.trim() === '')
44
+ return undefined;
45
+ if (typeof entry.hits !== 'number' || !Number.isFinite(entry.hits))
46
+ return undefined;
47
+ return {
48
+ query: normalizeQuery(entry.query),
49
+ hits: Math.max(0, Math.trunc(entry.hits)),
50
+ at: typeof entry.at === 'number' && Number.isFinite(entry.at) ? entry.at : 0,
51
+ };
52
+ }
53
+ /**
54
+ * 容错解析(纯函数):坏 JSON / 坏结构 → 空日志(绝不抛)。
55
+ * 接受两种形态:裸数组,以及 `{ queries: [...] }`(本模块写出的形态)。
56
+ */
57
+ export function parseQueryLog(text) {
58
+ let document;
59
+ try {
60
+ document = JSON.parse(text);
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ let raw;
66
+ if (Array.isArray(document))
67
+ raw = document;
68
+ else if (typeof document === 'object' && document !== null)
69
+ raw = document.queries;
70
+ else
71
+ return [];
72
+ if (!Array.isArray(raw))
73
+ return [];
74
+ return raw.map(toEntry).filter((entry) => entry !== undefined);
75
+ }
76
+ /** 序列化(纯函数):2 空格缩进 + 尾换行,与仓库其他存储文件一致。 */
77
+ export function serializeQueryLog(log) {
78
+ return `${JSON.stringify({ queries: log }, null, 2)}\n`;
79
+ }
80
+ /** 纯函数:追加一条并截到上限(最新的在末尾;返回新数组,不改入参)。 */
81
+ export function appendQueryEntry(log, input, limit = QUERY_LOG_LIMIT) {
82
+ const appended = [...log, {
83
+ query: normalizeQuery(input.query),
84
+ hits: Math.max(0, Math.trunc(input.hits)),
85
+ at: input.at ?? Date.now(),
86
+ }];
87
+ const cap = Math.max(0, Math.trunc(limit));
88
+ return appended.length <= cap ? appended : appended.slice(appended.length - cap);
89
+ }
90
+ /** 聚合摘要(纯函数):空命中计数 + 去重查询列表(反查入口)。 */
91
+ export function summarizeQueryLog(log, limit = GAP_QUERY_LIMIT) {
92
+ const byQuery = new Map();
93
+ let misses = 0;
94
+ let lastAt = 0;
95
+ for (const entry of log) {
96
+ if (entry.at > lastAt)
97
+ lastAt = entry.at;
98
+ if (entry.hits > 0)
99
+ continue;
100
+ misses += 1;
101
+ const seen = byQuery.get(entry.query);
102
+ if (seen === undefined) {
103
+ byQuery.set(entry.query, { query: entry.query, count: 1, lastAt: entry.at });
104
+ continue;
105
+ }
106
+ seen.count += 1;
107
+ if (entry.at > seen.lastAt)
108
+ seen.lastAt = entry.at;
109
+ }
110
+ const gaps = [...byQuery.values()]
111
+ .sort((left, right) => right.lastAt - left.lastAt)
112
+ .slice(0, Math.max(0, Math.trunc(limit)));
113
+ return { total: log.length, misses, gaps, lastAt };
114
+ }
115
+ /**
116
+ * 反查排序(纯函数):把一个空命中的查询与当前记忆库对起来,找出「本应命中」的候选。
117
+ *
118
+ * 两档匹配,都只看字面,因此结论可解释:
119
+ * ①**精确词元**(与检索同一套分词):命中即代表现在重搜会命中——日志里却是 0,
120
+ * 说明那条记忆是查询之后才写入的(或当时被过滤挡掉了);
121
+ * ②**前缀近似**(长度 ≥ {@link RELAXED_MIN_CHARS} 且互为前缀):覆盖「vue3 ↔ vue」
122
+ * 这类标识符/版本号变体——检索按整词比对必然漏,而它往往正是该补 keywords 的那条。
123
+ *
124
+ * 两档都无候选 = 知识缺口(这类知识库里确实还没有),本身也是有用信息。
125
+ */
126
+ export function rankGapCandidates(query, docs, limit = GAP_CANDIDATE_LIMIT) {
127
+ const queryTerms = [...new Set(tokenize(query))];
128
+ if (queryTerms.length === 0)
129
+ return [];
130
+ const candidates = [];
131
+ for (const doc of docs) {
132
+ const docTerms = new Set(tokenize(doc.tags === undefined ? doc.body : `${doc.body} ${doc.tags}`));
133
+ const exact = queryTerms.filter(term => docTerms.has(term)).length;
134
+ const relaxed = [];
135
+ for (const term of queryTerms) {
136
+ if (docTerms.has(term) || term.length < RELAXED_MIN_CHARS)
137
+ continue;
138
+ for (const candidate of docTerms) {
139
+ if (candidate.length < RELAXED_MIN_CHARS)
140
+ continue;
141
+ if (candidate.startsWith(term) || term.startsWith(candidate)) {
142
+ relaxed.push(term);
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ if (exact === 0 && relaxed.length === 0)
148
+ continue;
149
+ candidates.push({ id: doc.id, content: doc.body, exact, relaxed });
150
+ }
151
+ return candidates
152
+ .sort((left, right) => right.exact - left.exact
153
+ || right.relaxed.length - left.relaxed.length
154
+ || left.id.localeCompare(right.id))
155
+ .slice(0, Math.max(0, Math.trunc(limit)));
156
+ }
157
+ /**
158
+ * 按来源聚合影响力(纯函数,设计文档 §六.6 / 反例 5):写入量与被检索命中数。
159
+ *
160
+ * **第三个数的取舍**:设计文档要的「被人工删除数」在这里没有,原因是它无法归属——
161
+ * 来源字段长在记录上,`memory_forget` 删除记录时来源一起消失,查询日志也不携带来源,
162
+ * 于是删除量只能数出总数、数不出是谁写的。按「日志里曾经的 id 现已不存在」做差异法
163
+ * 同样归不到来源(能归的只有还活着的记录,活着的没被删)。要拿到它需要在 `forget()`
164
+ * 里追加带来源的删除墓碑,第一版不做:先攒前两个数,等真需要判 habit / guardian 时再补。
165
+ */
166
+ export function summarizeSources(records) {
167
+ const stats = new Map();
168
+ for (const record of records) {
169
+ const source = record.source ?? UNKNOWN_SOURCE;
170
+ const stat = stats.get(source) ?? { source, writes: 0, hits: 0, injected: 0 };
171
+ stat.writes += 1;
172
+ stat.hits += Math.max(0, Math.trunc(record.hitCount));
173
+ if (record.injected)
174
+ stat.injected += 1;
175
+ stats.set(source, stat);
176
+ }
177
+ const order = ['agent', 'human', UNKNOWN_SOURCE];
178
+ return [...stats.values()]
179
+ .sort((left, right) => order.indexOf(left.source) - order.indexOf(right.source));
180
+ }
181
+ /**
182
+ * 有界查询日志(文件 + 上面的纯函数)。每次追加都是读-改-写整个文件:单进程内由
183
+ * 事件循环串行化,多进程并发(两个 DSH 实例)可能丢条目——日志是旁路证据,可接受。
184
+ */
185
+ export class QueryLog {
186
+ options;
187
+ limit;
188
+ now;
189
+ constructor(options) {
190
+ this.options = options;
191
+ this.limit = options.limit ?? QUERY_LOG_LIMIT;
192
+ this.now = options.now ?? Date.now;
193
+ }
194
+ /** 读日志;文件缺失或损坏 → 空数组(绝不抛)。 */
195
+ readLog() {
196
+ try {
197
+ if (!existsSync(this.options.path))
198
+ return [];
199
+ return parseQueryLog(readFileSync(this.options.path, 'utf8'));
200
+ }
201
+ catch {
202
+ return [];
203
+ }
204
+ }
205
+ /**
206
+ * 追加一条(有界:越界丢最旧)。**写失败会抛**——由调用方兜住:
207
+ * 检索路径上的日志失败绝不能改变检索结果。
208
+ * @returns 写入后的完整日志(调用方可直接用,不必重读)。
209
+ */
210
+ appendQuery(input) {
211
+ const next = appendQueryEntry(this.readLog(), { ...input, at: input.at ?? this.now() }, this.limit);
212
+ mkdirSync(dirname(this.options.path), { recursive: true });
213
+ writeFileSync(this.options.path, serializeQueryLog(next));
214
+ return next;
215
+ }
216
+ /** 聚合摘要(空命中与去重查询列表)。 */
217
+ summarize(limit) {
218
+ return summarizeQueryLog(this.readLog(), limit);
219
+ }
220
+ }
package/dist/routes.js CHANGED
@@ -135,6 +135,8 @@ export function mountMemoryApi(ctx) {
135
135
  ...filter?.namespace !== undefined && typeof filter.namespace === 'string' ? { namespace: filter.namespace } : {},
136
136
  ...filter?.status !== undefined && typeof filter.status === 'string' ? { status: filter.status } : {},
137
137
  ...filter?.injected !== undefined && typeof filter.injected === 'boolean' ? { injected: filter.injected } : {},
138
+ // 隔离过滤(2026-09-15):引擎缺省排除隔离记录,面板要显式带 true 才取得到隔离区
139
+ ...filter?.quarantined !== undefined && typeof filter.quarantined === 'boolean' ? { quarantined: filter.quarantined } : {},
138
140
  }, cwdOf(record));
139
141
  },
140
142
  'reload': async (payload) => {
package/dist/self.d.ts CHANGED
@@ -1 +1,2 @@
1
+ export declare const SELF_VERSION: string;
1
2
  export declare const SELF_DESCRIPTION: string;
package/dist/self.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * 版本号动态读取 package.json("只跟随 dsh-memory 组件发版变动")。
5
5
  */
6
6
  import { createRequire } from 'node:module';
7
- const SELF_VERSION = (() => {
7
+ export const SELF_VERSION = (() => {
8
8
  try {
9
9
  // dist/index.js 位于包根 package.json 同级(../package.json)
10
10
  const require = createRequire(import.meta.url);
@@ -15,12 +15,16 @@ const SELF_VERSION = (() => {
15
15
  return 'unknown';
16
16
  }
17
17
  })();
18
- export const SELF_DESCRIPTION = `[记忆系统自述] 当前环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
19
- + '工作机制:①记忆以明文 JSON 存储(global→$DSH_HOME/storages、工作区→会话工作区 .dsh/storages 随 git 分享);'
20
- + '②模型写入即 suggested(待审核),仅人工审核(memory_confirm)后才 approved;'
21
- + '③每轮注入 system prompt 的 = 「global 的 approved + injected」+「当前会话工作区的 approved + injected」,逐条为单行摘要(内容全文靠 memory_search 检索),并受注入预算限制(超预算按最近更新优先,省略条数在面板「注入预览」可见;常驻注入由用户控制,审核≠注入);'
22
- + '④工作区记忆按会话工作区路由(存储跟随仓库),memory_search 为 BM25 检索(中文 2-gram + 关键词加权,可配置语义融合),可检索任意状态(含待审核条目,可先评估再引用);'
23
- + '⑤修正过时内容用 memory_update(改动重置待审核,注入开关保留)。'
24
- + `可用记忆工具共 6 个:memory_save / memory_list / memory_search / memory_confirm / memory_forget / memory_update。`
25
- + '使用建议:相关历史上下文先 memory_search 检索再引用;新习惯/约定用 memory_save 写入(落在待审核)。'
26
- + '(中/英文同义:This environment has a cross-session memory service; write suggestions via memory_save, retrieve via memory_search, and expect human review before approved.)';
18
+ export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
19
+ + '你有 6 个记忆工具:memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget。'
20
+ + '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
21
+ + '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
22
+ + '② 用户纠正了你的做法或理解——把正确结论记下来;'
23
+ + '③ 得出跨会话仍然有用的结论、路径或坑,而非本次任务的临时细节。'
24
+ + '**不要记**:临时状态、未经验证的推测、能从代码或文档直接读到的事实(那类记「去哪查」即可)。'
25
+ + '**怎么写**:一条一件事,并给多角度关键词(同义词、缩写、中英),否则将来检索不到它。'
26
+ + '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale,不会再被当成仍然正确。'
27
+ + '写入即生效(`approved`),人工只在例外时介入;命中密钥/凭据规则的写入会被隔离(不进注入与检索)。'
28
+ + '每轮注入的是「global + 当前会话工作区」里已生效且开着常驻开关的记忆摘要(受预算限制,超预算按最近更新优先);全文要靠 memory_search。'
29
+ + '发现某条记忆与实际不符时用 memory_update 修正(保持生效);确认没用的用 memory_forget。'
30
+ + '(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.)';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@max-null/dsh-memory",
3
- "version": "0.6.1",
4
- "description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, human-owned, no embeddings",
3
+ "version": "0.7.1",
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": {
7
7
  "access": "public"
@@ -22,7 +22,9 @@
22
22
  "client.js",
23
23
  "cordis.patch.yml",
24
24
  "README.md",
25
- "LICENSE", "docs/shots"],
25
+ "LICENSE",
26
+ "docs/shots"
27
+ ],
26
28
  "dsh": {
27
29
  "bundle": {
28
30
  "patch": "./cordis.patch.yml"