Merge compaction and native usage accounting fixes

This commit is contained in:
amosblomqvist
2026-10-24 19:53:18 +03:00
53 changed files with 930 additions and 2337 deletions
+6 -4
View File
@@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
import { isModelsRefusal, isProviderKeyRefusal } from "@/src/core";
import { formatTokens, planCostLabel } from "@/components/usage-format";
import { useServerEvent, useServerResync } from "@/components/server-events";
@@ -20,7 +20,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
* oldest-first + extraction runs), fetched when truth moves (events
* channel, S5) and folded client-side with core/prices.ts.
* Cost is INFERRED (jobCost); the context line is the LAST model call's
* Cost is INFERRED server-side through Pi; the context line is the LAST model call's
* input+output against the model's limit (JobUsage.lastStep — pending
* contract) — never a cumulative sum, which would be the wrong number.
* Absent data renders "—", never zero (the doctrine).
@@ -48,6 +48,8 @@ interface FeedUsage extends NonNullable<JobRecord["usage"]> {
}
export interface FeedJob extends JobRecord {
/** Read-time server estimate; absent on older transport payloads. */
costUsd?: number | null;
title: string;
usage?: FeedUsage;
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
@@ -200,7 +202,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
// blob shape's call — here the line simply hides).
const limit = job.contextLimit;
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
// Same metric as the session meter (prices.ts stepFootprint — the
// Same metric as the session meter (four normalized buckets — the
// context-meter audit, owner 2026-09-23): all four directions.
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
// alone reads wildly low on cache-heavy jobs.
@@ -220,7 +222,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
title: job.title,
chatId: job.chatId,
startedAt: Date.parse(job.createdAt) || 0,
cost: jobCost(job),
cost: job.costUsd ?? null,
context,
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
// the honest line the runner left ("failed") reads as setup
+17 -6
View File
@@ -57,7 +57,7 @@ import { PracticeDock } from "@/components/practice-dock";
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
import { TopBar } from "@/components/top-bar";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
import {
@@ -236,7 +236,7 @@ export function Chat({
// (core/view.ts projectView); sends are ONE explicit input event each
// ({chatId, input} — the stash indirection died with the SDK), and the
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
useChatSession({ chatId, initialMessages, initiallyInFlight });
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
@@ -717,8 +717,9 @@ export function Chat({
const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] };
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
if (Array.isArray(record.turns)) setTurns(record.turns);
setPricing(record.pricing);
if (!Array.isArray(record.messages)) return;
setMessages(record.messages);
// The continuation seam (contracts.ts protocol §4): a job result
@@ -845,12 +846,13 @@ export function Chat({
const planTree = useMemo(() => latestTree(messages), [messages]);
// ── Session usage (wave-4 stats bar) ──
// The money channel: the record's turns log, folded client-side by
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
// The money channel: the record's turns log and read-time server Pi
// pricing projection. Seeded once on mount (the page doesn't carry turns),
// refreshed when the local stream settles (each turn appends to the log
// server-side), and kept fresh by syncFromServer whenever it already
// fetches the record. No turns yet → the bar renders "—" everywhere.
const [turns, setTurns] = useState<TurnUsage[]>([]);
const [pricing, setPricing] = useState<SessionPricing | undefined>();
// The per-chat model control state (model-picker writes via PUT; the
// record is the source of truth, lifted here so the stats bar re-renders).
// The dock's reserved tail height (DockOverlay reports it; the spacer at
@@ -864,8 +866,9 @@ export function Chat({
try {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
const record = (await res.json()) as { turns?: TurnUsage[] };
const record = (await res.json()) as { turns?: TurnUsage[]; pricing?: SessionPricing };
if (Array.isArray(record.turns)) setTurns(record.turns);
setPricing(record.pricing);
} catch {
// transient blip: stats stay on the last known figures
}
@@ -1604,6 +1607,8 @@ export function Chat({
onReadingThinking={applyReadingThinking}
phase={phases.at(-1) ?? "probe"}
turns={turns}
contextTokens={contextTokens}
pricing={pricing}
onReserve={setDockReserve}
dictationSlot={<DictationBar dictation={dictation} />}
dictationCancel={dictation.cancel}
@@ -1824,6 +1829,8 @@ function DockOverlay({
onReadingThinking,
phase,
turns,
contextTokens,
pricing,
composer,
onSubmitAnswer,
chatId,
@@ -1855,6 +1862,8 @@ function DockOverlay({
phase: Phase3;
/** The session's usage log — the stats bar beneath the composer folds it. */
turns: TurnUsage[];
contextTokens: number | null;
pricing?: SessionPricing;
composer: React.ReactNode;
onSubmitAnswer: (output: QuestionAnswerData) => void;
/** The model control's pass-through (the stats bar hosts it). */
@@ -2327,6 +2336,8 @@ function DockOverlay({
morphing shell so it never joins a height animation. */}
<StatsBar
turns={turns}
contextTokens={contextTokens}
pricing={pricing}
dictationSlot={dictationSlot}
chatId={chatId}
/>
+1 -1
View File
@@ -303,7 +303,7 @@ function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">): string {
const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0);
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
const dollars = displayCostTotals(costs).totalDollars;
return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : `$${dollars.toFixed(2)}`;
return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : priced ? `$${dollars.toFixed(2)}` : "—";
}
/**
+4 -1
View File
@@ -81,7 +81,7 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
* changing the new-session default. Unpicked reads "—" (the
* no-factory-fallback language: the UI never presents a default model).
*/
export function ModelControl({ chatId }: { chatId?: string }) {
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState<ModelsSettings | undefined>();
const [session, setSession] = useState<ChatSettings | undefined>();
@@ -136,6 +136,9 @@ export function ModelControl({ chatId }: { chatId?: string }) {
: strong;
const effectiveId = override?.model?.id;
const effectiveProvider = override?.model?.provider;
useEffect(() => {
onModelChange?.(effectiveProvider && effectiveId ? `${effectiveProvider}:${effectiveId}` : undefined);
}, [effectiveProvider, effectiveId, onModelChange]);
const storedLevel = override?.thinkingLevel;
const entry = catalog?.models.find(
(m) => m.id === effectiveId && m.provider === effectiveProvider,
+11
View File
@@ -17,6 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import {
needsContinuation,
measuredContextTokens,
newAnswerResult,
newNextMessage,
newProblemDismissedMarker,
@@ -163,6 +164,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
export interface SessionState {
messages: LhViewMessage[];
nativeMessages: LhMessage[];
/** Completed conversation usage; null after compaction or before first usage. */
contextTokens: number | null;
status: SessionStatus;
error: { message: string } | null;
/** Steers sent but not yet drained (composer steering, 2026-10-03):
@@ -220,6 +223,7 @@ export class SessionModel {
// The live turn's execution layer (view model mid-turn states):
private executions = new Map<string, LiveExecution>();
private streamingIds = new Set<string>();
private incompleteUsageIds = new Set<string>();
/** The in-flight assistant message's client id (see applyWireEvent). */
private streamingMessageId: string | null = null;
private statusValue: SessionStatus = "ready";
@@ -257,6 +261,7 @@ export class SessionModel {
return {
messages: projectView([...this.base, ...this.tail], live),
nativeMessages: [...this.base, ...this.tail],
contextTokens: measuredContextTokens([...this.base, ...this.tail], this.incompleteUsageIds),
status: this.statusValue,
error: this.errorValue,
steerQueue: this.steerQueueValue,
@@ -364,6 +369,8 @@ export class SessionModel {
// the wire) still wins.
const id = message.id ?? this.streamingMessageId ?? localId();
const withId = { ...message, id } as LhMessage;
if (agent.type === "message_end") this.incompleteUsageIds.delete(id);
else this.incompleteUsageIds.add(id);
if (agent.type === "message_start") {
this.streamingMessageId = id;
this.streamingIds.add(id);
@@ -487,6 +494,7 @@ export class SessionModel {
// every path that actually replays onto a stale tail).
if (!refetchRecordFirst) {
this.mutate(() => {
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null;
});
@@ -502,6 +510,7 @@ export class SessionModel {
if (Array.isArray(record.messages)) {
this.mutate(() => {
this.base = record.messages!;
this.incompleteUsageIds.clear();
this.tail = [];
});
}
@@ -509,6 +518,7 @@ export class SessionModel {
// The record did not land — restore the clean-slate guard
// before the replay (no atomic swap is coming).
this.mutate(() => {
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null;
});
@@ -714,6 +724,7 @@ export class SessionModel {
setMessages(record: LhMessage[]): void {
this.mutate(() => {
this.base = record;
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null; // the tail (and its slot) is gone
});
+1
View File
@@ -124,6 +124,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
return {
messages: state.messages,
nativeMessages: state.nativeMessages,
contextTokens: state.contextTokens,
status: state.status,
error: state.error,
steerQueue: state.steerQueue,
+26 -25
View File
@@ -1,10 +1,15 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
import type { SessionPricing, TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
export function formatContextMeasurement(tokens: number | null, limit: number | null): string {
const numerator = tokens === null ? "?" : formatTokens(tokens);
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
}
/**
* The below-composer stats bar (wave 4 observability): the session's
* money/context footprint as one quiet row beneath the composer —
@@ -16,23 +21,23 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model
* there died with the injection; a "— · —" summary over an empty
* popover is pure noise) — the dictation strip's middle simply widens.
*
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
* — no cost math lives here. Absent data renders "—", never zero (the
* doctrine): no turns yet → every figure is "—"; turns the price table
* can't price count as unpriced, shown in the popover, never folded into
* a fake $0. The RIGHT side is the model + thinking control (components/
* model-picker.tsx): one controller for the conversation agents, backed by
* pi-ai's catalog; the buttons show the EFFECTIVE model (the record's
* settings, else config's pin), while the left-side figures still fold the
* turn log's actual billed models.
* Spending arrives already priced by the server through Pi's catalog and
* calculator. Missing cost stays "—", never fake zero. Context is a separate
* completed-conversation measurement, never a money-log/summarizer fold or
* transcript estimate. Its catalog denominator follows the selected model;
* model changes preserve the numerator. Successful compaction clears it to ?.
*/
export function StatsBar({
turns,
contextTokens = null,
pricing,
dictationSlot,
chatId,
leadingSlot,
}: {
turns: TurnUsage[];
contextTokens?: number | null;
pricing?: SessionPricing;
/** The bound session (per-session models, owner 2026-09-28): present
* on the in-session bar — the model control binds THE SESSION'S own
* record; absent on the draft surfaces, where it binds the strong
@@ -49,6 +54,7 @@ export function StatsBar({
leadingSlot?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>();
// Same dropdown discipline as the model control (owner, df4433f treatment,
// stats round 1): close on any pointerdown outside the bar or on Escape.
const barRef = useRef<HTMLDivElement>(null);
@@ -73,22 +79,17 @@ export function StatsBar({
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
// The live-limits join (context-meter amendment, owner 2026-09-23):
// contextWindow's denominator resolves ONLY from pi's live catalog —
// built client-side over the picker's cached /api/models fetch (the
// same fetch ModelControl below rides; module-level dedupe). Catalog
// pending or failed → undefined index → limit null → USAGE-ONLY (the
// honest-render precedent, never a fake window); the usage-only render
// IS the null path.
// Catalog-only denominator; the picker reports its effective selection.
// Missing/pending catalog means usage-only, never a hardcoded maximum.
const { catalog } = useModelCatalog();
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
const ctx = contextWindow({ turns }, limits);
const { cost, unpriced, subscription = 0 } = sessionCost({ turns });
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
const contextLabel = formatContextMeasurement(contextTokens, limit);
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
// Honest cost: show dollars only when at least one turn priced;
// otherwise "—" (never a fake $0.0000).
const pricedCount = turns.length - unpriced - subscription;
const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—";
const lastModel = turns.at(-1)?.model;
return (
<div
@@ -110,7 +111,7 @@ export function StatsBar({
{leadingSlot}
{/* The left side: the session-data summary — only once a session
exists (turns). Pre-chat renders nothing (see the header). */}
{turns.length > 0 && (
{(chatId || turns.length > 0) && (
<div ref={barRef} className="relative">
{/* No tooltip — the numbers describe themselves and the popover
speaks on click (owner, subtlety round 2026-09-25). */}
@@ -122,7 +123,7 @@ export function StatsBar({
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
>
<span data-testid="stats-context">
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
{contextLabel}
</span>
<span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span>
@@ -139,7 +140,7 @@ export function StatsBar({
<div className="flex justify-between gap-2">
<span>Context (last call)</span>
<span className="tabular-nums text-card-foreground">
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
{contextLabel}
</span>
</div>
<div className="flex justify-between gap-2">
@@ -161,7 +162,7 @@ export function StatsBar({
</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => {
const c = turnCost(turn);
const c = pricing?.turnCosts[turn.id] ?? null;
return (
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground">
@@ -190,7 +191,7 @@ export function StatsBar({
{/* The model + thinking control — one controller for the conversation
agents (prober/teacher); the record's `turns` above still folds
the ACTUAL models billed, regardless of what the picker says. */}
<ModelControl chatId={chatId} />
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
</div>
);
}