blob: 77872f06ce7503a94835dcf788e74490d1423c7e [file]
import type { RuntimeEvent } from '@maka/core';
import type { ModelMessage } from './model-protocol.js';
export interface MemoryExtractionEventEntry {
readonly ordinal: number;
readonly event: RuntimeEvent;
}
export interface MemoryExtractionEvidence {
readonly sourceRef: string;
readonly type: 'user_message';
/** Exact bounded text shown to the model and used for admission. */
readonly text: string;
readonly events: readonly RuntimeEvent[];
/** Full text actually visible in indexed Provider user messages. */
readonly providerVisibleTexts?: readonly string[];
}
export interface MemoryCoveragePlan {
readonly entries: readonly MemoryExtractionEventEntry[];
readonly evidence: readonly MemoryExtractionEvidence[];
}
export const MAX_MEMORY_EVIDENCE_JSON_CHARS = 12_000;
const MAX_EVIDENCE_TEXT_CHARS = 4_000;
const MIN_EVIDENCE_TEXT_CHARS = 64;
const MAX_LOCALIZED_TURNS = 7;
/**
* Plan one complete trigger range. Tool and Runtime-control Events deliberately
* produce no evidence, but remain in the range so the Session Cursor crosses
* them instead of repeatedly reconsidering them. If every User evidence record
* cannot fit the bounded Evidence Index, fail closed instead of silently
* consuming only part of the range.
*/
export function planMemoryCoverage(input: {
readonly pendingEntries: readonly MemoryExtractionEventEntry[];
readonly priorityEvidence?: readonly MemoryExtractionEvidence[];
readonly maxEvidenceJsonChars?: number;
readonly sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>;
readonly sourceMessages?: readonly ModelMessage[];
}): MemoryCoveragePlan | undefined {
if (input.pendingEntries.length === 0) return undefined;
const priority = bindProviderVisibleEvidence(
input.priorityEvidence ?? [],
input.sourceMessages,
input.sourceEventMessagePositions,
);
const budget = input.maxEvidenceJsonChars ?? MAX_MEMORY_EVIDENCE_JSON_CHARS;
const fittedPriority = fitMemoryExtractionEvidence(
priority,
budget,
input.sourceEventMessagePositions,
);
if (!fittedPriority) return undefined;
const coverage = bindProviderVisibleEvidence(
projectMemoryExtractionEvidence(input.pendingEntries.map(({ event }) => event)),
input.sourceMessages,
input.sourceEventMessagePositions,
);
const fitted = fitCoverageAroundPriority(
fittedPriority,
coverage,
budget,
input.sourceEventMessagePositions,
);
if (!fitted) return undefined;
const fittedRefs = new Set(fitted.map(({ sourceRef }) => sourceRef));
if (coverage.some(({ sourceRef }) => !fittedRefs.has(sourceRef))) return undefined;
return { entries: [...input.pendingEntries], evidence: fitted };
}
/** Projects only stable user-authored text into Memory evidence. */
export function projectMemoryExtractionEvidence(
events: readonly RuntimeEvent[],
options: {
readonly snippetTerms?: readonly string[];
} = {},
): readonly MemoryExtractionEvidence[] {
const stable = events.filter((event) => !event.partial);
const projected: MemoryExtractionEvidence[] = [];
for (const event of stable) {
const content = event.content;
if (!content) continue;
if (content.kind === 'text' && event.role === 'user' && event.author === 'user') {
const fullText = normalizeEvidenceText(content.text);
if (!fullText) continue;
projected.push({
sourceRef: `event:${event.id}`,
type: 'user_message',
text: boundedEvidenceText(fullText, options.snippetTerms),
events: [event],
});
}
}
return projected;
}
/**
* Keep every evidence record represented while shrinking supplemental text to
* the actual serialized JSON budget. Returning undefined means even the record
* identities cannot fit and the Cursor must not advance.
*/
export function fitMemoryExtractionEvidence(
evidence: readonly MemoryExtractionEvidence[],
maxJsonChars = MAX_MEMORY_EVIDENCE_JSON_CHARS,
sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>,
): readonly MemoryExtractionEvidence[] | undefined {
if (!Number.isSafeInteger(maxJsonChars) || maxJsonChars < 1) return undefined;
if (memoryExtractionEvidenceJsonSize(evidence, sourceEventMessagePositions) <= maxJsonChars) {
return evidence;
}
let low = MIN_EVIDENCE_TEXT_CHARS;
let high = MAX_EVIDENCE_TEXT_CHARS;
let best: readonly MemoryExtractionEvidence[] | undefined;
while (low <= high) {
const cap = Math.floor((low + high) / 2);
const candidate = evidence.map((entry) => ({
...entry,
text: sliceCodePoints(entry.text, cap),
}));
if (memoryExtractionEvidenceJsonSize(candidate, sourceEventMessagePositions) <= maxJsonChars) {
best = candidate;
low = cap + 1;
} else {
high = cap - 1;
}
}
return best;
}
export function memoryExtractionEvidenceJsonSize(
evidence: readonly MemoryExtractionEvidence[],
sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>,
): number {
return JSON.stringify(renderMemoryExtractionEvidence(evidence, sourceEventMessagePositions))
.length;
}
export function renderMemoryExtractionEvidence(
evidence: readonly MemoryExtractionEvidence[],
sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>,
) {
return evidence.map(({ sourceRef, type, text, events, providerVisibleTexts }) => {
const messagePositions = uniqueSorted(
events.flatMap((event) => sourceEventMessagePositions?.[event.id] ?? []),
);
const everyEventIndexed = providerVisibleTexts !== undefined;
return {
sourceRef,
type,
observedAt: minuteTimestamp(Math.max(0, ...events.map((event) => event.ts))),
...(everyEventIndexed ? { messagePositions } : { text }),
};
});
}
export function bindProviderVisibleEvidence(
evidence: readonly MemoryExtractionEvidence[],
sourceMessages: readonly ModelMessage[] | undefined,
sourceEventMessagePositions: Readonly<Record<string, readonly number[]>> | undefined,
): readonly MemoryExtractionEvidence[] {
if (!sourceMessages || !sourceEventMessagePositions) return evidence;
return evidence.map((entry) => {
const positions = uniqueSorted(
entry.events.flatMap((event) => sourceEventMessagePositions[event.id] ?? []),
);
if (
positions.length === 0 ||
entry.events.some((event) => (sourceEventMessagePositions[event.id]?.length ?? 0) === 0)
) {
return entry;
}
const visibleByPosition = positions.map((position) => {
const message = sourceMessages[position];
if (!message || message.role !== 'user') return [];
if (typeof message.content === 'string') {
const text = normalizeEvidenceText(message.content);
return text ? [text] : [];
}
return message.content.flatMap((part) => {
if (part.type !== 'text') return [];
const text = normalizeEvidenceText(part.text);
return text ? [text] : [];
});
});
if (visibleByPosition.some((texts) => texts.length === 0)) {
return entry;
}
return { ...entry, providerVisibleTexts: visibleByPosition.flat() };
});
}
/** Preserve requested evidence once fitted; all coverage records must fit. */
function fitCoverageAroundPriority(
priority: readonly MemoryExtractionEvidence[],
coverage: readonly MemoryExtractionEvidence[],
maxJsonChars: number,
sourceEventMessagePositions?: Readonly<Record<string, readonly number[]>>,
): readonly MemoryExtractionEvidence[] | undefined {
const priorityRefs = new Set(priority.map(({ sourceRef }) => sourceRef));
const remaining = coverage.filter(({ sourceRef }) => !priorityRefs.has(sourceRef));
const merged = [...priority, ...remaining];
if (memoryExtractionEvidenceJsonSize(merged, sourceEventMessagePositions) <= maxJsonChars) {
return merged;
}
if (remaining.length === 0) return undefined;
const fit = (entries: readonly MemoryExtractionEvidence[]) => {
let low = MIN_EVIDENCE_TEXT_CHARS;
let high = MAX_EVIDENCE_TEXT_CHARS;
let best: readonly MemoryExtractionEvidence[] | undefined;
while (low <= high) {
const cap = Math.floor((low + high) / 2);
const candidate = [
...priority,
...entries.map((entry) => ({ ...entry, text: sliceCodePoints(entry.text, cap) })),
];
if (
memoryExtractionEvidenceJsonSize(candidate, sourceEventMessagePositions) <= maxJsonChars
) {
best = candidate;
low = cap + 1;
} else {
high = cap - 1;
}
}
return best;
};
return fit(remaining);
}
/** Rank matching Turns by term coverage and recency, then add a one-Turn neighborhood. */
export function searchSameSessionMemoryHistory(
entries: readonly MemoryExtractionEventEntry[],
throughOrdinal: number,
search: { readonly terms: readonly string[]; readonly roles?: readonly string[] },
): readonly MemoryExtractionEventEntry[] {
const eligible = entries.filter(
({ ordinal, event }) =>
ordinal <= throughOrdinal &&
!event.partial &&
event.content?.kind === 'text' &&
event.role === 'user' &&
event.author === 'user',
);
const turns: Array<{ key: string; entries: MemoryExtractionEventEntry[] }> = [];
for (const entry of eligible) {
const key = `${entry.event.runId}\0${entry.event.turnId}`;
const last = turns.at(-1);
if (last?.key === key) last.entries.push(entry);
else turns.push({ key, entries: [entry] });
}
const terms = search.terms.map((term) => normalizeEvidenceText(term).toLowerCase());
const allowedRoles = search.roles ? new Set(search.roles) : undefined;
const hits = turns
.map((turn, index) => ({
index,
score: terms.filter((term) =>
turn.entries.some(({ event }) => {
if (allowedRoles && !allowedRoles.has(historyRole(event))) return false;
return normalizeEvidenceText(event.content?.kind === 'text' ? event.content.text : '')
.toLowerCase()
.includes(term);
}),
).length,
}))
.filter(({ score }) => score > 0)
.sort((left, right) => right.score - left.score || right.index - left.index);
const selected = new Set<number>();
for (const hit of hits) {
for (const index of [hit.index, hit.index - 1, hit.index + 1]) {
if (index < 0 || index >= turns.length || selected.has(index)) continue;
if (selected.size >= MAX_LOCALIZED_TURNS) break;
selected.add(index);
}
if (selected.size >= MAX_LOCALIZED_TURNS) break;
}
return [...selected]
.sort((left, right) => left - right)
.flatMap((index) => turns[index]!.entries);
}
export function isMemoryToolName(name: string): boolean {
return name === 'memory_remember' || name === 'memory_extract';
}
function boundedEvidenceText(value: string, terms: readonly string[] | undefined): string {
const codePoints = Array.from(value);
if (codePoints.length <= MAX_EVIDENCE_TEXT_CHARS) return value;
const normalizedTerms = terms
?.map((term) => normalizeEvidenceText(term).toLowerCase())
.filter(Boolean);
const lower = value.toLowerCase();
const hit = normalizedTerms
?.map((term) => lower.indexOf(term))
.filter((index) => index >= 0)
.sort((left, right) => left - right)[0];
if (hit === undefined) return codePoints.slice(0, MAX_EVIDENCE_TEXT_CHARS).join('');
const before = Math.floor(MAX_EVIDENCE_TEXT_CHARS / 3);
const start = Math.max(0, Array.from(value.slice(0, hit)).length - before);
return codePoints.slice(start, start + MAX_EVIDENCE_TEXT_CHARS).join('');
}
function normalizeEvidenceText(value: string): string {
return value.normalize('NFC').replace(/\s+/g, ' ').trim();
}
function minuteTimestamp(value: number): number {
return Math.floor(value / 60_000) * 60_000;
}
function historyRole(_event: RuntimeEvent): 'user' {
return 'user';
}
function sliceCodePoints(value: string, maximum: number): string {
return Array.from(value).slice(0, maximum).join('');
}
function uniqueSorted(values: readonly number[]): number[] {
return [...new Set(values)].sort((left, right) => left - right);
}