@lucid-agents/pi-xgate 0.0.0-stage → 0.1.6

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.
@@ -0,0 +1,178 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { BrokerClient } from "@lucid-agents/xgate-client/broker";
6
+ import { XgateError } from "@lucid-agents/xgate-client/types";
7
+ import { readFileSync, lstatSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, isAbsolute, join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { createXgateProvider } from "./provider.ts";
11
+ import type { XgatePayment, XgateTransport } from "./transport.ts";
12
+ export interface XgateExtensionOptions {
13
+ /**
14
+ * Host-managed transport, or a factory called on load and `/xgate refresh`. When set, the
15
+ * extension reads no broker configuration and `/xgate setup` is unavailable. Without it, the
16
+ * standalone `BrokerClient` from `XGATE_CONFIG` or `~/.pi/agent/xgate.json` is used.
17
+ */
18
+ transport?: XgateTransport | (() => XgateTransport | Promise<XgateTransport>);
19
+ }
20
+ function privateText(path: string, max = 8192): string {
21
+ if (!isAbsolute(path)) throw new XgateError("absolute_config_path_required");
22
+ const s = lstatSync(path);
23
+ if (
24
+ !s.isFile() ||
25
+ s.isSymbolicLink() ||
26
+ (s.mode & 0o077) !== 0 ||
27
+ s.size > max
28
+ )
29
+ throw new XgateError("private_config_required");
30
+ return readFileSync(path, "utf8").trim();
31
+ }
32
+ function standalone(configPath: string): BrokerClient {
33
+ const cfg = JSON.parse(privateText(configPath));
34
+ if (typeof cfg.url !== "string" || typeof cfg.tokenFile !== "string")
35
+ throw new XgateError("invalid_config");
36
+ return new BrokerClient(cfg.url, privateText(cfg.tokenFile, 128));
37
+ }
38
+ /** Rail-neutral: atomic units of the named asset; decimals are not assumed. */
39
+ export function paymentStatus(p: XgatePayment): string {
40
+ const left = p.channel
41
+ ? `; channel ${p.channel.refundableAtomic} refundable`
42
+ : "";
43
+ return `xgate paid ${p.chargedAtomic ?? "?"}/${p.authorizedAtomic} ${p.asset} on ${p.network} (${p.scheme})${left}`;
44
+ }
45
+ /** Pi extension factory. The default export is the standalone wallet-broker extension. */
46
+ export function createXgateExtension(
47
+ options: XgateExtensionOptions = {},
48
+ ): (pi: ExtensionAPI) => Promise<void> {
49
+ return (pi) => register(pi, options);
50
+ }
51
+ export default createXgateExtension();
52
+ async function register(
53
+ pi: ExtensionAPI,
54
+ options: XgateExtensionOptions,
55
+ ): Promise<void> {
56
+ const injected = options.transport;
57
+ const configPath =
58
+ process.env.XGATE_CONFIG ?? join(homedir(), ".pi", "agent", "xgate.json");
59
+ let broker: XgateTransport | undefined;
60
+ let context: ExtensionContext | undefined;
61
+ const load = async () => {
62
+ const next =
63
+ injected === undefined
64
+ ? standalone(configPath)
65
+ : typeof injected === "function"
66
+ ? await injected()
67
+ : injected;
68
+ const provider = await createXgateProvider(next, {
69
+ sessionId: () => context?.sessionManager.getSessionId() ?? "standalone",
70
+ onPaid: (r) =>
71
+ context?.ui.setStatus(
72
+ "xgate",
73
+ r.payment
74
+ ? paymentStatus(r.payment)
75
+ : `xgate paid ${(Number(r.settlement.chargedAtomic) / 1e6).toFixed(6)}`,
76
+ ),
77
+ onPurchaseFailed: (f) =>
78
+ context?.ui.setStatus(
79
+ "xgate",
80
+ `xgate ${f.pending ? "unresolved" : "failed"}: ${f.invocationId}`,
81
+ ),
82
+ });
83
+ pi.registerProvider(provider);
84
+ broker = next;
85
+ };
86
+ try {
87
+ await load();
88
+ } catch {}
89
+ pi.on("session_start", async (_event, ctx) => {
90
+ context = ctx;
91
+ ctx.ui.setStatus(
92
+ "xgate",
93
+ broker ? "xgate wallet ready" : "xgate: /xgate refresh or /xgate setup",
94
+ );
95
+ });
96
+ pi.on("cache_warming_decision", async (event, ctx) =>
97
+ ctx.model?.provider === "xgate" ? { action: "stop" as const } : undefined,
98
+ );
99
+ pi.registerCommand("xgate", {
100
+ description:
101
+ "Set up the wallet broker; show budget or reconcile an existing purchase.",
102
+ handler: async (args, ctx) => {
103
+ context = ctx;
104
+ const [command, id] = args.trim().split(/\s+/);
105
+ try {
106
+ if (command === "setup") {
107
+ if (injected !== undefined)
108
+ throw new XgateError("transport_managed_by_host");
109
+ if (!ctx.hasUI)
110
+ throw new XgateError("configure_XGATE_CONFIG_for_headless_mode");
111
+ const url = await ctx.ui.input(
112
+ "xgate wallet service",
113
+ "http://127.0.0.1:4021",
114
+ );
115
+ if (!url) return;
116
+ const tokenFile = await ctx.ui.input(
117
+ "Absolute path to the broker token file",
118
+ "/private/path/broker-token",
119
+ );
120
+ if (!tokenFile) return;
121
+ const next = new BrokerClient(url, privateText(tokenFile, 128));
122
+ await next.status();
123
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
124
+ writeFileSync(configPath, JSON.stringify({ url, tokenFile }) + "\n", {
125
+ mode: 0o600,
126
+ flag: "wx",
127
+ });
128
+ await load();
129
+ ctx.ui.notify(
130
+ "xgate configured. Select a qualified model with /model.",
131
+ "info",
132
+ );
133
+ return;
134
+ }
135
+ if (command === "refresh") {
136
+ await load();
137
+ ctx.ui.notify("xgate model list refreshed.", "info");
138
+ return;
139
+ }
140
+ if (!broker) throw new XgateError("wallet_not_configured");
141
+ if (command === "reconcile") {
142
+ if (!id) throw new XgateError("purchase_id_required");
143
+ if (!broker.reconcile)
144
+ throw new XgateError("unsupported_by_transport");
145
+ const result = await broker.reconcile(id);
146
+ ctx.ui.notify(JSON.stringify(result), "info");
147
+ return;
148
+ }
149
+ if (command === "receipts") {
150
+ if (!broker.receipts)
151
+ throw new XgateError("unsupported_by_transport");
152
+ ctx.ui.notify(
153
+ JSON.stringify(await broker.receipts(), null, 2),
154
+ "info",
155
+ );
156
+ return;
157
+ }
158
+ if (command === "status" || command === "budget" || !command) {
159
+ if (!broker.status) throw new XgateError("unsupported_by_transport");
160
+ const status = await broker.status();
161
+ ctx.ui.notify(JSON.stringify(status, null, 2), "info");
162
+ return;
163
+ }
164
+ ctx.ui.notify(
165
+ "Use /xgate setup, refresh, status, budget, receipts or reconcile <invocation ID>. Budgets are configured at the wallet service.",
166
+ "info",
167
+ );
168
+ } catch (error) {
169
+ ctx.ui.notify(
170
+ error instanceof XgateError
171
+ ? error.message
172
+ : "xgate setup failed; check the private configuration and wallet service.",
173
+ "error",
174
+ );
175
+ }
176
+ },
177
+ });
178
+ }