/** * What one conversation got, and what to do with it. * * Two jobs, both pure: * * 1. **Derive** the small record a tool call leaves in the session log. The * harness persists `output.presentationMeta` verbatim as the `meta` of the * `tool/result` event, so this is the one place a result can state facts * about itself without inventing an event type or a side channel. * 2. **Fold** those records into a running total per conversation. The fold is * the projection unit's transition function, which the framework requires to * be pure and to return the *same reference* for events it does not care * about — an unchanged reference costs nothing downstream, while a fresh * object on every tool call would republish on every step. * * ## Why `sourceBytes` rather than a headline saving * * The honest comparison is between what the caller received and what reading * those same files would have cost. The index already recorded every file's * size when it embedded it, so the second figure costs nothing at query time. * * It is still an **upper bound**, and the numbers are labelled that way * everywhere they surface: a careful agent might have read one of those four * files rather than all four. A figure that flatters the tool is worse than no * figure, because it stops being believed the first time somebody checks. */ /** State for a conversation that has never called the tool. */ export function emptyUsage() { return { version: 1, searches: 0, indexes: 0, failed: 0, excerpts: 0, returnedBytes: 0, sourceBytes: 0, files: 0, weak: 0, // Bytes of avoided context multiplied by the steps that have re-read them. // Accumulated here rather than derived, because deriving it needs a step // count this state does not own — and the fold sees every step anyway. rereadBytes: 0, lastFocus: '', } } /** The context a conversation is carrying that it would not have, in bytes. */ function avoidedBytesOf(state) { return Math.max(0, state.sourceBytes - state.returnedBytes) } /** * The record one call writes into the session log. * * Deliberately tiny: it is appended for every recall call and replayed on every * projection rebuild, so it carries totals and never content. * * @param {{action?: string, query?: string}} args the validated call arguments * @param {Record} value the canonical result * @returns {{recall: Record}} the persisted metadata */ export function deriveUsage(args, value) { const action = value?.action ?? args?.action ?? 'search' const record = { action } if (value?.ok === false) record.failed = 1 if (typeof value?.focus?.path === 'string') record.focus = value.focus.path if (value?.weak === true) record.weak = 1 if (action === 'search' && Array.isArray(value?.results)) { record.excerpts = value.results.length record.returnedBytes = value.results.reduce( (sum, result) => sum + Buffer.byteLength(String(result?.excerpt ?? ''), 'utf8'), 0, ) } if (action === 'index' && typeof value?.index?.embedded === 'number') { record.embedded = value.index.embedded } if (typeof value?.measure?.sourceBytes === 'number') { record.sourceBytes = value.measure.sourceBytes record.files = value.measure.files } return { recall: record } } /** * Fold one session event into the running total. * * @param {ReturnType} state state covering all prior events * @param {{type?: string, meta?: any}} event the next committed session event * @returns the next state, or **the same reference** when not this unit's event */ export function foldUsage(state, event) { // Every step re-reads whatever the conversation is already carrying, so each // step adds the avoided-so-far to the compounding total. This is what makes // the figure exact rather than an estimate the caller has to supply. if (event?.type === 'step/start') { const carried = avoidedBytesOf(state) if (carried === 0) return state return { ...state, rereadBytes: state.rereadBytes + carried } } const record = event?.type === 'tool/result' ? event.meta?.recall : undefined if (record === undefined || record === null || typeof record !== 'object') return state const next = { ...state, searches: state.searches + (record.action === 'search' ? 1 : 0), indexes: state.indexes + (record.action === 'index' ? 1 : 0), failed: state.failed + (record.failed === 1 ? 1 : 0), excerpts: state.excerpts + (record.excerpts ?? 0), returnedBytes: state.returnedBytes + (record.returnedBytes ?? 0), sourceBytes: state.sourceBytes + (record.sourceBytes ?? 0), files: state.files + (record.files ?? 0), weak: state.weak + (record.weak === 1 ? 1 : 0), lastFocus: typeof record.focus === 'string' ? record.focus : state.lastFocus, } return next } /** * The derived figures a surface shows, computed from state. * * Kept out of the state itself so the persisted fold stays a plain running * total: anything derivable is derived on read, which is what lets the formula * change without invalidating every checkpoint in the cache. * * @param {ReturnType} state folded totals * @param {{cacheReadPricePerMillion?: number}} [assumptions] * @returns {Record} */ export function usageSummary(state, assumptions = {}) { const { cacheReadPricePerMillion = 0.007 } = assumptions // What a read would have cost, less what the excerpt did cost. const avoidedBytes = avoidedBytesOf(state) // bytes/4 is an approximation of tokens; labelled as one wherever it is shown. const avoidedTokens = Math.round(avoidedBytes / 4) const compression = state.sourceBytes > 0 ? state.sourceBytes / Math.max(1, state.returnedBytes) : 0 // The compounding figure: context kept out, re-read once per step since. const rereadTokens = Math.round((state.rereadBytes ?? 0) / 4) const avoidedUsd = ((avoidedTokens + rereadTokens) / 1_000_000) * cacheReadPricePerMillion return { searches: state.searches, indexes: state.indexes, failed: state.failed, excerpts: state.excerpts, files: state.files, weak: state.weak, returnedBytes: state.returnedBytes, sourceBytes: state.sourceBytes, avoidedBytes, avoidedTokens, compression: Number(compression.toFixed(2)), rereadTokens, avoidedUsd: Number(avoidedUsd.toFixed(4)), lastFocus: state.lastFocus, } }