omegon 0.6.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/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
// @secret HF_TOKEN "HuggingFace token (gated model access for FLUX.1)"
|
|
2
|
+
// @config DIFFUSION_CLI_DIR "Path to uv project with mflux installed" [default: ~/diffusion-cli]
|
|
3
|
+
// @config PI_VISUALS_DIR "Output directory for generated images and diagrams" [default: ~/.pi/visuals]
|
|
4
|
+
// @config EXCALIDRAW_RENDER_DIR "Path to Excalidraw render pipeline (uv + playwright)" [default: <omegon>/extensions/render/excalidraw-renderer]
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* render — Visual rendering extension for pi
|
|
8
|
+
*
|
|
9
|
+
* Provides six tools:
|
|
10
|
+
* - generate_image_local: AI image generation via FLUX.1 (mflux, Apple Silicon MLX)
|
|
11
|
+
* - render_diagram: D2 diagram rendering via d2 CLI
|
|
12
|
+
* - render_native_diagram: constrained motif-based JSON → native SVG (optionally PNG)
|
|
13
|
+
* - render_excalidraw: Excalidraw JSON → PNG via Playwright + headless Chromium
|
|
14
|
+
* - render_composition_still: React composition → single PNG frame via Satori + resvg
|
|
15
|
+
* - render_composition_video: React composition → animated GIF/MP4 via Satori + gifenc/FFmpeg
|
|
16
|
+
*
|
|
17
|
+
* All tools save output to ~/.pi/visuals/ for persistence across sessions.
|
|
18
|
+
*
|
|
19
|
+
* Requirements:
|
|
20
|
+
* generate_image_local:
|
|
21
|
+
* - Apple Silicon Mac with sufficient RAM (16GB+ quantized, 32GB+ full)
|
|
22
|
+
* - uv + mflux installed (set DIFFUSION_CLI_DIR or use ~/diffusion-cli default)
|
|
23
|
+
* - HuggingFace token for gated models: /secrets configure HF_TOKEN
|
|
24
|
+
* render_diagram:
|
|
25
|
+
* - d2 CLI (installed via Nix or brew)
|
|
26
|
+
* - Falls back to syntax-highlighted source if d2 is not installed
|
|
27
|
+
* render_native_diagram:
|
|
28
|
+
* - Uses in-process SVG serialization and Node-native PNG rasterization via resvg-js
|
|
29
|
+
* - No browser or Playwright runtime required
|
|
30
|
+
* render_excalidraw:
|
|
31
|
+
* - uv + playwright + chromium for PNG rendering of existing .excalidraw files
|
|
32
|
+
* - First-time setup: cd <EXCALIDRAW_RENDER_DIR> && uv sync && uv run playwright install chromium
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
36
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
37
|
+
import { readFile } from "node:fs/promises";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { join, basename, resolve, isAbsolute } from "node:path";
|
|
40
|
+
import { StringEnum } from "../lib/typebox-helpers";
|
|
41
|
+
import {
|
|
42
|
+
NATIVE_DIAGRAM_DIRECTIONS,
|
|
43
|
+
NATIVE_DIAGRAM_MOTIFS,
|
|
44
|
+
parseNativeDiagramSpec,
|
|
45
|
+
composeNativeDiagram,
|
|
46
|
+
rasterizeSvgToPng,
|
|
47
|
+
} from "./native-diagrams/index.ts";
|
|
48
|
+
import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
|
|
49
|
+
import { Type } from "@sinclair/typebox";
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Shared output directory
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const VISUALS_DIR = process.env.PI_VISUALS_DIR || join(homedir(), ".pi", "visuals");
|
|
56
|
+
|
|
57
|
+
function ensureVisualsDir() {
|
|
58
|
+
mkdirSync(VISUALS_DIR, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function visualsPath(filename: string): string {
|
|
62
|
+
ensureVisualsDir();
|
|
63
|
+
return join(VISUALS_DIR, filename);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function timestamp(): string {
|
|
67
|
+
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasCmd(cmd: string): boolean {
|
|
71
|
+
try {
|
|
72
|
+
execSync(`which ${cmd}`, { stdio: "ignore" });
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Diffusion config
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const DIFFUSION_CLI_DIR = process.env.DIFFUSION_CLI_DIR || join(homedir(), "diffusion-cli");
|
|
84
|
+
|
|
85
|
+
// Excalidraw renderer lives alongside this extension.
|
|
86
|
+
const EXCALIDRAW_RENDER_DIR = process.env.EXCALIDRAW_RENDER_DIR ||
|
|
87
|
+
join(import.meta.dirname ?? __dirname, "excalidraw-renderer");
|
|
88
|
+
|
|
89
|
+
// Composition renderer: Satori + resvg + gifenc pipeline
|
|
90
|
+
const COMPOSITION_DIR = join(import.meta.dirname ?? __dirname, "composition");
|
|
91
|
+
|
|
92
|
+
const PRESETS = ["schnell", "dev", "dev-fast", "diagram", "portrait", "wide"] as const;
|
|
93
|
+
|
|
94
|
+
const PRESET_DESCRIPTIONS: Record<(typeof PRESETS)[number], string> = {
|
|
95
|
+
schnell: "FLUX.1-schnell — fastest, ~10s, 4 steps",
|
|
96
|
+
dev: "FLUX.1-dev — high quality, ~60s, 25 steps",
|
|
97
|
+
"dev-fast": "FLUX.1-dev — balanced, ~30s, 12 steps",
|
|
98
|
+
diagram: "Optimized for technical diagrams (1024x768)",
|
|
99
|
+
portrait: "Portrait orientation (768x1024), high quality",
|
|
100
|
+
wide: "Cinematic wide (1344x768), fast",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const PRESET_DEFAULTS: Record<string, { model: string; steps: number; guidance: number; width: number; height: number }> = {
|
|
104
|
+
schnell: { model: "schnell", steps: 4, guidance: 0.0, width: 1024, height: 1024 },
|
|
105
|
+
dev: { model: "dev", steps: 25, guidance: 3.5, width: 1024, height: 1024 },
|
|
106
|
+
"dev-fast": { model: "dev", steps: 12, guidance: 3.5, width: 1024, height: 1024 },
|
|
107
|
+
diagram: { model: "schnell", steps: 4, guidance: 0.0, width: 1024, height: 768 },
|
|
108
|
+
portrait: { model: "dev", steps: 25, guidance: 3.5, width: 768, height: 1024 },
|
|
109
|
+
wide: { model: "schnell", steps: 4, guidance: 0.0, width: 1344, height: 768 },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function ensureExcalidrawRendererReady(): string {
|
|
113
|
+
const renderScript = join(EXCALIDRAW_RENDER_DIR, "render_excalidraw.py");
|
|
114
|
+
if (!existsSync(renderScript)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Excalidraw render script not found at ${renderScript}.\n` +
|
|
117
|
+
`Expected at: ${EXCALIDRAW_RENDER_DIR}/render_excalidraw.py`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const uvLock = join(EXCALIDRAW_RENDER_DIR, "uv.lock");
|
|
122
|
+
if (!existsSync(uvLock)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Excalidraw renderer not set up. Run:\n` +
|
|
125
|
+
` cd ${EXCALIDRAW_RENDER_DIR} && uv sync && uv run playwright install chromium`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return renderScript;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function renderExcalidrawFile(
|
|
133
|
+
pi: ExtensionAPI,
|
|
134
|
+
excalidrawPath: string,
|
|
135
|
+
outPng: string,
|
|
136
|
+
scale: number,
|
|
137
|
+
signal?: AbortSignal,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const renderScript = ensureExcalidrawRendererReady();
|
|
140
|
+
const result = await pi.exec(
|
|
141
|
+
"uv",
|
|
142
|
+
["run", "python", renderScript, excalidrawPath, "--output", outPng, "--scale", String(scale)],
|
|
143
|
+
{ signal, timeout: 60_000, cwd: EXCALIDRAW_RENDER_DIR },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (result.code !== 0) {
|
|
147
|
+
const stderr = result.stderr || "";
|
|
148
|
+
if (stderr.includes("playwright not installed") || stderr.includes("Chromium not installed")) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Excalidraw renderer needs setup:\n` +
|
|
151
|
+
` cd ${EXCALIDRAW_RENDER_DIR} && uv sync && uv run playwright install chromium`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`Render failed (exit ${result.code}):\n${stderr.slice(-1500)}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!existsSync(outPng) || statSync(outPng).size === 0) {
|
|
158
|
+
throw new Error(`Render produced no output at ${outPng}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Extension
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export default function renderExtension(pi: ExtensionAPI) {
|
|
167
|
+
|
|
168
|
+
// ------------------------------------------------------------------
|
|
169
|
+
// generate_image_local — FLUX.1 via mflux on Apple Silicon
|
|
170
|
+
// ------------------------------------------------------------------
|
|
171
|
+
pi.registerTool({
|
|
172
|
+
name: "generate_image_local",
|
|
173
|
+
label: "Generate Image (Local)",
|
|
174
|
+
description: [
|
|
175
|
+
"Generate an image locally on Apple Silicon using FLUX.1 via MLX.",
|
|
176
|
+
"Returns the generated image inline. Runs entirely on-device, no cloud API needed.",
|
|
177
|
+
"Output is saved to ~/.pi/visuals/ for persistence.",
|
|
178
|
+
"",
|
|
179
|
+
"Presets:",
|
|
180
|
+
...PRESETS.map((p) => ` ${p}: ${PRESET_DESCRIPTIONS[p]}`),
|
|
181
|
+
"",
|
|
182
|
+
"For technical diagrams, use the 'diagram' preset.",
|
|
183
|
+
"For fast iteration, use 'schnell'. For quality, use 'dev'.",
|
|
184
|
+
"Quantize to 4 or 8 bits to reduce memory usage and speed up generation.",
|
|
185
|
+
].join("\n"),
|
|
186
|
+
promptSnippet: "Generate images locally via FLUX.1 on Apple Silicon (no cloud API)",
|
|
187
|
+
promptGuidelines: [
|
|
188
|
+
"Use 'diagram' preset for technical diagrams, 'schnell' for fast iteration, 'dev' for quality",
|
|
189
|
+
"Quantize to 4 or 8 bits to reduce memory usage and speed up generation",
|
|
190
|
+
],
|
|
191
|
+
|
|
192
|
+
parameters: Type.Object({
|
|
193
|
+
prompt: Type.String({ description: "Text prompt describing the image to generate" }),
|
|
194
|
+
preset: Type.Optional(StringEnum(PRESETS, { description: "Generation preset. Default: schnell" })),
|
|
195
|
+
width: Type.Optional(Type.Number({ description: "Image width in pixels (multiple of 64)" })),
|
|
196
|
+
height: Type.Optional(Type.Number({ description: "Image height in pixels (multiple of 64)" })),
|
|
197
|
+
steps: Type.Optional(Type.Number({ description: "Number of diffusion steps" })),
|
|
198
|
+
guidance: Type.Optional(Type.Number({ description: "Classifier-free guidance scale" })),
|
|
199
|
+
seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility" })),
|
|
200
|
+
quantize: Type.Optional(StringEnum(["3", "4", "5", "6", "8"] as const, { description: "Quantization bits (lower = faster/less VRAM)" })),
|
|
201
|
+
model: Type.Optional(Type.String({ description: "Override model (HuggingFace repo or local path)" })),
|
|
202
|
+
}),
|
|
203
|
+
|
|
204
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
205
|
+
if (!existsSync(DIFFUSION_CLI_DIR)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`diffusion-cli not found at ${DIFFUSION_CLI_DIR}. ` +
|
|
208
|
+
`Set it up with: uv init ~/diffusion-cli && cd ~/diffusion-cli && uv add mflux\n` +
|
|
209
|
+
`Or set DIFFUSION_CLI_DIR to point to an existing mflux project.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const preset = params.preset || "schnell";
|
|
214
|
+
const defaults = PRESET_DEFAULTS[preset] || PRESET_DEFAULTS.schnell;
|
|
215
|
+
const modelName = params.model || defaults.model;
|
|
216
|
+
|
|
217
|
+
const mfluxBin = join(DIFFUSION_CLI_DIR, ".venv", "bin", "mflux-generate");
|
|
218
|
+
const slug = params.prompt.slice(0, 40).replace(/[^a-zA-Z0-9]/g, "_");
|
|
219
|
+
const outputPath = visualsPath(`${timestamp()}_${slug}.png`);
|
|
220
|
+
|
|
221
|
+
const args = [
|
|
222
|
+
"--model", modelName,
|
|
223
|
+
"--prompt", params.prompt,
|
|
224
|
+
"--width", String(params.width || defaults.width),
|
|
225
|
+
"--height", String(params.height || defaults.height),
|
|
226
|
+
"--steps", String(params.steps || defaults.steps),
|
|
227
|
+
"--guidance", String(params.guidance ?? defaults.guidance),
|
|
228
|
+
"--output", outputPath,
|
|
229
|
+
"--metadata",
|
|
230
|
+
];
|
|
231
|
+
if (params.seed !== undefined) args.push("--seed", String(params.seed));
|
|
232
|
+
if (params.quantize) args.push("--quantize", params.quantize);
|
|
233
|
+
|
|
234
|
+
onUpdate?.({
|
|
235
|
+
content: [{ type: "text", text: `Generating with ${modelName} (${preset})…` }],
|
|
236
|
+
details: { preset, model: modelName },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const startTime = Date.now();
|
|
240
|
+
const result = await pi.exec(mfluxBin, args, { signal, timeout: 600_000 });
|
|
241
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
242
|
+
|
|
243
|
+
if (result.code !== 0) {
|
|
244
|
+
const stderr = result.stderr || "";
|
|
245
|
+
if (stderr.includes("GatedRepoError") || stderr.includes("401")) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
"HuggingFace authentication required. The model is gated.\n" +
|
|
248
|
+
"1. Accept the license at https://huggingface.co/black-forest-labs/FLUX.1-schnell\n" +
|
|
249
|
+
"2. Run: /secrets configure HF_TOKEN (paste your HuggingFace access token)\n" +
|
|
250
|
+
"3. Retry the generation."
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`mflux-generate failed (exit ${result.code}):\n${stderr.slice(-1500)}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!existsSync(outputPath)) {
|
|
257
|
+
throw new Error(`Image was not created at ${outputPath}. Stdout: ${result.stdout?.slice(-500)}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const imageBuffer = await readFile(outputPath);
|
|
261
|
+
const base64Data = imageBuffer.toString("base64");
|
|
262
|
+
|
|
263
|
+
const w = params.width || defaults.width;
|
|
264
|
+
const h = params.height || defaults.height;
|
|
265
|
+
const summary = [
|
|
266
|
+
`Generated in ${elapsed}s via mflux/${modelName} (${preset}).`,
|
|
267
|
+
`Resolution: ${w}×${h}`,
|
|
268
|
+
params.seed !== undefined ? `Seed: ${params.seed}` : "",
|
|
269
|
+
params.quantize ? `Quantized: ${params.quantize}-bit` : "",
|
|
270
|
+
`Saved: ${outputPath}`,
|
|
271
|
+
].filter(Boolean).join(" · ");
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
content: [
|
|
275
|
+
{ type: "text", text: summary },
|
|
276
|
+
{ type: "image", data: base64Data, mimeType: "image/png" },
|
|
277
|
+
],
|
|
278
|
+
details: { preset, model: modelName, elapsed: Number(elapsed), outputPath, width: w, height: h, seed: params.seed, quantize: params.quantize },
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ------------------------------------------------------------------
|
|
284
|
+
// render_diagram — D2 code → inline PNG via d2 CLI
|
|
285
|
+
// ------------------------------------------------------------------
|
|
286
|
+
pi.registerTool({
|
|
287
|
+
name: "render_diagram",
|
|
288
|
+
label: "Render Diagram",
|
|
289
|
+
description:
|
|
290
|
+
"Render a D2 diagram as an inline PNG image in the terminal. " +
|
|
291
|
+
"D2 is a modern declarative diagramming language (https://d2lang.com). " +
|
|
292
|
+
"Use for architecture diagrams, flowcharts, ER diagrams, sequence diagrams, " +
|
|
293
|
+
"class diagrams, state machines, and any structural diagram. " +
|
|
294
|
+
"Output is saved to ~/.pi/visuals/ for persistence. " +
|
|
295
|
+
"Requires d2 CLI (installed via Nix or brew).",
|
|
296
|
+
promptSnippet: "Render D2 diagrams as inline images (flowcharts, ER, sequence, architecture, etc.)",
|
|
297
|
+
promptGuidelines: [
|
|
298
|
+
"Use D2 syntax, NOT Mermaid. D2 reference: https://d2lang.com/tour/intro",
|
|
299
|
+
"Use --theme 200 (dark) and --layout elk for best results",
|
|
300
|
+
"Apply Alpharius semantic colors via style blocks: fill, stroke, font-color",
|
|
301
|
+
],
|
|
302
|
+
parameters: Type.Object({
|
|
303
|
+
code: Type.String({ description: "D2 diagram source code" }),
|
|
304
|
+
title: Type.Optional(Type.String({ description: "Optional title for the diagram" })),
|
|
305
|
+
layout: Type.Optional(StringEnum(["dagre", "elk"] as const, { description: "Layout engine (default: elk)" })),
|
|
306
|
+
theme: Type.Optional(Type.Number({ description: "D2 theme ID (default: 200 = dark)" })),
|
|
307
|
+
sketch: Type.Optional(Type.Boolean({ description: "Sketch/hand-drawn mode (default: false)" })),
|
|
308
|
+
}),
|
|
309
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
310
|
+
if (!hasCmd("d2")) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
"d2 CLI not found. Install via Nix (nix profile install nixpkgs#d2) " +
|
|
313
|
+
"or brew (brew install d2)."
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const slug = (params.title || "diagram").replace(/[^a-zA-Z0-9]/g, "_").slice(0, 40);
|
|
318
|
+
const d2Path = visualsPath(`${timestamp()}_${slug}.d2`);
|
|
319
|
+
const outPng = d2Path.replace(/\.d2$/, ".png");
|
|
320
|
+
writeFileSync(d2Path, params.code, "utf-8");
|
|
321
|
+
|
|
322
|
+
const titlePrefix = params.title ? `# ${params.title}\n\n` : "";
|
|
323
|
+
const layout = params.layout ?? "elk";
|
|
324
|
+
const theme = params.theme ?? 200; // dark theme
|
|
325
|
+
|
|
326
|
+
const args = [
|
|
327
|
+
"-l", layout,
|
|
328
|
+
"-t", String(theme),
|
|
329
|
+
"--pad", "40",
|
|
330
|
+
];
|
|
331
|
+
if (params.sketch) args.push("--sketch");
|
|
332
|
+
args.push(d2Path, outPng);
|
|
333
|
+
|
|
334
|
+
onUpdate?.({
|
|
335
|
+
content: [{ type: "text", text: `Rendering D2 diagram (${layout})…` }],
|
|
336
|
+
details: { d2Path, layout, theme },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const startTime = Date.now();
|
|
340
|
+
try {
|
|
341
|
+
const result = await pi.exec("d2", args, { signal, timeout: 30_000 });
|
|
342
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
343
|
+
|
|
344
|
+
if (result.code !== 0) {
|
|
345
|
+
const stderr = result.stderr || "";
|
|
346
|
+
throw new Error(`d2 failed (exit ${result.code}):\n${stderr.slice(-1500)}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!existsSync(outPng) || statSync(outPng).size === 0) {
|
|
350
|
+
throw new Error(`d2 produced no output at ${outPng}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const data = readFileSync(outPng).toString("base64");
|
|
354
|
+
return {
|
|
355
|
+
content: [
|
|
356
|
+
{ type: "text", text: `${titlePrefix}📊 D2 (${layout}, ${elapsed}s) · Saved: ${outPng}` },
|
|
357
|
+
{ type: "image", data, mimeType: "image/png" },
|
|
358
|
+
],
|
|
359
|
+
details: { rendered: true, d2Path, pngPath: outPng, layout, theme, elapsed: Number(elapsed) },
|
|
360
|
+
};
|
|
361
|
+
} catch (err: any) {
|
|
362
|
+
// Fallback: show source
|
|
363
|
+
return {
|
|
364
|
+
content: [{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: `${titlePrefix}📊 D2 source (render failed: ${err.message}) · Saved: ${d2Path}\n\n\`\`\`d2\n${params.code}\n\`\`\``,
|
|
367
|
+
}],
|
|
368
|
+
details: { rendered: false, d2Path, error: err.message },
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ------------------------------------------------------------------
|
|
375
|
+
// render_native_diagram — constrained motif JSON → native SVG/PNG
|
|
376
|
+
// ------------------------------------------------------------------
|
|
377
|
+
pi.registerTool({
|
|
378
|
+
name: "render_native_diagram",
|
|
379
|
+
label: "Render Native Diagram",
|
|
380
|
+
description:
|
|
381
|
+
"Render a document-bound technical diagram from a constrained motif-based JSON spec. " +
|
|
382
|
+
"Writes SVG directly and can optionally rasterize to PNG using a Node-native path. " +
|
|
383
|
+
"Use for deterministic page-friendly diagrams with canonical layouts such as pipeline, fanout, and panel-split.",
|
|
384
|
+
promptSnippet: "Render constrained motif-based native SVG/PNG diagrams for document-bound technical visuals",
|
|
385
|
+
promptGuidelines: [
|
|
386
|
+
"Use this tool for tightly scoped document-bound technical diagrams that fit canonical motifs.",
|
|
387
|
+
"Provide constrained JSON with motif, nodes, optional edges, and panels only for panel-split.",
|
|
388
|
+
"Supported motifs are intentionally narrow: pipeline, fanout, and panel-split.",
|
|
389
|
+
"Prefer this backend when deterministic SVG output and Node-native PNG export matter more than freeform canvas editing.",
|
|
390
|
+
],
|
|
391
|
+
parameters: Type.Object({
|
|
392
|
+
spec_json: Type.String({ description: "Constrained native-diagram JSON describing the motif, nodes, edges, and optional panels" }),
|
|
393
|
+
title: Type.Optional(Type.String({ description: "Optional file title override" })),
|
|
394
|
+
render_png: Type.Optional(Type.Boolean({ description: "Rasterize the generated SVG to PNG (default: true)" })),
|
|
395
|
+
}),
|
|
396
|
+
async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
|
|
397
|
+
let parsed: unknown;
|
|
398
|
+
try {
|
|
399
|
+
parsed = JSON.parse(params.spec_json);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
402
|
+
throw new Error(`Invalid spec_json: ${message}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const spec = parseNativeDiagramSpec(parsed);
|
|
406
|
+
const { scene, svg } = composeNativeDiagram(spec);
|
|
407
|
+
const slugSource = params.title || spec.title || spec.motif;
|
|
408
|
+
const slug = slugSource.replace(/[^a-zA-Z0-9]/g, "_").slice(0, 40);
|
|
409
|
+
const svgPath = visualsPath(`${timestamp()}_${slug}.svg`);
|
|
410
|
+
writeFileSync(svgPath, `${svg}\n`, "utf-8");
|
|
411
|
+
|
|
412
|
+
if (params.render_png === false) {
|
|
413
|
+
return {
|
|
414
|
+
content: [{ type: "text", text: `Rendered native diagram (${spec.motif}). SVG saved: ${svgPath}` }],
|
|
415
|
+
details: { rendered: false, svgPath, motif: spec.motif, width: scene.width, height: scene.height },
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
onUpdate?.({
|
|
420
|
+
content: [{ type: "text", text: `Rendering native ${spec.motif} diagram…` }],
|
|
421
|
+
details: { svgPath, motif: spec.motif },
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const pngPath = visualsPath(`${timestamp()}_${slug}.png`);
|
|
425
|
+
const pngBuffer = rasterizeSvgToPng(svg);
|
|
426
|
+
writeFileSync(pngPath, pngBuffer);
|
|
427
|
+
const data = pngBuffer.toString("base64");
|
|
428
|
+
const titlePrefix = (params.title || spec.title) ? `# ${params.title || spec.title}\n\n` : "";
|
|
429
|
+
return {
|
|
430
|
+
content: [
|
|
431
|
+
{ type: "text", text: `${titlePrefix}🧭 Native diagram (${spec.motif}) · SVG: ${svgPath} · PNG: ${pngPath}` },
|
|
432
|
+
{ type: "image", data, mimeType: "image/png" },
|
|
433
|
+
],
|
|
434
|
+
details: {
|
|
435
|
+
rendered: true,
|
|
436
|
+
motif: spec.motif,
|
|
437
|
+
supportedMotifs: [...NATIVE_DIAGRAM_MOTIFS],
|
|
438
|
+
supportedDirections: [...NATIVE_DIAGRAM_DIRECTIONS],
|
|
439
|
+
svgPath,
|
|
440
|
+
pngPath,
|
|
441
|
+
width: scene.width,
|
|
442
|
+
height: scene.height,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ------------------------------------------------------------------
|
|
449
|
+
// render_excalidraw — Excalidraw JSON → PNG via Playwright
|
|
450
|
+
// ------------------------------------------------------------------
|
|
451
|
+
pi.registerTool({
|
|
452
|
+
name: "render_excalidraw",
|
|
453
|
+
label: "Render Excalidraw",
|
|
454
|
+
description:
|
|
455
|
+
"Render an .excalidraw JSON file to PNG using Playwright + headless Chromium. " +
|
|
456
|
+
"Takes a path to an existing .excalidraw file, renders it, and returns the PNG inline. " +
|
|
457
|
+
"Output is saved to ~/.pi/visuals/. " +
|
|
458
|
+
"First-time setup: cd <render_dir> && uv sync && uv run playwright install chromium",
|
|
459
|
+
promptSnippet: "Render .excalidraw JSON files to inline PNG images",
|
|
460
|
+
promptGuidelines: [
|
|
461
|
+
"Use this tool to render existing .excalidraw files only.",
|
|
462
|
+
"Use Excalidraw for freeform visual arguments where spatial layout matters — not for structural diagrams (use D2 for those)",
|
|
463
|
+
"Prefer render_native_diagram for supported canonical document-bound motifs instead of introducing a new Excalidraw composition layer.",
|
|
464
|
+
],
|
|
465
|
+
parameters: Type.Object({
|
|
466
|
+
path: Type.String({ description: "Path to .excalidraw JSON file to render" }),
|
|
467
|
+
scale: Type.Optional(Type.Number({ description: "Device scale factor (default: 2)" })),
|
|
468
|
+
title: Type.Optional(Type.String({ description: "Optional title for the output" })),
|
|
469
|
+
}),
|
|
470
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
471
|
+
const excalidrawPath = params.path;
|
|
472
|
+
|
|
473
|
+
if (!existsSync(excalidrawPath)) {
|
|
474
|
+
throw new Error(`File not found: ${excalidrawPath}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const scale = params.scale ?? 2;
|
|
478
|
+
const slug = (params.title || basename(excalidrawPath, ".excalidraw")).replace(/[^a-zA-Z0-9]/g, "_").slice(0, 40);
|
|
479
|
+
const outPng = visualsPath(`${timestamp()}_${slug}.png`);
|
|
480
|
+
|
|
481
|
+
onUpdate?.({
|
|
482
|
+
content: [{ type: "text", text: `Rendering ${basename(excalidrawPath)}…` }],
|
|
483
|
+
details: { excalidrawPath },
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
await renderExcalidrawFile(pi, excalidrawPath, outPng, scale, signal);
|
|
488
|
+
const data = readFileSync(outPng).toString("base64");
|
|
489
|
+
const titlePrefix = params.title ? `# ${params.title}\n\n` : "";
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
content: [
|
|
493
|
+
{ type: "text", text: `${titlePrefix}📐 Excalidraw · Saved: ${outPng}` },
|
|
494
|
+
{ type: "image", data, mimeType: "image/png" },
|
|
495
|
+
],
|
|
496
|
+
details: { rendered: true, excalidrawPath, pngPath: outPng, scale },
|
|
497
|
+
};
|
|
498
|
+
} catch (err) {
|
|
499
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
500
|
+
if (message.includes("renderer needs setup") || message.includes("not set up")) {
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
throw new Error(`Excalidraw render failed: ${message}`);
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// ------------------------------------------------------------------
|
|
509
|
+
// render_composition_still — Satori-based React composition → PNG
|
|
510
|
+
// ------------------------------------------------------------------
|
|
511
|
+
pi.registerTool({
|
|
512
|
+
name: "render_composition_still",
|
|
513
|
+
label: "Render Composition Still",
|
|
514
|
+
description: [
|
|
515
|
+
"Render a single frame from a React composition using Satori + resvg.",
|
|
516
|
+
"The composition file must export a default React functional component.",
|
|
517
|
+
"Returns the rendered PNG inline when ≤1MB; otherwise returns only the file path.",
|
|
518
|
+
"",
|
|
519
|
+
"**CSS subset (Satori limitations)**:",
|
|
520
|
+
" - Layout: Flexbox ONLY. CSS Grid is not supported.",
|
|
521
|
+
" - Unsupported: box-shadow, CSS animations/transitions, CSS variables,",
|
|
522
|
+
" :pseudo-classes, most CSS filters, overflow: scroll.",
|
|
523
|
+
" - Supported: flexbox, position (absolute/relative), border, borderRadius,",
|
|
524
|
+
" padding, margin, color, background, fontSize, fontWeight, lineHeight,",
|
|
525
|
+
" letterSpacing, textAlign, opacity, transform (translate/rotate/scale).",
|
|
526
|
+
"",
|
|
527
|
+
"**FrameProps contract**:",
|
|
528
|
+
" The component receives a `frame` prop (current frame number, 0-indexed)",
|
|
529
|
+
" and any additional props passed via the `props` parameter.",
|
|
530
|
+
" Type: { frame: number; width: number; height: number; [key: string]: unknown }",
|
|
531
|
+
"",
|
|
532
|
+
"**Available fonts**:",
|
|
533
|
+
" - Tomorrow (monospace) — Regular and Bold weights",
|
|
534
|
+
" - Inter (sans-serif) — Regular and Bold weights",
|
|
535
|
+
" Reference fonts by family name in CSS: fontFamily: 'Tomorrow' or 'Inter'.",
|
|
536
|
+
"",
|
|
537
|
+
"Output is saved to ~/.pi/visuals/ for persistence.",
|
|
538
|
+
"Requires Node.js with the composition renderer set up in extensions/render/composition/.",
|
|
539
|
+
].join("\n"),
|
|
540
|
+
promptSnippet: "Render a single frame from a React composition to PNG using Satori + resvg",
|
|
541
|
+
promptGuidelines: [
|
|
542
|
+
"Composition uses Flexbox only — CSS Grid, box-shadow, and animations are NOT supported",
|
|
543
|
+
"Component receives FrameProps: { frame, width, height, ...props }",
|
|
544
|
+
"Fonts: Tomorrow (mono) and Inter (sans) are bundled",
|
|
545
|
+
"Returns inline PNG if ≤1MB, otherwise returns file path",
|
|
546
|
+
],
|
|
547
|
+
parameters: Type.Object({
|
|
548
|
+
composition_path: Type.String({
|
|
549
|
+
description: "Path to the composition file (.tsx or .jsx). Absolute or relative to cwd.",
|
|
550
|
+
}),
|
|
551
|
+
frame: Type.Optional(Type.Number({ description: "Frame number to render (default: 0)" })),
|
|
552
|
+
width: Type.Optional(Type.Number({ description: "Output width in pixels (default: 1920)" })),
|
|
553
|
+
height: Type.Optional(Type.Number({ description: "Output height in pixels (default: 1080)" })),
|
|
554
|
+
props: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
|
|
555
|
+
description: "Additional props to pass to the composition component",
|
|
556
|
+
})),
|
|
557
|
+
}),
|
|
558
|
+
|
|
559
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
560
|
+
const compositionPath = isAbsolute(params.composition_path)
|
|
561
|
+
? params.composition_path
|
|
562
|
+
: resolve(process.cwd(), params.composition_path);
|
|
563
|
+
|
|
564
|
+
if (!existsSync(compositionPath)) {
|
|
565
|
+
throw new Error(`Composition file not found: ${compositionPath}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const frame = params.frame ?? 0;
|
|
569
|
+
const width = params.width ?? 1920;
|
|
570
|
+
const height = params.height ?? 1080;
|
|
571
|
+
const outPath = visualsPath(`${timestamp()}_still_f${frame}.png`);
|
|
572
|
+
|
|
573
|
+
const args = [
|
|
574
|
+
join(COMPOSITION_DIR, "render.mjs"),
|
|
575
|
+
"--mode", "still",
|
|
576
|
+
"--composition", compositionPath,
|
|
577
|
+
"--frame", String(frame),
|
|
578
|
+
"--width", String(width),
|
|
579
|
+
"--height", String(height),
|
|
580
|
+
"--output", outPath,
|
|
581
|
+
];
|
|
582
|
+
if (params.props && Object.keys(params.props).length > 0) {
|
|
583
|
+
args.push("--props", JSON.stringify(params.props));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
onUpdate?.({
|
|
587
|
+
content: [{ type: "text", text: `Rendering still frame ${frame} from ${basename(compositionPath)}…` }],
|
|
588
|
+
details: { compositionPath, frame, width, height },
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const result = await pi.exec("node", args, {
|
|
592
|
+
signal,
|
|
593
|
+
timeout: 60_000,
|
|
594
|
+
cwd: COMPOSITION_DIR,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (result.code !== 0) {
|
|
598
|
+
const stderr = result.stderr || result.stdout || "";
|
|
599
|
+
throw new Error(`render.mjs failed (exit ${result.code}):\n${stderr.slice(-2000)}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Parse JSON result from render.mjs stdout
|
|
603
|
+
let renderResult: { path: string; sizeBytes: number };
|
|
604
|
+
try {
|
|
605
|
+
renderResult = JSON.parse(result.stdout?.trim() || "{}");
|
|
606
|
+
} catch {
|
|
607
|
+
throw new Error(`render.mjs returned invalid JSON: ${result.stdout?.slice(-500)}`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const ONE_MB = 1_048_576;
|
|
611
|
+
if (renderResult.sizeBytes <= ONE_MB) {
|
|
612
|
+
const imageBuffer = await readFile(renderResult.path);
|
|
613
|
+
const base64Data = imageBuffer.toString("base64");
|
|
614
|
+
return {
|
|
615
|
+
content: [
|
|
616
|
+
{ type: "text", text: `Still rendered — frame ${frame} · ${width}×${height} · ${(renderResult.sizeBytes / 1024).toFixed(1)} KB · Saved: ${renderResult.path}` },
|
|
617
|
+
{ type: "image", data: base64Data, mimeType: "image/png" },
|
|
618
|
+
],
|
|
619
|
+
details: { path: renderResult.path, frame, width, height, sizeBytes: renderResult.sizeBytes, inline: true },
|
|
620
|
+
};
|
|
621
|
+
} else {
|
|
622
|
+
return {
|
|
623
|
+
content: [
|
|
624
|
+
{ type: "text", text: `Still rendered — frame ${frame} · ${width}×${height} · ${(renderResult.sizeBytes / 1024 / 1024).toFixed(2)} MB (too large for inline) · Saved: ${renderResult.path}` },
|
|
625
|
+
],
|
|
626
|
+
details: { path: renderResult.path, frame, width, height, sizeBytes: renderResult.sizeBytes, inline: false },
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// ------------------------------------------------------------------
|
|
633
|
+
// render_composition_video — Satori + gifenc/FFmpeg → GIF and/or MP4
|
|
634
|
+
// ------------------------------------------------------------------
|
|
635
|
+
pi.registerTool({
|
|
636
|
+
name: "render_composition_video",
|
|
637
|
+
label: "Render Composition Video",
|
|
638
|
+
description: [
|
|
639
|
+
"Render a React composition as an animated GIF and/or MP4 video.",
|
|
640
|
+
"Each frame is rendered via Satori + resvg; GIF encoding uses gifenc (pure Node, always available).",
|
|
641
|
+
"MP4 output requires FFmpeg installed on PATH.",
|
|
642
|
+
"",
|
|
643
|
+
"**CSS subset (Satori limitations)**:",
|
|
644
|
+
" - Layout: Flexbox ONLY. CSS Grid is not supported.",
|
|
645
|
+
" - Unsupported: box-shadow, CSS animations/transitions, CSS variables,",
|
|
646
|
+
" :pseudo-classes, most CSS filters, overflow: scroll.",
|
|
647
|
+
" - Supported: flexbox, position (absolute/relative), border, borderRadius,",
|
|
648
|
+
" padding, margin, color, background, fontSize, fontWeight, lineHeight,",
|
|
649
|
+
" letterSpacing, textAlign, opacity, transform (translate/rotate/scale).",
|
|
650
|
+
"",
|
|
651
|
+
"**FrameProps contract**:",
|
|
652
|
+
" The component receives a `frame` prop (current frame number, 0-indexed)",
|
|
653
|
+
" and any additional props passed via the `props` parameter.",
|
|
654
|
+
" Type: { frame: number; width: number; height: number; [key: string]: unknown }",
|
|
655
|
+
" Use the `frame` prop to drive animations: const progress = frame / totalFrames.",
|
|
656
|
+
"",
|
|
657
|
+
"**Available fonts**:",
|
|
658
|
+
" - Tomorrow (monospace) — Regular and Bold weights",
|
|
659
|
+
" - Inter (sans-serif) — Regular and Bold weights",
|
|
660
|
+
" Reference fonts by family name in CSS: fontFamily: 'Tomorrow' or 'Inter'.",
|
|
661
|
+
"",
|
|
662
|
+
"Output is saved to ~/.pi/visuals/<timestamp>.[gif|mp4].",
|
|
663
|
+
"Returns an object with gif_path, mp4_path (if applicable), frames, and duration_seconds.",
|
|
664
|
+
].join("\n"),
|
|
665
|
+
promptSnippet: "Render a React composition to animated GIF and/or MP4 via Satori + gifenc",
|
|
666
|
+
promptGuidelines: [
|
|
667
|
+
"Composition uses Flexbox only — CSS Grid, box-shadow, and animations are NOT supported",
|
|
668
|
+
"Component receives FrameProps: { frame, width, height, ...props } — use frame to drive animation",
|
|
669
|
+
"Fonts: Tomorrow (mono) and Inter (sans) are bundled",
|
|
670
|
+
"GIF always available; MP4 requires FFmpeg on PATH",
|
|
671
|
+
"format='gif' for web/preview, format='mp4' for quality, format='both' for both",
|
|
672
|
+
],
|
|
673
|
+
parameters: Type.Object({
|
|
674
|
+
composition_path: Type.String({
|
|
675
|
+
description: "Path to the composition file (.tsx or .jsx). Absolute or relative to cwd.",
|
|
676
|
+
}),
|
|
677
|
+
duration_in_frames: Type.Number({ description: "Total number of frames to render (required)" }),
|
|
678
|
+
fps: Type.Optional(Type.Number({ description: "Frames per second (default: 30)" })),
|
|
679
|
+
width: Type.Optional(Type.Number({ description: "Output width in pixels (default: 1920)" })),
|
|
680
|
+
height: Type.Optional(Type.Number({ description: "Output height in pixels (default: 1080)" })),
|
|
681
|
+
props: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
|
|
682
|
+
description: "Additional props to pass to the composition component",
|
|
683
|
+
})),
|
|
684
|
+
format: Type.Optional(StringEnum(["gif", "mp4", "both"] as const, {
|
|
685
|
+
description: "Output format: gif (default), mp4, or both. MP4 requires FFmpeg.",
|
|
686
|
+
})),
|
|
687
|
+
}),
|
|
688
|
+
|
|
689
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
690
|
+
const compositionPath = isAbsolute(params.composition_path)
|
|
691
|
+
? params.composition_path
|
|
692
|
+
: resolve(process.cwd(), params.composition_path);
|
|
693
|
+
|
|
694
|
+
if (!existsSync(compositionPath)) {
|
|
695
|
+
throw new Error(`Composition file not found: ${compositionPath}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const fps = params.fps ?? 30;
|
|
699
|
+
const width = params.width ?? 1920;
|
|
700
|
+
const height = params.height ?? 1080;
|
|
701
|
+
const format = params.format ?? "gif";
|
|
702
|
+
const frames = params.duration_in_frames;
|
|
703
|
+
const durationSeconds = frames / fps;
|
|
704
|
+
const ts = timestamp();
|
|
705
|
+
|
|
706
|
+
const wantGif = format === "gif" || format === "both";
|
|
707
|
+
const wantMp4 = format === "mp4" || format === "both";
|
|
708
|
+
|
|
709
|
+
if (wantMp4 && !hasCmd("ffmpeg")) {
|
|
710
|
+
if (format === "mp4") {
|
|
711
|
+
throw new Error("MP4 output requires FFmpeg. Install it via `brew install ffmpeg` or `nix profile install nixpkgs#ffmpeg`.");
|
|
712
|
+
}
|
|
713
|
+
// format === "both": warn and fall back to GIF only
|
|
714
|
+
onUpdate?.({
|
|
715
|
+
content: [{ type: "text", text: `⚠️ FFmpeg not found — producing GIF only.` }],
|
|
716
|
+
details: {},
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const gifPath = wantGif ? visualsPath(`${ts}.gif`) : undefined;
|
|
721
|
+
const mp4Path = (wantMp4 && hasCmd("ffmpeg")) ? visualsPath(`${ts}.mp4`) : undefined;
|
|
722
|
+
|
|
723
|
+
const outputs: string[] = [];
|
|
724
|
+
if (gifPath) outputs.push("gif");
|
|
725
|
+
if (mp4Path) outputs.push("mp4");
|
|
726
|
+
|
|
727
|
+
onUpdate?.({
|
|
728
|
+
content: [{ type: "text", text: `Rendering ${frames} frames at ${fps}fps → ${outputs.join(" + ")} (${durationSeconds.toFixed(2)}s)…` }],
|
|
729
|
+
details: { compositionPath, frames, fps, width, height, format },
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
const baseArgs = [
|
|
733
|
+
join(COMPOSITION_DIR, "render.mjs"),
|
|
734
|
+
"--mode", "video",
|
|
735
|
+
"--composition", compositionPath,
|
|
736
|
+
"--duration-in-frames", String(frames),
|
|
737
|
+
"--fps", String(fps),
|
|
738
|
+
"--width", String(width),
|
|
739
|
+
"--height", String(height),
|
|
740
|
+
];
|
|
741
|
+
if (params.props && Object.keys(params.props).length > 0) {
|
|
742
|
+
baseArgs.push("--props", JSON.stringify(params.props));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const results: { gif_path?: string; mp4_path?: string } = {};
|
|
746
|
+
|
|
747
|
+
// Render GIF
|
|
748
|
+
if (gifPath) {
|
|
749
|
+
const gifArgs = [...baseArgs, "--output-gif", gifPath];
|
|
750
|
+
const gifResult = await pi.exec("node", gifArgs, {
|
|
751
|
+
signal,
|
|
752
|
+
timeout: 600_000,
|
|
753
|
+
cwd: COMPOSITION_DIR,
|
|
754
|
+
});
|
|
755
|
+
if (gifResult.code !== 0) {
|
|
756
|
+
const stderr = gifResult.stderr || gifResult.stdout || "";
|
|
757
|
+
throw new Error(`render.mjs GIF failed (exit ${gifResult.code}):\n${stderr.slice(-2000)}`);
|
|
758
|
+
}
|
|
759
|
+
results.gif_path = gifPath;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Render MP4
|
|
763
|
+
if (mp4Path) {
|
|
764
|
+
const mp4Args = [...baseArgs, "--output-mp4", mp4Path];
|
|
765
|
+
const mp4Result = await pi.exec("node", mp4Args, {
|
|
766
|
+
signal,
|
|
767
|
+
timeout: 600_000,
|
|
768
|
+
cwd: COMPOSITION_DIR,
|
|
769
|
+
});
|
|
770
|
+
if (mp4Result.code !== 0) {
|
|
771
|
+
const stderr = mp4Result.stderr || mp4Result.stdout || "";
|
|
772
|
+
throw new Error(`render.mjs MP4 failed (exit ${mp4Result.code}):\n${stderr.slice(-2000)}`);
|
|
773
|
+
}
|
|
774
|
+
results.mp4_path = mp4Path;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const parts: string[] = [
|
|
778
|
+
`Composition rendered: ${frames} frames @ ${fps}fps = ${durationSeconds.toFixed(2)}s`,
|
|
779
|
+
`Resolution: ${width}×${height}`,
|
|
780
|
+
];
|
|
781
|
+
if (results.gif_path) parts.push(`GIF: ${results.gif_path}`);
|
|
782
|
+
if (results.mp4_path) parts.push(`MP4: ${results.mp4_path}`);
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
content: [{ type: "text", text: parts.join(" · ") }],
|
|
786
|
+
details: {
|
|
787
|
+
...results,
|
|
788
|
+
frames,
|
|
789
|
+
duration_seconds: durationSeconds,
|
|
790
|
+
fps,
|
|
791
|
+
width,
|
|
792
|
+
height,
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// ------------------------------------------------------------------
|
|
799
|
+
// /render command — quick image generation shortcut
|
|
800
|
+
// ------------------------------------------------------------------
|
|
801
|
+
pi.registerCommand("render", {
|
|
802
|
+
description: "Generate an image locally (usage: /render <prompt>)",
|
|
803
|
+
handler: async (args, _ctx) => {
|
|
804
|
+
if (!args?.trim()) {
|
|
805
|
+
// Show status instead of error
|
|
806
|
+
const mfluxOk = existsSync(join(DIFFUSION_CLI_DIR, ".venv", "bin", "mflux-generate"));
|
|
807
|
+
const d2Ok = hasCmd("d2");
|
|
808
|
+
const excaliOk = existsSync(join(EXCALIDRAW_RENDER_DIR, "uv.lock"));
|
|
809
|
+
const status = [
|
|
810
|
+
`**Visual generation status**`,
|
|
811
|
+
``,
|
|
812
|
+
`FLUX.1 (generate_image_local): ${mfluxOk ? "✅ ready" : `❌ not found — set up ${DIFFUSION_CLI_DIR}`}`,
|
|
813
|
+
`D2 (render_diagram): ${d2Ok ? "✅ ready" : "❌ not installed — \`nix profile install nixpkgs#d2\` or \`brew install d2\`"}`,
|
|
814
|
+
`Native diagrams (render_native_diagram): ✅ ready — in-process SVG + resvg PNG export`,
|
|
815
|
+
`Excalidraw (render_excalidraw): ${excaliOk ? "✅ ready" : `⚠️ not set up — \`cd ${EXCALIDRAW_RENDER_DIR} && uv sync && uv run playwright install chromium\``}`,
|
|
816
|
+
`Output directory: \`${VISUALS_DIR}\``,
|
|
817
|
+
``,
|
|
818
|
+
`Usage: \`/render <prompt>\``,
|
|
819
|
+
].join("\n");
|
|
820
|
+
pi.sendMessage({ customType: "view", content: status, display: true });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
pi.sendUserMessage(
|
|
825
|
+
`Use the generate_image_local tool to create an image with this prompt: ${args}`,
|
|
826
|
+
{ deliverAs: "followUp" }
|
|
827
|
+
);
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
}
|