@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.7.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.14.0",
47
- "@xynogen/pix-data": "^0.5.5",
48
- "@xynogen/pix-pretty": "^1.32.0",
49
- "@xynogen/pix-welcome": "^0.2.8",
50
- "@xynogen/pix-footer": "^0.2.11",
51
- "@xynogen/pix-diagnostics": "^0.4.2",
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.8",
54
- "@xynogen/pix-skills": "^0.10.3",
55
- "@xynogen/pix-models": "^0.4.2",
56
- "@xynogen/pix-update": "^0.2.6",
57
- "@xynogen/pix-commands": "^0.6.10",
58
- "@xynogen/pix-nudge": "^0.3.4",
59
- "@xynogen/pix-read": "^0.4.5",
60
- "@xynogen/pix-write": "^0.2.10",
61
- "@xynogen/pix-edit": "^0.2.11",
62
- "@xynogen/pix-find": "^0.3.6",
63
- "@xynogen/pix-grep": "^0.3.6",
64
- "@xynogen/pix-ls": "^0.3.6",
65
- "@xynogen/pix-bash": "^0.2.12",
66
- "@xynogen/pix-powershell": "^0.1.3",
67
- "@xynogen/pix-todo": "^0.4.8",
68
- "@xynogen/pix-ask": "^0.3.3",
69
- "@xynogen/pix-optimizer": "^1.3.5",
70
- "@xynogen/pix-gate": "^0.4.2",
71
- "@xynogen/pix-subagent": "^0.10.9"
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
- ctx.ui.notify(
345
- `Compaction: trimmed ${edits.length} oversized message(s), ~${Math.round(saved / 4).toLocaleString()} tok (head+tail kept; originals stay in session file)`,
346
- "info",
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
 
@@ -10,7 +10,16 @@
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
 
13
- export const CORE_TOOLS: ReadonlySet<string> = new Set(["read", "bash", "edit", "write"]);
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 {