feat: update smart trigger and graph retrieval

This commit is contained in:
Youzini-afk
2026-03-23 13:37:24 +08:00
parent 4ba5202451
commit 28fdc6d7ea
10 changed files with 1126 additions and 722 deletions

View File

@@ -32,12 +32,12 @@ const INHIBIT_EDGE_TYPE = 255;
* 默认配置
*/
const DEFAULT_OPTIONS = {
maxSteps: 2, // 最大扩散步数
decayFactor: 0.6, // 每步衰减因子
topK: 100, // 每步保留的最大活跃节点数
minEnergy: 0.01, // 最小有效能量(低于此值视为不活跃)
maxEnergy: 2.0, // 能量上限
minEnergy_clamp: -2.0, // 能量下限(抑制)
maxSteps: 2, // 最大扩散步数
decayFactor: 0.6, // 每步衰减因子
topK: 100, // 每步保留的最大活跃节点数
minEnergy: 0.01, // 最小有效能量(低于此值视为不活跃)
maxEnergy: 2.0, // 能量上限
minEnergy_clamp: -2.0, // 能量下限(抑制)
};
/**
@@ -58,85 +58,88 @@ const DEFAULT_OPTIONS = {
* nodeId → energy正值=激活,负值=抑制)
*/
export function propagateActivation(adjacencyMap, seedNodes, options = {}) {
const opts = { ...DEFAULT_OPTIONS, ...options };
const opts = { ...DEFAULT_OPTIONS, ...options };
// Step 0: 初始化能量表
/** @type {Map<string, number>} */
let currentEnergy = new Map();
for (const seed of seedNodes || []) {
if (!seed?.id) continue;
const clamped = clampEnergy(Number(seed.energy) || 0, opts);
if (Math.abs(clamped) >= opts.minEnergy) {
const existing = currentEnergy.get(seed.id) || 0;
currentEnergy.set(seed.id, clampEnergy(existing + clamped, opts));
}
}
// 累积结果(所有步骤的最大能量)
/** @type {Map<string, number>} */
const result = new Map(currentEnergy);
// Step 1~N: 逐步扩散
for (let step = 0; step < opts.maxSteps; step++) {
/** @type {Map<string, number>} */
let currentEnergy = new Map();
const nextEnergy = new Map();
for (const seed of seedNodes) {
const clamped = clampEnergy(seed.energy, opts);
if (Math.abs(clamped) >= opts.minEnergy) {
currentEnergy.set(seed.id, clamped);
// 对每个当前活跃节点,传播能量到邻居
for (const [nodeId, energy] of currentEnergy) {
const neighbors = adjacencyMap.get(nodeId);
if (!Array.isArray(neighbors) || neighbors.length === 0) continue;
for (const neighbor of neighbors) {
if (!neighbor?.targetId) continue;
let propagated =
energy * (Number(neighbor.strength) || 0) * opts.decayFactor;
// 抑制边:传递负能量
if (neighbor.edgeType === INHIBIT_EDGE_TYPE) {
propagated = -Math.abs(propagated);
}
// 累加到邻居节点
const existing = nextEnergy.get(neighbor.targetId) || 0;
nextEnergy.set(neighbor.targetId, existing + propagated);
}
}
// 累积结果(所有步骤的最大能量
/** @type {Map<string, number>} */
const result = new Map(currentEnergy);
// Step 1~N: 逐步扩散
for (let step = 0; step < opts.maxSteps; step++) {
/** @type {Map<string, number>} */
const nextEnergy = new Map();
// 对每个当前活跃节点,传播能量到邻居
for (const [nodeId, energy] of currentEnergy) {
const neighbors = adjacencyMap.get(nodeId);
if (!neighbors) continue;
for (const neighbor of neighbors) {
// 计算传播能量
let propagated = energy * neighbor.strength * opts.decayFactor;
// 抑制边:传递负能量
if (neighbor.edgeType === INHIBIT_EDGE_TYPE) {
propagated = -Math.abs(propagated);
}
// 累加到邻居节点
const existing = nextEnergy.get(neighbor.targetId) || 0;
nextEnergy.set(neighbor.targetId, existing + propagated);
}
}
// 钳位 + 过滤低能量
for (const [nodeId, energy] of nextEnergy) {
const clamped = clampEnergy(energy, opts);
if (Math.abs(clamped) < opts.minEnergy) {
nextEnergy.delete(nodeId);
} else {
nextEnergy.set(nodeId, clamped);
}
}
// 动态剪枝:只保留 Top-K
if (nextEnergy.size > opts.topK) {
const sorted = [...nextEnergy.entries()]
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
nextEnergy.clear();
for (let i = 0; i < opts.topK && i < sorted.length; i++) {
nextEnergy.set(sorted[i][0], sorted[i][1]);
}
}
// 更新累积结果(取各步骤最大绝对值能量)
for (const [nodeId, energy] of nextEnergy) {
const existing = result.get(nodeId) || 0;
if (Math.abs(energy) > Math.abs(existing)) {
result.set(nodeId, energy);
}
}
// 准备下一步
currentEnergy = nextEnergy;
// 如果没有活跃节点了,提前终止
if (currentEnergy.size === 0) break;
// 钳位 + 过滤低能量
for (const [nodeId, energy] of nextEnergy) {
const clamped = clampEnergy(energy, opts);
if (Math.abs(clamped) < opts.minEnergy) {
nextEnergy.delete(nodeId);
} else {
nextEnergy.set(nodeId, clamped);
}
}
return result;
// 动态剪枝:只保留 Top-K
if (nextEnergy.size > opts.topK) {
const sorted = [...nextEnergy.entries()].sort(
(a, b) => Math.abs(b[1]) - Math.abs(a[1]),
);
nextEnergy.clear();
for (let i = 0; i < opts.topK && i < sorted.length; i++) {
nextEnergy.set(sorted[i][0], sorted[i][1]);
}
}
// 更新累积结果(取各步骤最大绝对值能量)
for (const [nodeId, energy] of nextEnergy) {
const existing = result.get(nodeId) || 0;
if (Math.abs(energy) > Math.abs(existing)) {
result.set(nodeId, energy);
}
}
// 准备下一步
currentEnergy = nextEnergy;
// 如果没有活跃节点了,提前终止
if (currentEnergy.size === 0) break;
}
return result;
}
/**
@@ -146,7 +149,7 @@ export function propagateActivation(adjacencyMap, seedNodes, options = {}) {
* @returns {number}
*/
function clampEnergy(energy, opts) {
return Math.max(opts.minEnergy_clamp, Math.min(opts.maxEnergy, energy));
return Math.max(opts.minEnergy_clamp, Math.min(opts.maxEnergy, energy));
}
/**
@@ -158,10 +161,13 @@ function clampEnergy(energy, opts) {
* @returns {Array<{nodeId: string, energy: number}>} 按能量降序排列
*/
export function diffuseAndRank(adjacencyMap, seeds, options = {}) {
const energyMap = propagateActivation(adjacencyMap, seeds, options);
const energyMap = propagateActivation(adjacencyMap, seeds, options);
return [...energyMap.entries()]
.filter(([_, energy]) => energy > 0) // 只返回正能量(被激活的)
.map(([nodeId, energy]) => ({ nodeId, energy }))
.sort((a, b) => b.energy - a.energy);
return [...energyMap.entries()]
.filter(([_, energy]) => energy > 0)
.map(([nodeId, energy]) => ({ nodeId, energy }))
.sort((a, b) => {
if (b.energy !== a.energy) return b.energy - a.energy;
return String(a.nodeId).localeCompare(String(b.nodeId));
});
}

View File

@@ -22,18 +22,22 @@ import { RELATION_TYPES } from "./schema.js";
*
* @param {object} params
* @param {object} params.graph - 当前图状态
* @param {Array<{role: string, content: string}>} params.messages - 要处理的对话消息
* @param {number} params.startSeq - 起始楼层号
* @param {Array<{seq?: number, role: string, content: string}>} params.messages - 要处理的对话消息
* @param {number} params.startSeq - 本批处理的首个 assistant 消息 chat 索引
* @param {number} params.endSeq - 本批处理的末个 assistant 消息 chat 索引
* @param {number} [params.lastProcessedSeq] - 上次处理到的 chat 索引
* @param {object[]} params.schema - 节点类型 Schema
* @param {object} params.embeddingConfig - Embedding API 配置
* @param {string} [params.extractPrompt] - 自定义提取提示词
* @param {object} [params.v2Options] - v2 增强选项
* @returns {Promise<{success: boolean, newNodes: number, updatedNodes: number, newEdges: number, newNodeIds: string[]}>}
* @returns {Promise<{success: boolean, newNodes: number, updatedNodes: number, newEdges: number, newNodeIds: string[], processedRange: [number, number]}>}
*/
export async function extractMemories({
graph,
messages,
startSeq,
endSeq,
lastProcessedSeq = -1,
schema,
embeddingConfig,
extractPrompt,
@@ -46,17 +50,33 @@ export async function extractMemories({
updatedNodes: 0,
newEdges: 0,
newNodeIds: [],
processedRange: [lastProcessedSeq, lastProcessedSeq],
};
}
const enablePreciseConflict = v2Options.enablePreciseConflict ?? true;
const conflictThreshold = v2Options.conflictThreshold ?? 0.85;
console.log(`[ST-BME] 提取开始: 楼层 ${startSeq}, ${messages.length} 条消息`);
const effectiveStartSeq = Number.isFinite(startSeq)
? startSeq
: (messages.find((m) => Number.isFinite(m.seq))?.seq ??
lastProcessedSeq + 1);
const effectiveEndSeq = Number.isFinite(endSeq)
? endSeq
: ([...messages].reverse().find((m) => Number.isFinite(m.seq))?.seq ??
effectiveStartSeq);
const currentSeq = effectiveEndSeq;
console.log(
`[ST-BME] 提取开始: chat[${effectiveStartSeq}..${effectiveEndSeq}], ${messages.length} 条消息`,
);
// 构建对话文本
const dialogueText = messages
.map((m) => `[${m.role}]: ${m.content}`)
.map((m) => {
const seqLabel = Number.isFinite(m.seq) ? `#${m.seq}` : "#?";
return `${seqLabel} [${m.role}]: ${m.content}`;
})
.join("\n\n");
// 构建当前图概览(让 LLM 知道已有哪些节点,避免重复)
@@ -89,7 +109,7 @@ export async function extractMemories({
maxRetries: 2,
});
if (!result || !result.operations) {
if (!result || !Array.isArray(result.operations)) {
console.warn("[ST-BME] 提取 LLM 未返回有效操作");
return {
success: false,
@@ -97,6 +117,7 @@ export async function extractMemories({
updatedNodes: 0,
newEdges: 0,
newNodeIds: [],
processedRange: [lastProcessedSeq, lastProcessedSeq],
};
}
@@ -107,6 +128,7 @@ export async function extractMemories({
result.operations,
embeddingConfig,
conflictThreshold,
effectiveEndSeq,
);
}
@@ -122,7 +144,7 @@ export async function extractMemories({
const createdId = handleCreate(
graph,
op,
startSeq,
currentSeq,
schema,
refMap,
stats,
@@ -131,7 +153,7 @@ export async function extractMemories({
break;
}
case "update":
handleUpdate(graph, op, stats);
handleUpdate(graph, op, currentSeq, stats);
break;
case "delete":
handleDelete(graph, op, stats);
@@ -150,15 +172,22 @@ export async function extractMemories({
// 为新建节点生成 embedding
await generateNodeEmbeddings(graph, embeddingConfig);
// 更新处理进度
graph.lastProcessedSeq =
startSeq + messages.filter((m) => m.role === "assistant").length;
console.log(
`[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}`,
// 更新处理进度:统一记录为已处理到的末个 chat 索引
graph.lastProcessedSeq = Math.max(
graph.lastProcessedSeq ?? -1,
effectiveEndSeq,
);
return { success: true, ...stats, newNodeIds };
console.log(
`[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}, lastProcessedSeq=${graph.lastProcessedSeq}`,
);
return {
success: true,
...stats,
newNodeIds,
processedRange: [effectiveStartSeq, effectiveEndSeq],
};
}
/**
@@ -217,7 +246,7 @@ function handleCreate(graph, op, seq, schema, refMap, stats) {
/**
* 处理 update 操作
*/
function handleUpdate(graph, op, stats) {
function handleUpdate(graph, op, currentSeq, stats) {
if (!op.nodeId) {
console.warn("[ST-BME] update 操作缺少 nodeId");
return;
@@ -233,8 +262,10 @@ function handleUpdate(graph, op, stats) {
const nextFields = { ...previousFields, ...(op.fields || {}) };
const changeSummary = buildFieldChangeSummary(previousFields, nextFields);
const updateSeq = Number.isFinite(op.seq) ? op.seq : currentSeq;
const updated = updateNode(graph, op.nodeId, {
fields: op.fields || {},
seq: Math.max(previousNode.seq || 0, updateSeq),
});
if (updated) {
@@ -242,19 +273,22 @@ function handleUpdate(graph, op, stats) {
const node = getNode(graph, op.nodeId);
if (node) {
node.embedding = null;
node.seq = Math.max(node.seq || 0, op.seq || 0);
node.seq = Math.max(node.seq || 0, updateSeq);
node.seqRange = [
Math.min(node.seqRange?.[0] ?? node.seq, op.seq || node.seq),
Math.max(node.seqRange?.[1] ?? node.seq, op.seq || node.seq),
Math.min(node.seqRange?.[0] ?? node.seq, updateSeq),
Math.max(node.seqRange?.[1] ?? node.seq, updateSeq),
];
}
// v2 Graphiti: 标记旧的 updates/temporal_update 边为失效
const oldEdges = graph.edges.filter(
(e) =>
e.toId === op.nodeId &&
(e.relation === "updates" || e.relation === "temporal_update") &&
!e.invalidAt,
!e.invalidAt &&
((e.relation === "updates" && e.toId === op.nodeId) ||
(e.relation === "temporal_update" &&
e.toId === op.nodeId &&
op.sourceNodeId &&
e.fromId === op.sourceNodeId)),
);
for (const e of oldEdges) {
invalidateEdge(e);
@@ -284,7 +318,7 @@ function handleUpdate(graph, op, stats) {
previousNode.id,
status: "resolved",
},
seq: op.seq || previousNode.seq || 0,
seq: updateSeq,
importance: Math.max(
4,
Math.min(8, op.importance ?? previousNode.importance ?? 5),
@@ -379,7 +413,10 @@ function handleLinks(graph, sourceId, links, refMap, stats) {
async function generateNodeEmbeddings(graph, embeddingConfig) {
if (!embeddingConfig?.apiUrl) return;
const needsEmbedding = graph.nodes.filter((n) => !n.embedding && !n.archived);
const needsEmbedding = graph.nodes.filter(
(n) =>
!n.archived && (!Array.isArray(n.embedding) || n.embedding.length === 0),
);
if (needsEmbedding.length === 0) return;
@@ -410,7 +447,9 @@ async function generateNodeEmbeddings(graph, embeddingConfig) {
* 构建图谱概览文本(给 LLM 看)
*/
function buildGraphOverview(graph, schema) {
const activeNodes = graph.nodes.filter((n) => !n.archived);
const activeNodes = graph.nodes
.filter((n) => !n.archived)
.sort((a, b) => (a.seq || 0) - (b.seq || 0));
if (activeNodes.length === 0) return "";
const lines = [];
@@ -502,8 +541,11 @@ async function mem0ConflictCheck(
operations,
embeddingConfig,
threshold,
fallbackSeq,
) {
const activeNodes = getActiveNodes(graph).filter((n) => n.embedding);
const activeNodes = getActiveNodes(graph).filter(
(n) => Array.isArray(n.embedding) && n.embedding.length > 0,
);
if (activeNodes.length === 0) return;
for (const op of operations) {
@@ -553,7 +595,8 @@ async function mem0ConflictCheck(
);
op.action = "update";
op.nodeId = decision.targetId;
op.sourceNodeId = topMatch.id;
op.sourceNodeId = op.sourceNodeId || topMatch.id;
op.seq = Number.isFinite(op.seq) ? op.seq : fallbackSeq;
if (decision.mergedFields) {
op.fields = { ...op.fields, ...decision.mergedFields };
}
@@ -627,7 +670,12 @@ export async function generateSynopsis({ graph, schema, currentSeq }) {
if (existingSynopsis) {
updateNode(graph, existingSynopsis.id, {
fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` },
seq: Math.max(existingSynopsis.seq || 0, currentSeq),
});
existingSynopsis.seqRange = [
Math.min(existingSynopsis.seqRange?.[0] ?? currentSeq, currentSeq),
Math.max(existingSynopsis.seqRange?.[1] ?? currentSeq, currentSeq),
];
existingSynopsis.embedding = null;
console.log("[ST-BME] 全局概要已更新");
} else {

535
graph.js
View File

@@ -4,17 +4,17 @@
/**
* 图状态版本号
*/
const GRAPH_VERSION = 2;
const GRAPH_VERSION = 3;
/**
* 生成 UUID v4
*/
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
@@ -22,13 +22,13 @@ function uuid() {
* @returns {GraphState}
*/
export function createEmptyGraph() {
return {
version: GRAPH_VERSION,
lastProcessedSeq: 0,
nodes: [],
edges: [],
lastRecallResult: null,
};
return {
version: GRAPH_VERSION,
lastProcessedSeq: -1,
nodes: [],
edges: [],
lastRecallResult: null,
};
}
// ==================== 节点操作 ====================
@@ -39,33 +39,33 @@ export function createEmptyGraph() {
* @returns {object} 新节点
*/
export function createNode({
type,
fields = {},
seq = 0,
seqRange = null,
importance = 5.0,
clusters = [],
type,
fields = {},
seq = 0,
seqRange = null,
importance = 5.0,
clusters = [],
}) {
const now = Date.now();
return {
id: uuid(),
type,
level: 0,
parentId: null,
childIds: [],
seq,
seqRange: seqRange || [seq, seq],
archived: false,
fields,
embedding: null,
importance: Math.max(0, Math.min(10, importance)),
accessCount: 0,
lastAccessTime: now,
createdTime: now,
prevId: null,
nextId: null,
clusters,
};
const now = Date.now();
return {
id: uuid(),
type,
level: 0,
parentId: null,
childIds: [],
seq,
seqRange: seqRange || [seq, seq],
archived: false,
fields,
embedding: null,
importance: Math.max(0, Math.min(10, importance)),
accessCount: 0,
lastAccessTime: now,
createdTime: now,
prevId: null,
nextId: null,
clusters,
};
}
/**
@@ -75,19 +75,19 @@ export function createNode({
* @returns {object} 添加的节点
*/
export function addNode(graph, node) {
// 同类型节点的时间链表:连接到最后一个同类型节点
const sameTypeNodes = graph.nodes
.filter(n => n.type === node.type && !n.archived && n.level === 0)
.sort((a, b) => a.seq - b.seq);
// 同类型节点的时间链表:连接到最后一个同类型节点
const sameTypeNodes = graph.nodes
.filter((n) => n.type === node.type && !n.archived && n.level === 0)
.sort((a, b) => a.seq - b.seq);
if (sameTypeNodes.length > 0) {
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
lastNode.nextId = node.id;
node.prevId = lastNode.id;
}
if (sameTypeNodes.length > 0) {
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
lastNode.nextId = node.id;
node.prevId = lastNode.id;
}
graph.nodes.push(node);
return node;
graph.nodes.push(node);
return node;
}
/**
@@ -97,7 +97,7 @@ export function addNode(graph, node) {
* @returns {object|null}
*/
export function getNode(graph, nodeId) {
return graph.nodes.find(n => n.id === nodeId) || null;
return graph.nodes.find((n) => n.id === nodeId) || null;
}
/**
@@ -108,16 +108,16 @@ export function getNode(graph, nodeId) {
* @returns {boolean} 是否找到并更新
*/
export function updateNode(graph, nodeId, updates) {
const node = getNode(graph, nodeId);
if (!node) return false;
const node = getNode(graph, nodeId);
if (!node) return false;
if (updates.fields) {
node.fields = { ...node.fields, ...updates.fields };
delete updates.fields;
}
if (updates.fields) {
node.fields = { ...node.fields, ...updates.fields };
delete updates.fields;
}
Object.assign(node, updates);
return true;
Object.assign(node, updates);
return true;
}
/**
@@ -127,39 +127,41 @@ export function updateNode(graph, nodeId, updates) {
* @returns {boolean}
*/
export function removeNode(graph, nodeId) {
const node = getNode(graph, nodeId);
if (!node) return false;
const node = getNode(graph, nodeId);
if (!node) return false;
// 修复时间链表
if (node.prevId) {
const prev = getNode(graph, node.prevId);
if (prev) prev.nextId = node.nextId;
}
if (node.nextId) {
const next = getNode(graph, node.nextId);
if (next) next.prevId = node.prevId;
// 修复时间链表
if (node.prevId) {
const prev = getNode(graph, node.prevId);
if (prev) prev.nextId = node.nextId;
}
if (node.nextId) {
const next = getNode(graph, node.nextId);
if (next) next.prevId = node.prevId;
}
// 递归删除子节点
for (const childId of node.childIds) {
removeNode(graph, childId);
}
// 从父节点中移除引用
if (node.parentId) {
const parent = getNode(graph, node.parentId);
if (parent) {
parent.childIds = parent.childIds.filter((id) => id !== nodeId);
}
}
// 递归删除子节点
for (const childId of node.childIds) {
removeNode(graph, childId);
}
// 删除相关边
graph.edges = graph.edges.filter(
(e) => e.fromId !== nodeId && e.toId !== nodeId,
);
// 从父节点中移除引用
if (node.parentId) {
const parent = getNode(graph, node.parentId);
if (parent) {
parent.childIds = parent.childIds.filter(id => id !== nodeId);
}
}
// 删除节点本身
graph.nodes = graph.nodes.filter((n) => n.id !== nodeId);
// 删除相关边
graph.edges = graph.edges.filter(e => e.fromId !== nodeId && e.toId !== nodeId);
// 删除节点本身
graph.nodes = graph.nodes.filter(n => n.id !== nodeId);
return true;
return true;
}
/**
@@ -169,11 +171,11 @@ export function removeNode(graph, nodeId) {
* @returns {object[]}
*/
export function getActiveNodes(graph, typeFilter = null) {
let nodes = graph.nodes.filter(n => !n.archived);
if (typeFilter) {
nodes = nodes.filter(n => n.type === typeFilter);
}
return nodes;
let nodes = graph.nodes.filter((n) => !n.archived);
if (typeFilter) {
nodes = nodes.filter((n) => n.type === typeFilter);
}
return nodes;
}
/**
@@ -184,12 +186,20 @@ export function getActiveNodes(graph, typeFilter = null) {
* @param {string} primaryKeyField - 主键字段名(默认 'name'
* @returns {object|null}
*/
export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = 'name') {
const candidates = graph.nodes.filter(
n => n.type === type && !n.archived && n.fields[primaryKeyField] === primaryKeyValue,
);
if (candidates.length === 0) return null;
return candidates.sort((a, b) => b.seq - a.seq)[0];
export function findLatestNode(
graph,
type,
primaryKeyValue,
primaryKeyField = "name",
) {
const candidates = graph.nodes.filter(
(n) =>
n.type === type &&
!n.archived &&
n.fields[primaryKeyField] === primaryKeyValue,
);
if (candidates.length === 0) return null;
return candidates.sort((a, b) => b.seq - a.seq)[0];
}
// ==================== 边操作 ====================
@@ -199,20 +209,26 @@ export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = '
* @param {object} params
* @returns {object} 新边
*/
export function createEdge({ fromId, toId, relation = 'related', strength = 0.8, edgeType = 0 }) {
return {
id: uuid(),
fromId,
toId,
relation,
strength: Math.max(0, Math.min(1, strength)),
edgeType,
createdTime: Date.now(),
// Graphiti 启发的时序字段
validAt: Date.now(), // 关系生效时间
invalidAt: null, // 关系失效时间null = 当前有效)
expiredAt: null, // 系统标记过期时间
};
export function createEdge({
fromId,
toId,
relation = "related",
strength = 0.8,
edgeType = 0,
}) {
return {
id: uuid(),
fromId,
toId,
relation,
strength: Math.max(0, Math.min(1, strength)),
edgeType,
createdTime: Date.now(),
// Graphiti 启发的时序字段
validAt: Date.now(), // 关系生效时间
invalidAt: null, // 关系失效时间null = 当前有效)
expiredAt: null, // 系统标记过期时间
};
}
/**
@@ -222,23 +238,42 @@ export function createEdge({ fromId, toId, relation = 'related', strength = 0.8,
* @returns {object|null} 添加的边或 null
*/
export function addEdge(graph, edge) {
const from = getNode(graph, edge.fromId);
const to = getNode(graph, edge.toId);
if (!from || !to) return null;
if (edge.fromId === edge.toId) return null;
const from = getNode(graph, edge.fromId);
const to = getNode(graph, edge.toId);
if (!from || !to) return null;
if (edge.fromId === edge.toId) return null;
// 检查重复边
const existing = graph.edges.find(
e => e.fromId === edge.fromId && e.toId === edge.toId && e.relation === edge.relation,
const isCurrentEdgeValid = (candidate) => {
if (candidate.invalidAt) return false;
if (candidate.expiredAt) return false;
return true;
};
// 对当前有效边去重;历史边保留,避免历史污染当前检索
const existing = graph.edges.find(
(e) =>
e.fromId === edge.fromId &&
e.toId === edge.toId &&
e.relation === edge.relation &&
isCurrentEdgeValid(e),
);
if (existing) {
existing.strength = Math.max(existing.strength, edge.strength ?? 0);
existing.validAt = Math.max(
existing.validAt || 0,
edge.validAt || Date.now(),
);
if (existing) {
// 更新已有边的强度
existing.strength = Math.max(existing.strength, edge.strength);
return existing;
if (edge.invalidAt) {
existing.invalidAt = edge.invalidAt;
}
if (edge.expiredAt) {
existing.expiredAt = edge.expiredAt;
}
return existing;
}
graph.edges.push(edge);
return edge;
graph.edges.push(edge);
return edge;
}
/**
@@ -248,10 +283,10 @@ export function addEdge(graph, edge) {
* @returns {boolean}
*/
export function removeEdge(graph, edgeId) {
const idx = graph.edges.findIndex(e => e.id === edgeId);
if (idx === -1) return false;
graph.edges.splice(idx, 1);
return true;
const idx = graph.edges.findIndex((e) => e.id === edgeId);
if (idx === -1) return false;
graph.edges.splice(idx, 1);
return true;
}
/**
@@ -261,7 +296,7 @@ export function removeEdge(graph, edgeId) {
* @returns {object[]}
*/
export function getOutEdges(graph, nodeId) {
return graph.edges.filter(e => e.fromId === nodeId);
return graph.edges.filter((e) => e.fromId === nodeId);
}
/**
@@ -271,7 +306,7 @@ export function getOutEdges(graph, nodeId) {
* @returns {object[]}
*/
export function getInEdges(graph, nodeId) {
return graph.edges.filter(e => e.toId === nodeId);
return graph.edges.filter((e) => e.toId === nodeId);
}
/**
@@ -281,7 +316,7 @@ export function getInEdges(graph, nodeId) {
* @returns {object[]}
*/
export function getNodeEdges(graph, nodeId) {
return graph.edges.filter(e => e.fromId === nodeId || e.toId === nodeId);
return graph.edges.filter((e) => e.fromId === nodeId || e.toId === nodeId);
}
// ==================== 查询辅助 ====================
@@ -292,27 +327,27 @@ export function getNodeEdges(graph, nodeId) {
* @returns {Map<string, Array<{targetId: string, strength: number, edgeType: number}>>}
*/
export function buildAdjacencyMap(graph) {
const adj = new Map();
const adj = new Map();
for (const edge of graph.edges) {
// 正向
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
adj.get(edge.fromId).push({
targetId: edge.toId,
strength: edge.strength,
edgeType: edge.edgeType,
});
for (const edge of graph.edges) {
if (!isEdgeActive(edge)) continue;
// 反向(图扩散是双向的)
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
adj.get(edge.toId).push({
targetId: edge.fromId,
strength: edge.strength,
edgeType: edge.edgeType,
});
}
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
adj.get(edge.fromId).push({
targetId: edge.toId,
strength: edge.strength,
edgeType: edge.edgeType,
});
return adj;
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
adj.get(edge.toId).push({
targetId: edge.fromId,
strength: edge.strength,
edgeType: edge.edgeType,
});
}
return adj;
}
/**
@@ -322,32 +357,34 @@ export function buildAdjacencyMap(graph) {
* @returns {Map}
*/
export function buildTemporalAdjacencyMap(graph) {
const adj = new Map();
const now = Date.now();
const adj = new Map();
for (const edge of graph.edges) {
// 跳过已失效的边
if (edge.invalidAt && edge.invalidAt <= now) continue;
if (edge.expiredAt) continue;
for (const edge of graph.edges) {
if (!isEdgeActive(edge)) continue;
// 正向
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
adj.get(edge.fromId).push({
targetId: edge.toId,
strength: edge.strength,
edgeType: edge.edgeType,
});
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
adj.get(edge.fromId).push({
targetId: edge.toId,
strength: edge.strength,
edgeType: edge.edgeType,
});
// 反向
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
adj.get(edge.toId).push({
targetId: edge.fromId,
strength: edge.strength,
edgeType: edge.edgeType,
});
}
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
adj.get(edge.toId).push({
targetId: edge.fromId,
strength: edge.strength,
edgeType: edge.edgeType,
});
}
return adj;
return adj;
}
function isEdgeActive(edge, now = Date.now()) {
if (!edge) return false;
if (edge.invalidAt && edge.invalidAt <= now) return false;
if (edge.expiredAt && edge.expiredAt <= now) return false;
return true;
}
/**
@@ -355,7 +392,10 @@ export function buildTemporalAdjacencyMap(graph) {
* @param {object} edge
*/
export function invalidateEdge(edge) {
if (!edge) return;
if (!edge.invalidAt) {
edge.invalidAt = Date.now();
}
}
/**
@@ -364,21 +404,21 @@ export function invalidateEdge(edge) {
* @returns {object}
*/
export function getGraphStats(graph) {
const activeNodes = graph.nodes.filter(n => !n.archived);
const archivedNodes = graph.nodes.filter(n => n.archived);
const typeCounts = {};
for (const node of activeNodes) {
typeCounts[node.type] = (typeCounts[node.type] || 0) + 1;
}
const activeNodes = graph.nodes.filter((n) => !n.archived);
const archivedNodes = graph.nodes.filter((n) => n.archived);
const typeCounts = {};
for (const node of activeNodes) {
typeCounts[node.type] = (typeCounts[node.type] || 0) + 1;
}
return {
totalNodes: graph.nodes.length,
activeNodes: activeNodes.length,
archivedNodes: archivedNodes.length,
totalEdges: graph.edges.length,
lastProcessedSeq: graph.lastProcessedSeq,
typeCounts,
};
return {
totalNodes: graph.nodes.length,
activeNodes: activeNodes.length,
archivedNodes: archivedNodes.length,
totalEdges: graph.edges.length,
lastProcessedSeq: graph.lastProcessedSeq,
typeCounts,
};
}
// ==================== 序列化 ====================
@@ -389,7 +429,7 @@ export function getGraphStats(graph) {
* @returns {string}
*/
export function serializeGraph(graph) {
return JSON.stringify(graph);
return JSON.stringify(graph);
}
/**
@@ -398,40 +438,75 @@ export function serializeGraph(graph) {
* @returns {GraphState}
*/
export function deserializeGraph(json) {
try {
const data = typeof json === 'string' ? JSON.parse(json) : json;
try {
const data = typeof json === "string" ? JSON.parse(json) : json;
if (!data || data.version === undefined) {
return createEmptyGraph();
}
// 版本迁移
if (data.version < GRAPH_VERSION) {
console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`);
// v1→v2 迁移:给旧边补充时序字段
if (data.version < 2 && data.edges) {
for (const edge of data.edges) {
if (edge.validAt === undefined) edge.validAt = edge.createdTime || Date.now();
if (edge.invalidAt === undefined) edge.invalidAt = null;
if (edge.expiredAt === undefined) edge.expiredAt = null;
}
}
data.version = GRAPH_VERSION;
}
// 确保字段完整
data.nodes = data.nodes || [];
data.edges = data.edges || [];
data.lastProcessedSeq = data.lastProcessedSeq || 0;
data.lastRecallResult = data.lastRecallResult || null;
return data;
} catch (e) {
console.error('[ST-BME] 图反序列化失败:', e);
return createEmptyGraph();
if (!data || data.version === undefined) {
return createEmptyGraph();
}
if (data.version < GRAPH_VERSION) {
console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`);
if (data.version < 2 && data.edges) {
for (const edge of data.edges) {
if (edge.validAt === undefined)
edge.validAt = edge.createdTime || Date.now();
if (edge.invalidAt === undefined) edge.invalidAt = null;
if (edge.expiredAt === undefined) edge.expiredAt = null;
}
}
if (data.version < 3) {
if (typeof data.lastProcessedSeq !== "number") {
data.lastProcessedSeq = -1;
}
for (const node of data.nodes || []) {
if (!Array.isArray(node.seqRange)) {
const seq = Number.isFinite(node.seq) ? node.seq : 0;
node.seqRange = [seq, seq];
}
}
}
data.version = GRAPH_VERSION;
}
data.nodes = (data.nodes || []).map((node) => {
const seq = Number.isFinite(node.seq) ? node.seq : 0;
return {
level: 0,
parentId: null,
childIds: [],
accessCount: 0,
lastAccessTime: node.createdTime || Date.now(),
prevId: null,
nextId: null,
clusters: [],
...node,
seq,
seqRange: Array.isArray(node.seqRange) ? node.seqRange : [seq, seq],
};
});
data.edges = (data.edges || []).map((edge) => ({
createdTime: Date.now(),
validAt: edge?.createdTime || Date.now(),
invalidAt: null,
expiredAt: null,
...edge,
}));
data.lastProcessedSeq = Number.isFinite(data.lastProcessedSeq)
? data.lastProcessedSeq
: -1;
data.lastRecallResult = Array.isArray(data.lastRecallResult)
? data.lastRecallResult
: null;
return data;
} catch (e) {
console.error("[ST-BME] 图反序列化失败:", e);
return createEmptyGraph();
}
}
/**
@@ -440,11 +515,11 @@ export function deserializeGraph(json) {
* @returns {string} JSON 字符串
*/
export function exportGraph(graph) {
const exportData = {
...graph,
nodes: graph.nodes.map(n => ({ ...n, embedding: null })),
};
return JSON.stringify(exportData, null, 2);
const exportData = {
...graph,
nodes: graph.nodes.map((n) => ({ ...n, embedding: null })),
};
return JSON.stringify(exportData, null, 2);
}
/**
@@ -453,10 +528,10 @@ export function exportGraph(graph) {
* @returns {GraphState}
*/
export function importGraph(json) {
const graph = deserializeGraph(json);
// 导入的节点需要重新生成 embedding
for (const node of graph.nodes) {
node.embedding = null;
}
return graph;
const graph = deserializeGraph(json);
// 导入的节点需要重新生成 embedding
for (const node of graph.nodes) {
node.embedding = null;
}
return graph;
}

154
index.js
View File

@@ -30,7 +30,7 @@ import {
} from "./graph.js";
import { estimateTokens, formatInjection } from "./injector.js";
import { retrieve } from "./retriever.js";
import { DEFAULT_NODE_SCHEMA } from "./schema.js";
import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js";
const MODULE_NAME = "st_bme";
const GRAPH_METADATA_KEY = "st_bme_graph";
@@ -121,15 +121,23 @@ let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗
// ==================== 设置管理 ====================
function getSettings() {
if (!extension_settings[MODULE_NAME]) {
extension_settings[MODULE_NAME] = { ...defaultSettings };
}
return extension_settings[MODULE_NAME];
const mergedSettings = {
...defaultSettings,
...(extension_settings[MODULE_NAME] || {}),
};
extension_settings[MODULE_NAME] = mergedSettings;
return mergedSettings;
}
function getSchema() {
const settings = getSettings();
return settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA;
const schema = settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA;
const validation = validateSchema(schema);
if (!validation.valid) {
console.warn("[ST-BME] Schema 非法,回退到默认 Schema:", validation.errors);
return DEFAULT_NODE_SCHEMA;
}
return schema;
}
function getEmbeddingConfig() {
@@ -192,14 +200,15 @@ const DEFAULT_TRIGGER_KEYWORDS = [
"来到",
];
function getSmartTriggerDecision(chat, lastProcessed, settings) {
export function getSmartTriggerDecision(chat, lastProcessed, settings) {
const pendingMessages = chat
.slice(lastProcessed + 1)
.slice(Math.max(0, (lastProcessed ?? -1) + 1))
.filter((msg) => !msg.is_system)
.map((msg) => ({
role: msg.is_user ? "user" : "assistant",
content: msg.mes || "",
}));
}))
.filter((msg) => msg.content.trim().length > 0);
if (pendingMessages.length === 0) {
return { triggered: false, score: 0, reasons: [] };
@@ -266,6 +275,18 @@ function getSmartTriggerDecision(chat, lastProcessed, settings) {
};
}
function clampInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
const num = Number.parseInt(value, 10);
if (!Number.isFinite(num)) return fallback;
return Math.min(max, Math.max(min, num));
}
function clampFloat(value, fallback, min = 0, max = 1) {
const num = Number.parseFloat(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(max, Math.max(min, num));
}
/**
* 提取管线:处理未提取的对话楼层
*/
@@ -279,7 +300,7 @@ async function runExtraction() {
const chat = context.chat;
if (!chat || chat.length === 0) return;
// 找出 assistant 楼层序号
// lastProcessedSeq / startSeq / endSeq 统一使用 chat 数组索引语义
const assistantTurns = [];
for (let i = 0; i < chat.length; i++) {
if (chat[i].is_user === false && !chat[i].is_system) {
@@ -287,40 +308,44 @@ async function runExtraction() {
}
}
const lastProcessed = currentGraph.lastProcessedSeq;
const unprocessedStarts = assistantTurns.filter((i) => i > lastProcessed);
const lastProcessed = Number.isFinite(currentGraph.lastProcessedSeq)
? currentGraph.lastProcessedSeq
: -1;
const unprocessedAssistantTurns = assistantTurns.filter(
(i) => i > lastProcessed,
);
if (unprocessedStarts.length === 0) return;
if (unprocessedAssistantTurns.length === 0) return;
const extractEvery = clampInt(settings.extractEvery, 1, 1, 50);
const smartTriggerDecision = settings.enableSmartTrigger
? getSmartTriggerDecision(chat, lastProcessed, settings)
: { triggered: false, score: 0, reasons: [] };
// 按 extractEvery 批次处理;若启用智能触发,则允许提前提取
if (
unprocessedStarts.length < settings.extractEvery &&
unprocessedAssistantTurns.length < extractEvery &&
!smartTriggerDecision.triggered
) {
return;
}
const batchAssistantTurns = smartTriggerDecision.triggered
? unprocessedAssistantTurns
: unprocessedAssistantTurns.slice(0, extractEvery);
const startIdx = batchAssistantTurns[0];
const endIdx = batchAssistantTurns[batchAssistantTurns.length - 1];
isExtracting = true;
try {
// 收集要处理的消息
const startIdx = unprocessedStarts[0];
const endIdx = unprocessedStarts[unprocessedStarts.length - 1];
// 包含上下文
const contextStart = Math.max(
0,
startIdx - settings.extractContextTurns * 2,
);
const contextTurns = clampInt(settings.extractContextTurns, 2, 0, 20);
const contextStart = Math.max(0, startIdx - contextTurns * 2);
const messages = [];
for (let i = contextStart; i <= endIdx && i < chat.length; i++) {
const msg = chat[i];
if (msg.is_system) continue;
messages.push({
seq: i,
role: msg.is_user ? "user" : "assistant",
content: msg.mes || "",
});
@@ -336,7 +361,9 @@ async function runExtraction() {
const result = await extractMemories({
graph: currentGraph,
messages,
startSeq: endIdx,
startSeq: startIdx,
endSeq: endIdx,
lastProcessedSeq: lastProcessed,
schema: getSchema(),
embeddingConfig: getEmbeddingConfig(),
extractPrompt: settings.extractPrompt || undefined,
@@ -478,7 +505,7 @@ async function runRecall() {
});
// 格式化注入文本
const injectionText = formatInjection(result, getSchema());
const injectionText = formatInjection(result, getSchema()).trim();
lastInjectionContent = injectionText;
if (injectionText) {
@@ -486,16 +513,16 @@ async function runRecall() {
console.log(
`[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`,
);
// 使用 ST 的 extension prompt API 注入
context.setExtensionPrompt(
MODULE_NAME,
injectionText,
1, // extension_prompt_types.IN_PROMPT
settings.injectDepth,
);
}
// 无结果时也要清空旧注入,避免脏 prompt 残留
context.setExtensionPrompt(
MODULE_NAME,
injectionText,
1, // extension_prompt_types.IN_PROMPT
clampInt(settings.injectDepth, 4, 0, 9999),
);
// 保存召回结果和访问强化
currentGraph.lastRecallResult = result.selectedNodeIds;
saveGraphToChat();
@@ -665,7 +692,13 @@ function bindSettingsUI() {
$("#st_bme_extract_every")
.val(settings.extractEvery)
.on("input", function () {
settings.extractEvery = Math.max(1, parseInt($(this).val()) || 1);
settings.extractEvery = clampInt($(this).val(), 1, 1, 50);
saveSettingsDebounced();
});
$("#st_bme_extract_context_turns")
.val(settings.extractContextTurns)
.on("input", function () {
settings.extractContextTurns = clampInt($(this).val(), 2, 0, 20);
saveSettingsDebounced();
});
@@ -685,11 +718,24 @@ function bindSettingsUI() {
saveSettingsDebounced();
});
$("#st_bme_recall_top_k")
.val(settings.recallTopK)
.on("input", function () {
settings.recallTopK = clampInt($(this).val(), 15, 1, 100);
saveSettingsDebounced();
});
$("#st_bme_recall_max_nodes")
.val(settings.recallMaxNodes)
.on("input", function () {
settings.recallMaxNodes = clampInt($(this).val(), 8, 1, 50);
saveSettingsDebounced();
});
// 注入深度
$("#st_bme_inject_depth")
.val(settings.injectDepth)
.on("input", function () {
settings.injectDepth = Math.max(0, parseInt($(this).val()) || 4);
settings.injectDepth = clampInt($(this).val(), 4, 0, 9999);
saveSettingsDebounced();
});
@@ -697,19 +743,19 @@ function bindSettingsUI() {
$("#st_bme_graph_weight")
.val(settings.graphWeight)
.on("input", function () {
settings.graphWeight = parseFloat($(this).val()) || 0.6;
settings.graphWeight = clampFloat($(this).val(), 0.6, 0, 1);
saveSettingsDebounced();
});
$("#st_bme_vector_weight")
.val(settings.vectorWeight)
.on("input", function () {
settings.vectorWeight = parseFloat($(this).val()) || 0.3;
settings.vectorWeight = clampFloat($(this).val(), 0.3, 0, 1);
saveSettingsDebounced();
});
$("#st_bme_importance_weight")
.val(settings.importanceWeight)
.on("input", function () {
settings.importanceWeight = parseFloat($(this).val()) || 0.1;
settings.importanceWeight = clampFloat($(this).val(), 0.1, 0, 1);
saveSettingsDebounced();
});
@@ -754,7 +800,13 @@ function bindSettingsUI() {
$("#st_bme_evo_neighbors")
.val(settings.evoNeighborCount)
.on("input", function () {
settings.evoNeighborCount = Math.max(1, parseInt($(this).val()) || 5);
settings.evoNeighborCount = clampInt($(this).val(), 5, 1, 20);
saveSettingsDebounced();
});
$("#st_bme_evo_consolidate_every")
.val(settings.evoConsolidateEvery)
.on("input", function () {
settings.evoConsolidateEvery = clampInt($(this).val(), 50, 1, 500);
saveSettingsDebounced();
});
@@ -768,7 +820,7 @@ function bindSettingsUI() {
$("#st_bme_conflict_threshold")
.val(settings.conflictThreshold)
.on("input", function () {
settings.conflictThreshold = parseFloat($(this).val()) || 0.85;
settings.conflictThreshold = clampFloat($(this).val(), 0.85, 0.5, 0.99);
saveSettingsDebounced();
});
@@ -782,7 +834,7 @@ function bindSettingsUI() {
$("#st_bme_synopsis_every")
.val(settings.synopsisEveryN)
.on("input", function () {
settings.synopsisEveryN = Math.max(1, parseInt($(this).val()) || 5);
settings.synopsisEveryN = clampInt($(this).val(), 5, 1, 100);
saveSettingsDebounced();
});
@@ -815,6 +867,12 @@ function bindSettingsUI() {
settings.triggerPatterns = $(this).val();
saveSettingsDebounced();
});
$("#st_bme_smart_trigger_threshold")
.val(settings.smartTriggerThreshold)
.on("input", function () {
settings.smartTriggerThreshold = clampInt($(this).val(), 2, 1, 10);
saveSettingsDebounced();
});
// P2: 主动遗忘
$("#st_bme_sleep_cycle")
@@ -826,7 +884,13 @@ function bindSettingsUI() {
$("#st_bme_forget_threshold")
.val(settings.forgetThreshold)
.on("input", function () {
settings.forgetThreshold = parseFloat($(this).val()) || 0.5;
settings.forgetThreshold = clampFloat($(this).val(), 0.5, 0.1, 1);
saveSettingsDebounced();
});
$("#st_bme_sleep_every")
.val(settings.sleepEveryN)
.on("input", function () {
settings.sleepEveryN = clampInt($(this).val(), 10, 1, 200);
saveSettingsDebounced();
});
@@ -840,7 +904,7 @@ function bindSettingsUI() {
$("#st_bme_prob_chance")
.val(settings.probRecallChance)
.on("input", function () {
settings.probRecallChance = parseFloat($(this).val()) || 0.15;
settings.probRecallChance = clampFloat($(this).val(), 0.15, 0.01, 0.5);
saveSettingsDebounced();
});
@@ -854,7 +918,7 @@ function bindSettingsUI() {
$("#st_bme_reflect_every")
.val(settings.reflectEveryN)
.on("input", function () {
settings.reflectEveryN = Math.max(3, parseInt($(this).val()) || 10);
settings.reflectEveryN = clampInt($(this).val(), 10, 1, 200);
saveSettingsDebounced();
});
}

View File

@@ -13,6 +13,7 @@ import { getSchemaType } from "./schema.js";
export function formatInjection(retrievalResult, schema) {
const { coreNodes, recallNodes, groupedRecallNodes } = retrievalResult;
const parts = [];
const appended = new Set();
// ========== Core 常驻注入 ==========
if (coreNodes.length > 0) {
@@ -24,7 +25,7 @@ export function formatInjection(retrievalResult, schema) {
const typeDef = getSchemaType(schema, typeId);
if (!typeDef) continue;
const table = formatTable(nodes, typeDef);
const table = formatTable(nodes, typeDef, appended);
if (table) parts.push(table);
}
}
@@ -90,7 +91,7 @@ function appendBucket(parts, title, nodes, schema) {
const typeDef = getSchemaType(schema, typeId);
if (!typeDef) continue;
const table = formatTable(groupedNodes, typeDef);
const table = formatTable(groupedNodes, typeDef, appended);
if (table) parts.push(table);
}
}
@@ -98,12 +99,22 @@ function appendBucket(parts, title, nodes, schema) {
/**
* 将同类型节点格式化为 Markdown 表格
*/
function formatTable(nodes, typeDef) {
if (nodes.length === 0) return "";
function formatTable(nodes, typeDef, appended = new Set()) {
if (!Array.isArray(nodes) || nodes.length === 0) return "";
const uniqueNodes = nodes.filter((node) => {
if (!node?.id || appended.has(node.id)) return false;
appended.add(node.id);
return true;
});
if (uniqueNodes.length === 0) return "";
// 确定要展示的列(有实际数据的列)
const activeCols = typeDef.columns.filter((col) =>
nodes.some((n) => n.fields[col.name]),
uniqueNodes.some(
(n) => n.fields?.[col.name] != null && n.fields[col.name] !== "",
),
);
if (activeCols.length === 0) return "";
@@ -113,9 +124,9 @@ function formatTable(nodes, typeDef) {
const separator = `| ${activeCols.map(() => "---").join(" | ")} |`;
// 数据行
const rows = nodes.map((node) => {
const rows = uniqueNodes.map((node) => {
const cells = activeCols.map((col) => {
const val = node.fields[col.name] || "";
const val = node.fields?.[col.name] ?? "";
// 转义管道符,限制单元格长度
return String(val)
.replace(/\|/g, "\\|")

View File

@@ -54,7 +54,12 @@ export async function retrieve({
const enableProbRecall = options.enableProbRecall ?? false;
const probRecallChance = options.probRecallChance ?? 0.15;
let activeNodes = getActiveNodes(graph);
let activeNodes = getActiveNodes(graph).filter(
(node) =>
!node.archived &&
Array.isArray(node.seqRange) &&
Number.isFinite(node.seqRange[1]),
);
// v2 ⑦: 认知边界过滤RoleRAG 启发)
if (enableVisibility && visibilityFilter) {
@@ -62,6 +67,8 @@ export async function retrieve({
}
const nodeCount = activeNodes.length;
const normalizedTopK = Math.max(1, topK);
const normalizedMaxRecallNodes = Math.max(1, maxRecallNodes);
console.log(
`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? " (认知边界已启用)" : ""}`,
);
@@ -81,7 +88,7 @@ export async function retrieve({
userMessage,
activeNodes,
embeddingConfig,
topK,
normalizedTopK,
);
}
@@ -129,6 +136,9 @@ export async function retrieve({
maxSteps: 2,
decayFactor: 0.6,
topK: 100,
}).filter((item) => {
const node = getNode(graph, item.nodeId);
return node && !node.archived;
});
}
}
@@ -203,14 +213,19 @@ export async function retrieve({
candidateNodes,
graph,
schema,
maxRecallNodes,
normalizedMaxRecallNodes,
);
} else {
// 中等图:直接取 Top-N
selectedNodeIds = scoredNodes.slice(0, topK).map((s) => s.nodeId);
selectedNodeIds = scoredNodes
.slice(0, Math.min(normalizedTopK, scoredNodes.length))
.map((s) => s.nodeId);
}
selectedNodeIds = reconstructSceneNodeIds(graph, selectedNodeIds, topK + 6);
selectedNodeIds = reconstructSceneNodeIds(
graph,
selectedNodeIds,
normalizedTopK + 6,
);
// 访问强化
const selectedNodes = selectedNodeIds
@@ -225,15 +240,19 @@ export async function retrieve({
// 未被选中的高重要性节点有概率随机激活
if (enableProbRecall && probRecallChance > 0) {
const selectedSet = new Set(selectedNodeIds);
const candidates = activeNodes.filter(
(n) =>
!selectedSet.has(n.id) &&
n.importance >= 6 &&
n.type !== "synopsis" &&
n.type !== "rule",
);
const probability = Math.max(0.01, Math.min(0.5, probRecallChance));
const candidates = activeNodes
.filter(
(n) =>
!selectedSet.has(n.id) &&
n.importance >= 6 &&
n.type !== "synopsis" &&
n.type !== "rule",
)
.sort((a, b) => (b.importance || 0) - (a.importance || 0))
.slice(0, 3);
for (const c of candidates) {
if (Math.random() < probRecallChance) {
if (Math.random() < probability) {
selectedNodeIds.push(c.id);
console.log(
`[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`,
@@ -261,7 +280,7 @@ async function vectorPreFilter(
if (!queryVec) return [];
const candidates = activeNodes
.filter((n) => n.embedding)
.filter((n) => Array.isArray(n.embedding) && n.embedding.length > 0)
.map((n) => ({ nodeId: n.id, embedding: n.embedding }));
return searchSimilar(queryVec, candidates, topK);
@@ -277,19 +296,21 @@ async function vectorPreFilter(
*/
function extractEntityAnchors(userMessage, activeNodes) {
const anchors = [];
const seen = new Set();
for (const node of activeNodes) {
// 检查 name 字段
const name = node.fields?.name;
if (name && userMessage.includes(name)) {
anchors.push({ nodeId: node.id, entity: name });
continue;
}
const candidates = [node.fields?.name, node.fields?.title]
.filter((value) => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length >= 2);
// 检查 title 字段
const title = node.fields?.title;
if (title && userMessage.includes(title)) {
anchors.push({ nodeId: node.id, entity: title });
for (const candidate of candidates) {
if (!userMessage.includes(candidate)) continue;
const key = `${node.id}:${candidate}`;
if (seen.has(key)) continue;
seen.add(key);
anchors.push({ nodeId: node.id, entity: candidate });
break;
}
}
@@ -370,16 +391,20 @@ async function llmRecall(
* @returns {object[]}
*/
function filterByVisibility(nodes, characterName) {
if (!characterName || typeof characterName !== "string") return nodes;
return nodes.filter((node) => {
// 没有 visibility 字段 → 对所有人可见
if (!node.fields?.visibility) return true;
// visibility 是数组 → 检查当前角色是否在列表中
if (Array.isArray(node.fields.visibility)) {
return node.fields.visibility.includes(characterName);
return (
node.fields.visibility.includes(characterName) ||
node.fields.visibility.includes("*")
);
}
// visibility 是字符串(逗号分隔)→ 解析后检查
if (typeof node.fields.visibility === "string") {
const visibleTo = node.fields.visibility.split(",").map((s) => s.trim());
const visibleTo = node.fields.visibility
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return visibleTo.includes(characterName) || visibleTo.includes("*");
}
return true;
@@ -391,15 +416,16 @@ function filterByVisibility(nodes, characterName) {
* 分离常驻注入Core和召回注入Recall
*/
function buildResult(graph, selectedNodeIds, schema) {
const coreNodes = []; // 常驻注入
const recallNodes = []; // 召回注入
const coreNodes = [];
const recallNodes = [];
const selectedSet = new Set(uniqueNodeIds(selectedNodeIds));
// 常驻注入节点alwaysInject=true 的类型)
const alwaysInjectTypes = new Set(
schema.filter((s) => s.alwaysInject).map((s) => s.id),
);
const activeNodes = getActiveNodes(graph);
const activeNodes = getActiveNodes(graph).filter((node) => !node.archived);
for (const node of activeNodes) {
if (alwaysInjectTypes.has(node.type)) {
@@ -407,21 +433,23 @@ function buildResult(graph, selectedNodeIds, schema) {
}
}
for (const nodeId of selectedNodeIds) {
for (const nodeId of selectedSet) {
const node = getNode(graph, nodeId);
if (!node) continue;
if (!node || node.archived) continue;
if (!alwaysInjectTypes.has(node.type)) {
recallNodes.push(node);
}
}
coreNodes.sort(compareNodeRecallOrder);
recallNodes.sort(compareNodeRecallOrder);
const groupedRecallNodes = groupRecallNodes(recallNodes);
return {
coreNodes,
recallNodes,
groupedRecallNodes,
selectedNodeIds: [...selectedNodeIds],
selectedNodeIds: [...selectedSet],
stats: {
totalActive: activeNodes.length,
coreCount: coreNodes.length,
@@ -439,14 +467,15 @@ function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) {
const seen = new Set();
function push(nodeId) {
if (!nodeId || seen.has(nodeId)) return;
if (!nodeId || seen.has(nodeId) || selected.length >= limit) return;
const node = getNode(graph, nodeId);
if (!node || node.archived) return;
seen.add(nodeId);
selected.push(nodeId);
}
for (const nodeId of seedNodeIds) {
for (const nodeId of uniqueNodeIds(seedNodeIds)) {
if (selected.length >= limit) break;
push(nodeId);
const node = getNode(graph, nodeId);
if (!node) continue;
@@ -455,26 +484,24 @@ function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) {
expandEventScene(graph, node, push);
} else if (node.type === "character" || node.type === "location") {
const relatedEvents = getNodeEdges(graph, node.id)
.filter((e) => !e.invalidAt)
.filter(isUsableSceneEdge)
.map((e) => (e.fromId === node.id ? e.toId : e.fromId))
.map((id) => getNode(graph, id))
.filter((n) => n && n.type === "event")
.sort((a, b) => b.seq - a.seq)
.filter((n) => n && !n.archived && n.type === "event")
.sort(compareNodeRecallOrder)
.slice(0, 2);
for (const eventNode of relatedEvents) {
push(eventNode.id);
expandEventScene(graph, eventNode, push);
}
}
if (selected.length >= limit) break;
}
return selected.slice(0, limit);
}
function expandEventScene(graph, eventNode, push) {
const edges = getNodeEdges(graph, eventNode.id).filter((e) => !e.invalidAt);
const edges = getNodeEdges(graph, eventNode.id).filter(isUsableSceneEdge);
for (const edge of edges) {
const neighborId = edge.fromId === eventNode.id ? edge.toId : edge.fromId;
const neighbor = getNode(graph, neighborId);
@@ -501,11 +528,27 @@ function expandEventScene(graph, eventNode, push) {
function getTemporalNeighborEvents(graph, seq, excludeId) {
return getActiveNodes(graph, "event")
.filter((n) => n.id !== excludeId)
.sort((a, b) => Math.abs(a.seq - seq) - Math.abs(b.seq - seq))
.filter((n) => n.id !== excludeId && !n.archived)
.sort((a, b) => {
const distance =
Math.abs((a.seq || 0) - seq) - Math.abs((b.seq || 0) - seq);
if (distance !== 0) return distance;
return (b.seq || 0) - (a.seq || 0);
})
.slice(0, 2);
}
function isUsableSceneEdge(edge) {
return edge && !edge.invalidAt && !edge.expiredAt;
}
function compareNodeRecallOrder(a, b) {
const aSeq = a?.seqRange?.[1] ?? a?.seq ?? 0;
const bSeq = b?.seqRange?.[1] ?? b?.seq ?? 0;
if (bSeq !== aSeq) return bSeq - aSeq;
return (b.importance || 0) - (a.importance || 0);
}
function groupRecallNodes(nodes) {
return {
state: nodes.filter((n) => n.type === "character" || n.type === "location"),

433
schema.js
View File

@@ -5,8 +5,8 @@
* 压缩模式
*/
export const COMPRESSION_MODE = {
NONE: 'none',
HIERARCHICAL: 'hierarchical',
NONE: "none",
HIERARCHICAL: "hierarchical",
};
/**
@@ -22,172 +22,189 @@ export const COMPRESSION_MODE = {
* - compression: 压缩配置
*/
export const DEFAULT_NODE_SCHEMA = [
{
id: 'event',
label: '事件',
tableName: 'event_table',
columns: [
{ name: 'summary', hint: '事件摘要,包含因果关系和结果', required: true },
{ name: 'participants', hint: '参与角色名,逗号分隔', required: false },
{ name: 'status', hint: '事件状态ongoing/resolved/blocked', required: false },
],
alwaysInject: true,
latestOnly: false,
forceUpdate: true,
compression: {
mode: COMPRESSION_MODE.HIERARCHICAL,
threshold: 9,
fanIn: 3,
maxDepth: 10,
keepRecentLeaves: 6,
instruction: '将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。',
},
{
id: "event",
label: "事件",
tableName: "event_table",
columns: [
{ name: "summary", hint: "事件摘要,包含因果关系和结果", required: true },
{ name: "participants", hint: "参与角色名,逗号分隔", required: false },
{
name: "status",
hint: "事件状态ongoing/resolved/blocked",
required: false,
},
],
alwaysInject: true,
latestOnly: false,
forceUpdate: true,
compression: {
mode: COMPRESSION_MODE.HIERARCHICAL,
threshold: 9,
fanIn: 3,
maxDepth: 10,
keepRecentLeaves: 6,
instruction:
"将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。",
},
{
id: 'character',
label: '角色',
tableName: 'character_table',
columns: [
{ name: 'name', hint: '角色名(仅规范名称)', required: true },
{ name: 'traits', hint: '稳定的性格特征和外貌标记', required: false },
{ name: 'state', hint: '当前状态或处境', required: false },
{ name: 'goal', hint: '当前目标或动机', required: false },
{ name: 'inventory', hint: '携带或拥有的关键物品', required: false },
{ name: 'core_note', hint: '值得长期记住的关键备注', required: false },
],
alwaysInject: false,
latestOnly: true,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.NONE,
threshold: 0,
fanIn: 0,
maxDepth: 0,
keepRecentLeaves: 0,
instruction: '',
},
},
{
id: "character",
label: "角色",
tableName: "character_table",
columns: [
{ name: "name", hint: "角色名(仅规范名称)", required: true },
{ name: "traits", hint: "稳定的性格特征和外貌标记", required: false },
{ name: "state", hint: "当前状态或处境", required: false },
{ name: "goal", hint: "当前目标或动机", required: false },
{ name: "inventory", hint: "携带或拥有的关键物品", required: false },
{ name: "core_note", hint: "值得长期记住的关键备注", required: false },
],
alwaysInject: false,
latestOnly: true,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.NONE,
threshold: 0,
fanIn: 0,
maxDepth: 0,
keepRecentLeaves: 0,
instruction: "",
},
{
id: 'location',
label: '地点',
tableName: 'location_table',
columns: [
{ name: 'name', hint: '地点名称(仅规范名称)', required: true },
{ name: 'state', hint: '当前状态或环境条件', required: false },
{ name: 'features', hint: '重要特征、资源或服务', required: false },
{ name: 'danger', hint: '危险等级或威胁', required: false },
],
alwaysInject: false,
latestOnly: true,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.NONE,
threshold: 0,
fanIn: 0,
maxDepth: 0,
keepRecentLeaves: 0,
instruction: '',
},
},
{
id: "location",
label: "地点",
tableName: "location_table",
columns: [
{ name: "name", hint: "地点名称(仅规范名称)", required: true },
{ name: "state", hint: "当前状态或环境条件", required: false },
{ name: "features", hint: "重要特征、资源或服务", required: false },
{ name: "danger", hint: "危险等级或威胁", required: false },
],
alwaysInject: false,
latestOnly: true,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.NONE,
threshold: 0,
fanIn: 0,
maxDepth: 0,
keepRecentLeaves: 0,
instruction: "",
},
{
id: 'rule',
label: '规则',
tableName: 'rule_table',
columns: [
{ name: 'title', hint: '简短规则名', required: true },
{ name: 'constraint', hint: '不可违反的规则内容', required: true },
{ name: 'scope', hint: '适用范围/场景', required: false },
{ name: 'status', hint: '当前有效性active/suspended/revoked', required: false },
],
alwaysInject: true,
latestOnly: false,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.NONE,
threshold: 0,
fanIn: 0,
maxDepth: 0,
keepRecentLeaves: 0,
instruction: '',
},
},
{
id: "rule",
label: "规则",
tableName: "rule_table",
columns: [
{ name: "title", hint: "简短规则名", required: true },
{ name: "constraint", hint: "不可违反的规则内容", required: true },
{ name: "scope", hint: "适用范围/场景", required: false },
{
name: "status",
hint: "当前有效性active/suspended/revoked",
required: false,
},
],
alwaysInject: true,
latestOnly: false,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.NONE,
threshold: 0,
fanIn: 0,
maxDepth: 0,
keepRecentLeaves: 0,
instruction: "",
},
{
id: 'thread',
label: '主线',
tableName: 'thread_table',
columns: [
{ name: 'title', hint: '主线名称', required: true },
{ name: 'summary', hint: '当前进展摘要', required: false },
{ name: 'status', hint: '状态active/completed/abandoned', required: false },
],
alwaysInject: true,
latestOnly: false,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.HIERARCHICAL,
threshold: 6,
fanIn: 3,
maxDepth: 5,
keepRecentLeaves: 3,
instruction: '将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。',
},
},
{
id: "thread",
label: "主线",
tableName: "thread_table",
columns: [
{ name: "title", hint: "主线名称", required: true },
{ name: "summary", hint: "当前进展摘要", required: false },
{
name: "status",
hint: "状态active/completed/abandoned",
required: false,
},
],
alwaysInject: true,
latestOnly: false,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.HIERARCHICAL,
threshold: 6,
fanIn: 3,
maxDepth: 5,
keepRecentLeaves: 3,
instruction: "将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。",
},
// ====== v2 新增节点类型 ======
{
id: 'synopsis',
label: '全局概要',
tableName: 'synopsis_table',
columns: [
{ name: 'summary', hint: '当前故事的全局概要(前情提要)', required: true },
{ name: 'scope', hint: '概要覆盖的楼层范围', required: false },
],
alwaysInject: true, // 常驻注入MemoRAG 启发)
latestOnly: true, // 只保留最新版本
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.NONE,
threshold: 0,
fanIn: 0,
maxDepth: 0,
keepRecentLeaves: 0,
instruction: '',
},
},
// ====== v2 新增节点类型 ======
{
id: "synopsis",
label: "全局概要",
tableName: "synopsis_table",
columns: [
{
name: "summary",
hint: "当前故事的全局概要(前情提要)",
required: true,
},
{ name: "scope", hint: "概要覆盖的楼层范围", required: false },
],
alwaysInject: true, // 常驻注入MemoRAG 启发)
latestOnly: true, // 只保留最新版本
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.NONE,
threshold: 0,
fanIn: 0,
maxDepth: 0,
keepRecentLeaves: 0,
instruction: "",
},
{
id: 'reflection',
label: '反思',
tableName: 'reflection_table',
columns: [
{ name: 'insight', hint: '对角色行为或情节的元认知反思', required: true },
{ name: 'trigger', hint: '触发反思的事件/矛盾', required: false },
{ name: 'suggestion', hint: '对后续叙事的建议', required: false },
],
alwaysInject: false, // 需要被召回
latestOnly: false,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.HIERARCHICAL,
threshold: 6,
fanIn: 3,
maxDepth: 3,
keepRecentLeaves: 3,
instruction: '将反思条目合并为高层次的叙事指导原则。',
},
},
{
id: "reflection",
label: "反思",
tableName: "reflection_table",
columns: [
{ name: "insight", hint: "对角色行为或情节的元认知反思", required: true },
{ name: "trigger", hint: "触发反思的事件/矛盾", required: false },
{ name: "suggestion", hint: "对后续叙事的建议", required: false },
],
alwaysInject: false, // 需要被召回
latestOnly: false,
forceUpdate: false,
compression: {
mode: COMPRESSION_MODE.HIERARCHICAL,
threshold: 6,
fanIn: 3,
maxDepth: 3,
keepRecentLeaves: 3,
instruction: "将反思条目合并为高层次的叙事指导原则。",
},
},
];
/**
* 规范化的关系类型
*/
export const RELATION_TYPES = [
'related', // 一般关联
'involved_in', // 参与事件
'occurred_at', // 发生于地点
'advances', // 推进主线
'updates', // 更新实体状态
'contradicts', // 矛盾/冲突(用于抑制边)
'evolves', // A-MEM 进化链接(新→旧)
'temporal_update', // 时序更新Graphiti新状态替代旧状态
"related", // 一般关联
"involved_in", // 参与事件
"occurred_at", // 发生于地点
"advances", // 推进主线
"updates", // 更新实体状态
"contradicts", // 矛盾/冲突(用于抑制边)
"evolves", // A-MEM 进化链接(新→旧)
"temporal_update", // 时序更新Graphiti新状态替代旧状态
];
/**
@@ -196,46 +213,80 @@ export const RELATION_TYPES = [
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateSchema(schema) {
const errors = [];
const errors = [];
if (!Array.isArray(schema) || schema.length === 0) {
errors.push('Schema 必须是非空数组');
return { valid: false, errors };
if (!Array.isArray(schema) || schema.length === 0) {
errors.push("Schema 必须是非空数组");
return { valid: false, errors };
}
const ids = new Set();
const tableNames = new Set();
for (const type of schema) {
if (!type || typeof type !== "object") {
errors.push("Schema 类型定义必须是对象");
continue;
}
const ids = new Set();
const tableNames = new Set();
for (const type of schema) {
if (!type.id || typeof type.id !== 'string') {
errors.push('每种类型必须有 id');
continue;
}
if (ids.has(type.id)) {
errors.push(`类型 ID 重复:${type.id}`);
}
ids.add(type.id);
if (!type.tableName || typeof type.tableName !== 'string') {
errors.push(`类型 ${type.id}:缺少 tableName`);
} else if (tableNames.has(type.tableName)) {
errors.push(`表名重复:${type.tableName}`);
} else {
tableNames.add(type.tableName);
}
if (!Array.isArray(type.columns) || type.columns.length === 0) {
errors.push(`类型 ${type.id}:至少需要一个列`);
}
const hasRequired = type.columns?.some(c => c.required);
if (!hasRequired) {
errors.push(`类型 ${type.id}:至少需要一个 required 列`);
}
if (!type.id || typeof type.id !== "string") {
errors.push("每种类型必须有 id");
continue;
}
return { valid: errors.length === 0, errors };
if (ids.has(type.id)) {
errors.push(`类型 ID 重复:${type.id}`);
}
ids.add(type.id);
if (!type.label || typeof type.label !== "string") {
errors.push(`类型 ${type.id}:缺少 label`);
}
if (!type.tableName || typeof type.tableName !== "string") {
errors.push(`类型 ${type.id}:缺少 tableName`);
} else if (tableNames.has(type.tableName)) {
errors.push(`表名重复:${type.tableName}`);
} else {
tableNames.add(type.tableName);
}
if (!Array.isArray(type.columns) || type.columns.length === 0) {
errors.push(`类型 ${type.id}:至少需要一个列`);
continue;
}
const columnNames = new Set();
for (const column of type.columns) {
if (!column?.name || typeof column.name !== "string") {
errors.push(`类型 ${type.id}:存在缺少 name 的列定义`);
continue;
}
if (columnNames.has(column.name)) {
errors.push(`类型 ${type.id}:列名重复 ${column.name}`);
}
columnNames.add(column.name);
}
const hasRequired = type.columns.some((c) => c?.required);
if (!hasRequired) {
errors.push(`类型 ${type.id}:至少需要一个 required 列`);
}
if (type.latestOnly) {
const hasPrimaryLikeField = ["name", "title", "summary"].some(
(fieldName) =>
type.columns.some((column) => column?.name === fieldName),
);
if (!hasPrimaryLikeField) {
errors.push(
`类型 ${type.id}latestOnly 类型至少需要 name/title/summary 之一作为主标识字段`,
);
}
}
}
return { valid: errors.length === 0, errors };
}
/**
@@ -245,5 +296,5 @@ export function validateSchema(schema) {
* @returns {object|null}
*/
export function getSchemaType(schema, typeId) {
return schema.find(t => t.id === typeId) || null;
return schema.find((t) => t.id === typeId) || null;
}

View File

@@ -23,10 +23,22 @@
id="st_bme_extract_every"
class="text_pole"
min="1"
max="10"
max="50"
value="1"
/>
</div>
<div class="st-bme-row">
<label for="st_bme_extract_context_turns">提取上下文轮数</label>
<input
type="number"
id="st_bme_extract_context_turns"
class="text_pole"
min="0"
max="20"
value="2"
/>
</div>
</div>
<hr class="st-bme-hr" />
@@ -49,6 +61,30 @@
</label>
</div>
<div class="st-bme-row">
<label for="st_bme_recall_top_k">召回候选上限</label>
<input
type="number"
id="st_bme_recall_top_k"
class="text_pole"
min="1"
max="100"
value="15"
/>
</div>
<div class="st-bme-row">
<label for="st_bme_recall_max_nodes">LLM 精确召回上限</label>
<input
type="number"
id="st_bme_recall_max_nodes"
class="text_pole"
min="1"
max="50"
value="8"
/>
</div>
<div class="st-bme-row">
<label for="st_bme_inject_depth">注入深度</label>
<input
@@ -138,6 +174,18 @@
/>
</div>
<div class="st-bme-row st-bme-indent">
<label for="st_bme_evo_consolidate_every">整理频率</label>
<input
type="number"
id="st_bme_evo_consolidate_every"
class="text_pole"
min="1"
max="500"
value="50"
/>
</div>
<div class="st-bme-row">
<label class="checkbox_label" for="st_bme_precise_conflict">
<input type="checkbox" id="st_bme_precise_conflict" />
@@ -215,6 +263,18 @@
/>
</div>
<div class="st-bme-row st-bme-indent">
<label for="st_bme_smart_trigger_threshold">触发阈值</label>
<input
type="number"
id="st_bme_smart_trigger_threshold"
class="text_pole"
min="1"
max="10"
value="2"
/>
</div>
<div class="st-bme-row">
<label class="checkbox_label" for="st_bme_sleep_cycle">
<input type="checkbox" id="st_bme_sleep_cycle" />
@@ -233,6 +293,17 @@
value="0.5"
/>
</div>
<div class="st-bme-row st-bme-indent">
<label for="st_bme_sleep_every">每 N 次提取遗忘</label>
<input
type="number"
id="st_bme_sleep_every"
class="text_pole"
min="1"
max="200"
value="10"
/>
</div>
<div class="st-bme-row">
<label class="checkbox_label" for="st_bme_prob_recall">

76
tests/graph-retrieval.mjs Normal file
View File

@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { diffuseAndRank } from "../diffusion.js";
import {
addEdge,
addNode,
buildTemporalAdjacencyMap,
createEdge,
createEmptyGraph,
createNode,
invalidateEdge,
} from "../graph.js";
const graph = createEmptyGraph();
const event1 = createNode({
type: "event",
seq: 1,
fields: { summary: "初始事件" },
importance: 5,
});
const event2 = createNode({
type: "event",
seq: 2,
fields: { summary: "后续事件" },
importance: 6,
});
const character = createNode({
type: "character",
seq: 2,
fields: { name: "艾琳", state: "警觉" },
importance: 7,
});
addNode(graph, event1);
addNode(graph, event2);
addNode(graph, character);
const currentEdge = createEdge({
fromId: event2.id,
toId: character.id,
relation: "involved_in",
strength: 0.9,
});
assert.ok(addEdge(graph, currentEdge));
const historicalEdge = createEdge({
fromId: event1.id,
toId: character.id,
relation: "involved_in",
strength: 0.4,
});
assert.ok(addEdge(graph, historicalEdge));
invalidateEdge(historicalEdge);
const replacementEdge = createEdge({
fromId: event1.id,
toId: character.id,
relation: "involved_in",
strength: 0.7,
});
assert.ok(addEdge(graph, replacementEdge));
assert.notEqual(replacementEdge.id, historicalEdge.id);
const adjacencyMap = buildTemporalAdjacencyMap(graph);
const event1Neighbors = adjacencyMap.get(event1.id) || [];
assert.equal(event1Neighbors.length, 1);
assert.equal(event1Neighbors[0].targetId, character.id);
assert.equal(event1Neighbors[0].strength, 0.7);
const diffusion = diffuseAndRank(adjacencyMap, [
{ id: event2.id, energy: 1 },
{ id: event2.id, energy: 0.5 },
]);
assert.ok(diffusion.some((item) => item.nodeId === character.id));
console.log("graph-retrieval tests passed");

View File

@@ -1,102 +1,36 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
function getSmartTriggerDecision(chat, lastProcessed, settings) {
const DEFAULT_TRIGGER_KEYWORDS = [
"突然",
"没想到",
"原来",
"其实",
"发现",
"背叛",
"死亡",
"复活",
"恢复记忆",
"失忆",
"告白",
"暴露",
"秘密",
"计划",
"规则",
"契约",
"位置",
"地点",
"离开",
"来到",
];
const pendingMessages = chat
.slice(lastProcessed + 1)
.filter((msg) => !msg.is_system)
.map((msg) => ({
role: msg.is_user ? "user" : "assistant",
content: msg.mes || "",
}));
if (pendingMessages.length === 0) {
return { triggered: false, score: 0, reasons: [] };
}
const reasons = [];
let score = 0;
const combinedText = pendingMessages.map((m) => m.content).join("\n");
const keywordHits = DEFAULT_TRIGGER_KEYWORDS.filter((keyword) =>
combinedText.includes(keyword),
async function loadSmartTriggerDecision() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const indexPath = path.resolve(__dirname, "../index.js");
const source = await fs.readFile(indexPath, "utf8");
const keywordMatch = source.match(
/const DEFAULT_TRIGGER_KEYWORDS = \[[\s\S]*?\];/m,
);
if (keywordHits.length > 0) {
score += Math.min(2, keywordHits.length);
reasons.push(`关键词: ${keywordHits.slice(0, 3).join(", ")}`);
const fnMatch = source.match(
/export function getSmartTriggerDecision\(chat, lastProcessed, settings\) \{[\s\S]*?^\}/m,
);
if (!keywordMatch || !fnMatch) {
throw new Error("无法从 index.js 提取 smart trigger 实现");
}
const customPatterns = String(settings.triggerPatterns || "")
.split(/\r?\n|,/)
.map((s) => s.trim())
.filter(Boolean);
for (const pattern of customPatterns) {
try {
const regex = new RegExp(pattern, "i");
if (regex.test(combinedText)) {
score += 2;
reasons.push(`自定义触发: ${pattern}`);
break;
}
} catch {
// ignore invalid regex
}
}
const roleSwitchCount = pendingMessages.reduce((count, message, index) => {
if (index === 0) return count;
return count + (message.role !== pendingMessages[index - 1].role ? 1 : 0);
}, 0);
if (roleSwitchCount >= 2) {
score += 1;
reasons.push("多轮往返互动");
}
const punctuationHits = (combinedText.match(/[!?]/g) || []).length;
if (punctuationHits >= 2) {
score += 1;
reasons.push("情绪/冲突波动");
}
const entityLikeHits =
combinedText.match(
/[A-Z][a-z]{2,}|[\u4e00-\u9fff]{2,6}(先生|小姐|王国|城|镇|村|学院|组织|公司|小队|军团)/g,
) || [];
if (entityLikeHits.length > 0) {
score += 1;
reasons.push("疑似新实体/新地点");
}
const threshold = Math.max(1, settings.smartTriggerThreshold || 2);
return {
triggered: score >= threshold,
score,
reasons,
};
const context = vm.createContext({});
const script = new vm.Script(`
${keywordMatch[0]}
${fnMatch[0].replace("export function", "function")}
this.getSmartTriggerDecision = getSmartTriggerDecision;
`);
script.runInContext(context);
return context.getSmartTriggerDecision;
}
const getSmartTriggerDecision = await loadSmartTriggerDecision();
const noTrigger = getSmartTriggerDecision(
[
{ is_user: true, mes: "今天天气不错。" },
@@ -129,4 +63,29 @@ const customTrigger = getSmartTriggerDecision(
assert.equal(customTrigger.triggered, true);
assert.ok(customTrigger.reasons.some((r) => r.includes("自定义触发")));
const ignoresProcessedMessages = getSmartTriggerDecision(
[
{ is_user: true, mes: "之前突然出现了秘密。" },
{ is_user: false, mes: "这已经处理过。" },
{ is_user: true, mes: "现在只是平静地走路。" },
{ is_user: false, mes: "没有新的异常。" },
],
1,
{ triggerPatterns: "", smartTriggerThreshold: 2 },
);
assert.equal(ignoresProcessedMessages.triggered, false);
assert.equal(ignoresProcessedMessages.score, 0);
const ignoresBlankAndInvalidRegex = getSmartTriggerDecision(
[
{ is_system: true, mes: "系统消息" },
{ is_user: true, mes: " " },
{ is_user: false, mes: "Alpha城发生了什么" },
],
-1,
{ triggerPatterns: "([\n真相", smartTriggerThreshold: 2 },
);
assert.equal(ignoresBlankAndInvalidRegex.triggered, true);
assert.ok(ignoresBlankAndInvalidRegex.reasons.includes("情绪/冲突波动"));
console.log("smart-trigger tests passed");