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

@@ -60,14 +60,15 @@ const DEFAULT_OPTIONS = {
export function propagateActivation(adjacencyMap, seedNodes, options = {}) { export function propagateActivation(adjacencyMap, seedNodes, options = {}) {
const opts = { ...DEFAULT_OPTIONS, ...options }; const opts = { ...DEFAULT_OPTIONS, ...options };
// Step 0: 初始化能量表
/** @type {Map<string, number>} */ /** @type {Map<string, number>} */
let currentEnergy = new Map(); let currentEnergy = new Map();
for (const seed of seedNodes) { for (const seed of seedNodes || []) {
const clamped = clampEnergy(seed.energy, opts); if (!seed?.id) continue;
const clamped = clampEnergy(Number(seed.energy) || 0, opts);
if (Math.abs(clamped) >= opts.minEnergy) { if (Math.abs(clamped) >= opts.minEnergy) {
currentEnergy.set(seed.id, clamped); const existing = currentEnergy.get(seed.id) || 0;
currentEnergy.set(seed.id, clampEnergy(existing + clamped, opts));
} }
} }
@@ -83,11 +84,12 @@ export function propagateActivation(adjacencyMap, seedNodes, options = {}) {
// 对每个当前活跃节点,传播能量到邻居 // 对每个当前活跃节点,传播能量到邻居
for (const [nodeId, energy] of currentEnergy) { for (const [nodeId, energy] of currentEnergy) {
const neighbors = adjacencyMap.get(nodeId); const neighbors = adjacencyMap.get(nodeId);
if (!neighbors) continue; if (!Array.isArray(neighbors) || neighbors.length === 0) continue;
for (const neighbor of neighbors) { for (const neighbor of neighbors) {
// 计算传播能量 if (!neighbor?.targetId) continue;
let propagated = energy * neighbor.strength * opts.decayFactor; let propagated =
energy * (Number(neighbor.strength) || 0) * opts.decayFactor;
// 抑制边:传递负能量 // 抑制边:传递负能量
if (neighbor.edgeType === INHIBIT_EDGE_TYPE) { if (neighbor.edgeType === INHIBIT_EDGE_TYPE) {
@@ -112,8 +114,9 @@ export function propagateActivation(adjacencyMap, seedNodes, options = {}) {
// 动态剪枝:只保留 Top-K // 动态剪枝:只保留 Top-K
if (nextEnergy.size > opts.topK) { if (nextEnergy.size > opts.topK) {
const sorted = [...nextEnergy.entries()] const sorted = [...nextEnergy.entries()].sort(
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])); (a, b) => Math.abs(b[1]) - Math.abs(a[1]),
);
nextEnergy.clear(); nextEnergy.clear();
for (let i = 0; i < opts.topK && i < sorted.length; i++) { for (let i = 0; i < opts.topK && i < sorted.length; i++) {
@@ -161,7 +164,10 @@ export function diffuseAndRank(adjacencyMap, seeds, options = {}) {
const energyMap = propagateActivation(adjacencyMap, seeds, options); const energyMap = propagateActivation(adjacencyMap, seeds, options);
return [...energyMap.entries()] return [...energyMap.entries()]
.filter(([_, energy]) => energy > 0) // 只返回正能量(被激活的) .filter(([_, energy]) => energy > 0)
.map(([nodeId, energy]) => ({ nodeId, energy })) .map(([nodeId, energy]) => ({ nodeId, energy }))
.sort((a, b) => b.energy - a.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
* @param {object} params.graph - 当前图状态 * @param {object} params.graph - 当前图状态
* @param {Array<{role: string, content: string}>} params.messages - 要处理的对话消息 * @param {Array<{seq?: number, role: string, content: string}>} params.messages - 要处理的对话消息
* @param {number} params.startSeq - 起始楼层号 * @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.schema - 节点类型 Schema
* @param {object} params.embeddingConfig - Embedding API 配置 * @param {object} params.embeddingConfig - Embedding API 配置
* @param {string} [params.extractPrompt] - 自定义提取提示词 * @param {string} [params.extractPrompt] - 自定义提取提示词
* @param {object} [params.v2Options] - v2 增强选项 * @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({ export async function extractMemories({
graph, graph,
messages, messages,
startSeq, startSeq,
endSeq,
lastProcessedSeq = -1,
schema, schema,
embeddingConfig, embeddingConfig,
extractPrompt, extractPrompt,
@@ -46,17 +50,33 @@ export async function extractMemories({
updatedNodes: 0, updatedNodes: 0,
newEdges: 0, newEdges: 0,
newNodeIds: [], newNodeIds: [],
processedRange: [lastProcessedSeq, lastProcessedSeq],
}; };
} }
const enablePreciseConflict = v2Options.enablePreciseConflict ?? true; const enablePreciseConflict = v2Options.enablePreciseConflict ?? true;
const conflictThreshold = v2Options.conflictThreshold ?? 0.85; 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 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"); .join("\n\n");
// 构建当前图概览(让 LLM 知道已有哪些节点,避免重复) // 构建当前图概览(让 LLM 知道已有哪些节点,避免重复)
@@ -89,7 +109,7 @@ export async function extractMemories({
maxRetries: 2, maxRetries: 2,
}); });
if (!result || !result.operations) { if (!result || !Array.isArray(result.operations)) {
console.warn("[ST-BME] 提取 LLM 未返回有效操作"); console.warn("[ST-BME] 提取 LLM 未返回有效操作");
return { return {
success: false, success: false,
@@ -97,6 +117,7 @@ export async function extractMemories({
updatedNodes: 0, updatedNodes: 0,
newEdges: 0, newEdges: 0,
newNodeIds: [], newNodeIds: [],
processedRange: [lastProcessedSeq, lastProcessedSeq],
}; };
} }
@@ -107,6 +128,7 @@ export async function extractMemories({
result.operations, result.operations,
embeddingConfig, embeddingConfig,
conflictThreshold, conflictThreshold,
effectiveEndSeq,
); );
} }
@@ -122,7 +144,7 @@ export async function extractMemories({
const createdId = handleCreate( const createdId = handleCreate(
graph, graph,
op, op,
startSeq, currentSeq,
schema, schema,
refMap, refMap,
stats, stats,
@@ -131,7 +153,7 @@ export async function extractMemories({
break; break;
} }
case "update": case "update":
handleUpdate(graph, op, stats); handleUpdate(graph, op, currentSeq, stats);
break; break;
case "delete": case "delete":
handleDelete(graph, op, stats); handleDelete(graph, op, stats);
@@ -150,15 +172,22 @@ export async function extractMemories({
// 为新建节点生成 embedding // 为新建节点生成 embedding
await generateNodeEmbeddings(graph, embeddingConfig); await generateNodeEmbeddings(graph, embeddingConfig);
// 更新处理进度 // 更新处理进度:统一记录为已处理到的末个 chat 索引
graph.lastProcessedSeq = graph.lastProcessedSeq = Math.max(
startSeq + messages.filter((m) => m.role === "assistant").length; graph.lastProcessedSeq ?? -1,
effectiveEndSeq,
console.log(
`[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}`,
); );
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 操作 * 处理 update 操作
*/ */
function handleUpdate(graph, op, stats) { function handleUpdate(graph, op, currentSeq, stats) {
if (!op.nodeId) { if (!op.nodeId) {
console.warn("[ST-BME] update 操作缺少 nodeId"); console.warn("[ST-BME] update 操作缺少 nodeId");
return; return;
@@ -233,8 +262,10 @@ function handleUpdate(graph, op, stats) {
const nextFields = { ...previousFields, ...(op.fields || {}) }; const nextFields = { ...previousFields, ...(op.fields || {}) };
const changeSummary = buildFieldChangeSummary(previousFields, nextFields); const changeSummary = buildFieldChangeSummary(previousFields, nextFields);
const updateSeq = Number.isFinite(op.seq) ? op.seq : currentSeq;
const updated = updateNode(graph, op.nodeId, { const updated = updateNode(graph, op.nodeId, {
fields: op.fields || {}, fields: op.fields || {},
seq: Math.max(previousNode.seq || 0, updateSeq),
}); });
if (updated) { if (updated) {
@@ -242,19 +273,22 @@ function handleUpdate(graph, op, stats) {
const node = getNode(graph, op.nodeId); const node = getNode(graph, op.nodeId);
if (node) { if (node) {
node.embedding = null; node.embedding = null;
node.seq = Math.max(node.seq || 0, op.seq || 0); node.seq = Math.max(node.seq || 0, updateSeq);
node.seqRange = [ node.seqRange = [
Math.min(node.seqRange?.[0] ?? node.seq, op.seq || node.seq), Math.min(node.seqRange?.[0] ?? node.seq, updateSeq),
Math.max(node.seqRange?.[1] ?? node.seq, op.seq || node.seq), Math.max(node.seqRange?.[1] ?? node.seq, updateSeq),
]; ];
} }
// v2 Graphiti: 标记旧的 updates/temporal_update 边为失效 // v2 Graphiti: 标记旧的 updates/temporal_update 边为失效
const oldEdges = graph.edges.filter( const oldEdges = graph.edges.filter(
(e) => (e) =>
!e.invalidAt &&
((e.relation === "updates" && e.toId === op.nodeId) ||
(e.relation === "temporal_update" &&
e.toId === op.nodeId && e.toId === op.nodeId &&
(e.relation === "updates" || e.relation === "temporal_update") && op.sourceNodeId &&
!e.invalidAt, e.fromId === op.sourceNodeId)),
); );
for (const e of oldEdges) { for (const e of oldEdges) {
invalidateEdge(e); invalidateEdge(e);
@@ -284,7 +318,7 @@ function handleUpdate(graph, op, stats) {
previousNode.id, previousNode.id,
status: "resolved", status: "resolved",
}, },
seq: op.seq || previousNode.seq || 0, seq: updateSeq,
importance: Math.max( importance: Math.max(
4, 4,
Math.min(8, op.importance ?? previousNode.importance ?? 5), Math.min(8, op.importance ?? previousNode.importance ?? 5),
@@ -379,7 +413,10 @@ function handleLinks(graph, sourceId, links, refMap, stats) {
async function generateNodeEmbeddings(graph, embeddingConfig) { async function generateNodeEmbeddings(graph, embeddingConfig) {
if (!embeddingConfig?.apiUrl) return; 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; if (needsEmbedding.length === 0) return;
@@ -410,7 +447,9 @@ async function generateNodeEmbeddings(graph, embeddingConfig) {
* 构建图谱概览文本(给 LLM 看) * 构建图谱概览文本(给 LLM 看)
*/ */
function buildGraphOverview(graph, schema) { 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 ""; if (activeNodes.length === 0) return "";
const lines = []; const lines = [];
@@ -502,8 +541,11 @@ async function mem0ConflictCheck(
operations, operations,
embeddingConfig, embeddingConfig,
threshold, 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; if (activeNodes.length === 0) return;
for (const op of operations) { for (const op of operations) {
@@ -553,7 +595,8 @@ async function mem0ConflictCheck(
); );
op.action = "update"; op.action = "update";
op.nodeId = decision.targetId; 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) { if (decision.mergedFields) {
op.fields = { ...op.fields, ...decision.mergedFields }; op.fields = { ...op.fields, ...decision.mergedFields };
} }
@@ -627,7 +670,12 @@ export async function generateSynopsis({ graph, schema, currentSeq }) {
if (existingSynopsis) { if (existingSynopsis) {
updateNode(graph, existingSynopsis.id, { updateNode(graph, existingSynopsis.id, {
fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` }, 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; existingSynopsis.embedding = null;
console.log("[ST-BME] 全局概要已更新"); console.log("[ST-BME] 全局概要已更新");
} else { } else {

161
graph.js
View File

@@ -4,15 +4,15 @@
/** /**
* 图状态版本号 * 图状态版本号
*/ */
const GRAPH_VERSION = 2; const GRAPH_VERSION = 3;
/** /**
* 生成 UUID v4 * 生成 UUID v4
*/ */
function uuid() { function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0; const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8; const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16); return v.toString(16);
}); });
} }
@@ -24,7 +24,7 @@ function uuid() {
export function createEmptyGraph() { export function createEmptyGraph() {
return { return {
version: GRAPH_VERSION, version: GRAPH_VERSION,
lastProcessedSeq: 0, lastProcessedSeq: -1,
nodes: [], nodes: [],
edges: [], edges: [],
lastRecallResult: null, lastRecallResult: null,
@@ -77,7 +77,7 @@ export function createNode({
export function addNode(graph, node) { export function addNode(graph, node) {
// 同类型节点的时间链表:连接到最后一个同类型节点 // 同类型节点的时间链表:连接到最后一个同类型节点
const sameTypeNodes = graph.nodes const sameTypeNodes = graph.nodes
.filter(n => n.type === node.type && !n.archived && n.level === 0) .filter((n) => n.type === node.type && !n.archived && n.level === 0)
.sort((a, b) => a.seq - b.seq); .sort((a, b) => a.seq - b.seq);
if (sameTypeNodes.length > 0) { if (sameTypeNodes.length > 0) {
@@ -97,7 +97,7 @@ export function addNode(graph, node) {
* @returns {object|null} * @returns {object|null}
*/ */
export function getNode(graph, nodeId) { export function getNode(graph, nodeId) {
return graph.nodes.find(n => n.id === nodeId) || null; return graph.nodes.find((n) => n.id === nodeId) || null;
} }
/** /**
@@ -149,15 +149,17 @@ export function removeNode(graph, nodeId) {
if (node.parentId) { if (node.parentId) {
const parent = getNode(graph, node.parentId); const parent = getNode(graph, node.parentId);
if (parent) { if (parent) {
parent.childIds = parent.childIds.filter(id => id !== nodeId); parent.childIds = parent.childIds.filter((id) => id !== nodeId);
} }
} }
// 删除相关边 // 删除相关边
graph.edges = graph.edges.filter(e => e.fromId !== nodeId && e.toId !== nodeId); graph.edges = graph.edges.filter(
(e) => e.fromId !== nodeId && e.toId !== nodeId,
);
// 删除节点本身 // 删除节点本身
graph.nodes = graph.nodes.filter(n => n.id !== nodeId); graph.nodes = graph.nodes.filter((n) => n.id !== nodeId);
return true; return true;
} }
@@ -169,9 +171,9 @@ export function removeNode(graph, nodeId) {
* @returns {object[]} * @returns {object[]}
*/ */
export function getActiveNodes(graph, typeFilter = null) { export function getActiveNodes(graph, typeFilter = null) {
let nodes = graph.nodes.filter(n => !n.archived); let nodes = graph.nodes.filter((n) => !n.archived);
if (typeFilter) { if (typeFilter) {
nodes = nodes.filter(n => n.type === typeFilter); nodes = nodes.filter((n) => n.type === typeFilter);
} }
return nodes; return nodes;
} }
@@ -184,9 +186,17 @@ export function getActiveNodes(graph, typeFilter = null) {
* @param {string} primaryKeyField - 主键字段名(默认 'name' * @param {string} primaryKeyField - 主键字段名(默认 'name'
* @returns {object|null} * @returns {object|null}
*/ */
export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = 'name') { export function findLatestNode(
graph,
type,
primaryKeyValue,
primaryKeyField = "name",
) {
const candidates = graph.nodes.filter( const candidates = graph.nodes.filter(
n => n.type === type && !n.archived && n.fields[primaryKeyField] === primaryKeyValue, (n) =>
n.type === type &&
!n.archived &&
n.fields[primaryKeyField] === primaryKeyValue,
); );
if (candidates.length === 0) return null; if (candidates.length === 0) return null;
return candidates.sort((a, b) => b.seq - a.seq)[0]; return candidates.sort((a, b) => b.seq - a.seq)[0];
@@ -199,7 +209,13 @@ export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = '
* @param {object} params * @param {object} params
* @returns {object} 新边 * @returns {object} 新边
*/ */
export function createEdge({ fromId, toId, relation = 'related', strength = 0.8, edgeType = 0 }) { export function createEdge({
fromId,
toId,
relation = "related",
strength = 0.8,
edgeType = 0,
}) {
return { return {
id: uuid(), id: uuid(),
fromId, fromId,
@@ -227,13 +243,32 @@ export function addEdge(graph, edge) {
if (!from || !to) return null; if (!from || !to) return null;
if (edge.fromId === edge.toId) return null; if (edge.fromId === edge.toId) return null;
// 检查重复边 const isCurrentEdgeValid = (candidate) => {
if (candidate.invalidAt) return false;
if (candidate.expiredAt) return false;
return true;
};
// 对当前有效边去重;历史边保留,避免历史污染当前检索
const existing = graph.edges.find( const existing = graph.edges.find(
e => e.fromId === edge.fromId && e.toId === edge.toId && e.relation === edge.relation, (e) =>
e.fromId === edge.fromId &&
e.toId === edge.toId &&
e.relation === edge.relation &&
isCurrentEdgeValid(e),
); );
if (existing) { if (existing) {
// 更新已有边的强度 existing.strength = Math.max(existing.strength, edge.strength ?? 0);
existing.strength = Math.max(existing.strength, edge.strength); existing.validAt = Math.max(
existing.validAt || 0,
edge.validAt || Date.now(),
);
if (edge.invalidAt) {
existing.invalidAt = edge.invalidAt;
}
if (edge.expiredAt) {
existing.expiredAt = edge.expiredAt;
}
return existing; return existing;
} }
@@ -248,7 +283,7 @@ export function addEdge(graph, edge) {
* @returns {boolean} * @returns {boolean}
*/ */
export function removeEdge(graph, edgeId) { export function removeEdge(graph, edgeId) {
const idx = graph.edges.findIndex(e => e.id === edgeId); const idx = graph.edges.findIndex((e) => e.id === edgeId);
if (idx === -1) return false; if (idx === -1) return false;
graph.edges.splice(idx, 1); graph.edges.splice(idx, 1);
return true; return true;
@@ -261,7 +296,7 @@ export function removeEdge(graph, edgeId) {
* @returns {object[]} * @returns {object[]}
*/ */
export function getOutEdges(graph, nodeId) { 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[]} * @returns {object[]}
*/ */
export function getInEdges(graph, nodeId) { 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[]} * @returns {object[]}
*/ */
export function getNodeEdges(graph, nodeId) { 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);
} }
// ==================== 查询辅助 ==================== // ==================== 查询辅助 ====================
@@ -295,7 +330,8 @@ export function buildAdjacencyMap(graph) {
const adj = new Map(); const adj = new Map();
for (const edge of graph.edges) { for (const edge of graph.edges) {
// 正向 if (!isEdgeActive(edge)) continue;
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []); if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
adj.get(edge.fromId).push({ adj.get(edge.fromId).push({
targetId: edge.toId, targetId: edge.toId,
@@ -303,7 +339,6 @@ export function buildAdjacencyMap(graph) {
edgeType: edge.edgeType, edgeType: edge.edgeType,
}); });
// 反向(图扩散是双向的)
if (!adj.has(edge.toId)) adj.set(edge.toId, []); if (!adj.has(edge.toId)) adj.set(edge.toId, []);
adj.get(edge.toId).push({ adj.get(edge.toId).push({
targetId: edge.fromId, targetId: edge.fromId,
@@ -323,14 +358,10 @@ export function buildAdjacencyMap(graph) {
*/ */
export function buildTemporalAdjacencyMap(graph) { export function buildTemporalAdjacencyMap(graph) {
const adj = new Map(); const adj = new Map();
const now = Date.now();
for (const edge of graph.edges) { for (const edge of graph.edges) {
// 跳过已失效的边 if (!isEdgeActive(edge)) continue;
if (edge.invalidAt && edge.invalidAt <= now) continue;
if (edge.expiredAt) continue;
// 正向
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []); if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
adj.get(edge.fromId).push({ adj.get(edge.fromId).push({
targetId: edge.toId, targetId: edge.toId,
@@ -338,7 +369,6 @@ export function buildTemporalAdjacencyMap(graph) {
edgeType: edge.edgeType, edgeType: edge.edgeType,
}); });
// 反向
if (!adj.has(edge.toId)) adj.set(edge.toId, []); if (!adj.has(edge.toId)) adj.set(edge.toId, []);
adj.get(edge.toId).push({ adj.get(edge.toId).push({
targetId: edge.fromId, targetId: edge.fromId,
@@ -350,13 +380,23 @@ export function buildTemporalAdjacencyMap(graph) {
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;
}
/** /**
* 将边标记为失效(不删除,保留历史) * 将边标记为失效(不删除,保留历史)
* @param {object} edge * @param {object} edge
*/ */
export function invalidateEdge(edge) { export function invalidateEdge(edge) {
if (!edge) return;
if (!edge.invalidAt) {
edge.invalidAt = Date.now(); edge.invalidAt = Date.now();
} }
}
/** /**
* 获取图的统计信息 * 获取图的统计信息
@@ -364,8 +404,8 @@ export function invalidateEdge(edge) {
* @returns {object} * @returns {object}
*/ */
export function getGraphStats(graph) { export function getGraphStats(graph) {
const activeNodes = graph.nodes.filter(n => !n.archived); const activeNodes = graph.nodes.filter((n) => !n.archived);
const archivedNodes = graph.nodes.filter(n => n.archived); const archivedNodes = graph.nodes.filter((n) => n.archived);
const typeCounts = {}; const typeCounts = {};
for (const node of activeNodes) { for (const node of activeNodes) {
typeCounts[node.type] = (typeCounts[node.type] || 0) + 1; typeCounts[node.type] = (typeCounts[node.type] || 0) + 1;
@@ -399,37 +439,72 @@ export function serializeGraph(graph) {
*/ */
export function deserializeGraph(json) { export function deserializeGraph(json) {
try { try {
const data = typeof json === 'string' ? JSON.parse(json) : json; const data = typeof json === "string" ? JSON.parse(json) : json;
if (!data || data.version === undefined) { if (!data || data.version === undefined) {
return createEmptyGraph(); return createEmptyGraph();
} }
// 版本迁移
if (data.version < GRAPH_VERSION) { if (data.version < GRAPH_VERSION) {
console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`); console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`);
// v1→v2 迁移:给旧边补充时序字段
if (data.version < 2 && data.edges) { if (data.version < 2 && data.edges) {
for (const edge of data.edges) { for (const edge of data.edges) {
if (edge.validAt === undefined) edge.validAt = edge.createdTime || Date.now(); if (edge.validAt === undefined)
edge.validAt = edge.createdTime || Date.now();
if (edge.invalidAt === undefined) edge.invalidAt = null; if (edge.invalidAt === undefined) edge.invalidAt = null;
if (edge.expiredAt === undefined) edge.expiredAt = 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.version = GRAPH_VERSION;
} }
// 确保字段完整 data.nodes = (data.nodes || []).map((node) => {
data.nodes = data.nodes || []; const seq = Number.isFinite(node.seq) ? node.seq : 0;
data.edges = data.edges || []; return {
data.lastProcessedSeq = data.lastProcessedSeq || 0; level: 0,
data.lastRecallResult = data.lastRecallResult || null; 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; return data;
} catch (e) { } catch (e) {
console.error('[ST-BME] 图反序列化失败:', e); console.error("[ST-BME] 图反序列化失败:", e);
return createEmptyGraph(); return createEmptyGraph();
} }
} }
@@ -442,7 +517,7 @@ export function deserializeGraph(json) {
export function exportGraph(graph) { export function exportGraph(graph) {
const exportData = { const exportData = {
...graph, ...graph,
nodes: graph.nodes.map(n => ({ ...n, embedding: null })), nodes: graph.nodes.map((n) => ({ ...n, embedding: null })),
}; };
return JSON.stringify(exportData, null, 2); return JSON.stringify(exportData, null, 2);
} }

144
index.js
View File

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

View File

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

View File

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

189
schema.js
View File

@@ -5,8 +5,8 @@
* 压缩模式 * 压缩模式
*/ */
export const COMPRESSION_MODE = { export const COMPRESSION_MODE = {
NONE: 'none', NONE: "none",
HIERARCHICAL: 'hierarchical', HIERARCHICAL: "hierarchical",
}; };
/** /**
@@ -23,13 +23,17 @@ export const COMPRESSION_MODE = {
*/ */
export const DEFAULT_NODE_SCHEMA = [ export const DEFAULT_NODE_SCHEMA = [
{ {
id: 'event', id: "event",
label: '事件', label: "事件",
tableName: 'event_table', tableName: "event_table",
columns: [ columns: [
{ name: 'summary', hint: '事件摘要,包含因果关系和结果', required: true }, { name: "summary", hint: "事件摘要,包含因果关系和结果", required: true },
{ name: 'participants', hint: '参与角色名,逗号分隔', required: false }, { name: "participants", hint: "参与角色名,逗号分隔", required: false },
{ name: 'status', hint: '事件状态ongoing/resolved/blocked', required: false }, {
name: "status",
hint: "事件状态ongoing/resolved/blocked",
required: false,
},
], ],
alwaysInject: true, alwaysInject: true,
latestOnly: false, latestOnly: false,
@@ -40,20 +44,21 @@ export const DEFAULT_NODE_SCHEMA = [
fanIn: 3, fanIn: 3,
maxDepth: 10, maxDepth: 10,
keepRecentLeaves: 6, keepRecentLeaves: 6,
instruction: '将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。', instruction:
"将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。",
}, },
}, },
{ {
id: 'character', id: "character",
label: '角色', label: "角色",
tableName: 'character_table', tableName: "character_table",
columns: [ columns: [
{ name: 'name', hint: '角色名(仅规范名称)', required: true }, { name: "name", hint: "角色名(仅规范名称)", required: true },
{ name: 'traits', hint: '稳定的性格特征和外貌标记', required: false }, { name: "traits", hint: "稳定的性格特征和外貌标记", required: false },
{ name: 'state', hint: '当前状态或处境', required: false }, { name: "state", hint: "当前状态或处境", required: false },
{ name: 'goal', hint: '当前目标或动机', required: false }, { name: "goal", hint: "当前目标或动机", required: false },
{ name: 'inventory', hint: '携带或拥有的关键物品', required: false }, { name: "inventory", hint: "携带或拥有的关键物品", required: false },
{ name: 'core_note', hint: '值得长期记住的关键备注', required: false }, { name: "core_note", hint: "值得长期记住的关键备注", required: false },
], ],
alwaysInject: false, alwaysInject: false,
latestOnly: true, latestOnly: true,
@@ -64,18 +69,18 @@ export const DEFAULT_NODE_SCHEMA = [
fanIn: 0, fanIn: 0,
maxDepth: 0, maxDepth: 0,
keepRecentLeaves: 0, keepRecentLeaves: 0,
instruction: '', instruction: "",
}, },
}, },
{ {
id: 'location', id: "location",
label: '地点', label: "地点",
tableName: 'location_table', tableName: "location_table",
columns: [ columns: [
{ name: 'name', hint: '地点名称(仅规范名称)', required: true }, { name: "name", hint: "地点名称(仅规范名称)", required: true },
{ name: 'state', hint: '当前状态或环境条件', required: false }, { name: "state", hint: "当前状态或环境条件", required: false },
{ name: 'features', hint: '重要特征、资源或服务', required: false }, { name: "features", hint: "重要特征、资源或服务", required: false },
{ name: 'danger', hint: '危险等级或威胁', required: false }, { name: "danger", hint: "危险等级或威胁", required: false },
], ],
alwaysInject: false, alwaysInject: false,
latestOnly: true, latestOnly: true,
@@ -86,18 +91,22 @@ export const DEFAULT_NODE_SCHEMA = [
fanIn: 0, fanIn: 0,
maxDepth: 0, maxDepth: 0,
keepRecentLeaves: 0, keepRecentLeaves: 0,
instruction: '', instruction: "",
}, },
}, },
{ {
id: 'rule', id: "rule",
label: '规则', label: "规则",
tableName: 'rule_table', tableName: "rule_table",
columns: [ columns: [
{ name: 'title', hint: '简短规则名', required: true }, { name: "title", hint: "简短规则名", required: true },
{ name: 'constraint', hint: '不可违反的规则内容', required: true }, { name: "constraint", hint: "不可违反的规则内容", required: true },
{ name: 'scope', hint: '适用范围/场景', required: false }, { name: "scope", hint: "适用范围/场景", required: false },
{ name: 'status', hint: '当前有效性active/suspended/revoked', required: false }, {
name: "status",
hint: "当前有效性active/suspended/revoked",
required: false,
},
], ],
alwaysInject: true, alwaysInject: true,
latestOnly: false, latestOnly: false,
@@ -108,17 +117,21 @@ export const DEFAULT_NODE_SCHEMA = [
fanIn: 0, fanIn: 0,
maxDepth: 0, maxDepth: 0,
keepRecentLeaves: 0, keepRecentLeaves: 0,
instruction: '', instruction: "",
}, },
}, },
{ {
id: 'thread', id: "thread",
label: '主线', label: "主线",
tableName: 'thread_table', tableName: "thread_table",
columns: [ columns: [
{ name: 'title', hint: '主线名称', required: true }, { name: "title", hint: "主线名称", required: true },
{ name: 'summary', hint: '当前进展摘要', required: false }, { name: "summary", hint: "当前进展摘要", required: false },
{ name: 'status', hint: '状态active/completed/abandoned', required: false }, {
name: "status",
hint: "状态active/completed/abandoned",
required: false,
},
], ],
alwaysInject: true, alwaysInject: true,
latestOnly: false, latestOnly: false,
@@ -129,17 +142,21 @@ export const DEFAULT_NODE_SCHEMA = [
fanIn: 3, fanIn: 3,
maxDepth: 5, maxDepth: 5,
keepRecentLeaves: 3, keepRecentLeaves: 3,
instruction: '将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。', instruction: "将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。",
}, },
}, },
// ====== v2 新增节点类型 ====== // ====== v2 新增节点类型 ======
{ {
id: 'synopsis', id: "synopsis",
label: '全局概要', label: "全局概要",
tableName: 'synopsis_table', tableName: "synopsis_table",
columns: [ columns: [
{ name: 'summary', hint: '当前故事的全局概要(前情提要)', required: true }, {
{ name: 'scope', hint: '概要覆盖的楼层范围', required: false }, name: "summary",
hint: "当前故事的全局概要(前情提要)",
required: true,
},
{ name: "scope", hint: "概要覆盖的楼层范围", required: false },
], ],
alwaysInject: true, // 常驻注入MemoRAG 启发) alwaysInject: true, // 常驻注入MemoRAG 启发)
latestOnly: true, // 只保留最新版本 latestOnly: true, // 只保留最新版本
@@ -150,17 +167,17 @@ export const DEFAULT_NODE_SCHEMA = [
fanIn: 0, fanIn: 0,
maxDepth: 0, maxDepth: 0,
keepRecentLeaves: 0, keepRecentLeaves: 0,
instruction: '', instruction: "",
}, },
}, },
{ {
id: 'reflection', id: "reflection",
label: '反思', label: "反思",
tableName: 'reflection_table', tableName: "reflection_table",
columns: [ columns: [
{ name: 'insight', hint: '对角色行为或情节的元认知反思', required: true }, { name: "insight", hint: "对角色行为或情节的元认知反思", required: true },
{ name: 'trigger', hint: '触发反思的事件/矛盾', required: false }, { name: "trigger", hint: "触发反思的事件/矛盾", required: false },
{ name: 'suggestion', hint: '对后续叙事的建议', required: false }, { name: "suggestion", hint: "对后续叙事的建议", required: false },
], ],
alwaysInject: false, // 需要被召回 alwaysInject: false, // 需要被召回
latestOnly: false, latestOnly: false,
@@ -171,7 +188,7 @@ export const DEFAULT_NODE_SCHEMA = [
fanIn: 3, fanIn: 3,
maxDepth: 3, maxDepth: 3,
keepRecentLeaves: 3, keepRecentLeaves: 3,
instruction: '将反思条目合并为高层次的叙事指导原则。', instruction: "将反思条目合并为高层次的叙事指导原则。",
}, },
}, },
]; ];
@@ -180,14 +197,14 @@ export const DEFAULT_NODE_SCHEMA = [
* 规范化的关系类型 * 规范化的关系类型
*/ */
export const RELATION_TYPES = [ export const RELATION_TYPES = [
'related', // 一般关联 "related", // 一般关联
'involved_in', // 参与事件 "involved_in", // 参与事件
'occurred_at', // 发生于地点 "occurred_at", // 发生于地点
'advances', // 推进主线 "advances", // 推进主线
'updates', // 更新实体状态 "updates", // 更新实体状态
'contradicts', // 矛盾/冲突(用于抑制边) "contradicts", // 矛盾/冲突(用于抑制边)
'evolves', // A-MEM 进化链接(新→旧) "evolves", // A-MEM 进化链接(新→旧)
'temporal_update', // 时序更新Graphiti新状态替代旧状态 "temporal_update", // 时序更新Graphiti新状态替代旧状态
]; ];
/** /**
@@ -199,7 +216,7 @@ export function validateSchema(schema) {
const errors = []; const errors = [];
if (!Array.isArray(schema) || schema.length === 0) { if (!Array.isArray(schema) || schema.length === 0) {
errors.push('Schema 必须是非空数组'); errors.push("Schema 必须是非空数组");
return { valid: false, errors }; return { valid: false, errors };
} }
@@ -207,8 +224,13 @@ export function validateSchema(schema) {
const tableNames = new Set(); const tableNames = new Set();
for (const type of schema) { for (const type of schema) {
if (!type.id || typeof type.id !== 'string') { if (!type || typeof type !== "object") {
errors.push('每种类型必须有 id'); errors.push("Schema 类型定义必须是对象");
continue;
}
if (!type.id || typeof type.id !== "string") {
errors.push("每种类型必须有 id");
continue; continue;
} }
@@ -217,7 +239,11 @@ export function validateSchema(schema) {
} }
ids.add(type.id); ids.add(type.id);
if (!type.tableName || typeof type.tableName !== 'string') { if (!type.label || typeof type.label !== "string") {
errors.push(`类型 ${type.id}:缺少 label`);
}
if (!type.tableName || typeof type.tableName !== "string") {
errors.push(`类型 ${type.id}:缺少 tableName`); errors.push(`类型 ${type.id}:缺少 tableName`);
} else if (tableNames.has(type.tableName)) { } else if (tableNames.has(type.tableName)) {
errors.push(`表名重复:${type.tableName}`); errors.push(`表名重复:${type.tableName}`);
@@ -227,12 +253,37 @@ export function validateSchema(schema) {
if (!Array.isArray(type.columns) || type.columns.length === 0) { if (!Array.isArray(type.columns) || type.columns.length === 0) {
errors.push(`类型 ${type.id}:至少需要一个列`); errors.push(`类型 ${type.id}:至少需要一个列`);
continue;
} }
const hasRequired = type.columns?.some(c => c.required); 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) { if (!hasRequired) {
errors.push(`类型 ${type.id}:至少需要一个 required 列`); 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 }; return { valid: errors.length === 0, errors };
@@ -245,5 +296,5 @@ export function validateSchema(schema) {
* @returns {object|null} * @returns {object|null}
*/ */
export function getSchemaType(schema, typeId) { 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" id="st_bme_extract_every"
class="text_pole" class="text_pole"
min="1" min="1"
max="10" max="50"
value="1" value="1"
/> />
</div> </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> </div>
<hr class="st-bme-hr" /> <hr class="st-bme-hr" />
@@ -49,6 +61,30 @@
</label> </label>
</div> </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"> <div class="st-bme-row">
<label for="st_bme_inject_depth">注入深度</label> <label for="st_bme_inject_depth">注入深度</label>
<input <input
@@ -138,6 +174,18 @@
/> />
</div> </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"> <div class="st-bme-row">
<label class="checkbox_label" for="st_bme_precise_conflict"> <label class="checkbox_label" for="st_bme_precise_conflict">
<input type="checkbox" id="st_bme_precise_conflict" /> <input type="checkbox" id="st_bme_precise_conflict" />
@@ -215,6 +263,18 @@
/> />
</div> </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"> <div class="st-bme-row">
<label class="checkbox_label" for="st_bme_sleep_cycle"> <label class="checkbox_label" for="st_bme_sleep_cycle">
<input type="checkbox" id="st_bme_sleep_cycle" /> <input type="checkbox" id="st_bme_sleep_cycle" />
@@ -233,6 +293,17 @@
value="0.5" value="0.5"
/> />
</div> </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"> <div class="st-bme-row">
<label class="checkbox_label" for="st_bme_prob_recall"> <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,101 +1,35 @@
import assert from "node:assert/strict"; 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) { async function loadSmartTriggerDecision() {
const DEFAULT_TRIGGER_KEYWORDS = [ 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,
"发现",
"背叛",
"死亡",
"复活",
"恢复记忆",
"失忆",
"告白",
"暴露",
"秘密",
"计划",
"规则",
"契约",
"位置",
"地点",
"离开",
"来到",
];
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),
); );
if (keywordHits.length > 0) { const fnMatch = source.match(
score += Math.min(2, keywordHits.length); /export function getSmartTriggerDecision\(chat, lastProcessed, settings\) \{[\s\S]*?^\}/m,
reasons.push(`关键词: ${keywordHits.slice(0, 3).join(", ")}`); );
if (!keywordMatch || !fnMatch) {
throw new Error("无法从 index.js 提取 smart trigger 实现");
} }
const customPatterns = String(settings.triggerPatterns || "") const context = vm.createContext({});
.split(/\r?\n|,/) const script = new vm.Script(`
.map((s) => s.trim()) ${keywordMatch[0]}
.filter(Boolean); ${fnMatch[0].replace("export function", "function")}
for (const pattern of customPatterns) { this.getSmartTriggerDecision = getSmartTriggerDecision;
try { `);
const regex = new RegExp(pattern, "i"); script.runInContext(context);
if (regex.test(combinedText)) { return context.getSmartTriggerDecision;
score += 2;
reasons.push(`自定义触发: ${pattern}`);
break;
}
} catch {
// ignore invalid regex
}
} }
const roleSwitchCount = pendingMessages.reduce((count, message, index) => { const getSmartTriggerDecision = await loadSmartTriggerDecision();
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 noTrigger = getSmartTriggerDecision( const noTrigger = getSmartTriggerDecision(
[ [
@@ -129,4 +63,29 @@ const customTrigger = getSmartTriggerDecision(
assert.equal(customTrigger.triggered, true); assert.equal(customTrigger.triggered, true);
assert.ok(customTrigger.reasons.some((r) => r.includes("自定义触发"))); 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"); console.log("smart-trigger tests passed");