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
32 changes: 28 additions & 4 deletions docs/providers/openclaw.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,30 @@ OpenClaw, plus the older Clawdbot / Moltbot / Moldbot lineage.

## Where it reads from

Four directories, all checked on every run (`openclaw.ts`):
When set, `OPENCLAW_STATE_DIR/agents` is checked first. The environment variable
names the state directory, so CodeBurn appends `agents` itself. Whitespace is
trimmed, relative paths resolve from the current working directory, and a leading
`~` expands against OpenClaw's effective home: `OPENCLAW_HOME`, then `HOME`,
`USERPROFILE`, the Termux home fallback, and finally the OS home. Blank home
values and the literal strings `undefined` and `null` are treated as unset.
If no home candidate resolves, OpenClaw falls back to the current working
directory, and CodeBurn uses that same fallback.
This follows OpenClaw's [state directory resolver](https://github.com/openclaw/openclaw/blob/main/src/config/state-dir.ts)
and [home path resolver](https://github.com/openclaw/openclaw/blob/main/src/infra/home-dir.ts).

Four historical directories are also checked on every run (`openclaw.ts`), under
that same effective home:

- `~/.openclaw/agents`
- `~/.clawdbot/agents`
- `~/.moltbot/agents`
- `~/.moldbot/agents`

The legacy directories are kept for users who upgraded from older builds.
The legacy directories are kept for users who upgraded from older builds. An
override that resolves to one of these roots is scanned once. Discovery and root
probes use the same list, so relocated sessions also receive watcher coverage.
The explicit `createOpenClawProvider(agentsDirectory)` constructor still selects
only its supplied agents directory, taking precedence over environment settings.

## Storage format

Expand All @@ -28,7 +44,15 @@ The SQLite source path carries the session id after the database path (`<db>:<se

## Caching

None.
Parsed sessions use the shared session cache. `OPENCLAW_STATE_DIR`,
`OPENCLAW_HOME`, and their home fallback variables are fingerprinted so changing
the selected roots invalidates that provider's entries. The `state-dir-v1` parser
revision and daily cache v76 backfill
surviving history that earlier versions missed. Archived daily totals continue to
be carried forward when their source logs are unavailable. The daily cache also
records OpenClaw's discovery fingerprint after a complete backfill. Changing
roots revisits finalized days, while an interrupted parse retains the previous
fingerprint and retries the backfill on the next hydration.

## Deduplication

Expand All @@ -46,7 +70,7 @@ When a session id exists both as a legacy `.jsonl` file and in the store (a part

## When fixing a bug here

1. If the bug is "session not found", check the four legacy dirs first. A user might have a stray `~/.moltbot/` that the parser is reading instead of the real `~/.openclaw/`.
1. If the bug is "session not found", check `OPENCLAW_STATE_DIR` and `OPENCLAW_HOME`, then the four historical dirs. A user might have a stray `~/.moltbot/` that the parser is reading instead of the real `~/.openclaw/`.
2. If the bug is "wrong cost", confirm whether `costUSD` is present in the source data; the parser trusts it over its own calculation.
3. The `sessions.json` index can drift when the user crashes mid-session. Make sure the directory-scan fallback triggers in those cases.
4. If a migrated install reports empty, confirm `agent/openclaw-agent.sqlite` exists and has a `transcript_events` table; a DB without that table is skipped silently (it is not an agent-schema store).
Expand Down
23 changes: 18 additions & 5 deletions src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getCodeburnCacheDir, RETIRED_PROVIDER_NAMES } from './cache-dir.js'
import { sweepSupersededCacheFiles } from './cache-sweep.js'
import { coverageFor, coversLocalDay, loadCursorImport } from './cursor-import.js'
import { projectOriginKey } from './git-origin.js'
import { computeEnvFingerprint } from './session-cache.js'
import type { ProjectFilterTarget } from './parser.js'
import type { DateRange, ProjectSummary } from './types.js'

Expand Down Expand Up @@ -361,7 +362,9 @@ import type { DateRange, ProjectSummary } from './types.js'
// copilotCredits, and a chat session that also ran on the agent host is left
// to it. That twin drops calls, so copilot joins
// PENDING_REDERIVE_PROVIDER_VERSIONS at 75.
export const DAILY_CACHE_VERSION = 75
// v76: discover OpenClaw's configured state directory and effective home.
// Finalized days may have omitted those sessions before the root override fix.
export const DAILY_CACHE_VERSION = 76
const MIN_SUPPORTED_VERSION = 28

/// Providers whose per-day CALL COUNT means something different at
Expand Down Expand Up @@ -514,6 +517,9 @@ export type DailyCache = {
/// hash mismatches and `ensureCacheHydrated` re-derives available history,
/// then carries forward slices whose sources are gone.
savingsConfigHash: string
/// OpenClaw discovery roots used by the last complete history backfill.
/// A root change re-derives available history while preserving absent slices.
openclawEnvFingerprint?: string
/// IANA local timezone the days were bucketed under (day boundaries are
/// local-time). If the machine's timezone changes, previously-cached days are
/// bucketed against the wrong midnight, so a mismatch forces a full re-hydrate
Expand Down Expand Up @@ -758,12 +764,13 @@ function pendingRederiveFor(fromVersion: number, parsed: unknown): string[] | un
return pending.size > 0 ? [...pending] : undefined
}

function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache {
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean; watermarkTrusted?: boolean; openclawEnvFingerprint?: unknown }): DailyCache {
const pendingRederive = pendingRederiveFor(parsed.version, parsed)
return {
...(pendingRederive ? { pendingRederive } : {}),
version: DAILY_CACHE_VERSION,
savingsConfigHash: parsed.savingsConfigHash ?? '',
...(typeof parsed.openclawEnvFingerprint === 'string' ? { openclawEnvFingerprint: parsed.openclawEnvFingerprint } : {}),
tzKey: parsed.tzKey,
lastComputedDate: typeof parsed.lastComputedDate === 'string' && DATE_KEY_RE.test(parsed.lastComputedDate)
? parsed.lastComputedDate
Expand Down Expand Up @@ -962,6 +969,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate
return {
version: DAILY_CACHE_VERSION,
savingsConfigHash: cache.savingsConfigHash,
...(cache.openclawEnvFingerprint !== undefined ? { openclawEnvFingerprint: cache.openclawEnvFingerprint } : {}),
tzKey: cache.tzKey,
lastComputedDate: nextLast,
days: applyRetention(merged, newestDate),
Expand Down Expand Up @@ -1790,7 +1798,7 @@ export async function ensureCacheHydrated(
c = { ...c, lastComputedDate: toDateString(pulledBack) }
}

// Three reasons to re-derive the whole retention window:
// Four reasons to re-derive the whole retention window:
// 1. Savings config changed — cached `savingsUSD` totals are stale.
// 2. The cache was never finalized against a COMPLETE session parse (an old
// pre-marker cache, an adoption from older cache files, or one frozen
Expand All @@ -1799,6 +1807,8 @@ export async function ensureCacheHydrated(
// TZ change mis-buckets every cached day. Only invalidate when a tzKey is
// present and differs (a cache written before this field, or a test
// fixture, has none → left alone rather than force a spurious rebuild).
// 4. OpenClaw's selected roots changed since the last complete backfill.
// Session-cache invalidation alone cannot revisit finalized days.
//
// Re-derive, NOT discard. Session files are ephemeral; a cached day whose
// sources are gone exists nowhere else, so the old days stay as a baseline
Expand All @@ -1808,7 +1818,9 @@ export async function ensureCacheHydrated(
// into permanently lost history.
const tzKey = currentTzKey()
const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) {
const openclawEnvFingerprint = computeEnvFingerprint('openclaw')
const openclawEnvChanged = c.openclawEnvFingerprint !== undefined && c.openclawEnvFingerprint !== openclawEnvFingerprint
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged || openclawEnvChanged) {
const baseline = c.days
const priorWatermark = c.lastComputedDate
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
Expand Down Expand Up @@ -1902,6 +1914,7 @@ export async function ensureCacheHydrated(
version: DAILY_CACHE_VERSION,
savingsConfigHash,
tzKey,
...(parseWasComplete ? { openclawEnvFingerprint } : c.openclawEnvFingerprint !== undefined ? { openclawEnvFingerprint: c.openclawEnvFingerprint } : {}),
// Spent: this re-derivation was the one the migration owed those
// providers. A PARTIAL parse never got to use it (its fresh data only
// filled gaps), so the entitlement is kept for the next complete run.
Expand Down Expand Up @@ -1976,7 +1989,7 @@ export async function ensureCacheHydrated(
// the same reason as the re-derive path above: a partial parse cannot
// vouch for the days it never read, and gapStart is the only thing that
// will ever bring them back.
c = { ...c, days: applyRetention(merged, yesterdayStr), lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete }
c = { ...c, ...(parseWasComplete ? { openclawEnvFingerprint } : {}), days: applyRetention(merged, yesterdayStr), lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete }
await saveDailyCache(c)
} else if (c.complete !== true && sessionComplete()) {
// No gap to fill (already current through yesterday) but not yet marked —
Expand Down
55 changes: 55 additions & 0 deletions src/openclaw-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { join, resolve } from 'path'
import { homedir } from 'os'

function normalizeHomeDir(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed && trimmed !== 'undefined' && trimmed !== 'null' ? trimmed : undefined
}

export function getOpenClawDirs(): string[] {
let osHome = normalizeHomeDir(process.env['HOME']) ?? normalizeHomeDir(process.env['USERPROFILE'])
// OpenClaw also supports Termux when neither conventional home variable is
// set. A generic PREFIX alone is not enough to select this fallback.
if (!osHome) {
const prefix = normalizeHomeDir(process.env['PREFIX'])
if (prefix && normalizeHomeDir(process.env['ANDROID_DATA'])
&& /(?:^|\/)com\.termux\/files\/usr\/?$/.test(prefix.replace(/\\/g, '/'))) {
osHome = resolve(prefix, '..', 'home')
}
}
if (!osHome) {
try {
osHome = normalizeHomeDir(homedir())
} catch {
// OpenClaw's required-home resolver falls back to cwd when the OS
// lookup cannot provide a home. Apply the same rule without aborting.
}
}
const expandHome = (path: string, home: string): string => resolve(path.replace(/^~(?=$|[\\/])/, () => home))
// OpenClaw resolves state overrides against its effective home. Trim before
// expanding so a quoted "~/state" works just like its own path resolver.
const configuredHome = normalizeHomeDir(process.env['OPENCLAW_HOME'])
let effectiveHome = configuredHome ?? osHome
if (configuredHome && /^~(?=$|[\\/])/.test(configuredHome)) {
// An unresolved tilde override has no effective home. OpenClaw falls back
// to cwd itself, rather than expanding the remaining suffix under cwd.
effectiveHome = osHome ? expandHome(configuredHome, osHome) : undefined
}
const home = resolve(effectiveHome ?? process.cwd())
const stateDir = process.env['OPENCLAW_STATE_DIR']?.trim()
const roots = [
...(stateDir ? [join(expandHome(stateDir, home), 'agents')] : []),
join(home, '.openclaw', 'agents'),
join(home, '.clawdbot', 'agents'),
join(home, '.moltbot', 'agents'),
join(home, '.moldbot', 'agents'),
]
// Keep the historical roots for pre-migration sessions, but scan an override
// pointing at one of those roots only once (including case aliases on Windows).
const unique = new Map<string, string>()
for (const root of roots) {
const key = process.platform === 'win32' ? root.toLowerCase() : root
if (!unique.has(key)) unique.set(key, root)
}
return [...unique.values()]
}
12 changes: 1 addition & 11 deletions src/providers/openclaw.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readdir, readFile, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { getOpenClawDirs } from '../openclaw-paths.js'
import { createHash } from 'node:crypto'
import zlib from 'zlib'

Expand Down Expand Up @@ -221,16 +221,6 @@ async function* finalizeCalls(
}
}

function getOpenClawDirs(): string[] {
const home = homedir()
return [
join(home, '.openclaw', 'agents'),
join(home, '.clawdbot', 'agents'),
join(home, '.moltbot', 'agents'),
join(home, '.moldbot', 'agents'),
]
}

function extractTools(content: Array<{ type?: string; name?: string; arguments?: Record<string, unknown> }> | undefined): { tools: string[]; bashCommands: string[] } {
const tools: string[] = []
const bashCommands: string[] = []
Expand Down
8 changes: 7 additions & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { acquireCacheRefreshLock, releaseOwnedRefreshLocksForExit } from './cach
import { parseBillingMode, type BillingMode } from './models.js'
import type { ToolCall } from './types.js'
import { isWslUncPath } from './wsl.js'
import { getOpenClawDirs } from './openclaw-paths.js'

// ── Types ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -315,6 +316,7 @@ export const PROVIDER_ENV_VARS: Record<string, string[]> = {
'cursor-agent': ['XDG_DATA_HOME'],
'open-design': ['CODEBURN_OPEN_DESIGN_DIR', 'APPDATA'],
openclaude: ['CODEBURN_OPENCLAUDE_DIR'],
openclaw: ['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME', 'HOME', 'USERPROFILE', 'PREFIX', 'ANDROID_DATA'],
opencode: ['XDG_DATA_HOME', 'OPENCODE_DATA_DIR', 'OPENCODE_DB_PREFIX'],
goose: ['XDG_DATA_HOME', 'GOOSE_PATH_ROOT'],
grok: ['GROK_HOME'],
Expand Down Expand Up @@ -524,7 +526,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// sqlite-store-v1: id-less dedup keys now carry an occurrence index
// (`h:<hash>:<n>`) instead of the bare payload hash; cached turns hold the
// old keys, so without this bump they would suppress the re-parsed calls.
openclaw: 'reported-cost-v1-sqlite-store-v1',
openclaw: 'reported-cost-v1-sqlite-store-v1-state-dir-v1',
// sessions-db-v1: usage now comes from sessions.db message_nodes, one
// source per session keyed by request_id; transcripts are read only when the
// database is unusable. The legacy metadata.metrics path no longer carves
Expand Down Expand Up @@ -1036,6 +1038,10 @@ export function computeEnvFingerprint(provider: string): string {
const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`)
const parseVersion = PROVIDER_PARSE_VERSIONS[provider]
if (parseVersion) parts.push(`parser=${parseVersion}`)
// Relative overrides and the no-home fallback resolve from cwd. Hash the
// actual roots so changing cwd invalidates OpenClaw without invalidating
// absolute-root installations on every launch from another project.
if (provider === 'openclaw') parts.push(`roots=${JSON.stringify(getOpenClawDirs())}`)
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
}

Expand Down
Loading
Loading