Reorganize modules into layered directories

This commit is contained in:
Youzini-afk
2026-04-08 01:17:47 +08:00
parent 59942541ea
commit feec17f3e3
90 changed files with 284 additions and 219 deletions

229
maintenance/chat-history.js Normal file
View File

@@ -0,0 +1,229 @@
// ST-BME: 聊天历史纯函数
// 此模块中的函数均不依赖 index.js 模块级可变状态,
// 可被 index.js 及其他模块安全导入。
import { clampInt } from "../ui/ui-status.js";
import { sanitizePlannerMessageText } from "../runtime/planner-tag-utils.js";
import { rollbackBatch } from "../runtime/runtime-state.js";
import { isInManagedHideRange } from "../ui/hide-engine.js";
export function isBmeManagedHiddenMessage(
message,
{ index = null, chat = null } = {},
) {
if (
Number.isFinite(index) &&
index > 0 &&
isInManagedHideRange(index, chat)
) {
return true;
}
return Boolean(
message?.extra &&
typeof message.extra === "object" &&
message.extra.__st_bme_hide_managed === true,
);
}
export function isSystemMessageForExtraction(
message,
{ index = null, chat = null } = {},
) {
if (!message?.is_system) return false;
if (Number.isFinite(index) && index === 0) return true;
return !isBmeManagedHiddenMessage(message, { index, chat });
}
export function isAssistantChatMessage(
message,
{ index = null, chat = null } = {},
) {
return (
Boolean(message) &&
!message.is_user &&
!isSystemMessageForExtraction(message, { index, chat })
);
}
export function getAssistantTurns(chat) {
const assistantTurns = [];
// 从 index 1 开始index 0 是角色卡首条消息greeting不参与提取
for (let index = 1; index < chat.length; index++) {
if (!isAssistantChatMessage(chat[index], { index, chat })) continue;
if (!String(chat[index]?.mes ?? "").trim()) continue;
assistantTurns.push(index);
}
return assistantTurns;
}
export function getMinExtractableAssistantFloor(chat) {
const assistantTurns = getAssistantTurns(chat);
return assistantTurns.length > 0 ? assistantTurns[0] : null;
}
export function buildExtractionMessages(chat, startIdx, endIdx, settings) {
const contextTurns = clampInt(settings.extractContextTurns, 2, 0, 20);
const contextStart = Math.max(0, startIdx - contextTurns * 2);
const messages = [];
for (
let index = contextStart;
index <= endIdx && index < chat.length;
index++
) {
const msg = chat[index];
if (isSystemMessageForExtraction(msg, { index, chat })) continue;
const content = sanitizePlannerMessageText(msg);
if (!String(content || "").trim()) continue;
messages.push({
seq: index,
role: msg.is_user ? "user" : "assistant",
content,
});
}
return messages;
}
export function getChatIndexForPlayableSeq(chat, playableSeq) {
if (!Array.isArray(chat) || !Number.isFinite(playableSeq)) return null;
let currentSeq = -1;
for (let index = 0; index < chat.length; index++) {
const message = chat[index];
if (isSystemMessageForExtraction(message, { index, chat })) continue;
currentSeq++;
if (currentSeq >= playableSeq) {
return index;
}
}
return chat.length;
}
export function getChatIndexForAssistantSeq(chat, assistantSeq) {
if (!Array.isArray(chat) || !Number.isFinite(assistantSeq)) return null;
let currentSeq = -1;
for (let index = 0; index < chat.length; index++) {
if (!isAssistantChatMessage(chat[index], { index, chat })) continue;
currentSeq++;
if (currentSeq >= assistantSeq) {
return index;
}
}
return chat.length;
}
export function resolveDirtyFloorFromMutationMeta(trigger, primaryArg, meta, chat) {
if (!meta || typeof meta !== "object") return null;
const candidates = [];
const isDeleteTrigger = String(trigger || "").includes("message-deleted");
const minExtractableFloor = getMinExtractableAssistantFloor(chat);
// 删除后 chat 已是收缩后的状态,删除事件携带的 seq 更接近"被删区间起点"
// 因此这里额外向前退一层,避免恢复仍停留在被删楼层对应的旧图谱边界。
if (!isDeleteTrigger && Number.isFinite(meta.messageId)) {
candidates.push({
floor: meta.messageId,
source: `${trigger}-meta`,
});
}
if (Number.isFinite(meta.deletedPlayableSeqFrom)) {
const floor = getChatIndexForPlayableSeq(chat, meta.deletedPlayableSeqFrom);
if (Number.isFinite(floor)) {
candidates.push({
floor: Number.isFinite(minExtractableFloor)
? Math.max(minExtractableFloor, floor - 1)
: Math.max(0, floor - 1),
source: `${trigger}-meta-delete-boundary`,
});
}
}
if (Number.isFinite(meta.deletedAssistantSeqFrom)) {
const floor = getChatIndexForAssistantSeq(
chat,
meta.deletedAssistantSeqFrom,
);
if (Number.isFinite(floor)) {
candidates.push({
floor: Number.isFinite(minExtractableFloor)
? Math.max(minExtractableFloor, floor - 1)
: Math.max(0, floor - 1),
source: `${trigger}-meta-delete-boundary`,
});
}
}
if (!isDeleteTrigger && Number.isFinite(meta.playableSeq)) {
const floor = getChatIndexForPlayableSeq(chat, meta.playableSeq);
if (Number.isFinite(floor)) {
candidates.push({
floor,
source: `${trigger}-meta`,
});
}
}
if (!isDeleteTrigger && Number.isFinite(meta.assistantSeq)) {
const floor = getChatIndexForAssistantSeq(chat, meta.assistantSeq);
if (Number.isFinite(floor)) {
candidates.push({
floor,
source: `${trigger}-meta`,
});
}
}
if (!isDeleteTrigger && Number.isFinite(primaryArg)) {
candidates.push({
floor: primaryArg,
source: `${trigger}-meta`,
});
}
if (candidates.length === 0) return null;
const validCandidates = Number.isFinite(minExtractableFloor)
? candidates.filter((c) => c.floor >= minExtractableFloor)
: candidates;
if (validCandidates.length === 0) return null;
return validCandidates.reduce((earliest, current) =>
current.floor < earliest.floor ? current : earliest,
);
}
export function clampRecoveryStartFloor(chat, floor) {
if (!Number.isFinite(floor)) return floor;
const minExtractableFloor = getMinExtractableAssistantFloor(chat);
if (!Number.isFinite(minExtractableFloor)) {
return floor;
}
return Math.max(floor, minExtractableFloor);
}
export function rollbackAffectedJournals(graph, affectedJournals = []) {
for (let index = affectedJournals.length - 1; index >= 0; index--) {
rollbackBatch(graph, affectedJournals[index]);
}
graph.batchJournal = Array.isArray(graph.batchJournal)
? graph.batchJournal.slice(
0,
Math.max(0, graph.batchJournal.length - affectedJournals.length),
)
: [];
}
export function pruneProcessedMessageHashesFromFloor(graph, fromFloor) {
if (!graph?.historyState?.processedMessageHashes) return;
if (!Number.isFinite(fromFloor)) return;
const hashes = graph.historyState.processedMessageHashes;
for (const key of Object.keys(hashes)) {
if (Number(key) >= fromFloor) {
delete hashes[key];
}
}
}

629
maintenance/compressor.js Normal file
View File

@@ -0,0 +1,629 @@
// ST-BME: 层级压缩引擎
// 超过阈值的节点被 LLM 总结为更高层级的压缩节点
import { debugLog } from "../runtime/debug-logging.js";
import { embedText } from "../vector/embedding.js";
import {
addEdge,
addNode,
createEdge,
createNode,
getActiveNodes,
getNode,
} from "../graph/graph.js";
import { callLLMForJSON } from "../llm/llm.js";
import {
getScopeOwnerKey,
getScopeRegionKey,
normalizeMemoryScope,
} from "../graph/memory-scope.js";
import { ensureEventTitle, getNodeDisplayName } from "../graph/node-labels.js";
import {
buildTaskExecutionDebugContext,
buildTaskLlmPayload,
buildTaskPrompt,
} from "../prompting/prompt-builder.js";
import { getSTContextForPrompt } from "../host/st-context.js";
import { applyTaskRegex } from "../prompting/task-regex.js";
import { isDirectVectorConfig } from "../vector/vector-index.js";
function createAbortError(message = "操作已终止") {
const error = new Error(message);
error.name = "AbortError";
return error;
}
function createTaskLlmDebugContext(promptBuild, regexInput) {
return typeof buildTaskExecutionDebugContext === "function"
? buildTaskExecutionDebugContext(promptBuild, { regexInput })
: null;
}
function resolveTaskPromptPayload(promptBuild, fallbackUserPrompt = "") {
if (typeof buildTaskLlmPayload === "function") {
return buildTaskLlmPayload(promptBuild, fallbackUserPrompt);
}
return {
systemPrompt: String(promptBuild?.systemPrompt || ""),
userPrompt: String(fallbackUserPrompt || ""),
promptMessages: [],
additionalMessages: Array.isArray(promptBuild?.privateTaskMessages)
? promptBuild.privateTaskMessages
: [],
};
}
function throwIfAborted(signal) {
if (signal?.aborted) {
throw signal.reason instanceof Error ? signal.reason : createAbortError();
}
}
function resolveCompressionWindow(compression = {}, force = false) {
const fanIn = Number.isFinite(Number(compression?.fanIn))
? Math.max(2, Number(compression.fanIn))
: 2;
const threshold = force
? fanIn
: Number.isFinite(Number(compression?.threshold))
? Math.max(2, Number(compression.threshold))
: fanIn;
const keepRecent = force
? 0
: Number.isFinite(Number(compression?.keepRecentLeaves))
? Math.max(0, Number(compression.keepRecentLeaves))
: 0;
return {
fanIn,
threshold,
keepRecent,
};
}
function normalizeCompressionFieldValue(value) {
if (value == null) return "";
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
return value
.map((item) => normalizeCompressionFieldValue(item))
.filter(Boolean)
.join("");
}
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return "";
}
}
return String(value).trim();
}
function buildCompressionFallbackSummary(batch = []) {
return batch
.map((node) =>
normalizeCompressionFieldValue(
node?.fields?.summary ||
node?.fields?.title ||
node?.fields?.name ||
node?.fields?.insight ||
getNodeDisplayName(node),
),
)
.filter(Boolean)
.slice(0, 6)
.join("");
}
function normalizeCompressedFields(summaryResult, typeDef, batch = []) {
const rawFields =
summaryResult?.fields &&
typeof summaryResult.fields === "object" &&
!Array.isArray(summaryResult.fields)
? summaryResult.fields
: summaryResult && typeof summaryResult === "object" && !Array.isArray(summaryResult)
? summaryResult
: {};
const columns = Array.isArray(typeDef?.columns) ? typeDef.columns : [];
const normalized = {};
for (const column of columns) {
const key = String(column?.name || "").trim();
if (!key) continue;
const normalizedValue = normalizeCompressionFieldValue(rawFields[key]);
if (normalizedValue) {
normalized[key] = normalizedValue;
}
}
const fallbackSummary = buildCompressionFallbackSummary(batch);
if (!normalized.summary && columns.some((column) => column?.name === "summary")) {
normalized.summary = fallbackSummary || "压缩批次摘要缺失";
}
if (!normalized.insight && columns.some((column) => column?.name === "insight")) {
normalized.insight = fallbackSummary || "压缩批次洞察缺失";
}
if (!normalized.title && columns.some((column) => column?.name === "title")) {
const titled = ensureEventTitle({ title: rawFields?.title, summary: normalized.summary });
normalized.title =
normalizeCompressionFieldValue(titled?.title) ||
normalizeCompressionFieldValue(rawFields?.name) ||
normalizeCompressionFieldValue(batch[batch.length - 1]?.fields?.title) ||
normalizeCompressionFieldValue(batch[batch.length - 1]?.fields?.name) ||
"压缩节点";
}
if (!normalized.name && columns.some((column) => column?.name === "name")) {
normalized.name =
normalizeCompressionFieldValue(rawFields?.title) ||
normalizeCompressionFieldValue(rawFields?.name) ||
normalizeCompressionFieldValue(batch[batch.length - 1]?.fields?.name) ||
"压缩节点";
}
return normalized;
}
/**
* 对指定类型执行层级压缩
*
* @param {object} params
* @param {object} params.graph - 当前图状态
* @param {object} params.typeDef - 要压缩的类型定义
* @param {object} params.embeddingConfig - Embedding API 配置
* @param {boolean} [params.force=false] - 忽略阈值强制压缩
* @returns {Promise<{created: number, archived: number}>}
*/
export async function compressType({
graph,
typeDef,
embeddingConfig,
force = false,
customPrompt,
signal,
settings = {},
}) {
const compression = typeDef.compression;
if (!compression || compression.mode !== "hierarchical") {
return { created: 0, archived: 0 };
}
const maxDepth = Number.isFinite(Number(compression.maxDepth))
? Math.max(1, Number(compression.maxDepth))
: 1;
let totalCreated = 0;
let totalArchived = 0;
// 从最低层级开始逐层压缩
for (let level = 0; level < maxDepth; level++) {
throwIfAborted(signal);
const result = await compressLevel({
graph,
typeDef,
level,
embeddingConfig,
force,
customPrompt,
signal,
settings,
});
totalCreated += result.created;
totalArchived += result.archived;
// 如果这一层没有压缩发生,停止
if (result.created === 0) break;
}
return { created: totalCreated, archived: totalArchived };
}
/**
* 压缩特定层级的节点
*/
async function compressLevel({
graph,
typeDef,
level,
embeddingConfig,
force,
customPrompt,
signal,
settings = {},
}) {
const compression = typeDef.compression;
const { fanIn, threshold, keepRecent } = resolveCompressionWindow(
compression,
force,
);
throwIfAborted(signal);
// 获取该层级的活跃叶子节点
const levelNodes = getActiveNodes(graph, typeDef.id)
.filter((n) => n.level === level)
.sort((a, b) => a.seq - b.seq);
let created = 0;
let archived = 0;
for (const group of groupCompressionCandidates(levelNodes)) {
if (force ? group.length < fanIn : group.length <= threshold) {
continue;
}
const compressible = group.slice(0, Math.max(0, group.length - keepRecent));
if (compressible.length < fanIn) {
continue;
}
for (let i = 0; i < compressible.length; i += fanIn) {
const batch = compressible.slice(i, i + fanIn);
if (batch.length < 2) break;
const summaryResult = await summarizeBatch(
batch,
typeDef,
customPrompt,
signal,
settings,
);
if (!summaryResult) continue;
const normalizedFields = normalizeCompressedFields(
summaryResult,
typeDef,
batch,
);
if (Object.keys(normalizedFields).length === 0) {
throw new Error(
`压缩结果缺少可用 fields无法创建 ${typeDef?.label || typeDef?.id || "压缩"} 节点`,
);
}
const compressedNode = createNode({
type: typeDef.id,
fields: normalizedFields,
seq: batch[batch.length - 1].seq,
seqRange: [
batch[0].seqRange?.[0] ?? batch[0].seq,
batch[batch.length - 1].seqRange?.[1] ?? batch[batch.length - 1].seq,
],
importance: Math.max(...batch.map((n) => n.importance)),
scope: normalizeMemoryScope(batch[0]?.scope),
});
compressedNode.level = level + 1;
compressedNode.childIds = batch.map((n) => n.id);
const embeddingText =
normalizeCompressionFieldValue(
normalizedFields.summary ||
normalizedFields.insight ||
normalizedFields.title ||
normalizedFields.name,
) || "";
if (isDirectVectorConfig(embeddingConfig) && embeddingText) {
const vec = await embedText(
embeddingText,
embeddingConfig,
{ signal },
);
if (vec) compressedNode.embedding = Array.from(vec);
}
addNode(graph, compressedNode);
migrateBatchEdges(graph, batch, compressedNode);
created++;
for (const child of batch) {
child.archived = true;
child.parentId = compressedNode.id;
archived++;
}
}
}
return { created, archived };
}
function groupCompressionCandidates(nodes = []) {
const groups = new Map();
for (const node of nodes) {
const normalizedScope = normalizeMemoryScope(node?.scope);
const key =
normalizedScope.layer === "pov"
? [
"pov",
getScopeOwnerKey(normalizedScope) || "owner:none",
node.type || "",
].join("::")
: [
"objective",
getScopeRegionKey(normalizedScope) || "region:global",
node.type || "",
].join("::");
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(node);
}
return [...groups.values()].map((group) =>
group.sort((a, b) => a.seq - b.seq),
);
}
function inspectCompressibleGroup(group = [], compression = {}, force = false) {
const { fanIn, threshold, keepRecent } = resolveCompressionWindow(
compression,
force,
);
if (force ? group.length < fanIn : group.length <= threshold) {
return null;
}
const compressible = group.slice(0, Math.max(0, group.length - keepRecent));
if (compressible.length < fanIn) {
return null;
}
return {
candidateCount: compressible.length,
fanIn,
threshold,
keepRecent,
};
}
export function inspectAutoCompressionCandidates(
graph,
schema = [],
force = false,
) {
const safeSchema = Array.isArray(schema) ? schema : [];
for (const typeDef of safeSchema) {
if (typeDef?.compression?.mode !== "hierarchical") continue;
const maxDepth = Number.isFinite(Number(typeDef?.compression?.maxDepth))
? Math.max(1, Number(typeDef.compression.maxDepth))
: 1;
for (let level = 0; level < maxDepth; level++) {
const levelNodes = getActiveNodes(graph, typeDef.id)
.filter((node) => Number(node?.level || 0) === level)
.sort((a, b) => a.seq - b.seq);
for (const group of groupCompressionCandidates(levelNodes)) {
const summary = inspectCompressibleGroup(
group,
typeDef.compression,
force,
);
if (!summary) continue;
return {
hasCandidates: true,
typeId: String(typeDef.id || ""),
level,
candidateCount: summary.candidateCount,
threshold: summary.threshold,
fanIn: summary.fanIn,
keepRecent: summary.keepRecent,
reason: "",
};
}
}
}
return {
hasCandidates: false,
typeId: "",
level: null,
candidateCount: 0,
threshold: 0,
fanIn: 0,
keepRecent: 0,
reason: "已到自动压缩周期,但当前没有达到内部压缩阈值的候选组",
};
}
function migrateBatchEdges(graph, batch, compressedNode) {
const batchIds = new Set(batch.map((node) => node.id));
for (const edge of graph.edges) {
if (edge.invalidAt || edge.expiredAt) continue;
const fromInside = batchIds.has(edge.fromId);
const toInside = batchIds.has(edge.toId);
if (!fromInside && !toInside) continue;
if (fromInside && toInside) continue;
const newFromId = fromInside ? compressedNode.id : edge.fromId;
const newToId = toInside ? compressedNode.id : edge.toId;
if (newFromId === newToId) continue;
if (!getNode(graph, newFromId) || !getNode(graph, newToId)) continue;
const migratedEdge = createEdge({
fromId: newFromId,
toId: newToId,
relation: edge.relation,
strength: edge.strength,
edgeType: edge.edgeType,
scope: edge.scope,
});
migratedEdge.validAt = edge.validAt ?? migratedEdge.validAt;
migratedEdge.invalidAt = edge.invalidAt ?? migratedEdge.invalidAt;
migratedEdge.expiredAt = edge.expiredAt ?? migratedEdge.expiredAt;
addEdge(graph, migratedEdge);
}
}
/**
* 调用 LLM 总结一批节点
*/
async function summarizeBatch(
nodes,
typeDef,
customPrompt,
signal,
settings = {},
) {
const nodeDescriptions = nodes
.map((n, i) => {
const fieldsStr = Object.entries(n.fields)
.filter(([_, v]) => v)
.map(([k, v]) => `${k}: ${v}`)
.join("\n ");
return `节点 ${i + 1} [楼层 ${n.seq}]:\n ${fieldsStr}`;
})
.join("\n\n");
const instruction =
typeDef.compression.instruction || "将以下节点压缩总结为一条精炼记录。";
const compressPromptBuild = await buildTaskPrompt(settings, "compress", {
taskName: "compress",
nodeContent: nodeDescriptions,
candidateNodes: nodeDescriptions,
currentRange: `${nodes[0]?.seq ?? "?"} ~ ${nodes[nodes.length - 1]?.seq ?? "?"}`,
graphStats: `node_count=${nodes.length}, node_type=${typeDef.id}`,
...getSTContextForPrompt(),
});
const compressRegexInput = { entries: [] };
const systemPrompt = applyTaskRegex(
settings,
"compress",
"finalPrompt",
compressPromptBuild.systemPrompt ||
customPrompt ||
[
"你是一个记忆压缩器。将多个同类型节点总结为一条更高层级的压缩节点。",
instruction,
"",
"输出格式为严格 JSON",
`{"fields": {${typeDef.columns.map((c) => `"${c.name}": "..."`).join(", ")}}}`,
"",
"规则:",
"- 保留关键信息:因果关系、不可逆结果、未解决伏笔",
"- 去除重复和低信息密度内容",
"- 压缩后文本应精炼,目标 150 字左右",
].join("\n"),
compressRegexInput,
"system",
);
const userPrompt = `请压缩以下 ${nodes.length} 个 "${typeDef.label}" 节点:\n\n${nodeDescriptions}`;
const promptPayload = resolveTaskPromptPayload(
compressPromptBuild,
userPrompt,
);
const llmSystemPrompt =
Array.isArray(promptPayload.promptMessages) &&
promptPayload.promptMessages.length > 0
? String(promptPayload.systemPrompt || "")
: String(promptPayload.systemPrompt || systemPrompt || "");
return await callLLMForJSON({
systemPrompt: llmSystemPrompt,
userPrompt: promptPayload.userPrompt,
maxRetries: 1,
signal,
taskType: "compress",
debugContext: createTaskLlmDebugContext(
compressPromptBuild,
compressRegexInput,
),
promptMessages: promptPayload.promptMessages,
additionalMessages: promptPayload.additionalMessages,
});
}
/**
* 对所有支持压缩的类型执行压缩
*
* @param {object} graph
* @param {object[]} schema
* @param {object} embeddingConfig
* @param {boolean} [force=false]
* @returns {Promise<{created: number, archived: number}>}
*/
export async function compressAll(
graph,
schema,
embeddingConfig,
force = false,
customPrompt,
signal,
settings = {},
) {
let totalCreated = 0;
let totalArchived = 0;
for (const typeDef of schema) {
throwIfAborted(signal);
if (typeDef.compression?.mode === "hierarchical") {
const result = await compressType({
graph,
typeDef,
embeddingConfig,
force,
customPrompt,
signal,
settings,
});
totalCreated += result.created;
totalArchived += result.archived;
}
}
return { created: totalCreated, archived: totalArchived };
}
// ==================== v2: 主动遗忘SleepGate 启发) ====================
/**
* 睡眠清理周期
* 评估每个节点的保留价值,低于阈值的归档(遗忘)
*
* @param {object} graph - 图状态
* @param {object} settings - 包含 forgetThreshold 的设置
* @returns {{forgotten: number}} 本次遗忘的节点数
*/
export function sleepCycle(graph, settings) {
const threshold = settings.forgetThreshold ?? 0.5;
const nodes = getActiveNodes(graph);
const now = Date.now();
let forgotten = 0;
for (const node of nodes) {
// 跳过常驻类型synopsis, rule 等重要节点不应被遗忘)
if (
node.type === "synopsis" ||
node.type === "rule" ||
node.type === "thread"
)
continue;
// 跳过高重要性节点
if (node.importance >= 8) continue;
// 跳过最近创建的节点(< 1 小时)
if (now - node.createdTime < 3600000) continue;
// 计算保留价值 = importance × recency × (1 + accessFreq)
const ageHours = (now - node.createdTime) / 3600000;
const recency = 1 / (1 + Math.log10(1 + ageHours));
const accessFreq = node.accessCount / Math.max(1, ageHours / 24);
const retentionValue = (node.importance / 10) * recency * (1 + accessFreq);
if (retentionValue < threshold) {
node.archived = true;
forgotten++;
}
}
if (forgotten > 0) {
debugLog(`[ST-BME] 主动遗忘: ${forgotten} 个低价值节点已归档`);
}
return { forgotten };
}

724
maintenance/consolidator.js Normal file
View File

@@ -0,0 +1,724 @@
// ST-BME: 统一记忆整合引擎(批量化版)
// 合并 Mem0 精确对照 + A-MEM 记忆进化为单一阶段
// 批量 embed + 批量查近邻 + 单次 LLM 调用
import { debugLog } from "../runtime/debug-logging.js";
import { embedBatch, searchSimilar } from "../vector/embedding.js";
import { addEdge, createEdge, getActiveNodes, getNode } from "../graph/graph.js";
import { callLLMForJSON } from "../llm/llm.js";
import {
buildScopeBadgeText,
canMergeScopedMemories,
describeMemoryScope,
} from "../graph/memory-scope.js";
import {
buildTaskExecutionDebugContext,
buildTaskLlmPayload,
buildTaskPrompt,
} from "../prompting/prompt-builder.js";
import { getSTContextForPrompt } from "../host/st-context.js";
import { applyTaskRegex } from "../prompting/task-regex.js";
import {
buildNodeVectorText,
findSimilarNodesByText,
isDirectVectorConfig,
validateVectorConfig,
} from "../vector/vector-index.js";
function createAbortError(message = "操作已终止") {
const error = new Error(message);
error.name = "AbortError";
return error;
}
function createTaskLlmDebugContext(promptBuild, regexInput) {
return typeof buildTaskExecutionDebugContext === "function"
? buildTaskExecutionDebugContext(promptBuild, { regexInput })
: null;
}
function resolveTaskPromptPayload(promptBuild, fallbackUserPrompt = "") {
if (typeof buildTaskLlmPayload === "function") {
return buildTaskLlmPayload(promptBuild, fallbackUserPrompt);
}
return {
systemPrompt: String(promptBuild?.systemPrompt || ""),
userPrompt: String(fallbackUserPrompt || ""),
promptMessages: [],
additionalMessages: Array.isArray(promptBuild?.privateTaskMessages)
? promptBuild.privateTaskMessages
: [],
};
}
function isAbortError(error) {
return error?.name === "AbortError";
}
function throwIfAborted(signal) {
if (signal?.aborted) {
throw signal.reason instanceof Error ? signal.reason : createAbortError();
}
}
/**
* 统一记忆整合系统提示词(支持批量输出)
*/
const CONSOLIDATION_SYSTEM_PROMPT = `你是一个记忆整合分析器。当新记忆加入知识图谱时,你需要同时完成两项任务:
**任务一:冲突检测**
判断新记忆与最近邻的已有记忆是否冲突或重复:
- skip: 新记忆与已有记忆完全重复,应丢弃
- merge: 新记忆是对旧记忆的修正或补充,应合并
- keep: 新记忆是全新信息,应保留
**任务二:进化分析**(仅当 action=keep 时需要)
分析新记忆是否揭示了关于旧记忆的新信息:
- 建立有意义的关联连接
- 反向更新旧记忆的描述或分类
输出严格 JSON
{
"results": [
{
"node_id": "新记忆的节点 ID",
"action": "keep" | "merge" | "skip",
"merge_target_id": "仅 action=merge 时必填:要合并到的旧节点 ID",
"merged_fields": { "仅 action=merge 时可选:合并后的字段更新" },
"reason": "判定理由(简述)",
"evolution": {
"should_evolve": true/false,
"connections": ["需要建立链接的旧记忆 ID 列表"],
"neighbor_updates": [
{
"nodeId": "需更新的旧节点 ID",
"newContext": "基于新信息修正后的描述(不需修改则为 null",
"newTags": ["更新后的分类标签,不需修改则为 null"]
}
]
}
}
]
}
整合规则:
- 必须对每条新记忆都给出一个 result 条目
- 当 action=skip 时evolution 可省略或设 should_evolve=false
- 当 action=merge 时evolution 可省略或设 should_evolve=false
- 仅当 action=keep 且新信息确实改变了对旧记忆的理解时,才设 should_evolve=true
- 例如:揭露卧底身份 → 修正该角色之前事件中的动机描述
- 例如:发现地点的隐藏特性 → 更新地点节点的描述
- 不要对无关记忆强行建立联系
- neighbor_updates 中每条必须有实际意义的修改`;
function normalizeLatestOnlyIdentityValue(value) {
return String(value ?? "")
.trim()
.replace(/\s+/g, " ")
.toLowerCase();
}
export async function analyzeAutoConsolidationGate({
graph,
newNodeIds,
embeddingConfig,
schema = [],
conflictThreshold = 0.85,
signal,
} = {}) {
const normalizedThreshold = Number.isFinite(Number(conflictThreshold))
? Math.max(0, Math.min(1, Number(conflictThreshold)))
: 0.85;
const safeNewNodeIds = Array.isArray(newNodeIds) ? newNodeIds : [];
if (!graph || safeNewNodeIds.length === 0) {
return {
triggered: false,
reason: "本批新增少且无明显重复风险,跳过自动整合",
matchedScore: null,
matchedNodeId: "",
detection: "none",
};
}
const schemaByType = new Map(
(Array.isArray(schema) ? schema : [])
.filter((typeDef) => typeDef?.id)
.map((typeDef) => [String(typeDef.id), typeDef]),
);
const activeNodes = getActiveNodes(graph).filter((node) => !node?.archived);
const vectorConfigValid = validateVectorConfig(embeddingConfig).valid;
let bestVectorMatch = null;
for (const newNodeId of safeNewNodeIds) {
throwIfAborted(signal);
const node = getNode(graph, newNodeId);
if (!node || node.archived) continue;
const typeDef = schemaByType.get(String(node.type || ""));
const scopedCandidates = activeNodes.filter(
(candidate) =>
candidate?.id !== node.id && canMergeScopedMemories(node, candidate),
);
if (typeDef?.latestOnly) {
for (const field of ["name", "title"]) {
const normalizedIdentity = normalizeLatestOnlyIdentityValue(
node?.fields?.[field],
);
if (!normalizedIdentity) continue;
const matchedNode = scopedCandidates.find(
(candidate) =>
candidate?.type === node.type &&
normalizeLatestOnlyIdentityValue(candidate?.fields?.[field]) ===
normalizedIdentity,
);
if (matchedNode) {
return {
triggered: true,
reason: `本批仅新增 ${safeNewNodeIds.length} 个节点,但 latestOnly 的 ${field} 与旧记忆完全一致,已触发自动整合`,
matchedScore: 1,
matchedNodeId: matchedNode.id,
detection: `latestOnly:${field}`,
};
}
}
}
if (!vectorConfigValid) continue;
const text = buildNodeVectorText(node);
if (!text) continue;
try {
const neighbors = await findSimilarNodesByText(
graph,
text,
embeddingConfig,
1,
scopedCandidates,
signal,
);
const topNeighbor = Array.isArray(neighbors) ? neighbors[0] : null;
if (!topNeighbor?.nodeId) continue;
if (
!bestVectorMatch ||
Number(topNeighbor.score || 0) > Number(bestVectorMatch.score || 0)
) {
bestVectorMatch = {
score: Number(topNeighbor.score || 0),
nodeId: topNeighbor.nodeId,
};
}
if (Number(topNeighbor.score || 0) >= normalizedThreshold) {
return {
triggered: true,
reason: `本批仅新增 ${safeNewNodeIds.length} 个节点,但与旧记忆高度相似(${Number(topNeighbor.score || 0).toFixed(3)} >= ${normalizedThreshold.toFixed(2)}),已触发自动整合`,
matchedScore: Number(topNeighbor.score || 0),
matchedNodeId: topNeighbor.nodeId,
detection: "vector",
};
}
} catch (error) {
if (isAbortError(error)) throw error;
console.warn(
`[ST-BME] 自动整合门禁近邻查询失败 (${newNodeId}):`,
error?.message || error,
);
}
}
if (bestVectorMatch) {
return {
triggered: false,
reason: `本批新增少且最高相似度 ${bestVectorMatch.score.toFixed(3)} 未达到阈值 ${normalizedThreshold.toFixed(2)},跳过自动整合`,
matchedScore: bestVectorMatch.score,
matchedNodeId: bestVectorMatch.nodeId,
detection: "vector",
};
}
if (!vectorConfigValid) {
return {
triggered: false,
reason: "本批新增少且当前向量不可用,未检测到明确重复风险,跳过自动整合",
matchedScore: null,
matchedNodeId: "",
detection: "vector-unavailable",
};
}
return {
triggered: false,
reason: "本批新增少且无明显重复风险,跳过自动整合",
matchedScore: null,
matchedNodeId: "",
detection: "none",
};
}
/**
* 统一记忆整合主函数(批量化版)
*
* 4 阶段架构:
* Phase 0: 收集有效新节点
* Phase 1: 批量 Embed直连 1 次 embedBatch / 后端逐次)
* Phase 2: 各节点查近邻(直连本地 cosine / 后端逐次 query
* Phase 3: 单次 LLM 批量判定
* Phase 4: 逐个处理结果
*
* @param {object} params
* @param {object} params.graph - 当前图状态
* @param {string[]} params.newNodeIds - 本次新创建的节点 ID 列表
* @param {object} params.embeddingConfig - Embedding API 配置
* @param {object} [params.options]
* @param {number} [params.options.neighborCount=5]
* @param {number} [params.options.conflictThreshold=0.85]
* @param {string} [params.customPrompt]
* @param {AbortSignal} [params.signal]
* @returns {Promise<{merged: number, skipped: number, kept: number, evolved: number, connections: number, updates: number}>}
*/
export async function consolidateMemories({
graph,
newNodeIds,
embeddingConfig,
options = {},
customPrompt,
signal,
settings = {},
}) {
const neighborCount = options.neighborCount ?? 5;
const conflictThreshold = options.conflictThreshold ?? 0.85;
const stats = {
merged: 0,
skipped: 0,
kept: 0,
evolved: 0,
connections: 0,
updates: 0,
};
if (!newNodeIds || newNodeIds.length === 0) return stats;
if (!validateVectorConfig(embeddingConfig).valid) {
debugLog("[ST-BME] 记忆整合跳过:向量配置不可用");
return stats;
}
// ══════════════════════════════════════════════
// Phase 0: 收集有效新节点
// ══════════════════════════════════════════════
const newEntries = [];
for (const id of newNodeIds) {
const node = getNode(graph, id);
if (!node || node.archived) continue;
const text = buildNodeVectorText(node);
if (!text) continue;
newEntries.push({ id, node, text });
}
if (newEntries.length === 0) return stats;
const activeNodes = getActiveNodes(graph).filter((n) => {
const text = buildNodeVectorText(n);
return typeof text === "string" && text.length > 0;
});
if (activeNodes.length < 2) {
// 图中节点不够,全部 keep
stats.kept = newEntries.length;
return stats;
}
throwIfAborted(signal);
debugLog(`[ST-BME] 记忆整合开始: ${newEntries.length} 个新节点`);
// ══════════════════════════════════════════════
// Phase 1 + 2: 批量 Embed + 查近邻
// ══════════════════════════════════════════════
/** @type {Map<string, Array<{nodeId: string, score: number}>>} */
const neighborsMap = new Map();
if (isDirectVectorConfig(embeddingConfig)) {
// ── 直连模式: 1 次 embedBatch + N 次本地 cosine ──
const texts = newEntries.map((e) => e.text);
let queryVectors;
try {
queryVectors = await embedBatch(texts, embeddingConfig, { signal });
} catch (e) {
if (isAbortError(e)) throw e;
console.warn("[ST-BME] 批量 embed 失败,回退到逐条:", e.message);
queryVectors = null;
}
// 构建候选池(含 embedding 的活跃节点)
const candidatePool = activeNodes
.filter((n) => Array.isArray(n.embedding) && n.embedding.length > 0)
.map((n) => ({ nodeId: n.id, embedding: n.embedding }));
for (let i = 0; i < newEntries.length; i++) {
throwIfAborted(signal);
const entry = newEntries[i];
const candidates = candidatePool.filter((c) => {
if (c.nodeId === entry.id) return false;
const candidateNode = getNode(graph, c.nodeId);
return canMergeScopedMemories(entry.node, candidateNode);
});
if (queryVectors?.[i] && candidates.length > 0) {
// 本地 cosine 搜索0 API 调用)
const neighbors = searchSimilar(
queryVectors[i],
candidates,
neighborCount,
);
neighborsMap.set(entry.id, neighbors);
} else {
// fallback: 逐条 embed
try {
const neighbors = await findSimilarNodesByText(
graph,
entry.text,
embeddingConfig,
neighborCount,
activeNodes.filter((n) => n.id !== entry.id),
signal,
);
neighborsMap.set(entry.id, neighbors);
} catch (e) {
if (isAbortError(e)) throw e;
console.warn(`[ST-BME] 近邻查询失败 (${entry.id}):`, e.message);
neighborsMap.set(entry.id, []);
}
}
}
} else {
// ── 后端模式: 逐条 /api/vector/query ──
for (let i = 0; i < newEntries.length; i++) {
throwIfAborted(signal);
const entry = newEntries[i];
try {
const neighbors = await findSimilarNodesByText(
graph,
entry.text,
embeddingConfig,
neighborCount,
activeNodes.filter(
(n) => n.id !== entry.id && canMergeScopedMemories(entry.node, n),
),
signal,
);
neighborsMap.set(entry.id, neighbors);
} catch (e) {
if (isAbortError(e)) throw e;
console.warn(`[ST-BME] 近邻查询失败 (${entry.id}):`, e.message);
neighborsMap.set(entry.id, []);
}
}
}
// ══════════════════════════════════════════════
// Phase 3: 单次 LLM 批量判定
// ══════════════════════════════════════════════
throwIfAborted(signal);
const userPromptSections = [];
userPromptSections.push(
`本轮共新增 ${newEntries.length} 条记忆,请逐条分析:\n`,
);
for (let i = 0; i < newEntries.length; i++) {
const entry = newEntries[i];
const neighbors = neighborsMap.get(entry.id) || [];
const newNodeFieldsStr = Object.entries(entry.node.fields)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
const newNodeScope = buildScopeBadgeText(entry.node.scope);
// 构建近邻描述
let neighborText;
if (neighbors.length === 0) {
neighborText = " (无近邻命中)";
} else {
neighborText = neighbors
.map((n) => {
const node = getNode(graph, n.nodeId);
if (!node) return null;
const fieldsStr = Object.entries(node.fields)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
return ` - [${node.id}] 类型=${node.type}, 作用域=${describeMemoryScope(node.scope)}, ${fieldsStr} (相似度=${n.score.toFixed(3)})`;
})
.filter(Boolean)
.join("\n");
}
// 检查高相似度
const hasHighSimilarity =
neighbors.length > 0 && neighbors[0].score > conflictThreshold;
const hint = hasHighSimilarity
? ` ⚠ 最高相似度 ${neighbors[0].score.toFixed(3)} 超过阈值 ${conflictThreshold}`
: "";
userPromptSections.push(
[
`### 新记忆 #${i + 1}`,
`[${entry.id}] 类型=${entry.node.type}, 作用域=${newNodeScope}, ${newNodeFieldsStr}`,
"近邻记忆:",
neighborText,
hint,
]
.filter(Boolean)
.join("\n"),
);
}
const userPrompt = userPromptSections.join("\n\n");
let decision;
const consolidationPromptBuild = await buildTaskPrompt(settings, "consolidation", {
taskName: "consolidation",
candidateNodes: userPrompt,
candidateText: userPrompt,
graphStats: `new_entries=${newEntries.length}, threshold=${conflictThreshold}`,
...getSTContextForPrompt(),
});
const consolidationRegexInput = { entries: [] };
const consolidationSystemPrompt = applyTaskRegex(
settings,
"consolidation",
"finalPrompt",
consolidationPromptBuild.systemPrompt ||
customPrompt ||
CONSOLIDATION_SYSTEM_PROMPT,
consolidationRegexInput,
"system",
);
const promptPayload = resolveTaskPromptPayload(
consolidationPromptBuild,
userPrompt,
);
const llmSystemPrompt =
Array.isArray(promptPayload.promptMessages) &&
promptPayload.promptMessages.length > 0
? String(promptPayload.systemPrompt || "")
: String(promptPayload.systemPrompt || consolidationSystemPrompt || "");
try {
decision = await callLLMForJSON({
systemPrompt: llmSystemPrompt,
userPrompt: promptPayload.userPrompt,
maxRetries: 1,
signal,
taskType: "consolidation",
debugContext: createTaskLlmDebugContext(
consolidationPromptBuild,
consolidationRegexInput,
),
promptMessages: promptPayload.promptMessages,
additionalMessages: promptPayload.additionalMessages,
});
} catch (e) {
if (isAbortError(e)) throw e;
console.error("[ST-BME] 记忆整合 LLM 调用失败:", e);
stats.kept = newEntries.length;
return stats;
}
// ══════════════════════════════════════════════
// Phase 4: 逐个处理结果
// ══════════════════════════════════════════════
// 解析 LLM 返回——兼容单条和批量格式
let results;
if (Array.isArray(decision?.results)) {
results = decision.results;
} else if (decision?.action) {
// 单条返回格式LLM 可能忽略 results 包装)
results = [{ ...decision, node_id: newEntries[0]?.id }];
} else {
console.warn("[ST-BME] 记忆整合: LLM 返回格式异常,全部 keep");
stats.kept = newEntries.length;
return stats;
}
// 建立 node_id → result 的映射
const resultMap = new Map();
for (const r of results) {
if (r.node_id) resultMap.set(r.node_id, r);
}
// 处理每个新节点
for (const entry of newEntries) {
const result = resultMap.get(entry.id);
if (!result) {
// LLM 未返回此节点的结果fallback 为 keep
stats.kept++;
continue;
}
processOneResult(graph, entry, result, stats);
}
// 日志
const actionSummary = [];
if (stats.merged > 0) actionSummary.push(`合并 ${stats.merged}`);
if (stats.skipped > 0) actionSummary.push(`跳过 ${stats.skipped}`);
if (stats.kept > 0) actionSummary.push(`保留 ${stats.kept}`);
if (stats.evolved > 0) actionSummary.push(`进化 ${stats.evolved}`);
if (stats.connections > 0) actionSummary.push(`新链接 ${stats.connections}`);
if (stats.updates > 0) actionSummary.push(`回溯更新 ${stats.updates}`);
if (actionSummary.length > 0) {
debugLog(`[ST-BME] 记忆整合完成: ${actionSummary.join(", ")}`);
}
return stats;
}
/**
* 处理单个节点的整合结果
*/
function processOneResult(graph, entry, result, stats) {
const { id: newId, node: newNode } = entry;
// ── 处理 action ──
switch (result.action) {
case "skip": {
debugLog(`[ST-BME] 记忆整合: skip (重复) — ${newId}`);
newNode.archived = true;
stats.skipped++;
break;
}
case "merge": {
const targetId = result.merge_target_id;
const targetNode = targetId ? getNode(graph, targetId) : null;
if (
targetNode &&
!targetNode.archived &&
canMergeScopedMemories(newNode, targetNode)
) {
debugLog(`[ST-BME] 记忆整合: merge ${newId}${targetId}`);
if (result.merged_fields && typeof result.merged_fields === "object") {
for (const [key, value] of Object.entries(result.merged_fields)) {
if (value != null && value !== "") {
targetNode.fields[key] = value;
}
}
} else {
for (const [key, value] of Object.entries(newNode.fields)) {
if (value != null && value !== "" && !targetNode.fields[key]) {
targetNode.fields[key] = value;
}
}
}
if (
Number.isFinite(newNode.seq) &&
newNode.seq > (targetNode.seq || 0)
) {
targetNode.seq = newNode.seq;
}
const targetRange = Array.isArray(targetNode.seqRange)
? targetNode.seqRange
: [targetNode.seq || 0, targetNode.seq || 0];
const newRange = Array.isArray(newNode.seqRange)
? newNode.seqRange
: [newNode.seq || 0, newNode.seq || 0];
targetNode.seqRange = [
Math.min(targetRange[0], newRange[0]),
Math.max(targetRange[1], newRange[1]),
];
targetNode.embedding = null;
newNode.archived = true;
stats.merged++;
} else {
console.warn(
`[ST-BME] 记忆整合: merge target ${targetId} 不存在,回退为 keep`,
);
stats.kept++;
}
break;
}
case "keep":
default: {
stats.kept++;
break;
}
}
// ── 处理 evolution ──
const evolution = result.evolution;
if (evolution?.should_evolve && !newNode.archived) {
stats.evolved++;
debugLog(`[ST-BME] 记忆整合/进化触发: ${result.reason || "(无理由)"}`);
if (Array.isArray(evolution.connections)) {
for (const targetId of evolution.connections) {
if (!getNode(graph, targetId)) continue;
const edge = createEdge({
fromId: newId,
toId: targetId,
relation: "related",
strength: 0.7,
});
if (addEdge(graph, edge)) {
stats.connections++;
}
}
}
if (Array.isArray(evolution.neighbor_updates)) {
for (const update of evolution.neighbor_updates) {
if (!update.nodeId) continue;
const oldNode = getNode(graph, update.nodeId);
if (
!oldNode ||
oldNode.archived ||
!canMergeScopedMemories(newNode, oldNode)
) {
continue;
}
let changed = false;
if (update.newContext && typeof update.newContext === "string") {
if (oldNode.fields.state !== undefined) {
oldNode.fields.state = update.newContext;
changed = true;
} else if (oldNode.fields.summary !== undefined) {
oldNode.fields.summary = update.newContext;
changed = true;
} else if (oldNode.fields.core_note !== undefined) {
oldNode.fields.core_note = update.newContext;
changed = true;
}
}
if (update.newTags && Array.isArray(update.newTags)) {
oldNode.clusters = update.newTags;
changed = true;
}
if (changed) {
oldNode.embedding = null;
if (!oldNode._evolutionHistory) oldNode._evolutionHistory = [];
oldNode._evolutionHistory.push({
triggeredBy: newId,
timestamp: Date.now(),
reason: result.reason || "",
});
stats.updates++;
}
}
}
}
}

View File

@@ -0,0 +1,732 @@
// ST-BME: 提取编排控制器(纯函数)
// 通过 runtime 依赖注入,避免直接访问 index.js 模块级状态。
import { debugLog } from "../runtime/debug-logging.js";
function toSafeFloor(value, fallback = null) {
if (value == null || value === "") return fallback;
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.floor(numeric) : fallback;
}
function clampIntValue(value, fallback = 0, min = 0, max = 9999) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(numeric)));
}
function isAssistantFloor(runtime, chat, index) {
if (!Array.isArray(chat)) return false;
const message = chat[index];
if (!message) return false;
if (typeof runtime?.isAssistantChatMessage === "function") {
return Boolean(
runtime.isAssistantChatMessage(message, {
index,
chat,
}),
);
}
return Boolean(message) && !message.is_user && !message.is_system;
}
function getAssistantTurnsFallback(runtime, chat = []) {
if (!Array.isArray(chat)) return [];
const assistantTurns = [];
for (let index = 0; index < chat.length; index++) {
if (!isAssistantFloor(runtime, chat, index)) continue;
if (!String(chat[index]?.mes ?? "").trim()) continue;
assistantTurns.push(index);
}
return assistantTurns;
}
function normalizeSmartTriggerDecision(decision = null) {
if (!decision || typeof decision !== "object") {
return { triggered: false, score: 0, reasons: [] };
}
return {
triggered: decision.triggered === true,
score: Number.isFinite(Number(decision.score)) ? Number(decision.score) : 0,
reasons: Array.isArray(decision.reasons)
? decision.reasons.map((item) => String(item || "")).filter(Boolean)
: [],
};
}
export function resolveAutoExtractionPlanController(
runtime,
{
chat = null,
settings = null,
lastProcessedAssistantFloor = null,
lockedEndFloor = null,
} = {},
) {
const resolvedChat = Array.isArray(chat)
? chat
: runtime?.getContext?.()?.chat || [];
const resolvedSettings =
settings && typeof settings === "object"
? settings
: runtime?.getSettings?.() || {};
const safeLastProcessedAssistantFloor = toSafeFloor(
lastProcessedAssistantFloor,
toSafeFloor(runtime?.getLastProcessedAssistantFloor?.(), -1),
);
const safeLockedEndFloor = toSafeFloor(lockedEndFloor, null);
const strategy =
resolvedSettings.extractAutoDelayLatestAssistant === true
? "lag-one-assistant"
: "normal";
const extractEvery = clampIntValue(
resolvedSettings.extractEvery,
1,
1,
50,
);
const assistantTurns =
typeof runtime?.getAssistantTurns === "function"
? runtime.getAssistantTurns(resolvedChat)
: getAssistantTurnsFallback(runtime, resolvedChat);
const pendingAssistantTurns = assistantTurns.filter(
(floor) => floor > safeLastProcessedAssistantFloor,
);
const candidateAssistantTurns =
safeLockedEndFloor == null
? pendingAssistantTurns
: pendingAssistantTurns.filter((floor) => floor <= safeLockedEndFloor);
let eligibleAssistantTurns = candidateAssistantTurns;
let waitingForNextAssistant = false;
if (safeLockedEndFloor == null && strategy === "lag-one-assistant") {
if (candidateAssistantTurns.length <= 1) {
eligibleAssistantTurns = [];
waitingForNextAssistant = candidateAssistantTurns.length === 1;
} else {
eligibleAssistantTurns = candidateAssistantTurns.slice(0, -1);
}
}
const eligibleEndFloor =
eligibleAssistantTurns.length > 0
? eligibleAssistantTurns[eligibleAssistantTurns.length - 1]
: null;
const smartTriggerDecision =
resolvedSettings.enableSmartTrigger && eligibleEndFloor != null
? normalizeSmartTriggerDecision(
runtime?.getSmartTriggerDecision?.(
resolvedChat,
safeLastProcessedAssistantFloor,
resolvedSettings,
eligibleEndFloor,
),
)
: { triggered: false, score: 0, reasons: [] };
const meetsExtractEvery = eligibleAssistantTurns.length >= extractEvery;
const canRun =
eligibleAssistantTurns.length > 0 &&
(meetsExtractEvery || smartTriggerDecision.triggered);
const batchAssistantTurns = canRun
? smartTriggerDecision.triggered
? eligibleAssistantTurns
: eligibleAssistantTurns.slice(0, extractEvery)
: [];
const plannedBatchEndFloor =
batchAssistantTurns.length > 0
? batchAssistantTurns[batchAssistantTurns.length - 1]
: null;
let reason = "";
if (pendingAssistantTurns.length === 0) {
reason = "no-unprocessed-assistant-turns";
} else if (candidateAssistantTurns.length === 0) {
reason =
safeLockedEndFloor == null
? "no-candidate-assistant-turns"
: "locked-target-missing";
} else if (waitingForNextAssistant) {
reason = "waiting-next-assistant";
} else if (!canRun && !smartTriggerDecision.triggered) {
reason = "below-extract-every";
}
return {
strategy,
chat: resolvedChat,
settings: resolvedSettings,
lastProcessedAssistantFloor: safeLastProcessedAssistantFloor,
lockedEndFloor: safeLockedEndFloor,
extractEvery,
pendingAssistantTurns,
candidateAssistantTurns,
eligibleAssistantTurns,
eligibleEndFloor,
waitingForNextAssistant,
smartTriggerDecision,
meetsExtractEvery,
canRun,
batchAssistantTurns,
plannedBatchEndFloor,
startIdx: batchAssistantTurns[0] ?? null,
endIdx: plannedBatchEndFloor,
reason,
};
}
export async function executeExtractionBatchController(
runtime,
{
chat,
startIdx,
endIdx,
settings,
smartTriggerDecision = null,
signal = undefined,
} = {},
) {
runtime.ensureCurrentGraphRuntimeState();
runtime.throwIfAborted(signal, "提取已终止");
const currentGraph = runtime.getCurrentGraph();
const lastProcessed = runtime.getLastProcessedAssistantFloor();
const extractionCountBefore = runtime.getExtractionCount();
const beforeSnapshot = runtime.cloneGraphSnapshot(currentGraph);
const messages = runtime.buildExtractionMessages(chat, startIdx, endIdx, settings);
const batchStatus = runtime.createBatchStatusSkeleton({
processedRange: [startIdx, endIdx],
extractionCountBefore,
});
debugLog(
`[ST-BME] 开始提取: 楼层 ${startIdx}-${endIdx}` +
(smartTriggerDecision?.triggered
? ` [智能触发 score=${smartTriggerDecision.score}; ${smartTriggerDecision.reasons.join(" / ")}]`
: ""),
);
const result = await runtime.extractMemories({
graph: currentGraph,
messages,
startSeq: startIdx,
endSeq: endIdx,
lastProcessedSeq: lastProcessed,
schema: runtime.getSchema(),
embeddingConfig: runtime.getEmbeddingConfig(),
extractPrompt: undefined,
settings,
signal,
onStreamProgress: ({ previewText, receivedChars }) => {
const preview =
previewText?.length > 60 ? "…" + previewText.slice(-60) : previewText || "";
runtime.setLastExtractionStatus(
"AI 生成中",
`${preview} [${receivedChars}字]`,
"running",
{ noticeMarquee: true },
);
},
});
if (!result.success) {
runtime.setBatchStageOutcome(
batchStatus,
"core",
"failed",
result?.error || "提取阶段未返回有效操作",
);
runtime.finalizeBatchStatus(batchStatus, runtime.getExtractionCount());
runtime.getCurrentGraph().historyState.lastBatchStatus = batchStatus;
return {
success: false,
result,
effects: null,
batchStatus,
error: result?.error || "提取阶段未返回有效操作",
};
}
runtime.setBatchStageOutcome(batchStatus, "core", "success");
const effects = await runtime.handleExtractionSuccess(
result,
endIdx,
settings,
signal,
batchStatus,
);
const finalizedBatchStatus =
effects?.batchStatus ||
runtime.finalizeBatchStatus(batchStatus, runtime.getExtractionCount());
runtime.getCurrentGraph().historyState.lastBatchStatus = {
...finalizedBatchStatus,
historyAdvanced: runtime.shouldAdvanceProcessedHistory(finalizedBatchStatus),
};
if (runtime.getCurrentGraph().historyState.lastBatchStatus.historyAdvanced) {
runtime.updateProcessedHistorySnapshot(chat, endIdx);
}
const afterSnapshot = runtime.cloneGraphSnapshot(runtime.getCurrentGraph());
const postProcessArtifacts = runtime.computePostProcessArtifacts(
beforeSnapshot,
afterSnapshot,
effects?.postProcessArtifacts || [],
);
runtime.appendBatchJournal(
runtime.getCurrentGraph(),
runtime.createBatchJournalEntry(beforeSnapshot, afterSnapshot, {
processedRange: [startIdx, endIdx],
postProcessArtifacts,
vectorHashesInserted: effects?.vectorHashesInserted || [],
extractionCountBefore,
}),
);
runtime.saveGraphToChat({ reason: "extraction-batch-complete" });
return {
success: finalizedBatchStatus.completed,
result,
effects,
batchStatus: finalizedBatchStatus,
error: finalizedBatchStatus.completed
? ""
: effects?.vectorError ||
finalizedBatchStatus.errors?.[0] ||
"批次未完成 finalize 闭环",
};
}
export async function runExtractionController(runtime, options = {}) {
const lockedEndFloor = toSafeFloor(options?.lockedEndFloor, null);
const triggerSource = String(options?.triggerSource || "auto").trim() || "auto";
const settings = runtime.getSettings?.() || {};
const context = runtime.getContext?.() || {};
const chat = Array.isArray(context?.chat) ? context.chat : [];
const plan = resolveAutoExtractionPlanController(runtime, {
chat,
settings,
lockedEndFloor,
});
const deferredTargetEndFloor =
plan.plannedBatchEndFloor ?? lockedEndFloor;
if (runtime.getIsExtracting()) {
runtime.console?.debug?.("[ST-BME] auto extraction deferred: extraction already in progress");
runtime.deferAutoExtraction?.("extracting", {
targetEndFloor: deferredTargetEndFloor,
strategy: plan.strategy,
});
return;
}
if (!settings.enabled) return;
if (!runtime.ensureGraphMutationReady("自动提取", { notify: false })) {
runtime.console?.debug?.("[ST-BME] auto extraction blocked: graph-not-ready", {
loadState: runtime.getGraphPersistenceState?.()?.loadState || "",
});
runtime.deferAutoExtraction?.("graph-not-ready", {
targetEndFloor: deferredTargetEndFloor,
strategy: plan.strategy,
});
runtime.setLastExtractionStatus(
"等待图谱加载",
runtime.getGraphMutationBlockReason("自动提取"),
"warning",
{ syncRuntime: true },
);
return;
}
if (!runtime.getCurrentGraph()) {
runtime.ensureCurrentGraphRuntimeState?.();
}
if (!(await runtime.recoverHistoryIfNeeded("auto-extract"))) {
runtime.console?.debug?.("[ST-BME] auto extraction paused during history recovery", {
recovering: runtime.getIsRecoveringHistory?.() === true,
});
if (runtime.getIsRecoveringHistory?.()) {
runtime.deferAutoExtraction?.("history-recovering", {
targetEndFloor: deferredTargetEndFloor,
strategy: plan.strategy,
});
}
return;
}
if (!chat || chat.length === 0) return;
if (!plan.canRun || plan.startIdx == null || plan.endIdx == null) {
return;
}
const startIdx = plan.startIdx;
const endIdx = plan.endIdx;
const smartTriggerDecision = plan.smartTriggerDecision;
runtime.setIsExtracting(true);
const extractionController = runtime.beginStageAbortController("extraction");
const extractionSignal = extractionController.signal;
runtime.setLastExtractionStatus(
"提取中",
`楼层 ${startIdx}-${endIdx}${smartTriggerDecision.triggered ? " · 智能触发" : ""}${triggerSource !== "auto" ? ` · ${triggerSource}` : ""}`,
"running",
{ syncRuntime: true },
);
try {
const batchResult = await runtime.executeExtractionBatch({
chat,
startIdx,
endIdx,
settings,
smartTriggerDecision,
signal: extractionSignal,
});
if (!batchResult.success) {
const message =
batchResult.error ||
batchResult?.result?.error ||
"提取批次未返回有效结果";
runtime.console.warn("[ST-BME] 提取批次未返回有效结果:", message);
runtime.notifyExtractionIssue(message);
return;
}
runtime.setLastExtractionStatus(
"提取完成",
`楼层 ${startIdx}-${endIdx} · 新建 ${batchResult.result?.newNodes || 0} · 更新 ${batchResult.result?.updatedNodes || 0} · 新边 ${batchResult.result?.newEdges || 0}`,
"success",
{ syncRuntime: true },
);
} catch (e) {
if (runtime.isAbortError(e)) {
runtime.setLastExtractionStatus(
"提取已终止",
e?.message || "已手动终止当前提取",
"warning",
{
syncRuntime: true,
},
);
return;
}
runtime.console.error("[ST-BME] 提取失败:", e);
runtime.notifyExtractionIssue(e?.message || String(e) || "自动提取失败");
} finally {
runtime.finishStageAbortController("extraction", extractionController);
runtime.setIsExtracting(false);
}
}
export async function onManualExtractController(runtime, options = {}) {
if (runtime.getIsExtracting()) {
runtime.toastr.info("记忆提取正在进行中,请稍候");
return;
}
if (!runtime.ensureGraphMutationReady("手动提取")) return;
if (!(await runtime.recoverHistoryIfNeeded("manual-extract"))) return;
if (!runtime.getCurrentGraph()) {
runtime.setCurrentGraph(
runtime.normalizeGraphRuntimeState(
runtime.createEmptyGraph(),
runtime.getCurrentChatId(),
),
);
}
const context = runtime.getContext();
const chat = context.chat;
if (!Array.isArray(chat) || chat.length === 0) {
runtime.toastr.info("当前聊天为空,暂无可提取内容");
return;
}
const assistantTurns = runtime.getAssistantTurns(chat);
const lastProcessed = runtime.getLastProcessedAssistantFloor();
const pendingAssistantTurns = assistantTurns.filter((i) => i > lastProcessed);
if (pendingAssistantTurns.length === 0) {
runtime.toastr.info("没有待提取的新回复");
return;
}
const settings = runtime.getSettings();
const extractEvery = runtime.clampInt(settings.extractEvery, 1, 1, 50);
const totals = {
newNodes: 0,
updatedNodes: 0,
newEdges: 0,
batches: 0,
};
const warnings = [];
runtime.setIsExtracting(true);
const extractionController = runtime.beginStageAbortController("extraction");
const extractionSignal = extractionController.signal;
runtime.setLastExtractionStatus(
"手动提取中",
`待处理 assistant 楼层 ${pendingAssistantTurns.length}`,
"running",
{ syncRuntime: true, toastKind: "info", toastTitle: "ST-BME 手动提取" },
);
try {
while (true) {
const pendingTurns = runtime
.getAssistantTurns(chat)
.filter((i) => i > runtime.getLastProcessedAssistantFloor());
if (pendingTurns.length === 0) break;
const batchAssistantTurns = pendingTurns.slice(0, extractEvery);
const startIdx = batchAssistantTurns[0];
const endIdx = batchAssistantTurns[batchAssistantTurns.length - 1];
const batchResult = await runtime.executeExtractionBatch({
chat,
startIdx,
endIdx,
settings,
signal: extractionSignal,
});
if (!batchResult.success) {
throw new Error(
batchResult.error ||
batchResult?.result?.error ||
"手动提取未返回有效结果",
);
}
totals.newNodes += batchResult.result.newNodes || 0;
totals.updatedNodes += batchResult.result.updatedNodes || 0;
totals.newEdges += batchResult.result.newEdges || 0;
totals.batches++;
if (Array.isArray(batchResult.effects?.warnings)) {
warnings.push(...batchResult.effects.warnings);
}
if (options?.drainAll === false) {
break;
}
}
if (totals.batches === 0) {
runtime.setLastExtractionStatus(
"无待提取内容",
"没有新的 assistant 回复需要处理",
"info",
{
syncRuntime: true,
},
);
runtime.toastr.info("没有待提取的新回复");
return;
}
runtime.toastr.success(
`提取完成:${totals.batches} 批,新建 ${totals.newNodes},更新 ${totals.updatedNodes},新边 ${totals.newEdges}`,
);
runtime.setLastExtractionStatus(
"手动提取完成",
`${totals.batches} 批 · 新建 ${totals.newNodes} · 更新 ${totals.updatedNodes} · 新边 ${totals.newEdges}`,
"success",
{
syncRuntime: true,
toastKind: "success",
toastTitle: "ST-BME 手动提取",
},
);
if (warnings.length > 0) {
runtime.toastr.warning(warnings.slice(0, 2).join(""), "ST-BME 提取警告", {
timeOut: 5000,
});
}
} catch (e) {
if (runtime.isAbortError(e)) {
runtime.setLastExtractionStatus(
"手动提取已终止",
e?.message || "已手动终止当前提取",
"warning",
{
syncRuntime: true,
},
);
return;
}
runtime.console.error("[ST-BME] 手动提取失败:", e);
runtime.setLastExtractionStatus("手动提取失败", e?.message || String(e), "error", {
syncRuntime: true,
toastKind: "",
toastTitle: "ST-BME 手动提取",
});
runtime.toastr.error(`手动提取失败: ${e.message || e}`);
} finally {
runtime.finishStageAbortController("extraction", extractionController);
runtime.setIsExtracting(false);
runtime.refreshPanelLiveState();
}
}
export async function onRerollController(runtime, { fromFloor } = {}) {
if (runtime.getIsExtracting?.()) {
runtime.toastr?.info?.("记忆提取正在进行中,请稍候");
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: null,
effectiveFromFloor: null,
recoveryPath: "busy",
affectedBatchCount: 0,
error: "记忆提取正在进行中",
};
}
if (
typeof runtime.ensureGraphMutationReady === "function" &&
!runtime.ensureGraphMutationReady("重新提取")
) {
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: Number.isFinite(fromFloor) ? fromFloor : null,
effectiveFromFloor: null,
recoveryPath: runtime.getGraphPersistenceState?.()?.loadState || "graph-not-ready",
affectedBatchCount: 0,
error:
typeof runtime.getGraphMutationBlockReason === "function"
? runtime.getGraphMutationBlockReason("重新提取")
: "重新提取已暂停:图谱尚未就绪。",
};
}
if (!runtime.getCurrentGraph?.()) {
runtime.toastr?.info?.("图谱为空,无需重 Roll");
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: null,
effectiveFromFloor: null,
recoveryPath: "empty-graph",
affectedBatchCount: 0,
error: "图谱为空",
};
}
const context = runtime.getContext();
const chat = context?.chat;
if (!Array.isArray(chat) || chat.length === 0) {
runtime.toastr?.info?.("当前聊天为空");
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: null,
effectiveFromFloor: null,
recoveryPath: "empty-chat",
affectedBatchCount: 0,
error: "当前聊天为空",
};
}
let targetFloor = Number.isFinite(fromFloor) ? fromFloor : null;
if (targetFloor === null) {
const assistantTurns = runtime.getAssistantTurns(chat);
if (assistantTurns.length === 0) {
runtime.toastr?.info?.("聊天中没有 AI 回复");
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: null,
effectiveFromFloor: null,
recoveryPath: "no-assistant-turn",
affectedBatchCount: 0,
error: "聊天中没有 AI 回复",
};
}
targetFloor = assistantTurns[assistantTurns.length - 1];
}
runtime.setRuntimeStatus(
"重新提取中",
Number.isFinite(targetFloor)
? `准备从楼层 ${targetFloor} 开始回滚并重新提取`
: "准备回滚最新 AI 楼并重新提取",
"running",
);
const lastProcessed = runtime.getLastProcessedAssistantFloor();
const alreadyExtracted = targetFloor <= lastProcessed;
if (!alreadyExtracted) {
runtime.toastr?.info?.("该楼层尚未提取,直接执行提取…", "ST-BME 重 Roll", {
timeOut: 2000,
});
await runtime.onManualExtract();
return {
success: true,
rollbackPerformed: false,
extractionTriggered: true,
requestedFloor: targetFloor,
effectiveFromFloor: lastProcessed + 1,
recoveryPath: "direct-extract",
affectedBatchCount: 0,
extractionStatus: runtime.getLastExtractionStatusLevel?.() || "idle",
error: "",
};
}
debugLog(`[ST-BME] 重 Roll 开始,目标楼层: ${targetFloor}`);
let rollbackResult;
try {
rollbackResult = await runtime.rollbackGraphForReroll(targetFloor, context);
} catch (e) {
if (runtime.isAbortError(e)) {
runtime.setRuntimeStatus(
"重新提取已取消",
e.message || "聊天已切换",
"warning",
);
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: targetFloor,
effectiveFromFloor: null,
recoveryPath: "aborted",
affectedBatchCount: 0,
error: e.message || "聊天已切换,重新提取已取消",
};
}
throw e;
}
if (!rollbackResult?.success) {
runtime.setRuntimeStatus(
"重新提取失败",
rollbackResult.error || "回滚失败",
"error",
);
runtime.toastr?.error?.(rollbackResult.error, "ST-BME 重 Roll");
return rollbackResult;
}
const rerollDesc =
rollbackResult.effectiveFromFloor !== targetFloor
? `已按批次边界回滚到楼层 ${rollbackResult.effectiveFromFloor} 开始重新提取…`
: `已回滚到楼层 ${targetFloor} 开始重新提取…`;
runtime.toastr?.info?.(rerollDesc, "ST-BME 重 Roll", {
timeOut: 2500,
});
await runtime.onManualExtract({ drainAll: false });
runtime.refreshPanelLiveState();
return {
...rollbackResult,
extractionTriggered: true,
extractionStatus: runtime.getLastExtractionStatusLevel?.() || "idle",
};
}

1244
maintenance/extractor.js Normal file

File diff suppressed because it is too large Load Diff