pi-mega-compact 0.4.11 → 0.4.13

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 CHANGED
@@ -111,50 +111,45 @@ OpenAI-style contract and the `MEGACOMPACT_EMBEDDING_KEY` / `MEGACOMPACT_EMBEDDI
111
111
  ### Requirements
112
112
 
113
113
  - **Node >= 18**
114
- - `npm install` builds the **`better-sqlite3`** native module (one-time, local
114
+ - install builds the **`better-sqlite3`** native module (one-time, local
115
115
  compile). No network call and no API key are needed at runtime.
116
- - A pi coding agent install that loads extensions from `~/.pi/agent/extensions/`.
116
+ - A pi coding agent install with package support (`pi install` / `pi update
117
+ --extensions`). npm-installed packages are auto-discovered via the package's
118
+ `pi` manifest entry; local checkouts load from `~/.pi/agent/extensions/`.
117
119
 
118
120
  ### Install from npm (recommended)
119
121
 
120
- ```bash
121
- npm install pi-mega-compact
122
- ```
123
-
124
- This places the package in `node_modules` and exposes the extension entry at
125
- `node_modules/pi-mega-compact/extensions/mega-compact.ts`. Then point pi at it
126
- (see "Register with pi" below).
127
-
128
- ### Register with pi
129
-
130
- Add the extension to your pi config's `pi.extensions` list, pointing at the
131
- installed entry (npm path or a symlink into pi's extensions dir — either works):
132
-
133
- ```jsonc
134
- {
135
- "pi": {
136
- "extensions": ["pi-mega-compact/extensions/mega-compact.ts"]
137
- }
138
- }
139
- ```
140
-
141
- Or symlink the installed package into pi's extensions dir (the simplest path if
142
- you run pi from the same machine):
122
+ pi installs extensions as **packages**. `pi install npm:<pkg>` writes an
123
+ `npm:` source into your pi config's `packages` array; pi then auto-discovers the
124
+ extension from the package's own `"pi": { "extensions": [...] }` manifest entry
125
+ — **no manual `settings.json` edit is needed**.
143
126
 
144
127
  ```bash
145
- ln -s "$(npm root)/pi-mega-compact" ~/.pi/agent/extensions/pi-mega-compact
128
+ pi install npm:pi-mega-compact # first time: adds to packages + installs
129
+ pi update --extensions # thereafter: pulls the latest published version
146
130
  ```
147
131
 
148
- > **From a git checkout (development).** To hack on the extension, clone instead
149
- > and build locally:
132
+ `pi update --extensions` refreshes every `npm:` entry in `packages` (including on
133
+ other devices that share this config). The package ships both the TypeScript
134
+ source (which pi loads directly) and the compiled `dist/`, so nothing else needs
135
+ building.
136
+
137
+ > **Tip — keep the spec unpinned.** Use `npm:pi-mega-compact`, not
138
+ > `npm:pi-mega-compact@0.4.11`. Version-pinned specs are *skipped* by
139
+ > `pi update --extensions`, so a pin would freeze you on that release.
140
+
141
+ > **From a git checkout (development only).** To hack on the extension, clone and
142
+ > build locally, then symlink it into pi's extensions dir — but this bypasses the
143
+ > package manager, so it is NOT updated by `pi update --extensions`. Convert to the
144
+ > npm package (above) before shipping. The bundled `./install.sh` helper does the
145
+ > symlink + config edit (needs `jq`).
146
+ >
150
147
  > ```bash
151
148
  > git clone https://github.com/TheArchitectit/pi-mega-compact.git \
152
149
  > ~/.pi/agent/extensions/pi-mega-compact
153
150
  > cd ~/.pi/agent/extensions/pi-mega-compact
154
151
  > npm install && npm run build
155
152
  > ```
156
- > The bundled `./install.sh` helper (`copy`) / `./install.sh -s` (`symlink`) does
157
- > the same and also registers the path in pi's config (needs `jq`).
158
153
 
159
154
  ### Verify
160
155
 
@@ -166,17 +161,17 @@ npm run lint # tsc --noEmit + guardrails scan clean
166
161
  ### Uninstall
167
162
 
168
163
  ```bash
164
+ pi uninstall npm:pi-mega-compact # removes from settings.packages + the npm tree
165
+ # or, to keep the config entry but drop the package:
169
166
  npm uninstall pi-mega-compact
170
167
  ```
171
168
 
172
- If you symlinked it into pi's extensions dir, also remove that link:
169
+ If you symlinked it into pi's extensions dir (dev only), also remove that link:
173
170
 
174
171
  ```bash
175
172
  rm -f ~/.pi/agent/extensions/pi-mega-compact
176
173
  ```
177
174
 
178
- Then remove the path from pi's `pi.extensions` array.
179
-
180
175
  ---
181
176
 
182
177
  ## How to use it
@@ -255,25 +255,43 @@ export default function (pi) {
255
255
  const usedStr = `${C.cyan}${fmt(st.totalTokenEstimate)} sess${C.reset} / ${C.blue}${fmt(repo.totalTokenEstimate)} repo${C.reset}`;
256
256
  const agentStr = activeAgents > 0 ? ` │ 🤖 ${activeAgents} agent${activeAgents === 1 ? "" : "s"}` : "";
257
257
  const turnStr = currentTurn > 0 ? ` │ turn ${currentTurn}` : "";
258
+ // Phase 3 — pulsing status glyph while a compaction is in flight.
259
+ const pulse = pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
258
260
  const lines = [
259
- ` ${C.amber}⚡ ${config.tier}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} chkpt${st.checkpointCount === 1 ? "" : "s"}${agentStr}${turnStr}`,
261
+ ` ${C.amber}⚡ ${config.tier}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} chkpt${agentStr}${turnStr}`,
260
262
  ` ${triggerLabel} │ ${C.magenta}dedup: ${dedupStr}${C.reset} │ ${C.gray}used:${C.reset} ${usedStr} │ ${C.gray}saved:${C.reset} ${savedStr}`,
261
263
  ];
264
+ // Phase 3 — compact progress bar: session tokens saved toward the rolling goal.
265
+ if (rt.tokensSaved > 0) {
266
+ const goal = Math.max(savedGoal, 1);
267
+ const pct = Math.min(100, Math.round((rt.tokensSaved / goal) * 100));
268
+ const filled = Math.round((pct / 100) * 10);
269
+ const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
270
+ lines.push(` ${C.green}saved ${fmt(rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
271
+ }
262
272
  // Live "now processing" line — teal while fresh (≤4s), then the last-seen
263
273
  // action keeps the widget lively. Cleared on session reset.
264
- if (tierTrace) {
265
- // Per-tier dedup progress is more relevant than the last action while a
266
- // compaction is mid-flight, so prefer it when fresh.
267
- const fresh = Date.now() - lastActivityAt < 4000;
268
- if (fresh)
269
- lines.push(` ${tierTrace}`);
270
- else if (currentActivity)
271
- lines.push(` ${C.dim}${currentActivity}${C.reset}`);
274
+ const fresh = Date.now() - lastActivityAt < 4000;
275
+ if (tierTrace && fresh) {
276
+ lines.push(` ${pulse}${tierTrace}`);
272
277
  }
273
278
  else if (currentActivity) {
274
- const fresh = Date.now() - lastActivityAt < 4000;
275
279
  lines.push(` ${fresh ? C.teal : C.dim}${currentActivity}${C.reset}`);
276
280
  }
281
+ else if (pulsing) {
282
+ lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
283
+ }
284
+ // Phase 3 — explain-why line (fresh only).
285
+ if (lastWhy && fresh)
286
+ lines.push(` ${C.gray}${lastWhy}${C.reset}`);
287
+ // Phase 3 — recall/activity ticker (most-recent first), fresh only.
288
+ if (fresh) {
289
+ for (let i = ticker.length - 1; i >= 0; i--) {
290
+ if (lines.length >= 10)
291
+ break; // MAX_WIDGET_LINES guard
292
+ lines.push(` ${i === ticker.length - 1 ? "" : C.dim}${ticker[i].text}${C.reset}`);
293
+ }
294
+ }
277
295
  ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
278
296
  }
279
297
  }
@@ -305,6 +323,24 @@ export default function (pi) {
305
323
  // Built from the store's sync onTier callback during a compaction so the user
306
324
  // watches each tier evaluate in real time. Cleared once the outcome settles.
307
325
  let tierTrace;
326
+ const ticker = [];
327
+ const TICKER_MAX = 5;
328
+ function pushTicker(text) {
329
+ ticker.push({ text, at: Date.now() });
330
+ while (ticker.length > TICKER_MAX)
331
+ ticker.shift();
332
+ lastActivityAt = Date.now();
333
+ }
334
+ // Pulsing status: set true while a compaction is in flight, cleared on result.
335
+ let pulsing = false;
336
+ // Rolling "saved" goal for the progress bar — grows as we save more, so the
337
+ // bar always has a meaningful denominator (never sits at 100% forever).
338
+ let savedGoal = 50_000;
339
+ // Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
340
+ // while fresh.
341
+ let lastWhy = undefined;
342
+ // Cycling glyph phases for the pulsing status.
343
+ const PULSE = ["◐", "◓", "◑", "◒"];
308
344
  // ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
309
345
  // escape codes (see wrapTextWithAnsi), so raw escapes render as colors. No
310
346
  // chalk dependency needed — these are just strings.
@@ -344,6 +380,10 @@ export default function (pi) {
344
380
  currentActivity = undefined;
345
381
  lastActivityAt = 0;
346
382
  tierTrace = undefined;
383
+ ticker.length = 0;
384
+ pulsing = false;
385
+ savedGoal = 50_000;
386
+ lastWhy = undefined;
347
387
  }
348
388
  /** Build the sync onTier callback that paints the live per-tier trace. */
349
389
  function makeTierCallback(ctx) {
@@ -382,6 +422,7 @@ export default function (pi) {
382
422
  const keepFrom = opts.keepFrom ?? Math.max(0, view.length - config.preserveRecent);
383
423
  if (keepFrom <= 0)
384
424
  return { skipped: true };
425
+ pulsing = true; // animate the status line while the (sync) pipeline runs
385
426
  const result = compactSession({
386
427
  sessionId: sid,
387
428
  messages: view,
@@ -390,6 +431,7 @@ export default function (pi) {
390
431
  timestamp: Date.now(),
391
432
  onTier: makeTierCallback(ctx),
392
433
  }, store);
434
+ pulsing = false;
393
435
  if (result.skipped)
394
436
  return { skipped: true };
395
437
  if (!result.deduped) {
@@ -410,6 +452,10 @@ export default function (pi) {
410
452
  rt.tokensSaved += saved;
411
453
  if (result.deduped)
412
454
  rt.dedupSkips++;
455
+ // Grow the rolling "saved" goal so the progress bar always has a fresh
456
+ // denominator (we don't want it pinned at 100% once we pass an old target).
457
+ if (rt.tokensSaved > savedGoal)
458
+ savedGoal = Math.ceil((rt.tokensSaved * 1.25) / 10_000) * 10_000;
413
459
  // Live toolbar "now processing" line: what file/region just got compacted or
414
460
  // deduped. Reset to the last-seen action after a few seconds (see snapshot).
415
461
  const files = result.filesModified ?? [];
@@ -420,6 +466,16 @@ export default function (pi) {
420
466
  ? `♻ deduped ${fileLabel}`
421
467
  : `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
422
468
  lastActivityAt = Date.now();
469
+ // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
470
+ // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
471
+ lastWhy = result.deduped
472
+ ? `why: deduped@${result.dedupReason ?? "tier"}`
473
+ : `why: compacted → ${result.checkpointId}`;
474
+ // Recall/activity ticker: record this event in the ring buffer.
475
+ const savedK = (saved / 1000).toFixed(1);
476
+ pushTicker(result.deduped
477
+ ? `${C.green}♻${C.reset} deduped ${fileLabel} · ${savedK}k saved`
478
+ : `${C.cyan}🗜${C.reset} ${result.checkpointId} · +${savedK}k · ${fileLabel}`);
423
479
  // The per-tier trace has settled into the final outcome — fold it back into
424
480
  // the activity line and stop showing the live trace.
425
481
  tierTrace = undefined;
@@ -472,6 +528,14 @@ export default function (pi) {
472
528
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
473
529
  const result = recallAndInline({ sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true }, store);
474
530
  dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
531
+ if (!result.empty && result.toInject.length > 0) {
532
+ const top = result.toInject[0];
533
+ const scorePct = Math.round((top.score ?? 0) * 100);
534
+ const files = top.checkpoint.filesModified ?? [];
535
+ const label = files.length ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ") : top.checkpoint.checkpointId;
536
+ pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
537
+ lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
538
+ }
475
539
  return result;
476
540
  }
477
541
  // ---- Session lifecycle (state reset points) -------------------------------
@@ -358,21 +358,38 @@ export default function (pi: ExtensionAPI) {
358
358
  const usedStr = `${C.cyan}${fmt(st.totalTokenEstimate)} sess${C.reset} / ${C.blue}${fmt(repo.totalTokenEstimate)} repo${C.reset}`;
359
359
  const agentStr = activeAgents > 0 ? ` │ 🤖 ${activeAgents} agent${activeAgents === 1 ? "" : "s"}` : "";
360
360
  const turnStr = currentTurn > 0 ? ` │ turn ${currentTurn}` : "";
361
+ // Phase 3 — pulsing status glyph while a compaction is in flight.
362
+ const pulse = pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
361
363
  const lines = [
362
- ` ${C.amber}⚡ ${config.tier}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} chkpt${st.checkpointCount === 1 ? "" : "s"}${agentStr}${turnStr}`,
364
+ ` ${C.amber}⚡ ${config.tier}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} chkpt${agentStr}${turnStr}`,
363
365
  ` ${triggerLabel} │ ${C.magenta}dedup: ${dedupStr}${C.reset} │ ${C.gray}used:${C.reset} ${usedStr} │ ${C.gray}saved:${C.reset} ${savedStr}`,
364
366
  ];
367
+ // Phase 3 — compact progress bar: session tokens saved toward the rolling goal.
368
+ if (rt.tokensSaved > 0) {
369
+ const goal = Math.max(savedGoal, 1);
370
+ const pct = Math.min(100, Math.round((rt.tokensSaved / goal) * 100));
371
+ const filled = Math.round((pct / 100) * 10);
372
+ const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
373
+ lines.push(` ${C.green}saved ${fmt(rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
374
+ }
365
375
  // Live "now processing" line — teal while fresh (≤4s), then the last-seen
366
376
  // action keeps the widget lively. Cleared on session reset.
367
- if (tierTrace) {
368
- // Per-tier dedup progress is more relevant than the last action while a
369
- // compaction is mid-flight, so prefer it when fresh.
370
- const fresh = Date.now() - lastActivityAt < 4000;
371
- if (fresh) lines.push(` ${tierTrace}`);
372
- else if (currentActivity) lines.push(` ${C.dim}${currentActivity}${C.reset}`);
377
+ const fresh = Date.now() - lastActivityAt < 4000;
378
+ if (tierTrace && fresh) {
379
+ lines.push(` ${pulse}${tierTrace}`);
373
380
  } else if (currentActivity) {
374
- const fresh = Date.now() - lastActivityAt < 4000;
375
381
  lines.push(` ${fresh ? C.teal : C.dim}${currentActivity}${C.reset}`);
382
+ } else if (pulsing) {
383
+ lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
384
+ }
385
+ // Phase 3 — explain-why line (fresh only).
386
+ if (lastWhy && fresh) lines.push(` ${C.gray}${lastWhy}${C.reset}`);
387
+ // Phase 3 — recall/activity ticker (most-recent first), fresh only.
388
+ if (fresh) {
389
+ for (let i = ticker.length - 1; i >= 0; i--) {
390
+ if (lines.length >= 10) break; // MAX_WIDGET_LINES guard
391
+ lines.push(` ${i === ticker.length - 1 ? "" : C.dim}${ticker[i].text}${C.reset}`);
392
+ }
376
393
  }
377
394
  ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
378
395
  }
@@ -406,6 +423,27 @@ export default function (pi: ExtensionAPI) {
406
423
  // Built from the store's sync onTier callback during a compaction so the user
407
424
  // watches each tier evaluate in real time. Cleared once the outcome settles.
408
425
  let tierTrace: string | undefined;
426
+ // Phase 3 — standout toolbar state.
427
+ // Recall/activity ticker: a small ring buffer (≤5) of recent compact/recall
428
+ // events so the widget shows a live history instead of a single last action.
429
+ interface TickerEntry { text: string; at: number; }
430
+ const ticker: TickerEntry[] = [];
431
+ const TICKER_MAX = 5;
432
+ function pushTicker(text: string): void {
433
+ ticker.push({ text, at: Date.now() });
434
+ while (ticker.length > TICKER_MAX) ticker.shift();
435
+ lastActivityAt = Date.now();
436
+ }
437
+ // Pulsing status: set true while a compaction is in flight, cleared on result.
438
+ let pulsing = false;
439
+ // Rolling "saved" goal for the progress bar — grows as we save more, so the
440
+ // bar always has a meaningful denominator (never sits at 100% forever).
441
+ let savedGoal = 50_000;
442
+ // Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
443
+ // while fresh.
444
+ let lastWhy: string | undefined = undefined;
445
+ // Cycling glyph phases for the pulsing status.
446
+ const PULSE = ["◐", "◓", "◑", "◒"];
409
447
  // ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
410
448
  // escape codes (see wrapTextWithAnsi), so raw escapes render as colors. No
411
449
  // chalk dependency needed — these are just strings.
@@ -446,6 +484,10 @@ export default function (pi: ExtensionAPI) {
446
484
  currentActivity = undefined;
447
485
  lastActivityAt = 0;
448
486
  tierTrace = undefined;
487
+ ticker.length = 0;
488
+ pulsing = false;
489
+ savedGoal = 50_000;
490
+ lastWhy = undefined;
449
491
  }
450
492
 
451
493
  /** Build the sync onTier callback that paints the live per-tier trace. */
@@ -488,6 +530,7 @@ export default function (pi: ExtensionAPI) {
488
530
  const keepFrom = opts.keepFrom ?? Math.max(0, view.length - config.preserveRecent);
489
531
  if (keepFrom <= 0) return { skipped: true as const };
490
532
 
533
+ pulsing = true; // animate the status line while the (sync) pipeline runs
491
534
  const result = compactSession(
492
535
  {
493
536
  sessionId: sid,
@@ -499,6 +542,7 @@ export default function (pi: ExtensionAPI) {
499
542
  },
500
543
  store,
501
544
  );
545
+ pulsing = false;
502
546
 
503
547
  if (result.skipped) return { skipped: true as const };
504
548
  if (!result.deduped) {
@@ -518,6 +562,9 @@ export default function (pi: ExtensionAPI) {
518
562
  : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
519
563
  rt.tokensSaved += saved;
520
564
  if (result.deduped) rt.dedupSkips++;
565
+ // Grow the rolling "saved" goal so the progress bar always has a fresh
566
+ // denominator (we don't want it pinned at 100% once we pass an old target).
567
+ if (rt.tokensSaved > savedGoal) savedGoal = Math.ceil((rt.tokensSaved * 1.25) / 10_000) * 10_000;
521
568
 
522
569
  // Live toolbar "now processing" line: what file/region just got compacted or
523
570
  // deduped. Reset to the last-seen action after a few seconds (see snapshot).
@@ -529,6 +576,18 @@ export default function (pi: ExtensionAPI) {
529
576
  ? `♻ deduped ${fileLabel}`
530
577
  : `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
531
578
  lastActivityAt = Date.now();
579
+ // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
580
+ // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
581
+ lastWhy = result.deduped
582
+ ? `why: deduped@${result.dedupReason ?? "tier"}`
583
+ : `why: compacted → ${result.checkpointId}`;
584
+ // Recall/activity ticker: record this event in the ring buffer.
585
+ const savedK = (saved / 1000).toFixed(1);
586
+ pushTicker(
587
+ result.deduped
588
+ ? `${C.green}♻${C.reset} deduped ${fileLabel} · ${savedK}k saved`
589
+ : `${C.cyan}🗜${C.reset} ${result.checkpointId} · +${savedK}k · ${fileLabel}`,
590
+ );
532
591
  // The per-tier trace has settled into the final outcome — fold it back into
533
592
  // the activity line and stop showing the live trace.
534
593
  tierTrace = undefined;
@@ -590,6 +649,14 @@ export default function (pi: ExtensionAPI) {
590
649
  store,
591
650
  );
592
651
  dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
652
+ if (!result.empty && result.toInject.length > 0) {
653
+ const top = result.toInject[0];
654
+ const scorePct = Math.round((top.score ?? 0) * 100);
655
+ const files = top.checkpoint.filesModified ?? [];
656
+ const label = files.length ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ") : top.checkpoint.checkpointId;
657
+ pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
658
+ lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
659
+ }
593
660
  return result;
594
661
  }
595
662
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
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",