Files
orca/src/main/muse-usage/scanner.ts
T
Neil eb18eaf2b6 feat(usage): add Muse Code local usage provider (#22379)
* feat(usage): add Muse Code local usage provider

Scan Muse session logs (including subagent logs, which hold usage the parent
log does not) for model_completed token events and surface them as a fourth
local usage provider: shared scan worker, persisted per-file cache reused by
mtime/size, cross-log dedupe, Stats tab, and Usage Overview integration.
Muse logs carry no price, so the provider reports tokens only.

* fix(usage): name Muse in Stats & Usage copy; skip partial-cost warning when nothing is priced

* fix(usage): surface unreadable Muse sessions root; name Muse in remaining Stats & Usage copy

* fix(usage): count distinct same-content Muse records within one log
2026-09-22 22:44:10 -07:00

233 lines
8.0 KiB
TypeScript

import { stat } from 'node:fs/promises'
import { attributeUsageEvent } from '../usage/usage-event-attribution'
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
import { readJsonlLinesFromOffset } from '../usage/jsonl-line-offsets'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
import {
createUsageWorktreeResolver,
type UsageWorktreeResolver
} from '../usage/usage-worktree-resolver'
import { listMuseSessionLogFiles, type MuseSessionLogRef } from './muse-session-log-discovery'
import { parseMuseUsageLine, type MuseUsageParseContext } from './muse-usage-record-parser'
import type {
MuseUsageAttributedEvent,
MuseUsageDailyAggregate,
MuseUsageMetric,
MuseUsagePersistedFile,
MuseUsageProcessedFile,
MuseUsageSession
} from './types'
const YIELD_EVERY_FILES = 10
export const museUsageAggregation = createUsageEventAggregation<
MuseUsageAttributedEvent,
MuseUsageMetric
>({
metric: { empty: () => ({}), fromEvent: () => ({}), fold: () => {} },
cloneSessionForMerge: (session) => ({
...session,
locationBreakdown: session.locationBreakdown.map((entry) => ({ ...entry })),
modelBreakdown: session.modelBreakdown.map((entry) => ({ ...entry })),
locationModelBreakdown: session.locationModelBreakdown.map((entry) => ({ ...entry }))
})
})
type MuseSessionLogInfo = MuseSessionLogRef & MuseUsageProcessedFile
type MuseUsageFileParseInput = {
info: MuseUsageProcessedFile
sessionId: string
inheritedCwd: string | null
resolveWorktree: UsageWorktreeResolver
claimEventKey: (eventKey: string) => boolean
}
async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve))
}
async function statSessionLog(ref: MuseSessionLogRef): Promise<MuseSessionLogInfo | null> {
try {
const fileStat = await stat(ref.path)
return fileStat.isFile() ? { ...ref, mtimeMs: fileStat.mtimeMs, size: fileStat.size } : null
} catch {
// Session dirs exist before their first log write, and `--no-session-log` never writes one.
return null
}
}
export async function parseMuseUsageFile(
input: MuseUsageFileParseInput
): Promise<MuseUsagePersistedFile> {
const context: MuseUsageParseContext = {
sessionId: input.sessionId,
cwd: input.inheritedCwd,
currentModel: null
}
const events: MuseUsageAttributedEvent[] = []
const ownedEventKeys = new Set<string>()
const occurrencesByContentKey = new Map<string, number>()
let hasDeferredClaims = false
// A partial tail line fails JSON.parse; the size change makes the next scan reparse it.
for await (const { line } of readJsonlLinesFromOffset(input.info.path, 0)) {
for (const parsed of parseMuseUsageLine(line, context)) {
// Why: distinct records in one log can share content; the ordinal keeps them apart
// while a fork's copy of the same sequence still maps onto identical keys.
const occurrence = occurrencesByContentKey.get(parsed.eventKey) ?? 0
occurrencesByContentKey.set(parsed.eventKey, occurrence + 1)
const eventKey = `${parsed.eventKey}#${occurrence}`
if (!input.claimEventKey(eventKey)) {
hasDeferredClaims = true
continue
}
ownedEventKeys.add(eventKey)
const attributed = attributeUsageEvent(parsed, input.resolveWorktree)
if (attributed) {
events.push(attributed)
}
}
}
return {
path: input.info.path,
mtimeMs: input.info.mtimeMs,
size: input.info.size,
...museUsageAggregation.aggregate(events),
ownedEventKeys: [...ownedEventKeys],
hasDeferredClaims,
sessionCwd: context.cwd,
inheritedCwd: input.inheritedCwd
}
}
export async function scanMuseUsageFiles(
worktrees: UsageScanWorktreeRef[],
previousProcessedFiles: MuseUsagePersistedFile[],
onFilesScanned?: (count: number) => void,
sessionsDir?: string
): Promise<{
processedFiles: MuseUsagePersistedFile[]
sessions: MuseUsageSession[]
dailyAggregates: MuseUsageDailyAggregate[]
}> {
const refs = await listMuseSessionLogFiles(sessionsDir)
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
const resolveWorktree = await createUsageWorktreeResolver(worktrees)
const logs: MuseSessionLogInfo[] = []
for (const [index, ref] of refs.entries()) {
const info = await statSessionLog(ref)
if (info) {
logs.push(info)
}
if ((index + 1) % YIELD_EVERY_FILES === 0) {
await yieldToEventLoop()
}
}
const currentPaths = new Set(logs.map((log) => log.path))
// Why: a deleted owner leaves its events unowned; only files that deferred to it must reclaim.
const lostOwnerPath = previousProcessedFiles.some(
(file) => !currentPaths.has(file.path) && file.ownedEventKeys.length > 0
)
const eventOwnerByKey = new Map<string, string>()
const claimFor =
(path: string) =>
(eventKey: string): boolean => {
const owner = eventOwnerByKey.get(eventKey)
if (owner !== undefined && owner !== path) {
return false
}
eventOwnerByKey.set(eventKey, path)
return true
}
const isStatUnchanged = (log: MuseSessionLogInfo): boolean => {
const previous = previousByPath.get(log.path)
const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false
return (
previous !== undefined &&
!mustReclaimDeferred &&
previous.mtimeMs === log.mtimeMs &&
previous.size === log.size
)
}
// Retained claims register before any parse so a reparsed file cannot steal them.
for (const log of logs) {
if (isStatUnchanged(log)) {
for (const eventKey of previousByPath.get(log.path)?.ownedEventKeys ?? []) {
if (!eventOwnerByKey.has(eventKey)) {
eventOwnerByKey.set(eventKey, log.path)
}
}
}
}
const processedByPath = new Map<string, MuseUsagePersistedFile>()
const cwdBySession = new Map<string, string | null>()
let parsedCount = 0
// Why: subagent logs carry no workspace, so parents resolve first and lend theirs.
for (const pass of [false, true]) {
const passLogs = logs.filter((log) => log.isSubagent === pass)
const toParse: { log: MuseSessionLogInfo; inheritedCwd: string | null }[] = []
for (const log of passLogs) {
const inheritedCwd = log.isSubagent ? (cwdBySession.get(log.sessionId) ?? null) : null
const previous = previousByPath.get(log.path)
// Why: only live sessions grow, so a refresh pays one stat per finished log.
if (previous && isStatUnchanged(log) && previous.inheritedCwd === inheritedCwd) {
processedByPath.set(log.path, previous)
} else {
toParse.push({ log, inheritedCwd })
}
}
for (const { log, inheritedCwd } of toParse) {
try {
processedByPath.set(
log.path,
await parseMuseUsageFile({
info: log,
sessionId: log.sessionId,
inheritedCwd,
resolveWorktree,
claimEventKey: claimFor(log.path)
})
)
} catch {
// Unreadable or vanished mid-scan; the next scan retries it.
}
onFilesScanned?.(1)
parsedCount++
if (parsedCount % YIELD_EVERY_FILES === 0) {
await yieldToEventLoop()
}
}
if (!pass) {
for (const log of passLogs) {
cwdBySession.set(log.sessionId, processedByPath.get(log.path)?.sessionCwd ?? null)
}
}
}
const processedFiles: MuseUsagePersistedFile[] = []
const sessionsById = new Map<string, MuseUsageSession>()
const dailyByKey = new Map<string, MuseUsageDailyAggregate>()
for (const log of logs) {
const processed = processedByPath.get(log.path)
if (!processed) {
continue
}
processedFiles.push(processed)
museUsageAggregation.mergeSessions(sessionsById, processed.sessions)
museUsageAggregation.mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
}
return {
processedFiles,
sessions: museUsageAggregation.finalizeSessions(sessionsById),
dailyAggregates: museUsageAggregation.sortDailyAggregates(dailyByKey)
}
}