pi-mega-compact 0.8.15 → 0.8.17

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 (52) hide show
  1. package/README.md +51 -486
  2. package/dist/extensions/dashboard-client/src/hooks/useApi.js +51 -0
  3. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +63 -0
  4. package/dist/extensions/dashboard-server/html.js +15 -2
  5. package/dist/extensions/mega-compact-s38.test.js +33 -0
  6. package/dist/extensions/mega-events/agent-handlers.js +51 -3
  7. package/dist/extensions/mega-events/compact-handlers.js +10 -4
  8. package/dist/extensions/mega-events/error-classifier.js +24 -1
  9. package/dist/extensions/mega-events/send-safe.js +12 -0
  10. package/dist/extensions/mega-events.js +1 -0
  11. package/dist/extensions/mega-runtime/state.js +66 -0
  12. package/dist/extensions/mega-runtime/state.test.js +37 -0
  13. package/dist/extensions/mega-runtime/widget.js +6 -2
  14. package/dist/extensions/mega-runtime/widget.test.js +47 -0
  15. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BH26e95w.js → AchievementTiles-BozP_h3c.js} +2 -2
  16. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BH26e95w.js.map → AchievementTiles-BozP_h3c.js.map} +1 -1
  17. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B94wvRV_.js → AchievementsTab-DKgqx1RQ.js} +2 -2
  18. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B94wvRV_.js.map → AchievementsTab-DKgqx1RQ.js.map} +1 -1
  19. package/extensions/dashboard-client/dist/assets/{CacheTab-DPmw3NHR.js → CacheTab-CPvso4-0.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{CacheTab-DPmw3NHR.js.map → CacheTab-CPvso4-0.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js +2 -0
  22. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +1 -0
  23. package/extensions/dashboard-client/dist/assets/{EventsTab-B5PicfFw.js → EventsTab-BGeuYvM3.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{EventsTab-B5PicfFw.js.map → EventsTab-BGeuYvM3.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/{GameTab-CsVwp78a.js → GameTab-Qj19W1Y-.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{GameTab-CsVwp78a.js.map → GameTab-Qj19W1Y-.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cf72UewX.js → MetricsTab-DA27vA95.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cf72UewX.js.map → MetricsTab-DA27vA95.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dh6aG3gy.js → OverviewTab-lVGR1Jl_.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dh6aG3gy.js.map → OverviewTab-lVGR1Jl_.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{ReposTab-HXf2dorl.js → ReposTab-_HNR3qYj.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{ReposTab-HXf2dorl.js.map → ReposTab-_HNR3qYj.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-kUnoospE.js → TimeSavedCard-B7aPcBMs.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-kUnoospE.js.map → TimeSavedCard-B7aPcBMs.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{index-DMntLu-8.js → index-B2JJExE9.js} +3 -3
  36. package/extensions/dashboard-client/dist/assets/{index-DMntLu-8.js.map → index-B2JJExE9.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/index.html +1 -1
  38. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +19 -2
  39. package/extensions/dashboard-server/html.ts +15 -2
  40. package/extensions/mega-compact-s38.test.ts +63 -0
  41. package/extensions/mega-events/agent-handlers.ts +47 -3
  42. package/extensions/mega-events/compact-handlers.ts +11 -4
  43. package/extensions/mega-events/error-classifier.ts +25 -1
  44. package/extensions/mega-events/send-safe.ts +37 -0
  45. package/extensions/mega-events.ts +1 -0
  46. package/extensions/mega-runtime/state.test.ts +33 -0
  47. package/extensions/mega-runtime/state.ts +64 -0
  48. package/extensions/mega-runtime/widget.test.ts +48 -0
  49. package/extensions/mega-runtime/widget.ts +6 -2
  50. package/package.json +1 -1
  51. package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js +0 -2
  52. package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js.map +0 -1
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>mega-compact dashboard</title>
7
- <script type="module" crossorigin src="./assets/index-DMntLu-8.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-B2JJExE9.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-Cfxniu2R.css">
9
9
  </head>
10
10
  <body>
@@ -17,7 +17,7 @@ import type React from "react";
17
17
  import { useState, useEffect, useCallback } from "react";
18
18
  import type { SnapshotResponse, GameStatePatch } from "@contracts";
19
19
  import { fetchSnapshot, fetchGameState, putGameState } from "../api/client";
20
- import { THEMES } from "../../../../src/config/themes";
20
+ import { THEMES, THEME_IDS } from "../../../../src/config/themes";
21
21
 
22
22
  /** Actual flat game state returned by the server (the server returns a flat
23
23
  * { game_mode_on, theme, tui_display_mode } shape, NOT the contract's nested
@@ -168,6 +168,20 @@ export default function ConfigTab(): React.ReactElement {
168
168
  </option>
169
169
  ))}
170
170
  </select>
171
+ <button
172
+ type="button"
173
+ disabled={saving}
174
+ className="theme-cycle-btn"
175
+ title="Cycle to the next theme (P3)"
176
+ onClick={() => {
177
+ const i = THEME_IDS.indexOf(themeVal);
178
+ const next =
179
+ THEME_IDS[(i < 0 ? 0 : i + 1) % THEME_IDS.length];
180
+ if (next) void handlePatch({ theme: next });
181
+ }}
182
+ >
183
+ Next theme →
184
+ </button>
171
185
  </label>
172
186
  </div>
173
187
  <div className="settings-row">
@@ -184,7 +198,10 @@ export default function ConfigTab(): React.ReactElement {
184
198
  >
185
199
  <option value="full">Full</option>
186
200
  <option value="minimal">Minimal</option>
187
- </select>
201
+ </select>{" "}
202
+ <span className="tui-mode-hint">
203
+ (affects the in-app TUI widget; not shown here)
204
+ </span>
188
205
  </label>
189
206
  </div>
190
207
  </section>
@@ -179,8 +179,8 @@ export function dashboardHtml(tierName: string): string {
179
179
 
180
180
  <div class="settings-strip">
181
181
  <label title="Turn game mode on/off (themes the widget + dashboard)"><input type="checkbox" id="set-game-mode"> Game mode</label>
182
- <label title="Visual theme (applies instantly)">Theme <select id="set-theme">${THEME_OPTIONS}</select></label>
183
- <label title="TUI widget display density">TUI <select id="set-tui-mode"><option value="full">Full</option><option value="minimal">Minimal</option></select></label>
182
+ <label title="Visual theme (applies instantly)">Theme <select id="set-theme">${THEME_OPTIONS}</select> <button type="button" id="set-theme-next" title="Cycle to the next theme (P3)">Next theme →</button></label>
183
+ <label title="TUI widget display density">TUI <select id="set-tui-mode"><option value="full">Full</option><option value="minimal">Minimal</option></select> <span class="tui-mode-hint">(affects the in-app TUI widget; not shown here)</span></label>
184
184
  </div>
185
185
 
186
186
  <nav class="tabs">
@@ -872,6 +872,19 @@ export function dashboardHtml(tierName: string): string {
872
872
  }
873
873
  if (gmCheckbox) gmCheckbox.addEventListener('change', function() { putGameState({ game_mode_on: gmCheckbox.checked }); });
874
874
  if (gmTheme) gmTheme.addEventListener('change', function() { putGameState({ theme: gmTheme.value }); });
875
+ // P3: client-side theme cycling (no new endpoint). Reads the already-fetched
876
+ // <option> values from the theme select, so it stays in sync with the server's
877
+ // THEMES list without a second fetch.
878
+ var gmThemeNext = document.getElementById('set-theme-next');
879
+ if (gmThemeNext) gmThemeNext.addEventListener('click', function() {
880
+ var opts = gmTheme ? gmTheme.options : [];
881
+ var n = opts.length;
882
+ var cur = gmTheme ? gmTheme.value : 'transparent';
883
+ var idx = -1;
884
+ for (var i = 0; i < n; i++) { if (opts[i].value === cur) { idx = i; break; } }
885
+ var next = n > 0 ? opts[(idx < 0 ? 0 : (idx + 1) % n)].value : 'transparent';
886
+ putGameState({ theme: next });
887
+ });
875
888
  if (gmTui) gmTui.addEventListener('change', function() { putGameState({ tui_display_mode: gmTui.value }); });
876
889
  pollGameState();
877
890
  setInterval(pollGameState, 5000);
@@ -191,6 +191,69 @@ test("S38: classifyError returns 'compaction-noop' for 'Auto compaction failed'
191
191
  assert.equal(classifyErrorFn("Auto-compaction failed"), "compaction-noop");
192
192
  });
193
193
 
194
+ // ---- context-overflow classifier (S38.8: 400 "too long... even after compaction") ----
195
+
196
+ test("S38: classifyError returns 'context-overflow' for the literal user 400 string", () => {
197
+ assert.equal(
198
+ classifyErrorFn("Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction."),
199
+ "context-overflow",
200
+ );
201
+ });
202
+
203
+ test("S38: classifyError returns 'context-overflow' for 'All targets failed' wrapper", () => {
204
+ assert.equal(
205
+ classifyErrorFn("All targets failed: ... Last error: ... too long ... context window even after compaction"),
206
+ "context-overflow",
207
+ );
208
+ });
209
+
210
+ test("S38: classifyError returns 'context-overflow' for invalid_request_error JSON shape", () => {
211
+ assert.equal(
212
+ classifyErrorFn('{"type":"invalid_request_error","message":"... too long for context window ..."}'),
213
+ "context-overflow",
214
+ );
215
+ });
216
+
217
+ test("S38: classifyError returns 'context-overflow' for OpenRouter 'All targets failed' max-context 400 (FAIL-20260725)", () => {
218
+ // Regression: the OpenAI/OpenRouter provider-side phrasing
219
+ // "maximum context length is N tokens ... requires at least M tokens ...
220
+ // reduce your input or max_tokens" does NOT contain "too long" / "context
221
+ // window" / "reduce the conversation", so it slipped past the original S38.8
222
+ // regex and fell through to the generic transient branch, firing 5 blind
223
+ // retry nudges that re-submitted the same oversized prompt -> re-400 ->
224
+ // busy-loop. The exact user-facing wrapper string from the router:
225
+ assert.equal(
226
+ classifyErrorFn('Error: 400: {"message":"All targets failed: neuralwatt/glm-5.2-short. Last error: This model\'s maximum context length is 200000 tokens. Your request requires at least 201070 tokens (201070 prompt + 0 max_tokens). Please reduce your input or max_tokens.","type":"invalid_request_error"}'),
227
+ "context-overflow",
228
+ );
229
+ });
230
+
231
+ test("S38: classifyError returns 'context-overflow' for bare 'maximum context length' phrasing", () => {
232
+ assert.equal(
233
+ classifyErrorFn("This model's maximum context length is 128000 tokens. Your request requires at least 130000 tokens. Please reduce your input or max_tokens."),
234
+ "context-overflow",
235
+ );
236
+ });
237
+
238
+ test("S38: classifyError returns 'context-overflow' for 'context length exceeded' phrasing", () => {
239
+ assert.equal(
240
+ classifyErrorFn("This model's context length exceeded: 200000 tokens"),
241
+ "context-overflow",
242
+ );
243
+ });
244
+
245
+ test("S38: context-overflow fires NO blind retry nudge and logs 'context_overflow'", async () => {
246
+ const h = harness();
247
+ await s38TurnEnd(
248
+ h,
249
+ "error",
250
+ "Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction.",
251
+ );
252
+ const ev = eventTypes(h.stateDir);
253
+ assert.equal(h.sendUserMessages.length, 0, "context-overflow: NO blind retry nudge fired");
254
+ assert.ok(ev.includes("context_overflow"), "context-overflow: 'context_overflow' event logged");
255
+ });
256
+
194
257
  // ---- integration tests (fire turn_end through the real extension) ----
195
258
 
196
259
  test("S38: compaction-noop logs 'compaction_noop_diagnostic' + resets counter + no retry fired", async () => {
@@ -19,6 +19,7 @@ import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achie
19
19
  import { isMegaCache } from "../../src/game/scoring.js";
20
20
  import { resolveRepoRoot } from "../mega-config.js";
21
21
  import { classifyError } from "./error-classifier.js";
22
+ import { safeSendUserMessage } from "./send-safe.js";
22
23
 
23
24
  /** Register agent/turn tracking event handlers. */
24
25
  export function registerAgentHandlers(
@@ -215,7 +216,7 @@ export function registerAgentHandlers(
215
216
  const nudgeMsg = lengthStop && !didDurableTrim
216
217
  ? "[mega-compact] the last response hit the output-token cap; continue from where it stopped."
217
218
  : "[mega-compact] continue from the compacted context above.";
218
- pi.sendUserMessage(nudgeMsg);
219
+ await safeSendUserMessage(pi, nudgeMsg);
219
220
  }
220
221
  } catch {
221
222
  /* non-fatal: a failed nudge never blocks */
@@ -346,6 +347,48 @@ export function registerAgentHandlers(
346
347
  sessionId: runtime.rt.sessionId,
347
348
  turnIndex: event.turnIndex,
348
349
  });
350
+ } else if (category === 'context-overflow') {
351
+ // (4b) context-window overflow 400 ("too long... even after compaction").
352
+ // NOT a blind retry: re-submitting the same oversized prompt would just
353
+ // re-400 and busy-loop. Reset the counters (this turn is terminal, not
354
+ // retryable in the S38 sense) and force ONE best-effort re-compact with
355
+ // the debounce bypassed + the same race-guard (lastNativeCompactAt
356
+ // cooldown + deferred setTimeout re-check) as the agent_end durable
357
+ // trim. Resume after that forced compact is handled by the existing
358
+ // nudgeResume() inside session_before_compact (it fires after
359
+ // driveNativeCompaction supplies the compaction) — do NOT add a
360
+ // separate nudge here.
361
+ runtime.rt.errorRetryCount = 0;
362
+ runtime.rt.consecutiveErrors = 0;
363
+ runtime.dashboard.event('context_overflow', {
364
+ turnIndex: event.turnIndex,
365
+ sessionId: runtime.rt.sessionId,
366
+ });
367
+ runtime.logger.warn('context-overflow', {
368
+ sessionId: runtime.rt.sessionId,
369
+ turnIndex: event.turnIndex,
370
+ });
371
+ if (config.auto) {
372
+ const now2 = Date.now();
373
+ const cooldownMs2 = config.raceGuardStrict ? 30_000 : 10_000;
374
+ const sinceCompact2 = now2 - (runtime.rt.lastNativeCompactAt ?? 0);
375
+ if (sinceCompact2 >= cooldownMs2 && !piCompactWouldNoop(ctx)) {
376
+ runtime.debounceUntil = now2 + 0;
377
+ const stamp2 = runtime.rt.lastNativeCompactAt;
378
+ const liveSid2 = runtime.rt.sessionId;
379
+ setTimeout(() => {
380
+ try {
381
+ if (runtime.rt.sessionId !== liveSid2) return; // session reset
382
+ const since3 = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
383
+ if (runtime.rt.lastNativeCompactAt !== stamp2 && since3 < cooldownMs2) return;
384
+ if (piCompactWouldNoop(ctx)) return;
385
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated. Forced re-compact after a context-overflow 400.
386
+ } catch {
387
+ /* non-fatal */
388
+ }
389
+ }, 500);
390
+ }
391
+ }
349
392
  } else {
350
393
  // (5) transient or permanent — retry with exponential backoff.
351
394
  // S38.7: hard-stop switch — bypass ALL retry logic when set.
@@ -421,8 +464,9 @@ export function registerAgentHandlers(
421
464
  count: runtime.rt.errorRetryCount,
422
465
  max,
423
466
  });
424
- // PREVENT-PI-003: user-role sendUserMessage only.
425
- pi.sendUserMessage(
467
+ // PREVENT-PI-003: user-role sendUserMessage only (queued + catch-guarded).
468
+ await safeSendUserMessage(
469
+ pi,
426
470
  '[mega-compact] the last turn ended with an error; please retry.',
427
471
  );
428
472
  }
@@ -22,6 +22,7 @@ import type { MegaConfig } from "../mega-config.js";
22
22
  import { recordScore, getDedupStats } from "../../src/store/sqlite.js";
23
23
  import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
24
24
  import { resolveRepoRoot } from "../mega-config.js";
25
+ import { safeSendUserMessage } from "./send-safe.js";
25
26
 
26
27
  /**
27
28
  * Build a minimal fallback compaction so pi never runs its throwing compact().
@@ -57,13 +58,19 @@ function fallbackCompaction(
57
58
  /**
58
59
  * Debounced resume-nudge: restart the agent loop after a compaction (which
59
60
  * may have stopped it). Idempotent — one nudge per 30s, never blocks.
61
+ *
62
+ * Uses safeSendUserMessage ({ deliverAs: 'followUp' } + catch-guard) so that a
63
+ * nudge fired during session_before_compact (which is mid-prompt-submission,
64
+ * so the agent can be busy) QUEUES instead of throwing
65
+ * "Agent is already processing. Specify streamingBehavior (steer or followUp)".
60
66
  */
61
- function nudgeResume(pi: ExtensionAPI, runtime: MegaRuntime): void {
67
+ async function nudgeResume(pi: ExtensionAPI, runtime: MegaRuntime): Promise<void> {
62
68
  try {
63
69
  const now = Date.now();
64
70
  if (now >= runtime.resumeNudgeUntil) {
65
71
  runtime.resumeNudgeUntil = now + 30_000;
66
- pi.sendUserMessage(
72
+ await safeSendUserMessage(
73
+ pi,
67
74
  "[mega-compact] continue from the compacted context above.",
68
75
  );
69
76
  }
@@ -113,7 +120,7 @@ export function registerCompactHandlers(
113
120
  tokensBefore: result.compaction.tokensBefore,
114
121
  summaryTokens: result.compaction.estimatedTokensAfter,
115
122
  });
116
- nudgeResume(pi, runtime);
123
+ await nudgeResume(pi, runtime);
117
124
  return { compaction: result.compaction };
118
125
  }
119
126
  // FIX "compacts but doesn't resume" + "Nothing to compact" regression:
@@ -134,7 +141,7 @@ export function registerCompactHandlers(
134
141
  tokensBefore: fb.compaction.tokensBefore,
135
142
  reason: event.reason,
136
143
  });
137
- nudgeResume(pi, runtime);
144
+ await nudgeResume(pi, runtime);
138
145
  return { compaction: fb.compaction };
139
146
  }
140
147
  } catch (err) {
@@ -12,12 +12,13 @@
12
12
  * exclusively (its agent_end nudge path is separate and must not be doubled).
13
13
  *
14
14
  * @param message the event.message (a pi AgentMessage) or an error string
15
- * @returns 'transient' | 'permanent' | 'compaction-noop' | null (success/unknown)
15
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | null (success/unknown)
16
16
  */
17
17
  export function classifyError(message: unknown):
18
18
  | 'transient'
19
19
  | 'permanent'
20
20
  | 'compaction-noop'
21
+ | 'context-overflow'
21
22
  | null {
22
23
  // Resolve a searchable text blob from a pi AgentMessage or raw string.
23
24
  let text = '';
@@ -81,6 +82,29 @@ export function classifyError(message: unknown):
81
82
  if (/compaction failed/.test(s)) return 'compaction-noop';
82
83
  if (/nothing to compact/.test(s)) return 'compaction-noop';
83
84
  if (/auto[\s-]?compaction failed/.test(s)) return 'compaction-noop';
85
+ // --- context-overflow (ORDER BEFORE generic transient!) ---
86
+ // A 400 from the model meaning "the prompt is bigger than the context window."
87
+ // Catches BOTH provider phrasings so the classification does not depend on
88
+ // which backend the router landed on:
89
+ // - pi/Anthropic wrapper: "too long for this model's context window even
90
+ // after compaction. Reduce the conversation length..." ->
91
+ // too long | context window | even after compaction | reduce the conversation
92
+ // - OpenAI/OpenRouter provider-side (often wrapped in "All targets failed:
93
+ // <model>. Last error: ..."): "This model's maximum context length is N
94
+ // tokens. Your request requires at least M tokens. Please reduce your
95
+ // input or max_tokens." -> maximum context length | context length
96
+ // exceeded | requires at least N tokens | reduce your input
97
+ // All of these carry `invalid_request_error` (UNDERSCORE, not 'invalid
98
+ // request' space) and would otherwise fall through to the generic
99
+ // `s.includes('error')` transient branch below, misclassifying as
100
+ // 'transient' and firing up to 5 blind retry nudges that re-submit the same
101
+ // oversized prompt -> re-400 -> busy-loop. 'context-overflow' instead forces
102
+ // ONE deferred re-compact (debounce-bypassed, race-guarded) and fires NO
103
+ // blind retry nudge. The forced re-compact is shaped by the existing
104
+ // session_before_compact durable trim (it cannot lower pi's firstKeptEntryId).
105
+ if (/too long|context window|maximum context length|context length exceeded|requires at least \d+ tokens|even after compaction|reduce the conversation|reduce your input/.test(s)) {
106
+ return 'context-overflow';
107
+ }
84
108
  // --- transient (retryable) ---
85
109
  if (s.includes('error') && !/\b(permanent|invalid request|malformed|bad request|auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
86
110
  return 'transient'; // generic pi stopReason 'error' / 'aborted'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * send-safe.ts — queue-safe wrapper for pi.sendUserMessage.
3
+ *
4
+ * All extension-initiated user-role nudges (resume after compaction, length-stop
5
+ * continue, error-retry) MUST go through this wrapper. It:
6
+ * 1. Passes { deliverAs: 'followUp' } so that when the agent is busy (e.g. a
7
+ * resume nudge fired during session_before_compact, which is mid-prompt
8
+ * submission) pi QUEUES the message instead of throwing
9
+ * "Agent is already processing. Specify streamingBehavior (steer or
10
+ * followUp) to queue the message" (pi agent-session.js:830).
11
+ * 2. Awaits the call and catch-guards it so a failed/queued nudge never throws
12
+ * or produces an unhandled rejection — it must never block the agent loop.
13
+ *
14
+ * PREVENT-PI-003: user-role sendUserMessage only (no role:'system' injection).
15
+ * PREVENT-PI-004: local pi ctx call, no network.
16
+ */
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+
19
+ /**
20
+ * safeSendUserMessage — await + catch-guard + queue-safe wrapper for
21
+ * pi.sendUserMessage. Never throws; never produces an unhandled rejection.
22
+ */
23
+ export async function safeSendUserMessage(
24
+ pi: ExtensionAPI,
25
+ content: string,
26
+ ): Promise<void> {
27
+ try {
28
+ await (
29
+ pi.sendUserMessage as (
30
+ c: string,
31
+ o?: { deliverAs?: "steer" | "followUp" },
32
+ ) => Promise<void> | void
33
+ )(content, { deliverAs: "followUp" });
34
+ } catch {
35
+ /* non-fatal: a failed/queued nudge never blocks the agent loop */
36
+ }
37
+ }
@@ -17,3 +17,4 @@ export * from "./mega-events/context-handler.js";
17
17
  export * from "./mega-events/compact-handlers.js";
18
18
  export * from "./mega-events/perf-handler.js";
19
19
  export * from "./mega-events/error-classifier.js";
20
+ export * from "./mega-events/send-safe.js";
@@ -116,6 +116,39 @@ describe("MegaRuntime setEffect lifecycle (v0.8.3)", () => {
116
116
  });
117
117
  });
118
118
 
119
+ describe("MegaRuntime pushTicker dedupe (P1)", () => {
120
+ const dirs: string[] = [];
121
+ after(() => {
122
+ for (const d of dirs) {
123
+ try { closeStore(d); } catch { /* */ }
124
+ }
125
+ delete process.env.MEGACOMPACT_STATE_DIR;
126
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
127
+ });
128
+
129
+ it("pushTicker skips consecutive identical entries (keeps ring at capacity for variety)", () => {
130
+ const dir = freshDir(); dirs.push(dir);
131
+ process.env.MEGACOMPACT_STATE_DIR = dir;
132
+ const rt = new MegaRuntime(minimalConfig(dir));
133
+ rt.pushTicker("compact");
134
+ rt.pushTicker("compact"); // dedup — skipped
135
+ rt.pushTicker("compact"); // dedup — skipped
136
+ rt.pushTicker("recall");
137
+ assert.equal(rt.ticker.length, 2, "only 2 unique entries kept");
138
+ assert.equal(rt.ticker[0].text, "compact");
139
+ assert.equal(rt.ticker[1].text, "recall");
140
+ });
141
+
142
+ it("pushTicker keeps distinct entries and respects TICKER_MAX", () => {
143
+ const dir = freshDir(); dirs.push(dir);
144
+ process.env.MEGACOMPACT_STATE_DIR = dir;
145
+ const rt = new MegaRuntime(minimalConfig(dir));
146
+ for (let i = 0; i < rt.TICKER_MAX + 3; i++) rt.pushTicker(`t${i}`);
147
+ assert.equal(rt.ticker.length, rt.TICKER_MAX, "capped at TICKER_MAX");
148
+ assert.equal(rt.ticker[rt.TICKER_MAX - 1].text, `t${rt.TICKER_MAX + 2}`);
149
+ });
150
+ });
151
+
119
152
  describe("MegaRuntime game-state cache (S31)", () => {
120
153
  const dirs: string[] = [];
121
154
  after(() => {
@@ -213,6 +213,11 @@ export class MegaRuntime {
213
213
  // getCachedGameState() snapshot re-queries the DB anyway.
214
214
  private gameStateWatcher?: FSWatcher;
215
215
  private gameStateWatchDir?: string;
216
+ // P2: the last ExtensionContext handed to snapshot()/renderWidget(), stashed
217
+ // so the fs.watch game-state callback can force a widget re-render without
218
+ // a context event (cross-process dashboard edits while pi is idle). Cleared
219
+ // implicitly on construction (undefined → watcher skips until first snap).
220
+ private lastWidgetCtx?: ExtensionContext;
216
221
 
217
222
  /**
218
223
  * DIAG counters for the "team run doesn't relieve context" investigation.
@@ -379,6 +384,7 @@ export class MegaRuntime {
379
384
 
380
385
  /** Collect live state and write it to disk (+ paint the above-editor widget). */
381
386
  snapshot(ctx?: ExtensionContext): void {
387
+ if (ctx) this.lastWidgetCtx = ctx;
382
388
  if (ctx) this.bindRepo(ctx.cwd);
383
389
  // v0.8.5: gate the expensive body (6 sync SQLite opens +
384
390
  // writeFileSync(dashboard.json)) behind a cheap material-change signature.
@@ -999,6 +1005,29 @@ export class MegaRuntime {
999
1005
  if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
1000
1006
  this.cachedGameState = undefined;
1001
1007
  this.gameStateBump++;
1008
+ // P2: force a widget re-render so a dashboard-made theme/toggle/
1009
+ // tui-mode change reflects in the live TUI immediately, even when
1010
+ // pi is idle (no context event to drive snapshot()). Use the
1011
+ // LIGHTWEIGHT refreshWidgetGameState() — NOT the full snapshot():
1012
+ // snapshot() recomputes 6 sync SQLite opens + writes dashboard.json
1013
+ // + writes to the store, and those store writes RETRIGGER this
1014
+ // same fs.watch callback (it fires on every sqlite.db* write) →
1015
+ // re-entrant thrash → 190s test timeout under mega-compact.test.js
1016
+ // / mega-teamrun.test.js. The lightweight path re-reads ONLY the
1017
+ // game_state row and patches the three game-mode fields on the
1018
+ // existing widgetData, then re-registers the factory via
1019
+ // renderWidget() — it writes nothing to the store or
1020
+ // dashboard.json, so it cannot retrigger itself. Guard: skip until
1021
+ // the first snapshot stashed a ctx (no widget registered yet →
1022
+ // nothing to refresh). Non-fatal: next context event re-snapshots.
1023
+ const ctx = this.lastWidgetCtx;
1024
+ if (ctx) {
1025
+ try {
1026
+ this.refreshWidgetGameState(ctx);
1027
+ } catch {
1028
+ /* non-fatal */
1029
+ }
1030
+ }
1002
1031
  }
1003
1032
  },
1004
1033
  );
@@ -1076,6 +1105,33 @@ export class MegaRuntime {
1076
1105
  return this.cachedGameState;
1077
1106
  }
1078
1107
 
1108
+ /** P2 cross-process re-render: lightweight game-state refresh for the
1109
+ * fs.watch callback. Eviction of cachedGameState + gameStateBump++ happens
1110
+ * in the caller BEFORE this runs. Here we re-read ONLY the game_state row
1111
+ * via getCachedGameState() (one SELECT; the cache is already evicted) and
1112
+ * patch ONLY the three game-mode fields on the EXISTING widgetData, then
1113
+ * re-register the widget factory via renderWidget() so pi redraws next
1114
+ * frame.
1115
+ *
1116
+ * WHY a lightweight path: the full snapshot(ctx) recomputes 6 synchronous
1117
+ * SQLite opens + writeFileSync(dashboard.json) + store writes, and those
1118
+ * store writes RETRIGGER this same fs.watch callback → re-entrant thrash
1119
+ * (the watcher fires on every sqlite.db* write, including context-event
1120
+ * checkpoint writes) → 190s test timeout under mega-compact.test.js /
1121
+ * mega-teamrun.test.js. This path writes NOTHING to the store or
1122
+ * dashboard.json, so it cannot retrigger itself.
1123
+ *
1124
+ * Guard: no-op when widgetData is null (no snapshot has run yet → nothing
1125
+ * to patch) or ctx is undefined. Field values mirror snapshot() exactly. */
1126
+ refreshWidgetGameState(ctx: ExtensionContext): void {
1127
+ if (!this.widgetData || !ctx) return;
1128
+ const gs = this.getCachedGameState();
1129
+ this.widgetData.gameMode = gs.game_mode_on;
1130
+ this.widgetData.theme = getTheme(gs.theme) ? gs.theme : "transparent";
1131
+ this.widgetData.tuiMode = gs.tui_display_mode;
1132
+ this.renderWidget(ctx);
1133
+ }
1134
+
1079
1135
  /** S31: evict the cached game-mode state so the next widget render re-reads
1080
1136
  * the game_state row. Called by /mega-game after every setGameState() so
1081
1137
  * the panel picks up theme/mode/toggle changes live. */
@@ -1164,6 +1220,14 @@ export class MegaRuntime {
1164
1220
 
1165
1221
  // Phase 3 — recall/activity ticker ring buffer.
1166
1222
  pushTicker(text: string): void {
1223
+ // P1: dedupe consecutive identical entries — skip the append when the
1224
+ // last entry's text matches, so a re-fired compact/recall/dedup event
1225
+ // doesn't flood the ring (keeps it at TICKER_MAX for real variety).
1226
+ // `at` is NOT refreshed on a skip (the original event time stands).
1227
+ if (this.ticker[this.ticker.length - 1]?.text === text) {
1228
+ this.lastActivityAt = Date.now();
1229
+ return;
1230
+ }
1167
1231
  this.ticker.push({ text, at: Date.now() });
1168
1232
  while (this.ticker.length > this.TICKER_MAX) this.ticker.shift();
1169
1233
  this.lastActivityAt = Date.now();
@@ -212,6 +212,54 @@ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
212
212
  });
213
213
  });
214
214
 
215
+ describe("buildWidgetLines footer stability (P1 — no 250ms rotation)", () => {
216
+ // P1: the L5 ticker branch used to re-pick the head text every 250ms via
217
+ // `step = floor(Date.now()/250)`, flipping the footer line on a 250·N ms
218
+ // cycle. After the fix it pins to the most-recent entry, so two renders of
219
+ // the SAME WidgetData at t and t+500ms (well past one rotation slot) must
220
+ // produce byte-identical footer lines. Proves the metronome is gone.
221
+ const stabBase = (overrides: Partial<WidgetData> = {}): WidgetData => baseWd({
222
+ theme: DEFAULT_THEME, tuiMode: "full", gameMode: false, level: 1, cachePct: 42, ...overrides,
223
+ });
224
+
225
+ it("ticker footer is byte-identical across simulated frames 500ms apart", () => {
226
+ const wd = stabBase({
227
+ ticker: [
228
+ { text: "first", at: 1000 },
229
+ { text: "second", at: 2000 },
230
+ { text: "third", at: 3000 },
231
+ ],
232
+ lastWhy: "because",
233
+ });
234
+ const realNow = Date.now;
235
+ try {
236
+ Date.now = () => 1_000_000; // t
237
+ const a = buildWidgetLines(wd, WIDTH, 0);
238
+ Date.now = () => 1_000_500; // t+500ms (would have rotated twice under the old code)
239
+ const b = buildWidgetLines(wd, WIDTH, 0);
240
+ assert.deepEqual(a, b, "footer byte-identical across 500ms with no state change");
241
+ // And the pinned head is the most-recent entry (third), not a rotation:
242
+ assert.ok(a.some((l) => l.includes("third")), "shows most-recent ticker entry");
243
+ assert.ok(a.some((l) => l.includes("(+2 more)")), "(+N more) suffix preserved");
244
+ assert.ok(a.some((l) => l.includes("because")), "lastWhy preserved");
245
+ } finally {
246
+ Date.now = realNow;
247
+ }
248
+ });
249
+
250
+ it("footer still flips when the ticker actually changes (new pushTicker)", () => {
251
+ const realNow = Date.now;
252
+ try {
253
+ Date.now = () => 1_000_000;
254
+ const before = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }] }), WIDTH, 0);
255
+ const after = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }, { text: "b", at: 2 }] }), WIDTH, 0);
256
+ assert.notDeepEqual(before, after, "footer changes when ticker grows");
257
+ } finally {
258
+ Date.now = realNow;
259
+ }
260
+ });
261
+ });
262
+
215
263
  describe("buildWidgetLines achievement flare (S35)", () => {
216
264
  const achBase = (overrides: Partial<WidgetData> = {}): WidgetData => baseWd({
217
265
  theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
@@ -388,8 +388,12 @@ export function buildWidgetLines(
388
388
  if (wd.tierTrace && wd.fresh) {
389
389
  lines.push(panelLine(` ${pulse}${wd.tierTrace}`, width, panelBg));
390
390
  } else if (wd.ticker.length > 0) {
391
- const step = Math.floor(Date.now() / 250);
392
- const idx = wd.ticker.length - 1 - (step % wd.ticker.length);
391
+ // P1: pin to the most-recent entry (no 250ms rotation). The old
392
+ // `step = floor(Date.now()/250)` re-picked the head every TUI frame,
393
+ // flipping the footer line on a 250·N ms cycle. Pinning makes the
394
+ // footer hold stable until a real state change (new pushTicker /
395
+ // tierTrace / pulsing transition). (+N more) + lastWhy are preserved.
396
+ const idx = wd.ticker.length - 1;
393
397
  const head = wd.ticker[idx].text;
394
398
  const why = wd.lastWhy ? ` ${C.gray}· ${wd.lastWhy}${C.reset}` : "";
395
399
  const more =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.15",
3
+ "version": "0.8.17",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",
@@ -1,2 +0,0 @@
1
- import{r as i,p as j,j as e,b as N,c as w}from"./index-DMntLu-8.js";const u=[{id:"transparent",label:"Transparent (accents only)",css:{bg:null,fg:"#c9d1d9",accent:"#3fb950",mega:"#f0883e"},ansi:{bg:null,fg:"39",accent:"32",mega:"33"}},{id:"retro",label:"Retro 8-bit green",css:{bg:"#003300",fg:"#33ff33",accent:"#00ff41",mega:"#39ff14"},ansi:{bg:"40",fg:"92",accent:"92",mega:"1;92"}},{id:"orange-bold",label:"Orange bold",css:{bg:"#1a0f00",fg:"#ff8c00",accent:"#ff6b00",mega:"#ff4500"},ansi:{bg:"48;5;52",fg:"38;5;208",accent:"38;5;208",mega:"1;38;5;202"}},{id:"cyan-neon",label:"Cyan neon",css:{bg:"#001a1a",fg:"#00ffff",accent:"#22d3ee",mega:"#7df9ff"},ansi:{bg:"48;5;23",fg:"38;5;51",accent:"38;5;51",mega:"1;38;5;51"}},{id:"amber-mono",label:"Amber phosphor",css:{bg:"#1a1200",fg:"#ffb000",accent:"#ff8c00",mega:"#ff5500"},ansi:{bg:"48;5;58",fg:"38;5;214",accent:"38;5;214",mega:"1;38;5;208"}},{id:"grayscale",label:"Grayscale minimal",css:{bg:"#161616",fg:"#b0b0b0",accent:"#8b949e",mega:"#e6edf3"},ansi:{bg:"48;5;235",fg:"38;5;249",accent:"38;5;249",mega:"1;38;5;255"}}];u.map(a=>a.id);const c={gameMode:"Turn game mode on/off (themes the widget + dashboard)",theme:"Visual theme (applies instantly)",tuiMode:"TUI widget display density",tier:"Live pressure band — climbs low→mega as context fills the window.",preset:"The env-resolved base compaction preset (low/medium/high/ultra/mega) that set the token threshold.",pressure:"Live pressure = currentTokens / threshold — % of the model context window (threshold fires at the tier's % of window).",threshold:"Compaction threshold = tierPct × model context window — mega-compact trims BELOW pi's native ~80% auto-compact for any model size.",fastGate:"Fast-gate arming floor — the live trim arms once context passes this % of the window."};function y(a,d,l){let o=a.toLocaleString();return d!=null&&l>0&&(o+=` (${Math.round(d*100)}% of ${l.toLocaleString()})`),o}function T(a){return`${Math.round((a||0)*100)}%`}function M(){const[a,d]=i.useState(null),[l,o]=i.useState(null),[m,g]=i.useState(!1);i.useEffect(()=>{let s=!0;const t=async()=>{try{const r=await N();s&&d(r)}catch{}};t();const f=setInterval(()=>void t(),5e3);return()=>{s=!1,clearInterval(f)}},[]),i.useEffect(()=>{let s=!0;const t=async()=>{try{const r=await w();s&&(o(r),r.theme&&(document.documentElement.dataset.theme=r.theme))}catch{}};t();const f=setInterval(()=>void t(),5e3);return()=>{s=!1,clearInterval(f)}},[]);const h=i.useCallback(async s=>{g(!0);try{const t=await j(s);o(t),t.theme&&(document.documentElement.dataset.theme=t.theme)}catch{}g(!1)},[]),p=l?.game_mode_on??!1,b=l?.theme??"transparent",x=l?.tui_display_mode??"full",n=a?.config,v=a?.context.contextWindow??0;return e.jsxs("div",{className:"config-tab",children:[e.jsxs("section",{className:"config-settings",children:[e.jsx("h3",{children:"Settings"}),e.jsx("div",{className:"settings-row",children:e.jsxs("label",{title:c.gameMode,children:[e.jsx("input",{type:"checkbox",checked:p,onChange:s=>h({game_mode_on:s.currentTarget.checked}),disabled:m}),"Game Mode"]})}),e.jsx("div",{className:"settings-row",children:e.jsxs("label",{title:c.theme,children:["Theme",e.jsx("select",{value:b,onChange:s=>h({theme:s.currentTarget.value}),disabled:m,children:u.map(s=>e.jsx("option",{value:s.id,children:s.label},s.id))})]})}),e.jsx("div",{className:"settings-row",children:e.jsxs("label",{title:c.tuiMode,children:["TUI Mode",e.jsxs("select",{value:x,onChange:s=>h({tui_display_mode:s.currentTarget.value}),disabled:m,children:[e.jsx("option",{value:"full",children:"Full"}),e.jsx("option",{value:"minimal",children:"Minimal"})]})]})})]}),e.jsxs("section",{className:"config-display",children:[e.jsx("h3",{children:"Configuration"}),e.jsxs("div",{className:"config-grid",children:[e.jsxs("div",{className:"config-item",children:[e.jsx("span",{className:"config-label",title:c.tier,children:"Tier"}),e.jsx("span",{className:"config-value",children:a?`${a.tier} (live)`:"—"})]}),e.jsxs("div",{className:"config-item",children:[e.jsx("span",{className:"config-label",title:c.preset,children:"Preset"}),e.jsx("span",{className:"config-value",children:a?.presetTier??"—"})]}),e.jsxs("div",{className:"config-item",children:[e.jsx("span",{className:"config-label",title:c.pressure,children:"Pressure"}),e.jsx("span",{className:"config-value",children:a?T(a.pressure):"—"})]}),e.jsxs("div",{className:"config-item",children:[e.jsx("span",{className:"config-label",title:c.threshold,children:"Threshold"}),e.jsx("span",{className:"config-value",children:n?y(n.thresholdTokens,n.tierPct,v):"—"})]}),e.jsxs("div",{className:"config-item",children:[e.jsx("span",{className:"config-label",title:c.fastGate,children:"Fast Gate"}),e.jsx("span",{className:"config-value",children:n?`${n.fastGatePct}%`:"—"})]}),e.jsxs("div",{className:"config-item",children:[e.jsx("span",{className:"config-label",children:"Auto"}),e.jsx("span",{className:"config-value",children:n?n.auto?"enabled":"disabled":"—"})]}),e.jsxs("div",{className:"config-item",children:[e.jsx("span",{className:"config-label",children:"Anchor"}),e.jsx("span",{className:"config-value",children:n?.anchorUserMessages??"—"})]})]})]})]})}export{M as default};
2
- //# sourceMappingURL=ConfigTab-C3g3smRL.js.map