@xynogen/pix-core 0.7.7 → 0.7.9
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/package.json +26 -26
- package/src/compaction.ts +159 -4
- package/src/defer-tools.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
4
4
|
"description": "Pi extension bundle \u2014 installs and activates all core pix-* extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/extension.ts",
|
|
@@ -43,32 +43,32 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@xynogen/pix-runtime": "^0.
|
|
47
|
-
"@xynogen/pix-data": "^0.5.
|
|
48
|
-
"@xynogen/pix-pretty": "^1.
|
|
49
|
-
"@xynogen/pix-welcome": "^0.2.
|
|
50
|
-
"@xynogen/pix-footer": "^0.2.
|
|
51
|
-
"@xynogen/pix-diagnostics": "^0.4.
|
|
46
|
+
"@xynogen/pix-runtime": "^0.15.1",
|
|
47
|
+
"@xynogen/pix-data": "^0.5.6",
|
|
48
|
+
"@xynogen/pix-pretty": "^1.33.0",
|
|
49
|
+
"@xynogen/pix-welcome": "^0.2.9",
|
|
50
|
+
"@xynogen/pix-footer": "^0.2.12",
|
|
51
|
+
"@xynogen/pix-diagnostics": "^0.4.3",
|
|
52
52
|
"@xynogen/pix-display": "^0.3.2",
|
|
53
|
-
"@xynogen/pix-prompts": "^0.5.
|
|
54
|
-
"@xynogen/pix-skills": "^0.10.
|
|
55
|
-
"@xynogen/pix-models": "^0.4.
|
|
56
|
-
"@xynogen/pix-update": "^0.2.
|
|
57
|
-
"@xynogen/pix-commands": "^0.6.
|
|
58
|
-
"@xynogen/pix-nudge": "^0.3.
|
|
59
|
-
"@xynogen/pix-read": "^0.4.
|
|
60
|
-
"@xynogen/pix-write": "^0.2.
|
|
61
|
-
"@xynogen/pix-edit": "^0.2.
|
|
62
|
-
"@xynogen/pix-find": "^0.3.
|
|
63
|
-
"@xynogen/pix-grep": "^0.3.
|
|
64
|
-
"@xynogen/pix-ls": "^0.3.
|
|
65
|
-
"@xynogen/pix-bash": "^0.2.
|
|
66
|
-
"@xynogen/pix-powershell": "^0.1.
|
|
67
|
-
"@xynogen/pix-todo": "^0.4.
|
|
68
|
-
"@xynogen/pix-ask": "^0.3.
|
|
69
|
-
"@xynogen/pix-optimizer": "^1.3.
|
|
70
|
-
"@xynogen/pix-gate": "^0.4.
|
|
71
|
-
"@xynogen/pix-subagent": "^0.10.
|
|
53
|
+
"@xynogen/pix-prompts": "^0.5.9",
|
|
54
|
+
"@xynogen/pix-skills": "^0.10.4",
|
|
55
|
+
"@xynogen/pix-models": "^0.4.3",
|
|
56
|
+
"@xynogen/pix-update": "^0.2.7",
|
|
57
|
+
"@xynogen/pix-commands": "^0.6.11",
|
|
58
|
+
"@xynogen/pix-nudge": "^0.3.6",
|
|
59
|
+
"@xynogen/pix-read": "^0.4.6",
|
|
60
|
+
"@xynogen/pix-write": "^0.2.11",
|
|
61
|
+
"@xynogen/pix-edit": "^0.2.12",
|
|
62
|
+
"@xynogen/pix-find": "^0.3.7",
|
|
63
|
+
"@xynogen/pix-grep": "^0.3.7",
|
|
64
|
+
"@xynogen/pix-ls": "^0.3.7",
|
|
65
|
+
"@xynogen/pix-bash": "^0.2.13",
|
|
66
|
+
"@xynogen/pix-powershell": "^0.1.4",
|
|
67
|
+
"@xynogen/pix-todo": "^0.4.9",
|
|
68
|
+
"@xynogen/pix-ask": "^0.3.4",
|
|
69
|
+
"@xynogen/pix-optimizer": "^1.3.6",
|
|
70
|
+
"@xynogen/pix-gate": "^0.4.3",
|
|
71
|
+
"@xynogen/pix-subagent": "^0.10.10"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"@earendil-works/pi-ai": "*",
|
package/src/compaction.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* notify line with reason, model, and token counts.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { resolve } from "node:path";
|
|
28
29
|
import { contentText, retryAssistantCall, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
29
30
|
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
30
31
|
import type { ContextEditableContent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -204,6 +205,123 @@ export function shrinkContent(
|
|
|
204
205
|
return saved > 0 ? { content: next, saved } : undefined;
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
// Observation masking (JetBrains, "The Complexity Trap", 2025): masking stale tool
|
|
209
|
+
// output matches LLM summarization at about half the cost. A read result whose
|
|
210
|
+
// file was later edited or written is provably stale: its anchors no longer
|
|
211
|
+
// match. Masking these first can bring context under the threshold and skip the
|
|
212
|
+
// summary call. Append-only context_edit; the original stays in the session file.
|
|
213
|
+
const MASK_PREFIX = "[pix compaction: stale read masked";
|
|
214
|
+
|
|
215
|
+
type MaskEntry = {
|
|
216
|
+
sourceEntry: { id: string };
|
|
217
|
+
messages: Array<{
|
|
218
|
+
role?: string;
|
|
219
|
+
content?: unknown;
|
|
220
|
+
toolCallId?: string;
|
|
221
|
+
toolName?: string;
|
|
222
|
+
isError?: boolean;
|
|
223
|
+
}>;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** Read results superseded by a later successful edit/write of the same path. */
|
|
227
|
+
export function staleReads(
|
|
228
|
+
entries: MaskEntry[],
|
|
229
|
+
resolvePath: (p: string) => string,
|
|
230
|
+
): Array<{ id: string; path: string }> {
|
|
231
|
+
const callPath = new Map<string, string>();
|
|
232
|
+
const reads = new Map<string, Array<{ id: string; path: string }>>();
|
|
233
|
+
const stale: Array<{ id: string; path: string }> = [];
|
|
234
|
+
for (const { sourceEntry, messages } of entries) {
|
|
235
|
+
const msg = messages[0];
|
|
236
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
|
237
|
+
for (const part of msg.content as Array<{
|
|
238
|
+
type?: string;
|
|
239
|
+
id?: string;
|
|
240
|
+
arguments?: { path?: unknown };
|
|
241
|
+
}>)
|
|
242
|
+
if (part.type === "toolCall" && part.id && typeof part.arguments?.path === "string")
|
|
243
|
+
callPath.set(part.id, resolvePath(part.arguments.path));
|
|
244
|
+
}
|
|
245
|
+
if (msg?.role !== "toolResult" || msg.isError || !msg.toolCallId) continue;
|
|
246
|
+
const path = callPath.get(msg.toolCallId);
|
|
247
|
+
if (!path) continue;
|
|
248
|
+
if (msg.toolName === "read") {
|
|
249
|
+
const text = Array.isArray(msg.content)
|
|
250
|
+
? (msg.content[0] as { text?: unknown })?.text
|
|
251
|
+
: undefined;
|
|
252
|
+
if (typeof text === "string" && text.startsWith(MASK_PREFIX)) continue;
|
|
253
|
+
reads.set(path, [...(reads.get(path) ?? []), { id: sourceEntry.id, path }]);
|
|
254
|
+
} else if (msg.toolName === "edit" || msg.toolName === "write") {
|
|
255
|
+
stale.push(...(reads.get(path) ?? []));
|
|
256
|
+
reads.delete(path);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return stale;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function maskedRead(path: string): string {
|
|
263
|
+
return `${MASK_PREFIX}: ${path} was edited later; read it again if needed. Original kept in session file.]`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Windowed masking (JetBrains M=10: about half the cost, solve rate on par with
|
|
267
|
+
// summarization; Anthropic clear_tool_uses keeps the last N). Over threshold, tool
|
|
268
|
+
// output older than the newest `keep` results is replaced by a restorable stub.
|
|
269
|
+
// Errors stay: they carry what went wrong. Only pass when it frees enough tokens
|
|
270
|
+
// (`minTokens`), because every pass breaks the prompt cache from that point on.
|
|
271
|
+
const OUTPUT_MASK_PREFIX = "[pix compaction: output masked";
|
|
272
|
+
const MASKABLE_TOOLS = new Set(["bash", "grep", "find", "ls", "read"]);
|
|
273
|
+
|
|
274
|
+
/** Older tool output to mask: id, stub text, and chars freed. */
|
|
275
|
+
export function windowedMasks(
|
|
276
|
+
entries: MaskEntry[],
|
|
277
|
+
keep: number,
|
|
278
|
+
skip: ReadonlySet<string> = new Set(),
|
|
279
|
+
): Array<{ id: string; text: string; saved: number }> {
|
|
280
|
+
const callArgs = new Map<string, Record<string, unknown>>();
|
|
281
|
+
const results: Array<{ id: string; tool: string; callId: string; text: string }> = [];
|
|
282
|
+
for (const { sourceEntry, messages } of entries) {
|
|
283
|
+
const msg = messages[0];
|
|
284
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
|
285
|
+
for (const part of msg.content as Array<{ type?: string; id?: string; arguments?: unknown }>)
|
|
286
|
+
if (part.type === "toolCall" && part.id)
|
|
287
|
+
callArgs.set(part.id, (part.arguments ?? {}) as Record<string, unknown>);
|
|
288
|
+
}
|
|
289
|
+
if (msg?.role !== "toolResult" || !msg.toolCallId) continue;
|
|
290
|
+
const text = Array.isArray(msg.content)
|
|
291
|
+
? (msg.content as TextOrImage[])
|
|
292
|
+
.map((p) => (p.type === "text" && typeof p.text === "string" ? p.text : ""))
|
|
293
|
+
.join("\n")
|
|
294
|
+
: typeof msg.content === "string"
|
|
295
|
+
? msg.content
|
|
296
|
+
: "";
|
|
297
|
+
// Errors count toward the window but are never masked.
|
|
298
|
+
results.push({
|
|
299
|
+
id: sourceEntry.id,
|
|
300
|
+
tool: msg.isError ? "" : (msg.toolName ?? ""),
|
|
301
|
+
callId: msg.toolCallId,
|
|
302
|
+
text,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const older = results.slice(0, Math.max(0, results.length - Math.max(0, keep)));
|
|
306
|
+
const out: Array<{ id: string; text: string; saved: number }> = [];
|
|
307
|
+
for (const r of older) {
|
|
308
|
+
if (!MASKABLE_TOOLS.has(r.tool) || skip.has(r.id)) continue;
|
|
309
|
+
if (r.text.startsWith(OUTPUT_MASK_PREFIX) || r.text.startsWith(MASK_PREFIX)) continue;
|
|
310
|
+
const args = callArgs.get(r.callId) ?? {};
|
|
311
|
+
const target = [args.command, args.pattern, args.path, args.paths]
|
|
312
|
+
.flat()
|
|
313
|
+
.find((v) => typeof v === "string" && v);
|
|
314
|
+
const label = String(target ?? "")
|
|
315
|
+
.replace(/\s+/g, " ")
|
|
316
|
+
.trim()
|
|
317
|
+
.slice(0, 120);
|
|
318
|
+
const text = `${OUTPUT_MASK_PREFIX} — ${r.tool}${label ? ` ${label}` : ""}; run again if needed. Original kept in session file.]`;
|
|
319
|
+
const saved = r.text.length - text.length;
|
|
320
|
+
if (saved > 0) out.push({ id: r.id, text, saved });
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
207
325
|
// Sent as a user message after a self-triggered compaction so the agent picks
|
|
208
326
|
// its task back up on its own. Kept short — the freshly written summary already
|
|
209
327
|
// carries the goal, progress, and next steps. Edit to taste.
|
|
@@ -314,7 +432,7 @@ export default function registerCompaction(pi: ExtensionAPI): void {
|
|
|
314
432
|
// Runs before agent_settled; committed edits shrink the context the trigger
|
|
315
433
|
// below measures, so a single oversized message no longer blocks compaction.
|
|
316
434
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
317
|
-
const { triggerPercent, minimumTokens } = config(compactionSection);
|
|
435
|
+
const { triggerPercent, minimumTokens, maskKeep, maskMinTokens } = config(compactionSection);
|
|
318
436
|
if (triggerPercent <= 0) return;
|
|
319
437
|
const usage = ctx.getContextUsage?.();
|
|
320
438
|
if (!usage || usage.tokens === null) return;
|
|
@@ -325,7 +443,38 @@ export default function registerCompaction(pi: ExtensionAPI): void {
|
|
|
325
443
|
|
|
326
444
|
const edits: typeof event.entries = [];
|
|
327
445
|
let saved = 0;
|
|
446
|
+
const masked = new Set<string>();
|
|
447
|
+
for (const { id, path } of staleReads(
|
|
448
|
+
event.context.contextEntries as unknown as MaskEntry[],
|
|
449
|
+
(p) => resolve(ctx.cwd, p),
|
|
450
|
+
)) {
|
|
451
|
+
masked.add(id);
|
|
452
|
+
edits.push({
|
|
453
|
+
type: "context_edit",
|
|
454
|
+
targetId: id,
|
|
455
|
+
replacement: { content: [{ type: "text", text: maskedRead(path) }] },
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
// ponytail: chars/4 token estimate, like the trim notice below. Use a tokenizer if the
|
|
459
|
+
// maskMinTokens gate needs to be exact for a provider.
|
|
460
|
+
const windowed = windowedMasks(
|
|
461
|
+
event.context.contextEntries as unknown as MaskEntry[],
|
|
462
|
+
maskKeep,
|
|
463
|
+
masked,
|
|
464
|
+
);
|
|
465
|
+
const windowSaved = windowed.reduce((sum, w) => sum + w.saved, 0);
|
|
466
|
+
const windowCount = windowSaved / 4 >= maskMinTokens ? windowed.length : 0;
|
|
467
|
+
for (const w of windowed.slice(0, windowCount)) {
|
|
468
|
+
masked.add(w.id);
|
|
469
|
+
edits.push({
|
|
470
|
+
type: "context_edit",
|
|
471
|
+
targetId: w.id,
|
|
472
|
+
replacement: { content: [{ type: "text", text: w.text }] },
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const stale = masked.size - windowCount;
|
|
328
476
|
for (const { sourceEntry, messages } of event.context.contextEntries) {
|
|
477
|
+
if (masked.has(sourceEntry.id)) continue;
|
|
329
478
|
const msg = messages[0];
|
|
330
479
|
const editable =
|
|
331
480
|
sourceEntry.type === "custom_message" ||
|
|
@@ -341,10 +490,16 @@ export default function registerCompaction(pi: ExtensionAPI): void {
|
|
|
341
490
|
});
|
|
342
491
|
}
|
|
343
492
|
if (edits.length === 0) return;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
493
|
+
const trimmed = edits.length - masked.size;
|
|
494
|
+
const parts = [`masked ${stale} stale read(s)`];
|
|
495
|
+
if (windowCount)
|
|
496
|
+
parts.push(
|
|
497
|
+
`masked ${windowCount} older tool output(s) beyond the last ${maskKeep} (~${Math.round(windowSaved / 4).toLocaleString()} tok)`,
|
|
498
|
+
);
|
|
499
|
+
parts.push(
|
|
500
|
+
`trimmed ${trimmed} oversized message(s)${trimmed ? ` (~${Math.round(saved / 4).toLocaleString()} tok)` : ""}`,
|
|
347
501
|
);
|
|
502
|
+
ctx.ui.notify(`Compaction: ${parts.join(", ")}; originals stay in session file`, "info");
|
|
348
503
|
return { entries: [...event.entries, ...edits] };
|
|
349
504
|
});
|
|
350
505
|
|
package/src/defer-tools.ts
CHANGED
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// grep/find stay direct: pix-nudge steers bash cat/grep/find toward them, and a
|
|
14
|
+
// tool_search round-trip for the most common lookups costs more than their schemas.
|
|
15
|
+
export const CORE_TOOLS: ReadonlySet<string> = new Set([
|
|
16
|
+
"read",
|
|
17
|
+
"bash",
|
|
18
|
+
"edit",
|
|
19
|
+
"write",
|
|
20
|
+
"grep",
|
|
21
|
+
"find",
|
|
22
|
+
]);
|
|
14
23
|
const TOOL_SEARCH = "tool_search";
|
|
15
24
|
|
|
16
25
|
export default function deferNonCoreTools(pi: ExtensionAPI): void {
|