blob: 1cdb5807c29448d4adbcf6476e1f8dc336b4e634 [file]
import { Buffer } from 'node:buffer';
import { createHash, randomUUID } from 'node:crypto';
import type { ArtifactRecord, ArtifactSource } from '@maka/core';
import { estimateTokens } from './context-budget-helpers.js';
import {
buildHistoryCompactBlockFromSummary,
renderHistoryCompactBlock,
validateHistoryCompactBlockShape,
type HistoryCompactBlock,
type HistoryCompactSourceArchiveRef,
} from './history-compact.js';
import type {
HistoryCompactLoadInput,
HistoryCompactLoadResult,
HistoryCompactWriteInput,
HistoryCompactWriteResult,
} from './ai-sdk-compaction-contract.js';
export interface HistoryCompactArtifactStore {
create(input: {
id?: string;
sessionId: string;
turnId: string;
name: string;
kind: 'file';
content: string;
mimeType?: string;
source: ArtifactSource;
summary?: string;
now?: number;
}): Promise<ArtifactRecord>;
delete(artifactId: string): Promise<void>;
purge(artifactIds: readonly string[]): Promise<void>;
list(sessionId: string, options?: { includeDeleted?: boolean }): Promise<ArtifactRecord[]>;
readText(
artifactId: string,
options?: { maxBytes?: number; includeDeleted?: boolean },
): Promise<{ ok: true; text: string } | { ok: false; reason: string }>;
}
export interface PersistHistoryCompactBlocksDeps {
now?: () => number;
summarize?: (input: HistoryCompactWriteInput) => Promise<string | undefined> | string | undefined;
onArtifactCreated?: (artifact: ArtifactRecord) => void | Promise<void>;
}
export async function persistHistoryCompactBlocksToArtifacts(
artifactStore: Pick<HistoryCompactArtifactStore, 'create' | 'delete'>,
input: HistoryCompactWriteInput,
deps: PersistHistoryCompactBlocksDeps = {},
): Promise<HistoryCompactWriteResult> {
throwIfHistoryCompactAborted(input.abortSignal);
const now = deps.now?.() ?? Date.now();
const sourceArchives = input.source.foldedRuntimeEvents.map((event) => {
const serializedBody = serializeHistoryCompactSourceBody(event.content ?? {});
const bodySha256 = sha256(serializedBody);
const artifactId = randomUUID();
const ref: HistoryCompactSourceArchiveRef = {
runtimeEventId: event.id,
artifactId,
bodySha256,
originalEstimatedTokens: estimateTokens(serializedBody.length, input.limits.charsPerToken),
originalBytes: Buffer.byteLength(serializedBody, 'utf8'),
};
return { event, ref };
});
const sourceArchiveRefs = sourceArchives.map((archive) => archive.ref);
const createdArtifacts: ArtifactRecord[] = [];
const hostSummary = await Promise.resolve(deps.summarize?.(input));
const block = buildHistoryCompactBlockFromSummary({
sessionId: input.sessionId,
foldedRuntimeEvents: input.source.foldedRuntimeEvents,
summary: hostSummary ?? input.source.draftBlock.summary,
highWaterName: input.source.draftBlock.highWaterName,
highWaterSeq: input.source.draftBlock.highWaterSeq,
maxSummaryEstimatedTokens: input.limits.maxBlockEstimatedTokens,
sourceArchiveRefs,
requestShapeHashBefore: input.requestShapeHashBefore,
requestShapeHashAfter: input.requestShapeHashAfter,
now,
charsPerToken: input.limits.charsPerToken,
});
if ((block.estimatedTokens ?? 0) > input.limits.maxBlockEstimatedTokens) {
return { blocks: [], skipped: 1, skippedReasonCounts: { max_block_tokens: 1 } };
}
if ((block.estimatedTokens ?? 0) > input.limits.maxEstimatedTokens) {
return { blocks: [], skipped: 1, skippedReasonCounts: { max_total_tokens: 1 } };
}
try {
for (const { event, ref } of sourceArchives) {
throwIfHistoryCompactAborted(input.abortSignal);
const artifact = await artifactStore.create({
id: ref.artifactId,
sessionId: input.sessionId,
turnId: event.turnId,
name: `history-compact-source-${event.id}.json`,
kind: 'file',
content: JSON.stringify(event, null, 2),
mimeType: 'application/json',
source: 'history_compact_source',
summary: 'Archived RuntimeEvent source for history compact replay',
now,
});
createdArtifacts.push(artifact);
throwIfHistoryCompactAborted(input.abortSignal);
await deps.onArtifactCreated?.(artifact);
throwIfHistoryCompactAborted(input.abortSignal);
}
throwIfHistoryCompactAborted(input.abortSignal);
const artifact = await artifactStore.create({
sessionId: input.sessionId,
turnId: input.turnId,
name: `history-compact-${block.blockId}.json`,
kind: 'file',
content: JSON.stringify(block, null, 2),
mimeType: 'application/json',
source: 'history_compact_block',
summary: 'History compact block for context budget replay',
now,
});
createdArtifacts.push(artifact);
throwIfHistoryCompactAborted(input.abortSignal);
await deps.onArtifactCreated?.(artifact);
throwIfHistoryCompactAborted(input.abortSignal);
return { blocks: [block] };
} catch (error) {
await deleteCreatedArtifacts(artifactStore, createdArtifacts);
throw error;
}
}
export async function loadHistoryCompactBlocksFromArtifacts(
artifactStore: Pick<HistoryCompactArtifactStore, 'list' | 'readText'>,
input: HistoryCompactLoadInput,
): Promise<HistoryCompactLoadResult> {
const maxBlocks = input.maxBlocks ?? 1;
const maxEstimatedTokens = input.maxEstimatedTokens ?? 2_048;
// The block JSON carries per-event provenance and grows with the folded
// event count; cap reads defensively by storage size, not by token budget.
// V1 provenance fan-out produced multi-megabyte blocks in real sessions.
// This path is read-only compatibility; V2 checkpoints stay bounded in the run ledger.
const maxBytes = input.maxBytes ?? 16 * 1_048_576;
const skippedReasonCounts: Record<string, number> = {};
const blocks: HistoryCompactBlock[] = [];
const records = await artifactStore.list(input.sessionId, { includeDeleted: true });
for (const record of records) {
if (record.source !== 'history_compact_block' || record.kind !== 'file') {
continue;
}
if (record.status !== 'live') {
incrementHistoryCompactCount(skippedReasonCounts, 'deleted');
continue;
}
if (record.sessionId !== input.sessionId) {
incrementHistoryCompactCount(skippedReasonCounts, 'session_mismatch');
continue;
}
if (blocks.length >= maxBlocks) {
incrementHistoryCompactCount(skippedReasonCounts, 'max_blocks');
continue;
}
if (record.sizeBytes > maxBytes) {
incrementHistoryCompactCount(skippedReasonCounts, 'max_bytes');
continue;
}
const read = await artifactStore.readText(record.id, { maxBytes });
if (!read.ok) {
incrementHistoryCompactCount(skippedReasonCounts, read.reason);
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(read.text) as unknown;
} catch {
incrementHistoryCompactCount(skippedReasonCounts, 'invalid_json');
continue;
}
if (hasSessionId(parsed) && parsed.sessionId !== input.sessionId) {
incrementHistoryCompactCount(skippedReasonCounts, 'session_mismatch');
continue;
}
if (!validateHistoryCompactBlockShape(parsed, input.sessionId)) {
incrementHistoryCompactCount(skippedReasonCounts, 'invalid_schema_version');
continue;
}
const block = parsed as HistoryCompactBlock;
// Never trust the persisted token estimate: recompute from the rendered
// model-visible text, which is what actually enters the prompt.
const estimatedTokens = estimateTokens(renderHistoryCompactBlock(block).length, 4);
if (estimatedTokens > maxEstimatedTokens) {
incrementHistoryCompactCount(skippedReasonCounts, 'max_total_tokens');
continue;
}
blocks.push({ ...block, estimatedTokens });
}
const skipped = Object.values(skippedReasonCounts).reduce((total, count) => total + count, 0);
return {
blocks,
...(skipped > 0 ? { skipped } : {}),
...(skipped > 0 ? { skippedReasonCounts } : {}),
};
}
async function deleteCreatedArtifacts(
artifactStore: Pick<HistoryCompactArtifactStore, 'delete'>,
artifacts: readonly ArtifactRecord[],
): Promise<void> {
for (const artifact of [...artifacts].reverse()) {
await artifactStore.delete(artifact.id).catch(() => {});
}
}
function throwIfHistoryCompactAborted(signal: AbortSignal | undefined): void {
if (!signal?.aborted) return;
throw signal.reason instanceof Error ? signal.reason : new Error('history compact write aborted');
}
function hasSessionId(value: unknown): value is { sessionId: string } {
return (
!!value &&
typeof value === 'object' &&
'sessionId' in value &&
typeof (value as { sessionId?: unknown }).sessionId === 'string'
);
}
function serializeHistoryCompactSourceBody(value: unknown): string {
if (value === undefined) return '';
try {
return JSON.stringify(value) ?? '';
} catch {
return String(value);
}
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function incrementHistoryCompactCount(counts: Record<string, number>, reason: string): void {
counts[reason] = (counts[reason] ?? 0) + 1;
}