feat: add reflective memory and scene reconstruction

This commit is contained in:
Youzini-afk
2026-03-23 12:29:09 +08:00
parent 436715216e
commit 90e942aed9
5 changed files with 1718 additions and 1171 deletions

View File

@@ -21,9 +21,12 @@ ST-BMEST-Bionic-Memory-Ecology是一个运行在 SillyTavern 第三方扩
- **聊天级持久化**:图状态写入当前聊天 `chat_metadata`,可随聊天保存与恢复。
- **自动记忆提取**:按设定频率从最近对话中抽取结构化操作并更新图谱。
- **三阶段召回编排**:支持向量预筛、图扩散排序、混合评分与可选 LLM 精确召回。
- **情景重构召回**:会围绕命中的事件、角色、地点自动补齐相邻场景节点,使注入结果更接近连续剧情记忆。
- **层级压缩**:对事件与主线等类型执行分层摘要,控制图谱膨胀。
- **全局概要节点**:周期性生成 `synopsis` 类型节点,作为长期叙事锚点。
- **记忆进化**:新节点写入后,基于近邻分析回溯更新已有记忆并建立新连接。
- **时序更新追踪增强**:节点更新时会补充 `updates` / `temporal_update` 语义链路,并生成可追踪的状态更新事件。
- **反思条目生成**:支持按提取周期生成 `reflection` 节点,用于沉淀高层叙事结论、关系趋势与后续建议。
- **时序边字段**:关系边携带 `validAt` / `invalidAt` / `expiredAt` 等时间语义字段。
- **导入导出与手动操作入口**:设置面板已提供查看图谱、查看注入、重建、压缩、导入、导出等入口。
@@ -34,6 +37,7 @@ ST-BMEST-Bionic-Memory-Ecology是一个运行在 SillyTavern 第三方扩
- **Mem0 风格精确对照**:新记忆可与近邻旧记忆对照后再决定新增、更新或跳过。
- **认知边界过滤**:可按可见性约束过滤检索结果,适用于“角色不知道的信息不应注入”的场景。
- **交叉检索**:实体命中后沿图边扩展相关事件节点,补充情境上下文。
- **分桶式注入编排**:召回结果会按“当前状态 / 情景事件 / 反思锚点 / 规则约束”分组组织,降低碎片化注入。
- **主动遗忘**:按保留价值归档低价值节点,缓解长期运行后的图谱膨胀。
- **概率触发回忆**:未被主流程命中的高重要性节点有概率被额外召回。
@@ -42,7 +46,6 @@ ST-BMEST-Bionic-Memory-Ecology是一个运行在 SillyTavern 第三方扩
以下方向在现有代码中仅有预留开关、设计痕迹或路线图描述,尚不应视为完整能力:
- **惊奇度分割 / 智能触发提取**
- **反思条目自动生成**
- **完整端到端测试与稳定性验证**
- **图谱可视化面板**
- **自定义 Schema 编辑体验增强**
@@ -173,17 +176,17 @@ SillyTavern/public/scripts/extensions/third-party/ST-BME/
### 增强能力开关
| 能力 | 默认状态 | 当前判断 |
| ------------ | -------- | ----------------- |
| 记忆进化 | 开启 | 已实现 |
| 精确对照 | 开启 | 实验性 |
| 全局概要 | 开启 | 已实现 |
| 认知边界 | 关闭 | 实验性 |
| 交叉检索 | 关闭 | 实验性 |
| 惊奇度分割 | 关闭 | 规划中 / 预留开关 |
| 主动遗忘 | 关闭 | 实验性 |
| 概率触发回忆 | 关闭 | 实验性 |
| 反思条目 | 关闭 | 规划中 / 部分预留 |
| 能力 | 默认状态 | 当前判断 |
| ------------ | -------- | -------------------------- |
| 记忆进化 | 开启 | 已实现 |
| 精确对照 | 开启 | 实验性 |
| 全局概要 | 开启 | 已实现 |
| 认知边界 | 关闭 | 实验性 |
| 交叉检索 | 关闭 | 实验性 |
| 惊奇度分割 | 关闭 | 规划中 / 预留开关 |
| 主动遗忘 | 关闭 | 实验性 |
| 概率触发回忆 | 关闭 | 实验性 |
| 反思条目 | 关闭 | 已实现(建议先小规模验证) |
## 数据模型
@@ -203,15 +206,15 @@ SillyTavern/public/scripts/extensions/third-party/ST-BME/
默认 Schema 定义在 [`schema.js`](ST-BME/schema.js)。当前内置节点类型包括:
| 类型 | 用途 | 当前状态 |
| ------------ | -------------------- | ------------------------------ |
| `event` | 事件、动作、剧情推进 | 已实现 |
| `character` | 角色状态快照 | 已实现 |
| `location` | 地点与环境状态 | 已实现 |
| `rule` | 世界规则、约束与设定 | 已实现 |
| `thread` | 主线与阶段性进度 | 已实现 |
| `synopsis` | 全局前情提要 | 已实现 |
| `reflection` | 反思与元认知记录 | 结构已定义,生成流程未完整落地 |
| 类型 | 用途 | 当前状态 |
| ------------ | -------------------- | ------------------------------------ |
| `event` | 事件、动作、剧情推进 | 已实现 |
| `character` | 角色状态快照 | 已实现 |
| `location` | 地点与环境状态 | 已实现 |
| `rule` | 世界规则、约束与设定 | 已实现 |
| `thread` | 主线与阶段性进度 | 已实现 |
| `synopsis` | 全局前情提要 | 已实现 |
| `reflection` | 反思与元认知记录 | 已实现周期生成,建议结合实际剧情验证 |
### 关系类型
@@ -277,12 +280,14 @@ SillyTavern/public/scripts/extensions/third-party/ST-BME/
- 交叉检索对召回质量的增益
- 主动遗忘的阈值与副作用控制
- 概率触发回忆的叙事收益与噪声控制
- 情景重构召回在大图、复杂多线叙事中的排序稳定性
### 规划中
- 惊奇度驱动的提取触发机制
- 反思节点自动生成闭环
- 更完整的可视化与调试工具
- 智能触发提取与惊奇度分割的进一步增强
- 更系统的 benchmark、回归测试与使用文档
## 路线图

File diff suppressed because it is too large Load Diff

1038
index.js

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
// ST-BME: Prompt 注入模块
// 将检索结果格式化为表格注入到 LLM 上下文中
import { getSchemaType } from './schema.js';
import { getSchemaType } from "./schema.js";
/**
* 将检索结果转换为注入文本
@@ -11,84 +11,121 @@ import { getSchemaType } from './schema.js';
* @returns {string} 注入文本
*/
export function formatInjection(retrievalResult, schema) {
const { coreNodes, recallNodes } = retrievalResult;
const parts = [];
const { coreNodes, recallNodes, groupedRecallNodes } = retrievalResult;
const parts = [];
// ========== Core 常驻注入 ==========
if (coreNodes.length > 0) {
parts.push('[Memory - Core]');
// ========== Core 常驻注入 ==========
if (coreNodes.length > 0) {
parts.push("[Memory - Core]");
// 按类型分组
const grouped = groupByType(coreNodes);
const grouped = groupByType(coreNodes);
for (const [typeId, nodes] of grouped) {
const typeDef = getSchemaType(schema, typeId);
if (!typeDef) continue;
for (const [typeId, nodes] of grouped) {
const typeDef = getSchemaType(schema, typeId);
if (!typeDef) continue;
const table = formatTable(nodes, typeDef);
if (table) parts.push(table);
}
const table = formatTable(nodes, typeDef);
if (table) parts.push(table);
}
}
// ========== Recall 召回注入 ==========
if (recallNodes.length > 0) {
parts.push('');
parts.push('[Memory - Recalled]');
// ========== Recall 召回注入 ==========
if (recallNodes.length > 0) {
parts.push("");
parts.push("[Memory - Recalled]");
const grouped = groupByType(recallNodes);
const buckets = groupedRecallNodes || {
state: recallNodes.filter(
(n) => n.type === "character" || n.type === "location",
),
episodic: recallNodes.filter(
(n) => n.type === "event" || n.type === "thread",
),
reflective: recallNodes.filter(
(n) => n.type === "reflection" || n.type === "synopsis",
),
rule: recallNodes.filter((n) => n.type === "rule"),
other: recallNodes.filter(
(n) =>
![
"character",
"location",
"event",
"thread",
"reflection",
"synopsis",
"rule",
].includes(n.type),
),
};
for (const [typeId, nodes] of grouped) {
const typeDef = getSchemaType(schema, typeId);
if (!typeDef) continue;
appendBucket(parts, "当前状态记忆", buckets.state, schema);
appendBucket(parts, "情景事件记忆", buckets.episodic, schema);
appendBucket(parts, "反思与长期锚点", buckets.reflective, schema);
appendBucket(parts, "规则与约束", buckets.rule, schema);
appendBucket(parts, "其他关联记忆", buckets.other, schema);
}
const table = formatTable(nodes, typeDef);
if (table) parts.push(table);
}
}
return parts.join('\n');
return parts.join("\n");
}
/**
* 按类型分组节点
*/
function groupByType(nodes) {
const map = new Map();
for (const node of nodes) {
if (!map.has(node.type)) map.set(node.type, []);
map.get(node.type).push(node);
}
return map;
const map = new Map();
for (const node of nodes) {
if (!map.has(node.type)) map.set(node.type, []);
map.get(node.type).push(node);
}
return map;
}
function appendBucket(parts, title, nodes, schema) {
if (!nodes || nodes.length === 0) return;
parts.push(`## ${title}`);
const grouped = groupByType(nodes);
for (const [typeId, groupedNodes] of grouped) {
const typeDef = getSchemaType(schema, typeId);
if (!typeDef) continue;
const table = formatTable(groupedNodes, typeDef);
if (table) parts.push(table);
}
}
/**
* 将同类型节点格式化为 Markdown 表格
*/
function formatTable(nodes, typeDef) {
if (nodes.length === 0) return '';
if (nodes.length === 0) return "";
// 确定要展示的列(有实际数据的列)
const activeCols = typeDef.columns.filter(col =>
nodes.some(n => n.fields[col.name]),
);
// 确定要展示的列(有实际数据的列)
const activeCols = typeDef.columns.filter((col) =>
nodes.some((n) => n.fields[col.name]),
);
if (activeCols.length === 0) return '';
if (activeCols.length === 0) return "";
// 表头
const header = `| ${activeCols.map(c => c.name).join(' | ')} |`;
const separator = `| ${activeCols.map(() => '---').join(' | ')} |`;
// 表头
const header = `| ${activeCols.map((c) => c.name).join(" | ")} |`;
const separator = `| ${activeCols.map(() => "---").join(" | ")} |`;
// 数据行
const rows = nodes.map(node => {
const cells = activeCols.map(col => {
const val = node.fields[col.name] || '';
// 转义管道符,限制单元格长度
return String(val).replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 200);
});
return `| ${cells.join(' | ')} |`;
// 数据行
const rows = nodes.map((node) => {
const cells = activeCols.map((col) => {
const val = node.fields[col.name] || "";
// 转义管道符,限制单元格长度
return String(val)
.replace(/\|/g, "\\|")
.replace(/\n/g, " ")
.slice(0, 200);
});
return `| ${cells.join(" | ")} |`;
});
return `${typeDef.tableName}:\n${header}\n${separator}\n${rows.join('\n')}`;
return `${typeDef.tableName}:\n${header}\n${separator}\n${rows.join("\n")}`;
}
/**
@@ -99,9 +136,9 @@ function formatTable(nodes, typeDef) {
* @returns {number} 估算 token 数
*/
export function estimateTokens(injectionText) {
if (!injectionText) return 0;
// 简单估算:中文 2 字符/token英文 4 字符/token
const cnChars = (injectionText.match(/[\u4e00-\u9fff]/g) || []).length;
const otherChars = injectionText.length - cnChars;
return Math.ceil(cnChars / 2 + otherChars / 4);
if (!injectionText) return 0;
// 简单估算:中文 2 字符/token英文 4 字符/token
const cnChars = (injectionText.match(/[\u4e00-\u9fff]/g) || []).length;
const otherChars = injectionText.length - cnChars;
return Math.ceil(cnChars / 2 + otherChars / 4);
}

View File

@@ -2,19 +2,24 @@
// 融合向量预筛PeroCore+ 图扩散PeroCore PEDSA+ 可选 LLM 精确召回
// v2: + 认知边界过滤(RoleRAG) + 双记忆交叉检索(AriGraph) + 概率触发
import { getActiveNodes, buildAdjacencyMap, buildTemporalAdjacencyMap, getNode, getNodeEdges } from './graph.js';
import { propagateActivation, diffuseAndRank } from './diffusion.js';
import { embedText, searchSimilar } from './embedding.js';
import { hybridScore, reinforceAccessBatch } from './dynamics.js';
import { callLLMForJSON } from './llm.js';
import { diffuseAndRank } from "./diffusion.js";
import { hybridScore, reinforceAccessBatch } from "./dynamics.js";
import { embedText, searchSimilar } from "./embedding.js";
import {
buildTemporalAdjacencyMap,
getActiveNodes,
getNode,
getNodeEdges,
} from "./graph.js";
import { callLLMForJSON } from "./llm.js";
/**
* 自适应阈值
*/
const STRATEGY_THRESHOLDS = {
SMALL: 20, // < 20 节点:跳过向量,全图 + LLM
MEDIUM: 200, // 20-200 节点:向量 + 图扩散 + 评分(不调 LLM
// > 200 节点:三层全开
SMALL: 20, // < 20 节点:跳过向量,全图 + LLM
MEDIUM: 200, // 20-200 节点:向量 + 图扩散 + 评分(不调 LLM
// > 200 节点:三层全开
};
/**
@@ -30,213 +35,240 @@ const STRATEGY_THRESHOLDS = {
* @returns {Promise<RetrievalResult>}
*/
export async function retrieve({
graph,
userMessage,
recentMessages = [],
embeddingConfig,
schema,
options = {},
graph,
userMessage,
recentMessages = [],
embeddingConfig,
schema,
options = {},
}) {
const topK = options.topK ?? 15;
const maxRecallNodes = options.maxRecallNodes ?? 8;
const enableLLMRecall = options.enableLLMRecall ?? true;
const weights = options.weights ?? {};
const topK = options.topK ?? 15;
const maxRecallNodes = options.maxRecallNodes ?? 8;
const enableLLMRecall = options.enableLLMRecall ?? true;
const weights = options.weights ?? {};
// v2 options
const enableVisibility = options.enableVisibility ?? false;
const visibilityFilter = options.visibilityFilter ?? null;
const enableCrossRecall = options.enableCrossRecall ?? false;
const enableProbRecall = options.enableProbRecall ?? false;
const probRecallChance = options.probRecallChance ?? 0.15;
// v2 options
const enableVisibility = options.enableVisibility ?? false;
const visibilityFilter = options.visibilityFilter ?? null;
const enableCrossRecall = options.enableCrossRecall ?? false;
const enableProbRecall = options.enableProbRecall ?? false;
const probRecallChance = options.probRecallChance ?? 0.15;
let activeNodes = getActiveNodes(graph);
let activeNodes = getActiveNodes(graph);
// v2 ⑦: 认知边界过滤RoleRAG 启发)
if (enableVisibility && visibilityFilter) {
activeNodes = filterByVisibility(activeNodes, visibilityFilter);
}
// v2 ⑦: 认知边界过滤RoleRAG 启发)
if (enableVisibility && visibilityFilter) {
activeNodes = filterByVisibility(activeNodes, visibilityFilter);
}
const nodeCount = activeNodes.length;
console.log(`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? ' (认知边界已启用)' : ''}`);
const nodeCount = activeNodes.length;
console.log(
`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? " (认知边界已启用)" : ""}`,
);
let vectorResults = [];
let diffusionResults = [];
let useLLM = false;
let vectorResults = [];
let diffusionResults = [];
let useLLM = false;
if (nodeCount === 0) {
return buildResult(graph, [], schema);
}
if (nodeCount === 0) {
return buildResult(graph, [], schema);
}
// ========== 第 1 层:向量预筛 ==========
if (nodeCount >= STRATEGY_THRESHOLDS.SMALL && embeddingConfig?.apiUrl) {
console.log('[ST-BME] 第1层: 向量预筛');
vectorResults = await vectorPreFilter(userMessage, activeNodes, embeddingConfig, topK);
}
// ========== 第 1 层:向量预筛 ==========
if (nodeCount >= STRATEGY_THRESHOLDS.SMALL && embeddingConfig?.apiUrl) {
console.log("[ST-BME] 第1层: 向量预筛");
vectorResults = await vectorPreFilter(
userMessage,
activeNodes,
embeddingConfig,
topK,
);
}
// ========== 第 2 层:图扩散 ==========
if (nodeCount >= STRATEGY_THRESHOLDS.SMALL) {
console.log('[ST-BME] 第2层: PEDSA 图扩散');
const entityAnchors = extractEntityAnchors(userMessage, activeNodes);
// ========== 第 2 层:图扩散 ==========
if (nodeCount >= STRATEGY_THRESHOLDS.SMALL) {
console.log("[ST-BME] 第2层: PEDSA 图扩散");
const entityAnchors = extractEntityAnchors(userMessage, activeNodes);
const seeds = [
...vectorResults.map(v => ({ id: v.nodeId, energy: v.score })),
...entityAnchors.map(a => ({ id: a.nodeId, energy: 2.0 })),
];
const seeds = [
...vectorResults.map((v) => ({ id: v.nodeId, energy: v.score })),
...entityAnchors.map((a) => ({ id: a.nodeId, energy: 2.0 })),
];
// v2 ⑧: 双记忆交叉检索AriGraph 启发)
// 实体锚点命中后,沿边展开关联的情景节点作为额外种子
if (enableCrossRecall && entityAnchors.length > 0) {
for (const anchor of entityAnchors) {
const connectedEdges = getNodeEdges(graph, anchor.nodeId);
for (const edge of connectedEdges) {
if (edge.invalidAt) continue;
const neighborId = edge.fromId === anchor.nodeId ? edge.toId : edge.fromId;
const neighbor = getNode(graph, neighborId);
if (neighbor && !neighbor.archived && neighbor.type === 'event') {
seeds.push({ id: neighborId, energy: 1.5 * edge.strength });
}
}
}
}
// 去重种子
const seedMap = new Map();
for (const s of seeds) {
const existing = seedMap.get(s.id) || 0;
if (s.energy > existing) seedMap.set(s.id, s.energy);
}
const uniqueSeeds = [...seedMap.entries()].map(([id, energy]) => ({ id, energy }));
if (uniqueSeeds.length > 0) {
const adjacencyMap = buildTemporalAdjacencyMap(graph);
diffusionResults = diffuseAndRank(adjacencyMap, uniqueSeeds, {
maxSteps: 2,
decayFactor: 0.6,
topK: 100,
});
// v2 ⑧: 双记忆交叉检索AriGraph 启发)
// 实体锚点命中后,沿边展开关联的情景节点作为额外种子
if (enableCrossRecall && entityAnchors.length > 0) {
for (const anchor of entityAnchors) {
const connectedEdges = getNodeEdges(graph, anchor.nodeId);
for (const edge of connectedEdges) {
if (edge.invalidAt) continue;
const neighborId =
edge.fromId === anchor.nodeId ? edge.toId : edge.fromId;
const neighbor = getNode(graph, neighborId);
if (neighbor && !neighbor.archived && neighbor.type === "event") {
seeds.push({ id: neighborId, energy: 1.5 * edge.strength });
}
}
}
}
// ========== 第 3 层:混合评分 + 可选 LLM 精确 ==========
console.log('[ST-BME] 第3层: 混合评分');
// 构建评分表
const scoreMap = new Map();
// 添加向量得分
for (const v of vectorResults) {
const entry = scoreMap.get(v.nodeId) || { graphScore: 0, vectorScore: 0 };
entry.vectorScore = v.score;
scoreMap.set(v.nodeId, entry);
// 去重种子
const seedMap = new Map();
for (const s of seeds) {
const existing = seedMap.get(s.id) || 0;
if (s.energy > existing) seedMap.set(s.id, s.energy);
}
const uniqueSeeds = [...seedMap.entries()].map(([id, energy]) => ({
id,
energy,
}));
// 添加图扩散得分
for (const d of diffusionResults) {
const entry = scoreMap.get(d.nodeId) || { graphScore: 0, vectorScore: 0 };
entry.graphScore = d.energy;
scoreMap.set(d.nodeId, entry);
if (uniqueSeeds.length > 0) {
const adjacencyMap = buildTemporalAdjacencyMap(graph);
diffusionResults = diffuseAndRank(adjacencyMap, uniqueSeeds, {
maxSteps: 2,
decayFactor: 0.6,
topK: 100,
});
}
}
// 小图模式:所有节点都参与评分
if (nodeCount < STRATEGY_THRESHOLDS.SMALL) {
for (const node of activeNodes) {
if (!scoreMap.has(node.id)) {
scoreMap.set(node.id, { graphScore: 0, vectorScore: 0 });
}
}
// ========== 第 3 层:混合评分 + 可选 LLM 精确 ==========
console.log("[ST-BME] 第3层: 混合评分");
// 构建评分表
const scoreMap = new Map();
// 添加向量得分
for (const v of vectorResults) {
const entry = scoreMap.get(v.nodeId) || { graphScore: 0, vectorScore: 0 };
entry.vectorScore = v.score;
scoreMap.set(v.nodeId, entry);
}
// 添加图扩散得分
for (const d of diffusionResults) {
const entry = scoreMap.get(d.nodeId) || { graphScore: 0, vectorScore: 0 };
entry.graphScore = d.energy;
scoreMap.set(d.nodeId, entry);
}
// 小图模式:所有节点都参与评分
if (nodeCount < STRATEGY_THRESHOLDS.SMALL) {
for (const node of activeNodes) {
if (!scoreMap.has(node.id)) {
scoreMap.set(node.id, { graphScore: 0, vectorScore: 0 });
}
}
}
// 计算混合得分
const scoredNodes = [];
for (const [nodeId, scores] of scoreMap) {
const node = getNode(graph, nodeId);
if (!node || node.archived) continue;
// 计算混合得分
const scoredNodes = [];
for (const [nodeId, scores] of scoreMap) {
const node = getNode(graph, nodeId);
if (!node || node.archived) continue;
const finalScore = hybridScore({
graphScore: scores.graphScore,
vectorScore: scores.vectorScore,
importance: node.importance,
createdTime: node.createdTime,
}, weights);
scoredNodes.push({ nodeId, node, finalScore, ...scores });
}
scoredNodes.sort((a, b) => b.finalScore - a.finalScore);
// 决定是否使用 LLM 精确召回
useLLM = enableLLMRecall && (
nodeCount < STRATEGY_THRESHOLDS.SMALL || // 小图:直接 LLM
nodeCount > STRATEGY_THRESHOLDS.MEDIUM // 大图LLM 精确
const finalScore = hybridScore(
{
graphScore: scores.graphScore,
vectorScore: scores.vectorScore,
importance: node.importance,
createdTime: node.createdTime,
},
weights,
);
let selectedNodeIds;
scoredNodes.push({ nodeId, node, finalScore, ...scores });
}
if (useLLM && nodeCount > 0) {
console.log('[ST-BME] LLM 精确召回');
const candidateNodes = scoredNodes.slice(0, Math.min(30, scoredNodes.length));
selectedNodeIds = await llmRecall(
userMessage,
recentMessages,
candidateNodes,
graph,
schema,
maxRecallNodes,
scoredNodes.sort((a, b) => b.finalScore - a.finalScore);
// 决定是否使用 LLM 精确召回
useLLM =
enableLLMRecall &&
(nodeCount < STRATEGY_THRESHOLDS.SMALL || // 小图:直接 LLM
nodeCount > STRATEGY_THRESHOLDS.MEDIUM); // 大图LLM 精确
let selectedNodeIds;
if (useLLM && nodeCount > 0) {
console.log("[ST-BME] LLM 精确召回");
const candidateNodes = scoredNodes.slice(
0,
Math.min(30, scoredNodes.length),
);
selectedNodeIds = await llmRecall(
userMessage,
recentMessages,
candidateNodes,
graph,
schema,
maxRecallNodes,
);
} else {
// 中等图:直接取 Top-N
selectedNodeIds = scoredNodes.slice(0, topK).map((s) => s.nodeId);
}
selectedNodeIds = reconstructSceneNodeIds(graph, selectedNodeIds, topK + 6);
// 访问强化
const selectedNodes = selectedNodeIds
.map((id) => getNode(graph, id))
.filter(Boolean);
reinforceAccessBatch(selectedNodes);
console.log(`[ST-BME] 检索完成: 选中 ${selectedNodeIds.length} 个节点`);
// v2 ⑧: 概率触发回忆
// 未被选中的高重要性节点有概率随机激活
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",
);
for (const c of candidates) {
if (Math.random() < probRecallChance) {
selectedNodeIds.push(c.id);
console.log(
`[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`,
);
} else {
// 中等图:直接取 Top-N
selectedNodeIds = scoredNodes
.slice(0, topK)
.map(s => s.nodeId);
}
}
}
// 访问强化
const selectedNodes = selectedNodeIds
.map(id => getNode(graph, id))
.filter(Boolean);
selectedNodeIds = uniqueNodeIds(selectedNodeIds);
reinforceAccessBatch(selectedNodes);
console.log(`[ST-BME] 检索完成: 选中 ${selectedNodeIds.length} 个节点`);
// v2 ⑧: 概率触发回忆
// 未被选中的高重要性节点有概率随机激活
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',
);
for (const c of candidates) {
if (Math.random() < probRecallChance) {
selectedNodeIds.push(c.id);
console.log(`[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`);
}
}
}
return buildResult(graph, selectedNodeIds, schema);
return buildResult(graph, selectedNodeIds, schema);
}
/**
* 向量预筛选
*/
async function vectorPreFilter(userMessage, activeNodes, embeddingConfig, topK) {
try {
const queryVec = await embedText(userMessage, embeddingConfig);
if (!queryVec) return [];
async function vectorPreFilter(
userMessage,
activeNodes,
embeddingConfig,
topK,
) {
try {
const queryVec = await embedText(userMessage, embeddingConfig);
if (!queryVec) return [];
const candidates = activeNodes
.filter(n => n.embedding)
.map(n => ({ nodeId: n.id, embedding: n.embedding }));
const candidates = activeNodes
.filter((n) => n.embedding)
.map((n) => ({ nodeId: n.id, embedding: n.embedding }));
return searchSimilar(queryVec, candidates, topK);
} catch (e) {
console.error('[ST-BME] 向量预筛失败:', e);
return [];
}
return searchSimilar(queryVec, candidates, topK);
} catch (e) {
console.error("[ST-BME] 向量预筛失败:", e);
return [];
}
}
/**
@@ -244,75 +276,88 @@ async function vectorPreFilter(userMessage, activeNodes, embeddingConfig, topK)
* 从用户消息中提取名词/实体,匹配图中的节点名称
*/
function extractEntityAnchors(userMessage, activeNodes) {
const anchors = [];
const anchors = [];
for (const node of activeNodes) {
// 检查 name 字段
const name = node.fields?.name;
if (name && userMessage.includes(name)) {
anchors.push({ nodeId: node.id, entity: name });
continue;
}
// 检查 title 字段
const title = node.fields?.title;
if (title && userMessage.includes(title)) {
anchors.push({ nodeId: node.id, entity: title });
}
for (const node of activeNodes) {
// 检查 name 字段
const name = node.fields?.name;
if (name && userMessage.includes(name)) {
anchors.push({ nodeId: node.id, entity: name });
continue;
}
return anchors;
// 检查 title 字段
const title = node.fields?.title;
if (title && userMessage.includes(title)) {
anchors.push({ nodeId: node.id, entity: title });
}
}
return anchors;
}
/**
* LLM 精确召回
*/
async function llmRecall(userMessage, recentMessages, candidates, graph, schema, maxNodes) {
const contextStr = recentMessages.join('\n---\n');
const candidateDescriptions = candidates.map(c => {
const node = c.node;
const typeDef = schema.find(s => s.id === node.type);
const typeLabel = typeDef?.label || node.type;
const fieldsStr = Object.entries(node.fields)
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
return `[${node.id}] 类型=${typeLabel}, ${fieldsStr} (评分=${c.finalScore.toFixed(3)})`;
}).join('\n');
async function llmRecall(
userMessage,
recentMessages,
candidates,
graph,
schema,
maxNodes,
) {
const contextStr = recentMessages.join("\n---\n");
const candidateDescriptions = candidates
.map((c) => {
const node = c.node;
const typeDef = schema.find((s) => s.id === node.type);
const typeLabel = typeDef?.label || node.type;
const fieldsStr = Object.entries(node.fields)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
return `[${node.id}] 类型=${typeLabel}, ${fieldsStr} (评分=${c.finalScore.toFixed(3)})`;
})
.join("\n");
const systemPrompt = [
'你是一个记忆召回分析器。',
'根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。',
'优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。',
`最多选择 ${maxNodes} 个节点。`,
'输出严格的 JSON 格式:',
'{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}',
].join('\n');
const systemPrompt = [
"你是一个记忆召回分析器。",
"根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。",
"优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。",
`最多选择 ${maxNodes} 个节点。`,
"输出严格的 JSON 格式:",
'{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}',
].join("\n");
const userPrompt = [
'## 最近对话上下文',
contextStr || '(无)',
'',
'## 用户最新输入',
userMessage,
'',
'## 候选记忆节点',
candidateDescriptions,
'',
'请选择最相关的节点并输出 JSON。',
].join('\n');
const userPrompt = [
"## 最近对话上下文",
contextStr || "(无)",
"",
"## 用户最新输入",
userMessage,
"",
"## 候选记忆节点",
candidateDescriptions,
"",
"请选择最相关的节点并输出 JSON。",
].join("\n");
const result = await callLLMForJSON({ systemPrompt, userPrompt, maxRetries: 1 });
const result = await callLLMForJSON({
systemPrompt,
userPrompt,
maxRetries: 1,
});
if (result?.selected_ids && Array.isArray(result.selected_ids)) {
// 校验 ID 有效性
const validIds = result.selected_ids.filter(
id => candidates.some(c => c.nodeId === id),
);
return validIds;
}
if (result?.selected_ids && Array.isArray(result.selected_ids)) {
// 校验 ID 有效性
const validIds = result.selected_ids.filter((id) =>
candidates.some((c) => c.nodeId === id),
);
return validIds;
}
// LLM 失败时回退到纯评分排序
return candidates.slice(0, maxNodes).map(c => c.nodeId);
// LLM 失败时回退到纯评分排序
return candidates.slice(0, maxNodes).map((c) => c.nodeId);
}
// ==================== v2 辅助函数 ====================
@@ -325,20 +370,20 @@ async function llmRecall(userMessage, recentMessages, candidates, graph, schema,
* @returns {object[]}
*/
function filterByVisibility(nodes, characterName) {
return nodes.filter(node => {
// 没有 visibility 字段 → 对所有人可见
if (!node.fields?.visibility) return true;
// visibility 是数组 → 检查当前角色是否在列表中
if (Array.isArray(node.fields.visibility)) {
return node.fields.visibility.includes(characterName);
}
// visibility 是字符串(逗号分隔)→ 解析后检查
if (typeof node.fields.visibility === 'string') {
const visibleTo = node.fields.visibility.split(',').map(s => s.trim());
return visibleTo.includes(characterName) || visibleTo.includes('*');
}
return true;
});
return nodes.filter((node) => {
// 没有 visibility 字段 → 对所有人可见
if (!node.fields?.visibility) return true;
// visibility 是数组 → 检查当前角色是否在列表中
if (Array.isArray(node.fields.visibility)) {
return node.fields.visibility.includes(characterName);
}
// visibility 是字符串(逗号分隔)→ 解析后检查
if (typeof node.fields.visibility === "string") {
const visibleTo = node.fields.visibility.split(",").map((s) => s.trim());
return visibleTo.includes(characterName) || visibleTo.includes("*");
}
return true;
});
}
/**
@@ -346,41 +391,144 @@ function filterByVisibility(nodes, characterName) {
* 分离常驻注入Core和召回注入Recall
*/
function buildResult(graph, selectedNodeIds, schema) {
const coreNodes = []; // 常驻注入
const recallNodes = []; // 召回注入
const coreNodes = []; // 常驻注入
const recallNodes = []; // 召回注入
// 常驻注入节点alwaysInject=true 的类型)
const alwaysInjectTypes = new Set(
schema.filter(s => s.alwaysInject).map(s => s.id),
);
// 常驻注入节点alwaysInject=true 的类型)
const alwaysInjectTypes = new Set(
schema.filter((s) => s.alwaysInject).map((s) => s.id),
);
const activeNodes = getActiveNodes(graph);
const activeNodes = getActiveNodes(graph);
for (const node of activeNodes) {
if (alwaysInjectTypes.has(node.type)) {
coreNodes.push(node);
}
for (const node of activeNodes) {
if (alwaysInjectTypes.has(node.type)) {
coreNodes.push(node);
}
}
// 召回注入节点
const selectedSet = new Set(selectedNodeIds);
for (const nodeId of selectedNodeIds) {
const node = getNode(graph, nodeId);
if (!node) continue;
// 已在 Core 中的不重复添加
if (!alwaysInjectTypes.has(node.type)) {
recallNodes.push(node);
}
for (const nodeId of selectedNodeIds) {
const node = getNode(graph, nodeId);
if (!node) continue;
if (!alwaysInjectTypes.has(node.type)) {
recallNodes.push(node);
}
}
return {
coreNodes,
recallNodes,
selectedNodeIds: [...selectedNodeIds],
stats: {
totalActive: activeNodes.length,
coreCount: coreNodes.length,
recallCount: recallNodes.length,
},
};
const groupedRecallNodes = groupRecallNodes(recallNodes);
return {
coreNodes,
recallNodes,
groupedRecallNodes,
selectedNodeIds: [...selectedNodeIds],
stats: {
totalActive: activeNodes.length,
coreCount: coreNodes.length,
recallCount: recallNodes.length,
episodicCount: groupedRecallNodes.episodic.length,
stateCount: groupedRecallNodes.state.length,
reflectiveCount: groupedRecallNodes.reflective.length,
ruleCount: groupedRecallNodes.rule.length,
},
};
}
function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) {
const selected = [];
const seen = new Set();
function push(nodeId) {
if (!nodeId || seen.has(nodeId)) return;
const node = getNode(graph, nodeId);
if (!node || node.archived) return;
seen.add(nodeId);
selected.push(nodeId);
}
for (const nodeId of seedNodeIds) {
push(nodeId);
const node = getNode(graph, nodeId);
if (!node) continue;
if (node.type === "event") {
expandEventScene(graph, node, push);
} else if (node.type === "character" || node.type === "location") {
const relatedEvents = getNodeEdges(graph, node.id)
.filter((e) => !e.invalidAt)
.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)
.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);
for (const edge of edges) {
const neighborId = edge.fromId === eventNode.id ? edge.toId : edge.fromId;
const neighbor = getNode(graph, neighborId);
if (!neighbor || neighbor.archived) continue;
if (
neighbor.type === "character" ||
neighbor.type === "location" ||
neighbor.type === "thread" ||
neighbor.type === "reflection"
) {
push(neighbor.id);
}
}
const adjacentEvents = getTemporalNeighborEvents(
graph,
eventNode.seq,
eventNode.id,
);
for (const neighborEvent of adjacentEvents) {
push(neighborEvent.id);
}
}
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))
.slice(0, 2);
}
function groupRecallNodes(nodes) {
return {
state: nodes.filter((n) => n.type === "character" || n.type === "location"),
episodic: nodes.filter((n) => n.type === "event" || n.type === "thread"),
reflective: nodes.filter(
(n) => n.type === "reflection" || n.type === "synopsis",
),
rule: nodes.filter((n) => n.type === "rule"),
other: nodes.filter(
(n) =>
![
"character",
"location",
"event",
"thread",
"reflection",
"synopsis",
"rule",
].includes(n.type),
),
};
}
function uniqueNodeIds(nodeIds) {
return [...new Set(nodeIds)];
}