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.
- package/README.md +1 -1
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +12 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +19 -6
- package/dist/extensions/dashboard-server/helpers.js +37 -0
- package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
- package/dist/extensions/dashboard-server/html/body-open.js +23 -0
- package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
- package/dist/extensions/dashboard-server/html/head-open.js +16 -0
- package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
- package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
- package/dist/extensions/dashboard-server/html/script.js +259 -0
- package/dist/extensions/dashboard-server/html/styles.js +103 -0
- package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
- package/dist/extensions/dashboard-server/html-template.js +41 -0
- package/dist/extensions/dashboard-server/server.js +83 -0
- package/dist/extensions/mega-compact-s38.test.js +33 -0
- package/dist/extensions/mega-events/agent-handlers.js +51 -3
- package/dist/extensions/mega-events/compact-handlers.js +10 -4
- package/dist/extensions/mega-events/error-classifier.js +24 -1
- package/dist/extensions/mega-events/send-safe.js +12 -0
- package/dist/extensions/mega-events.js +1 -0
- package/dist/extensions/mega-runtime/state.js +17 -1
- package/dist/src/store/sqlite/connection.js +35 -0
- package/dist/src/store/sqlite/global-index.js +199 -1
- package/dist/src/store/sqlite/global-index.test.js +136 -0
- package/dist/src/store/sqlite/index-store.js +167 -0
- package/dist/src/store/sqlite/memory.js +54 -0
- package/dist/src/store/sqlite/minhash-lsh.js +47 -0
- package/dist/src/store/sqlite/sessions.js +39 -0
- package/dist/src/store/sqlite/transaction.js +19 -0
- package/dist/src/vectorStore/add.js +260 -0
- package/dist/src/vectorStore/dedup.js +52 -0
- package/dist/src/vectorStore/index.js +10 -0
- package/dist/src/vectorStore/queries.js +83 -0
- package/dist/src/vectorStore/search.js +95 -0
- package/dist/src/vectorStore/session.js +19 -0
- package/dist/src/vectorStore/store.js +105 -0
- package/dist/src/vectorStore/types.js +6 -0
- package/dist/src/vectorStore/utils.js +23 -0
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-CNWZ5NjP.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-CNWZ5NjP.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js → AchievementsTab-BKPifNKj.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-BKPifNKj.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab--epzrQHD.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab--epzrQHD.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-D3ralwSj.js} +2 -2
- package/extensions/dashboard-client/dist/assets/ConfigTab-D3ralwSj.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js +2 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CgUvITYy.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CgUvITYy.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-DrlJqXGL.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-DrlJqXGL.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js → OverviewTab-_gD756wJ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js.map → OverviewTab-_gD756wJ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-DjEZmqUb.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-DjEZmqUb.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js +70 -0
- package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BbAXT1wO.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BbAXT1wO.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-BTft6DM_.css} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-CnRzusfi.js} +10 -10
- package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-CnRzusfi.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js +2 -0
- package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js.map +1 -0
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/package-lock.json +374 -2
- package/extensions/dashboard-client/package.json +2 -1
- package/extensions/dashboard-client/src/App.tsx +5 -1
- package/extensions/dashboard-client/src/api/client.ts +12 -0
- package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
- package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
- package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/sessions.css +193 -0
- package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
- package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
- package/extensions/dashboard-server/api-contracts/core.ts +25 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
- package/extensions/dashboard-server/api-contracts/index.ts +10 -1
- package/extensions/dashboard-server/api-contracts.test.ts +20 -7
- package/extensions/dashboard-server/server.ts +100 -0
- package/extensions/mega-compact-s38.test.ts +63 -0
- package/extensions/mega-events/agent-handlers.ts +47 -3
- package/extensions/mega-events/compact-handlers.ts +11 -4
- package/extensions/mega-events/error-classifier.ts +25 -1
- package/extensions/mega-events/send-safe.ts +37 -0
- package/extensions/mega-events.ts +1 -0
- package/extensions/mega-runtime/state.ts +31 -0
- package/package.json +1 -1
- package/src/store/sqlite/global-index.test.ts +222 -0
- package/src/store/sqlite/global-index.ts +316 -1
- package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
- package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
- package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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.
|
|
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
|
+
});
|