@xynogen/pix-core 0.7.5 → 0.7.7

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 +27 -27
  2. package/src/compaction.ts +136 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
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.13.1",
47
- "@xynogen/pix-data": "^0.5.4",
48
- "@xynogen/pix-pretty": "^1.30.1",
49
- "@xynogen/pix-welcome": "^0.2.7",
50
- "@xynogen/pix-footer": "^0.2.9",
51
- "@xynogen/pix-diagnostics": "^0.4.0",
52
- "@xynogen/pix-display": "^0.3.1",
53
- "@xynogen/pix-prompts": "^0.5.7",
54
- "@xynogen/pix-skills": "^0.10.2",
55
- "@xynogen/pix-models": "^0.4.0",
56
- "@xynogen/pix-update": "^0.2.5",
57
- "@xynogen/pix-commands": "^0.6.9",
58
- "@xynogen/pix-nudge": "^0.3.3",
59
- "@xynogen/pix-read": "^0.4.3",
60
- "@xynogen/pix-write": "^0.2.8",
61
- "@xynogen/pix-edit": "^0.2.9",
62
- "@xynogen/pix-find": "^0.3.4",
63
- "@xynogen/pix-grep": "^0.3.4",
64
- "@xynogen/pix-ls": "^0.3.4",
65
- "@xynogen/pix-bash": "^0.2.10",
66
- "@xynogen/pix-powershell": "^0.1.1",
67
- "@xynogen/pix-todo": "^0.4.6",
68
- "@xynogen/pix-ask": "^0.3.2",
69
- "@xynogen/pix-optimizer": "^1.3.4",
70
- "@xynogen/pix-gate": "^0.4.1",
71
- "@xynogen/pix-subagent": "^0.10.7"
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",
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"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "@earendil-works/pi-ai": "*",
package/src/compaction.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 1. Summary prompt — ALWAYS ours. `session_before_compact` fires for every
8
8
  * compaction (manual, threshold, overflow); we generate the summary with
9
- * the CURRENT conversation model (no silent routing) using SUMMARY_PROMPT
9
+ * the CURRENT conversation model (no silent routing) using summaryPrompt()
10
10
  * below. Edit the prompt/format to experiment.
11
11
  *
12
12
  * 2. Trigger — ours when `compaction.triggerPercent > 0`. After each settled
@@ -25,7 +25,8 @@
25
25
  * notify line with reason, model, and token counts.
26
26
  */
27
27
 
28
- import { complete } from "@earendil-works/pi-ai/compat";
28
+ import { contentText, retryAssistantCall, type ThinkingLevel } from "@earendil-works/pi-ai";
29
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
29
30
  import type { ContextEditableContent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
31
  import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
31
32
  import { getErrorMessage } from "@xynogen/pix-pretty/utils";
@@ -37,11 +38,41 @@ import { compactionSection } from "@xynogen/pix-runtime/sections";
37
38
  // pi keeps recent turns verbatim (via firstKeptEntryId). This matches current
38
39
  // context-engineering guidance — recency is highest-signal, and "context rot"
39
40
  // makes full-history replacement risky. Tune freely.
40
- const SUMMARY_PROMPT = (previousSummary: string | undefined, conversation: string): string => {
41
- const prior = previousSummary
42
- ? `\n\nPrevious summary (fold in, do not repeat verbatim):\n${previousSummary}`
41
+ //
42
+ // Adopted from pi 1.4.0's own summarizer: a system prompt that forbids
43
+ // continuing the chat, data first and instructions last, /compact focus text,
44
+ // no fixed output cap (the model's own limit), the current thinking level,
45
+ // transient-error retry, and refusing truncated (`length`) summaries.
46
+ // Kept ours: one call for a split turn (pi makes two) and file lists that
47
+ // carry across compactions, which pi drops for extension-made summaries.
48
+ const SYSTEM_PROMPT =
49
+ "You are a context summarization assistant. Read the conversation between a user and an AI assistant and produce the structured summary requested. Do NOT continue the conversation or answer its questions. Output ONLY the summary.";
50
+
51
+ export function summaryPrompt(p: {
52
+ conversation: string;
53
+ turnPrefix?: string;
54
+ previousSummary?: string;
55
+ customInstructions?: string;
56
+ }): string {
57
+ const blocks: string[] = [];
58
+ if (p.conversation) blocks.push(`<conversation>\n${p.conversation}\n</conversation>`);
59
+ if (p.turnPrefix) blocks.push(`<turn-prefix>\n${p.turnPrefix}\n</turn-prefix>`);
60
+ if (p.previousSummary)
61
+ blocks.push(`<previous-summary>\n${p.previousSummary}\n</previous-summary>`);
62
+
63
+ const prior = p.previousSummary
64
+ ? " Fold <previous-summary> in: keep what still matters, update Progress and Next Steps, do not repeat it verbatim."
65
+ : "";
66
+ const split = p.turnPrefix
67
+ ? "\n\n<turn-prefix> is the start of the current turn; its later messages are kept verbatim. Add a `## Turn Context` section with that turn's request and the progress shown in it."
43
68
  : "";
44
- return `You are summarizing the OLDER part of a coding session. Recent turns are kept verbatim after your summary, so capture only what those recent turns would otherwise lose.${prior}
69
+ const focus = p.customInstructions?.trim()
70
+ ? `\n\nAdditional focus: ${p.customInstructions.trim()}`
71
+ : "";
72
+
73
+ return `${blocks.join("\n\n")}
74
+
75
+ Summarize the OLDER part of this coding session above. Recent turns are kept verbatim after your summary, so capture only what they would otherwise lose.${prior}
45
76
 
46
77
  Produce compact, high-signal markdown with these sections (omit a section if empty):
47
78
 
@@ -52,14 +83,55 @@ Produce compact, high-signal markdown with these sections (omit a section if emp
52
83
  ## Next Steps
53
84
  ## Critical Context (facts, paths, values needed to continue)
54
85
 
55
- Be terse. Prefer names, paths, numbers over prose. No filler.
86
+ Be terse. Prefer names, paths, numbers over prose. No filler. Preserve exact file paths, function names, and error messages.${split}${focus}`;
87
+ }
88
+
89
+ // ── File lists ───────────────────────────────────────────────────────────────────
90
+ // Same <read-files>/<modified-files> tags pi appends. Pi rebuilds them from
91
+ // entry details only for its own compactions, so we carry them through the
92
+ // previous summary text instead — that works for both pi's and ours.
93
+ const FILE_TAGS = /\n*<(read-files|modified-files)>\n([\s\S]*?)\n<\/\1>/g;
56
94
 
57
- <conversation>
58
- ${conversation}
59
- </conversation>`;
60
- };
95
+ export function mergeFileLists(
96
+ fileOps: { read: Set<string>; written: Set<string>; edited: Set<string> },
97
+ previousSummary: string | undefined,
98
+ ): { readFiles: string[]; modifiedFiles: string[]; previousSummary: string | undefined } {
99
+ const read = new Set(fileOps.read);
100
+ const modified = new Set([...fileOps.edited, ...fileOps.written]);
101
+ for (const [, tag, body] of previousSummary?.matchAll(FILE_TAGS) ?? []) {
102
+ const into = tag === "read-files" ? read : modified;
103
+ for (const line of body?.split("\n") ?? []) if (line.trim()) into.add(line.trim());
104
+ }
105
+ return {
106
+ readFiles: [...read].filter((f) => !modified.has(f)).sort(),
107
+ modifiedFiles: [...modified].sort(),
108
+ previousSummary: previousSummary?.replace(FILE_TAGS, "").trim() || undefined,
109
+ };
110
+ }
61
111
 
62
- const MAX_SUMMARY_TOKENS = 8192;
112
+ export function formatFileLists(readFiles: string[], modifiedFiles: string[]): string {
113
+ const sections: string[] = [];
114
+ if (readFiles.length) sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
115
+ if (modifiedFiles.length)
116
+ sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
117
+ return sections.length ? `\n\n${sections.join("\n\n")}` : "";
118
+ }
119
+
120
+ /** Why a summary response must not become a checkpoint, or undefined when it is usable. */
121
+ export function summaryFailure(response: {
122
+ stopReason: string;
123
+ errorMessage?: string;
124
+ content: { type: string }[];
125
+ }): string | undefined {
126
+ if (response.stopReason === "error") return response.errorMessage || "unknown error";
127
+ if (response.stopReason === "length") return "hit the output limit; summary incomplete";
128
+ if (response.content.some((c) => c.type === "toolCall")) return "model tried to call a tool";
129
+ return undefined;
130
+ }
131
+
132
+ // ponytail: mirrors pi's default `retry` settings; extensions cannot read
133
+ // settings.retry. Move to the `compaction` config section if someone tunes it.
134
+ const SUMMARY_RETRY = { enabled: true, maxRetries: 3, baseDelayMs: 2000 };
63
135
 
64
136
  export function compactionThresholdTokens(
65
137
  contextWindow: number,
@@ -151,56 +223,76 @@ export default function registerCompaction(pi: ExtensionAPI): void {
151
223
  return;
152
224
  }
153
225
 
154
- const messages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
155
- const conversation = serializeConversation(convertToLlm(messages));
226
+ const toText = (m: typeof preparation.messagesToSummarize) =>
227
+ m.length ? serializeConversation(convertToLlm(m)) : "";
228
+ const files = mergeFileLists(preparation.fileOps, preparation.previousSummary);
229
+ const prompt = summaryPrompt({
230
+ conversation: toText(preparation.messagesToSummarize),
231
+ turnPrefix: toText(preparation.turnPrefixMessages),
232
+ previousSummary: files.previousSummary,
233
+ customInstructions: event.customInstructions,
234
+ });
235
+ const level = pi.getThinkingLevel?.();
236
+ const reasoning =
237
+ model.reasoning && level && level !== "off" ? (level as ThinkingLevel) : undefined;
238
+ const count = preparation.messagesToSummarize.length + preparation.turnPrefixMessages.length;
156
239
  ctx.ui.notify(
157
- `Compaction (${reason}): summarizing ${messages.length} msgs / ${preparation.tokensBefore.toLocaleString()} tok with ${model.id}`,
240
+ `Compaction (${reason}): summarizing ${count} msgs / ${preparation.tokensBefore.toLocaleString()} tok with ${model.id}${reasoning ? ` (thinking ${reasoning})` : ""}`,
158
241
  "info",
159
242
  );
160
243
 
161
244
  try {
162
- const response = await complete(
163
- model,
164
- {
165
- messages: [
245
+ const response = await retryAssistantCall(
246
+ () =>
247
+ completeSimple(
248
+ model,
166
249
  {
167
- role: "user" as const,
168
- content: [
169
- {
170
- type: "text" as const,
171
- text: SUMMARY_PROMPT(preparation.previousSummary, conversation),
172
- },
250
+ systemPrompt: SYSTEM_PROMPT,
251
+ messages: [
252
+ { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() },
173
253
  ],
174
- timestamp: Date.now(),
175
254
  },
176
- ],
177
- },
255
+ // No maxTokens: the model's own output limit, so long reasoning cannot
256
+ // starve the summary (pi #9512).
257
+ {
258
+ apiKey: auth.apiKey,
259
+ headers: auth.headers,
260
+ env: auth.env,
261
+ signal,
262
+ cacheRetention: "none",
263
+ reasoning,
264
+ },
265
+ ),
266
+ SUMMARY_RETRY,
267
+ signal,
178
268
  {
179
- apiKey: auth.apiKey,
180
- headers: auth.headers,
181
- env: auth.env,
182
- maxTokens: MAX_SUMMARY_TOKENS,
183
- signal,
184
- cacheRetention: "none",
269
+ onRetryScheduled: (attempt, max, delayMs, error) =>
270
+ ctx.ui.notify(
271
+ `Compaction: retry ${attempt}/${max} in ${Math.round(delayMs / 1000)}s (${error})`,
272
+ "warning",
273
+ ),
185
274
  },
186
275
  );
276
+ if (signal.aborted) return;
187
277
 
188
- const summary = response.content
189
- .flatMap((c) => (c.type === "text" ? [c.text] : []))
190
- .join("\n")
191
- .trim();
192
-
278
+ const failure = summaryFailure(response);
279
+ if (failure) {
280
+ ctx.ui.notify(`Compaction: summary failed (${failure}); using built-in`, "warning");
281
+ return;
282
+ }
283
+ const summary = contentText(response.content).trim();
193
284
  if (!summary) {
194
- if (!signal.aborted) ctx.ui.notify("Compaction: empty summary; using built-in", "warning");
285
+ ctx.ui.notify("Compaction: empty summary; using built-in", "warning");
195
286
  return;
196
287
  }
197
288
 
198
289
  return {
199
290
  compaction: {
200
- summary,
291
+ summary: summary + formatFileLists(files.readFiles, files.modifiedFiles),
201
292
  firstKeptEntryId: preparation.firstKeptEntryId,
202
293
  tokensBefore: preparation.tokensBefore,
203
294
  usage: response.usage,
295
+ details: { readFiles: files.readFiles, modifiedFiles: files.modifiedFiles },
204
296
  },
205
297
  };
206
298
  } catch (error) {
@@ -256,8 +348,9 @@ export default function registerCompaction(pi: ExtensionAPI): void {
256
348
  return { entries: [...event.entries, ...edits] };
257
349
  });
258
350
 
259
- pi.on("agent_settled", (_event, ctx) => {
260
- if (compacting || triggerDisabled) return;
351
+ pi.on("agent_settled", (event, ctx) => {
352
+ // Escape means the user wants control back, not a surprise compaction.
353
+ if (event.aborted || compacting || triggerDisabled) return;
261
354
  const { triggerPercent, minimumTokens } = config(compactionSection);
262
355
  if (triggerPercent <= 0) return;
263
356