memory-eternal 0.17.0 → 0.18.1

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,7 +31,7 @@ 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
37
  import { recordLedger } from './lib/ledger.js'
@@ -54,6 +54,12 @@ export const Config = z.object({
54
54
  enabled: z.boolean().default(true),
55
55
  autoCapture: z.boolean().default(true),
56
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),
61
+ // 记忆界面整体布局大小:compact / normal / large(只影响 UI,不影响召回)
62
+ uiScale: z.union([z.const('compact'), z.const('normal'), z.const('large')]).default('normal'),
57
63
  vaultDir: z.string().default(''),
58
64
  dedupThreshold: z.number().min(0).max(1).default(0.62),
59
65
  captureMinChars: z.number().default(200),
@@ -634,6 +640,8 @@ export function apply(ctx, config) {
634
640
 
635
641
  // 自动审核:根据配置决定新卡 status(免审→approved,否则 pending)
636
642
  const resolveAuditStatus = (cfg, kind, submittedBy) => {
643
+ // 核心记忆必须人工审核:自动沉淀/手动写入都先待审,避免一次误判污染每个会话。
644
+ if (kind === 'core') return 'pending'
637
645
  if (cfg.auditMode === 'none') return 'approved'
638
646
  const agents = Array.isArray(cfg.auditExemptAgents) ? cfg.auditExemptAgents : []
639
647
  const kinds = Array.isArray(cfg.auditExemptKinds) ? cfg.auditExemptKinds : []
@@ -684,18 +692,19 @@ export function apply(ctx, config) {
684
692
  const source = DSH_AGENT
685
693
  if (cfg.distillEnabled === false || !llm) {
686
694
  const rawTitle = deriveTitle(text)
695
+ const rawKind = guessRawKind({ title: rawTitle, body: text })
687
696
  const out = await captureCard(root, {
688
- kind: 'content',
697
+ kind: rawKind,
689
698
  title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
690
699
  tags: ['raw'],
691
700
  body: text,
692
701
  source,
693
- status: resolveAuditStatus(cfg, 'content', source || 'unknown'),
702
+ status: resolveAuditStatus(cfg, rawKind, source || 'unknown'),
694
703
  submittedBy: source || 'unknown',
695
704
  severity: 'info',
696
705
  reason: 'AI 自动沉淀(原文卡)',
697
706
  }, { threshold: cfg.dedupThreshold })
698
- if (out.ok) { countWrite(); logCapture(sessionId, 'created', '原文卡(蒸馏已关闭)', { path: out.path ?? out.rel, kind: 'content' }) }
707
+ if (out.ok) { countWrite(); logCapture(sessionId, 'created', '原文卡(蒸馏已关闭)', { path: out.path ?? out.rel, kind: rawKind }) }
699
708
  else if (out.duplicate) { countWrite(); logCapture(sessionId, 'appended', '与已有卡重复 → 追加更新记录', { path: out.duplicate.path }) }
700
709
  else logCapture(sessionId, 'fail', `写卡失败:${out.reason || '未知原因'}`)
701
710
  return
@@ -806,20 +815,21 @@ export function apply(ctx, config) {
806
815
  if (!result) {
807
816
  // 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
808
817
  const rawTitle = deriveTitle(text)
818
+ const rawKind = guessRawKind({ title: rawTitle, body: text })
809
819
  const raw = await captureCard(root, {
810
- kind: 'content',
820
+ kind: rawKind,
811
821
  title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
812
822
  // 蒸馏失败产出的原文卡与被批准的噪声卡是审核中心的主要污染源(#15 评论):
813
823
  // 打上独立 tag,便于按 tag 过滤 / 批量处理,不再和正常卡混作一团。
814
824
  tags: ['raw', 'distill-failed'],
815
825
  body: text,
816
826
  source,
817
- status: resolveAuditStatus(cfg, 'content', source),
827
+ status: resolveAuditStatus(cfg, rawKind, source),
818
828
  submittedBy: source,
819
829
  severity: 'info',
820
830
  reason: `AI 自动沉淀(蒸馏无输出 → 原文卡兜底;主因:${why})`,
821
831
  }, { threshold: cfg.dedupThreshold })
822
- if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: 'content', model: route.model }) }
832
+ if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: rawKind, model: route.model }) }
823
833
  else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
824
834
  else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
825
835
  return
@@ -966,6 +976,50 @@ export function apply(ctx, config) {
966
976
  }
967
977
  }, 'memory-eternal: recall section')
968
978
 
979
+ // -- 2b. 核心记忆:每次会话强制注入(不依赖 autoRecall / 工具调用) ---------
980
+ ctx.effect(() => {
981
+ let disposeSection = null
982
+ let timer = null
983
+ let alive = true
984
+ let lastText = ''
985
+ const clearSection = () => {
986
+ if (!disposeSection) return
987
+ const dispose = disposeSection
988
+ disposeSection = null
989
+ dispose()
990
+ }
991
+ const refresh = async () => {
992
+ try {
993
+ const cfg = settings.get() ?? {}
994
+ if (cfg.enabled === false || cfg.coreMemoryEnabled === false) { clearSection(); lastText = ''; return }
995
+ const { coreMemoryPrompt } = await import('./lib/core-memory.js')
996
+ const out = await coreMemoryPrompt(vaultDir(), {
997
+ enabled: true,
998
+ limit: cfg.coreMemoryLimit,
999
+ maxChars: cfg.coreMemoryMaxChars,
1000
+ })
1001
+ if (!alive) return
1002
+ if (!out.text) { clearSection(); lastText = ''; return }
1003
+ if (out.text === lastText) return
1004
+ lastText = out.text
1005
+ clearSection()
1006
+ disposeSection = ctx.systemPrompt.section({
1007
+ name: 'memory-eternal: core-memory',
1008
+ order: 120,
1009
+ text: out.text,
1010
+ })
1011
+ } catch { /* 核心记忆失败不能影响普通对话 */ }
1012
+ }
1013
+ refresh()
1014
+ timer = setInterval(refresh, 5000)
1015
+ if (timer && typeof timer.unref === 'function') timer.unref()
1016
+ return () => {
1017
+ alive = false
1018
+ if (timer) clearInterval(timer)
1019
+ clearSection()
1020
+ }
1021
+ }, 'memory-eternal: core memory section')
1022
+
969
1023
  const tools = ctx.get('tools')
970
1024
  if (tools !== undefined) {
971
1025
  tools.register(defineTool({
@@ -1220,7 +1274,7 @@ export function apply(ctx, config) {
1220
1274
  const cfg = settings.get() ?? {}
1221
1275
  // 只暴露可安全展示/回填的字段
1222
1276
  const safe = {
1223
- autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody, secretHint: cfg.secretHint || '',
1277
+ autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, coreMemoryEnabled: cfg.coreMemoryEnabled, coreMemoryLimit: cfg.coreMemoryLimit, coreMemoryMaxChars: cfg.coreMemoryMaxChars, uiScale: cfg.uiScale, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody, secretHint: cfg.secretHint || '',
1224
1278
  captureMinChars: cfg.captureMinChars, captureCooldownMs: cfg.captureCooldownMs, dedupThreshold: cfg.dedupThreshold, maxCardsPerDay: cfg.maxCardsPerDay,
1225
1279
  distillEnabled: cfg.distillEnabled, dedupByLLM: cfg.dedupByLLM, captureMaxTokens: cfg.captureMaxTokens, recallMinScore: cfg.recallMinScore,
1226
1280
  autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoRestartOnDrift: cfg.autoRestartOnDrift, autoMcpSetup: cfg.autoMcpSetup,
@@ -1369,6 +1423,14 @@ export function apply(ctx, config) {
1369
1423
  const cfg0 = settings.get() ?? {}
1370
1424
  if (cfg0.enabled === false) return () => {}
1371
1425
 
1426
+ // (0) issue #53 自动迁移:只修我们自己写过、且指向 Electron 宿主 exe 的旧条目。
1427
+ // 必须在 Electron 宿主里才有旧配置;普通 node 进程/测试不碰真实 home。
1428
+ if (process.versions.electron && process.env.MEMORY_ETERNAL_SKIP_AUTO !== '1') {
1429
+ import('./lib/setup.js')
1430
+ .then((m) => m.repairLegacySetup({ log: (msg) => console.log('[memory-eternal] ' + msg) }))
1431
+ .catch(() => {})
1432
+ }
1433
+
1372
1434
  // (1) MCP 自动挂载:默认关闭;只有用户显式开启才动外部配置。
1373
1435
  if (cfg0.autoMcpSetup === true && process.env.MEMORY_ETERNAL_SKIP_AUTO !== '1') {
1374
1436
  import('./lib/setup.js')
package/lib/api.js CHANGED
@@ -16,8 +16,8 @@ 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
23
  import { ledgerSummary } from './ledger.js'
@@ -625,17 +625,44 @@ export function createApi(deps = {}) {
625
625
  json(res, 200, { ok: true, soft: !permanent })
626
626
  return
627
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
+ }
628
652
  case '/audit/list': {
629
653
  const { pending, rejected } = await auditQueue(vaultRoot)
630
654
  json(res, 200, { ok: true, pending, rejected })
631
655
  return
632
656
  }
633
657
  case '/audit/approve': {
634
- // POST {path} 或 GET ?path= → status=approved
635
- 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()
636
662
  if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
637
663
  await setCardStatus(vaultRoot, rel, 'approved')
638
- 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 })
639
666
  return
640
667
  }
641
668
  case '/audit/reject': {
@@ -650,6 +677,21 @@ export function createApi(deps = {}) {
650
677
  json(res, 200, { ok: true, path: rel, status: 'rejected', reason })
651
678
  return
652
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
+ }
653
695
  case '/recycle/list': {
654
696
  const items = await recycleList(vaultRoot)
655
697
  json(res, 200, { ok: true, items })
@@ -996,7 +1038,7 @@ export function createApi(deps = {}) {
996
1038
  const cfg = getSettings() ?? {}
997
1039
  const env = typeof process !== 'undefined' ? process.env : {}
998
1040
  const safe = {
999
- 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, uiScale: cfg.uiScale || 'normal', recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false, secretHint: cfg.secretHint || '',
1000
1042
  captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
1001
1043
  distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? DEFAULT_CAPTURE_MAX_TOKENS, recallMinScore: cfg.recallMinScore ?? 2,
1002
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,
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
+ }