@shanepadgett/tau-agent 0.18.1 → 0.20.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/extensions/cache-diagnostics/README.md +11 -0
- package/extensions/cache-diagnostics/index.ts +655 -0
- package/extensions/context-pruning/README.md +123 -10
- package/extensions/context-pruning/projection.ts +18 -8
- package/extensions/image-gen/README.md +6 -4
- package/extensions/image-gen/client.ts +111 -12
- package/extensions/image-gen/constants.ts +5 -0
- package/extensions/image-gen/index.ts +56 -13
- package/extensions/tau-help/help.md +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Cache Diagnostics
|
|
2
|
+
|
|
3
|
+
Cache Diagnostics records compact fingerprints of Tau's final provider requests. It helps separate local prompt changes from unexplained provider cache misses without storing prompts, source code, or credentials.
|
|
4
|
+
|
|
5
|
+
The extension runs automatically. Logs are written under:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
~/.pi/agent/cache-diagnostics/
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Run `/cache-debug` after suspicious misses. Tau writes a bounded report under `~/.pi/agent/cache-diagnostics/reports/` for later investigation. Logs and reports older than 30 days are removed when a session starts.
|
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, type FileHandle, mkdir, open, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir, type BuildSystemPromptOptions, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const CACHE_NOISE_FLOOR = 1_024;
|
|
8
|
+
const MAX_MEMORY_RECORDS = 500;
|
|
9
|
+
const MAX_REPORT_INCIDENTS = 20;
|
|
10
|
+
const RECENT_REPORT_REQUESTS = 30;
|
|
11
|
+
const MAX_LOG_TAIL_BYTES = 10 * 1_024 * 1_024;
|
|
12
|
+
|
|
13
|
+
interface HashFingerprint {
|
|
14
|
+
hash: string;
|
|
15
|
+
bytes: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ItemFingerprint extends HashFingerprint {
|
|
19
|
+
type: string | null;
|
|
20
|
+
role: string | null;
|
|
21
|
+
name: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ToolFingerprint extends HashFingerprint {
|
|
25
|
+
name: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FieldFingerprint extends HashFingerprint {
|
|
29
|
+
name: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PromptState {
|
|
33
|
+
hash: string;
|
|
34
|
+
systemPromptHash: string;
|
|
35
|
+
selectedTools: string[];
|
|
36
|
+
toolSnippetsHash: string | null;
|
|
37
|
+
promptGuidelinesHash: string | null;
|
|
38
|
+
appendSystemPromptHash: string | null;
|
|
39
|
+
customPromptHash: string | null;
|
|
40
|
+
contextFiles: Array<{ path: string; hash: string }>;
|
|
41
|
+
skills: Array<{ name: string | null; path: string | null }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PayloadFingerprint {
|
|
45
|
+
model: string | null;
|
|
46
|
+
promptCacheKeyHash: string | null;
|
|
47
|
+
instructionsHash: string | null;
|
|
48
|
+
toolsHash: string | null;
|
|
49
|
+
nonSequenceHash: string;
|
|
50
|
+
sequenceField: "input" | "messages" | "none";
|
|
51
|
+
fields: FieldFingerprint[];
|
|
52
|
+
tools: ToolFingerprint[];
|
|
53
|
+
items: ItemFingerprint[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface RequestRecord {
|
|
57
|
+
kind: "request";
|
|
58
|
+
version: 2;
|
|
59
|
+
timestamp: string;
|
|
60
|
+
id: string;
|
|
61
|
+
turnIndex: number | null;
|
|
62
|
+
previousRequestId: string | null;
|
|
63
|
+
model: string | null;
|
|
64
|
+
promptCacheKeyHash: string | null;
|
|
65
|
+
instructionsHash: string | null;
|
|
66
|
+
toolsHash: string | null;
|
|
67
|
+
nonSequenceHash: string;
|
|
68
|
+
sequenceField: "input" | "messages" | "none";
|
|
69
|
+
fields: FieldFingerprint[];
|
|
70
|
+
tools: ToolFingerprint[];
|
|
71
|
+
items: ItemFingerprint[];
|
|
72
|
+
previousItems: number;
|
|
73
|
+
commonPrefixItems: number;
|
|
74
|
+
stableEnvelope: boolean;
|
|
75
|
+
previousExactPrefix: boolean;
|
|
76
|
+
previousPromptTokens: number;
|
|
77
|
+
changes: {
|
|
78
|
+
envelopeFields: string[];
|
|
79
|
+
toolsAdded: string[];
|
|
80
|
+
toolsRemoved: string[];
|
|
81
|
+
toolsChanged: string[];
|
|
82
|
+
firstChangedItem: number | null;
|
|
83
|
+
promptStateChanged: boolean;
|
|
84
|
+
};
|
|
85
|
+
promptState: PromptState | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ResponseRecord {
|
|
89
|
+
kind: "response";
|
|
90
|
+
version: 2;
|
|
91
|
+
timestamp: string;
|
|
92
|
+
id: string;
|
|
93
|
+
status: number;
|
|
94
|
+
headers: Record<string, string>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ResultRecord {
|
|
98
|
+
kind: "result";
|
|
99
|
+
version: 2;
|
|
100
|
+
timestamp: string;
|
|
101
|
+
id: string;
|
|
102
|
+
provider: string;
|
|
103
|
+
model: string;
|
|
104
|
+
stopReason: string;
|
|
105
|
+
usage: {
|
|
106
|
+
input: number;
|
|
107
|
+
cacheRead: number;
|
|
108
|
+
cacheWrite: number;
|
|
109
|
+
promptTokens: number;
|
|
110
|
+
};
|
|
111
|
+
previousExactPrefix: boolean;
|
|
112
|
+
missedTokens: number;
|
|
113
|
+
cacheMiss: boolean;
|
|
114
|
+
baselinePromoted: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface MarkerRecord {
|
|
118
|
+
kind: "marker";
|
|
119
|
+
version: 2;
|
|
120
|
+
timestamp: string;
|
|
121
|
+
name: string;
|
|
122
|
+
details: Record<string, string | number | boolean | null>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Attempt {
|
|
126
|
+
id: string;
|
|
127
|
+
fingerprint: PayloadFingerprint;
|
|
128
|
+
request: RequestRecord;
|
|
129
|
+
responseStatus: number | undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default function cacheDiagnosticsExtension(pi: ExtensionAPI): void {
|
|
133
|
+
const directory = join(getAgentDir(), "cache-diagnostics");
|
|
134
|
+
const reportsDirectory = join(directory, "reports");
|
|
135
|
+
const runtimeId = randomUUID();
|
|
136
|
+
let logFile = "";
|
|
137
|
+
let cwd = "";
|
|
138
|
+
let sessionId = "";
|
|
139
|
+
let sessionFile: string | null = null;
|
|
140
|
+
let requestSequence = 0;
|
|
141
|
+
let turnIndex: number | null = null;
|
|
142
|
+
let latestPromptState: PromptState | null = null;
|
|
143
|
+
let previousPromptState: PromptState | null = null;
|
|
144
|
+
let previousFingerprint: PayloadFingerprint | undefined;
|
|
145
|
+
let previousRequestId: string | null = null;
|
|
146
|
+
let previousPromptTokens = 0;
|
|
147
|
+
let cacheActivitySeen = false;
|
|
148
|
+
let awaitingResponses: Attempt[] = [];
|
|
149
|
+
let turnAttempts: Attempt[] = [];
|
|
150
|
+
let requests: RequestRecord[] = [];
|
|
151
|
+
let responses: ResponseRecord[] = [];
|
|
152
|
+
let results: ResultRecord[] = [];
|
|
153
|
+
let markers: MarkerRecord[] = [];
|
|
154
|
+
|
|
155
|
+
const appendRecord = async (record: object): Promise<void> => {
|
|
156
|
+
const path = logFile;
|
|
157
|
+
if (!path) return;
|
|
158
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
159
|
+
};
|
|
160
|
+
const addMarker = async (
|
|
161
|
+
name: string,
|
|
162
|
+
details: Record<string, string | number | boolean | null> = {},
|
|
163
|
+
): Promise<void> => {
|
|
164
|
+
const marker: MarkerRecord = {
|
|
165
|
+
kind: "marker",
|
|
166
|
+
version: 2,
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
name,
|
|
169
|
+
details,
|
|
170
|
+
};
|
|
171
|
+
markers = keepRecent([...markers, marker]);
|
|
172
|
+
await appendRecord(marker);
|
|
173
|
+
};
|
|
174
|
+
const resetComparison = () => {
|
|
175
|
+
previousFingerprint = undefined;
|
|
176
|
+
previousRequestId = null;
|
|
177
|
+
previousPromptState = null;
|
|
178
|
+
previousPromptTokens = 0;
|
|
179
|
+
cacheActivitySeen = false;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
pi.registerCommand("cache-debug", {
|
|
183
|
+
description: "Write a bounded prompt-cache diagnostic report for this session",
|
|
184
|
+
async handler(_args, ctx) {
|
|
185
|
+
await mkdir(reportsDirectory, { recursive: true });
|
|
186
|
+
const incidentResults = results.filter((result) => result.cacheMiss).slice(-MAX_REPORT_INCIDENTS);
|
|
187
|
+
const selectedIds = new Set<string>();
|
|
188
|
+
for (const result of incidentResults) {
|
|
189
|
+
selectedIds.add(result.id);
|
|
190
|
+
const request = requests.find((candidate) => candidate.id === result.id);
|
|
191
|
+
if (request?.previousRequestId) selectedIds.add(request.previousRequestId);
|
|
192
|
+
}
|
|
193
|
+
for (const request of requests.slice(-RECENT_REPORT_REQUESTS)) selectedIds.add(request.id);
|
|
194
|
+
const selectedRequests = requests.filter((request) => selectedIds.has(request.id));
|
|
195
|
+
const selectedResponses = responses.filter((response) => selectedIds.has(response.id));
|
|
196
|
+
const selectedResults = results.filter((result) => selectedIds.has(result.id));
|
|
197
|
+
const createdAt = new Date().toISOString();
|
|
198
|
+
const safeSessionId = (sessionId || "ephemeral").replaceAll(/[^a-zA-Z0-9_-]/g, "_");
|
|
199
|
+
const reportFile = join(reportsDirectory, `${safeSessionId}-${createdAt.replaceAll(/[:.]/g, "-")}.json`);
|
|
200
|
+
const temporaryFile = `${reportFile}.${randomUUID()}.tmp`;
|
|
201
|
+
const report = {
|
|
202
|
+
kind: "tau-cache-debug",
|
|
203
|
+
version: 1,
|
|
204
|
+
createdAt,
|
|
205
|
+
cwd: cwd || ctx.cwd,
|
|
206
|
+
runtimeId,
|
|
207
|
+
sessionId: sessionId || null,
|
|
208
|
+
sessionFile,
|
|
209
|
+
sourceLog: logFile || null,
|
|
210
|
+
summary: {
|
|
211
|
+
requestsObserved: requests.length,
|
|
212
|
+
resultsObserved: results.length,
|
|
213
|
+
cacheMissesObserved: results.filter((result) => result.cacheMiss).length,
|
|
214
|
+
requestsIncluded: selectedRequests.length,
|
|
215
|
+
models: [...new Set(results.map((result) => `${result.provider}/${result.model}`))],
|
|
216
|
+
},
|
|
217
|
+
selection: {
|
|
218
|
+
maxIncidents: MAX_REPORT_INCIDENTS,
|
|
219
|
+
recentRequests: RECENT_REPORT_REQUESTS,
|
|
220
|
+
},
|
|
221
|
+
markers,
|
|
222
|
+
requests: selectedRequests,
|
|
223
|
+
responses: selectedResponses,
|
|
224
|
+
results: selectedResults,
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
await writeFile(temporaryFile, `${JSON.stringify(report, null, "\t")}\n`, { encoding: "utf8", flag: "wx" });
|
|
228
|
+
await rename(temporaryFile, reportFile);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
await unlink(temporaryFile).catch(() => undefined);
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
ctx.ui.notify(`Cache debug written: ${reportFile}`, "info");
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
238
|
+
cwd = ctx.cwd;
|
|
239
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
240
|
+
sessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
241
|
+
requestSequence = 0;
|
|
242
|
+
turnIndex = null;
|
|
243
|
+
latestPromptState = null;
|
|
244
|
+
awaitingResponses = [];
|
|
245
|
+
turnAttempts = [];
|
|
246
|
+
resetComparison();
|
|
247
|
+
await mkdir(reportsDirectory, { recursive: true });
|
|
248
|
+
logFile = join(directory, `${sessionId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`);
|
|
249
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
250
|
+
for (const parent of [directory, reportsDirectory]) {
|
|
251
|
+
for (const entry of await readdir(parent, { withFileTypes: true })) {
|
|
252
|
+
if (
|
|
253
|
+
!entry.isFile() ||
|
|
254
|
+
(!entry.name.endsWith(".jsonl") && !entry.name.endsWith(".json") && !entry.name.endsWith(".tmp"))
|
|
255
|
+
)
|
|
256
|
+
continue;
|
|
257
|
+
const path = join(parent, entry.name);
|
|
258
|
+
if ((await stat(path)).mtimeMs < cutoff) await unlink(path);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const persisted = await readRecentLogRecords(logFile);
|
|
262
|
+
requests = keepRecent(
|
|
263
|
+
persisted
|
|
264
|
+
.filter((record) => record.kind === "request" && record.version === 2)
|
|
265
|
+
.map((record) => record as unknown as RequestRecord),
|
|
266
|
+
);
|
|
267
|
+
responses = keepRecent(
|
|
268
|
+
persisted
|
|
269
|
+
.filter((record) => record.kind === "response" && record.version === 2)
|
|
270
|
+
.map((record) => record as unknown as ResponseRecord),
|
|
271
|
+
);
|
|
272
|
+
results = keepRecent(
|
|
273
|
+
persisted
|
|
274
|
+
.filter((record) => record.kind === "result" && record.version === 2)
|
|
275
|
+
.map((record) => record as unknown as ResultRecord),
|
|
276
|
+
);
|
|
277
|
+
markers = keepRecent(
|
|
278
|
+
persisted
|
|
279
|
+
.filter((record) => record.kind === "marker" && record.version === 2)
|
|
280
|
+
.map((record) => record as unknown as MarkerRecord),
|
|
281
|
+
);
|
|
282
|
+
let latestModelSelectIndex = -1;
|
|
283
|
+
for (let index = persisted.length - 1; index >= 0; index -= 1) {
|
|
284
|
+
const record = persisted[index];
|
|
285
|
+
if (record?.kind !== "marker" || record.version !== 2 || record.name !== "model-select") continue;
|
|
286
|
+
latestModelSelectIndex = index;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
const comparisonRecords = persisted.slice(latestModelSelectIndex + 1);
|
|
290
|
+
const comparisonResults = comparisonRecords
|
|
291
|
+
.filter((record) => record.kind === "result" && record.version === 2)
|
|
292
|
+
.map((record) => record as unknown as ResultRecord);
|
|
293
|
+
const latestPromotedResult = [...comparisonResults].reverse().find((result) => result.baselinePromoted);
|
|
294
|
+
const latestRequest = latestPromotedResult
|
|
295
|
+
? requests.find((request) => request.id === latestPromotedResult.id)
|
|
296
|
+
: undefined;
|
|
297
|
+
if (latestPromotedResult && latestRequest) {
|
|
298
|
+
previousFingerprint = payloadFingerprintFromRequest(latestRequest);
|
|
299
|
+
previousRequestId = latestRequest.id;
|
|
300
|
+
previousPromptState = latestRequest.promptState;
|
|
301
|
+
previousPromptTokens = latestPromotedResult.usage.promptTokens;
|
|
302
|
+
cacheActivitySeen = comparisonResults.some(
|
|
303
|
+
(result) => result.baselinePromoted && result.usage.cacheRead + result.usage.cacheWrite > 0,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
await appendRecord({
|
|
307
|
+
kind: "runtime",
|
|
308
|
+
version: 2,
|
|
309
|
+
timestamp: new Date().toISOString(),
|
|
310
|
+
runtimeId,
|
|
311
|
+
cwd,
|
|
312
|
+
sessionId,
|
|
313
|
+
sessionFile,
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
pi.on("before_agent_start", (event) => {
|
|
318
|
+
latestPromptState = fingerprintPromptState(event.systemPrompt, event.systemPromptOptions);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
pi.on("turn_start", (event) => {
|
|
322
|
+
turnIndex = event.turnIndex;
|
|
323
|
+
turnAttempts = [];
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
pi.on("before_provider_request", async (event) => {
|
|
327
|
+
const unresolvedIds = new Set(
|
|
328
|
+
turnAttempts.filter((attempt) => attempt.responseStatus === undefined).map((attempt) => attempt.id),
|
|
329
|
+
);
|
|
330
|
+
if (unresolvedIds.size > 0) {
|
|
331
|
+
awaitingResponses = awaitingResponses.filter((attempt) => !unresolvedIds.has(attempt.id));
|
|
332
|
+
}
|
|
333
|
+
requestSequence += 1;
|
|
334
|
+
const id = `${runtimeId}:${requestSequence}`;
|
|
335
|
+
const fingerprint = fingerprintPayload(event.payload);
|
|
336
|
+
let commonPrefixItems = 0;
|
|
337
|
+
if (previousFingerprint) {
|
|
338
|
+
const limit = Math.min(previousFingerprint.items.length, fingerprint.items.length);
|
|
339
|
+
while (
|
|
340
|
+
commonPrefixItems < limit &&
|
|
341
|
+
previousFingerprint.items[commonPrefixItems]?.hash === fingerprint.items[commonPrefixItems]?.hash
|
|
342
|
+
) {
|
|
343
|
+
commonPrefixItems += 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const stableEnvelope = previousFingerprint?.nonSequenceHash === fingerprint.nonSequenceHash;
|
|
347
|
+
const previousExactPrefix =
|
|
348
|
+
previousFingerprint !== undefined &&
|
|
349
|
+
stableEnvelope &&
|
|
350
|
+
previousFingerprint.sequenceField === fingerprint.sequenceField &&
|
|
351
|
+
commonPrefixItems === previousFingerprint.items.length;
|
|
352
|
+
const request: RequestRecord = {
|
|
353
|
+
kind: "request",
|
|
354
|
+
version: 2,
|
|
355
|
+
timestamp: new Date().toISOString(),
|
|
356
|
+
id,
|
|
357
|
+
turnIndex,
|
|
358
|
+
previousRequestId,
|
|
359
|
+
model: fingerprint.model,
|
|
360
|
+
promptCacheKeyHash: fingerprint.promptCacheKeyHash,
|
|
361
|
+
instructionsHash: fingerprint.instructionsHash,
|
|
362
|
+
toolsHash: fingerprint.toolsHash,
|
|
363
|
+
nonSequenceHash: fingerprint.nonSequenceHash,
|
|
364
|
+
sequenceField: fingerprint.sequenceField,
|
|
365
|
+
fields: fingerprint.fields,
|
|
366
|
+
tools: fingerprint.tools,
|
|
367
|
+
items: fingerprint.items,
|
|
368
|
+
previousItems: previousFingerprint?.items.length ?? 0,
|
|
369
|
+
commonPrefixItems,
|
|
370
|
+
stableEnvelope,
|
|
371
|
+
previousExactPrefix,
|
|
372
|
+
previousPromptTokens,
|
|
373
|
+
changes: compareFingerprints(previousFingerprint, fingerprint, previousPromptState, latestPromptState),
|
|
374
|
+
promptState: latestPromptState,
|
|
375
|
+
};
|
|
376
|
+
const attempt: Attempt = { id, fingerprint, request, responseStatus: undefined };
|
|
377
|
+
awaitingResponses.push(attempt);
|
|
378
|
+
turnAttempts.push(attempt);
|
|
379
|
+
requests = keepRecent([...requests, request]);
|
|
380
|
+
await appendRecord(request);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
pi.on("after_provider_response", async (event) => {
|
|
384
|
+
const attempt = awaitingResponses.shift();
|
|
385
|
+
if (!attempt) return;
|
|
386
|
+
attempt.responseStatus = event.status;
|
|
387
|
+
const selectedHeaders = Object.fromEntries(
|
|
388
|
+
Object.entries(event.headers).filter(([name]) => {
|
|
389
|
+
const normalized = name.toLowerCase();
|
|
390
|
+
return normalized.includes("request-id") || normalized === "cf-ray";
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
const response: ResponseRecord = {
|
|
394
|
+
kind: "response",
|
|
395
|
+
version: 2,
|
|
396
|
+
timestamp: new Date().toISOString(),
|
|
397
|
+
id: attempt.id,
|
|
398
|
+
status: event.status,
|
|
399
|
+
headers: selectedHeaders,
|
|
400
|
+
};
|
|
401
|
+
responses = keepRecent([...responses, response]);
|
|
402
|
+
await appendRecord(response);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
pi.on("message_end", async (event) => {
|
|
406
|
+
if (event.message.role !== "assistant") return;
|
|
407
|
+
const attempt = turnAttempts.at(-1);
|
|
408
|
+
const completedAttemptIds = new Set(turnAttempts.map((candidate) => candidate.id));
|
|
409
|
+
awaitingResponses = awaitingResponses.filter((candidate) => !completedAttemptIds.has(candidate.id));
|
|
410
|
+
turnAttempts = [];
|
|
411
|
+
if (!attempt) return;
|
|
412
|
+
const usage = event.message.usage;
|
|
413
|
+
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
414
|
+
const baselinePromoted =
|
|
415
|
+
event.message.stopReason !== "error" &&
|
|
416
|
+
event.message.stopReason !== "aborted" &&
|
|
417
|
+
(attempt.responseStatus === undefined || (attempt.responseStatus >= 200 && attempt.responseStatus < 300));
|
|
418
|
+
const missedTokens =
|
|
419
|
+
baselinePromoted && attempt.request.previousExactPrefix
|
|
420
|
+
? Math.max(0, Math.min(attempt.request.previousPromptTokens, promptTokens) - usage.cacheRead)
|
|
421
|
+
: 0;
|
|
422
|
+
const cacheMiss = baselinePromoted && cacheActivitySeen && missedTokens > CACHE_NOISE_FLOOR;
|
|
423
|
+
const result: ResultRecord = {
|
|
424
|
+
kind: "result",
|
|
425
|
+
version: 2,
|
|
426
|
+
timestamp: new Date().toISOString(),
|
|
427
|
+
id: attempt.id,
|
|
428
|
+
provider: event.message.provider,
|
|
429
|
+
model: event.message.model,
|
|
430
|
+
stopReason: event.message.stopReason,
|
|
431
|
+
usage: {
|
|
432
|
+
input: usage.input,
|
|
433
|
+
cacheRead: usage.cacheRead,
|
|
434
|
+
cacheWrite: usage.cacheWrite,
|
|
435
|
+
promptTokens,
|
|
436
|
+
},
|
|
437
|
+
previousExactPrefix: attempt.request.previousExactPrefix,
|
|
438
|
+
missedTokens,
|
|
439
|
+
cacheMiss,
|
|
440
|
+
baselinePromoted,
|
|
441
|
+
};
|
|
442
|
+
results = keepRecent([...results, result]);
|
|
443
|
+
if (baselinePromoted) {
|
|
444
|
+
previousFingerprint = attempt.fingerprint;
|
|
445
|
+
previousRequestId = attempt.id;
|
|
446
|
+
previousPromptState = attempt.request.promptState;
|
|
447
|
+
previousPromptTokens = promptTokens;
|
|
448
|
+
cacheActivitySeen ||= usage.cacheRead + usage.cacheWrite > 0;
|
|
449
|
+
}
|
|
450
|
+
await appendRecord(result);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
pi.on("model_select", async (event) => {
|
|
454
|
+
resetComparison();
|
|
455
|
+
await addMarker("model-select", {
|
|
456
|
+
provider: event.model.provider,
|
|
457
|
+
model: event.model.id,
|
|
458
|
+
previousProvider: event.previousModel?.provider ?? null,
|
|
459
|
+
previousModel: event.previousModel?.id ?? null,
|
|
460
|
+
source: event.source,
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
pi.on("thinking_level_select", (event) =>
|
|
464
|
+
addMarker("thinking-level-select", { level: event.level, previousLevel: event.previousLevel }),
|
|
465
|
+
);
|
|
466
|
+
pi.on("session_compact", (event) =>
|
|
467
|
+
addMarker("session-compact", {
|
|
468
|
+
reason: event.reason,
|
|
469
|
+
fromExtension: event.fromExtension,
|
|
470
|
+
willRetry: event.willRetry,
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
pi.on("session_tree", () => addMarker("session-tree"));
|
|
474
|
+
pi.on("tool_execution_end", (event) => {
|
|
475
|
+
if (event.toolName !== "context_prune" && event.toolName !== "load_tools") return;
|
|
476
|
+
return addMarker("cache-affecting-tool", { tool: event.toolName, isError: event.isError });
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function readRecentLogRecords(path: string): Promise<Array<Record<string, unknown>>> {
|
|
481
|
+
let handle: FileHandle | undefined;
|
|
482
|
+
try {
|
|
483
|
+
handle = await open(path, "r");
|
|
484
|
+
const size = (await handle.stat()).size;
|
|
485
|
+
const start = Math.max(0, size - MAX_LOG_TAIL_BYTES);
|
|
486
|
+
const buffer = Buffer.alloc(size - start);
|
|
487
|
+
await handle.read(buffer, 0, buffer.length, start);
|
|
488
|
+
let text = buffer.toString("utf8");
|
|
489
|
+
if (start > 0) {
|
|
490
|
+
const firstNewline = text.indexOf("\n");
|
|
491
|
+
text = firstNewline < 0 ? "" : text.slice(firstNewline + 1);
|
|
492
|
+
}
|
|
493
|
+
const records: Array<Record<string, unknown>> = [];
|
|
494
|
+
for (const line of text.split("\n")) {
|
|
495
|
+
if (!line) continue;
|
|
496
|
+
try {
|
|
497
|
+
const value: unknown = JSON.parse(line);
|
|
498
|
+
if (isRecord(value)) records.push(value);
|
|
499
|
+
} catch {
|
|
500
|
+
// Ignore a partial final record left by an interrupted append.
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return records;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (isRecord(error) && error.code === "ENOENT") return [];
|
|
506
|
+
throw error;
|
|
507
|
+
} finally {
|
|
508
|
+
await handle?.close();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function payloadFingerprintFromRequest(request: RequestRecord): PayloadFingerprint {
|
|
513
|
+
return {
|
|
514
|
+
model: request.model,
|
|
515
|
+
promptCacheKeyHash: request.promptCacheKeyHash,
|
|
516
|
+
instructionsHash: request.instructionsHash,
|
|
517
|
+
toolsHash: request.toolsHash,
|
|
518
|
+
nonSequenceHash: request.nonSequenceHash,
|
|
519
|
+
sequenceField: request.sequenceField,
|
|
520
|
+
fields: request.fields,
|
|
521
|
+
tools: request.tools,
|
|
522
|
+
items: request.items,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function fingerprintPromptState(systemPrompt: string, options: BuildSystemPromptOptions): PromptState {
|
|
527
|
+
const state = {
|
|
528
|
+
systemPromptHash: hashJson(systemPrompt).hash,
|
|
529
|
+
selectedTools: options.selectedTools ?? [],
|
|
530
|
+
toolSnippetsHash: valueHash(options.toolSnippets),
|
|
531
|
+
promptGuidelinesHash: valueHash(options.promptGuidelines),
|
|
532
|
+
appendSystemPromptHash: valueHash(options.appendSystemPrompt),
|
|
533
|
+
customPromptHash: valueHash(options.customPrompt),
|
|
534
|
+
contextFiles: (options.contextFiles ?? []).map((file) => ({
|
|
535
|
+
path: file.path,
|
|
536
|
+
hash: hashJson(file.content).hash,
|
|
537
|
+
})),
|
|
538
|
+
skills: (options.skills ?? []).map((skill) => {
|
|
539
|
+
const record = skill as unknown as Record<string, unknown>;
|
|
540
|
+
return {
|
|
541
|
+
name: typeof record.name === "string" ? record.name : null,
|
|
542
|
+
path: typeof record.path === "string" ? record.path : null,
|
|
543
|
+
};
|
|
544
|
+
}),
|
|
545
|
+
};
|
|
546
|
+
return { hash: hashJson(state).hash, ...state };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function fingerprintPayload(payload: unknown): PayloadFingerprint {
|
|
550
|
+
const record = isRecord(payload) ? payload : {};
|
|
551
|
+
const sequenceField = Array.isArray(record.input) ? "input" : Array.isArray(record.messages) ? "messages" : "none";
|
|
552
|
+
const sequence = sequenceField === "none" ? [] : (record[sequenceField] as unknown[]);
|
|
553
|
+
const nonSequence = Object.fromEntries(Object.entries(record).filter(([key]) => key !== sequenceField));
|
|
554
|
+
const tools = Array.isArray(record.tools)
|
|
555
|
+
? record.tools.map((tool) => {
|
|
556
|
+
const fingerprint = hashJson(tool);
|
|
557
|
+
return { ...fingerprint, name: toolName(tool) };
|
|
558
|
+
})
|
|
559
|
+
: [];
|
|
560
|
+
return {
|
|
561
|
+
model: typeof record.model === "string" ? record.model : null,
|
|
562
|
+
promptCacheKeyHash: valueHash(record.prompt_cache_key),
|
|
563
|
+
instructionsHash: valueHash(record.instructions ?? record.system),
|
|
564
|
+
toolsHash: valueHash(record.tools),
|
|
565
|
+
nonSequenceHash: hashJson(nonSequence).hash,
|
|
566
|
+
sequenceField,
|
|
567
|
+
fields: Object.entries(nonSequence)
|
|
568
|
+
.map(([name, value]) => ({ name, ...hashJson(value) }))
|
|
569
|
+
.sort((left, right) => left.name.localeCompare(right.name)),
|
|
570
|
+
tools,
|
|
571
|
+
items: sequence.map((item) => {
|
|
572
|
+
const fingerprint = hashJson(item);
|
|
573
|
+
const itemRecord = isRecord(item) ? item : {};
|
|
574
|
+
return {
|
|
575
|
+
...fingerprint,
|
|
576
|
+
type: typeof itemRecord.type === "string" ? itemRecord.type : null,
|
|
577
|
+
role: typeof itemRecord.role === "string" ? itemRecord.role : null,
|
|
578
|
+
name: typeof itemRecord.name === "string" ? itemRecord.name : null,
|
|
579
|
+
};
|
|
580
|
+
}),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function compareFingerprints(
|
|
585
|
+
previous: PayloadFingerprint | undefined,
|
|
586
|
+
current: PayloadFingerprint,
|
|
587
|
+
previousPromptState: PromptState | null,
|
|
588
|
+
currentPromptState: PromptState | null,
|
|
589
|
+
): RequestRecord["changes"] {
|
|
590
|
+
const previousFields = new Map(previous?.fields.map((field) => [field.name, field.hash]) ?? []);
|
|
591
|
+
const currentFields = new Map(current.fields.map((field) => [field.name, field.hash]));
|
|
592
|
+
const envelopeFields = [...new Set([...previousFields.keys(), ...currentFields.keys()])]
|
|
593
|
+
.filter((name) => previousFields.get(name) !== currentFields.get(name))
|
|
594
|
+
.sort();
|
|
595
|
+
const previousTools = namedToolHashes(previous?.tools ?? []);
|
|
596
|
+
const currentTools = namedToolHashes(current.tools);
|
|
597
|
+
const toolsAdded = [...currentTools.keys()].filter((name) => !previousTools.has(name));
|
|
598
|
+
const toolsRemoved = [...previousTools.keys()].filter((name) => !currentTools.has(name));
|
|
599
|
+
const toolsChanged = [...currentTools.keys()].filter(
|
|
600
|
+
(name) => previousTools.has(name) && previousTools.get(name) !== currentTools.get(name),
|
|
601
|
+
);
|
|
602
|
+
let firstChangedItem: number | null = null;
|
|
603
|
+
if (previous) {
|
|
604
|
+
const limit = Math.max(previous.items.length, current.items.length);
|
|
605
|
+
for (let index = 0; index < limit; index += 1) {
|
|
606
|
+
if (previous.items[index]?.hash === current.items[index]?.hash) continue;
|
|
607
|
+
firstChangedItem = index;
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
envelopeFields,
|
|
613
|
+
toolsAdded,
|
|
614
|
+
toolsRemoved,
|
|
615
|
+
toolsChanged,
|
|
616
|
+
firstChangedItem,
|
|
617
|
+
promptStateChanged:
|
|
618
|
+
previousPromptState !== null &&
|
|
619
|
+
currentPromptState !== null &&
|
|
620
|
+
previousPromptState.hash !== currentPromptState.hash,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function namedToolHashes(tools: ToolFingerprint[]): Map<string, string> {
|
|
625
|
+
const named = new Map<string, string>();
|
|
626
|
+
for (const [index, tool] of tools.entries()) named.set(tool.name ?? `<unnamed:${index}>`, tool.hash);
|
|
627
|
+
return named;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function toolName(value: unknown): string | null {
|
|
631
|
+
if (!isRecord(value)) return null;
|
|
632
|
+
if (typeof value.name === "string") return value.name;
|
|
633
|
+
if (isRecord(value.function) && typeof value.function.name === "string") return value.function.name;
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function valueHash(value: unknown): string | null {
|
|
638
|
+
return value === undefined ? null : hashJson(value).hash;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function hashJson(value: unknown): HashFingerprint {
|
|
642
|
+
const serialized = JSON.stringify(value) ?? "undefined";
|
|
643
|
+
return {
|
|
644
|
+
hash: createHash("sha256").update(serialized).digest("hex"),
|
|
645
|
+
bytes: Buffer.byteLength(serialized, "utf8"),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function keepRecent<T>(items: T[]): T[] {
|
|
650
|
+
return items.length <= MAX_MEMORY_RECORDS ? items : items.slice(-MAX_MEMORY_RECORDS);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
654
|
+
return typeof value === "object" && value !== null;
|
|
655
|
+
}
|
|
@@ -1,22 +1,135 @@
|
|
|
1
1
|
# Context Pruning
|
|
2
2
|
|
|
3
|
-
Context Pruning removes
|
|
3
|
+
Context Pruning removes old tool calls and their results from the messages sent to the model. It does not delete them from the saved conversation. Branch switching, transcript review, and normal Pi compaction still see the original records.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The feature can also remove old full-file autoread messages and old private reasoning. User messages, visible assistant prose, branch summaries, compaction summaries, shell records, and unrelated custom messages remain.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## The important distinction
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Context usage and pruning are separate decisions.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- The context percentage decides when Tau gives the agent a pruning hint.
|
|
12
|
+
- A `context_prune` call decides what old evidence the agent wants to remove or retain.
|
|
13
|
+
- Tau then checks whether that exact removal is safe and whether it saves enough tokens.
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
Being at 70% context does not force Tau to apply a prune. With the default settings, 70% is high enough for a pressure hint, but every prune still has to pass the checks below.
|
|
16
|
+
|
|
17
|
+
## What happens when the agent calls `context_prune`
|
|
18
|
+
|
|
19
|
+
The agent supplies three required lists:
|
|
20
|
+
|
|
21
|
+
- `keepFiles`: files whose complete current contents must remain available.
|
|
22
|
+
- `keepToolCalls`: exact earlier tool calls and results that must remain available.
|
|
23
|
+
- `deferFiles`: files that are currently irrelevant but may matter under a stated condition.
|
|
24
|
+
|
|
25
|
+
Unless retained by one of the first two lists, every complete earlier tool exchange and every autoread currently eligible for pruning is selected for removal. The current `context_prune` call is the anchor: later messages and tool results are unaffected by that anchor.
|
|
26
|
+
|
|
27
|
+
The agent is instructed to put durable conclusions, conditional information, and its next action in visible prose immediately before the call. That prose survives pruning. Tau enforces that `context_prune` is the only tool call in its assistant message, but it does not judge whether the preceding prose is complete or useful.
|
|
28
|
+
|
|
29
|
+
Tau prepares the result without changing the active context. It estimates the token count before and after the proposed removal, including any replacement file contents and deferred-file note. It applies the prune only when the estimated saving is at least `minimumReclaimTokens`.
|
|
30
|
+
|
|
31
|
+
After a successful call, Tau removes the selected evidence from future model input. The saved conversation remains unchanged. The tool result records exactly which tool exchanges and autoreads were removed, retained, or refreshed.
|
|
32
|
+
|
|
33
|
+
## Why a prune is skipped
|
|
34
|
+
|
|
35
|
+
A skipped prune removes nothing and publishes no deferred-file note or replacement file contents. Tau skips when any of these checks fails:
|
|
36
|
+
|
|
37
|
+
1. Context Pruning is disabled.
|
|
38
|
+
2. Tau does not have a usable copy of the exact messages most recently sent to the model.
|
|
39
|
+
3. The `context_prune` call is missing from the active branch.
|
|
40
|
+
4. Another tool call appears beside `context_prune` in the same assistant message.
|
|
41
|
+
5. The current model input contains duplicate, mismatched, or orphaned tool records that Tau cannot safely remove. An unmatched call from an explicitly aborted assistant response is discarded and does not block pruning.
|
|
42
|
+
6. A retained tool-call ID is duplicated, missing, or is not a complete exchange in the current model input.
|
|
43
|
+
7. Two selected file paths resolve to the same file, including aliases and symbolic links across `keepFiles` and `deferFiles`.
|
|
44
|
+
8. A retained file does not have an earlier complete-file read, has malformed read evidence, cannot currently be read as UTF-8, or exceeds the 1 MiB complete-file limit.
|
|
45
|
+
9. The estimated saving is below `minimumReclaimTokens`.
|
|
46
|
+
|
|
47
|
+
Cancellation, a session lifecycle change during preparation, and failures while publishing an already prepared prune are reported as tool failures rather than skipped prunes.
|
|
48
|
+
|
|
49
|
+
### “The latest provider-context projection is unavailable”
|
|
50
|
+
|
|
51
|
+
This error uses an internal term. In plain language, Tau did not have a usable copy of the exact message list the model had just received.
|
|
52
|
+
|
|
53
|
+
Tau normally saves that list while Pi prepares a model turn. Tau clears it on session start, branch changes, compaction, and shutdown. It also refuses to save a list with invalid tool-call/result pairing. If the list is missing or belongs to an earlier session state, `context_prune` stops before checking the agent’s selections or estimating savings.
|
|
54
|
+
|
|
55
|
+
Pi can save an aborted assistant response that requested a tool but never received a result. Tau discards that abandoned call when preparing later model input. Other unmatched calls and results still fail validation because Tau cannot prove whether their evidence is incomplete.
|
|
56
|
+
|
|
57
|
+
For example, a row that says `context_prune 29 selections` followed by this error means:
|
|
58
|
+
|
|
59
|
+
- the agent supplied 29 items across the three selection lists;
|
|
60
|
+
- Tau did not inspect or apply those selections;
|
|
61
|
+
- no old tool calls were removed;
|
|
62
|
+
- the context percentage did not cause the rejection;
|
|
63
|
+
- the current implementation does not reconstruct the missing list during that call.
|
|
64
|
+
|
|
65
|
+
The response tells the agent not to retry immediately because repeated calls in the same state would usually fail for the same reason. A later model turn should normally give Tau a new list. Repeated occurrences indicate a feature defect or a session history that Tau cannot safely process.
|
|
66
|
+
|
|
67
|
+
## Retaining files
|
|
68
|
+
|
|
69
|
+
`keepFiles` preserves complete current file knowledge, not necessarily the original read row.
|
|
70
|
+
|
|
71
|
+
Each selected file must have been read completely earlier in the current model input. A partial read is insufficient. Tau then reads the file from disk again and chooses the cheaper valid representation:
|
|
72
|
+
|
|
73
|
+
- Keep an existing complete snapshot when it still matches the file.
|
|
74
|
+
- Keep a baseline plus later read diffs when that chain reconstructs the current file and costs no more than a fresh snapshot.
|
|
75
|
+
- Add one fresh complete snapshot when the earlier evidence is stale, broken, or more expensive.
|
|
76
|
+
|
|
77
|
+
The entire prune is skipped if any selected file fails. Every retained file must currently exist, be valid UTF-8, and fit within the 1 MiB complete-file snapshot limit. Read the complete file before selecting it for retention.
|
|
78
|
+
|
|
79
|
+
Paths are resolved from the session working directory. Paths outside it are stored as absolute paths. Existing symbolic links and other aliases are resolved before duplicate checks.
|
|
80
|
+
|
|
81
|
+
The required `relevance` text explains the agent’s selection. It does not loosen any validation rule.
|
|
82
|
+
|
|
83
|
+
## Retaining tool exchanges
|
|
84
|
+
|
|
85
|
+
`keepToolCalls` retains both sides of an earlier exchange: the assistant’s tool call and the matching tool result. The ID must occur exactly once, the tool names must match, and the result must follow the call.
|
|
86
|
+
|
|
87
|
+
Retention is exact. Selecting one call does not retain nearby calls from the same assistant message. Tau can remove one parallel tool exchange while keeping another, then removes the empty assistant message if no content remains.
|
|
88
|
+
|
|
89
|
+
The required `relevance` text explains why the exchange matters. Tau does not use that text to infer additional exchanges to retain.
|
|
90
|
+
|
|
91
|
+
## Deferring files
|
|
92
|
+
|
|
93
|
+
`deferFiles` does not read or preserve file contents. It adds a hidden advisory note telling the model why each file was deferred and when to reconsider it. The latest successful anchor replaces the previous deferred-file list.
|
|
94
|
+
|
|
95
|
+
A deferred path may be missing. It still participates in canonical path and duplicate checks. Its `reason` and `relevantWhen` text are passed to the model as written.
|
|
96
|
+
|
|
97
|
+
## Automatic hints
|
|
98
|
+
|
|
99
|
+
Tau checks context usage only after a turn that produced at least one tool result. It emits at most one marker for the strongest newly crossed boundary.
|
|
100
|
+
|
|
101
|
+
With the defaults:
|
|
102
|
+
|
|
103
|
+
- `nudgeEveryPercent: 20` allows markers after enough context growth to cross 20% intervals.
|
|
104
|
+
- `pressurePercent: 50` makes a marker a pressure hint only when raw usage is greater than 50%.
|
|
105
|
+
|
|
106
|
+
Below the pressure threshold, the hidden instruction says no prune is required unless broad exploration has converged or substantial evidence is already irrelevant. Above it, the instruction asks the agent to finish the current coherent step and prune when the projected saving is substantial. A hint never calls the tool or bypasses its checks.
|
|
107
|
+
|
|
108
|
+
After a successful prune, the first later tool-using turn records a new usage baseline and emits no automatic marker. Tau waits for context usage to grow by another `nudgeEveryPercent` from that baseline. The baseline and crossed boundaries are stored with the active branch so compaction and branch navigation do not immediately repeat hints.
|
|
109
|
+
|
|
110
|
+
The visible marker is compact. The full instruction stays hidden, and the agent is told not to discuss internal context management.
|
|
111
|
+
|
|
112
|
+
## Manual requests
|
|
113
|
+
|
|
114
|
+
Run `/prune` with no arguments to ask the agent to create an anchor and continue unfinished work. The command starts an agent turn immediately. It does not force a successful prune or bypass file, tool-pairing, current-message, lifecycle, or minimum-savings checks.
|
|
115
|
+
|
|
116
|
+
Extra arguments produce `Usage: /prune`. When Context Pruning is disabled, the command reports that state and does not start a prune turn.
|
|
117
|
+
|
|
118
|
+
Asking the agent to prune in ordinary chat has the same execution boundaries once it calls `context_prune`.
|
|
119
|
+
|
|
120
|
+
## Branches, compaction, and display
|
|
121
|
+
|
|
122
|
+
Applied anchors belong to the active session branch. Switching branches rebuilds the removed-evidence set, deferred-file list, automatic-hint baseline, and warning-colored rows from that branch’s valid tool results. A skipped or malformed prune result does not become an anchor.
|
|
123
|
+
|
|
124
|
+
Context Pruning does not cancel or replace Pi’s manual, threshold, or overflow compaction. Compaction clears Tau’s saved model-input list; Tau must receive another model-input event before a later prune can use it.
|
|
125
|
+
|
|
126
|
+
Tau-owned tool rows and autoreads turn warning-colored when their evidence has been pruned. Native Pi rows and fallback rows that do not use Tau’s row-state renderer do not change color. Their evidence is still removed from model input.
|
|
14
127
|
|
|
15
128
|
## Settings
|
|
16
129
|
|
|
17
130
|
Settings live under `extensions.contextPruning` in Tau settings.
|
|
18
131
|
|
|
19
|
-
- `enabled`: enables the tool, `/prune`, automatic markers, branch replay, context
|
|
20
|
-
- `nudgeEveryPercent`: context-growth interval between automatic
|
|
21
|
-
- `pressurePercent`: usage above which
|
|
22
|
-
- `minimumReclaimTokens`: minimum estimated saving required to apply a prune. Defaults to `8000`.
|
|
132
|
+
- `enabled`: enables the tool, `/prune`, automatic markers, branch replay, context filtering, and pruned-row display. Defaults to `true`.
|
|
133
|
+
- `nudgeEveryPercent`: integer context-growth interval between automatic hints. Allowed range: `1` through `100`. Defaults to `20`.
|
|
134
|
+
- `pressurePercent`: integer usage percentage above which hints use pressure wording. Allowed range: `1` through `99`. Defaults to `50`.
|
|
135
|
+
- `minimumReclaimTokens`: positive integer minimum estimated saving required to apply a prune. Defaults to `8000`.
|
|
@@ -10,7 +10,8 @@ export function projectContext(
|
|
|
10
10
|
messages: readonly ContextMessage[],
|
|
11
11
|
state: ActiveContextPruningState,
|
|
12
12
|
): ContextMessage[] {
|
|
13
|
-
const
|
|
13
|
+
const abandonedToolCallIds = new Set<string>();
|
|
14
|
+
const inputPairs = indexToolPairs(messages, abandonedToolCallIds);
|
|
14
15
|
const anchorBoundary = visibleAnchorBoundary(messages, state.latestAnchorToolCallId, inputPairs);
|
|
15
16
|
const prunedToolCallIds = state.latestAnchorToolCallId === undefined ? new Set<string>() : state.prunedToolCallIds;
|
|
16
17
|
const prunedAutoreadRowIds =
|
|
@@ -43,7 +44,7 @@ export function projectContext(
|
|
|
43
44
|
const content = message.content.filter((block) => {
|
|
44
45
|
const remove =
|
|
45
46
|
(removeThinking && block.type === "thinking") ||
|
|
46
|
-
(block.type === "toolCall" && prunedToolCallIds.has(block.id));
|
|
47
|
+
(block.type === "toolCall" && (prunedToolCallIds.has(block.id) || abandonedToolCallIds.has(block.id)));
|
|
47
48
|
if (remove) changed = true;
|
|
48
49
|
return !remove;
|
|
49
50
|
});
|
|
@@ -77,8 +78,11 @@ function visibleAnchorBoundary(
|
|
|
77
78
|
return block?.type === "toolCall" && block.name === "context_prune" ? pair.resultIndex : undefined;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
function indexToolPairs(
|
|
81
|
-
|
|
81
|
+
function indexToolPairs(
|
|
82
|
+
messages: readonly ContextMessage[],
|
|
83
|
+
abandonedToolCallIds?: Set<string>,
|
|
84
|
+
): Map<string, { callIndex: number; resultIndex: number }> {
|
|
85
|
+
const calls = new Map<string, { index: number; aborted: boolean }>();
|
|
82
86
|
const results = new Map<string, number>();
|
|
83
87
|
for (let index = 0; index < messages.length; index += 1) {
|
|
84
88
|
const message = messages[index];
|
|
@@ -86,7 +90,7 @@ function indexToolPairs(messages: readonly ContextMessage[]): Map<string, { call
|
|
|
86
90
|
for (const block of message.content) {
|
|
87
91
|
if (block.type !== "toolCall") continue;
|
|
88
92
|
if (calls.has(block.id)) throw new Error(`Duplicate tool call in projected context: ${block.id}`);
|
|
89
|
-
calls.set(block.id, index);
|
|
93
|
+
calls.set(block.id, { index, aborted: message.stopReason === "aborted" });
|
|
90
94
|
}
|
|
91
95
|
} else if (message.role === "toolResult") {
|
|
92
96
|
if (results.has(message.toolCallId))
|
|
@@ -96,10 +100,16 @@ function indexToolPairs(messages: readonly ContextMessage[]): Map<string, { call
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
const pairs = new Map<string, { callIndex: number; resultIndex: number }>();
|
|
99
|
-
for (const [id,
|
|
103
|
+
for (const [id, call] of calls) {
|
|
100
104
|
const resultIndex = results.get(id);
|
|
101
|
-
if (resultIndex === undefined)
|
|
102
|
-
|
|
105
|
+
if (resultIndex === undefined) {
|
|
106
|
+
if (call.aborted && abandonedToolCallIds) {
|
|
107
|
+
abandonedToolCallIds.add(id);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Orphaned tool call in projected context: ${id}`);
|
|
111
|
+
}
|
|
112
|
+
pairs.set(id, { callIndex: call.index, resultIndex });
|
|
103
113
|
}
|
|
104
114
|
for (const id of results.keys()) {
|
|
105
115
|
if (!calls.has(id)) throw new Error(`Orphaned tool result in projected context: ${id}`);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# Image Generation
|
|
2
2
|
|
|
3
|
-
`image_gen` generates raster images and edits up to three local raster images with Grok Imagine. It uses `
|
|
3
|
+
`image_gen` generates raster images and edits up to three local raster images with OpenAI GPT Image or xAI Grok Imagine. It uses `gpt-image-2` for OpenAI and `grok-imagine-image-quality` for xAI.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
By default, the tool follows the parent model: GPT models prefer OpenAI and Grok models prefer xAI. If that provider has no configured authentication, it tries the other provider. The model can pass `provider: "openai"` or `provider: "xai"` to override automatic selection.
|
|
6
|
+
|
|
7
|
+
Run `/login openai-codex` for GPT Image or `/login xai` for Grok Imagine before invoking the tool. OpenAI image generation uses the existing Codex subscription login; xAI supports its configured login methods.
|
|
6
8
|
|
|
7
9
|
Run `/reload` after installing or changing the extension.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
Results are saved under `~/.local/share/tau-agent/images/` by default. Pass an explicit path with the expected image extension when the image should be saved in the current repository or another chosen location. For edits, supply one to three local PNG, JPEG, or WebP paths. Successful images up to 12 MiB are returned inline for inspection; larger results remain available at the saved path.
|
|
10
12
|
|
|
11
|
-
xAI controls
|
|
13
|
+
The OpenAI path calls a private Codex backend, which may change without notice. xAI controls Grok Imagine availability and subscription entitlements. A successful login does not guarantee image access.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
OPENAI_IMAGE_API_BASE_URL,
|
|
3
|
+
OPENAI_IMAGE_MODEL,
|
|
4
|
+
XAI_API_BASE_URL,
|
|
5
|
+
XAI_IMAGE_MODEL,
|
|
6
|
+
type ImageProvider,
|
|
7
|
+
} from "./constants.ts";
|
|
2
8
|
|
|
3
9
|
const MAX_ERROR_BODY_BYTES = 8192;
|
|
4
10
|
const MAX_ERROR_MESSAGE_LENGTH = 2000;
|
|
@@ -6,6 +12,11 @@ const REQUEST_TIMEOUT_MS = 60_000;
|
|
|
6
12
|
const MAX_ATTEMPTS = 3;
|
|
7
13
|
const RETRY_BASE_DELAY_MS = 500;
|
|
8
14
|
|
|
15
|
+
export interface CodexAuth {
|
|
16
|
+
token: string;
|
|
17
|
+
accountId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
export interface EditImage {
|
|
10
21
|
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
|
11
22
|
data: string;
|
|
@@ -28,6 +39,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
28
39
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
export function resolveCodexAuth(token: string): CodexAuth {
|
|
43
|
+
const invalidCredential = () =>
|
|
44
|
+
new Error("The OpenAI Codex credential does not contain a usable ChatGPT account ID. Run /login again.");
|
|
45
|
+
const segments = token.split(".");
|
|
46
|
+
if (segments.length !== 3 || !segments[1]) throw invalidCredential();
|
|
47
|
+
if (!/^[A-Za-z0-9_-]+$/.test(segments[1]) || segments[1].length % 4 === 1) throw invalidCredential();
|
|
48
|
+
|
|
49
|
+
let payload: unknown;
|
|
50
|
+
try {
|
|
51
|
+
const decoded = Buffer.from(segments[1], "base64url");
|
|
52
|
+
if (decoded.toString("base64url") !== segments[1]) throw invalidCredential();
|
|
53
|
+
payload = JSON.parse(decoded.toString("utf8"));
|
|
54
|
+
} catch {
|
|
55
|
+
throw invalidCredential();
|
|
56
|
+
}
|
|
57
|
+
if (!isRecord(payload)) throw invalidCredential();
|
|
58
|
+
const authClaim = payload["https://api.openai.com/auth"];
|
|
59
|
+
if (!isRecord(authClaim)) throw invalidCredential();
|
|
60
|
+
const accountId = authClaim.chatgpt_account_id;
|
|
61
|
+
if (typeof accountId !== "string" || !accountId.trim()) throw invalidCredential();
|
|
62
|
+
return { token, accountId: accountId.trim() };
|
|
63
|
+
}
|
|
64
|
+
|
|
31
65
|
async function boundedError(response: HttpResponse): Promise<string> {
|
|
32
66
|
if (!response.body) return "";
|
|
33
67
|
const reader = response.body.getReader();
|
|
@@ -85,25 +119,61 @@ export function detectImageMimeType(bytes: Buffer): GeneratedImage["mimeType"] |
|
|
|
85
119
|
return undefined;
|
|
86
120
|
}
|
|
87
121
|
|
|
88
|
-
function decodeImageResponse(value: unknown): GeneratedImage {
|
|
122
|
+
function decodeImageResponse(value: unknown, service: "OpenAI Codex" | "xAI", pngOnly: boolean): GeneratedImage {
|
|
89
123
|
if (!isRecord(value) || !Array.isArray(value.data) || !isRecord(value.data[0])) {
|
|
90
|
-
throw new Error(
|
|
124
|
+
throw new Error(`${service} returned an invalid image response`);
|
|
91
125
|
}
|
|
92
126
|
const encoded = value.data[0].b64_json;
|
|
93
|
-
if (typeof encoded !== "string" || !encoded.trim()) throw new Error(
|
|
127
|
+
if (typeof encoded !== "string" || !encoded.trim()) throw new Error(`${service} returned an invalid image response`);
|
|
94
128
|
const base64 = encoded.trim();
|
|
95
129
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64) || base64.length % 4 === 1) {
|
|
96
|
-
throw new Error(
|
|
130
|
+
throw new Error(`${service} returned invalid base64 image data`);
|
|
97
131
|
}
|
|
98
132
|
const bytes = Buffer.from(base64, "base64");
|
|
99
133
|
if (bytes.toString("base64").replace(/=+$/, "") !== base64.replace(/=+$/, "")) {
|
|
100
|
-
throw new Error(
|
|
134
|
+
throw new Error(`${service} returned invalid base64 image data`);
|
|
101
135
|
}
|
|
102
136
|
const mimeType = detectImageMimeType(bytes);
|
|
103
|
-
if (!mimeType
|
|
137
|
+
if (!mimeType || (pngOnly && mimeType !== "image/png")) {
|
|
138
|
+
throw new Error(`${service} returned unsupported image data`);
|
|
139
|
+
}
|
|
104
140
|
return { bytes, base64: bytes.toString("base64"), mimeType };
|
|
105
141
|
}
|
|
106
142
|
|
|
143
|
+
async function requestOpenAIImage(
|
|
144
|
+
operation: "generation" | "edit",
|
|
145
|
+
body: Record<string, unknown>,
|
|
146
|
+
auth: CodexAuth,
|
|
147
|
+
signal?: AbortSignal,
|
|
148
|
+
): Promise<GeneratedImage> {
|
|
149
|
+
const route = operation === "generation" ? "generations" : "edits";
|
|
150
|
+
const response = (await fetch(`${OPENAI_IMAGE_API_BASE_URL}/${route}`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: {
|
|
153
|
+
Accept: "application/json",
|
|
154
|
+
Authorization: `Bearer ${auth.token}`,
|
|
155
|
+
"chatgpt-account-id": auth.accountId,
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
originator: "pi",
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify(body),
|
|
160
|
+
signal,
|
|
161
|
+
})) as HttpResponse;
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const message = serverErrorMessage(await boundedError(response), auth.token);
|
|
164
|
+
throw new Error(
|
|
165
|
+
`OpenAI Codex image ${operation} failed with status ${response.status}${message ? `: ${message}` : ""}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
let value: unknown;
|
|
169
|
+
try {
|
|
170
|
+
value = await response.json();
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error("OpenAI Codex returned a non-JSON image response");
|
|
173
|
+
}
|
|
174
|
+
return decodeImageResponse(value, "OpenAI Codex", true);
|
|
175
|
+
}
|
|
176
|
+
|
|
107
177
|
function retryable(status: number): boolean {
|
|
108
178
|
return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
|
|
109
179
|
}
|
|
@@ -124,7 +194,7 @@ async function delay(milliseconds: number, signal?: AbortSignal): Promise<void>
|
|
|
124
194
|
});
|
|
125
195
|
}
|
|
126
196
|
|
|
127
|
-
async function
|
|
197
|
+
async function requestXaiImage(
|
|
128
198
|
operation: "generation" | "edit",
|
|
129
199
|
body: Record<string, unknown>,
|
|
130
200
|
token: string,
|
|
@@ -160,7 +230,7 @@ async function requestImage(
|
|
|
160
230
|
} catch {
|
|
161
231
|
throw new Error("xAI returned a non-JSON image response");
|
|
162
232
|
}
|
|
163
|
-
return decodeImageResponse(value);
|
|
233
|
+
return decodeImageResponse(value, "xAI", false);
|
|
164
234
|
}
|
|
165
235
|
const message = serverErrorMessage(await boundedError(response), token);
|
|
166
236
|
if (!retryable(response.status) || attempt === MAX_ATTEMPTS) {
|
|
@@ -173,8 +243,21 @@ async function requestImage(
|
|
|
173
243
|
throw new Error(`xAI image ${operation} failed`);
|
|
174
244
|
}
|
|
175
245
|
|
|
176
|
-
export function generateImage(
|
|
177
|
-
|
|
246
|
+
export function generateImage(
|
|
247
|
+
provider: ImageProvider,
|
|
248
|
+
prompt: string,
|
|
249
|
+
token: string,
|
|
250
|
+
signal?: AbortSignal,
|
|
251
|
+
): Promise<GeneratedImage> {
|
|
252
|
+
if (provider === "openai") {
|
|
253
|
+
return requestOpenAIImage(
|
|
254
|
+
"generation",
|
|
255
|
+
{ prompt, model: OPENAI_IMAGE_MODEL, background: "auto", quality: "auto", size: "auto" },
|
|
256
|
+
resolveCodexAuth(token),
|
|
257
|
+
signal,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return requestXaiImage(
|
|
178
261
|
"generation",
|
|
179
262
|
{ model: XAI_IMAGE_MODEL, prompt, n: 1, resolution: "1k", response_format: "b64_json" },
|
|
180
263
|
token,
|
|
@@ -183,13 +266,29 @@ export function generateImage(prompt: string, token: string, signal?: AbortSigna
|
|
|
183
266
|
}
|
|
184
267
|
|
|
185
268
|
export function editImage(
|
|
269
|
+
provider: ImageProvider,
|
|
186
270
|
prompt: string,
|
|
187
271
|
images: readonly EditImage[],
|
|
188
272
|
token: string,
|
|
189
273
|
signal?: AbortSignal,
|
|
190
274
|
): Promise<GeneratedImage> {
|
|
275
|
+
if (provider === "openai") {
|
|
276
|
+
return requestOpenAIImage(
|
|
277
|
+
"edit",
|
|
278
|
+
{
|
|
279
|
+
images: images.map((image) => ({ image_url: `data:${image.mimeType};base64,${image.data}` })),
|
|
280
|
+
prompt,
|
|
281
|
+
model: OPENAI_IMAGE_MODEL,
|
|
282
|
+
background: "auto",
|
|
283
|
+
quality: "auto",
|
|
284
|
+
size: "auto",
|
|
285
|
+
},
|
|
286
|
+
resolveCodexAuth(token),
|
|
287
|
+
signal,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
191
290
|
const references = images.map((image) => ({ url: `data:${image.mimeType};base64,${image.data}` }));
|
|
192
|
-
return
|
|
291
|
+
return requestXaiImage(
|
|
193
292
|
"edit",
|
|
194
293
|
{
|
|
195
294
|
model: XAI_IMAGE_MODEL,
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export const OPENAI_PROVIDER = "openai-codex";
|
|
2
|
+
export const OPENAI_IMAGE_MODEL = "gpt-image-2";
|
|
3
|
+
export const OPENAI_IMAGE_API_BASE_URL = "https://chatgpt.com/backend-api/codex/images";
|
|
1
4
|
export const XAI_PROVIDER = "xai";
|
|
2
5
|
export const XAI_IMAGE_MODEL = "grok-imagine-image-quality";
|
|
3
6
|
export const XAI_API_BASE_URL = "https://api.x.ai/v1";
|
|
7
|
+
|
|
8
|
+
export type ImageProvider = "openai" | "xai";
|
|
@@ -5,7 +5,13 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { type Static, Type } from "typebox";
|
|
7
7
|
import { detectImageMimeType, editImage, generateImage, type EditImage, type GeneratedImage } from "./client.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
OPENAI_IMAGE_MODEL,
|
|
10
|
+
OPENAI_PROVIDER,
|
|
11
|
+
XAI_IMAGE_MODEL,
|
|
12
|
+
XAI_PROVIDER,
|
|
13
|
+
type ImageProvider,
|
|
14
|
+
} from "./constants.ts";
|
|
9
15
|
|
|
10
16
|
const MAX_INPUT_BYTES = 50 * 1024 * 1024;
|
|
11
17
|
const MAX_INLINE_BYTES = 12 * 1024 * 1024;
|
|
@@ -13,6 +19,12 @@ const MAX_INLINE_BYTES = 12 * 1024 * 1024;
|
|
|
13
19
|
const imageGenSchema = Type.Object(
|
|
14
20
|
{
|
|
15
21
|
prompt: Type.String({ minLength: 1 }),
|
|
22
|
+
provider: Type.Optional(
|
|
23
|
+
Type.Union([Type.Literal("openai"), Type.Literal("xai")], {
|
|
24
|
+
description:
|
|
25
|
+
"Image provider override. Omit to follow the parent model, preferring OpenAI for GPT and xAI for Grok.",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
16
28
|
path: Type.Optional(
|
|
17
29
|
Type.String({ description: "Explicit image destination path; defaults to Tau's external image store" }),
|
|
18
30
|
),
|
|
@@ -25,7 +37,8 @@ type ImageGenParams = Static<typeof imageGenSchema>;
|
|
|
25
37
|
|
|
26
38
|
interface ImageGenDetails {
|
|
27
39
|
path: string;
|
|
28
|
-
|
|
40
|
+
provider: ImageProvider;
|
|
41
|
+
model: typeof OPENAI_IMAGE_MODEL | typeof XAI_IMAGE_MODEL;
|
|
29
42
|
operation: "generate" | "edit";
|
|
30
43
|
}
|
|
31
44
|
|
|
@@ -41,7 +54,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
41
54
|
name: "image_gen",
|
|
42
55
|
label: "Image Generation",
|
|
43
56
|
description:
|
|
44
|
-
"Generate a requested raster image or AI-edit existing images with
|
|
57
|
+
"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.",
|
|
45
58
|
parameters: imageGenSchema,
|
|
46
59
|
async execute(_toolCallId, params: ImageGenParams, signal, onUpdate, ctx) {
|
|
47
60
|
signal?.throwIfAborted();
|
|
@@ -61,10 +74,38 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
61
74
|
throw new Error("Image path must end in .jpg, .jpeg, .png, or .webp");
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
const parentUsesXai =
|
|
78
|
+
ctx.model?.provider.toLowerCase() === XAI_PROVIDER || ctx.model?.id.toLowerCase().includes("grok") === true;
|
|
79
|
+
const preferredProvider: ImageProvider = params.provider ?? (parentUsesXai ? "xai" : "openai");
|
|
80
|
+
const providers: readonly ImageProvider[] = params.provider
|
|
81
|
+
? [params.provider]
|
|
82
|
+
: preferredProvider === "xai"
|
|
83
|
+
? ["xai", "openai"]
|
|
84
|
+
: ["openai", "xai"];
|
|
85
|
+
let provider: ImageProvider | undefined;
|
|
86
|
+
let token: string | undefined;
|
|
87
|
+
for (const candidate of providers) {
|
|
88
|
+
const candidateToken = await ctx.modelRegistry.getApiKeyForProvider(
|
|
89
|
+
candidate === "openai" ? OPENAI_PROVIDER : XAI_PROVIDER,
|
|
90
|
+
);
|
|
91
|
+
if (candidateToken) {
|
|
92
|
+
provider = candidate;
|
|
93
|
+
token = candidateToken;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!provider || !token) {
|
|
98
|
+
if (params.provider === "openai") {
|
|
99
|
+
throw new Error("OpenAI Codex authentication is unavailable. Run /login openai-codex.");
|
|
100
|
+
}
|
|
101
|
+
if (params.provider === "xai") {
|
|
102
|
+
throw new Error("xAI authentication is unavailable. Run /login xai and choose a login method.");
|
|
103
|
+
}
|
|
104
|
+
throw new Error(
|
|
105
|
+
"Image generation authentication is unavailable. Run /login for OpenAI Codex or xAI.",
|
|
106
|
+
);
|
|
67
107
|
}
|
|
108
|
+
const model = provider === "openai" ? OPENAI_IMAGE_MODEL : XAI_IMAGE_MODEL;
|
|
68
109
|
const images: EditImage[] = [];
|
|
69
110
|
for (const path of params.referenced_image_paths ?? []) {
|
|
70
111
|
const rawPath = path.startsWith("@") ? path.slice(1) : path;
|
|
@@ -88,16 +129,16 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
88
129
|
type: "text",
|
|
89
130
|
text:
|
|
90
131
|
operation === "generate"
|
|
91
|
-
? `Generating image with ${
|
|
92
|
-
: `Editing image with ${
|
|
132
|
+
? `Generating image with ${model}...`
|
|
133
|
+
: `Editing image with ${model}...`,
|
|
93
134
|
},
|
|
94
135
|
],
|
|
95
136
|
details: undefined,
|
|
96
137
|
});
|
|
97
138
|
const generated =
|
|
98
139
|
operation === "generate"
|
|
99
|
-
? await generateImage(prompt, token, signal)
|
|
100
|
-
: await editImage(prompt, images, token, signal);
|
|
140
|
+
? await generateImage(provider, prompt, token, signal)
|
|
141
|
+
: await editImage(provider, prompt, images, token, signal);
|
|
101
142
|
signal?.throwIfAborted();
|
|
102
143
|
const generatedExtension = outputExtension(generated);
|
|
103
144
|
if (requestedAbsolutePath) {
|
|
@@ -106,7 +147,9 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
106
147
|
requestedExtension === generatedExtension ||
|
|
107
148
|
(generatedExtension === ".jpg" && requestedExtension === ".jpeg");
|
|
108
149
|
if (!matches)
|
|
109
|
-
throw new Error(
|
|
150
|
+
throw new Error(
|
|
151
|
+
`${provider === "openai" ? "OpenAI Codex" : "xAI"} returned ${generated.mimeType}; destination must end in ${generatedExtension}`,
|
|
152
|
+
);
|
|
110
153
|
}
|
|
111
154
|
const absolutePath =
|
|
112
155
|
requestedAbsolutePath ??
|
|
@@ -129,13 +172,13 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
129
172
|
});
|
|
130
173
|
|
|
131
174
|
const verb = operation === "generate" ? "Generated" : "Edited";
|
|
132
|
-
const details: ImageGenDetails = { path: absolutePath, model
|
|
175
|
+
const details: ImageGenDetails = { path: absolutePath, provider, model, operation };
|
|
133
176
|
if (generated.bytes.length > MAX_INLINE_BYTES) {
|
|
134
177
|
return {
|
|
135
178
|
content: [
|
|
136
179
|
{
|
|
137
180
|
type: "text",
|
|
138
|
-
text: `${verb} image saved to ${absolutePath}. The
|
|
181
|
+
text: `${verb} image saved to ${absolutePath}. The image exceeds the 12 MiB attachment limit, so it was not added to model context.`,
|
|
139
182
|
},
|
|
140
183
|
],
|
|
141
184
|
details,
|
|
@@ -18,6 +18,10 @@ Names sessions from their first request so saved sessions remain findable.
|
|
|
18
18
|
|
|
19
19
|
Adds `/branch` to create and switch Git branches from the TUI.
|
|
20
20
|
|
|
21
|
+
## cache-diagnostics
|
|
22
|
+
|
|
23
|
+
Records private prompt-cache fingerprints without storing prompt content. Run `/cache-debug` after suspicious cache misses to write a bounded investigation report under `~/.pi/agent/cache-diagnostics/reports/`.
|
|
24
|
+
|
|
21
25
|
## clear-screen
|
|
22
26
|
|
|
23
27
|
Adds `/clear-screen` to clear terminal output without changing the session.
|
|
@@ -48,7 +52,7 @@ Adds `/ideas` to log rough ideas or open the ideas browser.
|
|
|
48
52
|
|
|
49
53
|
## image-gen
|
|
50
54
|
|
|
51
|
-
Gives the agent
|
|
55
|
+
Gives the agent an OpenAI GPT Image and xAI Grok Imagine generation and editing tool. It follows the parent model by default and can override the provider per request. Run `/login openai-codex` or `/login xai` before use. Generated images are saved for inspection.
|
|
52
56
|
|
|
53
57
|
## manage-sessions
|
|
54
58
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shanepadgett/tau-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Tau is a custom agentic harness built with pi extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@shanepadgett/tau-tui": "0.
|
|
31
|
+
"@shanepadgett/tau-tui": "0.20.0",
|
|
32
32
|
"@toon-format/toon": "2.3.0",
|
|
33
33
|
"smol-toml": "1.7.0"
|
|
34
34
|
},
|