memory-eternal 0.20.0 → 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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/cursor-extension/package.json +1 -1
- package/dsh-tui-plugin/dsh-plugin.json +1 -1
- package/dsh-tui-plugin/package.json +1 -1
- package/lib/api.js +43 -1
- package/lib/client.js +36 -20
- package/lib/vault.js +667 -0
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +2 -2
- package/web/app.js +38 -22
package/lib/vault.js
CHANGED
|
@@ -1100,6 +1100,673 @@ export async function graphAll(roots) {
|
|
|
1100
1100
|
return { nodes, edges }
|
|
1101
1101
|
}
|
|
1102
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
|
+
|
|
1103
1770
|
/** 跨库聚合检索。 */
|
|
1104
1771
|
export async function searchAll(roots, query, opts = {}) {
|
|
1105
1772
|
const out = []
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"id": "memory-eternal",
|
|
4
4
|
"publisher": "eternalnight996",
|
|
5
5
|
"name": "memory-eternal",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.21.0",
|
|
7
7
|
"description": "Memory Atlas for Orca: open the local memory vault, pending review and knowledge graph from a sandboxed Orca panel.",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "EternalNight996",
|