pi-mega-compact 0.8.17 → 0.8.19

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 (93) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +12 -0
  2. package/dist/extensions/dashboard-server/api-contracts.test.js +19 -6
  3. package/dist/extensions/dashboard-server/helpers.js +37 -0
  4. package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
  5. package/dist/extensions/dashboard-server/html/body-open.js +23 -0
  6. package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
  7. package/dist/extensions/dashboard-server/html/head-open.js +16 -0
  8. package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
  9. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
  10. package/dist/extensions/dashboard-server/html/script.js +259 -0
  11. package/dist/extensions/dashboard-server/html/styles.js +103 -0
  12. package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
  13. package/dist/extensions/dashboard-server/html-template.js +41 -0
  14. package/dist/extensions/dashboard-server/server.js +83 -0
  15. package/dist/extensions/mega-events/context-handler.js +22 -8
  16. package/dist/extensions/mega-runtime/state.js +17 -1
  17. package/dist/src/store/sqlite/connection.js +35 -0
  18. package/dist/src/store/sqlite/global-index.js +199 -1
  19. package/dist/src/store/sqlite/global-index.test.js +136 -0
  20. package/dist/src/store/sqlite/index-store.js +167 -0
  21. package/dist/src/store/sqlite/memory.js +54 -0
  22. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  23. package/dist/src/store/sqlite/sessions.js +39 -0
  24. package/dist/src/store/sqlite/transaction.js +19 -0
  25. package/dist/src/vectorStore/add.js +260 -0
  26. package/dist/src/vectorStore/dedup.js +52 -0
  27. package/dist/src/vectorStore/index.js +10 -0
  28. package/dist/src/vectorStore/queries.js +83 -0
  29. package/dist/src/vectorStore/search.js +95 -0
  30. package/dist/src/vectorStore/session.js +19 -0
  31. package/dist/src/vectorStore/store.js +105 -0
  32. package/dist/src/vectorStore/types.js +6 -0
  33. package/dist/src/vectorStore/utils.js +23 -0
  34. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-DVgFowc-.js} +2 -2
  35. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-DVgFowc-.js.map} +1 -1
  36. package/extensions/dashboard-client/dist/assets/AchievementsTab-Bg86TWPU.js +2 -0
  37. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-Bg86TWPU.js.map} +1 -1
  38. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab-DBzoH_q_.js} +2 -2
  39. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab-DBzoH_q_.js.map} +1 -1
  40. package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-Bj12LgTh.js} +2 -2
  41. package/extensions/dashboard-client/dist/assets/ConfigTab-Bj12LgTh.js.map +1 -0
  42. package/extensions/dashboard-client/dist/assets/EventsTab-EC0wMsp_.js +2 -0
  43. package/extensions/dashboard-client/dist/assets/EventsTab-EC0wMsp_.js.map +1 -0
  44. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CISQitbu.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CISQitbu.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-us4vVG5D.js} +2 -2
  47. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-us4vVG5D.js.map} +1 -1
  48. package/extensions/dashboard-client/dist/assets/OverviewTab-eJ7vFgBE.js +2 -0
  49. package/extensions/dashboard-client/dist/assets/OverviewTab-eJ7vFgBE.js.map +1 -0
  50. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-CiU9AJkb.js} +2 -2
  51. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-CiU9AJkb.js.map} +1 -1
  52. package/extensions/dashboard-client/dist/assets/SessionsTab-DaCUvTC7.js +70 -0
  53. package/extensions/dashboard-client/dist/assets/SessionsTab-DaCUvTC7.js.map +1 -0
  54. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BAUSCzq5.js} +2 -2
  55. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BAUSCzq5.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-5MPZyayK.js} +10 -10
  57. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-5MPZyayK.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-CDkmYP9l.css} +1 -1
  59. package/extensions/dashboard-client/dist/assets/useSSE-BSpmWZZi.js +2 -0
  60. package/extensions/dashboard-client/dist/assets/useSSE-BSpmWZZi.js.map +1 -0
  61. package/extensions/dashboard-client/dist/index.html +2 -2
  62. package/extensions/dashboard-client/package-lock.json +374 -2
  63. package/extensions/dashboard-client/package.json +2 -1
  64. package/extensions/dashboard-client/src/App.tsx +5 -1
  65. package/extensions/dashboard-client/src/api/client.ts +12 -0
  66. package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
  67. package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
  68. package/extensions/dashboard-client/src/components/RepoContextStack.tsx +238 -0
  69. package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
  70. package/extensions/dashboard-client/src/main.tsx +2 -0
  71. package/extensions/dashboard-client/src/styles/repostack.css +87 -0
  72. package/extensions/dashboard-client/src/styles/sessions.css +193 -0
  73. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
  74. package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +49 -1
  75. package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
  76. package/extensions/dashboard-server/api-contracts/core.ts +25 -0
  77. package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
  78. package/extensions/dashboard-server/api-contracts/index.ts +10 -1
  79. package/extensions/dashboard-server/api-contracts.test.ts +20 -7
  80. package/extensions/dashboard-server/server.ts +100 -0
  81. package/extensions/mega-events/context-handler.ts +40 -18
  82. package/extensions/mega-runtime/state.ts +31 -0
  83. package/package.json +1 -1
  84. package/src/store/sqlite/global-index.test.ts +222 -0
  85. package/src/store/sqlite/global-index.ts +316 -1
  86. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  87. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
  88. package/extensions/dashboard-client/dist/assets/AchievementsTab-DKgqx1RQ.js +0 -2
  89. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
  90. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
  91. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js.map +0 -1
  92. package/extensions/dashboard-client/dist/assets/OverviewTab-lVGR1Jl_.js +0 -2
  93. package/extensions/dashboard-client/dist/assets/OverviewTab-lVGR1Jl_.js.map +0 -1
@@ -336,6 +336,94 @@ export interface AchievementRow {
336
336
  readonly unlocked_at: number | null;
337
337
  }
338
338
 
339
+ // ─── Sessions Memory Graph (S39) ─────────────────────────────────────────
340
+
341
+ /**
342
+ * A single active session in the GET /api/sessions response. Represents a
343
+ * live pi process with its latest token usage and heartbeat.
344
+ */
345
+ export interface ActiveSession {
346
+ /** OS process ID of the pi process. */
347
+ readonly pid: number;
348
+ /** Session identifier (normalized). */
349
+ readonly sessionId: string;
350
+ /** Absolute path to the repo root, or null if not in a git repo. */
351
+ readonly repoRoot: string | null;
352
+ /** Display name (repo basename or state dir basename). */
353
+ readonly displayName: string;
354
+ /** Model name, or null if not captured. Available from repo_registry join. */
355
+ readonly model: string | null;
356
+ /** Latest context token count, or null if no sample yet. */
357
+ readonly tokens: number | null;
358
+ /** Latest context pressure percentage (0–100), or null. */
359
+ readonly percent: number | null;
360
+ /** Context window size in tokens. */
361
+ readonly ctxWindow: number;
362
+ /** Unix timestamp (ms) of the last heartbeat. */
363
+ readonly lastSeen: number;
364
+ /** State directory of the session. */
365
+ readonly stateDir: string | null;
366
+ }
367
+
368
+ /**
369
+ * Response for GET /api/sessions. Lists active sessions with their latest
370
+ * token usage after pruning stale entries.
371
+ */
372
+ export interface SessionsResponse {
373
+ /** ISO timestamp when the response was generated. */
374
+ readonly updatedAt: string;
375
+ /** Number of stale sessions pruned during this request. */
376
+ readonly pruned: number;
377
+ /** Array of active sessions, sorted by lastSeen descending. */
378
+ readonly sessions: ActiveSession[];
379
+ }
380
+
381
+ /** A single time-series data point for a session. */
382
+ export interface SessionDataPoint {
383
+ /** Unix timestamp (ms). */
384
+ readonly ts: number;
385
+ /** Token count at this sample. */
386
+ readonly tokens: number;
387
+ /** Context pressure percentage (0–100). */
388
+ readonly percent: number;
389
+ }
390
+
391
+ /** A recharts-ready per-session series with a stable color. */
392
+ export interface SessionSeries {
393
+ /** Session identifier. */
394
+ readonly sessionId: string;
395
+ /** Short display label for the legend. */
396
+ readonly label: string;
397
+ /** Stable hex color string (e.g. "#60a5fa"). */
398
+ readonly color: string;
399
+ /** Data points ordered by timestamp ascending. */
400
+ readonly data: SessionDataPoint[];
401
+ }
402
+
403
+ /**
404
+ * Query parameters for GET /api/sessions/timeseries. The `minutes` parameter
405
+ * controls the rolling window size.
406
+ */
407
+ export interface SessionTimeseriesQuery {
408
+ /** Rolling window size in minutes (default: 30, clamped to [1, 1440]). */
409
+ readonly minutes?: number;
410
+ }
411
+
412
+ /**
413
+ * Response for GET /api/sessions/timeseries. Returns recharts-ready stacked
414
+ * per-session series + a totals array.
415
+ */
416
+ export interface SessionTimeseriesResponse {
417
+ /** ISO timestamp when the response was generated. */
418
+ readonly updatedAt: string;
419
+ /** Rolling window size in minutes. */
420
+ readonly windowMinutes: number;
421
+ /** Per-session series with stable colors. */
422
+ readonly series: SessionSeries[];
423
+ /** Totals (sum of all sessions) per timestamp. */
424
+ readonly totals: { readonly ts: number; readonly tokens: number }[];
425
+ }
426
+
339
427
  // ─── Game State Patch (PUT request body) ────────────────────────────────────
340
428
 
341
429
  /**
@@ -473,4 +561,18 @@ export const ENDPOINTS = {
473
561
  path: '/api/achievements',
474
562
  description: 'All achievement rows with unlock state.',
475
563
  } as const satisfies EndpointDef<'GET', undefined, AchievementRow[]>,
564
+
565
+ /** GET /api/sessions — Active sessions with latest token usage (S39). */
566
+ sessions: {
567
+ method: 'GET',
568
+ path: '/api/sessions',
569
+ description: 'Active pi sessions with latest token usage + heartbeat.',
570
+ } as const satisfies EndpointDef<'GET', undefined, SessionsResponse>,
571
+
572
+ /** GET /api/sessions/timeseries — Stacked per-session token timeseries (S39). */
573
+ sessionTimeseries: {
574
+ method: 'GET',
575
+ path: '/api/sessions/timeseries',
576
+ description: 'Recharts-ready per-session token timeseries for the stacked memory graph.',
577
+ } as const satisfies EndpointDef<'GET', SessionTimeseriesQuery, SessionTimeseriesResponse>,
476
578
  } as const;
@@ -26,6 +26,7 @@ export type {
26
26
  SseCrewPresenceChanged,
27
27
  SseCrewTurnChanged,
28
28
  SseCrewBanditChosen,
29
+ SseSessionSample,
29
30
  } from './core.js';
30
31
 
31
32
  // Snapshot / store / compression / session
@@ -103,6 +104,7 @@ import type {
103
104
  SseCrewPresenceChanged,
104
105
  SseCrewTurnChanged,
105
106
  SseCrewBanditChosen,
107
+ SseSessionSample,
106
108
  } from './core.js';
107
109
 
108
110
  // Endpoints registry (Sprint A1)
@@ -130,6 +132,12 @@ export type {
130
132
  GameScoresQuery,
131
133
  AchievementRow,
132
134
  GameStatePatch,
135
+ ActiveSession,
136
+ SessionsResponse,
137
+ SessionDataPoint,
138
+ SessionSeries,
139
+ SessionTimeseriesQuery,
140
+ SessionTimeseriesResponse,
133
141
  SseEndpointDef,
134
142
  } from './endpoints.js';
135
143
 
@@ -156,4 +164,5 @@ export type SseEvent =
156
164
  | SseGameRitualStage
157
165
  | SseGameRitualEnd
158
166
  | SseGameModeChanged
159
- | SseGameRender;
167
+ | SseGameRender
168
+ | SseSessionSample;
@@ -158,10 +158,11 @@ void _c_perf;
158
158
  void _c_achievements;
159
159
 
160
160
  // ─── Compile-Time: ENDPOINTS path/method consistency ─────────────────────────
161
- // Verify the ENDPOINTS registry has exactly 13 entries with correct paths.
161
+ // Verify the ENDPOINTS registry has exactly 15 entries with correct paths.
162
+ // (Sprint 39 added /api/sessions + /api/sessions/timeseries.)
162
163
 
163
164
  const ENDPOINT_KEYS = Object.keys(ENDPOINTS) as (keyof typeof ENDPOINTS)[];
164
- const EXPECTED_ENDPOINT_COUNT = 13;
165
+ const EXPECTED_ENDPOINT_COUNT = 15;
165
166
 
166
167
  /** All `/api/*` paths served by server.ts (extracted from the route handlers). */
167
168
  const SERVER_TS_PATHS: string[] = [
@@ -177,12 +178,14 @@ const SERVER_TS_PATHS: string[] = [
177
178
  "/api/game-scores",
178
179
  "/api/perf",
179
180
  "/api/achievements",
181
+ "/api/sessions",
182
+ "/api/sessions/timeseries",
180
183
  ];
181
184
 
182
185
  // ─── Tests ───────────────────────────────────────────────────────────────────
183
186
 
184
187
  describe("ENDPOINTS registry", () => {
185
- test("has exactly 13 endpoint entries", () => {
188
+ test("has exactly 15 endpoint entries", () => {
186
189
  assert.equal(
187
190
  ENDPOINT_KEYS.length,
188
191
  EXPECTED_ENDPOINT_COUNT,
@@ -803,7 +806,7 @@ describe("GET /api/achievements", () => {
803
806
  // matching its discriminator.
804
807
 
805
808
  describe("SSE /api/events — SseEvent union discriminator validation", () => {
806
- /** All 20 SseEvent variants with their discriminator `type` value and a minimal payload. */
809
+ /** All 21 SseEvent variants with their discriminator `type` value and a minimal payload. */
807
810
  const SSE_VARIANTS: ReadonlyArray<{
808
811
  discriminator: string;
809
812
  payload: string;
@@ -996,13 +999,23 @@ describe("SSE /api/events — SseEvent union discriminator validation", () => {
996
999
  frame: "\x1b[2J\x1b[H",
997
1000
  }),
998
1001
  },
1002
+ {
1003
+ discriminator: "session_sample",
1004
+ payload: JSON.stringify({
1005
+ type: "session_sample",
1006
+ ts: "2025-01-01T00:00:00Z",
1007
+ sessionId: "sess-abc",
1008
+ tokens: 42_000,
1009
+ percent: 35.5,
1010
+ }),
1011
+ },
999
1012
  ];
1000
1013
 
1001
- test("all 20 SseEvent variants have a type field matching their discriminator", () => {
1014
+ test("all 21 SseEvent variants have a type field matching their discriminator", () => {
1002
1015
  assert.equal(
1003
1016
  SSE_VARIANTS.length,
1004
- 20,
1005
- "SseEvent union should have 20 variants",
1017
+ 21,
1018
+ "SseEvent union should have 21 variants",
1006
1019
  );
1007
1020
 
1008
1021
  for (const variant of SSE_VARIANTS) {
@@ -770,6 +770,106 @@ export async function launchDashboardServer(
770
770
  return;
771
771
  }
772
772
 
773
+ // /api/sessions — S39: active pi sessions with latest token usage + heartbeat.
774
+ // GET returns {updatedAt, pruned, sessions[]} after pruning stale heartbeats.
775
+ // The dashboard server is a detached child with no MegaRuntime ref, so it
776
+ // reads session_heartbeats via a require()'d sqlite helper (same pattern as
777
+ // /api/achievements, /api/perf). Non-GET -> 405. PREVENT-PI-004: loopback.
778
+ if (req.url?.startsWith("/api/sessions")) {
779
+ // Guard: /api/sessions/timeseries handled separately below.
780
+ if (req.url.startsWith("/api/sessions/timeseries")) {
781
+ // Fall through to the timeseries handler below.
782
+ } else {
783
+ const sReq = createRequire(import.meta.url);
784
+ const {
785
+ readActiveSessions,
786
+ pruneStaleSessions,
787
+ listRepoRegistry,
788
+ } = sReq(
789
+ "../../src/store/sqlite.js",
790
+ ) as typeof import("../../src/store/sqlite.js");
791
+ if (req.method !== "GET") {
792
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
793
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
794
+ return;
795
+ }
796
+ try {
797
+ const pruned = pruneStaleSessions(); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
798
+ const active = readActiveSessions();
799
+ const repos = listRepoRegistry();
800
+ const repoMap = new Map(repos.map((r) => [r.repoRoot, r]));
801
+ const sessions = active.map((s) => ({
802
+ pid: s.pid,
803
+ sessionId: s.sessionId,
804
+ repoRoot: s.repoRoot,
805
+ displayName: s.repoRoot
806
+ ? (s.repoRoot.split(/[\\/]/).filter(Boolean).pop() ?? s.repoRoot)
807
+ : (s.stateDir?.split(/[\\/]/).filter(Boolean).pop() ?? "unknown"),
808
+ model: s.repoRoot ? (repoMap.get(s.repoRoot)?.modelName ?? null) : null,
809
+ tokens: s.tokens,
810
+ percent: s.percent,
811
+ ctxWindow: s.ctxWindow,
812
+ lastSeen: s.lastSeen,
813
+ stateDir: s.stateDir,
814
+ }));
815
+ res.writeHead(200, { "Content-Type": "application/json" });
816
+ res.end(
817
+ JSON.stringify({ updatedAt: new Date().toISOString(), pruned, sessions }),
818
+ );
819
+ } catch (e) {
820
+ res.writeHead(500, { "Content-Type": "application/json" });
821
+ res.end(
822
+ JSON.stringify({ error: "sessions_unavailable", detail: String(e) }),
823
+ );
824
+ }
825
+ return;
826
+ }
827
+ }
828
+
829
+ // /api/sessions/timeseries — S39: stacked per-session token timeseries for
830
+ // the recharts memory graph. GET ?minutes=N (clamped [1,1440]) returns
831
+ // {updatedAt, windowMinutes, series[], totals[]} in recharts-ready shape.
832
+ // Non-GET -> 405. PREVENT-PI-004: loopback.
833
+ if (req.url?.startsWith("/api/sessions/timeseries")) {
834
+ const tsReq = createRequire(import.meta.url);
835
+ const {
836
+ readSessionTimeseries,
837
+ pruneTokenSamples,
838
+ } = tsReq(
839
+ "../../src/store/sqlite.js",
840
+ ) as typeof import("../../src/store/sqlite.js");
841
+ if (req.method !== "GET") {
842
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
843
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
844
+ return;
845
+ }
846
+ try {
847
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
848
+ let minutes = Number(url.searchParams.get("minutes") ?? "30");
849
+ if (!Number.isFinite(minutes) || minutes <= 0) minutes = 30;
850
+ minutes = Math.min(Math.max(minutes, 1), 1440);
851
+ const sinceTs = Date.now() - minutes * 60_000;
852
+ const pruneMs = Math.max(minutes * 60_000, 1_800_000);
853
+ pruneTokenSamples(pruneMs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
854
+ const result = readSessionTimeseries(sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
855
+ res.writeHead(200, { "Content-Type": "application/json" });
856
+ res.end(
857
+ JSON.stringify({
858
+ updatedAt: new Date().toISOString(),
859
+ windowMinutes: minutes,
860
+ series: result.series,
861
+ totals: result.totals,
862
+ }),
863
+ );
864
+ } catch (e) {
865
+ res.writeHead(500, { "Content-Type": "application/json" });
866
+ res.end(
867
+ JSON.stringify({ error: "timeseries_unavailable", detail: String(e) }),
868
+ );
869
+ }
870
+ return;
871
+ }
872
+
773
873
  // Fallback — serve the React client build (SPA route) or legacy dashboard.
774
874
  // Non-/api/* GETs hit here: serve client assets if built, else inline HTML.
775
875
  if (
@@ -21,7 +21,7 @@ import {
21
21
  import { epochIdFor } from "../../src/mirror/epoch.js";
22
22
  import { autoCompactCheck } from "../../src/compact.js";
23
23
  import { estimateSessionTokens } from "../../src/tokens.js";
24
- import { type MegaRuntime } from "../mega-runtime.js";
24
+ import type { MegaRuntime } from "../mega-runtime.js";
25
25
  import { runCompact, piCompactWouldNoop } from "../mega-pipeline.js";
26
26
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "../mega-trim.js";
27
27
  import {
@@ -42,13 +42,19 @@ function toRawTranscriptRow(
42
42
  epochId: string,
43
43
  ): RawTranscriptRow | null {
44
44
  // Narrow to Message union (has content + timestamp).
45
- const m = msg as { role?: string; content?: unknown; timestamp?: number; toolName?: string };
45
+ const m = msg as {
46
+ role?: string;
47
+ content?: unknown;
48
+ timestamp?: number;
49
+ toolName?: string;
50
+ };
46
51
  const content = m.content;
47
52
  if (content == null || content === "") return null;
48
53
  // Canonical form: sort object keys for deterministic hashing.
49
- const contentBytes = typeof content === "string"
50
- ? content
51
- : JSON.stringify(content, Object.keys(content as object).sort());
54
+ const contentBytes =
55
+ typeof content === "string"
56
+ ? content
57
+ : JSON.stringify(content, Object.keys(content as object).sort());
52
58
  const contentHash = createHash("sha256").update(contentBytes).digest("hex");
53
59
  return {
54
60
  contentHash,
@@ -85,21 +91,32 @@ export function registerContextHandler(
85
91
  pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
86
92
  const usage = ctx.getContextUsage();
87
93
  const pct = usage?.percent;
94
+ const messages = event.messages;
88
95
  // Always track context for the dashboard/widget, even when auto is off.
89
96
  // (v0.8 regression: !config.auto gate sat above this, leaving ctx stats
90
97
  // null -> widget '?% / ?/?' when auto disabled. Track first, THEN gate.)
91
- runtime.lastCtxTokens = usage?.tokens ?? null;
98
+ // S40 fix: fall back to estimateSessionTokens(view) when the provider
99
+ // doesn't report tokens (e.g. plexus / claude-mythos-5 via OpenRouter).
100
+ // Without this, lastCtxTokens is null -> appendTokenSample (S39) never
101
+ // fires -> Sessions chart + Overview per-repo stack + ContextGauge all
102
+ // show empty/zero. Compute view lazily only when the fallback is needed
103
+ // (at most one engineView call per context event; when auto is on and
104
+ // usage.tokens is present, view is computed once below via reuse).
105
+ const viewForFallback =
106
+ usage?.tokens == null ? runtime.engineView(messages) : null;
107
+ const currentTokens =
108
+ usage?.tokens ??
109
+ (viewForFallback != null
110
+ ? estimateSessionTokens(viewForFallback)
111
+ : null) ??
112
+ Math.round(((pct ?? 0) / 100) * (usage?.contextWindow ?? 0));
113
+ runtime.lastCtxTokens = currentTokens ?? null;
92
114
  runtime.lastCtxPercent = pct ?? null;
93
115
  runtime.lastCtxWindow = usage?.contextWindow ?? 0;
94
116
  runtime.snapshot(ctx);
95
117
  if (!config.auto) return;
96
118
 
97
- const messages = event.messages;
98
- const view = runtime.engineView(messages);
99
- const currentTokens =
100
- usage?.tokens ??
101
- estimateSessionTokens(view) ??
102
- Math.round(((pct ?? 0) / 100) * (usage?.contextWindow ?? 0));
119
+ const view = viewForFallback ?? runtime.engineView(messages);
103
120
 
104
121
  // S27 DB-mirror: append ALL incoming messages to raw_transcript.
105
122
  // Runs BEFORE fast-gate so every message is captured, even if we
@@ -178,7 +195,7 @@ export function registerContextHandler(
178
195
  pct != null && runtime.trimCache.ctxPct != null
179
196
  ? pct - runtime.trimCache.ctxPct >= RECOMPACT_PCT_DELTA
180
197
  : currentTokens - (runtime.trimCache.ctxTokens ?? 0) >=
181
- runtime.effectiveThreshold * 0.5;
198
+ runtime.effectiveThreshold * 0.5;
182
199
  if (!grewEnough) {
183
200
  const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized once by computeLiveTrimCut (src/boundary.ts) and replayed verbatim; the transcript only grows within an epoch (cache is cleared on durable truncation), so the preserved run still starts on a toolPair-safe index.
184
201
  runtime.diagLiveTrimFires++; // trim view returned this call (replay counts as a fire)
@@ -186,7 +203,9 @@ export function registerContextHandler(
186
203
  runtime.snapshot(ctx);
187
204
  // v0.8.7: shallow-copy the cached summary so pi's transformContext can't
188
205
  // mutate the shared reference across replays (audit P3).
189
- return { messages: [{ ...runtime.trimCache.summaryAgentMsg }, ...recent] };
206
+ return {
207
+ messages: [{ ...runtime.trimCache.summaryAgentMsg }, ...recent],
208
+ };
190
209
  }
191
210
  // else: context grew enough → fall through to re-compact (cache is stale)
192
211
  }
@@ -195,7 +214,10 @@ export function registerContextHandler(
195
214
  // with how close we are to the model context limit. Null-safe: when the
196
215
  // token-fallback path ran (pct unavailable) use the token-basis pressure
197
216
  // (the same basis the runtime `pressure` getter uses for custom/no-window).
198
- const pressure = pct != null ? pressureFromPct(pct) : pressureRatio(currentTokens, runtime.effectiveThreshold);
217
+ const pressure =
218
+ pct != null
219
+ ? pressureFromPct(pct)
220
+ : pressureRatio(currentTokens, runtime.effectiveThreshold);
199
221
  const ran = runCompact(pi, runtime, config, ctx, messages, {
200
222
  compressionPressure: pressure,
201
223
  });
@@ -269,9 +291,9 @@ export function registerContextHandler(
269
291
  setTimeout(() => {
270
292
  try {
271
293
  if (runtime.rt.sessionId !== liveSid) return; // session reset
272
- const since2 =
273
- Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
274
- if (runtime.rt.lastNativeCompactAt !== stamp && since2 < cooldownMs) return;
294
+ const since2 = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
295
+ if (runtime.rt.lastNativeCompactAt !== stamp && since2 < cooldownMs)
296
+ return;
275
297
  if (piCompactWouldNoop(ctx)) return;
276
298
  ctx.compact({
277
299
  customInstructions: undefined,
@@ -29,6 +29,8 @@ import {
29
29
  getCacheHitTokensSaved,
30
30
  getGameState,
31
31
  recordPerfSample,
32
+ recordSessionHeartbeat,
33
+ appendTokenSample,
32
34
  type ModelSnapshot,
33
35
  type GameState,
34
36
  } from "../../src/store/sqlite.js";
@@ -575,6 +577,35 @@ export class MegaRuntime {
575
577
  } as DashboardSnapshot);
576
578
  const perfDiskMs = this.dashboard.lastWriteMs;
577
579
 
580
+ // S39: record a session heartbeat + token sample into the shared
581
+ // machine-wide index.sqlite so the dashboard can show a real-time
582
+ // stacked-memory graph across all active pi processes. Behind the
583
+ // material-change gate (this code only runs when sig changed). Non-fatal
584
+ // try/catch mirrors the recordPerfSample pattern below. Skip the token
585
+ // sample when lastCtxTokens is null (no context data yet).
586
+ try {
587
+ const repo = resolveRepoRoot(ctx?.cwd ?? this.currentStateDir) ?? this.currentStateDir;
588
+ recordSessionHeartbeat(
589
+ process.pid,
590
+ this.rt.sessionId,
591
+ repo,
592
+ this.currentStateDir,
593
+ this.lastCtxWindow || 0,
594
+ );
595
+ if (this.lastCtxTokens != null) {
596
+ appendTokenSample(
597
+ this.rt.sessionId,
598
+ repo,
599
+ this.lastCtxTokens,
600
+ this.lastCtxPercent ?? 0,
601
+ this.lastCtxWindow || 0,
602
+ join(this.currentStateDir, "events.log"),
603
+ );
604
+ }
605
+ } catch {
606
+ /* non-fatal: S39 monitoring must never block the snapshot path */
607
+ }
608
+
578
609
  // Live stats widget above the editor
579
610
  if (ctx) {
580
611
  // ── gather widget data (computed per snapshot, rendered per frame) ────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
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",