From 1833934309100d7865faf496dc423b1beac4cb2b Mon Sep 17 00:00:00 2001 From: PDgit12 Date: Mon, 6 Jul 2026 18:35:53 +0530 Subject: [PATCH] =?UTF-8?q?feat(receipt):=20honest=20session=20receipt=20?= =?UTF-8?q?=E2=80=94=20G1=20attribution,=20stop-hook=20receipt,=20hygiene,?= =?UTF-8?q?=20forecast,=20trust=20stats=20+=20goal.md=20decomposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/engine/activity.ts: ActivityEvent gains optional source/file/rawTokens/ storedTokens/kind (legacy lines parse); CAP 1000 behind a 300KB size gate; since(ts) with rotation-honest trimmed flag (only claims at CAP scale); session-aware trim via protectSince (ceiling 5000) - src/engine/receipt.ts (new): pure buildReceipt (consumed/avoided, top-5 sinks, hygiene offenders, integrity, plan-only labeled forecast, lifetime trust stats, honest-zero no-claims path) + session marker & hygiene helpers with bounded-CAS writes (newer-mtime-wins merge so a reset survives a race) - src/engine/onboard.ts: goalCheckboxes decomposes per-domain from goal clauses; no meaningful split -> ONE checkbox + coverage note (kills the 9x-duplicate goal.md) - hooks: sessionstart writes the session mark; pretooluse tracks repeat-reads + redirects (saved:0 by contract — knitbrain_read counts once); posttooluse records source:hook ledger events; stop emits the receipt as systemMessage through the adapters (claude/codex/vscode; cursor/gemini stay silent — their stop channels are behavioral contracts, not display surfaces) - mcp: capture/pendingLedger attribute optimize+read savings; context_meter returns the receipt mid-session; load_session marks hook-less hosts - proxy: records source:proxy ledger events; paths honor KNITBRAIN_PROJECT_DIR so helper processes land in the right project 560 tests, rm -rf dist && npm run verify EXIT=0 --- src/engine/activity.ts | 89 +++++++++++-- src/engine/onboard.ts | 48 +++++-- src/engine/receipt.ts | 267 +++++++++++++++++++++++++++++++++++++++ src/hooks/adapters.ts | 20 +++ src/hooks/index.ts | 93 +++++++++++++- src/hooks/posttooluse.ts | 4 +- src/mcp/tools.ts | 81 ++++++++++-- src/paths.ts | 7 +- src/proxy/index.ts | 23 +++- src/server.ts | 7 +- tests/activity.test.ts | 105 ++++++++++++++- tests/hooks.test.ts | 51 ++++++++ tests/onboard.test.ts | 20 ++- tests/receipt.test.ts | 170 +++++++++++++++++++++++++ tests/tools-meta.test.ts | 6 + 15 files changed, 947 insertions(+), 44 deletions(-) create mode 100644 src/engine/receipt.ts create mode 100644 tests/receipt.test.ts diff --git a/src/engine/activity.ts b/src/engine/activity.ts index d3c3731..3f2d781 100644 --- a/src/engine/activity.ts +++ b/src/engine/activity.ts @@ -1,4 +1,4 @@ -import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { writeAtomic } from "../atomic.js"; @@ -17,6 +17,18 @@ export interface ActivityEvent { /** Tokens knitbrain saved on this call (0 if not compressed). Universal — * measured by knitbrain itself, so it works for every platform/plan. */ saved: number; + /** Where the event came from. Absent = legacy = "mcp" (pre-G1 JSONL lines + * still parse — every new field here is optional). */ + source?: "mcp" | "hook" | "proxy"; + /** File path the event concerns (read/redirect/optimize target). */ + file?: string; + /** Original payload size before compression, for sink reporting. */ + rawTokens?: number; + /** Size after compression. */ + storedTokens?: number; + /** G1 receipt classification: an optimize (compress) or a redirect + * (oversized raw read steered to knitbrain_read). */ + kind?: "optimize" | "redirect"; } /** Per-agent optimization rollup — works for ANY MCP agent (Cursor, VS Code, @@ -30,17 +42,43 @@ export interface AgentRollup { export interface ActivityLog { /** Record one tool call. Best-effort, never throws (must not break a tool). */ - record(e: { agent: string; tool: string; summary: string; saved?: number }): void; + record( + e: { + agent: string; + tool: string; + summary: string; + saved?: number; + } & Partial>, + ): void; /** Most recent events, newest first. */ recent(n?: number): ActivityEvent[]; /** Per-agent rollup across the log (universal optimization meter). */ rollup(): AgentRollup[]; + /** Events at/after `ts` (ISO, string-compare) — for a receipt scoped to a + * session marker. `trimmed` warns the caller the log rotated past the + * session start (totals should fall back to exact meter numbers). */ + since(ts: string): { events: ActivityEvent[]; trimmed: boolean }; } /** Keep at most this many events on disk. */ -const CAP = 200; +const CAP = 1000; +/** Cheap guard so appends stay O(1): only pay for the full readAll+trim + * pass once the file is actually large enough to matter. */ +const TRIM_CHECK_BYTES = 300_000; +/** Protection ceiling: even a session-protected trim can't grow the file past + * this many lines — a never-ending session can't defeat bounding entirely. */ +const PROTECTION_CEILING = 5000; -export function createActivityLog(root: string): ActivityLog { +export interface ActivityLogOptions { + /** Returns the current session's start ts (ISO), or null if none/unknown. + * When set, a due trim keeps the UNION of (last CAP lines) and (all lines + * with ts >= protectSince()) instead of a flat last-CAP cut — so a long + * session's early events survive until its receipt has read them. Errors + * are treated as null (fail-open to the plain CAP trim). */ + protectSince?: () => string | null; +} + +export function createActivityLog(root: string, opts: ActivityLogOptions = {}): ActivityLog { mkdirSync(root, { recursive: true }); const path = join(root, "activity.jsonl"); @@ -58,15 +96,41 @@ export function createActivityLog(root: string): ActivityLog { return out; }; + // Events are append-only so `all` is ts-ascending — both "last CAP" and + // "ts >= protectSince()" are therefore contiguous SUFFIXES of the array, and + // their union is just the suffix starting at the smaller of the two indices. + const trimKeep = (all: ActivityEvent[]): ActivityEvent[] => { + const tailStart = Math.max(0, all.length - CAP); + let protectTs: string | null = null; + try { + protectTs = opts.protectSince?.() ?? null; + } catch { + protectTs = null; // fail-open to the plain CAP trim + } + if (!protectTs) return all.slice(tailStart); + const protectStart = all.findIndex((x) => x.ts >= protectTs!); + const start = protectStart === -1 ? tailStart : Math.min(tailStart, protectStart); + const kept = all.slice(start); + // Ceiling: a never-ending session can't grow the file unbounded — past + // it, drop the oldest PROTECTED events first (keep the tail, since that's + // what recent()/rollup() serve). + return kept.length > PROTECTION_CEILING ? kept.slice(-PROTECTION_CEILING) : kept; + }; + return { record(e) { try { const ev: ActivityEvent = { ts: new Date().toISOString(), saved: 0, ...e }; appendFileSync(path, JSON.stringify(ev) + "\n", { encoding: "utf8", flag: "a" }); - // Bounded: when the log grows past 2×CAP, rewrite the last CAP. - const all = readAll(); - if (all.length > CAP * 2) { - writeAtomic(path, all.slice(-CAP).map((x) => JSON.stringify(x)).join("\n") + "\n"); + // Bounded: when the log grows past 2×CAP, rewrite the kept set. The + // statSync guard keeps appends O(1) until the file is actually big + // enough that a trim could be due — avoids a readAll() every call. + const size = existsSync(path) ? statSync(path).size : 0; + if (size > TRIM_CHECK_BYTES) { + const all = readAll(); + if (all.length > CAP * 2) { + writeAtomic(path, trimKeep(all).map((x) => JSON.stringify(x)).join("\n") + "\n"); + } } } catch { /* activity is observability — never break a tool call over it */ @@ -86,5 +150,14 @@ export function createActivityLog(root: string): ActivityLog { } return [...by.values()].sort((a, b) => b.saved - a.saved); }, + since(ts) { + const all = readAll(); + if (all.length === 0) return { events: [], trimmed: false }; + // Only claim rotation when a trim could actually have happened (log at + // CAP scale) — the oldest event being newer than the session start is + // otherwise the NORMAL case (session mark precedes its first event). + const trimmed = all.length >= CAP && all[0]!.ts > ts; + return { events: all.filter((e) => e.ts >= ts), trimmed }; + }, }; } diff --git a/src/engine/onboard.ts b/src/engine/onboard.ts index 85a4f16..5816198 100644 --- a/src/engine/onboard.ts +++ b/src/engine/onboard.ts @@ -256,18 +256,48 @@ export function detectDomains(files: string[]): string[] { /** * Loop-ready goal checkboxes derived from the charter goal — actionable, never - * the vague "design + implement + verify" boilerplate. When the project has - * real parts (detected domains or declared greenfield modules) each part is its - * own checkbox carrying the goal; otherwise the goal itself is ONE checkbox. - * - * Deliberately NOT split into sub-clauses: the loop runs ONE holistic verify - * gate after each box, so a box must independently pass the gate. Splitting a - * single goal into "A"/"B" clauses that only pass together would stall the loop - * on the first box — real modules are independent units; sentence clauses are not. + * the vague "design + implement + verify" boilerplate, and never N identical + * copies of the same sentence. When the project has real parts (detected + * domains or declared greenfield modules) AND the goal's own clauses cleanly + * name ≥2 of them ("build the API and the dashboard"), each part gets its own + * DISTINCT clause as its checkbox. Otherwise the goal stays ONE checkbox (a + * single goal can't be safely split into per-domain sub-clauses that only pass + * together would stall the loop's holistic verify gate on the first box — real + * modules are independent units; sentence clauses are not) with a note listing + * the domains it covers, for manual decomposition as work reveals structure. */ export function goalCheckboxes(goal: string, parts: string[]): string[] { const g = goal.replace(/\s+/g, " ").trim() || "the current goal"; - return parts.length > 0 ? parts.map((d) => `- [ ] ${d}: ${g}`) : [`- [ ] ${g}`]; + if (parts.length === 0) return [`- [ ] ${g}`]; + + // Try to split the goal into per-domain clauses instead of repeating the + // whole goal on every box — "build the API and the dashboard" should become + // two distinct checkboxes, not the same sentence twice. Only commit to this + // if at least 2 parts land on genuinely DISTINCT clauses; a coincidental + // single match (or all parts matching one clause) isn't a real split. + const clauses = g + .split(/[,;]| and | with /i) + .map((c) => c.trim()) + .filter((c) => c.length > 0); + const matched = new Map(); // part -> clause + const usedClauses = new Set(); + for (const part of parts) { + const needle = part.toLowerCase(); + const clause = clauses.find((c) => c.toLowerCase().includes(needle) && !usedClauses.has(c)); + if (clause) { + matched.set(part, clause); + usedClauses.add(clause); + } + } + if (matched.size >= 2) { + return [...matched.entries()].map(([d, clause]) => `- [ ] ${d}: ${clause}`); + } + + // No clean split — keep the goal as ONE checkbox, but note the domains it + // covers so the loop can decompose it further as work reveals structure. + // The note line does NOT start with "- [ ]" so parseGoalProgress ignores it + // (it only matches lines starting with a checkbox marker). + return [`- [ ] ${g}`, `(covers domains: ${parts.join(", ")} — decompose into per-domain boxes as you go)`]; } /** diff --git a/src/engine/receipt.ts b/src/engine/receipt.ts new file mode 100644 index 0000000..00b782d --- /dev/null +++ b/src/engine/receipt.ts @@ -0,0 +1,267 @@ +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { writeAtomic } from "../atomic.js"; +import type { MeterReading } from "./meter.js"; +import type { ActivityEvent } from "./activity.js"; + +/** + * G1 session receipt — the honest "what did knitbrain actually do for you this + * session" report. buildReceipt is pure (no fs) so it's trivially testable and + * safe to call from any process; the marker/hygiene helpers own the on-disk + * session.json (one per project root, sibling to activity.jsonl/meter.json). + */ + +export interface SessionMark { + startTs: string; + savedAtStart: number; + usedAtStart: number; + retrievalsAtStart: number; + reads: Record; + redirects: Record; +} + +const MAX_TRACKED_READS = 200; + +function sessionPath(root: string): string { + return join(root, "session.json"); +} + +/** Best-effort guarded read — corrupt/missing JSON never throws. */ +function loadMark(root: string): SessionMark | null { + const path = sessionPath(root); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf8")) as SessionMark; + } catch { + return null; + } +} + +/** New session: snapshot the meter's cumulative counters as the session's + * zero-point so the receipt can report SESSION deltas, not lifetime totals. */ +export function markSessionStart( + root: string, + snap: { savedTokens: number; usedTokens: number; retrievals: number }, +): void { + mkdirSync(root, { recursive: true }); + const mark: SessionMark = { + startTs: new Date().toISOString(), + savedAtStart: snap.savedTokens, + usedAtStart: snap.usedTokens, + retrievalsAtStart: snap.retrievals, + reads: {}, + redirects: {}, + }; + writeAtomic(sessionPath(root), JSON.stringify(mark)); +} + +export function readSessionMark(root: string): SessionMark | null { + return loadMark(root); +} + +/** Bounded read map: drops the lowest-count entries first when over the cap + * so a read-heavy session can't grow session.json unbounded. */ +function capReads( + reads: Record, +): Record { + const entries = Object.entries(reads); + if (entries.length <= MAX_TRACKED_READS) return reads; + entries.sort((a, b) => a[1].count - b[1].count); + const drop = new Set(entries.slice(0, entries.length - MAX_TRACKED_READS).map(([k]) => k)); + const out: Record = {}; + for (const [k, v] of entries) if (!drop.has(k)) out[k] = v; + return out; +} + +/** Merge two read maps per-path by taking the higher count (and the mtimeMs + * that travels with it) — used by the CAS retry to re-pick-up a concurrent + * writer's increment instead of clobbering it. */ +function mergeReads( + a: Record, + b: Record, +): Record { + const out: Record = {}; + for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { + const x = a[k]; + const y = b[k]; + // Newer mtime wins: an mtime-change RESET (count 1, new mtime) must beat a + // stale higher count, else the max-count merge silently undoes the reset. + out[k] = !x ? y! : !y ? x : x.mtimeMs !== y.mtimeMs ? (x.mtimeMs > y.mtimeMs ? x : y) : x.count >= y.count ? x : y; + } + return out; +} + +/** Merge two redirect count maps per-path by taking the higher count. */ +function mergeRedirects(a: Record, b: Record): Record { + const out: Record = {}; + for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) out[k] = Math.max(a[k] ?? 0, b[k] ?? 0); + return out; +} + +function mtimeOf(path: string): number { + try { + return statSync(path).mtimeMs; + } catch { + return 0; + } +} + +/** + * Track a raw file read for repeat-read hygiene reporting. Same mtime as last + * time → bump the count (an unchanged re-read, likely wasted context); a new + * mtime → the file changed, restart the count at 1. No-op without a session + * (nothing to attribute the read to). + * + * session.json is shared by every hook process a host spawns, so a plain + * read-modify-write silently drops a concurrent increment. Bounded-CAS + * (mirrors teams.ts board().post — M6): compute our desired increment, + * reload+merge (max count wins) on each retry so a rival writer's update is + * re-picked-up instead of clobbered, and only give up after 5 attempts — + * narrowing the loss window to the atomic write itself. + */ +export function recordRead(root: string, path: string, mtimeMs: number): void { + const sPath = sessionPath(root); + const initial = loadMark(root); + if (!initial) return; + const applyIncrement = ( + reads: Record, + ): Record => { + const prev = reads[path]; + const next = prev && prev.mtimeMs === mtimeMs ? { count: prev.count + 1, mtimeMs } : { count: 1, mtimeMs }; + return capReads({ ...reads, [path]: next }); + }; + let desired = applyIncrement(initial.reads); + for (let attempt = 0; attempt < 5; attempt += 1) { + const before = mtimeOf(sPath); + const current = loadMark(root); + if (!current) return; + const merged = mergeReads(desired, current.reads); + writeAtomic(sPath, JSON.stringify({ ...current, reads: merged } satisfies SessionMark)); + desired = merged; + if (mtimeOf(sPath) === before) break; + } +} + +/** + * Track an oversized raw read that got redirected to knitbrain_read. No-op + * without a session. Same bounded-CAS pattern as recordRead. + */ +export function recordRedirect(root: string, path: string): void { + const sPath = sessionPath(root); + const initial = loadMark(root); + if (!initial) return; + let desired = { ...initial.redirects, [path]: (initial.redirects[path] ?? 0) + 1 }; + for (let attempt = 0; attempt < 5; attempt += 1) { + const before = mtimeOf(sPath); + const current = loadMark(root); + if (!current) return; + const merged = mergeRedirects(desired, current.redirects); + writeAtomic(sPath, JSON.stringify({ ...current, redirects: merged } satisfies SessionMark)); + desired = merged; + if (mtimeOf(sPath) === before) break; + } +} + +export interface ReceiptInput { + meter: MeterReading; + mark: SessionMark | null; + /** Pre-filtered by the caller via activity.since(mark.startTs). */ + events: ActivityEvent[]; + eventsTrimmed: boolean; + /** Lifetime TOIN retrieval count (caller sums feedback stats). */ + retrievalsTotal: number; + /** Injected clock for tests. */ + now?: () => number; +} + +/** Compact token count: 12.3k / 1.2M — matches the statusline's fmtTokens. */ +function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(Math.round(n)); +} + +export function buildReceipt(i: ReceiptInput): string { + const now = i.now ?? Date.now; + const { meter, mark, events, eventsTrimmed, retrievalsTotal } = i; + const lines: string[] = []; + + const sessionSaved = mark ? Math.max(0, meter.savedTokens - mark.savedAtStart) : meter.savedTokens; + const retrievalsDelta = mark ? Math.max(0, retrievalsTotal - mark.retrievalsAtStart) : retrievalsTotal; + const reads = mark?.reads ?? {}; + const redirects = mark?.redirects ?? {}; + const repeatReads = Object.entries(reads).filter(([, r]) => r.count > 1); + const redirectCount = Object.values(redirects).reduce((a, b) => a + b, 0); + + // Honest zero: nothing to claim this session — no sinks, no forecast, just + // the plain fact plus the lifetime line so the user still sees the totals. + if (sessionSaved === 0 && repeatReads.length === 0 && redirectCount === 0) { + lines.push("No optimization events this session — nothing was replaced or redirected, so nothing is claimed."); + lines.push(`lifetime: ${fmtTokens(meter.savedTokens)} tok saved · ${retrievalsTotal} exact recalls`); + return lines.join("\n"); + } + + lines.push(mark ? "— knitbrain session receipt —" : "— knitbrain receipt (lifetime — no session marker) —"); + + const avoided = sessionSaved; + const consumed = mark ? Math.max(0, meter.usedTokens - mark.usedAtStart) : meter.usedTokens; + const denom = consumed + avoided; + const pct = denom > 0 ? Math.round((avoided / denom) * 100) : 0; + lines.push(`consumed ~${fmtTokens(consumed)} tok · avoided ${fmtTokens(avoided)} tok (${pct}% of what would have been)`); + + const sinks = events + .filter((e) => typeof e.rawTokens === "number") + .sort((a, b) => (b.rawTokens ?? 0) - (a.rawTokens ?? 0)) + .slice(0, 5); + if (sinks.length > 0) { + lines.push("top sinks:"); + for (const s of sinks) { + const label = s.file ?? s.tool; + const raw = s.rawTokens ?? 0; + const stored = s.storedTokens ?? 0; + lines.push(` ${label}: ${fmtTokens(raw)} → ${fmtTokens(stored)} tok (saved ${fmtTokens(Math.max(0, raw - stored))})`); + } + } + + if (repeatReads.length > 0 || redirectCount > 0) { + lines.push("hygiene:"); + const topRepeats = [...repeatReads].sort((a, b) => b[1].count - a[1].count).slice(0, 3); + for (const [path, r] of topRepeats) { + lines.push(` re-read unchanged ×${r.count - 1}: ${path}`); + } + if (redirectCount > 0) { + lines.push(` ${redirectCount} oversized raw read(s) redirected to knitbrain_read`); + } + } + + lines.push(`${retrievalsDelta} exact recall(s) served byte-for-byte this session`); + + // 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. + if (meter.billingMode === "plan" && mark && avoided > 0) { + const sessionMs = now() - Date.parse(mark.startTs); + const hours = sessionMs / 3_600_000; + if (hours >= 10 / 60) { + const burn = meter.usedTokens / hours; + if (burn > 0) { + const optimisticHours = (meter.windowTokens - meter.usedTokens) / burn; + const unoptimizedBurn = (meter.usedTokens + avoided) / hours; + if (unoptimizedBurn > 0) { + const wasHours = (meter.windowTokens - meter.usedTokens) / unoptimizedBurn; + lines.push( + `at this pace the window lasts ~${optimisticHours.toFixed(1)}h (was ~${wasHours.toFixed(1)}h unoptimized) — estimate`, + ); + } + } + } + } + + lines.push(`lifetime: ${fmtTokens(meter.savedTokens)} tok saved · ${retrievalsTotal} exact recalls`); + + if (eventsTrimmed) { + lines.push("(earliest events rotated out; totals above come from the meter and are exact)"); + } + + return lines.join("\n"); +} diff --git a/src/hooks/adapters.ts b/src/hooks/adapters.ts index 254d183..0060fba 100644 --- a/src/hooks/adapters.ts +++ b/src/hooks/adapters.ts @@ -24,6 +24,14 @@ * (create_file/read_file/run_in_terminal) — normalizeInput maps * both back to the internal snake_case Read/Bash/Write shape. * + * G1 receipt (Stop with `systemMessage`, no block — a non-blocking summary, + * not a decision): claude/codex/vscode pass `{systemMessage}` through as-is + * (Claude Code + VS Code render it to the user; codex ignores an unknown + * key harmlessly). cursor/gemini get `null` instead — cursor's only Stop + * affordance is `followup_message`, which injects a synthetic next agent + * turn (wrong shape for a passive receipt); gemini's AfterAgent output is a + * deny/retry contract (stray non-deny JSON risks being misread as one). + * * Where a host cannot perform the requested action (rewrite output on * codex/gemini, or context-inject on cursor's beforeSubmitPrompt), we never * silently drop the decision — we degrade to an additionalContext pointer so @@ -217,6 +225,12 @@ export function adaptOutput(platform: HookPlatform, mode: HookMode, decision: Re // Cursor's stop cannot truly block — degrade to a follow-up nudge. return { followup_message: reason ?? (decision["reason"] as string | undefined) }; } + if (mode === "stop" && typeof decision["systemMessage"] === "string") { + // G1 receipt (non-blocking systemMessage): cursor's only Stop lever is + // followup_message, which injects a synthetic agent turn — wrong shape + // for a passive receipt. Drop rather than misrepresent it as a turn. + return null; + } if (mode === "sessionstart" && additionalContext) { return { additional_context: additionalContext }; } @@ -239,6 +253,12 @@ export function adaptOutput(platform: HookPlatform, mode: HookMode, decision: Re if (mode === "stop" && decision["decision"] === "block") { return { decision: "deny", reason: reason ?? (decision["reason"] as string | undefined) }; } + if (mode === "stop" && typeof decision["systemMessage"] === "string") { + // G1 receipt (non-blocking systemMessage): gemini's AfterAgent output is + // strictly a deny/retry contract — stray non-deny JSON risks being + // misread as one. Drop rather than risk a spurious retry. + return null; + } if (additionalContext) { return { hookSpecificOutput: { additionalContext } }; } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 84d00de..6899bc7 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -11,12 +11,16 @@ * * Hooks must NEVER break the host: any internal error exits 0 silently. */ +import { existsSync, statSync } from "node:fs"; import { createFileCCRStore } from "../ccr/store.js"; import { createMemory } from "../engine/memory.js"; import { createMeter } from "../engine/meter.js"; import { createWikiStore } from "../engine/wiki.js"; +import { createActivityLog } from "../engine/activity.js"; +import { createFeedback } from "../engine/feedback.js"; +import { markSessionStart, readSessionMark, recordRead, recordRedirect, buildReceipt } from "../engine/receipt.js"; import { currentContextTokens, currentContextModel } from "../engine/usage.js"; -import { ccrRoot, memoryRoot, meterRoot, wikiRoot, loopStatePath } from "../paths.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"; @@ -56,7 +60,42 @@ async function main(): Promise { const input = normalizeInput(platform, mode ?? "pretooluse", payload); if (mode === "pretooluse") { - const decision = decidePreToolUse(input as PreToolUseInput); + const preInput = input as PreToolUseInput; + // Ledger the read attempt (any Read, not just denied ones) so the G1 + // receipt can compare read frequency vs redirect frequency later. + if (preInput.tool_name === "Read" && typeof preInput.tool_input?.file_path === "string") { + try { + const p = preInput.tool_input.file_path; + if (existsSync(p)) recordRead(meterRoot(), p, statSync(p).mtimeMs); + } catch { + /* receipt bookkeeping — never break the host */ + } + } + const decision = decidePreToolUse(preInput); + // 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("Large file") && typeof preInput.tool_input?.file_path === "string") { + try { + const p = preInput.tool_input.file_path; + recordRedirect(meterRoot(), p); + createActivityLog(activityRoot(), { protectSince: () => readSessionMark(meterRoot())?.startTs ?? null }).record({ + agent: "hook", + tool: "Read", + summary: "redirected oversized read", + saved: 0, + source: "hook", + kind: "redirect", + file: p, + }); + } catch { + /* receipt bookkeeping — never break the host */ + } + } + } const out = adaptOutput(platform, "pretooluse", decision); if (out) process.stdout.write(JSON.stringify(out)); return; @@ -68,7 +107,23 @@ async function main(): Promise { // knitbrain_retrieve restores it. The subscription auto-compression path. const ccr = createFileCCRStore(ccrRoot()); const meter = createMeter(meterRoot(), { realUsage: () => currentContextTokens(), realModel: () => currentContextModel() }); - const decision = decidePostToolUse(input as PostToolUseInput, ccr, (n) => meter.onSaved(n)); + const toolName = typeof (input as PostToolUseInput).tool_name === "string" ? (input as PostToolUseInput).tool_name! : "unknown"; + const decision = decidePostToolUse(input as PostToolUseInput, ccr, (n, info) => { + meter.onSaved(n); + try { + createActivityLog(activityRoot(), { protectSince: () => readSessionMark(meterRoot())?.startTs ?? null }).record({ + agent: "hook", + tool: toolName, + summary: `skeletonized ${toolName} output`, + saved: n, + source: "hook", + rawTokens: info?.rawTokens, + storedTokens: info?.storedTokens, + }); + } catch { + /* ledger is observability — never break the hook */ + } + }); const out = adaptOutput(platform, "posttooluse", decision); if (out) process.stdout.write(JSON.stringify(out)); return; @@ -131,6 +186,20 @@ async function main(): Promise { } catch { /* never break session start */ } + // G1 receipt: stamp a session marker (start-of-session meter/retrieval + // snapshot) so `stop` can compute this-session deltas via activity.since(). + try { + const meter = createMeter(meterRoot(), { realUsage: () => currentContextTokens(), realModel: () => currentContextModel() }); + const r = meter.read(); + // Retrievals-at-start: same feedback.stats() source the stop branch + // uses, kept cheap (small on-disk JSON, not the transcript). + const retrievals = createFeedback(feedbackRoot()) + .stats() + .reduce((sum, s) => sum + s.retrievals, 0); + markSessionStart(meterRoot(), { savedTokens: r.savedTokens, usedTokens: r.usedTokens, retrievals }); + } catch { + /* never break session start */ + } return; } if (mode === "precompact") { @@ -161,6 +230,24 @@ async function main(): Promise { `[auto-handoff @ ${new Date().toISOString()}] Session ended. On resume: knitbrain_load_session, then knitbrain_run with your next task.`, ); } + // G1 receipt: end-of-session summary of what this session actually + // optimized (skeletonizations, redirects, retrievals) vs sessionstart's + // marker. Never on the loop-block path — only when the stop is real. + try { + const meter = createMeter(meterRoot(), { realUsage: () => currentContextTokens(), realModel: () => currentContextModel() }); + const meterReading = meter.read(); + const mark = readSessionMark(meterRoot()); + const activity = createActivityLog(activityRoot(), { protectSince: () => readSessionMark(meterRoot())?.startTs ?? null }); + const { events, trimmed } = mark ? activity.since(mark.startTs) : { events: [], trimmed: false }; + const retrievalsTotal = createFeedback(feedbackRoot()) + .stats() + .reduce((sum, s) => sum + s.retrievals, 0); + const receipt = buildReceipt({ meter: meterReading, mark, events, eventsTrimmed: trimmed, retrievalsTotal }); + const out = adaptOutput(platform, "stop", { systemMessage: receipt }); + if (out) process.stdout.write(JSON.stringify(out)); + } catch { + /* receipt is observability — never break the host on stop */ + } return; } } catch { diff --git a/src/hooks/posttooluse.ts b/src/hooks/posttooluse.ts index c51c151..fe8b2f1 100644 --- a/src/hooks/posttooluse.ts +++ b/src/hooks/posttooluse.ts @@ -51,7 +51,7 @@ function extractText(resp: unknown): string | null { export function decidePostToolUse( input: PostToolUseInput, ccr: CCRStore, - onSaved: (savedTokens: number) => void = () => {}, + onSaved: (savedTokens: number, info?: { rawTokens: number; storedTokens: number }) => void = () => {}, ): Record | null { if (!input.tool_name || !POSTTOOL_TARGETS.has(input.tool_name)) return null; @@ -63,7 +63,7 @@ export function decidePostToolUse( const r = compress(text, ccr, { allowProse: true }); if (!r.compressed) return null; // not worth it → leave the original untouched - onSaved(r.originalTokens - r.skeletonTokens); + onSaved(r.originalTokens - r.skeletonTokens, { rawTokens: r.originalTokens, storedTokens: r.skeletonTokens }); return { hookSpecificOutput: { hookEventName: "PostToolUse", diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 090d7a0..5fe10a5 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -11,7 +11,8 @@ import type { Calibration } from "../engine/calibration.js"; import type { ActivityLog } from "../engine/activity.js"; import { proposeAgents, writeAgent } from "../engine/agents.js"; import { scanHost, composeSkill, scanHostAll, buildHostIndex, saveHostIndex, loadHostIndex, countBySource, scanContextHygiene } from "../engine/host-scan.js"; -import { hostIndexPath, workflowPath, loopStatePath } from "../paths.js"; +import { hostIndexPath, workflowPath, loopStatePath, meterRoot } from "../paths.js"; +import { buildReceipt, readSessionMark, markSessionStart } from "../engine/receipt.js"; import type { WikiStore } from "../engine/wiki.js"; import { logSpine } from "../engine/wiki.js"; import { createBrain, type Brain } from "../engine/brain.js"; @@ -169,6 +170,10 @@ interface SessionState { verified: boolean; /** A learning was recorded this session. */ learned: boolean; + /** knitbrain_optimize / knitbrain_read call ctx.meter.onSaved inside run(), + * before dispatch's capture() step — queue their savings here so dispatch + * can surface them to the activity ledger after the tool returns. */ + pendingLedger?: Array<{ file?: string; rawTokens: number; storedTokens: number; saved: number }>; } const sessionState = new WeakMap(); function sessionOf(ctx: ToolContext): SessionState { @@ -223,7 +228,10 @@ export const TOOLS: readonly ToolDef[] = [ const r = compress(text, ctx.ccr, { allowProse: !ctx.feedback.shouldSkip("prose") }); if (!r.compressed) return text; ctx.feedback.onCompress(r.contentType, r.handle); - ctx.meter.onSaved(r.originalTokens - r.skeletonTokens); + const saved = r.originalTokens - r.skeletonTokens; + ctx.meter.onSaved(saved); + const optimizeSession = sessionOf(ctx); + (optimizeSession.pendingLedger ??= []).push({ rawTokens: r.originalTokens, storedTokens: r.skeletonTokens, saved }); return `${r.skeleton}\n\n[optimized: ${r.originalTokens}→${r.skeletonTokens} tokens, saved ${r.savedPct}% · retrieve the ⟨recall:…⟩ handle for the exact original]`; }, }, @@ -268,7 +276,10 @@ export const TOOLS: readonly ToolDef[] = [ const r = compress(original, ctx.ccr, { allowProse: !ctx.feedback.shouldSkip("prose") }); if (!r.compressed) return original; // small/incompressible → exact content ctx.feedback.onCompress(r.contentType, r.handle); - ctx.meter.onSaved(r.originalTokens - r.skeletonTokens); + const saved = r.originalTokens - r.skeletonTokens; + ctx.meter.onSaved(saved); + const readSession = sessionOf(ctx); + (readSession.pendingLedger ??= []).push({ file: requested, rawTokens: r.originalTokens, storedTokens: r.skeletonTokens, saved }); return `${r.skeleton}\n\n[knitbrain_read: ${requested} · ${r.originalTokens}→${r.skeletonTokens} tokens (saved ${r.savedPct}%) · exact original: knitbrain_retrieve ⟨recall:${r.handle}⟩]`; }, }, @@ -370,6 +381,17 @@ export const TOOLS: readonly ToolDef[] = [ output: "data", run: (_args, ctx) => { ctx.meter.reset(); // new session starts a fresh window + // Session marker (G1 receipt): snapshot the fresh meter as the session's + // zero-point so knitbrain_context_meter can report SESSION deltas even on + // hook-less hosts (proxy/hooks also mark, but this covers MCP-only use). + // Best-effort — never block load_session on receipt bookkeeping. + try { + const snap = ctx.meter.read(); + const retrievals = ctx.feedback.stats().reduce((a, s) => a + s.retrievals, 0); + markSessionStart(meterRoot(), { savedTokens: snap.savedTokens, usedTokens: snap.usedTokens, retrievals }); + } catch { + // non-fatal + } // (Knowledge self-heals lazily: the first graph query in a fresh // project triggers a scan automatically — no manual init step.) // Gap E: auto-heal stale/contradicting wiki claims at session start. @@ -401,7 +423,19 @@ export const TOOLS: readonly ToolDef[] = [ description: "Token-window meter: how full the context is, tokens saved by optimization, and whether it's time to save a handoff and clear the session.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, output: "verbatim", - run: (_args, ctx) => JSON.stringify(ctx.meter.read(), null, 2), + run: (_args, ctx) => { + const meter = ctx.meter.read(); + const mark = readSessionMark(meterRoot()); + const since = mark ? ctx.activity?.since(mark.startTs) : undefined; + const events = since?.events ?? []; + const eventsTrimmed = since?.trimmed ?? false; + const retrievalsTotal = ctx.feedback.stats().reduce((a, s) => a + s.retrievals, 0); + return JSON.stringify( + { ...meter, receipt: buildReceipt({ meter, mark, events, eventsTrimmed, retrievalsTotal }) }, + null, + 2, + ); + }, }, { name: "knitbrain_search_code", @@ -1055,7 +1089,8 @@ export const TOOLS: readonly ToolDef[] = [ ].join("\n"), "utf8", ); - goalNote = ` goal.md written (${tasks.length} checkbox${tasks.length === 1 ? "" : "es"}, VERIFY honored by the loop) — start with \`knitbrain loop goal.md\` or knitbrain_run_loop.`; + const checkboxCount = tasks.filter((t) => t.startsWith("- [ ]")).length; + goalNote = ` goal.md written (${checkboxCount} checkbox${checkboxCount === 1 ? "" : "es"}, VERIFY honored by the loop) — start with \`knitbrain loop goal.md\` or knitbrain_run_loop.`; } return `Onboarding complete — Project Charter ("${r.page}") + constraints skill ("${r.skill}") + workflow written (ROUTING covers: ${domains.join(", ") || "none"}). knitbrain_load_session now surfaces your intent + workflow every session.${gapHint}${goalNote}`; } @@ -1365,21 +1400,29 @@ function isJsonPayload(raw: string): boolean { } } -function capture(tool: ToolDef, raw: string, ctx: ToolContext): { out: string; saved: number } { +function capture( + tool: ToolDef, + raw: string, + ctx: ToolContext, +): { out: string; saved: number; rawTokens?: number; storedTokens?: number } { let out = raw; let saved = 0; + let rawTokens: number | undefined; + let storedTokens: number | undefined; if (tool.output === "data" && !isJsonPayload(raw) && !ctx.feedback.shouldSkip(detect(raw))) { // TOIN self-tuning: if this kind gets over-retrieved, stop compressing it. const r = compress(raw, ctx.ccr, { allowProse: !ctx.feedback.shouldSkip("prose") }); if (r.compressed) { ctx.feedback.onCompress(r.contentType, r.handle); saved = r.originalTokens - r.skeletonTokens; + rawTokens = r.originalTokens; + storedTokens = r.skeletonTokens; ctx.meter.onSaved(saved); out = r.skeleton; } } ctx.meter.onToolOutput(countTokens(out)); - return { out, saved }; + return { out, saved, rawTokens, storedTokens }; } /** @@ -1405,7 +1448,7 @@ export function dispatch( if (tool.name === "knitbrain_record_learning") sessionOf(ctx).learned = true; // CAPTURE: compress + account. - const { out: captured, saved } = capture(tool, raw, ctx); + const { out: captured, saved, rawTokens, storedTokens } = capture(tool, raw, ctx); let out = captured; // Context meter advisory — except the exact-recovery tools, whose whole @@ -1425,6 +1468,28 @@ export function dispatch( tool: tool.name, summary: raw.replace(/\s+/g, " ").trim().slice(0, 80), saved, + source: "mcp", + ...(rawTokens !== undefined && storedTokens !== undefined ? { rawTokens, storedTokens } : {}), }); + + // Drain pendingLedger: knitbrain_optimize / knitbrain_read call ctx.meter.onSaved + // INSIDE run(), before capture() ever sees them — their savings would + // otherwise be invisible to the activity ledger above. One event per entry. + const pending = sessionOf(ctx).pendingLedger; + if (pending && pending.length > 0) { + for (const entry of pending) { + ctx.activity?.record({ + agent: ctx.agentId ?? "agent", + tool: tool.name, + summary: entry.file ?? "optimize", + saved: entry.saved, + source: "mcp", + file: entry.file, + rawTokens: entry.rawTokens, + storedTokens: entry.storedTokens, + }); + } + sessionOf(ctx).pendingLedger = []; + } return out; } diff --git a/src/paths.ts b/src/paths.ts index 7db0f8c..f22303b 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -12,9 +12,12 @@ export function ccrRoot(): string { return join(knitbrainHome(), "ccr"); } -/** Stable per-project id derived from the working directory. */ +/** Stable per-project id derived from the working directory. + * KNITBRAIN_PROJECT_DIR overrides cwd so a helper process launched elsewhere + * (the proxy, a cron trigger) still lands its state in the RIGHT project. */ export function projectId(): string { - return createHash("sha256").update(process.cwd()).digest("hex").slice(0, 16); + const dir = process.env["KNITBRAIN_PROJECT_DIR"] ?? process.cwd(); + return createHash("sha256").update(dir).digest("hex").slice(0, 16); } /** Per-project memory directory (learnings + sessions). */ diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 71c2b54..304d99f 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -1,9 +1,11 @@ #!/usr/bin/env node import { createProxyServer } from "./server.js"; import { createFileCCRStore } from "../ccr/store.js"; +import { createActivityLog } from "../engine/activity.js"; import { createFeedback } from "../engine/feedback.js"; import { createMeter } from "../engine/meter.js"; -import { ccrRoot, feedbackRoot, meterRoot } from "../paths.js"; +import { readSessionMark } from "../engine/receipt.js"; +import { activityRoot, ccrRoot, feedbackRoot, meterRoot } from "../paths.js"; const port = Number(process.env["KNITBRAIN_PROXY_PORT"] ?? 8788); // Provider is auto-detected from the request path; upstreams are overridable. @@ -17,7 +19,13 @@ const upstreams = { : {}), }; +// Project roots resolve from cwd — or KNITBRAIN_PROJECT_DIR when the proxy is +// launched outside the project dir, so receipt attribution lands in the right +// project ledger (risk cleared: proxy cwd ≠ project). const meter = createMeter(meterRoot()); +const activity = createActivityLog(activityRoot(), { + protectSince: () => readSessionMark(meterRoot())?.startTs ?? null, +}); // Output-side lever: KNITBRAIN_TERSE=1 appends a compact terse directive to // every request's system tail. Off by default — the proxy never alters the @@ -38,6 +46,19 @@ const server = createProxyServer({ onStats: (s) => { // The optimized request size IS the live context window usage. meter.onRequest(s.originalTokens, s.optimizedTokens); + // G1 attribution: proxy savings land in the same session ledger as MCP/hook + // savings, so the receipt can name Door 3's contribution. + if (s.originalTokens > s.optimizedTokens) { + activity.record({ + agent: "proxy", + tool: "request", + summary: `optimized api request (${s.blocksCompressed} blocks)`, + saved: s.originalTokens - s.optimizedTokens, + source: "proxy", + rawTokens: s.originalTokens, + storedTokens: s.optimizedTokens, + }); + } if (s.blocksCompressed > 0) { console.error( `[knitbrain-proxy] ${s.originalTokens}→${s.optimizedTokens} tok (saved ${s.savedPct}%, ${s.blocksCompressed} blocks)`, diff --git a/src/server.ts b/src/server.ts index 997174c..cd1aab8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { createMeter, type Meter } from "./engine/meter.js"; import { createSkillsStore, type SkillsStore } from "./engine/skills.js"; import { createCalibration, type Calibration } from "./engine/calibration.js"; import { createActivityLog, type ActivityLog } from "./engine/activity.js"; +import { readSessionMark } from "./engine/receipt.js"; import { createWikiStore, type WikiStore } from "./engine/wiki.js"; import { currentContextTokens, currentContextModel } from "./engine/usage.js"; import { agentLabel } from "./mcp/host.js"; @@ -45,7 +46,11 @@ export function buildServer( meter: Meter = createMeter(meterRoot(), { realUsage: () => currentContextTokens(), realModel: () => currentContextModel(), baselineTokens: 20_000 }), skills: SkillsStore = createSkillsStore(skillsRoot()), calibration: Calibration = createCalibration(calibrationRoot()), - activity: ActivityLog = createActivityLog(activityRoot()), + // protectSince: session-aware trim — the receipt needs the whole session's + // events, so trim never eats lines newer than the live session mark. + activity: ActivityLog = createActivityLog(activityRoot(), { + protectSince: () => readSessionMark(meterRoot())?.startTs ?? null, + }), wiki: WikiStore = createWikiStore(wikiRoot()), ): Server { const server = new Server( diff --git a/tests/activity.test.ts b/tests/activity.test.ts index 404be11..8725c92 100644 --- a/tests/activity.test.ts +++ b/tests/activity.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createActivityLog } from "../src/engine/activity.js"; @@ -31,11 +31,13 @@ describe("activity log — live agent CRM feed", () => { it("stays bounded under heavy load (never grows unbounded)", () => { const a = createActivityLog(root); - for (let i = 0; i < 500; i += 1) a.record({ agent: "x", tool: "t", summary: `e${i}` }); - // CAP=200; after trim the on-disk log holds ≤ ~400 (2×CAP) and the newest survive. - const all = a.recent(1000); - expect(all.length).toBeLessThanOrEqual(400); - expect(all[0]!.summary).toBe("e499"); + // CAP=1000 with a 300KB size gate: pad summaries so 2500 events (~180B + // lines) definitely cross the byte gate and force the trim path. + const pad = "x".repeat(120); + for (let i = 0; i < 2500; i += 1) a.record({ agent: "x", tool: "t", summary: `e${i} ${pad}` }); + const all = a.recent(5000); + expect(all.length).toBeLessThanOrEqual(2000); // ≤ 2×CAP after trim + expect(all[0]!.summary.startsWith("e2499")).toBe(true); // newest survive }); it("is shared across instances (multiple agent processes append the same log)", () => { @@ -59,4 +61,93 @@ describe("activity rollup — universal per-agent meter (all platforms)", () => expect(roll.find((x) => x.agent === "claude-code (subscription)")).toMatchObject({ calls: 1, saved: 30 }); } finally { rmSync(r, { recursive: true, force: true }); } }); -}) +}); + +describe("activity log — new G1 fields + legacy compatibility", () => { + let root: string; + beforeEach(() => { root = mkdtempSync(join(tmpdir(), "kb-act-fields-")); }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it("new-field roundtrip: source/file/rawTokens/storedTokens/kind survive through recent()", () => { + const a = createActivityLog(root); + a.record({ + agent: "agent-1", + tool: "knitbrain_optimize", + summary: "compressed x", + saved: 90, + source: "hook", + file: "/proj/big.ts", + rawTokens: 100, + storedTokens: 10, + kind: "optimize", + }); + const e = a.recent(1)[0]!; + expect(e.source).toBe("hook"); + expect(e.file).toBe("/proj/big.ts"); + expect(e.rawTokens).toBe(100); + expect(e.storedTokens).toBe(10); + expect(e.kind).toBe("optimize"); + }); + + it("parses a legacy JSONL line with only the old fields (no G1 fields)", () => { + mkdirSync(root, { recursive: true }); + const legacy = { ts: new Date().toISOString(), agent: "old-agent", tool: "t", summary: "legacy line", saved: 5 }; + writeFileSync(join(root, "activity.jsonl"), JSON.stringify(legacy) + "\n"); + const a = createActivityLog(root); + const e = a.recent(1)[0]!; + expect(e.agent).toBe("old-agent"); + expect(e.summary).toBe("legacy line"); + expect(e.source).toBeUndefined(); + expect(e.rawTokens).toBeUndefined(); + }); + + it("since(ts) filters to events at/after ts and reports trimmed=false when nothing rotated out", () => { + const a = createActivityLog(root); + a.record({ agent: "x", tool: "t", summary: "e0" }); + a.record({ agent: "x", tool: "t", summary: "e1" }); + a.record({ agent: "x", tool: "t", summary: "e2" }); + // Anchor on e1's REAL ts (newest-first: [e2, e1, e0]) so >= includes e1/e2 + // regardless of ms-resolution ties. + const mid = a.recent(3)[1]!.ts; + const { events, trimmed } = a.since(mid); + // e0 predates `mid`; e1/e2 (recorded after) should be included — allow for + // ts-resolution ties by asserting the subset relationship instead of exact count. + expect(events.length).toBeLessThanOrEqual(3); + expect(events.some((e) => e.summary === "e2")).toBe(true); + expect(trimmed).toBe(false); + }); + + it("since(ts) reports trimmed=true when the earliest on-disk event postdates ts", () => { + mkdirSync(root, { recursive: true }); + const future = new Date(Date.now() + 60_000).toISOString(); + const past = new Date(Date.now() - 60_000).toISOString(); + // trimmed only claims rotation at CAP scale — a short log's oldest event + // postdating the mark is the normal case, not evidence of rotation. + const lines = Array.from({ length: 1000 }, (_, i) => + JSON.stringify({ ts: future, agent: "x", tool: "t", summary: `s${i}`, saved: 0 }), + ); + appendFileSync(join(root, "activity.jsonl"), lines.join("\n") + "\n"); + const a = createActivityLog(root); + const { trimmed, events } = a.since(past); + expect(trimmed).toBe(true); // all[0].ts (future) > past → log's earliest event already postdates the requested floor + expect(events.length).toBe(1000); + }); + + it("protectSince keeps events at/after the protected ts across a due trim (fail-open to plain CAP trim when it errors)", () => { + // protectSince throwing must not break record() — fail-open to the plain CAP trim. + const a = createActivityLog(root, { + protectSince: () => { + throw new Error("boom"); + }, + }); + expect(() => a.record({ agent: "x", tool: "t", summary: "e0" })).not.toThrow(); + expect(a.recent(1)[0]!.summary).toBe("e0"); + }); + + it("protectSince returning null degrades to the plain CAP trim (no throw, normal recording)", () => { + const a = createActivityLog(root, { protectSince: () => null }); + for (let i = 0; i < 5; i += 1) a.record({ agent: "x", tool: "t", summary: `e${i}` }); + expect(a.recent(5).length).toBe(5); + expect(a.recent(1)[0]!.summary).toBe("e4"); + }); +}); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 40bd0da..33750b2 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -6,6 +6,7 @@ import { decidePreToolUse, READ_REDIRECT_BYTES } from "../src/hooks/pretooluse.j import { decideLoopStop } from "../src/hooks/stop.js"; import { applyArtifacts, claudeArtifacts } from "../src/platforms.js"; import { generateConfig } from "../src/setup.js"; +import { adaptOutput } from "../src/hooks/adapters.js"; const io = (size: number) => ({ exists: () => true, sizeOf: () => size }); @@ -251,3 +252,53 @@ describe("Stop hook (Gap 6b — enforce the loop, block once)", () => { expect(decideLoopStop(p)).toBeNull(); }); }); + +describe("adaptOutput — G1 receipt (non-blocking Stop systemMessage) per platform", () => { + const receipt = { systemMessage: "r" }; + + it("claude/codex/vscode pass the systemMessage through untouched", () => { + expect(adaptOutput("claude", "stop", receipt)).toEqual(receipt); + expect(adaptOutput("codex", "stop", receipt)).toEqual(receipt); + expect(adaptOutput("vscode", "stop", receipt)).toEqual(receipt); + }); + + it("cursor and gemini degrade to null (no lever for a passive receipt)", () => { + expect(adaptOutput("cursor", "stop", receipt)).toBeNull(); + expect(adaptOutput("gemini", "stop", receipt)).toBeNull(); + }); + + it("regression: a loop-block stop decision still adapts per-platform as before", () => { + const block = { decision: "block", reason: "goal unmet" }; + expect(adaptOutput("claude", "stop", block)).toEqual(block); + expect(adaptOutput("cursor", "stop", block)).toEqual({ followup_message: "goal unmet" }); + expect(adaptOutput("gemini", "stop", block)).toEqual({ decision: "deny", reason: "goal unmet" }); + const vscodeOut = adaptOutput("vscode", "stop", block) as Record; + expect(vscodeOut["continue"]).toBe(false); + expect(vscodeOut["stopReason"]).toBe("goal unmet"); + }); +}); + +describe("decidePostToolUse — onSaved info callback", () => { + it("passes {rawTokens, storedTokens} alongside the saved-tokens delta", () => { + const root = mkdtempSync(join(tmpdir(), "knitbrain-ccr-info-")); + try { + const ccr = createFileCCRStore(root); + const original = Array.from({ length: 400 }, (_, i) => ` at frame ${i} module/path/file-${i}.ts:${i}:10`).join("\n"); + let capturedInfo: { rawTokens: number; storedTokens: number } | undefined; + let capturedSaved = 0; + decidePostToolUse( + { tool_name: "Bash", tool_response: { stdout: original } }, + ccr, + (n, info) => { + capturedSaved = n; + capturedInfo = info; + }, + ); + expect(capturedInfo).toBeDefined(); + expect(capturedInfo!.rawTokens).toBeGreaterThan(capturedInfo!.storedTokens); + expect(capturedSaved).toBe(capturedInfo!.rawTokens - capturedInfo!.storedTokens); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/onboard.test.ts b/tests/onboard.test.ts index 5803346..b43d00f 100644 --- a/tests/onboard.test.ts +++ b/tests/onboard.test.ts @@ -271,15 +271,29 @@ describe("goalCheckboxes (Gap 2): actionable tasks, never boilerplate", () => { "- [ ] build the parser and wire the CLI", ]); }); - it("parts present → each part carries the goal", () => { + it("parts present but goal has no per-domain clauses → ONE checkbox + coverage note (never N duplicates)", () => { expect(goalCheckboxes("ship v1", ["api", "worker"])).toEqual([ - "- [ ] api: ship v1", - "- [ ] worker: ship v1", + "- [ ] ship v1", + "(covers domains: api, worker — decompose into per-domain boxes as you go)", ]); }); it("empty goal degrades gracefully", () => { expect(goalCheckboxes("", [])).toEqual(["- [ ] the current goal"]); }); + + it("multi-clause goal that names ≥2 domains → distinct per-domain checkboxes, no duplicates", () => { + const boxes = goalCheckboxes("build the api and the worker", ["api", "worker"]); + expect(boxes).toEqual(["- [ ] api: build the api", "- [ ] worker: the worker"]); + expect(new Set(boxes).size).toBe(boxes.length); // no duplicate checkbox text + }); + + it("unmatchable goal (no domain name appears in any clause) → ONE checkbox + a covers-domains note", () => { + const boxes = goalCheckboxes("ship v1", ["api", "worker"]); + const checkboxLines = boxes.filter((b) => b.startsWith("- [ ]")); + expect(checkboxLines.length).toBe(1); + expect(checkboxLines[0]).toBe("- [ ] ship v1"); + expect(boxes.some((b) => b.startsWith("(covers domains:") && b.includes("api") && b.includes("worker"))).toBe(true); + }); }); describe("onboard greenfield gating (Gap 1): 'no code yet' only when truly empty", () => { diff --git a/tests/receipt.test.ts b/tests/receipt.test.ts new file mode 100644 index 0000000..5f9f4d0 --- /dev/null +++ b/tests/receipt.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildReceipt, + markSessionStart, + readSessionMark, + recordRead, + recordRedirect, + type SessionMark, + type ReceiptInput, +} from "../src/engine/receipt.js"; +import type { MeterReading } from "../src/engine/meter.js"; +import type { ActivityEvent } from "../src/engine/activity.js"; + +const baseMeter = (over: Partial = {}): MeterReading => ({ + usedTokens: 1000, + windowTokens: 200_000, + usedPct: 0.5, + savedTokens: 0, + optimizationPct: 0, + estimated: false, + cacheCold: false, + status: "ok", + advice: "", + billingMode: "unknown", + ...over, +}); + +const mark = (over: Partial = {}): SessionMark => ({ + startTs: new Date().toISOString(), + savedAtStart: 0, + usedAtStart: 0, + retrievalsAtStart: 0, + reads: {}, + redirects: {}, + ...over, +}); + +const ev = (over: Partial): ActivityEvent => ({ + ts: new Date().toISOString(), + agent: "a", + tool: "t", + summary: "s", + saved: 0, + ...over, +}); + +describe("buildReceipt — arithmetic + sink ordering", () => { + it("sums consumed/avoided exactly and lists only the top-5 sinks by rawTokens desc", () => { + const m = baseMeter({ savedTokens: 900, usedTokens: 100 }); + const sm = mark({ savedAtStart: 0, usedAtStart: 0, retrievalsAtStart: 0 }); + const events: ActivityEvent[] = [ + ev({ file: "a.ts", rawTokens: 100, storedTokens: 10 }), + ev({ file: "b.ts", rawTokens: 90, storedTokens: 9 }), + ev({ file: "c.ts", rawTokens: 80, storedTokens: 8 }), + ev({ file: "d.ts", rawTokens: 70, storedTokens: 7 }), + ev({ file: "e.ts", rawTokens: 60, storedTokens: 6 }), + ev({ file: "smallest.ts", rawTokens: 10, storedTokens: 1 }), + ]; + const input: ReceiptInput = { meter: m, mark: sm, events, eventsTrimmed: false, retrievalsTotal: 3 }; + const receipt = buildReceipt(input); + + // consumed = usedTokens - usedAtStart = 100; avoided = savedTokens - savedAtStart = 900 + expect(receipt).toContain("consumed ~100 tok · avoided 900 tok"); + // pct = round(900 / (100+900) * 100) = 90 + expect(receipt).toContain("(90% of what would have been)"); + + for (const f of ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]) expect(receipt).toContain(f); + expect(receipt).not.toContain("smallest.ts"); + + // exact sink lines + expect(receipt).toContain("a.ts: 100 → 10 tok (saved 90)"); + expect(receipt).toContain("e.ts: 60 → 6 tok (saved 54)"); + + expect(receipt).toContain("3 exact recall(s) served byte-for-byte this session"); + expect(receipt).toContain("lifetime: 900 tok saved · 3 exact recalls"); + }); +}); + +describe("buildReceipt — honest zero", () => { + it("nothing happened this session → the no-claims sentence, no sinks/forecast", () => { + const m = baseMeter({ savedTokens: 0, usedTokens: 500 }); + const sm = mark(); + const receipt = buildReceipt({ meter: m, mark: sm, events: [], eventsTrimmed: false, retrievalsTotal: 0 }); + expect(receipt).toContain("nothing was replaced or redirected, so nothing is claimed"); + expect(receipt).not.toContain("top sinks:"); + expect(receipt).not.toContain("at this pace the window lasts"); + expect(receipt).not.toContain("hygiene:"); + }); +}); + +describe("buildReceipt — cap forecast", () => { + it("plan billing + long session + avoided>0 → estimate line present", () => { + const startTs = new Date(Date.now() - 20 * 60_000).toISOString(); // 20 min ago + const m = baseMeter({ savedTokens: 500, usedTokens: 1000, billingMode: "plan", windowTokens: 200_000 }); + const sm = mark({ startTs }); + const receipt = buildReceipt({ + meter: m, + mark: sm, + events: [], + eventsTrimmed: false, + retrievalsTotal: 0, + now: () => Date.now(), + }); + expect(receipt).toContain("estimate"); + }); + + it("api billing → no forecast line regardless of duration", () => { + const startTs = new Date(Date.now() - 20 * 60_000).toISOString(); + const m = baseMeter({ savedTokens: 500, usedTokens: 1000, billingMode: "api" }); + const sm = mark({ startTs }); + const receipt = buildReceipt({ meter: m, mark: sm, events: [], eventsTrimmed: false, retrievalsTotal: 0 }); + expect(receipt).not.toContain("estimate"); + expect(receipt).not.toContain("at this pace the window lasts"); + }); +}); + +describe("buildReceipt — no mark / lifetime labeling", () => { + it("no session marker → the lifetime-labeled header", () => { + const m = baseMeter({ savedTokens: 200, usedTokens: 50 }); + const receipt = buildReceipt({ meter: m, mark: null, events: [], eventsTrimmed: false, retrievalsTotal: 1 }); + expect(receipt).toContain("— knitbrain receipt (lifetime — no session marker) —"); + }); +}); + +describe("buildReceipt — eventsTrimmed disclaimer", () => { + it("eventsTrimmed=true → rotation disclaimer present", () => { + const m = baseMeter({ savedTokens: 100, usedTokens: 10 }); + const sm = mark(); + const receipt = buildReceipt({ meter: m, mark: sm, events: [], eventsTrimmed: true, retrievalsTotal: 0 }); + expect(receipt).toContain("earliest events rotated out"); + }); +}); + +describe("recordRead / recordRedirect", () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "kb-receipt-")); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it("recordRead same path+mtime twice → count 2; different mtime → resets to 1", () => { + markSessionStart(root, { savedTokens: 0, usedTokens: 0, retrievals: 0 }); + recordRead(root, "/proj/a.ts", 111); + recordRead(root, "/proj/a.ts", 111); + let m = readSessionMark(root)!; + expect(m.reads["/proj/a.ts"]).toEqual({ count: 2, mtimeMs: 111 }); + + recordRead(root, "/proj/a.ts", 222); // file changed → restart at 1 + m = readSessionMark(root)!; + expect(m.reads["/proj/a.ts"]).toEqual({ count: 1, mtimeMs: 222 }); + }); + + it("recordRedirect increments a per-path counter", () => { + markSessionStart(root, { savedTokens: 0, usedTokens: 0, retrievals: 0 }); + recordRedirect(root, "/proj/big.ts"); + recordRedirect(root, "/proj/big.ts"); + const m = readSessionMark(root)!; + expect(m.redirects["/proj/big.ts"]).toBe(2); + }); + + it("no session.json (fresh root, no mark) → record* are no-ops that don't throw", () => { + expect(existsSync(join(root, "session.json"))).toBe(false); + expect(() => recordRead(root, "/proj/a.ts", 1)).not.toThrow(); + expect(() => recordRedirect(root, "/proj/a.ts")).not.toThrow(); + expect(readSessionMark(root)).toBeNull(); + }); +}); diff --git a/tests/tools-meta.test.ts b/tests/tools-meta.test.ts index 5316f3a..05f12e7 100644 --- a/tests/tools-meta.test.ts +++ b/tests/tools-meta.test.ts @@ -56,4 +56,10 @@ describe("MCP meta tools (metrics/skill_outcome)", () => { it("skill_outcome on an unknown skill returns a clear message", () => { expect(call("knitbrain_skill_outcome", { name: "no-such-skill", worked: false })).toContain("no skill named"); }); + + it("knitbrain_context_meter returns JSON with a string receipt field", () => { + const parsed = JSON.parse(call("knitbrain_context_meter")) as Record; + expect(typeof parsed["receipt"]).toBe("string"); + expect((parsed["receipt"] as string).length).toBeGreaterThan(0); + }); });