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
28 changes: 21 additions & 7 deletions packages/agentctx/src/extract/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,13 @@ async function extract(

const db = openDatabase(env.dbPath);
try {
recordCost(db, sessionId, totalCost);
const projectId = sessionProjectId(db, sessionId) ?? resolveProjectId(env.cwd);
recordCost(db, sessionId, projectId, totalCost);
const result = parseExtraction(responseText);
if (result === null) {
log(`extract: unparseable model output for session ${sessionId} — skipped`);
return 0;
}
const projectId = sessionProjectId(db, sessionId) ?? resolveProjectId(env.cwd);
const stats = ingestExtraction(db, projectId, sessionId, result, log);
logStats(log, sessionId, stats, totalCost, result.flushOk);
} finally {
Expand All @@ -135,13 +135,27 @@ async function extract(
return 0;
}

/** Self-accounting (SPEC §3.1, §7): cost lands on the session row. */
function recordCost(db: ReturnType<typeof openDatabase>, sessionId: string, cost: number): void {
/**
* Self-accounting (SPEC §3.1, §7): cost lands on the session row, attributed to
* its project. When the cost row is the session's first DB write (a sparse
* session that never triggered a SessionStart/UserPromptSubmit injection),
* `project_id` would otherwise stay NULL and `status` would drop the cost from
* the per-project total. Fill it here, but never clobber a value an injection
* already set.
*/
function recordCost(
db: ReturnType<typeof openDatabase>,
sessionId: string,
projectId: string,
cost: number,
): void {
db.prepare(
`INSERT INTO sessions (session_id, extraction_cost_usd) VALUES (@sessionId, @cost)
`INSERT INTO sessions (session_id, project_id, extraction_cost_usd)
VALUES (@sessionId, @projectId, @cost)
ON CONFLICT(session_id) DO UPDATE SET
extraction_cost_usd = extraction_cost_usd + @cost`,
).run({ sessionId, cost });
extraction_cost_usd = extraction_cost_usd + @cost,
project_id = COALESCE(project_id, @projectId)`,
).run({ sessionId, projectId, cost });
}

function sessionProjectId(db: ReturnType<typeof openDatabase>, sessionId: string): string | null {
Expand Down
18 changes: 18 additions & 0 deletions packages/agentctx/test/extract/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ describe("agentctx extract", () => {
}
});

it("attributes the cost to the project even with no prior injection", async () => {
// Sparse session: the cost row is the first DB write (no SessionStart /
// UserPromptSubmit injection ever ran), so project_id must be filled here
// or `status` drops the cost from the per-project total (#66).
const calls: MockCall[] = [];
expect(await run({ fetchFn: mockFetch(calls), apiKey: "key" })).toBe(0);

const db = openDatabase(t.env.dbPath);
try {
const session = db
.prepare("SELECT project_id FROM sessions WHERE session_id = 's1'")
.get() as { project_id: string | null };
expect(session.project_id).toBe(resolveProjectId(t.env.cwd));
} finally {
db.close();
}
});

it("degrades cleanly without an API key (SPEC §8 rung 3)", async () => {
const calls: MockCall[] = [];
expect(await run({ fetchFn: mockFetch(calls), apiKey: undefined })).toBe(0);
Expand Down
Loading