blob: 4ce7542226a01a90df5e1c731da35ea34603f7c9 [file]
import { createHash } from 'node:crypto';
import type { RuntimeEvent } from '@maka/core';
/**
* Cross-block shared pure helpers for the context-budget / history-compact /
* synthesis-cache domain. Extracted from `context-budget.ts` so the sibling
* modules can reuse them without a reverse import into `context-budget.ts`.
*
* These are intentionally dependency-free (only `node:crypto` and the
* `RuntimeEvent` type); no domain policy types live here.
*/
export function estimateTokens(chars: number, charsPerToken = 4): number {
if (chars <= 0) return 0;
return Math.ceil(chars / Math.max(1, charsPerToken));
}
export function stableJsonLength(value: unknown): number {
if (value === undefined) return 0;
try {
return JSON.stringify(value)?.length ?? 0;
} catch {
return String(value).length;
}
}
export function estimateRuntimeEventChars(event: RuntimeEvent): number {
let total = 0;
const content = event.content;
if (content?.kind === 'text' || content?.kind === 'thinking') total += content.text.length;
else if (content?.kind === 'function_call')
total += content.name.length + stableJsonLength(content.args);
else if (content?.kind === 'function_response')
total +=
content.name.length +
stableJsonLength(
content.providerExecuted && content.providerOutput !== undefined
? content.providerOutput
: content.result,
);
else if (content?.kind === 'error') total += content.message.length;
return total;
}
export function estimateRuntimeEventsTokens(
events: readonly RuntimeEvent[],
charsPerToken = 4,
): number {
const chars = events.reduce(
(total, event) =>
event.modelVisibility === 'hidden' ? total : total + estimateRuntimeEventChars(event),
0,
);
return estimateTokens(chars, charsPerToken);
}
export function turnKey(event: RuntimeEvent): string {
return event.turnId || '<unknown-turn>';
}
export function groupEventsByTurn(
events: readonly RuntimeEvent[],
charsPerToken: number,
): Array<{
turnId: string;
estimatedTokens: number;
events: RuntimeEvent[];
}> {
const order: string[] = [];
const byTurn = new Map<string, RuntimeEvent[]>();
for (const event of events) {
const key = turnKey(event);
const group = byTurn.get(key);
if (group) group.push(event);
else {
order.push(key);
byTurn.set(key, [event]);
}
}
return order.map((turnId) => ({
turnId,
events: byTurn.get(turnId) ?? [],
estimatedTokens: estimateRuntimeEventsTokens(byTurn.get(turnId) ?? [], charsPerToken),
}));
}
export function uniqueSorted(values: readonly string[]): string[] {
return [...new Set(values.filter((value) => value.length > 0))].sort();
}
export function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
export function stableStringify(value: unknown): string {
if (value === undefined) return '';
try {
return JSON.stringify(value) ?? '';
} catch {
return String(value);
}
}
export function finitePositive(value: number | undefined): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
export function nonEmpty(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
export function allNonEmpty(values: readonly unknown[]): boolean {
return values.every(nonEmpty);
}
export function increment(counts: Record<string, number>, key: string): void {
counts[key] = (counts[key] ?? 0) + 1;
}
export function escapeAttribute(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
}
export function tokenizeSearchQuery(query: string): string[] {
return [
...new Set(
query
.toLowerCase()
.split(/[^a-z0-9_./:-]+/i)
.map((term) => term.trim())
.filter((term) => term.length >= 2),
),
].slice(0, 16);
}
export function finiteRatio(value: number | undefined, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;
return Math.min(1, value);
}
export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
export function boundText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(0, maxChars - 14)).trimEnd()}\n[truncated]`;
}
export function utf8ByteLength(text: string): number {
return Buffer.byteLength(text, 'utf8');
}
export function optionalNonNegativeFiniteNumber(value: unknown): boolean {
return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
}