@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.
- package/CHANGELOG.md +19 -0
- package/README.md +17 -41
- package/docs/ANTIGRAVITY-INTEGRATIONS.md +2 -0
- package/docs/ARCHITECTURE.md +38 -25
- package/docs/DEVELOPMENT.md +16 -22
- package/docs/PI-BRIDGE-GAPS.md +65 -82
- package/extensions/index.ts +2 -5
- package/package.json +2 -4
- package/src/config.ts +0 -13
- package/src/provider.ts +9 -181
- package/src/poller.ts +0 -202
- package/src/protobuf.ts +0 -184
- package/src/runner.ts +0 -390
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
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.
|
|
4
|
-
"description": "Streaming Gemini provider for pi, built on the agy CLI. Registers antigravity/* models in pi's /model picker
|
|
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
|
-
/**
|
|
249
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|