This commit is contained in:
NotExpectedYet committed 2026-09-26 05:35:29 +01:00
commit 0964819cdc
34 files changed
+7912

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
/**
* 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<string, any>} value the canonical result
* @returns {{recall: Record<string, any>}} 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<typeof emptyUsage>} 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<typeof emptyUsage>} state folded totals
* @param {{cacheReadPricePerMillion?: number}} [assumptions]
* @returns {Record<string, number|string|boolean>}
*/
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,
}
}