@xynogen/pix-core 0.4.60 → 0.5.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.4.60",
4
- "description": "Pi extension bundle \u2014 installs and activates all core pix-* extensions",
3
+ "version": "0.5.0",
4
+ "description": "Pi extension bundle — installs and activates all core pix-* extensions",
5
5
  "type": "module",
6
6
  "main": "src/extension.ts",
7
7
  "pi": {
@@ -40,16 +40,16 @@
40
40
  "dependencies": {
41
41
  "@xynogen/pix-runtime": "^0.5.2",
42
42
  "@xynogen/pix-data": "^0.4.1",
43
- "@xynogen/pix-pretty": "^1.8.1",
44
- "@xynogen/pix-welcome": "^0.1.18",
45
- "@xynogen/pix-footer": "^0.1.20",
43
+ "@xynogen/pix-pretty": "^1.11.0",
44
+ "@xynogen/pix-welcome": "^0.1.19",
45
+ "@xynogen/pix-footer": "^0.1.21",
46
46
  "@xynogen/pix-diagnostics": "^0.1.10",
47
47
  "@xynogen/pix-display": "^0.1.14",
48
48
  "@xynogen/pix-prompts": "^0.3.5",
49
- "@xynogen/pix-skills": "^0.7.6",
49
+ "@xynogen/pix-skills": "^0.7.8",
50
50
  "@xynogen/pix-models": "^0.1.27",
51
- "@xynogen/pix-update": "^0.1.17",
52
- "@xynogen/pix-commands": "^0.2.5",
51
+ "@xynogen/pix-update": "^0.1.18",
52
+ "@xynogen/pix-commands": "^0.3.2",
53
53
  "@xynogen/pix-nudge": "^0.1.13",
54
54
  "@xynogen/pix-read": "^0.1.20",
55
55
  "@xynogen/pix-write": "^0.1.19",
@@ -57,12 +57,12 @@
57
57
  "@xynogen/pix-find": "^0.1.20",
58
58
  "@xynogen/pix-grep": "^0.1.20",
59
59
  "@xynogen/pix-ls": "^0.1.20",
60
- "@xynogen/pix-bash": "^0.1.18",
61
- "@xynogen/pix-todo": "^0.1.20",
60
+ "@xynogen/pix-bash": "^0.1.19",
61
+ "@xynogen/pix-todo": "^0.1.21",
62
62
  "@xynogen/pix-ask": "^0.2.15",
63
63
  "@xynogen/pix-optimizer": "^1.1.22",
64
- "@xynogen/pix-gate": "^0.2.2",
65
- "@xynogen/pix-subagent": "^0.6.3"
64
+ "@xynogen/pix-gate": "^0.2.3",
65
+ "@xynogen/pix-subagent": "^0.6.5"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@earendil-works/pi-ai": "*",
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { compactionThresholdTokens } from "./compaction.ts";
2
+ import { compactionThresholdTokens, resumeDecisionAfterCompaction } from "./compaction.ts";
3
3
 
4
4
  describe("compactionThresholdTokens", () => {
5
5
  it("uses the 100k floor when the percentage threshold is lower", () => {
@@ -10,3 +10,73 @@ describe("compactionThresholdTokens", () => {
10
10
  expect(compactionThresholdTokens(1_000_000, 60, 100_000)).toBe(600_000);
11
11
  });
12
12
  });
13
+
14
+ describe("resumeDecisionAfterCompaction", () => {
15
+ const threshold = 100_000;
16
+
17
+ it("resumes when idle, no pending messages, and under threshold", () => {
18
+ expect(
19
+ resumeDecisionAfterCompaction({
20
+ estimatedTokensAfter: 40_000,
21
+ threshold,
22
+ idle: true,
23
+ hasPending: false,
24
+ }),
25
+ ).toBe("resume");
26
+ });
27
+
28
+ it("skips when the agent is busy with the user's in-flight prompt", () => {
29
+ expect(
30
+ resumeDecisionAfterCompaction({
31
+ estimatedTokensAfter: 40_000,
32
+ threshold,
33
+ idle: false,
34
+ hasPending: false,
35
+ }),
36
+ ).toBe("skip");
37
+ });
38
+
39
+ it("skips when the user has a prompt queued during compaction", () => {
40
+ expect(
41
+ resumeDecisionAfterCompaction({
42
+ estimatedTokensAfter: 40_000,
43
+ threshold,
44
+ idle: true,
45
+ hasPending: true,
46
+ }),
47
+ ).toBe("skip");
48
+ });
49
+
50
+ it("latches off (loop) when still at/above threshold after compaction", () => {
51
+ expect(
52
+ resumeDecisionAfterCompaction({
53
+ estimatedTokensAfter: threshold,
54
+ threshold,
55
+ idle: true,
56
+ hasPending: false,
57
+ }),
58
+ ).toBe("loop");
59
+ });
60
+
61
+ it("treats a missing estimate as 0 tokens (does not latch)", () => {
62
+ expect(
63
+ resumeDecisionAfterCompaction({
64
+ estimatedTokensAfter: undefined,
65
+ threshold,
66
+ idle: true,
67
+ hasPending: false,
68
+ }),
69
+ ).toBe("resume");
70
+ });
71
+
72
+ it("loop takes priority over a busy agent", () => {
73
+ expect(
74
+ resumeDecisionAfterCompaction({
75
+ estimatedTokensAfter: 200_000,
76
+ threshold,
77
+ idle: false,
78
+ hasPending: true,
79
+ }),
80
+ ).toBe("loop");
81
+ });
82
+ });
package/src/compaction.ts CHANGED
@@ -68,6 +68,29 @@ export function compactionThresholdTokens(
68
68
  return Math.max(minimumTokens, Math.ceil((contextWindow * triggerPercent) / 100));
69
69
  }
70
70
 
71
+ /**
72
+ * Decide what to do after a self-triggered compaction completes. Pure so the
73
+ * three branches (the tricky part) are unit-testable without a Pi session.
74
+ *
75
+ * - "loop": compaction landed still at/above threshold — latch the auto-trigger
76
+ * off so the resume turn doesn't re-fire agent_settled forever.
77
+ * - "skip": the user is already driving (a prompt is in flight or queued).
78
+ * Injecting the resume nudge would collide with that prompt (the reported
79
+ * "Agent is already processing a prompt" crash) and, worse, tell the agent to
80
+ * resume the ORIGINAL task right after the user redirected it.
81
+ * - "resume": agent is idle with room to spare — send the nudge.
82
+ */
83
+ export function resumeDecisionAfterCompaction(args: {
84
+ estimatedTokensAfter: number | undefined;
85
+ threshold: number;
86
+ idle: boolean;
87
+ hasPending: boolean;
88
+ }): "loop" | "skip" | "resume" {
89
+ if ((args.estimatedTokensAfter ?? 0) >= args.threshold) return "loop";
90
+ if (!args.idle || args.hasPending) return "skip";
91
+ return "resume";
92
+ }
93
+
71
94
  // Sent as a user message after a self-triggered compaction so the agent picks
72
95
  // its task back up on its own. Kept short — the freshly written summary already
73
96
  // carries the goal, progress, and next steps. Edit to taste.
@@ -122,8 +145,7 @@ export default function registerCompaction(pi: ExtensionAPI): void {
122
145
  );
123
146
 
124
147
  const summary = response.content
125
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
126
- .map((c) => c.text)
148
+ .flatMap((c) => (c.type === "text" ? [c.text] : []))
127
149
  .join("\n")
128
150
  .trim();
129
151
 
@@ -150,8 +172,13 @@ export default function registerCompaction(pi: ExtensionAPI): void {
150
172
 
151
173
  // ── Lever 2: our own trigger, driven by live context usage ────────────────
152
174
  let compacting = false;
175
+ // Latched off if a compaction lands still above threshold (e.g. a large
176
+ // minimumTokens floor on a small model). Without this the resume turn settles,
177
+ // agent_settled re-fires, and we compact again every turn. Reset by /compact or
178
+ // a new session; a session-scoped bool is the smallest fix.
179
+ let triggerDisabled = false;
153
180
  pi.on("agent_settled", (_event, ctx) => {
154
- if (compacting) return;
181
+ if (compacting || triggerDisabled) return;
155
182
  const { triggerPercent, minimumTokens } = config(compactionSection);
156
183
  if (triggerPercent <= 0) return;
157
184
 
@@ -167,12 +194,48 @@ export default function registerCompaction(pi: ExtensionAPI): void {
167
194
  "info",
168
195
  );
169
196
  ctx.compact({
170
- onComplete: () => {
197
+ onComplete: (result) => {
171
198
  compacting = false;
172
- // Nudge the agent to pick its task back up. Visible as a normal
173
- // user message in the transcript (no hidden automation).
199
+ const decision = resumeDecisionAfterCompaction({
200
+ estimatedTokensAfter: result.estimatedTokensAfter,
201
+ threshold,
202
+ idle: ctx.isIdle(),
203
+ hasPending: ctx.hasPendingMessages(),
204
+ });
205
+
206
+ if (decision === "loop") {
207
+ // Still over the line (e.g. large minimumTokens floor on a small model).
208
+ // Latch the auto-trigger off so the resume turn doesn't re-fire
209
+ // agent_settled and compact every turn. Reset by /compact or new session.
210
+ triggerDisabled = true;
211
+ ctx.ui.notify(
212
+ `Compaction: ${(result.estimatedTokensAfter ?? 0).toLocaleString()} tok still ≥ ${threshold.toLocaleString()} tok threshold — auto-trigger disabled for this session`,
213
+ "warning",
214
+ );
215
+ return;
216
+ }
217
+
218
+ if (decision === "skip") {
219
+ // User is already driving; skipping avoids colliding with their in-flight
220
+ // prompt and avoids overriding an explicit redirect. The fresh summary is
221
+ // already in context, so the agent continues without a nudge.
222
+ ctx.ui.notify("Compaction: resume nudge skipped (your prompt is in flight)", "info");
223
+ return;
224
+ }
225
+
226
+ // Idle: nudge the agent to pick its task back up. Visible as a normal user
227
+ // message in the transcript (no hidden automation). deliverAs "followUp" is
228
+ // a backstop for the narrow await-window where a prompt starts between the
229
+ // isIdle() check and this send; pi swallows a residual rejection into its
230
+ // own emitError, so it can't crash the session. The try/catch only guards a
231
+ // stale-ctx throw from sendUserMessage.
174
232
  ctx.ui.notify("Compaction: resuming task", "info");
175
- pi.sendUserMessage(RESUME_NUDGE);
233
+ try {
234
+ pi.sendUserMessage(RESUME_NUDGE, { deliverAs: "followUp" });
235
+ } catch (err) {
236
+ const message = err instanceof Error ? err.message : String(err);
237
+ ctx.ui.notify(`Compaction: resume nudge skipped (${message})`, "warning");
238
+ }
176
239
  },
177
240
  onError: (err) => {
178
241
  compacting = false;
package/src/once.test.ts CHANGED
@@ -152,7 +152,7 @@ describe("member factory dedupe (pix-commands)", () => {
152
152
  registerCommand(name: string) {
153
153
  commandNames.push(name);
154
154
  },
155
- registerMessageRenderer() {},
155
+ registerEntryRenderer() {},
156
156
  on() {},
157
157
  } as never;
158
158
  return { pi, commandNames };