Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/engine/meter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
},
};
Expand Down
22 changes: 22 additions & 0 deletions src/engine/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
42 changes: 39 additions & 3 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -71,13 +72,48 @@ async function main(): Promise<void> {
/* 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<string, unknown> | 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;
Expand Down
46 changes: 44 additions & 2 deletions src/hooks/pretooluse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down Expand Up @@ -107,10 +113,46 @@ function decideConstraintDenial(input: PreToolUseInput, io: PreToolUseIo): Recor
};
}

export function decidePreToolUse(input: PreToolUseInput, io: PreToolUseIo = defaultIo): Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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;
Expand Down
57 changes: 56 additions & 1 deletion tests/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown> = {}) => ({
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<string, unknown>)?.["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<string, unknown>)?.["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<string, unknown>)?.["permissionDecisionReason"] as string;
expect(reason.startsWith("Blocked by project CONSTRAINTS")).toBe(true);
});
});
19 changes: 19 additions & 0 deletions tests/meter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
29 changes: 29 additions & 0 deletions tests/receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => ({
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("$");
});
});
Loading