pi-mega-compact 0.8.16 → 0.8.18

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 (98) hide show
  1. package/README.md +1 -1
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +12 -0
  3. package/dist/extensions/dashboard-server/api-contracts.test.js +19 -6
  4. package/dist/extensions/dashboard-server/helpers.js +37 -0
  5. package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
  6. package/dist/extensions/dashboard-server/html/body-open.js +23 -0
  7. package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
  8. package/dist/extensions/dashboard-server/html/head-open.js +16 -0
  9. package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
  10. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
  11. package/dist/extensions/dashboard-server/html/script.js +259 -0
  12. package/dist/extensions/dashboard-server/html/styles.js +103 -0
  13. package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
  14. package/dist/extensions/dashboard-server/html-template.js +41 -0
  15. package/dist/extensions/dashboard-server/server.js +83 -0
  16. package/dist/extensions/mega-compact-s38.test.js +33 -0
  17. package/dist/extensions/mega-events/agent-handlers.js +51 -3
  18. package/dist/extensions/mega-events/compact-handlers.js +10 -4
  19. package/dist/extensions/mega-events/error-classifier.js +24 -1
  20. package/dist/extensions/mega-events/send-safe.js +12 -0
  21. package/dist/extensions/mega-events.js +1 -0
  22. package/dist/extensions/mega-runtime/state.js +17 -1
  23. package/dist/src/store/sqlite/connection.js +35 -0
  24. package/dist/src/store/sqlite/global-index.js +199 -1
  25. package/dist/src/store/sqlite/global-index.test.js +136 -0
  26. package/dist/src/store/sqlite/index-store.js +167 -0
  27. package/dist/src/store/sqlite/memory.js +54 -0
  28. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  29. package/dist/src/store/sqlite/sessions.js +39 -0
  30. package/dist/src/store/sqlite/transaction.js +19 -0
  31. package/dist/src/vectorStore/add.js +260 -0
  32. package/dist/src/vectorStore/dedup.js +52 -0
  33. package/dist/src/vectorStore/index.js +10 -0
  34. package/dist/src/vectorStore/queries.js +83 -0
  35. package/dist/src/vectorStore/search.js +95 -0
  36. package/dist/src/vectorStore/session.js +19 -0
  37. package/dist/src/vectorStore/store.js +105 -0
  38. package/dist/src/vectorStore/types.js +6 -0
  39. package/dist/src/vectorStore/utils.js +23 -0
  40. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-CNWZ5NjP.js} +2 -2
  41. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-CNWZ5NjP.js.map} +1 -1
  42. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js → AchievementsTab-BKPifNKj.js} +2 -2
  43. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-BKPifNKj.js.map} +1 -1
  44. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab--epzrQHD.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab--epzrQHD.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-D3ralwSj.js} +2 -2
  47. package/extensions/dashboard-client/dist/assets/ConfigTab-D3ralwSj.js.map +1 -0
  48. package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js +2 -0
  49. package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js.map +1 -0
  50. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CgUvITYy.js} +2 -2
  51. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CgUvITYy.js.map} +1 -1
  52. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-DrlJqXGL.js} +2 -2
  53. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-DrlJqXGL.js.map} +1 -1
  54. package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js → OverviewTab-_gD756wJ.js} +2 -2
  55. package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js.map → OverviewTab-_gD756wJ.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-DjEZmqUb.js} +2 -2
  57. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-DjEZmqUb.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js +70 -0
  59. package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js.map +1 -0
  60. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BbAXT1wO.js} +2 -2
  61. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BbAXT1wO.js.map} +1 -1
  62. package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-BTft6DM_.css} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-CnRzusfi.js} +10 -10
  64. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-CnRzusfi.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js +2 -0
  66. package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js.map +1 -0
  67. package/extensions/dashboard-client/dist/index.html +2 -2
  68. package/extensions/dashboard-client/package-lock.json +374 -2
  69. package/extensions/dashboard-client/package.json +2 -1
  70. package/extensions/dashboard-client/src/App.tsx +5 -1
  71. package/extensions/dashboard-client/src/api/client.ts +12 -0
  72. package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
  73. package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
  74. package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
  75. package/extensions/dashboard-client/src/main.tsx +1 -0
  76. package/extensions/dashboard-client/src/styles/sessions.css +193 -0
  77. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
  78. package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
  79. package/extensions/dashboard-server/api-contracts/core.ts +25 -0
  80. package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
  81. package/extensions/dashboard-server/api-contracts/index.ts +10 -1
  82. package/extensions/dashboard-server/api-contracts.test.ts +20 -7
  83. package/extensions/dashboard-server/server.ts +100 -0
  84. package/extensions/mega-compact-s38.test.ts +63 -0
  85. package/extensions/mega-events/agent-handlers.ts +47 -3
  86. package/extensions/mega-events/compact-handlers.ts +11 -4
  87. package/extensions/mega-events/error-classifier.ts +25 -1
  88. package/extensions/mega-events/send-safe.ts +37 -0
  89. package/extensions/mega-events.ts +1 -0
  90. package/extensions/mega-runtime/state.ts +31 -0
  91. package/package.json +1 -1
  92. package/src/store/sqlite/global-index.test.ts +222 -0
  93. package/src/store/sqlite/global-index.ts +316 -1
  94. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  95. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
  96. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
  97. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
  98. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js.map +0 -1
@@ -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";
@@ -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.16",
3
+ "version": "0.8.18",
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",
@@ -0,0 +1,222 @@
1
+ /**
2
+ * global-index.test.ts — S39 session heartbeats + token_samples round-trip.
3
+ *
4
+ * Verifies recordSessionHeartbeat + appendTokenSample + readActiveSessions +
5
+ * readSessionTimeseries + pruneStaleSessions + pruneTokenSamples +
6
+ * clearSessionHeartbeat, with particular focus on the Step 5 events.log
7
+ * session_sample JSONL line shape (the SSE real-time push wire format).
8
+ *
9
+ * Pi-agnostic. Uses an isolated mock index dir (never the real user
10
+ * `~/.mega-compact-index/` — guardrail G7). PREVENT-PI-004 (zero network):
11
+ * every operation is local SQLite + local FS append.
12
+ */
13
+ import { describe, it, before, after } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
18
+
19
+ import {
20
+ recordSessionHeartbeat,
21
+ appendTokenSample,
22
+ readActiveSessions,
23
+ readSessionTimeseries,
24
+ pruneStaleSessions,
25
+ pruneTokenSamples,
26
+ clearSessionHeartbeat,
27
+ closeIndexStore,
28
+ } from "./global-index.js";
29
+
30
+ describe("global-index session time-series (S39)", () => {
31
+ let indexDir: string;
32
+ let eventsLogPath: string;
33
+
34
+ before(() => {
35
+ indexDir = mkdtempSync(join(tmpdir(), "mc-global-idx-"));
36
+ eventsLogPath = join(indexDir, "events.log");
37
+ });
38
+
39
+ after(() => {
40
+ closeIndexStore();
41
+ rmSync(indexDir, { recursive: true, force: true });
42
+ });
43
+
44
+ it("recordSessionHeartbeat + readActiveSessions: heartbeat row appears", () => {
45
+ recordSessionHeartbeat(
46
+ 12_345,
47
+ "sess-abc-1",
48
+ "/home/u/repos/proj-a",
49
+ "/home/u/.pi/state-a",
50
+ 200_000,
51
+ indexDir,
52
+ );
53
+ const sessions = readActiveSessions(indexDir);
54
+ assert.equal(sessions.length, 1);
55
+ const s = sessions[0];
56
+ assert.equal(s.pid, 12_345);
57
+ assert.equal(s.sessionId, "sess-abc-1");
58
+ assert.equal(s.repoRoot, "/home/u/repos/proj-a");
59
+ assert.equal(s.stateDir, "/home/u/.pi/state-a");
60
+ assert.equal(s.ctxWindow, 200_000);
61
+ assert.ok(s.lastSeen > 0);
62
+ // No token sample yet → tokens/percent are null.
63
+ assert.equal(s.tokens, null);
64
+ assert.equal(s.percent, null);
65
+ });
66
+
67
+ it("appendTokenSample writes a token_samples row + a session_sample JSONL line", () => {
68
+ appendTokenSample(
69
+ "sess-abc-1",
70
+ "/home/u/repos/proj-a",
71
+ 42_000,
72
+ 21.5,
73
+ 200_000,
74
+ eventsLogPath,
75
+ indexDir,
76
+ );
77
+ // The token_samples row should now be the latest sample for this session.
78
+ const sessions = readActiveSessions(indexDir);
79
+ const s = sessions.find((x) => x.sessionId === "sess-abc-1");
80
+ assert.ok(s, "session must still appear in readActiveSessions");
81
+ assert.equal(s!.tokens, 42_000);
82
+ assert.equal(s!.percent, 21.5);
83
+
84
+ // The events.log JSONL line should exist and match the wire format
85
+ // described by the SseSessionSample contract: an object with `ts`
86
+ // (ISO 8601 string), `type: 'session_sample'`, and the payload fields.
87
+ assert.ok(existsSync(eventsLogPath), "events.log should exist");
88
+ const lines = readFileSync(eventsLogPath, "utf-8")
89
+ .trim()
90
+ .split("\n")
91
+ .filter(Boolean);
92
+ assert.equal(lines.length, 1, "exactly one JSONL line expected");
93
+ const parsed = JSON.parse(lines[0]) as Record<string, unknown>;
94
+ assert.equal(parsed["type"], "session_sample");
95
+ assert.equal(parsed["sessionId"], "sess-abc-1");
96
+ assert.equal(parsed["tokens"], 42_000);
97
+ assert.equal(parsed["percent"], 21.5);
98
+ // Step 5 contract mirroring: `ts` is an ISO 8601 string (matches every
99
+ // other SseEvent variant's `ts: string` and the DashboardEmitter
100
+ // events.log pattern), NOT a numeric ms value.
101
+ assert.equal(
102
+ typeof parsed["ts"],
103
+ "string",
104
+ "ts must be an ISO 8601 string (matches other SseEvent variants)",
105
+ );
106
+ // Must be ISO-parseable (NaN if invalid → typeof won't be number).
107
+ const tsMs = Date.parse(parsed["ts"] as string);
108
+ assert.ok(
109
+ Number.isFinite(tsMs),
110
+ "ts must be a valid ISO 8601 timestamp",
111
+ );
112
+ // And recent (within the last 5s).
113
+ assert.ok(
114
+ Math.abs(Date.now() - tsMs) < 5_000,
115
+ "ts must be within ~5s of now",
116
+ );
117
+ });
118
+
119
+ it("readSessionTimeseries returns per-session series + totals in chronological order", () => {
120
+ // Two sessions, two samples each — fully within the rolling window.
121
+ // sess-xyz-9 needs its own heartbeat row so readActiveSessions can later
122
+ // surface it (heartbeats are the primary key, not the samples).
123
+ recordSessionHeartbeat(
124
+ 67_890,
125
+ "sess-xyz-9",
126
+ "/home/u/repos/proj-b",
127
+ "/home/u/.pi/state-b",
128
+ 200_000,
129
+ indexDir,
130
+ );
131
+ appendTokenSample(
132
+ "sess-abc-1",
133
+ "/home/u/repos/proj-a",
134
+ 50_000,
135
+ 25,
136
+ 200_000,
137
+ null, // no events.log line for this assertion
138
+ indexDir,
139
+ );
140
+ appendTokenSample(
141
+ "sess-xyz-9",
142
+ "/home/u/repos/proj-b",
143
+ 10_000,
144
+ 5,
145
+ 200_000,
146
+ null,
147
+ indexDir,
148
+ );
149
+ appendTokenSample(
150
+ "sess-xyz-9",
151
+ "/home/u/repos/proj-b",
152
+ 30_000,
153
+ 15,
154
+ 200_000,
155
+ null,
156
+ indexDir,
157
+ );
158
+ const since = Date.now() - 60_000;
159
+ const result = readSessionTimeseries(since, indexDir);
160
+
161
+ const seriesIds = result.series.map((s) => s.sessionId).sort();
162
+ assert.deepEqual(seriesIds, ["sess-abc-1", "sess-xyz-9"]);
163
+
164
+ const xyz = result.series.find((s) => s.sessionId === "sess-xyz-9");
165
+ assert.ok(xyz, "sess-xyz-9 series must exist");
166
+ assert.equal(xyz!.data.length, 2);
167
+ assert.equal(xyz!.data[0].tokens, 10_000);
168
+ assert.equal(xyz!.data[1].tokens, 30_000);
169
+ // Stable hash-based color from the SESSION_COLORS palette.
170
+ assert.match(xyz!.color, /^#[0-9a-f]{6}$/i);
171
+
172
+ // Totals: each timestamp summed across sessions.
173
+ assert.ok(result.totals.length >= 2);
174
+ for (const t of result.totals) {
175
+ assert.ok(typeof t.ts === "number" && t.ts > 0);
176
+ assert.ok(typeof t.tokens === "number" && t.tokens >= 0);
177
+ }
178
+ // Totals are sorted ascending by ts.
179
+ for (let i = 1; i < result.totals.length; i++) {
180
+ assert.ok(
181
+ result.totals[i].ts >= result.totals[i - 1].ts,
182
+ "totals must be chronologically ascending",
183
+ );
184
+ }
185
+ });
186
+
187
+ it("pruneStaleSessions clears heartbeats older than the cutoff (and only those)", () => {
188
+ // At this point we have 2 sessions; both are fresh (lastSeen ≈ now).
189
+ const beforeCount = readActiveSessions(indexDir).length;
190
+ assert.equal(beforeCount, 2);
191
+
192
+ // With a 0 ms cutoff, both are stale → pruned.
193
+ const pruned = pruneStaleSessions(0, indexDir);
194
+ assert.equal(pruned, 2, "both heartbeats should be pruned");
195
+ assert.equal(readActiveSessions(indexDir).length, 0);
196
+ });
197
+
198
+ it("pruneTokenSamples clears token_samples older than the cutoff", () => {
199
+ // Re-seed two samples; the previously-written ones were for two
200
+ // sessions (sess-abc-1 + sess-xyz-9). The prunedStaleSessions call
201
+ // above left token_samples untouched (separate table).
202
+ const sinceBefore = Date.now() - 60_000;
203
+ const before = readSessionTimeseries(sinceBefore, indexDir);
204
+ const beforeCount = before.series.reduce((n, s) => n + s.data.length, 0);
205
+ assert.ok(beforeCount >= 3, "expected to find the seeded samples");
206
+
207
+ const npurged = pruneTokenSamples(0, indexDir);
208
+ assert.ok(npurged >= 3, "should have pruned the seeded samples");
209
+
210
+ const after = readSessionTimeseries(sinceBefore, indexDir);
211
+ const afterCount = after.series.reduce((n, s) => n + s.data.length, 0);
212
+ assert.equal(afterCount, 0, "no samples should remain after pruning");
213
+ });
214
+
215
+ it("clearSessionHeartbeat is a no-op on missing rows (never throws)", () => {
216
+ const before = readActiveSessions(indexDir).length;
217
+ assert.doesNotThrow(() =>
218
+ clearSessionHeartbeat(99_999, "nonexistent-session", indexDir),
219
+ );
220
+ assert.equal(readActiveSessions(indexDir).length, before);
221
+ });
222
+ });