memory-eternal 0.10.0 → 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.
@@ -1,59 +1,65 @@
1
- // 记忆核心 · 自动沉淀日志的持久层(JSONL 追加)。
2
- //
3
- // 为什么要有它:
4
- // 1) 独立 Web 页(lib/web.js 起的进程)拿不到宿主内存里的日志,面板只能显示
5
- // 「日志仅在 DSH 宿主内可用」——用户看到的就是"记录里没内容";
6
- // 2) 宿主重启后内存环形缓冲清空,历史诊断信息全丢。
7
- // 落一个 JSONL 到 DSH_HOME 里,两边都能读:宿主写、两边读,最多保留最近 500 条。
8
-
9
- import { promises as fs } from 'node:fs'
10
- import path from 'node:path'
11
- import os from 'node:os'
12
-
13
- const FILE = 'memory-eternal-capture.jsonl'
14
- export const CAPTURE_LOG_MAX_LINES = 500
15
-
16
- /** 日志文件路径(与 vault 同级的 DSH_HOME 下)。 */
17
- export function captureLogPath(env = process.env) {
18
- const home = env.DSH_HOME || path.join(os.homedir(), '.dsh')
19
- return path.join(home, FILE)
20
- }
21
-
22
- /** 追加一条(失败静默:日志不能反过来搞坏沉淀管线)。 */
23
- export async function appendCaptureLog(entry, env = process.env) {
24
- try { await fs.appendFile(captureLogPath(env), JSON.stringify(entry) + '\n', 'utf8') } catch { /* 忽略 */ }
25
- }
26
-
27
- /** 读最近 limit 条,**最新在前**(与 /capture-log 接口语义一致)。 */
28
- export async function readCaptureLog(limit = 100, env = process.env) {
29
- try {
30
- const txt = await fs.readFile(captureLogPath(env), 'utf8')
31
- const lines = txt.split('\n').filter(Boolean)
32
- const out = []
33
- for (const line of lines.slice(-limit)) {
34
- try { out.push(JSON.parse(line)) } catch { /* 跳过坏行 */ }
35
- }
36
- return out.reverse()
37
- } catch { return [] }
38
- }
39
-
40
- /** 从日志推健康状态(独立 Web 页没有宿主内存状态,只能看最近一条结论)。 */
41
- export function deriveHealth(entries) {
42
- for (const e of entries || []) {
43
- if (e.action === 'fail') return { ok: false, reason: e.reason || '', since: e.time || 0, lastOkAt: 0, lastFailAt: e.time || 0 }
44
- if (e.action === 'created' || e.action === 'appended') return { ok: true, reason: '', since: 0, lastOkAt: e.time || 0, lastFailAt: 0 }
45
- }
46
- return null
47
- }
48
-
49
- /** 行数超上限时重写为最近 CAPTURE_LOG_MAX_LINES 行(由宿主定期调用)。 */
50
- export async function rotateCaptureLog(env = process.env) {
51
- try {
52
- const p = captureLogPath(env)
53
- const txt = await fs.readFile(p, 'utf8')
54
- const lines = txt.split('\n').filter(Boolean)
55
- if (lines.length <= CAPTURE_LOG_MAX_LINES) return false
56
- await fs.writeFile(p, lines.slice(-CAPTURE_LOG_MAX_LINES).join('\n') + '\n', 'utf8')
57
- return true
58
- } catch { return false }
59
- }
1
+ // 记忆核心 · 自动沉淀日志的持久层(JSONL 追加)。
2
+ //
3
+ // 为什么要有它:
4
+ // 1) 独立 Web 页(lib/web.js 起的进程)拿不到宿主内存里的日志,面板只能显示
5
+ // 「日志仅在 DSH 宿主内可用」——用户看到的就是"记录里没内容";
6
+ // 2) 宿主重启后内存环形缓冲清空,历史诊断信息全丢。
7
+ // 落一个 JSONL 到 DSH_HOME 里,两边都能读:宿主写、两边读,最多保留最近 500 条。
8
+
9
+ import { promises as fs } from 'node:fs'
10
+ import path from 'node:path'
11
+ import os from 'node:os'
12
+
13
+ const FILE = 'memory-eternal-capture.jsonl'
14
+ export const CAPTURE_LOG_MAX_LINES = 500
15
+
16
+ /** 日志文件路径(与 vault 同级的 DSH_HOME 下)。 */
17
+ export function captureLogPath(env = process.env) {
18
+ const home = env.DSH_HOME || path.join(os.homedir(), '.dsh')
19
+ return path.join(home, FILE)
20
+ }
21
+
22
+ /** 追加一条(失败静默:日志不能反过来搞坏沉淀管线)。 */
23
+ export async function appendCaptureLog(entry, env = process.env) {
24
+ try {
25
+ const file = captureLogPath(env)
26
+ // DSH_HOME 还不存在时先建出来:否则 appendFile 直接 ENOENT,而这里又是静默失败 ——
27
+ // 诊断信息(如「插件安装不完整」)会连一条都留不下。
28
+ await fs.mkdir(path.dirname(file), { recursive: true })
29
+ await fs.appendFile(file, JSON.stringify(entry) + '\n', 'utf8')
30
+ } catch { /* 忽略 */ }
31
+ }
32
+
33
+ /** 读最近 limit 条,**最新在前**(与 /capture-log 接口语义一致)。 */
34
+ export async function readCaptureLog(limit = 100, env = process.env) {
35
+ try {
36
+ const txt = await fs.readFile(captureLogPath(env), 'utf8')
37
+ const lines = txt.split('\n').filter(Boolean)
38
+ const out = []
39
+ for (const line of lines.slice(-limit)) {
40
+ try { out.push(JSON.parse(line)) } catch { /* 跳过坏行 */ }
41
+ }
42
+ return out.reverse()
43
+ } catch { return [] }
44
+ }
45
+
46
+ /** 从日志推健康状态(独立 Web 页没有宿主内存状态,只能看最近一条结论)。 */
47
+ export function deriveHealth(entries) {
48
+ for (const e of entries || []) {
49
+ if (e.action === 'fail') return { ok: false, reason: e.reason || '', since: e.time || 0, lastOkAt: 0, lastFailAt: e.time || 0 }
50
+ if (e.action === 'created' || e.action === 'appended') return { ok: true, reason: '', since: 0, lastOkAt: e.time || 0, lastFailAt: 0 }
51
+ }
52
+ return null
53
+ }
54
+
55
+ /** 行数超上限时重写为最近 CAPTURE_LOG_MAX_LINES 行(由宿主定期调用)。 */
56
+ export async function rotateCaptureLog(env = process.env) {
57
+ try {
58
+ const p = captureLogPath(env)
59
+ const txt = await fs.readFile(p, 'utf8')
60
+ const lines = txt.split('\n').filter(Boolean)
61
+ if (lines.length <= CAPTURE_LOG_MAX_LINES) return false
62
+ await fs.writeFile(p, lines.slice(-CAPTURE_LOG_MAX_LINES).join('\n') + '\n', 'utf8')
63
+ return true
64
+ } catch { return false }
65
+ }
package/lib/capture.js CHANGED
@@ -19,6 +19,18 @@ 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
 
@@ -218,13 +230,17 @@ export async function summarizeTurnDetailed(llm, route, conversation, opts = {})
218
230
  source: { kind: 'plugin', plugin: 'memory-eternal' },
219
231
  })]
220
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
221
237
  const options = {
222
238
  provider: route.provider,
223
239
  model: route.model,
224
240
  messages,
225
241
  system,
226
- maxTokens: opts.maxTokens ?? 900,
227
- reasoningEffort: 'off',
242
+ maxTokens,
243
+ ...(effort ? { reasoningEffort: effort } : {}),
228
244
  purpose: 'memory-capture',
229
245
  signal: opts.signal ?? AbortSignal.timeout(45000),
230
246
  }
@@ -240,10 +256,24 @@ export async function summarizeTurnDetailed(llm, route, conversation, opts = {})
240
256
  },
241
257
  }
242
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
+ }
243
270
  const out = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
244
271
  const parsed = parseCaptureJson(out)
245
272
  if (!parsed) {
246
- 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) : '') } }
247
277
  }
248
278
  return { card: parsed }
249
279
  }
@@ -315,6 +345,30 @@ async function importLlmHelpers() {
315
345
  }
316
346
  }
317
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
+
318
372
  export function parseCaptureJson(text) {
319
373
  if (!text) return null
320
374
  // 去掉可能的 ```json 围栏
@@ -530,25 +584,76 @@ export async function routeCandidates(llm, opts = {}) {
530
584
  try {
531
585
  const providers = llm.listProviders() || []
532
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
+ }
533
601
  if (want) {
534
602
  const hit = providers.find((p) => p && p.id === want)
535
603
  if (hit) {
536
- const models = (await llm.listModels(hit.id)) || []
604
+ const models = await modelsOf(hit.id)
537
605
  const model = wantModel || (models[0] && models[0].id)
538
- if (model) out.push({ provider: hit.id, model })
606
+ push(hit, model, models.find((m) => m && m.id === model) || null)
539
607
  }
540
608
  }
609
+ const rest = []
541
610
  for (const p of providers) {
542
611
  if (!p || !p.id || out.some((r) => r.provider === p.id)) continue
543
- const models = (await llm.listModels(p.id)) || []
612
+ const models = await modelsOf(p.id)
544
613
  const model = (p.id === want && wantModel) || (models[0] && models[0].id)
545
- 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) {
546
620
  if (out.length >= limit) break
621
+ push(r.p, r.model, r.info)
547
622
  }
548
623
  } catch { /* 路由解析失败按空列表返回,由调用方兜底 */ }
549
624
  return out
550
625
  }
551
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
+
552
657
  /** 解析当前模型路由:显式配置优先,其次第一个 provider 的首个模型(保持旧行为作兜底)。 */
553
658
  export async function resolveRoute(llm, opts = {}) {
554
659
  const list = await routeCandidates(llm, opts)