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
@@ -0,0 +1,63 @@
1
+ /**
2
+ * dashboard-client/src/hooks/useSSE.ts — Server-Sent Events hook.
3
+ *
4
+ * Connects to /api/events, provides real-time event stream.
5
+ * SPRINT-B1: basic connection. SPRINT-D1: reconnect with backoff.
6
+ */
7
+ import { useState, useEffect, useRef, useCallback } from 'react';
8
+ // SPRINT-D1-REMAINING: exponential backoff (1s → 2s → 4s → max 30s).
9
+ // SPRINT-D1-REMAINING: after 5 failures, set status='disconnected'.
10
+ export function useSSE(options = {}) {
11
+ const { maxEvents = 500, maxReconnects = 5 } = options;
12
+ const [events, setEvents] = useState([]);
13
+ const [status, setStatus] = useState('connecting');
14
+ const [eventCount, setEventCount] = useState(0);
15
+ const [lastEventAt, setLastEventAt] = useState(null);
16
+ const reconnectCount = useRef(0);
17
+ const sourceRef = useRef(null);
18
+ const connect = useCallback(() => {
19
+ if (sourceRef.current) {
20
+ sourceRef.current.close();
21
+ }
22
+ setStatus('connecting');
23
+ const source = new EventSource('/api/events');
24
+ sourceRef.current = source;
25
+ source.onopen = () => {
26
+ setStatus('connected');
27
+ reconnectCount.current = 0;
28
+ };
29
+ source.onmessage = (event) => {
30
+ try {
31
+ const parsed = JSON.parse(event.data);
32
+ setEvents(prev => {
33
+ const next = [...prev, parsed];
34
+ return next.length > maxEvents ? next.slice(-maxEvents) : next;
35
+ });
36
+ setEventCount(c => c + 1);
37
+ setLastEventAt(Date.now());
38
+ }
39
+ catch {
40
+ // non-fatal: skip malformed events
41
+ }
42
+ };
43
+ source.onerror = () => {
44
+ source.close();
45
+ if (reconnectCount.current < maxReconnects) {
46
+ reconnectCount.current += 1;
47
+ setStatus('connecting');
48
+ // SPRINT-D1: use exponential backoff instead of fixed delay
49
+ setTimeout(connect, 1000);
50
+ }
51
+ else {
52
+ setStatus('disconnected');
53
+ }
54
+ };
55
+ }, [maxEvents, maxReconnects]);
56
+ useEffect(() => {
57
+ connect();
58
+ return () => {
59
+ sourceRef.current?.close();
60
+ };
61
+ }, [connect]);
62
+ return { events, status, eventCount, lastEventAt };
63
+ }
@@ -176,8 +176,8 @@ export function dashboardHtml(tierName) {
176
176
 
177
177
  <div class="settings-strip">
178
178
  <label title="Turn game mode on/off (themes the widget + dashboard)"><input type="checkbox" id="set-game-mode"> Game mode</label>
179
- <label title="Visual theme (applies instantly)">Theme <select id="set-theme">${THEME_OPTIONS}</select></label>
180
- <label title="TUI widget display density">TUI <select id="set-tui-mode"><option value="full">Full</option><option value="minimal">Minimal</option></select></label>
179
+ <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>
180
+ <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>
181
181
  </div>
182
182
 
183
183
  <nav class="tabs">
@@ -869,6 +869,19 @@ export function dashboardHtml(tierName) {
869
869
  }
870
870
  if (gmCheckbox) gmCheckbox.addEventListener('change', function() { putGameState({ game_mode_on: gmCheckbox.checked }); });
871
871
  if (gmTheme) gmTheme.addEventListener('change', function() { putGameState({ theme: gmTheme.value }); });
872
+ // P3: client-side theme cycling (no new endpoint). Reads the already-fetched
873
+ // <option> values from the theme select, so it stays in sync with the server's
874
+ // THEMES list without a second fetch.
875
+ var gmThemeNext = document.getElementById('set-theme-next');
876
+ if (gmThemeNext) gmThemeNext.addEventListener('click', function() {
877
+ var opts = gmTheme ? gmTheme.options : [];
878
+ var n = opts.length;
879
+ var cur = gmTheme ? gmTheme.value : 'transparent';
880
+ var idx = -1;
881
+ for (var i = 0; i < n; i++) { if (opts[i].value === cur) { idx = i; break; } }
882
+ var next = n > 0 ? opts[(idx < 0 ? 0 : (idx + 1) % n)].value : 'transparent';
883
+ putGameState({ theme: next });
884
+ });
872
885
  if (gmTui) gmTui.addEventListener('change', function() { putGameState({ tui_display_mode: gmTui.value }); });
873
886
  pollGameState();
874
887
  setInterval(pollGameState, 5000);
@@ -179,6 +179,39 @@ test("S38: classifyError returns 'compaction-noop' for 'Auto compaction failed'
179
179
  assert.equal(classifyErrorFn("Auto compaction failed"), "compaction-noop");
180
180
  assert.equal(classifyErrorFn("Auto-compaction failed"), "compaction-noop");
181
181
  });
182
+ // ---- context-overflow classifier (S38.8: 400 "too long... even after compaction") ----
183
+ test("S38: classifyError returns 'context-overflow' for the literal user 400 string", () => {
184
+ assert.equal(classifyErrorFn("Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction."), "context-overflow");
185
+ });
186
+ test("S38: classifyError returns 'context-overflow' for 'All targets failed' wrapper", () => {
187
+ assert.equal(classifyErrorFn("All targets failed: ... Last error: ... too long ... context window even after compaction"), "context-overflow");
188
+ });
189
+ test("S38: classifyError returns 'context-overflow' for invalid_request_error JSON shape", () => {
190
+ assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"... too long for context window ..."}'), "context-overflow");
191
+ });
192
+ test("S38: classifyError returns 'context-overflow' for OpenRouter 'All targets failed' max-context 400 (FAIL-20260725)", () => {
193
+ // Regression: the OpenAI/OpenRouter provider-side phrasing
194
+ // "maximum context length is N tokens ... requires at least M tokens ...
195
+ // reduce your input or max_tokens" does NOT contain "too long" / "context
196
+ // window" / "reduce the conversation", so it slipped past the original S38.8
197
+ // regex and fell through to the generic transient branch, firing 5 blind
198
+ // retry nudges that re-submitted the same oversized prompt -> re-400 ->
199
+ // busy-loop. The exact user-facing wrapper string from the router:
200
+ assert.equal(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"}'), "context-overflow");
201
+ });
202
+ test("S38: classifyError returns 'context-overflow' for bare 'maximum context length' phrasing", () => {
203
+ assert.equal(classifyErrorFn("This model's maximum context length is 128000 tokens. Your request requires at least 130000 tokens. Please reduce your input or max_tokens."), "context-overflow");
204
+ });
205
+ test("S38: classifyError returns 'context-overflow' for 'context length exceeded' phrasing", () => {
206
+ assert.equal(classifyErrorFn("This model's context length exceeded: 200000 tokens"), "context-overflow");
207
+ });
208
+ test("S38: context-overflow fires NO blind retry nudge and logs 'context_overflow'", async () => {
209
+ const h = harness();
210
+ await s38TurnEnd(h, "error", "Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction.");
211
+ const ev = eventTypes(h.stateDir);
212
+ assert.equal(h.sendUserMessages.length, 0, "context-overflow: NO blind retry nudge fired");
213
+ assert.ok(ev.includes("context_overflow"), "context-overflow: 'context_overflow' event logged");
214
+ });
182
215
  // ---- integration tests (fire turn_end through the real extension) ----
183
216
  test("S38: compaction-noop logs 'compaction_noop_diagnostic' + resets counter + no retry fired", async () => {
184
217
  const h = harness();
@@ -5,6 +5,7 @@ import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achie
5
5
  import { isMegaCache } from "../../src/game/scoring.js";
6
6
  import { resolveRepoRoot } from "../mega-config.js";
7
7
  import { classifyError } from "./error-classifier.js";
8
+ import { safeSendUserMessage } from "./send-safe.js";
8
9
  /** Register agent/turn tracking event handlers. */
9
10
  export function registerAgentHandlers(pi, runtime, config) {
10
11
  // ---- Agent tracking for real-time widget + status-line updates ---------
@@ -188,7 +189,7 @@ export function registerAgentHandlers(pi, runtime, config) {
188
189
  const nudgeMsg = lengthStop && !didDurableTrim
189
190
  ? "[mega-compact] the last response hit the output-token cap; continue from where it stopped."
190
191
  : "[mega-compact] continue from the compacted context above.";
191
- pi.sendUserMessage(nudgeMsg);
192
+ await safeSendUserMessage(pi, nudgeMsg);
192
193
  }
193
194
  }
194
195
  catch {
@@ -312,6 +313,53 @@ export function registerAgentHandlers(pi, runtime, config) {
312
313
  turnIndex: event.turnIndex,
313
314
  });
314
315
  }
316
+ else if (category === 'context-overflow') {
317
+ // (4b) context-window overflow 400 ("too long... even after compaction").
318
+ // NOT a blind retry: re-submitting the same oversized prompt would just
319
+ // re-400 and busy-loop. Reset the counters (this turn is terminal, not
320
+ // retryable in the S38 sense) and force ONE best-effort re-compact with
321
+ // the debounce bypassed + the same race-guard (lastNativeCompactAt
322
+ // cooldown + deferred setTimeout re-check) as the agent_end durable
323
+ // trim. Resume after that forced compact is handled by the existing
324
+ // nudgeResume() inside session_before_compact (it fires after
325
+ // driveNativeCompaction supplies the compaction) — do NOT add a
326
+ // separate nudge here.
327
+ runtime.rt.errorRetryCount = 0;
328
+ runtime.rt.consecutiveErrors = 0;
329
+ runtime.dashboard.event('context_overflow', {
330
+ turnIndex: event.turnIndex,
331
+ sessionId: runtime.rt.sessionId,
332
+ });
333
+ runtime.logger.warn('context-overflow', {
334
+ sessionId: runtime.rt.sessionId,
335
+ turnIndex: event.turnIndex,
336
+ });
337
+ if (config.auto) {
338
+ const now2 = Date.now();
339
+ const cooldownMs2 = config.raceGuardStrict ? 30_000 : 10_000;
340
+ const sinceCompact2 = now2 - (runtime.rt.lastNativeCompactAt ?? 0);
341
+ if (sinceCompact2 >= cooldownMs2 && !piCompactWouldNoop(ctx)) {
342
+ runtime.debounceUntil = now2 + 0;
343
+ const stamp2 = runtime.rt.lastNativeCompactAt;
344
+ const liveSid2 = runtime.rt.sessionId;
345
+ setTimeout(() => {
346
+ try {
347
+ if (runtime.rt.sessionId !== liveSid2)
348
+ return; // session reset
349
+ const since3 = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
350
+ if (runtime.rt.lastNativeCompactAt !== stamp2 && since3 < cooldownMs2)
351
+ return;
352
+ if (piCompactWouldNoop(ctx))
353
+ return;
354
+ 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.
355
+ }
356
+ catch {
357
+ /* non-fatal */
358
+ }
359
+ }, 500);
360
+ }
361
+ }
362
+ }
315
363
  else {
316
364
  // (5) transient or permanent — retry with exponential backoff.
317
365
  // S38.7: hard-stop switch — bypass ALL retry logic when set.
@@ -388,8 +436,8 @@ export function registerAgentHandlers(pi, runtime, config) {
388
436
  count: runtime.rt.errorRetryCount,
389
437
  max,
390
438
  });
391
- // PREVENT-PI-003: user-role sendUserMessage only.
392
- pi.sendUserMessage('[mega-compact] the last turn ended with an error; please retry.');
439
+ // PREVENT-PI-003: user-role sendUserMessage only (queued + catch-guarded).
440
+ await safeSendUserMessage(pi, '[mega-compact] the last turn ended with an error; please retry.');
393
441
  }
394
442
  }
395
443
  }
@@ -3,6 +3,7 @@ import { estimateBlockTokens } from "../../src/tokens.js";
3
3
  import { recordScore, getDedupStats } from "../../src/store/sqlite.js";
4
4
  import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
5
5
  import { resolveRepoRoot } from "../mega-config.js";
6
+ import { safeSendUserMessage } from "./send-safe.js";
6
7
  /**
7
8
  * Build a minimal fallback compaction so pi never runs its throwing compact().
8
9
  *
@@ -34,13 +35,18 @@ function fallbackCompaction(event) {
34
35
  /**
35
36
  * Debounced resume-nudge: restart the agent loop after a compaction (which
36
37
  * may have stopped it). Idempotent — one nudge per 30s, never blocks.
38
+ *
39
+ * Uses safeSendUserMessage ({ deliverAs: 'followUp' } + catch-guard) so that a
40
+ * nudge fired during session_before_compact (which is mid-prompt-submission,
41
+ * so the agent can be busy) QUEUES instead of throwing
42
+ * "Agent is already processing. Specify streamingBehavior (steer or followUp)".
37
43
  */
38
- function nudgeResume(pi, runtime) {
44
+ async function nudgeResume(pi, runtime) {
39
45
  try {
40
46
  const now = Date.now();
41
47
  if (now >= runtime.resumeNudgeUntil) {
42
48
  runtime.resumeNudgeUntil = now + 30_000;
43
- pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
49
+ await safeSendUserMessage(pi, "[mega-compact] continue from the compacted context above.");
44
50
  }
45
51
  }
46
52
  catch {
@@ -83,7 +89,7 @@ export function registerCompactHandlers(pi, runtime, config) {
83
89
  tokensBefore: result.compaction.tokensBefore,
84
90
  summaryTokens: result.compaction.estimatedTokensAfter,
85
91
  });
86
- nudgeResume(pi, runtime);
92
+ await nudgeResume(pi, runtime);
87
93
  return { compaction: result.compaction };
88
94
  }
89
95
  // FIX "compacts but doesn't resume" + "Nothing to compact" regression:
@@ -104,7 +110,7 @@ export function registerCompactHandlers(pi, runtime, config) {
104
110
  tokensBefore: fb.compaction.tokensBefore,
105
111
  reason: event.reason,
106
112
  });
107
- nudgeResume(pi, runtime);
113
+ await nudgeResume(pi, runtime);
108
114
  return { compaction: fb.compaction };
109
115
  }
110
116
  }
@@ -11,7 +11,7 @@
11
11
  * exclusively (its agent_end nudge path is separate and must not be doubled).
12
12
  *
13
13
  * @param message the event.message (a pi AgentMessage) or an error string
14
- * @returns 'transient' | 'permanent' | 'compaction-noop' | null (success/unknown)
14
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | null (success/unknown)
15
15
  */
16
16
  export function classifyError(message) {
17
17
  // Resolve a searchable text blob from a pi AgentMessage or raw string.
@@ -84,6 +84,29 @@ export function classifyError(message) {
84
84
  return 'compaction-noop';
85
85
  if (/auto[\s-]?compaction failed/.test(s))
86
86
  return 'compaction-noop';
87
+ // --- context-overflow (ORDER BEFORE generic transient!) ---
88
+ // A 400 from the model meaning "the prompt is bigger than the context window."
89
+ // Catches BOTH provider phrasings so the classification does not depend on
90
+ // which backend the router landed on:
91
+ // - pi/Anthropic wrapper: "too long for this model's context window even
92
+ // after compaction. Reduce the conversation length..." ->
93
+ // too long | context window | even after compaction | reduce the conversation
94
+ // - OpenAI/OpenRouter provider-side (often wrapped in "All targets failed:
95
+ // <model>. Last error: ..."): "This model's maximum context length is N
96
+ // tokens. Your request requires at least M tokens. Please reduce your
97
+ // input or max_tokens." -> maximum context length | context length
98
+ // exceeded | requires at least N tokens | reduce your input
99
+ // All of these carry `invalid_request_error` (UNDERSCORE, not 'invalid
100
+ // request' space) and would otherwise fall through to the generic
101
+ // `s.includes('error')` transient branch below, misclassifying as
102
+ // 'transient' and firing up to 5 blind retry nudges that re-submit the same
103
+ // oversized prompt -> re-400 -> busy-loop. 'context-overflow' instead forces
104
+ // ONE deferred re-compact (debounce-bypassed, race-guarded) and fires NO
105
+ // blind retry nudge. The forced re-compact is shaped by the existing
106
+ // session_before_compact durable trim (it cannot lower pi's firstKeptEntryId).
107
+ 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)) {
108
+ return 'context-overflow';
109
+ }
87
110
  // --- transient (retryable) ---
88
111
  if (s.includes('error') && !/\b(permanent|invalid request|malformed|bad request|auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
89
112
  return 'transient'; // generic pi stopReason 'error' / 'aborted'
@@ -0,0 +1,12 @@
1
+ /**
2
+ * safeSendUserMessage — await + catch-guard + queue-safe wrapper for
3
+ * pi.sendUserMessage. Never throws; never produces an unhandled rejection.
4
+ */
5
+ export async function safeSendUserMessage(pi, content) {
6
+ try {
7
+ await pi.sendUserMessage(content, { deliverAs: "followUp" });
8
+ }
9
+ catch {
10
+ /* non-fatal: a failed/queued nudge never blocks the agent loop */
11
+ }
12
+ }
@@ -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";
@@ -167,6 +167,11 @@ export class MegaRuntime {
167
167
  // getCachedGameState() snapshot re-queries the DB anyway.
168
168
  gameStateWatcher;
169
169
  gameStateWatchDir;
170
+ // P2: the last ExtensionContext handed to snapshot()/renderWidget(), stashed
171
+ // so the fs.watch game-state callback can force a widget re-render without
172
+ // a context event (cross-process dashboard edits while pi is idle). Cleared
173
+ // implicitly on construction (undefined → watcher skips until first snap).
174
+ lastWidgetCtx;
170
175
  /**
171
176
  * DIAG counters for the "team run doesn't relieve context" investigation.
172
177
  * Plain integers, incremented at the three compaction decision points. They
@@ -321,6 +326,8 @@ export class MegaRuntime {
321
326
  // ---- dashboard snapshot + widget ------------------------------------------
322
327
  /** Collect live state and write it to disk (+ paint the above-editor widget). */
323
328
  snapshot(ctx) {
329
+ if (ctx)
330
+ this.lastWidgetCtx = ctx;
324
331
  if (ctx)
325
332
  this.bindRepo(ctx.cwd);
326
333
  // v0.8.5: gate the expensive body (6 sync SQLite opens +
@@ -908,6 +915,30 @@ export class MegaRuntime {
908
915
  if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
909
916
  this.cachedGameState = undefined;
910
917
  this.gameStateBump++;
918
+ // P2: force a widget re-render so a dashboard-made theme/toggle/
919
+ // tui-mode change reflects in the live TUI immediately, even when
920
+ // pi is idle (no context event to drive snapshot()). Use the
921
+ // LIGHTWEIGHT refreshWidgetGameState() — NOT the full snapshot():
922
+ // snapshot() recomputes 6 sync SQLite opens + writes dashboard.json
923
+ // + writes to the store, and those store writes RETRIGGER this
924
+ // same fs.watch callback (it fires on every sqlite.db* write) →
925
+ // re-entrant thrash → 190s test timeout under mega-compact.test.js
926
+ // / mega-teamrun.test.js. The lightweight path re-reads ONLY the
927
+ // game_state row and patches the three game-mode fields on the
928
+ // existing widgetData, then re-registers the factory via
929
+ // renderWidget() — it writes nothing to the store or
930
+ // dashboard.json, so it cannot retrigger itself. Guard: skip until
931
+ // the first snapshot stashed a ctx (no widget registered yet →
932
+ // nothing to refresh). Non-fatal: next context event re-snapshots.
933
+ const ctx = this.lastWidgetCtx;
934
+ if (ctx) {
935
+ try {
936
+ this.refreshWidgetGameState(ctx);
937
+ }
938
+ catch {
939
+ /* non-fatal */
940
+ }
941
+ }
911
942
  }
912
943
  });
913
944
  this.gameStateWatchDir = this.currentStateDir;
@@ -987,6 +1018,33 @@ export class MegaRuntime {
987
1018
  }
988
1019
  return this.cachedGameState;
989
1020
  }
1021
+ /** P2 cross-process re-render: lightweight game-state refresh for the
1022
+ * fs.watch callback. Eviction of cachedGameState + gameStateBump++ happens
1023
+ * in the caller BEFORE this runs. Here we re-read ONLY the game_state row
1024
+ * via getCachedGameState() (one SELECT; the cache is already evicted) and
1025
+ * patch ONLY the three game-mode fields on the EXISTING widgetData, then
1026
+ * re-register the widget factory via renderWidget() so pi redraws next
1027
+ * frame.
1028
+ *
1029
+ * WHY a lightweight path: the full snapshot(ctx) recomputes 6 synchronous
1030
+ * SQLite opens + writeFileSync(dashboard.json) + store writes, and those
1031
+ * store writes RETRIGGER this same fs.watch callback → re-entrant thrash
1032
+ * (the watcher fires on every sqlite.db* write, including context-event
1033
+ * checkpoint writes) → 190s test timeout under mega-compact.test.js /
1034
+ * mega-teamrun.test.js. This path writes NOTHING to the store or
1035
+ * dashboard.json, so it cannot retrigger itself.
1036
+ *
1037
+ * Guard: no-op when widgetData is null (no snapshot has run yet → nothing
1038
+ * to patch) or ctx is undefined. Field values mirror snapshot() exactly. */
1039
+ refreshWidgetGameState(ctx) {
1040
+ if (!this.widgetData || !ctx)
1041
+ return;
1042
+ const gs = this.getCachedGameState();
1043
+ this.widgetData.gameMode = gs.game_mode_on;
1044
+ this.widgetData.theme = getTheme(gs.theme) ? gs.theme : "transparent";
1045
+ this.widgetData.tuiMode = gs.tui_display_mode;
1046
+ this.renderWidget(ctx);
1047
+ }
990
1048
  /** S31: evict the cached game-mode state so the next widget render re-reads
991
1049
  * the game_state row. Called by /mega-game after every setGameState() so
992
1050
  * the panel picks up theme/mode/toggle changes live. */
@@ -1060,6 +1118,14 @@ export class MegaRuntime {
1060
1118
  }
1061
1119
  // Phase 3 — recall/activity ticker ring buffer.
1062
1120
  pushTicker(text) {
1121
+ // P1: dedupe consecutive identical entries — skip the append when the
1122
+ // last entry's text matches, so a re-fired compact/recall/dedup event
1123
+ // doesn't flood the ring (keeps it at TICKER_MAX for real variety).
1124
+ // `at` is NOT refreshed on a skip (the original event time stands).
1125
+ if (this.ticker[this.ticker.length - 1]?.text === text) {
1126
+ this.lastActivityAt = Date.now();
1127
+ return;
1128
+ }
1063
1129
  this.ticker.push({ text, at: Date.now() });
1064
1130
  while (this.ticker.length > this.TICKER_MAX)
1065
1131
  this.ticker.shift();
@@ -110,6 +110,43 @@ describe("MegaRuntime setEffect lifecycle (v0.8.3)", () => {
110
110
  assert.equal(rt.activeEffect, null, "cleared once expired");
111
111
  });
112
112
  });
113
+ describe("MegaRuntime pushTicker dedupe (P1)", () => {
114
+ const dirs = [];
115
+ after(() => {
116
+ for (const d of dirs) {
117
+ try {
118
+ closeStore(d);
119
+ }
120
+ catch { /* */ }
121
+ }
122
+ delete process.env.MEGACOMPACT_STATE_DIR;
123
+ for (const d of dirs)
124
+ rmSync(d, { recursive: true, force: true });
125
+ });
126
+ it("pushTicker skips consecutive identical entries (keeps ring at capacity for variety)", () => {
127
+ const dir = freshDir();
128
+ dirs.push(dir);
129
+ process.env.MEGACOMPACT_STATE_DIR = dir;
130
+ const rt = new MegaRuntime(minimalConfig(dir));
131
+ rt.pushTicker("compact");
132
+ rt.pushTicker("compact"); // dedup — skipped
133
+ rt.pushTicker("compact"); // dedup — skipped
134
+ rt.pushTicker("recall");
135
+ assert.equal(rt.ticker.length, 2, "only 2 unique entries kept");
136
+ assert.equal(rt.ticker[0].text, "compact");
137
+ assert.equal(rt.ticker[1].text, "recall");
138
+ });
139
+ it("pushTicker keeps distinct entries and respects TICKER_MAX", () => {
140
+ const dir = freshDir();
141
+ dirs.push(dir);
142
+ process.env.MEGACOMPACT_STATE_DIR = dir;
143
+ const rt = new MegaRuntime(minimalConfig(dir));
144
+ for (let i = 0; i < rt.TICKER_MAX + 3; i++)
145
+ rt.pushTicker(`t${i}`);
146
+ assert.equal(rt.ticker.length, rt.TICKER_MAX, "capped at TICKER_MAX");
147
+ assert.equal(rt.ticker[rt.TICKER_MAX - 1].text, `t${rt.TICKER_MAX + 2}`);
148
+ });
149
+ });
113
150
  describe("MegaRuntime game-state cache (S31)", () => {
114
151
  const dirs = [];
115
152
  after(() => {
@@ -292,8 +292,12 @@ export function buildWidgetLines(wd, width, activeAgents) {
292
292
  lines.push(panelLine(` ${pulse}${wd.tierTrace}`, width, panelBg));
293
293
  }
294
294
  else if (wd.ticker.length > 0) {
295
- const step = Math.floor(Date.now() / 250);
296
- const idx = wd.ticker.length - 1 - (step % wd.ticker.length);
295
+ // P1: pin to the most-recent entry (no 250ms rotation). The old
296
+ // `step = floor(Date.now()/250)` re-picked the head every TUI frame,
297
+ // flipping the footer line on a 250·N ms cycle. Pinning makes the
298
+ // footer hold stable until a real state change (new pushTicker /
299
+ // tierTrace / pulsing transition). (+N more) + lastWhy are preserved.
300
+ const idx = wd.ticker.length - 1;
297
301
  const head = wd.ticker[idx].text;
298
302
  const why = wd.lastWhy ? ` ${C.gray}· ${wd.lastWhy}${C.reset}` : "";
299
303
  const more = wd.ticker.length > 1
@@ -181,6 +181,53 @@ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
181
181
  assert.ok(borders.some((b) => b.includes("\x1b[38;5;203m")), `flash-on phase uses red base 203`);
182
182
  });
183
183
  });
184
+ describe("buildWidgetLines footer stability (P1 — no 250ms rotation)", () => {
185
+ // P1: the L5 ticker branch used to re-pick the head text every 250ms via
186
+ // `step = floor(Date.now()/250)`, flipping the footer line on a 250·N ms
187
+ // cycle. After the fix it pins to the most-recent entry, so two renders of
188
+ // the SAME WidgetData at t and t+500ms (well past one rotation slot) must
189
+ // produce byte-identical footer lines. Proves the metronome is gone.
190
+ const stabBase = (overrides = {}) => baseWd({
191
+ theme: DEFAULT_THEME, tuiMode: "full", gameMode: false, level: 1, cachePct: 42, ...overrides,
192
+ });
193
+ it("ticker footer is byte-identical across simulated frames 500ms apart", () => {
194
+ const wd = stabBase({
195
+ ticker: [
196
+ { text: "first", at: 1000 },
197
+ { text: "second", at: 2000 },
198
+ { text: "third", at: 3000 },
199
+ ],
200
+ lastWhy: "because",
201
+ });
202
+ const realNow = Date.now;
203
+ try {
204
+ Date.now = () => 1_000_000; // t
205
+ const a = buildWidgetLines(wd, WIDTH, 0);
206
+ Date.now = () => 1_000_500; // t+500ms (would have rotated twice under the old code)
207
+ const b = buildWidgetLines(wd, WIDTH, 0);
208
+ assert.deepEqual(a, b, "footer byte-identical across 500ms with no state change");
209
+ // And the pinned head is the most-recent entry (third), not a rotation:
210
+ assert.ok(a.some((l) => l.includes("third")), "shows most-recent ticker entry");
211
+ assert.ok(a.some((l) => l.includes("(+2 more)")), "(+N more) suffix preserved");
212
+ assert.ok(a.some((l) => l.includes("because")), "lastWhy preserved");
213
+ }
214
+ finally {
215
+ Date.now = realNow;
216
+ }
217
+ });
218
+ it("footer still flips when the ticker actually changes (new pushTicker)", () => {
219
+ const realNow = Date.now;
220
+ try {
221
+ Date.now = () => 1_000_000;
222
+ const before = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }] }), WIDTH, 0);
223
+ const after = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }, { text: "b", at: 2 }] }), WIDTH, 0);
224
+ assert.notDeepEqual(before, after, "footer changes when ticker grows");
225
+ }
226
+ finally {
227
+ Date.now = realNow;
228
+ }
229
+ });
230
+ });
184
231
  describe("buildWidgetLines achievement flare (S35)", () => {
185
232
  const achBase = (overrides = {}) => baseWd({
186
233
  theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
@@ -1,2 +1,2 @@
1
- import{r as o,j as n}from"./index-DMntLu-8.js";function x(s){try{return new Date(s*1e3).toLocaleString()}catch{return"—"}}function f({achievements:s}){const i=o.useRef(0),[r,a]=o.useState(null),l=s.reduce((e,t)=>Math.max(e,t.unlocked_at??0),0),c=i.current;o.useEffect(()=>{l>i.current&&(i.current=l)},[l]),o.useEffect(()=>{if(c>0&&l>c){const e=s.filter(t=>t.unlocked_at!=null&&(t.unlocked_at??0)>c);if(e.length>0){const t=e.map(d=>`${d.icon??""} ${d.title}`.trim()).join(", ")+" unlocked!";a(t);const h=setTimeout(()=>a(null),4e3);return()=>clearTimeout(h)}}},[s,l,c]);const u=s.filter(e=>!(e.hidden===1&&e.unlocked_at==null));return n.jsxs("div",{className:"ach-section",children:[r!=null&&n.jsx("div",{className:"ach-toast show",children:r}),n.jsxs("div",{className:"ach-tiles",children:[u.length===0&&n.jsx("span",{className:"repo-none",children:"no achievements yet"}),u.map(e=>{if(e.unlocked_at!=null){const t=c>0&&(e.unlocked_at??0)>c;return n.jsxs("div",{className:`ach-tile unlocked${t?" just-unlocked":""}`,children:[e.icon??""," ",e.title,n.jsxs("span",{className:"ach-detail",children:["unlocked ",x(e.unlocked_at)]})]},e.id)}return n.jsxs("div",{className:"ach-tile locked",children:["??? ",e.title]},e.id)})]})]})}export{f as A};
2
- //# sourceMappingURL=AchievementTiles-BH26e95w.js.map
1
+ import{r as o,j as n}from"./index-B2JJExE9.js";function x(s){try{return new Date(s*1e3).toLocaleString()}catch{return"—"}}function f({achievements:s}){const i=o.useRef(0),[r,a]=o.useState(null),l=s.reduce((e,t)=>Math.max(e,t.unlocked_at??0),0),c=i.current;o.useEffect(()=>{l>i.current&&(i.current=l)},[l]),o.useEffect(()=>{if(c>0&&l>c){const e=s.filter(t=>t.unlocked_at!=null&&(t.unlocked_at??0)>c);if(e.length>0){const t=e.map(d=>`${d.icon??""} ${d.title}`.trim()).join(", ")+" unlocked!";a(t);const h=setTimeout(()=>a(null),4e3);return()=>clearTimeout(h)}}},[s,l,c]);const u=s.filter(e=>!(e.hidden===1&&e.unlocked_at==null));return n.jsxs("div",{className:"ach-section",children:[r!=null&&n.jsx("div",{className:"ach-toast show",children:r}),n.jsxs("div",{className:"ach-tiles",children:[u.length===0&&n.jsx("span",{className:"repo-none",children:"no achievements yet"}),u.map(e=>{if(e.unlocked_at!=null){const t=c>0&&(e.unlocked_at??0)>c;return n.jsxs("div",{className:`ach-tile unlocked${t?" just-unlocked":""}`,children:[e.icon??""," ",e.title,n.jsxs("span",{className:"ach-detail",children:["unlocked ",x(e.unlocked_at)]})]},e.id)}return n.jsxs("div",{className:"ach-tile locked",children:["??? ",e.title]},e.id)})]})]})}export{f as A};
2
+ //# sourceMappingURL=AchievementTiles-BozP_h3c.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AchievementTiles-BH26e95w.js","sources":["../../src/components/AchievementTiles.tsx"],"sourcesContent":["/**\n * dashboard-client/src/components/AchievementTiles.tsx — shared achievement grid.\n *\n * Renders a grid of achievement tiles from fetchAchievements() data.\n * Tile states: unlocked (icon+title+date), visible-locked (??? teaser),\n * hidden+locked (not rendered). Just-unlocked tiles get a pulse animation.\n * Toast notification fires for newly-unlocked achievements.\n *\n * Used by both GameTab (sub-section) and AchievementsTab (standalone).\n */\n\nimport type React from \"react\";\nimport { useRef, useState, useEffect } from \"react\";\nimport type { AchievementRow } from \"@contracts\";\n\nexport interface AchievementTilesProps {\n\t/** Achievement rows from /api/achievements. */\n\tachievements: AchievementRow[];\n}\n\n/** Format unlock timestamp (seconds → locale string). */\nfunction fmtUnlockDate(ts: number): string {\n\ttry {\n\t\treturn new Date(ts * 1000).toLocaleString();\n\t} catch {\n\t\treturn \"\\u2014\";\n\t}\n}\n\nexport function AchievementTiles({\n\tachievements,\n}: AchievementTilesProps): React.ReactElement {\n\tconst lastMaxTsRef = useRef(0);\n\tconst [toastText, setToastText] = useState<string | null>(null);\n\n\tconst maxTs = achievements.reduce(\n\t\t(mx, a) => Math.max(mx, a.unlocked_at ?? 0),\n\t\t0,\n\t);\n\tconst prevMaxTs = lastMaxTsRef.current;\n\n\t/* Update ref after render so next poll sees previous baseline. */\n\tuseEffect(() => {\n\t\tif (maxTs > lastMaxTsRef.current) {\n\t\t\tlastMaxTsRef.current = maxTs;\n\t\t}\n\t}, [maxTs]);\n\n\t/* Toast for newly-unlocked achievements. */\n\tuseEffect(() => {\n\t\tif (prevMaxTs > 0 && maxTs > prevMaxTs) {\n\t\t\tconst newly = achievements.filter(\n\t\t\t\t(a) => a.unlocked_at != null && (a.unlocked_at ?? 0) > prevMaxTs,\n\t\t\t);\n\t\t\tif (newly.length > 0) {\n\t\t\t\tconst text =\n\t\t\t\t\tnewly\n\t\t\t\t\t\t.map((a) => `${a.icon ?? \"\"} ${a.title}`.trim())\n\t\t\t\t\t\t.join(\", \") + \" unlocked!\";\n\t\t\t\tsetToastText(text);\n\t\t\t\tconst t = setTimeout(() => setToastText(null), 4000);\n\t\t\t\treturn (): void => clearTimeout(t);\n\t\t\t}\n\t\t}\n\t}, [achievements, maxTs, prevMaxTs]);\n\n\t/* Visible tiles: skip hidden+locked (hidden === 1 && unlocked_at == null). */\n\tconst tiles = achievements.filter(\n\t\t(a) => !(a.hidden === 1 && a.unlocked_at == null),\n\t);\n\n\treturn (\n\t\t<div className=\"ach-section\">\n\t\t\t{toastText != null && (\n\t\t\t\t<div className=\"ach-toast show\">{toastText}</div>\n\t\t\t)}\n\t\t\t<div className=\"ach-tiles\">\n\t\t\t\t{tiles.length === 0 && (\n\t\t\t\t\t<span className=\"repo-none\">no achievements yet</span>\n\t\t\t\t)}\n\t\t\t\t{tiles.map((a) => {\n\t\t\t\t\tif (a.unlocked_at != null) {\n\t\t\t\t\t\tconst isNew = prevMaxTs > 0 && (a.unlocked_at ?? 0) > prevMaxTs;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={a.id}\n\t\t\t\t\t\t\t\tclassName={`ach-tile unlocked${isNew ? \" just-unlocked\" : \"\"}`}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{a.icon ?? \"\"} {a.title}\n\t\t\t\t\t\t\t\t<span className=\"ach-detail\">\n\t\t\t\t\t\t\t\t\tunlocked {fmtUnlockDate(a.unlocked_at)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={a.id} className=\"ach-tile locked\">\n\t\t\t\t\t\t\t??? {a.title}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"],"names":["fmtUnlockDate","ts","AchievementTiles","achievements","lastMaxTsRef","useRef","toastText","setToastText","useState","maxTs","mx","a","prevMaxTs","useEffect","newly","text","t","tiles","jsxs","jsx","isNew"],"mappings":"+CAqBA,SAASA,EAAcC,EAAoB,CAC1C,GAAI,CACH,OAAO,IAAI,KAAKA,EAAK,GAAI,EAAE,eAAA,CAC5B,MAAQ,CACP,MAAO,GACR,CACD,CAEO,SAASC,EAAiB,CAChC,aAAAC,CACD,EAA8C,CAC7C,MAAMC,EAAeC,EAAAA,OAAO,CAAC,EACvB,CAACC,EAAWC,CAAY,EAAIC,EAAAA,SAAwB,IAAI,EAExDC,EAAQN,EAAa,OAC1B,CAACO,EAAIC,IAAM,KAAK,IAAID,EAAIC,EAAE,aAAe,CAAC,EAC1C,CAAA,EAEKC,EAAYR,EAAa,QAG/BS,EAAAA,UAAU,IAAM,CACXJ,EAAQL,EAAa,UACxBA,EAAa,QAAUK,EAEzB,EAAG,CAACA,CAAK,CAAC,EAGVI,EAAAA,UAAU,IAAM,CACf,GAAID,EAAY,GAAKH,EAAQG,EAAW,CACvC,MAAME,EAAQX,EAAa,OACzBQ,GAAMA,EAAE,aAAe,OAASA,EAAE,aAAe,GAAKC,CAAA,EAExD,GAAIE,EAAM,OAAS,EAAG,CACrB,MAAMC,EACLD,EACE,IAAKH,GAAM,GAAGA,EAAE,MAAQ,EAAE,IAAIA,EAAE,KAAK,GAAG,KAAA,CAAM,EAC9C,KAAK,IAAI,EAAI,aAChBJ,EAAaQ,CAAI,EACjB,MAAMC,EAAI,WAAW,IAAMT,EAAa,IAAI,EAAG,GAAI,EACnD,MAAO,IAAY,aAAaS,CAAC,CAClC,CACD,CACD,EAAG,CAACb,EAAcM,EAAOG,CAAS,CAAC,EAGnC,MAAMK,EAAQd,EAAa,OACzBQ,GAAM,EAAEA,EAAE,SAAW,GAAKA,EAAE,aAAe,KAAA,EAG7C,OACCO,EAAAA,KAAC,MAAA,CAAI,UAAU,cACb,SAAA,CAAAZ,GAAa,MACba,EAAAA,IAAC,MAAA,CAAI,UAAU,iBAAkB,SAAAb,EAAU,EAE5CY,EAAAA,KAAC,MAAA,CAAI,UAAU,YACb,SAAA,CAAAD,EAAM,SAAW,GACjBE,MAAC,OAAA,CAAK,UAAU,YAAY,SAAA,sBAAmB,EAE/CF,EAAM,IAAKN,GAAM,CACjB,GAAIA,EAAE,aAAe,KAAM,CAC1B,MAAMS,EAAQR,EAAY,IAAMD,EAAE,aAAe,GAAKC,EACtD,OACCM,EAAAA,KAAC,MAAA,CAEA,UAAW,oBAAoBE,EAAQ,iBAAmB,EAAE,GAE3D,SAAA,CAAAT,EAAE,MAAQ,GAAG,IAAEA,EAAE,MAClBO,EAAAA,KAAC,OAAA,CAAK,UAAU,aAAa,SAAA,CAAA,YAClBlB,EAAcW,EAAE,WAAW,CAAA,CAAA,CACtC,CAAA,CAAA,EANKA,EAAE,EAAA,CASV,CACA,OACCO,EAAAA,KAAC,MAAA,CAAe,UAAU,kBAAkB,SAAA,CAAA,OACtCP,EAAE,KAAA,CAAA,EADEA,EAAE,EAEZ,CAEF,CAAC,CAAA,CAAA,CACF,CAAA,EACD,CAEF"}
1
+ {"version":3,"file":"AchievementTiles-BozP_h3c.js","sources":["../../src/components/AchievementTiles.tsx"],"sourcesContent":["/**\n * dashboard-client/src/components/AchievementTiles.tsx — shared achievement grid.\n *\n * Renders a grid of achievement tiles from fetchAchievements() data.\n * Tile states: unlocked (icon+title+date), visible-locked (??? teaser),\n * hidden+locked (not rendered). Just-unlocked tiles get a pulse animation.\n * Toast notification fires for newly-unlocked achievements.\n *\n * Used by both GameTab (sub-section) and AchievementsTab (standalone).\n */\n\nimport type React from \"react\";\nimport { useRef, useState, useEffect } from \"react\";\nimport type { AchievementRow } from \"@contracts\";\n\nexport interface AchievementTilesProps {\n\t/** Achievement rows from /api/achievements. */\n\tachievements: AchievementRow[];\n}\n\n/** Format unlock timestamp (seconds → locale string). */\nfunction fmtUnlockDate(ts: number): string {\n\ttry {\n\t\treturn new Date(ts * 1000).toLocaleString();\n\t} catch {\n\t\treturn \"\\u2014\";\n\t}\n}\n\nexport function AchievementTiles({\n\tachievements,\n}: AchievementTilesProps): React.ReactElement {\n\tconst lastMaxTsRef = useRef(0);\n\tconst [toastText, setToastText] = useState<string | null>(null);\n\n\tconst maxTs = achievements.reduce(\n\t\t(mx, a) => Math.max(mx, a.unlocked_at ?? 0),\n\t\t0,\n\t);\n\tconst prevMaxTs = lastMaxTsRef.current;\n\n\t/* Update ref after render so next poll sees previous baseline. */\n\tuseEffect(() => {\n\t\tif (maxTs > lastMaxTsRef.current) {\n\t\t\tlastMaxTsRef.current = maxTs;\n\t\t}\n\t}, [maxTs]);\n\n\t/* Toast for newly-unlocked achievements. */\n\tuseEffect(() => {\n\t\tif (prevMaxTs > 0 && maxTs > prevMaxTs) {\n\t\t\tconst newly = achievements.filter(\n\t\t\t\t(a) => a.unlocked_at != null && (a.unlocked_at ?? 0) > prevMaxTs,\n\t\t\t);\n\t\t\tif (newly.length > 0) {\n\t\t\t\tconst text =\n\t\t\t\t\tnewly\n\t\t\t\t\t\t.map((a) => `${a.icon ?? \"\"} ${a.title}`.trim())\n\t\t\t\t\t\t.join(\", \") + \" unlocked!\";\n\t\t\t\tsetToastText(text);\n\t\t\t\tconst t = setTimeout(() => setToastText(null), 4000);\n\t\t\t\treturn (): void => clearTimeout(t);\n\t\t\t}\n\t\t}\n\t}, [achievements, maxTs, prevMaxTs]);\n\n\t/* Visible tiles: skip hidden+locked (hidden === 1 && unlocked_at == null). */\n\tconst tiles = achievements.filter(\n\t\t(a) => !(a.hidden === 1 && a.unlocked_at == null),\n\t);\n\n\treturn (\n\t\t<div className=\"ach-section\">\n\t\t\t{toastText != null && (\n\t\t\t\t<div className=\"ach-toast show\">{toastText}</div>\n\t\t\t)}\n\t\t\t<div className=\"ach-tiles\">\n\t\t\t\t{tiles.length === 0 && (\n\t\t\t\t\t<span className=\"repo-none\">no achievements yet</span>\n\t\t\t\t)}\n\t\t\t\t{tiles.map((a) => {\n\t\t\t\t\tif (a.unlocked_at != null) {\n\t\t\t\t\t\tconst isNew = prevMaxTs > 0 && (a.unlocked_at ?? 0) > prevMaxTs;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={a.id}\n\t\t\t\t\t\t\t\tclassName={`ach-tile unlocked${isNew ? \" just-unlocked\" : \"\"}`}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{a.icon ?? \"\"} {a.title}\n\t\t\t\t\t\t\t\t<span className=\"ach-detail\">\n\t\t\t\t\t\t\t\t\tunlocked {fmtUnlockDate(a.unlocked_at)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={a.id} className=\"ach-tile locked\">\n\t\t\t\t\t\t\t??? {a.title}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"],"names":["fmtUnlockDate","ts","AchievementTiles","achievements","lastMaxTsRef","useRef","toastText","setToastText","useState","maxTs","mx","a","prevMaxTs","useEffect","newly","text","t","tiles","jsxs","jsx","isNew"],"mappings":"+CAqBA,SAASA,EAAcC,EAAoB,CAC1C,GAAI,CACH,OAAO,IAAI,KAAKA,EAAK,GAAI,EAAE,eAAA,CAC5B,MAAQ,CACP,MAAO,GACR,CACD,CAEO,SAASC,EAAiB,CAChC,aAAAC,CACD,EAA8C,CAC7C,MAAMC,EAAeC,EAAAA,OAAO,CAAC,EACvB,CAACC,EAAWC,CAAY,EAAIC,EAAAA,SAAwB,IAAI,EAExDC,EAAQN,EAAa,OAC1B,CAACO,EAAIC,IAAM,KAAK,IAAID,EAAIC,EAAE,aAAe,CAAC,EAC1C,CAAA,EAEKC,EAAYR,EAAa,QAG/BS,EAAAA,UAAU,IAAM,CACXJ,EAAQL,EAAa,UACxBA,EAAa,QAAUK,EAEzB,EAAG,CAACA,CAAK,CAAC,EAGVI,EAAAA,UAAU,IAAM,CACf,GAAID,EAAY,GAAKH,EAAQG,EAAW,CACvC,MAAME,EAAQX,EAAa,OACzBQ,GAAMA,EAAE,aAAe,OAASA,EAAE,aAAe,GAAKC,CAAA,EAExD,GAAIE,EAAM,OAAS,EAAG,CACrB,MAAMC,EACLD,EACE,IAAKH,GAAM,GAAGA,EAAE,MAAQ,EAAE,IAAIA,EAAE,KAAK,GAAG,KAAA,CAAM,EAC9C,KAAK,IAAI,EAAI,aAChBJ,EAAaQ,CAAI,EACjB,MAAMC,EAAI,WAAW,IAAMT,EAAa,IAAI,EAAG,GAAI,EACnD,MAAO,IAAY,aAAaS,CAAC,CAClC,CACD,CACD,EAAG,CAACb,EAAcM,EAAOG,CAAS,CAAC,EAGnC,MAAMK,EAAQd,EAAa,OACzBQ,GAAM,EAAEA,EAAE,SAAW,GAAKA,EAAE,aAAe,KAAA,EAG7C,OACCO,EAAAA,KAAC,MAAA,CAAI,UAAU,cACb,SAAA,CAAAZ,GAAa,MACba,EAAAA,IAAC,MAAA,CAAI,UAAU,iBAAkB,SAAAb,EAAU,EAE5CY,EAAAA,KAAC,MAAA,CAAI,UAAU,YACb,SAAA,CAAAD,EAAM,SAAW,GACjBE,MAAC,OAAA,CAAK,UAAU,YAAY,SAAA,sBAAmB,EAE/CF,EAAM,IAAKN,GAAM,CACjB,GAAIA,EAAE,aAAe,KAAM,CAC1B,MAAMS,EAAQR,EAAY,IAAMD,EAAE,aAAe,GAAKC,EACtD,OACCM,EAAAA,KAAC,MAAA,CAEA,UAAW,oBAAoBE,EAAQ,iBAAmB,EAAE,GAE3D,SAAA,CAAAT,EAAE,MAAQ,GAAG,IAAEA,EAAE,MAClBO,EAAAA,KAAC,OAAA,CAAK,UAAU,aAAa,SAAA,CAAA,YAClBlB,EAAcW,EAAE,WAAW,CAAA,CAAA,CACtC,CAAA,CAAA,EANKA,EAAE,EAAA,CASV,CACA,OACCO,EAAAA,KAAC,MAAA,CAAe,UAAU,kBAAkB,SAAA,CAAA,OACtCP,EAAE,KAAA,CAAA,EADEA,EAAE,EAEZ,CAEF,CAAC,CAAA,CAAA,CACF,CAAA,EACD,CAEF"}
@@ -1,2 +1,2 @@
1
- import{r as t,g as m,j as s}from"./index-DMntLu-8.js";import{A as v}from"./AchievementTiles-BH26e95w.js";function x(){const[r,o]=t.useState([]),[l,u]=t.useState(!0),[a,n]=t.useState(null),c=t.useCallback(async()=>{try{const e=await m();o(e),n(null)}catch(e){n(e instanceof Error?e:new Error(String(e)))}finally{u(!1)}},[]);return t.useEffect(()=>{let e=!0;const i=async()=>{e&&await c()};i();const h=setInterval(()=>void i(),15e3);return()=>{e=!1,clearInterval(h)}},[c]),l&&r.length===0?s.jsx("div",{className:"tab-stub",children:"Loading achievements…"}):a&&r.length===0?s.jsxs("div",{className:"tab-stub",children:["Error: ",a.message]}):s.jsxs("div",{className:"achievements-tab",children:[s.jsx("h2",{children:"Achievements"}),s.jsx(v,{achievements:r})]})}export{x as default};
2
- //# sourceMappingURL=AchievementsTab-B94wvRV_.js.map
1
+ import{r as t,g as m,j as s}from"./index-B2JJExE9.js";import{A as v}from"./AchievementTiles-BozP_h3c.js";function x(){const[r,o]=t.useState([]),[l,u]=t.useState(!0),[a,n]=t.useState(null),c=t.useCallback(async()=>{try{const e=await m();o(e),n(null)}catch(e){n(e instanceof Error?e:new Error(String(e)))}finally{u(!1)}},[]);return t.useEffect(()=>{let e=!0;const i=async()=>{e&&await c()};i();const h=setInterval(()=>void i(),15e3);return()=>{e=!1,clearInterval(h)}},[c]),l&&r.length===0?s.jsx("div",{className:"tab-stub",children:"Loading achievements…"}):a&&r.length===0?s.jsxs("div",{className:"tab-stub",children:["Error: ",a.message]}):s.jsxs("div",{className:"achievements-tab",children:[s.jsx("h2",{children:"Achievements"}),s.jsx(v,{achievements:r})]})}export{x as default};
2
+ //# sourceMappingURL=AchievementsTab-DKgqx1RQ.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AchievementsTab-B94wvRV_.js","sources":["../../src/tabs/AchievementsTab.tsx"],"sourcesContent":["/**\n * dashboard-client/src/tabs/AchievementsTab.tsx — Achievements tab (NEW).\n *\n * Renders the shared AchievementTiles component standalone, plus a toast area.\n * Polls /api/achievements every 15s.\n */\n\nimport type React from \"react\";\nimport { useState, useEffect, useCallback } from \"react\";\nimport type { AchievementRow } from \"@contracts\";\nimport { fetchAchievements } from \"../api/client\";\nimport { AchievementTiles } from \"../components/AchievementTiles\";\n\nexport default function AchievementsTab(): React.ReactElement {\n\tconst [achievements, setAchievements] = useState<AchievementRow[]>([]);\n\tconst [loading, setLoading] = useState(true);\n\tconst [error, setError] = useState<Error | null>(null);\n\n\tconst doFetch = useCallback(async (): Promise<void> => {\n\t\ttry {\n\t\t\tconst rows = await fetchAchievements();\n\t\t\tsetAchievements(rows);\n\t\t\tsetError(null);\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e : new Error(String(e)));\n\t\t} finally {\n\t\t\tsetLoading(false);\n\t\t}\n\t}, []);\n\n\tuseEffect(() => {\n\t\tlet active = true;\n\t\tconst run = async (): Promise<void> => {\n\t\t\tif (active) await doFetch();\n\t\t};\n\t\tvoid run();\n\t\tconst timer = setInterval(() => void run(), 15000);\n\t\treturn () => {\n\t\t\tactive = false;\n\t\t\tclearInterval(timer);\n\t\t};\n\t}, [doFetch]);\n\n\tif (loading && achievements.length === 0)\n\t\treturn <div className=\"tab-stub\">Loading achievements…</div>;\n\tif (error && achievements.length === 0)\n\t\treturn <div className=\"tab-stub\">Error: {error.message}</div>;\n\n\treturn (\n\t\t<div className=\"achievements-tab\">\n\t\t\t<h2>Achievements</h2>\n\t\t\t<AchievementTiles achievements={achievements} />\n\t\t</div>\n\t);\n}\n"],"names":["AchievementsTab","achievements","setAchievements","useState","loading","setLoading","error","setError","doFetch","useCallback","rows","fetchAchievements","useEffect","active","run","timer","jsx","jsxs","AchievementTiles"],"mappings":"yGAaA,SAAwBA,GAAsC,CAC7D,KAAM,CAACC,EAAcC,CAAe,EAAIC,EAAAA,SAA2B,CAAA,CAAE,EAC/D,CAACC,EAASC,CAAU,EAAIF,EAAAA,SAAS,EAAI,EACrC,CAACG,EAAOC,CAAQ,EAAIJ,EAAAA,SAAuB,IAAI,EAE/CK,EAAUC,EAAAA,YAAY,SAA2B,CACtD,GAAI,CACH,MAAMC,EAAO,MAAMC,EAAA,EACnBT,EAAgBQ,CAAI,EACpBH,EAAS,IAAI,CACd,OAAS,EAAG,CACXA,EAAS,aAAa,MAAQ,EAAI,IAAI,MAAM,OAAO,CAAC,CAAC,CAAC,CACvD,QAAA,CACCF,EAAW,EAAK,CACjB,CACD,EAAG,CAAA,CAAE,EAeL,OAbAO,EAAAA,UAAU,IAAM,CACf,IAAIC,EAAS,GACb,MAAMC,EAAM,SAA2B,CAClCD,SAAcL,EAAA,CACnB,EACKM,EAAA,EACL,MAAMC,EAAQ,YAAY,IAAM,KAAKD,EAAA,EAAO,IAAK,EACjD,MAAO,IAAM,CACZD,EAAS,GACT,cAAcE,CAAK,CACpB,CACD,EAAG,CAACP,CAAO,CAAC,EAERJ,GAAWH,EAAa,SAAW,EAC/Be,EAAAA,IAAC,MAAA,CAAI,UAAU,WAAW,SAAA,wBAAqB,EACnDV,GAASL,EAAa,SAAW,EAC7BgB,EAAAA,KAAC,MAAA,CAAI,UAAU,WAAW,SAAA,CAAA,UAAQX,EAAM,OAAA,EAAQ,EAGvDW,EAAAA,KAAC,MAAA,CAAI,UAAU,mBACd,SAAA,CAAAD,EAAAA,IAAC,MAAG,SAAA,cAAA,CAAY,EAChBA,MAACE,GAAiB,aAAAjB,CAAA,CAA4B,CAAA,EAC/C,CAEF"}
1
+ {"version":3,"file":"AchievementsTab-DKgqx1RQ.js","sources":["../../src/tabs/AchievementsTab.tsx"],"sourcesContent":["/**\n * dashboard-client/src/tabs/AchievementsTab.tsx — Achievements tab (NEW).\n *\n * Renders the shared AchievementTiles component standalone, plus a toast area.\n * Polls /api/achievements every 15s.\n */\n\nimport type React from \"react\";\nimport { useState, useEffect, useCallback } from \"react\";\nimport type { AchievementRow } from \"@contracts\";\nimport { fetchAchievements } from \"../api/client\";\nimport { AchievementTiles } from \"../components/AchievementTiles\";\n\nexport default function AchievementsTab(): React.ReactElement {\n\tconst [achievements, setAchievements] = useState<AchievementRow[]>([]);\n\tconst [loading, setLoading] = useState(true);\n\tconst [error, setError] = useState<Error | null>(null);\n\n\tconst doFetch = useCallback(async (): Promise<void> => {\n\t\ttry {\n\t\t\tconst rows = await fetchAchievements();\n\t\t\tsetAchievements(rows);\n\t\t\tsetError(null);\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e : new Error(String(e)));\n\t\t} finally {\n\t\t\tsetLoading(false);\n\t\t}\n\t}, []);\n\n\tuseEffect(() => {\n\t\tlet active = true;\n\t\tconst run = async (): Promise<void> => {\n\t\t\tif (active) await doFetch();\n\t\t};\n\t\tvoid run();\n\t\tconst timer = setInterval(() => void run(), 15000);\n\t\treturn () => {\n\t\t\tactive = false;\n\t\t\tclearInterval(timer);\n\t\t};\n\t}, [doFetch]);\n\n\tif (loading && achievements.length === 0)\n\t\treturn <div className=\"tab-stub\">Loading achievements…</div>;\n\tif (error && achievements.length === 0)\n\t\treturn <div className=\"tab-stub\">Error: {error.message}</div>;\n\n\treturn (\n\t\t<div className=\"achievements-tab\">\n\t\t\t<h2>Achievements</h2>\n\t\t\t<AchievementTiles achievements={achievements} />\n\t\t</div>\n\t);\n}\n"],"names":["AchievementsTab","achievements","setAchievements","useState","loading","setLoading","error","setError","doFetch","useCallback","rows","fetchAchievements","useEffect","active","run","timer","jsx","jsxs","AchievementTiles"],"mappings":"yGAaA,SAAwBA,GAAsC,CAC7D,KAAM,CAACC,EAAcC,CAAe,EAAIC,EAAAA,SAA2B,CAAA,CAAE,EAC/D,CAACC,EAASC,CAAU,EAAIF,EAAAA,SAAS,EAAI,EACrC,CAACG,EAAOC,CAAQ,EAAIJ,EAAAA,SAAuB,IAAI,EAE/CK,EAAUC,EAAAA,YAAY,SAA2B,CACtD,GAAI,CACH,MAAMC,EAAO,MAAMC,EAAA,EACnBT,EAAgBQ,CAAI,EACpBH,EAAS,IAAI,CACd,OAAS,EAAG,CACXA,EAAS,aAAa,MAAQ,EAAI,IAAI,MAAM,OAAO,CAAC,CAAC,CAAC,CACvD,QAAA,CACCF,EAAW,EAAK,CACjB,CACD,EAAG,CAAA,CAAE,EAeL,OAbAO,EAAAA,UAAU,IAAM,CACf,IAAIC,EAAS,GACb,MAAMC,EAAM,SAA2B,CAClCD,SAAcL,EAAA,CACnB,EACKM,EAAA,EACL,MAAMC,EAAQ,YAAY,IAAM,KAAKD,EAAA,EAAO,IAAK,EACjD,MAAO,IAAM,CACZD,EAAS,GACT,cAAcE,CAAK,CACpB,CACD,EAAG,CAACP,CAAO,CAAC,EAERJ,GAAWH,EAAa,SAAW,EAC/Be,EAAAA,IAAC,MAAA,CAAI,UAAU,WAAW,SAAA,wBAAqB,EACnDV,GAASL,EAAa,SAAW,EAC7BgB,EAAAA,KAAC,MAAA,CAAI,UAAU,WAAW,SAAA,CAAA,UAAQX,EAAM,OAAA,EAAQ,EAGvDW,EAAAA,KAAC,MAAA,CAAI,UAAU,mBACd,SAAA,CAAAD,EAAAA,IAAC,MAAG,SAAA,cAAA,CAAY,EAChBA,MAACE,GAAiB,aAAAjB,CAAA,CAA4B,CAAA,EAC/C,CAEF"}
@@ -1,2 +1,2 @@
1
- import{u as n,r,b as d,j as s}from"./index-DMntLu-8.js";import{C as l,T as h}from"./TimeSavedCard-kUnoospE.js";function p(){const{data:a,loading:i,error:c}=n(r.useCallback(()=>d(),[]),{pollInterval:5e3});if(i&&!a)return s.jsx("div",{className:"tab-stub",children:"Loading snapshot…"});if(c&&!a)return s.jsxs("div",{className:"tab-stub",children:["Error: ",c.message]});if(!a)return s.jsx("div",{className:"tab-stub",children:"No snapshot data."});const{cacheHits:e,compacts:o,timeSaved:t}=a;return s.jsx("div",{className:"cache-tab",children:s.jsxs("div",{className:"card-grid overview-card-grid",children:[s.jsx(l,{cacheHitsSession:e.session,cacheHitsTotal:e.total,tokensSavedSession:e.sessionTokensSaved,tokensSavedTotal:e.totalTokensSaved,compactionsSession:o.session,compactionsTotal:o.total}),s.jsx(h,{compactSessionSec:t.compact.sessionSec,compactTotalSec:t.compact.totalSec,cacheHitSessionSec:t.cacheHit.sessionSec,cacheHitTotalSec:t.cacheHit.totalSec})]})})}export{p as default};
2
- //# sourceMappingURL=CacheTab-DPmw3NHR.js.map
1
+ import{u as n,r,b as d,j as s}from"./index-B2JJExE9.js";import{C as l,T as h}from"./TimeSavedCard-B7aPcBMs.js";function p(){const{data:a,loading:i,error:c}=n(r.useCallback(()=>d(),[]),{pollInterval:5e3});if(i&&!a)return s.jsx("div",{className:"tab-stub",children:"Loading snapshot…"});if(c&&!a)return s.jsxs("div",{className:"tab-stub",children:["Error: ",c.message]});if(!a)return s.jsx("div",{className:"tab-stub",children:"No snapshot data."});const{cacheHits:e,compacts:o,timeSaved:t}=a;return s.jsx("div",{className:"cache-tab",children:s.jsxs("div",{className:"card-grid overview-card-grid",children:[s.jsx(l,{cacheHitsSession:e.session,cacheHitsTotal:e.total,tokensSavedSession:e.sessionTokensSaved,tokensSavedTotal:e.totalTokensSaved,compactionsSession:o.session,compactionsTotal:o.total}),s.jsx(h,{compactSessionSec:t.compact.sessionSec,compactTotalSec:t.compact.totalSec,cacheHitSessionSec:t.cacheHit.sessionSec,cacheHitTotalSec:t.cacheHit.totalSec})]})})}export{p as default};
2
+ //# sourceMappingURL=CacheTab-CPvso4-0.js.map