feat: add story timeline layer

This commit is contained in:
Youzini-afk
2026-04-08 22:41:49 +08:00
parent d7989303d9
commit 323e1fd5b0
19 changed files with 2073 additions and 75 deletions

View File

@@ -18,6 +18,11 @@ import {
normalizeMemoryScope,
} from "../graph/memory-scope.js";
import { ensureEventTitle, getNodeDisplayName } from "../graph/node-labels.js";
import {
deriveStoryTimeSpanFromNodes,
describeNodeStoryTime,
normalizeStoryTime,
} from "../graph/story-timeline.js";
import {
buildTaskExecutionDebugContext,
buildTaskLlmPayload,
@@ -296,6 +301,12 @@ async function compressLevel({
compressedNode.level = level + 1;
compressedNode.childIds = batch.map((n) => n.id);
compressedNode.storyTime = normalizeStoryTime();
compressedNode.storyTimeSpan = deriveStoryTimeSpanFromNodes(
graph,
batch,
"derived",
);
const embeddingText =
normalizeCompressionFieldValue(
@@ -471,11 +482,12 @@ async function summarizeBatch(
) {
const nodeDescriptions = nodes
.map((n, i) => {
const storyTimeLabel = describeNodeStoryTime(n);
const fieldsStr = Object.entries(n.fields)
.filter(([_, v]) => v)
.map(([k, v]) => `${k}: ${v}`)
.join("\n ");
return `节点 ${i + 1} [楼层 ${n.seq}]:\n ${fieldsStr}`;
return `节点 ${i + 1} [楼层 ${n.seq}]${storyTimeLabel ? ` [剧情时间 ${storyTimeLabel}]` : ""}:\n ${fieldsStr}`;
})
.join("\n\n");
@@ -508,6 +520,8 @@ async function summarizeBatch(
"- 保留关键信息:因果关系、不可逆结果、未解决伏笔",
"- 去除重复和低信息密度内容",
"- 压缩后文本应精炼,目标 150 字左右",
"- 必须保持剧情时间顺序,不要把不同阶段的内容写反",
"- 不要把未来计划写成已经发生的客观事实",
].join("\n"),
compressRegexInput,
"system",

View File

@@ -11,6 +11,10 @@ import {
canMergeScopedMemories,
describeMemoryScope,
} from "../graph/memory-scope.js";
import {
describeNodeStoryTime,
isStoryTimeCompatible,
} from "../graph/story-timeline.js";
import {
buildTaskExecutionDebugContext,
buildTaskLlmPayload,
@@ -110,7 +114,9 @@ const CONSOLIDATION_SYSTEM_PROMPT = `你是一个记忆整合分析器。当新
- 例如:揭露卧底身份 → 修正该角色之前事件中的动机描述
- 例如:发现地点的隐藏特性 → 更新地点节点的描述
- 不要对无关记忆强行建立联系
- neighbor_updates 中每条必须有实际意义的修改`;
- neighbor_updates 中每条必须有实际意义的修改
- 必须保持剧情时间一致;不同时间段的事件默认不要 merge
- 同名事件若剧情时间不同,除非明确是同一事件的补充,否则应 keep`;
function normalizeLatestOnlyIdentityValue(value) {
return String(value ?? "")
@@ -119,6 +125,13 @@ function normalizeLatestOnlyIdentityValue(value) {
.toLowerCase();
}
function canMergeTemporalScopedMemories(leftNode, rightNode) {
if (!canMergeScopedMemories(leftNode, rightNode)) {
return false;
}
return isStoryTimeCompatible(leftNode, rightNode).compatible;
}
export async function analyzeAutoConsolidationGate({
graph,
newNodeIds,
@@ -159,7 +172,7 @@ export async function analyzeAutoConsolidationGate({
const typeDef = schemaByType.get(String(node.type || ""));
const scopedCandidates = activeNodes.filter(
(candidate) =>
candidate?.id !== node.id && canMergeScopedMemories(node, candidate),
candidate?.id !== node.id && canMergeTemporalScopedMemories(node, candidate),
);
if (typeDef?.latestOnly) {
@@ -364,7 +377,7 @@ export async function consolidateMemories({
const candidates = candidatePool.filter((c) => {
if (c.nodeId === entry.id) return false;
const candidateNode = getNode(graph, c.nodeId);
return canMergeScopedMemories(entry.node, candidateNode);
return canMergeTemporalScopedMemories(entry.node, candidateNode);
});
if (queryVectors?.[i] && candidates.length > 0) {
@@ -406,7 +419,7 @@ export async function consolidateMemories({
embeddingConfig,
neighborCount,
activeNodes.filter(
(n) => n.id !== entry.id && canMergeScopedMemories(entry.node, n),
(n) => n.id !== entry.id && canMergeTemporalScopedMemories(entry.node, n),
),
signal,
);
@@ -437,6 +450,7 @@ export async function consolidateMemories({
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
const newNodeScope = buildScopeBadgeText(entry.node.scope);
const newNodeStoryTime = describeNodeStoryTime(entry.node);
// 构建近邻描述
let neighborText;
@@ -450,7 +464,7 @@ export async function consolidateMemories({
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)})`;
return ` - [${node.id}] 类型=${node.type}, 作用域=${describeMemoryScope(node.scope)}${describeNodeStoryTime(node) ? `, 剧情时间=${describeNodeStoryTime(node)}` : ""}, ${fieldsStr} (相似度=${n.score.toFixed(3)})`;
})
.filter(Boolean)
.join("\n");
@@ -466,7 +480,7 @@ export async function consolidateMemories({
userPromptSections.push(
[
`### 新记忆 #${i + 1}`,
`[${entry.id}] 类型=${entry.node.type}, 作用域=${newNodeScope}, ${newNodeFieldsStr}`,
`[${entry.id}] 类型=${entry.node.type}, 作用域=${newNodeScope}${newNodeStoryTime ? `, 剧情时间=${newNodeStoryTime}` : ""}, ${newNodeFieldsStr}`,
"近邻记忆:",
neighborText,
hint,
@@ -600,7 +614,7 @@ function processOneResult(graph, entry, result, stats) {
if (
targetNode &&
!targetNode.archived &&
canMergeScopedMemories(newNode, targetNode)
canMergeTemporalScopedMemories(newNode, targetNode)
) {
debugLog(`[ST-BME] 记忆整合: merge ${newId}${targetId}`);
@@ -635,6 +649,14 @@ function processOneResult(graph, entry, result, stats) {
Math.min(targetRange[0], newRange[0]),
Math.max(targetRange[1], newRange[1]),
];
if (!String(targetNode?.storyTime?.segmentId || targetNode?.storyTime?.label || "").trim()) {
targetNode.storyTime = { ...(newNode.storyTime || targetNode.storyTime || {}) };
}
if (!String(targetNode?.storyTimeSpan?.startSegmentId || targetNode?.storyTimeSpan?.startLabel || "").trim()) {
targetNode.storyTimeSpan = {
...(newNode.storyTimeSpan || targetNode.storyTimeSpan || {}),
};
}
targetNode.embedding = null;
newNode.archived = true;
@@ -683,7 +705,7 @@ function processOneResult(graph, entry, result, stats) {
if (
!oldNode ||
oldNode.archived ||
!canMergeScopedMemories(newNode, oldNode)
!canMergeTemporalScopedMemories(newNode, oldNode)
) {
continue;
}

View File

@@ -26,6 +26,14 @@ import {
applyRegionUpdates,
resolveKnowledgeOwner,
} from "../graph/knowledge-state.js";
import {
applyBatchStoryTime,
createSpanFromStoryTime,
deriveStoryTimeSpanFromNodes,
describeNodeStoryTime,
normalizeStoryTime,
upsertTimelineSegment,
} from "../graph/story-timeline.js";
import {
buildTaskExecutionDebugContext,
buildTaskLlmPayload,
@@ -120,6 +128,7 @@ const EXTRACTION_OPERATION_META_KEYS = new Set([
"importance",
"clusters",
"scope",
"storyTime",
"seq",
"temporalStrength",
"temporal_strength",
@@ -302,6 +311,14 @@ function normalizeExtractionOperation(rawOp, schema) {
delete normalized.scope;
}
if (isPlainObject(rawOp?.storyTime)) {
normalized.storyTime = normalizeStoryTime(rawOp.storyTime, {
source: "extract",
});
} else if (action === "create" || action === "update") {
delete normalized.storyTime;
}
if (action === "create" || action === "update") {
const fields = collectNormalizedOperationFields(rawOp, typeDef);
if (Object.keys(fields).length > 0) {
@@ -370,12 +387,19 @@ function normalizeExtractionResultPayload(result, schema) {
: [],
}
: null;
const normalizedBatchStoryTime = isPlainObject(result?.batchStoryTime)
? {
...normalizeStoryTime(result.batchStoryTime, { source: "extract" }),
advancesActiveTimeline: result.batchStoryTime?.advancesActiveTimeline === true,
}
: null;
if (Array.isArray(result) || !isPlainObject(result)) {
return {
operations: normalizedOperations,
cognitionUpdates: normalizedCognitionUpdates,
regionUpdates: normalizedRegionUpdates,
batchStoryTime: normalizedBatchStoryTime,
};
}
@@ -384,6 +408,7 @@ function normalizeExtractionResultPayload(result, schema) {
operations: normalizedOperations,
cognitionUpdates: normalizedCognitionUpdates,
regionUpdates: normalizedRegionUpdates,
batchStoryTime: normalizedBatchStoryTime,
};
}
@@ -546,6 +571,75 @@ function normalizeCognitionUpdatesWithOwnerContext(
return normalized;
}
function supportsPointStoryTime(type = "") {
return ["event", "pov_memory"].includes(String(type || ""));
}
function supportsSpanStoryTime(type = "") {
return ["thread", "synopsis", "reflection"].includes(String(type || ""));
}
function resolveOperationStoryTime(
graph,
op = {},
batchStoryTime = null,
{ source = "extract" } = {},
) {
const explicitStoryTime = normalizeStoryTime(op?.storyTime, { source });
const fallbackStoryTime = normalizeStoryTime(batchStoryTime, { source });
const candidate =
explicitStoryTime.segmentId || explicitStoryTime.label
? explicitStoryTime
: fallbackStoryTime.segmentId || fallbackStoryTime.label
? fallbackStoryTime
: null;
if (!candidate) {
return {
storyTime: normalizeStoryTime(),
storyTimeSpan: createSpanFromStoryTime(null, source),
timelineAdvanceApplied: false,
};
}
const activeReferenceSegmentId = String(
graph?.historyState?.activeStorySegmentId ||
graph?.historyState?.lastExtractedStorySegmentId ||
"",
).trim();
const upserted = upsertTimelineSegment(graph, candidate, {
referenceSegmentId: activeReferenceSegmentId,
source,
});
return {
storyTime: upserted.storyTime,
storyTimeSpan: createSpanFromStoryTime(upserted.storyTime, source),
timelineAdvanceApplied: false,
};
}
function applyOperationStoryTimeToNode(
graph,
node,
op = {},
batchStoryTime = null,
{ source = "extract" } = {},
) {
if (!node || typeof node !== "object") return;
const resolved = resolveOperationStoryTime(graph, op, batchStoryTime, { source });
if (supportsPointStoryTime(node.type)) {
node.storyTime = resolved.storyTime;
node.storyTimeSpan = createSpanFromStoryTime(null, source);
return;
}
if (supportsSpanStoryTime(node.type)) {
node.storyTime = normalizeStoryTime();
node.storyTimeSpan = resolved.storyTimeSpan;
return;
}
node.storyTime = normalizeStoryTime();
node.storyTimeSpan = createSpanFromStoryTime(null, source);
}
/**
* 对未处理的对话楼层执行记忆提取
*
@@ -774,6 +868,7 @@ export async function extractMemories({
const updatedNodeIds = [];
const refMap = new Map();
const operationErrors = [];
const normalizedBatchStoryTime = normalizedResult?.batchStoryTime || null;
for (const op of normalizedResult.operations) {
try {
@@ -789,6 +884,7 @@ export async function extractMemories({
scopeRuntime,
extractionOwnerContext,
ownershipWarnings,
normalizedBatchStoryTime,
);
if (createdId) newNodeIds.push(createdId);
break;
@@ -803,6 +899,7 @@ export async function extractMemories({
scopeRuntime,
extractionOwnerContext,
ownershipWarnings,
normalizedBatchStoryTime,
);
if (updatedNodeId) updatedNodeIds.push(updatedNodeId);
}
@@ -867,6 +964,11 @@ export async function extractMemories({
changedNodeIds,
source: "extract",
});
const batchStoryTimeResult = applyBatchStoryTime(
graph,
normalizedBatchStoryTime,
"extract",
);
updateRuntimeScopeState(graph, newNodeIds, scopeRuntime, extractionOwnerContext);
debugLog(
@@ -879,6 +981,8 @@ export async function extractMemories({
...stats,
newNodeIds,
ownerWarnings: ownershipWarnings,
batchStoryTime: normalizedBatchStoryTime,
batchStoryTimeResult,
processedRange: [effectiveStartSeq, effectiveEndSeq],
};
}
@@ -896,6 +1000,7 @@ function handleCreate(
scopeRuntime = {},
ownerContext = {},
ownershipWarnings = [],
batchStoryTime = null,
) {
const normalizedFields =
op.type === "event" ? ensureEventTitle(op.fields || {}) : op.fields || {};
@@ -933,6 +1038,7 @@ function handleCreate(
if (existing) {
// 转为更新操作
updateNode(graph, existing.id, { fields: op.fields, seq, scope: nodeScope });
applyOperationStoryTimeToNode(graph, existing, op, batchStoryTime);
stats.updatedNodes++;
if (op.ref) refMap.set(op.ref, existing.id);
@@ -954,6 +1060,7 @@ function handleCreate(
clusters: op.clusters || [],
scope: nodeScope,
});
applyOperationStoryTimeToNode(graph, node, op, batchStoryTime);
addNode(graph, node);
stats.newNodes++;
@@ -982,6 +1089,7 @@ function handleUpdate(
scopeRuntime = {},
ownerContext = {},
ownershipWarnings = [],
batchStoryTime = null,
) {
if (!op.nodeId) {
console.warn("[ST-BME] update 操作缺少 nodeId");
@@ -1029,6 +1137,7 @@ function handleUpdate(
stats.updatedNodes++;
const node = getNode(graph, op.nodeId);
if (node) {
applyOperationStoryTimeToNode(graph, node, op, batchStoryTime);
node.embedding = null;
node.seq = Math.max(node.seq || 0, updateSeq);
node.seqRange = [
@@ -1381,14 +1490,16 @@ function buildDefaultExtractPrompt(schema) {
"输出格式为严格 JSON",
"{",
' "thought": "你对本段对话的分析(事件/角色变化/新信息/谁如何理解)",',
' "batchStoryTime": {"label": "第二天清晨", "tense": "ongoing", "relation": "after", "anchorLabel": "昨夜冲突之后", "confidence": "high", "advancesActiveTimeline": true},',
' "operations": [',
" {",
' "action": "create",',
' "type": "event",',
' "fields": {"title": "简短事件名", "summary": "...", "participants": "...", "status": "ongoing"},',
' "scope": {"layer": "objective", "regionPrimary": "主地区", "regionPath": ["上级地区", "主地区"], "regionSecondary": ["次级地区"]},',
' "importance": 6,',
' "ref": "evt1",',
' "action": "create",',
' "type": "event",',
' "fields": {"title": "简短事件名", "summary": "...", "participants": "...", "status": "ongoing"},',
' "scope": {"layer": "objective", "regionPrimary": "主地区", "regionPath": ["上级地区", "主地区"], "regionSecondary": ["次级地区"]},',
' "storyTime": {"label": "第二天清晨", "tense": "ongoing", "relation": "same", "confidence": "high"},',
' "importance": 6,',
' "ref": "evt1",',
' "links": [',
' {"targetNodeId": "existing-id", "relation": "involved_in", "strength": 0.9},',
' {"targetRef": "char1", "relation": "occurred_at", "strength": 0.8}',
@@ -1430,6 +1541,10 @@ function buildDefaultExtractPrompt(schema) {
"",
"规则:",
"- 每批对话最多创建 1 个事件节点,多个子事件合并为一条",
"- batchStoryTime 表示这批对话主叙事所处的剧情时间;普通当前场景尽量填写,推不出来就留空",
"- operations[].storyTime 用于节点自己的剧情时间;不写时系统会继承 batchStoryTime",
"- 必须区分聊天顺序和剧情顺序,不要把“后说到”误当成“后发生”",
"- flashback / hypothetical / future 可以写 storyTime但通常不要把 advancesActiveTimeline 设为 true",
"- 涉及到的角色都尽量尝试生成对应 POV 记忆和 cognitionUpdates不必强行覆盖全图所有角色",
"- cognitionUpdates 用来表达谁确定知道、谁误解了什么、谁只是模糊可见",
"- 多角色场景里pov_memory 和 cognitionUpdates 必须写清具体人物;不要把角色卡名当作 POV owner",
@@ -1462,6 +1577,8 @@ function buildCognitiveExtractAugmentPrompt() {
"- visibility.score 取 0..11 表示亲历或明确得知0.5 左右表示间接听闻。",
"- regionUpdates.activeRegionHint 只在这批对话明确落到某个地区时填写。",
"- regionUpdates.adjacency 只在文本里明确出现邻接关系时填写,不要猜。",
"- batchStoryTime.label 尽量写成可复用的剧情时间标签,例如“第二天清晨”“昨夜之后”“回忆里的童年时期”。",
"- advancesActiveTimeline 只有在这批确实推动当前主叙事时间线时才写 true。",
"- 若没有认知或空间变化,可返回空数组或空对象,但不要返回无效结构。",
].join("\n");
}
@@ -1493,7 +1610,10 @@ export async function generateSynopsis({
if (eventNodes.length < 3) return;
const eventSummaries = eventNodes
.map((n) => `[楼${n.seq}] ${n.fields.summary || "(无)"}`)
.map((n) => {
const storyLabel = describeNodeStoryTime(n);
return `[楼${n.seq}]${storyLabel ? ` [${storyLabel}]` : ""} ${n.fields.summary || "(无)"}`;
})
.join("\n");
const characterNodes = getActiveNodes(graph, "character");
@@ -1503,8 +1623,16 @@ export async function generateSynopsis({
const threadNodes = getActiveNodes(graph, "thread");
const threadSummary = threadNodes
.map((n) => `${n.fields.title}: ${n.fields.status || "active"}`)
.map((n) => {
const storyLabel = describeNodeStoryTime(n);
return `${n.fields.title}: ${n.fields.status || "active"}${storyLabel ? `${storyLabel}` : ""}`;
})
.join("; ");
const synopsisStoryTimeSpan = deriveStoryTimeSpanFromNodes(
graph,
[...eventNodes, ...threadNodes],
"derived",
);
const synopsisPromptBuild = await buildTaskPrompt(settings, "synopsis", {
taskName: "synopsis",
@@ -1572,6 +1700,7 @@ export async function generateSynopsis({
updateNode(graph, existingSynopsis.id, {
fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` },
seq: Math.max(existingSynopsis.seq || 0, currentSeq),
storyTimeSpan: synopsisStoryTimeSpan,
});
existingSynopsis.seqRange = [
Math.min(existingSynopsis.seqRange?.[0] ?? currentSeq, currentSeq),
@@ -1586,6 +1715,7 @@ export async function generateSynopsis({
seq: currentSeq,
importance: 9.0,
});
node.storyTimeSpan = synopsisStoryTimeSpan;
addNode(graph, node);
debugLog("[ST-BME] 全局概要已创建");
}
@@ -1618,7 +1748,10 @@ export async function generateReflection({
.slice(-5);
const eventSummary = recentEvents
.map((n) => `[楼${n.seq}] ${n.fields.summary || "(无)"}`)
.map((n) => {
const storyLabel = describeNodeStoryTime(n);
return `[楼${n.seq}]${storyLabel ? ` [${storyLabel}]` : ""} ${n.fields.summary || "(无)"}`;
})
.join("\n");
const characterSummary = recentCharacters
.map(
@@ -1629,9 +1762,14 @@ export async function generateReflection({
const threadSummary = recentThreads
.map(
(n) =>
`${n.fields.title || n.fields.name || n.id}: ${n.fields.status || n.fields.summary || "active"}`,
`${n.fields.title || n.fields.name || n.id}: ${n.fields.status || n.fields.summary || "active"}${describeNodeStoryTime(n) ? `${describeNodeStoryTime(n)}` : ""}`,
)
.join("\n");
const reflectionStoryTimeSpan = deriveStoryTimeSpanFromNodes(
graph,
[...recentEvents, ...recentThreads],
"derived",
);
const contradictionSummary = contradictEdges
.map((e) => `${e.fromId} -> ${e.toId} (${e.relation})`)
.join("\n");
@@ -1714,6 +1852,7 @@ export async function generateReflection({
seq: currentSeq,
importance: Math.max(5, Math.min(10, result.importance ?? 7)),
});
reflectionNode.storyTimeSpan = reflectionStoryTimeSpan;
addNode(graph, reflectionNode);
for (const eventNode of recentEvents.slice(-3)) {