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
17 changes: 14 additions & 3 deletions src/notifications/usage-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
* written at SessionEnd and read at SessionStart for the savings recap.
*
* Storage: `~/.deeplake/usage-stats.jsonl`. JSONL, one record per session.
* Append-only at write time. The SessionStart-side reader sums across ALL
* records (cumulative since install — see plan).
* Append-only at write time; a resumed session appends a newer cumulative
* snapshot and the reader keeps only the last one per session. The
* SessionStart-side reader sums across ALL sessions (cumulative since
* install — see plan).
*
* Failure mode: every operation is fail-soft. A broken stats file must
* never break a SessionEnd or SessionStart hook — it just means the recap
Expand Down Expand Up @@ -61,12 +63,20 @@ export function appendUsageRecord(record: UsageRecord): void {
* Read all usage records. Returns [] on missing file or read error.
* Malformed lines are skipped individually so a partially-corrupt file
* still yields the valid records.
*
* One record per session: SessionEnd re-parses the FULL transcript each
* time, so a resumed session appends a cumulative snapshot that already
* includes every earlier snapshot for that session. Summing them would
* double-count, so for a nonempty `sessionId` only the last valid record
* is kept (at the position of that last record). Records with an empty
* `sessionId` can't be attributed to a session and are kept individually.
*/
export function readUsageRecords(): UsageRecord[] {
try {
if (!existsSync(statsFilePath())) return [];
const raw = readFileSync(statsFilePath(), "utf-8");
const out: UsageRecord[] = [];
const lastIndexBySession = new Map<string, number>();
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
Expand All @@ -90,12 +100,13 @@ export function readUsageRecords(): UsageRecord[] {
memorySearchBytes: typeof rec.memorySearchBytes === "number" ? rec.memorySearchBytes : 0,
memorySearchCount: typeof rec.memorySearchCount === "number" ? rec.memorySearchCount : 0,
});
if (rec.sessionId) lastIndexBySession.set(rec.sessionId, out.length - 1);
}
} catch {
// skip malformed line
}
}
return out;
return out.filter((r, i) => !r.sessionId || lastIndexBySession.get(r.sessionId) === i);
} catch (e: any) {
log(`readUsageRecords failed: ${e?.message ?? String(e)}`);
return [];
Expand Down
105 changes: 104 additions & 1 deletion tests/claude-code/notifications-usage-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { appendFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

Expand All @@ -10,6 +10,7 @@ import {
sumMetric,
type UsageRecord,
} from "../../src/notifications/usage-tracker.js";
import { parseTranscript } from "../../src/notifications/transcript-parser.js";
import { setFakeHome, clearFakeHome } from "../shared/fake-home.js";

let TEMP_HOME = "";
Expand Down Expand Up @@ -139,6 +140,108 @@ describe("usage-tracker — append/read", () => {
});
});

describe("usage-tracker — resumed sessions (one cumulative record per session)", () => {
it("keeps only the last record for a repeated nonempty sessionId", () => {
appendUsageRecord(rec({ sessionId: "resumed", endedAt: "2026-05-13T01:00:00Z", memorySearchBytes: 1000, memorySearchCount: 1 }));
appendUsageRecord(rec({ sessionId: "resumed", endedAt: "2026-05-13T02:00:00Z", memorySearchBytes: 2500, memorySearchCount: 3 }));
const records = readUsageRecords();
expect(records).toEqual([
{ endedAt: "2026-05-13T02:00:00Z", sessionId: "resumed", memorySearchBytes: 2500, memorySearchCount: 3 },
]);
expect(sumMetric(records, "memorySearchBytes")).toBe(2500);
expect(sumMetric(records, "memorySearchCount")).toBe(3);
});

it("storage stays append-only: every snapshot line is still on disk", () => {
appendUsageRecord(rec({ sessionId: "resumed", memorySearchBytes: 1000 }));
appendUsageRecord(rec({ sessionId: "resumed", memorySearchBytes: 2500 }));
const lines = readFileSync(statsFilePath(), "utf-8").split("\n").filter(Boolean);
expect(lines).toHaveLength(2);
});

it("preserves distinct sessions and orders by each session's last record", () => {
appendUsageRecord(rec({ sessionId: "a", memorySearchBytes: 100, memorySearchCount: 1 }));
appendUsageRecord(rec({ sessionId: "b", memorySearchBytes: 200, memorySearchCount: 2 }));
appendUsageRecord(rec({ sessionId: "a", memorySearchBytes: 300, memorySearchCount: 4 }));
const records = readUsageRecords();
expect(records.map(r => [r.sessionId, r.memorySearchBytes])).toEqual([["b", 200], ["a", 300]]);
expect(sumMetric(records, "memorySearchBytes")).toBe(500);
expect(sumMetric(records, "memorySearchCount")).toBe(6);
});

it("keeps records with an empty sessionId independently (unknown sessions are not conflated)", () => {
appendUsageRecord(rec({ sessionId: "", memorySearchBytes: 10, memorySearchCount: 1 }));
appendUsageRecord(rec({ sessionId: "", memorySearchBytes: 20, memorySearchCount: 1 }));
appendUsageRecord(rec({ sessionId: "known", memorySearchBytes: 30, memorySearchCount: 1 }));
const records = readUsageRecords();
expect(records.map(r => [r.sessionId, r.memorySearchBytes])).toEqual([["", 10], ["", 20], ["known", 30]]);
});

it("an invalid later line does not displace the last valid record for the session", () => {
const file = join(TEMP_HOME, ".deeplake", "usage-stats.jsonl");
mkdirSync(join(TEMP_HOME, ".deeplake"));
const first = JSON.stringify(rec({ sessionId: "resumed", memorySearchBytes: 1000 }));
const second = JSON.stringify(rec({ sessionId: "resumed", memorySearchBytes: 2500 }));
const invalid = JSON.stringify({ sessionId: "resumed", memorySearchBytes: 99999 }); // no endedAt
writeFileSync(file, `${first}\n${second}\n${invalid}\nnot-json\n`, "utf-8");
const records = readUsageRecords();
expect(records).toHaveLength(1);
expect(records[0].memorySearchBytes).toBe(2500);
});

it("dedup keeps missing-counter compatibility for the last legacy snapshot", () => {
const file = join(TEMP_HOME, ".deeplake", "usage-stats.jsonl");
mkdirSync(join(TEMP_HOME, ".deeplake"));
const first = JSON.stringify(rec({ sessionId: "legacy", memorySearchBytes: 1000, memorySearchCount: 2 }));
const legacy = JSON.stringify({ endedAt: "2026-05-14T00:00:00Z", sessionId: "legacy", memorySearchBytes: 1500 });
writeFileSync(file, `${first}\n${legacy}\n`, "utf-8");
expect(readUsageRecords()).toEqual([
{ endedAt: "2026-05-14T00:00:00Z", sessionId: "legacy", memorySearchBytes: 1500, memorySearchCount: 0 },
]);
});

it("lifecycle: parseTranscript → appendUsageRecord across two SessionEnds → readUsageRecords counts once", () => {
const transcript = join(TEMP_HOME, "transcript.jsonl");
const toolUse = (id: string, ts: string) => ({
type: "assistant",
message: {
role: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: "grep -r foo ~/.deeplake/memory/" } }],
},
timestamp: ts,
sessionId: "real-session",
});
const toolResult = (id: string, content: string, ts: string) => ({
type: "user",
message: { role: "user", content: [{ type: "tool_result", tool_use_id: id, content }] },
timestamp: ts,
sessionId: "real-session",
});
const toJsonl = (lines: object[]) => lines.map(l => JSON.stringify(l)).join("\n") + "\n";

// First SessionEnd: one memory lookup returning 100 bytes.
writeFileSync(transcript, toJsonl([
toolUse("t1", "2026-05-13T10:00:00Z"),
toolResult("t1", "a".repeat(100), "2026-05-13T10:00:05Z"),
]), "utf-8");
appendUsageRecord(parseTranscript(transcript, "fallback"));

// Resume: the transcript grows, and SessionEnd re-parses the whole file.
appendFileSync(transcript, toJsonl([
toolUse("t2", "2026-05-13T11:00:00Z"),
toolResult("t2", "b".repeat(250), "2026-05-13T11:00:05Z"),
]), "utf-8");
appendUsageRecord(parseTranscript(transcript, "fallback"));

const records = readUsageRecords();
expect(records).toEqual([
{ endedAt: "2026-05-13T11:00:05Z", sessionId: "real-session", memorySearchBytes: 350, memorySearchCount: 2 },
]);
expect(sumMetric(records, "memorySearchBytes")).toBe(350);
expect(sumMetric(records, "memorySearchCount")).toBe(2);
});
});

describe("usage-tracker — sumMetric", () => {
const records: UsageRecord[] = [
rec({ memorySearchBytes: 1000, memorySearchCount: 2 }),
Expand Down