From c4bbdf53dbbb9a6a9d1583a7ebdbc53c361b1f5d Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:44:06 +0600 Subject: [PATCH 01/12] Test relocated OpenClaw state directories --- tests/providers/openclaw.test.ts | 110 +++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 4 deletions(-) diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts index 703ae489c..7e5f09e28 100644 --- a/tests/providers/openclaw.test.ts +++ b/tests/providers/openclaw.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, afterAll, vi } from 'vitest' +import { describe, it, expect, afterAll, beforeEach, afterEach, vi } from 'vitest' import { createOpenClawProvider } from '../../src/providers/openclaw.js' import { isSqliteBusyError } from '../../src/sqlite.js' -import { writeFile, mkdir, rm, stat } from 'fs/promises' +import { writeFile, mkdir, rm, stat, mkdtemp } from 'fs/promises' import { mkdirSync } from 'node:fs' -import { join } from 'path' -import { tmpdir } from 'os' +import { join, relative } from 'path' +import { tmpdir, homedir } from 'os' import { createRequire } from 'node:module' import zlib from 'node:zlib' @@ -670,3 +670,105 @@ describe('openclaw provider', () => { await rm(baseDir, { recursive: true, force: true }) }) }) + +describe('OpenClaw state directory discovery', () => { + let root: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'codeburn-openclaw-state-')) + vi.stubEnv('HOME', join(root, 'home')) + vi.stubEnv('USERPROFILE', join(root, 'home')) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + }) + + it('discovers and parses JSONL beneath OPENCLAW_STATE_DIR/agents', async () => { + const state = join(root, 'custom state') + const path = await setupFixture(join(state, 'agents'), 'custom-agent', 'custom-session', SESSION_LINES) + vi.stubEnv('OPENCLAW_STATE_DIR', state) + const provider = createOpenClawProvider() + const sources = await provider.discoverSessions() + expect(sources.map(s => s.path)).toEqual([path]) + expect(await provider.probeRoots!()).toContainEqual({ path: join(state, 'agents'), label: 'agents' }) + const calls = await parseAll(provider, sources[0], new Set()) + expect(calls).toHaveLength(2) + expect(calls[0].usage.inputTokens).toBe(500) + expect(calls[1].usage.outputTokens).toBe(200) + }) + + it('discovers the SQLite store under the same relocated agents root', async () => { + const state = join(root, 'sqlite-state') + const events = sessionEvents() + const db = createAgentDb(join(state, 'agents'), 'custom-agent', [{ + sessionId: 'test-sess-1', + rows: [events.session, events.modelChange, events.user, events.assistant].map((event, seq) => ({ + seq, event, createdAt: Date.parse('2026-04-20T10:00:03Z'), + })), + }]) + vi.stubEnv('OPENCLAW_STATE_DIR', state) + const provider = createOpenClawProvider() + const sources = await provider.discoverSessions() + expect(sources.map(s => s.path)).toEqual([`${db}:test-sess-1`]) + expect(await parseAll(provider, sources[0], new Set())).toHaveLength(1) + }) + + it('keeps legacy home roots alongside the relocated state directory', async () => { + const state = join(root, 'custom-state') + const custom = await setupFixture(join(state, 'agents'), 'custom', 'custom', SESSION_LINES) + const legacy = await setupFixture(join(homedir(), '.clawdbot', 'agents'), 'old', 'old', SESSION_LINES) + vi.stubEnv('OPENCLAW_STATE_DIR', state) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([custom, legacy]) + }) + + it.each(['', ' '])('uses default roots when the override is blank (%j)', async value => { + vi.stubEnv('OPENCLAW_STATE_DIR', value) + const path = await setupFixture(join(homedir(), '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([path]) + expect(await createOpenClawProvider().probeRoots!()).toHaveLength(4) + }) + + it('trims the override and expands a leading home prefix', async () => { + vi.stubEnv('OPENCLAW_STATE_DIR', ' ~/custom-state ') + const path = await setupFixture(join(homedir(), 'custom-state', 'agents'), 'custom', 'custom', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([path]) + }) + + it('resolves relative overrides consistently for discovery and probing', async () => { + const state = join(root, 'relative-state') + vi.stubEnv('OPENCLAW_STATE_DIR', relative(process.cwd(), state)) + const path = await setupFixture(join(state, 'agents'), 'custom', 'custom', SESSION_LINES) + const provider = createOpenClawProvider() + expect((await provider.discoverSessions()).map(s => s.path)).toEqual([path]) + expect((await provider.probeRoots!())[0].path).toBe(join(state, 'agents')) + }) + + it('deduplicates an override that resolves to the default state root', async () => { + vi.stubEnv('OPENCLAW_STATE_DIR', join(homedir(), '.openclaw', '.')) + const path = await setupFixture(join(homedir(), '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + const provider = createOpenClawProvider() + expect((await provider.discoverSessions()).map(s => s.path)).toEqual([path]) + expect(await provider.probeRoots!()).toHaveLength(4) + }) + + it('preserves the explicit agents-directory constructor override', async () => { + vi.stubEnv('OPENCLAW_STATE_DIR', join(root, 'ignored-state')) + const agents = join(root, 'explicit-agents') + const path = await setupFixture(agents, 'explicit', 'explicit', SESSION_LINES) + const provider = createOpenClawProvider(agents) + expect((await provider.discoverSessions()).map(s => s.path)).toEqual([path]) + expect(await provider.probeRoots!()).toEqual([{ path: agents, label: 'agents' }]) + }) + + it('honors OPENCLAW_HOME for the default state root and home-relative override', async () => { + const home = join(root, 'openclaw-home') + vi.stubEnv('OPENCLAW_HOME', home) + const defaultPath = await setupFixture(join(home, '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([defaultPath]) + vi.stubEnv('OPENCLAW_STATE_DIR', '~/relocated') + const custom = await setupFixture(join(home, 'relocated', 'agents'), 'custom', 'custom', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([custom, defaultPath]) + }) +}) From da3aef47bcb5944b4efff2ec69044293b1683151 Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:49:22 +0600 Subject: [PATCH 02/12] Honor OpenClaw state and home overrides in discovery --- src/providers/openclaw.ts | 21 ++++++++++++++++++--- src/session-cache.ts | 3 ++- tests/provider-env-declarations.test.ts | 1 + tests/providers/openclaw.test.ts | 4 ++-- tests/setup/env-isolation-vars.ts | 2 ++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts index dc82f78fb..f31f5de7e 100644 --- a/src/providers/openclaw.ts +++ b/src/providers/openclaw.ts @@ -1,5 +1,5 @@ import { readdir, readFile, stat } from 'fs/promises' -import { basename, join } from 'path' +import { basename, join, resolve } from 'path' import { homedir } from 'os' import { createHash } from 'node:crypto' import zlib from 'zlib' @@ -222,13 +222,28 @@ async function* finalizeCalls( } function getOpenClawDirs(): string[] { - const home = homedir() - return [ + const osHome = homedir() + 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 = process.env['OPENCLAW_HOME']?.trim() + const home = configuredHome ? expandHome(configuredHome, osHome) : osHome + 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() + for (const root of roots) { + const key = process.platform === 'win32' ? root.toLowerCase() : root + if (!unique.has(key)) unique.set(key, root) + } + return [...unique.values()] } function extractTools(content: Array<{ type?: string; name?: string; arguments?: Record }> | undefined): { tools: string[]; bashCommands: string[] } { diff --git a/src/session-cache.ts b/src/session-cache.ts index 65c65ba4f..941856740 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -315,6 +315,7 @@ export const PROVIDER_ENV_VARS: Record = { 'cursor-agent': ['XDG_DATA_HOME'], 'open-design': ['CODEBURN_OPEN_DESIGN_DIR', 'APPDATA'], openclaude: ['CODEBURN_OPENCLAUDE_DIR'], + openclaw: ['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME'], opencode: ['XDG_DATA_HOME', 'OPENCODE_DATA_DIR', 'OPENCODE_DB_PREFIX'], goose: ['XDG_DATA_HOME', 'GOOSE_PATH_ROOT'], grok: ['GROK_HOME'], @@ -524,7 +525,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // sqlite-store-v1: id-less dedup keys now carry an occurrence index // (`h::`) 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 diff --git a/tests/provider-env-declarations.test.ts b/tests/provider-env-declarations.test.ts index 7c585e021..1bb60489b 100644 --- a/tests/provider-env-declarations.test.ts +++ b/tests/provider-env-declarations.test.ts @@ -45,6 +45,7 @@ const FILE_PROVIDERS: Record = { // The ENV_DIR const (open-design.ts:10) resolves to CODEBURN_OPEN_DESIGN_DIR. 'open-design.ts': ['open-design'], 'openclaude.ts': ['openclaude'], + 'openclaw.ts': ['openclaw'], 'opencode.ts': ['opencode'], 'goose.ts': ['goose'], 'grok.ts': ['grok'], diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts index 7e5f09e28..26de6b62c 100644 --- a/tests/providers/openclaw.test.ts +++ b/tests/providers/openclaw.test.ts @@ -695,8 +695,8 @@ describe('OpenClaw state directory discovery', () => { expect(await provider.probeRoots!()).toContainEqual({ path: join(state, 'agents'), label: 'agents' }) const calls = await parseAll(provider, sources[0], new Set()) expect(calls).toHaveLength(2) - expect(calls[0].usage.inputTokens).toBe(500) - expect(calls[1].usage.outputTokens).toBe(200) + expect(calls[0].inputTokens).toBe(500) + expect(calls[1].outputTokens).toBe(200) }) it('discovers the SQLite store under the same relocated agents root', async () => { diff --git a/tests/setup/env-isolation-vars.ts b/tests/setup/env-isolation-vars.ts index 0407b1b6c..a66443f02 100644 --- a/tests/setup/env-isolation-vars.ts +++ b/tests/setup/env-isolation-vars.ts @@ -47,6 +47,8 @@ export const CLEARED = [ 'LINGTAI_TUI_HOME', 'MUX_ROOT', 'OPENCODE_DATA_DIR', + 'OPENCLAW_STATE_DIR', + 'OPENCLAW_HOME', 'OPENCODE_DB_PREFIX', 'QUICKWORK_HOME', 'QWEN_DATA_DIR', From 09398e25b2c58ced14bb7cdd52bc5ca6137c02a0 Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:49:22 +0600 Subject: [PATCH 03/12] Backfill relocated OpenClaw history and document discovery --- docs/providers/openclaw.md | 24 +++++- src/daily-cache.ts | 4 +- tests/openclaw-discovery-cache.test.ts | 100 +++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 tests/openclaw-discovery-cache.test.ts diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md index cd0f29071..ad0491d23 100644 --- a/docs/providers/openclaw.md +++ b/docs/providers/openclaw.md @@ -8,14 +8,26 @@ 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`, or the OS home). +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 @@ -28,7 +40,11 @@ The SQLite source path carries the session id after the database path (`: { + clearSessionCache() + vi.unstubAllEnvs() +}) + +async function seed(state: string, id: string, input: number, timestamp: string): Promise { + const sessions = join(state, 'agents', 'fixture-agent', 'sessions') + await mkdir(sessions, { recursive: true }) + const events = [ + { type: 'session', version: 3, id, timestamp, cwd: '/fixture/project' }, + { type: 'message', id: `${id}-user`, timestamp, message: { role: 'user', content: [{ type: 'text', text: 'Fix the fixture' }] } }, + { type: 'message', id: `${id}-reply`, timestamp, message: { + role: 'assistant', model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'Done' }], + usage: { input, output: 20, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }, + } }, + ] + await writeFile(join(sessions, `${id}.jsonl`), events.map(event => JSON.stringify(event)).join('\n') + '\n') +} + +describe('OpenClaw discovery cache invalidation', () => { + it.each(['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME'])('fingerprints changes to %s', name => { + const before = computeEnvFingerprint('openclaw') + const unrelated = computeEnvFingerprint('claude') + vi.stubEnv(name, '/fixture/relocated') + expect(computeEnvFingerprint('openclaw')).not.toBe(before) + expect(computeEnvFingerprint('claude')).toBe(unrelated) + }) + + it('rediscovers the selected state directory after a warm parse', async () => { + const root = await mkdtemp(join(tmpdir(), 'openclaw-warm-root-')) + try { + vi.stubEnv('CODEBURN_CACHE_DIR', join(root, 'cache')) + const first = join(root, 'first') + const second = join(root, 'second') + const timestamp = '2026-04-20T10:00:00Z' + await seed(first, 'first', 100, timestamp) + await seed(second, 'second', 200, timestamp) + vi.stubEnv('OPENCLAW_STATE_DIR', first) + const initial = aggregateProjectsIntoDays(await parseAllSessions(undefined, 'openclaw')) + expect(initial[0]).toMatchObject({ calls: 1, inputTokens: 100, outputTokens: 20 }) + vi.stubEnv('OPENCLAW_STATE_DIR', second) + // A new CLI invocation has no in-process report memo, but retains the + // disk session cache produced under the previous environment. + clearSessionCache() + const changed = aggregateProjectsIntoDays(await parseAllSessions(undefined, 'openclaw')) + expect(changed[0]).toMatchObject({ calls: 1, inputTokens: 200, outputTokens: 20 }) + expect(changed).toHaveLength(1) + } finally { + clearSessionCache() + await rm(root, { recursive: true, force: true }) + } + }) + + it('backfills relocated history from a finalized v51 daily cache and retains archived usage', async () => { + const root = await mkdtemp(join(tmpdir(), 'openclaw-daily-backfill-')) + try { + const cache = join(root, 'cache') + const state = join(root, 'state') + vi.stubEnv('CODEBURN_CACHE_DIR', cache) + vi.stubEnv('OPENCLAW_STATE_DIR', state) + const timestamp = new Date(Date.now() - 4 * 86400000).toISOString() + const date = toDateString(new Date(timestamp)) + await seed(state, 'relocated', 100, timestamp) + const parse = () => parseAllSessions(undefined, 'openclaw') + const fresh = await parse() + expect(aggregateProjectsIntoDays(fresh)[0]).toMatchObject({ date, calls: 1, inputTokens: 100 }) + + // Simulate an old finalized day with another provider's unavailable log. + const archived = structuredClone(fresh) + for (const project of archived) for (const session of project.sessions) for (const turn of session.turns) for (const call of turn.assistantCalls) { + call.provider = 'claude' + call.usage.inputTokens = 50 + call.costUSD = 0.02 + } + await mkdir(cache, { recursive: true }) + await writeFile(join(cache, 'daily-cache.v51.json'), JSON.stringify({ + version: 51, savingsConfigHash: '', tzKey: currentTzKey(), + lastComputedDate: toDateString(new Date(Date.now() - 86400000)), + days: aggregateProjectsIntoDays(archived), complete: true, watermarkTrusted: true, + })) + const hydrated = await ensureCacheHydrated(parse, aggregateProjectsIntoDays) + const day = hydrated.days.find(d => d.date === date)! + expect(day).toMatchObject({ calls: 2, inputTokens: 150, outputTokens: 40 }) + expect(day.providers.openclaw).toMatchObject({ calls: 1, inputTokens: 100, cost: 0.01 }) + expect(day.providers.claude).toMatchObject({ calls: 1, inputTokens: 50, cost: 0.02 }) + } finally { + clearSessionCache() + await rm(root, { recursive: true, force: true }) + } + }) +}) From 5eda792e00c50badcab78b06720c98bdbb2e66f6 Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:52:42 +0600 Subject: [PATCH 04/12] Cover mirrored OpenClaw roots and Windows path aliases --- tests/providers/openclaw.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts index 26de6b62c..6214f9c56 100644 --- a/tests/providers/openclaw.test.ts +++ b/tests/providers/openclaw.test.ts @@ -762,6 +762,29 @@ describe('OpenClaw state directory discovery', () => { expect(await provider.probeRoots!()).toEqual([{ path: agents, label: 'agents' }]) }) + it('counts a session mirrored in custom and legacy roots only once', async () => { + const state = join(root, 'custom-state') + vi.stubEnv('OPENCLAW_STATE_DIR', state) + await setupFixture(join(state, 'agents'), 'custom', 'mirror', SESSION_LINES) + await setupFixture(join(homedir(), '.clawdbot', 'agents'), 'legacy', 'mirror', SESSION_LINES) + const provider = createOpenClawProvider() + const seen = new Set() + const counts: number[] = [] + for (const source of await provider.discoverSessions()) { + counts.push((await parseAll(provider, source, seen)).length) + } + expect(counts).toEqual([2, 0]) + }) + + it.skipIf(process.platform !== 'win32')('deduplicates Windows case aliases of the default state root', async () => { + const state = join(homedir(), '.openclaw') + vi.stubEnv('OPENCLAW_STATE_DIR', state.toUpperCase()) + await setupFixture(join(state, 'agents'), 'default', 'default', SESSION_LINES) + const provider = createOpenClawProvider() + expect(await provider.probeRoots!()).toHaveLength(4) + expect(await provider.discoverSessions()).toHaveLength(1) + }) + it('honors OPENCLAW_HOME for the default state root and home-relative override', async () => { const home = join(root, 'openclaw-home') vi.stubEnv('OPENCLAW_HOME', home) From 0b66b316b408d30af12862a99b6706fd073bf584 Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:06:52 +0600 Subject: [PATCH 05/12] Match OpenClaw home precedence and unset placeholders --- docs/providers/openclaw.md | 11 ++++--- src/providers/openclaw.ts | 21 ++++++++++++-- src/session-cache.ts | 2 +- tests/openclaw-discovery-cache.test.ts | 4 +-- tests/providers/openclaw.test.ts | 40 ++++++++++++++++++++++++++ tests/setup/env-isolation-vars.ts | 2 ++ 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md index ad0491d23..21c81c2ef 100644 --- a/docs/providers/openclaw.md +++ b/docs/providers/openclaw.md @@ -11,7 +11,9 @@ OpenClaw, plus the older Clawdbot / Moltbot / Moldbot lineage. 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`, or the OS home). +`~` 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. 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). @@ -40,9 +42,10 @@ The SQLite source path carries the session id after the database path (`: 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 = process.env['OPENCLAW_HOME']?.trim() - const home = configuredHome ? expandHome(configuredHome, osHome) : osHome + const configuredHome = normalizeHomeDir(process.env['OPENCLAW_HOME']) + const home = configuredHome ? expandHome(configuredHome, osHome) : resolve(osHome) const stateDir = process.env['OPENCLAW_STATE_DIR']?.trim() const roots = [ ...(stateDir ? [join(expandHome(stateDir, home), 'agents')] : []), diff --git a/src/session-cache.ts b/src/session-cache.ts index 941856740..2018e2d55 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -315,7 +315,7 @@ export const PROVIDER_ENV_VARS: Record = { 'cursor-agent': ['XDG_DATA_HOME'], 'open-design': ['CODEBURN_OPEN_DESIGN_DIR', 'APPDATA'], openclaude: ['CODEBURN_OPENCLAUDE_DIR'], - openclaw: ['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME'], + 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'], diff --git a/tests/openclaw-discovery-cache.test.ts b/tests/openclaw-discovery-cache.test.ts index e51d2b6ff..6ec2209be 100644 --- a/tests/openclaw-discovery-cache.test.ts +++ b/tests/openclaw-discovery-cache.test.ts @@ -27,12 +27,10 @@ async function seed(state: string, id: string, input: number, timestamp: string) } describe('OpenClaw discovery cache invalidation', () => { - it.each(['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME'])('fingerprints changes to %s', name => { + it.each(['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME', 'HOME', 'USERPROFILE', 'PREFIX', 'ANDROID_DATA'])('fingerprints changes to %s', name => { const before = computeEnvFingerprint('openclaw') - const unrelated = computeEnvFingerprint('claude') vi.stubEnv(name, '/fixture/relocated') expect(computeEnvFingerprint('openclaw')).not.toBe(before) - expect(computeEnvFingerprint('claude')).toBe(unrelated) }) it('rediscovers the selected state directory after a warm parse', async () => { diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts index 6214f9c56..21397e263 100644 --- a/tests/providers/openclaw.test.ts +++ b/tests/providers/openclaw.test.ts @@ -794,4 +794,44 @@ describe('OpenClaw state directory discovery', () => { const custom = await setupFixture(join(home, 'relocated', 'agents'), 'custom', 'custom', SESSION_LINES) expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([custom, defaultPath]) }) + + it('prefers HOME to USERPROFILE when OpenClaw resolves its home', async () => { + const home = join(root, 'shell-home') + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', join(root, 'profile-home')) + const path = await setupFixture(join(home, '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([path]) + vi.stubEnv('OPENCLAW_STATE_DIR', '~/relocated') + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(home, 'relocated', 'agents')) + }) + + it.each(['', ' ', ' undefined ', ' null '])('treats an unset OPENCLAW_HOME placeholder (%j) as absent', async value => { + vi.stubEnv('OPENCLAW_HOME', value) + const path = await setupFixture(join(homedir(), '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([path]) + }) + + it('expands OPENCLAW_HOME against the OS home fallback chain', async () => { + const home = join(root, 'shell-home') + vi.stubEnv('HOME', home) + vi.stubEnv('OPENCLAW_HOME', ' ~/service ') + vi.stubEnv('OPENCLAW_STATE_DIR', '~/state') + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(home, 'service', 'state', 'agents')) + }) + + it.each(['undefined', 'null', ''])('falls back to USERPROFILE for an unset HOME value (%j)', async value => { + const profile = join(root, 'profile-home') + vi.stubEnv('HOME', value) + vi.stubEnv('USERPROFILE', profile) + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(profile, '.openclaw', 'agents')) + }) + + it('supports OpenClaw\'s Termux home fallback before the OS resolver', async () => { + vi.stubEnv('HOME', '') + vi.stubEnv('USERPROFILE', '') + const termux = join(root, 'com.termux', 'files') + vi.stubEnv('PREFIX', join(termux, 'usr')) + vi.stubEnv('ANDROID_DATA', '/data') + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(termux, 'home', '.openclaw', 'agents')) + }) }) diff --git a/tests/setup/env-isolation-vars.ts b/tests/setup/env-isolation-vars.ts index a66443f02..47b455ac9 100644 --- a/tests/setup/env-isolation-vars.ts +++ b/tests/setup/env-isolation-vars.ts @@ -49,6 +49,8 @@ export const CLEARED = [ 'OPENCODE_DATA_DIR', 'OPENCLAW_STATE_DIR', 'OPENCLAW_HOME', + 'PREFIX', + 'ANDROID_DATA', 'OPENCODE_DB_PREFIX', 'QUICKWORK_HOME', 'QWEN_DATA_DIR', From 3f2e674f0e9524652aac8a70a3dfdb9dcdea2a81 Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:15:17 +0600 Subject: [PATCH 06/12] Handle unavailable OpenClaw home lookups --- docs/providers/openclaw.md | 2 ++ src/providers/openclaw.ts | 10 +++++++++- tests/providers/openclaw.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md index 21c81c2ef..9f7e1cf71 100644 --- a/docs/providers/openclaw.md +++ b/docs/providers/openclaw.md @@ -14,6 +14,8 @@ trimmed, relative paths resolve from the current working directory, and a leadin `~` 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). diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts index a53a7953c..baea59cce 100644 --- a/src/providers/openclaw.ts +++ b/src/providers/openclaw.ts @@ -237,7 +237,15 @@ function getOpenClawDirs(): string[] { osHome = resolve(prefix, '..', 'home') } } - osHome ??= homedir() + 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. + } + } + osHome ??= process.cwd() 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. diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts index 21397e263..bd0df1b88 100644 --- a/tests/providers/openclaw.test.ts +++ b/tests/providers/openclaw.test.ts @@ -9,6 +9,17 @@ import { createRequire } from 'node:module' import zlib from 'node:zlib' let sqliteRuntimeAvailable = true +const homeResolver = vi.hoisted(() => ({ value: undefined as string | undefined, throws: false })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: () => { + if (homeResolver.throws) throw new Error('fixture home lookup failed') + return homeResolver.value ?? actual.homedir() + }, + } +}) vi.mock('../../src/sqlite.js', async (importOriginal) => { const actual = await importOriginal() return { @@ -681,6 +692,8 @@ describe('OpenClaw state directory discovery', () => { }) afterEach(async () => { + homeResolver.value = undefined + homeResolver.throws = false vi.unstubAllEnvs() await rm(root, { recursive: true, force: true }) }) @@ -834,4 +847,18 @@ describe('OpenClaw state directory discovery', () => { vi.stubEnv('ANDROID_DATA', '/data') expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(termux, 'home', '.openclaw', 'agents')) }) + + it.each(['null', 'undefined', ''])('uses cwd when every home candidate is unset, including the OS value (%j)', async value => { + vi.stubEnv('HOME', value) + vi.stubEnv('USERPROFILE', '') + homeResolver.value = value + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(process.cwd(), '.openclaw', 'agents')) + }) + + it('uses cwd when the OS home lookup fails and no home override exists', async () => { + vi.stubEnv('HOME', '') + vi.stubEnv('USERPROFILE', '') + homeResolver.throws = true + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(process.cwd(), '.openclaw', 'agents')) + }) }) From 8a2cc94df86563aab3b88773397edac214b8b2ad Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:36:48 +0600 Subject: [PATCH 07/12] Test unresolved OpenClaw homes and current cache backfills --- tests/openclaw-discovery-cache.test.ts | 6 +++--- tests/providers/openclaw.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/openclaw-discovery-cache.test.ts b/tests/openclaw-discovery-cache.test.ts index 6ec2209be..62163ed29 100644 --- a/tests/openclaw-discovery-cache.test.ts +++ b/tests/openclaw-discovery-cache.test.ts @@ -58,7 +58,7 @@ describe('OpenClaw discovery cache invalidation', () => { } }) - it('backfills relocated history from a finalized v51 daily cache and retains archived usage', async () => { + it.each([51, 73])('backfills relocated history from a finalized v%i daily cache and retains archived usage', async version => { const root = await mkdtemp(join(tmpdir(), 'openclaw-daily-backfill-')) try { const cache = join(root, 'cache') @@ -80,8 +80,8 @@ describe('OpenClaw discovery cache invalidation', () => { call.costUSD = 0.02 } await mkdir(cache, { recursive: true }) - await writeFile(join(cache, 'daily-cache.v51.json'), JSON.stringify({ - version: 51, savingsConfigHash: '', tzKey: currentTzKey(), + await writeFile(join(cache, `daily-cache.v${version}.json`), JSON.stringify({ + version, savingsConfigHash: '', tzKey: currentTzKey(), lastComputedDate: toDateString(new Date(Date.now() - 86400000)), days: aggregateProjectsIntoDays(archived), complete: true, watermarkTrusted: true, })) diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts index bd0df1b88..9076c8eb0 100644 --- a/tests/providers/openclaw.test.ts +++ b/tests/providers/openclaw.test.ts @@ -861,4 +861,15 @@ describe('OpenClaw state directory discovery', () => { homeResolver.throws = true expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(process.cwd(), '.openclaw', 'agents')) }) + + it.each(['~', '~/service', '~\\service'])('falls back to cwd when OPENCLAW_HOME %j has no home to expand', async value => { + vi.stubEnv('HOME', '') + vi.stubEnv('USERPROFILE', '') + vi.stubEnv('OPENCLAW_HOME', value) + homeResolver.throws = true + const provider = createOpenClawProvider() + expect((await provider.probeRoots!())[0].path).toBe(join(process.cwd(), '.openclaw', 'agents')) + vi.stubEnv('OPENCLAW_STATE_DIR', '~/relocated') + expect((await provider.probeRoots!())[0].path).toBe(join(process.cwd(), 'relocated', 'agents')) + }) }) From 9ef0d42df6a4d9ac008f5aa0ef0d11d06ddad8b7 Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:39:54 +0600 Subject: [PATCH 08/12] Match OpenClaw fallback for unresolved home prefixes --- src/providers/openclaw.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts index baea59cce..4f787f48c 100644 --- a/src/providers/openclaw.ts +++ b/src/providers/openclaw.ts @@ -245,12 +245,17 @@ function getOpenClawDirs(): string[] { // lookup cannot provide a home. Apply the same rule without aborting. } } - osHome ??= process.cwd() 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']) - const home = configuredHome ? expandHome(configuredHome, osHome) : resolve(osHome) + 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')] : []), From 4e0746bc6de0c55c1b5a60d0600c5b066f541dcf Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:46:30 +0600 Subject: [PATCH 09/12] Test OpenClaw root changes after history finalization --- tests/openclaw-discovery-cache.test.ts | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/openclaw-discovery-cache.test.ts b/tests/openclaw-discovery-cache.test.ts index 62163ed29..3c0cd1886 100644 --- a/tests/openclaw-discovery-cache.test.ts +++ b/tests/openclaw-discovery-cache.test.ts @@ -58,6 +58,50 @@ describe('OpenClaw discovery cache invalidation', () => { } }) + it('refreshes finalized history after a same-version state-root change without losing archived usage', async () => { + const root = await mkdtemp(join(tmpdir(), 'openclaw-finalized-root-')) + try { + vi.stubEnv('CODEBURN_CACHE_DIR', join(root, 'cache')) + const first = join(root, 'first') + const second = join(root, 'second') + const timestamp = new Date(Date.now() - 30 * 86400000).toISOString() + const date = toDateString(new Date(timestamp)) + await seed(first, 'first', 100, timestamp) + await seed(second, 'second', 200, timestamp) + vi.stubEnv('OPENCLAW_STATE_DIR', first) + let includeArchivedUsage = true + const parse = async () => { + const projects = await parseAllSessions(undefined, 'openclaw') + if (!includeArchivedUsage) return projects + const archived = structuredClone(projects) + for (const project of archived) for (const session of project.sessions) for (const turn of session.turns) for (const call of turn.assistantCalls) { + call.provider = 'claude' + call.usage.inputTokens = 50 + call.costUSD = 0.02 + } + return [...projects, ...archived] + } + const initial = await ensureCacheHydrated(parse, aggregateProjectsIntoDays) + expect(initial.complete).toBe(true) + expect(initial.days.find(day => day.date === date)).toMatchObject({ calls: 2, inputTokens: 150 }) + + includeArchivedUsage = false + vi.stubEnv('OPENCLAW_STATE_DIR', second) + clearSessionCache() + const refreshed = await ensureCacheHydrated(parse, aggregateProjectsIntoDays) + const day = refreshed.days.find(day => day.date === date)! + expect(day).toMatchObject({ calls: 2, inputTokens: 250, outputTokens: 40 }) + expect(day.providers.openclaw).toMatchObject({ calls: 1, inputTokens: 200, cost: 0.01 }) + expect(day.providers.claude).toMatchObject({ calls: 1, inputTokens: 50, cost: 0.02 }) + const warmParse = vi.fn(parse) + await ensureCacheHydrated(warmParse, aggregateProjectsIntoDays) + expect(warmParse).not.toHaveBeenCalled() + } finally { + clearSessionCache() + await rm(root, { recursive: true, force: true }) + } + }) + it.each([51, 73])('backfills relocated history from a finalized v%i daily cache and retains archived usage', async version => { const root = await mkdtemp(join(tmpdir(), 'openclaw-daily-backfill-')) try { From ae23115e7f00e6484298b0e56f7bb96f0ae957e8 Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:55:26 +0600 Subject: [PATCH 10/12] Refresh settled OpenClaw history when discovery roots change --- docs/providers/openclaw.md | 5 ++- src/daily-cache.ts | 19 +++++++-- src/openclaw-paths.ts | 55 +++++++++++++++++++++++++ src/providers/openclaw.ts | 57 +------------------------- src/session-cache.ts | 5 +++ tests/openclaw-discovery-cache.test.ts | 23 ++++++++--- 6 files changed, 99 insertions(+), 65 deletions(-) create mode 100644 src/openclaw-paths.ts diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md index 9f7e1cf71..140f9430e 100644 --- a/docs/providers/openclaw.md +++ b/docs/providers/openclaw.md @@ -49,7 +49,10 @@ Parsed sessions use the shared session cache. `OPENCLAW_STATE_DIR`, the selected roots invalidates that provider's entries. The `state-dir-v1` parser revision and daily cache v74 backfill surviving history that earlier versions missed. Archived daily totals continue to -be carried forward when their source logs are unavailable. +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 diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 44452d3ef..bd34ab2fb 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -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' @@ -516,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 @@ -760,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[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache { +function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; 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 @@ -964,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), @@ -1792,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 @@ -1801,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 @@ -1810,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) @@ -1904,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. @@ -1978,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 — diff --git a/src/openclaw-paths.ts b/src/openclaw-paths.ts new file mode 100644 index 000000000..421df7e4e --- /dev/null +++ b/src/openclaw-paths.ts @@ -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() + for (const root of roots) { + const key = process.platform === 'win32' ? root.toLowerCase() : root + if (!unique.has(key)) unique.set(key, root) + } + return [...unique.values()] +} diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts index 4f787f48c..a37009312 100644 --- a/src/providers/openclaw.ts +++ b/src/providers/openclaw.ts @@ -1,6 +1,6 @@ import { readdir, readFile, stat } from 'fs/promises' -import { basename, join, resolve } from 'path' -import { homedir } from 'os' +import { basename, join } from 'path' +import { getOpenClawDirs } from '../openclaw-paths.js' import { createHash } from 'node:crypto' import zlib from 'zlib' @@ -221,59 +221,6 @@ async function* finalizeCalls( } } -function normalizeHomeDir(value: string | undefined): string | undefined { - const trimmed = value?.trim() - return trimmed && trimmed !== 'undefined' && trimmed !== 'null' ? trimmed : undefined -} - -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() - for (const root of roots) { - const key = process.platform === 'win32' ? root.toLowerCase() : root - if (!unique.has(key)) unique.set(key, root) - } - return [...unique.values()] -} - function extractTools(content: Array<{ type?: string; name?: string; arguments?: Record }> | undefined): { tools: string[]; bashCommands: string[] } { const tools: string[] = [] const bashCommands: string[] = [] diff --git a/src/session-cache.ts b/src/session-cache.ts index 2018e2d55..757bb898a 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -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 ────────────────────────────────────────────────────────────── @@ -1037,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) } diff --git a/tests/openclaw-discovery-cache.test.ts b/tests/openclaw-discovery-cache.test.ts index 3c0cd1886..48b5a44bb 100644 --- a/tests/openclaw-discovery-cache.test.ts +++ b/tests/openclaw-discovery-cache.test.ts @@ -58,17 +58,26 @@ describe('OpenClaw discovery cache invalidation', () => { } }) - it('refreshes finalized history after a same-version state-root change without losing archived usage', async () => { + it.each(['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME', 'cwd'])('refreshes finalized history after a same-version root change (%s) without losing archived usage', async rootSetting => { const root = await mkdtemp(join(tmpdir(), 'openclaw-finalized-root-')) + let cwd: ReturnType | undefined try { vi.stubEnv('CODEBURN_CACHE_DIR', join(root, 'cache')) const first = join(root, 'first') const second = join(root, 'second') const timestamp = new Date(Date.now() - 30 * 86400000).toISOString() const date = toDateString(new Date(timestamp)) - await seed(first, 'first', 100, timestamp) - await seed(second, 'second', 200, timestamp) - vi.stubEnv('OPENCLAW_STATE_DIR', first) + const state = (base: string) => rootSetting === 'OPENCLAW_HOME' ? join(base, '.openclaw') : rootSetting === 'cwd' ? join(base, 'relative-state') : base + await seed(state(first), 'first', 100, timestamp) + await seed(state(second), 'second', 200, timestamp) + const selectRoot = (base: string) => { + if (rootSetting === 'cwd') { + vi.stubEnv('OPENCLAW_STATE_DIR', 'relative-state') + if (!cwd) cwd = vi.spyOn(process, 'cwd') + cwd.mockReturnValue(base) + } else vi.stubEnv(rootSetting, base) + } + selectRoot(first) let includeArchivedUsage = true const parse = async () => { const projects = await parseAllSessions(undefined, 'openclaw') @@ -86,8 +95,11 @@ describe('OpenClaw discovery cache invalidation', () => { expect(initial.days.find(day => day.date === date)).toMatchObject({ calls: 2, inputTokens: 150 }) includeArchivedUsage = false - vi.stubEnv('OPENCLAW_STATE_DIR', second) + selectRoot(second) clearSessionCache() + const partial = await ensureCacheHydrated(parse, aggregateProjectsIntoDays, '', () => false) + expect(partial.complete).toBe(false) + expect(partial.days.find(day => day.date === date)).toMatchObject({ calls: 2, inputTokens: 150 }) const refreshed = await ensureCacheHydrated(parse, aggregateProjectsIntoDays) const day = refreshed.days.find(day => day.date === date)! expect(day).toMatchObject({ calls: 2, inputTokens: 250, outputTokens: 40 }) @@ -97,6 +109,7 @@ describe('OpenClaw discovery cache invalidation', () => { await ensureCacheHydrated(warmParse, aggregateProjectsIntoDays) expect(warmParse).not.toHaveBeenCalled() } finally { + cwd?.mockRestore() clearSessionCache() await rm(root, { recursive: true, force: true }) } From d409a1a19338c28499f729f8db14c2a4f1a28f1a Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:20:08 +0600 Subject: [PATCH 11/12] Cover OpenClaw backfills after upstream cache v74 --- docs/providers/openclaw.md | 2 +- tests/openclaw-discovery-cache.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md index 140f9430e..36ea471e2 100644 --- a/docs/providers/openclaw.md +++ b/docs/providers/openclaw.md @@ -47,7 +47,7 @@ The SQLite source path carries the session id after the database path (`: { } }) - it.each([51, 73])('backfills relocated history from a finalized v%i daily cache and retains archived usage', async version => { + // Pin released versions: deriving this from DAILY_CACHE_VERSION would stop + // detecting a reused version when another provider's migration lands first. + it.each([51, 73, 74])('backfills relocated history from a finalized v%i daily cache and retains archived usage', async version => { const root = await mkdtemp(join(tmpdir(), 'openclaw-daily-backfill-')) try { const cache = join(root, 'cache') From e41269229f36091972caa920950125281300f37f Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:02:46 +0600 Subject: [PATCH 12/12] Cover OpenClaw backfills after Copilot cache v75 --- docs/providers/openclaw.md | 2 +- tests/openclaw-discovery-cache.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md index 36ea471e2..9d6d391f8 100644 --- a/docs/providers/openclaw.md +++ b/docs/providers/openclaw.md @@ -47,7 +47,7 @@ The SQLite source path carries the session id after the database path (`: { // Pin released versions: deriving this from DAILY_CACHE_VERSION would stop // detecting a reused version when another provider's migration lands first. - it.each([51, 73, 74])('backfills relocated history from a finalized v%i daily cache and retains archived usage', async version => { + it.each([51, 73, 74, 75])('backfills relocated history from a finalized v%i daily cache and retains archived usage', async version => { const root = await mkdtemp(join(tmpdir(), 'openclaw-daily-backfill-')) try { const cache = join(root, 'cache')