pi-mega-compact 0.4.23 → 0.4.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.
@@ -11,11 +11,32 @@
11
11
  * @module
12
12
  */
13
13
  import { createServer } from "node:http";
14
- import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, appendFileSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { join, dirname } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { DatabaseSync } from "node:sqlite";
19
+ // ---------------------------------------------------------------------------
20
+ // Local runtime log
21
+ //
22
+ // The dashboard server is spawned as a DETACHED child. When it is launched with
23
+ // `stdio: "ignore"` (the old default) any crash before the first console.log is
24
+ // invisible — there is no log to "check". We therefore mirror every lifecycle
25
+ // line to a file in the state dir so a failed start is always diagnosable. The
26
+ // launcher also captures stderr, so this doubles as defense-in-depth.
27
+ // ---------------------------------------------------------------------------
28
+ let LOG_PATH = null;
29
+ function log(...parts) {
30
+ const line = `[mega-compact][dashboard] ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}`;
31
+ // eslint-disable-next-line no-console
32
+ console.error(line); // stderr — captured by the launcher pipe
33
+ if (LOG_PATH) {
34
+ try {
35
+ appendFileSync(LOG_PATH, new Date().toISOString() + " " + line + "\n");
36
+ }
37
+ catch { /* non-fatal */ }
38
+ }
39
+ }
19
40
  // --- Multi-repo index (Phase 5b) ------------------------------------------------
20
41
  // The extension writes a machine-wide repo registry into a single SQLite DB
21
42
  // (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
@@ -651,7 +672,7 @@ function dashboardHtml(tierName) {
651
672
  // ---------------------------------------------------------------------------
652
673
  // Server
653
674
  // ---------------------------------------------------------------------------
654
- export function launchDashboardServer(stateDir) {
675
+ export async function launchDashboardServer(stateDir) {
655
676
  // Our own package version — exposed at /api/version so the launcher can
656
677
  // detect a stale server (started by an older build) and replace it on
657
678
  // upgrade instead of reuse it.
@@ -675,17 +696,41 @@ export function launchDashboardServer(stateDir) {
675
696
  const portFile = join(stateDir, "port.pid");
676
697
  const snapshotPath = join(stateDir, "dashboard.json");
677
698
  const eventsPath = join(stateDir, "events.log");
678
- // ── Existing server? ──────────────────────────────────────────────────────
699
+ LOG_PATH = join(stateDir, "dashboard.log");
700
+ log("launch invoked", { stateDir });
701
+ // ── Existing server? ───────────────────────────────────────────────────────
702
+ // A stale port.pid pointing at a dead/competing process is the classic cause
703
+ // of "dashboard failed to start" — we return a port that is NOT actually
704
+ // serving. Probe for a live server on that port first; only reuse the marker
705
+ // when something real answers /api/version. Otherwise drop it and start fresh.
679
706
  if (existsSync(portFile)) {
680
707
  try {
681
708
  const info = JSON.parse(readFileSync(portFile, "utf-8"));
682
709
  if (info && info.port) {
683
- return Promise.resolve({ port: info.port, url: `http://localhost:${info.port}` });
710
+ let live = false;
711
+ try {
712
+ const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) });
713
+ live = probe.ok;
714
+ }
715
+ catch {
716
+ live = false;
717
+ }
718
+ if (live) {
719
+ log("reusing live server from port.pid", { port: info.port });
720
+ return { port: info.port, url: `http://localhost:${info.port}` };
721
+ }
722
+ log("port.pid present but no live server — treating as stale", { port: info.port });
684
723
  }
685
724
  }
686
725
  catch {
687
- // stale file, overwrite
726
+ log("port.pid unparseable treating as stale");
688
727
  }
728
+ // stale file, remove so the fresh bind does not collide with a lingering
729
+ // process that still holds the port
730
+ try {
731
+ unlinkSync(portFile);
732
+ }
733
+ catch { /* ignore */ }
689
734
  }
690
735
  // ── New server ────────────────────────────────────────────────────────────
691
736
  mkdirSync(stateDir, { recursive: true });
@@ -799,20 +844,26 @@ export function launchDashboardServer(stateDir) {
799
844
  function tryPort(port) {
800
845
  server.once("error", (err) => {
801
846
  if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
847
+ log("port in use, trying next", { port });
802
848
  tryPort(port + 1);
803
849
  }
804
850
  else {
851
+ log("listen failed", { port, code: err.code, message: err.message });
805
852
  reject(err);
806
853
  }
807
854
  });
808
855
  server.listen(port, "127.0.0.1", () => {
809
856
  const url = `http://localhost:${port}`;
857
+ log("server running", { url });
858
+ // eslint-disable-next-line no-console
810
859
  console.log(`[mega-compact] dashboard server running: ${url}`);
811
860
  // Write port.pid
812
861
  try {
813
862
  writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
814
863
  }
815
- catch { /* non-fatal */ }
864
+ catch (e) {
865
+ log("could not write port.pid", { error: String(e) });
866
+ }
816
867
  // Graceful cleanup
817
868
  const cleanup = () => {
818
869
  try {
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
9
9
  import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
+ import { spawn } from "node:child_process";
12
13
  // ---------------------------------------------------------------------------
13
14
  // helpers
14
15
  // ---------------------------------------------------------------------------
@@ -109,3 +110,79 @@ describe("port.pid file", () => {
109
110
  rmSync(dir, { recursive: true });
110
111
  });
111
112
  });
113
+ // ---------------------------------------------------------------------------
114
+ // Lifecycle integration — launch the compiled server as a real subprocess
115
+ // (the same way the /dashboard command spawns it) and assert the two failure
116
+ // modes that historically produced a silent "failed to start":
117
+ // 1. a stale port.pid pointing at a dead port is dropped, and the server
118
+ // binds fresh (instead of returning the dead port);
119
+ // 2. a module-load crash is captured to the launch log instead of going
120
+ // silent under stdio:"ignore".
121
+ // ---------------------------------------------------------------------------
122
+ const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
123
+ function waitFor(cond, timeoutMs = 6000) {
124
+ const start = Date.now();
125
+ return new Promise((resolve, reject) => {
126
+ const tick = async () => {
127
+ if (await cond())
128
+ return resolve();
129
+ if (Date.now() - start > timeoutMs)
130
+ return reject(new Error("timeout"));
131
+ setTimeout(tick, 50);
132
+ };
133
+ tick();
134
+ });
135
+ }
136
+ describe("server lifecycle", () => {
137
+ test("drops a stale port.pid and binds a fresh port", async () => {
138
+ const dir = mkdtempSync(join(tmpdir(), "dash-stale-"));
139
+ // A marker claiming a port where nothing is listening.
140
+ writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: 9325, pid: 999999 }));
141
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
142
+ try {
143
+ // Wait for the server to actually be live (not just any port.pid — the
144
+ // stale marker already exists at t=0 and would pass a naive check).
145
+ await waitFor(async () => {
146
+ try {
147
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
148
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
149
+ return res.ok;
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ });
155
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
156
+ assert.equal(typeof raw.port, "number");
157
+ assert.notEqual(raw.port, 9325, "should not reuse the dead port from the stale marker");
158
+ // And a real server must answer on it.
159
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
160
+ assert.equal(res.ok, true);
161
+ }
162
+ finally {
163
+ child.kill("SIGTERM");
164
+ rmSync(dir, { recursive: true, force: true });
165
+ }
166
+ });
167
+ test("writes a dashboard.log with startup lines", async () => {
168
+ const dir = mkdtempSync(join(tmpdir(), "dash-log-"));
169
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
170
+ try {
171
+ await waitFor(() => {
172
+ try {
173
+ return /server running/.test(readFileSync(join(dir, "dashboard.log"), "utf-8"));
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ });
179
+ const log = readFileSync(join(dir, "dashboard.log"), "utf-8");
180
+ assert.match(log, /\[mega-compact\]\[dashboard\]/);
181
+ assert.match(log, /server running/);
182
+ }
183
+ finally {
184
+ child.kill("SIGTERM");
185
+ rmSync(dir, { recursive: true, force: true });
186
+ }
187
+ });
188
+ });
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { join, dirname, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
- import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
10
+ import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
11
11
  import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
12
12
  /** Register the dashboard server lifecycle commands. */
13
13
  export function registerDashboardCommands(pi, runtime) {
@@ -213,11 +213,41 @@ export function registerDashboardCommands(pi, runtime) {
213
213
  ctx.ui.notify("[mega-compact] dashboard entry not found — check logs.");
214
214
  return;
215
215
  }
216
+ // Clear any stale marker so a fresh bind never collides with a lingering
217
+ // orphan, and truncate the launch log so the next error report shows only
218
+ // this attempt's output.
219
+ try {
220
+ unlinkSync(portFile);
221
+ }
222
+ catch { /* ignore */ }
223
+ try {
224
+ writeFileSync(launchLog, "");
225
+ }
226
+ catch { /* ignore */ }
216
227
  const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
228
+ // Redirect the child's stderr to the launch log so that a CRASH BEFORE the
229
+ // runner's own __fail handler runs (e.g. an ESM module-load / parse error,
230
+ // or a missing entry) is still captured. With the old `stdio: "ignore"`
231
+ // these failures were completely silent and the "check logs" message
232
+ // pointed at an empty file. We open the fd in the parent and pass it to the
233
+ // child; once spawned we close our copy (the child keeps its own dup).
234
+ let stderrFd;
235
+ try {
236
+ stderrFd = openSync(launchLog, "a");
237
+ }
238
+ catch {
239
+ stderrFd = -1; // fall back to ignored stderr
240
+ }
217
241
  const child = spawn(process.execPath, args, {
218
242
  detached: true,
219
- stdio: "ignore",
243
+ stdio: ["ignore", "ignore", stderrFd >= 0 ? stderrFd : "ignore"],
220
244
  });
245
+ if (stderrFd >= 0) {
246
+ try {
247
+ closeSync(stderrFd);
248
+ }
249
+ catch { /* ignore */ }
250
+ }
221
251
  child.unref();
222
252
  // Poll for a live server (port 9320–9329) instead of relying solely on the
223
253
  // port.pid marker, which can land in a different state dir than the one we
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
10
10
  import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
+ import { spawn } from "node:child_process";
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // helpers
@@ -122,3 +123,79 @@ describe("port.pid file", () => {
122
123
  rmSync(dir, { recursive: true });
123
124
  });
124
125
  });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Lifecycle integration — launch the compiled server as a real subprocess
129
+ // (the same way the /dashboard command spawns it) and assert the two failure
130
+ // modes that historically produced a silent "failed to start":
131
+ // 1. a stale port.pid pointing at a dead port is dropped, and the server
132
+ // binds fresh (instead of returning the dead port);
133
+ // 2. a module-load crash is captured to the launch log instead of going
134
+ // silent under stdio:"ignore".
135
+ // ---------------------------------------------------------------------------
136
+
137
+ const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
138
+
139
+ function waitFor(cond: () => boolean | Promise<boolean>, timeoutMs = 6000): Promise<void> {
140
+ const start = Date.now();
141
+ return new Promise((resolve, reject) => {
142
+ const tick = async () => {
143
+ if (await cond()) return resolve();
144
+ if (Date.now() - start > timeoutMs) return reject(new Error("timeout"));
145
+ setTimeout(tick, 50);
146
+ };
147
+ tick();
148
+ });
149
+ }
150
+
151
+ describe("server lifecycle", () => {
152
+ test("drops a stale port.pid and binds a fresh port", async () => {
153
+ const dir = mkdtempSync(join(tmpdir(), "dash-stale-"));
154
+ // A marker claiming a port where nothing is listening.
155
+ writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: 9325, pid: 999999 }));
156
+
157
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
158
+ try {
159
+ // Wait for the server to actually be live (not just any port.pid — the
160
+ // stale marker already exists at t=0 and would pass a naive check).
161
+ await waitFor(async () => {
162
+ try {
163
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
164
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
165
+ return res.ok;
166
+ } catch {
167
+ return false;
168
+ }
169
+ });
170
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
171
+ assert.equal(typeof raw.port, "number");
172
+ assert.notEqual(raw.port, 9325, "should not reuse the dead port from the stale marker");
173
+ // And a real server must answer on it.
174
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
175
+ assert.equal(res.ok, true);
176
+ } finally {
177
+ child.kill("SIGTERM");
178
+ rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test("writes a dashboard.log with startup lines", async () => {
183
+ const dir = mkdtempSync(join(tmpdir(), "dash-log-"));
184
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
185
+ try {
186
+ await waitFor(() => {
187
+ try {
188
+ return /server running/.test(readFileSync(join(dir, "dashboard.log"), "utf-8"));
189
+ } catch {
190
+ return false;
191
+ }
192
+ });
193
+ const log = readFileSync(join(dir, "dashboard.log"), "utf-8");
194
+ assert.match(log, /\[mega-compact\]\[dashboard\]/);
195
+ assert.match(log, /server running/);
196
+ } finally {
197
+ child.kill("SIGTERM");
198
+ rmSync(dir, { recursive: true, force: true });
199
+ }
200
+ });
201
+ });
@@ -12,12 +12,32 @@
12
12
  */
13
13
 
14
14
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
15
- import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
15
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, appendFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
17
  import { join, dirname } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { DatabaseSync } from "node:sqlite";
20
20
 
21
+ // ---------------------------------------------------------------------------
22
+ // Local runtime log
23
+ //
24
+ // The dashboard server is spawned as a DETACHED child. When it is launched with
25
+ // `stdio: "ignore"` (the old default) any crash before the first console.log is
26
+ // invisible — there is no log to "check". We therefore mirror every lifecycle
27
+ // line to a file in the state dir so a failed start is always diagnosable. The
28
+ // launcher also captures stderr, so this doubles as defense-in-depth.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ let LOG_PATH: string | null = null;
32
+ function log(...parts: unknown[]): void {
33
+ const line = `[mega-compact][dashboard] ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}`;
34
+ // eslint-disable-next-line no-console
35
+ console.error(line); // stderr — captured by the launcher pipe
36
+ if (LOG_PATH) {
37
+ try { appendFileSync(LOG_PATH, new Date().toISOString() + " " + line + "\n"); } catch { /* non-fatal */ }
38
+ }
39
+ }
40
+
21
41
  // --- Multi-repo index (Phase 5b) ------------------------------------------------
22
42
  // The extension writes a machine-wide repo registry into a single SQLite DB
23
43
  // (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
@@ -738,7 +758,7 @@ function dashboardHtml(tierName: string): string {
738
758
  // Server
739
759
  // ---------------------------------------------------------------------------
740
760
 
741
- export function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
761
+ export async function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
742
762
  // Our own package version — exposed at /api/version so the launcher can
743
763
  // detect a stale server (started by an older build) and replace it on
744
764
  // upgrade instead of reuse it.
@@ -757,17 +777,37 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
757
777
  const portFile = join(stateDir, "port.pid");
758
778
  const snapshotPath = join(stateDir, "dashboard.json");
759
779
  const eventsPath = join(stateDir, "events.log");
760
-
761
- // ── Existing server? ──────────────────────────────────────────────────────
780
+ LOG_PATH = join(stateDir, "dashboard.log");
781
+ log("launch invoked", { stateDir });
782
+
783
+ // ── Existing server? ───────────────────────────────────────────────────────
784
+ // A stale port.pid pointing at a dead/competing process is the classic cause
785
+ // of "dashboard failed to start" — we return a port that is NOT actually
786
+ // serving. Probe for a live server on that port first; only reuse the marker
787
+ // when something real answers /api/version. Otherwise drop it and start fresh.
762
788
  if (existsSync(portFile)) {
763
789
  try {
764
790
  const info = JSON.parse(readFileSync(portFile, "utf-8"));
765
791
  if (info && info.port) {
766
- return Promise.resolve({ port: info.port, url: `http://localhost:${info.port}` });
792
+ let live = false;
793
+ try {
794
+ const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) });
795
+ live = probe.ok;
796
+ } catch {
797
+ live = false;
798
+ }
799
+ if (live) {
800
+ log("reusing live server from port.pid", { port: info.port });
801
+ return { port: info.port, url: `http://localhost:${info.port}` };
802
+ }
803
+ log("port.pid present but no live server — treating as stale", { port: info.port });
767
804
  }
768
805
  } catch {
769
- // stale file, overwrite
806
+ log("port.pid unparseable treating as stale");
770
807
  }
808
+ // stale file, remove so the fresh bind does not collide with a lingering
809
+ // process that still holds the port
810
+ try { unlinkSync(portFile); } catch { /* ignore */ }
771
811
  }
772
812
 
773
813
  // ── New server ────────────────────────────────────────────────────────────
@@ -891,20 +931,26 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
891
931
  function tryPort(port: number) {
892
932
  server.once("error", (err: NodeJS.ErrnoException) => {
893
933
  if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
934
+ log("port in use, trying next", { port });
894
935
  tryPort(port + 1);
895
936
  } else {
937
+ log("listen failed", { port, code: err.code, message: err.message });
896
938
  reject(err);
897
939
  }
898
940
  });
899
941
 
900
942
  server.listen(port, "127.0.0.1", () => {
901
943
  const url = `http://localhost:${port}`;
944
+ log("server running", { url });
945
+ // eslint-disable-next-line no-console
902
946
  console.log(`[mega-compact] dashboard server running: ${url}`);
903
947
 
904
948
  // Write port.pid
905
949
  try {
906
950
  writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
907
- } catch { /* non-fatal */ }
951
+ } catch (e) {
952
+ log("could not write port.pid", { error: String(e) });
953
+ }
908
954
 
909
955
  // Graceful cleanup
910
956
  const cleanup = () => {
@@ -9,7 +9,7 @@
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { join, dirname, sep } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
- import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
12
+ import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
13
13
  import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
14
14
  import { MegaRuntime } from "./mega-runtime.js";
15
15
 
@@ -209,11 +209,32 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
209
209
  return;
210
210
  }
211
211
 
212
+ // Clear any stale marker so a fresh bind never collides with a lingering
213
+ // orphan, and truncate the launch log so the next error report shows only
214
+ // this attempt's output.
215
+ try { unlinkSync(portFile); } catch { /* ignore */ }
216
+ try { writeFileSync(launchLog, ""); } catch { /* ignore */ }
217
+
212
218
  const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
219
+ // Redirect the child's stderr to the launch log so that a CRASH BEFORE the
220
+ // runner's own __fail handler runs (e.g. an ESM module-load / parse error,
221
+ // or a missing entry) is still captured. With the old `stdio: "ignore"`
222
+ // these failures were completely silent and the "check logs" message
223
+ // pointed at an empty file. We open the fd in the parent and pass it to the
224
+ // child; once spawned we close our copy (the child keeps its own dup).
225
+ let stderrFd: number;
226
+ try {
227
+ stderrFd = openSync(launchLog, "a");
228
+ } catch {
229
+ stderrFd = -1; // fall back to ignored stderr
230
+ }
213
231
  const child = spawn(process.execPath, args, {
214
232
  detached: true,
215
- stdio: "ignore",
233
+ stdio: ["ignore", "ignore", stderrFd >= 0 ? stderrFd : "ignore"],
216
234
  });
235
+ if (stderrFd >= 0) {
236
+ try { closeSync(stderrFd); } catch { /* ignore */ }
237
+ }
217
238
  child.unref();
218
239
 
219
240
  // Poll for a live server (port 9320–9329) instead of relying solely on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
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",