diff --git a/src/engine/meter.ts b/src/engine/meter.ts index d259e26..eb822df 100644 --- a/src/engine/meter.ts +++ b/src/engine/meter.ts @@ -38,6 +38,8 @@ export interface MeterReading { /** Billing surface (Gap 8): api = pay-per-token (optimize OUTPUT tokens hard), * plan = subscription (context window is the constraint), unknown = can't tell. */ billingMode: "api" | "plan" | "unknown"; + /** G5: last seen model name (when known) — the receipt keys $ rates off it. */ + model?: string; } /** @@ -112,6 +114,8 @@ interface State { savedTokens: number; /** Window adopted from the request's model id (proxy path). */ modelWindowTokens?: number; + /** G5: last seen model NAME (proxy onModel / host probe) — receipt rates key off it. */ + lastModel?: string; /** Last time any traffic hit the meter — cache-staleness signal. */ lastActivityTs?: number; } @@ -200,12 +204,17 @@ export function createMeter(root: string, opts: MeterOptions = {}): Meter { }, onModel(model) { const w = modelWindow(model); - if (w === null) return; reload(); - if (state.modelWindowTokens !== w) { + let dirty = false; + if (state.lastModel !== model) { + state.lastModel = model; + dirty = true; + } + if (w !== null && state.modelWindowTokens !== w) { state.modelWindowTokens = w; - save(); + dirty = true; } + if (dirty) save(); }, read() { reload(); @@ -262,10 +271,10 @@ export function createMeter(root: string, opts: MeterOptions = {}): Meter { if (status !== "ok") advice += billingHint(billingMode); // Report the EFFECTIVE window so the dashboard/meter show the honest // denominator (e.g. "420k / 1M"), not the stale configured 200k. - return { usedTokens, windowTokens: effectiveWindow, usedPct, savedTokens: state.savedTokens, optimizationPct, estimated, cacheCold, status, advice, billingMode }; + return { usedTokens, windowTokens: effectiveWindow, usedPct, savedTokens: state.savedTokens, optimizationPct, estimated, cacheCold, status, advice, billingMode, ...(state.lastModel ? { model: state.lastModel } : {}) }; }, reset() { - state = { lastRequestTokens: 0, toolTokens: 0, savedTokens: state.savedTokens }; + state = { lastRequestTokens: 0, toolTokens: 0, savedTokens: state.savedTokens, ...(state.lastModel ? { lastModel: state.lastModel } : {}) }; save(); }, }; diff --git a/src/engine/receipt.ts b/src/engine/receipt.ts index ae416e0..a87f1eb 100644 --- a/src/engine/receipt.ts +++ b/src/engine/receipt.ts @@ -162,6 +162,18 @@ export function recordRedirect(root: string, path: string): void { } } +/** G5: input-token $ per MTok — static, DATED; update alongside provider + * price changes. Used ONLY for api-billing sessions (plan cost is quota, not $). */ +const RATES_AS_OF = "2026-07"; +const RATE_PER_MTOK: Array<{ match: RegExp; usd: number }> = [ + { match: /fable|opus/i, usd: 15 }, + { match: /sonnet/i, usd: 3 }, + { match: /haiku/i, usd: 1 }, + { match: /gpt-5/i, usd: 10 }, + { match: /gpt-4o|gpt-4\.1/i, usd: 5 }, + { match: /gemini/i, usd: 2.5 }, +]; + export interface ReceiptInput { meter: MeterReading; mark: SessionMark | null; @@ -239,6 +251,16 @@ export function buildReceipt(i: ReceiptInput): string { // G6 forecast: only when it's a plan-billed session, we have a marker, the // session has run long enough to trust a burn rate, and there's something // to project (avoided > 0) — otherwise it's noise or a divide-by-garbage. + // G5 dollars: api-billing only — plan users' cost is quota, never shown $. + if (meter.billingMode === "api" && avoided > 0 && meter.model) { + const rate = RATE_PER_MTOK.find((r) => r.match.test(meter.model!)); + if (rate) { + lines.push( + `≈ $${((avoided / 1_000_000) * rate.usd).toFixed(2)} avoided at ${meter.model} input rates (as of ${RATES_AS_OF}, estimate)`, + ); + } + } + if (meter.billingMode === "plan" && mark && avoided > 0) { const sessionMs = now() - Date.parse(mark.startTs); const hours = sessionMs / 3_600_000; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 6899bc7..7049635 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -11,7 +11,8 @@ * * Hooks must NEVER break the host: any internal error exits 0 silently. */ -import { existsSync, statSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { createHash } from "node:crypto"; import { createFileCCRStore } from "../ccr/store.js"; import { createMemory } from "../engine/memory.js"; import { createMeter } from "../engine/meter.js"; @@ -23,7 +24,7 @@ import { currentContextTokens, currentContextModel } from "../engine/usage.js"; import { ccrRoot, memoryRoot, meterRoot, wikiRoot, loopStatePath, activityRoot, feedbackRoot } from "../paths.js"; import { decideLoopStop } from "./stop.js"; import { decidePostToolUse, type PostToolUseInput } from "./posttooluse.js"; -import { decidePreToolUse, type PreToolUseInput } from "./pretooluse.js"; +import { decidePreToolUse, defaultPreToolUseIo, type PreToolUseInput } from "./pretooluse.js"; import { sessionStartOutput } from "./sessionstart.js"; import { mineNewTranscripts } from "../learn.js"; import { GOAL_LOOP_NUDGE } from "../platforms.js"; @@ -71,13 +72,48 @@ async function main(): Promise { /* receipt bookkeeping — never break the host */ } } - const decision = decidePreToolUse(preInput); + // G4 io: session reads-map entry + current mtime + CCR content-hash + // probe. All fail-open — any error means the decide sees null and allows. + const decision = decidePreToolUse(preInput, { + ...defaultPreToolUseIo, // keeps readWorkflow → constraint denial intact + readEntry: (fp) => { + try { + return readSessionMark(meterRoot())?.reads[fp] ?? null; + } catch { + return null; + } + }, + mtimeOf: (fp) => statSync(fp).mtimeMs, + recallHandleFor: (fp) => { + try { + const h = createHash("sha256").update(readFileSync(fp, "utf8"), "utf8").digest("hex"); + return createFileCCRStore(ccrRoot()).has(h) ? h : null; + } catch { + return null; + } + }, + }); // Distinguish the LARGE-READ redirect deny from a CONSTRAINTS deny by // reason text — decidePreToolUse's redirect reason always starts with // "Large file"; the constraints reason starts with "Blocked by project". if (decision) { const hso = decision["hookSpecificOutput"] as Record | undefined; const reason = hso?.["permissionDecisionReason"] as string | undefined; + if (typeof reason === "string" && reason.startsWith("unchanged since last read") && typeof preInput.tool_input?.file_path === "string") { + try { + createActivityLog(activityRoot(), { protectSince: () => readSessionMark(meterRoot())?.startTs ?? null }).record({ + agent: "hook", + tool: "Read", + summary: "served from recall (repeat read)", + saved: 0, // honest math: counted only when the recall is retrieved + source: "hook", + kind: "redirect", + file: preInput.tool_input.file_path, + }); + } catch { + /* receipt bookkeeping — never break the host */ + } + } if (typeof reason === "string" && reason.startsWith("Large file") && typeof preInput.tool_input?.file_path === "string") { try { const p = preInput.tool_input.file_path; diff --git a/src/hooks/pretooluse.ts b/src/hooks/pretooluse.ts index e7c359a..362c3e7 100644 --- a/src/hooks/pretooluse.ts +++ b/src/hooks/pretooluse.ts @@ -54,9 +54,15 @@ export interface PreToolUseIo { exists: (p: string) => boolean; sizeOf: (p: string) => number; readWorkflow?: () => string | null; + /** G4 (all optional, fail-open): session reads-map entry for a path. */ + readEntry?: (path: string) => { count: number; mtimeMs: number } | null; + /** Current mtime — belt+braces against a change racing the reads map. */ + mtimeOf?: (path: string) => number; + /** sha256 of the file's exact bytes IF that content is already in CCR. */ + recallHandleFor?: (path: string) => string | null; } -const defaultIo: PreToolUseIo = { +export const defaultPreToolUseIo: PreToolUseIo = { exists: existsSync, sizeOf: (p) => statSync(p).size, readWorkflow: () => { @@ -107,10 +113,46 @@ function decideConstraintDenial(input: PreToolUseInput, io: PreToolUseIo): Recor }; } -export function decidePreToolUse(input: PreToolUseInput, io: PreToolUseIo = defaultIo): Record | null { +/** + * G4: repeat-read of an UNCHANGED file whose exact content already lives in + * CCR → deny with the real, resolvable recall handle. Never blocks fresh + * content: any missing io, changed mtime, absent CCR entry, or error → null. + * Honest math: the deny itself claims saved:0 — savings count only when the + * recall is actually retrieved. + */ +export function decideRepeatReadRecall(input: PreToolUseInput, io: PreToolUseIo): Record | null { + if (input.tool_name !== "Read") return null; + const path = input.tool_input?.file_path; + if (typeof path !== "string" || path.length === 0) return null; + if (!io.readEntry || !io.mtimeOf || !io.recallHandleFor) return null; + try { + const entry = io.readEntry(path); + // recordRead has already run for THIS attempt, so count>=2 = genuine + // repeat; mtime equality guards a change racing the reads map. + if (!entry || entry.count < 2 || entry.mtimeMs !== io.mtimeOf(path)) return null; + const handle = io.recallHandleFor(path); + if (!handle) return null; + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: `unchanged since last read — the exact content is already in recall. Retrieve ⟨recall:${handle}⟩ (knitbrain_retrieve) instead of re-reading; byte-exact.`, + }, + }; + } catch { + return null; + } +} + +export function decidePreToolUse(input: PreToolUseInput, io: PreToolUseIo = defaultPreToolUseIo): Record | null { const constraintDenial = decideConstraintDenial(input, io); if (constraintDenial) return constraintDenial; + // G4 repeat-read recall beats the large-file redirect: if the content is + // already stored, serving the handle is strictly better than a redirect. + const recallDenial = decideRepeatReadRecall(input, io); + if (recallDenial) return recallDenial; + if (input.tool_name !== "Read") return null; const path = input.tool_input?.file_path; if (typeof path !== "string" || path.length === 0) return null; diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 33750b2..6b74962 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { decidePreToolUse, READ_REDIRECT_BYTES } from "../src/hooks/pretooluse.js"; +import { decidePreToolUse, decideRepeatReadRecall, READ_REDIRECT_BYTES } from "../src/hooks/pretooluse.js"; import { decideLoopStop } from "../src/hooks/stop.js"; import { applyArtifacts, claudeArtifacts } from "../src/platforms.js"; import { generateConfig } from "../src/setup.js"; @@ -302,3 +302,58 @@ describe("decidePostToolUse — onSaved info callback", () => { } }); }); + +describe("G4 repeat-read recall (writer never re-reads unchanged content)", () => { + const H = "a".repeat(64); + const read = (fp: string) => ({ tool_name: "Read", tool_input: { file_path: fp } }); + const io = (over: Record = {}) => ({ + exists: () => true, + sizeOf: () => 100, + readEntry: () => ({ count: 2, mtimeMs: 111 }), + mtimeOf: () => 111, + recallHandleFor: () => H, + ...over, + }); + + it("count>=2 + same mtime + handle in CCR → deny with the exact resolvable handle", () => { + const d = decideRepeatReadRecall(read("/p/a.ts"), io()); + const reason = (d?.["hookSpecificOutput"] as Record)?.["permissionDecisionReason"] as string; + expect(reason).toContain(`⟨recall:${H}⟩`); + expect(reason.startsWith("unchanged since last read")).toBe(true); + }); + + it("first read (count 1) → allow", () => { + expect(decideRepeatReadRecall(read("/p/a.ts"), io({ readEntry: () => ({ count: 1, mtimeMs: 111 }) }))).toBeNull(); + }); + + it("mtime changed since the reads-map entry → allow (fresh content never blocked)", () => { + expect(decideRepeatReadRecall(read("/p/a.ts"), io({ mtimeOf: () => 222 }))).toBeNull(); + }); + + it("content not in CCR → allow", () => { + expect(decideRepeatReadRecall(read("/p/a.ts"), io({ recallHandleFor: () => null }))).toBeNull(); + }); + + it("legacy io without the G4 fns → allow (fail-open, old callers unchanged)", () => { + expect(decideRepeatReadRecall(read("/p/a.ts"), { exists: () => true, sizeOf: () => 100 })).toBeNull(); + }); + + it("throwing io → allow (fail-open)", () => { + expect(decideRepeatReadRecall(read("/p/a.ts"), io({ readEntry: () => { throw new Error("x"); } }))).toBeNull(); + }); + + it("repeat-read recall WINS over the large-file redirect via decidePreToolUse", () => { + const d = decidePreToolUse(read("/p/big.ts"), io({ sizeOf: () => READ_REDIRECT_BYTES + 1 })); + const reason = (d?.["hookSpecificOutput"] as Record)?.["permissionDecisionReason"] as string; + expect(reason.startsWith("unchanged since last read")).toBe(true); // not "Large file" + }); + + it("constraint denial still WINS over repeat-read (Bash isn't a Read anyway; Write path check)", () => { + const d = decidePreToolUse( + { tool_name: "Bash", tool_input: { command: "npm publish" } }, + io({ readWorkflow: () => "CONSTRAINTS: never publish without OK" }), + ); + const reason = (d?.["hookSpecificOutput"] as Record)?.["permissionDecisionReason"] as string; + expect(reason.startsWith("Blocked by project CONSTRAINTS")).toBe(true); + }); +}); diff --git a/tests/meter.test.ts b/tests/meter.test.ts index 8f96a95..5d75704 100644 --- a/tests/meter.test.ts +++ b/tests/meter.test.ts @@ -281,3 +281,22 @@ describe("meter estimate + cache-cold (MCP-only honesty)", () => { expect(m.read().cacheCold).toBe(false); }); }); + +describe("G5: model name persistence", () => { + it("onModel persists the NAME; a fresh instance reads it back; reset keeps it", async () => { + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { createMeter } = await import("../src/engine/meter.js"); + const root = mkdtempSync(join(tmpdir(), "kb-meter-model-")); + try { + createMeter(root).onModel("claude-sonnet-5"); + const m2 = createMeter(root); + expect(m2.read().model).toBe("claude-sonnet-5"); + m2.reset(); + expect(createMeter(root).read().model).toBe("claude-sonnet-5"); // survives reset like savings + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/receipt.test.ts b/tests/receipt.test.ts index ba6ede7..96c8d70 100644 --- a/tests/receipt.test.ts +++ b/tests/receipt.test.ts @@ -191,3 +191,32 @@ describe("G3 cold-restart waste line", () => { expect(r).not.toContain("idle gap"); }); }); + +describe("G5 dollar conversion (api-billing only)", () => { + const meter = (over: Record = {}) => ({ + usedTokens: 100_000, windowTokens: 1_000_000, usedPct: 10, savedTokens: 2_000_000, + optimizationPct: 5, estimated: false, cacheCold: false, status: "ok" as const, + advice: "", billingMode: "api" as const, model: "claude-sonnet-5", ...over, + }); + const mark = { startTs: new Date(0).toISOString(), savedAtStart: 0, usedAtStart: 0, retrievalsAtStart: 0, reads: {}, redirects: {} }; + const base = { mark, events: [], eventsTrimmed: false, retrievalsTotal: 0 }; + + it("api + known model → exact $ arithmetic, labeled estimate (2M tok @ $3/MTok = $6.00)", async () => { + const { buildReceipt } = await import("../src/engine/receipt.js"); + const r = buildReceipt({ meter: meter(), ...base }); + expect(r).toContain("$6.00"); + expect(r).toContain("estimate"); + }); + + it("plan billing → NO dollars ever", async () => { + const { buildReceipt } = await import("../src/engine/receipt.js"); + const r = buildReceipt({ meter: meter({ billingMode: "plan" }), ...base }); + expect(r).not.toContain("$"); + }); + + it("api + unknown model → no $ line", async () => { + const { buildReceipt } = await import("../src/engine/receipt.js"); + const r = buildReceipt({ meter: meter({ model: "mystery-llm-9000" }), ...base }); + expect(r).not.toContain("$"); + }); +});