pi-mega-compact 0.9.0 → 0.9.2

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.
@@ -147,6 +147,13 @@ export interface MegaConfig {
147
147
  * a hit whose summary is ≥ dedupSim similar to a live message — "dedupe on
148
148
  * inline/read" so we never re-inject context already resident. */
149
149
  windowDedupe: boolean;
150
+ /** Render the above-editor TUI widget (MEGACOMPACT_TUI_WIDGET). Default true.
151
+ * Set to 0 to suppress the panel entirely — the widget is a persistent,
152
+ * animated, full-width region that repaints on its own cadence, which fights
153
+ * terminals where the user drives scrollback themselves (notably pi running
154
+ * inside a Neovim `:terminal`, where every repaint yanks the view back to
155
+ * the bottom). Compaction is unaffected; only the panel is suppressed. */
156
+ tuiWidget: boolean;
150
157
  debug: boolean;
151
158
  }
152
159
 
@@ -292,6 +299,7 @@ export function loadConfig(): MegaConfig {
292
299
  memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
293
300
  recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
294
301
  windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
302
+ tuiWidget: envBool("MEGACOMPACT_TUI_WIDGET", true),
295
303
  debug: envBool("MEGACOMPACT_DEBUG", false),
296
304
  };
297
305
  }
@@ -14,6 +14,8 @@ import { disposePerf, type PerfContext } from "./perf.js";
14
14
 
15
15
  export interface GameWatcherLike {
16
16
  close(): void;
17
+ /** fs.FSWatcher has it; test doubles need not. */
18
+ unref?(): unknown;
17
19
  }
18
20
 
19
21
  export interface GameStateContext {
@@ -142,6 +144,13 @@ export function ensureGameStateWatcherImpl(self: GameStateContext, view: GameSta
142
144
  }
143
145
  },
144
146
  );
147
+ // The watcher is a cache-eviction convenience, never a reason to stay
148
+ // alive: an active+referenced fs_event handle holds node's event loop
149
+ // open, so a runtime that was never dispose()d (every extension test, and
150
+ // any `pi -p` run that skips session_shutdown) hangs the process after all
151
+ // work is done. unref'd like the perf interval — while pi runs there are
152
+ // always other referenced handles, so the watcher still fires normally.
153
+ self.gameStateWatcher.unref?.();
145
154
  self.gameStateWatchDir = self.currentStateDir;
146
155
  } catch {
147
156
  /* non-fatal: missing dir / platform issue — next snapshot re-queries */
@@ -0,0 +1,88 @@
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
+ lastErrorRetryAt: 0,
66
+ retryNudgePending: false,
67
+ errorRetrySessionCount: 0,
68
+ lastErrorText: undefined,
69
+ errorTextRepeatCount: 0,
70
+ poisonedAdviseSent: false,
71
+ poisonedCompactSignatures: new Set(),
72
+ poisonedCount: 0,
73
+ };
74
+ self.trimCache = null; // v0.8.6: never replay a stale trim into a new session
75
+ self.statusKey = undefined;
76
+ self.activeAgents = 0;
77
+ self.currentTurn = 0;
78
+ self.lastActivityAt = 0;
79
+ self.tierTrace = undefined;
80
+ self.ticker.length = 0;
81
+ self.pulsing = false;
82
+ self.savedGoal = 50_000;
83
+ self.lastWhy = undefined;
84
+ // S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
85
+ // that re-binds the repo, so drop the memo too. Cheap; the next
86
+ // getCachedGameState() re-queries lazily.
87
+ self.cachedGameState = undefined;
88
+ }
@@ -20,11 +20,22 @@ import { VectorStore } from "../../src/vectorStore.js";
20
20
  import type { toEngineMessages } from "../../src/adapt.js";
21
21
  import { normalizeSessionId } from "../../src/store.js";
22
22
  import { Logger } from "../../src/log.js";
23
- import type { ModelSnapshot, GameState } from "../../src/store/sqlite.js";
24
- import type { MegaConfig, PressureBand } from "../mega-config.js";
23
+ import type {
24
+ ModelSnapshot,
25
+ GameState,
26
+ } from "../../src/store/sqlite.js";
27
+ import type {
28
+ MegaConfig,
29
+ PressureBand,
30
+ } from "../mega-config.js";
25
31
  import { Dashboard } from "../mega-dashboard.js";
26
- import type { SessionRuntime } from "./helpers.js";
27
- import type { TickerEntry, WidgetData } from "./widget.js";
32
+ import type {
33
+ SessionRuntime,
34
+ } from "./helpers.js";
35
+ import type {
36
+ TickerEntry,
37
+ WidgetData,
38
+ } from "./widget.js";
28
39
  import {
29
40
  ensureGameStateWatcherImpl,
30
41
  getCachedGameStateImpl,
@@ -48,6 +59,7 @@ import {
48
59
  effectiveThresholdImpl,
49
60
  pressureBandImpl,
50
61
  } from "./pressure-getters.js";
62
+ import { resetRuntimeImpl } from "./reset-runtime.js";
51
63
  import { appendEventImpl } from "./append-event.js";
52
64
  import { getStateDirImpl } from "./get-state-dir.js";
53
65
  import { renderWidgetImpl } from "./render-widget.js";
@@ -83,7 +95,6 @@ export class MegaRuntime {
83
95
  errorRetryCount: 0,
84
96
  errorRetryUntil: 0,
85
97
  consecutiveErrors: 0,
86
- // R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
87
98
  lastErrorRetryAt: 0,
88
99
  retryNudgePending: false,
89
100
  errorRetrySessionCount: 0,
@@ -122,12 +133,7 @@ export class MegaRuntime {
122
133
  * compaction start). Threaded into widgetData as `activeEffect`; the widget
123
134
  * computes the per-frame phase from startedAt vs Date.now() (non-expired).
124
135
  * Null when idle/expired. */
125
- activeEffect: {
126
- type: "pulse" | "flash";
127
- role: "accent" | "mega" | "red";
128
- startedAt: number;
129
- durationMs: number;
130
- } | null = null;
136
+ activeEffect: { type: "pulse" | "flash"; role: "accent" | "mega" | "red"; startedAt: number; durationMs: number } | null = null;
131
137
  megaCacheFlarePct = 0;
132
138
  levelUpFlare = false;
133
139
  lastLevel = 0;
@@ -307,8 +313,14 @@ export class MegaRuntime {
307
313
  }
308
314
 
309
315
  /** Width-aware above-editor widget factory registration — thin delegate to
310
- * `renderWidgetImpl` (render-widget.ts). */
316
+ * `renderWidgetImpl` (render-widget.ts).
317
+ *
318
+ * Gated on `config.tuiWidget` (MEGACOMPACT_TUI_WIDGET=0 to disable). This
319
+ * is the single chokepoint every caller funnels through, so returning here
320
+ * means setWidget is never called and the panel is never registered — as
321
+ * opposed to registering an empty one, which would still occupy a row. */
311
322
  renderWidget(ctx: ExtensionContext): void {
323
+ if (!this.config.tuiWidget) return;
312
324
  renderWidgetImpl(this, ctx);
313
325
  }
314
326
 
@@ -318,54 +330,10 @@ export class MegaRuntime {
318
330
  setStatusImpl(this, ctx, text);
319
331
  }
320
332
 
321
- /** Per-session state reset (session_start / session_tree) — inlined by the
322
- * raptor-promotion merge (R1–R3 retry-redesign fields); reset-runtime.ts was
323
- * retired by that branch. */
333
+ /** Per-session state reset (session_start / session_tree) — thin delegate to
334
+ * `resetRuntimeImpl` (reset-runtime.ts). */
324
335
  resetRuntime(sessionId: string | undefined): void {
325
- const sid = normalizeSessionId(sessionId);
326
- if (this.rt.sessionId === sid && this.rt.persistedThisSession) return; // same session, keep checkpoint memory
327
- this.rt = {
328
- sessionId: sid,
329
- persistedThisSession: false,
330
- lastCheckpointId: undefined,
331
- lastCompactedFrom: 0,
332
- lastCompactedTokens: 0,
333
- dedupSkips: 0,
334
- dedupAttempts: 0,
335
- tokensSaved: 0,
336
- lastCompactAt: null,
337
- lastNativeCompactAt: null,
338
- compactCount: 0,
339
- recallInjections: 0,
340
- cacheHitTokens: 0,
341
- lengthStopPending: false,
342
- errorRetryCount: 0,
343
- errorRetryUntil: 0,
344
- consecutiveErrors: 0,
345
- // R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
346
- lastErrorRetryAt: 0,
347
- retryNudgePending: false,
348
- errorRetrySessionCount: 0,
349
- lastErrorText: undefined,
350
- errorTextRepeatCount: 0,
351
- poisonedAdviseSent: false,
352
- poisonedCompactSignatures: new Set(),
353
- poisonedCount: 0,
354
- };
355
- this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
356
- this.statusKey = undefined;
357
- this.activeAgents = 0;
358
- this.currentTurn = 0;
359
- this.lastActivityAt = 0;
360
- this.tierTrace = undefined;
361
- this.ticker.length = 0;
362
- this.pulsing = false;
363
- this.savedGoal = 50_000;
364
- this.lastWhy = undefined;
365
- // S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
366
- // that re-binds the repo, so drop the memo too. Cheap; the next
367
- // getCachedGameState() re-queries lazily.
368
- this.cachedGameState = undefined;
336
+ resetRuntimeImpl(this, sessionId);
369
337
  }
370
338
 
371
339
  captureModel(ctx: ExtensionContext): void {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * mega-shutdown-widget.test.ts — regression cover for two host-integration
3
+ * fixes that the rest of the suite cannot see, because both are about what
4
+ * happens at the boundary with pi rather than inside the compaction engine:
5
+ *
6
+ * 1. session_shutdown must close the PGlite indexes. They are lazily-opened
7
+ * module singletons whose handles keep node's event loop alive, so leaving
8
+ * them open made `pi -p` produce its answer and then hang instead of
9
+ * exiting. closeVectorIndex()/closeMemoryIndex() existed but had no
10
+ * non-test callers.
11
+ *
12
+ * 2. The above-editor widget must be suppressible. It is a persistent,
13
+ * animated, full-width panel that repaints on its own cadence, which fights
14
+ * terminals where the user drives scrollback (pi inside a Neovim
15
+ * `:terminal`). MEGACOMPACT_TUI_WIDGET=0 turns it off without touching
16
+ * compaction.
17
+ */
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { mkdtempSync, rmSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import {
25
+ closeVectorIndex,
26
+ initVectorIndex,
27
+ isVectorIndexDisabled,
28
+ } from "../src/store/vectorIndex.js";
29
+ import { closeMemoryIndex } from "../src/store/memoryIndex.js";
30
+ import { loadConfig } from "./mega-config.js";
31
+ import { MegaRuntime } from "./mega-runtime.js";
32
+
33
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-shutdown-"));
34
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
35
+ let counter = 0;
36
+
37
+ /** Fresh per-test state dir so concurrent runs never collide on disk. */
38
+ function isolate(): void {
39
+ process.env.MEGACOMPACT_STATE_DIR = join(baseTmp, `run-${counter++}`);
40
+ }
41
+
42
+ /** Minimal ExtensionContext slice: renderWidget only reaches ctx.ui.setWidget. */
43
+ function widgetCtx(calls: string[]): any {
44
+ return {
45
+ ui: {
46
+ setWidget: (key: string) => calls.push(key),
47
+ },
48
+ };
49
+ }
50
+
51
+ test.after(async () => {
52
+ // This file is itself a demonstration of the bug under test: without these
53
+ // closes the PGlite handles opened above keep the event loop alive and the
54
+ // test process never exits.
55
+ await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
56
+ rmSync(baseTmp, { recursive: true, force: true });
57
+ });
58
+
59
+ test("MEGACOMPACT_TUI_WIDGET defaults on and is disabled by 0", () => {
60
+ isolate();
61
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
62
+ assert.equal(loadConfig().tuiWidget, true, "widget should default to on");
63
+
64
+ process.env.MEGACOMPACT_TUI_WIDGET = "0";
65
+ assert.equal(loadConfig().tuiWidget, false);
66
+
67
+ process.env.MEGACOMPACT_TUI_WIDGET = "1";
68
+ assert.equal(loadConfig().tuiWidget, true);
69
+
70
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
71
+ });
72
+
73
+ test("renderWidget registers the panel when tuiWidget is on", () => {
74
+ isolate();
75
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
76
+ const runtime = new MegaRuntime(loadConfig());
77
+ try {
78
+ const calls: string[] = [];
79
+ runtime.renderWidget(widgetCtx(calls));
80
+ assert.equal(calls.length, 1, "expected one setWidget registration");
81
+ } finally {
82
+ runtime.dispose();
83
+ }
84
+ });
85
+
86
+ test("renderWidget registers nothing when tuiWidget is off", () => {
87
+ isolate();
88
+ process.env.MEGACOMPACT_TUI_WIDGET = "0";
89
+ const runtime = new MegaRuntime(loadConfig());
90
+ try {
91
+ const calls: string[] = [];
92
+ // Repeated calls, because the panel is re-registered on every snapshot
93
+ // and every game-state change — one guarded path is not enough.
94
+ runtime.renderWidget(widgetCtx(calls));
95
+ runtime.renderWidget(widgetCtx(calls));
96
+ assert.deepEqual(calls, [], "widget must never be registered when disabled");
97
+ } finally {
98
+ runtime.dispose();
99
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
100
+ }
101
+ });
102
+
103
+ test("the extension's session_shutdown handler awaits index teardown", async () => {
104
+ isolate();
105
+ const handlers: Record<string, Function[]> = {};
106
+ const pi: any = {
107
+ on(event: string, handler: Function) {
108
+ (handlers[event] ??= []).push(handler);
109
+ },
110
+ registerCommand() {},
111
+ registerProvider() {},
112
+ };
113
+
114
+ const { default: extension } = await import("./mega-compact.js");
115
+ extension(pi);
116
+
117
+ const shutdown = handlers["session_shutdown"] ?? [];
118
+ assert.ok(shutdown.length, "extension must register a session_shutdown handler");
119
+
120
+ const event = { type: "session_shutdown" } as any;
121
+ const ctx = {
122
+ ui: { setStatus: () => {}, notify: () => {}, setWidget: () => {} },
123
+ cwd: process.env.MEGACOMPACT_STATE_DIR,
124
+ } as any;
125
+
126
+ // Assert the close by identity rather than by "some handler returned a
127
+ // promise": several modules register on session_shutdown and at least one
128
+ // other is already async, so a promise proves nothing about teardown. The
129
+ // index is a module singleton, so if shutdown really closed it, re-init
130
+ // hands back a *different* instance.
131
+ const before = await initVectorIndex();
132
+ if (isVectorIndexDisabled() || !before) return; // PGlite unavailable — nothing to close
133
+
134
+ await Promise.all(shutdown.map((handler) => handler(event, ctx)));
135
+
136
+ const after = await initVectorIndex();
137
+ assert.notEqual(after, before, "session_shutdown must close the PGlite vector index");
138
+
139
+ // Idempotent: a second shutdown (reload, double-fire) must not throw.
140
+ await Promise.all(shutdown.map((handler) => handler(event, ctx)));
141
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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-3-Clause",