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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 65 additions & 39 deletions sdk/typescript/src/cost.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { open, readdir } from "node:fs/promises";
import { join } from "node:path";
import {
Expand Down Expand Up @@ -37,6 +38,7 @@ interface SessionReasoning {
}

interface SessionUsage {
tracked: boolean;
offset: number;
pendingLine: Buffer[];
pendingLineBytes: number;
Expand All @@ -50,7 +52,7 @@ interface SessionUsage {
usage: ScanTokenUsage | null;
calls: Map<string, ScanActivity>;
activities: ScanActivity[];
progress: ScanProgress[];
progress?: ScanProgress[];
filesCompleted: number;
filesTotal: number | null;
prose: Set<string>;
Expand Down Expand Up @@ -83,6 +85,7 @@ const SESSION_READ_SIZE = 64 * 1_024;

function createSessionUsage(): SessionUsage {
return {
tracked: false,
offset: 0,
pendingLine: [],
pendingLineBytes: 0,
Expand All @@ -96,7 +99,6 @@ function createSessionUsage(): SessionUsage {
usage: null,
calls: new Map(),
activities: [],
progress: [],
filesCompleted: 0,
filesTotal: null,
prose: new Set(),
Expand Down Expand Up @@ -199,7 +201,6 @@ export class ScanCostTracker {

async #readSessions(): Promise<void> {
if (this.#threadId === null) return;
const unreadable: Array<{ session: SessionUsage; error: unknown }> = [];
for await (const path of sessionFiles(
join(this.#options.codexHome, "sessions"),
)) {
Expand All @@ -208,11 +209,10 @@ export class ScanCostTracker {
session = createSessionUsage();
this.#sessions.set(path, session);
}
try {
await readSessionUsage(path, session, this.#options.repository);
} catch (error) {
if (session.threadId === null) throw error;
unreadable.push({ session, error });
if (session.threadId === null) {
// Index ownership before reading a transcript. Unrelated sessions need
// only their metadata, including parents discovered by a later poll.
await readSessionUsage(path, session, undefined, true);
}
}

Expand Down Expand Up @@ -258,30 +258,34 @@ export class ScanCostTracker {
}
}
}
for (const { session, error } of unreadable) {
if (included.has(session.threadId!)) throw error;
}

const usages = new Map(this.#receipts);
for (const [path, tracked] of this.#sessions) {
const threadId = tracked.threadId;
if (threadId === null || !included.has(threadId)) continue;
let session = tracked;
if (
this.#options.onSessionEvent !== undefined &&
session.events === undefined
) {
// Replay only newly associated sessions, including their early events.
session = createSessionUsage();
session.events = [];
await readSessionUsage(path, session, this.#options.repository);
this.#sessions.set(path, session);
}
let worker: number | undefined;
if (threadId !== this.#threadId) {
worker = this.#workers.get(threadId) ?? this.#workers.size + 1;
this.#workers.set(threadId, worker);
}
if (!session.tracked) {
// Replay newly associated sessions from the start for every observer,
// not just raw session events. Their early usage and activity matter.
session = createSessionUsage();
session.tracked = true;
if (this.#options.onSessionEvent !== undefined) session.events = [];
if (worker !== undefined && this.#options.onProgress !== undefined) {
session.progress = [];
}
this.#sessions.set(path, session);
}
await readSessionUsage(
path,
session,
worker !== undefined && this.#options.onActivity !== undefined
? this.#options.repository
: undefined,
);
for (const event of session.events?.splice(0) ?? []) {
this.#options.onSessionEvent?.({
threadId,
Expand Down Expand Up @@ -325,7 +329,7 @@ export class ScanCostTracker {
if (this.#options.onProgress === undefined || session.threadId === null) {
return;
}
for (const progress of session.progress.splice(0)) {
for (const progress of session.progress?.splice(0) ?? []) {
const expectedFilesTotal = this.#expectedFilesTotal;
if (
(expectedFilesTotal !== undefined &&
Expand Down Expand Up @@ -390,6 +394,7 @@ async function readSessionUsage(
path: string,
session: SessionUsage,
repository?: string,
metadataOnly = false,
): Promise<void> {
if (session.unreadable) return;
let file;
Expand All @@ -411,13 +416,19 @@ async function readSessionUsage(
if (bytesRead === 0) return;
session.offset += bytesRead;
try {
readSessionChunk(buffer.subarray(0, bytesRead), session, repository);
readSessionChunk(
buffer.subarray(0, bytesRead),
session,
repository,
metadataOnly,
);
} catch (error) {
session.unreadable = true;
session.pendingLine = [];
session.pendingLineBytes = 0;
throw error;
}
if (metadataOnly && session.threadId !== null) return;
}
} finally {
await file.close();
Expand All @@ -428,6 +439,7 @@ function readSessionChunk(
contents: Buffer,
session: SessionUsage,
repository?: string,
metadataOnly = false,
): void {
let lineStart = 0;
while (lineStart < contents.length) {
Expand All @@ -445,17 +457,24 @@ function readSessionChunk(
}

if (session.pendingLineBytes === 0) {
readSessionEvent(fragment.toString("utf8"), session, repository);
readSessionEvent(
fragment.toString("utf8"),
session,
repository,
metadataOnly,
);
} else {
if (fragment.length > 0) session.pendingLine.push(Buffer.from(fragment));
readSessionEvent(
Buffer.concat(session.pendingLine, lineBytes).toString("utf8"),
session,
repository,
metadataOnly,
);
session.pendingLine = [];
session.pendingLineBytes = 0;
}
if (metadataOnly && session.threadId !== null) return;
lineStart = newline + 1;
}
}
Expand All @@ -464,6 +483,7 @@ function readSessionEvent(
line: string,
session: SessionUsage,
repository?: string,
metadataOnly = false,
): void {
if (line.length === 0) return;
let event: unknown;
Expand Down Expand Up @@ -491,6 +511,7 @@ function readSessionEvent(
session.events?.push(event);
return;
}
if (metadataOnly) return;
if (session.replaying) {
if (event["type"] !== "event_msg") return;
if (payload["type"] === "token_count" && isRecord(payload["info"])) {
Expand All @@ -516,7 +537,7 @@ function readSessionEvent(
}
session.events?.push(event);
if (event["type"] === "response_item") {
session.progress.push(...sessionProgressUpdates(payload));
session.progress?.push(...sessionProgressUpdates(payload));
if (repository === undefined) return;
if (
payload["type"] === "reasoning" &&
Expand All @@ -537,10 +558,7 @@ function readSessionEvent(
},
repository,
);
if (
activity === null ||
session.prose.has(`${activity.kind}:${activity.description}`)
) {
if (activity === null || session.prose.has(proseKey(activity))) {
continue;
}
session.reasoning = {
Expand Down Expand Up @@ -575,12 +593,12 @@ function readSessionEvent(
session.reasoning = null;
if (
activity.kind === "message" &&
session.prose.has(`${activity.kind}:${activity.description}`)
session.prose.has(proseKey(activity))
) {
return;
}
if (activity.kind === "message") {
session.prose.add(`${activity.kind}:${activity.description}`);
session.prose.add(proseKey(activity));
}
if (activity.status === "running") {
session.calls.set(activity.id, activity);
Expand Down Expand Up @@ -616,7 +634,7 @@ function readSessionEvent(
payload["type"] === "agent_message" &&
typeof payload["message"] === "string"
) {
session.progress.push(
session.progress?.push(
...scanProgressUpdatesFromEvent({
type: "item.completed",
item: { type: "agent_message", text: payload["message"] },
Expand All @@ -630,11 +648,8 @@ function readSessionEvent(
}
session.reasoning = null;
const activity = scanActivityFromSessionEvent(event, repository);
if (
activity !== null &&
!session.prose.has(`${activity.kind}:${activity.description}`)
) {
session.prose.add(`${activity.kind}:${activity.description}`);
if (activity !== null && !session.prose.has(proseKey(activity))) {
session.prose.add(proseKey(activity));
session.activities.push(activity);
}
return;
Expand Down Expand Up @@ -731,10 +746,21 @@ function recordReasoningActivity(
return;
}
reasoning.activity = activity;
session.prose.add(`${activity.kind}:${activity.description}`);
session.prose.add(proseKey(activity));
session.activities.push(activity);
}

function proseKey(activity: ScanActivity): string {
// Deduplication needs an identity, not a retained copy of every transcript
// message and every expanding reasoning prefix.
// Hash UTF-16 code units to keep distinct lone surrogates distinct too.
return createHash("sha256")
.update(activity.kind)
.update(":")
.update(activity.description, "utf16le")
.digest("hex");
}

function sessionProgressUpdates(
payload: Readonly<Record<string, unknown>>,
): ScanProgress[] {
Expand Down
Loading