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
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* GET /api/activity — the dock's one poll: every running background
* process, every view (chat, home, everywhere). Composition only — the
* feed lives in server/observability.ts; cost folds client-side via
* core/prices.ts. See that module's header for why /api/jobs stays
* feed lives in server/observability.ts; costs are projected server-side
* through Pi's catalog and public calculator. See that module's header for why /api/jobs stays
* chat-scoped and untouched.
*/
import { activityFeed } from "@/src/server";
+2 -1
View File
@@ -1,4 +1,4 @@
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -17,6 +17,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
return Response.json({
...chat,
messages: chat.messages,
pricing: sessionCost(chat),
turnInFlight: isTurnRunning(id),
compacting: compactionInFlight(id),
coverage: sessionCoverage(id),
+1 -1
View File
@@ -4,7 +4,7 @@
* three usage channels (chat extraction runs, notes-source runs,
* cluster-naming runs). Thin transport over src/server's graphCosts();
* the fold itself is pure core (core/cost-summary.ts). Dollars are
* inferred from PRICES at read time — never stored, so a price sync
* inferred from Pi's catalog/calculator at read time — never stored, so a catalog update
* re-prices the whole history for free.
*
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
+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>
);
}
+84
View File
@@ -0,0 +1,84 @@
# Compaction and usage accounting
## Retention
`src/core/compaction.ts` locally ports the cutoff-selection behavior and relevant
message-size heuristic from **@earendil-works/pi-coding-agent 1.4.0**. There is no
coding-agent dependency. Upgrade this port deliberately; the native golden
fixtures are in `tests/compaction-native-parity.test.ts`.
Walk backward to the approximately 20,000-token retained-tail budget, then select
the nearest valid user/assistant boundary at or after the crossing. Never start
at a tool result. If trailing results alone exceed the budget, keep the preceding
assistant call. Preserve adjacent context-invisible metadata. LearnHarness adapts
its visible custom markers to user-like entries, excludes UI-only markers, and
uses the active compaction's retained history rather than discarded messages.
Text, visible thinking, tool-call names/arguments use characters divided by four;
images receive the native fixed 1,200-token allowance. Encrypted signatures are
not measured or stripped. Whole-message boundaries can overshoot. This does
**not** guarantee a measured 20k tail or a fixed post-compaction request size.
Only cutoff selection is ported. LearnHarness retains its educational summary
prompt, plan continuity, persistence, threshold, scheduling, and authorization
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
calls. A cut that drops no active history does not launch a summarizer.
## Conversation meter
The numerator is the latest qualifying completed conversation response's native
Pi `input + output + cacheRead + cacheWrite`. Reasoning is already inside output;
`totalTokens` is not an override. Partial stream updates, errors, aborts, invalid
counts, and all-zero usage do not replace the last measurement. A successful
compaction marker invalidates earlier measurements: show `? / maximum` until a
new qualifying response completes. Summarizer usage never supplies this meter.
The denominator follows the selected model's catalog limit, independently of the
last numeric measurement. Changing models preserves that numerator. No catalog
limit means no invented denominator; no percentage display is added.
## Spending
`src/server/pricing.ts` uses Pi AI's full, key-independent catalog and public
`calculateCost` on a new usage object. The browser receives derived costs, not a
second calculator/rate table. Totals are estimates using the currently shipped
catalog, not provider invoices; catalog updates can reprice history.
Saved input is Pi-normalized **fresh input**, disjoint from cache buckets. Never
subtract cached reads from it again or migrate old logs as raw inclusive prompt
tokens. New captures preserve the one-hour subset of cache writes and individual
background-job calls. Older write-bearing records with a lost retention split,
and tier-priced multi-call jobs with only aggregate usage, remain unpriced when
the lost detail can change the answer. They are not reconstructed from totals.
Flat-rate historical jobs with sufficient counts remain priceable. Missing models
or required usage stay unpriced; ChatGPT subscriptions remain plan usage, not
API dollars. Summarizer costs continue to count toward session spending.
## Upstream attribution
The cutoff/estimator port adapts MIT-licensed code from
`@earendil-works/pi-coding-agent` 1.4.0, author Mario Zechner (published package
metadata). The inspected `dist/core/compaction/compaction.js` SHA-256 is
`f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6`.
MIT License
Copyright (c) Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+5 -2
View File
@@ -30,7 +30,6 @@ npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY key
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables
```
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
@@ -144,7 +143,7 @@ src/
electron/ desktop launcher + security guard (spawns the standalone
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
scripts/ build-server.mjs (packaging guards), import-notes,
backfill-graph, replay-layout, sync-pricing
backfill-graph, replay-layout
data/ chats/ · graph/ (nodes/chains/sessions/coverage
ledgers + positions/sources/render-settings maps) ·
jobs/ · settings.json — runtime state, git-ignored
@@ -152,6 +151,10 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage
## Key mechanisms
See [compaction and accounting](compaction-accounting.md) for the local Pi cutoff port,
measurement-only context meter, and server-side current-catalog pricing. There is no
pricing-generation step: Pi AI's catalog and public calculator are the authority.
**Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**.
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
+3
View File
@@ -84,6 +84,9 @@ files:
# Main-process ID-token signature verification (no bundled OAuth implementation).
- node_modules/jose/**/*
extraResources:
# Attribution/license for the bounded local Pi cutoff-selector port.
- from: docs/compaction-accounting.md
to: licenses/pi-compaction.md
# Copy from the PARENT dir (.next), not .next/standalone itself:
# electron-builder hard-excludes a copy's root node_modules and the
# default no-filter pattern is a literal path match (empirically both
-121
View File
@@ -1,121 +0,0 @@
/**
* Regenerate src/core/prices.ts tables from pi-ai's shipped per-provider
* catalogs (providers/data/*.json).
*
* Usage: npx tsx scripts/sync-pricing.ts
*
* The catalogs are pi-ai's curated, versioned snapshots — the SAME source
* piEngineFor resolves engines from and /api/models serves the picker
* from — so the money channel's lookup table can never disagree with the
* engine. Fully offline (the JSON ships in node_modules); refreshes happen
* by bumping @earendil-works/pi-ai and re-running this script. The tables
* are generated; hand edits are overwritten. A model missing from the
* catalogs gets no entry — cost renders as absent ("—"), never zero.
*
* Key format (model slots wave, 2026-09-20): uniformly provider-qualified
* ("provider:id", incl. "openrouter:<slug>"). Owner decision: no legacy
* compat — records stamped with pre-wave bare slugs simply render unpriced.
*/
import * as fs from "node:fs";
import * as path from "node:path";
const DATA_DIR = path.join(
import.meta.dirname,
"..",
"node_modules",
"@earendil-works",
"pi-ai",
"dist",
"providers",
"data",
);
const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts");
/** One raw catalog entry (fields we consume; the files carry more). */
interface CatalogModel {
id: string;
name: string;
/** pi-ai 1.0 (catalog schema v6): every entry is type-tagged. */
type?: "chat" | "image" | "classifier";
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
/** The pure walk: every catalog entry that carries pricing, keyed
* "provider:id". Exported for the drift-guard test (tests/prices-sync)
* so the guard asserts EXACTLY what this script would generate — the
* test and the generator can never disagree (owner, 2026-09-29). */
export function buildPrices(): Map<string, CatalogModel["cost"]> {
const priced = new Map<string, CatalogModel["cost"]>();
for (const file of fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json"))) {
const provider = file.slice(0, -".json".length);
const catalog = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), "utf-8")) as Record<
string,
Record<string, CatalogModel>
>;
for (const models of Object.values(catalog)) {
for (const [id, m] of Object.entries(models)) {
// pi-ai 1.0 (catalog schema v6): keys are type-qualified
// ("chat:<id>", "image:<id>", "classifier:<id>") and entries
// carry their own `type`. We price CHAT models only — our
// usage records are chat — and keys stay "provider:id"
// (owner format, 2026-09-20): strip the chat: prefix, skip the
// rest.
if (m.type && m.type !== "chat") continue;
const plainId = id.startsWith("chat:") ? id.slice("chat:".length) : id;
// A catalog entry without pricing data gets no key at all
// (cost renders "—", never zero).
if (!m.cost) continue;
// First writer wins (an id can appear under two apis within one
// file with identical pricing; cross-file collisions cannot
// happen — keys are provider-qualified).
priced.set(`${provider}:${plainId}`, m.cost);
}
}
}
return priced;
}
function main(): void {
const priced = new Map<string, { cost: CatalogModel["cost"] }>(
[...buildPrices()].map(([k, cost]) => [k, { cost }] as const),
);
// Sort by key for a stable, diff-friendly generated table.
const rows = [...priced.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, m]) => ({
price: `\t${JSON.stringify(key)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`,
}));
const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render
* as absent ("—"), never zero. Keys are uniformly provider-qualified
* ("provider:id") — NO legacy bare-slug compat (owner decision, sync
* script 2026-09-20): pre-wave records stamped with bare slugs render
* unpriced.
* Context-window limits are NOT a table (owner amendment, 2026-09-23):
* every meter's denominator reads pi's LIVE catalog (GET /api/models /
* the activity feed's server-side join) — missing model, usage-only.
* Refresh: bump @earendil-works/pi-ai, then npx tsx scripts/sync-pricing.ts
* (reads pi-ai's shipped catalogs — the same source the engine and the
* picker use). Forgetting the re-run is exactly how models go unpriced
* (deepseek-v4.1-flash, 2026-09-29) — the drift-guard test
* (tests/prices-sync.test.ts) fails the gates until you do. */
export const PRICES: Record<string, ModelPrice> = {
${rows.map((r) => r.price).join("\n")}
};
`;
const src = fs.readFileSync(OUT, "utf-8");
const start = src.indexOf("/** Token prices");
const end = src.indexOf("// ─── Pure helpers");
if (start === -1 || end === -1) throw new Error("prices.ts markers not found — file structure changed");
fs.writeFileSync(OUT, src.slice(0, start) + tables + "\n" + src.slice(end), "utf-8");
console.log(`prices.ts regenerated from pi-ai's catalogs: ${priced.size} keys priced.`);
}
// Run only as a script, not when imported (the drift-guard test).
if (process.argv[1] && import.meta.filename && path.resolve(process.argv[1]) === import.meta.filename) {
main();
}
+11 -5
View File
@@ -14,6 +14,12 @@ interface BridgeState {
const key = Symbol.for("lh.chatgpt.bridge");
const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null });
const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
/** Next dev also uses parent/child IPC. IPC alone is not our desktop
* transport: require the same launch markers set by Electron startServer.
* These identify the transport, not a substitute for admission/lease checks. */
function desktopIPCConnected(): boolean {
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
}
function bridge(): BridgeState {
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
if (!global[key]) {
@@ -21,7 +27,7 @@ function bridge(): BridgeState {
global[key] = state;
process.on("message", (raw: unknown) => {
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
if (m?.channel !== "lh:chatgpt") return;
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
acceptStatus(state, { ...m.stamp, status: m.status });
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
@@ -57,7 +63,7 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
if (!process.send || !process.connected) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
return new Promise<T>((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000);
@@ -68,7 +74,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
});
}
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
@@ -80,7 +86,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
}
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
const s = bridge();
return !!process.connected && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
}
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
@@ -102,7 +108,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
/** Preserve account order; unknown capabilities/windows are never fabricated. */
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
const state = bridge();
if (!process.send || !process.connected) { clearCatalog(state); return; }
if (!desktopIPCConnected()) { clearCatalog(state); return; }
const sequence = ++state.catalogRequest;
try {
const value = await chatGPTRequest<ModelsValue>("models");
+1
View File
@@ -777,6 +777,7 @@ export function callUsage(usage: Usage): CallUsage {
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
// Optional in pi's Usage (providers that never emit reasoning
// tokens leave it unset) — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
+62 -51
View File
@@ -1,7 +1,8 @@
/**
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
* mirroring pi's design (docs aside, this is OUR implementation — pi's
* compaction lives in the coding-agent package we don't depend on).
* with a bounded local port of Pi coding-agent 1.4.0 cutoff selection.
* No coding-agent dependency. See docs/compaction-accounting.md for the
* behavior boundary, native golden fixtures, and upstream MIT notice.
*
* THE CONTRACT: full history stays persisted in the record — extraction
* and the user's transcript keep reading everything. A compaction marker
@@ -38,7 +39,7 @@ import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts";
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
import { newMessageId } from "./native-messages.ts";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import type { AssistantMessage, ToolResultMessage, SystemMessage } from "@earendil-works/pi-ai";
// ─── constants (the tunables, owner-approved) ────────────────────────────────
@@ -77,9 +78,8 @@ export function calculateContextTokens(usage: {
);
}
/** Estimate a message's context tokens from its WIRE-relevant text only
* (pi's estimator): text + thinking + toolCall name/args for assistants,
* content text for user/toolResult/custom. NEVER JSON.stringify of the
/** Pi 1.4.0's heuristic: text + thinking + toolCall name/args for
* assistants, content text plus a fixed image allowance for other roles. NEVER JSON.stringify of the
* whole message — record scaffolding (ids, usage, responseId, app-side
* details, escaped quotes/backslashes) inflates ~3x (measured on the
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
@@ -100,23 +100,32 @@ export function estimateMessageTokens(message: LhMessage | AgentMessage): number
}
} else {
chars += textCharsOfContent(content);
if (message.role === "system") {
const system = message as SystemMessage;
for (const section of Object.values(system.sections ?? {})) chars += section?.length ?? 0;
if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length;
}
}
return Math.ceil(chars / 4);
}
// Pi coding-agent 1.4.0 estimateTokens: fixed image allowance, NOT a
// measurement of encrypted reasoning or actual provider vision tokens.
const ESTIMATED_IMAGE_CHARS = 4_800;
function textCharsOfContent(content: unknown): number {
if (typeof content === "string") return content.length;
if (!Array.isArray(content)) return 0;
let chars = 0;
for (const block of content as Array<string | { type?: string; text?: string }>) {
if (typeof block === "string") chars += block.length;
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS;
else if (block?.type === "text") chars += block.text?.length ?? 0;
}
return chars;
}
/** The newest assistant message carrying VALID provider-reported usage
* (pi's rule: skip aborted messages and all-zero usage — they carry no
* (pi's rule: skip aborted/error messages and all-zero usage — they carry no
* truth about the wire). The scan STOPS at the newest compaction
* marker: usage captured before a compaction described the pre-cut
* context — the next request carries the summary + kept tail instead,
@@ -131,7 +140,7 @@ export function lastAssistantUsageIndex(
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
if ((m as { role?: string }).role !== "assistant") continue;
const a = m as AssistantMessage & { stopReason?: string };
if (a.stopReason === "aborted") continue;
if (a.stopReason === "aborted" || a.stopReason === "error") continue;
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
return i;
}
@@ -180,51 +189,53 @@ export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]):
// ─── the cut walk ────────────────────────────────────────────────────────────
/**
* Find the compaction cut index: walk BACKWARDS from the end accumulating
* estimates until ~keepRecentTokens are kept; the cut lands there.
*
* HARD INVARIANT — a tool result is never separated from its call: if the
* walk stops on a toolResult, the KEPT region extends to the assistant
* message carrying its call (keeping the result means keeping the call).
* This is the provider protocol's shape law: dropping a call while
* keeping its result strands an orphan the provider rejects. A PENDING
* question/problem call (no result yet — the pose→answer window) sits at
* the transcript end and is always kept: the walk never crosses it.
*
* Returns 0 when everything must be summarized (the whole history is the
* span — valid), or messages.length when there is nothing meaningful to
* cut (kept budget covers everything — caller checks before compacting).
*/
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
let kept = 0;
for (let i = messages.length - 1; i >= 0; i--) {
if (kept >= keepRecentTokens) {
// The cut candidate: extend past orphaned tool results to the
// assistant message carrying their calls.
let cut = i + 1;
while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") {
const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut);
if (callIndex === -1) break; // no call found (defensive): stop extending
cut = callIndex;
}
return cut;
}
kept += estimateMessageTokens(messages[i]!);
}
return 0; // the whole history fits inside the kept budget — summarize all
/** Model-visible entry adapter for the local Pi 1.4.0 cutoff port.
* Compaction entries are boundaries, not ordinary cut candidates; UI-only
* markers have no context, just like native metadata entries. */
function cutMessage(message: LhMessage): LhMessage | null {
if (message.role !== "custom") return message;
if (["compaction", "plan-block", "problem-dismissed"].includes(message.customType)) return null;
return message;
}
/** The newest assistant message at or before `before` whose content
* carries the tool call `toolCallId` — the call a result answers. */
function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number {
for (let i = before - 1; i >= 0; i--) {
const m = messages[i]!;
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage;
if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i;
/** Local port of pi-coding-agent 1.4.0 findCutPoint (MIT).
* Walk backwards to >= budget, then choose the nearest valid cut AT OR
* AFTER that entry. If oversized trailing results exhaust the budget,
* retain their preceding assistant call, not the entire session.
* No encrypted-signature sizing and no exact retained-token guarantee.
* Returns the first kept record index; a zero/no-progress cut drops nothing.
* Keep this and estimateMessageTokens aligned deliberately when upgrading Pi.
*/
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
const previous = activeCompactionOf(messages);
const previousKept = previous ? keptIndexFor(messages, previous) : -1;
const start = Math.max(0, previousKept);
const points: number[] = [];
for (let i = start; i < messages.length; i++) {
const message = cutMessage(messages[i]!);
if (message && (message.role === "user" || message.role === "assistant" || message.role === "custom")) points.push(i);
}
return -1;
if (!points.length) return start;
let cut = points[0]!;
let accumulated = 0;
for (let i = messages.length - 1; i >= start; i--) {
const message = cutMessage(messages[i]!);
const tokens = message ? estimateMessageTokens(message) : 0;
if (tokens === 0) continue;
accumulated += tokens;
if (accumulated >= keepRecentTokens) {
cut = points.find((candidate) => candidate >= i) ?? points.at(-1)!;
break;
}
}
// Native entries preserve adjacent non-context metadata, but never walk
// backwards across a previous compaction entry.
while (cut > start) {
const before = messages[cut - 1]!;
if ((before.role === "custom" && before.customType === "compaction") || cutMessage(before)) break;
cut--;
}
return cut;
}
// ─── the marker ──────────────────────────────────────────────────────────────
+27
View File
@@ -0,0 +1,27 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { LhMessage } from "./native-messages.ts";
/** Latest completed conversation measurement, never a request-size estimate.
* Ordered compaction markers invalidate every earlier reading. Live callers
* must exclude assistant slots that have not received message_end.
* Reasoning is already inside output; totalTokens is deliberately not used.
*/
export function measuredContextTokens(
messages: readonly LhMessage[],
incompleteIds: ReadonlySet<string> = new Set(),
): number | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (message.role === "custom" && message.customType === "compaction") return null;
if (message.role !== "assistant" || incompleteIds.has(message.id)) continue;
const assistant = message as AssistantMessage;
if (!["stop", "length", "toolUse"].includes(assistant.stopReason)) continue;
const usage = assistant.usage;
if (!usage) continue;
const buckets = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
if (buckets.some((value) => !Number.isFinite(value) || value < 0)) continue;
const tokens = buckets.reduce((sum, value) => sum + value, 0);
if (tokens > 0) return tokens;
}
return null;
}
+8 -5
View File
@@ -87,8 +87,8 @@ export interface ChatSettings {
* billed (CallUsage per agent step). Steps, not a sum: cost and context
* folds happen at read time (core/prices.ts). */
export interface ExtractionStageUsage {
/** The model that ran this stage, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
/** The model that ran this stage, provider-qualified ("provider:id")
* for key-independent Pi catalog lookup at read time. */
model: string;
steps: CallUsage[];
}
@@ -118,7 +118,7 @@ export interface ExtractionRunUsage {
* flattened to the fields we persist (undefined = provider didn't report;
* renderers show absent, never zero). */
export interface CallUsage {
/** Total input (prompt) tokens, cached portion included. */
/** Pi-normalized fresh input tokens; cache buckets are disjoint. */
inputTokens: number | undefined;
outputTokens: number | undefined;
/** Cached input tokens READ (billed at the cache-read rate, if any). */
@@ -126,6 +126,9 @@ export interface CallUsage {
/** Input tokens written to the cache this call (OpenRouter bills writes
* for some providers; undefined = not reported/not billed). */
cacheWriteTokens: number | undefined;
/** Subset of cacheWriteTokens written with one-hour retention. Missing
* on older logs; new native captures explicitly record zero if absent. */
cacheWrite1hTokens?: number;
/** The reasoning portion of the output tokens. */
reasoningTokens: number | undefined;
}
@@ -144,8 +147,8 @@ export interface TurnUsage {
* entry to the transcript (the block this turn "belongs" to). */
messageId: string;
at: string;
/** The model that ran the turn, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
/** The model that ran the turn, provider-qualified ("provider:id")
* for key-independent Pi catalog lookup at read time. */
model: string;
/** One entry per agent step (model call) in this turn. */
steps: CallUsage[];
+21 -10
View File
@@ -43,7 +43,7 @@ export interface JobRecord {
* money-channel doctrine; cost renders "—", never $0). */
export interface JobUsage {
/** The model that ran the sub-agent, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
* model slots wave, 2026-09-20; matches Pi catalog lookup keys). */
model: string;
/** Running totals summed over the run's model calls so far. */
inputTokens: number | undefined;
@@ -51,6 +51,10 @@ export interface JobUsage {
cacheReadTokens: number | undefined;
cacheWriteTokens: number | undefined;
reasoningTokens: number | undefined;
cacheWrite1hTokens?: number;
/** Individual calls for current-catalog per-call tier pricing. Absent on
* older jobs; never backfill them from cumulative totals. */
calls?: CallUsage[];
/** How many model calls the run has made (each bills separately). */
steps: number;
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
@@ -67,7 +71,7 @@ export interface JobUsage {
* object with these fields folds, including the real thing and test fakes). */
export interface UsageStepLike {
inputTokens?: number | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number; cacheWrite1hTokens?: number } | undefined;
outputTokens?: number | undefined;
outputTokenDetails?: { reasoningTokens?: number } | undefined;
}
@@ -80,12 +84,13 @@ export function piUsageToStep(usage: {
output: number;
cacheRead: number;
cacheWrite: number;
cacheWrite1h?: number;
reasoning?: number;
}): UsageStepLike {
return {
inputTokens: usage.input,
outputTokens: usage.output,
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite },
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, cacheWrite1hTokens: usage.cacheWrite1h ?? 0 },
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
};
}
@@ -101,6 +106,14 @@ export function accumulateJobUsage(
): JobUsage {
const add = (a: number | undefined, b: number | undefined): number | undefined =>
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
const call: CallUsage = {
inputTokens: step.inputTokens,
outputTokens: step.outputTokens,
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
cacheWrite1hTokens: step.inputTokenDetails?.cacheWrite1hTokens,
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
};
return {
model,
inputTokens: add(total?.inputTokens, step.inputTokens),
@@ -108,14 +121,12 @@ export function accumulateJobUsage(
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
cacheWrite1hTokens: ((total?.cacheWriteTokens ?? 0) > 0 && total?.cacheWrite1hTokens === undefined) ||
((call.cacheWriteTokens ?? 0) > 0 && call.cacheWrite1hTokens === undefined)
? undefined : add(total?.cacheWrite1hTokens, call.cacheWrite1hTokens),
steps: (total?.steps ?? 0) + 1,
lastStep: {
inputTokens: step.inputTokens,
outputTokens: step.outputTokens,
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
},
...(!total || total.calls ? { calls: [...(total?.calls ?? []), call] } : {}),
lastStep: call,
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ import type { BackgroundModelRole, ModelSlotName } from "../settings.ts";
/** One picker row: everything the UI shows about a model, projected from
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
* units core/prices.ts's generated table carries). */
* units as Pi's server-side pricing calculator). */
export interface ModelCatalogEntry {
id: string;
name: string;
+12 -9
View File
@@ -9,7 +9,7 @@
* the same ruling as wave 7's stages.embedding.)
*
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
* core/prices.ts — never stored anywhere. An unpriced model contributes
* an injected server-side Pi pricer — never stored anywhere. An unpriced model contributes
* ZERO dollars but is counted in `unpricedRuns` so the UI can render
* "— unpriced" honestly, never a silent zero. Tokens always sum.
*
@@ -18,7 +18,7 @@
*/
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
import { callCost, sumSteps, isChatGPTPlan } from "./prices.ts";
import { sumSteps, isChatGPTPlan, type CallPricer } from "./prices.ts";
/** One pipeline stage's cumulative cost across every run. */
export interface StageCost {
@@ -26,7 +26,7 @@ export interface StageCost {
subscriptionRuns?: number;
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
dollars: number;
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
unpricedRuns: number;
/** Runs that billed this stage. */
runs: number;
@@ -92,11 +92,11 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
/** One stage's dollars over its steps; null when the model is unpriced
* (all-or-nothing: a stage runs one model). */
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
if (isChatGPTPlan(model)) return null;
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
if (isChatGPTPlan(model) || steps.length === 0) return null;
let total = 0;
for (const step of steps) {
const c = callCost(step, model);
const c = price(step, model);
if (c === null) return null;
total += c;
}
@@ -106,6 +106,9 @@ function stageDollars(model: string, steps: readonly CallUsage[]): number | null
/** Merge one step-set's tokens into an accumulating CallUsage. */
function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
const summed = sumSteps(steps);
// Fold this before updating total writes, so an older write-bearing
// aggregate with a missing split remains unknown even after fresh data.
into.cacheWrite1hTokens = sumSteps([into, summed]).cacheWrite1hTokens;
into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0);
into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0);
into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0);
@@ -115,7 +118,7 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
/** Fold every usage channel into the pill's payload. Pure: same input,
* same output — no clock, no fs, no store reads. */
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
export function graphCostSummary(input: CostSummaryInput, price: CallPricer): GraphCostSummary {
const extraction = emptyStage();
const audit = emptyStage();
const runs: RecentRun[] = [];
@@ -125,14 +128,14 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
// Per-stage: tokens always count; dollars only when priced.
extraction.runs++;
mergeTokens(extraction.tokens, stages.extractor.steps);
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price);
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
else if (extDollars === null) extraction.unpricedRuns++;
else extraction.dollars += extDollars;
audit.runs++;
mergeTokens(audit.tokens, stages.auditor.steps);
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price);
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
else if (audDollars === null) audit.unpricedRuns++;
else audit.dollars += audDollars;
+1
View File
@@ -48,6 +48,7 @@ export * from "./input-events.ts";
export * from "./view.ts";
export * from "./tool-parts.ts";
export * from "./prices.ts";
export * from "./context-usage.ts";
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
export * from "./cost-summary.ts";
export * from "./fuzzy.ts";
+38 -1742
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -333,6 +333,7 @@ export async function handleChatInput(
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
// Optional in pi's Usage — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
});
+2 -1
View File
@@ -175,7 +175,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited
const cut = compactionCutIndex(messages);
if (cut >= messages.length) return; // nothing to drop (defensive)
if (cut <= 0 || cut >= messages.length) return; // no history can be dropped
// The previous compaction's kept boundary (chained compactions
// summarize the old summary + what rode after it).
@@ -240,6 +240,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
reasoningTokens: usage.reasoning ?? 0,
},
],
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from "../core/graph-deletion.ts";
import { extractionStatuses } from "../agent/subagents/extraction.ts";
import { stores } from "./stores.ts";
import { callCost } from "./pricing.ts";
/**
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
@@ -41,7 +42,7 @@ export function graphCosts(): GraphCostSummary {
.readSources()
.filter((s) => s.extractions?.length)
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
return graphCostSummary({ chats, sources });
return graphCostSummary({ chats, sources }, callCost);
}
/** The graph's version stamp (session-deletion wave 6): the max mtime
+1
View File
@@ -43,3 +43,4 @@ export * from "./compaction.ts";
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
export * from "./observability.ts";
export * from "./model-catalog.ts";
export { sessionCost } from "./pricing.ts";
+5 -3
View File
@@ -24,6 +24,7 @@ import { chatStore } from "../store/chats.ts";
import { graphRevision } from "./graph.ts";
import { catalogContextLimits } from "./model-catalog.ts";
import { listJobs } from "./jobs.ts";
import { jobCost } from "./pricing.ts";
import type { JobRecord } from "../core/contracts.ts";
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
@@ -36,6 +37,7 @@ export { chatStore } from "../store/chats.ts";
* model is missing from the catalog (the blob then renders no context
* line — never a fake window). */
export interface ActivityJob extends JobRecord {
costUsd: number | null;
title: string;
contextLimit: number | null;
}
@@ -56,9 +58,8 @@ export interface ActivityExtraction {
/** The dock's whole world in one payload: every running process, every view.
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
* the blob disappears when the process ends. Cost math is NOT done here:
* raw usage rides on the records and the client folds it with the pure
* helpers in core/prices.ts (transport stays thin). */
* the blob disappears when the process ends. Costs are joined here through
* the server-side Pi pricing adapter; clients never reconstruct rates. */
/** The dock's whole world + the graph's version stamp (wave 6 session
* deletion): the payload carries graphRevision (max mtime across the
* graph data files — see server/graph.ts) so any view can detect "the
@@ -78,6 +79,7 @@ export function activityFeed(): {
if (job.status === "running") {
jobs.push({
...job,
costUsd: jobCost(job),
title: chat.title ?? chat.id,
contextLimit: job.usage ? (limits.get(job.usage.model) ?? null) : null,
});
+50
View File
@@ -0,0 +1,50 @@
/** Read-time API-dollar estimates. Pi owns both rates and arithmetic.
* Catalog resolution is key-independent; no provider dispatch/auth is needed.
* Never mutate saved native messages: calculateCost mutates its usage.cost.
*/
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../agent/models.ts";
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
import { isChatGPTPlan, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
function catalogModel(id: string) {
const colon = id.indexOf(":");
if (colon < 0 || isChatGPTPlan(id)) return undefined;
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
}
export function callCost(call: Partial<CallUsage>, modelId: string): number | null {
const model = catalogModel(modelId);
if (!model || call.inputTokens === undefined || call.outputTokens === undefined) return null;
const input = call.inputTokens;
const output = call.outputTokens;
const cacheRead = call.cacheReadTokens ?? 0;
const cacheWrite = call.cacheWriteTokens ?? 0;
const cacheWrite1h = call.cacheWrite1hTokens ?? 0;
if ([input, output, cacheRead, cacheWrite, cacheWrite1h].some((v) => !Number.isFinite(v) || v < 0) || cacheWrite1h > cacheWrite) return null;
// Old records discarded this split. Only price them if it cannot affect
// the result; do not invent short-retention precision from a missing field.
if (cacheWrite > 0 && call.cacheWrite1hTokens === undefined &&
[model.cost, ...(model.cost.tiers ?? [])].some((rate) => rate.cacheWrite !== 2 * rate.input)) return null;
const usage: Usage = {
input, output, cacheRead, cacheWrite, cacheWrite1h,
totalTokens: input + output + cacheRead + cacheWrite,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const total = calculateCost(model, usage).total;
return Number.isFinite(total) && total >= 0 ? total : null;
}
export function turnCost(turn: TurnUsage): number | null {
return foldTurnCost(turn, callCost);
}
export function sessionCost(record: Pick<ChatRecord, "turns">) {
return foldSessionCost(record, callCost);
}
export function jobCost(job: Pick<JobRecord, "usage">): number | null {
const usage = job.usage;
if (!usage || usage.steps < 1) return null;
const hasCalls = usage.calls?.length === usage.steps;
if (!hasCalls && usage.steps > 1 && catalogModel(usage.model)?.cost.tiers?.length) return null;
return foldJobCost(job, callCost);
}
+3
View File
@@ -88,10 +88,13 @@ test("activityFeed: contextLimit is the server's live-catalog join (owner amendm
// pi 1.0.2's catalog: glm-5.3-flash contextWindow 1_048_575 (was
// 1_048_576 at 0.87.1 — upstream models.dev refresh).
assert.equal(knownJob.contextLimit, 1_048_575, "pi's catalog, joined server-side onto the feed");
assert.ok(knownJob.costUsd !== null && Math.abs(knownJob.costUsd - 0.0002) < 1e-12, "Pi's price is projected on the server");
const unknownJob = feed.jobs.find((j) => j.id === unknown.id)!;
assert.equal(unknownJob.contextLimit, null, "missing from the live catalog → null, never a guessed window");
assert.equal(unknownJob.costUsd, null);
const bareJob = feed.jobs.find((j) => j.id === bare.id)!;
assert.equal(bareJob.contextLimit, null, "no usage yet → no context line");
assert.equal(bareJob.costUsd, null);
} finally {
fs.unlinkSync(`${PATHS.chats}/${chat.id}.json`);
const jobsDir = path.join(PATHS.data, "jobs");
+15 -2
View File
@@ -1,4 +1,4 @@
import { test, after } from "node:test";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
@@ -21,7 +21,20 @@ let revision = 1;
function connection(background: boolean) {
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: revision++ }, invalidate: background ? "none" : "background", status: { available: true, pending: false, allowBackgroundUse: background, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background: !background }], error: null } });
}
after(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
before(() => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: true });
Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { callback?.(new Error("unexpected fixture desktop request")); return false; } });
});
after(() => {
fs.rmSync(dir, { recursive: true, force: true });
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
});
for (const revokedAfterQueue of [false, true]) {
test(`mixed learner+automatic batch: foreground runs, automatic result stays owned/deferred without consent (${revokedAfterQueue ? "revoked after handoff" : "initially off"})`, { timeout: 5000 }, async () => {
+4
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { piModels } from "../src/agent/models.ts";
import { refreshChatGPTModels, chatGPTModels, chatGPTStatus, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTAbortController } from "../src/agent/chatgpt-bridge.ts";
const send = Object.getOwnPropertyDescriptor(process, "send"), connected = Object.getOwnPropertyDescriptor(process, "connected");
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
const pending: any[] = [];
const model = piModels().getModels("openai").find((m) => m.api === "openai-responses")!;
const ipc = (m: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", m);
@@ -10,10 +11,13 @@ const status = (account: string, background = true) => ({ available: true, pendi
const value = (name: string, revision: number, account = "A", authorityRevision = 1) => ({ epoch: account === "A" ? 1 : 2, authorityRevision, revision, status: status(account), accountId: account, models: [{ id: model.id, name }] });
function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); }
before(() => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: true });
Object.defineProperty(process, "send", { configurable: true, value: (m: unknown, cb?: () => void) => { pending.push(m); cb?.(); return true; } });
});
after(() => {
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
if (send) Object.defineProperty(process, "send", send); else delete process.send;
if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected;
});
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-dev-ipc-"));
process.env.LH_DATA_DIR = scratch;
process.env.LH_AUTH_FILE = path.join(scratch, "auth.json");
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
process.env.OPENAI_API_KEY = "sk-fixture-not-used-for-generation";
const { GET } = await import("../app/api/models/route.ts");
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => {
const send = Object.getOwnPropertyDescriptor(process, "send");
const connected = Object.getOwnPropertyDescriptor(process, "connected");
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
const originalFetch = globalThis.fetch;
let sends = 0;
try {
globalThis.fetch = async () => { throw new Error("provider/network calls forbidden in catalog fixture"); };
Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { sends++; callback?.(new Error("not a desktop parent")); return false; } });
Object.defineProperty(process, "connected", { configurable: true, value: true });
for (const [mode, capability] of [[undefined, undefined], ["1", undefined], [undefined, "d".repeat(64)], ["1", "short"]]) {
if (mode === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = mode;
if (capability === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = capability;
// A framework parent cannot establish ChatGPT state by sharing only IPC.
(process.emit as (name: string, value: unknown) => boolean)("message", { channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 1 }, status: { available: true, activeId: "fixture", accounts: [{ id: "fixture", connected: true, planEnabled: true }] } });
const response = await GET();
assert.equal(response.status, 200);
const catalog = await response.json();
assert.ok(catalog.models.some((m: { provider: string }) => m.provider === "openai"));
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "openai").keyPresent, true);
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
assert.equal(chatGPTConnected(), false);
assert.deepEqual(chatGPTModels(), []);
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
}
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: false });
await GET();
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
assert.equal(sends, 0, "desktop markers without connected IPC do not suffice");
} finally {
globalThis.fetch = originalFetch;
if (send) Object.defineProperty(process, "send", send); else delete process.send;
if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected;
if (marker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = marker;
if (secret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = secret;
}
});
+4
View File
@@ -20,6 +20,7 @@ import { newUserMessage, newNextMessage, newThinkingMessage, newAnswerResult, re
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
const realFetch = globalThis.fetch;
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
let epoch = 1, revision = 1, background = true;
let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none";
@@ -31,6 +32,7 @@ function revoke(full = false) {
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch, authorityRevision: epoch, revision: ++revision }, status: status(), invalidate: full ? "all" : "background" });
}
before(() => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: true });
Object.defineProperty(process, "send", { configurable: true, value: (m: any, callback?: () => void) => {
if (m.op === "lease") {
@@ -59,6 +61,8 @@ before(() => {
};
});
after(() => {
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
globalThis.fetch = realFetch;
fs.rmSync(scratch, { recursive: true, force: true });
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
+4
View File
@@ -9,6 +9,7 @@ import { chatGPTHistory, chatGPTPayload } from "../src/agent/chatgpt-stream.ts";
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
const realFetch = globalThis.fetch;
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
const originalKey = process.env.OPENAI_API_KEY;
let leaseError: string | null = null;
let requests: { authorization: string | null; body: Record<string, any>; url: string }[] = [];
@@ -17,6 +18,7 @@ const ipc = (value: unknown) => (process.emit as (name: string, value: unknown)
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
const status = { available: true, pending: false, allowBackgroundUse: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null };
before(async () => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
assert(model);
process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used";
Object.defineProperty(process, "connected", { configurable: true, value: true });
@@ -36,6 +38,8 @@ before(async () => {
};
});
after(() => {
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
globalThis.fetch = realFetch;
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
+2 -1
View File
@@ -10,7 +10,8 @@ import { notifySettingsSaved } from "../components/settings-signal.ts";
import { SidebarProvider } from "../components/sidebar.tsx";
import { ChatGPTConnection, ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx";
import { GraphCostControl } from "../components/graph-view.tsx";
import { graphCostSummary } from "../src/core/cost-summary.ts";
import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts";
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, () => null);
import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
+4 -3
View File
@@ -1,7 +1,8 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { callCost, sessionCost, turnCost, jobCost } from "../src/core/prices.ts";
import { graphCostSummary } from "../src/core/cost-summary.ts";
import { callCost, sessionCost, turnCost, jobCost } from "../src/server/pricing.ts";
import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts";
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost);
import type { TurnUsage } from "../src/core/contracts.ts";
import { planCostLabel } from "../components/usage-format.ts";
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
@@ -12,7 +13,7 @@ test("subscription usage is neither API dollars nor an unpriced-model mistake, i
assert.equal(callCost(step, plan.model), null);
assert.equal(turnCost(turn(plan.model, [])), null);
assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), null);
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2 });
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2, turnCosts: { "scratch-turn": null } });
const api = turn("openai:gpt-5");
const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] });
assert.equal(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1);
+55
View File
@@ -0,0 +1,55 @@
/** Golden outputs from the verified published pi-coding-agent 1.4.0
* findCutPoint + estimateTokens helpers, executed in isolation on synthetic
* message/metadata entries. No coding-agent dependency or private data.
* Source compaction.js SHA256:
* f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6
* Update deliberately alongside the documented local port, not automatically.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { compactionCutIndex, estimateMessageTokens, newCompactionMarker, KEEP_RECENT_TOKENS, type LhMessage } from "../src/core/index.ts";
let serial = 0;
const user = (chars: number): LhMessage => ({ id: `u${serial++}`, role: "user", content: "u".repeat(chars), timestamp: 1 });
const assistant = (chars: number): LhMessage => ({ id: `a${serial++}`, role: "assistant", content: [{ type: "text", text: "a".repeat(chars) }], timestamp: 1 }) as LhMessage;
const call = (chars: number): LhMessage => ({ ...assistant(chars), content: [{ type: "text", text: "a".repeat(chars) }, { type: "toolCall", name: "quiz", arguments: {}, id: "c" }] }) as LhMessage;
const result = (chars: number): LhMessage => ({ id: `r${serial++}`, role: "toolResult", content: [{ type: "text", text: "r".repeat(chars) }], toolCallId: "c", toolName: "quiz", isError: false, timestamp: 1 });
const image = (): LhMessage => ({ id: `i${serial++}`, role: "user", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], timestamp: 1 });
const metadata = (): LhMessage => ({ id: `m${serial++}`, role: "custom", customType: "plan-block", content: "invisible".repeat(1000), display: false, details: {}, timestamp: 1 });
test("local cutoff matches published native golden boundaries and estimator values", () => {
const cases: [string, LhMessage[], number, number, number[]][] = [
["exact threshold", [user(40), user(40), user(40)], 20, 1, [10, 10, 10]],
["under budget", [user(4), assistant(4)], 20, 0, [1, 1]],
["split turn", [user(400), assistant(40), assistant(40)], 15, 1, [100, 10, 10]],
["completed result boundary", [user(400), call(4), result(400), user(40)], 30, 3, [100, 3, 100, 10]],
["oversized trailing result", [user(400), user(40), call(4), result(400)], 30, 2, [100, 10, 3, 100]],
["multiple trailing results", [user(400), call(4), result(200), result(200)], 30, 1, [100, 3, 50, 50]],
["image allowance", [user(400), image(), user(40)], 1000, 1, [100, 1200, 10]],
["empty trailing text", [user(400), assistant(0), user(0)], 30, 0, [100, 0, 0]],
["pending call", [user(400), user(400), call(4)], 10, 1, [100, 100, 3]],
];
for (const [name, messages, budget, expected, tokens] of cases) {
assert.equal(compactionCutIndex(messages, budget), expected, name);
assert.deepEqual(messages.map(estimateMessageTokens), tokens, name);
}
assert.equal(compactionCutIndex([user(400), metadata(), assistant(40), metadata(), user(40)], 15), 1, "adjacent invisible metadata stays with the cut");
assert.equal(KEEP_RECENT_TOKENS, 20_000);
});
test("chained cutoff operates on active retained history, never the previous summary or discarded prefix", () => {
const old = user(400_000);
const kept = user(40);
const marker = newCompactionMarker("summary".repeat(100_000), { firstKeptMessageId: kept.id, tokensBefore: 100_000, at: "fixture" });
const messages = [old, kept, assistant(40), marker, user(40)];
assert.equal(compactionCutIndex(messages, 100), 1, "retained tail fits; no progress");
assert.equal(compactionCutIndex(messages, 15), 2, "old summary does not consume the kept-tail budget");
});
test("opaque signatures remain unmeasured and preserved; system/image allowances match native", () => {
const signed = { ...assistant(4), content: [{ type: "thinking", thinking: "", thinkingSignature: "opaque".repeat(100_000) }] } as LhMessage;
assert.equal(estimateMessageTokens(signed), 0);
assert.equal((signed as { content: { thinkingSignature: string }[] }).content[0]!.thinkingSignature.length, 600_000);
const system = { role: "system", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], sections: { a: "abcd" }, toolsAdded: [{ name: "fixture" }] };
assert.equal(estimateMessageTokens(system as never), Math.ceil((4800 + 4 + JSON.stringify(system.toolsAdded).length) / 4));
});
+32 -1
View File
@@ -32,7 +32,7 @@ const {
} = await import("../src/server/compaction.ts");
import type { CompactionDeps } from "../src/server/compaction.ts";
const { subscribe } = await import("../src/core/events.ts");
const { activeCompactionOf, newUserMessage, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
import type { LhMessage } from "../src/core/index.ts";
after(() => {
@@ -204,6 +204,37 @@ describe("the compaction service", () => {
kickCompaction(() => null, chatId);
});
it("a usage-heavy zero cut does not spend or append a marker", async () => {
const chat = chatStore.createChat("no-progress fixture");
const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage;
chatStore.saveMessages(chat.id, [newUserMessage("tiny visible history"), assistant]);
const before = summarizeCalls;
await ensureCompacted(depsOf(chat.id, 40_000), chat.id);
assert.equal(summarizeCalls, before);
assert.equal(activeCompactionOf(chatStore.getChat(chat.id)!.messages), null);
assert.equal(measuredContextTokens(chatStore.getChat(chat.id)!.messages), 120_001);
});
it("starting/failing compaction preserves the meter; committing its marker clears it, regardless of summary usage", async () => {
const id = overThresholdChat("meter validity fixture");
const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage;
chatStore.saveMessages(id, [...chatStore.getChat(id)!.messages, assistant]);
let release!: () => void;
const gate = new Promise<void>((done) => { release = done; });
const deps = depsOf(id, 40_000);
const running = ensureCompacted({ ...deps, summarize: async () => {
await gate;
throw new Error("fixture failure");
} }, id);
assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001);
release(); await running;
assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001);
await ensureCompacted(deps, id);
const record = chatStore.getChat(id)!;
assert.ok(record.turns!.at(-1)!.steps[0]!.inputTokens! > 0);
assert.equal(measuredContextTokens(record.messages), null);
});
it("a summarizer failure leaves the record untouched and the gate re-arms", async () => {
const chatId = overThresholdChat("compact: fail");
const before = summarizeCalls;
+8 -8
View File
@@ -156,7 +156,7 @@ describe("compactionCutIndex", () => {
assert.equal((messages[cut] as { content: string }).content.startsWith("m2"), true, "the oldest kept message sits at the cut");
});
it("NEVER separates a tool result from its call — the cut extends to the call's assistant message", () => {
it("native cut after an oversized result drops both call and result, never keeps an orphan", () => {
const messages = [
big("m1"),
big("m2"),
@@ -165,13 +165,13 @@ describe("compactionCutIndex", () => {
big("m3"),
big("m4"),
];
// Budget lands such that the naive stop is ON the toolResult (m2 kept,
// result next): the walk must extend to a1's assistant message.
// Budget lands on the result. Pi chooses the next valid cut AT OR
// AFTER it, dropping the complete exchange rather than extending.
const cut = compactionCutIndex(messages, estimateTokens([big("m3"), big("m4")]) + 1);
assert.ok(cut <= 2, `the cut must not sit between the call (idx 2) and its result (idx 3) — got ${cut}`);
const kept = messages.slice(cut);
const keptIds = new Set(kept.map((m) => m.id));
assert.ok(keptIds.has("a1") && keptIds.has("r_call_1"), "call and result stay together");
assert.equal(cut, 4);
const keptIds = new Set(messages.slice(cut).map((m) => m.id));
assert.equal(keptIds.has("a1"), false);
assert.equal(keptIds.has("r_call_1"), false);
});
it("a PENDING question call (no result — the pose→answer window) always survives", () => {
@@ -181,7 +181,7 @@ describe("compactionCutIndex", () => {
assert.ok(kept.some((m) => m.id === "a_pending"), "the pending pose is never dropped");
});
it("zero budget summarizes everything; whole-history-in-budget returns 0 only when nothing precedes", () => {
it("whole history within budget keeps everything (zero cut)", () => {
assert.equal(compactionCutIndex([newUserMessage("hi")], 100), 0);
});
});
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { measuredContextTokens, newCompactionMarker, type LhMessage } from "../src/core/index.ts";
import { SessionModel } from "../components/session-core.ts";
function assistant(id: string, stopReason: AssistantMessage["stopReason"] = "stop", total = 999_999): LhMessage {
return { id, role: "assistant", content: [{ type: "text", text: "fixture" }], api: "openai-completions", provider: "openrouter", model: "fixture", stopReason, timestamp: 1,
usage: { input: 10_000, output: 1_000, cacheRead: 70_000, cacheWrite: 2_000, reasoning: 500, totalTokens: total, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as LhMessage;
}
const marker = () => newCompactionMarker("educational summary", { firstKeptMessageId: "old", tokensBefore: 110_001, at: "fixture" });
test("meter sums four disjoint fields, ignoring reasoning subset and totalTokens overrides", () => {
assert.equal(measuredContextTokens([assistant("old")]), 83_000);
assert.equal(measuredContextTokens([]), null);
for (const stop of ["stop", "length", "toolUse"] as const) assert.equal(measuredContextTokens([assistant("a", stop)]), 83_000);
});
test("newest marker invalidates retained pre-cut usage; summarizer money log cannot supply a measurement", () => {
const old = assistant("old");
assert.equal(measuredContextTokens([old, marker()]), null);
assert.equal(measuredContextTokens([old, marker(), assistant("fresh")]), 83_000);
assert.equal(measuredContextTokens([old, marker(), assistant("fresh"), marker()]), null);
// The summarizer is logged as a money-channel turn, never an assistant
// in the native conversation list. No money-channel input exists here.
});
test("errors, aborts, all-zero/invalid usage and unfinished slots preserve the previous valid reading", () => {
const old = assistant("old");
for (const stop of ["error", "aborted"] as const) assert.equal(measuredContextTokens([old, assistant("bad", stop)]), 83_000);
const zero = assistant("zero") as AssistantMessage & { id: string };
zero.usage = { ...zero.usage, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
assert.equal(measuredContextTokens([old, zero]), 83_000);
assert.equal(measuredContextTokens([zero]), null);
assert.equal(measuredContextTokens([old, assistant("partial")], new Set(["partial"])), 83_000);
assert.equal(measuredContextTokens([marker(), assistant("bad", "error")]), null);
});
test("live terminal-looking message_update is not completion; each message_end updates immediately", () => {
const model = new SessionModel({ chatId: "fixture", initialMessages: [assistant("old")] });
const fresh = assistant("live") as AssistantMessage & { id: string };
fresh.usage = { ...fresh.usage, input: 20_000 };
model.applyWireEvent({ type: "message_start", message: fresh });
model.applyWireEvent({ type: "message_update", message: fresh });
assert.equal(model.getState().contextTokens, 83_000);
model.applyWireEvent({ type: "message_end", message: fresh });
assert.equal(model.getState().contextTokens, 93_000);
model.applyWireEvent({ type: "message_start", message: assistant("next") });
assert.equal(model.getState().contextTokens, 93_000);
model.applyWireEvent({ type: "message_end", message: assistant("next", "toolUse") });
assert.equal(model.getState().contextTokens, 83_000);
model.setMessages([assistant("old"), marker()]);
assert.equal(model.getState().contextTokens, null);
});
+4 -3
View File
@@ -8,16 +8,17 @@
import assert from "node:assert/strict";
import { describe, it as test } from "node:test";
import {
graphCostSummary,
graphCostSummary as foldGraphCostSummary,
type CostSummaryInput,
type GraphCostSummary,
} from "../src/core/cost-summary.ts";
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
import { PRICES, callCost } from "../src/core/prices.ts";
import { callCost } from "../src/server/pricing.ts";
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost);
/** A model with full published pricing (from the generated registry). */
const PRICED = "openrouter:z-ai/glm-5.3-flash";
assert.ok(PRICES[PRICED], "fixture model must exist in PRICES");
assert.notEqual(callCost({ inputTokens: 1, outputTokens: 1 }, PRICED), null);
const step = (input: number, output: number): CallUsage => ({
inputTokens: input,
+60
View File
@@ -0,0 +1,60 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { GraphCostControl } from "../components/graph-view.tsx";
import { graphCostSummary } from "../src/core/cost-summary.ts";
import { callCost } from "../src/server/pricing.ts";
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
const usage = (over: Partial<CallUsage> = {}): CallUsage => ({ inputTokens: 1000, outputTokens: 100, cacheReadTokens: 0, cacheWriteTokens: 20_000, reasoningTokens: 0, ...over });
const run = (steps: CallUsage[], model = "anthropic:claude-haiku-4-5", at = 1): ExtractionRunUsage => ({ startedAt: at, at: "fixture", durationMs: 1, stages: { extractor: { model, steps }, auditor: { model, steps } } });
const summary = (runs: ExtractionRunUsage[]) => graphCostSummary({ chats: [{ title: "fixture", extractions: runs }], sources: [] }, callCost);
const headline = (runs: ExtractionRunUsage[]) => renderToStaticMarkup(<GraphCostControl costs={summary(runs)} />);
test("wholly unpriced historical graph costs render unknown, not known-zero dollars", () => {
const historical = run([usage()]); // missing long-cache split
const costs = summary([historical]);
assert.equal(costs.totalDollars, 0);
assert.equal(costs.unpricedRuns, 1);
assert.equal(costs.stages.extraction.unpricedRuns, 1);
const html = headline([historical]);
assert.match(html, />—<\/button>/);
assert.doesNotMatch(html, /\$0\.00/);
});
test("known zero, mixed priced/unknown, and subscription headline semantics stay distinct", () => {
const zero = run([usage({ inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0 })]);
assert.match(headline([zero]), />\$0\.00<\/button>/, "known zero is not unknown");
const priced = run([usage({ cacheWrite1hTokens: 0 })]);
const historical = run([usage()], undefined, 2);
const mixed = summary([priced, historical]);
assert.equal(mixed.unpricedRuns, 1);
assert.ok(mixed.totalDollars > 0);
assert.match(headline([priced, historical]), new RegExp(`>\\$${mixed.totalDollars.toFixed(2).replace(".", "\\.")}<\\/button>`));
const plan = run([usage()], "chatgpt:fixture");
assert.match(headline([plan]), />ChatGPT plan<\/button>/);
assert.match(headline([plan, zero]), />\$0\.00 API \+ ChatGPT plan<\/button>/);
});
test("graph stage and recent-run aggregates preserve known one-hour cache writes", () => {
const costs = summary([run([usage({ cacheWrite1hTokens: 20_000 })]), run([usage({ cacheWrite1hTokens: 5000 })], undefined, 2)]);
assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, 25_000);
assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, 25_000);
assert.deepEqual(costs.runs.map((r) => r.tokens.cacheWrite1hTokens), [10_000, 40_000]);
});
test("unknown historical long-cache splits stay unknown regardless of merge order", () => {
const known = run([usage({ cacheWrite1hTokens: 20_000 })]);
const historical = run([usage()], undefined, 2);
for (const runs of [[known, historical], [historical, known]]) {
const costs = summary(runs);
assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, undefined);
assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, undefined);
}
const mixedWithinRun = summary([run([usage({ cacheWrite1hTokens: 20_000 }), usage()])]);
assert.equal(mixedWithinRun.stages.extraction.tokens.cacheWrite1hTokens, undefined);
const absentNoWrites = run([usage({ cacheWriteTokens: 0 })], undefined, 2);
assert.equal(summary([absentNoWrites, known]).stages.extraction.tokens.cacheWrite1hTokens, 20_000, "absence without writes must not poison known write detail");
});
+5
View File
@@ -346,8 +346,11 @@ describe("steer helpers (contracts.ts)", () => {
cacheReadTokens: 4,
cacheWriteTokens: undefined,
reasoningTokens: undefined,
cacheWrite1hTokens: undefined,
calls: [{ inputTokens: 10, outputTokens: 5, cacheReadTokens: 4, cacheWriteTokens: undefined, cacheWrite1hTokens: undefined, reasoningTokens: undefined }],
steps: 1,
lastStep: {
cacheWrite1hTokens: undefined,
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 4,
@@ -364,7 +367,9 @@ describe("steer helpers (contracts.ts)", () => {
assert.equal(total.cacheWriteTokens, undefined, "never reported stays absent — never zero");
assert.equal(total.reasoningTokens, undefined);
assert.equal(total.steps, 2, "one step per model call, reported or not");
assert.equal(total.calls?.length, 2);
assert.deepEqual(total.lastStep, {
cacheWrite1hTokens: undefined,
inputTokens: 20,
outputTokens: 0,
cacheReadTokens: undefined,
+1 -1
View File
@@ -4,7 +4,7 @@ import { normalizeContext } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
import { modelCatalog, validateChatSettings, validateModelsSettings } from "../src/server/model-catalog.ts";
import { callCost } from "../src/core/prices.ts";
import { callCost } from "../src/server/pricing.ts";
const specimens = [
{ id: "recursive/flint-v1.0:free", input: ["text"] },
-56
View File
@@ -1,56 +0,0 @@
/**
* THE PRICE-TABLE DRIFT GUARD (owner, 2026-09-29).
*
* Failure mode this kills: bumping @earendil-works/pi-ai adds new models
* (and price corrections) to the shipped catalogs, but nobody re-runs
* scripts/sync-pricing.ts — src/core/prices.ts stays a stale generation
* and every run on a new model renders cost as "—" (deepseek-v4.1-flash,
* 2026-09-29, commit df654fb). The table must stay a generated static
* file (core is client-safe: activity-dock and stats-bar fold costs in
* the renderer, and reading the catalog JSON needs fs), so staleness is
* guarded instead of eliminated.
*
* The test asserts PRICES equals EXACTLY what the sync script would
* generate right now — it imports the script's own builder, so the guard
* and the generator can never disagree:
* - a catalog model with pricing missing from PRICES → "run the sync"
* - a price field that drifted in the catalog → "run the sync"
* - a PRICES key whose model left the catalog → "run the sync"
*
* On failure: npx tsx scripts/sync-pricing.ts, commit the regenerated
* file, re-derive any pinned-cost tests.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { PRICES } from "../src/core/prices.ts";
import { buildPrices } from "../scripts/sync-pricing.ts";
test("PRICES matches pi-ai's shipped catalogs exactly — the sync was run after the last bump", () => {
const catalog = buildPrices();
const missing = [...catalog.keys()].filter((k) => !(k in PRICES));
const stale = Object.keys(PRICES).filter((k) => !catalog.has(k));
const drifted: string[] = [];
for (const [key, cost] of catalog) {
const price = PRICES[key];
if (!price) continue;
if (
price.input !== cost.input ||
price.output !== cost.output ||
price.cacheRead !== cost.cacheRead ||
price.cacheWrite !== cost.cacheWrite
) {
drifted.push(key);
}
}
assert.equal(
`${missing.length} / ${stale.length} / ${drifted.length}`,
"0 / 0 / 0",
`price table is stale: ${missing.length} unpriced catalog models (e.g. ${missing[0] ?? "—"}), ` +
`${stale.length} keys gone from the catalog (e.g. ${stale[0] ?? "—"}), ` +
`${drifted.length} drifted prices (e.g. ${drifted[0] ?? "—"}). ` +
"Run: npx tsx scripts/sync-pricing.ts, commit the regenerated file, re-derive pinned-cost tests.",
);
});
+45
View File
@@ -0,0 +1,45 @@
/** Catalog/calculator parity replaces the old generated-table drift test. */
import assert from "node:assert/strict";
import test from "node:test";
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../src/agent/models.ts";
import { callCost, jobCost } from "../src/server/pricing.ts";
import { accumulateJobUsage, piUsageToStep } from "../src/core/contracts/jobs.ts";
const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.id));
const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 });
test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => {
assert.ok(models.length > 100);
for (const model of models) {
const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])];
for (const input of sizes) {
const usage = nativeUsage(input);
const call = Object.freeze(saved(usage));
const before = structuredClone(call);
const expected = calculateCost(model, usage).total;
assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`);
assert.deepEqual(call, before);
}
}
});
test("job capture preserves individual tier sizes and one-hour writes; old tier aggregates are unknown", () => {
const model = models.find((m) => m.cost.tiers?.length)!;
assert.ok(model);
const id = `${model.provider}:${model.id}`;
const threshold = model.cost.tiers![0]!.inputTokensAbove;
const first = nativeUsage(Math.ceil(threshold / 2));
const second = nativeUsage(Math.ceil(threshold / 2));
const total = accumulateJobUsage(accumulateJobUsage(undefined, id, piUsageToStep(first)), id, piUsageToStep(second));
assert.equal(total.calls?.length, 2);
assert.equal(total.cacheWrite1hTokens, 1000);
assert.equal(total.lastStep?.cacheWrite1hTokens, 500);
assert.equal(jobCost({ usage: total }), calculateCost(model, first).total + calculateCost(model, second).total);
assert.equal(jobCost({ usage: { ...total, calls: undefined } }), null);
const legacyOne = { ...total, ...saved(first), calls: undefined, steps: 1 };
assert.equal(jobCost({ usage: legacyOne }), calculateCost(model, first).total);
const resumedLegacy = accumulateJobUsage({ ...total, calls: undefined }, id, piUsageToStep(first));
assert.equal(resumedLegacy.calls, undefined, "never pretend the missing old calls were reconstructed");
});
+34
View File
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pricing-transport-"));
process.env.LH_DATA_DIR = scratch;
process.env.LH_AUTH_FILE = path.join(scratch, "empty-auth.json");
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
const { chatStore } = await import("../src/store/chats.ts");
const { GET } = await import("../app/api/chats/[id]/route.ts");
const { sessionCost } = await import("../src/server/pricing.ts");
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
test("chat GET supplies derived Pi costs without persisting dollars or changing native history", async () => {
const record = chatStore.createChat("price projection fixture");
chatStore.saveMessages(record.id, [{ id: "fixture-input", role: "user", content: "synthetic", timestamp: 1 }]);
const step = { inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000, cacheWriteTokens: 0, cacheWrite1hTokens: 0, reasoningTokens: 500 };
for (const [id, model] of [["api", "openrouter:openai/gpt-6.1-sol"], ["plan", "chatgpt:fixture"], ["unknown", "missing:fixture"]]) {
chatStore.appendTurnUsage(record.id, { id: id!, model: model!, messageId: "fixture-input", at: "fixture", steps: [step] });
}
const file = path.join(scratch, "chats", `${record.id}.json`);
const before = fs.readFileSync(file, "utf8");
const response = await GET(new Request(`http://localhost/api/chats/${record.id}`), { params: Promise.resolve({ id: record.id }) });
assert.equal(response.status, 200);
const body = await response.json();
assert.deepEqual(body.messages, chatStore.getChat(record.id)!.messages);
assert.deepEqual(body.pricing, sessionCost(chatStore.getChat(record.id)!));
assert.equal(body.pricing.cost, 0.037);
assert.equal(body.pricing.subscription, 1);
assert.equal(body.pricing.unpriced, 1);
assert.deepEqual(body.pricing.turnCosts, { api: 0.037, plan: null, unknown: null });
assert.equal(fs.readFileSync(file, "utf8"), before, "read-time estimates must never rewrite the usage log");
});
+7 -5
View File
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed, type FeedExtraction } from "../components/activity-dock.tsx";
import { formatTokens, formatUsd } from "../components/usage-format.ts";
import { jobCost } from "../src/server/pricing.ts";
// The section's pure layer: folds, formatting, and blob order — then the SSR
// render (blobs, "—" doctrine, the abort affordance, the always-present
@@ -27,6 +28,7 @@ function job(over: Partial<ActivityFeed["jobs"][number]> = {}): ActivityFeed["jo
updatedAt: new Date(NOW - 1000).toISOString(),
title: "Limits lesson",
contextLimit: null,
costUsd: jobCost({ usage: over.usage }),
...over,
};
}
@@ -57,7 +59,7 @@ describe("activity dock pure layer", () => {
assert.ok(blobs.every((b) => b.chatId === "src_a"), "abort/link identity stays source-scoped");
});
it("folds cost with jobCost; absent usage is null, never zero", () => {
it("uses the server's Pi price projection; absent pricing is null, never zero", () => {
const priced = job({
usage: {
model: "openrouter:z-ai/glm-5.3-flash",
@@ -70,10 +72,10 @@ describe("activity dock pure layer", () => {
},
});
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
// (pi-ai catalog prices — glm-5.3-flash cacheRead corrected
// 0.05 → 0.03 in the pi 1.0.2 catalog refresh)
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
// Pi-normalized fresh input is priced directly, never reduced by reads.
assert.equal(blob.cost, 0.00215);
const missingProjection = toBlobs({ jobs: [{ ...priced, costUsd: undefined }], extractions: [] })[0]!;
assert.equal(missingProjection.cost, null, "the browser must not invent a fallback price");
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
assert.equal(absent.cost, null);
});
+15 -3
View File
@@ -1,7 +1,8 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { StatsBar } from "../components/stats-bar.tsx";
import { StatsBar, formatContextMeasurement } from "../components/stats-bar.tsx";
import { sessionCost } from "../src/server/pricing.ts";
import { catalogLimits } from "../components/model-picker.tsx";
import type { ModelCatalogEntry, ModelCatalogResponse } from "../src/core/contracts.ts";
import { prettyModel } from "../components/usage-format.ts";
@@ -50,7 +51,7 @@ describe("stats bar", () => {
it("folds context + cost from the turns log (core/prices, no local math)", () => {
const html = renderToStaticMarkup(
<StatsBar turns={[turn(), turn()]} />,
<StatsBar turns={[turn(), turn()]} contextTokens={11_000} pricing={sessionCost({ turns: [turn(), turn()] })} />,
);
// last turn's last call: 10k in + 1k out = 11k. The LIMIT is the
// live catalog's — and the bar doesn't hold the index yet (the
@@ -68,7 +69,7 @@ describe("stats bar", () => {
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
const html = renderToStaticMarkup(
<StatsBar turns={[turn({ model: "nope/unknown" })]} />,
<StatsBar turns={[turn({ model: "nope/unknown" })]} contextTokens={11_000} pricing={sessionCost({ turns: [turn({ model: "nope/unknown" })] })} />,
);
assert.match(html, /data-testid="stats-cost">—</);
// unknown model: no catalog limit exists, but the footprint is
@@ -77,6 +78,17 @@ describe("stats bar", () => {
assert.match(html, /data-testid="stats-context">11k</);
});
it("unknown context is explicit; changing the selected limit preserves the measurement", () => {
assert.equal(formatContextMeasurement(null, 100_000), "? / 100k");
assert.equal(formatContextMeasurement(83_000, 100_000), "83k / 100k");
assert.equal(formatContextMeasurement(83_000, 200_000), "83k / 200k");
assert.equal(formatContextMeasurement(83_000, null), "83k");
const html = renderToStaticMarkup(<StatsBar chatId="fixture" turns={[turn()]} contextTokens={null} />);
assert.match(html, /data-testid="stats-context">\?</);
const empty = renderToStaticMarkup(<StatsBar chatId="fixture" turns={[]} />);
assert.match(empty, /data-testid="stats-context">\?</);
});
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
+46
View File
@@ -0,0 +1,46 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import test from "node:test";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { StatsBar } from "../components/stats-bar.tsx";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts";
(globalThis as Record<string, unknown>).React = React;
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
test("meter keeps its measured numerator across selected-model/catalog changes, and renders post-compaction unknown", async () => {
const originalFetch = globalThis.fetch;
let selected = "a";
const catalog: ModelCatalogResponse = {
models: ["a", "b"].map((id, i) => ({ id, provider: "fixture", name: id, input: ["text"], reasoning: false, supportedLevels: ["off"], maxTokens: 1000, contextWindow: 100_000 * (i + 1), cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } })),
providers: [], defaults: null, slots: { strong: null, cheap: null, vision: null }, rolePins: {} as ModelCatalogResponse["rolePins"],
};
globalThis.fetch = async (input) => {
if (input === "/api/models") return Response.json(catalog);
if (input === "/api/settings") return Response.json({ settings: { models: {} } });
if (input === "/api/chats/fixture") return Response.json({ settings: { model: { provider: "fixture", id: selected }, thinkingLevel: "off" } });
throw new Error(`unexpected fixture request ${String(input)}`);
};
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const turn: TurnUsage = { id: "summary", messageId: "marker", at: "fixture", model: "fixture:a", steps: [{ inputTokens: 4300, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }] };
const pricing = { cost: 0.003, unpriced: 0, turnCosts: { summary: 0.003 } };
try {
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={83_000} pricing={pricing} />));
const context = () => host.querySelector('[data-testid="stats-context"]')?.textContent;
assert.equal(context(), "83k / 100k", "summarizer's 4.3k money log is not the meter source");
selected = "b";
await act(async () => notifySettingsSaved("models"));
assert.equal(context(), "83k / 200k", "only denominator changes");
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={null} pricing={pricing} />));
assert.equal(context(), "? / 200k");
assert.equal(host.querySelector('[data-testid="stats-cost"]')?.textContent, "$0.0030", "summary spending is still visible");
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={72_000} pricing={pricing} />));
assert.equal(context(), "72k / 200k");
} finally {
await act(async () => root.unmount()); host.remove(); globalThis.fetch = originalFetch;
}
});
+69 -262
View File
@@ -1,307 +1,114 @@
/**
* Usage-accounting tests — the money channel's pure helpers (core/prices.ts)
* and the ChatStore usage-log seam (appendTurnUsage).
*
* Pure helper tests are pure. The store test uses the real fs chatStore with
* a fixture chat, cleaned up in after().
*/
import assert from "node:assert/strict";
import test from "node:test";
import * as fs from "node:fs";
import { chatStore } from "../src/store/chats.ts";
import { graphStore } from "../src/store/graph.ts";
import { PATHS, SOURCES_FILE } from "../config.ts";
import { callCost, contextWindow, jobCost, sessionCost, sumSteps, turnCost } from "../src/core/prices.ts";
import { callCost, jobCost, sessionCost, turnCost } from "../src/server/pricing.ts";
import { sumSteps } from "../src/core/prices.ts";
import type { CallUsage, ExtractionRunUsage, JobRecord, TurnUsage } from "../src/core/contracts.ts";
const call = (over: Partial<CallUsage> = {}): CallUsage => ({
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
reasoningTokens: undefined,
...over,
inputTokens: 0, outputTokens: 0, cacheReadTokens: undefined,
cacheWriteTokens: undefined, reasoningTokens: undefined, ...over,
});
test("sumSteps folds steps; absent stays absent until a value appears", () => {
const sum = sumSteps([
call({ inputTokens: 100, outputTokens: 20 }),
call({ inputTokens: 50, outputTokens: 10, cacheReadTokens: 30 }),
]);
test("sumSteps: absent stays absent; unknown long-cache detail stays unknown", () => {
const sum = sumSteps([call({ inputTokens: 100, outputTokens: 20 }), call({ inputTokens: 50, outputTokens: 10, cacheReadTokens: 30 })]);
assert.equal(sum.inputTokens, 150);
assert.equal(sum.outputTokens, 30);
assert.equal(sum.cacheReadTokens, 30);
assert.equal(sum.cacheWriteTokens, undefined); // never reported → stays undefined
// all-absent → all-undefined (helper defaults input/output to 0, so
// explicit undefined here)
const empty = sumSteps([call({ inputTokens: undefined, outputTokens: undefined }), call({ inputTokens: undefined, outputTokens: undefined })]);
assert.equal(empty.inputTokens, undefined);
assert.equal(sum.cacheWriteTokens, undefined);
assert.equal(sumSteps([call({ inputTokens: undefined, outputTokens: undefined })]).inputTokens, undefined);
assert.equal(sumSteps([call({ cacheWriteTokens: 10 }), call({ cacheWriteTokens: 20, cacheWrite1hTokens: 5 })]).cacheWrite1hTokens, undefined);
});
test("callCost: glm-5.3-flash math — fresh input, cached read, output", () => {
test("callCost prices normalized fresh input directly, without subtracting caches again", () => {
const cost = callCost(call({ inputTokens: 100_000, outputTokens: 20_000, cacheReadTokens: 10_000 }), "openrouter:z-ai/glm-5.3-flash");
assert.ok(cost !== null);
// fresh 90k @ $0.15/M + cached 10k @ $0.03/M + output 20k @ $0.5/M
// (pi-ai catalog prices — regenerated tables, scripts/sync-pricing;
// glm-5.3-flash cacheRead corrected 0.05 → 0.03 in pi 1.0.2)
assert.ok(Math.abs(cost! - (0.0135 + 0.0003 + 0.01)) < 1e-9);
assert.ok(Math.abs(cost - (0.015 + 0.0003 + 0.01)) < 1e-9);
assert.equal(callCost(call({ inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000 }), "openrouter:openai/gpt-6.1-sol"), 0.037);
});
test("callCost: cache writes bill at the cacheWrite rate", () => {
const cost = callCost(
call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }),
"anthropic:claude-haiku-4-5",
);
assert.ok(cost !== null);
// Native 1.4 catalog: fresh 1M @ $1/M + cache write 500k @ $1.25/M.
assert.ok(Math.abs(cost! - (1 + 0.625)) < 1e-9);
test("cache writes: precise short/one-hour costs and honest unknown historical splits", () => {
const usage = call({ inputTokens: 1_000_000, cacheWriteTokens: 500_000, cacheWrite1hTokens: 0 });
assert.equal(callCost(usage, "anthropic:claude-haiku-4-5"), 1.625);
assert.equal(callCost({ ...usage, cacheWrite1hTokens: 200_000 }, "anthropic:claude-haiku-4-5"), 1.775);
assert.equal(callCost({ ...usage, cacheWrite1hTokens: undefined }, "anthropic:claude-haiku-4-5"), null);
});
test("callCost: unknown model and absent fields are null, never zero", () => {
assert.equal(callCost(call({ inputTokens: 100, outputTokens: 5 }), "not/a-model"), null);
assert.equal(callCost(call({ inputTokens: 100, outputTokens: undefined }), "openrouter:z-ai/glm-5.3-flash"), null);
test("unknown model, absent or invalid required usage is null, never fake zero", () => {
assert.equal(callCost(call(), "not/a-model"), null);
assert.equal(callCost(call({ outputTokens: undefined }), "openai:gpt-5"), null);
for (const value of [-1, NaN, Infinity]) assert.equal(callCost(call({ inputTokens: value }), "openai:gpt-5"), null);
});
test("turnCost sums steps; sessionCost reports unpriced turns", () => {
const turn: TurnUsage = {
id: "t1",
messageId: "m1",
at: "2026-09-09T00:00:00Z",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })],
};
assert.ok(turnCost(turn) !== null);
const totals = sessionCost({
turns: [turn, { id: "t2", messageId: "m2", at: "x", model: "not/a-model", steps: [call({ inputTokens: 1, outputTokens: 1 })] }],
});
test("turn/session costs sum calls and count unpriced separately", () => {
const turn: TurnUsage = { id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })] };
const totals = sessionCost({ turns: [turn, { ...turn, id: "t2", model: "not/a-model" }] });
assert.equal(totals.unpriced, 1);
assert.ok(totals.cost > 0);
assert.equal(totals.cost, turnCost(turn));
assert.equal(totals.turnCosts.t1, totals.cost);
assert.equal(totals.turnCosts.t2, null);
});
test("contextWindow: last call's footprint against the live index's limit", () => {
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
const cw = contextWindow(
{
turns: [
{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] },
{ id: "t2", messageId: "m2", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 90_000, outputTokens: 10_000 })] },
],
},
limits,
);
assert.ok(cw);
assert.equal(cw.model, "openrouter:z-ai/glm-5.3-flash");
assert.equal(cw.usedTokens, 100_000);
assert.equal(cw.limit, 1_048_576);
assert.equal(contextWindow({ turns: [] }, limits), null);
});
test("contextWindow NUMERATOR: cache tokens count (owner audit, 2026-09-23)", () => {
// The audit's live case: a cache-heavy session read 40k on the meter
// while the real footprint was ~190k — provider `input` EXCLUDES the
// cached portion, so input+output alone is wildly low. The fold is
// pi's totalTokens semantics: all four fields.
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
const cw = contextWindow(
{
turns: [
{
id: "t1",
messageId: "m1",
at: "x",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [call({ inputTokens: 40_000, outputTokens: 1_000, cacheReadTokens: 145_000, cacheWriteTokens: 4_000 })],
},
],
},
limits,
);
assert.ok(cw);
assert.equal(cw.usedTokens, 40_000 + 1_000 + 145_000 + 4_000, "input + output + cacheRead + cacheWrite");
// Cache fields absent (non-caching providers): the fold is unchanged.
const plain = contextWindow(
{ turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] }] },
limits,
);
assert.ok(plain);
assert.equal(plain.usedTokens, 10_000);
});
test("contextWindow DENOMINATOR: the live index is the ONLY source — no fallback (owner amendment, 2026-09-23)", () => {
const turns = [
{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 1_000 })] },
];
const live = new Map([["openrouter:z-ai/glm-5.3-flash", 65_536]]);
const cw = contextWindow({ turns }, live);
assert.ok(cw);
assert.equal(cw.limit, 65_536, "pi's catalog, via the index, is the one source");
// No index at all (a client that hasn't fetched the catalog): the
// meter renders usage-only — limit null, never a guessed window.
const noIndex = contextWindow({ turns });
assert.ok(noIndex);
assert.equal(noIndex.usedTokens, 11_000);
assert.equal(noIndex.limit, null);
const emptyIndex = contextWindow({ turns }, new Map<string, number>());
assert.ok(emptyIndex);
assert.equal(emptyIndex.limit, null, "an empty index (failed fetch) is usage-only too");
// A model missing from the live catalog (historical session, package
// update removed it): usage-only, never a fake window.
const missing = contextWindow({ turns: [{ ...turns[0]!, model: "nope/gone-model" }] }, live);
assert.ok(missing);
assert.equal(missing.limit, null);
assert.equal(missing.usedTokens, 11_000, "the footprint still reads honestly");
});
test("contextWindow ride-along: a zeroed/aborted last step falls back to the previous step", () => {
// An aborted last call reports zeroed usage — that is not the session
// footprint. The honest reading is the previous step's usage.
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
const cw = contextWindow(
{
turns: [
{
id: "t1",
messageId: "m1",
at: "x",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [
call({ inputTokens: 80_000, outputTokens: 5_000 }),
call({ inputTokens: 0, outputTokens: 0 }), // aborted — zeroed
],
},
],
},
limits,
);
assert.ok(cw);
assert.equal(cw.usedTokens, 85_000, "the previous step's footprint, not a bogus tiny meter");
// ALL steps zeroed (a first-call abort): no reading at all — null.
assert.equal(
contextWindow(
{ turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 0, outputTokens: 0 })] }] },
limits,
),
null,
);
});
test("jobCost: null without usage, computed with it", () => {
test("legacy flat-rate jobs remain priceable; missing usage stays absent", () => {
assert.equal(jobCost({}), null);
const job: Pick<JobRecord, "usage"> = {
usage: {
model: "openrouter:z-ai/glm-5.3-flash",
inputTokens: 1_000_000,
outputTokens: 100_000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
steps: 4,
},
};
assert.ok(jobCost(job) !== null);
const job: Pick<JobRecord, "usage"> = { usage: { model: "openrouter:z-ai/glm-5.3-flash", inputTokens: 1_000_000, outputTokens: 100_000, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, steps: 4 } };
assert.ok(jobCost(job)! > 0);
});
// ─── the store seam ──────────────────────────────────────────────────────────
test("appendTurnUsage: appends, idempotent per turn id, continuation turns (same message) all kept, survives saveMessages", () => {
const record = chatStore.createChat("usage test chat");
const entry: TurnUsage = {
id: "turn_1",
messageId: "msg_a",
at: "2026-09-09T00:00:00Z",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [call({ inputTokens: 100, outputTokens: 10 })],
};
chatStore.appendTurnUsage(record.id, entry);
chatStore.appendTurnUsage(record.id, entry); // replay (abort/retry) → no-op
// continuation turn: SAME response message (SDK coalesces), new turn id —
// usage must survive, not collapse into the first entry
chatStore.appendTurnUsage(record.id, { ...entry, id: "turn_2" });
let reread = chatStore.getChat(record.id);
assert.equal(reread?.turns?.length, 2);
// saveMessages must not clobber the log (read-mutate-write record) —
// Wave B: a NATIVE message list (the record holds agent history).
chatStore.saveMessages(record.id, [
{ id: "msg_a", role: "user", content: "native", timestamp: Date.now() } as never,
]);
reread = chatStore.getChat(record.id);
assert.equal(reread?.turns?.length, 2);
test("appendTurnUsage: idempotent per turn; continuations survive saveMessages; list omits log", () => {
const record = chatStore.createChat("usage fixture");
try {
const entry: TurnUsage = { id: "turn_1", messageId: "msg_a", at: "x", model: "openai:gpt-5", steps: [call({ inputTokens: 100, outputTokens: 10, cacheWrite1hTokens: 0 })] };
chatStore.appendTurnUsage(record.id, entry);
chatStore.appendTurnUsage(record.id, entry);
chatStore.appendTurnUsage(record.id, { ...entry, id: "turn_2" });
assert.equal(chatStore.getChat(record.id)?.turns?.length, 2);
chatStore.saveMessages(record.id, [{ id: "msg_a", role: "user", content: "fixture", timestamp: 1 }]);
assert.deepEqual(chatStore.getChat(record.id)?.turns?.[0]?.steps, JSON.parse(JSON.stringify(entry.steps)));
assert.equal(chatStore.getChat(record.id)?.turns?.length, 2);
assert.equal("turns" in chatStore.listChats().find((c) => c.id === record.id)!, false);
} finally { fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); }
});
// and the list payload must NOT leak turns
const listed = chatStore.listChats().find((c) => c.id === record.id);
assert.ok(listed);
assert.equal("turns" in listed, false);
fs.unlinkSync(`${PATHS.chats}/${record.id}.json`);
const runEntry = (startedAt: number): ExtractionRunUsage => ({
startedAt, at: "2026-09-09T00:00:00Z", durationMs: 42_000,
stats: { nodes: 1, chains: 2 }, stages: {
extractor: { model: "openai:gpt-5", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "openai:gpt-5", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
},
});
test("appendExtractionUsage: appends, idempotent per startedAt, not in list payload", () => {
const record = chatStore.createChat("extraction usage test chat");
const runEntry = (startedAt: number): ExtractionRunUsage => ({
startedAt,
at: "2026-09-09T00:00:00Z",
durationMs: 42_000,
stats: { nodes: 1, chains: 2 },
stages: {
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
},
});
chatStore.appendExtractionUsage(record.id, runEntry(1));
chatStore.appendExtractionUsage(record.id, runEntry(1)); // same run appended twice → no-op
chatStore.appendExtractionUsage(record.id, runEntry(2)); // a retried run is a NEW spend
let reread = chatStore.getChat(record.id);
assert.equal(reread?.extractions?.length, 2);
// failed runs (stats absent) persist with usage — money was spent
chatStore.appendExtractionUsage(record.id, { ...runEntry(3), stats: undefined });
reread = chatStore.getChat(record.id);
assert.equal(reread?.extractions?.length, 3);
assert.equal(reread?.extractions?.at(-1)?.stats, undefined);
// the list payload must NOT leak the extractions log
const listed = chatStore.listChats().find((c) => c.id === record.id);
assert.ok(listed);
assert.equal("extractions" in listed, false);
fs.unlinkSync(`${PATHS.chats}/${record.id}.json`);
test("appendExtractionUsage: idempotent; failed runs persist; list omits log", () => {
const record = chatStore.createChat("extraction usage fixture");
try {
chatStore.appendExtractionUsage(record.id, runEntry(1));
chatStore.appendExtractionUsage(record.id, runEntry(1));
chatStore.appendExtractionUsage(record.id, runEntry(2));
chatStore.appendExtractionUsage(record.id, { ...runEntry(3), stats: undefined });
assert.equal(chatStore.getChat(record.id)?.extractions?.length, 3);
assert.equal(chatStore.getChat(record.id)?.extractions?.at(-1)?.stats, undefined);
assert.equal("extractions" in chatStore.listChats().find((c) => c.id === record.id)!, false);
} finally { fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); }
});
test("appendSourceUsage: appends to the source record, idempotent per startedAt, upsert keeps history", () => {
// The real fs graphStore — sources.json backed up and restored around
// this test (same discipline as the chat fixture files).
test("appendSourceUsage: idempotent; reimport keeps history; absent source is a no-op", () => {
fs.mkdirSync(PATHS.graph, { recursive: true });
let bak: string | undefined;
if (fs.existsSync(SOURCES_FILE)) bak = `${SOURCES_FILE}.w5test.bak`;
if (bak) fs.renameSync(SOURCES_FILE, bak);
const backup = fs.existsSync(SOURCES_FILE) ? fs.readFileSync(SOURCES_FILE) : undefined;
try {
const runEntry = (startedAt: number): ExtractionRunUsage => ({
startedAt,
at: "2026-09-13T00:00:00Z",
durationMs: 42_000,
stats: { nodes: 1, chains: 2 },
stages: {
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
},
});
const id = "src_usagetest01";
graphStore.upsertSource({ id, filePath: "/vault/usagetest.md", importedAt: "2026-09-13T00:00:00Z" });
graphStore.upsertSource({ id, filePath: "/fixture/notes.md", importedAt: "2026-09-13T00:00:00Z" });
graphStore.appendSourceUsage(id, runEntry(1));
graphStore.appendSourceUsage(id, runEntry(1)); // same run appended twice → no-op
graphStore.appendSourceUsage(id, runEntry(2)); // a retried run is a NEW spend
graphStore.appendSourceUsage(id, runEntry(1));
graphStore.appendSourceUsage(id, runEntry(2));
assert.equal(graphStore.readSources().find((s) => s.id === id)?.extractions?.length, 2);
// An upsert (re-import) KEEPS the usage history.
graphStore.upsertSource({ id, filePath: "/vault/usagetest.md", importedAt: "2026-09-14T00:00:00Z" });
graphStore.upsertSource({ id, filePath: "/fixture/notes.md", importedAt: "2026-09-14T00:00:00Z" });
assert.equal(graphStore.readSources().find((s) => s.id === id)?.extractions?.length, 2);
// Unknown ids are a silent no-op (nothing to bill against).
graphStore.appendSourceUsage("src_nope", runEntry(1));
graphStore.appendSourceUsage("src_missing", runEntry(1));
} finally {
fs.rmSync(SOURCES_FILE, { force: true });
if (bak) fs.renameSync(bak, SOURCES_FILE);
if (backup) fs.writeFileSync(SOURCES_FILE, backup);
else fs.rmSync(SOURCES_FILE, { force: true });
}
});