memory-eternal 0.1.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/lib/capture.js ADDED
@@ -0,0 +1,267 @@
1
+ // 记忆核心 · 自动沉淀管线
2
+ //
3
+ // 移植 boujoy-harness 的「知识捕获」理念:模型负责判定价值(score)与压缩
4
+ // (卡片正文),本模块只负责安全(写哪、多短拒绝、去重守卫)。
5
+ //
6
+ // 与 boujoy 的区别:不需要用户点「沉淀」——会话每轮结束后由 index.js 监听
7
+ // agent/turn-stopping 自动触发 summarizeTurn → writeCard/appendUpdate。
8
+ //
9
+ // 去重:两级。第一级是词法(Jaccard bigram,阈值 0.62,捕获纯重复);第二级
10
+ // 是语义——把「现有卡片索引」(标题+摘要,最多近邻 8 张)喂给模型,让模型
11
+ // 判断新知识是「新建卡片」还是「追加到已有卡片」(append_to),这正是
12
+ // boujoy 的「追加更新记录,不要堆重复卡」语义,且对 LLM 改写免疫。
13
+
14
+ import { writeCard, appendUpdate, textSimilarity, dedupCheck, listCards, queryTerms } from './vault.js'
15
+
16
+ /** 默认去重阈值(与 boujoy 一致)。 */
17
+ export const DEDUP_THRESHOLD = 0.62
18
+
19
+ /** 参与语义去重的近邻卡片数上限(控制 token)。 */
20
+ export const DEDUP_NEIGHBORS = 8
21
+
22
+ // 沉淀预筛:仅当对话含「可复用」信号时才调用 LLM,避免对纯闲聊/寒暄发起昂贵的 LLM 判定。
23
+ const DURABLE_SIGNALS = /(#{1,3}\s|```|\[\[|\]|\(https?:|\.(md|ts|js|py|json|yml|yaml|sh|sql)\b|`|\b\d{2,}\b|选择|决定|采用|方案|结论|原因|根因|问题|解决|修复|报错|错误|教训|架构|设计|配置|部署|流程|步骤|算法|原理|函数|接口|参数|命令|依赖|版本|坑|踩|优化|性能|数据库|索引|模型|token|推荐|偏好|约定|规范)/
24
+
25
+ /** 判断一段对话是否值得送 LLM 判定(省 token 的启发式预筛)。 */
26
+ export function looksDurable(text) {
27
+ const t = String(text || '')
28
+ if (t.trim().length < 120) return false
29
+ if (DURABLE_SIGNALS.test(t)) return true
30
+ // 列表/编号密度:≥2 个列表项且有一定长度 => 大概率是结构化知识
31
+ const items = (t.match(/(^|\n)\s*[-*]\s+/gm) || []).length + (t.match(/(^|\n)\s*\d+[.、)]\s+/gm) || []).length
32
+ return items >= 2 && t.trim().length >= 160
33
+ }
34
+
35
+ /**
36
+ * 用当前模型把一段对话压缩成知识卡(或判定不值得保存 / 应追加到已有卡)。
37
+ * @param llm - dsh 的 llm 服务(ctx.get('llm'))
38
+ * @param route - { provider, model } 解析结果
39
+ * @param conversation - 本轮对话文本(用户+助手)
40
+ * @param opts - { maxTokens, signal, existing }:existing 为 [{path,title,summary}]
41
+ * @returns Promise<null | { save:boolean } | { save:true, title, kind, tags, body } | { append_to, update }>
42
+ */
43
+ export async function summarizeTurn(llm, route, conversation, opts = {}) {
44
+ const text = String(conversation || '').trim()
45
+ if (text.length < 120) return null // 太短,没有沉淀价值
46
+ if (!looksDurable(text)) return null // 启发式预筛:无可复用信号则跳过 LLM 判定(省 token)
47
+
48
+ const existing = Array.isArray(opts.existing) ? opts.existing.slice(0, DEDUP_NEIGHBORS) : []
49
+ const existingBlock = existing.length
50
+ ? '\n\n## 记忆库已有卡片索引(若新知识属于其中某张,必须用 append_to 追加而不是新建)\n' +
51
+ existing.map((e, i) => `${i + 1}. [${e.path}] ${e.title}${e.summary ? `:${e.summary.slice(0, 120)}` : ''}`).join('\n')
52
+ : ''
53
+
54
+ const system = [
55
+ '你是本地知识库的「记忆沉淀引擎」。你的唯一任务:判断一段对话中是否有值得长期复用的知识,若有则压缩成一张知识卡。',
56
+ '判断标准(全部满足才保存):',
57
+ '1. 内容对未来有复用价值(技术方案、项目背景、设计决策、领域知识、偏好约定等);',
58
+ '2. 不是纯闲聊、寒暄或一次性指令;',
59
+ '3. 与已有知识不重复(见下)。',
60
+ '输出严格 JSON(不要 markdown 代码块、不要多余文字):',
61
+ '{"save": false} 表示不值得保存;',
62
+ '否则输出:',
63
+ '{"save": true, "title": "简短标题", "kind": "knowledge|project|content|prompt|business|tool|mistake", "tags": ["标签"], "body": "300-800字的压缩正文,markdown,含要点列表,首行是# 标题"}',
64
+ 'kind 含义:project=项目背景/进度;knowledge=通用知识/技术方案/设计决策;content=内容素材/资料;prompt=提示词/工作流;business=业务/商业;tool=工具链/CLI/配置/环境坑;mistake=错误/反模式/踩坑/“别这么做”/debug 教训。',
65
+ '正文要求:去掉对话口水,只留可复用的结论、参数、步骤、原因。语言与对话一致。',
66
+ '已有卡片规则:若新知识与某张已有卡片是同一主题(同一技术、同一项目、同一决策),不要新建——输出',
67
+ '{"append_to": "已有卡片的 path 字段原样", "update": "追加/修正的短文本(一段话)"}',
68
+ '只有当已有卡片索引为空或不相关时,才新建卡片。',
69
+ ].join('\n')
70
+
71
+ const { BlockAssembler, createUserMessage } = await importLlmHelpers()
72
+ const assembler = new BlockAssembler()
73
+ const messages = [createUserMessage({
74
+ content: [{ type: 'text', text: text + existingBlock }],
75
+ source: { kind: 'plugin', plugin: 'memory-eternal' },
76
+ })]
77
+
78
+ const options = {
79
+ provider: route.provider,
80
+ model: route.model,
81
+ messages,
82
+ system,
83
+ maxTokens: opts.maxTokens ?? 900,
84
+ reasoningEffort: 'off',
85
+ purpose: 'memory-capture',
86
+ signal: opts.signal ?? AbortSignal.timeout(45000),
87
+ }
88
+ for await (const chunk of llm.stream(options)) assembler.push(chunk)
89
+ const out = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
90
+ return parseCaptureJson(out)
91
+ }
92
+
93
+ /**
94
+ * 语义去重近邻:按关键词重叠为候选卡排序,返回最相似的若干张。
95
+ * 关键词 = 新卡标题/正文的 CJK 整词 + bigram 与已有卡标题/摘要的命中数。
96
+ */
97
+ export async function pickNeighbors(root, card, limit = DEDUP_NEIGHBORS) {
98
+ const haystack = `${card.title}\n${card.body}`.toLowerCase()
99
+ const wanted = queryTerms(haystack)
100
+ const cards = await listCards(root)
101
+ const scored = []
102
+ for (const existing of cards) {
103
+ let score = 0
104
+ const target = `${existing.title}\n${existing.summary}`.toLowerCase()
105
+ for (const term of wanted) if (target.includes(term.toLowerCase())) score += 1
106
+ if (score > 0) scored.push({ ...existing, score })
107
+ }
108
+ scored.sort((a, b) => b.score - a.score)
109
+ return scored.slice(0, limit)
110
+ }
111
+
112
+ /**
113
+ * 去重守卫:目标目录里已有高度相似卡片时返回它,否则返回 null。
114
+ * @returns Promise<null | { path, score }>
115
+ */
116
+ export function makeDedupChecker(dir) {
117
+ return (text, threshold = DEDUP_THRESHOLD) => dedupCheck(dir, text, threshold)
118
+ }
119
+
120
+ /** 写卡(带目录级去重守卫),命中返回 duplicate 而非写卡。 */
121
+ export async function captureCard(root, card, opts = {}) {
122
+ const threshold = opts.threshold ?? DEDUP_THRESHOLD
123
+ return writeCard(root, card, { threshold, dedup: true })
124
+ }
125
+
126
+ /** 追加更新记录(去重命中时)。 */
127
+ export async function captureUpdate(root, rel, text, opts = {}) {
128
+ return appendUpdate(root, rel, text, { threshold: opts.threshold ?? DEDUP_THRESHOLD })
129
+ }
130
+
131
+ // -- helpers ---------------------------------------------------------------
132
+
133
+ async function importLlmHelpers() {
134
+ // 延迟加载,避免在无 llm 服务时拉高启动成本;dsh-llm 是 peerDependency。
135
+ // 独立进程(MCP server / CLI / web)没有 DSH 环境,import 失败时退回本地
136
+ // 极简 shim:只实现 summarizeTurn 用到的最小面(push text chunk / blocks /
137
+ // 构造 user message),供 lib/llm-openai.js 的 OpenAI 兼容适配器配合使用。
138
+ try {
139
+ const { BlockAssembler, createUserMessage } = await import('@deepseek-ai/dsh-llm')
140
+ return { BlockAssembler, createUserMessage }
141
+ } catch {
142
+ return {
143
+ BlockAssembler: class {
144
+ constructor() { this.parts = [] }
145
+ push(chunk) {
146
+ if (typeof chunk === 'string') this.parts.push(chunk)
147
+ else if (chunk && typeof chunk.text === 'string') this.parts.push(chunk.text)
148
+ else if (chunk && typeof chunk.delta?.text === 'string') this.parts.push(chunk.delta.text)
149
+ }
150
+ blocks() {
151
+ const text = this.parts.join('')
152
+ return text ? [{ type: 'text', text }] : []
153
+ }
154
+ },
155
+ createUserMessage: (msg) => ({ role: 'user', content: msg?.content ?? msg, source: msg?.source }),
156
+ }
157
+ }
158
+ }
159
+
160
+ export function parseCaptureJson(text) {
161
+ if (!text) return null
162
+ // 去掉可能的 ```json 围栏
163
+ const cleaned = text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim()
164
+ let parsed
165
+ try {
166
+ parsed = JSON.parse(cleaned)
167
+ } catch {
168
+ // 容错:截取第一个 { 到最后一个 }
169
+ const start = cleaned.indexOf('{')
170
+ const end = cleaned.lastIndexOf('}')
171
+ if (start < 0 || end <= start) return null
172
+ try {
173
+ parsed = JSON.parse(cleaned.slice(start, end + 1))
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+ if (!parsed || parsed.save === false) return { save: false }
179
+ // 追加已有卡片:append_to 命中的优先级最高
180
+ if (parsed.append_to) {
181
+ const target = String(parsed.append_to).trim()
182
+ const update = String(parsed.update || '').trim()
183
+ if (target && update.length >= 10) return { append_to: target, update }
184
+ return null
185
+ }
186
+ const title = String(parsed.title || '').trim().slice(0, 80)
187
+ const body = String(parsed.body || '').trim()
188
+ if (!title || body.length < 20) return null
189
+ const kind = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake'].includes(parsed.kind) ? parsed.kind : 'knowledge'
190
+ const tags = Array.isArray(parsed.tags)
191
+ ? parsed.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 8)
192
+ : []
193
+ return { save: true, title, kind, tags, body }
194
+ }
195
+
196
+ /** 从会话事件里提取「增量片段」的可读对话文本(用户 + 助手)。 */
197
+ export function extractLastTurn(events, _turn) {
198
+ const lines = []
199
+ const pushMessage = (event) => {
200
+ const data = event.data || event
201
+ const message = data.message || data
202
+ const role = message.role
203
+ const content = message.content
204
+ if (Array.isArray(content)) {
205
+ for (const block of content) {
206
+ if (block.type === 'text' && block.text) lines.push(`${role === 'user' ? '用户' : '助手'}:${block.text}`)
207
+ }
208
+ } else if (typeof content === 'string' && content) {
209
+ lines.push(`${role === 'user' ? '用户' : '助手'}:${content}`)
210
+ }
211
+ }
212
+ for (const event of events) {
213
+ const type = event.type
214
+ const data = event.data || event
215
+ if (type === 'user/message' || (type === 'message' && data.role === 'user')) {
216
+ pushMessage(data)
217
+ } else if (type === 'assistant/message' || type === 'assistant/chunk') {
218
+ if (type === 'assistant/message') pushMessage(data)
219
+ else if (data.chunk?.content) {
220
+ const content = data.chunk.content
221
+ if (typeof content === 'string') lines.push(`助手:${content}`)
222
+ else if (Array.isArray(content)) {
223
+ for (const block of content) {
224
+ if (block.type === 'text' && block.text) lines.push(`助手:${block.text}`)
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ return lines.join('\n').trim()
231
+ }
232
+
233
+ /** 取增量事件:seq 大于 lastSeq 的 user/assistant 消息事件。 */
234
+ export function sliceNewEvents(events, lastSeq) {
235
+ const out = []
236
+ for (const event of events) {
237
+ const seq = event.seq
238
+ if (seq !== undefined && lastSeq !== undefined && seq <= lastSeq) continue
239
+ const type = event.type
240
+ if (type === 'user/message' || type === 'assistant/message') out.push(event)
241
+ }
242
+ return out
243
+ }
244
+
245
+ /** 解析当前模型路由:取第一个 provider 的旗舰模型(与驯兽师一致)。 */
246
+ /** 记忆侧「压缩产物」接口:确定性(无 LLM)从一段文本抽出结构化关键内容,供 harness 会话内压缩旧轮次时注入。 */
247
+ export function compressExcerpt(text, maxChars = 2400) {
248
+ const lines = String(text || '').split(/\r?\n/).map((L) => L.trim()).filter(Boolean)
249
+ const structured = lines.filter((L) => /^(#{1,6}\s|[-*]\s|\d+[.、)]\s|>|`|\[x\]|\[ \])/.test(L))
250
+ const src = structured.length ? structured : lines
251
+ let out = src.join('\n')
252
+ if (out.length > maxChars) out = out.slice(0, maxChars).replace(/\n+$/, '') + '\n…(已压缩,细节见记忆库)'
253
+ return out
254
+ }
255
+
256
+ /** 解析当前模型路由:取第一个 provider 的旗舰模型(与驯兽师一致)。 */
257
+ export async function resolveRoute(llm) {
258
+ try {
259
+ const providers = llm.listProviders()
260
+ if (!providers || providers.length === 0) return null
261
+ const models = await llm.listModels(providers[0].id)
262
+ if (!models || models.length === 0) return null
263
+ return { provider: providers[0].id, model: models[0].id }
264
+ } catch {
265
+ return null
266
+ }
267
+ }