@gezi-wen/dsh-mem 0.5.5
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 +202 -0
- package/NOTICE +8 -0
- package/README.md +180 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1970 -0
- package/lib/index.js +558 -0
- package/lib/typert.host.js +137 -0
- package/package.json +85 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mem — 文件式记忆插件(CC 原生方式)。
|
|
3
|
+
*
|
|
4
|
+
* 记忆存在本地 markdown 文件目录(frontmatter + 正文,4 类),不是数据库。
|
|
5
|
+
* host 侧两件事:
|
|
6
|
+
* 1. 按问题检索注入:system-prompt/assemble 时扫 memory 目录,按双字匹配
|
|
7
|
+
* 选相关文件,读全文注入 system prompt,让 agent 第一轮就「想起」
|
|
8
|
+
* 2. TypertRemoteService:给设置页「记忆管理」提供文件列表/读/写/删
|
|
9
|
+
*
|
|
10
|
+
* 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写
|
|
11
|
+
* memory 目录——透明、可检查、防膨胀。无 worker、无 SQLite、无端口。
|
|
12
|
+
*
|
|
13
|
+
* 旧 worker 版见 git 分支 `sqlite-worker`。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { readFile, readdir, writeFile, rename, unlink, stat } from 'node:fs/promises'
|
|
19
|
+
import { join, basename } from 'node:path'
|
|
20
|
+
|
|
21
|
+
const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem', 'memory')
|
|
22
|
+
const MAX_RESULTS = 5
|
|
23
|
+
const MAX_CHARS_PER_FILE = 1500
|
|
24
|
+
const MAX_BASELINE = 5
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 保留名:设置页的写/删方法一律不得触碰。
|
|
28
|
+
*
|
|
29
|
+
* 列进来的两类文件:
|
|
30
|
+
* 1. MEMORY.md / session-log.md —— 不是记忆条目:不进检索、不进星图、不出现在
|
|
31
|
+
* 设置页列表里,它们是索引与追加型流水(session-log.md 已 130+ KB)。
|
|
32
|
+
* 「+ 添加记忆」表单里手打这两个名字就能整份覆盖,而这个表单是照
|
|
33
|
+
* buildFrontmatter() 重建 frontmatter 的,原有内容一个字都留不下。
|
|
34
|
+
* 2. project_heartscape.md / project_self-cognition.md —— 人格连续性的手写载体
|
|
35
|
+
* (AGENTS.md 指定的落盘目标),单副本、纯人工维护;表单写还会把
|
|
36
|
+
* frontmatter 里的 originSessionId / birthday 等字段一并抹掉。
|
|
37
|
+
*
|
|
38
|
+
* 判定依据是「丢了能不能从别处重建」:memory 目录**不在任何版本控制下**
|
|
39
|
+
* (该目录里没有 .git,`git rev-parse` 直接报 not a git repository),
|
|
40
|
+
* deleteFile 是 unlink、没有回收站,所以宁严勿松。
|
|
41
|
+
* 这几个文件要改,用文件工具直接编辑。
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ 全部小写存放,比较一律走 nameKey()(NTFS 不区分大小写,集合里放 'MEMORY.md'
|
|
44
|
+
* 而拿 'memory.md' 去 has() 是查不到的 —— 曾经就是这个漏洞:表单里填 memory.md
|
|
45
|
+
* 就能整份覆盖 19 KB 的索引,deleteFile 还能不可逆删掉它)。
|
|
46
|
+
*/
|
|
47
|
+
const RESERVED_FILES = new Set([
|
|
48
|
+
'memory.md',
|
|
49
|
+
'session-log.md',
|
|
50
|
+
'project_heartscape.md',
|
|
51
|
+
'project_self-cognition.md',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 非记忆条目:不参与检索 / 不进星图 / 不出现在设置页列表里,读写也一律不通。
|
|
56
|
+
*
|
|
57
|
+
* 是 RESERVED_FILES 的子集,区别在「列不列出来」:MEMORY.md 是索引、
|
|
58
|
+
* session-log.md 是追加型流水(已 390+ KB),两者都不是一条记忆;
|
|
59
|
+
* 而 project_heartscape.md / project_self-cognition.md 是正常记忆条目
|
|
60
|
+
* (要出现在列表与星图里、要能读),只是不许从表单写/删。
|
|
61
|
+
*/
|
|
62
|
+
const NON_ENTRY_FILES = new Set([
|
|
63
|
+
'memory.md',
|
|
64
|
+
'session-log.md',
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 单文件体积上限(字节)。定 512 KB 的理由:
|
|
69
|
+
* - 当前 memory 目录最大的一条正文 19 KB(project_guikit.md),索引与流水都排除在外,
|
|
70
|
+
* 512 KB 是它的 26 倍,任何正常记忆都够用;
|
|
71
|
+
* - 记忆是要塞进 system prompt 的,一条 512 KB 的记忆本身就等于把上下文撑爆——
|
|
72
|
+
* 到这个量级基本可以判定是写错了目标(比如把日志、代码贴进来)。
|
|
73
|
+
* 与 lib/typert.host.js 的 fileContentSchema.max(512 * 1024) 保持一致。
|
|
74
|
+
*/
|
|
75
|
+
const MAX_FILE_BYTES = 512 * 1024
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
|
|
79
|
+
* (历史坑见 assemble 监听器里那段注释:表现是「插件在、记忆不再注入」,日志一字没有)。
|
|
80
|
+
* @param ctx — 插件上下文,取 ctx.logger.warn
|
|
81
|
+
* @param msg — 消息正文,调用方负责带上文件名 / 目录名
|
|
82
|
+
* @param sessionId — 可选 session id,有就带
|
|
83
|
+
*/
|
|
84
|
+
function warnLog(ctx, msg, sessionId) {
|
|
85
|
+
const line = `dsh-mem: ${msg}${sessionId ? ` (session ${sessionId})` : ''}`
|
|
86
|
+
if (ctx?.logger?.warn) ctx.logger.warn(line)
|
|
87
|
+
else console.warn(line)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 提取消息文本,兼容 content 为字符串或 text-block 数组两种形态。 */
|
|
91
|
+
function extractText(content) {
|
|
92
|
+
if (typeof content === 'string') return content.trim()
|
|
93
|
+
if (!Array.isArray(content)) return ''
|
|
94
|
+
return content
|
|
95
|
+
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
|
|
96
|
+
.map(b => b.text)
|
|
97
|
+
.join('\n')
|
|
98
|
+
.trim()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 提取双字(2-gram)集合,中英文数字通用。 */
|
|
102
|
+
function bigrams(s) {
|
|
103
|
+
const clean = String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
|
104
|
+
const set = new Set()
|
|
105
|
+
for (let i = 0; i < clean.length - 1; i++) set.add(clean.slice(i, i + 2))
|
|
106
|
+
return set
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** query 的双字在 text 中的覆盖率(0~1),作为相关性得分。 */
|
|
110
|
+
function score(query, text) {
|
|
111
|
+
const q = bigrams(query)
|
|
112
|
+
if (q.size === 0) return 0
|
|
113
|
+
const t = bigrams(text)
|
|
114
|
+
let hit = 0
|
|
115
|
+
for (const g of q) if (t.has(g)) hit++
|
|
116
|
+
return hit / q.size
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
|
|
120
|
+
async function scanMemoryFiles(ctx, sessionId) {
|
|
121
|
+
try {
|
|
122
|
+
const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
|
|
123
|
+
const names = entries
|
|
124
|
+
.filter(e => e.isFile() && isMemoryEntry(e.name))
|
|
125
|
+
.map(e => e.name)
|
|
126
|
+
const loaded = await Promise.all(names.map(async (name) => {
|
|
127
|
+
try {
|
|
128
|
+
const content = await readFile(join(MEMORY_DIR, name), 'utf8')
|
|
129
|
+
const meta = parseFrontmatter(content)
|
|
130
|
+
return {
|
|
131
|
+
file: name,
|
|
132
|
+
content,
|
|
133
|
+
description: meta.description || '',
|
|
134
|
+
baseline: parseBaseline(content),
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
|
|
138
|
+
// 一律出声:这里的静默化就是把「记忆不再注入」变成无声事故的那一手。
|
|
139
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`, sessionId)
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}))
|
|
143
|
+
return loaded.filter(Boolean)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`, sessionId)
|
|
146
|
+
return []
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 相关性打分只用 frontmatter 的 description + 文件名,不扫全文。
|
|
152
|
+
*
|
|
153
|
+
* 旧版拿 query 的双字去全文里找,得分是「覆盖率」,分母是 query,不惩罚
|
|
154
|
+
* 文件长度 —— 越长的记忆越容易蒙中。实测 8 个典型问题:project_heartscape
|
|
155
|
+
* (5.9KB) 与 project_self-cognition (6.8KB) 命中 7 个,连「今天天气不错」都
|
|
156
|
+
* 注入 2300 token;而「论文写得怎么样了」该命中的 project_position-paper
|
|
157
|
+
* 反被挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
|
|
158
|
+
* 信号,噪音大幅下降。
|
|
159
|
+
*/
|
|
160
|
+
function selectRelevant(query, files) {
|
|
161
|
+
return files
|
|
162
|
+
.map(f => {
|
|
163
|
+
const hay = f.description + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
164
|
+
return { ...f, score: score(query, hay) }
|
|
165
|
+
})
|
|
166
|
+
.filter(f => f.score > 0)
|
|
167
|
+
.sort((a, b) => b.score - a.score)
|
|
168
|
+
.slice(0, MAX_RESULTS)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 解析 frontmatter 的 name/description/type(兼容顶层与 metadata 嵌套两种 type)。 */
|
|
172
|
+
function parseFrontmatter(content) {
|
|
173
|
+
// 换行必须容忍 CRLF:任何用 Windows 编辑器碰过一次的文件都会被换成 \r\n,
|
|
174
|
+
// 而旧版这里写的是 /^---\n/ —— 匹配失败 → description/type 全空 →
|
|
175
|
+
// 「baseline 走 parseBaseline 那条带 \r?\n 的正则照旧命中,但这条记忆永远检索不到」,
|
|
176
|
+
// 症状极具欺骗性。:123 的 parseBaseline 与 :277 的 extractTitle 早已是 \r?\n。
|
|
177
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
178
|
+
if (!m) return { name: '', description: '', type: '' }
|
|
179
|
+
const body = m[1]
|
|
180
|
+
const grab = (key) => {
|
|
181
|
+
// [ \t] / [ \t\r] 而不是 \s:\s 会连 CRLF 的 \r 一起吃进捕获组,值尾留一个 \r
|
|
182
|
+
// 会让下面的引号剥离(["']$)失配,description 尾巴上挂一个残引号。
|
|
183
|
+
const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
|
|
184
|
+
if (!hit) return ''
|
|
185
|
+
return hit[1].replace(/^["']|["']$/g, '').trim()
|
|
186
|
+
}
|
|
187
|
+
let type = grab('type')
|
|
188
|
+
if (!type) {
|
|
189
|
+
const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
|
|
190
|
+
if (nested) type = nested[1]
|
|
191
|
+
}
|
|
192
|
+
return { name: grab('name'), description: grab('description'), type }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 解析 frontmatter 的 `baseline` 标记:会话第一回合无条件注入。 */
|
|
196
|
+
function parseBaseline(content) {
|
|
197
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
198
|
+
if (!m) return false
|
|
199
|
+
const hit = m[1].match(/^baseline:\s*(.+)$/m)
|
|
200
|
+
if (!hit) return false
|
|
201
|
+
return /^(true|yes|1)$/i.test(hit[1].replace(/^["']|["']$/g, '').trim())
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 原子写:先写同目录临时文件,再 rename 覆盖目标。
|
|
206
|
+
*
|
|
207
|
+
* 直写的风险是崩在中途(断电、进程被杀、磁盘满)留下半截文件 —— frontmatter
|
|
208
|
+
* 缺半边,这条记忆从此解析不出来、检索不到,而文件看上去还在。
|
|
209
|
+
* 同卷 rename 是原子的:读方要么看到旧全文、要么看到新全文,不存在中间态。
|
|
210
|
+
* 任何一步失败都不碰目标文件,临时文件收尾清掉。
|
|
211
|
+
*/
|
|
212
|
+
async function writeFileAtomic(target, content) {
|
|
213
|
+
const tmp = `${target}.${process.pid}.tmp`
|
|
214
|
+
try {
|
|
215
|
+
await writeFile(tmp, content, 'utf8')
|
|
216
|
+
await rename(tmp, target)
|
|
217
|
+
} finally {
|
|
218
|
+
// rename 成功后 tmp 已不存在(ENOENT),失败时把它清掉不留垃圾。
|
|
219
|
+
await unlink(tmp).catch(() => {})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 文件名归一:只取单段文件名再小写。
|
|
225
|
+
*
|
|
226
|
+
* 这是**唯一**的名字比较基准(保留名、非条目名、扫描过滤全走它)。
|
|
227
|
+
* 不能直接用原始字符串比:NTFS 不区分大小写,`memory.md` 与 `MEMORY.md`
|
|
228
|
+
* 是同一个文件;`basename` 还顺手挡掉 `..\..\MEMORY.md` 这类带目录成分的输入。
|
|
229
|
+
*/
|
|
230
|
+
function nameKey(raw) {
|
|
231
|
+
return basename(String(raw ?? '')).toLowerCase()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 是否是保留名(写/删一律拒绝)。大小写不敏感。 */
|
|
235
|
+
function isReserved(raw) {
|
|
236
|
+
return RESERVED_FILES.has(nameKey(raw))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 是否是非记忆条目(索引与流水):列出/检索/星图/读写全部排除。大小写不敏感。 */
|
|
240
|
+
function isNonEntry(raw) {
|
|
241
|
+
return NON_ENTRY_FILES.has(nameKey(raw))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 目录扫描过滤:这个名字算不算一条记忆条目。 */
|
|
245
|
+
function isMemoryEntry(name) {
|
|
246
|
+
return /\.md$/i.test(String(name ?? '')) && !isNonEntry(name)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 唯一的路径校验函数(防穿越 + 扩展名 + 排除非记忆条目)。
|
|
251
|
+
*
|
|
252
|
+
* MemoryGateway.readFile / StarmapGateway.readFile 都走这里,不再各写一套
|
|
253
|
+
* —— 旧版 starmap 那套内联校验只排除了 'MEMORY.md'(大小写敏感)且**完全不排除
|
|
254
|
+
* session-log.md**,于是 `starmap.readFile('session-log.md')` 能把 390+ KB 的流水
|
|
255
|
+
* 整个拉进浏览器。
|
|
256
|
+
*
|
|
257
|
+
* 不复用 nameKey 的原因:这里要保留磁盘上的原始大小写(返回值直接 join 进路径)。
|
|
258
|
+
* @param raw — 调用方传来的文件名(任意值)
|
|
259
|
+
* @returns 通过校验的原始文件名;非法返回 null
|
|
260
|
+
*/
|
|
261
|
+
function safeName(raw) {
|
|
262
|
+
if (typeof raw !== 'string' || raw === '') return null
|
|
263
|
+
// basename 与原文不一致=含目录成分。旧版是「悄悄取 basename 继续」,
|
|
264
|
+
// 与 starmap 那套(要求 safe === raw)行为不一致;现在统一为拒绝。
|
|
265
|
+
if (basename(raw) !== raw) return null
|
|
266
|
+
if (!/\.md$/i.test(raw)) return null
|
|
267
|
+
if (isNonEntry(raw)) return null
|
|
268
|
+
return raw
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 手动 @Remote marker 注册(Node 24 不解析装饰器语法,手写 decorator context)。
|
|
273
|
+
*/
|
|
274
|
+
function markRemote(cls, method, exportName) {
|
|
275
|
+
const instance = Object.create(cls.prototype)
|
|
276
|
+
Remote(exportName)(undefined, {
|
|
277
|
+
kind: 'method',
|
|
278
|
+
name: method,
|
|
279
|
+
private: false,
|
|
280
|
+
static: false,
|
|
281
|
+
addInitializer(fn) {
|
|
282
|
+
fn.call(instance)
|
|
283
|
+
},
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export class MemoryGateway extends TypertRemoteService {
|
|
288
|
+
constructor(ctx) {
|
|
289
|
+
super(ctx, 'memory')
|
|
290
|
+
// 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
|
|
291
|
+
new StarmapGateway(ctx)
|
|
292
|
+
|
|
293
|
+
// 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
|
|
294
|
+
// 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
|
|
295
|
+
const recallCache = new Map()
|
|
296
|
+
|
|
297
|
+
// 「本会话是否已注入过 baseline」——会话级状态,按 session id 记。
|
|
298
|
+
//
|
|
299
|
+
// 为什么不是消息条数:见下面 baseline 那段注释(恢复会话的历史消息早就在
|
|
300
|
+
// session 里,条数永不为 0)。会话 id 在整场会话里稳定,与 assemble 时
|
|
301
|
+
// 当前用户消息有没有落盘完全无关,所以新会话、恢复会话两种时序都成立。
|
|
302
|
+
// 容量 64 与 recallCache 同理,只防长驻进程无限增长:要挤掉一个 id 得先有
|
|
303
|
+
// 63 场别的会话开起来,那场老会话再被唤醒时最多是把同样的 baseline 再注入一次。
|
|
304
|
+
const BASELINE_CACHE_MAX = 64
|
|
305
|
+
const baselineInjected = new Set()
|
|
306
|
+
|
|
307
|
+
// 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
|
|
308
|
+
// 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
|
|
309
|
+
// 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
|
|
310
|
+
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
311
|
+
const assembled = await next()
|
|
312
|
+
// session id 在 try 外先取好:catch 里也要能带上它(拿到什么带什么)。
|
|
313
|
+
const sessionId = context?.agent?.session?.id ?? ''
|
|
314
|
+
try {
|
|
315
|
+
const agent = context?.agent
|
|
316
|
+
const session = agent?.session
|
|
317
|
+
if (!session) return assembled
|
|
318
|
+
// DSH 0.1.7 起 Session 的历史读取接口在陆续调整(snapshotEvents / eventAt /
|
|
319
|
+
// ownEvents 已弃用)。deriveMessages 在 0.1.7-rc.1 实测仍在且仍同步,但这三行
|
|
320
|
+
// 不是白写的:方法一旦被改名或改成异步,老写法要么静默不注入、要么把 Promise
|
|
321
|
+
// 当数组展开成空。宁可日志里响一声,也不要"记忆不注入但日志一字没有"。
|
|
322
|
+
if (typeof session.deriveMessages !== 'function') {
|
|
323
|
+
warnLog(ctx, 'session.deriveMessages() 不可用 —— 记忆召回已跳过(DSH session API 变了?)', sessionId)
|
|
324
|
+
return assembled
|
|
325
|
+
}
|
|
326
|
+
const derived = session.deriveMessages()
|
|
327
|
+
const messages = [...(derived && typeof derived.then === 'function' ? await derived : derived)]
|
|
328
|
+
|
|
329
|
+
// 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
|
|
330
|
+
// (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
|
|
331
|
+
// 「最后一条」会取到 tool-result,extractText 得到空串,下面的长度检查
|
|
332
|
+
// 直接返回——表现就是全程静默不注入。只有 source.kind === 'user' 是真人输入。
|
|
333
|
+
const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
|
|
334
|
+
const text = extractText(userMessages[userMessages.length - 1]?.content)
|
|
335
|
+
|
|
336
|
+
const files = await scanMemoryFiles(ctx, sessionId)
|
|
337
|
+
const picked = []
|
|
338
|
+
const seen = new Set()
|
|
339
|
+
const take = (f) => {
|
|
340
|
+
if (seen.has(f.file)) return
|
|
341
|
+
seen.add(f.file)
|
|
342
|
+
picked.push(f)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// baseline:每个会话注入一次,**不看消息条数**。
|
|
346
|
+
//
|
|
347
|
+
// 旧写法 `if (userMessages.length === 0)` 依赖一个真实存在的时序:assemble
|
|
348
|
+
// 触发时当前这条用户消息还没写进 session。已在 DSH 检出里核实为真 ——
|
|
349
|
+
// dsh-agent-loop/lib/index.js:906 const claimed = this.inbox.claim(...) // 只从 pending 队列取走
|
|
350
|
+
// dsh-agent-loop/lib/index.js:907 await this.loopCtx.systemPrompt.assemble(...) // ← 本监听器在这里跑
|
|
351
|
+
// dsh-agent-loop/lib/index.js:1046 this.session.append("user/message", ...) // 落 session 在 preStep 之后
|
|
352
|
+
// 所以全新会话首步 pick 到 0 条 user message,分支命中。
|
|
353
|
+
//
|
|
354
|
+
// 但**恢复 / 续聊会话**的历史消息早就在 session 里,条数永远 ≥ 1 → 分支永不
|
|
355
|
+
// 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
|
|
356
|
+
// 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
|
|
357
|
+
let baselineTaken = 0
|
|
358
|
+
const baselineKey = session.id ? session.id : session
|
|
359
|
+
if (!baselineInjected.has(baselineKey)) {
|
|
360
|
+
files
|
|
361
|
+
.filter(f => f.baseline)
|
|
362
|
+
.sort((a, b) => a.file.localeCompare(b.file))
|
|
363
|
+
.slice(0, MAX_BASELINE)
|
|
364
|
+
.forEach(f => {
|
|
365
|
+
const before = picked.length
|
|
366
|
+
take(f)
|
|
367
|
+
if (picked.length > before) baselineTaken++
|
|
368
|
+
})
|
|
369
|
+
// 只有真取到才落标记:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
|
|
370
|
+
// 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
|
|
371
|
+
if (baselineTaken > 0) {
|
|
372
|
+
baselineInjected.add(baselineKey)
|
|
373
|
+
if (baselineInjected.size > BASELINE_CACHE_MAX) {
|
|
374
|
+
baselineInjected.delete(baselineInjected.values().next().value)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
|
|
380
|
+
// 旧版两者共用 signature,首步签名一旦与后续某步相同,baseline 会连坐被吞;
|
|
381
|
+
// 反过来,旧版把 baseline 文件名也算进签名,会让同一步的检索结果在下一步
|
|
382
|
+
// 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
|
|
383
|
+
// 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
|
|
384
|
+
const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
|
|
385
|
+
const signature = text + '\u0000' + relevant.map(f => f.file).join(',')
|
|
386
|
+
if (recallCache.get(session.id) !== signature) {
|
|
387
|
+
recallCache.set(session.id, signature)
|
|
388
|
+
if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
|
|
389
|
+
relevant.forEach(take)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (picked.length === 0) return assembled
|
|
393
|
+
|
|
394
|
+
const parts = picked.map(f => {
|
|
395
|
+
const body = f.content.length > MAX_CHARS_PER_FILE
|
|
396
|
+
? f.content.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
|
|
397
|
+
: f.content
|
|
398
|
+
return `### ${f.file}\n${body}`
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
...assembled,
|
|
403
|
+
contexts: [...(assembled.contexts ?? []), {
|
|
404
|
+
name: 'dsh-mem:recall',
|
|
405
|
+
order: 1000,
|
|
406
|
+
text: `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n${parts.join('\n\n')}`,
|
|
407
|
+
}],
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
// 这是整条注入链的外壳。裸吞的代价就是 :174-193 记的那个坑:权限错、编码坏、
|
|
411
|
+
// frontmatter 正则哪天不匹配,表现都是「插件在、但记忆不再注入」,日志一字没有。
|
|
412
|
+
// 现在只降级(返回未注入的 assembly),但一定留痕。
|
|
413
|
+
warnLog(ctx, `recall injection failed: ${err?.message ?? err}`, sessionId)
|
|
414
|
+
return assembled
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小)。 */
|
|
420
|
+
async listFiles() {
|
|
421
|
+
const files = await scanMemoryFiles(this.ctx, '')
|
|
422
|
+
return files.map(f => {
|
|
423
|
+
const meta = parseFrontmatter(f.content)
|
|
424
|
+
return {
|
|
425
|
+
file: f.file,
|
|
426
|
+
type: meta.type || 'reference',
|
|
427
|
+
description: meta.description || '',
|
|
428
|
+
size: f.content.length,
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 读单个记忆文件全文。 */
|
|
434
|
+
async readFile(name) {
|
|
435
|
+
const safe = safeName(name)
|
|
436
|
+
if (!safe) throw new Error('dsh-mem: invalid file name')
|
|
437
|
+
const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
|
|
438
|
+
return { name: safe, content }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 写(新增或覆盖)单个记忆文件。
|
|
443
|
+
*
|
|
444
|
+
* 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
|
|
445
|
+
* 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
|
|
446
|
+
*/
|
|
447
|
+
async writeFile(name, content) {
|
|
448
|
+
// 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
|
|
449
|
+
// 'Session-Log.md' 在 NTFS 上都是同一个文件,必须一律走 { ok: false } 这条路
|
|
450
|
+
// (设置页按 ok 判定,见 client.js 的 submit())。
|
|
451
|
+
if (isReserved(name)) {
|
|
452
|
+
return { ok: false, file: nameKey(name), error: `dsh-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
|
|
453
|
+
}
|
|
454
|
+
const safe = safeName(name)
|
|
455
|
+
if (!safe) throw new Error('dsh-mem: invalid file name')
|
|
456
|
+
// 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
|
|
457
|
+
// 原文件就此被一行垃圾顶掉,且没有任何错误信号。
|
|
458
|
+
if (typeof content !== 'string') {
|
|
459
|
+
return { ok: false, file: safe, error: `dsh-mem: content must be a string, got ${typeof content}` }
|
|
460
|
+
}
|
|
461
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
462
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
463
|
+
return { ok: false, file: safe, error: `dsh-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
|
|
464
|
+
}
|
|
465
|
+
await writeFileAtomic(join(MEMORY_DIR, safe), content)
|
|
466
|
+
return { ok: true, file: safe }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
|
|
470
|
+
async deleteFile(name) {
|
|
471
|
+
if (isReserved(name)) {
|
|
472
|
+
return { ok: false, error: `dsh-mem: reserved file, refusing to delete: ${nameKey(name)}` }
|
|
473
|
+
}
|
|
474
|
+
const safe = safeName(name)
|
|
475
|
+
if (!safe) throw new Error('dsh-mem: invalid file name')
|
|
476
|
+
await unlink(join(MEMORY_DIR, safe))
|
|
477
|
+
return { ok: true }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
markRemote(MemoryGateway, 'listFiles', 'listFiles')
|
|
482
|
+
markRemote(MemoryGateway, 'readFile', 'readFile')
|
|
483
|
+
markRemote(MemoryGateway, 'writeFile', 'writeFile')
|
|
484
|
+
markRemote(MemoryGateway, 'deleteFile', 'deleteFile')
|
|
485
|
+
|
|
486
|
+
/** 提取正文第一个 `# ` 标题作为展示标题。 */
|
|
487
|
+
function extractTitle(content) {
|
|
488
|
+
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---/)
|
|
489
|
+
const body = m ? content.slice(m[0].length) : content
|
|
490
|
+
const h1 = body.match(/^#\s+(.+)$/m)
|
|
491
|
+
return h1 ? h1[1].trim() : ''
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** 扫记忆目录,读全部 .md(跳过索引与 session-log),带体积与修改时间。 */
|
|
495
|
+
async function scanStars(ctx) {
|
|
496
|
+
let entries
|
|
497
|
+
try {
|
|
498
|
+
entries = await readdir(MEMORY_DIR, { withFileTypes: true })
|
|
499
|
+
} catch (err) {
|
|
500
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`)
|
|
501
|
+
return []
|
|
502
|
+
}
|
|
503
|
+
const names = entries
|
|
504
|
+
.filter(e => e.isFile() && isMemoryEntry(e.name))
|
|
505
|
+
.map(e => e.name)
|
|
506
|
+
const loaded = await Promise.all(names.map(async (name) => {
|
|
507
|
+
try {
|
|
508
|
+
const full = join(MEMORY_DIR, name)
|
|
509
|
+
const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
|
|
510
|
+
return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
|
|
511
|
+
} catch (err) {
|
|
512
|
+
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`)
|
|
513
|
+
return null
|
|
514
|
+
}
|
|
515
|
+
}))
|
|
516
|
+
return loaded.filter(Boolean)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 记忆星图(host 半)—— 只读扫描记忆目录,把每条记忆解析成一颗「星」。
|
|
521
|
+
* 通过 TypertRemoteService 暴露两个 remote 方法给浏览器半:
|
|
522
|
+
* - starmap.listStars() 全部星(元数据,不含正文)
|
|
523
|
+
* - starmap.readFile(n) 单条记忆全文(文件名白名单校验)
|
|
524
|
+
*/
|
|
525
|
+
export class StarmapGateway extends TypertRemoteService {
|
|
526
|
+
constructor(ctx) { super(ctx, 'starmap') }
|
|
527
|
+
|
|
528
|
+
async listStars() {
|
|
529
|
+
const files = await scanStars(this.ctx)
|
|
530
|
+
const stars = files.map(f => {
|
|
531
|
+
const meta = parseFrontmatter(f.content)
|
|
532
|
+
const title = extractTitle(f.content)
|
|
533
|
+
return {
|
|
534
|
+
file: f.name,
|
|
535
|
+
kind: meta.type || 'special',
|
|
536
|
+
title: title || meta.name || (meta.description ? meta.description.slice(0, 24) : f.name.replace(/\.md$/, '')),
|
|
537
|
+
desc: meta.description || '',
|
|
538
|
+
bytes: f.bytes,
|
|
539
|
+
mtimeMs: f.mtimeMs,
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
return { count: stars.length, stars }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async readFile(name) {
|
|
546
|
+
// 与 MemoryGateway.readFile 收敛到同一个 safeName:它现在同时排除
|
|
547
|
+
// MEMORY.md 与 session-log.md,且大小写不敏感。
|
|
548
|
+
const safe = safeName(name)
|
|
549
|
+
if (!safe) throw new Error('sage-starmap: invalid file name')
|
|
550
|
+
const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
|
|
551
|
+
return { name: safe, content }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
markRemote(StarmapGateway, 'listStars', 'listStars')
|
|
556
|
+
markRemote(StarmapGateway, 'readFile', 'readFile')
|
|
557
|
+
|
|
558
|
+
export default MemoryGateway
|