memory-eternal 0.18.2 → 0.21.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
@@ -18,12 +18,13 @@ export const KIND_ROOTS = {
18
18
  business: '06-Business',
19
19
  tool: '07-Tools',
20
20
  mistake: '08-Mistakes',
21
+ skill: '10-Skills',
21
22
  }
22
23
 
23
24
  /** 核心记忆:人工批准后,每次会话注入 systemPrompt 的卡片类型。 */
24
25
  export const CORE_KIND = 'core'
25
26
 
26
- export const CAPTURE_KINDS = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake']
27
+ export const CAPTURE_KINDS = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake', 'skill']
27
28
 
28
29
  /** 从 Markdown 文本提取 frontmatter 与正文。 */
29
30
  export function parseCard(text) {
@@ -677,6 +678,30 @@ export async function appendUpdate(root, rel, updateText) {
677
678
  return { ok: true, path: rel, updated: now, store: found.table }
678
679
  }
679
680
 
681
+ /**
682
+ * 原地更新一张卡的内容(标题 / 标签 / 正文 / 原因),不改变它的状态与所在表。
683
+ *
684
+ * 仅供「技能卡同步」这类**系统生成**路径使用:技能定义变了,待审卡应就地刷新,
685
+ * 而不是每轮扫描都新建一张重复的待审卡。人工卡审核流转仍走 setCardStatus。
686
+ *
687
+ * @returns {{ok:boolean, path:string, store:'cards'|'quarantine', updated:string}}
688
+ */
689
+ export async function updateCardContent(root, rel, { title, tags, body, reason } = {}) {
690
+ const db = getDb(root)
691
+ const found = findCardRow(db, rel)
692
+ if (!found) throw new Error('卡片不存在: ' + rel)
693
+ const row = found.row
694
+ const now = new Date().toISOString()
695
+ const nextTitle = title === undefined ? row.title : String(title)
696
+ const nextTags = tags === undefined ? normalizeTags(row.tags) : normalizeTags(tags)
697
+ const nextBody = body === undefined ? row.body : String(body).trim()
698
+ const nextReason = reason === undefined ? row.reason : String(reason)
699
+ db.prepare(
700
+ `UPDATE ${found.table} SET title = ?, tags = ?, body = ?, summary = ?, reason = ?, updated_at = ? WHERE path = ?`,
701
+ ).run(nextTitle, JSON.stringify(nextTags), nextBody, nextBody.slice(0, 200), nextReason, now, rel)
702
+ return { ok: true, path: rel, store: found.table, updated: now }
703
+ }
704
+
680
705
  /** 合并多张卡为一张。 */
681
706
  export async function mergeCards(root, paths) {
682
707
  const list = (Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean)
@@ -1075,6 +1100,673 @@ export async function graphAll(roots) {
1075
1100
  return { nodes, edges }
1076
1101
  }
1077
1102
 
1103
+ // ---- 星球分层视图:按层请求的规划(纯函数,可单测)--------------------------------
1104
+ //
1105
+ // 为什么需要:2049 张卡一次性返回 nodes+edges(实测 2049 节点 / 10106 条边),
1106
+ // 前端也一次性渲染全部节点 —— 首屏是一团墨点,既看不清也点不准,10k 时更不可能。
1107
+ // 现在改成「一层一层看」:
1108
+ // · 根层按 kind 折叠:某类 >200 张 → 一颗带环大星球;≤200 张 → 逐颗小星球;
1109
+ // 根层图元 >500 时把「最小的 kind」依次并进「其他」桶(每并一个类,图元数减少 size-1);
1110
+ // · 进入某类 / 某个语义簇后:集合 ≤1000 张直接铺卡片;>1000 张按语义簇(标签优先)
1111
+ // 拆成 ≤20 颗大星球,逐级下钻(每个大星球仍 >1000 时继续按同样规则拆);
1112
+ // · 语义聚类没有有效信号(标签太散/没有标签)时回退:按权重(更新时间)等分 ≤20 块。
1113
+ //
1114
+ // 「这一层显示什么」全部是 planGraphLevel 这组纯函数算出来的:路由层只负责读库 + 缓存,
1115
+ // 于是 10k 规模的分层规则能在单测里穷举,不必真的点进浏览器。
1116
+ export const LEVEL_MAX_ITEMS = 1000 // 单层图元上限(再多就分簇)
1117
+ export const ROOT_BIG_KIND_MIN = 200 // 某类 >200 张 → 一颗大星球(严格大于)
1118
+ export const ROOT_MAX_ITEMS = 500 // 根层图元上限(超出 → 最小 kind 合并「其他」)
1119
+ export const MAX_CLUSTERS = 20 // 一层最多几颗语义簇大星球
1120
+ export const CLUSTER_TARGET_SIZE = 250 // 语义簇目标体量(决定分几簇)
1121
+ export const LEVEL_EDGE_LIMIT = 2400 // 单层边上限(与 payload 红线一起兜住传输量)
1122
+ export const PLANET_EDGE_LIMIT = 1200 // 星球层聚合边上限(共享标签关系网,卡片折叠后仍保留「连线」视觉)
1123
+ export const LEVEL_PAYLOAD_LIMIT = 1024 * 1024 // 单次层响应 payload 上限:1MB
1124
+
1125
+ const LEVEL_KIND_LABELS = {
1126
+ core: '核心记忆', skill: '技能', project: '项目', knowledge: '知识', content: '内容',
1127
+ prompt: '提示词', business: '业务', tool: '工具', mistake: '教训', other: '其他',
1128
+ }
1129
+ const LEVEL_KIND_KEYS = {
1130
+ core: 'kindCore', skill: 'kindSkill', project: 'kindProject', knowledge: 'kindKnowledge',
1131
+ content: 'kindContent', prompt: 'kindPrompt', business: 'kindBusiness', tool: 'kindTool',
1132
+ mistake: 'kindMistake', other: 'graphOther',
1133
+ }
1134
+
1135
+ /**
1136
+ * 分层请求的错误分类:调用方(lib/api.js)按 code 决定 HTTP 状态。
1137
+ * LEVEL_PATH —— 路径写错(客户端可纠正)→ 400 + 原文案
1138
+ * LEVEL_STALE —— 语义簇已变化(卡片增删导致)→ 400 + 提示返回上一层
1139
+ * 其余异常一律视为「未知内部错误」→ 500 + 通用文案(细节只进日志)。
1140
+ */
1141
+ export const LEVEL_ERR_PATH = 'LEVEL_PATH'
1142
+ export const LEVEL_ERR_STALE = 'LEVEL_STALE'
1143
+
1144
+ function levelPathError(message) {
1145
+ const err = new Error(message)
1146
+ err.code = LEVEL_ERR_PATH
1147
+ return err
1148
+ }
1149
+
1150
+ function levelStaleError(message) {
1151
+ const err = new Error(message)
1152
+ err.code = LEVEL_ERR_STALE
1153
+ return err
1154
+ }
1155
+
1156
+ /**
1157
+ * 解析层级路径字符串(客户端 → 宿主的唯一寻址方式)。段之间用 `/` 分隔:
1158
+ * k:<kind> 某一类(如 k:project)
1159
+ * k:<kind1>,<kind2> 合并桶(如 k:content,mistake,skill → 「其他」)
1160
+ * t:<tag>[#n] 按标签聚出来的语义簇(n = 大标签拆片序号)
1161
+ * g:<n> 按权重等分出来的兜底块
1162
+ * 非法段直接抛错(调用方转成 400 + 明确文案),不做「猜一个」的兜底。
1163
+ */
1164
+ export function parseLevelPath(pathStr) {
1165
+ const raw = String(pathStr == null ? '' : pathStr).trim()
1166
+ if (!raw) return []
1167
+ return raw.split('/').filter(Boolean).map((seg) => {
1168
+ if (seg.startsWith('k:')) {
1169
+ const kinds = seg.slice(2).split(',').map((s) => s.trim()).filter(Boolean)
1170
+ if (!kinds.length) throw levelPathError('层级路径缺少 kind:' + seg)
1171
+ return { type: 'kind', id: seg, kinds }
1172
+ }
1173
+ if (seg.startsWith('t:') || seg.startsWith('g:')) return { type: 'cluster', id: seg }
1174
+ throw levelPathError('无法识别的层级路径段:' + seg)
1175
+ })
1176
+ }
1177
+
1178
+ /** 把段数组拼回路径字符串(客户端面包屑用它返回上层)。 */
1179
+ export function encodeLevelPath(segments) {
1180
+ return (Array.isArray(segments) ? segments : []).map((s) => (typeof s === 'string' ? s : s && s.id)).filter(Boolean).join('/')
1181
+ }
1182
+
1183
+ function levelCardItem(card) {
1184
+ return {
1185
+ type: 'card',
1186
+ id: String(card.id || card.path || ''),
1187
+ label: String(card.title || card.id || ''),
1188
+ kind: card.kind || 'other',
1189
+ updated: card.updated || '',
1190
+ tags: Array.isArray(card.tags) ? card.tags.slice(0, 8) : [],
1191
+ }
1192
+ }
1193
+
1194
+ function levelPlanetItem({ id, kind, kinds, label, labelKey, count, by }) {
1195
+ return {
1196
+ type: 'planet',
1197
+ id,
1198
+ kind: kind || 'other',
1199
+ kinds: Array.isArray(kinds) && kinds.length > 1 ? kinds.slice() : null,
1200
+ label: label || '',
1201
+ labelKey: labelKey || '',
1202
+ count: Math.max(0, Math.floor(Number(count) || 0)),
1203
+ by: by || 'kind',
1204
+ }
1205
+ }
1206
+
1207
+ /** 根层:按 kind 折叠 + 超限合并「其他」。纯函数,返回这一层的图元。 */
1208
+ export function planRootLevel(cards, opts = {}) {
1209
+ const bigMin = opts.bigKindMin === undefined ? ROOT_BIG_KIND_MIN : opts.bigKindMin
1210
+ const rootMax = opts.rootMaxItems === undefined ? ROOT_MAX_ITEMS : opts.rootMaxItems
1211
+ const groups = new Map()
1212
+ for (const c of Array.isArray(cards) ? cards : []) {
1213
+ const kind = c.kind || 'other'
1214
+ const g = groups.get(kind)
1215
+ if (g) g.cards.push(c)
1216
+ else groups.set(kind, { kind, cards: [c] })
1217
+ }
1218
+ const bigGroups = []
1219
+ const smallGroups = []
1220
+ for (const g of groups.values()) (g.cards.length > bigMin ? bigGroups : smallGroups).push(g)
1221
+ // 先算账(不在数组里反复删除):大类占 1 个图元,小类逐颗展开占 size 个
1222
+ let total = bigGroups.length
1223
+ for (const g of smallGroups) total += g.cards.length
1224
+ const merged = []
1225
+ const mergedSet = new Set()
1226
+ let mergedCount = 0
1227
+ const queue = smallGroups
1228
+ .map((g) => ({ kind: g.kind, size: g.cards.length }))
1229
+ .sort((a, b) => a.size - b.size || String(a.kind).localeCompare(String(b.kind)))
1230
+ while (total > rootMax && queue.length) {
1231
+ const g = queue.shift()
1232
+ merged.push(g.kind)
1233
+ mergedSet.add(g.kind)
1234
+ mergedCount += g.size
1235
+ // 这一类原来占 size 个图元(逐颗小星球);并进「其他」后:
1236
+ // 少掉 size 个,同时「其他」桶自己第一次出现时占 1 个 —— 所以净减少 size,首次再 +1
1237
+ total -= g.size
1238
+ if (merged.length === 1) total += 1
1239
+ }
1240
+ // 保险(>500 个「大 kind」,实盘不会出现):除最大的一类外全部并成一颗
1241
+ if (total > rootMax) {
1242
+ const bigLeft = bigGroups.filter((g) => !mergedSet.has(g.kind))
1243
+ if (bigLeft.length > 1) {
1244
+ const sorted = bigLeft.slice().sort((a, b) => a.cards.length - b.cards.length || String(a.kind).localeCompare(String(b.kind)))
1245
+ const keep = sorted[sorted.length - 1]
1246
+ for (const g of sorted) {
1247
+ if (g === keep) continue
1248
+ merged.push(g.kind)
1249
+ mergedSet.add(g.kind)
1250
+ mergedCount += g.cards.length
1251
+ }
1252
+ total = 1 + (mergedCount ? 1 : 0)
1253
+ }
1254
+ }
1255
+ const items = []
1256
+ for (const g of bigGroups) {
1257
+ if (mergedSet.has(g.kind)) continue
1258
+ items.push(levelPlanetItem({
1259
+ id: 'k:' + g.kind, kind: g.kind, label: LEVEL_KIND_LABELS[g.kind] || g.kind,
1260
+ labelKey: LEVEL_KIND_KEYS[g.kind] || 'kindKnowledge', count: g.cards.length, by: 'kind',
1261
+ }))
1262
+ }
1263
+ for (const g of smallGroups) {
1264
+ if (mergedSet.has(g.kind)) continue
1265
+ for (const c of g.cards) items.push(levelCardItem(c))
1266
+ }
1267
+ if (merged.length) {
1268
+ items.push(levelPlanetItem({
1269
+ id: 'k:' + merged.slice().sort().join(','), kinds: merged.slice().sort(), kind: 'other',
1270
+ label: '其他', labelKey: 'graphOther', count: mergedCount, by: 'merged',
1271
+ }))
1272
+ }
1273
+ return {
1274
+ mode: 'root',
1275
+ total: Array.isArray(cards) ? cards.length : 0,
1276
+ items,
1277
+ unit: 'root',
1278
+ bigKinds: bigGroups.filter((g) => !mergedSet.has(g.kind)).map((g) => g.kind),
1279
+ mergedKinds: merged.slice(),
1280
+ rootLimit: rootMax,
1281
+ bigKindMin: bigMin,
1282
+ }
1283
+ }
1284
+
1285
+ function cardWeight(card) {
1286
+ const t = Number(card && (card.mtime || Date.parse(card.updated || '') || 0))
1287
+ return Number.isFinite(t) ? t : 0
1288
+ }
1289
+
1290
+ function pickClusterKind(cards) {
1291
+ const counts = new Map()
1292
+ for (const c of cards) counts.set(c.kind || 'other', (counts.get(c.kind || 'other') || 0) + 1)
1293
+ let best = 'other'
1294
+ let n = -1
1295
+ for (const [k, v] of counts) if (v > n || (v === n && String(k) < String(best))) { best = k; n = v }
1296
+ return best
1297
+ }
1298
+
1299
+ /**
1300
+ * 语义簇:标签优先,标签没有有效信号时按权重(更新时间)等分。
1301
+ * 返回的簇 id 是**确定性**的(同一份卡片集合必定得到同样的 id),所以下钻时能原样复算。
1302
+ * @param {Array<object>} cards
1303
+ * @param {{maxClusters?:number, targetSize?:number}} opts
1304
+ * @returns {Array<{id:string,label:string,kind:string,count:number,cards:Array<object>,by:string}>}
1305
+ */
1306
+ export function planClusters(cards, opts = {}) {
1307
+ const list = Array.isArray(cards) ? cards : []
1308
+ const n = list.length
1309
+ if (!n) return []
1310
+ const maxClusters = Math.max(2, Math.floor(opts.maxClusters || MAX_CLUSTERS))
1311
+ const target = Math.max(1, Math.floor(opts.targetSize || CLUSTER_TARGET_SIZE))
1312
+ const wanted = Math.min(maxClusters, Math.max(2, Math.ceil(n / target)))
1313
+ const cap = Math.max(1, Math.ceil(n / wanted))
1314
+ const ordered = list.slice().sort((a, b) => cardWeight(b) - cardWeight(a) || String(a.id || '').localeCompare(String(b.id || '')))
1315
+ const tagCount = new Map()
1316
+ for (const c of ordered) for (const t of Array.isArray(c.tags) ? c.tags : []) tagCount.set(t, (tagCount.get(t) || 0) + 1)
1317
+ const tags = [...tagCount.entries()]
1318
+ .filter(([, cnt]) => cnt >= 2)
1319
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
1320
+ // 语义信号门槛:最强的标签至少覆盖 2%(且 ≥3 张),否则「按标签分簇」只是把噪声切成小块
1321
+ const signal = tags.length > 0 && tags[0][1] >= Math.max(3, Math.ceil(n * 0.02))
1322
+ const clusters = []
1323
+ const assigned = new Set()
1324
+ if (signal) {
1325
+ // 给「没标签的卡」预留至少 1 个名额,否则它们会被标签簇挤掉
1326
+ const untagged = ordered.filter((c) => !(Array.isArray(c.tags) && c.tags.length)).length
1327
+ const tagBudget = Math.max(1, wanted - (untagged > 0 ? 1 : 0))
1328
+ for (const [tag] of tags) {
1329
+ if (clusters.length >= tagBudget) break
1330
+ const members = ordered.filter((c) => !assigned.has(c.id) && Array.isArray(c.tags) && c.tags.includes(tag))
1331
+ if (members.length < 2) continue
1332
+ for (let i = 0; i < members.length && clusters.length < tagBudget; i += cap) {
1333
+ const part = members.slice(i, i + cap)
1334
+ // 名额守恒:拿这一簇之后,剩下的卡还必须有地方放(每块 ≤ cap)。
1335
+ // 少了这一步,「最热的几个标签」会把 20 个名额吃光,剩下的卡最后挤成一颗巨球。
1336
+ const restAfter = n - assigned.size - part.length
1337
+ const slotsNeededAfter = Math.ceil(restAfter / cap)
1338
+ if (clusters.length + 1 + slotsNeededAfter > wanted) break
1339
+ for (const c of part) assigned.add(c.id)
1340
+ const partNo = i / cap + 1
1341
+ clusters.push({
1342
+ id: partNo === 1 ? 't:' + tag : 't:' + tag + '#' + partNo,
1343
+ label: partNo === 1 ? '#' + tag : '#' + tag + ' · ' + partNo,
1344
+ kind: pickClusterKind(part),
1345
+ count: part.length,
1346
+ cards: part,
1347
+ by: 'tag',
1348
+ })
1349
+ }
1350
+ }
1351
+ }
1352
+ // 剩下没被标签覆盖的卡:按权重等分成 ≤ 剩余名额 的若干块
1353
+ const rest = ordered.filter((c) => !assigned.has(c.id))
1354
+ if (rest.length) {
1355
+ const slots = Math.max(1, Math.min(maxClusters - clusters.length, Math.ceil(rest.length / cap)))
1356
+ const chunk = Math.ceil(rest.length / slots)
1357
+ for (let i = 0; i < rest.length; i += chunk) {
1358
+ const part = rest.slice(i, i + chunk)
1359
+ clusters.push({
1360
+ id: 'g:' + (clusters.length + 1),
1361
+ label: signal ? '其他卡片' : '第 ' + (clusters.length + 1) + ' 组',
1362
+ kind: pickClusterKind(part),
1363
+ count: part.length,
1364
+ cards: part,
1365
+ by: 'weight',
1366
+ })
1367
+ }
1368
+ }
1369
+ // 保险(标签拆片把名额用超时):合并「最小的两颗」,不把某一颗喂成吞掉全库的巨兽
1370
+ while (clusters.length > maxClusters) {
1371
+ let i1 = 0
1372
+ for (let i = 1; i < clusters.length; i++) if (clusters[i].count < clusters[i1].count) i1 = i
1373
+ let i2 = i1 === 0 ? 1 : 0
1374
+ for (let i = 0; i < clusters.length; i++) if (i !== i1 && clusters[i].count < clusters[i2].count) i2 = i
1375
+ const a = Math.min(i1, i2)
1376
+ const b = Math.max(i1, i2)
1377
+ clusters[b].cards = clusters[b].cards.concat(clusters[a].cards)
1378
+ clusters[b].count += clusters[a].count
1379
+ clusters.splice(a, 1)
1380
+ }
1381
+ return clusters
1382
+ }
1383
+
1384
+ /**
1385
+ * 星球层(根层 / 语义簇层)的聚合连线:按「共享标签」给可见图元连边。
1386
+ * 大星球用它成员卡片的标签并集参与,所以即使卡片被折叠,关系网也不会消失 ——
1387
+ * 这是旧版图谱最核心的视觉,星球层不能没有。纯函数,便于单测。
1388
+ * @param {Array<object>} items 本层图元(planet/card)
1389
+ * @param {{tagSets?:Map<string,Set<string>>, excludes?:Map<string,string>, limit?:number, maxPerNode?:number}} opts
1390
+ */
1391
+ export function planetLevelEdges(items, opts = {}) {
1392
+ const limit = Math.max(0, Math.floor(opts.limit === undefined ? PLANET_EDGE_LIMIT : opts.limit))
1393
+ const maxPerNode = Math.max(1, Math.floor(opts.maxPerNode || 8))
1394
+ const tagSets = opts.tagSets || new Map()
1395
+ const excludes = opts.excludes || new Map()
1396
+ const list = (Array.isArray(items) ? items : []).filter((it) => it && it.id)
1397
+ const scored = []
1398
+ for (let i = 0; i < list.length; i++) {
1399
+ const a = list[i]
1400
+ const sa = tagSets.get(a.id)
1401
+ if (!sa || !sa.size) continue
1402
+ for (let j = i + 1; j < list.length; j++) {
1403
+ const b = list[j]
1404
+ const sb = tagSets.get(b.id)
1405
+ if (!sb || !sb.size) continue
1406
+ const exA = excludes.get(a.id)
1407
+ const exB = excludes.get(b.id)
1408
+ let score = 0
1409
+ let shared = ''
1410
+ for (const t of sa) {
1411
+ if (t === exA || t === exB) continue
1412
+ if (sb.has(t)) { score += 1; if (!shared) shared = t }
1413
+ }
1414
+ if (score > 0) scored.push({ a: a.id, b: b.id, score, tag: shared })
1415
+ }
1416
+ }
1417
+ // 分数高的先连;每个图元最多 maxPerNode 条,避免少数热标签把整层连成毛线球
1418
+ scored.sort((x, y) => y.score - x.score || String(x.a).localeCompare(String(y.a)) || String(x.b).localeCompare(String(y.b)))
1419
+ const deg = new Map()
1420
+ const seen = new Set()
1421
+ const edges = []
1422
+ for (const s of scored) {
1423
+ const da = deg.get(s.a) || 0
1424
+ const db = deg.get(s.b) || 0
1425
+ if (da >= maxPerNode || db >= maxPerNode) continue
1426
+ const key = s.a < s.b ? s.a + '\u0000' + s.b : s.b + '\u0000' + s.a
1427
+ if (seen.has(key)) continue
1428
+ seen.add(key)
1429
+ deg.set(s.a, da + 1)
1430
+ deg.set(s.b, db + 1)
1431
+ edges.push({ source: s.a, target: s.b, type: 'tag', tag: s.tag, weight: s.score })
1432
+ if (edges.length >= limit) break
1433
+ }
1434
+ return edges
1435
+ }
1436
+
1437
+ /** 层内边:共享标签 TopK 连边(与全量图谱同一语义,只是限流到 LEVEL_EDGE_LIMIT 条)。 */
1438
+ export function levelEdges(items, opts = {}) {
1439
+ const limit = Math.max(0, Math.floor(opts.limit === undefined ? LEVEL_EDGE_LIMIT : opts.limit))
1440
+ const perTag = Math.max(2, Math.floor(opts.perTag || 6))
1441
+ const byTag = new Map()
1442
+ for (const it of Array.isArray(items) ? items : []) {
1443
+ if (!it || it.type !== 'card') continue
1444
+ for (const t of Array.isArray(it.tags) ? it.tags : []) {
1445
+ const arr = byTag.get(t)
1446
+ if (arr) arr.push(it.id)
1447
+ else byTag.set(t, [it.id])
1448
+ }
1449
+ }
1450
+ const seen = new Set()
1451
+ const edges = []
1452
+ for (const [tag, ids] of byTag) {
1453
+ if (ids.length < 2) continue
1454
+ const k = Math.min(perTag, ids.length)
1455
+ for (let i = 0; i < k; i++) {
1456
+ for (let j = i + 1; j < k; j++) {
1457
+ const a = String(ids[i])
1458
+ const b = String(ids[j])
1459
+ const key = a < b ? a + '\u0000' + b : b + '\u0000' + a
1460
+ if (seen.has(key)) continue
1461
+ seen.add(key)
1462
+ edges.push({ source: ids[i], target: ids[j], type: 'tag', tag })
1463
+ if (edges.length >= limit) return edges
1464
+ }
1465
+ }
1466
+ }
1467
+ return edges
1468
+ }
1469
+
1470
+ /**
1471
+ * 算出「这一层该显示什么」。纯函数:传入全部卡片 + 路径段,返回图元、面包屑、统计。
1472
+ * @param {Array<{id:string,title:string,kind:string,tags:string[],updated:string,mtime?:number}>} cards
1473
+ * @param {Array<string|{type:string,id:string,kinds?:string[]}>} segments
1474
+ * @param {{maxItems?:number,rootMaxItems?:number,bigKindMin?:number,maxClusters?:number,targetSize?:number,edgeLimit?:number}} opts
1475
+ */
1476
+ export function planGraphLevel(cards, segments = [], opts = {}) {
1477
+ const all = Array.isArray(cards) ? cards : []
1478
+ const maxItems = Math.max(1, Math.floor(opts.maxItems || LEVEL_MAX_ITEMS))
1479
+ const parsed = (Array.isArray(segments) ? segments : []).map((s) => (typeof s === 'string' ? parseLevelPath(s)[0] : s)).filter(Boolean)
1480
+ let scope = all
1481
+ const crumbs = [{ id: '', label: '全部', labelKey: 'graphAllCrumb', path: '', kinds: null, count: all.length }]
1482
+ for (const seg of parsed) {
1483
+ const prev = crumbs[crumbs.length - 1]
1484
+ const nextPath = prev.path ? prev.path + '/' + seg.id : seg.id
1485
+ if (seg.type === 'kind') {
1486
+ scope = scope.filter((c) => seg.kinds.includes(c.kind || 'other'))
1487
+ const single = seg.kinds.length === 1
1488
+ crumbs.push({
1489
+ id: seg.id,
1490
+ label: single ? (LEVEL_KIND_LABELS[seg.kinds[0]] || seg.kinds[0]) : '其他',
1491
+ labelKey: single ? (LEVEL_KIND_KEYS[seg.kinds[0]] || 'kindKnowledge') : 'graphOther',
1492
+ path: nextPath,
1493
+ kinds: seg.kinds,
1494
+ count: scope.length,
1495
+ })
1496
+ } else {
1497
+ const clusters = planClusters(scope, { maxClusters: opts.maxClusters, targetSize: opts.targetSize })
1498
+ const hit = clusters.find((cl) => cl.id === seg.id)
1499
+ // 卡片集合变了(新增/删除)→ 旧簇 id 失效:明确报错,让客户端回到上一层重取,而不是显示空层
1500
+ if (!hit) throw levelStaleError('语义簇已变化,请返回上一层重试:' + seg.id)
1501
+ scope = hit.cards
1502
+ crumbs.push({ id: seg.id, label: hit.label, labelKey: '', path: nextPath, kinds: null, count: scope.length })
1503
+ }
1504
+ }
1505
+ const levelId = parsed.map((s) => s.id).join('/')
1506
+ let level
1507
+ let clusterList = null
1508
+ if (!parsed.length) {
1509
+ const root = planRootLevel(all, opts)
1510
+ level = { id: '', ...root }
1511
+ } else if (scope.length <= maxItems) {
1512
+ level = {
1513
+ id: levelId,
1514
+ mode: 'cards',
1515
+ total: scope.length,
1516
+ unit: 'card',
1517
+ items: scope.map((c) => levelCardItem(c)),
1518
+ }
1519
+ } else {
1520
+ clusterList = planClusters(scope, { maxClusters: opts.maxClusters, targetSize: opts.targetSize })
1521
+ level = {
1522
+ id: levelId,
1523
+ mode: 'clusters',
1524
+ total: scope.length,
1525
+ unit: 'cluster',
1526
+ items: clusterList.map((cl) => levelPlanetItem({
1527
+ // 图元 id 就是路径段(t:tag / g:1):下钻时原样拼进 path,宿主按同一份集合复算即可
1528
+ id: cl.id, kind: cl.kind, label: cl.label, labelKey: '', count: cl.count, by: cl.by,
1529
+ })),
1530
+ clusterIds: clusterList.map((cl) => cl.id),
1531
+ }
1532
+ }
1533
+ // 连线是旧版图谱的核心视觉,星球层也不能省:用「共享标签」把可见图元连起来。
1534
+ // 大星球的标签取它成员卡片的并集;语义簇层排除「簇名本身的标签」,否则同源簇会全部两两相连。
1535
+ let edges
1536
+ if (level.mode === 'cards') {
1537
+ edges = levelEdges(level.items, { limit: opts.edgeLimit })
1538
+ } else {
1539
+ const tagSets = new Map()
1540
+ const excludes = new Map()
1541
+ if (level.mode === 'root') {
1542
+ const byKind = new Map()
1543
+ for (const c of all) {
1544
+ const k = c.kind || 'other'
1545
+ const arr = byKind.get(k)
1546
+ if (arr) arr.push(c)
1547
+ else byKind.set(k, [c])
1548
+ }
1549
+ for (const it of level.items) {
1550
+ if (it.type === 'card') {
1551
+ tagSets.set(it.id, new Set(Array.isArray(it.tags) ? it.tags : []))
1552
+ } else {
1553
+ const kinds = Array.isArray(it.kinds) && it.kinds.length ? it.kinds : [String(it.id).slice(2)]
1554
+ const set = new Set()
1555
+ for (const k of kinds) for (const c of byKind.get(k) || []) for (const t of c.tags || []) set.add(t)
1556
+ tagSets.set(it.id, set)
1557
+ }
1558
+ }
1559
+ } else {
1560
+ for (const it of level.items) {
1561
+ const cl = clusterList && clusterList.find((x) => x.id === it.id)
1562
+ const set = new Set()
1563
+ for (const c of (cl ? cl.cards : [])) for (const t of c.tags || []) set.add(t)
1564
+ tagSets.set(it.id, set)
1565
+ const m = String(it.id).match(/^t:([^#]+)/)
1566
+ if (m) excludes.set(it.id, m[1])
1567
+ }
1568
+ }
1569
+ edges = planetLevelEdges(level.items, { tagSets, excludes, limit: opts.planetEdgeLimit })
1570
+ }
1571
+ return {
1572
+ path: levelId,
1573
+ crumbs,
1574
+ level: { ...level, edges },
1575
+ stats: {
1576
+ items: level.items.length,
1577
+ edges: edges.length,
1578
+ total: all.length,
1579
+ scope: scope.length,
1580
+ maxItems,
1581
+ rootLimit: opts.rootMaxItems === undefined ? ROOT_MAX_ITEMS : opts.rootMaxItems,
1582
+ maxClusters: opts.maxClusters === undefined ? MAX_CLUSTERS : opts.maxClusters,
1583
+ },
1584
+ }
1585
+ }
1586
+
1587
+ // 分层接口的缓存:按(库 + 指纹 + 路径)失效,不是 TTL —— 任何写卡都会改变指纹。
1588
+ // 上界是双重的(条数 + 字节):单层响应最大 1MB,只按条数限(48 条)最坏也要 ~48MB,
1589
+ // 热缓存不该这么吃内存。超预算时从最久未用的那条开始淘汰。
1590
+ export const LEVEL_CACHE_MAX = 48
1591
+ export const LEVEL_CACHE_MAX_BYTES = 16 * 1024 * 1024
1592
+ export const LEVEL_CARDS_CACHE_MAX_ROOTS = 8
1593
+ const levelCache = new Map() // cacheKey -> { payload, bytes }
1594
+ const levelCardsCache = new Map() // root -> { fp, cards }
1595
+ let levelCacheBytes = 0
1596
+ // 生效中的上界:生产恒为上面的常量;单测用 setLevelCacheLimits() 临时收紧来验证淘汰逻辑。
1597
+ let levelLimitEntries = LEVEL_CACHE_MAX
1598
+ let levelLimitBytes = LEVEL_CACHE_MAX_BYTES
1599
+
1600
+ /** 测试/诊断用:清空分层缓存(不影响图谱主缓存,也不动上界)。 */
1601
+ export function resetLevelCache() {
1602
+ levelCache.clear()
1603
+ levelCardsCache.clear()
1604
+ levelCacheBytes = 0
1605
+ }
1606
+
1607
+ /**
1608
+ * 只读缓存账本:条数与字节数都必须有界(回归测试直接断言它)。
1609
+ * @returns {{levelEntries:number, levelBytes:number, levelMaxEntries:number, levelMaxBytes:number, cardsRoots:number, cardsMaxRoots:number}}
1610
+ */
1611
+ export function levelCacheStats() {
1612
+ return {
1613
+ levelEntries: levelCache.size,
1614
+ levelBytes: levelCacheBytes,
1615
+ levelMaxEntries: levelLimitEntries,
1616
+ levelMaxBytes: levelLimitBytes,
1617
+ cardsRoots: levelCardsCache.size,
1618
+ cardsMaxRoots: LEVEL_CARDS_CACHE_MAX_ROOTS,
1619
+ }
1620
+ }
1621
+
1622
+ /**
1623
+ * 测试/诊断用:临时调整缓存上界(生产默认 48 条 / 16MB;传 0/缺省表示恢复默认)。
1624
+ * 用来在单测里用很小的预算验证「字节上限会触发淘汰」,不必真的灌满 16MB。
1625
+ * @returns 生效后的上界
1626
+ */
1627
+ export function setLevelCacheLimits(opts = {}) {
1628
+ const max = Math.floor(Number(opts.maxEntries))
1629
+ const maxBytes = Math.floor(Number(opts.maxBytes))
1630
+ levelLimitEntries = Number.isFinite(max) && max > 0 ? max : LEVEL_CACHE_MAX
1631
+ levelLimitBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : LEVEL_CACHE_MAX_BYTES
1632
+ while (levelCache.size > levelLimitEntries) evictOldestLevelCache()
1633
+ while (levelCacheBytes > levelLimitBytes && levelCache.size > 0) evictOldestLevelCache()
1634
+ return { maxEntries: levelLimitEntries, maxBytes: levelLimitBytes }
1635
+ }
1636
+
1637
+ function evictOldestLevelCache() {
1638
+ const oldest = levelCache.keys().next().value
1639
+ if (oldest === undefined) return
1640
+ const rec = levelCache.get(oldest)
1641
+ levelCache.delete(oldest)
1642
+ if (rec) levelCacheBytes -= rec.bytes
1643
+ }
1644
+
1645
+ function levelCacheGet(key) {
1646
+ const hit = levelCache.get(key)
1647
+ if (!hit) return null
1648
+ // LRU:Map 的插入序就是淘汰序,命中即刷新热度
1649
+ levelCache.delete(key)
1650
+ levelCache.set(key, hit)
1651
+ return hit
1652
+ }
1653
+
1654
+ function levelCacheSet(key, payload, bytes) {
1655
+ const prev = levelCache.get(key)
1656
+ if (prev) {
1657
+ levelCacheBytes -= prev.bytes
1658
+ levelCache.delete(key)
1659
+ }
1660
+ levelCache.set(key, { payload, bytes })
1661
+ levelCacheBytes += bytes
1662
+ // 条数上限 / 字节上限:从最久未用的那条开始淘汰
1663
+ while (levelCacheBytes > levelLimitBytes && levelCache.size > 1) evictOldestLevelCache()
1664
+ while (levelCache.size > levelLimitEntries) evictOldestLevelCache()
1665
+ // 单条本身就超预算:连它也不留(本次响应照常返回)—— 保证字节账永远有界
1666
+ if (levelCacheBytes > levelLimitBytes) {
1667
+ const rec = levelCache.get(key)
1668
+ if (rec) {
1669
+ levelCache.delete(key)
1670
+ levelCacheBytes -= rec.bytes
1671
+ }
1672
+ }
1673
+ }
1674
+
1675
+ function readLevelCards(db) {
1676
+ // 只取分层需要的列(不读 body):10k 卡时整表扫描仍是个位数毫秒
1677
+ const rows = db.prepare('SELECT path, kind, title, tags, updated_at FROM cards').all()
1678
+ return rows.map((r) => ({
1679
+ id: r.path,
1680
+ path: r.path,
1681
+ title: r.title || '',
1682
+ kind: r.kind || 'other',
1683
+ tags: normalizeTags(r.tags),
1684
+ updated: r.updated_at || '',
1685
+ mtime: new Date(r.updated_at || 0).getTime() || 0,
1686
+ }))
1687
+ }
1688
+
1689
+ function levelCardsFor(roots) {
1690
+ const entries = []
1691
+ for (const r of roots) {
1692
+ const db = getDb(r.root)
1693
+ const fp = graphFingerprint(db)
1694
+ // 与 graphCache 同款:键是**库根**,命中后再比对指纹。
1695
+ // 旧实现把指纹拼进 key —— 每写一张卡指纹就变,于是每次写卡都白留一整份全库卡片
1696
+ // (10k 卡时是几 MB/次),长期运行等于慢性泄漏。
1697
+ let entry = levelCardsCache.get(r.root)
1698
+ if (!entry || entry.fp !== fp) {
1699
+ entry = { fp, cards: readLevelCards(db) }
1700
+ levelCardsCache.delete(r.root)
1701
+ levelCardsCache.set(r.root, entry)
1702
+ while (levelCardsCache.size > LEVEL_CARDS_CACHE_MAX_ROOTS) {
1703
+ const oldest = levelCardsCache.keys().next().value
1704
+ levelCardsCache.delete(oldest)
1705
+ }
1706
+ } else {
1707
+ // LRU:同一个库刚用过,挪到队尾
1708
+ levelCardsCache.delete(r.root)
1709
+ levelCardsCache.set(r.root, entry)
1710
+ }
1711
+ entries.push({ name: r.name || '', fp, cards: entry.cards })
1712
+ }
1713
+ const fingerprint = entries.map((e) => (e.name || '') + ':' + e.fp).join('|')
1714
+ const cards = []
1715
+ const seen = new Set()
1716
+ for (const e of entries) {
1717
+ const pref = e.name ? e.name + '::' : ''
1718
+ for (const c of e.cards) {
1719
+ const id = pref + c.id
1720
+ if (seen.has(id)) continue
1721
+ seen.add(id)
1722
+ cards.push(pref ? { ...c, id, path: id } : c)
1723
+ }
1724
+ }
1725
+ return { fingerprint, cards }
1726
+ }
1727
+
1728
+ /**
1729
+ * 分层图谱:一次只返回「一层」的图元(≤1000)与层内边,客户端按层请求。
1730
+ * 与 graph() 的关系:graph() 仍是全量图(导出/诊断/兼容旧客户端),分层视图只走这里。
1731
+ * @param {string} root 当前库根
1732
+ * @param {{path?:string, roots?:Array<{name:string,root:string}>, plan?:object}} opts
1733
+ * @returns {Promise<{ok:true, path:string, crumbs:Array, level:object, stats:object}>}
1734
+ */
1735
+ export async function graphLevel(root, opts = {}) {
1736
+ const started = Date.now()
1737
+ const pathStr = String(opts.path == null ? '' : opts.path).trim()
1738
+ const roots = Array.isArray(opts.roots) && opts.roots.length ? opts.roots : [{ name: '', root }]
1739
+ const segments = parseLevelPath(pathStr) // 非法路径直接抛(调用方转 400)
1740
+ const { fingerprint, cards } = levelCardsFor(roots)
1741
+ const cacheKey = fingerprint + '\u0000' + pathStr
1742
+ const hit = levelCacheGet(cacheKey)
1743
+ if (hit) return { ...hit.payload, stats: { ...hit.payload.stats, cached: true, ms: Date.now() - started } }
1744
+ const plan = planGraphLevel(cards, segments, opts.plan || {})
1745
+ // payload 守卫:先砍边;极端情况下(图元本身过大)如实标记 truncated,绝不静默假装没事
1746
+ let bytes = Buffer.byteLength(JSON.stringify({ ok: true, ...plan }))
1747
+ let edgesTrimmed = false
1748
+ while (bytes > LEVEL_PAYLOAD_LIMIT && plan.level.edges.length > 0) {
1749
+ plan.level.edges.length = Math.floor(plan.level.edges.length / 2)
1750
+ edgesTrimmed = true
1751
+ bytes = Buffer.byteLength(JSON.stringify({ ok: true, ...plan }))
1752
+ }
1753
+ const payload = {
1754
+ ok: true,
1755
+ ...plan,
1756
+ stats: {
1757
+ ...plan.stats,
1758
+ edges: plan.level.edges.length,
1759
+ edgesTrimmed,
1760
+ bytes,
1761
+ ms: Date.now() - started,
1762
+ cached: false,
1763
+ payloadLimit: LEVEL_PAYLOAD_LIMIT,
1764
+ },
1765
+ }
1766
+ levelCacheSet(cacheKey, payload, bytes)
1767
+ return payload
1768
+ }
1769
+
1078
1770
  /** 跨库聚合检索。 */
1079
1771
  export async function searchAll(roots, query, opts = {}) {
1080
1772
  const out = []