| 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('&', '&').replaceAll('"', '"').replaceAll('<', '<'); |
| } |
| |
| 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); |
| } |