blob: b6945700d1c2ecb2fc2efcd157da4e6639443c30 [file]
import type { ArtifactRecord, ArtifactSource } from '@maka/core';
import { estimateTokens } from './context-budget-helpers.js';
import {
buildSynthesisCacheBlocksFromHydratedArchives,
validateSynthesisCacheBlockShape,
type SynthesisCacheBlock,
} from './synthesis-cache.js';
import type {
SynthesisCacheLoadInput,
SynthesisCacheLoadResult,
SynthesisCacheWriteInput,
SynthesisCacheWriteResult,
} from './ai-sdk-compaction-contract.js';
/**
* The synthesis-cache persistence glue lives here (rather than on a desktop or
* headless surface) so every embedder shares one implementation — mirroring the
* sibling `history-compact-artifacts` module. It is typed against the
* *structural* artifact-store contract below instead of importing
* `@maka/storage`, keeping runtime storage-agnostic; `@maka/storage`'s
* `ArtifactStore` satisfies this interface, so desktop and headless both pass
* their real store unchanged.
*/
export interface SynthesisCacheArtifactStore {
create(input: {
id?: string;
sessionId: string;
turnId: string;
name: string;
kind: 'file';
content: string;
mimeType?: string;
source: ArtifactSource;
summary?: string;
now?: number;
}): Promise<ArtifactRecord>;
list(sessionId: string, options?: { includeDeleted?: boolean }): Promise<ArtifactRecord[]>;
readText(
artifactId: string,
options?: { maxBytes?: number },
): Promise<{ ok: true; text: string } | { ok: false; reason: string }>;
}
export interface PersistSynthesisCacheBlocksDeps {
now?: () => number;
onArtifactCreated?: (artifact: ArtifactRecord) => void | Promise<void>;
}
export async function persistSynthesisCacheBlocksToArtifacts(
artifactStore: Pick<SynthesisCacheArtifactStore, 'create'>,
input: SynthesisCacheWriteInput,
deps: PersistSynthesisCacheBlocksDeps = {},
): Promise<SynthesisCacheWriteResult> {
const now = deps.now?.() ?? Date.now();
const built = buildSynthesisCacheBlocksFromHydratedArchives({
sessionId: input.sessionId,
query: input.source.query,
hydratedRuntimeEvents: input.source.hydratedRuntimeEvents,
retrievedArchiveRefs: input.source.retrievedArchiveRefs,
archiveRetrievalMode: input.source.archiveRetrievalMode,
limits: input.limits,
...(input.requestShapeHashBefore
? { requestShapeHashBefore: input.requestShapeHashBefore }
: {}),
...(input.requestShapeHashAfter ? { requestShapeHashAfter: input.requestShapeHashAfter } : {}),
now,
});
const persisted: SynthesisCacheBlock[] = [];
for (const block of built.blocks) {
const artifact = await artifactStore.create({
sessionId: input.sessionId,
turnId: input.turnId,
name: `synthesis-cache-${block.blockId}.json`,
kind: 'file',
content: JSON.stringify(block, null, 2),
mimeType: 'application/json',
source: 'synthesis_cache_block',
summary: 'Synthesis cache block for context budget replay',
now,
});
await deps.onArtifactCreated?.(artifact);
persisted.push(block);
}
return {
blocks: persisted,
...(built.skipped > 0 ? { skipped: built.skipped } : {}),
...(built.skippedReasonCounts ? { skippedReasonCounts: built.skippedReasonCounts } : {}),
};
}
export async function loadSynthesisCacheBlocksFromArtifacts(
artifactStore: Pick<SynthesisCacheArtifactStore, 'list' | 'readText'>,
input: SynthesisCacheLoadInput,
): Promise<SynthesisCacheLoadResult> {
const maxBlocks = input.maxBlocks ?? 1;
const maxEstimatedTokens = input.maxEstimatedTokens ?? 2_048;
const maxBytes = input.maxBytes ?? maxEstimatedTokens * 4;
const skippedReasonCounts: Record<string, number> = {};
const blocks: SynthesisCacheBlock[] = [];
const records = await artifactStore.list(input.sessionId, { includeDeleted: true });
for (const record of records) {
if (record.status !== 'live') {
incrementSynthesisCacheCount(skippedReasonCounts, 'deleted');
continue;
}
if (record.source !== 'synthesis_cache_block' || record.kind !== 'file') {
continue;
}
if (record.sessionId !== input.sessionId) {
incrementSynthesisCacheCount(skippedReasonCounts, 'session_mismatch');
continue;
}
if (blocks.length >= maxBlocks) {
incrementSynthesisCacheCount(skippedReasonCounts, 'max_blocks');
continue;
}
if (record.sizeBytes > maxBytes) {
incrementSynthesisCacheCount(skippedReasonCounts, 'max_bytes');
continue;
}
const read = await artifactStore.readText(record.id, { maxBytes });
if (!read.ok) {
incrementSynthesisCacheCount(skippedReasonCounts, read.reason);
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(read.text) as unknown;
} catch {
incrementSynthesisCacheCount(skippedReasonCounts, 'invalid_json');
continue;
}
if (hasSessionId(parsed) && parsed.sessionId !== input.sessionId) {
incrementSynthesisCacheCount(skippedReasonCounts, 'session_mismatch');
continue;
}
if (!validateSynthesisCacheBlockShape(parsed, input.sessionId)) {
incrementSynthesisCacheCount(skippedReasonCounts, 'invalid_schema_version');
continue;
}
const block = parsed as SynthesisCacheBlock;
const estimatedTokens = block.estimatedTokens ?? estimateTokens(read.text.length, 4);
if (estimatedTokens > maxEstimatedTokens) {
incrementSynthesisCacheCount(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 } : {}),
};
}
function hasSessionId(value: unknown): value is { sessionId: string } {
return (
!!value &&
typeof value === 'object' &&
'sessionId' in value &&
typeof (value as { sessionId?: unknown }).sessionId === 'string'
);
}
function incrementSynthesisCacheCount(counts: Record<string, number>, reason: string): void {
counts[reason] = (counts[reason] ?? 0) + 1;
}