@estebanforge/pi-antigravity-bridge 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,8 @@
2
2
  //
3
3
  // Registers Gemini (via the agy CLI) as a pi model provider so it shows up in
4
4
  // the /model picker as antigravity/gemini-*. When selected, pi routes each turn
5
- // through streamSimple, which spawns `agy -p`, polls the conversation SQLite DB
6
- // agy writes, decodes the protobuf step payloads, and streams the agent text
7
- // back into pi's TUI.
5
+ // through streamSimple, which feeds the persistent stream-json driver process
6
+ // and streams the agent text back into pi's TUI.
8
7
  //
9
8
  // Architectural wall (cannot be worked around - see PLAN.md):
10
9
  // agy runs its OWN closed tool loop against --add-dir. pi's read/write/edit/
@@ -301,7 +300,6 @@ function statusText(ctx: AgyCommandCtx): string {
301
300
  ` tool thinking: ${config.defaultThinking}`,
302
301
  ` sessions: ${ctx.store.size} bound`,
303
302
  ` config: ${CONFIG_PATH}`,
304
- ` engine: ${config.engine}`,
305
303
  ` bridge tools: ${config.bridgeTools}`,
306
304
  ` digest: ${config.digest ? "on" : "off"}`,
307
305
  "",
@@ -352,7 +350,6 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
352
350
  const port = ctx.getMcpPort();
353
351
  const lines = [
354
352
  "Antigravity doctor (no tokens spent)",
355
- ` engine: ${config.engine}`,
356
353
  ` bridge: ${config.bridgeTools}${port ? ` (port ${port})` : " (not running)"}`,
357
354
  ` driver: ${snap.state}${snap.pid ? ` pid=${snap.pid}` : ""}${snap.conversationId ? ` conv=${snap.conversationId.slice(0, 8)}` : ""}`,
358
355
  ` driver stats: spawns=${snap.stats.spawns} turns=${snap.stats.turns} reused=${snap.stats.reused} recycles=${snap.stats.recycles}${snap.stats.lastRecycleReason ? ` (last: ${snap.stats.lastRecycleReason})` : ""}`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.3.0",
4
- "description": "Streaming Gemini provider for pi, built on the agy CLI. Registers antigravity/* models in pi's /model picker via SQLite polling + protobuf decode of agy's conversation DBs.",
3
+ "version": "1.3.2",
4
+ "description": "Streaming Gemini provider for pi, built on the agy CLI. Registers antigravity/* models in pi's /model picker; drives agy through its stream-json protocol (persistent process, tool round-trips, live usage).",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -30,8 +30,6 @@
30
30
  "build": "tsc --noEmit",
31
31
  "test": "vitest run",
32
32
  "prepublishOnly": "npm run build && npm test",
33
- "run-agy": "tsx scripts/run-agy.ts",
34
- "decode-db": "tsx scripts/decode-db.ts",
35
33
  "smoke:pi": "bash scripts/smoke-in-pi.sh"
36
34
  },
37
35
  "files": [
package/src/config.ts CHANGED
@@ -24,7 +24,6 @@ const CONFIG_PATH = path.join(
24
24
 
25
25
  export type AgyMode = "accept-edits" | "plan";
26
26
  export type ThinkingTier = "low" | "medium" | "high";
27
- export type AgyEngine = "stream-json" | "legacy-sqlite";
28
27
  export type BridgeTools = "none" | "mcp" | "all";
29
28
 
30
29
  export interface AgyConfig {
@@ -39,11 +38,6 @@ export interface AgyConfig {
39
38
  defaultModel: string;
40
39
  /** AskAntigravity tool: default thinking tier when the alias names none. */
41
40
  defaultThinking: ThinkingTier;
42
- /** Turn engine. "stream-json" (default): one persistent agy process fed
43
- * NDJSON user events; enables live toolUse round-trips, native usage, and
44
- * conversation binding from the init event. "legacy-sqlite": the old
45
- * spawn-`agy -p`-and-poll-SQLite path, kept as a fallback for one release. */
46
- engine: AgyEngine;
47
41
  /** Set after the one-time notice about a leftover legacy invokeTool patch
48
42
  * on the installed pi. The notice never repeats; /agy patch-cleanup is
49
43
  * always available. */
@@ -73,7 +67,6 @@ const DEFAULTS: AgyConfig = {
73
67
  skipPermissions: true,
74
68
  defaultModel: "flash",
75
69
  defaultThinking: "medium",
76
- engine: "stream-json",
77
70
  bridgeTools: "mcp",
78
71
  digest: false,
79
72
  };
@@ -120,11 +113,6 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
120
113
  const defaultThinking: ThinkingTier =
121
114
  thinkRaw === "low" || thinkRaw === "high" ? thinkRaw : "medium";
122
115
 
123
- const engine: AgyEngine =
124
- process.env.AGY_ENGINE === "legacy-sqlite" || file.engine === "legacy-sqlite"
125
- ? "legacy-sqlite"
126
- : "stream-json";
127
-
128
116
  const bridgeRaw = (process.env.AGY_BRIDGE_TOOLS ?? file.bridgeTools ?? DEFAULTS.bridgeTools).toLowerCase();
129
117
  const bridgeTools: BridgeTools =
130
118
  bridgeRaw === "none" || bridgeRaw === "all" ? bridgeRaw : "mcp";
@@ -138,7 +126,6 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
138
126
  skipPermissions,
139
127
  defaultModel,
140
128
  defaultThinking,
141
- engine,
142
129
  bridgeTools,
143
130
  digest,
144
131
  patchCleanupNotified: file.patchCleanupNotified === true,
package/src/provider.ts CHANGED
@@ -29,7 +29,6 @@ import {
29
29
  type Usage,
30
30
  } from "@earendil-works/pi-ai";
31
31
  import type { Api } from "@earendil-works/pi-ai";
32
- import { runAgyTurn, type AgyEvent, type AgyRunOptions } from "./runner.js";
33
32
  import { AgyDriver, type DriverActivity, type TurnHandle } from "./driver.js";
34
33
  import { toPiUsage } from "./stream-events.js";
35
34
  import { mapAgyToolToNative } from "./native-tools.js";
@@ -245,11 +244,8 @@ export interface BlockState {
245
244
  export interface StreamSimpleDeps {
246
245
  entries: AgyModelEntry[];
247
246
  store: SessionStore;
248
- /** Override the agy turn runner (tests inject a scripted event source).
249
- * Defaults to the real runAgyTurn. Legacy engine only. */
250
- runAgyTurn?: typeof runAgyTurn;
251
- /** Persistent stream-json engine. When set (and config.engine selects it),
252
- * turns run on the driver and bridge calls park as toolUse round-trips. */
247
+ /** Persistent stream-json driver. Turns run on the driver and bridge
248
+ * calls park as toolUse round-trips. Required with roundTrips. */
253
249
  driver?: AgyDriver;
254
250
  roundTrips?: ToolRoundTrips;
255
251
  /** Replay store for the display-only antigravity wrapper tool. Required
@@ -692,13 +688,12 @@ async function runTurnDriver(
692
688
  export function createStreamSimple(
693
689
  deps: StreamSimpleDeps,
694
690
  ): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
695
- const { entries, store, runAgyTurn: runFn = runAgyTurn, driver, roundTrips } = deps;
691
+ const { entries, store, driver, roundTrips } = deps;
696
692
 
697
693
  return function streamSimple(model, context, options) {
698
694
  const stream = createAssistantMessageEventStream();
699
695
  // Fire the async turn; return the stream synchronously per pi's contract.
700
- const config = loadConfig();
701
- if (driver && roundTrips && config.engine === "stream-json") {
696
+ if (driver && roundTrips) {
702
697
  void runTurnDriver(stream, model, context, options, entries, store, {
703
698
  driver,
704
699
  roundTrips,
@@ -706,183 +701,16 @@ export function createStreamSimple(
706
701
  nativeActive: deps.nativeActive,
707
702
  });
708
703
  } else {
709
- void runTurn(stream, model, context, options, entries, store, runFn);
704
+ // Miswired extension: no driver means no engine. Fail the turn visibly
705
+ // instead of silently producing an empty assistant message.
706
+ const partial = newAssistant(model);
707
+ const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
708
+ finalize(stream, blocks, "error", "antigravity driver not configured");
710
709
  }
711
710
  return stream;
712
711
  };
713
712
  }
714
713
 
715
- async function runTurn(
716
- stream: AssistantMessageEventStream,
717
- model: Model<Api>,
718
- context: Context,
719
- options: SimpleStreamOptions | undefined,
720
- entries: AgyModelEntry[],
721
- store: SessionStore,
722
- runFn: typeof runAgyTurn,
723
- ): Promise<void> {
724
- const partial = newAssistant(model);
725
- const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
726
-
727
- // Direct emit helpers. agy streams deltas that may not align to line
728
- // boundaries; pi's TUI renders partial lines fine, so we append and push
729
- // each delta straight through (no filtering, no buffering).
730
- const appendTextDelta = (delta: string): void => {
731
- appendText(stream, blocks, delta);
732
- };
733
- const appendThinkingDelta = (delta: string): void => {
734
- appendThinking(stream, blocks, delta);
735
- };
736
-
737
- // Signal the turn has begun IMMEDIATELY. pi's native Working indicator is
738
- // driven by the stream's start event (isStreaming). Without this, agy's
739
- // initial thinking seconds (before it emits any step) show nothing and the
740
- // UI looks frozen. Lazy start (on first content) was the old behavior.
741
- ensureStarted(stream, blocks);
742
-
743
- const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
744
- const key = sessionKey(options, cwd);
745
- const existing = store.get(key);
746
- const messageCount = context.messages.length;
747
-
748
- const prompt = extractUserPrompt(context);
749
- if (!prompt) {
750
- finalize(stream, blocks, "error", "No user message to send to agy.");
751
- return;
752
- }
753
-
754
- // Runtime config (mode, permissions, digest). Loaded fresh each turn so
755
- // /agy toggles take effect immediately without a reload.
756
- const config = loadConfig();
757
-
758
- // G1: inject a delta digest of pi-side context agy was not spawned for
759
- // (compaction summaries, other-provider turns), gated on config.digest:
760
- // the digest changes every turn and defeats agy's prompt cache. agy keeps
761
- // its own history; see docs/PI-BRIDGE-GAPS.md (G1).
762
- const watermark = existing?.lastMessageCount ?? 0;
763
- const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
764
- const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
765
-
766
- // Resolve the pi model id to its catalog entry. On a miss, fall through to
767
- // the id itself - agy will likely reject, but the error reaches the user
768
- // instead of a silent no-op.
769
- const entry = entries.find((e) => e.id === model.id) ?? null;
770
- const agyModel = entry?.full ?? model.id;
771
-
772
-
773
- // Effort-driven bases always need --effort (a base slug is invalid on its
774
- // own); fixed models never get it (agy rejects --effort for them). For an
775
- // effort-driven base we clamp pi's level to the tiers agy offers it.
776
- const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
777
-
778
- const runOpts: AgyRunOptions = {
779
- cwd,
780
- model: agyModel,
781
- mode: config.mode,
782
- skipPermissions: config.skipPermissions,
783
- effort,
784
- prompt: fullPrompt,
785
- conversationId: existing?.conversationId ?? null,
786
- baseStepIdx: existing?.lastStepIdx ?? -1,
787
- timeoutMin: DEFAULT_TIMEOUT_MIN,
788
- signal: options?.signal,
789
- };
790
-
791
- // G8: per-turn diff context for agy's file edits (write_to_file et al.).
792
- // Turn-scoped so concurrent turns never share OLD-content caches.
793
- const diffCtx = new TurnDiffContext(createExecGitOps());
794
-
795
- const onEvent = (event: AgyEvent) => {
796
- switch (event.kind) {
797
- case "text":
798
- appendTextDelta(event.text);
799
- break;
800
- case "thinking":
801
- appendThinkingDelta(event.text);
802
- break;
803
- case "tool": {
804
- // G8: if agy wrote a file, surface a git-sourced diff; else the plain
805
- // tool label. Always shown (agy's own tool loop, surfaced for visibility).
806
- const edit = parseEditToolInput(event.inputJson ?? "");
807
- if (edit) {
808
- const absFile = path.isAbsolute(edit.file) ? edit.file : path.resolve(cwd, edit.file);
809
- const outcome = diffCtx.diffEdit(absFile, edit.content);
810
- const label = edit.description ?? path.basename(absFile);
811
- appendThinkingDelta(`[agy edit: ${label}]\n`);
812
- if (outcome.text) appendThinkingDelta(`${outcome.text}\n`);
813
- } else {
814
- appendThinkingDelta(`[agy tool: ${event.name}]\n`);
815
- }
816
- break;
817
- }
818
- case "title":
819
- // Conversation title metadata - not streamed to the user.
820
- break;
821
- }
822
- };
823
-
824
- let result;
825
- try {
826
- result = await runFn(runOpts, onEvent);
827
- } catch (err) {
828
- const msg = err instanceof Error ? err.message : String(err);
829
- finalize(stream, blocks, "error", `agy failed to start: ${msg}`);
830
- return;
831
- }
832
-
833
- // Persist for the next turn (resume). Only bind when we actually discovered
834
- // an id - a discovery miss shouldn't clobber a prior good binding.
835
- // Persist for the next turn (resume). Only bind when we actually discovered
836
- // an id - a discovery miss shouldn't clobber a prior good binding. The
837
- // lastMessageCount watermark advances on a successful bind even if the turn
838
- // later aborted or timed out: the prompt (digest included) was handed to
839
- // agy at spawn, so its DB has seen that context. Guarding this on
840
- // exitCode===0 would re-inject stale deltas after retryable failures.
841
- if (result.conversationId) {
842
- store.set(key, {
843
- conversationId: result.conversationId,
844
- lastStepIdx: result.lastIdx,
845
- lastMessageCount: messageCount,
846
- });
847
- }
848
-
849
- if (result.aborted) {
850
- finalize(stream, blocks, "aborted", "Operation aborted");
851
- return;
852
- }
853
- if (result.timedOut) {
854
- const note = `agy exceeded the ${runOpts.timeoutMin}m timeout`;
855
- finalize(stream, blocks, "error", note);
856
- return;
857
- }
858
- if (result.exitCode !== 0) {
859
- const detail = result.stderr.trim() || `agy exited with status ${result.exitCode}`;
860
- finalize(stream, blocks, "error", detail);
861
- return;
862
- }
863
-
864
- // Discovery miss: agy exited cleanly but we never bound a conversation id
865
- // this turn (ambiguous snapshot, DB not created in time, or a prior session
866
- // whose id failed CONV_ID_RE and silently fell through to fresh discovery).
867
- // Guard on whether we bound THIS turn, not on whether a prior session
868
- // existed - otherwise a corrupt existing entry re-opens the silent-empty-
869
- // success hole the first review closed.
870
- if (!result.conversationId) {
871
- const detail =
872
- "agy exited cleanly but its conversation database could not be bound. " +
873
- "The run may have partially applied edits with no visible output.";
874
- finalize(stream, blocks, "error", detail);
875
- return;
876
- }
877
-
878
- // Success. If no text ever streamed (agy did only tool work, or returned
879
- // empty), emit an empty text block so pi has a well-formed assistant turn.
880
- if (blocks.textIdx === null && blocks.thinkingIdx === null) {
881
- ensureTextOpen(stream, blocks);
882
- }
883
- finalize(stream, blocks, "stop");
884
- }
885
-
886
714
  /** Signal the start of the assistant turn exactly once. `start` is
887
715
  * turn-level (analogous to Anthropic's message_start), not per-block - the
888
716
  * per-block signals are text_start / thinking_start. */
package/src/poller.ts DELETED
@@ -1,202 +0,0 @@
1
- // Read-only poller over an agy conversation SQLite DB.
2
- //
3
- // Opens ~/.gemini/antigravity-cli/conversations/<uuid>.db read-only and reads
4
- // newly-appended rows from the `steps` table on each poll. Uses node:sqlite
5
- // (built into Node >= 22.5; this machine is 26.5.0) so there is no native
6
- // dependency to ship. The caller drives a 250ms poll loop.
7
- //
8
- // Coalescing: agy's writer commits through its own connection, so we use
9
- // SQLite's `PRAGMA data_version` to skip the SELECT when nothing has changed
10
- // since the last poll (agy-acp pattern). data_version bumps on every commit
11
- // by another connection - cheap and exact.
12
-
13
- import fs from "node:fs";
14
- import { DatabaseSync } from "node:sqlite";
15
- import { toUint8 } from "./protobuf.js";
16
-
17
- /** A raw step row as read from the DB. payload is the undecoded step_payload
18
- * BLOB; callers pass it to the protobuf extractor. */
19
- export interface Step {
20
- idx: number;
21
- stepType: number;
22
- status: number;
23
- payload: Uint8Array;
24
- }
25
-
26
- const SELECT_STEPS =
27
- "SELECT idx, step_type, status, step_payload FROM steps WHERE idx > ? ORDER BY idx";
28
- const SELECT_STEP_AT =
29
- "SELECT idx, step_type, status, step_payload FROM steps WHERE idx = ?";
30
-
31
- const HAS_STEPS =
32
- "SELECT COUNT(*) > 0 AS present FROM sqlite_master WHERE type='table' AND name='steps'";
33
-
34
- /** Open the DB read-only. Returns null when the file doesn't exist yet or
35
- * lacks a steps table (agy hasn't created/flushed it). Throws are swallowed
36
- * so a transient lock or half-written file is retried on the next poll. */
37
- function openReadOnly(dbPath: string): DatabaseSync | null {
38
- if (!fs.existsSync(dbPath)) return null;
39
- try {
40
- const db = new DatabaseSync(dbPath, { readOnly: true });
41
- const row = db.prepare(HAS_STEPS).get() as { present?: number } | undefined;
42
- if (!row?.present) {
43
- db.close();
44
- return null;
45
- }
46
- return db;
47
- } catch {
48
- return null;
49
- }
50
- }
51
-
52
- /** A reusable read handle on one conversation's steps table.
53
- *
54
- * Keeps one DB connection + prepared statement open for the life of a turn,
55
- * so the poll loop isn't re-opening the file each tick. `poll()` returns only
56
- * rows newer than the last one seen, advancing an internal cursor.
57
- *
58
- * A row whose payload fails to materialize (torn read while agy is mid-write)
59
- * is dropped, not thrown - its idx is NOT advanced past, so it's retried on
60
- * the next poll once the write settles. (agy-acp database.ts pattern.) */
61
- export class ConversationPoller {
62
-
63
- private db: DatabaseSync | null = null;
64
- private selectStmt: ReturnType<DatabaseSync["prepare"]> | null = null;
65
- private selectAtStmt: ReturnType<DatabaseSync["prepare"]> | null = null;
66
- private dataVersionStmt: ReturnType<DatabaseSync["prepare"]> | null = null;
67
- private lastDataVersion: number | null = null;
68
- private _lastIdx: number;
69
-
70
- constructor(
71
- private readonly dbPath: string,
72
- baseStepIdx = -1,
73
- ) {
74
- this._lastIdx = baseStepIdx;
75
- this.db = openReadOnly(dbPath);
76
- if (this.db) {
77
- this.selectStmt = this.db.prepare(SELECT_STEPS);
78
- this.selectAtStmt = this.db.prepare(SELECT_STEP_AT);
79
- this.dataVersionStmt = this.db.prepare("PRAGMA data_version");
80
- }
81
- }
82
-
83
- /** True if the DB was openable at construction. False means agy hasn't
84
- * created/flushed it yet - call tryOpen() on later polls. */
85
- get isOpen(): boolean {
86
- return this.db !== null;
87
- }
88
-
89
- /** The highest idx seen (or the base passed at construction). Persist this
90
- * across turns so a resumed conversation only streams new steps. */
91
- get lastIdx(): number {
92
- return this._lastIdx;
93
- }
94
-
95
- /** Retry opening the DB if it wasn't ready at construction. Returns the
96
- * new open state. Idempotent. */
97
- tryOpen(): boolean {
98
- if (this.db) return true;
99
- this.db = openReadOnly(this.dbPath);
100
- if (this.db) {
101
- this.selectStmt = this.db.prepare(SELECT_STEPS);
102
- this.selectAtStmt = this.db.prepare(SELECT_STEP_AT);
103
- this.dataVersionStmt = this.db.prepare("PRAGMA data_version");
104
- return true;
105
- }
106
- return false;
107
- }
108
-
109
- /** Returns true when another connection has committed since the last poll.
110
- * When false, readNewSteps() and poll() are guaranteed to return [] and can
111
- * be skipped. Call ONCE per tick: it advances the data_version cursor, so a
112
- * second call in the same tick sees no change. Exposed so the runner can
113
- * gate its in-place step re-read (readStepAt) behind the same check and
114
- * avoid a redundant SELECT every idle tick while agy is thinking. */
115
- hasChanged(): boolean {
116
- if (!this.db || !this.dataVersionStmt) return true; // force a read on first poll
117
- const row = this.dataVersionStmt.get() as { data_version?: number } | undefined;
118
- const v = row?.data_version ?? 0;
119
- if (this.lastDataVersion === null) {
120
- this.lastDataVersion = v;
121
- return true;
122
- }
123
- if (v === this.lastDataVersion) return false;
124
- this.lastDataVersion = v;
125
- return true;
126
- }
127
-
128
- /** Read new steps since the last call WITHOUT re-checking data_version.
129
- * The caller gates this behind hasChanged() so the SELECT only fires when a
130
- * commit actually landed. Returns [] when the DB isn't open or has no new
131
- * rows. Advances the cursor past every successfully-read row. */
132
- readNewSteps(): Step[] {
133
- if (!this.db || !this.selectStmt) return [];
134
- const rows = this.selectStmt.all(this._lastIdx) as Array<{
135
- idx: number;
136
- step_type: number;
137
- status: number;
138
- step_payload: unknown;
139
- }>;
140
- const out: Step[] = [];
141
- let advanced = this._lastIdx;
142
- for (const r of rows) {
143
- try {
144
- out.push({
145
- idx: r.idx,
146
- stepType: r.step_type,
147
- status: r.status,
148
- payload: toUint8(r.step_payload),
149
- });
150
- advanced = r.idx;
151
- } catch {
152
- // Torn read: drop this row, do not advance. Retried next poll.
153
- break;
154
- }
155
- }
156
- this._lastIdx = Math.max(this._lastIdx, advanced);
157
- return out;
158
- }
159
-
160
- /** Convenience: hasChanged() + readNewSteps() in one call. Kept for the
161
- * decode-db diagnostic and any caller that doesn't need the separate
162
- * in-place re-read. */
163
- poll(): Step[] {
164
- return this.hasChanged() ? this.readNewSteps() : [];
165
- }
166
-
167
- /** Read a single step by idx without advancing the cursor. Used to re-check
168
- * the last text/thinking step: agy extends the step it is currently writing
169
- * in place (same idx, growing text), and poll() only returns idx > lastIdx. */
170
- readStepAt(idx: number): Step | null {
171
- if (!this.db || !this.selectAtStmt) return null;
172
- let row;
173
- try {
174
- row = this.selectAtStmt.get(idx) as
175
- | { idx: number; step_type: number; status: number; step_payload: unknown }
176
- | undefined;
177
- } catch {
178
- return null;
179
- }
180
- if (!row) return null;
181
- try {
182
- return {
183
- idx: row.idx,
184
- stepType: row.step_type,
185
- status: row.status,
186
- payload: toUint8(row.step_payload),
187
- };
188
- } catch {
189
- return null;
190
- }
191
- }
192
-
193
- /** Release the DB handle. Safe to call multiple times. */
194
- close(): void {
195
- try {
196
- this.db?.close();
197
- } catch {
198
- // already closed
199
- }
200
- this.db = null;
201
- }
202
- }