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/.claude-plugin/plugin.json +2 -16
- package/.codex-plugin/plugin.json +2 -16
- package/.cursor-plugin/plugin.json +2 -16
- package/.mcp.json +9 -9
- package/LICENSE +21 -21
- package/README.en.md +27 -7
- package/README.md +40 -7
- package/bin/dsh-memory.mjs +318 -188
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -0
- package/hooks/capture.mjs +77 -77
- package/hooks/claude-session.js +56 -56
- package/hooks/hooks.claude.json +12 -12
- package/hooks/hooks.codex.json +15 -15
- package/hooks/hooks.cursor.json +11 -11
- package/index.js +187 -32
- package/lib/api.js +592 -536
- package/lib/capture-log.js +65 -59
- package/lib/capture-run.js +134 -126
- package/lib/capture.js +278 -14
- package/lib/client.js +20 -17
- package/lib/config-sync.js +161 -89
- package/lib/db.js +359 -212
- package/lib/feedback.js +214 -205
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +197 -166
- package/lib/node-bin.js +99 -99
- package/lib/setup.js +470 -470
- package/lib/sse.js +51 -51
- package/lib/stall.js +30 -30
- package/lib/sweep.js +56 -56
- package/lib/vault-resolve.js +84 -84
- package/lib/vault.js +347 -106
- package/lib/watchdog.js +527 -392
- package/lib/web-assets.js +41 -0
- package/lib/web.js +316 -176
- package/package.json +12 -7
- package/scripts/release.mjs +204 -0
- package/skills/memory-recall/SKILL.md +14 -14
- package/web/app.js +20 -17
- package/web/index.html +46 -46
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
|
-
|
|
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
|
|
98
|
-
reasoningEffort:
|
|
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
|
-
|
|
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)
|
|
448
|
+
if (block.type === 'text' && block.text) push(role, block.text)
|
|
236
449
|
}
|
|
237
450
|
} else if (typeof content === 'string' && content) {
|
|
238
|
-
|
|
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')
|
|
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)
|
|
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 =
|
|
604
|
+
const models = await modelsOf(hit.id)
|
|
378
605
|
const model = wantModel || (models[0] && models[0].id)
|
|
379
|
-
|
|
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 =
|
|
612
|
+
const models = await modelsOf(p.id)
|
|
385
613
|
const model = (p.id === want && wantModel) || (models[0] && models[0].id)
|
|
386
|
-
if (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)
|