@shanepadgett/tau-agent 0.21.0 → 0.22.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 +18 -0
- package/docs/extending-tau-agent.md +34 -2
- package/extensions/context-pruning/index.ts +2 -4
- package/extensions/context-pruning/projection.ts +1 -3
- package/extensions/context-pruning/prune.ts +5 -12
- package/extensions/context-pruning/render.ts +1 -5
- package/extensions/handoff/index.ts +12 -6
- package/extensions/image-gen/index.ts +24 -133
- package/package.json +9 -2
- package/shared/model-fallback/index.ts +1 -1
- package/{extensions/image-gen → src/image-generation}/client.ts +30 -20
- package/src/image-generation/index.ts +224 -0
- package/src/index.ts +7 -0
- /package/{extensions/image-gen → src/image-generation}/constants.ts +0 -0
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Tau is a custom agentic harness built with Pi extensions: tools, commands, prompts, skills, and themes.
|
|
4
4
|
|
|
5
|
+
The package also exports programmatic Tau capabilities for trusted Pi extensions. The built-in extensions and public API use the same underlying operations.
|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -13,6 +15,22 @@ pi install ./path/to/tau-agent
|
|
|
13
15
|
pi install ./path/to/tau-agent/packages/agent
|
|
14
16
|
```
|
|
15
17
|
|
|
18
|
+
## Programmatic use
|
|
19
|
+
|
|
20
|
+
Install the package in the extension's project, then import from its root:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { generateImage } from "@shanepadgett/tau-agent";
|
|
24
|
+
|
|
25
|
+
const image = await generateImage(ctx, {
|
|
26
|
+
prompt: "A quiet mountain lake",
|
|
27
|
+
path: "assets/lake.png",
|
|
28
|
+
signal,
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
See [Extending Tau Agent](./docs/extending-tau-agent.md) for the supported API.
|
|
33
|
+
|
|
16
34
|
## Development
|
|
17
35
|
|
|
18
36
|
From the monorepo root:
|
|
@@ -1,8 +1,40 @@
|
|
|
1
1
|
# Extending Tau Agent
|
|
2
2
|
|
|
3
|
-
Tau Agent is a Pi extension harness
|
|
3
|
+
Tau Agent is a Pi extension harness and a programmatic API for trusted Pi extensions.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Install `@shanepadgett/tau-agent` as a dependency when calling its API. Import only from the package root. Files under `extensions/` and other package paths are private.
|
|
6
|
+
|
|
7
|
+
## Image generation
|
|
8
|
+
|
|
9
|
+
`generateImage()` runs the same image-generation pipeline as Tau's `image_gen` tool without opening UI, injecting messages, or starting an agent turn.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { generateImage } from "@shanepadgett/tau-agent";
|
|
13
|
+
|
|
14
|
+
const result = await generateImage(ctx, {
|
|
15
|
+
prompt: "A stone archway at sunrise",
|
|
16
|
+
provider: "openai",
|
|
17
|
+
path: candidatePath,
|
|
18
|
+
referencedImagePaths: [perspectivePath, palettePath],
|
|
19
|
+
signal,
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The context must provide `cwd`, `model`, and `modelRegistry`; Pi's `ExtensionContext` and `ExtensionCommandContext` already do. Relative destination and reference paths resolve from `ctx.cwd`.
|
|
24
|
+
|
|
25
|
+
Fields:
|
|
26
|
+
|
|
27
|
+
- `prompt`: complete non-empty image prompt.
|
|
28
|
+
- `provider`: optional `"openai"` or `"xai"` override. Without one, Tau follows the active model and falls back when authentication is unavailable.
|
|
29
|
+
- `path`: optional destination. Tau uses its external image store when omitted and never overwrites an explicit destination.
|
|
30
|
+
- `referencedImagePaths`: optional list of up to three local PNG, JPEG, or WebP files. Supplying references selects image editing or composition.
|
|
31
|
+
- `signal`: optional cancellation signal.
|
|
32
|
+
|
|
33
|
+
The promise resolves after publication with the generated bytes, absolute path, provider, model, operation, MIME type, width, and height. The bytes are the same bytes written to `result.path`, so callers can attach the image without reopening the file. Failures reject with a bounded, sanitized error. Cancellation before publication rejects with the signal's abort reason and removes staged files.
|
|
34
|
+
|
|
35
|
+
## Events
|
|
36
|
+
|
|
37
|
+
External event integration uses Pi's native `pi.events` bus. The caller and Tau Agent must be loaded in the same Pi runtime. Event callers use string channel names and documented payloads. They do not import Tau Agent internals.
|
|
6
38
|
|
|
7
39
|
Only events documented in this file are public. Extensions run trusted in-process; event emitters can ask Tau Agent to do work.
|
|
8
40
|
|
|
@@ -235,9 +235,7 @@ export default function contextPruningExtension(pi: ExtensionAPI): void {
|
|
|
235
235
|
const boundary = baseline + reminder * nudgeEveryPercent;
|
|
236
236
|
if (boundary <= nudgeState.highestBoundary) return undefined;
|
|
237
237
|
const tierCount = nudgeInstructions.length;
|
|
238
|
-
const tierFloor = nudgeState.terminalTierReached
|
|
239
|
-
? tierCount
|
|
240
|
-
: Math.min(nudgeState.highestTier, tierCount);
|
|
238
|
+
const tierFloor = nudgeState.terminalTierReached ? tierCount : Math.min(nudgeState.highestTier, tierCount);
|
|
241
239
|
const tier = Math.max(Math.min(reminder, tierCount), tierFloor);
|
|
242
240
|
const instruction = nudgeInstructions[tier - 1] ?? nudgeInstructions[0];
|
|
243
241
|
const details: ContextPruningNudgeDetailsV2 = {
|
|
@@ -328,7 +326,7 @@ function reconstructNudgeState(branch: readonly SessionEntry[], anchorToolCallId
|
|
|
328
326
|
(growthBaselinePercent !== undefined && details.growthBaselinePercent !== growthBaselinePercent)
|
|
329
327
|
)
|
|
330
328
|
continue;
|
|
331
|
-
const expectedTierFloor = terminalTierReached
|
|
329
|
+
const expectedTierFloor: number = terminalTierReached
|
|
332
330
|
? details.tierCount
|
|
333
331
|
: Math.min(highestTier, details.tierCount);
|
|
334
332
|
if (details.boundary <= highestBoundary || details.tierFloor !== expectedTierFloor) continue;
|
|
@@ -15,9 +15,7 @@ export function projectContext(
|
|
|
15
15
|
message?.role === "assistant" &&
|
|
16
16
|
message.content.some(
|
|
17
17
|
(block) =>
|
|
18
|
-
block.type === "toolCall" &&
|
|
19
|
-
block.id === state.latestAnchorToolCallId &&
|
|
20
|
-
block.name === "context_prune",
|
|
18
|
+
block.type === "toolCall" && block.id === state.latestAnchorToolCallId && block.name === "context_prune",
|
|
21
19
|
)
|
|
22
20
|
) {
|
|
23
21
|
anchorIndex = index;
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
type ExtensionContext,
|
|
4
|
-
type SessionEntry,
|
|
5
|
-
} from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
6
3
|
import { type Static, Type } from "typebox";
|
|
7
|
-
import type {
|
|
8
|
-
ContextPruneDeferredFileV2,
|
|
9
|
-
ContextPruneDetailsV2,
|
|
10
|
-
} from "../../shared/context-pruning-state.ts";
|
|
4
|
+
import type { ContextPruneDeferredFileV2, ContextPruneDetailsV2 } from "../../shared/context-pruning-state.ts";
|
|
11
5
|
import { prepareAutoreadMessage, type PreparedAutoreadMessage } from "../explore/autoread.ts";
|
|
12
6
|
import { MAX_COMPLETE_FILE_SNAPSHOT_BYTES } from "../explore/full-file-knowledge.ts";
|
|
13
7
|
|
|
@@ -119,9 +113,7 @@ export async function executeContextPrune(options: ContextPruneExecutionOptions)
|
|
|
119
113
|
content: [
|
|
120
114
|
{ type: "text", text: status },
|
|
121
115
|
...preparedSnapshots.map((snapshot) => ({ type: "text" as const, text: snapshot.content })),
|
|
122
|
-
...(deferredFiles.length === 0
|
|
123
|
-
? []
|
|
124
|
-
: [{ type: "text" as const, text: deferredFileText(deferredFiles) }]),
|
|
116
|
+
...(deferredFiles.length === 0 ? [] : [{ type: "text" as const, text: deferredFileText(deferredFiles) }]),
|
|
125
117
|
],
|
|
126
118
|
details,
|
|
127
119
|
};
|
|
@@ -169,7 +161,8 @@ function collectPrunedRows(
|
|
|
169
161
|
if (entry.message.toolName !== "context_prune" || !isRecord(entry.message.details)) continue;
|
|
170
162
|
const files = entry.message.details.refreshedFiles;
|
|
171
163
|
if (!Array.isArray(files)) continue;
|
|
172
|
-
for (const file of files)
|
|
164
|
+
for (const file of files)
|
|
165
|
+
if (isRecord(file) && typeof file.rowId === "string") prunedAutoreadRowIds.add(file.rowId);
|
|
173
166
|
}
|
|
174
167
|
return { prunedToolCallIds: [...prunedToolCallIds], prunedAutoreadRowIds: [...prunedAutoreadRowIds] };
|
|
175
168
|
}
|
|
@@ -128,11 +128,7 @@ export function renderContextPruningNudge(details: unknown, theme: Theme): Marke
|
|
|
128
128
|
? ["Prune requested."]
|
|
129
129
|
: [
|
|
130
130
|
`${parsed.percent}%`,
|
|
131
|
-
...(parsed.tier === parsed.tierCount
|
|
132
|
-
? ["Prune now."]
|
|
133
|
-
: parsed.tier > 1
|
|
134
|
-
? ["Prune soon."]
|
|
135
|
-
: []),
|
|
131
|
+
...(parsed.tier === parsed.tierCount ? ["Prune now."] : parsed.tier > 1 ? ["Prune soon."] : []),
|
|
136
132
|
],
|
|
137
133
|
});
|
|
138
134
|
}
|
|
@@ -35,10 +35,7 @@ export default function handoffExtension(pi: ExtensionAPI): void {
|
|
|
35
35
|
ctx.ui.notify("The current session must be persisted before handoff.", "error");
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
const messages = buildSessionContext(
|
|
39
|
-
ctx.sessionManager.getEntries(),
|
|
40
|
-
ctx.sessionManager.getLeafId(),
|
|
41
|
-
).messages;
|
|
38
|
+
const messages = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages;
|
|
42
39
|
if (messages.length === 0) {
|
|
43
40
|
ctx.ui.notify("No conversation context to hand off.", "error");
|
|
44
41
|
return;
|
|
@@ -53,7 +50,13 @@ export default function handoffExtension(pi: ExtensionAPI): void {
|
|
|
53
50
|
const thinking = pi.getThinkingLevel();
|
|
54
51
|
const candidates = await resolveCandidates(
|
|
55
52
|
ctx,
|
|
56
|
-
[
|
|
53
|
+
[
|
|
54
|
+
{
|
|
55
|
+
provider: model.provider,
|
|
56
|
+
model: model.id,
|
|
57
|
+
reasoning: thinking === "off" ? undefined : thinking,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
57
60
|
false,
|
|
58
61
|
);
|
|
59
62
|
const conversation = serializeConversation(convertToLlm(messages));
|
|
@@ -82,7 +85,10 @@ export default function handoffExtension(pi: ExtensionAPI): void {
|
|
|
82
85
|
});
|
|
83
86
|
|
|
84
87
|
if (!draft) {
|
|
85
|
-
ctx.ui.notify(
|
|
88
|
+
ctx.ui.notify(
|
|
89
|
+
generationError ? `Handoff failed: ${generationError}` : "Handoff cancelled.",
|
|
90
|
+
generationError ? "error" : "info",
|
|
91
|
+
);
|
|
86
92
|
return;
|
|
87
93
|
}
|
|
88
94
|
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { defineTool,
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
generateImage,
|
|
4
|
+
type GeneratedImageResult,
|
|
5
|
+
type GenerateImageRequest,
|
|
6
|
+
type ImageGenerationContext,
|
|
7
|
+
type ImageProvider,
|
|
8
|
+
} from "@shanepadgett/tau-agent";
|
|
6
9
|
import { type Static, Type } from "typebox";
|
|
7
|
-
import { detectImageMimeType, editImage, generateImage, type EditImage, type GeneratedImage } from "./client.ts";
|
|
8
|
-
import { OPENAI_IMAGE_MODEL, OPENAI_PROVIDER, XAI_IMAGE_MODEL, XAI_PROVIDER, type ImageProvider } from "./constants.ts";
|
|
9
10
|
|
|
10
|
-
const MAX_INPUT_BYTES = 50 * 1024 * 1024;
|
|
11
11
|
const MAX_INLINE_BYTES = 12 * 1024 * 1024;
|
|
12
12
|
|
|
13
13
|
const imageGenSchema = Type.Object(
|
|
14
14
|
{
|
|
15
15
|
prompt: Type.String({ minLength: 1 }),
|
|
16
16
|
provider: Type.Optional(
|
|
17
|
-
Type.Union([Type.Literal("openai"), Type.Literal("xai")], {
|
|
17
|
+
Type.Union([Type.Literal("openai" satisfies ImageProvider), Type.Literal("xai" satisfies ImageProvider)], {
|
|
18
18
|
description:
|
|
19
19
|
"Image provider override. Omit to follow the parent model, preferring OpenAI for GPT and xAI for Grok.",
|
|
20
20
|
}),
|
|
@@ -28,19 +28,7 @@ const imageGenSchema = Type.Object(
|
|
|
28
28
|
);
|
|
29
29
|
|
|
30
30
|
type ImageGenParams = Static<typeof imageGenSchema>;
|
|
31
|
-
|
|
32
|
-
interface ImageGenDetails {
|
|
33
|
-
path: string;
|
|
34
|
-
provider: ImageProvider;
|
|
35
|
-
model: typeof OPENAI_IMAGE_MODEL | typeof XAI_IMAGE_MODEL;
|
|
36
|
-
operation: "generate" | "edit";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function outputExtension(image: GeneratedImage): string {
|
|
40
|
-
if (image.mimeType === "image/png") return ".png";
|
|
41
|
-
if (image.mimeType === "image/webp") return ".webp";
|
|
42
|
-
return ".jpg";
|
|
43
|
-
}
|
|
31
|
+
type ImageGenDetails = Omit<GeneratedImageResult, "bytes">;
|
|
44
32
|
|
|
45
33
|
export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
46
34
|
pi.registerTool(
|
|
@@ -51,127 +39,30 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
51
39
|
"Generate a requested raster image or AI-edit existing images with OpenAI GPT Image or xAI Grok Imagine. Omit provider to follow the parent model; set it to openai or xai to override. Omit referenced_image_paths to generate; pass one to three local paths to edit or compose. Omit path to use Tau's external image store; pass path only when the user explicitly requests a repository file or other destination. Returns the image for inspection.",
|
|
52
40
|
parameters: imageGenSchema,
|
|
53
41
|
async execute(_toolCallId, params: ImageGenParams, signal, onUpdate, ctx) {
|
|
54
|
-
signal?.throwIfAborted();
|
|
55
|
-
const prompt = params.prompt.trim();
|
|
56
|
-
if (!prompt) throw new Error("Image prompt cannot be empty");
|
|
57
|
-
const requestedPath = params.path?.startsWith("@") ? params.path.slice(1) : params.path;
|
|
58
|
-
if (requestedPath !== undefined && !requestedPath.trim()) throw new Error("Image path cannot be empty");
|
|
59
|
-
const requestedAbsolutePath = requestedPath
|
|
60
|
-
? isAbsolute(requestedPath)
|
|
61
|
-
? requestedPath
|
|
62
|
-
: resolve(ctx.cwd, requestedPath)
|
|
63
|
-
: undefined;
|
|
64
|
-
if (
|
|
65
|
-
requestedAbsolutePath &&
|
|
66
|
-
![".jpg", ".jpeg", ".png", ".webp"].includes(extname(requestedAbsolutePath).toLowerCase())
|
|
67
|
-
) {
|
|
68
|
-
throw new Error("Image path must end in .jpg, .jpeg, .png, or .webp");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const parentUsesXai =
|
|
72
|
-
ctx.model?.provider.toLowerCase() === XAI_PROVIDER ||
|
|
73
|
-
ctx.model?.id.toLowerCase().includes("grok") === true;
|
|
74
|
-
const preferredProvider: ImageProvider = params.provider ?? (parentUsesXai ? "xai" : "openai");
|
|
75
|
-
const providers: readonly ImageProvider[] = params.provider
|
|
76
|
-
? [params.provider]
|
|
77
|
-
: preferredProvider === "xai"
|
|
78
|
-
? ["xai", "openai"]
|
|
79
|
-
: ["openai", "xai"];
|
|
80
|
-
let provider: ImageProvider | undefined;
|
|
81
|
-
let token: string | undefined;
|
|
82
|
-
for (const candidate of providers) {
|
|
83
|
-
const candidateToken = await ctx.modelRegistry.getApiKeyForProvider(
|
|
84
|
-
candidate === "openai" ? OPENAI_PROVIDER : XAI_PROVIDER,
|
|
85
|
-
);
|
|
86
|
-
if (candidateToken) {
|
|
87
|
-
provider = candidate;
|
|
88
|
-
token = candidateToken;
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (!provider || !token) {
|
|
93
|
-
if (params.provider === "openai") {
|
|
94
|
-
throw new Error("OpenAI Codex authentication is unavailable. Run /login openai-codex.");
|
|
95
|
-
}
|
|
96
|
-
if (params.provider === "xai") {
|
|
97
|
-
throw new Error("xAI authentication is unavailable. Run /login xai and choose a login method.");
|
|
98
|
-
}
|
|
99
|
-
throw new Error("Image generation authentication is unavailable. Run /login for OpenAI Codex or xAI.");
|
|
100
|
-
}
|
|
101
|
-
const model = provider === "openai" ? OPENAI_IMAGE_MODEL : XAI_IMAGE_MODEL;
|
|
102
|
-
const images: EditImage[] = [];
|
|
103
|
-
for (const path of params.referenced_image_paths ?? []) {
|
|
104
|
-
const rawPath = path.startsWith("@") ? path.slice(1) : path;
|
|
105
|
-
if (!rawPath.trim()) throw new Error("Referenced image path cannot be empty");
|
|
106
|
-
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(ctx.cwd, rawPath);
|
|
107
|
-
const metadata = await stat(absolutePath);
|
|
108
|
-
if (!metadata.isFile()) throw new Error(`Referenced image is not a file: ${absolutePath}`);
|
|
109
|
-
if (metadata.size > MAX_INPUT_BYTES) throw new Error(`Referenced image exceeds 50 MiB: ${absolutePath}`);
|
|
110
|
-
const bytes = await readFile(absolutePath);
|
|
111
|
-
if (bytes.length > MAX_INPUT_BYTES) throw new Error(`Referenced image exceeds 50 MiB: ${absolutePath}`);
|
|
112
|
-
const mimeType = detectImageMimeType(bytes);
|
|
113
|
-
if (!mimeType)
|
|
114
|
-
throw new Error(`Referenced image is not a supported PNG, JPEG, or WebP file: ${absolutePath}`);
|
|
115
|
-
images.push({ mimeType, data: bytes.toString("base64") });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const operation = images.length === 0 ? "generate" : "edit";
|
|
119
42
|
await onUpdate?.({
|
|
120
43
|
content: [
|
|
121
44
|
{
|
|
122
45
|
type: "text",
|
|
123
|
-
text:
|
|
124
|
-
operation === "generate"
|
|
125
|
-
? `Generating image with ${model}...`
|
|
126
|
-
: `Editing image with ${model}...`,
|
|
46
|
+
text: "Processing image request...",
|
|
127
47
|
},
|
|
128
48
|
],
|
|
129
49
|
details: undefined,
|
|
130
50
|
});
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
(generatedExtension === ".jpg" && requestedExtension === ".jpeg");
|
|
142
|
-
if (!matches)
|
|
143
|
-
throw new Error(
|
|
144
|
-
`${provider === "openai" ? "OpenAI Codex" : "xAI"} returned ${generated.mimeType}; destination must end in ${generatedExtension}`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
const absolutePath =
|
|
148
|
-
requestedAbsolutePath ??
|
|
149
|
-
join(homedir(), ".local", "share", "tau-agent", "images", `image-${randomUUID()}${generatedExtension}`);
|
|
150
|
-
|
|
151
|
-
const outputDirectory = dirname(absolutePath);
|
|
152
|
-
await withFileMutationQueue(absolutePath, async () => {
|
|
153
|
-
await mkdir(outputDirectory, { recursive: true });
|
|
154
|
-
const temporaryPath = join(
|
|
155
|
-
outputDirectory,
|
|
156
|
-
`.${basename(absolutePath)}.${randomUUID()}.tmp${generatedExtension}`,
|
|
157
|
-
);
|
|
158
|
-
try {
|
|
159
|
-
await writeFile(temporaryPath, generated.bytes, { flag: "wx" });
|
|
160
|
-
signal?.throwIfAborted();
|
|
161
|
-
await link(temporaryPath, absolutePath);
|
|
162
|
-
} finally {
|
|
163
|
-
await rm(temporaryPath, { force: true });
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
const verb = operation === "generate" ? "Generated" : "Edited";
|
|
168
|
-
const details: ImageGenDetails = { path: absolutePath, provider, model, operation };
|
|
169
|
-
if (generated.bytes.length > MAX_INLINE_BYTES) {
|
|
51
|
+
const request = {
|
|
52
|
+
prompt: params.prompt,
|
|
53
|
+
provider: params.provider,
|
|
54
|
+
path: params.path,
|
|
55
|
+
referencedImagePaths: params.referenced_image_paths,
|
|
56
|
+
signal,
|
|
57
|
+
} satisfies GenerateImageRequest;
|
|
58
|
+
const { bytes, ...details } = await generateImage(ctx satisfies ImageGenerationContext, request);
|
|
59
|
+
const verb = details.operation === "generate" ? "Generated" : "Edited";
|
|
60
|
+
if (bytes.length > MAX_INLINE_BYTES) {
|
|
170
61
|
return {
|
|
171
62
|
content: [
|
|
172
63
|
{
|
|
173
64
|
type: "text",
|
|
174
|
-
text: `${verb} image saved to ${
|
|
65
|
+
text: `${verb} image saved to ${details.path}. The image exceeds the 12 MiB attachment limit, so it was not added to model context.`,
|
|
175
66
|
},
|
|
176
67
|
],
|
|
177
68
|
details,
|
|
@@ -179,8 +70,8 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
179
70
|
}
|
|
180
71
|
return {
|
|
181
72
|
content: [
|
|
182
|
-
{ type: "text", text: `${verb} image saved to ${
|
|
183
|
-
{ type: "image", data:
|
|
73
|
+
{ type: "text", text: `${verb} image saved to ${details.path}` },
|
|
74
|
+
{ type: "image", data: bytes.toString("base64"), mimeType: details.mimeType },
|
|
184
75
|
],
|
|
185
76
|
details,
|
|
186
77
|
};
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shanepadgett/tau-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Tau is a custom agentic harness built with pi extensions",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
6
11
|
"license": "MIT",
|
|
7
12
|
"keywords": [
|
|
8
13
|
"pi-package",
|
|
@@ -18,6 +23,7 @@
|
|
|
18
23
|
"access": "public"
|
|
19
24
|
},
|
|
20
25
|
"files": [
|
|
26
|
+
"src/**/*",
|
|
21
27
|
"extensions/**/*",
|
|
22
28
|
"shared/**/*",
|
|
23
29
|
"prompts/**/*",
|
|
@@ -28,8 +34,9 @@
|
|
|
28
34
|
"README.md"
|
|
29
35
|
],
|
|
30
36
|
"dependencies": {
|
|
31
|
-
"@shanepadgett/tau-tui": "0.
|
|
37
|
+
"@shanepadgett/tau-tui": "0.22.0",
|
|
32
38
|
"@toon-format/toon": "2.3.0",
|
|
39
|
+
"image-size": "2.0.2",
|
|
33
40
|
"smol-toml": "1.7.0"
|
|
34
41
|
},
|
|
35
42
|
"peerDependencies": {
|
|
@@ -24,7 +24,7 @@ interface ModelFallbackOptions {
|
|
|
24
24
|
|
|
25
25
|
export async function resolveCandidates(
|
|
26
26
|
ctx: Pick<ExtensionContext, "modelRegistry" | "model" | "cwd" | "isProjectTrusted">,
|
|
27
|
-
preferredModels: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }>,
|
|
27
|
+
preferredModels: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel | undefined }>,
|
|
28
28
|
includeParentModel: boolean,
|
|
29
29
|
): Promise<ModelCandidate[]> {
|
|
30
30
|
const settings = await loadTauExtensionSettings(ctx, modelFallbackSettings);
|
|
@@ -24,7 +24,6 @@ export interface EditImage {
|
|
|
24
24
|
|
|
25
25
|
export interface GeneratedImage {
|
|
26
26
|
bytes: Buffer;
|
|
27
|
-
base64: string;
|
|
28
27
|
mimeType: EditImage["mimeType"];
|
|
29
28
|
}
|
|
30
29
|
|
|
@@ -58,7 +57,7 @@ export function resolveCodexAuth(token: string): CodexAuth {
|
|
|
58
57
|
const authClaim = payload["https://api.openai.com/auth"];
|
|
59
58
|
if (!isRecord(authClaim)) throw invalidCredential();
|
|
60
59
|
const accountId = authClaim.chatgpt_account_id;
|
|
61
|
-
if (typeof accountId !== "string" ||
|
|
60
|
+
if (typeof accountId !== "string" || !/^[\x21-\x7e]+$/.test(accountId.trim())) throw invalidCredential();
|
|
62
61
|
return { token, accountId: accountId.trim() };
|
|
63
62
|
}
|
|
64
63
|
|
|
@@ -87,7 +86,7 @@ async function boundedError(response: HttpResponse): Promise<string> {
|
|
|
87
86
|
.trim();
|
|
88
87
|
}
|
|
89
88
|
|
|
90
|
-
function serverErrorMessage(body: string,
|
|
89
|
+
function serverErrorMessage(body: string, secrets: readonly string[]): string {
|
|
91
90
|
let message = body;
|
|
92
91
|
try {
|
|
93
92
|
const value: unknown = JSON.parse(body);
|
|
@@ -98,7 +97,10 @@ function serverErrorMessage(body: string, token: string): string {
|
|
|
98
97
|
} catch {
|
|
99
98
|
// Plain-text error body.
|
|
100
99
|
}
|
|
101
|
-
|
|
100
|
+
for (const secret of secrets) {
|
|
101
|
+
if (secret) message = message.replaceAll(secret, "[redacted]");
|
|
102
|
+
}
|
|
103
|
+
return message.slice(0, MAX_ERROR_MESSAGE_LENGTH).trim();
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
export function detectImageMimeType(bytes: Buffer): GeneratedImage["mimeType"] | undefined {
|
|
@@ -137,7 +139,7 @@ function decodeImageResponse(value: unknown, service: "OpenAI Codex" | "xAI", pn
|
|
|
137
139
|
if (!mimeType || (pngOnly && mimeType !== "image/png")) {
|
|
138
140
|
throw new Error(`${service} returned unsupported image data`);
|
|
139
141
|
}
|
|
140
|
-
return { bytes,
|
|
142
|
+
return { bytes, mimeType };
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
async function requestOpenAIImage(
|
|
@@ -147,20 +149,28 @@ async function requestOpenAIImage(
|
|
|
147
149
|
signal?: AbortSignal,
|
|
148
150
|
): Promise<GeneratedImage> {
|
|
149
151
|
const route = operation === "generation" ? "generations" : "edits";
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
152
|
+
let response: HttpResponse;
|
|
153
|
+
try {
|
|
154
|
+
response = (await fetch(`${OPENAI_IMAGE_API_BASE_URL}/${route}`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
Accept: "application/json",
|
|
158
|
+
Authorization: `Bearer ${auth.token}`,
|
|
159
|
+
"chatgpt-account-id": auth.accountId,
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
originator: "pi",
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
signal,
|
|
165
|
+
})) as HttpResponse;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (signal?.aborted) throw signal.reason;
|
|
168
|
+
let message = error instanceof Error ? error.message : "OpenAI Codex image request failed";
|
|
169
|
+
for (const secret of [auth.token, auth.accountId]) message = message.replaceAll(secret, "[redacted]");
|
|
170
|
+
throw new Error(message.slice(0, MAX_ERROR_MESSAGE_LENGTH).trim() || "OpenAI Codex image request failed");
|
|
171
|
+
}
|
|
162
172
|
if (!response.ok) {
|
|
163
|
-
const message = serverErrorMessage(await boundedError(response), auth.token);
|
|
173
|
+
const message = serverErrorMessage(await boundedError(response), [auth.token, auth.accountId]);
|
|
164
174
|
throw new Error(
|
|
165
175
|
`OpenAI Codex image ${operation} failed with status ${response.status}${message ? `: ${message}` : ""}`,
|
|
166
176
|
);
|
|
@@ -232,7 +242,7 @@ async function requestXaiImage(
|
|
|
232
242
|
}
|
|
233
243
|
return decodeImageResponse(value, "xAI", false);
|
|
234
244
|
}
|
|
235
|
-
const message = serverErrorMessage(await boundedError(response), token);
|
|
245
|
+
const message = serverErrorMessage(await boundedError(response), [token]);
|
|
236
246
|
if (!retryable(response.status) || attempt === MAX_ATTEMPTS) {
|
|
237
247
|
throw new Error(
|
|
238
248
|
`xAI image ${operation} failed with status ${response.status}${message ? `: ${message}` : ""}`,
|
|
@@ -243,7 +253,7 @@ async function requestXaiImage(
|
|
|
243
253
|
throw new Error(`xAI image ${operation} failed`);
|
|
244
254
|
}
|
|
245
255
|
|
|
246
|
-
export function
|
|
256
|
+
export function requestGeneratedImage(
|
|
247
257
|
provider: ImageProvider,
|
|
248
258
|
prompt: string,
|
|
249
259
|
token: string,
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { withFileMutationQueue, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { imageSize } from "image-size";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { link, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { detectImageMimeType, editImage, requestGeneratedImage } from "./client.ts";
|
|
8
|
+
import { OPENAI_IMAGE_MODEL, OPENAI_PROVIDER, XAI_IMAGE_MODEL, XAI_PROVIDER, type ImageProvider } from "./constants.ts";
|
|
9
|
+
|
|
10
|
+
export type { ImageProvider } from "./constants.ts";
|
|
11
|
+
|
|
12
|
+
const MAX_INPUT_BYTES = 50 * 1024 * 1024;
|
|
13
|
+
const MAX_ERROR_MESSAGE_LENGTH = 2000;
|
|
14
|
+
|
|
15
|
+
export type ImageGenerationContext = Pick<ExtensionContext, "cwd" | "model" | "modelRegistry">;
|
|
16
|
+
|
|
17
|
+
export interface GenerateImageRequest {
|
|
18
|
+
prompt: string;
|
|
19
|
+
provider?: ImageProvider;
|
|
20
|
+
path?: string;
|
|
21
|
+
referencedImagePaths?: readonly string[];
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GeneratedImageResult {
|
|
26
|
+
bytes: Buffer;
|
|
27
|
+
path: string;
|
|
28
|
+
provider: ImageProvider;
|
|
29
|
+
model: typeof OPENAI_IMAGE_MODEL | typeof XAI_IMAGE_MODEL;
|
|
30
|
+
operation: "generate" | "edit";
|
|
31
|
+
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function outputExtension(mimeType: GeneratedImageResult["mimeType"]): string {
|
|
37
|
+
if (mimeType === "image/png") return ".png";
|
|
38
|
+
if (mimeType === "image/webp") return ".webp";
|
|
39
|
+
return ".jpg";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizedError(error: unknown, signal: AbortSignal | undefined, secrets: readonly string[]): unknown {
|
|
43
|
+
if (signal?.aborted) return signal.reason;
|
|
44
|
+
let message = error instanceof Error ? error.message : "Image generation failed";
|
|
45
|
+
for (const secret of secrets) {
|
|
46
|
+
if (secret) message = message.replaceAll(secret, "[redacted]");
|
|
47
|
+
}
|
|
48
|
+
message = [...message]
|
|
49
|
+
.filter((character) => {
|
|
50
|
+
const code = character.charCodeAt(0);
|
|
51
|
+
return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127);
|
|
52
|
+
})
|
|
53
|
+
.join("")
|
|
54
|
+
.slice(0, MAX_ERROR_MESSAGE_LENGTH)
|
|
55
|
+
.trim();
|
|
56
|
+
return new Error(message || "Image generation failed");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function generateImage(
|
|
60
|
+
ctx: ImageGenerationContext,
|
|
61
|
+
request: GenerateImageRequest,
|
|
62
|
+
): Promise<GeneratedImageResult> {
|
|
63
|
+
const signal = request.signal;
|
|
64
|
+
const secrets: string[] = [];
|
|
65
|
+
try {
|
|
66
|
+
signal?.throwIfAborted();
|
|
67
|
+
if (typeof request.prompt !== "string") throw new Error("Image prompt must be a string");
|
|
68
|
+
if (request.provider !== undefined && request.provider !== "openai" && request.provider !== "xai") {
|
|
69
|
+
throw new Error("Image provider must be openai or xai");
|
|
70
|
+
}
|
|
71
|
+
if (request.path !== undefined && typeof request.path !== "string")
|
|
72
|
+
throw new Error("Image path must be a string");
|
|
73
|
+
if (request.referencedImagePaths !== undefined && !Array.isArray(request.referencedImagePaths)) {
|
|
74
|
+
throw new Error("Referenced image paths must be an array");
|
|
75
|
+
}
|
|
76
|
+
const prompt = request.prompt.trim();
|
|
77
|
+
if (!prompt) throw new Error("Image prompt cannot be empty");
|
|
78
|
+
if (request.referencedImagePaths && request.referencedImagePaths.length > 3) {
|
|
79
|
+
throw new Error("Image generation accepts at most three referenced images");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const requestedPath = request.path?.startsWith("@") ? request.path.slice(1) : request.path;
|
|
83
|
+
if (requestedPath !== undefined && !requestedPath.trim()) throw new Error("Image path cannot be empty");
|
|
84
|
+
const requestedAbsolutePath = requestedPath
|
|
85
|
+
? isAbsolute(requestedPath)
|
|
86
|
+
? requestedPath
|
|
87
|
+
: resolve(ctx.cwd, requestedPath)
|
|
88
|
+
: undefined;
|
|
89
|
+
if (
|
|
90
|
+
requestedAbsolutePath &&
|
|
91
|
+
![".jpg", ".jpeg", ".png", ".webp"].includes(extname(requestedAbsolutePath).toLowerCase())
|
|
92
|
+
) {
|
|
93
|
+
throw new Error("Image path must end in .jpg, .jpeg, .png, or .webp");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parentUsesXai =
|
|
97
|
+
ctx.model?.provider.toLowerCase() === XAI_PROVIDER || ctx.model?.id.toLowerCase().includes("grok") === true;
|
|
98
|
+
const preferredProvider: ImageProvider = request.provider ?? (parentUsesXai ? "xai" : "openai");
|
|
99
|
+
const providers: readonly ImageProvider[] = request.provider
|
|
100
|
+
? [request.provider]
|
|
101
|
+
: preferredProvider === "xai"
|
|
102
|
+
? ["xai", "openai"]
|
|
103
|
+
: ["openai", "xai"];
|
|
104
|
+
let provider: ImageProvider | undefined;
|
|
105
|
+
let token: string | undefined;
|
|
106
|
+
for (const candidate of providers) {
|
|
107
|
+
signal?.throwIfAborted();
|
|
108
|
+
const candidateToken = await ctx.modelRegistry.getApiKeyForProvider(
|
|
109
|
+
candidate === "openai" ? OPENAI_PROVIDER : XAI_PROVIDER,
|
|
110
|
+
);
|
|
111
|
+
signal?.throwIfAborted();
|
|
112
|
+
if (candidateToken) {
|
|
113
|
+
provider = candidate;
|
|
114
|
+
token = candidateToken;
|
|
115
|
+
secrets.push(candidateToken);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!provider || !token) {
|
|
120
|
+
if (request.provider === "openai") {
|
|
121
|
+
throw new Error("OpenAI Codex authentication is unavailable. Run /login openai-codex.");
|
|
122
|
+
}
|
|
123
|
+
if (request.provider === "xai") {
|
|
124
|
+
throw new Error("xAI authentication is unavailable. Run /login xai and choose a login method.");
|
|
125
|
+
}
|
|
126
|
+
throw new Error("Image generation authentication is unavailable. Run /login for OpenAI Codex or xAI.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const images: Array<{
|
|
130
|
+
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
|
131
|
+
data: string;
|
|
132
|
+
}> = [];
|
|
133
|
+
for (const path of request.referencedImagePaths ?? []) {
|
|
134
|
+
signal?.throwIfAborted();
|
|
135
|
+
if (typeof path !== "string") throw new Error("Referenced image path must be a string");
|
|
136
|
+
const rawPath = path.startsWith("@") ? path.slice(1) : path;
|
|
137
|
+
if (!rawPath.trim()) throw new Error("Referenced image path cannot be empty");
|
|
138
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(ctx.cwd, rawPath);
|
|
139
|
+
const metadata = await stat(absolutePath);
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
if (!metadata.isFile()) throw new Error(`Referenced image is not a file: ${absolutePath}`);
|
|
142
|
+
if (metadata.size > MAX_INPUT_BYTES) throw new Error(`Referenced image exceeds 50 MiB: ${absolutePath}`);
|
|
143
|
+
const bytes = await readFile(absolutePath, { signal });
|
|
144
|
+
if (bytes.length > MAX_INPUT_BYTES) throw new Error(`Referenced image exceeds 50 MiB: ${absolutePath}`);
|
|
145
|
+
const mimeType = detectImageMimeType(bytes);
|
|
146
|
+
if (!mimeType) {
|
|
147
|
+
throw new Error(`Referenced image is not a supported PNG, JPEG, or WebP file: ${absolutePath}`);
|
|
148
|
+
}
|
|
149
|
+
images.push({ mimeType, data: bytes.toString("base64") });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const operation = images.length === 0 ? "generate" : "edit";
|
|
153
|
+
const model = provider === "openai" ? OPENAI_IMAGE_MODEL : XAI_IMAGE_MODEL;
|
|
154
|
+
const selectedProvider = provider;
|
|
155
|
+
const selectedToken = token;
|
|
156
|
+
signal?.throwIfAborted();
|
|
157
|
+
const generated =
|
|
158
|
+
operation === "generate"
|
|
159
|
+
? await requestGeneratedImage(selectedProvider, prompt, selectedToken, signal)
|
|
160
|
+
: await editImage(selectedProvider, prompt, images, selectedToken, signal);
|
|
161
|
+
signal?.throwIfAborted();
|
|
162
|
+
const generatedExtension = outputExtension(generated.mimeType);
|
|
163
|
+
if (requestedAbsolutePath) {
|
|
164
|
+
const requestedExtension = extname(requestedAbsolutePath).toLowerCase();
|
|
165
|
+
const matches =
|
|
166
|
+
requestedExtension === generatedExtension ||
|
|
167
|
+
(generatedExtension === ".jpg" && requestedExtension === ".jpeg");
|
|
168
|
+
if (!matches) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${selectedProvider === "openai" ? "OpenAI Codex" : "xAI"} returned ${generated.mimeType}; destination must end in ${generatedExtension}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let dimensions: ReturnType<typeof imageSize>;
|
|
176
|
+
try {
|
|
177
|
+
dimensions = imageSize(generated.bytes);
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error(`${selectedProvider === "openai" ? "OpenAI Codex" : "xAI"} returned invalid image dimensions`);
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
!Number.isInteger(dimensions.width) ||
|
|
183
|
+
dimensions.width <= 0 ||
|
|
184
|
+
!Number.isInteger(dimensions.height) ||
|
|
185
|
+
dimensions.height <= 0
|
|
186
|
+
) {
|
|
187
|
+
throw new Error(`${selectedProvider === "openai" ? "OpenAI Codex" : "xAI"} returned invalid image dimensions`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const absolutePath =
|
|
191
|
+
requestedAbsolutePath ??
|
|
192
|
+
join(homedir(), ".local", "share", "tau-agent", "images", `image-${randomUUID()}${generatedExtension}`);
|
|
193
|
+
const outputDirectory = dirname(absolutePath);
|
|
194
|
+
await withFileMutationQueue(absolutePath, async () => {
|
|
195
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
196
|
+
const temporaryPath = join(
|
|
197
|
+
outputDirectory,
|
|
198
|
+
`.${basename(absolutePath)}.${randomUUID()}.tmp${generatedExtension}`,
|
|
199
|
+
);
|
|
200
|
+
try {
|
|
201
|
+
await writeFile(temporaryPath, generated.bytes, { flag: "wx", signal });
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
await link(temporaryPath, absolutePath);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
bytes: generated.bytes,
|
|
213
|
+
path: absolutePath,
|
|
214
|
+
provider: selectedProvider,
|
|
215
|
+
model,
|
|
216
|
+
operation,
|
|
217
|
+
mimeType: generated.mimeType,
|
|
218
|
+
width: dimensions.width,
|
|
219
|
+
height: dimensions.height,
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw sanitizedError(error, signal, secrets);
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/index.ts
ADDED
|
File without changes
|