| 1 | /** |
| 2 | * AntigravityUsage - Antigravity's usage history as a usage source. |
| 3 | * |
| 4 | * Antigravity keeps conversations in SQLite databases under its data |
| 5 | * directories and each instance's profile. Parsed databases stay in memory |
| 6 | * while a database and its WAL keep the same `(size, mtime, ctime)`, so a |
| 7 | * warm scan decodes only what changed. |
| 8 | * |
| 9 | * @module provider/Drivers/AntigravityUsage |
| 10 | */ |
| 11 | |
| 12 | import { type AntigravitySettings, UsageReadError } from "@t3tools/contracts"; |
| 13 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 14 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 15 | import type { |
| 16 | ProviderUsageInstance, |
| 17 | ProviderUsageReader, |
| 18 | ProviderUsageScan, |
| 19 | } from "@t3tools/provider-core/server/usage"; |
| 20 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 21 | import * as Context from "effect/Context"; |
| 22 | import * as Crypto from "effect/Crypto"; |
| 23 | import * as Effect from "effect/Effect"; |
| 24 | import * as FileSystem from "effect/FileSystem"; |
| 25 | import * as Layer from "effect/Layer"; |
| 26 | import * as Path from "effect/Path"; |
| 27 | |
| 28 | import { resolveAntigravityInstanceDirectories } from "../antigravityAuthSupport.ts"; |
| 29 | import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; |
| 30 | |
| 31 | export class AntigravityUsage extends Context.Service< |
| 32 | AntigravityUsage, |
| 33 | { |
| 34 | /** Every Antigravity store: the data directories and each configured instance's profile. */ |
| 35 | readonly scan: (input: { |
| 36 | readonly instances: ReadonlyArray<ProviderUsageInstance<AntigravitySettings>>; |
| 37 | readonly windowStartMs: number; |
| 38 | }) => Effect.Effect<ReadonlyArray<ProviderUsageScan>, UsageReadError>; |
| 39 | } |
| 40 | >()("t3/provider/Drivers/AntigravityUsage") {} |
| 41 | |
| 42 | const make = Effect.gen(function* () { |
| 43 | const crypto = yield* Crypto.Crypto; |
| 44 | const fileSystem = yield* FileSystem.FileSystem; |
| 45 | const path = yield* Path.Path; |
| 46 | const host = yield* ProviderHost.ProviderHost; |
| 47 | const hostEnvironment = yield* HostProcess.Environment; |
| 48 | const cache = makeAntigravityUsageCache(); |
| 49 | |
| 50 | /** `ANTIGRAVITY_DATA_DIR` (comma-separated) or the defaults, canonicalized. */ |
| 51 | const dataRoots = Effect.gen(function* () { |
| 52 | const home = yield* HostProcess.HomeDirectory; |
| 53 | const configured = hostEnvironment["ANTIGRAVITY_DATA_DIR"] |
| 54 | ?.split(",") |
| 55 | .map((value) => value.trim()) |
| 56 | .filter(Boolean); |
| 57 | const defaults = [ |
| 58 | ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => |
| 59 | path.join(home, ".gemini", name), |
| 60 | ), |
| 61 | path.join(home, ".config", "antigravity"), |
| 62 | ]; |
| 63 | const canonical = new Set<string>(); |
| 64 | for (const root of configured?.length ? configured : defaults) { |
| 65 | const resolved = path.resolve(expandHomePath(root, home)); |
| 66 | canonical.add( |
| 67 | yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)), |
| 68 | ); |
| 69 | } |
| 70 | return [...canonical]; |
| 71 | }); |
| 72 | |
| 73 | const scan: AntigravityUsage["Service"]["scan"] = Effect.fn("AntigravityUsage.scan")(function* ({ |
| 74 | instances, |
| 75 | windowStartMs, |
| 76 | }) { |
| 77 | const roots = yield* dataRoots; |
| 78 | // Only configured instances have a profile; the implicit default never ran. |
| 79 | for (const { instanceId } of instances.filter((instance) => instance.configured)) { |
| 80 | const directories = yield* resolveAntigravityInstanceDirectories( |
| 81 | host.paths.stateDir, |
| 82 | instanceId, |
| 83 | ).pipe( |
| 84 | Effect.provideService(Crypto.Crypto, crypto), |
| 85 | Effect.provideService(Path.Path, path), |
| 86 | Effect.mapError( |
| 87 | (cause) => |
| 88 | new UsageReadError({ |
| 89 | reason: "scanFailed", |
| 90 | detail: "Antigravity profile directory could not be resolved.", |
| 91 | cause, |
| 92 | }), |
| 93 | ), |
| 94 | ); |
| 95 | roots.push(path.join(directories.profile, "antigravity-acp")); |
| 96 | } |
| 97 | const conversationDirs = new Set<string>(); |
| 98 | for (const root of roots) { |
| 99 | const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); |
| 100 | const nested = path.join(resolvedRoot, "conversations"); |
| 101 | const dir = (yield* fileSystem |
| 102 | .exists(nested) |
| 103 | .pipe(Effect.catchCause(() => Effect.succeed(false)))) |
| 104 | ? nested |
| 105 | : resolvedRoot; |
| 106 | conversationDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir))); |
| 107 | } |
| 108 | const result = yield* readAntigravityUsage([...conversationDirs], windowStartMs, cache).pipe( |
| 109 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 110 | Effect.provideService(Path.Path, path), |
| 111 | ); |
| 112 | const scanned: ProviderUsageScan[] = []; |
| 113 | for (const dir of conversationDirs) { |
| 114 | const exists = yield* fileSystem |
| 115 | .exists(dir) |
| 116 | .pipe(Effect.catchCause(() => Effect.succeed(false))); |
| 117 | const failed = result.errors.some( |
| 118 | (error) => error === dir || error.startsWith(`${dir}${path.sep}`), |
| 119 | ); |
| 120 | scanned.push({ |
| 121 | dir, |
| 122 | files: !exists && !failed ? null : result.files.filter((file) => file.root === dir), |
| 123 | status: failed ? "partial" : "ok", |
| 124 | ...(failed ? { message: "Some Antigravity history could not be read." } : {}), |
| 125 | }); |
| 126 | } |
| 127 | return scanned; |
| 128 | }); |
| 129 | |
| 130 | return AntigravityUsage.of({ scan }); |
| 131 | }); |
| 132 | |
| 133 | export const layer = Layer.effect(AntigravityUsage, make); |
| 134 | |
| 135 | export const antigravityUsageReader: ProviderUsageReader<AntigravitySettings, AntigravityUsage> = { |
| 136 | kind: "scan", |
| 137 | provider: "antigravity", |
| 138 | scan: ({ instances, windowStartMs }) => |
| 139 | AntigravityUsage.pipe(Effect.flatMap((usage) => usage.scan({ instances, windowStartMs }))), |
| 140 | }; |