dsh-memory-trigger 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-memory-trigger contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,206 @@
1
- # Temporary Holding Version
1
+ # dsh-memory-trigger
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > 上下文被压缩后,模型会"忘记"较早的对话;但会话事件日志一直都在。
4
+ > 这个插件在你说出"你忘了 / 我说过 / 那个 / 之前说的"这类话时,**自己**去翻日志,
5
+ > 把带时间戳的原文片段塞回模型眼前——不需要模型想起来,也不需要它主动调工具。
6
+
7
+ ## 它解决什么
8
+
9
+ DSH 的上下文压缩只影响模型「眼前」的窗口,不删除会话事件日志(`~/.dsh/sessions/` 下的 zstd JSONL)。
10
+ 所以"忘记"是可见性问题,不是存储问题。本插件把可见性补回来。
11
+
12
+ ## 工作方式
13
+
14
+ ```
15
+ 用户消息
16
+ → [agent/inbox/claimed] 纯程序判定
17
+ ① 回忆关键词:「你忘了 / 我说过 / 之前提过 / 记得吗 / 你答应过 …」
18
+ ② 指代:「那个 / 那次 / 上次 / 这事 / 前几天 …」
19
+ ③ 实体缺失:消息里的英文词/引号词/路径在当前上下文没有,但历史里有
20
+ → 本地索引检索(BM25 + 中文 bigram,毫秒级,无网络、无 embedding)
21
+ → [agent/pre-step] 追加一条 user 消息,内容是带时间戳的回忆片段
22
+ → 模型拿着原文回答,不再"没印象"
23
+ ```
24
+
25
+ 注入示例:
26
+
27
+ ```text
28
+ [记忆检索] 用户可能在引用过去的对话(触发:keyword/我说过)。从会话历史检索到 2 条相关片段:
29
+ 1. 2026-11-03 22:30(5 天前)· 我说过 · seq 10650
30
+ 「Ghidra 配好了……Ghidra 12.1.4 … REA 只认 12.1.x…」
31
+ 2. 2026-11-03 22:32(5 天前)· 我说过 · seq 10666
32
+ 「REA 的 MCP 配置已就位,下次重启 DSH 后 mcp__rea__* 工具就能用…」
33
+ 请结合上述原文回答;需要更多细节时可用 session_event_read 读对应 seq,不要笼统说"记不清"。
34
+ ```
35
+
36
+ ## 注入消息的来源(v4 格式硬要求)
37
+
38
+ 会话格式 v3 起(本机已是 v4)**只认「生产者自己的 kind」**:注入消息的 `source` 不能再用
39
+ `{ kind: 'plugin', plugin: 'xxx' }` 这种 v3 包装,否则宿主会在写入 / 校验时直接抛
40
+
41
+ ```
42
+ format v4 message requires a producer-owned source kind
43
+ ```
44
+
45
+ 整轮运行失败(用户侧看到「本轮运行失败 …」)。所以这里固定写自家 kind,与 dsh-heart
46
+ (`kind: 'dsh-heart'`)保持一致:
47
+
48
+ ```js
49
+ const message = createUserMessage({
50
+ content: [{ type: 'text', text: item.text }],
51
+ source: { kind: 'memory-trigger' },
52
+ });
53
+ ```
54
+
55
+ 注意:`node_modules/dsh-memory-trigger` 是 `file:` 依赖的拷贝,改完 `plugins/` 里的源码要重新
56
+ `pnpm add file:./plugins/dsh-memory-trigger`(或直接改拷贝)并重启 DSH 才生效。
57
+
58
+ ## 安装
59
+
60
+ 从 npm 安装(推荐):
61
+
62
+ ```bash
63
+ dsh plugin --profile <你的 profile> add dsh-memory-trigger
64
+ # 然后把 "dsh-memory-trigger" 加进该 profile package.json 的 dsh.profile.bundles,并重启 DSH
65
+ ```
66
+
67
+ 从源码安装:
68
+
69
+ ```bash
70
+ cd ~/.dsh/profiles/<你的 profile>
71
+ pnpm add file:/path/to/dsh-memory-trigger
72
+ # package.json 的 dsh.profile.bundles 里要有 "dsh-memory-trigger"
73
+ ```
74
+
75
+ 改完重启 DSH 生效(宿主插件在启动时加载)。
76
+
77
+ ### 首次回填历史
78
+
79
+ 实时消息会自动入库;但**插件安装前**的历史需要回填一次:
80
+
81
+ ```bash
82
+ node plugins/dsh-memory-trigger/lib/backfill.mjs
83
+ ```
84
+
85
+ 需要支持 zstd 的 Node(>= 22.15 或 24 系列)。DSH 自带运行时若不含 zstd,用系统 Node 24 执行:
86
+
87
+ ```bash
88
+ node plugins/dsh-memory-trigger/lib/backfill.mjs
89
+ ```
90
+
91
+ 回填产物在 `~/.dsh/memory-trigger/events/<sessionId>.jsonl`,一行一条:
92
+
93
+ ```json
94
+ {"session":"…","seq":10650,"ts":1793706600000,"role":"assistant","text":"…"}
95
+ ```
96
+
97
+ ## 配置(`cordis.patch.yml` 的 `dsh-memory-trigger` 条目)
98
+
99
+ ```yaml
100
+ - id: dsh-memory-trigger
101
+ name: 'dsh-memory-trigger'
102
+ config:
103
+ enabled: true
104
+ keywords: ['你忘', '我说过', '之前提过', '还记得', '你答应过']
105
+ anaphora: ['那个', '那次', '上次', '这事', '前几天']
106
+ timeAnaphora: # 时间指代正则;留空 = 用内置表
107
+ - '\\d{1,3}\\s*天前'
108
+ - '昨天'
109
+ - '\\d{1,2}\\s*月\\s*\\d{1,2}\\s*[日号]'
110
+ - '星期[一二三四五六日天1-7]'
111
+ - '(?:下午|晚上)?\\s*\\d{1,2}\\s*[点时]'
112
+ entityTrigger: true
113
+ includeToolResults: true
114
+ topK: 3 # 每条消息最多注入几条片段
115
+ maxChars: 1200 # 注入总字数上限
116
+ minScore: 1.2 # BM25 阈值,低于此不注入
117
+ scope: all # all = 全部历史会话(默认);session = 只搜当前会话
118
+ groupByProject: true # 跨项目时按项目分组标注
119
+ logTriggers: true
120
+ ```
121
+
122
+ 所有字段都有默认值,不写也能跑。改了配置重启 DSH 生效。
123
+
124
+ ## 拦截关键词(默认值)
125
+
126
+ **回忆关键词**(命中必搜):
127
+ `你忘` `忘了` `我说过` `你说过` `之前说过` `之前提过` `提过` `记得吗` `还记得` `你答应过` `上次说的` `上次那个` `之前那个` `前面说的` `刚才说的` `回忆一下`
128
+
129
+ **指代词**(命中后搜索,分数够才注入):
130
+ `那个` `那次` `这事` `这个事` `那件事` `上次` `之前` `前几天` `那天` `当时` `你说的` `我说过的` `接着说` `继续那个`
131
+
132
+ **时间指代**(`timeAnaphora`,命中后解析成时间窗):
133
+ `3天前` `三天前` `昨天` `前天` `大前天` `今天` `11月3日` `星期三` `礼拜天` `3点` `下午3点半` `上周` `上个月` `最近7天` `今年` `去年` `本月` …
134
+
135
+ 解析结果会作为检索的 `since/until` 过滤条件,只搜那个时间窗内的事件;注入头部会标注「时间窗:2026-11-03」。
136
+ 解析失败或窗口内无结果时,会自动放开时间窗重搜一次,不会因为时间误差而完全没结果。
137
+
138
+ | 说法 | 解析成 |
139
+ |---|---|
140
+ | 3 天前 / 三天前 | 那一天 00:00–24:00 |
141
+ | 昨天 / 前天 / 大前天 | 对应自然日 |
142
+ | 11 月 3 日 | 那一天(未来日期自动算去年) |
143
+ | 星期三 / 礼拜天 / 周5 | 最近一个该星期几 |
144
+ | 3 点 / 下午 3 点半 | 对应时刻 ±1 小时 |
145
+ | 上周 / 上个月 / 去年 / 今年 / 本月 | 对应区间 |
146
+ | 最近 N 天 | N 天前至今 |
147
+
148
+ **实体缺失**(`entityTrigger`):消息里的英文词、引号词、文件路径,在当前上下文没有但历史索引里有时触发。
149
+
150
+ ## 索引内容
151
+
152
+ | 事件类型 | 是否入索引 | 注入时标注 |
153
+ |---|---|---|
154
+ | `user/message`(你说的) | ✅ | 你说过 |
155
+ | `assistant/message`(我说的) | ✅ | 我说过 |
156
+ | `tool/result`(工具输出) | ✅ 默认开启,只留前 4000 字 | 工具结果 |
157
+
158
+ 工具输出很长,默认截断到 4000 字参与索引;不想要可在配置里 `includeToolResults: false` 关掉。
159
+
160
+ ## 数据文件
161
+
162
+ | 路径 | 内容 |
163
+ |---|---|
164
+ | `~/.dsh/memory-trigger/events/*.jsonl` | 明文事件索引(回填 + 实时追加) |
165
+ | `~/.dsh/memory-trigger/trigger.log.jsonl` | 每次触发的记录(时间、触发词、命中条数) |
166
+
167
+ ## 自测
168
+
169
+ ```bash
170
+ node plugins/dsh-memory-trigger/lib/selftest.mjs
171
+ ```
172
+
173
+ 会在真实历史上跑几条查询,打印命中片段和耗时(当前索引约 2300 条事件,查询 1–10ms)。
174
+
175
+ ## 已知限制
176
+
177
+ - 触发是**字面匹配**:完全绕开关键词的暗示可能漏;开了 `entityTrigger` 后覆盖大部分情况
178
+ - 注入是"给模型看原文",不保证它一定引用;提示语里已要求"不要笼统说记不清"
179
+ - 会话日志格式(`session.v4`)随 DSH 升级可能变;若 `history.` 读不到,插件会 log warn 并退化为"只捕获实时消息"
180
+ - 注入片段占上下文预算,默认上限 1200 字;长会话可适当调小 `topK`
181
+ - 跨会话检索(`scope: all`)默认开启:结果可能来自别的项目,注入时会按项目分组标注;只想看当前会话就设 `scope: session`
182
+
183
+ ## 日志
184
+
185
+ `trigger.log.jsonl` 同时记录命中和**未命中**(`hit: 0, miss: true`),可用来调词典和阈值:
186
+
187
+ ```json
188
+ {"at":"2026-11-08T06:30:00Z","kind":"keyword","trigger":"我说过","query":"…","hits":3}
189
+ {"at":"2026-11-08T06:31:00Z","kind":"anaphora","trigger":"那个","query":"…","hits":0,"miss":true}
190
+ ```
191
+
192
+ ## 开发注意(血泪教训)
193
+
194
+ 1. **改插件前先读当前 DSH 的最新开发文档 / 源码**(DSH 安装目录下的 `resources/app.asar/dsh/`),不要凭旧版本的记忆写。
195
+ 2. **会话格式以宿主的 `SESSION_FORMAT_VERSION` 为准**。v4 起注入消息的 `source.kind` 必须是"生产者自己的 kind";
196
+ 旧写法 `{ kind: 'plugin', plugin: 'xxx' }` 会被宿主拒绝并**让整轮运行失败**。
197
+ 本插件已用 `dshSession.SESSION_FORMAT_VERSION` 自动选择写法。
198
+ 3. **插件必须 fail-soft**:注入/判定逻辑全部包在 try/catch 里,出错只记 warn、跳过本次,绝不能让用户的当轮对话失败。
199
+ 4. **必须在真实宿主里验证**:离线 smoke(`lib/smoke.mjs`)只验证逻辑,不能证明宿主接受这条消息;
200
+ 至少要重启一次 DSH,发一条会触发注入的消息,确认能正常往返。
201
+
202
+ ## 后续可做
203
+
204
+ - 语义召回(embedding)作为 BM25 的补充
205
+ - 词典热更新(改配置不用重启)
206
+ - 未命中统计与自动调词(按 `trigger.log.jsonl` 分析)
@@ -0,0 +1,7 @@
1
+ # dsh-memory-trigger bundle patch
2
+ #
3
+ # 插入记忆触发插件:监听 agent/inbox/claimed 判定「用户在引用旧记忆」,
4
+ # 检索本地会话事件索引,并在 agent/pre-step 时把带时间戳的回忆片段注入本轮上下文。
5
+ - insert:
6
+ - id: dsh-memory-trigger
7
+ name: 'dsh-memory-trigger'
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 一次性回填:把会话历史(zstd 压缩的 JSONL)解压成明文事件索引。
3
+ *
4
+ * 用法(需要支持 zstd 的 Node,>= 22.15 或 24 系列):
5
+ * node lib/backfill.mjs
6
+ * node lib/backfill.mjs --store C:\path\to\store
7
+ *
8
+ * 产物:<store>/events/<sessionId>.jsonl,一行一条 {session,seq,ts,role,text}
9
+ * 只抽取 user/message 与 assistant/message 的正文;系统注入的噪音(心跳摘要、
10
+ * 运行时上下文)和插件消息会被跳过。
11
+ */
12
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
13
+ import { basename, join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { zstdDecompressSync } from 'node:zlib';
16
+
17
+ const args = process.argv.slice(2);
18
+ function argOf(name, fallback = '') {
19
+ const i = args.indexOf(name);
20
+ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
21
+ }
22
+
23
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
24
+ const sessionsDir = join(dshHome, 'sessions');
25
+ const storeDir = argOf('--store', join(dshHome, 'memory-trigger'));
26
+ const eventsDir = join(storeDir, 'events');
27
+
28
+ const NOISE_PREFIXES = ['Current runtime context', '[dsh-heart]', '眼前的对话快放不下'];
29
+
30
+ function isNoise(text) {
31
+ const t = text.trimStart();
32
+ return NOISE_PREFIXES.some((p) => t.startsWith(p));
33
+ }
34
+
35
+ function toText(content) {
36
+ if (typeof content === 'string') return content;
37
+ if (!Array.isArray(content)) return '';
38
+ const parts = [];
39
+ for (const p of content) if (p && p.type === 'text' && typeof p.text === 'string') parts.push(p.text);
40
+ return parts.join('\n');
41
+ }
42
+
43
+ function walk(dir, out = []) {
44
+ let entries;
45
+ try {
46
+ entries = readdirSync(dir, { withFileTypes: true });
47
+ } catch {
48
+ return out;
49
+ }
50
+ for (const e of entries) {
51
+ const p = join(dir, e.name);
52
+ if (e.isDirectory()) walk(p, out);
53
+ else if (e.name.endsWith('.jsonl.zstd')) out.push(p);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ mkdirSync(eventsDir, { recursive: true });
59
+ const files = walk(sessionsDir);
60
+ let total = 0;
61
+ const summary = [];
62
+
63
+ for (const f of files) {
64
+ let lines;
65
+ try {
66
+ lines = zstdDecompressSync(readFileSync(f)).toString('utf8').split('\n');
67
+ } catch (error) {
68
+ summary.push(`${f}: 解压失败 ${String(error)}`);
69
+ continue;
70
+ }
71
+ const sessionId = f.split(/[\\/]/).slice(-2, -1)[0] || 'unknown';
72
+ const out = [];
73
+ let project = '';
74
+ for (const line of lines) {
75
+ if (!line.trim()) continue;
76
+ let ev;
77
+ try {
78
+ ev = JSON.parse(line);
79
+ } catch {
80
+ continue;
81
+ }
82
+ if (ev.type === 'session') {
83
+ // 会话头里有 cwd:用它给每条事件标上项目
84
+ const cwd = ev.cwd ?? ev.data?.cwd ?? '';
85
+ if (cwd) project = basename(String(cwd)) || String(cwd);
86
+ continue;
87
+ }
88
+ if (ev.type === 'user/message') {
89
+ const text = toText(ev.data?.content);
90
+ const srcKind = ev.data?.source?.kind;
91
+ if (text && (srcKind === undefined || srcKind === 'user') && !isNoise(text))
92
+ out.push({ session: sessionId, project, seq: ev.seq, ts: ev.time, role: 'user', text });
93
+ } else if (ev.type === 'assistant/message') {
94
+ const text = toText(ev.data?.message?.content) || String(ev.data?.text ?? '');
95
+ if (text) out.push({ session: sessionId, project, seq: ev.seq, ts: ev.time, role: 'assistant', text });
96
+ } else if (ev.type === 'tool/result') {
97
+ const text = toText(ev.data?.message?.content).slice(0, 4000);
98
+ if (text) out.push({ session: sessionId, project, seq: ev.seq, ts: ev.time, role: 'tool', text });
99
+ }
100
+ }
101
+ if (out.length === 0) continue;
102
+ writeFileSync(join(eventsDir, `${sessionId}.jsonl`), out.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8');
103
+ total += out.length;
104
+ summary.push(`${sessionId}: ${out.length} 条(${(statSync(f).size / 1048576).toFixed(1)} MB 源文件)`);
105
+ }
106
+
107
+ console.log(`回填完成:${files.length} 个会话文件,${total} 条事件 → ${eventsDir}`);
108
+ for (const s of summary) console.log(' ' + s);
package/lib/index.js ADDED
@@ -0,0 +1,481 @@
1
+ /**
2
+ * dsh-memory-trigger — 记忆触发插件
3
+ *
4
+ * 目的:上下文压缩后,较早的对话会从模型「眼前」消失,但会话事件日志一直都在。
5
+ * 当用户消息看起来在引用旧记忆时,本插件自己检索日志,把带时间戳的原文片段
6
+ * 注入本轮上下文,模型直接拿着原文回答,不必再"没印象"。
7
+ *
8
+ * 触发(纯程序判断,不依赖模型):
9
+ * 1. 回忆关键词:「你忘了 / 我说过 / 之前提过 / 还记得吗 / 你答应过 …」
10
+ * 2. 指代:「那个 / 那次 / 上次 / 这事 / 前几天 …」
11
+ * 3. 实体缺失:消息里的专有名词 / 英文词 / 引号词 / 路径,在当前上下文里没出现,
12
+ * 但历史索引里搜得到 → 判定为引用旧记忆
13
+ *
14
+ * 注入:agent/pre-step 时追加一条 user 角色消息(带时间戳与 seq),
15
+ * 每轮最多一次、最多 topK 条、总字数受 maxChars 限制。
16
+ */
17
+ import { appendFile, mkdir, readFile, readdir } from 'node:fs/promises';
18
+ import { basename, join } from 'node:path';
19
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
20
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
21
+ import * as dshSession from '@deepseek-ai/dsh-session';
22
+ import z from '@deepseek-ai/schemastery';
23
+ import { createIndex } from './search.js';
24
+
25
+ export const name = 'memory-trigger';
26
+
27
+ /** 宿主会话格式版本:v4 起注入消息只接受「生产者自己的 kind」。 */
28
+ const SESSION_FORMAT = typeof dshSession.SESSION_FORMAT_VERSION === 'number' ? dshSession.SESSION_FORMAT_VERSION : 0;
29
+
30
+ const DEFAULT_KEYWORDS = [
31
+ '你忘', '忘了', '我说过', '你说过', '之前说过', '之前提过', '提过', '记得吗', '还记得',
32
+ '你答应过', '上次说的', '上次那个', '之前那个', '前面说的', '刚才说的', '回忆一下',
33
+ ];
34
+ const DEFAULT_ANAPHORA = [
35
+ '那个', '那次', '这事', '这个事', '那件事', '上次', '之前', '前几天', '那天', '当时',
36
+ '你说的', '我说过的', '接着说', '继续那个',
37
+ ];
38
+ /**
39
+ * 时间指代(正则):命中后解析成时间窗,只检索该窗口内的事件。
40
+ * 例:3天前 / 11月3日 / 星期三 / 昨天 / 上周 / 上个月 / 3点 / 下午3点
41
+ */
42
+ const DEFAULT_TIME_PATTERNS = [
43
+ '大前天', '前天', '昨天', '昨日', '今天', '今日',
44
+ '\\d{1,3}\\s*天前', '[一二两三四五六七八九十]{1,3}\\s*天前',
45
+ '\\d{1,2}\\s*周前', '[一二两三四五六七八九十]{1,2}\\s*周前',
46
+ '\\d{1,3}\\s*个月前', '[一二两三四五六七八九十]{1,3}\\s*个月前',
47
+ '最近\\s*\\d{1,2}\\s*天', '前\\s*\\d{1,2}\\s*天',
48
+ '上周', '上星期', '上个?月', '去年', '今年', '本月', '这个月',
49
+ '星期[一二三四五六日天1-7]', '礼拜[一二三四五六日天1-7]', '周[一二三四五六日天1-7]',
50
+ '\\d{1,2}\\s*月\\s*\\d{1,2}\\s*[日号]',
51
+ '(?:上午|下午|晚上|早上|中午)?\\s*\\d{1,2}\\s*[点时](?:\\s*半|\\s*\\d{1,2}\\s*分)?',
52
+ ];
53
+ const NOISE_PREFIXES = ['Current runtime context', '[dsh-heart]', '眼前的对话快放不下'];
54
+
55
+ function isNoise(text) {
56
+ const t = text.trimStart();
57
+ return NOISE_PREFIXES.some((p) => t.startsWith(p));
58
+ }
59
+
60
+ export const Config = z.object({
61
+ enabled: z.boolean().default(true).description('总开关。'),
62
+ keywords: z.array(z.string()).default(DEFAULT_KEYWORDS).description('回忆关键词(子串匹配)。'),
63
+ anaphora: z.array(z.string()).default(DEFAULT_ANAPHORA).description('指代词(子串匹配)。'),
64
+ timeAnaphora: z.array(z.string()).default(DEFAULT_TIME_PATTERNS).description('时间指代正则:命中后解析成时间窗,只搜该窗口内的事件。'),
65
+ entityTrigger: z.boolean().default(true).description('实体缺失触发:消息里的专有名词/英文词在历史里有、当前上下文没有时触发。'),
66
+ includeToolResults: z.boolean().default(true).description('把 tool/result 也纳入索引(只留前 4000 字)。'),
67
+ topK: z.number().step(1).min(1).max(8).default(3).description('每次注入的最大片段数。'),
68
+ maxChars: z.number().step(1).min(200).max(4000).default(1200).description('注入内容总字数上限。'),
69
+ minScore: z.number().min(0).default(1.2).description('BM25 最低分,低于此不注入。'),
70
+ scope: z.union([z.const('session'), z.const('all')]).default('all').description('检索范围:all = 全部历史会话(默认);session = 只搜当前会话。'),
71
+ groupByProject: z.boolean().default(true).description('跨项目检索时按项目分组展示。'),
72
+ storeDir: z.string().default('').description('索引目录;留空 = <DSH_HOME>/memory-trigger。'),
73
+ logTriggers: z.boolean().default(true).description('把每次触发写入 trigger.log.jsonl。'),
74
+ });
75
+
76
+ function toText(content) {
77
+ if (typeof content === 'string') return content;
78
+ if (!Array.isArray(content)) return '';
79
+ const parts = [];
80
+ for (const p of content) if (p && p.type === 'text' && typeof p.text === 'string') parts.push(p.text);
81
+ return parts.join('\n');
82
+ }
83
+
84
+ function extractEntities(text) {
85
+ const out = new Set();
86
+ for (const m of text.matchAll(/[「"“]([^「」"”]{2,40})[」"”]/g)) out.add(m[1].trim());
87
+ for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9_.\-]{2,}/g)) out.add(m[0]);
88
+ for (const m of text.matchAll(/[A-Za-z]:\\[^\s,。;]+/g)) out.add(m[0]);
89
+ return [...out].filter((s) => s.length >= 2);
90
+ }
91
+
92
+ function fmtTime(ts) {
93
+ const d = new Date(ts);
94
+ const pad = (n) => String(n).padStart(2, '0');
95
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
96
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
97
+ const days = Math.floor((Date.now() - ts) / 86400000);
98
+ const rel = days <= 0 ? '今天' : days === 1 ? '昨天' : `${days} 天前`;
99
+ return `${date} ${time}(${rel})`;
100
+ }
101
+
102
+ const ROLE_LABEL = { user: '你说过', assistant: '我说过', tool: '工具结果' };
103
+
104
+ /** 会话所属项目(取 cwd 的最后一段)。 */
105
+ function projectOf(session) {
106
+ const cwd = session?.header?.cwd ?? session?.cwd ?? '';
107
+ if (!cwd) return '未知项目';
108
+ return basename(String(cwd)) || String(cwd);
109
+ }
110
+
111
+ /**
112
+ * 注入消息的来源标记。
113
+ *
114
+ * 会话格式 v3 起(本机是 v4)只认「生产者自己的 kind」:消息 source 必须是插件自己的名字,
115
+ * 旧的 { kind: 'plugin', plugin } 包装会被直接拒绝
116
+ * (format v4 message requires a producer-owned source kind),整轮运行失败。
117
+ * 所以这里固定用自家 kind,和 dsh-heart(kind: 'dsh-heart')的做法一致。
118
+ */
119
+ function memorySource() {
120
+ // v4 起只接受「生产者自己的 kind」;v3 及更早用旧的 plugin 包装。
121
+ if (SESSION_FORMAT >= 4) return { kind: 'memory-trigger' };
122
+ return { kind: 'plugin', plugin: 'memory-trigger' };
123
+ }
124
+
125
+ export function apply(ctx, config) {
126
+ const conf = config ?? {};
127
+ if (conf.enabled === false) {
128
+ ctx.logger?.info?.('dsh-memory-trigger: disabled');
129
+ return;
130
+ }
131
+ const storeDir = conf.storeDir || dshHomePath('memory-trigger');
132
+ const eventsDir = join(storeDir, 'events');
133
+ const logPath = join(storeDir, 'trigger.log.jsonl');
134
+ const index = createIndex();
135
+ const pending = new Map(); // agentId -> { text, at }
136
+ let loaded = false;
137
+
138
+ const logger = {
139
+ info: (m) => ctx.logger?.info?.('dsh-memory-trigger: ' + m),
140
+ warn: (m) => ctx.logger?.warn?.('dsh-memory-trigger: ' + m),
141
+ };
142
+
143
+ async function loadStore() {
144
+ try {
145
+ await mkdir(eventsDir, { recursive: true });
146
+ const files = await readdir(eventsDir);
147
+ for (const f of files) {
148
+ if (!f.endsWith('.jsonl')) continue;
149
+ const raw = await readFile(join(eventsDir, f), 'utf8');
150
+ for (const line of raw.split('\n')) {
151
+ if (!line.trim()) continue;
152
+ try {
153
+ const ev = JSON.parse(line);
154
+ index.add({ session: ev.session, project: ev.project, seq: ev.seq, ts: ev.ts, role: ev.role, text: ev.text });
155
+ } catch {
156
+ /* 跳过坏行 */
157
+ }
158
+ }
159
+ }
160
+ loaded = true;
161
+ logger.info(`index ready: ${index.size} events from ${files.length} files`);
162
+ } catch (error) {
163
+ logger.warn(`loadStore failed: ${String(error)}`);
164
+ }
165
+ }
166
+
167
+ function appendEvent(sessionId, ev) {
168
+ const line = JSON.stringify(ev) + '\n';
169
+ appendFile(join(eventsDir, `${sessionId}.jsonl`), line, 'utf8').catch(() => {});
170
+ }
171
+
172
+ // 实时捕获会话事件,增量补进索引(新消息不必等回填)
173
+ ctx.on('session/event', (session, event) => {
174
+ if (!loaded) return;
175
+ const sid = String(session?.id ?? session?.header?.id ?? 'unknown');
176
+ const project = projectOf(session);
177
+ try {
178
+ if (event?.type === 'user/message') {
179
+ const text = toText(event.data?.content);
180
+ const srcKind = event.data?.source?.kind;
181
+ if (!text || isNoise(text) || (srcKind !== undefined && srcKind !== 'user')) return;
182
+ const ev = { session: sid, project, seq: event.seq, ts: event.time, role: 'user', text };
183
+ index.add(ev);
184
+ appendEvent(sid, ev);
185
+ } else if (event?.type === 'assistant/message') {
186
+ const text = toText(event.data?.message?.content) || String(event.data?.text ?? '');
187
+ if (!text) return;
188
+ const ev = { session: sid, project, seq: event.seq, ts: event.time, role: 'assistant', text };
189
+ index.add(ev);
190
+ appendEvent(sid, ev);
191
+ } else if (event?.type === 'tool/result' && conf.includeToolResults !== false) {
192
+ // 工具输出很长,只留前 4000 字参与索引,避免淹没对话
193
+ const text = toText(event.data?.message?.content).slice(0, 4000);
194
+ if (!text) return;
195
+ const ev = { session: sid, project, seq: event.seq, ts: event.time, role: 'tool', text };
196
+ index.add(ev);
197
+ appendEvent(sid, ev);
198
+ }
199
+ } catch (error) {
200
+ logger.warn(`capture failed: ${String(error)}`);
201
+ }
202
+ });
203
+
204
+ // 判定:用户消息是否在引用旧记忆
205
+ ctx.on('agent/inbox/claimed', ({ agent, message }) => {
206
+ if (!loaded || !agent?.id) return;
207
+ try {
208
+ const text = toText(message?.content);
209
+ if (!text || text.length < 4) return;
210
+ const sessionId = String(agent?.session?.id ?? agent?.id);
211
+ const hits = triggerReason(text, conf, index, sessionId);
212
+ if (!hits) return;
213
+ const searchOpts = {
214
+ topK: conf.topK ?? 3,
215
+ minScore: conf.minScore ?? 1.2,
216
+ sessionId: (conf.scope ?? 'session') === 'session' ? sessionId : null,
217
+ since: hits.since ?? null,
218
+ until: hits.until ?? null,
219
+ };
220
+ let results = index.search(hits.query || text, searchOpts);
221
+ if (results.length === 0 && searchOpts.sessionId) {
222
+ // 当前会话没搜到:退一步搜全部历史会话(时间窗仍保留)
223
+ results = index.search(hits.query || text, { ...searchOpts, sessionId: null });
224
+ }
225
+ if (results.length === 0 && (searchOpts.since || searchOpts.until)) {
226
+ // 时间窗内没有:放开时间窗再搜一次,避免时间解析误差导致完全没结果
227
+ results = index.search(hits.query || text, { ...searchOpts, sessionId: null, since: null, until: null });
228
+ }
229
+ if (results.length === 0) {
230
+ // 未命中也要记日志,方便事后调词典/阈值
231
+ if (conf.logTriggers !== false) {
232
+ appendFile(
233
+ logPath,
234
+ JSON.stringify({ at: new Date().toISOString(), agent: agent.id, session: sessionId, kind: hits.kind, trigger: hits.trigger, query: hits.query || text, hits: 0, miss: true }) + '\n',
235
+ 'utf8',
236
+ ).catch(() => {});
237
+ }
238
+ if (hits.kind !== 'keyword') return; // 指代/实体/时间没搜到就不打扰
239
+ pending.set(agent.id, { text: '[记忆检索] 没找到相关记录。可以换关键词用 session_event_search 再找一次,再回答。', at: Date.now() });
240
+ return;
241
+ }
242
+ pending.set(agent.id, { text: formatBlock(results, hits, conf), at: Date.now() });
243
+ if (conf.logTriggers !== false) {
244
+ appendFile(
245
+ logPath,
246
+ JSON.stringify({ at: new Date().toISOString(), agent: agent.id, session: sessionId, kind: hits.kind, trigger: hits.trigger, query: hits.query || text, hits: results.length }) + '\n',
247
+ 'utf8',
248
+ ).catch(() => {});
249
+ }
250
+ } catch (error) {
251
+ // 判定阶段失败只记日志,不影响本轮对话
252
+ logger.warn(`trigger skipped: ${String(error)}`);
253
+ }
254
+ });
255
+
256
+ // 注入:进入新一轮时,把回忆片段作为一条 user 消息追加
257
+ ctx.on('agent/pre-step', async (payload, next) => {
258
+ const decision = await next();
259
+ try {
260
+ const { agent } = payload;
261
+ if (decision.kind !== 'enter' || !agent?.id) return decision;
262
+ const item = pending.get(agent.id);
263
+ if (!item) return decision;
264
+ pending.delete(agent.id);
265
+ if (Date.now() - item.at > 120000) return decision; // 太久没轮到的丢弃
266
+ const message = createUserMessage({
267
+ content: [{ type: 'text', text: item.text }],
268
+ source: memorySource(),
269
+ });
270
+ return { ...decision, messages: [...decision.messages, message] };
271
+ } catch (error) {
272
+ // 关键:注入失败只跳过,绝不让整轮运行失败
273
+ logger.warn(`inject skipped: ${String(error)}`);
274
+ return decision;
275
+ }
276
+ });
277
+
278
+ loadStore();
279
+ }
280
+
281
+ const CN_DIGITS = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
282
+ const CN_WEEKDAY = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7, 天: 7, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 };
283
+
284
+ function cnNumber(raw) {
285
+ const s = String(raw ?? '').trim();
286
+ if (!s) return NaN;
287
+ if (/^\d+$/.test(s)) return Number(s);
288
+ if (s === '十') return 10;
289
+ const m = s.match(/^([一二两三四五六七八九])?十([一二三四五六七八九])?$/);
290
+ if (m) return (m[1] ? CN_DIGITS[m[1]] : 1) * 10 + (m[2] ? CN_DIGITS[m[2]] : 0);
291
+ return CN_DIGITS[s] ?? NaN;
292
+ }
293
+
294
+ function startOfDay(ts) {
295
+ const d = new Date(ts);
296
+ d.setHours(0, 0, 0, 0);
297
+ return d.getTime();
298
+ }
299
+ function endOfDay(ts) {
300
+ const d = new Date(ts);
301
+ d.setHours(23, 59, 59, 999);
302
+ return d.getTime();
303
+ }
304
+ function fmtDate(ts) {
305
+ const d = new Date(ts);
306
+ const p = (n) => String(n).padStart(2, '0');
307
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
308
+ }
309
+ function dayWindow(ts) {
310
+ return { since: startOfDay(ts), until: endOfDay(ts), label: fmtDate(ts) };
311
+ }
312
+ function shiftDays(base, n) {
313
+ const d = new Date(base);
314
+ d.setDate(d.getDate() + n);
315
+ return d.getTime();
316
+ }
317
+ function shiftMonths(base, n) {
318
+ const d = new Date(base);
319
+ d.setMonth(d.getMonth() + n);
320
+ return d.getTime();
321
+ }
322
+
323
+ /** 把时间指代解析成 [since, until];解析不出返回 null。 */
324
+ export function parseTimeWindow(text, now = Date.now()) {
325
+ let m;
326
+ if (text.match(/大前天/)) return dayWindow(shiftDays(now, -3));
327
+ if (text.match(/前天/)) return dayWindow(shiftDays(now, -2));
328
+ if (text.match(/昨[天日]/)) return dayWindow(shiftDays(now, -1));
329
+ if (text.match(/今[天日]/)) return dayWindow(now);
330
+ if ((m = text.match(/(\d{1,3}|[一二两三四五六七八九十]{1,3})\s*天前/))) {
331
+ const n = cnNumber(m[1]);
332
+ if (n > 0) return dayWindow(shiftDays(now, -n));
333
+ }
334
+ if ((m = text.match(/(\d{1,2}|[一二两三四五六七八九十]{1,2})\s*周前/))) {
335
+ const n = cnNumber(m[1]);
336
+ if (n > 0) return dayWindow(shiftDays(now, -7 * n));
337
+ }
338
+ if ((m = text.match(/(\d{1,3}|[一二两三四五六七八九十]{1,3})\s*个月前/))) {
339
+ const n = cnNumber(m[1]);
340
+ if (n > 0) return dayWindow(shiftMonths(now, -n));
341
+ }
342
+ if ((m = text.match(/最近\s*(\d{1,2}|[一二两三四五六七八九十]{1,2})\s*天/))) {
343
+ const n = cnNumber(m[1]);
344
+ if (n > 0) return { since: startOfDay(shiftDays(now, -n)), until: now, label: `最近 ${n} 天` };
345
+ }
346
+ if ((m = text.match(/前\s*(\d{1,2}|[一二两三四五六七八九十]{1,2})\s*天/))) {
347
+ const n = cnNumber(m[1]);
348
+ if (n > 0) return dayWindow(shiftDays(now, -n));
349
+ }
350
+ if (text.match(/上周|上星期/)) {
351
+ const d = new Date(now);
352
+ const wd = (d.getDay() + 6) % 7; // 周一=0
353
+ const monday = shiftDays(now, -wd - 7);
354
+ return { since: startOfDay(monday), until: endOfDay(shiftDays(monday, 6)), label: '上周' };
355
+ }
356
+ if (text.match(/上个?月/)) {
357
+ const d = new Date(now);
358
+ return {
359
+ since: new Date(d.getFullYear(), d.getMonth() - 1, 1).getTime(),
360
+ until: new Date(d.getFullYear(), d.getMonth(), 0, 23, 59, 59, 999).getTime(),
361
+ label: '上个月',
362
+ };
363
+ }
364
+ if (text.match(/去年/)) {
365
+ const y = new Date(now).getFullYear() - 1;
366
+ return { since: new Date(y, 0, 1).getTime(), until: new Date(y, 11, 31, 23, 59, 59, 999).getTime(), label: `${y} 年` };
367
+ }
368
+ if (text.match(/今年/)) {
369
+ const y = new Date(now).getFullYear();
370
+ return { since: new Date(y, 0, 1).getTime(), until: now, label: `${y} 年至今` };
371
+ }
372
+ if (text.match(/本月|这个月/)) {
373
+ const d = new Date(now);
374
+ return { since: new Date(d.getFullYear(), d.getMonth(), 1).getTime(), until: now, label: '本月' };
375
+ }
376
+ if ((m = text.match(/(?:星期|礼拜|周)\s*([一二三四五六日天1-7])/))) {
377
+ const target = CN_WEEKDAY[m[1]];
378
+ if (target) {
379
+ const d = new Date(now);
380
+ const cur = d.getDay() === 0 ? 7 : d.getDay();
381
+ let back = cur - target;
382
+ if (back < 0) back += 7;
383
+ return dayWindow(shiftDays(now, -back));
384
+ }
385
+ }
386
+ if ((m = text.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]/))) {
387
+ const mon = Number(m[1]) - 1;
388
+ const day = Number(m[2]);
389
+ if (mon >= 0 && mon < 12 && day >= 1 && day <= 31) {
390
+ const y = new Date(now).getFullYear();
391
+ let t = new Date(y, mon, day).getTime();
392
+ if (t > now) t = new Date(y - 1, mon, day).getTime();
393
+ return dayWindow(t);
394
+ }
395
+ }
396
+ if ((m = text.match(/(上午|下午|晚上|早上|中午)?\s*(\d{1,2})\s*[点时](?:\s*半|\s*(\d{1,2})\s*分)?/))) {
397
+ let hour = Number(m[2]);
398
+ let minute = 0;
399
+ if (/半/.test(m[0])) minute = 30;
400
+ else if (m[3]) minute = Number(m[3]);
401
+ if (/下午|晚上/.test(m[0]) && hour < 12) hour += 12;
402
+ if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) {
403
+ const d = new Date(now);
404
+ d.setHours(hour, minute, 0, 0);
405
+ let t = d.getTime();
406
+ if (t > now + 3600000) t -= 86400000;
407
+ return { since: t - 3600000, until: t + 3600000, label: `${fmtDate(t)} 附近` };
408
+ }
409
+ }
410
+ return null;
411
+ }
412
+
413
+ /** 命中时间指代 → 返回触发信息(含时间窗)。 */
414
+ function parseTimeTrigger(text, conf) {
415
+ const patterns = conf.timeAnaphora ?? DEFAULT_TIME_PATTERNS;
416
+ for (const src of patterns) {
417
+ let m = null;
418
+ try {
419
+ m = text.match(new RegExp(src));
420
+ } catch {
421
+ m = null;
422
+ }
423
+ if (!m) continue;
424
+ const win = parseTimeWindow(text);
425
+ if (win) return { trigger: String(m[0]).trim(), ...win };
426
+ }
427
+ return null;
428
+ }
429
+
430
+ /** 判断触发原因,返回 {kind, trigger, query, since?, until?, label?} 或 null */
431
+ function triggerReason(text, conf, index, sessionId) {
432
+ const keywords = conf.keywords ?? DEFAULT_KEYWORDS;
433
+ for (const k of keywords) if (text.includes(k)) return { kind: 'keyword', trigger: k, query: text };
434
+ const time = parseTimeTrigger(text, conf);
435
+ if (time) return { kind: 'time', trigger: time.trigger, query: text, since: time.since, until: time.until, label: time.label };
436
+ const anaphora = conf.anaphora ?? DEFAULT_ANAPHORA;
437
+ const anaphoraHit = anaphora.find((a) => text.includes(a));
438
+ if (anaphoraHit) return { kind: 'anaphora', trigger: anaphoraHit, query: text };
439
+ if (conf.entityTrigger !== false) {
440
+ const ents = extractEntities(text);
441
+ const minScore = conf.minScore ?? 1.2;
442
+ const hits = ents.filter((e) => index.search(e, { topK: 1, minScore, sessionId }).length > 0);
443
+ if (hits.length > 0) return { kind: 'entity', trigger: hits[0], query: hits.join(' ') + ' ' + text };
444
+ }
445
+ return null;
446
+ }
447
+
448
+ function formatBlock(results, hits, conf) {
449
+ const maxChars = conf.maxChars ?? 1200;
450
+ const perItem = Math.max(120, Math.floor((maxChars - 300) / Math.max(1, results.length)));
451
+ const lines = [];
452
+ lines.push(`[记忆检索] 用户可能在引用过去的对话(触发:${hits.kind}/${hits.trigger}${hits.label ? `,时间窗:${hits.label}` : ''})。从会话历史检索到 ${results.length} 条相关片段:`);
453
+ let n = 0;
454
+ const render = (r) => {
455
+ n += 1;
456
+ const body = r.doc.text.replace(/\s+/g, ' ').slice(0, perItem);
457
+ const proj = r.doc.project ? ` · 项目 ${r.doc.project}` : '';
458
+ lines.push(`${n}. ${fmtTime(r.doc.ts)} · ${ROLE_LABEL[r.doc.role] ?? r.doc.role} · seq ${r.doc.seq}${proj}`);
459
+ lines.push(` 「${body}」`);
460
+ };
461
+ if (conf.groupByProject !== false) {
462
+ const groups = new Map();
463
+ for (const r of results) {
464
+ const key = r.doc.project || '未知项目';
465
+ if (!groups.has(key)) groups.set(key, []);
466
+ groups.get(key).push(r);
467
+ }
468
+ if (groups.size > 1) {
469
+ for (const [proj, list] of groups) {
470
+ lines.push(`【项目:${proj}】`);
471
+ for (const r of list) render(r);
472
+ }
473
+ } else {
474
+ for (const r of results) render(r);
475
+ }
476
+ } else {
477
+ for (const r of results) render(r);
478
+ }
479
+ lines.push('请结合上述原文回答;需要更多细节时可用 session_event_read 读对应 seq,不要笼统说"记不清"。');
480
+ return lines.join('\n').slice(0, maxChars + 400);
481
+ }
package/lib/search.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 轻量全文检索:中文 bigram + 英文词,BM25 打分,纯 JS、零依赖。
3
+ * 文档模型:{ session, seq, ts, role, text }
4
+ */
5
+
6
+ const CJK = /[\u3400-\u9fff]/;
7
+
8
+ /** 分词:英文/数字按词,中文按单字 + 双字 bigram。 */
9
+ export function tokenize(text) {
10
+ const out = [];
11
+ const lower = String(text ?? '').toLowerCase();
12
+ const latin = lower.match(/[a-z0-9_][a-z0-9_.\-]{1,}/g) ?? [];
13
+ for (const w of latin) out.push(w);
14
+ const cjkRuns = lower.match(/[\u3400-\u9fff]+/g) ?? [];
15
+ for (const run of cjkRuns) {
16
+ for (let i = 0; i < run.length; i += 1) {
17
+ out.push(run[i]);
18
+ if (i + 1 < run.length) out.push(run.slice(i, i + 2));
19
+ }
20
+ }
21
+ return out;
22
+ }
23
+
24
+ export function createIndex() {
25
+ /** @type {Array<{session:string,seq:number,ts:number,role:string,text:string,tokens:string[],len:number}>} */
26
+ const docs = [];
27
+ /** @type {Map<string, number[]>} token -> doc indexes */
28
+ const postings = new Map();
29
+ /** 去重:同一会话同一 seq 只收一次(跨项目目录重复的会话文件) */
30
+ const seen = new Set();
31
+ let totalLen = 0;
32
+
33
+ function add(doc) {
34
+ if (!doc || typeof doc.text !== 'string') return;
35
+ const text = doc.text.trim();
36
+ if (!text) return;
37
+ const key = `${doc.session}|${doc.seq}|${doc.role}`;
38
+ if (seen.has(key)) return;
39
+ seen.add(key);
40
+ const tokens = tokenize(text);
41
+ if (tokens.length === 0) return;
42
+ const idx = docs.length;
43
+ docs.push({ ...doc, text, tokens, len: tokens.length });
44
+ totalLen += tokens.length;
45
+ for (const t of tokens) {
46
+ let list = postings.get(t);
47
+ if (!list) postings.set(t, (list = []));
48
+ if (list[list.length - 1] !== idx) list.push(idx);
49
+ }
50
+ }
51
+
52
+ function search(query, opts = {}) {
53
+ const topK = opts.topK ?? 5;
54
+ const minScore = opts.minScore ?? 1.2;
55
+ const sessionFilter = opts.sessionId ?? null;
56
+ const since = opts.since ?? null;
57
+ const until = opts.until ?? null;
58
+ const terms = [...new Set(tokenize(query))].filter((t) => t.length > 1 || CJK.test(t));
59
+ if (terms.length === 0 || docs.length === 0) return [];
60
+ const N = docs.length;
61
+ const avgdl = totalLen / N || 1;
62
+ const k1 = 1.2;
63
+ const b = 0.75;
64
+ const scores = new Map();
65
+ for (const term of terms) {
66
+ const list = postings.get(term);
67
+ if (!list) continue;
68
+ const df = list.length;
69
+ const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5));
70
+ for (const idx of list) {
71
+ const doc = docs[idx];
72
+ if (sessionFilter && doc.session !== sessionFilter) continue;
73
+ if (since && doc.ts < since) continue;
74
+ if (until && doc.ts > until) continue;
75
+ let tf = 0;
76
+ for (const t of doc.tokens) if (t === term) tf += 1;
77
+ if (tf === 0) continue;
78
+ const denom = tf + k1 * (1 - b + (b * doc.len) / avgdl);
79
+ scores.set(idx, (scores.get(idx) ?? 0) + idf * ((tf * (k1 + 1)) / denom));
80
+ }
81
+ }
82
+ const ranked = [...scores.entries()]
83
+ .map(([idx, score]) => ({ doc: docs[idx], score }))
84
+ .sort((a, b2) => b2.score - a.score || b2.doc.ts - a.doc.ts);
85
+
86
+ // 去重:同会话里内容高度相似(如 tool/result 与 assistant 复述)只留一条,优先 user 原话
87
+ const picked = [];
88
+ for (const item of ranked) {
89
+ if (picked.length >= topK) break;
90
+ if (item.score < minScore) break;
91
+ const dup = picked.some((p) => p.doc.session === item.doc.session && similarity(p.doc.text, item.doc.text) > 0.7);
92
+ if (dup) {
93
+ const i = picked.findIndex((p) => p.doc.session === item.doc.session && similarity(p.doc.text, item.doc.text) > 0.7);
94
+ if (i >= 0 && item.doc.role === 'user' && picked[i].doc.role !== 'user') picked[i] = item;
95
+ continue;
96
+ }
97
+ picked.push(item);
98
+ }
99
+ return picked;
100
+ }
101
+
102
+ return { add, search, get size() { return docs.length; }, docs };
103
+ }
104
+
105
+ function similarity(a, b) {
106
+ const sa = new Set(tokenize(a));
107
+ const sb = new Set(tokenize(b));
108
+ if (sa.size === 0 || sb.size === 0) return 0;
109
+ let inter = 0;
110
+ for (const t of sa) if (sb.has(t)) inter += 1;
111
+ return inter / (sa.size + sb.size - inter);
112
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "dsh-memory-trigger",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "DSH 记忆触发插件:上下文压缩后,用户引用旧记忆时自动检索会话历史并注入带时间戳的片段。",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./backfill": "./lib/backfill.mjs"
11
+ },
12
+ "files": [
13
+ "lib/index.js",
14
+ "lib/search.js",
15
+ "lib/backfill.mjs",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "dsh",
22
+ "deepseek-harness",
23
+ "plugin",
24
+ "memory",
25
+ "context",
26
+ "bm25",
27
+ "recall"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/G4bri3lYang/dsh-memory-trigger.git"
32
+ },
33
+ "engines": {
34
+ "node": ">=22.15"
35
+ },
36
+ "scripts": {
37
+ "backfill": "node lib/backfill.mjs",
38
+ "selftest": "node lib/selftest.mjs"
39
+ },
40
+ "dsh": {
41
+ "bundle": {
42
+ "patch": "./cordis.patch.yml"
43
+ }
44
+ }
45
+ }