memory-eternal 0.9.23 → 0.10.2

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 CHANGED
@@ -19,10 +19,114 @@ export const DEDUP_THRESHOLD = 0.62
19
19
  /** 参与语义去重的近邻卡片数上限(控制 token)。 */
20
20
  export const DEDUP_NEIGHBORS = 8
21
21
 
22
+ /**
23
+ * 蒸馏输出 token 上限的默认值(issue #18)。
24
+ *
25
+ * 旧默认 900 与 system prompt 要求的「300-800 字正文」不匹配:中文 800 字本身就逼近 900 token,
26
+ * 再叠加 JSON 结构开销与超长会话输入,轻易撞上限 → 输出被截断 → 旧代码把它误报成
27
+ * UNPARSEABLE_OUTPUT,最终落一张 raw 噪声卡。2000 给正文留出余量(仍是单次调用的量级)。
28
+ */
29
+ export const DEFAULT_CAPTURE_MAX_TOKENS = 2000
30
+
31
+ /** 蒸馏输出上限的硬顶(与 api.js 的 NUM_RANGE.captureMaxTokens 一致)。 */
32
+ export const MAX_CAPTURE_MAX_TOKENS = 4000
33
+
22
34
  // 沉淀预筛:仅当对话含「可复用」信号时才调用 LLM,避免对纯闲聊/寒暄发起昂贵的 LLM 判定。
23
35
  const DURABLE_SIGNALS = /(#{1,3}\s|```|\[\[|\]|\(https?:|\.(md|ts|js|py|json|yml|yaml|sh|sql)\b|`|\b\d{2,}\b|选择|决定|采用|方案|结论|原因|根因|问题|解决|修复|报错|错误|教训|架构|设计|配置|部署|流程|步骤|算法|原理|函数|接口|参数|命令|依赖|版本|坑|踩|优化|性能|数据库|索引|模型|token|推荐|偏好|约定|规范)/
24
36
 
25
- /** 判断一段对话是否值得送 LLM 判定(省 token 的启发式预筛)。 */
37
+ // -- 入料噪声闸门(P0 修复)--------------------------------------------------
38
+ //
39
+ // 背景:turn-stopping 抓的是「本会话新增的 user/assistant 消息」,而 DSH 会把
40
+ // 大量**运行时注入**伪装成 user 消息塞进会话:环境快照、team 广播、teammate 原文、
41
+ // 工具使用说明(`# create-hook`)、文件片段("g libs.")。旧实现全部当正文喂给模型,
42
+ // 于是待审队列里出现「用户:<system-reminder> You are teammate」这类垃圾卡。
43
+ // 这里只做**机械剥离**,不做价值判断(价值判断仍归模型 + 审核中心)。
44
+
45
+ /** 运行时注入的段落标记:成对出现,整段剥掉。 */
46
+ const NOISE_BLOCKS = [
47
+ /<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi,
48
+ /<local-command-stdout>[\s\S]*?<\/local-command-stdout>/gi,
49
+ /<system-reminder>[\s\S]*?<\/system-reminder>/gi,
50
+ /<team-[a-z-]+>[\s\S]*?<\/team-[a-z-]+>/gi,
51
+ /<teammate[^>]*>[\s\S]*?<\/teammate>/gi,
52
+ /<agent-message[^>]*>[\s\S]*?<\/agent-message>/gi,
53
+ /<user-prompt-submit-hook>[\s\S]*?<\/user-prompt-submit-hook>/gi,
54
+ /<command-[a-z-]+>[\s\S]*?<\/command-[a-z-]+>/gi,
55
+ /\[Tool use: [^\]]*\]/gi,
56
+ ]
57
+ /** 运行时注入的行前缀:整行丢掉。 */
58
+ const NOISE_LINE = /^\s*(相关上下文|Current runtime context|Runtime context|Approval prompts are disabled|DSH file policy|Current DSH file policy|You are a teammate|Team Lead|Teammate |Subagent |Note: the user|系统提示|环境快照)[::]?/i
59
+ /** 工具/命令使用说明的标题行(`# create-hook`、`## `codex create-hook``)。 */
60
+ const TOOL_DOC_HEADING = /^\s*#{1,4}\s*`?[a-z][a-z0-9-]{2,40}`?\s*$/i
61
+
62
+ /** 段落级剥离:去掉注入块、注入行、工具说明标题行,并在相邻行间插入换行(保持行结构)。 */
63
+ export function stripRuntimeNoise(text) {
64
+ let out = String(text || '')
65
+ // 注入块整段删除(不留新行:相邻两行必须重新贴合,否则会粘出「A用户:B」这种错位文本)
66
+ for (const re of NOISE_BLOCKS) out = out.replace(re, '')
67
+ const kept = []
68
+ for (const line of out.split('\n')) {
69
+ if (NOISE_LINE.test(line) || TOOL_DOC_HEADING.test(line)) continue
70
+ kept.push(line)
71
+ }
72
+ return kept.join('\n').trim()
73
+ }
74
+
75
+ /**
76
+ * 剥掉运行时注入块之后是否**什么都不剩**(最可靠的注入特征:整段就是被标签包起来的注入)。
77
+ * `[\s\S]*?` 非贪婪匹配,长注入块也能正确剥掉;旧的 `hasUsableContent` 用「≥120 字」近似,
78
+ * 对长度超过 120 的注入块会失效(例如整段 teammate 原文),这里直接按「剥完是否为空」判定。
79
+ */
80
+ function isPureInjection(text) {
81
+ let out = String(text || '')
82
+ for (const re of NOISE_BLOCKS) out = out.replace(re, '')
83
+ return out.replace(/[\s\-*_`]+/g, '').length === 0
84
+ }
85
+
86
+ /**
87
+ * 判定「这段文本像不像被截断的片段」——用于挡住碎片卡。
88
+ * 现象来源:DSH 注入的上下文块被从中间切开,得到 `` chema-form` 当 external `` 这种标题。
89
+ * 保守起见只在**明确可疑**时返回 true:以标点/反引号开头,或以 `、-`、`(`、`/` 等
90
+ * 明显未完结字符结尾。
91
+ */
92
+ export function looksTruncated(text) {
93
+ const t = String(text || '').trim()
94
+ if (!t) return true
95
+ if (/^[`'"))】」》.,,。;;::\-–—/\\|>]/.test(t)) return true
96
+ if (/[((【「《,,、::;;\-–—/\\|]$/.test(t)) return true
97
+ return false
98
+ }
99
+
100
+ /** 过滤掉碎片行与注入行:抽标题 / 摘要前先用一次。 */
101
+ function dropFragmentLines(text) {
102
+ return String(text || '')
103
+ .split('\n')
104
+ .map((l) => l.trim())
105
+ .filter((l) => l && !looksTruncated(l))
106
+ .join('\n')
107
+ }
108
+
109
+ /**
110
+ * 剥离噪声后的正文是否还够「像一次真实对话」。
111
+ *
112
+ * 为什么不用固定 120 字门槛:真实对话常常是短行列表(每行几十字),按「逐行丢碎片 +
113
+ * 总量 ≥120」会**误杀**这类正常记录(tests/capture-route.test.mjs 的 189 字短行会话就被
114
+ * 误判过)。这里改成「事实量 + 噪声占比」双判据:
115
+ * 1. 剥完至少还剩 60 字事实内容(挡住纯注入 / 极短寒暄);
116
+ * 2. 剥掉的部分不能超过原文的 85%(挡住「主体都是注入、只带一句人话」的轮次)。
117
+ * 真实内容被误伤的成本远高于偶尔放进一条噪声(噪声还有审核中心兜底)。
118
+ */
119
+ export function hasUsableContent(text) {
120
+ const raw = String(text || '').trim()
121
+ if (raw.length < 60) return false
122
+ if (isPureInjection(raw)) return false // 整段都是注入块(无论多长)一律不算内容
123
+ const stripped = stripRuntimeNoise(raw)
124
+ if (stripped.length < 60) return false
125
+ if (raw.length > 120 && stripped.length / raw.length < 0.15) return false
126
+ return true
127
+ }
128
+
129
+ /** 判断一段(已剥离噪声的)对话是否值得交 LLM 判定(省 token 的启发式预筛)。 */
26
130
  export function looksDurable(text) {
27
131
  const t = String(text || '')
28
132
  if (t.trim().length < 120) return false
@@ -32,6 +136,42 @@ export function looksDurable(text) {
32
136
  return items >= 2 && t.trim().length >= 160
33
137
  }
34
138
 
139
+ /**
140
+ * 从正文里取一个「像标题」的片段(降级原文卡 / 无 LLM 场景用)。
141
+ *
142
+ * 旧实现是 `text.slice(0, 40)`:一句话被硬切,于是出现 `` chema-form` 当 external ``
143
+ * 这种从半截开始的标题,审核中心根本没法判断(P0 修复)。这里:
144
+ * 1. 取第一行非碎片文本;2. 去掉 `用户:` / `助手:` 说话人前缀与 markdown 记号;
145
+ * 3. 优先在首个句读(。!?;!?;)处断句;4. 仍以 `、和/` 这类连接性字符结尾就再截一点。
146
+ */
147
+ export function deriveTitle(text, maxLen = 60) {
148
+ const lines = dropFragmentLines(text).split('\n')
149
+ const firstLine = lines[0] || String(text || '').trim()
150
+ let t = cleanTitleLine(firstLine)
151
+ // 首行是 "# T" 这种过短的装饰性标题时,往后找第一句真正像标题的话(上限 5 行,避免扫全文)。
152
+ if (!isUsableTitle(t)) {
153
+ for (const line of lines.slice(1, 6)) {
154
+ const cand = cleanTitleLine(line)
155
+ if (isUsableTitle(cand)) { t = cand; break }
156
+ }
157
+ }
158
+ if (!t) return ''
159
+ const cut = t.search(/[。!?;!?;]/)
160
+ if (cut >= 4) t = t.slice(0, cut)
161
+ if (t.length > maxLen) t = t.slice(0, maxLen)
162
+ t = t.replace(/[、,,和与\/\\\-–—::]+$/, '').trim()
163
+ return t
164
+ }
165
+
166
+ /** 单行清洗:去说话人前缀、markdown 记号、压缩空白。 */
167
+ function cleanTitleLine(line) {
168
+ return String(line || '')
169
+ .replace(/^(用户|助手|user|assistant)\s*[::]\s*/i, '')
170
+ .replace(/^[#>\-*\s]+/, '')
171
+ .replace(/\s+/g, ' ')
172
+ .trim()
173
+ }
174
+
35
175
  /**
36
176
  * 用当前模型把一段对话压缩成知识卡(或判定不值得保存 / 应追加到已有卡)。
37
177
  * @param llm - dsh 的 llm 服务(ctx.get('llm'))
@@ -57,6 +197,7 @@ export async function summarizeTurn(llm, route, conversation, opts = {}) {
57
197
  export async function summarizeTurnDetailed(llm, route, conversation, opts = {}) {
58
198
  const text = String(conversation || '').trim()
59
199
  if (text.length < 120) return { skip: 'too-short' } // 太短,没有沉淀价值
200
+ if (!hasUsableContent(text)) return { skip: 'noisy' } // 剥离注入噪声后没剩多少真实内容(P0 噪声闸门)
60
201
  if (!looksDurable(text)) return { skip: 'not-durable' } // 启发式预筛:无可复用信号则跳过 LLM 判定(省 token)
61
202
 
62
203
  const existing = Array.isArray(opts.existing) ? opts.existing.slice(0, DEDUP_NEIGHBORS) : []
@@ -89,13 +230,17 @@ export async function summarizeTurnDetailed(llm, route, conversation, opts = {})
89
230
  source: { kind: 'plugin', plugin: 'memory-eternal' },
90
231
  })]
91
232
 
233
+ const maxTokens = opts.maxTokens ?? DEFAULT_CAPTURE_MAX_TOKENS
234
+ // #15:route.reasoningEffort === null 表示该 provider 明确声明不支持 reasoning effort,
235
+ // 此时**不传**这个字段(传了会被 LLM 层硬校验拒绝,等于每次沉淀先白烧一次调用)。
236
+ const effort = route.reasoningEffort === undefined ? 'off' : route.reasoningEffort
92
237
  const options = {
93
238
  provider: route.provider,
94
239
  model: route.model,
95
240
  messages,
96
241
  system,
97
- maxTokens: opts.maxTokens ?? 900,
98
- reasoningEffort: 'off',
242
+ maxTokens,
243
+ ...(effort ? { reasoningEffort: effort } : {}),
99
244
  purpose: 'memory-capture',
100
245
  signal: opts.signal ?? AbortSignal.timeout(45000),
101
246
  }
@@ -111,10 +256,24 @@ export async function summarizeTurnDetailed(llm, route, conversation, opts = {})
111
256
  },
112
257
  }
113
258
  }
259
+ // #18 根因 1:撞到输出上限时 finish.kind === 'max-tokens'(dsh-llm 的 FinishReasonMap)。
260
+ // 旧实现只认 error/aborted,于是截断的 JSON 一路落到 parseCaptureJson 上被误报成
261
+ // UNPARSEABLE_OUTPUT —— 真实原因(输出被截断)被完全掩盖。这里给独立错误码。
262
+ if (finish.kind === 'max-tokens') {
263
+ return {
264
+ failure: {
265
+ code: 'MAX_TOKENS',
266
+ message: `输出撞到 maxTokens=${maxTokens} 上限被截断(不是解析失败):调大「蒸馏输出上限」captureMaxTokens,或缩短会话输入`,
267
+ },
268
+ }
269
+ }
114
270
  const out = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
115
271
  const parsed = parseCaptureJson(out)
116
272
  if (!parsed) {
117
- return { failure: { code: 'UNPARSEABLE_OUTPUT', message: out ? '模型输出无法解析为 JSON:' + out.slice(0, 160) : '模型返回空文本' } }
273
+ const why = out ? '模型输出无法解析为 JSON' : '模型返回空文本'
274
+ // 括号没闭合 = 截断的强特征:即便宿主没给 max-tokens finish,也把方向指对(#18 建议 1)
275
+ const hint = out && looksTruncatedJson(out) ? '(JSON 括号未闭合,疑似输出被截断)' : ''
276
+ return { failure: { code: 'UNPARSEABLE_OUTPUT', message: why + hint + (out ? ':' + out.slice(0, 160) : '') } }
118
277
  }
119
278
  return { card: parsed }
120
279
  }
@@ -186,6 +345,30 @@ async function importLlmHelpers() {
186
345
  }
187
346
  }
188
347
 
348
+ /**
349
+ * 判断一段文本的 JSON 结构是否**没闭合**(截断的强特征,issue #18 建议 1)。
350
+ * 只做括号/引号配对扫描,不解析;用于把「疑似被截断」和「模型乱输出」区分开。
351
+ */
352
+ export function looksTruncatedJson(text) {
353
+ const s = String(text || '').trim()
354
+ if (!s) return false
355
+ let depth = 0
356
+ let inStr = false
357
+ let esc = false
358
+ for (const ch of s) {
359
+ if (inStr) {
360
+ if (esc) esc = false
361
+ else if (ch === '\\') esc = true
362
+ else if (ch === '"') inStr = false
363
+ continue
364
+ }
365
+ if (ch === '"') inStr = true
366
+ else if (ch === '{' || ch === '[') depth++
367
+ else if (ch === '}' || ch === ']') depth--
368
+ }
369
+ return depth > 0 || inStr
370
+ }
371
+
189
372
  export function parseCaptureJson(text) {
190
373
  if (!text) return null
191
374
  // 去掉可能的 ```json 围栏
@@ -215,6 +398,26 @@ export function parseCaptureJson(text) {
215
398
  const title = String(parsed.title || '').trim().slice(0, 80)
216
399
  const body = String(parsed.body || '').trim()
217
400
  if (!title || body.length < 20) return null
401
+ // 标题体检(P0 噪声闸门):标题必须是「像标题」的一句话(去掉标点后 ≥4 字、不以标点/反引号开头、
402
+ // 不被截断)。糊掉的标题会让审核中心无法判断,也会在召回结果里变成噪声。
403
+ // 但**不放行垃圾标题、也不吞掉正文**:标题不合格时从正文首行派生一个,派生不出来才算无效。
404
+ if (!isUsableTitle(title)) {
405
+ const derived = deriveTitle(body)
406
+ if (!isUsableTitle(derived)) return null
407
+ return finishCard(parsed, derived, body)
408
+ }
409
+ return finishCard(parsed, title, body)
410
+ }
411
+
412
+ /** 标题是否「像标题」:≥4 个字母/数字字符,且不是碎片(错位开头 / 未完结结尾)。 */
413
+ export function isUsableTitle(title) {
414
+ const t = String(title || '').trim()
415
+ if (!t || looksTruncated(t)) return false
416
+ return t.replace(/[^\p{L}\p{N}]/gu, '').length >= 4
417
+ }
418
+
419
+ /** 组装最终卡片对象(标题已体检通过)。 */
420
+ function finishCard(parsed, title, body) {
218
421
  const kind = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake'].includes(parsed.kind) ? parsed.kind : 'knowledge'
219
422
  const tags = Array.isArray(parsed.tags)
220
423
  ? parsed.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 8)
@@ -222,20 +425,30 @@ export function parseCaptureJson(text) {
222
425
  return { save: true, title, kind, tags, body }
223
426
  }
224
427
 
225
- /** 从会话事件里提取「增量片段」的可读对话文本(用户 + 助手)。 */
428
+ /**
429
+ * 从会话事件里提取「增量片段」的可读对话文本(用户 + 助手)。
430
+ *
431
+ * 每条消息先过 `stripRuntimeNoise`:DSH 注入的环境快照 / team 广播 / teammate 原文 /
432
+ * 工具说明都不是用户的真实表达,混进来会变成垃圾卡(P0 修复)。剥离后为空的消息直接丢。
433
+ */
226
434
  export function extractLastTurn(events, _turn) {
227
435
  const lines = []
436
+ const push = (role, raw) => {
437
+ const text = stripRuntimeNoise(raw)
438
+ if (!text) return
439
+ lines.push(`${role}:${text}`)
440
+ }
228
441
  const pushMessage = (event) => {
229
442
  const data = event.data || event
230
443
  const message = data.message || data
231
- const role = message.role
444
+ const role = message.role === 'user' ? '用户' : '助手'
232
445
  const content = message.content
233
446
  if (Array.isArray(content)) {
234
447
  for (const block of content) {
235
- if (block.type === 'text' && block.text) lines.push(`${role === 'user' ? '用户' : '助手'}:${block.text}`)
448
+ if (block.type === 'text' && block.text) push(role, block.text)
236
449
  }
237
450
  } else if (typeof content === 'string' && content) {
238
- lines.push(`${role === 'user' ? '用户' : '助手'}:${content}`)
451
+ push(role, content)
239
452
  }
240
453
  }
241
454
  for (const event of events) {
@@ -247,10 +460,10 @@ export function extractLastTurn(events, _turn) {
247
460
  if (type === 'assistant/message') pushMessage(data)
248
461
  else if (data.chunk?.content) {
249
462
  const content = data.chunk.content
250
- if (typeof content === 'string') lines.push(`助手:${content}`)
463
+ if (typeof content === 'string') push('助手', content)
251
464
  else if (Array.isArray(content)) {
252
465
  for (const block of content) {
253
- if (block.type === 'text' && block.text) lines.push(`助手:${block.text}`)
466
+ if (block.type === 'text' && block.text) push('助手', block.text)
254
467
  }
255
468
  }
256
469
  }
@@ -371,25 +584,76 @@ export async function routeCandidates(llm, opts = {}) {
371
584
  try {
372
585
  const providers = llm.listProviders() || []
373
586
  if (!providers.length) return out
587
+ // 候选按「能力」排序:明确不支持 reasoning effort 的往后放(#15 问题 2)。
588
+ // 注意**不删除**它们 —— 不传 reasoningEffort 之后它们完全可能正常出卡,
589
+ // 而删掉它们会让「候选全不支持」的机器直接没有兜底(见 #15 的实测:四个网关全不支持)。
590
+ const push = (provider, model, modelInfo) => {
591
+ if (!provider || !provider.id || !model) return
592
+ if (out.some((r) => r.provider === provider.id)) return
593
+ const entry = { provider: provider.id, model }
594
+ if (!supportsReasoningEffort(provider, modelInfo)) entry.reasoningEffort = null
595
+ out.push(entry)
596
+ }
597
+ const modelsOf = async (id) => {
598
+ const list = await llm.listModels(id)
599
+ return Array.isArray(list) ? list : []
600
+ }
374
601
  if (want) {
375
602
  const hit = providers.find((p) => p && p.id === want)
376
603
  if (hit) {
377
- const models = (await llm.listModels(hit.id)) || []
604
+ const models = await modelsOf(hit.id)
378
605
  const model = wantModel || (models[0] && models[0].id)
379
- if (model) out.push({ provider: hit.id, model })
606
+ push(hit, model, models.find((m) => m && m.id === model) || null)
380
607
  }
381
608
  }
609
+ const rest = []
382
610
  for (const p of providers) {
383
611
  if (!p || !p.id || out.some((r) => r.provider === p.id)) continue
384
- const models = (await llm.listModels(p.id)) || []
612
+ const models = await modelsOf(p.id)
385
613
  const model = (p.id === want && wantModel) || (models[0] && models[0].id)
386
- if (model) out.push({ provider: p.id, model })
614
+ if (!model) continue
615
+ rest.push({ p, model, info: models.find((m) => m && m.id === model) || null })
616
+ }
617
+ // Array.prototype.sort 是稳定的:能力相同的候选保持原有的「注册顺序」语义
618
+ rest.sort((a, b) => Number(!supportsReasoningEffort(a.p, a.info)) - Number(!supportsReasoningEffort(b.p, b.info)))
619
+ for (const r of rest) {
387
620
  if (out.length >= limit) break
621
+ push(r.p, r.model, r.info)
388
622
  }
389
623
  } catch { /* 路由解析失败按空列表返回,由调用方兜底 */ }
390
624
  return out
391
625
  }
392
626
 
627
+ /**
628
+ * 该 provider/model 是否**明确声明**不支持 reasoning effort(#15 问题 2)。
629
+ *
630
+ * DSH 的 LLM 层会按 provider 声明的能力硬校验:声明不支持时,请求里带上
631
+ * reasoningEffort 会在发出前/发出时以 UNSUPPORTED_REASONING_EFFORT 失败。
632
+ * 声明位置随版本而异(provider.compat / provider.capabilities / 模型条目的 reasoning),
633
+ * 这里全部读一遍;**未知一律按「支持」处理**,避免把能力推断错成功能退化。
634
+ *
635
+ * @param {object|null} providerInfo llm.listProviders() 的条目
636
+ * @param {object|null} [modelInfo] llm.listModels() 的对应条目
637
+ * @returns {boolean}
638
+ */
639
+ export function supportsReasoningEffort(providerInfo, modelInfo = null) {
640
+ const flag = (o) => {
641
+ if (!o || typeof o !== 'object') return undefined
642
+ if (typeof o.supportsReasoningEffort === 'boolean') return o.supportsReasoningEffort
643
+ if (o.compat && typeof o.compat.supportsReasoningEffort === 'boolean') return o.compat.supportsReasoningEffort
644
+ if (o.capabilities && typeof o.capabilities.supportsReasoningEffort === 'boolean') return o.capabilities.supportsReasoningEffort
645
+ return undefined
646
+ }
647
+ const fromProvider = flag(providerInfo)
648
+ if (fromProvider === false) return false
649
+ const fromModel = flag(modelInfo)
650
+ if (fromModel === false) return false
651
+ // 模型条目自带 reasoning 能力表且**为空** → 该模型没有可选 effort,等于不支持
652
+ const reasoning = modelInfo && modelInfo.reasoning
653
+ if (reasoning && Array.isArray(reasoning.efforts) && reasoning.efforts.length === 0) return false
654
+ return true
655
+ }
656
+
393
657
  /** 解析当前模型路由:显式配置优先,其次第一个 provider 的首个模型(保持旧行为作兜底)。 */
394
658
  export async function resolveRoute(llm, opts = {}) {
395
659
  const list = await routeCandidates(llm, opts)