memory-eternal 0.20.0 → 0.22.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/lib/vault.js CHANGED
@@ -341,7 +341,7 @@ export async function listCards(root, { status, kind, agent, tag = '', limit, of
341
341
  rows.push(...db.prepare(`SELECT * FROM ${store.table} WHERE path IN (${ph})`).all(...paths))
342
342
  }
343
343
  const byPath = new Map(rows.map((r) => [r.path, rowToCard(r)]))
344
- return page.map((r) => byPath.get(r.path)).filter(Boolean)
344
+ return attachRevisions(db, page.map((r) => byPath.get(r.path)).filter(Boolean))
345
345
  }
346
346
 
347
347
  const tail = Number.isInteger(limit) && limit > 0
@@ -355,7 +355,7 @@ export async function listCards(root, { status, kind, agent, tag = '', limit, of
355
355
  { kind, agent, tag, tail },
356
356
  )
357
357
  const finalArgs = Number.isInteger(limit) && limit > 0 ? [...args, limit, Math.max(0, offset | 0)] : args
358
- return db.prepare(sql).all(...finalArgs).map(rowToCard)
358
+ return attachRevisions(db, db.prepare(sql).all(...finalArgs).map(rowToCard))
359
359
  }
360
360
 
361
361
  /** 计数(分页接口要用 total 判断还有没有下一页)。 */
@@ -464,12 +464,15 @@ export async function readCard(root, rel, { allowUnapproved = false } = {}) {
464
464
  }
465
465
  const status = String(row.status || '')
466
466
  const { text } = rowToMarkdown(row)
467
+ // 修改副本要带上「改的是哪张卡」,审核界面据此标注「修改」而不是「新卡」。
468
+ const rev = getRevision(db, rel)
469
+ const revision = rev ? { targetPath: rev.target_path, targetId: rev.target_id } : null
467
470
  const updates = db.prepare('SELECT content, created_at FROM card_updates WHERE card_id = ? ORDER BY created_at ASC').all(row.id)
468
471
  if (updates.length > 0) {
469
472
  const updateLines = updates.map((u) => `- ${u.created_at.slice(0, 10)}:${u.content}`)
470
- return { path: rel, status, store: mainRow ? 'main' : 'quarantine', text: text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n' }
473
+ return { path: rel, status, store: mainRow ? 'main' : 'quarantine', text: text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n', ...(revision ? { revision } : {}) }
471
474
  }
472
- return { path: rel, status, store: mainRow ? 'main' : 'quarantine', text }
475
+ return { path: rel, status, store: mainRow ? 'main' : 'quarantine', text, ...(revision ? { revision } : {}) }
473
476
  }
474
477
 
475
478
  /** 状态中文标签(错误信息与日志用)。 */
@@ -499,6 +502,11 @@ export async function deleteCard(root, rel, { permanent = false } = {}) {
499
502
  db.prepare('DELETE FROM card_updates WHERE card_id = ?').run(id)
500
503
  db.prepare('DELETE FROM feedback WHERE card_path = ?').run(rel)
501
504
  }
505
+ // 修改副本与原卡的关联也一起清掉:删原卡时,指向它的待审副本没有应用对象了
506
+ const revs = db.prepare('SELECT revision_path FROM card_revisions WHERE target_path = ?').all(rel)
507
+ for (const r of revs) db.prepare('DELETE FROM quarantine WHERE path = ?').run(r.revision_path)
508
+ db.prepare('DELETE FROM card_revisions WHERE target_path = ?').run(rel)
509
+ db.prepare('DELETE FROM card_revisions WHERE revision_path = ?').run(rel)
502
510
  db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
503
511
  db.prepare('DELETE FROM quarantine WHERE path = ?').run(rel)
504
512
  })
@@ -522,6 +530,8 @@ export async function restoreCard(root, rel) {
522
530
  const db = getDb(root)
523
531
  const existing = db.prepare('SELECT status FROM quarantine WHERE path = ?').get(rel)
524
532
  if (!existing) throw new Error('卡片不在回收站/隔离区: ' + rel)
533
+ // 修改副本不能「恢复」成一张独立卡 —— 那会跟原卡重复。它只能被批准(覆盖原卡)或删除。
534
+ if (getRevision(db, rel)) throw new Error('这是知识卡的修改副本:请在审核中心「批准」以覆盖原卡,或直接删除')
525
535
  const r = moveCardBetweenStores(db, 'quarantine', 'cards', rel, { status: MAIN_STATUS, deletedAt: null })
526
536
  db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, MAIN_STATUS, 'restore', '回收站恢复')
527
537
  return { ok: true, restored: true, ...r }
@@ -539,6 +549,12 @@ export async function setCardStatus(root, rel, status, { changedBy = 'system', r
539
549
  const existing = findCardRow(db, rel)
540
550
  if (!existing) throw new Error('卡片不存在: ' + rel)
541
551
 
552
+ // 审核制修改:待审副本被批准 = 用副本内容覆盖原卡(身份/状态/类型不变),副本随即消失。
553
+ if (target === MAIN_STATUS && existing.table === 'quarantine') {
554
+ const rev = getRevision(db, rel)
555
+ if (rev) return applyRevision(db, rel, rev, { changedBy, reason })
556
+ }
557
+
542
558
  if (target === MAIN_STATUS) {
543
559
  if (existing.table === 'cards') {
544
560
  db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(target, new Date().toISOString(), rel)
@@ -702,6 +718,99 @@ export async function updateCardContent(root, rel, { title, tags, body, reason }
702
718
  return { ok: true, path: rel, store: found.table, updated: now }
703
719
  }
704
720
 
721
+ // ── 审核制修改(agent 提交 → 人工审核 → 覆盖原卡)────────────────────────
722
+
723
+ /** 查「待审副本 → 原卡」映射;不是修改副本时返回 null。 */
724
+ function getRevision(db, revisionPath) {
725
+ return db.prepare('SELECT * FROM card_revisions WHERE revision_path = ?').get(revisionPath) || null
726
+ }
727
+
728
+ /** 给卡片对象挂上 revision 信息(审核中心据此标「修改」而不是「新卡」)。 */
729
+ function attachRevisions(db, cards) {
730
+ if (!cards.length) return cards
731
+ const ph = cards.map(() => '?').join(',')
732
+ const rows = db.prepare(`SELECT revision_path, target_path, target_id FROM card_revisions WHERE revision_path IN (${ph})`).all(...cards.map((c) => c.path))
733
+ if (!rows.length) return cards
734
+ const byPath = new Map(rows.map((r) => [r.revision_path, r]))
735
+ const targets = [...new Set(rows.map((r) => r.target_path))]
736
+ const tph = targets.map(() => '?').join(',')
737
+ const titles = new Map(db.prepare(`SELECT path, title FROM cards WHERE path IN (${tph})`).all(...targets).map((r) => [r.path, r.title]))
738
+ return cards.map((c) => {
739
+ const r = byPath.get(c.path)
740
+ return r ? { ...c, revision: { targetPath: r.target_path, targetId: r.target_id, targetTitle: titles.get(r.target_path) || '' } } : c
741
+ })
742
+ }
743
+
744
+ /**
745
+ * 提交一张「审核制修改」:把已批准的卡片复制一份到隔离区(status=pending),
746
+ * 内容用调用方给的新标题/标签/正文。**这里绝不改原卡** —— 只有审核通过(setCardStatus)
747
+ * 才会用副本覆盖原卡。agent 走 /card/revise 或 MCP memory_revise,只能到这一步。
748
+ *
749
+ * 同一张卡已有待审修改时就地更新,避免审核队列里堆一串同一张卡的副本。
750
+ */
751
+ export async function reviseCard(root, { target, title, tags, body, reason = '', source = '', submittedBy = '' } = {}) {
752
+ const db = getDb(root)
753
+ const rel = String(target || '').trim()
754
+ if (!rel) throw new Error('缺少要修改的卡片 path')
755
+ const found = findCardRow(db, rel)
756
+ if (!found) throw new Error('原卡片不存在: ' + rel)
757
+ if (found.table !== 'cards') throw new Error('只能修改已审核通过的卡片(当前状态:' + found.row.status + ')')
758
+ const targetRow = found.row
759
+ // 已有待审修改时,未传的字段沿用副本当前值(否则第二次提交会把上次的改动抹掉)
760
+ const pending = db.prepare('SELECT q.* FROM card_revisions r JOIN quarantine q ON q.path = r.revision_path WHERE r.target_path = ? AND q.status = ?').get(rel, 'pending')
761
+ const baseRow = pending || targetRow
762
+ const nextTitle = title === undefined ? baseRow.title : String(title).trim()
763
+ const nextTags = tags === undefined ? normalizeTags(baseRow.tags) : normalizeTags(tags)
764
+ const nextBody = body === undefined ? String(baseRow.body).trim() : String(body).trim()
765
+ if (!nextTitle) throw new Error('标题不能为空')
766
+ if (!nextBody) throw new Error('正文不能为空')
767
+ const now = new Date().toISOString()
768
+ if (pending) {
769
+ db.prepare('UPDATE quarantine SET title = ?, tags = ?, body = ?, summary = ?, updated_at = ?, reason = ? WHERE path = ?')
770
+ .run(nextTitle, JSON.stringify(nextTags), nextBody, nextBody.slice(0, 200), now, reason || pending.reason || '', pending.path)
771
+ db.prepare('UPDATE card_revisions SET updated_at = ? WHERE revision_path = ?').run(now, pending.path)
772
+ return { ok: true, path: pending.path, target: rel, targetId: targetRow.id, reused: true, status: 'pending' }
773
+ }
774
+ // 副本放在独立目录 99-Revisions/:不与任何 kind 根目录冲突,也永远不会被当成新卡批准进主库
775
+ const base = `99-Revisions/${safeSlug(targetRow.title || 'card')}`
776
+ let revisionPath = `${base}.md`
777
+ let i = 2
778
+ while (pathTaken(db, revisionPath)) { revisionPath = `${base}-${i}.md`; i++ }
779
+ const id = nextCardId(db)
780
+ db.prepare(`
781
+ INSERT INTO quarantine
782
+ (id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, quarantined_at, quarantine_reason)
783
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, 'info', ?, ?, ?, datetime('now'), '修改待审')
784
+ `).run(id, revisionPath, targetRow.kind, nextTitle, JSON.stringify(nextTags), nextBody, nextBody.slice(0, 200), source, submittedBy, reason || ('修改知识卡:' + rel), now, now)
785
+ db.prepare('INSERT INTO card_revisions (revision_path, target_path, target_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(revisionPath, rel, targetRow.id, now, now)
786
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(revisionPath, null, 'pending', submittedBy || 'agent', '提交修改:' + rel)
787
+ return { ok: true, path: revisionPath, target: rel, targetId: targetRow.id, reused: false, status: 'pending' }
788
+ }
789
+
790
+ /**
791
+ * 审核通过修改副本:用副本内容覆盖原卡,副本及其映射一起消失。
792
+ * 只改内容(标题 / 标签 / 正文 / 摘要 + updated_at);状态、类型、来源、创建时间仍属原卡。
793
+ */
794
+ function applyRevision(db, revisionPath, rev, { changedBy = 'user', reason = '' } = {}) {
795
+ return inTransaction(db, () => {
796
+ const revRow = db.prepare('SELECT * FROM quarantine WHERE path = ?').get(revisionPath)
797
+ if (!revRow) throw new Error('修改副本不存在: ' + revisionPath)
798
+ const target = db.prepare('SELECT * FROM cards WHERE path = ?').get(rev.target_path)
799
+ if (!target) throw new Error('原卡片已不存在(可能已被删除):' + rev.target_path)
800
+ const now = new Date().toISOString()
801
+ const tags = normalizeTags(revRow.tags)
802
+ db.prepare('UPDATE cards SET title = ?, tags = ?, body = ?, summary = ?, updated_at = ? WHERE path = ?')
803
+ .run(revRow.title, JSON.stringify(tags), revRow.body, String(revRow.body).trim().slice(0, 200), now, rev.target_path)
804
+ db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)')
805
+ .run(target.id, '经审核修改:' + (reason || ('由 ' + (revRow.submitted_by || 'agent') + ' 提交,' + changedBy + ' 批准')), now)
806
+ db.prepare('DELETE FROM quarantine WHERE path = ?').run(revisionPath)
807
+ db.prepare('DELETE FROM card_revisions WHERE revision_path = ?').run(revisionPath)
808
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)')
809
+ .run(rev.target_path, MAIN_STATUS, MAIN_STATUS, changedBy, '应用修改(来源 ' + revisionPath + ')')
810
+ return { ok: true, status: MAIN_STATUS, store: 'cards', applied: true, path: rev.target_path, revisionPath, updated: now }
811
+ })
812
+ }
813
+
705
814
  /** 合并多张卡为一张。 */
706
815
  export async function mergeCards(root, paths) {
707
816
  const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
@@ -1100,6 +1209,673 @@ export async function graphAll(roots) {
1100
1209
  return { nodes, edges }
1101
1210
  }
1102
1211
 
1212
+ // ---- 星球分层视图:按层请求的规划(纯函数,可单测)--------------------------------
1213
+ //
1214
+ // 为什么需要:2049 张卡一次性返回 nodes+edges(实测 2049 节点 / 10106 条边),
1215
+ // 前端也一次性渲染全部节点 —— 首屏是一团墨点,既看不清也点不准,10k 时更不可能。
1216
+ // 现在改成「一层一层看」:
1217
+ // · 根层按 kind 折叠:某类 >200 张 → 一颗带环大星球;≤200 张 → 逐颗小星球;
1218
+ // 根层图元 >500 时把「最小的 kind」依次并进「其他」桶(每并一个类,图元数减少 size-1);
1219
+ // · 进入某类 / 某个语义簇后:集合 ≤1000 张直接铺卡片;>1000 张按语义簇(标签优先)
1220
+ // 拆成 ≤20 颗大星球,逐级下钻(每个大星球仍 >1000 时继续按同样规则拆);
1221
+ // · 语义聚类没有有效信号(标签太散/没有标签)时回退:按权重(更新时间)等分 ≤20 块。
1222
+ //
1223
+ // 「这一层显示什么」全部是 planGraphLevel 这组纯函数算出来的:路由层只负责读库 + 缓存,
1224
+ // 于是 10k 规模的分层规则能在单测里穷举,不必真的点进浏览器。
1225
+ export const LEVEL_MAX_ITEMS = 1000 // 单层图元上限(再多就分簇)
1226
+ export const ROOT_BIG_KIND_MIN = 200 // 某类 >200 张 → 一颗大星球(严格大于)
1227
+ export const ROOT_MAX_ITEMS = 500 // 根层图元上限(超出 → 最小 kind 合并「其他」)
1228
+ export const MAX_CLUSTERS = 20 // 一层最多几颗语义簇大星球
1229
+ export const CLUSTER_TARGET_SIZE = 250 // 语义簇目标体量(决定分几簇)
1230
+ export const LEVEL_EDGE_LIMIT = 2400 // 单层边上限(与 payload 红线一起兜住传输量)
1231
+ export const PLANET_EDGE_LIMIT = 1200 // 星球层聚合边上限(共享标签关系网,卡片折叠后仍保留「连线」视觉)
1232
+ export const LEVEL_PAYLOAD_LIMIT = 1024 * 1024 // 单次层响应 payload 上限:1MB
1233
+
1234
+ const LEVEL_KIND_LABELS = {
1235
+ core: '核心记忆', skill: '技能', project: '项目', knowledge: '知识', content: '内容',
1236
+ prompt: '提示词', business: '业务', tool: '工具', mistake: '教训', other: '其他',
1237
+ }
1238
+ const LEVEL_KIND_KEYS = {
1239
+ core: 'kindCore', skill: 'kindSkill', project: 'kindProject', knowledge: 'kindKnowledge',
1240
+ content: 'kindContent', prompt: 'kindPrompt', business: 'kindBusiness', tool: 'kindTool',
1241
+ mistake: 'kindMistake', other: 'graphOther',
1242
+ }
1243
+
1244
+ /**
1245
+ * 分层请求的错误分类:调用方(lib/api.js)按 code 决定 HTTP 状态。
1246
+ * LEVEL_PATH —— 路径写错(客户端可纠正)→ 400 + 原文案
1247
+ * LEVEL_STALE —— 语义簇已变化(卡片增删导致)→ 400 + 提示返回上一层
1248
+ * 其余异常一律视为「未知内部错误」→ 500 + 通用文案(细节只进日志)。
1249
+ */
1250
+ export const LEVEL_ERR_PATH = 'LEVEL_PATH'
1251
+ export const LEVEL_ERR_STALE = 'LEVEL_STALE'
1252
+
1253
+ function levelPathError(message) {
1254
+ const err = new Error(message)
1255
+ err.code = LEVEL_ERR_PATH
1256
+ return err
1257
+ }
1258
+
1259
+ function levelStaleError(message) {
1260
+ const err = new Error(message)
1261
+ err.code = LEVEL_ERR_STALE
1262
+ return err
1263
+ }
1264
+
1265
+ /**
1266
+ * 解析层级路径字符串(客户端 → 宿主的唯一寻址方式)。段之间用 `/` 分隔:
1267
+ * k:<kind> 某一类(如 k:project)
1268
+ * k:<kind1>,<kind2> 合并桶(如 k:content,mistake,skill → 「其他」)
1269
+ * t:<tag>[#n] 按标签聚出来的语义簇(n = 大标签拆片序号)
1270
+ * g:<n> 按权重等分出来的兜底块
1271
+ * 非法段直接抛错(调用方转成 400 + 明确文案),不做「猜一个」的兜底。
1272
+ */
1273
+ export function parseLevelPath(pathStr) {
1274
+ const raw = String(pathStr == null ? '' : pathStr).trim()
1275
+ if (!raw) return []
1276
+ return raw.split('/').filter(Boolean).map((seg) => {
1277
+ if (seg.startsWith('k:')) {
1278
+ const kinds = seg.slice(2).split(',').map((s) => s.trim()).filter(Boolean)
1279
+ if (!kinds.length) throw levelPathError('层级路径缺少 kind:' + seg)
1280
+ return { type: 'kind', id: seg, kinds }
1281
+ }
1282
+ if (seg.startsWith('t:') || seg.startsWith('g:')) return { type: 'cluster', id: seg }
1283
+ throw levelPathError('无法识别的层级路径段:' + seg)
1284
+ })
1285
+ }
1286
+
1287
+ /** 把段数组拼回路径字符串(客户端面包屑用它返回上层)。 */
1288
+ export function encodeLevelPath(segments) {
1289
+ return (Array.isArray(segments) ? segments : []).map((s) => (typeof s === 'string' ? s : s && s.id)).filter(Boolean).join('/')
1290
+ }
1291
+
1292
+ function levelCardItem(card) {
1293
+ return {
1294
+ type: 'card',
1295
+ id: String(card.id || card.path || ''),
1296
+ label: String(card.title || card.id || ''),
1297
+ kind: card.kind || 'other',
1298
+ updated: card.updated || '',
1299
+ tags: Array.isArray(card.tags) ? card.tags.slice(0, 8) : [],
1300
+ }
1301
+ }
1302
+
1303
+ function levelPlanetItem({ id, kind, kinds, label, labelKey, count, by }) {
1304
+ return {
1305
+ type: 'planet',
1306
+ id,
1307
+ kind: kind || 'other',
1308
+ kinds: Array.isArray(kinds) && kinds.length > 1 ? kinds.slice() : null,
1309
+ label: label || '',
1310
+ labelKey: labelKey || '',
1311
+ count: Math.max(0, Math.floor(Number(count) || 0)),
1312
+ by: by || 'kind',
1313
+ }
1314
+ }
1315
+
1316
+ /** 根层:按 kind 折叠 + 超限合并「其他」。纯函数,返回这一层的图元。 */
1317
+ export function planRootLevel(cards, opts = {}) {
1318
+ const bigMin = opts.bigKindMin === undefined ? ROOT_BIG_KIND_MIN : opts.bigKindMin
1319
+ const rootMax = opts.rootMaxItems === undefined ? ROOT_MAX_ITEMS : opts.rootMaxItems
1320
+ const groups = new Map()
1321
+ for (const c of Array.isArray(cards) ? cards : []) {
1322
+ const kind = c.kind || 'other'
1323
+ const g = groups.get(kind)
1324
+ if (g) g.cards.push(c)
1325
+ else groups.set(kind, { kind, cards: [c] })
1326
+ }
1327
+ const bigGroups = []
1328
+ const smallGroups = []
1329
+ for (const g of groups.values()) (g.cards.length > bigMin ? bigGroups : smallGroups).push(g)
1330
+ // 先算账(不在数组里反复删除):大类占 1 个图元,小类逐颗展开占 size 个
1331
+ let total = bigGroups.length
1332
+ for (const g of smallGroups) total += g.cards.length
1333
+ const merged = []
1334
+ const mergedSet = new Set()
1335
+ let mergedCount = 0
1336
+ const queue = smallGroups
1337
+ .map((g) => ({ kind: g.kind, size: g.cards.length }))
1338
+ .sort((a, b) => a.size - b.size || String(a.kind).localeCompare(String(b.kind)))
1339
+ while (total > rootMax && queue.length) {
1340
+ const g = queue.shift()
1341
+ merged.push(g.kind)
1342
+ mergedSet.add(g.kind)
1343
+ mergedCount += g.size
1344
+ // 这一类原来占 size 个图元(逐颗小星球);并进「其他」后:
1345
+ // 少掉 size 个,同时「其他」桶自己第一次出现时占 1 个 —— 所以净减少 size,首次再 +1
1346
+ total -= g.size
1347
+ if (merged.length === 1) total += 1
1348
+ }
1349
+ // 保险(>500 个「大 kind」,实盘不会出现):除最大的一类外全部并成一颗
1350
+ if (total > rootMax) {
1351
+ const bigLeft = bigGroups.filter((g) => !mergedSet.has(g.kind))
1352
+ if (bigLeft.length > 1) {
1353
+ const sorted = bigLeft.slice().sort((a, b) => a.cards.length - b.cards.length || String(a.kind).localeCompare(String(b.kind)))
1354
+ const keep = sorted[sorted.length - 1]
1355
+ for (const g of sorted) {
1356
+ if (g === keep) continue
1357
+ merged.push(g.kind)
1358
+ mergedSet.add(g.kind)
1359
+ mergedCount += g.cards.length
1360
+ }
1361
+ total = 1 + (mergedCount ? 1 : 0)
1362
+ }
1363
+ }
1364
+ const items = []
1365
+ for (const g of bigGroups) {
1366
+ if (mergedSet.has(g.kind)) continue
1367
+ items.push(levelPlanetItem({
1368
+ id: 'k:' + g.kind, kind: g.kind, label: LEVEL_KIND_LABELS[g.kind] || g.kind,
1369
+ labelKey: LEVEL_KIND_KEYS[g.kind] || 'kindKnowledge', count: g.cards.length, by: 'kind',
1370
+ }))
1371
+ }
1372
+ for (const g of smallGroups) {
1373
+ if (mergedSet.has(g.kind)) continue
1374
+ for (const c of g.cards) items.push(levelCardItem(c))
1375
+ }
1376
+ if (merged.length) {
1377
+ items.push(levelPlanetItem({
1378
+ id: 'k:' + merged.slice().sort().join(','), kinds: merged.slice().sort(), kind: 'other',
1379
+ label: '其他', labelKey: 'graphOther', count: mergedCount, by: 'merged',
1380
+ }))
1381
+ }
1382
+ return {
1383
+ mode: 'root',
1384
+ total: Array.isArray(cards) ? cards.length : 0,
1385
+ items,
1386
+ unit: 'root',
1387
+ bigKinds: bigGroups.filter((g) => !mergedSet.has(g.kind)).map((g) => g.kind),
1388
+ mergedKinds: merged.slice(),
1389
+ rootLimit: rootMax,
1390
+ bigKindMin: bigMin,
1391
+ }
1392
+ }
1393
+
1394
+ function cardWeight(card) {
1395
+ const t = Number(card && (card.mtime || Date.parse(card.updated || '') || 0))
1396
+ return Number.isFinite(t) ? t : 0
1397
+ }
1398
+
1399
+ function pickClusterKind(cards) {
1400
+ const counts = new Map()
1401
+ for (const c of cards) counts.set(c.kind || 'other', (counts.get(c.kind || 'other') || 0) + 1)
1402
+ let best = 'other'
1403
+ let n = -1
1404
+ for (const [k, v] of counts) if (v > n || (v === n && String(k) < String(best))) { best = k; n = v }
1405
+ return best
1406
+ }
1407
+
1408
+ /**
1409
+ * 语义簇:标签优先,标签没有有效信号时按权重(更新时间)等分。
1410
+ * 返回的簇 id 是**确定性**的(同一份卡片集合必定得到同样的 id),所以下钻时能原样复算。
1411
+ * @param {Array<object>} cards
1412
+ * @param {{maxClusters?:number, targetSize?:number}} opts
1413
+ * @returns {Array<{id:string,label:string,kind:string,count:number,cards:Array<object>,by:string}>}
1414
+ */
1415
+ export function planClusters(cards, opts = {}) {
1416
+ const list = Array.isArray(cards) ? cards : []
1417
+ const n = list.length
1418
+ if (!n) return []
1419
+ const maxClusters = Math.max(2, Math.floor(opts.maxClusters || MAX_CLUSTERS))
1420
+ const target = Math.max(1, Math.floor(opts.targetSize || CLUSTER_TARGET_SIZE))
1421
+ const wanted = Math.min(maxClusters, Math.max(2, Math.ceil(n / target)))
1422
+ const cap = Math.max(1, Math.ceil(n / wanted))
1423
+ const ordered = list.slice().sort((a, b) => cardWeight(b) - cardWeight(a) || String(a.id || '').localeCompare(String(b.id || '')))
1424
+ const tagCount = new Map()
1425
+ for (const c of ordered) for (const t of Array.isArray(c.tags) ? c.tags : []) tagCount.set(t, (tagCount.get(t) || 0) + 1)
1426
+ const tags = [...tagCount.entries()]
1427
+ .filter(([, cnt]) => cnt >= 2)
1428
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
1429
+ // 语义信号门槛:最强的标签至少覆盖 2%(且 ≥3 张),否则「按标签分簇」只是把噪声切成小块
1430
+ const signal = tags.length > 0 && tags[0][1] >= Math.max(3, Math.ceil(n * 0.02))
1431
+ const clusters = []
1432
+ const assigned = new Set()
1433
+ if (signal) {
1434
+ // 给「没标签的卡」预留至少 1 个名额,否则它们会被标签簇挤掉
1435
+ const untagged = ordered.filter((c) => !(Array.isArray(c.tags) && c.tags.length)).length
1436
+ const tagBudget = Math.max(1, wanted - (untagged > 0 ? 1 : 0))
1437
+ for (const [tag] of tags) {
1438
+ if (clusters.length >= tagBudget) break
1439
+ const members = ordered.filter((c) => !assigned.has(c.id) && Array.isArray(c.tags) && c.tags.includes(tag))
1440
+ if (members.length < 2) continue
1441
+ for (let i = 0; i < members.length && clusters.length < tagBudget; i += cap) {
1442
+ const part = members.slice(i, i + cap)
1443
+ // 名额守恒:拿这一簇之后,剩下的卡还必须有地方放(每块 ≤ cap)。
1444
+ // 少了这一步,「最热的几个标签」会把 20 个名额吃光,剩下的卡最后挤成一颗巨球。
1445
+ const restAfter = n - assigned.size - part.length
1446
+ const slotsNeededAfter = Math.ceil(restAfter / cap)
1447
+ if (clusters.length + 1 + slotsNeededAfter > wanted) break
1448
+ for (const c of part) assigned.add(c.id)
1449
+ const partNo = i / cap + 1
1450
+ clusters.push({
1451
+ id: partNo === 1 ? 't:' + tag : 't:' + tag + '#' + partNo,
1452
+ label: partNo === 1 ? '#' + tag : '#' + tag + ' · ' + partNo,
1453
+ kind: pickClusterKind(part),
1454
+ count: part.length,
1455
+ cards: part,
1456
+ by: 'tag',
1457
+ })
1458
+ }
1459
+ }
1460
+ }
1461
+ // 剩下没被标签覆盖的卡:按权重等分成 ≤ 剩余名额 的若干块
1462
+ const rest = ordered.filter((c) => !assigned.has(c.id))
1463
+ if (rest.length) {
1464
+ const slots = Math.max(1, Math.min(maxClusters - clusters.length, Math.ceil(rest.length / cap)))
1465
+ const chunk = Math.ceil(rest.length / slots)
1466
+ for (let i = 0; i < rest.length; i += chunk) {
1467
+ const part = rest.slice(i, i + chunk)
1468
+ clusters.push({
1469
+ id: 'g:' + (clusters.length + 1),
1470
+ label: signal ? '其他卡片' : '第 ' + (clusters.length + 1) + ' 组',
1471
+ kind: pickClusterKind(part),
1472
+ count: part.length,
1473
+ cards: part,
1474
+ by: 'weight',
1475
+ })
1476
+ }
1477
+ }
1478
+ // 保险(标签拆片把名额用超时):合并「最小的两颗」,不把某一颗喂成吞掉全库的巨兽
1479
+ while (clusters.length > maxClusters) {
1480
+ let i1 = 0
1481
+ for (let i = 1; i < clusters.length; i++) if (clusters[i].count < clusters[i1].count) i1 = i
1482
+ let i2 = i1 === 0 ? 1 : 0
1483
+ for (let i = 0; i < clusters.length; i++) if (i !== i1 && clusters[i].count < clusters[i2].count) i2 = i
1484
+ const a = Math.min(i1, i2)
1485
+ const b = Math.max(i1, i2)
1486
+ clusters[b].cards = clusters[b].cards.concat(clusters[a].cards)
1487
+ clusters[b].count += clusters[a].count
1488
+ clusters.splice(a, 1)
1489
+ }
1490
+ return clusters
1491
+ }
1492
+
1493
+ /**
1494
+ * 星球层(根层 / 语义簇层)的聚合连线:按「共享标签」给可见图元连边。
1495
+ * 大星球用它成员卡片的标签并集参与,所以即使卡片被折叠,关系网也不会消失 ——
1496
+ * 这是旧版图谱最核心的视觉,星球层不能没有。纯函数,便于单测。
1497
+ * @param {Array<object>} items 本层图元(planet/card)
1498
+ * @param {{tagSets?:Map<string,Set<string>>, excludes?:Map<string,string>, limit?:number, maxPerNode?:number}} opts
1499
+ */
1500
+ export function planetLevelEdges(items, opts = {}) {
1501
+ const limit = Math.max(0, Math.floor(opts.limit === undefined ? PLANET_EDGE_LIMIT : opts.limit))
1502
+ const maxPerNode = Math.max(1, Math.floor(opts.maxPerNode || 8))
1503
+ const tagSets = opts.tagSets || new Map()
1504
+ const excludes = opts.excludes || new Map()
1505
+ const list = (Array.isArray(items) ? items : []).filter((it) => it && it.id)
1506
+ const scored = []
1507
+ for (let i = 0; i < list.length; i++) {
1508
+ const a = list[i]
1509
+ const sa = tagSets.get(a.id)
1510
+ if (!sa || !sa.size) continue
1511
+ for (let j = i + 1; j < list.length; j++) {
1512
+ const b = list[j]
1513
+ const sb = tagSets.get(b.id)
1514
+ if (!sb || !sb.size) continue
1515
+ const exA = excludes.get(a.id)
1516
+ const exB = excludes.get(b.id)
1517
+ let score = 0
1518
+ let shared = ''
1519
+ for (const t of sa) {
1520
+ if (t === exA || t === exB) continue
1521
+ if (sb.has(t)) { score += 1; if (!shared) shared = t }
1522
+ }
1523
+ if (score > 0) scored.push({ a: a.id, b: b.id, score, tag: shared })
1524
+ }
1525
+ }
1526
+ // 分数高的先连;每个图元最多 maxPerNode 条,避免少数热标签把整层连成毛线球
1527
+ scored.sort((x, y) => y.score - x.score || String(x.a).localeCompare(String(y.a)) || String(x.b).localeCompare(String(y.b)))
1528
+ const deg = new Map()
1529
+ const seen = new Set()
1530
+ const edges = []
1531
+ for (const s of scored) {
1532
+ const da = deg.get(s.a) || 0
1533
+ const db = deg.get(s.b) || 0
1534
+ if (da >= maxPerNode || db >= maxPerNode) continue
1535
+ const key = s.a < s.b ? s.a + '\u0000' + s.b : s.b + '\u0000' + s.a
1536
+ if (seen.has(key)) continue
1537
+ seen.add(key)
1538
+ deg.set(s.a, da + 1)
1539
+ deg.set(s.b, db + 1)
1540
+ edges.push({ source: s.a, target: s.b, type: 'tag', tag: s.tag, weight: s.score })
1541
+ if (edges.length >= limit) break
1542
+ }
1543
+ return edges
1544
+ }
1545
+
1546
+ /** 层内边:共享标签 TopK 连边(与全量图谱同一语义,只是限流到 LEVEL_EDGE_LIMIT 条)。 */
1547
+ export function levelEdges(items, opts = {}) {
1548
+ const limit = Math.max(0, Math.floor(opts.limit === undefined ? LEVEL_EDGE_LIMIT : opts.limit))
1549
+ const perTag = Math.max(2, Math.floor(opts.perTag || 6))
1550
+ const byTag = new Map()
1551
+ for (const it of Array.isArray(items) ? items : []) {
1552
+ if (!it || it.type !== 'card') continue
1553
+ for (const t of Array.isArray(it.tags) ? it.tags : []) {
1554
+ const arr = byTag.get(t)
1555
+ if (arr) arr.push(it.id)
1556
+ else byTag.set(t, [it.id])
1557
+ }
1558
+ }
1559
+ const seen = new Set()
1560
+ const edges = []
1561
+ for (const [tag, ids] of byTag) {
1562
+ if (ids.length < 2) continue
1563
+ const k = Math.min(perTag, ids.length)
1564
+ for (let i = 0; i < k; i++) {
1565
+ for (let j = i + 1; j < k; j++) {
1566
+ const a = String(ids[i])
1567
+ const b = String(ids[j])
1568
+ const key = a < b ? a + '\u0000' + b : b + '\u0000' + a
1569
+ if (seen.has(key)) continue
1570
+ seen.add(key)
1571
+ edges.push({ source: ids[i], target: ids[j], type: 'tag', tag })
1572
+ if (edges.length >= limit) return edges
1573
+ }
1574
+ }
1575
+ }
1576
+ return edges
1577
+ }
1578
+
1579
+ /**
1580
+ * 算出「这一层该显示什么」。纯函数:传入全部卡片 + 路径段,返回图元、面包屑、统计。
1581
+ * @param {Array<{id:string,title:string,kind:string,tags:string[],updated:string,mtime?:number}>} cards
1582
+ * @param {Array<string|{type:string,id:string,kinds?:string[]}>} segments
1583
+ * @param {{maxItems?:number,rootMaxItems?:number,bigKindMin?:number,maxClusters?:number,targetSize?:number,edgeLimit?:number}} opts
1584
+ */
1585
+ export function planGraphLevel(cards, segments = [], opts = {}) {
1586
+ const all = Array.isArray(cards) ? cards : []
1587
+ const maxItems = Math.max(1, Math.floor(opts.maxItems || LEVEL_MAX_ITEMS))
1588
+ const parsed = (Array.isArray(segments) ? segments : []).map((s) => (typeof s === 'string' ? parseLevelPath(s)[0] : s)).filter(Boolean)
1589
+ let scope = all
1590
+ const crumbs = [{ id: '', label: '全部', labelKey: 'graphAllCrumb', path: '', kinds: null, count: all.length }]
1591
+ for (const seg of parsed) {
1592
+ const prev = crumbs[crumbs.length - 1]
1593
+ const nextPath = prev.path ? prev.path + '/' + seg.id : seg.id
1594
+ if (seg.type === 'kind') {
1595
+ scope = scope.filter((c) => seg.kinds.includes(c.kind || 'other'))
1596
+ const single = seg.kinds.length === 1
1597
+ crumbs.push({
1598
+ id: seg.id,
1599
+ label: single ? (LEVEL_KIND_LABELS[seg.kinds[0]] || seg.kinds[0]) : '其他',
1600
+ labelKey: single ? (LEVEL_KIND_KEYS[seg.kinds[0]] || 'kindKnowledge') : 'graphOther',
1601
+ path: nextPath,
1602
+ kinds: seg.kinds,
1603
+ count: scope.length,
1604
+ })
1605
+ } else {
1606
+ const clusters = planClusters(scope, { maxClusters: opts.maxClusters, targetSize: opts.targetSize })
1607
+ const hit = clusters.find((cl) => cl.id === seg.id)
1608
+ // 卡片集合变了(新增/删除)→ 旧簇 id 失效:明确报错,让客户端回到上一层重取,而不是显示空层
1609
+ if (!hit) throw levelStaleError('语义簇已变化,请返回上一层重试:' + seg.id)
1610
+ scope = hit.cards
1611
+ crumbs.push({ id: seg.id, label: hit.label, labelKey: '', path: nextPath, kinds: null, count: scope.length })
1612
+ }
1613
+ }
1614
+ const levelId = parsed.map((s) => s.id).join('/')
1615
+ let level
1616
+ let clusterList = null
1617
+ if (!parsed.length) {
1618
+ const root = planRootLevel(all, opts)
1619
+ level = { id: '', ...root }
1620
+ } else if (scope.length <= maxItems) {
1621
+ level = {
1622
+ id: levelId,
1623
+ mode: 'cards',
1624
+ total: scope.length,
1625
+ unit: 'card',
1626
+ items: scope.map((c) => levelCardItem(c)),
1627
+ }
1628
+ } else {
1629
+ clusterList = planClusters(scope, { maxClusters: opts.maxClusters, targetSize: opts.targetSize })
1630
+ level = {
1631
+ id: levelId,
1632
+ mode: 'clusters',
1633
+ total: scope.length,
1634
+ unit: 'cluster',
1635
+ items: clusterList.map((cl) => levelPlanetItem({
1636
+ // 图元 id 就是路径段(t:tag / g:1):下钻时原样拼进 path,宿主按同一份集合复算即可
1637
+ id: cl.id, kind: cl.kind, label: cl.label, labelKey: '', count: cl.count, by: cl.by,
1638
+ })),
1639
+ clusterIds: clusterList.map((cl) => cl.id),
1640
+ }
1641
+ }
1642
+ // 连线是旧版图谱的核心视觉,星球层也不能省:用「共享标签」把可见图元连起来。
1643
+ // 大星球的标签取它成员卡片的并集;语义簇层排除「簇名本身的标签」,否则同源簇会全部两两相连。
1644
+ let edges
1645
+ if (level.mode === 'cards') {
1646
+ edges = levelEdges(level.items, { limit: opts.edgeLimit })
1647
+ } else {
1648
+ const tagSets = new Map()
1649
+ const excludes = new Map()
1650
+ if (level.mode === 'root') {
1651
+ const byKind = new Map()
1652
+ for (const c of all) {
1653
+ const k = c.kind || 'other'
1654
+ const arr = byKind.get(k)
1655
+ if (arr) arr.push(c)
1656
+ else byKind.set(k, [c])
1657
+ }
1658
+ for (const it of level.items) {
1659
+ if (it.type === 'card') {
1660
+ tagSets.set(it.id, new Set(Array.isArray(it.tags) ? it.tags : []))
1661
+ } else {
1662
+ const kinds = Array.isArray(it.kinds) && it.kinds.length ? it.kinds : [String(it.id).slice(2)]
1663
+ const set = new Set()
1664
+ for (const k of kinds) for (const c of byKind.get(k) || []) for (const t of c.tags || []) set.add(t)
1665
+ tagSets.set(it.id, set)
1666
+ }
1667
+ }
1668
+ } else {
1669
+ for (const it of level.items) {
1670
+ const cl = clusterList && clusterList.find((x) => x.id === it.id)
1671
+ const set = new Set()
1672
+ for (const c of (cl ? cl.cards : [])) for (const t of c.tags || []) set.add(t)
1673
+ tagSets.set(it.id, set)
1674
+ const m = String(it.id).match(/^t:([^#]+)/)
1675
+ if (m) excludes.set(it.id, m[1])
1676
+ }
1677
+ }
1678
+ edges = planetLevelEdges(level.items, { tagSets, excludes, limit: opts.planetEdgeLimit })
1679
+ }
1680
+ return {
1681
+ path: levelId,
1682
+ crumbs,
1683
+ level: { ...level, edges },
1684
+ stats: {
1685
+ items: level.items.length,
1686
+ edges: edges.length,
1687
+ total: all.length,
1688
+ scope: scope.length,
1689
+ maxItems,
1690
+ rootLimit: opts.rootMaxItems === undefined ? ROOT_MAX_ITEMS : opts.rootMaxItems,
1691
+ maxClusters: opts.maxClusters === undefined ? MAX_CLUSTERS : opts.maxClusters,
1692
+ },
1693
+ }
1694
+ }
1695
+
1696
+ // 分层接口的缓存:按(库 + 指纹 + 路径)失效,不是 TTL —— 任何写卡都会改变指纹。
1697
+ // 上界是双重的(条数 + 字节):单层响应最大 1MB,只按条数限(48 条)最坏也要 ~48MB,
1698
+ // 热缓存不该这么吃内存。超预算时从最久未用的那条开始淘汰。
1699
+ export const LEVEL_CACHE_MAX = 48
1700
+ export const LEVEL_CACHE_MAX_BYTES = 16 * 1024 * 1024
1701
+ export const LEVEL_CARDS_CACHE_MAX_ROOTS = 8
1702
+ const levelCache = new Map() // cacheKey -> { payload, bytes }
1703
+ const levelCardsCache = new Map() // root -> { fp, cards }
1704
+ let levelCacheBytes = 0
1705
+ // 生效中的上界:生产恒为上面的常量;单测用 setLevelCacheLimits() 临时收紧来验证淘汰逻辑。
1706
+ let levelLimitEntries = LEVEL_CACHE_MAX
1707
+ let levelLimitBytes = LEVEL_CACHE_MAX_BYTES
1708
+
1709
+ /** 测试/诊断用:清空分层缓存(不影响图谱主缓存,也不动上界)。 */
1710
+ export function resetLevelCache() {
1711
+ levelCache.clear()
1712
+ levelCardsCache.clear()
1713
+ levelCacheBytes = 0
1714
+ }
1715
+
1716
+ /**
1717
+ * 只读缓存账本:条数与字节数都必须有界(回归测试直接断言它)。
1718
+ * @returns {{levelEntries:number, levelBytes:number, levelMaxEntries:number, levelMaxBytes:number, cardsRoots:number, cardsMaxRoots:number}}
1719
+ */
1720
+ export function levelCacheStats() {
1721
+ return {
1722
+ levelEntries: levelCache.size,
1723
+ levelBytes: levelCacheBytes,
1724
+ levelMaxEntries: levelLimitEntries,
1725
+ levelMaxBytes: levelLimitBytes,
1726
+ cardsRoots: levelCardsCache.size,
1727
+ cardsMaxRoots: LEVEL_CARDS_CACHE_MAX_ROOTS,
1728
+ }
1729
+ }
1730
+
1731
+ /**
1732
+ * 测试/诊断用:临时调整缓存上界(生产默认 48 条 / 16MB;传 0/缺省表示恢复默认)。
1733
+ * 用来在单测里用很小的预算验证「字节上限会触发淘汰」,不必真的灌满 16MB。
1734
+ * @returns 生效后的上界
1735
+ */
1736
+ export function setLevelCacheLimits(opts = {}) {
1737
+ const max = Math.floor(Number(opts.maxEntries))
1738
+ const maxBytes = Math.floor(Number(opts.maxBytes))
1739
+ levelLimitEntries = Number.isFinite(max) && max > 0 ? max : LEVEL_CACHE_MAX
1740
+ levelLimitBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : LEVEL_CACHE_MAX_BYTES
1741
+ while (levelCache.size > levelLimitEntries) evictOldestLevelCache()
1742
+ while (levelCacheBytes > levelLimitBytes && levelCache.size > 0) evictOldestLevelCache()
1743
+ return { maxEntries: levelLimitEntries, maxBytes: levelLimitBytes }
1744
+ }
1745
+
1746
+ function evictOldestLevelCache() {
1747
+ const oldest = levelCache.keys().next().value
1748
+ if (oldest === undefined) return
1749
+ const rec = levelCache.get(oldest)
1750
+ levelCache.delete(oldest)
1751
+ if (rec) levelCacheBytes -= rec.bytes
1752
+ }
1753
+
1754
+ function levelCacheGet(key) {
1755
+ const hit = levelCache.get(key)
1756
+ if (!hit) return null
1757
+ // LRU:Map 的插入序就是淘汰序,命中即刷新热度
1758
+ levelCache.delete(key)
1759
+ levelCache.set(key, hit)
1760
+ return hit
1761
+ }
1762
+
1763
+ function levelCacheSet(key, payload, bytes) {
1764
+ const prev = levelCache.get(key)
1765
+ if (prev) {
1766
+ levelCacheBytes -= prev.bytes
1767
+ levelCache.delete(key)
1768
+ }
1769
+ levelCache.set(key, { payload, bytes })
1770
+ levelCacheBytes += bytes
1771
+ // 条数上限 / 字节上限:从最久未用的那条开始淘汰
1772
+ while (levelCacheBytes > levelLimitBytes && levelCache.size > 1) evictOldestLevelCache()
1773
+ while (levelCache.size > levelLimitEntries) evictOldestLevelCache()
1774
+ // 单条本身就超预算:连它也不留(本次响应照常返回)—— 保证字节账永远有界
1775
+ if (levelCacheBytes > levelLimitBytes) {
1776
+ const rec = levelCache.get(key)
1777
+ if (rec) {
1778
+ levelCache.delete(key)
1779
+ levelCacheBytes -= rec.bytes
1780
+ }
1781
+ }
1782
+ }
1783
+
1784
+ function readLevelCards(db) {
1785
+ // 只取分层需要的列(不读 body):10k 卡时整表扫描仍是个位数毫秒
1786
+ const rows = db.prepare('SELECT path, kind, title, tags, updated_at FROM cards').all()
1787
+ return rows.map((r) => ({
1788
+ id: r.path,
1789
+ path: r.path,
1790
+ title: r.title || '',
1791
+ kind: r.kind || 'other',
1792
+ tags: normalizeTags(r.tags),
1793
+ updated: r.updated_at || '',
1794
+ mtime: new Date(r.updated_at || 0).getTime() || 0,
1795
+ }))
1796
+ }
1797
+
1798
+ function levelCardsFor(roots) {
1799
+ const entries = []
1800
+ for (const r of roots) {
1801
+ const db = getDb(r.root)
1802
+ const fp = graphFingerprint(db)
1803
+ // 与 graphCache 同款:键是**库根**,命中后再比对指纹。
1804
+ // 旧实现把指纹拼进 key —— 每写一张卡指纹就变,于是每次写卡都白留一整份全库卡片
1805
+ // (10k 卡时是几 MB/次),长期运行等于慢性泄漏。
1806
+ let entry = levelCardsCache.get(r.root)
1807
+ if (!entry || entry.fp !== fp) {
1808
+ entry = { fp, cards: readLevelCards(db) }
1809
+ levelCardsCache.delete(r.root)
1810
+ levelCardsCache.set(r.root, entry)
1811
+ while (levelCardsCache.size > LEVEL_CARDS_CACHE_MAX_ROOTS) {
1812
+ const oldest = levelCardsCache.keys().next().value
1813
+ levelCardsCache.delete(oldest)
1814
+ }
1815
+ } else {
1816
+ // LRU:同一个库刚用过,挪到队尾
1817
+ levelCardsCache.delete(r.root)
1818
+ levelCardsCache.set(r.root, entry)
1819
+ }
1820
+ entries.push({ name: r.name || '', fp, cards: entry.cards })
1821
+ }
1822
+ const fingerprint = entries.map((e) => (e.name || '') + ':' + e.fp).join('|')
1823
+ const cards = []
1824
+ const seen = new Set()
1825
+ for (const e of entries) {
1826
+ const pref = e.name ? e.name + '::' : ''
1827
+ for (const c of e.cards) {
1828
+ const id = pref + c.id
1829
+ if (seen.has(id)) continue
1830
+ seen.add(id)
1831
+ cards.push(pref ? { ...c, id, path: id } : c)
1832
+ }
1833
+ }
1834
+ return { fingerprint, cards }
1835
+ }
1836
+
1837
+ /**
1838
+ * 分层图谱:一次只返回「一层」的图元(≤1000)与层内边,客户端按层请求。
1839
+ * 与 graph() 的关系:graph() 仍是全量图(导出/诊断/兼容旧客户端),分层视图只走这里。
1840
+ * @param {string} root 当前库根
1841
+ * @param {{path?:string, roots?:Array<{name:string,root:string}>, plan?:object}} opts
1842
+ * @returns {Promise<{ok:true, path:string, crumbs:Array, level:object, stats:object}>}
1843
+ */
1844
+ export async function graphLevel(root, opts = {}) {
1845
+ const started = Date.now()
1846
+ const pathStr = String(opts.path == null ? '' : opts.path).trim()
1847
+ const roots = Array.isArray(opts.roots) && opts.roots.length ? opts.roots : [{ name: '', root }]
1848
+ const segments = parseLevelPath(pathStr) // 非法路径直接抛(调用方转 400)
1849
+ const { fingerprint, cards } = levelCardsFor(roots)
1850
+ const cacheKey = fingerprint + '\u0000' + pathStr
1851
+ const hit = levelCacheGet(cacheKey)
1852
+ if (hit) return { ...hit.payload, stats: { ...hit.payload.stats, cached: true, ms: Date.now() - started } }
1853
+ const plan = planGraphLevel(cards, segments, opts.plan || {})
1854
+ // payload 守卫:先砍边;极端情况下(图元本身过大)如实标记 truncated,绝不静默假装没事
1855
+ let bytes = Buffer.byteLength(JSON.stringify({ ok: true, ...plan }))
1856
+ let edgesTrimmed = false
1857
+ while (bytes > LEVEL_PAYLOAD_LIMIT && plan.level.edges.length > 0) {
1858
+ plan.level.edges.length = Math.floor(plan.level.edges.length / 2)
1859
+ edgesTrimmed = true
1860
+ bytes = Buffer.byteLength(JSON.stringify({ ok: true, ...plan }))
1861
+ }
1862
+ const payload = {
1863
+ ok: true,
1864
+ ...plan,
1865
+ stats: {
1866
+ ...plan.stats,
1867
+ edges: plan.level.edges.length,
1868
+ edgesTrimmed,
1869
+ bytes,
1870
+ ms: Date.now() - started,
1871
+ cached: false,
1872
+ payloadLimit: LEVEL_PAYLOAD_LIMIT,
1873
+ },
1874
+ }
1875
+ levelCacheSet(cacheKey, payload, bytes)
1876
+ return payload
1877
+ }
1878
+
1103
1879
  /** 跨库聚合检索。 */
1104
1880
  export async function searchAll(roots, query, opts = {}) {
1105
1881
  const out = []