memory-eternal 0.16.0 → 0.18.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/index.js CHANGED
@@ -31,9 +31,10 @@ import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
31
31
  import { evaluateStall, MAX_STAMPS } from './lib/stall.js'
32
32
  import { resolveVaultDir, currentWorkspace } from './lib/vault-resolve.js'
33
33
  import { createHub } from './lib/sse.js'
34
- import { summarizeTurn, summarizeTurnDetailed, routeCandidates, sessionRouteFrom, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, maxTokenLadder, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
34
+ import { summarizeTurn, summarizeTurnDetailed, routeCandidates, sessionRouteFrom, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, maxTokenLadder, guessRawKind, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
35
35
  import { createApi, json, encodeBody, crossOriginError, emptyNumericFields } from './lib/api.js'
36
36
  import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
37
+ import { recordLedger } from './lib/ledger.js'
37
38
  import { missingWebAssets, missingAssetsReason } from './lib/web-assets.js'
38
39
  import { writeHostMarker, clearHostMarker } from './lib/host-heartbeat.js'
39
40
  import { nodeBinary, childEnv } from './lib/node-bin.js'
@@ -53,6 +54,10 @@ export const Config = z.object({
53
54
  enabled: z.boolean().default(true),
54
55
  autoCapture: z.boolean().default(true),
55
56
  autoRecall: z.boolean().default(true),
57
+ // 核心记忆:人工批准的核心卡,每次会话强制注入 systemPrompt(不依赖 autoRecall / 工具调用)
58
+ coreMemoryEnabled: z.boolean().default(true),
59
+ coreMemoryLimit: z.number().min(1).max(20).default(6),
60
+ coreMemoryMaxChars: z.number().min(200).max(10000).default(2000),
56
61
  vaultDir: z.string().default(''),
57
62
  dedupThreshold: z.number().min(0).max(1).default(0.62),
58
63
  captureMinChars: z.number().default(200),
@@ -633,6 +638,8 @@ export function apply(ctx, config) {
633
638
 
634
639
  // 自动审核:根据配置决定新卡 status(免审→approved,否则 pending)
635
640
  const resolveAuditStatus = (cfg, kind, submittedBy) => {
641
+ // 核心记忆必须人工审核:自动沉淀/手动写入都先待审,避免一次误判污染每个会话。
642
+ if (kind === 'core') return 'pending'
636
643
  if (cfg.auditMode === 'none') return 'approved'
637
644
  const agents = Array.isArray(cfg.auditExemptAgents) ? cfg.auditExemptAgents : []
638
645
  const kinds = Array.isArray(cfg.auditExemptKinds) ? cfg.auditExemptKinds : []
@@ -683,18 +690,19 @@ export function apply(ctx, config) {
683
690
  const source = DSH_AGENT
684
691
  if (cfg.distillEnabled === false || !llm) {
685
692
  const rawTitle = deriveTitle(text)
693
+ const rawKind = guessRawKind({ title: rawTitle, body: text })
686
694
  const out = await captureCard(root, {
687
- kind: 'content',
695
+ kind: rawKind,
688
696
  title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
689
697
  tags: ['raw'],
690
698
  body: text,
691
699
  source,
692
- status: resolveAuditStatus(cfg, 'content', source || 'unknown'),
700
+ status: resolveAuditStatus(cfg, rawKind, source || 'unknown'),
693
701
  submittedBy: source || 'unknown',
694
702
  severity: 'info',
695
703
  reason: 'AI 自动沉淀(原文卡)',
696
704
  }, { threshold: cfg.dedupThreshold })
697
- if (out.ok) { countWrite(); logCapture(sessionId, 'created', '原文卡(蒸馏已关闭)', { path: out.path ?? out.rel, kind: 'content' }) }
705
+ if (out.ok) { countWrite(); logCapture(sessionId, 'created', '原文卡(蒸馏已关闭)', { path: out.path ?? out.rel, kind: rawKind }) }
698
706
  else if (out.duplicate) { countWrite(); logCapture(sessionId, 'appended', '与已有卡重复 → 追加更新记录', { path: out.duplicate.path }) }
699
707
  else logCapture(sessionId, 'fail', `写卡失败:${out.reason || '未知原因'}`)
700
708
  return
@@ -737,6 +745,8 @@ export function apply(ctx, config) {
737
745
  let failure = null // 主失败 = **第一个**候选的失败(通常是显式配置的那个,信息量最大)
738
746
  const failures = [] // 全部候选的失败,用于汇总上报(#18 根因 2)
739
747
  const maxTokens = Number(cfg.captureMaxTokens) || DEFAULT_CAPTURE_MAX_TOKENS
748
+ const captureT0 = Date.now()
749
+ let llmCalls = 0
740
750
  for (let i = 0; i < routes.length; i++) {
741
751
  route = routes[i]
742
752
  // issue #48:超时不再硬编码 45s(本地 30B 冷启动 40–60s 必超时),且超时后**同候选**用更长超时重试一次。
@@ -744,7 +754,7 @@ export function apply(ctx, config) {
744
754
  const isTimeoutFailure = (x) => /ABORTED|TIMEOUT|ETIMEDOUT|aborted/i.test(String((x && (x.code + ' ' + x.message)) || ''))
745
755
  // issue #51:把真实会话 id 透传给 LLM(opencode-go 网关靠它注入 x-opencode-session,
746
756
  // 缺了首跳固定 400 MissingSessionID)。同一会话的蒸馏请求也因此能被网关按会话归并。
747
- const attempt = (budget, ms) => summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(ms), existing: neighbors, maxTokens: budget, sessionId })
757
+ const attempt = (budget, ms) => { llmCalls++; return summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(ms), existing: neighbors, maxTokens: budget, sessionId }) }
748
758
  let detailed = await attempt(maxTokens, baseTimeout)
749
759
  if (detailed.failure && isTimeoutFailure(detailed.failure)) {
750
760
  const longer = Math.min(Math.max(Math.round(baseTimeout * 2.5), baseTimeout + 30000), 180000)
@@ -781,6 +791,21 @@ export function apply(ctx, config) {
781
791
  }
782
792
  break // skip(太短 / 不值得保存)或没有更多候选
783
793
  }
794
+ // 账本:记下这次沉淀的真实开销(输入字符 / LLM 调用次数 / 输出字符),失败也记(便于看失败率与白烧次数)
795
+ try {
796
+ recordLedger(root, {
797
+ kind: 'capture',
798
+ session: sessionId,
799
+ charsIn: String(text || '').length,
800
+ chars: result ? JSON.stringify(result).length : 0,
801
+ llmCalls,
802
+ provider: route.provider,
803
+ model: route.model,
804
+ ms: Date.now() - captureT0,
805
+ ok: !!result,
806
+ note: failure ? failure.code : '',
807
+ })
808
+ } catch { /* 账本失败不影响沉淀主流程 */ }
784
809
  // 真失败要亮红并进提示段:此前只有笼统的「蒸馏无输出」,真实原因(如缺凭证)完全不可见。
785
810
  if (failure) { health.fail(`蒸馏失败(${failure.provider}):${failure.code} ${failure.message}`); touchPrompt() }
786
811
  // 主因 + 兜底候选失败汇总:卡片 reason 与 created 日志都用这个,避免把排查者引向错误方向。
@@ -788,20 +813,21 @@ export function apply(ctx, config) {
788
813
  if (!result) {
789
814
  // 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
790
815
  const rawTitle = deriveTitle(text)
816
+ const rawKind = guessRawKind({ title: rawTitle, body: text })
791
817
  const raw = await captureCard(root, {
792
- kind: 'content',
818
+ kind: rawKind,
793
819
  title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
794
820
  // 蒸馏失败产出的原文卡与被批准的噪声卡是审核中心的主要污染源(#15 评论):
795
821
  // 打上独立 tag,便于按 tag 过滤 / 批量处理,不再和正常卡混作一团。
796
822
  tags: ['raw', 'distill-failed'],
797
823
  body: text,
798
824
  source,
799
- status: resolveAuditStatus(cfg, 'content', source),
825
+ status: resolveAuditStatus(cfg, rawKind, source),
800
826
  submittedBy: source,
801
827
  severity: 'info',
802
828
  reason: `AI 自动沉淀(蒸馏无输出 → 原文卡兜底;主因:${why})`,
803
829
  }, { threshold: cfg.dedupThreshold })
804
- if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: 'content', model: route.model }) }
830
+ if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: rawKind, model: route.model }) }
805
831
  else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
806
832
  else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
807
833
  return
@@ -948,6 +974,50 @@ export function apply(ctx, config) {
948
974
  }
949
975
  }, 'memory-eternal: recall section')
950
976
 
977
+ // -- 2b. 核心记忆:每次会话强制注入(不依赖 autoRecall / 工具调用) ---------
978
+ ctx.effect(() => {
979
+ let disposeSection = null
980
+ let timer = null
981
+ let alive = true
982
+ let lastText = ''
983
+ const clearSection = () => {
984
+ if (!disposeSection) return
985
+ const dispose = disposeSection
986
+ disposeSection = null
987
+ dispose()
988
+ }
989
+ const refresh = async () => {
990
+ try {
991
+ const cfg = settings.get() ?? {}
992
+ if (cfg.enabled === false || cfg.coreMemoryEnabled === false) { clearSection(); lastText = ''; return }
993
+ const { coreMemoryPrompt } = await import('./lib/core-memory.js')
994
+ const out = await coreMemoryPrompt(vaultDir(), {
995
+ enabled: true,
996
+ limit: cfg.coreMemoryLimit,
997
+ maxChars: cfg.coreMemoryMaxChars,
998
+ })
999
+ if (!alive) return
1000
+ if (!out.text) { clearSection(); lastText = ''; return }
1001
+ if (out.text === lastText) return
1002
+ lastText = out.text
1003
+ clearSection()
1004
+ disposeSection = ctx.systemPrompt.section({
1005
+ name: 'memory-eternal: core-memory',
1006
+ order: 120,
1007
+ text: out.text,
1008
+ })
1009
+ } catch { /* 核心记忆失败不能影响普通对话 */ }
1010
+ }
1011
+ refresh()
1012
+ timer = setInterval(refresh, 5000)
1013
+ if (timer && typeof timer.unref === 'function') timer.unref()
1014
+ return () => {
1015
+ alive = false
1016
+ if (timer) clearInterval(timer)
1017
+ clearSection()
1018
+ }
1019
+ }, 'memory-eternal: core memory section')
1020
+
951
1021
  const tools = ctx.get('tools')
952
1022
  if (tools !== undefined) {
953
1023
  tools.register(defineTool({
@@ -1010,7 +1080,10 @@ export function apply(ctx, config) {
1010
1080
  } catch { /* 单个库失败不影响其它库 */ }
1011
1081
  }
1012
1082
  if (multi) hits = hits.slice(0, limit)
1013
- if (hits.length === 0) return `记忆库中没有与「${query}」相关的内容。`
1083
+ if (hits.length === 0) {
1084
+ recordLedger((targets[0] && targets[0].root) || vaultDir(workspace), { kind: 'recall', results: 0, chars: 0, note: query.slice(0, 120) })
1085
+ return `记忆库中没有与「${query}」相关的内容。`
1086
+ }
1014
1087
  const lines = hits.map((h, i) => {
1015
1088
  const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
1016
1089
  const snippet = String(h.summary || '').replace(/\s+/g, ' ').trim().slice(0, defLen)
@@ -1019,7 +1092,9 @@ export function apply(ctx, config) {
1019
1092
  return `### ${i + 1}. ${h.title}${tags}${from}\n路径:${h.path}\n${snippet}${body}`
1020
1093
  })
1021
1094
  const where = multi ? `(作用域 ${scope || 'all'},共 ${targets.length} 个库)` : ''
1022
- return `从记忆核心检索到 ${hits.length} 条相关卡片${where}:\n\n${lines.join('\n\n')}`
1095
+ const out = `从记忆核心检索到 ${hits.length} 条相关卡片${where}:\n\n${lines.join('\n\n')}`
1096
+ recordLedger((targets[0] && targets[0].root) || vaultDir(workspace), { kind: 'recall', results: hits.length, chars: out.length, note: query.slice(0, 120) })
1097
+ return out
1023
1098
  },
1024
1099
  }))
1025
1100
  }
@@ -1197,7 +1272,7 @@ export function apply(ctx, config) {
1197
1272
  const cfg = settings.get() ?? {}
1198
1273
  // 只暴露可安全展示/回填的字段
1199
1274
  const safe = {
1200
- autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody, secretHint: cfg.secretHint || '',
1275
+ autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, coreMemoryEnabled: cfg.coreMemoryEnabled, coreMemoryLimit: cfg.coreMemoryLimit, coreMemoryMaxChars: cfg.coreMemoryMaxChars, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody, secretHint: cfg.secretHint || '',
1201
1276
  captureMinChars: cfg.captureMinChars, captureCooldownMs: cfg.captureCooldownMs, dedupThreshold: cfg.dedupThreshold, maxCardsPerDay: cfg.maxCardsPerDay,
1202
1277
  distillEnabled: cfg.distillEnabled, dedupByLLM: cfg.dedupByLLM, captureMaxTokens: cfg.captureMaxTokens, recallMinScore: cfg.recallMinScore,
1203
1278
  autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoRestartOnDrift: cfg.autoRestartOnDrift, autoMcpSetup: cfg.autoMcpSetup,
package/lib/api.js CHANGED
@@ -16,10 +16,11 @@ import os from 'node:os'
16
16
  import fs, { readFileSync } from 'node:fs'
17
17
  import path from 'node:path'
18
18
  import { spawn } from 'node:child_process'
19
- import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard, mainStoreBodies } from './vault.js'
20
- import { compressExcerpt, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
19
+ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, setCardKind, restoreCard, mainStoreBodies } from './vault.js'
20
+ import { compressExcerpt, guessRawKind, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
21
21
  import { buildDiagnostics } from './feedback.js'
22
22
  import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
23
+ import { ledgerSummary } from './ledger.js'
23
24
  import { backupDb, dbStatus, rebuildGuidance, withDbRetry } from './db.js'
24
25
  import { createHub } from './sse.js'
25
26
  import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive, probeLockWritable } from './watchdog.js'
@@ -624,17 +625,44 @@ export function createApi(deps = {}) {
624
625
  json(res, 200, { ok: true, soft: !permanent })
625
626
  return
626
627
  }
628
+ case '/reclassify': {
629
+ // 重新识别待审原文卡的类型:修复旧版本「自动沉淀全是 content」的历史数据。
630
+ // 只处理带 raw / 原文卡标记的待审卡,不动人工创建的内容。
631
+ const dryRun = query.get('dry') === '1'
632
+ const { getDb } = await import('./db.js')
633
+ const db = getDb(vaultRoot)
634
+ const rows = db.prepare("SELECT id, path, title, body, tags, reason FROM quarantine WHERE status = 'pending'").all()
635
+ let scanned = 0, changed = 0
636
+ const byKind = {}
637
+ for (const row of rows) {
638
+ let tags = []
639
+ try { tags = JSON.parse(row.tags || '[]') } catch { tags = [] }
640
+ const isRaw = (Array.isArray(tags) && tags.includes('raw')) || /原文卡|自动沉淀/.test(String(row.reason || ''))
641
+ if (!isRaw) continue
642
+ scanned++
643
+ const kind = guessRawKind({ title: row.title, body: row.body })
644
+ if (kind === 'content') continue
645
+ changed++
646
+ byKind[kind] = (byKind[kind] || 0) + 1
647
+ if (!dryRun) db.prepare('UPDATE quarantine SET kind = ?, updated_at = ? WHERE id = ?').run(kind, new Date().toISOString(), row.id)
648
+ }
649
+ json(res, 200, { ok: true, dryRun, scanned, changed, byKind })
650
+ return
651
+ }
627
652
  case '/audit/list': {
628
653
  const { pending, rejected } = await auditQueue(vaultRoot)
629
654
  json(res, 200, { ok: true, pending, rejected })
630
655
  return
631
656
  }
632
657
  case '/audit/approve': {
633
- // POST {path} 或 GET ?path= → status=approved
634
- const rel = query.get('path') || (await readBody(req))?.path || ''
658
+ // POST {path, kind?} 或 GET ?path=&kind= → status=approved;kind=core 时同时设为核心
659
+ const body = query.get('path') ? null : await readBody(req)
660
+ const rel = query.get('path') || body?.path || ''
661
+ const kind = String(query.get('kind') || body?.kind || '').trim()
635
662
  if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
636
663
  await setCardStatus(vaultRoot, rel, 'approved')
637
- json(res, 200, { ok: true, path: rel, status: 'approved' })
664
+ if (kind === 'core' || kind === 'knowledge') await setCardKind(vaultRoot, rel, kind)
665
+ json(res, 200, { ok: true, path: rel, status: 'approved', kind: kind || undefined })
638
666
  return
639
667
  }
640
668
  case '/audit/reject': {
@@ -649,6 +677,21 @@ export function createApi(deps = {}) {
649
677
  json(res, 200, { ok: true, path: rel, status: 'rejected', reason })
650
678
  return
651
679
  }
680
+ case '/card-kind': {
681
+ // POST {path, kind:'core'|'knowledge'|...}:人工把已批准卡片设为核心/取消核心
682
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
683
+ const body = await readBody(req)
684
+ const rel = String(query.get('path') || body.path || '').trim()
685
+ const kind = String(body.kind || '').trim()
686
+ if (!rel || !kind) return json(res, 400, { ok: false, error: '缺少 path 或 kind' })
687
+ try {
688
+ const out = await setCardKind(vaultRoot, rel, kind)
689
+ json(res, 200, { ok: true, ...out })
690
+ } catch (e) {
691
+ json(res, 400, { ok: false, error: String(e?.message || e) })
692
+ }
693
+ return
694
+ }
652
695
  case '/recycle/list': {
653
696
  const items = await recycleList(vaultRoot)
654
697
  json(res, 200, { ok: true, items })
@@ -952,6 +995,14 @@ export function createApi(deps = {}) {
952
995
  json(res, 200, { ok: true })
953
996
  return
954
997
  }
998
+ case '/ledger': {
999
+ // 记忆账本:近 N 天召回注入 / 蒸馏调用开销 + 召回有用率
1000
+ try {
1001
+ const days = Number(query.get('days')) || 7
1002
+ json(res, 200, { ok: true, ...ledgerSummary(vaultRoot, { days }) })
1003
+ } catch (e) { json(res, 200, { ok: false, error: String(e?.message || e) }) }
1004
+ return
1005
+ }
955
1006
  case '/budget': {
956
1007
  try {
957
1008
  const cfg = getSettings() ?? {}
@@ -987,7 +1038,7 @@ export function createApi(deps = {}) {
987
1038
  const cfg = getSettings() ?? {}
988
1039
  const env = typeof process !== 'undefined' ? process.env : {}
989
1040
  const safe = {
990
- autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false, secretHint: cfg.secretHint || '',
1041
+ autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, coreMemoryEnabled: cfg.coreMemoryEnabled ?? true, coreMemoryLimit: cfg.coreMemoryLimit ?? 6, coreMemoryMaxChars: cfg.coreMemoryMaxChars ?? 2000, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false, secretHint: cfg.secretHint || '',
991
1042
  captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
992
1043
  distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? DEFAULT_CAPTURE_MAX_TOKENS, recallMinScore: cfg.recallMinScore ?? 2,
993
1044
  autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoRestartOnDrift: cfg.autoRestartOnDrift ?? true, autoMcpSetup: cfg.autoMcpSetup ?? false,
@@ -11,6 +11,7 @@ import fs from 'node:fs'
11
11
  import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates, stripRuntimeNoise, hasUsableContent, deriveTitle, isUsableTitle } from './capture.js'
12
12
  import { resolveVaultDir, defaultVaultRoot } from './vault-resolve.js'
13
13
  import { createLlmFromEnv } from './llm-openai.js'
14
+ import { recordLedger } from './ledger.js'
14
15
 
15
16
  /**
16
17
  * 解析当前 vault 目录。
@@ -63,6 +64,28 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
63
64
  const title = deriveTitle(clean)
64
65
  if (!isUsableTitle(title)) return { ok: false, action: 'skipped', reason: 'cannot derive a usable title' }
65
66
 
67
+ // 记忆账本:独立进程(MCP / CLI / hooks)的沉淀也要记账 —— 否则「哪个入口花了 token」永远说不清。
68
+ const t0 = Date.now()
69
+ let llmCalls = 0
70
+ let routeUsed = null
71
+ let outChars = 0
72
+ const finish = (res) => {
73
+ try {
74
+ recordLedger(vaultRoot, {
75
+ kind: 'capture',
76
+ charsIn: clean.length,
77
+ chars: outChars,
78
+ llmCalls,
79
+ provider: routeUsed ? routeUsed.provider : '',
80
+ model: routeUsed ? routeUsed.model : '',
81
+ ms: Date.now() - t0,
82
+ ok: !!(res && res.ok === true),
83
+ note: res && res.action ? res.action : '',
84
+ })
85
+ } catch { /* 账本失败不影响沉淀 */ }
86
+ return res
87
+ }
88
+
66
89
  // 自动审核:共享配置文件(DSH)优先,其次 env,默认 all(独立进程无 DSH settings)
67
90
  // 注:`shared` 原先在回调里局部读取,第 80 行的路由兜底却引用了外层同名变量 → ReferenceError
68
91
  // 被下面的 try/catch 静默吞掉,导致 captureProvider/captureModel 在本进程从不生效。这里提到函数级。
@@ -85,9 +108,12 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
85
108
  // 有 LLM:蒸馏判定链(与 DSH 侧一致)
86
109
  if (llmClient && route) {
87
110
  try {
111
+ routeUsed = route
88
112
  const neighbors = await pickNeighbors(vaultRoot, { title: '', body: clean.slice(0, 400) }, 8)
113
+ llmCalls++
89
114
  const detailed = await summarizeTurnDetailed(llmClient, route, clean, { existing: neighbors, signal: AbortSignal.timeout(45000) })
90
115
  const result = detailed.card
116
+ if (result) outChars = JSON.stringify(result).length
91
117
  // 独立进程也必须把真实失败暴露出来(走 stderr),别再静默降级成「无输出」
92
118
  if (detailed.failure) {
93
119
  process.stderr.write(`[memory-eternal] 蒸馏失败(${route.provider}/${route.model}):${detailed.failure.code} ${detailed.failure.message}\n`)
@@ -95,18 +121,18 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
95
121
  if (result && result.save === true) {
96
122
  if (result.append_to) {
97
123
  await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
98
- return { ok: true, action: 'appended', path: result.append_to }
124
+ return finish({ ok: true, action: 'appended', path: result.append_to })
99
125
  }
100
126
  const card = { kind: result.kind, title: result.title, tags: result.tags, body: result.body, source, status: resolveAuditStatus(result.kind, source || 'agent'), submittedBy: source || 'agent', severity: 'info', reason: 'AI 自动沉淀(蒸馏卡)' }
101
127
  const out = await captureCard(vaultRoot, card, { threshold: 0.62 })
102
- if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel }
128
+ if (out.ok) return finish({ ok: true, action: 'created', path: out.path ?? out.rel })
103
129
  if (out.duplicate) {
104
130
  await captureUpdate(vaultRoot, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, { threshold: 0.62 })
105
- return { ok: true, action: 'appended', path: out.duplicate.path }
131
+ return finish({ ok: true, action: 'appended', path: out.duplicate.path })
106
132
  }
107
- return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
133
+ return finish({ ok: false, action: 'failed', reason: String(out.reason || 'write failed') })
108
134
  }
109
- if (result && result.save === false) return { ok: true, action: 'skipped', reason: 'model judged not durable' }
135
+ if (result && result.save === false) return finish({ ok: true, action: 'skipped', reason: 'model judged not durable' })
110
136
  } catch (error) {
111
137
  // LLM 失败 → 落入降级路径,不能让一次网络抖动丢掉沉淀
112
138
  }
@@ -114,6 +140,7 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
114
140
 
115
141
  // 降级:无 LLM(或 LLM 调用失败)→ 原文卡(kind 默认 content,dedup 开)
116
142
  // 标题用 deriveTitle 派生(不再 slice 硬切);正文用剥离噪声后的 clean。
143
+ outChars = clean.length
117
144
  const out = await captureCard(vaultRoot, {
118
145
  kind: 'content',
119
146
  title,
@@ -125,10 +152,10 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
125
152
  severity: 'info',
126
153
  reason: 'AI 自动沉淀(原文卡)',
127
154
  }, { threshold: 0.62 })
128
- if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel, degraded: true }
155
+ if (out.ok) return finish({ ok: true, action: 'created', path: out.path ?? out.rel, degraded: true })
129
156
  if (out.duplicate) {
130
157
  await captureUpdate(vaultRoot, out.duplicate.path, clean.slice(0, 400), { threshold: 0.62 })
131
- return { ok: true, action: 'appended', path: out.duplicate.path, degraded: true }
158
+ return finish({ ok: true, action: 'appended', path: out.duplicate.path, degraded: true })
132
159
  }
133
- return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
160
+ return finish({ ok: false, action: 'failed', reason: String(out.reason || 'write failed') })
134
161
  }
package/lib/capture.js CHANGED
@@ -316,8 +316,9 @@ export async function summarizeTurnDetailed(llm, route, conversation, opts = {})
316
316
  '输出严格 JSON(不要 markdown 代码块、不要多余文字):',
317
317
  '{"save": false} 表示不值得保存;',
318
318
  '否则输出:',
319
- '{"save": true, "title": "简短标题", "kind": "knowledge|project|content|prompt|business|tool|mistake", "tags": ["标签"], "body": "300-800字的压缩正文,markdown,含要点列表,首行是# 标题"}',
319
+ '{"save": true, "title": "简短标题", "kind": "knowledge|project|content|prompt|business|tool|mistake|core", "tags": ["标签"], "body": "300-800字的压缩正文,markdown,含要点列表,首行是# 标题"}',
320
320
  'kind 含义:project=项目背景/进度;knowledge=通用知识/技术方案/设计决策;content=内容素材/资料;prompt=提示词/工作流;business=业务/商业;tool=工具链/CLI/配置/环境坑;mistake=错误/反模式/踩坑/“别这么做”/debug 教训。',
321
+ 'core 只在“用户明确要求长期/每次都遵守的偏好、硬约束、核心记忆”时使用(如“以后都…/永远记住/每次都…/不要再用…”);一次性偏好或普通知识不要标 core。core 卡一律进待审核,由用户决定是否批准。',
321
322
  '正文要求:去掉对话口水,只留可复用的结论、参数、步骤、原因。语言与对话一致。',
322
323
  '已有卡片规则:若新知识与某张已有卡片是同一主题(同一技术、同一项目、同一决策),不要新建——输出',
323
324
  '{"append_to": "已有卡片的 path 字段原样", "update": "追加/修正的短文本(一段话)"}',
@@ -704,7 +705,7 @@ export function isUsableTitle(title) {
704
705
 
705
706
  /** 组装最终卡片对象(标题已体检通过)。 */
706
707
  function finishCard(parsed, title, body) {
707
- const kind = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake'].includes(parsed.kind) ? parsed.kind : 'knowledge'
708
+ const kind = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake', 'core'].includes(parsed.kind) ? parsed.kind : 'knowledge'
708
709
  const tags = Array.isArray(parsed.tags)
709
710
  ? parsed.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 8)
710
711
  : []
@@ -1006,3 +1007,37 @@ export async function resolveRoute(llm, opts = {}) {
1006
1007
  const list = await routeCandidates(llm, opts)
1007
1008
  return list[0] ?? null
1008
1009
  }
1010
+
1011
+
1012
+ const RAW_KIND_SIGNALS = [
1013
+ ['mistake', /(报错|错误|异常|失败|根因|原因|修复|解决|踩坑|教训|bug|error|exception|crash|stack trace|排查|回滚)/i],
1014
+ ['tool', /(命令|CLI|终端|安装|配置|环境变量|端口|依赖|版本|npm|pnpm|yarn|\bgit\b|docker|powershell|bash|PATH|shell|SDK|API key|token)/i],
1015
+ ['prompt', /(提示词|prompt|模板|工作流|角色设定|系统指令|AI 指令)/i],
1016
+ ['project', /(项目|进度|里程碑|需求|交付|上线|排期|任务|计划|迭代|版本发布|roadmap)/i],
1017
+ ['business', /(客户|合同|报价|价格|商业|营收|成本|预算|市场|销售|付费|订阅)/i],
1018
+ ['content', /(文案|素材|文章|博客|视频脚本|标题|封面|宣传|内容创作|草稿)/i],
1019
+ ]
1020
+
1021
+ /**
1022
+ * 不调 LLM(关闭蒸馏 / 蒸馏失败兜底)时的轻量类型判断。
1023
+ * 只做启发式:宁可回退 knowledge,也不要把所有原文卡都塞成 content。
1024
+ * 明确出现长期偏好/硬约束时返回 core(仍会强制进待审)。
1025
+ */
1026
+ export function guessRawKind({ title = '', body = '' } = {}) {
1027
+ const head = String(title || '')
1028
+ const text = String(body || '').slice(0, 6000)
1029
+ if (/(核心记忆|永远记住|以后都|永久(记住|遵守)|必须(遵守|记住)|不要再)/.test(head + '\n' + text)) return 'core'
1030
+ const scores = {}
1031
+ for (const [kind, re] of RAW_KIND_SIGNALS) {
1032
+ const h = (head.match(re) || []).length
1033
+ const b = (text.match(re) || []).length
1034
+ const score = Math.min(5, h * 2 + b)
1035
+ if (score) scores[kind] = score
1036
+ }
1037
+ let best = 'knowledge'
1038
+ let bestScore = 0
1039
+ for (const kind of ['mistake', 'tool', 'prompt', 'project', 'business', 'content']) {
1040
+ if ((scores[kind] || 0) > bestScore) { best = kind; bestScore = scores[kind] }
1041
+ }
1042
+ return best
1043
+ }