From 5caadc423c92416a71b8e52b1fdba3b70dc9dbbe Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 4 Oct 2026 19:13:50 -0700 Subject: [PATCH] Show persistent thread inference cost beside Whistlegraph brain --- aesel/src/attempt-receipt.mjs | 4 +- aesel/src/receipt-cost.mjs | 29 +++++++++++++ apple/whistlegraph/Resources/Web/engine.mjs | 6 +-- .../whistlegraph/Sources/BrainSettings.swift | 33 +++++++++++--- .../Sources/NativeScreenFixture.swift | 3 +- .../Sources/WhistlegraphScreen.swift | 5 ++- apple/whistlegraph/Tests/thread-cost.test.mjs | 43 +++++++++++++++++++ .../UITests/HeaderSheetsTests.swift | 17 ++++++++ apple/whistlegraph/project.yml | 2 +- 9 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 aesel/src/receipt-cost.mjs create mode 100644 apple/whistlegraph/Tests/thread-cost.test.mjs diff --git a/aesel/src/attempt-receipt.mjs b/aesel/src/attempt-receipt.mjs index 1772837c6b..89afa02042 100644 --- a/aesel/src/attempt-receipt.mjs +++ b/aesel/src/attempt-receipt.mjs @@ -1,3 +1,4 @@ +import {ReceiptCost} from './receipt-cost.mjs'; // Content-free, client-observed receipts. They are not authoritative billing. export const RECEIPT_LIMIT = 100; const number = n => Number.isFinite(n) && n >= 0 ? n : null; @@ -13,6 +14,7 @@ export class ReceiptJournal { for (const row of this.rows) if (row.receipt.status === 'running') { Object.assign(row.receipt, {status: 'interrupted', finishedAt: new Date().toISOString(), elapsedMs: null}); row.pending = true; } + this.cost = new ReceiptCost(storage, key, this.rows, RECEIPT_LIMIT); this.persist(); } persist() { try { this.storage.setItem(this.key, JSON.stringify(this.rows)); } catch { /* Generation must survive a full storage quota. */ } } @@ -20,7 +22,7 @@ export class ReceiptJournal { const row = {receipt: structuredClone(receipt), pending: receipt.status !== 'running'}; const index = this.rows.findIndex(r => r.receipt.id === receipt.id); if (index < 0) this.rows.push(row); else this.rows[index] = row; - this.rows = this.rows.slice(-RECEIPT_LIMIT); this.persist(); + this.rows = this.rows.slice(-RECEIPT_LIMIT); this.cost.record(receipt, this.rows); this.persist(); } pending() { return this.rows.find(r => r.pending)?.receipt ?? null; } acknowledge(id) { const row = this.rows.find(r => r.receipt.id === id); if (row) { row.pending = false; this.persist(); } } diff --git a/aesel/src/receipt-cost.mjs b/aesel/src/receipt-cost.mjs new file mode 100644 index 0000000000..4692936ed1 --- /dev/null +++ b/aesel/src/receipt-cost.mjs @@ -0,0 +1,29 @@ +// Provider-reported USD, not the wallet tariff. Keep lifetime totals when the +// detailed receipt journal rolls over; each retained attempt replaces itself. +export class ReceiptCost { + constructor(storage, key, rows, limit) { + this.storage = storage; this.key = key + '-receipt-cost'; + this.value = {usd: 0, missing: 0, partial: rows.length >= limit, recent: {}}; + try { + const saved = JSON.parse(storage.getItem(this.key)); + if (saved && Number.isFinite(saved.usd) && saved.usd >= 0 && Number.isInteger(saved.missing) && saved.missing >= 0 && saved.recent && typeof saved.recent === 'object') this.value = saved; + } catch {} + for (const row of rows) this.record(row.receipt, rows); + } + record(receipt, rows) { + const next = {usd: 0, missing: 0}; + for (const round of receipt.rounds) { + const cost = round.usage?.costUSD; + if (Number.isFinite(cost) && cost >= 0) next.usd += cost; + else if (round.httpStatus === null || round.httpStatus === undefined || round.httpStatus < 400) next.missing++; + } + const previous = this.value.recent[receipt.id] || {usd: 0, missing: 0}; + this.value.usd = Math.max(0, this.value.usd + next.usd - previous.usd); + this.value.missing = Math.max(0, this.value.missing + next.missing - previous.missing); + this.value.recent[receipt.id] = next; + const retained = new Set(rows.map(row => row.receipt.id)); + for (const id of Object.keys(this.value.recent)) if (!retained.has(id)) delete this.value.recent[id]; + try { this.storage.setItem(this.key, JSON.stringify(this.value)); } catch {} + } + snapshot() { return {usd: this.value.usd, partial: !!this.value.partial || this.value.missing > 0}; } +} diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index b6dca1a29f..ac8599759d 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -62,7 +62,7 @@ async function refreshBraincells(){ } function inferenceSnapshot(){ const model=activeModel||profile().model,receipt=activeReceipt?.value||receipts.rows.at(-1)?.receipt; - return {model,label:MODEL_LABELS[model]||model,provider:'OpenRouter',selection:profile().model,models:modelChoices(accountHandle),braincells,braincellsError, + return {model,label:MODEL_LABELS[model]||model,provider:'OpenRouter',selection:profile().model,models:modelChoices(accountHandle),braincells,braincellsError,threadCost:receipts.cost.snapshot(), usage:receipt?{inputTokens:receipt.rounds.reduce((n,r)=>n+(r.usage?.inputTokens||0),0),outputTokens:receipt.rounds.reduce((n,r)=>n+(r.usage?.outputTokens||0),0),rounds:receipt.rounds.length,repairs:receipt.repairs,status:receipt.status}:null}; } function paintHandle(handle,colors=handleCharacterColors('@'+handle)){ @@ -574,12 +574,12 @@ if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!windo }; // Every piece on this phone: the open one plus the archives "New piece" left. // Opening another swaps archives, so the current one is never lost. - const ARCHIVE='walkieware-archive-',ARCHIVE_SUFFIXES=['-cloud-revision','-cloud-ledger','-receipts']; + const ARCHIVE='walkieware-archive-',ARCHIVE_SUFFIXES=['-cloud-revision','-cloud-ledger','-receipts','-receipt-cost']; function archiveCurrentPiece(){ const extras={};for(const suffix of ARCHIVE_SUFFIXES){const v=localStorage.getItem(storageKey+suffix);if(v!==null)extras[suffix]=v;} localStorage.setItem(ARCHIVE+thread.identity.id,JSON.stringify({identity:thread.identity,ledger:versions.value,source,extras,archivedAt:new Date().toISOString()})); thread.suspend(); - for(const suffix of ['', '-versions','-thread','-cloud-revision','-cloud-ledger','-attempt','-inflight','-receipts'])localStorage.removeItem(storageKey+suffix); + for(const suffix of ['', '-versions','-thread','-cloud-revision','-cloud-ledger','-attempt','-inflight','-receipts','-receipt-cost'])localStorage.removeItem(storageKey+suffix); } function pieceSummary(id,identity,ledger,current){ const made=(ledger?.versions||[]).filter(v=>v.id>0),last=made.at(-1); diff --git a/apple/whistlegraph/Sources/BrainSettings.swift b/apple/whistlegraph/Sources/BrainSettings.swift index ba3e0c01e8..461d5f9bf6 100644 --- a/apple/whistlegraph/Sources/BrainSettings.swift +++ b/apple/whistlegraph/Sources/BrainSettings.swift @@ -8,6 +8,7 @@ struct InferenceSnapshot: Decodable { let limit: Double let purchased: Double let resetsAt: String? + let unlimited: Bool? } struct Usage: Decodable { let inputTokens: Int @@ -24,6 +25,8 @@ struct InferenceSnapshot: Decodable { let braincells: Braincells? let braincellsError: String let usage: Usage? + struct ThreadCost: Decodable { let usd: Double; let partial: Bool } + let threadCost: ThreadCost? } struct BrainButton: View { @@ -66,12 +69,16 @@ struct BrainSettings: View { } Section("Braincells") { if let balance = inference.braincells { - LabeledContent("Daily remaining", value: cells(balance.remaining) + " / " + cells(balance.limit)) - .accessibilityIdentifier("brain-balance") - ProgressView(value: min(balance.used, balance.limit), total: max(1, balance.limit)) - .accessibilityLabel("Daily braincells used").accessibilityValue(cells(balance.used)) + if balance.unlimited == true { + LabeledContent("Allowance", value: "Unlimited") + } else { + LabeledContent("Daily remaining", value: cells(balance.remaining) + " / " + cells(balance.limit)) + .accessibilityIdentifier("brain-balance") + ProgressView(value: min(balance.used, balance.limit), total: max(1, balance.limit)) + .accessibilityLabel("Daily braincells used").accessibilityValue(cells(balance.used)) + } LabeledContent("Purchased", value: cells(balance.purchased)) - if let reset = balance.resetsAt.flatMap({ ISO8601DateFormatter().date(from: $0) ?? ISO8601DateFormatter.fullPrecision.date(from: $0) }) { + if balance.unlimited != true, let reset = balance.resetsAt.flatMap({ ISO8601DateFormatter().date(from: $0) ?? ISO8601DateFormatter.fullPrecision.date(from: $0) }) { LabeledContent("Daily reset", value: reset.formatted(date: .omitted, time: .shortened)) } } else if inference.braincellsError.isEmpty { @@ -105,3 +112,19 @@ private extension ISO8601DateFormatter { return formatter } } + +struct ThreadCostLabel: View { + let cost: InferenceSnapshot.ThreadCost + private var amount: String { + cost.partial ? (floor(cost.usd * 100) / 100).formatted(.currency(code: "USD")) : + (cost.usd > 0 && cost.usd < 0.01 ? "< $0.01" : cost.usd.formatted(.currency(code: "USD"))) + } + var body: some View { + Text((cost.partial ? "≥ " : "") + amount) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + .lineLimit(1).fixedSize(horizontal: true, vertical: false) + .accessibilityLabel("Thread inference cost, USD") + .accessibilityValue((cost.partial ? "At least " : "") + amount) + .accessibilityIdentifier("thread-cost") + } +} diff --git a/apple/whistlegraph/Sources/NativeScreenFixture.swift b/apple/whistlegraph/Sources/NativeScreenFixture.swift index b8687c334b..16007ad4df 100644 --- a/apple/whistlegraph/Sources/NativeScreenFixture.swift +++ b/apple/whistlegraph/Sources/NativeScreenFixture.swift @@ -27,7 +27,8 @@ enum NativeScreenFixture { let ledger: [String: Any] = ["format": 1, "head": 3, "versions": versions] let json = String(data: try! JSONSerialization.data(withJSONObject: ledger), encoding: .utf8)! let busy = mode == "working" ? "window.__walkiewareFixtureBusy='Someone is eating them';" : "" - return busy + "window.__whistlegraphFixture=true;window.__walkiewareDisableThread=true;localStorage.setItem('whistlegraph-fixture-source-versions',JSON.stringify(\(json)));localStorage.setItem('whistlegraph-fixture-source',\(json).versions[3].source);" + let cost = "localStorage.removeItem('whistlegraph-fixture-source-receipt-cost');localStorage.setItem('whistlegraph-fixture-source-receipts',JSON.stringify([{receipt:{format:1,id:'fixture-cost',repairs:0,status:'success',rounds:[{usage:{costUSD:0.42}}]}}]));" + return busy + cost + "window.__whistlegraphFixture=true;window.__walkiewareDisableThread=true;localStorage.setItem('whistlegraph-fixture-source-versions',JSON.stringify(\(json)));localStorage.setItem('whistlegraph-fixture-source',\(json).versions[3].source);" } } #endif diff --git a/apple/whistlegraph/Sources/WhistlegraphScreen.swift b/apple/whistlegraph/Sources/WhistlegraphScreen.swift index c2ab8bf4a8..71832a0402 100644 --- a/apple/whistlegraph/Sources/WhistlegraphScreen.swift +++ b/apple/whistlegraph/Sources/WhistlegraphScreen.swift @@ -147,7 +147,10 @@ struct WhistlegraphScreen: View { .accessibilityLabel("Send chalk").accessibilityIdentifier("drawing-send") .disabled(!canTalk || session.capturePhase != .idle) } - BrainButton(session: session) { showComposer = false } + HStack(spacing: 6) { + if let cost = session.snapshot.inference?.threadCost { ThreadCostLabel(cost: cost) } + BrainButton(session: session) { showComposer = false } + } }.font(.title3).buttonStyle(.plain).frame(minHeight: 44) if drawing.full { Text("Chalk full · send or undo a stroke").font(.caption) } if session.capturePhase == .recording || session.capturePhase == .opening { diff --git a/apple/whistlegraph/Tests/thread-cost.test.mjs b/apple/whistlegraph/Tests/thread-cost.test.mjs new file mode 100644 index 0000000000..fe999685cd --- /dev/null +++ b/apple/whistlegraph/Tests/thread-cost.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ReceiptJournal, AttemptReceipt, RECEIPT_LIMIT} from '../../../aesel/src/attempt-receipt.mjs'; +const storage = () => { const m=new Map(); return {getItem:k=>m.get(k)??null,setItem:(k,v)=>m.set(k,v)}; }; +const receipt = (id, costs, status='success') => ({format:1,id,status,rounds:costs.map(costUSD=>({httpStatus:200,usage:{costUSD}}))}); +test('thread total includes failed attempts, repairs and reviews; duplicate delivery replaces usage',()=>{ + const s=storage(),j=new ReceiptJournal(s,'a'); + j.save(receipt('one',[0.25,0.125,0.125]));j.save(receipt('two',[0.5],'failed')); + j.save(receipt('two',[0.5],'failed')); + assert.deepEqual(j.cost.snapshot(),{usd:1,partial:false}); + assert.deepEqual(new ReceiptJournal(s,'a').cost.snapshot(),{usd:1,partial:false}); + assert.deepEqual(new ReceiptJournal(s,'b').cost.snapshot(),{usd:0,partial:false}); +}); +test('lifetime cost survives journal eviction, reload, and updates to retained attempts',()=>{ + const s=storage(),j=new ReceiptJournal(s,'a'); + for(let i=0;i{ + const j=new ReceiptJournal(storage(),'a'); + j.save(receipt('one',[null])); assert.deepEqual(j.cost.snapshot(),{usd:0,partial:true}); + j.save(receipt('one',[0])); assert.deepEqual(j.cost.snapshot(),{usd:0,partial:false}); + j.save({format:1,id:'rejected',status:'failed',rounds:[{httpStatus:429,usage:null}]}); + assert.equal(j.cost.snapshot().partial,false); +}); +test('legacy receipts migrate and a full old journal marks possibly missing history',()=>{ + const s=storage();s.setItem('a-receipts',JSON.stringify([{receipt:receipt('old',[0.5])}])); + assert.deepEqual(new ReceiptJournal(s,'a').cost.snapshot(),{usd:0.5,partial:false}); + s.setItem('b-receipts',JSON.stringify(Array.from({length:100},(_,i)=>({receipt:receipt(String(i),[1])})))); + assert.deepEqual(new ReceiptJournal(s,'b').cost.snapshot(),{usd:100,partial:true}); +}); +test('live receipt usage reaches cost without waiting for successful completion',()=>{ + const j=new ReceiptJournal(storage(),'a'); + const r=new AttemptReceipt({requestID:'test',parent:0,parentHash:null,path:'compiled',model:'test',journal:j}); + const round=r.request();r.headers(round,{status:200}); + r.notify('turn/usage',{usage:{cost:0.25}}); + r.notify('turn/usage',{usage:{cost:0.25}}); + assert.deepEqual(j.cost.snapshot(),{usd:0.25,partial:false}); +}); diff --git a/apple/whistlegraph/UITests/HeaderSheetsTests.swift b/apple/whistlegraph/UITests/HeaderSheetsTests.swift index ff41e7df55..d0ebce482b 100644 --- a/apple/whistlegraph/UITests/HeaderSheetsTests.swift +++ b/apple/whistlegraph/UITests/HeaderSheetsTests.swift @@ -14,6 +14,10 @@ final class HeaderSheetsTests: XCTestCase { let app = XCUIApplication() app.launchEnvironment["WALKIE_NATIVE_SCREEN_FIXTURE"] = "history" app.launch() + // The button exists as "Log in" before the engine has loaded the fixture. + let account = app.buttons.matching(identifier: "workspace-account") + .matching(NSPredicate(format: "label == %@", "@preview, account")).firstMatch + XCTAssertTrue(account.waitForExistence(timeout: 25), "the signed-in fixture is ready") return app } @@ -70,6 +74,19 @@ final class HeaderSheetsTests: XCTestCase { XCTAssertTrue(waitForDisappearance(of: app.buttons["account-sign-out"]), "Done closes the account sheet") } + func testThreadCostAppearsLeftOfBrain() { + let app = launch() + let cost = app.staticTexts["thread-cost"] + XCTAssertTrue(cost.waitForExistence(timeout: 20)) + XCTAssertEqual(cost.value as? String, "$0.42") + let brain = app.buttons["brain-settings"] + XCTAssertTrue(brain.exists) + XCTAssertLessThanOrEqual(cost.frame.maxX, brain.frame.minX) + XCTAssertLessThan(abs(cost.frame.midY - brain.frame.midY), 3) + let image = XCTAttachment(screenshot: app.screenshot()) + image.name = "Thread cost left of brain"; image.lifetime = .keepAlways; add(image) + } + func testPixelSizePersistsAcrossLaunches() { let app = launch() let code = app.buttons["brain-settings"] diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index 04301ed594..ecf0a43c83 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -9,7 +9,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "98" + CURRENT_PROJECT_VERSION: "99" targets: Whistlegraph: type: application -- 2.51.2