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
12import { type AntigravitySettings, UsageReadError } from "@t3tools/contracts";
13import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
14import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
15import type {
16 ProviderUsageInstance,
17 ProviderUsageReader,
18 ProviderUsageScan,
19} from "@t3tools/provider-core/server/usage";
20import * as HostProcess from "@t3tools/shared/HostProcess";
21import * as Context from "effect/Context";
22import * as Crypto from "effect/Crypto";
23import * as Effect from "effect/Effect";
24import * as FileSystem from "effect/FileSystem";
25import * as Layer from "effect/Layer";
26import * as Path from "effect/Path";
27
28import { resolveAntigravityInstanceDirectories } from "../antigravityAuthSupport.ts";
29import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";
30
31export 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
42const 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
133export const layer = Layer.effect(AntigravityUsage, make);
134
135export 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};