memory-eternal 0.10.3 → 0.10.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/lib/capture.js CHANGED
@@ -268,14 +268,18 @@ export async function summarizeTurnDetailed(llm, route, conversation, opts = {})
268
268
  }
269
269
  }
270
270
  const out = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
271
- const parsed = parseCaptureJson(out)
272
- if (!parsed) {
271
+ const parsed = parseCaptureJsonDetailed(out)
272
+ if (!parsed.card) {
273
273
  const why = out ? '模型输出无法解析为 JSON' : '模型返回空文本'
274
274
  // 括号没闭合 = 截断的强特征:即便宿主没给 max-tokens finish,也把方向指对(#18 建议 1)
275
275
  const hint = out && looksTruncatedJson(out) ? '(JSON 括号未闭合,疑似输出被截断)' : ''
276
- return { failure: { code: 'UNPARSEABLE_OUTPUT', message: why + hint + (out ? ':' + out.slice(0, 160) : '') } }
276
+ // #24:JSON.parse 的原始报错(如 Bad control character in string literal at position N)
277
+ // 以前被两个裸 catch{} 完全吞掉,排查只能靠猜;现场摘要也从「只留前 160 字」改成首尾各留一段。
278
+ const raw = parsed.error ? '(JSON.parse 原始报错:' + parsed.error + ')' : ''
279
+ return { failure: { code: 'UNPARSEABLE_OUTPUT', message: why + hint + raw + (out ? ':' + describeOutputExcerpt(out) : '') } }
277
280
  }
278
- return { card: parsed }
281
+ // repaired=true = 走了「字符串内裸控制字符转义」这条容错路径(#24),排障时能看到它真的生效了。
282
+ return { card: parsed.card, repaired: parsed.repaired }
279
283
  }
280
284
 
281
285
  /**
@@ -369,24 +373,65 @@ export function looksTruncatedJson(text) {
369
373
  return depth > 0 || inStr
370
374
  }
371
375
 
372
- export function parseCaptureJson(text) {
373
- if (!text) return null
374
- // 去掉可能的 ```json 围栏
375
- const cleaned = text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim()
376
- let parsed
377
- try {
378
- parsed = JSON.parse(cleaned)
379
- } catch {
380
- // 容错:截取第一个 { 到最后一个 }
381
- const start = cleaned.indexOf('{')
382
- const end = cleaned.lastIndexOf('}')
383
- if (start < 0 || end <= start) return null
384
- try {
385
- parsed = JSON.parse(cleaned.slice(start, end + 1))
386
- } catch {
387
- return null
376
+ /**
377
+ * 把 JSON **字符串值内部**未转义的裸控制字符改成合法转义(issue #24)。
378
+ *
379
+ * 模型写 markdown 正文时经常把换行写成真实的 U+000A(而不是 `\n` 两字符),
380
+ * 这种输出括号是闭合的(looksTruncatedJson 得 false),却因为「字符串内不得出现
381
+ * 未转义控制字符」被 JSON.parse 必然拒绝 —— 旧实现只做「截 { … }」一层容错,
382
+ * 于是稳定地误报成 UNPARSEABLE_OUTPUT,真正的知识卡全丢进原文卡兜底。
383
+ *
384
+ * 只在字符串内部动手:字符串外的换行/制表符是合法 JSON 空白,改了反而是破坏。
385
+ * 标准转义(\n \r \t \b \f)用短写,其余控制字符转 \u00XX。
386
+ *
387
+ * @returns {{ text: string, repaired: boolean }} repaired=true 表示确实改过字符
388
+ */
389
+ export function repairJsonControlChars(text) {
390
+ const s = String(text || '')
391
+ let out = ''
392
+ let inStr = false
393
+ let esc = false
394
+ let repaired = false
395
+ for (let i = 0; i < s.length; i++) {
396
+ const ch = s[i]
397
+ if (!inStr) {
398
+ if (ch === '"') inStr = true
399
+ out += ch
400
+ continue
401
+ }
402
+ if (esc) { esc = false; out += ch; continue }
403
+ if (ch === '\\') { esc = true; out += ch; continue }
404
+ if (ch === '"') { inStr = false; out += ch; continue }
405
+ const code = ch.charCodeAt(0)
406
+ if (code < 0x20) {
407
+ repaired = true
408
+ out += code === 0x0a ? '\\n'
409
+ : code === 0x0d ? '\\r'
410
+ : code === 0x09 ? '\\t'
411
+ : code === 0x08 ? '\\b'
412
+ : code === 0x0c ? '\\f'
413
+ : '\\u' + code.toString(16).padStart(4, '0')
414
+ continue
388
415
  }
416
+ out += ch
389
417
  }
418
+ return { text: out, repaired }
419
+ }
420
+
421
+ /**
422
+ * 失败现场摘要:首尾各留一段,中间省略多少字写清楚。
423
+ *
424
+ * 旧实现 `out.slice(0, 160)` 把正文后半段全砍了,而模型输出恰好经常被切在
425
+ * `"body": "# ...` 的半截处 —— 看上去更像「输出断了」,把排查引向错误方向(#24)。
426
+ */
427
+ export function describeOutputExcerpt(text, { head = 120, tail = 120 } = {}) {
428
+ const s = String(text || '')
429
+ if (s.length <= head + tail + 20) return s
430
+ return s.slice(0, head) + '…(中间省略 ' + (s.length - head - tail) + ' 字)…' + s.slice(-tail)
431
+ }
432
+
433
+ /** 解析出来的对象 → 知识卡(返回 null = 解析成功但内容不合格 / 不值得保存)。 */
434
+ function cardFromParsedJson(parsed) {
390
435
  if (!parsed || parsed.save === false) return { save: false }
391
436
  // 追加已有卡片:append_to 命中的优先级最高
392
437
  if (parsed.append_to) {
@@ -409,6 +454,69 @@ export function parseCaptureJson(text) {
409
454
  return finishCard(parsed, title, body)
410
455
  }
411
456
 
457
+ /**
458
+ * 解析模型输出:返回「卡片 + 解析诊断」。
459
+ *
460
+ * 候选串顺序(#24):原样 → 截 \`{ … }\` → 各自的「字符串内裸控制字符已转义」版本。
461
+ * 只要有一个 JSON.parse 成功就停在那一个上(解析成功但语义不合格时不再换候选 ——
462
+ * 换候选只会把同一份内容再判一遍,与旧实现行为一致)。
463
+ *
464
+ * @returns {{ card: object|null, parsed: boolean, error: string, repaired: boolean }}
465
+ * parsed=false → 所有候选都没能 JSON.parse(error 里是**原始**报错,不再吞掉)
466
+ * parsed=true → JSON 解析成功;card=null 表示内容不合格 / 不值得保存
467
+ */
468
+ export function parseCaptureJsonDetailed(text) {
469
+ if (!text) return { card: null, parsed: false, error: '空输出', repaired: false }
470
+ // 去掉可能的 ```json 围栏
471
+ const cleaned = String(text).replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim()
472
+ const bases = [cleaned]
473
+ const start = cleaned.indexOf('{')
474
+ const end = cleaned.lastIndexOf('}')
475
+ if (start >= 0 && end > start) bases.push(cleaned.slice(start, end + 1))
476
+ const candidates = []
477
+ for (const base of bases) candidates.push({ text: base, repaired: false })
478
+ for (const base of bases) {
479
+ const fixed = repairJsonControlChars(base)
480
+ if (fixed.repaired) candidates.push({ text: fixed.text, repaired: true })
481
+ }
482
+ let lastError = ''
483
+ for (const candidate of candidates) {
484
+ let parsed
485
+ try {
486
+ parsed = JSON.parse(candidate.text)
487
+ } catch (error) {
488
+ lastError = String((error && error.message) || error)
489
+ continue
490
+ }
491
+ return { card: cardFromParsedJson(parsed), parsed: true, error: '', repaired: candidate.repaired }
492
+ }
493
+ return { card: null, parsed: false, error: lastError || '输出不是合法 JSON', repaired: false }
494
+ }
495
+
496
+ /** 兼容旧签名:解析成卡片;解析失败 / 内容不合格返回 null。 */
497
+ export function parseCaptureJson(text) {
498
+ return parseCaptureJsonDetailed(text).card
499
+ }
500
+
501
+ /**
502
+ * 撞到输出上限后的重试上限序列(#24):一路翻倍到 schema 上限,不再只翻一次。
503
+ *
504
+ * 旧实现 index.js 里只做一次 倍=2 的重试:1200 → 2400 仍不够就退成原文卡,
505
+ * 尽管 4000 本来装得下。抽成纯函数,便于单测这条序列。
506
+ *
507
+ * @returns {number[]} 例如 start=1200, cap=4000 → [2400, 4000];start>=cap → []
508
+ */
509
+ export function maxTokenLadder(start, cap = MAX_CAPTURE_MAX_TOKENS) {
510
+ const out = []
511
+ const ceiling = Number(cap) || MAX_CAPTURE_MAX_TOKENS
512
+ let cur = Number(start) || DEFAULT_CAPTURE_MAX_TOKENS
513
+ while (cur < ceiling) {
514
+ cur = Math.min(cur * 2, ceiling)
515
+ out.push(cur)
516
+ }
517
+ return out
518
+ }
519
+
412
520
  /** 标题是否「像标题」:≥4 个字母/数字字符,且不是碎片(错位开头 / 未完结结尾)。 */
413
521
  export function isUsableTitle(title) {
414
522
  const t = String(title || '').trim()