pi-mega-compact 0.8.22 → 0.8.24

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 (63) hide show
  1. package/LICENSE +6 -2
  2. package/README.md +1 -1
  3. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +8 -0
  4. package/dist/extensions/dashboard-server/api-contracts/game-types.js +7 -0
  5. package/dist/extensions/mega-runtime/append-event.js +24 -0
  6. package/dist/extensions/mega-runtime/bind-repo.js +65 -0
  7. package/dist/extensions/mega-runtime/capture-model.js +87 -0
  8. package/dist/extensions/mega-runtime/dashboard-snapshot.js +118 -0
  9. package/dist/extensions/mega-runtime/effects.js +86 -0
  10. package/dist/extensions/mega-runtime/engine-view.js +11 -0
  11. package/dist/extensions/mega-runtime/game-state.js +116 -0
  12. package/dist/extensions/mega-runtime/get-state-dir.js +10 -0
  13. package/dist/extensions/mega-runtime/perf.js +49 -0
  14. package/dist/extensions/mega-runtime/pressure-getters.js +64 -0
  15. package/dist/extensions/mega-runtime/render-widget.js +17 -0
  16. package/dist/extensions/mega-runtime/reset-runtime.js +50 -0
  17. package/dist/extensions/mega-runtime/runtime-helpers.js +73 -0
  18. package/dist/extensions/mega-runtime/runtime-snapshot.js +204 -0
  19. package/dist/extensions/mega-runtime/runtime.js +352 -0
  20. package/dist/extensions/mega-runtime/snapshot.js +142 -0
  21. package/dist/extensions/mega-runtime/state.js +5 -1151
  22. package/dist/extensions/mega-runtime/status.js +11 -0
  23. package/dist/extensions/mega-runtime/widget-ansi.js +207 -0
  24. package/dist/extensions/mega-runtime/widget-types.js +8 -0
  25. package/dist/extensions/mega-runtime/widget.js +15 -204
  26. package/dist/extensions/openclaw-mega-compact.js +291 -0
  27. package/dist/src/dedup/raptor/multilevel.js +172 -0
  28. package/dist/src/dedup/raptor/multilevel.test.js +203 -0
  29. package/dist/src/dedup/raptor/retrieval.js +1 -1
  30. package/dist/src/minilm.js +92 -0
  31. package/dist/src/wordpiece.js +129 -0
  32. package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +1 -1
  33. package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -155
  34. package/extensions/dashboard-server/api-contracts/game-types.ts +172 -0
  35. package/extensions/mega-runtime/DECOMPOSITION.md +180 -0
  36. package/extensions/mega-runtime/README.md +38 -0
  37. package/extensions/mega-runtime/append-event.ts +40 -0
  38. package/extensions/mega-runtime/bind-repo.ts +81 -0
  39. package/extensions/mega-runtime/capture-model.ts +101 -0
  40. package/extensions/mega-runtime/dashboard-snapshot.ts +173 -0
  41. package/extensions/mega-runtime/effects.ts +129 -0
  42. package/extensions/mega-runtime/engine-view.ts +17 -0
  43. package/extensions/mega-runtime/game-state.ts +149 -0
  44. package/extensions/mega-runtime/get-state-dir.ts +19 -0
  45. package/extensions/mega-runtime/perf.ts +60 -0
  46. package/extensions/mega-runtime/pressure-getters.ts +96 -0
  47. package/extensions/mega-runtime/render-widget.ts +41 -0
  48. package/extensions/mega-runtime/reset-runtime.ts +80 -0
  49. package/extensions/mega-runtime/runtime-helpers.ts +119 -0
  50. package/extensions/mega-runtime/runtime-snapshot.ts +289 -0
  51. package/extensions/mega-runtime/runtime.ts +437 -0
  52. package/extensions/mega-runtime/snapshot.ts +230 -0
  53. package/extensions/mega-runtime/state.ts +5 -1268
  54. package/extensions/mega-runtime/status.ts +26 -0
  55. package/extensions/mega-runtime/widget-ansi.ts +217 -0
  56. package/extensions/mega-runtime/widget-types.ts +80 -0
  57. package/extensions/mega-runtime/widget.ts +34 -285
  58. package/package.json +2 -2
  59. package/src/dedup/raptor/multilevel.test.ts +278 -0
  60. package/src/dedup/raptor/multilevel.ts +246 -0
  61. package/src/dedup/raptor/retrieval.ts +1 -1
  62. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  63. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
@@ -0,0 +1,60 @@
1
+ /**
2
+ * perf.ts — extracted perf-interval sampling for MegaRuntime.
3
+ *
4
+ * The 5s cpu/mem sampling interval + its teardown. Keeps the setInterval
5
+ * boilerplate out of the main state.ts class body.
6
+ */
7
+
8
+ import { recordPerfSample } from "../../src/store/sqlite.js";
9
+
10
+ // ---------------------------------------------------------------------- types
11
+
12
+ export interface PerfContext {
13
+ readonly currentStateDir: string;
14
+ perfCpuInterval: NodeJS.Timeout | null;
15
+ perfCpuBaseline: { user: number; sys: number } | undefined;
16
+ }
17
+
18
+ // ---------------------------------------------------------- ensurePerfInterval
19
+
20
+ /** v0.8.8: (re)start the 5s cpu/mem sampling interval (idempotent). One per
21
+ * MegaRuntime; cleared in disposePerf(). Samples process.cpuUsage() (user/sys
22
+ * delta vs the last tick → ms) + process.memoryUsage() (rss/heap → MB) and
23
+ * records them as perf_samples. unref'd so it never keeps the process alive
24
+ * on its own. Non-fatal: any failure is swallowed (instrumentation never
25
+ * blocks the agent). PREVENT-PI-004: local process stats + SQLite only. */
26
+ export function ensurePerfIntervalImpl(ctx: PerfContext): void {
27
+ if (ctx.perfCpuInterval) return;
28
+ ctx.perfCpuBaseline = undefined; // first tick sets the baseline (no delta)
29
+ ctx.perfCpuInterval = setInterval(() => {
30
+ try {
31
+ const dir = ctx.currentStateDir;
32
+ const cpu = process.cpuUsage();
33
+ const mem = process.memoryUsage();
34
+ if (ctx.perfCpuBaseline) {
35
+ const du = (cpu.user - ctx.perfCpuBaseline.user) / 1000; // μs → ms
36
+ const ds = (cpu.system - ctx.perfCpuBaseline.sys) / 1000;
37
+ recordPerfSample(dir, "cpu_user_ms", Math.max(0, du));
38
+ recordPerfSample(dir, "cpu_sys_ms", Math.max(0, ds));
39
+ }
40
+ ctx.perfCpuBaseline = { user: cpu.user, sys: cpu.system };
41
+ recordPerfSample(dir, "rss_mb", mem.rss / 1_000_000);
42
+ recordPerfSample(dir, "heap_mb", mem.heapUsed / 1_000_000);
43
+ } catch {
44
+ /* non-fatal */
45
+ }
46
+ }, 5000);
47
+ ctx.perfCpuInterval.unref?.();
48
+ }
49
+
50
+ // ------------------------------------------------------------------ disposePerf
51
+
52
+ /** Stop the cpu/mem sampling interval on teardown. Re-armed lazily by
53
+ * ensurePerfInterval() on the next turn_start. */
54
+ export function disposePerf(ctx: PerfContext): void {
55
+ if (ctx.perfCpuInterval) {
56
+ clearInterval(ctx.perfCpuInterval);
57
+ ctx.perfCpuInterval = null;
58
+ ctx.perfCpuBaseline = undefined;
59
+ }
60
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * pressure-getters.ts — extracted pressure accessors from the `MegaRuntime`
3
+ * class (runtime.ts): the `pressure` / `effectiveThreshold` / `pressureBand`
4
+ * getters, so the class body shrinks and the threshold logic is independently
5
+ * testable.
6
+ *
7
+ * Follows the same context-interface + free-function + thin-delegate pattern as
8
+ * effects.ts / game-state.ts / capture-model.ts / bind-repo.ts / perf.ts /
9
+ * runtime-helpers.ts.
10
+ */
11
+
12
+ import {
13
+ pressureRatio,
14
+ pressureFromPct,
15
+ pressureBand,
16
+ effectiveThresholdTokens,
17
+ type MegaConfig,
18
+ type PressureBand,
19
+ } from "../mega-config.js";
20
+
21
+ // ---------------------------------------------------------------------- types
22
+
23
+ /**
24
+ * The slice of `MegaRuntime` the pressure accessors read. `MegaRuntime`
25
+ * satisfies this structurally with no visibility changes — every field was
26
+ * already public.
27
+ */
28
+ export interface PressureContext {
29
+ config: MegaConfig;
30
+ lastCtxTokens: number | null;
31
+ lastCtxPercent: number | null;
32
+ lastCtxWindow: number;
33
+ }
34
+
35
+ // ------------------------------------------------------------------ pressure
36
+
37
+ /**
38
+ * Live 0–1 pressure — how full the context window is relative to the
39
+ * compaction threshold.
40
+ *
41
+ * RECONCILE (BACKLOG dual-basis flicker): when the model context window is
42
+ * known we base pressure consistently on the *percentage* basis
43
+ * (`lastCtxPercent / (tierPct*100)`). This keeps the band stable whether the
44
+ * latest context event carried a token count or only a percentage, so the
45
+ * threshold comparison doesn't jump when a token-count event arrives vs a
46
+ * percent-only event. We only fall back to the token-count basis
47
+ * (`config.thresholdTokens`) when the window is unknown (e.g. before the first
48
+ * context event, or a `custom` tier with no tierPct). Always finite + in [0,1].
49
+ */
50
+ export function pressureImpl(self: PressureContext): number {
51
+ if (
52
+ self.lastCtxWindow > 0 &&
53
+ self.config.tierPct != null &&
54
+ self.lastCtxPercent != null
55
+ ) {
56
+ // pressureFromPct(x) = x/100, and x = lastCtxPercent/tierPct, so this is
57
+ // exactly the intended lastCtxPercent/(tierPct*100) 0–1 ratio: at the
58
+ // fire point (lastCtxPercent == tierPct*100) pressure == 1.0, matching the
59
+ // token-based pressureRatio(currentTokens, effectiveThreshold) reading so
60
+ // the band doesn't jump when a token-count vs percent-only event arrives.
61
+ return pressureFromPct(self.lastCtxPercent / self.config.tierPct);
62
+ }
63
+ if (
64
+ self.lastCtxTokens != null &&
65
+ self.lastCtxTokens > 0 &&
66
+ self.config.thresholdTokens > 0
67
+ ) {
68
+ return pressureRatio(self.lastCtxTokens, self.config.thresholdTokens);
69
+ }
70
+ return pressureFromPct(self.lastCtxPercent);
71
+ }
72
+
73
+ // -------------------------------------------------------- effectiveThreshold
74
+
75
+ /**
76
+ * The live compaction FIRE POINT in tokens: the effective threshold scaled by
77
+ * the current model context window (`tierPct * window`) when known, else the
78
+ * boot fallback `config.thresholdTokens`. This is what the FAST GATE /
79
+ * `autoCompactCheck` / agent_end durable-trigger compare against, so
80
+ * compaction fires at tier% of the window for ANY model size (200k or 1M),
81
+ * always below pi's native auto-compaction (~80% of window).
82
+ */
83
+ export function effectiveThresholdImpl(self: PressureContext): number {
84
+ return effectiveThresholdTokens({
85
+ tierPct: self.config.tierPct,
86
+ fallbackThreshold: self.config.thresholdTokens,
87
+ window: self.lastCtxWindow,
88
+ });
89
+ }
90
+
91
+ // -------------------------------------------------------------- pressureBand
92
+
93
+ /** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
94
+ export function pressureBandImpl(self: PressureContext): PressureBand {
95
+ return pressureBand(pressureImpl(self));
96
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * render-widget.ts — extracted `MegaRuntime.renderWidget()`: the width-aware
3
+ * above-editor widget factory registration. Same thin-delegate pattern as the
4
+ * other runtime.ts extractions.
5
+ */
6
+
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { WIDGET_KEY } from "./helpers.js";
9
+ import { buildWidgetLines, type WidgetData } from "./widget.js";
10
+
11
+ // ---------------------------------------------------------------------- types
12
+
13
+ /** The slice of `MegaRuntime` renderWidget reads at render time. */
14
+ export interface RenderWidgetContext {
15
+ readonly widgetData: WidgetData | null;
16
+ readonly activeAgents: number;
17
+ }
18
+
19
+ // -------------------------------------------------------------- renderWidget
20
+
21
+ /** Register the above-editor widget as a width-aware factory so pi re-renders
22
+ * it at the REAL terminal width every frame (auto-fit wide/narrow). The
23
+ * factory returns a minimal Component whose render() reads self.widgetData. */
24
+ export function renderWidgetImpl(
25
+ self: RenderWidgetContext,
26
+ ctx: ExtensionContext,
27
+ ): void {
28
+ ctx.ui.setWidget(
29
+ WIDGET_KEY,
30
+ (_tui, _theme) => ({
31
+ render: (width: number) =>
32
+ buildWidgetLines(
33
+ self.widgetData,
34
+ width > 0 ? width : 200,
35
+ self.activeAgents,
36
+ ),
37
+ invalidate: () => {},
38
+ }),
39
+ { placement: "aboveEditor" },
40
+ );
41
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * reset-runtime.ts — extracted `MegaRuntime.resetRuntime()`: the per-session
3
+ * state reset used by the session_start / session_tree handlers. The class
4
+ * keeps a thin `resetRuntimeImpl(this, sessionId)` delegate so every call
5
+ * site is unchanged.
6
+ *
7
+ * Follows the same context-interface + free-function + thin-delegate pattern as
8
+ * effects.ts / game-state.ts / capture-model.ts / bind-repo.ts / perf.ts /
9
+ * runtime-helpers.ts.
10
+ */
11
+
12
+ import { normalizeSessionId } from "../../src/store.js";
13
+ import type { TickerEntry } from "./widget.js";
14
+ import type { GameState } from "../../src/store/sqlite.js";
15
+ import type { SessionRuntime } from "./helpers.js";
16
+
17
+ // ---------------------------------------------------------------------- types
18
+
19
+ /**
20
+ * The slice of `MegaRuntime` resetRuntime mutates. `trimCache` is typed
21
+ * `unknown` — this function only ever *clears* it, so the precise
22
+ * snapshot-cache shape does not need to be imported.
23
+ */
24
+ export interface ResetRuntimeContext {
25
+ rt: SessionRuntime;
26
+ trimCache: unknown;
27
+ ticker: TickerEntry[];
28
+ cachedGameState: GameState | undefined;
29
+ statusKey: string | undefined;
30
+ activeAgents: number;
31
+ currentTurn: number;
32
+ lastActivityAt: number;
33
+ tierTrace: string | undefined;
34
+ pulsing: boolean;
35
+ savedGoal: number;
36
+ lastWhy: string | undefined;
37
+ }
38
+
39
+ // --------------------------------------------------------------- resetRuntime
40
+
41
+ export function resetRuntimeImpl(
42
+ self: ResetRuntimeContext,
43
+ sessionId: string | undefined,
44
+ ): void {
45
+ const sid = normalizeSessionId(sessionId);
46
+ if (self.rt.sessionId === sid && self.rt.persistedThisSession) return; // same session, keep checkpoint memory
47
+ self.rt = {
48
+ sessionId: sid,
49
+ persistedThisSession: false,
50
+ lastCheckpointId: undefined,
51
+ lastCompactedFrom: 0,
52
+ lastCompactedTokens: 0,
53
+ dedupSkips: 0,
54
+ dedupAttempts: 0,
55
+ tokensSaved: 0,
56
+ lastCompactAt: null,
57
+ lastNativeCompactAt: null,
58
+ compactCount: 0,
59
+ recallInjections: 0,
60
+ cacheHitTokens: 0,
61
+ lengthStopPending: false,
62
+ errorRetryCount: 0,
63
+ errorRetryUntil: 0,
64
+ consecutiveErrors: 0,
65
+ };
66
+ self.trimCache = null; // v0.8.6: never replay a stale trim into a new session
67
+ self.statusKey = undefined;
68
+ self.activeAgents = 0;
69
+ self.currentTurn = 0;
70
+ self.lastActivityAt = 0;
71
+ self.tierTrace = undefined;
72
+ self.ticker.length = 0;
73
+ self.pulsing = false;
74
+ self.savedGoal = 50_000;
75
+ self.lastWhy = undefined;
76
+ // S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
77
+ // that re-binds the repo, so drop the memo too. Cheap; the next
78
+ // getCachedGameState() re-queries lazily.
79
+ self.cachedGameState = undefined;
80
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * runtime-helpers.ts — extracted private helpers from the `MegaRuntime` class
3
+ * (runtime.ts) so the class body shrinks and the pure/instance logic is
4
+ * independently testable.
5
+ *
6
+ * Follows the same context-interface + free-function + thin-delegate pattern as
7
+ * effects.ts / game-state.ts / capture-model.ts / bind-repo.ts / perf.ts.
8
+ */
9
+
10
+ import type { SessionRuntime } from "./helpers.js";
11
+ import type { TickerEntry } from "./widget-types.js";
12
+ import type { ModelSnapshot } from "../../src/store/sqlite.js";
13
+ import { detectCrossRepoDrift } from "../../src/driftDetection.js";
14
+ import { turnLevel } from "../../src/game/scoring.js";
15
+
16
+ // ---------------------------------------------------------------------- types
17
+
18
+ /**
19
+ * The slice of `MegaRuntime` the extracted helpers read (and, for `driftStatus`,
20
+ * write). `MegaRuntime` satisfies this structurally once `driftCache` is public.
21
+ */
22
+ export interface RuntimeHelpersContext {
23
+ rt: SessionRuntime;
24
+ activeEffect: {
25
+ type: "pulse" | "flash";
26
+ role: "accent" | "mega" | "red";
27
+ startedAt: number;
28
+ durationMs: number;
29
+ } | null;
30
+ lastCtxTokens: number | null;
31
+ lastCtxPercent: number | null;
32
+ lastCtxWindow: number;
33
+ activeAgents: number;
34
+ currentTurn: number;
35
+ statusKey: string | undefined;
36
+ currentModel: ModelSnapshot | undefined;
37
+ gameStateBump: number;
38
+ megaCacheFlare: boolean;
39
+ megaCacheFlarePct: number;
40
+ levelUpFlare: boolean;
41
+ achievementFlare: boolean;
42
+ achievementFlareTitles: string[];
43
+ tierTrace: string | undefined;
44
+ lastWhy: string | undefined;
45
+ pulsing: boolean;
46
+ ticker: TickerEntry[];
47
+ /** Cross-repo drift cache (30s TTL). Mutated by `driftStatusImpl`. */
48
+ driftCache: { at: number; status: "ok" | "warn" } | null;
49
+ }
50
+
51
+ // -------------------------------------------------------------- materialSig
52
+
53
+ /** v0.8.5: cheap material-change signature over live runtime fields (no
54
+ * SQLite). Two snapshots with the same signature produce identical
55
+ * dashboard.json + widgetData, so the 6 synchronous SQLite opens + the
56
+ * writeFileSync(dashboard.json) can be skipped. Built from in-memory state
57
+ * only; `gameStateBump` covers cross-process game_state edits (fs.watch) +
58
+ * in-process /mega-game writes (bumpGameState) + repo switches (bindRepo).
59
+ * The transient flare flags are included so a one-shot flare forces the
60
+ * recompute that renders (then clears) it for exactly one cycle. */
61
+ export function materialSigImpl(ctx: RuntimeHelpersContext): string {
62
+ const rt = ctx.rt;
63
+ const ae = ctx.activeEffect;
64
+ return JSON.stringify([
65
+ ctx.lastCtxTokens, ctx.lastCtxPercent, ctx.lastCtxWindow,
66
+ ctx.activeAgents, ctx.currentTurn,
67
+ rt.compactCount, rt.tokensSaved, rt.dedupSkips, rt.dedupAttempts,
68
+ rt.recallInjections, rt.cacheHitTokens, rt.persistedThisSession,
69
+ rt.lastCheckpointId ?? null, rt.lastCompactedFrom, rt.lastCompactedTokens,
70
+ ctx.statusKey ?? null,
71
+ ctx.currentModel?.modelId ?? null, ctx.currentModel?.provider ?? null,
72
+ ae ? `${ae.type}:${ae.role}:${ae.startedAt}` : null,
73
+ ctx.gameStateBump,
74
+ ctx.megaCacheFlare, ctx.megaCacheFlarePct,
75
+ ctx.levelUpFlare, ctx.achievementFlare,
76
+ ctx.achievementFlareTitles.join("|"),
77
+ ctx.tierTrace ?? null, ctx.lastWhy ?? null, ctx.pulsing,
78
+ ctx.ticker.length,
79
+ ]);
80
+ }
81
+
82
+ // -------------------------------------------------------------- embedderName
83
+
84
+ /** Active embedder name for the memory-store line (Trigram default / MiniLM).
85
+ * Pure — reads only `process.env` (the same flag the embedder factory reads). */
86
+ export function embedderNameImpl(): string {
87
+ // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
88
+ // the embedder factory uses so the label matches what's actually running.
89
+ return process.env.MEGACOMPACT_MINILM === "true" ||
90
+ process.env.MEGACOMPACT_MINILM === "1"
91
+ ? "MiniLM"
92
+ : "Trigram";
93
+ }
94
+
95
+ // -------------------------------------------------------------- driftStatus
96
+
97
+ /** Cross-repo drift status (ok | warn), cached for 30s (opens the registry DB). */
98
+ export function driftStatusImpl(ctx: RuntimeHelpersContext): "ok" | "warn" {
99
+ const now = Date.now();
100
+ if (ctx.driftCache && now - ctx.driftCache.at < 30_000)
101
+ return ctx.driftCache.status;
102
+ let status: "ok" | "warn" = "ok";
103
+ try {
104
+ const report = detectCrossRepoDrift();
105
+ status = report.totals.warn > 0 ? "warn" : "ok";
106
+ } catch {
107
+ status = "ok";
108
+ }
109
+ ctx.driftCache = { at: now, status };
110
+ return status;
111
+ }
112
+
113
+ // -------------------------------------------------------------- getTurnLevel
114
+
115
+ /** S33: player level for game mode — floor(log2(turns+1))+1 (gentle).
116
+ * Defensive: non-finite/negative collapses to 1 (never NaN). */
117
+ export function getTurnLevelImpl(ctx: RuntimeHelpersContext): number {
118
+ return turnLevel(ctx.currentTurn);
119
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * runtime-snapshot.ts — extracted `MegaRuntime.snapshot()` orchestration.
3
+ *
4
+ * The snapshot() body (dashboard write + S39 heartbeat + widget-data compute
5
+ * + flare/effect consumption + perf recording + the v0.8.5 material-change
6
+ * gate) is moved here verbatim. `MegaRuntime.snapshot()` becomes a thin
7
+ * delegate (`snapshotImpl(this, ctx)`), so the public method and every call
8
+ * site is unchanged.
9
+ *
10
+ * Follows the same context-interface + free-function + thin-delegate pattern
11
+ * as effects.ts / game-state.ts / capture-model.ts / runtime-helpers.ts.
12
+ * The pure widget-data computation lives in snapshot.ts (`computeMegaSnapshot`);
13
+ * this module is the orchestration that calls it. The pure/instance helpers
14
+ * materialSig/embedderName/driftStatus/getTurnLevel are called directly via
15
+ * their `*Impl` functions (imported from runtime-helpers.ts) — they were only
16
+ * ever called from snapshot(), so the in-class private delegates are removed
17
+ * from runtime.ts (dead code) rather than kept.
18
+ */
19
+
20
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { join } from "node:path";
22
+ import {
23
+ VectorStore,
24
+ vectorStats,
25
+ vectorRepoStats,
26
+ vectorDataInvariant,
27
+ } from "../../src/vectorStore.js";
28
+ import {
29
+ latestModelSnapshot,
30
+ recordPerfSample,
31
+ recordSessionHeartbeat,
32
+ appendTokenSample,
33
+ type GameState,
34
+ } from "../../src/store/sqlite.js";
35
+ import { resolveRepoRoot, type MegaConfig, type PressureBand } from "../mega-config.js";
36
+ import { Dashboard } from "../mega-dashboard.js";
37
+ import type { WidgetData } from "./widget.js";
38
+ import { computeMegaSnapshot } from "./snapshot.js";
39
+ import { buildDashboardSnapshot } from "./dashboard-snapshot.js";
40
+ import {
41
+ type RuntimeHelpersContext,
42
+ materialSigImpl,
43
+ embedderNameImpl,
44
+ driftStatusImpl,
45
+ getTurnLevelImpl,
46
+ } from "./runtime-helpers.js";
47
+
48
+ // ---------------------------------------------------------------------- types
49
+
50
+ /**
51
+ * The slice of `MegaRuntime` the snapshot orchestration reads + writes.
52
+ * Extends `RuntimeHelpersContext` (the fields materialSig/driftStatus/
53
+ * getTurnLevel read) so `self` can be passed straight to those `*Impl`
54
+ * functions. `MegaRuntime` satisfies this structurally once `lastSnapshotSig`
55
+ * is public — the same one-token visibility change Phase 2b made for
56
+ * `driftCache` (internal state, not an API contract).
57
+ */
58
+ export interface RuntimeSnapshotContext extends RuntimeHelpersContext {
59
+ // ── owned state ──
60
+ store: VectorStore;
61
+ config: MegaConfig;
62
+ dashboard: Dashboard;
63
+ currentStateDir: string;
64
+ widgetData: WidgetData | null;
65
+ /** v0.8.5: material-change signature; read + written by snapshot(). Public so
66
+ * the extracted orchestration can reach it (internal state, not an API). */
67
+ lastSnapshotSig: string | null;
68
+ lastWidgetCtx?: ExtensionContext;
69
+ lastActivityAt: number;
70
+ lastLevel: number;
71
+ diagCtxFastGate: number;
72
+ diagLiveTrimFires: number;
73
+ diagLiveTrimReplays: number;
74
+
75
+ // ── public methods the orchestration calls ──
76
+ bindRepo(cwd: string | undefined): string;
77
+ renderWidget(ctx: ExtensionContext): void;
78
+ getCachedGameState(): GameState;
79
+ setEffect(
80
+ type: "pulse" | "flash",
81
+ role: "accent" | "mega" | "red",
82
+ durationMs: number,
83
+ ): void;
84
+
85
+ // ── public getters ──
86
+ readonly pressureBand: PressureBand;
87
+ readonly pressure: number;
88
+ readonly effectiveThreshold: number;
89
+ }
90
+
91
+ // -------------------------------------------------------------- snapshotImpl
92
+
93
+ /** Collect live state and write it to disk (+ paint the above-editor widget).
94
+ * Extracted verbatim from `MegaRuntime.snapshot()` (runtime.ts); the public
95
+ * method there is now `snapshotImpl(this, ctx)`. */
96
+ export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContext): void {
97
+ if (ctx) self.lastWidgetCtx = ctx;
98
+ if (ctx) self.bindRepo(ctx.cwd);
99
+ // v0.8.5: gate the expensive body (6 sync SQLite opens +
100
+ // writeFileSync(dashboard.json)) behind a cheap material-change signature.
101
+ // During typing / idle / no-compaction streaming, the 'context' event
102
+ // fires repeatedly with NO material change — skip the recompute + write and
103
+ // just re-register the (live) widget factory, which reads the cached
104
+ // widgetData every frame. This removes the per-event main-thread block
105
+ // WITHOUT changing write timing, so tests that read dashboard.json
106
+ // synchronously after a compaction still see it written (compaction changes
107
+ // compactCount/tokensSaved → the signature changes → the full recompute +
108
+ // write runs).
109
+ const sig = materialSigImpl(self);
110
+ if (ctx && self.widgetData && self.lastSnapshotSig === sig) {
111
+ self.renderWidget(ctx);
112
+ return;
113
+ }
114
+ const perfT0 = performance.now();
115
+ const st = vectorStats(self.store, self.rt.sessionId);
116
+ const repo = vectorRepoStats(self.store);
117
+ const di = vectorDataInvariant(self.store);
118
+ // Effective threshold + armed/ready status for the dashboard.
119
+ // effectiveThresholdPct: the live fire point as a % of the window (null for
120
+ // `custom`, which has no tierPct). S29: honors MEGACOMPACT_AUTO_PCT_TRIGGER
121
+ // override so the dashboard's armed/ready match the context-handler gate
122
+ // (which fires on this same %). Used by armed/ready + the dashboard.
123
+ const effectiveThresholdPct =
124
+ self.config.tierPct != null
125
+ ? (self.config.autoPctTrigger ?? self.config.tierPct) * 100
126
+ : null;
127
+ // armed lights at/above the REAL fire point: max(effectiveThresholdPct,
128
+ // fastGatePct). fastGatePct already equals tierPct*100 by default, but a
129
+ // MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
130
+ const armed =
131
+ self.lastCtxPercent != null &&
132
+ self.lastCtxPercent >=
133
+ Math.max(effectiveThresholdPct ?? 0, self.config.fastGatePct);
134
+ // S29: ready mirrors the context-handler gate's basis — percent for tiered
135
+ // (the gate fires on pct), tokens for custom (the gate fires on tokens).
136
+ // Previously this always required tokens, so the dashboard could show
137
+ // "armed" (percent high) but never "ready" when tokens were under-reported
138
+ // — the same inconsistency the S29 gate fix removes.
139
+ const ready =
140
+ self.config.tierPct != null
141
+ ? armed && (self.lastCtxPercent ?? 0) >= (effectiveThresholdPct ?? 0)
142
+ : armed && (self.lastCtxTokens ?? 0) >= self.effectiveThreshold;
143
+ self.dashboard.snapshot(
144
+ buildDashboardSnapshot({
145
+ config: self.config,
146
+ rt: self.rt,
147
+ pressureBand: self.pressureBand,
148
+ pressure: self.pressure,
149
+ effectiveThreshold: self.effectiveThreshold,
150
+ statusKey: self.statusKey,
151
+ lastCtxTokens: self.lastCtxTokens,
152
+ lastCtxPercent: self.lastCtxPercent,
153
+ lastCtxWindow: self.lastCtxWindow,
154
+ diagCtxFastGate: self.diagCtxFastGate,
155
+ diagLiveTrimFires: self.diagLiveTrimFires,
156
+ diagLiveTrimReplays: self.diagLiveTrimReplays,
157
+ errorRetryCount: self.rt.errorRetryCount,
158
+ consecutiveErrors: self.rt.consecutiveErrors,
159
+ ERROR_RETRY_MAX_CONSECUTIVE: self.config.maxConsecutiveErrors,
160
+ errorRetryHardStop: self.config.errorRetryHardStop,
161
+ activeAgents: self.activeAgents,
162
+ currentTurn: self.currentTurn,
163
+ currentModel: self.currentModel,
164
+ st,
165
+ repo,
166
+ di,
167
+ }),
168
+ );
169
+ const perfDiskMs = self.dashboard.lastWriteMs;
170
+
171
+ // S39: record a session heartbeat + token sample into the shared
172
+ // machine-wide index.sqlite so the dashboard can show a real-time
173
+ // stacked-memory graph across all active pi processes. Behind the
174
+ // material-change gate (this code only runs when sig changed). Non-fatal
175
+ // try/catch mirrors the recordPerfSample pattern below. Skip the token
176
+ // sample when lastCtxTokens is null (no context data yet).
177
+ try {
178
+ const repo = resolveRepoRoot(ctx?.cwd ?? self.currentStateDir) ?? self.currentStateDir;
179
+ recordSessionHeartbeat(
180
+ process.pid,
181
+ self.rt.sessionId,
182
+ repo,
183
+ self.currentStateDir,
184
+ self.lastCtxWindow || 0,
185
+ );
186
+ if (self.lastCtxTokens != null) {
187
+ appendTokenSample(
188
+ self.rt.sessionId,
189
+ repo,
190
+ self.lastCtxTokens,
191
+ self.lastCtxPercent ?? 0,
192
+ self.lastCtxWindow || 0,
193
+ join(self.currentStateDir, "events.log"),
194
+ );
195
+ }
196
+ } catch {
197
+ /* non-fatal: S39 monitoring must never block the snapshot path */
198
+ }
199
+
200
+ // Live stats widget above the editor
201
+ if (ctx) {
202
+ // S31: game-mode state — fetched before the widget computation so the
203
+ // pure function gets a plain value rather than another callback.
204
+ const gs = self.getCachedGameState();
205
+ // S34: derive the level-up flare from the turn count. This side-effect
206
+ // check must happen BEFORE computeMegaSnapshot so the flare and the
207
+ // ambient effect are armed for the current frame.
208
+ const curLevel = getTurnLevelImpl(self);
209
+ if (curLevel > self.lastLevel) {
210
+ self.levelUpFlare = true;
211
+ // v0.8.3: arm a pulse border effect to celebrate the level-up.
212
+ self.setEffect("pulse", "accent", 1500);
213
+ }
214
+ // ── gather widget data (computed per snapshot, rendered per frame) ────
215
+ const modelSnap = latestModelSnapshot(self.currentStateDir);
216
+ const _snapResult = computeMegaSnapshot({
217
+ lastCtxTokens: self.lastCtxTokens,
218
+ lastCtxWindow: self.lastCtxWindow,
219
+ lastCtxPercent: self.lastCtxPercent,
220
+ activeAgents: self.activeAgents,
221
+ currentTurn: self.currentTurn,
222
+ statusKey: self.statusKey,
223
+ st,
224
+ repo,
225
+ rtTokensSaved: self.rt.tokensSaved,
226
+ lastCompactAt: self.rt.lastCompactAt,
227
+ ticker: self.ticker,
228
+ lastWhy: self.lastWhy,
229
+ tierTrace: self.tierTrace,
230
+ pulsing: self.pulsing,
231
+ getCachedGameState: () => gs,
232
+ getTurnLevel: () => getTurnLevelImpl(self),
233
+ embedderName: () => embedderNameImpl(),
234
+ driftStatus: () => driftStatusImpl(self),
235
+ megaCacheFlare: self.megaCacheFlare,
236
+ megaCacheFlarePct: self.megaCacheFlarePct,
237
+ levelUpFlare: self.levelUpFlare,
238
+ achievementFlare: self.achievementFlare,
239
+ achievementFlareTitles: self.achievementFlareTitles,
240
+ activeEffect: self.activeEffect,
241
+ lastActivityAt: self.lastActivityAt,
242
+ pressureBand: self.pressureBand,
243
+ configTier: self.config.tier,
244
+ ready,
245
+ armed,
246
+ modelSnap,
247
+ });
248
+ self.widgetData = _snapResult.widgetData;
249
+ // S33: consume the flare after copying it into widgetData so it fires
250
+ // for exactly one render cycle (the gag flares once, then clears).
251
+ self.megaCacheFlare = false;
252
+ self.megaCacheFlarePct = 0;
253
+
254
+ // S34: consume the level-up flare after one render cycle (mirrors the
255
+ // megaCacheFlare one-shot semantics), and advance lastLevel.
256
+ self.levelUpFlare = false;
257
+ self.lastLevel = curLevel;
258
+ // S35: consume the achievement-unlock flare after one render cycle
259
+ // (mirrors the megaCacheFlare/levelUpFlare one-shot semantics).
260
+ self.achievementFlare = false;
261
+ self.achievementFlareTitles = [];
262
+ // v0.8.3: expire the ambient border effect once its time window has
263
+ // elapsed. SEPARATE from the one-shot flares above (those are per-cycle
264
+ // consumes; activeEffect is time-windowed and cleared when Date.now()
265
+ // crosses startedAt + durationMs). The widget also defends this per-frame
266
+ // (effectBorderSgr returns '' once expired), so this is bookkeeping to
267
+ // free the slot and prevent a stale effect lingering between snapshots.
268
+ if (
269
+ self.activeEffect &&
270
+ Date.now() - self.activeEffect.startedAt >=
271
+ self.activeEffect.durationMs
272
+ ) {
273
+ self.activeEffect = null;
274
+ }
275
+ // Auto-fit: register a factory so pi re-renders the panel at the REAL
276
+ // terminal width every frame (tui.columns), instead of guessing with
277
+ // process.stdout.columns. buildWidgetLines reads this.widgetData live.
278
+ self.renderWidget(ctx);
279
+ }
280
+ // v0.8.5: record the material-change signature computed at the top so the
281
+ // next snapshot() can skip this whole body when nothing material changed.
282
+ try {
283
+ recordPerfSample(self.currentStateDir, "db_recompute_ms", performance.now() - perfT0);
284
+ recordPerfSample(self.currentStateDir, "disk_write_ms", perfDiskMs);
285
+ } catch {
286
+ /* non-fatal: perf instrumentation never blocks the agent */
287
+ }
288
+ self.lastSnapshotSig = sig;
289
+ }