pi-agnes-tools 0.1.0

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/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # pi-agnes-tools
2
+
3
+ Pi extension that exposes **Agnes AI** image/video generation as callable tools — no model switch required. Pairs with [`pi-agnes`](https://pi.dev/packages/pi-agnes) for model registration / auth.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install path:/path/to/pi-agnes-tools
9
+ ```
10
+
11
+ ## What it registers
12
+
13
+ | Tool | Endpoint | What it does |
14
+ |---|---|---|
15
+ | `agnes_image` | `POST /v1/images/generations` | Generate an image. Saves to `.pi/generated-images/`. Returns the local path and (possibly expiring) remote URL. |
16
+ | `agnes_video` | `POST /v1/videos` + poll | Generate a video. Polls every 5s (up to 30 min). Saves `.mp4` to `.pi/generated-videos/`. Supports image-to-video (1 ref image) and keyframes (>1 image). |
17
+
18
+ ## Parameters
19
+
20
+ ### `agnes_image`
21
+ - `prompt` (required): text prompt
22
+ - `model` (default `agnes-image-2.1-flash`): any `agnes-image-*` id
23
+ - `endpoint` (`agnes` | `agnes-cn`, default `agnes`): which Agnes region
24
+ - `images` (optional): array of base64 data URIs for reference/conditioning
25
+ - `response_format` (default `png`)
26
+
27
+ ### `agnes_video`
28
+ - `prompt` (required): text prompt
29
+ - `model` (default `agnes-video-2.5-flash`): any `agnes-video-*` id
30
+ - `endpoint` (default `agnes`)
31
+ - `images` (optional): 1 image → image-to-video, >1 → keyframes mode
32
+ - `num_frames` (default 121), `frame_rate` (default 24)
33
+
34
+ ## Auth
35
+
36
+ Key resolution order:
37
+
38
+ 1. `AGNES_API_KEY` / `AGNES_CN_API_KEY` env vars (same as `pi-agnes`)
39
+ 2. `/login`-stored key from `~/.pi/agent/auth.json` (`agnes` provider)
40
+
41
+ | Provider | Endpoint | Env var |
42
+ |---|---|---|
43
+ | `agnes` | `https://apihub.agnes-ai.com/v1` | `AGNES_API_KEY` |
44
+ | `agnes-cn` | `https://api.agnes-ai.cn/v1` | `AGNES_CN_API_KEY` |
45
+
46
+ ## Example (what the LLM sees / can call)
47
+
48
+ ```jsonc
49
+ // Text-to-image
50
+ {"name": "agnes_image", "arguments": {"prompt": "a watercolor painting of a fox in a misty pine forest"}}
51
+
52
+ // Image-to-video
53
+ {"name": "agnes_video", "arguments": {"prompt": "the fox slowly turns its head", "images": ["data:image/png;base64,..."]}}
54
+
55
+ // Multi-frame keyframes
56
+ {"name": "agnes_video", "arguments": {"prompt": "fly through a canyon", "images": ["data:...frame1", "data:...frame2"], "model": "agnes-video-2.5"}}
57
+ ```
58
+
59
+ ## Notes
60
+
61
+ - Output paths are project-relative (`.pi/generated-images/`, `.pi/generated-videos/`) — matches where `pi-agnes` model-based generation saves.
62
+ - `executionMode: parallel` for image (fast, stateless), `sequential` for video (long-running task, poll loop).
@@ -0,0 +1,292 @@
1
+ /**
2
+ * pi-agnes-tools
3
+ *
4
+ * Exposes Agnes AI image/video generation as callable tools, so you don't
5
+ * have to switch models just to generate media.
6
+ *
7
+ * • agnes_image — POST /v1/images/generations (agnes-image-2.1-flash etc.)
8
+ * • agnes_video — POST /v1/videos + poll until done (agnes-video-2.5-flash etc.)
9
+ *
10
+ * Auth: reuses AGNES_API_KEY / AGNES_CN_API_KEY (same env vars as pi-agnes).
11
+ * Saves: .pi/generated-images/ and .pi/generated-videos/ (project-relative).
12
+ */
13
+ import { mkdir, writeFile } from "node:fs/promises";
14
+ import { readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { pathToFileURL } from "node:url";
18
+
19
+ // typebox: prefer the pi-bundled copy when available (compiled binary / SEA),
20
+ // fall back to a bare import when running as plain ESM (dev / jiti).
21
+ import * as _typebox from "typebox";
22
+ const Type = _typebox.Type;
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Shared helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const ENDPOINTS = {
29
+ agnes: { baseUrl: "https://apihub.agnes-ai.com/v1", apiKeyEnv: "AGNES_API_KEY", authKey: "agnes" },
30
+ "agnes-cn": { baseUrl: "https://api.agnes-ai.cn/v1", apiKeyEnv: "AGNES_CN_API_KEY", authKey: "agnes-cn" },
31
+ };
32
+
33
+ const IMAGE_MODELS = new Set(["agnes-image-2.0-flash", "agnes-image-2.1-flash"]);
34
+ const VIDEO_MODELS = new Set(["agnes-video-v2.0", "agnes-video-2.5", "agnes-video-2.5-flash"]);
35
+
36
+ function fileLink(p, label = p) {
37
+ return "[" + label + "](" + pathToFileURL(p).href + ")";
38
+ }
39
+
40
+ function resolveApiKey(endpoint) {
41
+ const cfg = ENDPOINTS[endpoint];
42
+ // 1) Environment variable (fastest, always works)
43
+ const envKey = process.env[cfg.apiKeyEnv];
44
+ if (envKey) return envKey;
45
+ // 2) /login-stored key in pi's auth store
46
+ try {
47
+ const authPath = join(homedir(), ".pi", "agent", "auth.json");
48
+ const auth = JSON.parse(readFileSync(authPath, "utf8"));
49
+ // Primary key: 'agnes' for both endpoints (pi-agnes registers one key per
50
+ // provider id, and the CN endpoint uses the same account/key in most setups).
51
+ const entry = auth[cfg.authKey] || auth["agnes"];
52
+ if (entry && entry.key) return entry.key;
53
+ } catch {
54
+ // ignore read/parse failures; fall through
55
+ }
56
+ throw new Error(
57
+ "No API key found for " + cfg.apiKeyEnv + ". Set the " + cfg.apiKeyEnv + " environment variable, or run /login with the pi-agnes provider."
58
+ );
59
+ }
60
+
61
+ function getEndpoint(endpoint) {
62
+ const cfg = ENDPOINTS[endpoint];
63
+ const apiKey = resolveApiKey(endpoint);
64
+ return { baseUrl: cfg.baseUrl, apiKey, headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" } };
65
+ }
66
+
67
+ function checkImageModel(model) {
68
+ if (!IMAGE_MODELS.has(model) && !model.startsWith("agnes-image-")) {
69
+ throw new Error("Unknown Agnes image model: " + model + ". Known models: " + [...IMAGE_MODELS].join(", "));
70
+ }
71
+ }
72
+
73
+ function checkVideoModel(model) {
74
+ if (!VIDEO_MODELS.has(model) && !model.startsWith("agnes-video-")) {
75
+ throw new Error("Unknown Agnes video model: " + model + ". Known models: " + [...VIDEO_MODELS].join(", "));
76
+ }
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // agnes_image tool
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const imageParams = Type.Object({
84
+ prompt: { type: "string", description: "Text prompt describing the image to generate." },
85
+ model: {
86
+ type: "string",
87
+ description: "Agnes image model id. One of: " + [...IMAGE_MODELS].join(", ") + ". Default: agnes-image-2.1-flash.",
88
+ },
89
+ endpoint: {
90
+ type: "string",
91
+ enum: ["agnes", "agnes-cn"],
92
+ description: "Which Agnes endpoint to use. Default: agnes (apihub.agnes-ai.com).",
93
+ },
94
+ images: {
95
+ type: "array",
96
+ items: { type: "string" },
97
+ description:
98
+ "Optional list of base64-encoded image data URIs (data:<mime>;base64,<data>) to use as reference/conditioning images.",
99
+ },
100
+ response_format: {
101
+ type: "string",
102
+ description: "Response image format. Default: png.",
103
+ },
104
+ });
105
+
106
+ async function executeImage(_toolCallId, params) {
107
+ const prompt = params.prompt;
108
+ const rawModel = params.model || "agnes-image-2.1-flash";
109
+ const endpointId = params.endpoint || "agnes";
110
+ const images = params.images || [];
111
+ const response_format = params.response_format || "png";
112
+
113
+ checkImageModel(rawModel);
114
+ const { baseUrl, headers } = getEndpoint(endpointId);
115
+
116
+ const body = { model: rawModel, prompt, response_format };
117
+ if (images.length > 0) {
118
+ body.image = images;
119
+ }
120
+
121
+ const response = await fetch(baseUrl + "/images/generations", {
122
+ method: "POST",
123
+ headers,
124
+ body: JSON.stringify(body),
125
+ });
126
+ const payload = await response.json().catch(() => null);
127
+ if (!response.ok) {
128
+ throw new Error((payload && payload.error && payload.error.message) || "Agnes image API HTTP " + response.status);
129
+ }
130
+ const image = payload && payload.data && payload.data[0];
131
+ if (!image) throw new Error("Agnes image API returned no image data");
132
+
133
+ const directory = join(process.cwd(), ".pi", "generated-images");
134
+ await mkdir(directory, { recursive: true });
135
+ const mime = image.mime_type || "image/png";
136
+ const ext =
137
+ mime.includes("png") ? "png" : mime.includes("jpeg") ? "jpg" : mime.includes("webp") ? "webp" : mime.includes("gif") ? "gif" : "png";
138
+ const filePath = join(directory, rawModel + "-" + Date.now() + "." + ext);
139
+ if (image.b64_json) {
140
+ await writeFile(filePath, Buffer.from(image.b64_json, "base64"));
141
+ } else if (image.url) {
142
+ const imgRes = await fetch(image.url);
143
+ if (!imgRes.ok) throw new Error("Unable to download image: HTTP " + imgRes.status);
144
+ await writeFile(filePath, Buffer.from(await imgRes.arrayBuffer()));
145
+ } else {
146
+ throw new Error("Agnes image API returned no url or b64_json");
147
+ }
148
+
149
+ const text = image.url
150
+ ? "![](" + image.url + ")\n\nSaved local copy: " + fileLink(filePath) + "\n\nImage URL may expire according to Agnes retention policy."
151
+ : "Generated image saved to: " + fileLink(filePath);
152
+
153
+ return {
154
+ content: [{ type: "text", text }],
155
+ details: { filePath, model: rawModel, endpoint: endpointId, remoteUrl: image.url || null },
156
+ };
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // agnes_video tool
161
+ // ---------------------------------------------------------------------------
162
+
163
+ const videoParams = Type.Object({
164
+ prompt: { type: "string", description: "Text prompt describing the video to generate." },
165
+ model: {
166
+ type: "string",
167
+ description: "Agnes video model id. One of: " + [...VIDEO_MODELS].join(", ") + ". Default: agnes-video-2.5-flash.",
168
+ },
169
+ endpoint: {
170
+ type: "string",
171
+ enum: ["agnes", "agnes-cn"],
172
+ description: "Which Agnes endpoint to use. Default: agnes (apihub.agnes-ai.com).",
173
+ },
174
+ images: {
175
+ type: "array",
176
+ items: { type: "string" },
177
+ description: "Optional reference image(s) as base64 data URIs. 1 image = image-to-video; >1 = keyframes mode.",
178
+ },
179
+ num_frames: { type: "integer", description: "Number of frames. Default: 121." },
180
+ frame_rate: { type: "integer", description: "Frames per second. Default: 24." },
181
+ });
182
+
183
+ async function pollVideo(baseUrl, videoId, apiKey, signal) {
184
+ const deadline = Date.now() + 30 * 60 * 1000;
185
+ while (Date.now() < deadline) {
186
+ if (signal && signal.aborted) throw new Error("Video generation aborted");
187
+ await new Promise((resolve, reject) => {
188
+ const timer = setTimeout(resolve, 5000);
189
+ if (signal) {
190
+ signal.addEventListener(
191
+ "abort",
192
+ () => {
193
+ clearTimeout(timer);
194
+ reject(new Error("Video generation aborted"));
195
+ },
196
+ { once: true }
197
+ );
198
+ }
199
+ });
200
+ const apiRoot = baseUrl.replace(/\/v1\/?$/, "");
201
+ const response = await fetch(apiRoot + "/agnesapi?video_id=" + encodeURIComponent(videoId), {
202
+ headers: { Authorization: "Bearer " + apiKey },
203
+ signal,
204
+ });
205
+ const payload = await response.json().catch(() => null);
206
+ if (!response.ok) {
207
+ throw new Error((payload && payload.error && payload.error.message) || "Agnes video status HTTP " + response.status);
208
+ }
209
+ if (payload.status === "completed") return payload;
210
+ if (payload.status === "failed") {
211
+ throw new Error((payload && payload.error && payload.error.message) || "Agnes video generation failed");
212
+ }
213
+ }
214
+ throw new Error("Agnes video generation timed out after 30 minutes");
215
+ }
216
+
217
+ async function executeVideo(_toolCallId, params, signal) {
218
+ const prompt = params.prompt;
219
+ const rawModel = params.model || "agnes-video-2.5-flash";
220
+ const endpointId = params.endpoint || "agnes";
221
+ const images = params.images || [];
222
+ const num_frames = params.num_frames || 121;
223
+ const frame_rate = params.frame_rate || 24;
224
+
225
+ checkVideoModel(rawModel);
226
+ const { baseUrl, apiKey, headers } = getEndpoint(endpointId);
227
+
228
+ const body = { model: rawModel, prompt, num_frames, frame_rate };
229
+ if (images.length === 1) body.image = images[0];
230
+ if (images.length > 1) body.extra_body = { image: images, mode: "keyframes" };
231
+
232
+ const response = await fetch(baseUrl + "/videos", {
233
+ method: "POST",
234
+ headers,
235
+ body: JSON.stringify(body),
236
+ signal,
237
+ });
238
+ const task = await response.json().catch(() => null);
239
+ if (!response.ok) {
240
+ throw new Error((task && task.error && task.error.message) || "Agnes video API HTTP " + response.status);
241
+ }
242
+ const videoId = (task && (task.video_id || task.id || task.task_id)) || null;
243
+ if (!videoId) throw new Error("Agnes video API returned no video_id");
244
+
245
+ const result = task.status === "completed" ? task : await pollVideo(baseUrl, videoId, apiKey, signal);
246
+ const url = result && result.metadata && result.metadata.url;
247
+ if (!url) throw new Error("Agnes video API returned no metadata.url");
248
+
249
+ const directory = join(process.cwd(), ".pi", "generated-videos");
250
+ await mkdir(directory, { recursive: true });
251
+ const filePath = join(directory, rawModel + "-" + Date.now() + ".mp4");
252
+ const videoRes = await fetch(url);
253
+ if (!videoRes.ok) throw new Error("Unable to download generated video: HTTP " + videoRes.status);
254
+ await writeFile(filePath, Buffer.from(await videoRes.arrayBuffer()));
255
+
256
+ const text = "Generated video saved to: " + fileLink(filePath) + "\n\nVideo URL: " + url;
257
+ return {
258
+ content: [{ type: "text", text }],
259
+ details: { filePath, model: rawModel, endpoint: endpointId, remoteUrl: url },
260
+ };
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Extension entry
265
+ // ---------------------------------------------------------------------------
266
+
267
+ export default function (pi) {
268
+ pi.registerTool({
269
+ name: "agnes_image",
270
+ label: "Agnes Image",
271
+ description:
272
+ "Generate an image via the Agnes AI API (no model switch needed). " +
273
+ "Saves a local copy under .pi/generated-images/ and returns the saved path plus the (possibly expiring) remote URL. " +
274
+ "Auth: AGNES_API_KEY (default endpoint) or AGNES_CN_API_KEY (endpoint=agnes-cn).",
275
+ parameters: imageParams,
276
+ executionMode: "parallel",
277
+ execute: executeImage,
278
+ });
279
+
280
+ pi.registerTool({
281
+ name: "agnes_video",
282
+ label: "Agnes Video",
283
+ description:
284
+ "Generate a video via the Agnes AI API (no model switch needed). " +
285
+ "Async: creates a task, polls every 5s until done (up to 30 min), then downloads the .mp4 to .pi/generated-videos/. " +
286
+ "Supports image-to-video (1 reference image) and keyframes mode (>1 image). " +
287
+ "Auth: AGNES_API_KEY (default endpoint) or AGNES_CN_API_KEY (endpoint=agnes-cn).",
288
+ parameters: videoParams,
289
+ executionMode: "sequential",
290
+ execute: executeVideo,
291
+ });
292
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "pi-agnes-tools",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that exposes Agnes AI image/video generation as callable tools (no model switch needed). Pairs with pi-agnes for auth.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi",
8
+ "extension",
9
+ "pi-package",
10
+ "agnes",
11
+ "agnes-ai",
12
+ "image-generation",
13
+ "video-generation",
14
+ "tool"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/DraconDev/pi-agnes-tools.git"
20
+ },
21
+ "files": [
22
+ "extensions",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "*",
30
+ "typebox": "*"
31
+ },
32
+ "dependencies": {},
33
+ "pi": {
34
+ "extensions": [
35
+ "./extensions"
36
+ ]
37
+ }
38
+ }