evOo-local/packages/core/src/message-pages.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

81 lines
2.5 KiB
TypeScript

interface MessageIdentity {
id: string;
seq?: number;
}
export interface ThreadHistory<TMessage extends MessageIdentity> {
threadId: string;
messages: readonly TMessage[];
olderCursor: number | null;
}
export function mergeThreadHistory<
TMessage extends MessageIdentity,
TSnapshot extends ThreadHistory<TMessage>,
>(previous: TSnapshot | null, recent: TSnapshot, preserveLoadedHistory = false): TSnapshot {
if (!previous || previous.threadId !== recent.threadId || !preserveLoadedHistory) return recent;
return {
...recent,
messages: mergeMessagePages(previous.messages, recent.messages),
olderCursor: previous.olderCursor,
};
}
export function prependThreadHistoryPage<
TMessage extends MessageIdentity,
TSnapshot extends ThreadHistory<TMessage>,
>(previous: TSnapshot | null, page: ThreadHistory<TMessage>): TSnapshot | null {
if (!previous || previous.threadId !== page.threadId || previous.olderCursor == null) {
return previous;
}
return {
...previous,
messages: mergeMessagesById(page.messages, previous.messages),
olderCursor: page.olderCursor,
};
}
export function mergeMessagePages<T extends MessageIdentity>(
previous: readonly T[],
recent: readonly T[],
): T[] {
const firstRecentSeq = recent.reduce<number | null>((first, message) => {
if (!isDurableMessage(message) || typeof message.seq !== "number") return first;
return Math.min(first ?? message.seq, message.seq);
}, null);
const retained =
firstRecentSeq === null
? []
: previous.filter(
(message) =>
isDurableMessage(message) &&
typeof message.seq === "number" &&
message.seq < firstRecentSeq,
);
return mergeMessagesById(retained, recent);
}
export function mergeMessagesById<T extends { id: string }>(
first: readonly T[],
second: readonly T[],
): T[] {
const seen = new Set<string>();
return [...first, ...second].filter((message) => {
if (seen.has(message.id)) return false;
seen.add(message.id);
return true;
});
}
export function upsertMessageById<T extends { id: string }>(messages: readonly T[], next: T): T[] {
const index = messages.findIndex((message) => message.id === next.id);
if (index < 0) return [...messages, next];
const updated = [...messages];
updated[index] = next;
return updated;
}
function isDurableMessage(message: { id: string }): boolean {
return !message.id.startsWith("progress:") && !message.id.startsWith("subagent:");
}