@shanepadgett/tau-agent 0.21.0 → 0.22.1
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/README.md +1 -1
- package/extensions/context-pruning/index.ts +7 -5
- package/extensions/context-pruning/projection.ts +1 -3
- package/extensions/context-pruning/prune.ts +18 -20
- package/extensions/context-pruning/render.ts +1 -5
- package/extensions/explore/read-cache.ts +8 -32
- package/extensions/handoff/index.ts +12 -6
- package/extensions/image-gen/index.ts +24 -133
- package/package.json +9 -2
- package/shared/context-pruning-state.ts +1 -9
- 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
|
|
|
@@ -11,7 +11,7 @@ Before calling the tool, the agent writes visible prose containing the durable c
|
|
|
11
11
|
The tool accepts three required lists:
|
|
12
12
|
|
|
13
13
|
- `keepToolCalls` retains exact earlier tool exchanges by tool-call ID. Parallel calls remain independently selectable: retaining one call does not retain its siblings.
|
|
14
|
-
- `keepFiles` reads each selected file from disk and carries its complete current contents forward as a fresh autoread snapshot. It does not require an earlier complete read.
|
|
14
|
+
- `keepFiles` reads each selected file from disk and carries its complete current contents forward as a fresh autoread snapshot. Each successful snapshot appears as its own autoread marker below the compact checkpoint result. It does not require an earlier complete read.
|
|
15
15
|
- `deferFiles` carries forward a short advisory note explaining why a file is irrelevant now and when to reconsider it.
|
|
16
16
|
|
|
17
17
|
Duplicate selections are collapsed. A file selected in both `keepFiles` and `deferFiles` is kept. Missing, unreadable, or otherwise unsnapshotable files produce warnings in the successful tool result; they do not block the checkpoint or other file snapshots.
|
|
@@ -117,7 +117,7 @@ export default function contextPruningExtension(pi: ExtensionAPI): void {
|
|
|
117
117
|
parameters: contextPruneParameters,
|
|
118
118
|
executionMode: "sequential",
|
|
119
119
|
async execute(toolCallId, params, signal, _onUpdate, executionContext) {
|
|
120
|
-
|
|
120
|
+
const execution = await executeContextPrune({
|
|
121
121
|
toolCallId,
|
|
122
122
|
params,
|
|
123
123
|
signal,
|
|
@@ -125,6 +125,10 @@ export default function contextPruningExtension(pi: ExtensionAPI): void {
|
|
|
125
125
|
generation: lifecycleGeneration,
|
|
126
126
|
currentGeneration: () => lifecycleGeneration,
|
|
127
127
|
});
|
|
128
|
+
for (const autoread of execution.autoreads) {
|
|
129
|
+
pi.sendMessage(autoread, { deliverAs: "steer" });
|
|
130
|
+
}
|
|
131
|
+
return execution.result;
|
|
128
132
|
},
|
|
129
133
|
renderCall(args, theme, context) {
|
|
130
134
|
return renderContextPruneCall(args, theme, {
|
|
@@ -235,9 +239,7 @@ export default function contextPruningExtension(pi: ExtensionAPI): void {
|
|
|
235
239
|
const boundary = baseline + reminder * nudgeEveryPercent;
|
|
236
240
|
if (boundary <= nudgeState.highestBoundary) return undefined;
|
|
237
241
|
const tierCount = nudgeInstructions.length;
|
|
238
|
-
const tierFloor = nudgeState.terminalTierReached
|
|
239
|
-
? tierCount
|
|
240
|
-
: Math.min(nudgeState.highestTier, tierCount);
|
|
242
|
+
const tierFloor = nudgeState.terminalTierReached ? tierCount : Math.min(nudgeState.highestTier, tierCount);
|
|
241
243
|
const tier = Math.max(Math.min(reminder, tierCount), tierFloor);
|
|
242
244
|
const instruction = nudgeInstructions[tier - 1] ?? nudgeInstructions[0];
|
|
243
245
|
const details: ContextPruningNudgeDetailsV2 = {
|
|
@@ -328,7 +330,7 @@ function reconstructNudgeState(branch: readonly SessionEntry[], anchorToolCallId
|
|
|
328
330
|
(growthBaselinePercent !== undefined && details.growthBaselinePercent !== growthBaselinePercent)
|
|
329
331
|
)
|
|
330
332
|
continue;
|
|
331
|
-
const expectedTierFloor = terminalTierReached
|
|
333
|
+
const expectedTierFloor: number = terminalTierReached
|
|
332
334
|
? details.tierCount
|
|
333
335
|
: Math.min(highestTier, details.tierCount);
|
|
334
336
|
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
|
|
|
@@ -40,11 +34,16 @@ interface ContextPruneExecutionOptions {
|
|
|
40
34
|
currentGeneration: () => number;
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
interface
|
|
37
|
+
interface ContextPruneToolResult {
|
|
44
38
|
content: Array<{ type: "text"; text: string }>;
|
|
45
39
|
details: ContextPruneDetailsV2;
|
|
46
40
|
}
|
|
47
41
|
|
|
42
|
+
interface ContextPruneExecutionResult {
|
|
43
|
+
result: ContextPruneToolResult;
|
|
44
|
+
autoreads: PreparedAutoreadMessage[];
|
|
45
|
+
}
|
|
46
|
+
|
|
48
47
|
export async function executeContextPrune(options: ContextPruneExecutionOptions): Promise<ContextPruneExecutionResult> {
|
|
49
48
|
assertCurrent(options);
|
|
50
49
|
const retainedToolCallIds = [...new Set(options.params.keepToolCalls.map((selection) => selection.toolCallId))];
|
|
@@ -97,8 +96,6 @@ export async function executeContextPrune(options: ContextPruneExecutionOptions)
|
|
|
97
96
|
const refreshedFiles = preparedSnapshots.map((snapshot) => ({
|
|
98
97
|
path: snapshot.details.path,
|
|
99
98
|
rowId: snapshot.details.rowId,
|
|
100
|
-
servedHash: snapshot.details.readCache.servedHash,
|
|
101
|
-
autoreadDetails: { ...snapshot.details },
|
|
102
99
|
}));
|
|
103
100
|
const details: ContextPruneDetailsV2 = {
|
|
104
101
|
v: 2,
|
|
@@ -116,14 +113,14 @@ export async function executeContextPrune(options: ContextPruneExecutionOptions)
|
|
|
116
113
|
? "Context checkpoint applied. Continue with the next action stated before this call."
|
|
117
114
|
: `Context checkpoint applied with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}:\n${warnings.map((warning) => `- ${warning}`).join("\n")}\nContinue with the next action stated before this call.`;
|
|
118
115
|
return {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
116
|
+
result: {
|
|
117
|
+
content: [
|
|
118
|
+
{ type: "text", text: status },
|
|
119
|
+
...(deferredFiles.length === 0 ? [] : [{ type: "text" as const, text: deferredFileText(deferredFiles) }]),
|
|
120
|
+
],
|
|
121
|
+
details,
|
|
122
|
+
},
|
|
123
|
+
autoreads: preparedSnapshots,
|
|
127
124
|
};
|
|
128
125
|
}
|
|
129
126
|
|
|
@@ -169,7 +166,8 @@ function collectPrunedRows(
|
|
|
169
166
|
if (entry.message.toolName !== "context_prune" || !isRecord(entry.message.details)) continue;
|
|
170
167
|
const files = entry.message.details.refreshedFiles;
|
|
171
168
|
if (!Array.isArray(files)) continue;
|
|
172
|
-
for (const file of files)
|
|
169
|
+
for (const file of files)
|
|
170
|
+
if (isRecord(file) && typeof file.rowId === "string") prunedAutoreadRowIds.add(file.rowId);
|
|
173
171
|
}
|
|
174
172
|
return { prunedToolCallIds: [...prunedToolCallIds], prunedAutoreadRowIds: [...prunedAutoreadRowIds] };
|
|
175
173
|
}
|
|
@@ -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
|
}
|
|
@@ -128,30 +128,25 @@ export function replayReadCache(
|
|
|
128
128
|
if (entry.type === "message" && "message" in entry) message = entry.message;
|
|
129
129
|
else if (entry.type === "custom_message") message = entry;
|
|
130
130
|
else continue;
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!parsedMeta) continue;
|
|
136
|
-
acceptedCarryForward = true;
|
|
137
|
-
const candidateRowId = readRowId(candidate);
|
|
138
|
-
if (candidateRowId && ignoredRowIds.has(candidateRowId)) continue;
|
|
131
|
+
const parsedMeta = readMetaFromMessage(message);
|
|
132
|
+
if (parsedMeta) {
|
|
133
|
+
const rowId = readRowId(message);
|
|
134
|
+
if (!rowId || ignoredRowIds.has(rowId)) continue;
|
|
139
135
|
const pathKey = resolve(cwd, parsedMeta.pathKey);
|
|
140
136
|
const meta = pathKey === parsedMeta.pathKey ? parsedMeta : { ...parsedMeta, pathKey };
|
|
141
|
-
if (
|
|
137
|
+
if (applyReadMeta(trust, failedPatchRecoveryPaths, message, meta, rowId)) {
|
|
142
138
|
const acceptedTrust = trust.get(meta.pathKey)?.get(meta.scopeKey);
|
|
143
|
-
if (acceptedTrust)
|
|
139
|
+
if (acceptedTrust)
|
|
144
140
|
acceptedRows.push({
|
|
145
|
-
rowId
|
|
141
|
+
rowId,
|
|
146
142
|
pathKey: meta.pathKey,
|
|
147
143
|
scopeKey: meta.scopeKey,
|
|
148
144
|
meta,
|
|
149
145
|
dependencyRowIds: [...acceptedTrust.rowIds],
|
|
150
146
|
});
|
|
151
|
-
}
|
|
152
147
|
}
|
|
148
|
+
continue;
|
|
153
149
|
}
|
|
154
|
-
if (acceptedCarryForward) continue;
|
|
155
150
|
for (const path of failedPatchPaths(message, cwd)) failedPatchRecoveryPaths.add(path);
|
|
156
151
|
}
|
|
157
152
|
const completeFileChains = new Map<string, CompleteFileDependencyChain>();
|
|
@@ -168,25 +163,6 @@ export function replayReadCache(
|
|
|
168
163
|
return { scopeTrust: trust, acceptedRows, completeFileChains, failedPatchRecoveryPaths };
|
|
169
164
|
}
|
|
170
165
|
|
|
171
|
-
function carriedFileMessages(message: unknown): unknown[] | undefined {
|
|
172
|
-
if (!isRecord(message) || message.role !== "toolResult" || message.toolName !== "context_prune") return undefined;
|
|
173
|
-
if (!isRecord(message.details) || message.details.v !== 2 || !Array.isArray(message.details.refreshedFiles)) {
|
|
174
|
-
return undefined;
|
|
175
|
-
}
|
|
176
|
-
if (!Array.isArray(message.content)) {
|
|
177
|
-
return undefined;
|
|
178
|
-
}
|
|
179
|
-
const messages: unknown[] = [];
|
|
180
|
-
for (let index = 0; index < message.details.refreshedFiles.length; index += 1) {
|
|
181
|
-
const file = message.details.refreshedFiles[index];
|
|
182
|
-
const part = message.content[index + 1];
|
|
183
|
-
if (!isRecord(file) || !isRecord(file.autoreadDetails)) continue;
|
|
184
|
-
if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") continue;
|
|
185
|
-
messages.push({ customType: "tau.autoread", content: part.text, details: file.autoreadDetails });
|
|
186
|
-
}
|
|
187
|
-
return messages;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
166
|
function applyReadMeta(
|
|
191
167
|
trust: Map<string, Map<string, ReadCacheScopeTrust>>,
|
|
192
168
|
failedPatchRecoveryPaths: Set<string>,
|
|
@@ -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.1",
|
|
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.1",
|
|
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": {
|
|
@@ -5,8 +5,6 @@ const CONTEXT_PRUNE_TOOL = "context_prune";
|
|
|
5
5
|
export interface ContextPruneRefreshedFileV2 {
|
|
6
6
|
path: string;
|
|
7
7
|
rowId: string;
|
|
8
|
-
servedHash: string;
|
|
9
|
-
autoreadDetails: Record<string, unknown>;
|
|
10
8
|
}
|
|
11
9
|
|
|
12
10
|
export interface ContextPruneDeferredFileV2 {
|
|
@@ -116,13 +114,7 @@ function parseRefreshedFiles(value: unknown): ContextPruneRefreshedFileV2[] | un
|
|
|
116
114
|
const files: ContextPruneRefreshedFileV2[] = [];
|
|
117
115
|
for (const item of value) {
|
|
118
116
|
if (!isRecord(item) || !isNonEmptyString(item.path) || !isNonEmptyString(item.rowId)) return undefined;
|
|
119
|
-
|
|
120
|
-
files.push({
|
|
121
|
-
path: item.path,
|
|
122
|
-
rowId: item.rowId,
|
|
123
|
-
servedHash: item.servedHash,
|
|
124
|
-
autoreadDetails: { ...item.autoreadDetails },
|
|
125
|
-
});
|
|
117
|
+
files.push({ path: item.path, rowId: item.rowId });
|
|
126
118
|
}
|
|
127
119
|
return files;
|
|
128
120
|
}
|
|
@@ -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
|