@xynogen/pix-core 0.6.2 → 0.6.3

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.
Files changed (2) hide show
  1. package/package.json +6 -6
  2. package/src/compaction.ts +86 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Pi extension bundle \u2014 installs and activates all core pix-* extensions",
5
5
  "type": "module",
6
6
  "main": "src/extension.ts",
@@ -46,11 +46,11 @@
46
46
  "@xynogen/pix-runtime": "^0.13.0",
47
47
  "@xynogen/pix-data": "^0.5.4",
48
48
  "@xynogen/pix-pretty": "^1.30.0",
49
- "@xynogen/pix-welcome": "^0.2.6",
49
+ "@xynogen/pix-welcome": "^0.2.7",
50
50
  "@xynogen/pix-footer": "^0.2.8",
51
51
  "@xynogen/pix-diagnostics": "^0.3.6",
52
52
  "@xynogen/pix-display": "^0.3.1",
53
- "@xynogen/pix-prompts": "^0.5.5",
53
+ "@xynogen/pix-prompts": "^0.5.6",
54
54
  "@xynogen/pix-skills": "^0.9.2",
55
55
  "@xynogen/pix-models": "^0.3.1",
56
56
  "@xynogen/pix-update": "^0.2.5",
@@ -65,10 +65,10 @@
65
65
  "@xynogen/pix-bash": "^0.2.10",
66
66
  "@xynogen/pix-powershell": "^0.1.1",
67
67
  "@xynogen/pix-todo": "^0.4.6",
68
- "@xynogen/pix-ask": "^0.3.1",
69
- "@xynogen/pix-optimizer": "^1.3.1",
68
+ "@xynogen/pix-ask": "^0.3.2",
69
+ "@xynogen/pix-optimizer": "^1.3.2",
70
70
  "@xynogen/pix-gate": "^0.4.1",
71
- "@xynogen/pix-subagent": "^0.10.6"
71
+ "@xynogen/pix-subagent": "^0.10.7"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "@earendil-works/pi-ai": "*",
package/src/compaction.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { complete } from "@earendil-works/pi-ai/compat";
29
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import type { ContextEditableContent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
30
  import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
31
31
  import { getErrorMessage } from "@xynogen/pix-pretty/utils";
32
32
  import { config } from "@xynogen/pix-runtime/config";
@@ -95,6 +95,43 @@ export function resumeDecisionAfterCompaction(args: {
95
95
  return "resume";
96
96
  }
97
97
 
98
+ // Pi cuts compaction only at message boundaries, so one huge message (a pasted
99
+ // HAR/log, a giant tool result) can't be summarized away: "Nothing to compact".
100
+ // Over threshold we instead trim oversized text to head+tail via a visible,
101
+ // append-only context_edit — the original stays in the session file.
102
+ // ponytail: fixed char limits (~20k tok kept per message); make them config if
103
+ // someone needs to tune per model.
104
+ const MAX_MESSAGE_CHARS = 80_000;
105
+ const KEEP_CHARS = 20_000;
106
+
107
+ type TextOrImage = { type: string; text?: string };
108
+
109
+ /** Head+tail trim of oversized text. Undefined when the text already fits. */
110
+ export function truncateLargeText(text: string): string | undefined {
111
+ if (text.length <= MAX_MESSAGE_CHARS) return undefined;
112
+ const cut = text.length - 2 * KEEP_CHARS;
113
+ return `${text.slice(0, KEEP_CHARS)}\n\n[… ${cut.toLocaleString()} chars trimmed by pix compaction (original kept in session file) …]\n\n${text.slice(-KEEP_CHARS)}`;
114
+ }
115
+
116
+ /** Trimmed copy of user/tool-result content, or undefined when nothing is oversized. */
117
+ export function shrinkContent(
118
+ content: string | TextOrImage[],
119
+ ): { content: string | TextOrImage[]; saved: number } | undefined {
120
+ if (typeof content === "string") {
121
+ const t = truncateLargeText(content);
122
+ return t === undefined ? undefined : { content: t, saved: content.length - t.length };
123
+ }
124
+ let saved = 0;
125
+ const next = content.map((part) => {
126
+ if (part.type !== "text" || typeof part.text !== "string") return part;
127
+ const t = truncateLargeText(part.text);
128
+ if (t === undefined) return part;
129
+ saved += part.text.length - t.length;
130
+ return { ...part, text: t };
131
+ });
132
+ return saved > 0 ? { content: next, saved } : undefined;
133
+ }
134
+
98
135
  // Sent as a user message after a self-triggered compaction so the agent picks
99
136
  // its task back up on its own. Kept short — the freshly written summary already
100
137
  // carries the goal, progress, and next steps. Edit to taste.
@@ -181,6 +218,44 @@ export default function registerCompaction(pi: ExtensionAPI): void {
181
218
  // agent_settled re-fires, and we compact again every turn. Reset by /compact or
182
219
  // a new session; a session-scoped bool is the smallest fix.
183
220
  let triggerDisabled = false;
221
+
222
+ // Runs before agent_settled; committed edits shrink the context the trigger
223
+ // below measures, so a single oversized message no longer blocks compaction.
224
+ pi.on("agent_before_settle", (event, ctx) => {
225
+ const { triggerPercent, minimumTokens } = config(compactionSection);
226
+ if (triggerPercent <= 0) return;
227
+ const usage = ctx.getContextUsage?.();
228
+ if (!usage || usage.tokens === null) return;
229
+ if (
230
+ usage.tokens < compactionThresholdTokens(usage.contextWindow, triggerPercent, minimumTokens)
231
+ )
232
+ return;
233
+
234
+ const edits: typeof event.entries = [];
235
+ let saved = 0;
236
+ for (const { sourceEntry, messages } of event.context.contextEntries) {
237
+ const msg = messages[0];
238
+ const editable =
239
+ sourceEntry.type === "custom_message" ||
240
+ (sourceEntry.type === "message" && (msg?.role === "user" || msg?.role === "toolResult"));
241
+ if (!editable || !msg || !("content" in msg)) continue;
242
+ const shrunk = shrinkContent(msg.content as string | TextOrImage[]);
243
+ if (!shrunk) continue;
244
+ saved += shrunk.saved;
245
+ edits.push({
246
+ type: "context_edit",
247
+ targetId: sourceEntry.id,
248
+ replacement: { content: shrunk.content as ContextEditableContent },
249
+ });
250
+ }
251
+ if (edits.length === 0) return;
252
+ ctx.ui.notify(
253
+ `Compaction: trimmed ${edits.length} oversized message(s), ~${Math.round(saved / 4).toLocaleString()} tok (head+tail kept; originals stay in session file)`,
254
+ "info",
255
+ );
256
+ return { entries: [...event.entries, ...edits] };
257
+ });
258
+
184
259
  pi.on("agent_settled", (_event, ctx) => {
185
260
  if (compacting || triggerDisabled) return;
186
261
  const { triggerPercent, minimumTokens } = config(compactionSection);
@@ -259,7 +334,16 @@ export default function registerCompaction(pi: ExtensionAPI): void {
259
334
  },
260
335
  onError: (err) => {
261
336
  compacting = false;
262
- ctx.ui.notify(`Compaction trigger failed: ${err.message}`, "error");
337
+ // Pi already reported the failure (compaction_end). "Nothing to compact" /
338
+ // "Already compacted" will fail identically next turn, so latch off instead
339
+ // of re-firing every agent_settled. Oversized messages are trimmed in
340
+ // agent_before_settle, so this is now only a backstop.
341
+ if (!/nothing to compact|already compacted/i.test(err.message)) return;
342
+ triggerDisabled = true;
343
+ ctx.ui.notify(
344
+ `Compaction: ${tokens.toLocaleString()} tok but nothing older to summarize. Auto-trigger disabled for this session; start a new session if context keeps growing.`,
345
+ "warning",
346
+ );
263
347
  },
264
348
  });
265
349
  });