diff --git a/package-lock.json b/package-lock.json index c82dd0782..f89a9bcee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1769,6 +1769,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -4706,6 +4707,120 @@ "node": ">=20.0.0" } }, + "node_modules/ai-hist-native": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native/-/ai-hist-native-0.4.1.tgz", + "integrity": "sha512-KXe5eATUMRzsZMFx0580zEM+F1qG4K/JF1elSJ/mFo2rYWmsdWw4Nao7n5S28P4ySn+EwOQ6FNCRRpvm7oYCMA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "ai-hist-native-darwin-arm64": "0.4.1", + "ai-hist-native-darwin-x64": "0.4.1", + "ai-hist-native-linux-arm64-gnu": "0.4.1", + "ai-hist-native-linux-arm64-musl": "0.4.1", + "ai-hist-native-linux-x64-gnu": "0.4.1", + "ai-hist-native-linux-x64-musl": "0.4.1" + } + }, + "node_modules/ai-hist-native-darwin-arm64": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-darwin-arm64/-/ai-hist-native-darwin-arm64-0.4.1.tgz", + "integrity": "sha512-4P39HLRFH67XOniP355KMrZAvInR0wJf+BgKIZvLdSuS5yWPhOJt7DQPnMgBjsYP5W0Exo5hOjOS8Bx9yBeuwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-darwin-x64": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-darwin-x64/-/ai-hist-native-darwin-x64-0.4.1.tgz", + "integrity": "sha512-EyC94hWSkP4enADhEtrs+pQEyfpTVRh4CVmIXQC4jxLrBRdfcHsgEOUFtnPvzyPKe/9p/GZDX2IYIESfpEbP7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-linux-arm64-gnu": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-linux-arm64-gnu/-/ai-hist-native-linux-arm64-gnu-0.4.1.tgz", + "integrity": "sha512-2TOLTJxvPefs7SavzwZl72p+/B+KkTJUaoxdIqm2pAjGvHO6OPZYa0ga5LniCrSqFcGuGAyLlH5/h9obVbuOmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-linux-arm64-musl": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-linux-arm64-musl/-/ai-hist-native-linux-arm64-musl-0.4.1.tgz", + "integrity": "sha512-sgEluX89qtLD4VXX0x0BwPXUwir6VRrZBFiUwXGz4EFecFu58QUZmgOju0nhw4GiomxC2R3LahGIl/u3w+vhNw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-linux-x64-gnu": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-linux-x64-gnu/-/ai-hist-native-linux-x64-gnu-0.4.1.tgz", + "integrity": "sha512-RdaBksaPPnAhynjA7Hzs+uC3xV9HbJHOj1sfMlpF7qcX8GGn5C7uN8BuFq0qEf3Zq8FsLDKmE7NcMcjs2jLEPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, + "node_modules/ai-hist-native-linux-x64-musl": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist-native-linux-x64-musl/-/ai-hist-native-linux-x64-musl-0.4.1.tgz", + "integrity": "sha512-NHviM8TNIrLWgnzAOzaGXIqJ+N96b0zjjT5ZFdCMIi+DSH9pSiaEoBhkvukso9LcJU1qdVDwg4DEL1JuZwV6gQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 18" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -9998,6 +10113,9 @@ }, "engines": { "node": ">=20.9.0" + }, + "optionalDependencies": { + "ai-hist-native": "^0.4.1" } }, "packages/cli/node_modules/@relaycast/sdk": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 8174de3f1..5e51365e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -61,6 +61,9 @@ "ws": "^8.18.3", "zod": "^3.23.8" }, + "optionalDependencies": { + "ai-hist-native": "^0.4.1" + }, "devDependencies": { "esbuild": "^0.27.2" }, diff --git a/packages/cli/src/cli/commands/reflex.test.ts b/packages/cli/src/cli/commands/reflex.test.ts index bd05f4428..ba40a2c89 100644 --- a/packages/cli/src/cli/commands/reflex.test.ts +++ b/packages/cli/src/cli/commands/reflex.test.ts @@ -35,8 +35,7 @@ function createHarness(overrides?: Partial) { } const deps: ReflexDependencies = { - fs, - homedir: vi.fn(() => tmpHome), + homedir: vi.fn(() => tmpHome as string), readRelayAuth: vi.fn(async () => ({ accessToken: FAKE_RELAY_TOKEN })), loginToCloud: vi.fn(async () => ({ ok: true as const })), prompt: vi.fn(async () => true), @@ -82,6 +81,7 @@ describe('registerReflexCommands', () => { expect.arrayContaining([ 'Reflex will capture your agent sessions and sync to history.agentrelay.com', 'Reflex is on.', + 'History syncs to relayhistory-cloud automatically while `agent-relay up` is running.', 'State file: ~/.agentworkforce/reflex.json', ]) ); @@ -165,6 +165,10 @@ describe('registerReflexCommands', () => { 'Not logged in to Agent Relay. Run `agent-relay login` first to sync Reflex history to the cloud.' ); expect(outputLines(deps)).toContain('Reflex is on.'); + // No cloud auth → don't claim automatic sync is happening. + expect(outputLines(deps)).not.toContain( + 'History syncs to relayhistory-cloud automatically while `agent-relay up` is running.' + ); }); it('reflex on when cloud login fails warns instead of treating it as complete', async () => { @@ -184,5 +188,9 @@ describe('registerReflexCommands', () => { 'Reflex is enabled locally, but cloud login did not complete: Login failed (HTTP 401): Unauthorized' ); expect(outputLines(deps)).toContain('Reflex is on.'); + // Cloud login failed → don't claim automatic sync is happening. + expect(outputLines(deps)).not.toContain( + 'History syncs to relayhistory-cloud automatically while `agent-relay up` is running.' + ); }); }); diff --git a/packages/cli/src/cli/commands/reflex.ts b/packages/cli/src/cli/commands/reflex.ts index 854aef86a..44958e15d 100644 --- a/packages/cli/src/cli/commands/reflex.ts +++ b/packages/cli/src/cli/commands/reflex.ts @@ -1,20 +1,14 @@ -import fs from 'node:fs'; import { chmod, mkdir, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; +import { readReflexState, writeReflexState } from '@agent-relay/config'; import { Command } from 'commander'; -interface ReflexState { - enabled: boolean; - enabledAt?: string; -} - export type LoginCloudResult = { ok: true } | { ok: false; error: string }; export interface ReflexDependencies { - fs: typeof fs; homedir: () => string; readRelayAuth: () => Promise<{ accessToken: string } | null>; loginToCloud: (relayAccessToken: string) => Promise; @@ -106,16 +100,20 @@ async function defaultLoginToCloud(relayAccessToken: string): Promise = {}): ReflexDependencies { return { - fs, homedir: os.homedir, readRelayAuth: defaultReadRelayAuth, loginToCloud: defaultLoginToCloud, @@ -138,32 +135,6 @@ function withDefaults(overrides: Partial = {}): ReflexDepend }; } -function getReflexDir(deps: ReflexDependencies): string { - return path.join(deps.homedir(), '.agentworkforce'); -} - -function getReflexStateFile(deps: ReflexDependencies): string { - return path.join(getReflexDir(deps), 'reflex.json'); -} - -function writeReflexState(deps: ReflexDependencies, state: ReflexState): void { - deps.fs.mkdirSync(getReflexDir(deps), { recursive: true }); - deps.fs.writeFileSync(getReflexStateFile(deps), JSON.stringify(state, null, 2), 'utf-8'); -} - -function readReflexState(deps: ReflexDependencies): ReflexState | null { - const stateFile = getReflexStateFile(deps); - if (!deps.fs.existsSync(stateFile)) { - return null; - } - - try { - return JSON.parse(deps.fs.readFileSync(stateFile, 'utf-8')) as ReflexState; - } catch { - return null; - } -} - export function registerReflexCommands(program: Command, overrides: Partial = {}): void { const deps = withDefaults(overrides); const reflex = program.command('reflex').description('Manage Reflex history sync'); @@ -180,24 +151,35 @@ export function registerReflexCommands(program: Command, overrides: Partial { - writeReflexState(deps, { enabled: false }); + writeReflexState({ enabled: false }, deps.homedir()); deps.log('Reflex is off.'); }); @@ -213,7 +195,7 @@ export function registerReflexCommands(program: Command, overrides: Partial { - const state = readReflexState(deps); + const state = readReflexState(deps.homedir()); if (!state) { deps.log('Reflex is off (never enabled).'); return; diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index e6b878ade..2f7d12585 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -15,6 +15,7 @@ import { buildBundledAgentRelayMcpCommand } from './agent-relay-mcp-command.js'; import { errorClassName } from './telemetry-helpers.js'; import { createImplicitLocalFleetNode, createTriggerSyncClient, fleetStatusPath } from './fleet-sidecar.js'; import { discoverNodeConfigPath, loadNodeDefinition } from './node-definition-loader.js'; +import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; type UpOptions = { spawn?: boolean; @@ -948,6 +949,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): let relay: CoreRelay | null = null; let fleetSidecar: RunningNode | undefined; + let reflexCapture: RunningReflexCapture | undefined; let shuttingDown = false; let sigintCount = 0; let shutdownPromise: Promise | undefined; @@ -958,6 +960,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): shutdownPromise = Promise.resolve(); } else { shutdownPromise = (async () => { + await reflexCapture?.stop(); await fleetSidecar?.stop(); await shutdownUpResources(relay, paths.dataDir, deps); })(); @@ -1010,6 +1013,10 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): vlog(deps, options.verbose, 'Loading teams.json and starting implicit fleet sidecar (if any)...'); const teamsConfig = deps.loadTeamsConfig(paths.projectRoot); fleetSidecar = startImplicitLocalFleetSidecar(paths, relay, options, deps, teamsConfig, nodeDefinition); + // When Reflex is enabled, periodically sync + push local session history to + // relayhistory-cloud in-process via the ai-hist-native addon (no subprocess). + // No-op when disabled or the addon isn't available. + reflexCapture = startReflexCapture({ log: (message) => deps.log(message) }); const shouldSpawn = options.spawn === true ? true : options.spawn === false ? false : Boolean(teamsConfig?.autoSpawn); diff --git a/packages/cli/src/cli/lib/reflex-capture.test.ts b/packages/cli/src/cli/lib/reflex-capture.test.ts new file mode 100644 index 000000000..490d3171b --- /dev/null +++ b/packages/cli/src/cli/lib/reflex-capture.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { reflexSyncAndPush, startReflexCapture } from './reflex-capture.js'; + +describe('startReflexCapture', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('is a no-op when reflex is disabled', async () => { + const push = vi.fn(async () => ({ sent: 0, accepted: 0 })); + const capture = startReflexCapture({ isEnabled: () => false, push, log: () => undefined }); + + await vi.advanceTimersByTimeAsync(1_000_000); + await capture.stop(); + + expect(push).not.toHaveBeenCalled(); + }); + + it('pushes after the initial delay and again on each interval', async () => { + const push = vi.fn(async () => ({ sent: 2, accepted: 2 })); + const log = vi.fn(); + const capture = startReflexCapture({ + isEnabled: () => true, + push, + log, + initialDelayMs: 100, + intervalMs: 1000, + }); + + await vi.advanceTimersByTimeAsync(100); + expect(push).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith('[reflex] synced 2 record(s) to relayhistory-cloud'); + + await vi.advanceTimersByTimeAsync(1000); + expect(push).toHaveBeenCalledTimes(2); + + await capture.stop(); + }); + + it('stops pushing when Reflex is disabled mid-run', async () => { + let enabled = true; + const push = vi.fn(async () => ({ sent: 1, accepted: 1 })); + const capture = startReflexCapture({ + isEnabled: () => enabled, + push, + log: () => undefined, + initialDelayMs: 10, + intervalMs: 100, + }); + + await vi.advanceTimersByTimeAsync(10); // kickoff push + expect(push).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(100); // one interval push + expect(push).toHaveBeenCalledTimes(2); + + enabled = false; // `agent-relay reflex off` while `up` keeps running + await vi.advanceTimersByTimeAsync(500); // several intervals, all gated off + expect(push).toHaveBeenCalledTimes(2); + + await capture.stop(); // final flush is also gated off + expect(push).toHaveBeenCalledTimes(2); + }); + + it('does not push before the initial delay even when the interval is shorter', async () => { + const push = vi.fn(async () => ({ sent: 0, accepted: 0 })); + const capture = startReflexCapture({ + isEnabled: () => true, + push, + log: () => undefined, + initialDelayMs: 1000, + intervalMs: 50, + }); + + await vi.advanceTimersByTimeAsync(900); + expect(push).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); // reach the initial delay + expect(push).toHaveBeenCalledTimes(1); + + await capture.stop(); + }); + + it('stop() flushes a final batch when idle', async () => { + const push = vi.fn(async () => ({ sent: 1, accepted: 1 })); + const capture = startReflexCapture({ + isEnabled: () => true, + push, + log: () => undefined, + initialDelayMs: 100_000, + intervalMs: 100_000, + }); + + // Timers are far out, so nothing has fired yet. + expect(push).not.toHaveBeenCalled(); + + await capture.stop(); + expect(push).toHaveBeenCalledTimes(1); + }); + + it('swallows push errors and keeps running', async () => { + const push = vi + .fn<[], Promise<{ sent: number; accepted: number }>>() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue({ sent: 0, accepted: 0 }); + const log = vi.fn(); + const capture = startReflexCapture({ + isEnabled: () => true, + push, + log, + initialDelayMs: 10, + intervalMs: 100, + }); + + await vi.advanceTimersByTimeAsync(10); + expect(log).toHaveBeenCalledWith(expect.stringContaining('[reflex] cloud sync failed: boom')); + + await vi.advanceTimersByTimeAsync(100); + expect(push).toHaveBeenCalledTimes(2); + + await capture.stop(); + }); +}); + +describe('reflexSyncAndPush', () => { + it('returns the report when authenticated', async () => { + const native = { syncAndPush: async () => ({ sent: 2, accepted: 2, authenticated: true }) }; + expect(await reflexSyncAndPush({ native })).toEqual({ sent: 2, accepted: 2 }); + }); + + it('no-ops when not authenticated', async () => { + const native = { syncAndPush: async () => ({ sent: 0, accepted: 0, authenticated: false }) }; + expect(await reflexSyncAndPush({ native })).toBeNull(); + }); + + it('no-ops when the native addon is unavailable', async () => { + expect(await reflexSyncAndPush({ native: null })).toBeNull(); + }); + + it('propagates native errors', async () => { + const native = { + syncAndPush: async () => { + throw new Error('boom'); + }, + }; + await expect(reflexSyncAndPush({ native })).rejects.toThrow(/boom/); + }); +}); diff --git a/packages/cli/src/cli/lib/reflex-capture.ts b/packages/cli/src/cli/lib/reflex-capture.ts new file mode 100644 index 000000000..f9dca0933 --- /dev/null +++ b/packages/cli/src/cli/lib/reflex-capture.ts @@ -0,0 +1,158 @@ +/** + * Reflex in-process cloud capture. + * + * When Reflex is enabled (`agent-relay reflex on`), the long-running + * `agent-relay up` host periodically syncs local agent history into the ai-hist + * DB and pushes new records to relayhistory-cloud — no launchd/cron, no CLI the + * user runs by hand, and **no subprocess**. Mirrors the telemetry client: an + * unref'd timer that never blocks the event loop, plus a best-effort final + * flush on shutdown. + * + * The work runs in-process through the `ai-hist-native` napi addon + * (`syncAndPush()`), which ships as a per-platform optional-dependency package + * so a plain `agent-relay` install works with no extra setup. Everything is a + * silent no-op when the addon is unavailable or the user isn't authenticated. + */ +import { isReflexEnabled } from '@agent-relay/config'; + +export interface ReflexPushResult { + sent: number; + accepted: number; +} + +export interface ReflexCaptureDeps { + /** Whether Reflex is enabled (checked once at start). */ + isEnabled: () => boolean; + /** Perform one push; resolves `null` when not authed / addon unavailable. */ + push: () => Promise; + /** Diagnostic logger. */ + log: (message: string) => void; + /** Milliseconds between pushes. */ + intervalMs: number; + /** Delay before the first push so startup isn't blocked. */ + initialDelayMs: number; +} + +export interface RunningReflexCapture { + /** Stop the timer and flush a final batch (best-effort). */ + stop: () => Promise; +} + +const DEFAULT_INTERVAL_MS = 5 * 60_000; +const DEFAULT_INITIAL_DELAY_MS = 30_000; + +/** The native addon surface we depend on. */ +export interface NativeAiHist { + syncAndPush: () => Promise<{ sent: number; accepted: number; authenticated: boolean }>; +} + +/** Lazily load the `ai-hist-native` napi addon; null when it isn't installed. */ +async function loadNative(): Promise { + // Non-literal spec keeps the native addon out of the esbuild bundle; it + // resolves from node_modules (the per-platform optional dep) at runtime. + const spec = 'ai-hist-native'; + let mod: Partial & { default?: Partial }; + try { + mod = (await import(spec)) as typeof mod; + } catch (err) { + // Not installed for this platform → a clean no-op. Anything else (ABI + // mismatch, missing system lib, addon init failure) is a real problem — + // rethrow so the caller logs it instead of silently doing nothing. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') { + return null; + } + throw err; + } + const fn = mod.syncAndPush ?? mod.default?.syncAndPush; + return typeof fn === 'function' ? { syncAndPush: fn } : null; +} + +export interface ReflexPushOptions { + /** Injectable native addon (tests); defaults to lazy-loading `ai-hist-native`. */ + native?: NativeAiHist | null; +} + +/** + * Sync local agent history into the ai-hist DB and push new records to + * relayhistory-cloud, **in-process** via the native addon — no subprocess. This + * is what makes `reflex on` "just work": no separate ai-hist install, no CLI. + * Resolves null when the addon is unavailable or the user isn't authenticated. + */ +export async function reflexSyncAndPush(opts: ReflexPushOptions = {}): Promise { + const native = opts.native !== undefined ? opts.native : await loadNative(); + if (!native) return null; + const result = await native.syncAndPush(); + if (!result.authenticated) return null; // not logged in yet + return { sent: result.sent, accepted: result.accepted }; +} + +function withDefaults(overrides: Partial): ReflexCaptureDeps { + return { + isEnabled: isReflexEnabled, + push: () => reflexSyncAndPush(), + log: (message: string) => console.error(message), + intervalMs: DEFAULT_INTERVAL_MS, + initialDelayMs: DEFAULT_INITIAL_DELAY_MS, + ...overrides, + }; +} + +export function startReflexCapture(overrides: Partial = {}): RunningReflexCapture { + const deps = withDefaults(overrides); + + let stopped = false; + // Dedup concurrent ticks: a slow push must not overlap the next interval. + let inFlight: Promise | null = null; + // The recurring interval starts only after the first (delayed) push. + let timer: ReturnType | null = null; + + const tick = (): Promise => { + if (inFlight) return inFlight; + // Re-check enablement every tick so `agent-relay reflex off` (or `on`) + // takes effect immediately in an already-running `agent-relay up`, without + // restarting the host. + if (!deps.isEnabled()) return Promise.resolve(); + inFlight = (async () => { + try { + const result = await deps.push(); + if (result && result.sent > 0) { + deps.log(`[reflex] synced ${result.sent} record(s) to relayhistory-cloud`); + } + } catch (err) { + deps.log(`[reflex] cloud sync failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + inFlight = null; + } + })(); + return inFlight; + }; + + const kickoff = setTimeout(() => { + if (stopped) return; + void tick(); + // Start the interval only now, so the first push can never fire before + // initialDelayMs regardless of how small intervalMs is. + timer = setInterval(() => { + if (!stopped) void tick(); + }, deps.intervalMs); + // Don't keep the process alive just for the capture timer. + timer.unref?.(); + }, deps.initialDelayMs); + kickoff.unref?.(); + + return { + stop: async () => { + stopped = true; + clearTimeout(kickoff); + if (timer) clearInterval(timer); + // Let an in-flight push finish, then flush one final batch (a no-op if + // Reflex was disabled in the meantime — tick() re-checks). + if (inFlight) { + await inFlight; + } else { + await tick(); + } + }, + }; +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index f2fc25618..5e1e73cba 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -4,6 +4,7 @@ export * from './bridge-utils.js'; export * from './teams-config.js'; export * from './shadow-config.js'; export * from './trajectory-config.js'; +export * from './reflex-config.js'; export * from './agent-config.js'; export * from './cli-auth-config.js'; export * from './cloud-config.js'; diff --git a/packages/config/src/reflex-config.ts b/packages/config/src/reflex-config.ts new file mode 100644 index 000000000..ac70935b0 --- /dev/null +++ b/packages/config/src/reflex-config.ts @@ -0,0 +1,48 @@ +/** + * Reflex feature state. + * + * A single global toggle stored at `~/.agentworkforce/reflex.json` (NOT + * per-repo). Written by `agent-relay reflex on/off`; read by the runtime to + * decide whether to capture + push session history to relayhistory-cloud + * in-process. This module is the single source of truth for the file shape and + * location so the command and the runtime never drift. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export interface ReflexState { + enabled: boolean; + enabledAt?: string; +} + +function reflexDir(home: string = homedir()): string { + return join(home, '.agentworkforce'); +} + +/** Absolute path to the reflex state file (`~/.agentworkforce/reflex.json`). */ +export function getReflexStateFile(home: string = homedir()): string { + return join(reflexDir(home), 'reflex.json'); +} + +/** Read the reflex state, or `null` when it was never written / is malformed. */ +export function readReflexState(home: string = homedir()): ReflexState | null { + const file = getReflexStateFile(home); + if (!existsSync(file)) return null; + try { + return JSON.parse(readFileSync(file, 'utf-8')) as ReflexState; + } catch { + return null; + } +} + +/** Persist the reflex state (creates `~/.agentworkforce/` as needed). */ +export function writeReflexState(state: ReflexState, home: string = homedir()): void { + mkdirSync(reflexDir(home), { recursive: true }); + writeFileSync(getReflexStateFile(home), JSON.stringify(state, null, 2), 'utf-8'); +} + +/** Whether Reflex capture is currently enabled. */ +export function isReflexEnabled(home: string = homedir()): boolean { + return readReflexState(home)?.enabled === true; +}