Files
ST-Bionic-Memory-Ecology/graph/story-timeline.js
2026-04-08 22:41:49 +08:00

916 lines
30 KiB
JavaScript

const STORY_TENSE_VALUES = new Set([
"past",
"ongoing",
"future",
"flashback",
"hypothetical",
"unknown",
]);
const STORY_RELATION_VALUES = new Set([
"same",
"after",
"before",
"parallel",
"unknown",
]);
const STORY_CONFIDENCE_VALUES = new Set(["high", "medium", "low"]);
const STORY_SOURCE_VALUES = new Set(["extract", "derived", "manual"]);
export const STORY_TIMELINE_VERSION = 1;
export const STORY_TEMPORAL_BUCKETS = Object.freeze({
CURRENT: "current",
ADJACENT_PAST: "adjacentPast",
DISTANT_PAST: "distantPast",
FLASHBACK: "flashback",
FUTURE: "future",
UNDATED: "undated",
});
function normalizeString(value) {
return String(value ?? "").trim();
}
function normalizeKey(value) {
return normalizeString(value)
.replace(/\s+/g, " ")
.toLowerCase();
}
function normalizeEnum(value, allowed, fallback) {
const normalized = normalizeString(value);
return allowed.has(normalized) ? normalized : fallback;
}
function uniqueStrings(values = []) {
const result = [];
const seen = new Set();
for (const value of Array.isArray(values) ? values : [values]) {
const normalized = normalizeString(value);
const key = normalizeKey(normalized);
if (!normalized || seen.has(key)) continue;
seen.add(key);
result.push(normalized);
}
return result;
}
function buildMatcherKey(label = "", anchorLabel = "", relation = "unknown") {
return [normalizeKey(label), normalizeKey(anchorLabel), normalizeString(relation)]
.filter(Boolean)
.join("::");
}
function buildStorySegmentId() {
const native = globalThis.crypto?.randomUUID?.();
if (native) return `tl-${native}`;
const suffix = Math.random().toString(36).slice(2, 10);
return `tl-${Date.now().toString(36)}-${suffix}`;
}
export function createDefaultStoryTime(overrides = {}) {
return {
segmentId: normalizeString(overrides.segmentId),
label: normalizeString(overrides.label),
tense: normalizeEnum(overrides.tense, STORY_TENSE_VALUES, "unknown"),
relation: normalizeEnum(
overrides.relation,
STORY_RELATION_VALUES,
"unknown",
),
anchorLabel: normalizeString(overrides.anchorLabel),
confidence: normalizeEnum(
overrides.confidence,
STORY_CONFIDENCE_VALUES,
"medium",
),
source: normalizeEnum(overrides.source, STORY_SOURCE_VALUES, "derived"),
};
}
export function createDefaultStoryTimeSpan(overrides = {}) {
return {
startSegmentId: normalizeString(overrides.startSegmentId),
endSegmentId: normalizeString(overrides.endSegmentId),
startLabel: normalizeString(overrides.startLabel),
endLabel: normalizeString(overrides.endLabel),
mixed: overrides.mixed === true,
source: normalizeEnum(overrides.source, STORY_SOURCE_VALUES, "derived"),
};
}
export function createDefaultTimelineSegment(overrides = {}) {
const label = normalizeString(overrides.label);
const anchorLabel = normalizeString(overrides.anchorLabel);
const relationToParent = normalizeEnum(
overrides.relationToParent,
STORY_RELATION_VALUES,
"unknown",
);
return {
id: normalizeString(overrides.id) || buildStorySegmentId(),
label,
normalizedKey: normalizeKey(overrides.normalizedKey || label),
matcherKey:
normalizeString(overrides.matcherKey) ||
buildMatcherKey(label, anchorLabel, relationToParent),
order: Number.isFinite(Number(overrides.order))
? Math.max(1, Math.trunc(Number(overrides.order)))
: 1,
aliases: uniqueStrings(overrides.aliases),
parentId: normalizeString(overrides.parentId),
relationToParent,
anchorLabel,
confidence: normalizeEnum(
overrides.confidence,
STORY_CONFIDENCE_VALUES,
"medium",
),
source: normalizeEnum(overrides.source, STORY_SOURCE_VALUES, "derived"),
updatedAt: Number.isFinite(Number(overrides.updatedAt))
? Number(overrides.updatedAt)
: 0,
};
}
export function createDefaultTimelineState(overrides = {}) {
return {
version: STORY_TIMELINE_VERSION,
segments: Array.isArray(overrides.segments) ? overrides.segments : [],
nextOrder: Number.isFinite(Number(overrides.nextOrder))
? Math.max(1, Math.trunc(Number(overrides.nextOrder)))
: 1,
manualActiveSegmentId: normalizeString(overrides.manualActiveSegmentId),
lastExtractedSegmentId: normalizeString(overrides.lastExtractedSegmentId),
recentSegmentIds: uniqueStrings(overrides.recentSegmentIds).slice(0, 12),
};
}
export function normalizeStoryTime(value = {}, defaults = {}) {
return createDefaultStoryTime({
...defaults,
...(value && typeof value === "object" && !Array.isArray(value) ? value : {}),
});
}
export function normalizeStoryTimeSpan(value = {}, defaults = {}) {
return createDefaultStoryTimeSpan({
...defaults,
...(value && typeof value === "object" && !Array.isArray(value) ? value : {}),
});
}
export function normalizeTimelineState(state = {}) {
const normalized = createDefaultTimelineState(state);
const segments = [];
const seenIds = new Set();
for (const rawSegment of Array.isArray(normalized.segments)
? normalized.segments
: []) {
const segment = createDefaultTimelineSegment(rawSegment);
if (!segment.label || seenIds.has(segment.id)) continue;
seenIds.add(segment.id);
segments.push(segment);
}
segments.sort((left, right) => {
if ((left.order || 0) !== (right.order || 0)) {
return (left.order || 0) - (right.order || 0);
}
return String(left.updatedAt || 0).localeCompare(String(right.updatedAt || 0));
});
const nextOrder = Math.max(
normalized.nextOrder || 1,
segments.reduce((max, segment) => Math.max(max, Number(segment.order) || 0), 0) +
1,
);
return {
version: STORY_TIMELINE_VERSION,
segments,
nextOrder,
manualActiveSegmentId: normalized.manualActiveSegmentId,
lastExtractedSegmentId: normalized.lastExtractedSegmentId,
recentSegmentIds: uniqueStrings(normalized.recentSegmentIds)
.filter((segmentId) => segments.some((segment) => segment.id === segmentId))
.slice(0, 12),
};
}
export function normalizeNodeStoryTimeline(node, defaults = {}) {
if (!node || typeof node !== "object") return node;
node.storyTime = normalizeStoryTime(node.storyTime, defaults.storyTime || {});
node.storyTimeSpan = normalizeStoryTimeSpan(
node.storyTimeSpan,
defaults.storyTimeSpan || {},
);
return node;
}
export function normalizeGraphStoryTimeline(graph) {
if (!graph || typeof graph !== "object") return graph;
graph.timelineState = normalizeTimelineState(graph.timelineState);
if (Array.isArray(graph.nodes)) {
graph.nodes.forEach((node) => normalizeNodeStoryTimeline(node));
}
return graph;
}
function pushRecentSegment(timelineState, segmentId = "") {
const normalizedSegmentId = normalizeString(segmentId);
if (!normalizedSegmentId) return;
timelineState.recentSegmentIds = [
normalizedSegmentId,
...timelineState.recentSegmentIds.filter((value) => value !== normalizedSegmentId),
].slice(0, 12);
}
function getTimelineState(graphOrState) {
return graphOrState?.timelineState && typeof graphOrState.timelineState === "object"
? graphOrState.timelineState
: graphOrState;
}
export function getTimelineSegmentById(graphOrState, segmentId = "") {
const timelineState = getTimelineState(graphOrState);
const normalizedSegmentId = normalizeString(segmentId);
if (!normalizedSegmentId) return null;
return (
(Array.isArray(timelineState?.segments) ? timelineState.segments : []).find(
(segment) => segment.id === normalizedSegmentId,
) || null
);
}
export function findTimelineSegmentByLabel(graphOrState, label = "") {
const timelineState = getTimelineState(graphOrState);
const normalizedLabelKey = normalizeKey(label);
if (!normalizedLabelKey) return null;
return (
(Array.isArray(timelineState?.segments) ? timelineState.segments : []).find(
(segment) =>
segment.normalizedKey === normalizedLabelKey ||
(Array.isArray(segment.aliases) &&
segment.aliases.some((alias) => normalizeKey(alias) === normalizedLabelKey)),
) || null
);
}
function getTimelineSegmentOrder(graphOrState, segmentId = "") {
return Number(getTimelineSegmentById(graphOrState, segmentId)?.order || 0) || null;
}
function shiftSegmentOrders(timelineState, minOrder, delta = 1) {
for (const segment of timelineState.segments || []) {
if ((Number(segment.order) || 0) >= minOrder) {
segment.order = Math.max(1, (Number(segment.order) || 1) + delta);
}
}
}
function createStoryTimeMatcher(storyTime = {}) {
return buildMatcherKey(
storyTime.label,
storyTime.anchorLabel,
storyTime.relation || "unknown",
);
}
export function resolveTimelineSegment(graphOrState, storyTime = {}) {
const timelineState = getTimelineState(graphOrState);
const normalizedStoryTime = normalizeStoryTime(storyTime);
if (!normalizedStoryTime.segmentId && !normalizedStoryTime.label) {
return null;
}
if (normalizedStoryTime.segmentId) {
const byId = getTimelineSegmentById(timelineState, normalizedStoryTime.segmentId);
if (byId) return byId;
}
const matcherKey = createStoryTimeMatcher(normalizedStoryTime);
const segments = Array.isArray(timelineState?.segments) ? timelineState.segments : [];
if (matcherKey) {
const byMatcher = segments.find((segment) => segment.matcherKey === matcherKey);
if (byMatcher) return byMatcher;
}
return findTimelineSegmentByLabel(timelineState, normalizedStoryTime.label);
}
export function upsertTimelineSegment(
graph,
storyTime = {},
{ referenceSegmentId = "", source = "extract" } = {},
) {
if (!graph || typeof graph !== "object") {
return {
segment: null,
storyTime: createDefaultStoryTime(storyTime),
created: false,
reused: false,
};
}
graph.timelineState = normalizeTimelineState(graph.timelineState);
const timelineState = graph.timelineState;
const normalizedStoryTime = normalizeStoryTime(storyTime, { source });
if (!normalizedStoryTime.label && !normalizedStoryTime.segmentId) {
return {
segment: null,
storyTime: normalizedStoryTime,
created: false,
reused: false,
};
}
const existing = resolveTimelineSegment(timelineState, normalizedStoryTime);
if (existing) {
if (normalizedStoryTime.label && existing.label !== normalizedStoryTime.label) {
existing.aliases = uniqueStrings([
...(existing.aliases || []),
normalizedStoryTime.label,
]);
}
existing.updatedAt = Date.now();
pushRecentSegment(timelineState, existing.id);
return {
segment: existing,
storyTime: normalizeStoryTime({
...normalizedStoryTime,
segmentId: existing.id,
label: existing.label || normalizedStoryTime.label,
}),
created: false,
reused: true,
};
}
const referenceSegment = getTimelineSegmentById(timelineState, referenceSegmentId);
const relation = normalizedStoryTime.relation || "unknown";
let desiredOrder = Number(timelineState.nextOrder || 1) || 1;
if (referenceSegment) {
if (relation === "same" || relation === "parallel") {
desiredOrder = Number(referenceSegment.order || desiredOrder) || desiredOrder;
} else if (relation === "after") {
desiredOrder = (Number(referenceSegment.order || 0) || 0) + 1;
shiftSegmentOrders(timelineState, desiredOrder, 1);
} else if (relation === "before") {
desiredOrder = Math.max(1, Number(referenceSegment.order || 1) || 1);
shiftSegmentOrders(timelineState, desiredOrder, 1);
}
}
const createdSegment = createDefaultTimelineSegment({
label: normalizedStoryTime.label || normalizedStoryTime.anchorLabel || "未命名时间段",
order: desiredOrder,
aliases: [normalizedStoryTime.label],
parentId:
relation === "after" ||
relation === "before" ||
relation === "same" ||
relation === "parallel"
? normalizeString(referenceSegment?.id)
: "",
relationToParent: relation,
anchorLabel: normalizedStoryTime.anchorLabel,
confidence: normalizedStoryTime.confidence,
source,
updatedAt: Date.now(),
});
timelineState.segments.push(createdSegment);
timelineState.segments.sort((left, right) => (left.order || 0) - (right.order || 0));
timelineState.nextOrder = Math.max(
Number(timelineState.nextOrder || 1),
...timelineState.segments.map((segment) => Number(segment.order || 0) + 1),
);
pushRecentSegment(timelineState, createdSegment.id);
return {
segment: createdSegment,
storyTime: normalizeStoryTime({
...normalizedStoryTime,
segmentId: createdSegment.id,
label: createdSegment.label,
source,
}),
created: true,
reused: false,
};
}
export function createSpanFromStoryTime(storyTime = {}, source = "derived") {
const normalizedStoryTime = normalizeStoryTime(storyTime, { source });
if (!normalizedStoryTime.segmentId && !normalizedStoryTime.label) {
return createDefaultStoryTimeSpan({ source });
}
return createDefaultStoryTimeSpan({
startSegmentId: normalizedStoryTime.segmentId,
endSegmentId: normalizedStoryTime.segmentId,
startLabel: normalizedStoryTime.label,
endLabel: normalizedStoryTime.label,
mixed: false,
source,
});
}
export function deriveStoryTimeSpanFromNodes(graph, nodes = [], source = "derived") {
const safeNodes = (Array.isArray(nodes) ? nodes : []).filter(Boolean);
if (safeNodes.length === 0) {
return createDefaultStoryTimeSpan({ source });
}
const points = [];
for (const node of safeNodes) {
const storyTime = normalizeStoryTime(node?.storyTime);
const storyTimeSpan = normalizeStoryTimeSpan(node?.storyTimeSpan);
if (storyTime.segmentId || storyTime.label) {
points.push({
type: "point",
segmentId: storyTime.segmentId,
label: storyTime.label,
order: getTimelineSegmentOrder(graph, storyTime.segmentId),
seq: Number(node?.seq ?? 0) || 0,
});
}
if (storyTimeSpan.startSegmentId || storyTimeSpan.startLabel) {
points.push({
type: "span-start",
segmentId: storyTimeSpan.startSegmentId,
label: storyTimeSpan.startLabel,
order: getTimelineSegmentOrder(graph, storyTimeSpan.startSegmentId),
seq: Number(node?.seq ?? 0) || 0,
});
}
if (storyTimeSpan.endSegmentId || storyTimeSpan.endLabel) {
points.push({
type: "span-end",
segmentId: storyTimeSpan.endSegmentId,
label: storyTimeSpan.endLabel,
order: getTimelineSegmentOrder(graph, storyTimeSpan.endSegmentId),
seq: Number(node?.seq ?? 0) || 0,
});
}
}
if (points.length === 0) {
return createDefaultStoryTimeSpan({ source });
}
points.sort((left, right) => {
const leftOrder = Number.isFinite(left.order) ? left.order : Number.MAX_SAFE_INTEGER;
const rightOrder = Number.isFinite(right.order)
? right.order
: Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return (left.seq || 0) - (right.seq || 0);
});
const first = points[0];
const last = points[points.length - 1];
const uniqueLabels = new Set(points.map((point) => normalizeKey(point.label)).filter(Boolean));
const uniqueSegments = new Set(
points.map((point) => normalizeString(point.segmentId)).filter(Boolean),
);
return createDefaultStoryTimeSpan({
startSegmentId: first.segmentId,
endSegmentId: last.segmentId,
startLabel: first.label,
endLabel: last.label,
mixed: uniqueLabels.size > 1 || uniqueSegments.size > 1,
source,
});
}
function resolveSegmentFromHistory(graph) {
const historyState = graph?.historyState || {};
const activeSegmentId = normalizeString(historyState.activeStorySegmentId);
if (activeSegmentId) {
const byId = getTimelineSegmentById(graph, activeSegmentId);
if (byId) return { segment: byId, source: normalizeString(historyState.activeStoryTimeSource) || "history" };
}
const activeLabel = normalizeString(historyState.activeStoryTimeLabel);
if (activeLabel) {
const byLabel = findTimelineSegmentByLabel(graph, activeLabel);
if (byLabel) return { segment: byLabel, source: normalizeString(historyState.activeStoryTimeSource) || "history" };
}
const extractedSegmentId = normalizeString(historyState.lastExtractedStorySegmentId);
if (extractedSegmentId) {
const byExtracted = getTimelineSegmentById(graph, extractedSegmentId);
if (byExtracted) return { segment: byExtracted, source: "extract" };
}
return null;
}
export function resolveActiveStoryContext(graph, preferred = {}) {
const timelineState = normalizeTimelineState(graph?.timelineState);
const preferredSegmentId = normalizeString(preferred.segmentId);
const preferredLabel = normalizeString(preferred.label);
if (timelineState.manualActiveSegmentId) {
const manualSegment = getTimelineSegmentById(timelineState, timelineState.manualActiveSegmentId);
if (manualSegment) {
return {
activeSegmentId: manualSegment.id,
activeStoryTimeLabel: manualSegment.label,
source: "manual",
segment: manualSegment,
resolved: true,
};
}
}
if (preferredSegmentId) {
const preferredSegment = getTimelineSegmentById(timelineState, preferredSegmentId);
if (preferredSegment) {
return {
activeSegmentId: preferredSegment.id,
activeStoryTimeLabel: preferredSegment.label,
source: "runtime",
segment: preferredSegment,
resolved: true,
};
}
}
if (preferredLabel) {
const preferredSegment = findTimelineSegmentByLabel(timelineState, preferredLabel);
if (preferredSegment) {
return {
activeSegmentId: preferredSegment.id,
activeStoryTimeLabel: preferredSegment.label,
source: "runtime",
segment: preferredSegment,
resolved: true,
};
}
}
const historyMatch = resolveSegmentFromHistory(graph);
if (historyMatch?.segment) {
return {
activeSegmentId: historyMatch.segment.id,
activeStoryTimeLabel: historyMatch.segment.label,
source: historyMatch.source,
segment: historyMatch.segment,
resolved: true,
};
}
const recentSegmentId = timelineState.recentSegmentIds.find((segmentId) =>
Boolean(getTimelineSegmentById(timelineState, segmentId)),
);
if (recentSegmentId) {
const recentSegment = getTimelineSegmentById(timelineState, recentSegmentId);
return {
activeSegmentId: recentSegment.id,
activeStoryTimeLabel: recentSegment.label,
source: "recent",
segment: recentSegment,
resolved: true,
};
}
return {
activeSegmentId: "",
activeStoryTimeLabel: "",
source: "",
segment: null,
resolved: false,
};
}
export function applyBatchStoryTime(graph, batchStoryTime = {}, source = "extract") {
if (!graph || typeof graph !== "object") {
return {
ok: false,
activeSegmentId: "",
activeStoryTimeLabel: "",
timelineAdvanceApplied: false,
extractedSegmentId: "",
};
}
graph.timelineState = normalizeTimelineState(graph.timelineState);
graph.historyState ||= {};
const normalizedBatch = normalizeStoryTime(batchStoryTime, { source });
if (!normalizedBatch.label && !normalizedBatch.segmentId) {
return {
ok: false,
activeSegmentId: normalizeString(graph.historyState.activeStorySegmentId),
activeStoryTimeLabel: normalizeString(graph.historyState.activeStoryTimeLabel),
timelineAdvanceApplied: false,
extractedSegmentId: "",
};
}
const activeContext = resolveActiveStoryContext(graph);
const upserted = upsertTimelineSegment(graph, normalizedBatch, {
referenceSegmentId: activeContext.activeSegmentId,
source,
});
const storyTime = upserted.storyTime;
const shouldAdvance =
batchStoryTime?.advancesActiveTimeline === true &&
!["future", "hypothetical", "flashback"].includes(storyTime.tense);
graph.timelineState.lastExtractedSegmentId = storyTime.segmentId || "";
pushRecentSegment(graph.timelineState, storyTime.segmentId);
graph.historyState.lastExtractedStorySegmentId = storyTime.segmentId || "";
if (shouldAdvance) {
graph.historyState.activeStorySegmentId = storyTime.segmentId || "";
graph.historyState.activeStoryTimeLabel = storyTime.label || "";
graph.historyState.activeStoryTimeSource = source;
} else if (
!normalizeString(graph.historyState.activeStorySegmentId) &&
storyTime.segmentId
) {
graph.historyState.activeStorySegmentId = storyTime.segmentId;
graph.historyState.activeStoryTimeLabel = storyTime.label || "";
graph.historyState.activeStoryTimeSource = source;
}
return {
ok: true,
activeSegmentId: normalizeString(graph.historyState.activeStorySegmentId),
activeStoryTimeLabel: normalizeString(graph.historyState.activeStoryTimeLabel),
timelineAdvanceApplied: shouldAdvance,
extractedSegmentId: storyTime.segmentId || "",
storyTime,
};
}
export function isStoryTimeCompatible(leftNode, rightNode) {
const leftStoryTime = normalizeStoryTime(leftNode?.storyTime);
const rightStoryTime = normalizeStoryTime(rightNode?.storyTime);
const leftSpan = normalizeStoryTimeSpan(leftNode?.storyTimeSpan);
const rightSpan = normalizeStoryTimeSpan(rightNode?.storyTimeSpan);
const leftIds = [
leftStoryTime.segmentId,
leftSpan.startSegmentId,
leftSpan.endSegmentId,
].filter(Boolean);
const rightIds = [
rightStoryTime.segmentId,
rightSpan.startSegmentId,
rightSpan.endSegmentId,
].filter(Boolean);
if (leftIds.length === 0 || rightIds.length === 0) {
return { compatible: true, reason: "undated" };
}
const overlaps = leftIds.some((segmentId) => rightIds.includes(segmentId));
if (overlaps) {
return { compatible: true, reason: "overlap" };
}
return { compatible: false, reason: "different-story-segment" };
}
export function describeStoryTime(storyTime = {}) {
const normalized = normalizeStoryTime(storyTime);
if (!normalized.label) return "";
const parts = [normalized.label];
if (normalized.tense && normalized.tense !== "unknown") {
parts.push(normalized.tense);
}
return parts.join(" · ");
}
export function describeStoryTimeSpan(storyTimeSpan = {}) {
const normalized = normalizeStoryTimeSpan(storyTimeSpan);
if (!normalized.startLabel && !normalized.endLabel) return "";
if (
normalized.startLabel &&
normalized.endLabel &&
normalized.startLabel !== normalized.endLabel
) {
return `${normalized.startLabel} -> ${normalized.endLabel}`;
}
return normalized.startLabel || normalized.endLabel || "";
}
export function describeNodeStoryTime(node = {}) {
return (
describeStoryTime(node.storyTime) ||
describeStoryTimeSpan(node.storyTimeSpan) ||
""
);
}
export function resolveStoryCueMode(userMessage = "", recentMessages = []) {
const text = [userMessage, ...(Array.isArray(recentMessages) ? recentMessages : [])]
.map((value) => normalizeString(value))
.filter(Boolean)
.join("\n");
if (!text) return "";
if (/(曾经|以前|当年|从前|回忆|过去|背景|来历|小时候|往事)/i.test(text)) {
return "flashback";
}
if (/(未来|以后|之后会|将要|明天|预告|计划|打算|准备|承诺)/i.test(text)) {
return "future";
}
return "";
}
export function classifyStoryTemporalBucket(
graph,
node,
{ activeSegmentId = "", cueMode = "" } = {},
) {
const storyTime = normalizeStoryTime(node?.storyTime);
const storyTimeSpan = normalizeStoryTimeSpan(node?.storyTimeSpan);
const activeOrder = getTimelineSegmentOrder(graph, activeSegmentId);
const pointOrder = getTimelineSegmentOrder(graph, storyTime.segmentId);
const spanStartOrder = getTimelineSegmentOrder(graph, storyTimeSpan.startSegmentId);
const spanEndOrder = getTimelineSegmentOrder(graph, storyTimeSpan.endSegmentId);
const hasStoryTime = Boolean(
storyTime.segmentId ||
storyTime.label ||
storyTimeSpan.startSegmentId ||
storyTimeSpan.startLabel ||
storyTimeSpan.endSegmentId ||
storyTimeSpan.endLabel,
);
if (!hasStoryTime) {
return {
bucket: STORY_TEMPORAL_BUCKETS.UNDATED,
weight: 0.88,
suppressed: false,
rescued: false,
reason: "undated",
};
}
if (storyTime.tense === "future" || storyTime.tense === "hypothetical") {
const allowFutureCue = cueMode === "future";
return {
bucket: STORY_TEMPORAL_BUCKETS.FUTURE,
weight: allowFutureCue ? 0.72 : 0.2,
suppressed: !allowFutureCue,
rescued: false,
reason: allowFutureCue ? "future-cue" : "future-suppressed",
};
}
if (!Number.isFinite(activeOrder)) {
return {
bucket: STORY_TEMPORAL_BUCKETS.UNDATED,
weight: 0.92,
suppressed: false,
rescued: false,
reason: "no-active-story-time",
};
}
const effectiveStart = Number.isFinite(spanStartOrder) ? spanStartOrder : pointOrder;
const effectiveEnd = Number.isFinite(spanEndOrder) ? spanEndOrder : pointOrder;
if (Number.isFinite(effectiveStart) && Number.isFinite(effectiveEnd)) {
if (activeOrder >= effectiveStart && activeOrder <= effectiveEnd) {
return {
bucket: STORY_TEMPORAL_BUCKETS.CURRENT,
weight: 1.15,
suppressed: false,
rescued: false,
reason: "span-current",
};
}
if (effectiveEnd < activeOrder) {
const distance = activeOrder - effectiveEnd;
if (storyTime.tense === "flashback" || cueMode === "flashback") {
return {
bucket: STORY_TEMPORAL_BUCKETS.FLASHBACK,
weight: cueMode === "flashback" ? 1.02 : 0.72,
suppressed: false,
rescued: cueMode === "flashback",
reason: cueMode === "flashback" ? "flashback-rescued" : "flashback",
};
}
return {
bucket:
distance <= 2
? STORY_TEMPORAL_BUCKETS.ADJACENT_PAST
: STORY_TEMPORAL_BUCKETS.DISTANT_PAST,
weight: distance <= 2 ? 1.0 : 0.64,
suppressed: false,
rescued: false,
reason: distance <= 2 ? "adjacent-past" : "distant-past",
};
}
if (effectiveStart > activeOrder) {
const allowFutureCue = cueMode === "future";
return {
bucket: STORY_TEMPORAL_BUCKETS.FUTURE,
weight: allowFutureCue ? 0.74 : 0.22,
suppressed: !allowFutureCue,
rescued: false,
reason: allowFutureCue ? "future-cue" : "future-suppressed",
};
}
}
return {
bucket: STORY_TEMPORAL_BUCKETS.UNDATED,
weight: 0.9,
suppressed: false,
rescued: false,
reason: "temporal-unknown",
};
}
export function setManualActiveStorySegment(
graph,
{ segmentId = "", label = "" } = {},
) {
if (!graph || typeof graph !== "object") {
return { ok: false, reason: "missing-graph", activeStorySegmentId: "", activeStoryTimeLabel: "" };
}
graph.timelineState = normalizeTimelineState(graph.timelineState);
graph.historyState ||= {};
let segment = null;
if (segmentId) {
segment = getTimelineSegmentById(graph, segmentId);
}
if (!segment && label) {
segment = findTimelineSegmentByLabel(graph, label);
}
if (!segment && label) {
const upserted = upsertTimelineSegment(
graph,
{ label, relation: "same", confidence: "low", source: "manual" },
{ source: "manual" },
);
segment = upserted.segment;
}
graph.timelineState.manualActiveSegmentId = segment?.id || "";
graph.historyState.activeStorySegmentId = segment?.id || "";
graph.historyState.activeStoryTimeLabel = segment?.label || "";
graph.historyState.activeStoryTimeSource = segment ? "manual" : "";
if (segment?.id) {
pushRecentSegment(graph.timelineState, segment.id);
}
return {
ok: true,
activeStorySegmentId: graph.historyState.activeStorySegmentId || "",
activeStoryTimeLabel: graph.historyState.activeStoryTimeLabel || "",
};
}
export function clearManualActiveStorySegment(graph) {
if (!graph || typeof graph !== "object") {
return { ok: false, reason: "missing-graph" };
}
graph.timelineState = normalizeTimelineState(graph.timelineState);
graph.historyState ||= {};
graph.timelineState.manualActiveSegmentId = "";
const fallback = resolveSegmentFromHistory({
...graph,
historyState: {
...(graph.historyState || {}),
activeStorySegmentId: "",
activeStoryTimeLabel: "",
activeStoryTimeSource: "",
},
});
graph.historyState.activeStorySegmentId = fallback?.segment?.id || "";
graph.historyState.activeStoryTimeLabel = fallback?.segment?.label || "";
graph.historyState.activeStoryTimeSource = fallback?.source || "";
return {
ok: true,
activeStorySegmentId: graph.historyState.activeStorySegmentId || "",
activeStoryTimeLabel: graph.historyState.activeStoryTimeLabel || "",
};
}
export function setNodeStoryTimeManual(graph, nodeId = "", storyTime = {}) {
if (!graph || typeof graph !== "object") {
return { ok: false, reason: "missing-graph" };
}
const node = Array.isArray(graph.nodes)
? graph.nodes.find((candidate) => candidate?.id === normalizeString(nodeId))
: null;
if (!node) {
return { ok: false, reason: "node-not-found" };
}
const normalizedStoryTime = normalizeStoryTime(storyTime, {
source: "manual",
confidence: "medium",
});
if (!normalizedStoryTime.label && !normalizedStoryTime.segmentId) {
node.storyTime = createDefaultStoryTime();
node.storyTimeSpan = createDefaultStoryTimeSpan();
return { ok: true, nodeId: node.id, storyTime: node.storyTime };
}
const activeSegmentId = normalizeString(graph?.historyState?.activeStorySegmentId);
const upserted = upsertTimelineSegment(graph, normalizedStoryTime, {
referenceSegmentId: activeSegmentId,
source: "manual",
});
node.storyTime = upserted.storyTime;
node.storyTimeSpan = createDefaultStoryTimeSpan();
return { ok: true, nodeId: node.id, storyTime: node.storyTime };
}